Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ HANDOFF.md

# wrangler local state / build cache
.wrangler
.dev.vars*

# playwright artifacts
.playwright-mcp
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Visit `https://<worker>/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).

Expand Down Expand Up @@ -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

Expand Down
14 changes: 8 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,15 @@ 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 `<hash>: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
[*] --> active: createToken (plaintext shown once)
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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -221,13 +223,13 @@ 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

Invariants are detailed in §5 (auth swap), §6 (token hashing, revoke-safe `lastUsed`), §11 (admin HMAC cookie), and §12 (real-key handling).

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).
2 changes: 1 addition & 1 deletion docs/learnings/token-expiry-check-at-validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<hash>: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

Expand Down
63 changes: 39 additions & 24 deletions src/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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)}
Expand All @@ -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<Pick<TokenMetadata, "status" | "expiresAt">> = {};
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);
});

Expand Down
Loading
Loading