From 6b9d052825992d3d36cc727c903de8971c34398f Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Mon, 17 Aug 2026 18:44:36 -0400 Subject: [PATCH 1/3] feat: read-only tokens, last-used, and a stabler developers page Mint can be files:read only. Successful auth stamps last_used_at at most once an hour. The developers page SSR-paints the form and list so the layout does not hide-then-show. CLI help no longer mentions a separate API key. --- .changeset/api-token-read-only-last-used.md | 5 + .../20260817180000_token_last_used.sql | 4 + apps/api/src/admin.ts | 3 +- apps/api/src/auth-db.ts | 44 +++-- apps/api/src/routes/tokens.test.ts | 3 + apps/api/src/routes/tokens.ts | 1 + apps/api/src/workspace.ts | 3 + apps/api/test/auth-db.test.ts | 26 +++ apps/api/test/usage-fake-d1.ts | 3 + apps/web/src/lib/api-client.test.ts | 8 + apps/web/src/lib/api-client.ts | 42 +++-- apps/web/src/lib/developers-ui.test.ts | 30 ++++ apps/web/src/lib/developers-ui.ts | 69 ++++++++ apps/web/src/pages/account/developers.astro | 166 ++++++++++-------- docs/enrollment.md | 8 +- packages/uploads/src/commands/config.ts | 2 +- 16 files changed, 318 insertions(+), 99 deletions(-) create mode 100644 .changeset/api-token-read-only-last-used.md create mode 100644 apps/api/migrations/20260817180000_token_last_used.sql create mode 100644 apps/web/src/lib/developers-ui.test.ts create mode 100644 apps/web/src/lib/developers-ui.ts diff --git a/.changeset/api-token-read-only-last-used.md b/.changeset/api-token-read-only-last-used.md new file mode 100644 index 00000000..362158fc --- /dev/null +++ b/.changeset/api-token-read-only-last-used.md @@ -0,0 +1,5 @@ +--- +"@buildinternet/uploads": patch +--- + +`uploads config` help no longer mentions a separate API key. Workspace tokens are `up__…`. diff --git a/apps/api/migrations/20260817180000_token_last_used.sql b/apps/api/migrations/20260817180000_token_last_used.sql new file mode 100644 index 00000000..833da964 --- /dev/null +++ b/apps/api/migrations/20260817180000_token_last_used.sql @@ -0,0 +1,4 @@ +-- Last-used stamp for issued tokens on /account/developers. Nullable: unused +-- tokens and rows minted before this migration stay NULL. Touched on a +-- successful auth, at most once an hour (see touchTokenLastUsed). +ALTER TABLE auth_tokens ADD COLUMN last_used_at TEXT; diff --git a/apps/api/src/admin.ts b/apps/api/src/admin.ts index ede96817..45cb57eb 100644 --- a/apps/api/src/admin.ts +++ b/apps/api/src/admin.ts @@ -1,6 +1,6 @@ import { ForbiddenError, UnauthorizedError } from "@uploads/errors"; import type { MiddlewareHandler } from "hono"; -import { findActiveToken, isOperatorScope } from "./auth-db"; +import { findActiveToken, isOperatorScope, touchTokenLastUsed } from "./auth-db"; import { hexToBytes, sha256Hex, workspaceNameFromToken } from "./workspace"; const READ_METHODS = new Set(["GET", "HEAD"]); @@ -41,6 +41,7 @@ export const adminAuth: MiddlewareHandler<{ const record = await findActiveToken(c.env.DB, workspace, token); if (!record) throw new UnauthorizedError(); + await touchTokenLastUsed(c.env.DB, record.id); // record.scopes is operator-token-or-file-token JSON; parseScopes (auth-db.ts) // is file-scope-only, so parse directly here and keep just the operator ones. diff --git a/apps/api/src/auth-db.ts b/apps/api/src/auth-db.ts index 6a4bdf26..b56117fc 100644 --- a/apps/api/src/auth-db.ts +++ b/apps/api/src/auth-db.ts @@ -34,6 +34,11 @@ export function isWorkspaceScope(value: unknown): value is WorkspaceScope { export const DEFAULT_ENROLLMENT_SECONDS = 2 * 60 * 60; export const DEFAULT_TOKEN_SECONDS = 90 * 24 * 60 * 60; export const MAX_TOKEN_SECONDS = 365 * 24 * 60 * 60; +/** How stale last_used_at must be before a successful auth rewrites it. */ +export const LAST_USED_TOUCH_SECONDS = 60 * 60; + +const TOKEN_COLUMNS = `id, workspace, token_hash, label, scopes, created_at, expires_at, revoked_at, + minting_user_id, last_used_at`; export interface AuthTokenRecord { id: string; @@ -47,6 +52,7 @@ export interface AuthTokenRecord { // Better Auth user id that minted this token (POST /v1/tokens), or null for // enrollment-code tokens and rows created before the Phase 4 migration. minting_user_id: string | null; + last_used_at: string | null; } interface EnrollmentRecord { @@ -118,8 +124,7 @@ export async function findActiveToken( const hash = await sha256Hex(rawToken); return db .prepare( - `SELECT id, workspace, token_hash, label, scopes, created_at, expires_at, revoked_at, - minting_user_id + `SELECT ${TOKEN_COLUMNS} FROM auth_tokens WHERE workspace = ? AND token_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) @@ -155,6 +160,7 @@ export async function createToken( expires_at: input.expiresAt?.toISOString() ?? null, revoked_at: null, minting_user_id: input.mintedByUserId ?? null, + last_used_at: null, }; await db .prepare( @@ -311,11 +317,9 @@ export async function listTokens( const result = await db .prepare( includeRevoked - ? `SELECT id, workspace, token_hash, label, scopes, created_at, expires_at, revoked_at, - minting_user_id + ? `SELECT ${TOKEN_COLUMNS} FROM auth_tokens WHERE workspace = ? ORDER BY created_at ASC` - : `SELECT id, workspace, token_hash, label, scopes, created_at, expires_at, revoked_at, - minting_user_id + : `SELECT ${TOKEN_COLUMNS} FROM auth_tokens WHERE workspace = ? AND revoked_at IS NULL ORDER BY created_at ASC`, ) .bind(workspace) @@ -380,8 +384,7 @@ export async function listTokensForMintingUser( if (!userId) return []; const result = await db .prepare( - `SELECT id, workspace, token_hash, label, scopes, created_at, expires_at, revoked_at, - minting_user_id + `SELECT ${TOKEN_COLUMNS} FROM auth_tokens WHERE minting_user_id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) @@ -406,8 +409,7 @@ export async function findTokenForMintingUser( const iso = now.toISOString(); const match = await db .prepare( - `SELECT id, workspace, token_hash, label, scopes, created_at, expires_at, revoked_at, - minting_user_id + `SELECT ${TOKEN_COLUMNS} FROM auth_tokens WHERE id = ? AND minting_user_id = ? AND revoked_at IS NULL LIMIT 1`, @@ -437,3 +439,25 @@ export async function revokeTokenForMintingUser( .run(); return match; } + +/** + * Stamp last_used_at on a successful auth. No-ops when the column was + * written in the last hour so a busy token does not pay a D1 write per request. + */ +export async function touchTokenLastUsed( + db: D1Database, + tokenId: string, + now = new Date(), +): Promise { + if (!tokenId) return; + const iso = now.toISOString(); + const staleBefore = new Date(now.getTime() - LAST_USED_TOUCH_SECONDS * 1000).toISOString(); + await db + .prepare( + `UPDATE auth_tokens SET last_used_at = ? + WHERE id = ? AND revoked_at IS NULL + AND (last_used_at IS NULL OR last_used_at < ?)`, + ) + .bind(iso, tokenId, staleBefore) + .run(); +} diff --git a/apps/api/src/routes/tokens.test.ts b/apps/api/src/routes/tokens.test.ts index 1c0e1d5d..d43505f5 100644 --- a/apps/api/src/routes/tokens.test.ts +++ b/apps/api/src/routes/tokens.test.ts @@ -495,6 +495,7 @@ const OWN_TOKEN: { expires_at: string; revoked_at: string | null; minting_user_id: string; + last_used_at: string | null; } = { id: "tok-1", workspace: "acme", @@ -505,6 +506,7 @@ const OWN_TOKEN: { expires_at: "2026-11-01T00:00:00.000Z", revoked_at: null, minting_user_id: USER.id, + last_used_at: "2026-08-17T12:00:00.000Z", }; function issuedDb(tokens: (typeof OWN_TOKEN)[]) { @@ -581,6 +583,7 @@ describe("GET /v1/tokens/issued", () => { scopes: ["files:read", "files:write"], createdAt: OWN_TOKEN.created_at, expiresAt: OWN_TOKEN.expires_at, + lastUsedAt: OWN_TOKEN.last_used_at, }, ], }); diff --git a/apps/api/src/routes/tokens.ts b/apps/api/src/routes/tokens.ts index f3a7bcca..a07134cf 100644 --- a/apps/api/src/routes/tokens.ts +++ b/apps/api/src/routes/tokens.ts @@ -270,6 +270,7 @@ export const tokens = new Hono() scopes: parseIssuedScopes(token.scopes), createdAt: token.created_at, expiresAt: token.expires_at, + lastUsedAt: token.last_used_at, })); return c.json({ tokens: issued }); }) diff --git a/apps/api/src/workspace.ts b/apps/api/src/workspace.ts index 59071cf0..dfd6046d 100644 --- a/apps/api/src/workspace.ts +++ b/apps/api/src/workspace.ts @@ -6,6 +6,7 @@ import { findActiveToken, isWorkspaceScope, parseScopes, + touchTokenLastUsed, type FileScope, type WorkspaceScope, } from "./auth-db"; @@ -423,6 +424,7 @@ function workspaceAuthWith( c.set("authSource", d1Token ? "d1" : "legacy"); // Uploader attribution (issue #340) — null for legacy/enrollment tokens. c.set("mintingUserId", d1Token?.minting_user_id ?? null); + if (d1Token) await touchTokenLastUsed(c.env.DB, d1Token.id); await next(); }; } @@ -530,6 +532,7 @@ export function workspaceGovernanceAuth(scope: WorkspaceScope): MiddlewareHandle if (!scopes.has(scope)) throw new ForbiddenError(); c.set("governanceMintingUserId", record.minting_user_id); + await touchTokenLastUsed(c.env.DB, record.id); await next(); }; } diff --git a/apps/api/test/auth-db.test.ts b/apps/api/test/auth-db.test.ts index da234e1c..8c9328a3 100644 --- a/apps/api/test/auth-db.test.ts +++ b/apps/api/test/auth-db.test.ts @@ -7,6 +7,7 @@ import { findActiveToken, findEnrollmentPage, parseScopes, + touchTokenLastUsed, } from "../src/auth-db"; type Row = Record; @@ -260,3 +261,28 @@ describe("D1 enrollment exchange", () => { expect(await exchangeEnrollment(database(fake), "upe_unknown", later)).toBeNull(); }); }); + +describe("touchTokenLastUsed", () => { + it("stamps last_used_at when the row is stale or unused", async () => { + const updates: unknown[][] = []; + const db = { + prepare() { + return { + bind(...values: unknown[]) { + return { + run: async () => { + updates.push(values); + return { meta: { changes: 1 } }; + }, + }; + }, + }; + }, + } as unknown as D1Database; + const now = new Date("2026-08-17T12:00:00.000Z"); + await touchTokenLastUsed(db, "tok-1", now); + expect(updates).toHaveLength(1); + expect(updates[0]?.[0]).toBe(now.toISOString()); + expect(updates[0]?.[1]).toBe("tok-1"); + }); +}); diff --git a/apps/api/test/usage-fake-d1.ts b/apps/api/test/usage-fake-d1.ts index fa80027f..0b6c86b7 100644 --- a/apps/api/test/usage-fake-d1.ts +++ b/apps/api/test/usage-fake-d1.ts @@ -281,6 +281,9 @@ export class UsageFakeD1 { }); return { success: true as const, meta: { changes: 1 }, results: [] }; } + if (normalized.startsWith("UPDATE auth_tokens SET last_used_at")) { + return { success: true as const, meta: { changes: 1 }, results: [] }; + } throw new Error(`unsupported run: ${normalized}`); }, }; diff --git a/apps/web/src/lib/api-client.test.ts b/apps/web/src/lib/api-client.test.ts index 78b8444a..9e0777dc 100644 --- a/apps/web/src/lib/api-client.test.ts +++ b/apps/web/src/lib/api-client.test.ts @@ -999,12 +999,20 @@ describe("parseIssuedWorkspaceTokens", () => { scopes: ["files:read", "files:write"], createdAt: "2026-08-01T00:00:00.000Z", expiresAt: "2026-11-01T00:00:00.000Z", + lastUsedAt: "2026-08-17T12:00:00.000Z", }; it("reads the GET /v1/tokens/issued envelope", () => { expect(parseIssuedWorkspaceTokens({ tokens: [row] })).toEqual([row]); }); + it("treats a missing lastUsedAt as null", () => { + const { lastUsedAt: _dropped, ...withoutUsed } = row; + expect(parseIssuedWorkspaceTokens({ tokens: [withoutUsed] })).toEqual([ + { ...withoutUsed, lastUsedAt: null }, + ]); + }); + it("returns null for a malformed payload", () => { expect(parseIssuedWorkspaceTokens({ tokens: [{ workspace: "acme" }] })).toBeNull(); expect(parseIssuedWorkspaceTokens(null)).toBeNull(); diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 3449cfaa..5d2c1a08 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -1110,6 +1110,7 @@ export interface IssuedWorkspaceToken { scopes: string[]; createdAt: string; expiresAt: string | null; + lastUsedAt: string | null; } export type MintWorkspaceTokenResult = @@ -1145,6 +1146,7 @@ function asIssuedWorkspaceToken(value: unknown): IssuedWorkspaceToken | null { scopes: row.scopes, createdAt: row.createdAt, expiresAt: row.expiresAt ?? null, + lastUsedAt: typeof row.lastUsedAt === "string" ? row.lastUsedAt : null, }; } @@ -1176,14 +1178,23 @@ export function parseIssuedWorkspaceTokens(body: unknown): IssuedWorkspaceToken[ return out; } +function sessionFetchInit(cookie?: string): RequestInit { + return { + credentials: "include", + cache: "no-store", + ...(cookie ? { headers: { cookie } } : {}), + }; +} + /** GET /v1/tokens — workspaces the signed-in user can mint a token for. */ export async function listMintableWorkspaces( apiOrigin: string, + opts?: { cookie?: string }, ): Promise { - const result = await fetchWithTimeout(`${trimOrigin(apiOrigin)}/v1/tokens`, { - credentials: "include", - cache: "no-store", - }); + const result = await fetchWithTimeout( + `${trimOrigin(apiOrigin)}/v1/tokens`, + sessionFetchInit(opts?.cookie), + ); if (result.kind === "unavailable" || !result.response.ok) return null; const body = await result.response.json().catch(() => undefined); return parseMintableWorkspaces(body); @@ -1192,11 +1203,12 @@ export async function listMintableWorkspaces( /** GET /v1/tokens/issued — tokens this session user minted. */ export async function listIssuedWorkspaceTokens( apiOrigin: string, + opts?: { cookie?: string }, ): Promise { - const result = await fetchWithTimeout(`${trimOrigin(apiOrigin)}/v1/tokens/issued`, { - credentials: "include", - cache: "no-store", - }); + const result = await fetchWithTimeout( + `${trimOrigin(apiOrigin)}/v1/tokens/issued`, + sessionFetchInit(opts?.cookie), + ); if (result.kind === "unavailable" || !result.response.ok) return null; const body = await result.response.json().catch(() => undefined); return parseIssuedWorkspaceTokens(body); @@ -1205,7 +1217,12 @@ export async function listIssuedWorkspaceTokens( /** POST /v1/tokens — mint a `up__` token. Secret is returned once. */ export async function mintWorkspaceToken( apiOrigin: string, - input: { workspace: string; label?: string; ttlSeconds?: number | null }, + input: { + workspace: string; + label?: string; + ttlSeconds?: number | null; + scopes?: string[]; + }, ): Promise { const result = await fetchWithTimeout(`${trimOrigin(apiOrigin)}/v1/tokens`, { method: "POST", @@ -1213,7 +1230,12 @@ export async function mintWorkspaceToken( cache: "no-store", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - grants: [{ workspace: input.workspace }], + grants: [ + { + workspace: input.workspace, + ...(input.scopes ? { scopes: input.scopes } : {}), + }, + ], ...(input.label ? { label: input.label } : {}), ...(input.ttlSeconds !== undefined ? { ttlSeconds: input.ttlSeconds } : {}), }), diff --git a/apps/web/src/lib/developers-ui.test.ts b/apps/web/src/lib/developers-ui.test.ts new file mode 100644 index 00000000..9b28b94c --- /dev/null +++ b/apps/web/src/lib/developers-ui.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { renderIssuedTokenListHtml, tokenAccessLabel } from "./developers-ui"; + +describe("tokenAccessLabel", () => { + it("calls out read-only and stays quiet for the default read+write grant", () => { + expect(tokenAccessLabel(["files:read"])).toBe("read-only"); + expect(tokenAccessLabel(["files:read", "files:write"])).toBe(""); + }); +}); + +describe("renderIssuedTokenListHtml", () => { + it("renders the empty state and a used read-only row", () => { + expect(renderIssuedTokenListHtml([])).toMatch(/No tokens yet/); + const html = renderIssuedTokenListHtml([ + { + id: "tok-1", + workspace: "acme", + label: "ci", + scopes: ["files:read"], + createdAt: "2026-08-01T00:00:00.000Z", + expiresAt: null, + lastUsedAt: "2026-08-17T12:00:00.000Z", + }, + ]); + expect(html).toMatch(/read-only/); + expect(html).toMatch(/no expiry/); + expect(html).toMatch(/last used 2026-08-17/); + expect(html).toMatch(/data-token-id="tok-1"/); + }); +}); diff --git a/apps/web/src/lib/developers-ui.ts b/apps/web/src/lib/developers-ui.ts new file mode 100644 index 00000000..e13e1ae9 --- /dev/null +++ b/apps/web/src/lib/developers-ui.ts @@ -0,0 +1,69 @@ +/** + * Shared markup for /account/developers so SSR and the client swap the same + * token-list rows. Keep this next to any placeholder that occupies the list. + */ +import { + listIssuedWorkspaceTokens, + listMintableWorkspaces, + type IssuedWorkspaceToken, + type MintableWorkspace, +} from "./api-client"; +import { escapeHtml } from "./workspace-ui"; + +export async function loadDevelopersPageData( + apiOrigin: string, + cookie: string, +): Promise<{ workspaces: MintableWorkspace[] | null; tokens: IssuedWorkspaceToken[] | null }> { + if (!cookie.trim()) return { workspaces: null, tokens: null }; + const [workspaces, tokens] = await Promise.all([ + listMintableWorkspaces(apiOrigin, { cookie }), + listIssuedWorkspaceTokens(apiOrigin, { cookie }), + ]); + return { workspaces, tokens }; +} + +export function formatTokenWhen(value: string | null | undefined): string { + if (!value) return ""; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return ""; + return date.toISOString().slice(0, 10); +} + +/** Short access label. Default read+write stays quiet; read-only is called out. */ +export function tokenAccessLabel(scopes: string[]): string { + const files = scopes.filter((scope) => scope.startsWith("files:")); + if (files.length === 1 && files[0] === "files:read") return "read-only"; + return ""; +} + +export function renderIssuedTokenListHtml(tokens: IssuedWorkspaceToken[]): string { + if (tokens.length === 0) { + return `
  • No tokens yet
    Create one above to start.
  • `; + } + return tokens + .map((token) => { + const title = escapeHtml(token.label || "Untitled"); + const workspace = escapeHtml(token.workspace); + const created = formatTokenWhen(token.createdAt); + const expires = formatTokenWhen(token.expiresAt); + const used = formatTokenWhen(token.lastUsedAt); + const access = tokenAccessLabel(token.scopes); + const meta = [ + workspace, + access, + created ? `created ${created}` : "", + expires ? `expires ${expires}` : "no expiry", + used ? `last used ${used}` : "never used", + ] + .filter(Boolean) + .join(" · "); + return `
  • ${title}
    ${meta}
  • `; + }) + .join(""); +} + +export function renderWorkspaceOptionsHtml(names: string[]): string { + return names + .map((name) => ``) + .join(""); +} diff --git a/apps/web/src/pages/account/developers.astro b/apps/web/src/pages/account/developers.astro index 81370046..a182d513 100644 --- a/apps/web/src/pages/account/developers.astro +++ b/apps/web/src/pages/account/developers.astro @@ -1,8 +1,22 @@ --- +import { env } from "cloudflare:workers"; import AccountLayout from "../../layouts/AccountLayout.astro"; +import { + loadDevelopersPageData, + renderIssuedTokenListHtml, + renderWorkspaceOptionsHtml, +} from "../../lib/developers-ui"; +import { resolveSignedInOrigins } from "../../lib/signed-in-page"; import { renderDetailListPlaceholderHtml } from "../../lib/workspace-ui"; export const prerender = false; + +const { apiOrigin } = resolveSignedInOrigins(env); +const initial = await loadDevelopersPageData(apiOrigin, Astro.request.headers.get("cookie") ?? ""); +const workspaceNames = initial.workspaces?.map((row) => row.workspace) ?? []; +const hasWorkspaces = workspaceNames.length > 0; +const knownEmpty = initial.workspaces !== null && !hasWorkspaces; +const tokensReady = initial.tokens !== null; --- @@ -30,7 +44,7 @@ export const prerender = false; -
    + -

    +

    -
    +