From 07cfe74fed1f2cb29a06623c96f2739b3d2a0efa Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Sat, 11 Jul 2026 13:56:44 +0530 Subject: [PATCH 1/7] feat: edit token expiry from the admin dashboard 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. --- .gitignore | 1 + docs/architecture.md | 7 ++-- src/admin/index.ts | 48 ++++++++++++++++++--------- src/admin/views.ts | 34 +++++++++++++++++--- src/tokens.ts | 9 +++--- test/admin.test.ts | 66 +++++++++++++++++++++++++++++++++++++- test/proxy-handler.test.ts | 4 +-- test/tokens.test.ts | 17 ++++++++-- 8 files changed, 153 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index b1daaed..208f10a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ HANDOFF.md # wrangler local state / build cache .wrangler +.dev.vars # playwright artifacts .playwright-mcp diff --git a/docs/architecture.md b/docs/architecture.md index 9f3ea80..655757d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,7 +106,7 @@ type TokenMetadata = { - **Tokens** are opaque: `ptk_` + 32 url-safe chars (24 random bytes). Custom admin-typed tokens are allowed; validation is by hash of the full string. - **Validation** (`getValidatedByHash`): returns the record only if `status === "active"` AND, when `expiresAt` is set, it parses to a future timestamp - malformed or past expiry is rejected **fail-closed**. Not KV `expirationTtl` - why: [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). - **`lastUsed`** is a separate `:lu` key. The first qualifying use per token/day/isolate schedules a stamp: either an authorized HTTP request that receives any upstream response or a successful WebSocket upgrade. The dashboard localizes the stored ISO timestamp. See [`proxy-token-security.md`](learnings/proxy-token-security.md) and [`kv-free-tier-write-quota.md`](learnings/kv-free-tier-write-quota.md). -- **Lifecycle:** `createToken`, `listTokens` (one `kv.list` page plus batched multi-key reads), `setTokenStatus`, `deleteToken` (record + `:lu`). Changes can take 60 seconds or more to become visible in other locations. +- **Lifecycle:** `createToken`, `listTokens` (one `kv.list` page plus batched multi-key reads), `patchToken` (status and/or expiry), `deleteToken` (record + `:lu`). Changes can take 60 seconds or more to become visible in other locations. ```mermaid stateDiagram-v2 @@ -114,6 +114,7 @@ stateDiagram-v2 active --> disabled: PUT status=disabled disabled --> active: PUT status=active active --> expired: expiresAt passes (derived at validate, record kept) + expired --> active: PUT expiresAt (future or cleared) active --> [*]: DELETE (record + lu key) disabled --> [*]: DELETE expired --> [*]: DELETE @@ -182,8 +183,8 @@ Caveats: the rate limit gates the **connection**, not each frame, and revocation Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus **HTMX 2.x** loaded from a CDN with a pinned version **and an SRI hash** (`integrity` + `crossorigin`): the page holds token-mint power and receives `ADMIN_SECRET`, so a tampered CDN response must refuse to execute. No authored client JS beyond a handful of inline `hx-on` attributes; nothing in the worker bundle but markup + attributes. The login form also carries `method="post"` so a no-htmx fallback submit can never default to GET and leak the password into the URL. - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` is rate-limited per client IP (dedicated `LOGIN_LIMITER`, 10/60s, fail-open) and compared in constant time (hono's `timingSafeEqual`, which hashes both sides with SHA-256); failures are logged. Success sets a signed cookie via `hono/cookie` (`setSignedCookie`: HMAC-SHA-256 over the issue timestamp, `Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; `getSignedCookie` verifies in constant time (`crypto.subtle.verify`) and the guard re-checks the 24h age server-side, so a client ignoring `Max-Age` gains nothing. Tampered/expired cookies are pinned by negative-path tests. -- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. -- **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, loads immediately, and refreshes every 120 seconds while visible. The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. +- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Patches are unlocked KV read-modify-writes (last write wins, as everywhere on KV); same-row actions carry `hx-sync="closest tr"` so one browser cannot race its own PUTs. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. +- **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO on change; a "Today" pick snaps to 23:59 local; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible. The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. - **Errors surface:** a body-level `hx-on::response-error` writes failures into a flash div (htmx swaps nothing on non-2xx by default - previously a wrong password or an expired session silently no-opped), and a 401 mid-session bounces back to the login page. ## 12. Real key handling diff --git a/src/admin/index.ts b/src/admin/index.ts index f1a8f6f..749edec 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -7,10 +7,11 @@ import { createToken, deleteToken, listTokens, - setTokenStatus, + luKey, + patchToken, sha256hex, } from "../tokens"; -import type { CoarseProvider, Env } from "../types"; +import type { CoarseProvider, Env, TokenMetadata } from "../types"; import { createdNotice, dashboardPage, @@ -34,6 +35,16 @@ const parseProviders = (fd: FormData): CoarseProvider[] => VALID_PROVIDERS.includes(p as CoarseProvider), ); +// Normalized ISO for a valid value, undefined for blank, null for a malformed or +// offset-less value (which would parse in the runtime's local tz, not the admin's). +const parseExpiry = (raw: string): string | null | undefined => { + if (!raw.trim()) return undefined; + const d = new Date(raw); + return Number.isNaN(d.getTime()) || !/(Z|[+-]\d{2}:\d{2})$/i.test(raw.trim()) + ? null + : d.toISOString(); +}; + const app = new Hono<{ Bindings: Env }>().basePath("/admin"); // HTMX compiles hx-on and filtered hx-trigger attributes with Function. @@ -117,15 +128,8 @@ app.post("/api/tokens", async (c) => { if (await c.env.TOKENS.get(await sha256hex(custom))) return c.text("token already exists - delete it first", 409); } - const rawExp = String(fd.get("expiresAt") || "").trim(); - let expiresAt: string | undefined; - if (rawExp) { - // The browser submits UTC ISO; an offset-less value would parse in the runtime's local tz, so reject it. - const d = new Date(rawExp); - if (Number.isNaN(d.getTime()) || !/(Z|[+-]\d{2}:\d{2})$/i.test(rawExp)) - return c.text("invalid expiry", 400); - expiresAt = d.toISOString(); - } + const expiresAt = parseExpiry(String(fd.get("expiresAt") || "")); + if (expiresAt === null) return c.text("invalid expiry", 400); const { token, hash, meta } = await createToken(c.env.TOKENS, { label, providers, @@ -142,12 +146,24 @@ app.post("/api/tokens", async (c) => { app.put("/api/tokens/:hash", async (c) => { const hash = c.req.param("hash"); if (!isHash(hash)) return c.text("bad token id", 400); - const status = String((await c.req.formData()).get("status")); - if (status !== "active" && status !== "disabled") - return c.text("bad status", 400); - const meta = await setTokenStatus(c.env.TOKENS, hash, status); + const fd = await c.req.formData(); + const patch: Partial> = {}; + if (fd.has("status")) { + const status = String(fd.get("status")); + if (status !== "active" && status !== "disabled") + return c.text("bad status", 400); + patch.status = status; + } + if (fd.has("expiresAt")) { + const expiresAt = parseExpiry(String(fd.get("expiresAt"))); + if (expiresAt === null) return c.text("invalid expiry", 400); + patch.expiresAt = expiresAt; // undefined = never expires + } + if (!Object.keys(patch).length) return c.text("nothing to update", 400); + const meta = await patchToken(c.env.TOKENS, hash, patch); if (!meta) return c.text("not found", 404); - return c.html(tokenRow({ hash, ...meta })); + const lastUsed = (await c.env.TOKENS.get(luKey(hash))) ?? undefined; + return c.html(tokenRow({ hash, ...meta, lastUsed })); }); app.delete("/api/tokens/:hash", async (c) => { diff --git a/src/admin/views.ts b/src/admin/views.ts index 7fdfd90..f3a740e 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -38,6 +38,9 @@ tr.empty:not(:only-child){display:none} .notice code{display:block;background:#04140c;padding:8px 10px;border-radius:6px;margin-top:6px;word-break:break-all} .copy{cursor:pointer} .copy.copied::after{content:"✓";float:right;color:#74e0bb} +.edit{cursor:pointer} +.edit:hover{text-decoration:underline dotted} +td input[type=datetime-local]{width:auto;padding:4px 6px;font-size:12px} .danger{color:#f08a8a;border-color:#5c2a2a} `; @@ -69,7 +72,26 @@ export const tokenRow = (r: TokenRow) => { …${r.last4} ${providerPills(r.providers)} ${r.status} - ${expired ? html`expired` : html`${localTime(r.expiresAt)}`} + + ${expired ? "expired" : localTime(r.expiresAt)} + + ${localTime(r.lastUsed)} @@ -86,6 +109,7 @@ export const tokenRow = (r: TokenRow) => { hx-delete="/admin/api/tokens/${r.hash}" hx-target="#tok-${r.hash}" hx-swap="outerHTML" + hx-sync="closest tr" hx-confirm="Delete this token?" > delete @@ -170,7 +194,10 @@ export const dashboardPage = () => html` hx-on::send-error="document.getElementById('flash').textContent = 'network error - proxy unreachable'" hx-on::after-request="if (event.detail.successful && event.detail.requestConfig.verb !== 'get') document.getElementById('flash').textContent = ''" hx-on::after-settle="for (const t of document.querySelectorAll('time[datetime]')) t.textContent = new Date(t.getAttribute('datetime')).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })" - hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) })" + hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('span.edit'); if (e) { const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; try { i.showPicker() } catch {} i.focus() }" + hx-on:keydown="if ((event.key === 'Enter' || event.key === ' ') && event.target.matches('span.edit')) { event.preventDefault(); event.target.click() }" + hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { t.style.display = 'none'; t.previousElementSibling.style.display = '' }" + hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) { if (p.expiresAt.slice(11) === new Date().toTimeString().slice(0, 5)) p.expiresAt = p.expiresAt.slice(0, 11) + '23:59'; p.expiresAt = new Date(p.expiresAt).toISOString() }" >
@@ -186,7 +213,6 @@ export const dashboardPage = () => html` hx-post="/admin/api/tokens" hx-target="#created" hx-swap="innerHTML" - hx-on::config-request="if(event.detail.parameters.expiresAt) event.detail.parameters.expiresAt = new Date(event.detail.parameters.expiresAt).toISOString()" hx-on::after-request="if(event.detail.successful) this.reset()" >
@@ -218,7 +244,7 @@ export const dashboardPage = () => html`

Tokens

-
+
Loading…
diff --git a/src/tokens.ts b/src/tokens.ts index 7f66f0b..0dad1db 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -39,7 +39,7 @@ export async function createToken( } // Own key so stamping never rewrites (or resurrects) a record the admin is concurrently disabling. -const luKey = (hash: string) => `${hash}:lu`; +export const luKey = (hash: string) => `${hash}:lu`; export type TokenRow = TokenMetadata & { hash: string; lastUsed?: string }; @@ -79,14 +79,15 @@ export async function listTokens(kv: KVNamespace): Promise { }); } -export async function setTokenStatus( +export async function patchToken( kv: KVNamespace, hash: string, - status: TokenMetadata["status"], + patch: Partial>, ): Promise { const meta = parseMeta(await kv.get(hash)); if (!meta) return null; - const updated = { ...meta, status }; + // An explicit `expiresAt: undefined` clears it: JSON.stringify drops the key. + const updated = { ...meta, ...patch }; await kv.put(hash, JSON.stringify(updated)); return updated; } diff --git a/test/admin.test.ts b/test/admin.test.ts index d15a430..e3d2994 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -175,6 +175,10 @@ describe("admin token CRUD", () => { ).text(); expect(table).toContain('datetime="2030-01-01T00:00:00.000Z"'); expect(table).toContain("2030-01-01 00:00 UTC"); + // Each row carries a click-to-edit expiry editor (behavior is delegated at the body). + expect(table).toContain('hx-trigger="change"'); + expect(table).toContain('data-iso="2030-01-01T00:00:00.000Z"'); + expect(table).toContain('hx-sync="closest tr"'); const bad = await call("/admin/api/tokens", { ...form( @@ -204,12 +208,72 @@ describe("admin token CRUD", () => { expect(bare.status).toBe(400); }); + it("edits expiry via PUT so the proxy honors it, and an empty value clears it", async () => { + const cookie = await login(); + await call("/admin/api/tokens", { + ...form( + { label: "ee", providers: "openai", token: "edit-expiry-token" }, + cookie, + ), + }); + const hash = await sha256hex("edit-expiry-token"); + await env.TOKENS.put(`${hash}:lu`, "2026-07-01T00:00:00.000Z"); + + const past = await put( + `/admin/api/tokens/${hash}`, + { expiresAt: "2020-01-01T00:00:00.000Z" }, + cookie, + ); + expect(past.status).toBe(200); + const row = await past.text(); + expect(row).toContain(">expired { + const cookie = await login(); + await call("/admin/api/tokens", { + ...form( + { label: "eb", providers: "openai", token: "bad-patch-token" }, + cookie, + ), + }); + const hash = await sha256hex("bad-patch-token"); + // POST already pins both parseExpiry rejection branches; one case pins the PUT wiring. + const bad = await put( + `/admin/api/tokens/${hash}`, + { expiresAt: "2030-01-01T00:00" }, + cookie, + ); + expect(bad.status).toBe(400); + expect((await put(`/admin/api/tokens/${hash}`, {}, cookie)).status).toBe( + 400, + ); + expect(await getValidated(env.TOKENS, "bad-patch-token")).toMatchObject({ + label: "eb", + }); + }); + it("dashboard markup pins the browser-side UTC conversion and the visibility-gated poll", async () => { const cookie = await login(); const page = await (await call("/admin", { headers: { cookie } })).text(); expect(page).toContain("hx-on::config-request"); expect(page).toContain("toISOString()"); - expect(page).toContain("every 120s [document.visibilityState==='visible']"); + expect(page).toContain( + "every 120s [document.visibilityState==='visible' && document.activeElement?.type !== 'datetime-local']", + ); // Pin the hash so HTMX upgrades must update SRI deliberately. expect(page).toContain( 'integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V"', diff --git a/test/proxy-handler.test.ts b/test/proxy-handler.test.ts index e40ee1f..d9ce958 100644 --- a/test/proxy-handler.test.ts +++ b/test/proxy-handler.test.ts @@ -5,7 +5,7 @@ import { import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import worker from "../src/index"; -import { createToken, setTokenStatus, sha256hex } from "../src/tokens"; +import { createToken, patchToken, sha256hex } from "../src/tokens"; import { fakeEgress, geo403, seed, setLimiter } from "./helpers"; let captured: Request | null; @@ -174,7 +174,7 @@ describe("auth failures (upstream never called)", () => { providers: ["openai"], token: "tk-disabled", }); - await setTokenStatus(env.TOKENS, hash, "disabled"); + await patchToken(env.TOKENS, hash, { status: "disabled" }); const res = await call( new Request("https://proxy.example/v1/chat/completions", { method: "POST", diff --git a/test/tokens.test.ts b/test/tokens.test.ts index c6ea389..8fa705f 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -4,7 +4,7 @@ import { createToken, generateToken, listTokens, - setTokenStatus, + patchToken, sha256hex, touchLastUsed, } from "../src/tokens"; @@ -46,7 +46,18 @@ describe("createToken + getValidated", () => { }); }); -describe("listTokens / setTokenStatus / deleteToken", () => { +describe("listTokens / patchToken / deleteToken", () => { + it("patches expiry and an explicit undefined clears it from the stored JSON", async () => { + const { hash } = await createToken(env.TOKENS, { + label: "patch", + providers: ["openai"], + token: "patch-exp-token", + expiresAt: "2030-01-01T00:00:00.000Z", + }); + await patchToken(env.TOKENS, hash, { expiresAt: undefined }); + expect(await env.TOKENS.get(hash)).not.toContain("expiresAt"); + }); + it("lists created tokens by hash with metadata", async () => { await createToken(env.TOKENS, { label: "L1", @@ -125,7 +136,7 @@ describe("touchLastUsed", () => { providers: ["openai"], token: "to-revoke", }); - await setTokenStatus(env.TOKENS, hash, "disabled"); + await patchToken(env.TOKENS, hash, { status: "disabled" }); await touchLastUsed(env.TOKENS, hash); expect(await getValidated(env.TOKENS, token)).toBeNull(); }); From ec004231625c81351d99e57abab7d6f4e90cfb7c Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Sat, 11 Jul 2026 13:58:59 +0530 Subject: [PATCH 2/7] refactor: single snap site for the datetime picker 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. --- src/admin/views.ts | 6 +----- test/admin.test.ts | 5 ++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/admin/views.ts b/src/admin/views.ts index f3a740e..55f8456 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -226,11 +226,7 @@ export const dashboardPage = () => html`
- - +
diff --git a/test/admin.test.ts b/test/admin.test.ts index e3d2994..1a25fb2 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -294,9 +294,8 @@ describe("admin token CRUD", () => { expect(page).toContain("code.copy"); expect(page).toContain("navigator.clipboard.writeText"); expect(page).toContain('name="label" placeholder="alice-laptop" required'); - expect(page).toContain("hx-on:input="); - expect(page).toContain("this.value.slice(0, 11) + '23:59'"); - expect(page).toContain("this.blur(); try { this.showPicker() } catch {}"); + // One body-level converter serves every datetime-local: Today snaps to 23:59, then UTC ISO. + expect(page).toContain("p.expiresAt.slice(0, 11) + '23:59'"); const table = await ( await call("/admin/api/tokens", { headers: { cookie } }) ).text(); From dba159aa6905b09dd1a297b21243d227e3588741 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Sat, 11 Jul 2026 15:48:53 +0530 Subject: [PATCH 3/7] fix: address expiry-edit review findings - 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. --- .gitignore | 2 +- docs/architecture.md | 2 +- .../token-expiry-check-at-validate.md | 2 +- src/admin/index.ts | 9 ++++--- src/admin/views.ts | 27 ++++++++++--------- test/admin.test.ts | 4 +-- 6 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 208f10a..a35f69f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ HANDOFF.md # wrangler local state / build cache .wrangler -.dev.vars +.dev.vars* # playwright artifacts .playwright-mcp diff --git a/docs/architecture.md b/docs/architecture.md index 655757d..9d07690 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,7 @@ Caveats: the rate limit gates the **connection**, not each frame, and revocation Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus **HTMX 2.x** loaded from a CDN with a pinned version **and an SRI hash** (`integrity` + `crossorigin`): the page holds token-mint power and receives `ADMIN_SECRET`, so a tampered CDN response must refuse to execute. No authored client JS beyond a handful of inline `hx-on` attributes; nothing in the worker bundle but markup + attributes. The login form also carries `method="post"` so a no-htmx fallback submit can never default to GET and leak the password into the URL. - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` is rate-limited per client IP (dedicated `LOGIN_LIMITER`, 10/60s, fail-open) and compared in constant time (hono's `timingSafeEqual`, which hashes both sides with SHA-256); failures are logged. Success sets a signed cookie via `hono/cookie` (`setSignedCookie`: HMAC-SHA-256 over the issue timestamp, `Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; `getSignedCookie` verifies in constant time (`crypto.subtle.verify`) and the guard re-checks the 24h age server-side, so a client ignoring `Max-Age` gains nothing. Tampered/expired cookies are pinned by negative-path tests. -- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Patches are unlocked KV read-modify-writes (last write wins, as everywhere on KV); same-row actions carry `hx-sync="closest tr"` so one browser cannot race its own PUTs. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. +- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Patches are unlocked KV read-modify-writes (last write wins, as everywhere on KV); same-row actions carry `hx-sync="closest tr:queue all"` so one browser issues them in order instead of racing or dropping them; concurrent writes from different clients remain last-write-wins. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. - **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO on change; a "Today" pick snaps to 23:59 local; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible. The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. - **Errors surface:** a body-level `hx-on::response-error` writes failures into a flash div (htmx swaps nothing on non-2xx by default - previously a wrong password or an expired session silently no-opped), and a 401 mid-session bounces back to the login page. diff --git a/docs/learnings/token-expiry-check-at-validate.md b/docs/learnings/token-expiry-check-at-validate.md index f045145..63d0da0 100644 --- a/docs/learnings/token-expiry-check-at-validate.md +++ b/docs/learnings/token-expiry-check-at-validate.md @@ -7,7 +7,7 @@ Optional per-token expiry, enforced cheaply, without a second storage backend. ## What we found - **KV `expirationTtl` is the wrong tool:** 60s floor, it *deletes* the record on expiry (so the dashboard can't show an "expired" row), and it orphans the separate `:lu` last-used key. -- `expiresAt` is set once and never mutates, so a check at read time has no consistency window - it is exact and adds zero extra reads (the field rides in the JSON already fetched to validate). +- A check at read time is exact at the moment of validation and adds zero extra reads (the field rides in the JSON already fetched to validate). Admin edits to `expiresAt` simply change the compared value, subject to normal KV propagation like every other metadata change. ## The decision we keep diff --git a/src/admin/index.ts b/src/admin/index.ts index 749edec..8648cc5 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -160,10 +160,13 @@ app.put("/api/tokens/:hash", async (c) => { patch.expiresAt = expiresAt; // undefined = never expires } if (!Object.keys(patch).length) return c.text("nothing to update", 400); - const meta = await patchToken(c.env.TOKENS, hash, patch); + // lastUsed is cosmetic: read it in parallel and never fail a committed patch over it. + const [meta, lastUsed] = await Promise.all([ + patchToken(c.env.TOKENS, hash, patch), + c.env.TOKENS.get(luKey(hash)).catch(() => null), + ]); if (!meta) return c.text("not found", 404); - const lastUsed = (await c.env.TOKENS.get(luKey(hash))) ?? undefined; - return c.html(tokenRow({ hash, ...meta, lastUsed })); + return c.html(tokenRow({ hash, ...meta, lastUsed: lastUsed ?? undefined })); }); app.delete("/api/tokens/:hash", async (c) => { diff --git a/src/admin/views.ts b/src/admin/views.ts index 55f8456..db77359 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -38,7 +38,8 @@ tr.empty:not(:only-child){display:none} .notice code{display:block;background:#04140c;padding:8px 10px;border-radius:6px;margin-top:6px;word-break:break-all} .copy{cursor:pointer} .copy.copied::after{content:"✓";float:right;color:#74e0bb} -.edit{cursor:pointer} +.edit{background:none;border:0;padding:0;font:inherit;cursor:pointer} +tr.htmx-request button{pointer-events:none;opacity:.5} .edit:hover{text-decoration:underline dotted} td input[type=datetime-local]{width:auto;padding:4px 6px;font-size:12px} .danger{color:#f08a8a;border-color:#5c2a2a} @@ -73,23 +74,24 @@ export const tokenRow = (r: TokenRow) => { ${providerPills(r.providers)} ${r.status} - ${expired ? "expired" : localTime(r.expiresAt)} + ${expired ? "expired" : localTime(r.expiresAt)} + ${localTime(r.lastUsed)} @@ -100,7 +102,8 @@ export const tokenRow = (r: TokenRow) => { hx-vals='{"status":"${r.status === "active" ? "disabled" : "active"}"}' hx-target="#tok-${r.hash}" hx-swap="outerHTML" - hx-sync="closest tr" + hx-sync="closest tr:queue all" + hx-indicator="closest tr" > ${r.status === "active" ? "disable" : "enable"} @@ -109,7 +112,8 @@ export const tokenRow = (r: TokenRow) => { hx-delete="/admin/api/tokens/${r.hash}" hx-target="#tok-${r.hash}" hx-swap="outerHTML" - hx-sync="closest tr" + hx-sync="closest tr:queue all" + hx-indicator="closest tr" hx-confirm="Delete this token?" > delete @@ -194,10 +198,9 @@ export const dashboardPage = () => html` hx-on::send-error="document.getElementById('flash').textContent = 'network error - proxy unreachable'" hx-on::after-request="if (event.detail.successful && event.detail.requestConfig.verb !== 'get') document.getElementById('flash').textContent = ''" hx-on::after-settle="for (const t of document.querySelectorAll('time[datetime]')) t.textContent = new Date(t.getAttribute('datetime')).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })" - hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('span.edit'); if (e) { const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; try { i.showPicker() } catch {} i.focus() }" - hx-on:keydown="if ((event.key === 'Enter' || event.key === ' ') && event.target.matches('span.edit')) { event.preventDefault(); event.target.click() }" + hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; try { i.showPicker() } catch {} i.focus() }" hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { t.style.display = 'none'; t.previousElementSibling.style.display = '' }" - hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) { if (p.expiresAt.slice(11) === new Date().toTimeString().slice(0, 5)) p.expiresAt = p.expiresAt.slice(0, 11) + '23:59'; p.expiresAt = new Date(p.expiresAt).toISOString() }" + hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) { const d = new Date(p.expiresAt); const n = new Date(); if (d <= n && d.toDateString() === n.toDateString()) p.expiresAt = p.expiresAt.slice(0, 11) + '23:59'; p.expiresAt = new Date(p.expiresAt).toISOString() }" >
diff --git a/test/admin.test.ts b/test/admin.test.ts index 1a25fb2..3e01057 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -178,7 +178,7 @@ describe("admin token CRUD", () => { // Each row carries a click-to-edit expiry editor (behavior is delegated at the body). expect(table).toContain('hx-trigger="change"'); expect(table).toContain('data-iso="2030-01-01T00:00:00.000Z"'); - expect(table).toContain('hx-sync="closest tr"'); + expect(table).toContain('hx-sync="closest tr:queue all"'); const bad = await call("/admin/api/tokens", { ...form( @@ -226,7 +226,7 @@ describe("admin token CRUD", () => { ); expect(past.status).toBe(200); const row = await past.text(); - expect(row).toContain(">expired Date: Sat, 11 Jul 2026 16:42:21 +0530 Subject: [PATCH 4/7] fix: retire the snap heuristic, serialize writes, make mutations beat 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. --- docs/architecture.md | 7 ++++--- src/admin/index.ts | 13 +++---------- src/admin/views.ts | 9 +++------ src/index.ts | 1 + src/tokens.ts | 24 +++++++++++++++++++++++- src/types.ts | 1 + test/admin.test.ts | 36 ++++++++++++++++++++++++++++++++---- vitest.config.ts | 1 + wrangler.toml | 9 +++++++++ 9 files changed, 77 insertions(+), 24 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9d07690..fc55117 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,8 +183,8 @@ Caveats: the rate limit gates the **connection**, not each frame, and revocation Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus **HTMX 2.x** loaded from a CDN with a pinned version **and an SRI hash** (`integrity` + `crossorigin`): the page holds token-mint power and receives `ADMIN_SECRET`, so a tampered CDN response must refuse to execute. No authored client JS beyond a handful of inline `hx-on` attributes; nothing in the worker bundle but markup + attributes. The login form also carries `method="post"` so a no-htmx fallback submit can never default to GET and leak the password into the URL. - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` is rate-limited per client IP (dedicated `LOGIN_LIMITER`, 10/60s, fail-open) and compared in constant time (hono's `timingSafeEqual`, which hashes both sides with SHA-256); failures are logged. Success sets a signed cookie via `hono/cookie` (`setSignedCookie`: HMAC-SHA-256 over the issue timestamp, `Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; `getSignedCookie` verifies in constant time (`crypto.subtle.verify`) and the guard re-checks the 24h age server-side, so a client ignoring `Max-Age` gains nothing. Tampered/expired cookies are pinned by negative-path tests. -- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Patches are unlocked KV read-modify-writes (last write wins, as everywhere on KV); same-row actions carry `hx-sync="closest tr:queue all"` so one browser issues them in order instead of racing or dropping them; concurrent writes from different clients remain last-write-wins. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. -- **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO on change; a "Today" pick snaps to 23:59 local; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible. The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. +- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Patches and deletes are serialized through a per-hash `TokenWriter` Durable Object, because a bare KV read-modify-write let a concurrent (or KV-stale) expiry patch resurrect a disabled token; every merge now follows its own prior write. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. +- **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO exactly as picked - a past pick renders as an expired row immediately; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible; the poll and row actions share one `hx-sync` scope on the persistent `#tokens` container so a stale in-flight poll response cannot overwrite a newer row swap, and in-flight rows dim and ignore further clicks (`hx-indicator`). The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. - **Errors surface:** a body-level `hx-on::response-error` writes failures into a flash div (htmx swaps nothing on non-2xx by default - previously a wrong password or an expired session silently no-opped), and a 401 mid-session bounces back to the login page. ## 12. Real key handling @@ -197,10 +197,11 @@ Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via ` |---|---|---| | `TOKENS` | KV namespace | token store (by `SHA-256`) + `:lu` last-used keys | | `US_EGRESS` | SQLite Durable Object (`UsEgress`) | US-jurisdiction egress fallback for OpenAI (HTTP + wss) | +| `TOKEN_WRITER` | SQLite Durable Object (`TokenWriter`) | serializes admin token writes (KV has no atomic read-modify-write) | | `RATE_LIMITER` | Rate Limit | per-token RPM ceiling (100/60s) | | `LOGIN_LIMITER` | Rate Limit | per-IP `/admin/login` throttle (10/60s, separate ruleset) | -Plus `[[migrations]] tag="v1" new_sqlite_classes=["UsEgress"]`. Upstreams resolve through `upstreamBase()`: the `*_UPSTREAM` env vars (plain vars, not secrets) default to the real hosts and are overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites just protocol/host/port. +Plus migrations `v1` (`UsEgress`) and `v2` (`TokenWriter`). Upstreams resolve through `upstreamBase()`: the `*_UPSTREAM` env vars (plain vars, not secrets) default to the real hosts and are overridden only by tests pointing at a mock; `rewriteToUpstream` rewrites just protocol/host/port. `[observability] enabled = true` persists `console.*` to Workers Logs. Infrastructure failures log their cause: KV reads, upstream fetches, geo-403 fallback, admin errors, failed logins, and `lastUsed` writes. Auth rejections and fail-open limiter errors stay unlogged to avoid request-driven log volume. diff --git a/src/admin/index.ts b/src/admin/index.ts index 8648cc5..ba65f85 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -3,14 +3,7 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { html } from "hono/html"; import { secureHeaders } from "hono/secure-headers"; import { timingSafeEqual } from "hono/utils/buffer"; -import { - createToken, - deleteToken, - listTokens, - luKey, - patchToken, - sha256hex, -} from "../tokens"; +import { createToken, listTokens, luKey, sha256hex } from "../tokens"; import type { CoarseProvider, Env, TokenMetadata } from "../types"; import { createdNotice, @@ -162,7 +155,7 @@ app.put("/api/tokens/:hash", async (c) => { if (!Object.keys(patch).length) return c.text("nothing to update", 400); // lastUsed is cosmetic: read it in parallel and never fail a committed patch over it. const [meta, lastUsed] = await Promise.all([ - patchToken(c.env.TOKENS, hash, patch), + c.env.TOKEN_WRITER.getByName(hash).patch(hash, patch), c.env.TOKENS.get(luKey(hash)).catch(() => null), ]); if (!meta) return c.text("not found", 404); @@ -172,7 +165,7 @@ app.put("/api/tokens/:hash", async (c) => { app.delete("/api/tokens/:hash", async (c) => { const hash = c.req.param("hash"); if (!isHash(hash)) return c.text("bad token id", 400); - await deleteToken(c.env.TOKENS, hash); + await c.env.TOKEN_WRITER.getByName(hash).remove(hash); return c.body("", 200); }); diff --git a/src/admin/views.ts b/src/admin/views.ts index db77359..8ee417c 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -90,7 +90,6 @@ export const tokenRow = (r: TokenRow) => { hx-trigger="change" hx-target="#tok-${r.hash}" hx-swap="outerHTML" - hx-sync="closest tr:queue all" hx-indicator="closest tr" /> @@ -102,7 +101,6 @@ export const tokenRow = (r: TokenRow) => { hx-vals='{"status":"${r.status === "active" ? "disabled" : "active"}"}' hx-target="#tok-${r.hash}" hx-swap="outerHTML" - hx-sync="closest tr:queue all" hx-indicator="closest tr" > ${r.status === "active" ? "disable" : "enable"} @@ -112,7 +110,6 @@ export const tokenRow = (r: TokenRow) => { hx-delete="/admin/api/tokens/${r.hash}" hx-target="#tok-${r.hash}" hx-swap="outerHTML" - hx-sync="closest tr:queue all" hx-indicator="closest tr" hx-confirm="Delete this token?" > @@ -135,7 +132,7 @@ export const tokenTable = (rows: TokenRow[]) => html` - + No tokens yet. ${rows.map((r) => tokenRow(r))} @@ -200,7 +197,7 @@ export const dashboardPage = () => html` hx-on::after-settle="for (const t of document.querySelectorAll('time[datetime]')) t.textContent = new Date(t.getAttribute('datetime')).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })" hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; try { i.showPicker() } catch {} i.focus() }" hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { t.style.display = 'none'; t.previousElementSibling.style.display = '' }" - hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) { const d = new Date(p.expiresAt); const n = new Date(); if (d <= n && d.toDateString() === n.toDateString()) p.expiresAt = p.expiresAt.slice(0, 11) + '23:59'; p.expiresAt = new Date(p.expiresAt).toISOString() }" + hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) p.expiresAt = new Date(p.expiresAt).toISOString()" >
@@ -243,7 +240,7 @@ export const dashboardPage = () => html`

Tokens

-
+
Loading…
diff --git a/src/index.ts b/src/index.ts index 1d4f66e..f8c3138 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { handleProxy } from "./proxy"; import type { Env } from "./types"; import { handleWsProxy } from "./ws"; +export { TokenWriter } from "./tokens"; export { UsEgress } from "./upstreams"; export default { diff --git a/src/tokens.ts b/src/tokens.ts index 0dad1db..78646c5 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -1,5 +1,6 @@ // Tokens are keyed by SHA-256(token); the plaintext is never persisted. -import type { CoarseProvider, TokenMetadata } from "./types"; +import { DurableObject } from "cloudflare:workers"; +import type { CoarseProvider, Env, TokenMetadata } from "./types"; export async function sha256hex(input: string): Promise { const digest = await crypto.subtle.digest( @@ -118,3 +119,24 @@ export async function touchLastUsed( console.warn("lastUsed stamp failed", err); } } + +// KV has no atomic read-modify-write, so admin mutations are serialized through one +// DO instance per hash: each merge follows its own prior write instead of a possibly +// stale read, which is what let an expiry edit resurrect a concurrently disabled token. +export class TokenWriter extends DurableObject { + private queue: Promise = Promise.resolve(); + private run(job: () => Promise): Promise { + const next = this.queue.then(job, job); + this.queue = next.catch(() => {}); + return next; + } + patch( + hash: string, + patch: Partial>, + ) { + return this.run(() => patchToken(this.env.TOKENS, hash, patch)); + } + remove(hash: string) { + return this.run(() => deleteToken(this.env.TOKENS, hash)); + } +} diff --git a/src/types.ts b/src/types.ts index f57732a..b4b8dcc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,7 @@ export interface TokenMetadata { export interface Env { TOKENS: KVNamespace; US_EGRESS: DurableObjectNamespace; + TOKEN_WRITER: DurableObjectNamespace; RATE_LIMITER: RateLimit; LOGIN_LIMITER: RateLimit; OPENAI_API_KEY: string; diff --git a/test/admin.test.ts b/test/admin.test.ts index 3e01057..d35e35c 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -178,7 +178,7 @@ describe("admin token CRUD", () => { // Each row carries a click-to-edit expiry editor (behavior is delegated at the body). expect(table).toContain('hx-trigger="change"'); expect(table).toContain('data-iso="2030-01-01T00:00:00.000Z"'); - expect(table).toContain('hx-sync="closest tr:queue all"'); + expect(table).toContain('hx-indicator="closest tr"'); const bad = await call("/admin/api/tokens", { ...form( @@ -242,6 +242,31 @@ describe("admin token CRUD", () => { }); }); + it("keeps a token disabled when an expiry edit races the disable", async () => { + const cookie = await login(); + await call("/admin/api/tokens", { + ...form( + { label: "race", providers: "openai", token: "race-check-token" }, + cookie, + ), + }); + const hash = await sha256hex("race-check-token"); + for (let i = 0; i < 5; i++) { + await put(`/admin/api/tokens/${hash}`, { status: "active" }, cookie); + // Concurrent handlers interleave at KV awaits; the writer DO must serialize + // the merges so the stale-read expiry patch cannot resurrect the disable. + await Promise.all([ + put(`/admin/api/tokens/${hash}`, { status: "disabled" }, cookie), + put( + `/admin/api/tokens/${hash}`, + { expiresAt: "2040-01-01T00:00:00.000Z" }, + cookie, + ), + ]); + expect(await getValidated(env.TOKENS, "race-check-token")).toBeNull(); + } + }); + it("400s an invalid expiry patch and a PUT with nothing to update", async () => { const cookie = await login(); await call("/admin/api/tokens", { @@ -294,12 +319,15 @@ describe("admin token CRUD", () => { expect(page).toContain("code.copy"); expect(page).toContain("navigator.clipboard.writeText"); expect(page).toContain('name="label" placeholder="alice-laptop" required'); - // One body-level converter serves every datetime-local: Today snaps to 23:59, then UTC ISO. - expect(page).toContain("p.expiresAt.slice(0, 11) + '23:59'"); + // One body-level converter serves every datetime-local; values save exactly as picked. + expect(page).toContain("p.expiresAt = new Date(p.expiresAt).toISOString()"); + // The poll and row mutations share one persistent sync scope (in-flight polls + // must never overwrite a newer row swap). + expect(page).toContain('id="tokens" hx-sync="this:drop"'); const table = await ( await call("/admin/api/tokens", { headers: { cookie } }) ).text(); - expect(table).toContain(''); + expect(table).toContain(''); expect(table).toContain('class="empty"'); }); diff --git a/vitest.config.ts b/vitest.config.ts index c27c848..6362b39 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ kvNamespaces: ["TOKENS"], durableObjects: { US_EGRESS: { className: "UsEgress", useSQLite: true }, + TOKEN_WRITER: { className: "TokenWriter", useSQLite: true }, }, bindings: { OPENAI_API_KEY: "real-openai-key-FAKE", diff --git a/wrangler.toml b/wrangler.toml index 0a1b335..0ab2956 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -20,10 +20,19 @@ id = "1b212581e15144fb9f931b221a8cc5cd" name = "US_EGRESS" class_name = "UsEgress" +# Serializes admin token writes (KV alone has no atomic read-modify-write). +[[durable_objects.bindings]] +name = "TOKEN_WRITER" +class_name = "TokenWriter" + [[migrations]] tag = "v1" new_sqlite_classes = ["UsEgress"] +[[migrations]] +tag = "v2" +new_sqlite_classes = ["TokenWriter"] + # Per-token, per-colo limit keyed by token hash; `period` must be 10 or 60. # `namespace_id` is an arbitrary integer string unique to this ruleset. [[ratelimits]] From 8985b383ada40d1bccc37262c8f2f0cc6a462fa4 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Sat, 11 Jul 2026 17:07:30 +0530 Subject: [PATCH 5/7] fix: merge from writer storage, abort polls when an editor opens - 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. --- README.md | 2 +- docs/architecture.md | 2 +- src/admin/views.ts | 2 +- src/tokens.ts | 60 +++++++++++++++++++++----------------- test/admin.test.ts | 48 ++++++++++++++++++++++++++++++ test/proxy-handler.test.ts | 6 ++-- test/tokens.test.ts | 18 ++---------- 7 files changed, 90 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index b86cded..78a4ee5 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ uv pip install -r test/requirements.txt ## Cost -The Worker and SQLite Durable Object have separate allowances. Cloudflare's Free Durable Object tier lists **100,000 requests/day** and **13,000 GB-s/day** (pricing page updated 2026-06-19). Each OpenAI geo-block fallback consumes one DO request, and its execution contributes to active duration. See [Durable Objects pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/); upstream API usage is billed by the provider. +The Worker and SQLite Durable Object have separate allowances. Cloudflare's Free Durable Object tier lists **100,000 requests/day** and **13,000 GB-s/day** (pricing page updated 2026-06-19). Each OpenAI geo-block fallback consumes one DO request, and its execution contributes to active duration; each admin token edit or delete adds one `TokenWriter` DO request. See [Durable Objects pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/); upstream API usage is billed by the provider. ## Contributing diff --git a/docs/architecture.md b/docs/architecture.md index fc55117..a210edf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,7 @@ Caveats: the rate limit gates the **connection**, not each frame, and revocation Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus **HTMX 2.x** loaded from a CDN with a pinned version **and an SRI hash** (`integrity` + `crossorigin`): the page holds token-mint power and receives `ADMIN_SECRET`, so a tampered CDN response must refuse to execute. No authored client JS beyond a handful of inline `hx-on` attributes; nothing in the worker bundle but markup + attributes. The login form also carries `method="post"` so a no-htmx fallback submit can never default to GET and leak the password into the URL. - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` is rate-limited per client IP (dedicated `LOGIN_LIMITER`, 10/60s, fail-open) and compared in constant time (hono's `timingSafeEqual`, which hashes both sides with SHA-256); failures are logged. Success sets a signed cookie via `hono/cookie` (`setSignedCookie`: HMAC-SHA-256 over the issue timestamp, `Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; `getSignedCookie` verifies in constant time (`crypto.subtle.verify`) and the guard re-checks the 24h age server-side, so a client ignoring `Max-Age` gains nothing. Tampered/expired cookies are pinned by negative-path tests. -- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Patches and deletes are serialized through a per-hash `TokenWriter` Durable Object, because a bare KV read-modify-write let a concurrent (or KV-stale) expiry patch resurrect a disabled token; every merge now follows its own prior write. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. +- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Patches and deletes are serialized through a per-hash `TokenWriter` Durable Object whose own storage is the merge base (KV does not guarantee read-your-write, so merging from a KV read let a stale expiry patch resurrect a disabled token); KV is written through for the hot path and only bootstraps a hash the writer has never touched, guarded by a deletion tombstone. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. - **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO exactly as picked - a past pick renders as an expired row immediately; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible; the poll and row actions share one `hx-sync` scope on the persistent `#tokens` container so a stale in-flight poll response cannot overwrite a newer row swap, and in-flight rows dim and ignore further clicks (`hx-indicator`). The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. - **Errors surface:** a body-level `hx-on::response-error` writes failures into a flash div (htmx swaps nothing on non-2xx by default - previously a wrong password or an expired session silently no-opped), and a 401 mid-session bounces back to the login page. diff --git a/src/admin/views.ts b/src/admin/views.ts index 8ee417c..9431325 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -195,7 +195,7 @@ export const dashboardPage = () => html` hx-on::send-error="document.getElementById('flash').textContent = 'network error - proxy unreachable'" hx-on::after-request="if (event.detail.successful && event.detail.requestConfig.verb !== 'get') document.getElementById('flash').textContent = ''" hx-on::after-settle="for (const t of document.querySelectorAll('time[datetime]')) t.textContent = new Date(t.getAttribute('datetime')).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })" - hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; try { i.showPicker() } catch {} i.focus() }" + hx-on:click="const c = event.target.closest('code.copy'); if (c) navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 1000) }); const e = event.target.closest('button.edit'); if (e) { htmx.trigger('#tokens', 'htmx:abort'); const i = e.nextElementSibling; e.style.display = 'none'; i.style.display = ''; const d = new Date(e.dataset.iso); i.value = e.dataset.iso ? new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16) : ''; try { i.showPicker() } catch {} i.focus() }" hx-on:focusout="const t = event.target; if (t.matches('#rows input[type=datetime-local]')) { t.style.display = 'none'; t.previousElementSibling.style.display = '' }" hx-on::config-request="const p = event.detail.parameters; if (p.expiresAt) p.expiresAt = new Date(p.expiresAt).toISOString()" > diff --git a/src/tokens.ts b/src/tokens.ts index 78646c5..510a170 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -80,26 +80,6 @@ export async function listTokens(kv: KVNamespace): Promise { }); } -export async function patchToken( - kv: KVNamespace, - hash: string, - patch: Partial>, -): Promise { - const meta = parseMeta(await kv.get(hash)); - if (!meta) return null; - // An explicit `expiresAt: undefined` clears it: JSON.stringify drops the key. - const updated = { ...meta, ...patch }; - await kv.put(hash, JSON.stringify(updated)); - return updated; -} - -export async function deleteToken( - kv: KVNamespace, - hash: string, -): Promise { - await Promise.all([kv.delete(hash), kv.delete(luKey(hash))]); -} - // Record the first observed use per UTC day and isolate, limiting KV writes. const luStampedDay = new Map(); @@ -120,9 +100,10 @@ export async function touchLastUsed( } } -// KV has no atomic read-modify-write, so admin mutations are serialized through one -// DO instance per hash: each merge follows its own prior write instead of a possibly -// stale read, which is what let an expiry edit resurrect a concurrently disabled token. +// KV has no atomic read-modify-write and does not even guarantee read-your-write, so +// mutations are serialized through one DO instance per hash whose own storage is the +// merge base; KV is written through for the proxy's hot-path reads and never merged +// from once this instance has state (a stale KV echo once resurrected a disabled token). export class TokenWriter extends DurableObject { private queue: Promise = Promise.resolve(); private run(job: () => Promise): Promise { @@ -130,13 +111,38 @@ export class TokenWriter extends DurableObject { this.queue = next.catch(() => {}); return next; } + patch( hash: string, patch: Partial>, - ) { - return this.run(() => patchToken(this.env.TOKENS, hash, patch)); + ): Promise { + return this.run(async () => { + let base = await this.ctx.storage.get("meta"); + if (!base) { + // First contact: bootstrap from KV - unless a tombstone marks the KV record + // as a stale echo of a deleted token (a recreation has a newer createdAt). + const tomb = await this.ctx.storage.get("deleted"); + const kvMeta = parseMeta(await this.env.TOKENS.get(hash)); + base = + kvMeta && (!tomb || kvMeta.createdAt > tomb) ? kvMeta : undefined; + } + if (!base) return null; + // An explicit `expiresAt: undefined` clears it: JSON.stringify drops the key. + const updated = { ...base, ...patch }; + await this.ctx.storage.put("meta", updated); + await this.env.TOKENS.put(hash, JSON.stringify(updated)); + return updated; + }); } - remove(hash: string) { - return this.run(() => deleteToken(this.env.TOKENS, hash)); + + remove(hash: string): Promise { + return this.run(async () => { + await this.ctx.storage.delete("meta"); + await this.ctx.storage.put("deleted", new Date().toISOString()); + await Promise.all([ + this.env.TOKENS.delete(hash), + this.env.TOKENS.delete(luKey(hash)), + ]); + }); } } diff --git a/test/admin.test.ts b/test/admin.test.ts index d35e35c..c74694f 100644 --- a/test/admin.test.ts +++ b/test/admin.test.ts @@ -237,6 +237,8 @@ describe("admin token CRUD", () => { cookie, ); expect(cleared.status).toBe(200); + // Clearing drops the key from the stored JSON entirely (never expires). + expect(await env.TOKENS.get(hash)).not.toContain("expiresAt"); expect(await getValidated(env.TOKENS, "edit-expiry-token")).toMatchObject({ status: "active", }); @@ -267,6 +269,52 @@ describe("admin token CRUD", () => { } }); + it("merges from the writer's own storage, not a stale KV echo", async () => { + const cookie = await login(); + const res = await call("/admin/api/tokens", { + ...form( + { label: "stale", providers: "openai", token: "stale-echo-token" }, + cookie, + ), + }); + expect(res.status).toBe(200); + const hash = await sha256hex("stale-echo-token"); + await put(`/admin/api/tokens/${hash}`, { status: "disabled" }, cookie); + // Simulate KV serving a stale pre-disable record (KV has no read-your-write guarantee). + const stale = JSON.parse((await env.TOKENS.get(hash))!); + await env.TOKENS.put(hash, JSON.stringify({ ...stale, status: "active" })); + await put( + `/admin/api/tokens/${hash}`, + { expiresAt: "2040-01-01T00:00:00.000Z" }, + cookie, + ); + expect(await getValidated(env.TOKENS, "stale-echo-token")).toBeNull(); + }); + + it("does not resurrect a deleted token from a stale KV echo (tombstone)", async () => { + const cookie = await login(); + await call("/admin/api/tokens", { + ...form( + { label: "tomb", providers: "openai", token: "tombstone-token" }, + cookie, + ), + }); + const hash = await sha256hex("tombstone-token"); + const record = (await env.TOKENS.get(hash))!; + await call(`/admin/api/tokens/${hash}`, { + method: "DELETE", + headers: { cookie }, + }); + // The stale record resurfaces after the delete; a patch must not resurrect it. + await env.TOKENS.put(hash, record); + const res = await put( + `/admin/api/tokens/${hash}`, + { status: "active" }, + cookie, + ); + expect(res.status).toBe(404); + }); + it("400s an invalid expiry patch and a PUT with nothing to update", async () => { const cookie = await login(); await call("/admin/api/tokens", { diff --git a/test/proxy-handler.test.ts b/test/proxy-handler.test.ts index d9ce958..88cf69a 100644 --- a/test/proxy-handler.test.ts +++ b/test/proxy-handler.test.ts @@ -5,7 +5,7 @@ import { import { env } from "cloudflare:workers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import worker from "../src/index"; -import { createToken, patchToken, sha256hex } from "../src/tokens"; +import { createToken, sha256hex } from "../src/tokens"; import { fakeEgress, geo403, seed, setLimiter } from "./helpers"; let captured: Request | null; @@ -169,12 +169,12 @@ describe("auth failures (upstream never called)", () => { expect(captured).toBeNull(); }); it("401 for a disabled token", async () => { - const { hash } = await createToken(env.TOKENS, { + const { hash, meta } = await createToken(env.TOKENS, { label: "d", providers: ["openai"], token: "tk-disabled", }); - await patchToken(env.TOKENS, hash, { status: "disabled" }); + await env.TOKENS.put(hash, JSON.stringify({ ...meta, status: "disabled" })); const res = await call( new Request("https://proxy.example/v1/chat/completions", { method: "POST", diff --git a/test/tokens.test.ts b/test/tokens.test.ts index 8fa705f..afa4bcc 100644 --- a/test/tokens.test.ts +++ b/test/tokens.test.ts @@ -4,7 +4,6 @@ import { createToken, generateToken, listTokens, - patchToken, sha256hex, touchLastUsed, } from "../src/tokens"; @@ -46,18 +45,7 @@ describe("createToken + getValidated", () => { }); }); -describe("listTokens / patchToken / deleteToken", () => { - it("patches expiry and an explicit undefined clears it from the stored JSON", async () => { - const { hash } = await createToken(env.TOKENS, { - label: "patch", - providers: ["openai"], - token: "patch-exp-token", - expiresAt: "2030-01-01T00:00:00.000Z", - }); - await patchToken(env.TOKENS, hash, { expiresAt: undefined }); - expect(await env.TOKENS.get(hash)).not.toContain("expiresAt"); - }); - +describe("listTokens", () => { it("lists created tokens by hash with metadata", async () => { await createToken(env.TOKENS, { label: "L1", @@ -131,12 +119,12 @@ describe("touchLastUsed", () => { }); it("does not resurrect a disabled token when lastUsed is stamped", async () => { - const { token, hash } = await createToken(env.TOKENS, { + const { token, hash, meta } = await createToken(env.TOKENS, { label: "rev", providers: ["openai"], token: "to-revoke", }); - await patchToken(env.TOKENS, hash, { status: "disabled" }); + await env.TOKENS.put(hash, JSON.stringify({ ...meta, status: "disabled" })); await touchLastUsed(env.TOKENS, hash); expect(await getValidated(env.TOKENS, token)).toBeNull(); }); From 4a67af08232cab3d80dc8c6b56d066de9809bd62 Mon Sep 17 00:00:00 2001 From: Sudharsan Date: Sat, 11 Jul 2026 17:38:28 +0530 Subject: [PATCH 6/7] fix: self-review findings - writer failure ordering and creation seeding - 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. --- docs/architecture.md | 4 ++-- src/admin/index.ts | 7 +++++-- src/admin/views.ts | 20 +++++++++----------- src/tokens.ts | 44 ++++++++++++++++++++++++++++++++++---------- test/admin.test.ts | 27 +++++++++++++++++++++++++-- 5 files changed, 75 insertions(+), 27 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a210edf..d4deaa1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,7 +106,7 @@ type TokenMetadata = { - **Tokens** are opaque: `ptk_` + 32 url-safe chars (24 random bytes). Custom admin-typed tokens are allowed; validation is by hash of the full string. - **Validation** (`getValidatedByHash`): returns the record only if `status === "active"` AND, when `expiresAt` is set, it parses to a future timestamp - malformed or past expiry is rejected **fail-closed**. Not KV `expirationTtl` - why: [`token-expiry-check-at-validate.md`](learnings/token-expiry-check-at-validate.md). - **`lastUsed`** is a separate `:lu` key. The first qualifying use per token/day/isolate schedules a stamp: either an authorized HTTP request that receives any upstream response or a successful WebSocket upgrade. The dashboard localizes the stored ISO timestamp. See [`proxy-token-security.md`](learnings/proxy-token-security.md) and [`kv-free-tier-write-quota.md`](learnings/kv-free-tier-write-quota.md). -- **Lifecycle:** `createToken`, `listTokens` (one `kv.list` page plus batched multi-key reads), `patchToken` (status and/or expiry), `deleteToken` (record + `:lu`). Changes can take 60 seconds or more to become visible in other locations. +- **Lifecycle:** `createToken`, `listTokens` (one `kv.list` page plus batched multi-key reads), `TokenWriter.patch`/`.remove` (status and/or expiry; delete covers the record + `:lu`). Changes can take 60 seconds or more to become visible in other locations. ```mermaid stateDiagram-v2 @@ -183,7 +183,7 @@ Caveats: the rate limit gates the **connection**, not each frame, and revocation Embedded **Hono** sub-app at `/admin` (`src/admin/`), server-rendered HTML via `hono/html` plus **HTMX 2.x** loaded from a CDN with a pinned version **and an SRI hash** (`integrity` + `crossorigin`): the page holds token-mint power and receives `ADMIN_SECRET`, so a tampered CDN response must refuse to execute. No authored client JS beyond a handful of inline `hx-on` attributes; nothing in the worker bundle but markup + attributes. The login form also carries `method="post"` so a no-htmx fallback submit can never default to GET and leak the password into the URL. - **Auth:** one `ADMIN_SECRET` password. `POST /admin/login` is rate-limited per client IP (dedicated `LOGIN_LIMITER`, 10/60s, fail-open) and compared in constant time (hono's `timingSafeEqual`, which hashes both sides with SHA-256); failures are logged. Success sets a signed cookie via `hono/cookie` (`setSignedCookie`: HMAC-SHA-256 over the issue timestamp, `Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`). A middleware guards every `/admin/*` route except login; `getSignedCookie` verifies in constant time (`crypto.subtle.verify`) and the guard re-checks the 24h age server-side, so a client ignoring `Max-Age` gains nothing. Tampered/expired cookies are pinned by negative-path tests. -- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Patches and deletes are serialized through a per-hash `TokenWriter` Durable Object whose own storage is the merge base (KV does not guarantee read-your-write, so merging from a KV read let a stale expiry patch resurrect a disabled token); KV is written through for the hot path and only bootstraps a hash the writer has never touched, guarded by a deletion tombstone. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. +- **CRUD:** `GET`, `POST`, `PUT`, and `DELETE` under `/admin/api/tokens`. Hash params must be 64-hex; provider scope and status are whitelisted; custom tokens need at least 12 characters. `PUT` patches status and/or expiry - a blank expiry clears it (never expires). Creation, patches, and deletes are serialized through a per-hash `TokenWriter` Durable Object whose own storage is the merge base (KV guarantees neither atomic read-modify-write nor read-your-write, so merging from a KV read let a stale expiry patch resurrect a disabled token); KV is written through for the hot path, only pre-writer hashes bootstrap from a KV read, a deletion tombstone blocks stale echoes, and recreation clears it. Creation checks for an existing hash and returns 409, but KV offers no transaction across that read and write, so concurrent identical creations are not an atomic uniqueness guarantee. - **UI:** creation returns the plaintext once, base URLs, and an out-of-band row; `PUT` returns a replacement row; `DELETE` returns an empty 200 body. The table localizes expiry and `lastUsed`, marks expired rows, edits expiry in place (click or focus+Enter on the cell, pick a local datetime, saved as UTC ISO exactly as picked - a past pick renders as an expired row immediately; the poll pauses while an editor is focused), loads immediately, and refreshes every 120 seconds while visible; the poll and row actions share one `hx-sync` scope on the persistent `#tokens` container so a stale in-flight poll response cannot overwrite a newer row swap, and in-flight rows dim and ignore further clicks (`hx-indicator`). The immediate POST row avoids waiting for KV propagation, which can take 60 seconds or more in other locations. - **Errors surface:** a body-level `hx-on::response-error` writes failures into a flash div (htmx swaps nothing on non-2xx by default - previously a wrong password or an expired session silently no-opped), and a 401 mid-session bounces back to the login page. diff --git a/src/admin/index.ts b/src/admin/index.ts index ba65f85..83e9b75 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -3,7 +3,7 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; import { html } from "hono/html"; import { secureHeaders } from "hono/secure-headers"; import { timingSafeEqual } from "hono/utils/buffer"; -import { createToken, listTokens, luKey, sha256hex } from "../tokens"; +import { listTokens, luKey, mintToken, sha256hex } from "../tokens"; import type { CoarseProvider, Env, TokenMetadata } from "../types"; import { createdNotice, @@ -123,12 +123,15 @@ app.post("/api/tokens", async (c) => { } const expiresAt = parseExpiry(String(fd.get("expiresAt") || "")); if (expiresAt === null) return c.text("invalid expiry", 400); - const { token, hash, meta } = await createToken(c.env.TOKENS, { + const { token, hash, meta } = await mintToken({ label, providers, token: custom || undefined, expiresAt, }); + // Creating through the writer seeds its merge base, so the first edit of a fresh + // token never depends on a KV read (no read-your-write guarantee). + await c.env.TOKEN_WRITER.getByName(hash).create(hash, meta); // KV list() can lag by 60 seconds or more, so return the new row out-of-band. return c.html( html`${createdNotice(token, providers, new URL(c.req.url).origin)} diff --git a/src/admin/views.ts b/src/admin/views.ts index 9431325..91c9dd3 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -39,7 +39,7 @@ tr.empty:not(:only-child){display:none} .copy{cursor:pointer} .copy.copied::after{content:"✓";float:right;color:#74e0bb} .edit{background:none;border:0;padding:0;font:inherit;cursor:pointer} -tr.htmx-request button{pointer-events:none;opacity:.5} +tr.htmx-request button{opacity:.5} .edit:hover{text-decoration:underline dotted} td input[type=datetime-local]{width:auto;padding:4px 6px;font-size:12px} .danger{color:#f08a8a;border-color:#5c2a2a} @@ -68,7 +68,13 @@ const localTime = (iso?: string) => export const tokenRow = (r: TokenRow) => { const expired = !!r.expiresAt && Date.parse(r.expiresAt) <= Date.now(); return html` - + ${r.label || "(no label)"} …${r.last4} ${providerPills(r.providers)} @@ -88,9 +94,6 @@ export const tokenRow = (r: TokenRow) => { style="display:none" hx-put="/admin/api/tokens/${r.hash}" hx-trigger="change" - hx-target="#tok-${r.hash}" - hx-swap="outerHTML" - hx-indicator="closest tr" /> ${localTime(r.lastUsed)} @@ -99,18 +102,12 @@ export const tokenRow = (r: TokenRow) => { class="ghost" hx-put="/admin/api/tokens/${r.hash}" hx-vals='{"status":"${r.status === "active" ? "disabled" : "active"}"}' - hx-target="#tok-${r.hash}" - hx-swap="outerHTML" - hx-indicator="closest tr" > ${r.status === "active" ? "disable" : "enable"}