diff --git a/.gitignore b/.gitignore index b1daaed..a35f69f 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/README.md b/README.md index b86cded..b4f97c7 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Visit `https:///admin`, sign in with `ADMIN_SECRET`, and create tokens w ## Per-token controls -- **Expiry** - optionally set an expiry at creation; past it the token is rejected and the dashboard shows it as `expired`. +- **Expiry** - optionally set or edit an expiry; past it the token is rejected and the dashboard shows it as `expired`. - **Rate limit** - each token is capped at 100 requests / 60s (`429` + `Retry-After` over the limit). Tune `[[ratelimits]]` in `wrangler.toml`. It is a per-location, loose ceiling for abuse protection, not a strict quota. - **Scope & revoke** - a token only reaches the providers you check; disable or delete to revoke (KV changes can take 60 seconds or more to reach other locations). @@ -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 9f3ea80..0e99d49 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:** admin creation uses `mintToken` + `TokenWriter.create`; `listTokens` performs one `kv.list` page plus batched multi-key reads; `TokenWriter.patch`/`.remove` handle status, expiry, and deletion (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). 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. Polls and mutations share the persistent `#tokens` sync scope; in-flight rows dim but stay clickable so same-row gestures reach the writer. A different row's mutation can abort the earlier row swap, leaving that cell stale until the next poll. 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 @@ -196,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. @@ -221,7 +223,7 @@ nubx wrangler secret put OPENAI_API_KEY # + ANTHROPIC / GEMINI / AD nubx wrangler deploy ``` -See the README's [cost note](../README.md#cost): fallback traffic consumes Durable Object request and duration allowance as well as the Worker request. +See the README's [cost note](../README.md#cost): OpenAI fallbacks and admin token mutations consume Durable Object request and duration allowance as well as the Worker request. ## 16. Security model @@ -229,5 +231,5 @@ Invariants are detailed in §5 (auth swap), §6 (token hashing, revoke-safe `las Caveats: -- KV-backed create, revoke, and status changes can take 60 seconds or more to appear in another location; rotate the provider credential when a provider-wide cutoff cannot wait for KV propagation. +- KV-backed create, revoke, status, and expiry changes can take 60 seconds or more to appear in another location; rotate the provider credential when a provider-wide cutoff cannot wait for KV propagation. - Do not host on `*.openai.azure.com` / `*.cognitiveservices.azure.com` (the OpenAI SDK switches to Azure auth on those hostnames). 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 f1a8f6f..83e9b75 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -3,14 +3,8 @@ 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, - setTokenStatus, - sha256hex, -} from "../tokens"; -import type { CoarseProvider, Env } from "../types"; +import { listTokens, luKey, mintToken, sha256hex } from "../tokens"; +import type { CoarseProvider, Env, TokenMetadata } from "../types"; import { createdNotice, dashboardPage, @@ -34,6 +28,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,21 +121,17 @@ 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 { token, hash, meta } = await createToken(c.env.TOKENS, { + const expiresAt = parseExpiry(String(fd.get("expiresAt") || "")); + if (expiresAt === null) return c.text("invalid expiry", 400); + 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)} @@ -142,18 +142,33 @@ 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); + // lastUsed is cosmetic: read it in parallel and never fail a committed patch over it. + const [meta, lastUsed] = await Promise.all([ + 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); - return c.html(tokenRow({ hash, ...meta })); + return c.html(tokenRow({ hash, ...meta, lastUsed: lastUsed ?? undefined })); }); 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 7fdfd90..91c9dd3 100644 --- a/src/admin/views.ts +++ b/src/admin/views.ts @@ -38,6 +38,10 @@ 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{background:none;border:0;padding:0;font:inherit;cursor:pointer} +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} `; @@ -64,28 +68,46 @@ 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)} ${r.status} - ${expired ? html`expired` : html`${localTime(r.expiresAt)}`} + + + + ${localTime(r.lastUsed)}