Skip to content

feat: edit token expiry from the admin dashboard#11

Merged
Bug-Finderr merged 7 commits into
mainfrom
edit-expiry
Jul 11, 2026
Merged

feat: edit token expiry from the admin dashboard#11
Bug-Finderr merged 7 commits into
mainfrom
edit-expiry

Conversation

@Bug-Finderr

@Bug-Finderr Bug-Finderr commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Admins can now edit or clear the expiry of existing tokens directly from the table.

UX

  • The Expires cell is click-to-edit (native button: Tab + Enter/Space work). A datetime-local picker opens prefilled with the current expiry converted to your local time.
  • Locale in, UTC stored, locale displayed: one body-level config-request delegate converts the picked local value to UTC ISO on submit (shared with the create form); the server validates and stores UTC; the time[datetime] localizer renders it back. Values save exactly as picked - a past pick renders as a red "expired" row immediately.
  • Blank = never expires, a future date revives an expired token.

API / server

  • PUT /admin/api/tokens/:hash patches status and/or expiresAt through the same parseExpiry validation as creation (offset-less 400, empty patch 400); blank clears. Zero KV data migration.
  • All token mutations (create, patch, delete) are serialized through a per-hash TokenWriter Durable Object (new binding + migration v2) whose own storage is the merge base: KV guarantees neither atomic read-modify-write nor read-your-write, and merging from a KV read let a stale expiry patch resurrect a disabled token. Creation seeds the merge base (a fresh token's first edit never depends on a KV read); only pre-writer hashes bootstrap from KV, gated by a deletion tombstone that recreation clears. Failure ordering is safe: KV deletes precede the tombstone, and a failed KV write rolls the merge base back instead of silently replaying later.
  • The PUT response row carries the separately stored lastUsed (parallel, best-effort read).

Concurrency & UI honesty

  • Mutations beat polls: the 120s poll drops its tick while any #tokens request is in flight and pauses while an editor is focused; row mutations and the create form abort a stale in-flight poll (hx-sync replace); opening an editor aborts an in-flight poll so its late response cannot destroy the focused editor.
  • In-flight rows dim but stay clickable: same-row actions serialize at the writer, so a commit-and-toggle gesture lands both writes with the last response owning the row. Known residual: a mutation from a different row inside the same sub-second flight window aborts the first row's swap - server state stays correct; that cell is stale until the next poll.
  • Row request wiring (hx-target/hx-swap/hx-indicator) is declared once on the tr; editor behavior is delegated once at the body; keyboard handling is native.

Verification

  • Full gate: 84 tier-1 (incl. disable/expiry race, stale-KV-echo, delete-tombstone, and delete-recreate-edit pins) + 19 compat + 4 python, tsc + biome clean, wrangler deploy --dry-run validates the DO binding and migration.
  • Live-verified in Chromium via Playwright against wrangler dev with route-level response delays: edit/clear/cancel roundtrips with IST conversion, past-pick renders expired, keyboard open, both poll-contention directions, editor-open aborting an in-flight poll with the typed value surviving, and the commit-and-toggle gesture applying both writes.
  • Self-review ran before this revision: 4 adversarially-verified Fable lenses + an independent gpt-5.6-sol pass over the full diff; the confirmed findings (writer failure ordering, creation seeding, form sync scope, doc staleness, pin overlap) are fixed in the final commits. Known-and-accepted list: Firefox commits datetime-local change on day-click (mid-edit save; benign, row re-renders), cross-row abort residual above, dup-check 409 creation race (pre-existing, documented).

Each row's Expires cell is now click-to-edit (or focus+Enter): a
datetime-local editor prefilled from the stored UTC value saves on
change, a blank value clears expiry, and a Today pick snaps to 23:59
local. PUT /admin/api/tokens/:hash patches status and/or expiresAt
through the shared parseExpiry validation; setTokenStatus becomes
patchToken. Row wiring is delegated at the body (one copy instead of
per-row handlers), same-row requests serialize via hx-sync, the 120s
poll pauses while an editor is focused, and the PUT response row keeps
the separately stored lastUsed.
The body-level config-request delegate already snaps a Today pick to
23:59 local before the UTC conversion, so the create form's input-time
dance (blur/showPicker Chromium workaround) duplicated the same logic.
Both the form and the row editors now share one converter.
@Bug-Finderr Bug-Finderr self-assigned this Jul 11, 2026
- Snap rule is now deterministic: only a today-and-already-past value
  (what a Chromium Today pick commits) snaps to 23:59; any valid future
  choice is untouched regardless of submit delay.
- Same-row actions show a busy state while a request is in flight
  (hx-indicator on the row + pointer-events:none) so a second click is
  visibly blocked instead of silently swallowed; hx-sync stays as a
  queue backstop for non-swapping error responses. This also closes the
  one-browser stale read-modify-write path; cross-client writes remain
  last-write-wins on KV, documented.
- The cosmetic lastUsed read is parallel and best-effort so a committed
  patch can no longer surface as a 500.
- The expiry trigger is a native button (focusable, named by content)
  and the editor input carries aria-label; the custom keydown delegate
  is gone.
- token-expiry-check-at-validate.md no longer claims expiry never
  mutates; .dev.vars* covers environment variants.
… polls

- Expiry saves exactly as picked: the today+past-to-23:59 snap could
  extend authorization when the admin deliberately entered a past time,
  so no client rewrite survives; a past pick renders as an expired row
  immediately.
- A per-hash TokenWriter Durable Object serializes patch/delete, since
  a bare KV read-modify-write let a concurrent or stale-read expiry
  patch resurrect a disabled token; every merge now follows its own
  prior write. Regression test races disable against expiry PUTs.
- Poll vs mutation contention is asymmetric now: the poll drops itself
  when any #tokens-scoped request is in flight, and row mutations abort
  a stale in-flight poll (hx-sync replace, inherited from tbody), so a
  late poll response can never overwrite a newer row swap and clicks
  are never eaten. Verified with route-level response delays.
- TokenWriter now merges against its own strongly consistent storage;
  KV (which does not guarantee read-your-write) only bootstraps a hash
  the writer has never touched, guarded by a deletion tombstone so a
  stale KV echo can neither revert a disable nor resurrect a delete.
  patchToken/deleteToken are absorbed into the writer; stale-echo and
  tombstone regression tests simulate the exact failure.
- Opening an expiry editor aborts any in-flight poll (opening issues no
  request, so the sync scopes could not see it and the late response
  would destroy the focused editor). Verified with a 1.2s route delay.
- README cost note covers the TokenWriter DO request per admin write.
- remove() deletes from KV before tombstoning: a thrown KV delete used
  to leave a tombstoned-but-live token that 404'd every disable attempt.
- patch() rolls its merge base back when the KV write throws, so an
  errored edit cannot silently replay weeks later via an unrelated patch.
- Creation goes through the writer (mintToken splits pure minting from
  KV persistence): the merge base is seeded at birth, the first edit of
  a fresh token no longer depends on a KV read, and the tombstone
  becomes a plain flag cleared on recreation - the cross-clock createdAt
  comparison is gone. Recreate-then-edit regression test added.
- Row request wiring (target/swap/indicator) hoisted to the tr; with the
  tbody replace scope serializing at the writer, busy rows now dim but
  stay clickable, so a commit-and-toggle gesture lands both writes.
- Create form joins the #tokens sync scope so a stale poll cannot
  overwrite the out-of-band row insert; two subsumed test pins dropped;
  lifecycle doc names the writer methods.
@Bug-Finderr Bug-Finderr merged commit adcead0 into main Jul 11, 2026
4 checks passed
@Bug-Finderr Bug-Finderr deleted the edit-expiry branch July 11, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant