From 57e16759eda42f41a4bb29f8a12f4f5dd1256d0d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 15:18:35 +0000 Subject: [PATCH] feat: warn before the login token expires, and open the mod pane on a new hold The API sends Uploads-Token-Expires on requests made with an expiring D1 token. The CLI warns once a day on stderr in the token's last 7 days, with the date and `uploads login --force`. JSON output and --quiet stay silent, so the Claude Code mod's background refreshes never swallow it. The mod opens its staged pane on a row when a refresh finds a file held that wasn't held at the last look on the same branch. The first look of a session opens nothing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MKDvJCH8YysWRhymtbqvn2 --- .changeset/token-expiry-warning.md | 6 +++ apps/api/src/workspace.ts | 2 + apps/api/test/routes-auth.test.ts | 11 ++++ apps/web/src/content/docs/api.mdx | 3 +- packages/uploads/src/cli.ts | 15 ++++-- packages/uploads/src/client.ts | 5 ++ packages/uploads/src/expired-tokens.ts | 40 ++++++++++++++ .../uploads/test/client-expired-token.test.ts | 53 ++++++++++++++++++- plugins/claude/uploads/hooks/register.test.ts | 22 ++++++++ plugins/claude/uploads/hooks/register.tsx | 20 +++++++ .../uploads/skills/uploads-cli/SKILL.md | 3 +- skills/uploads-cli/SKILL.md | 3 +- 12 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 .changeset/token-expiry-warning.md diff --git a/.changeset/token-expiry-warning.md b/.changeset/token-expiry-warning.md new file mode 100644 index 000000000..164f782db --- /dev/null +++ b/.changeset/token-expiry-warning.md @@ -0,0 +1,6 @@ +--- +"@buildinternet/uploads": patch +"@uploads/plugin": patch +--- + +The CLI warns once a day when your login token expires within 7 days, with the date and `uploads login --force` to renew it. JSON output (`--json`, `--format json`) and `--quiet` stay silent. The Claude Code mod opens its staged attachments pane on a file when uploads.sh newly holds it for review, so its Approve is one click away. diff --git a/apps/api/src/workspace.ts b/apps/api/src/workspace.ts index b9a5417c8..9eb6babca 100644 --- a/apps/api/src/workspace.ts +++ b/apps/api/src/workspace.ts @@ -796,6 +796,8 @@ function workspaceAuthWith( appendServerTiming(c, touchTiming); } } + // Lets the CLI warn before the token runs out (90-day tokens). + if (d1Token?.expires_at) c.header("Uploads-Token-Expires", d1Token.expires_at); const userAgent = c.req.header("User-Agent"); const client = parseCliUserAgent(userAgent) ?? legacyCliUserAgent(userAgent); if (client) await recordClient(c, client); diff --git a/apps/api/test/routes-auth.test.ts b/apps/api/test/routes-auth.test.ts index 89fa79963..bfef4aef5 100644 --- a/apps/api/test/routes-auth.test.ts +++ b/apps/api/test/routes-auth.test.ts @@ -330,6 +330,17 @@ describe("auth routes", () => { expect(remove.status).toBe(403); }); + it("tells the client when a D1 credential expires", async () => { + const token = "up_default_expiring-token-value"; + const tokenHash = await sha256Hex(token); + const response = await app.request( + "/v1/default/files/bad%20key", + { headers: { Authorization: `Bearer ${token}` } }, + env({ d1: { tokenHash, scopes: JSON.stringify(["files:read"]), expiresAt: "2099-01-01" } }), + ); + expect(response.headers.get("Uploads-Token-Expires")).toBe("2099-01-01"); + }); + it("rejects expired and revoked D1 credentials", async () => { const token = "up_default_expired-token-value"; const tokenHash = await sha256Hex(token); diff --git a/apps/web/src/content/docs/api.mdx b/apps/web/src/content/docs/api.mdx index e18184b6b..77308354f 100644 --- a/apps/web/src/content/docs/api.mdx +++ b/apps/web/src/content/docs/api.mdx @@ -62,7 +62,8 @@ Authorization: Bearer up__… ``` - **Personal token.** `uploads login` creates one, or create one under - [Developers](/account/developers) in your account. Tokens last 90 days by default. + [Developers](/account/developers) in your account. Tokens last 90 days by default, and each + response carries the expiry in an `Uploads-Token-Expires` header. - **Service token.** For CI and bots, a workspace admin creates an `ups__…` token in the workspace settings. It is not tied to a member. diff --git a/packages/uploads/src/cli.ts b/packages/uploads/src/cli.ts index 3ee56f052..2616e6755 100644 --- a/packages/uploads/src/cli.ts +++ b/packages/uploads/src/cli.ts @@ -1,5 +1,5 @@ import { createUploadsClient } from "./client.js"; -import { fileExpiredTokenStore } from "./expired-tokens.js"; +import { fileExpiredTokenStore, tokenExpiryWarner } from "./expired-tokens.js"; import { DEFAULT_API_URL, resolveApiUrl, resolveConfig } from "./config.js"; import { scopeRemedy } from "./client.js"; import { UploadsError } from "./errors.js"; @@ -102,7 +102,8 @@ function createContext( requireToken: boolean, commandArgs: string[], ): CliContext { - const cmdWorkspace = commandWorkspace(parseCommandArgs(commandArgs).flags); + const cmdFlags = parseCommandArgs(commandArgs).flags; + const cmdWorkspace = commandWorkspace(cmdFlags); const config = resolveConfig({ apiUrl: globals.apiUrl, workspace: cmdWorkspace ?? globals.workspace, @@ -110,10 +111,16 @@ function createContext( envFile: globals.envFile, requireToken, }); + const json = globals.json ?? false; + // JSON callers are scripts and the Claude Code mod; the warning is for people and agents. + const machine = json || flagString(cmdFlags, "--format") === "json"; return { config, - client: createUploadsClient(config, { expiredTokens: fileExpiredTokenStore() }), - json: globals.json ?? false, + client: createUploadsClient(config, { + expiredTokens: fileExpiredTokenStore(), + ...(machine || globals.quiet ? {} : { onTokenExpiry: tokenExpiryWarner() }), + }), + json, quiet: globals.quiet ?? false, envFile: globals.envFile, }; diff --git a/packages/uploads/src/client.ts b/packages/uploads/src/client.ts index 8e2716a80..4e449e9df 100644 --- a/packages/uploads/src/client.ts +++ b/packages/uploads/src/client.ts @@ -1231,6 +1231,8 @@ export interface UploadsClientOptions { * file-backed store (`expired-tokens.ts`); omitted, every call asks the server. */ expiredTokens?: ExpiredTokenStore; + /** Called with the token's expiry (ISO) when the API reports one. */ + onTokenExpiry?: (expiresAt: string) => void; } export interface ExpiredTokenStore { @@ -1287,6 +1289,9 @@ export function createUploadsClient( throw mapApiError(res.status, message, code, requiredScope, existingUrl); } + const expiresAt = sendsToken ? res.headers.get("Uploads-Token-Expires") : null; + if (expiresAt) clientOpts.onTokenExpiry?.(expiresAt); + if (res.status === 204) return undefined as T; return (await res.json()) as T; } diff --git a/packages/uploads/src/expired-tokens.ts b/packages/uploads/src/expired-tokens.ts index b2b38c8cf..0b3c7bd9f 100644 --- a/packages/uploads/src/expired-tokens.ts +++ b/packages/uploads/src/expired-tokens.ts @@ -61,3 +61,43 @@ export function fileExpiredTokenStore(path = expiredTokensPath()): ExpiredTokenS }, }; } + +const EXPIRY_WARN_DAYS = 7; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Warns on stderr when the token expires within a week, at most once a day. + * The day of the last warning is kept as `token-expiry-warned` beside the + * config file. Best-effort like the store above. + */ +export function tokenExpiryWarner( + path = join(dirname(resolveConfigPath()), "token-expiry-warned"), + write: (line: string) => void = (line) => process.stderr.write(line), + now: () => Date = () => new Date(), +): (expiresAt: string) => void { + let done = false; + return (expiresAt) => { + if (done) return; + done = true; + const at = Date.parse(expiresAt); + const today = now(); + if (Number.isNaN(at) || at - today.getTime() > EXPIRY_WARN_DAYS * DAY_MS) return; + const day = today.toISOString().slice(0, 10); + try { + if (readFileSync(path, "utf8").trim() === day) return; + } catch { + // never warned + } + const days = Math.max(0, Math.ceil((at - today.getTime()) / DAY_MS)); + const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`; + write( + `uploads: your login expires ${when} (${expiresAt.slice(0, 10)}). Run \`uploads login --force\` to renew it.\n`, + ); + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${day}\n`, { mode: 0o600 }); + } catch { + // best-effort: it warns again next run + } + }; +} diff --git a/packages/uploads/test/client-expired-token.test.ts b/packages/uploads/test/client-expired-token.test.ts index 145dc16e9..2a5d7473f 100644 --- a/packages/uploads/test/client-expired-token.test.ts +++ b/packages/uploads/test/client-expired-token.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createUploadsClient } from "../src/client.js"; import { UploadsError } from "../src/errors.js"; -import { fileExpiredTokenStore } from "../src/expired-tokens.js"; +import { fileExpiredTokenStore, tokenExpiryWarner } from "../src/expired-tokens.js"; let dir: string; beforeEach(() => { @@ -91,3 +91,54 @@ describe("expired token memory", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); }); + +describe("token expiry warning", () => { + const NOW = new Date("2026-10-22T12:00:00Z"); + + function warned(expiresAt: string | null, path = join(dir, "warned")) { + const lines: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ files: [], cursor: null }), { + status: 200, + headers: expiresAt ? { "Uploads-Token-Expires": expiresAt } : {}, + }), + ), + ); + const c = createUploadsClient( + { apiUrl: "https://api.test", workspace: "test", token: "up_test_live" }, + { + onTokenExpiry: tokenExpiryWarner( + path, + (line) => lines.push(line), + () => NOW, + ), + }, + ); + return { c, lines }; + } + + it("warns once when the token expires within a week", async () => { + const { c, lines } = warned("2026-10-27T09:00:00.000Z"); + await c.list({ limit: 1 }); + await c.list({ limit: 1 }); + expect(lines).toEqual([ + "uploads: your login expires in 5 days (2026-10-27). Run `uploads login --force` to renew it.\n", + ]); + + // A later run the same day stays quiet. + const again = warned("2026-10-27T09:00:00.000Z"); + await again.c.list({ limit: 1 }); + expect(again.lines).toEqual([]); + }); + + it("stays quiet further out, or without the header", async () => { + const far = warned("2026-12-01T00:00:00.000Z"); + await far.c.list({ limit: 1 }); + const none = warned(null); + await none.c.list({ limit: 1 }); + expect([...far.lines, ...none.lines]).toEqual([]); + }); +}); diff --git a/plugins/claude/uploads/hooks/register.test.ts b/plugins/claude/uploads/hooks/register.test.ts index 71fe8b1cf..4a7eaf2c9 100644 --- a/plugins/claude/uploads/hooks/register.test.ts +++ b/plugins/claude/uploads/hooks/register.test.ts @@ -712,6 +712,28 @@ describe("staged pane", () => { expect(await ui.find({ type: "Button", text: "Approve" })).toBeUndefined(); }); + test("opens the pane on a file once it is held", async ($, on) => { + const world = setup(on, { counts: { "feat/x": 2 }, files: [{ filename: "env.png" }] }); + await runBash($, world, STAGE); + expect(world.opened).toEqual([]); + + // The classifier's verdict lands after the upload; the next refresh sees it. + world.cli.files = [{ filename: "env.png", held: true }]; + await runBash($, world, STAGE); + expect(world.opened).toEqual(["uploads-staged:env.png"]); + + // The same hold doesn't open it again. + await runBash($, world, STAGE); + expect(world.opened).toHaveLength(1); + }); + + test("leaves the pane shut for a hold already there when the session starts", async ($, on) => { + const world = setup(on, { files: [{ filename: "env.png", held: true }] }); + await runBash($, world, STAGE); + await runBash($, world, STAGE); + expect(world.opened).toEqual([]); + }); + test("Remove deletes each file, then shows the grid", async ($, on) => { const world = setup(on, { counts: { "feat/x": 2 }, files: pairFiles.slice(0, 2) }); await runBash($, world, STAGE); diff --git a/plugins/claude/uploads/hooks/register.tsx b/plugins/claude/uploads/hooks/register.tsx index 1a70671f5..e6ce919a2 100644 --- a/plugins/claude/uploads/hooks/register.tsx +++ b/plugins/claude/uploads/hooks/register.tsx @@ -706,10 +706,30 @@ async function fetchOpenPr($: EngineInterface): Promise<{ number: number; url: s } } +// The first row with a file held since the last look at the same branch, so +// the pane opens on its Approve. The first look of a session opens nothing. +const newHold = (before: Staged | null, view: Staged | null): BandRow | null => { + if (!before || !view || before.repo !== view.repo || before.branch !== view.branch) { + return null; + } + const wasHeld = new Set( + (Array.isArray(before.rows) ? before.rows : []) + .flatMap(filesOf) + .filter((f) => f.held) + .map((f) => f.key), + ); + return view.rows.find((row) => filesOf(row).some((f) => f.held && !wasHeld.has(f.key))) ?? null; +}; + // Refreshes what the band shows: the session directory's branch. async function refreshBand($: EngineInterface): Promise { + const before = await read($, staged); const view = await fetchStaged($, null); await update($, staged, () => view); + const newlyHeld = newHold(before, view); + if (newlyHeld) { + await showRow($, newlyHeld); + } const pr = view && view.count > 0 ? await fetchOpenPr($) : null; if ((await read($, openPr))?.number !== pr?.number) { await update($, feedUrl, () => null); diff --git a/plugins/claude/uploads/skills/uploads-cli/SKILL.md b/plugins/claude/uploads/skills/uploads-cli/SKILL.md index 2af166115..18d11abf5 100644 --- a/plugins/claude/uploads/skills/uploads-cli/SKILL.md +++ b/plugins/claude/uploads/skills/uploads-cli/SKILL.md @@ -338,7 +338,8 @@ agent as `UPLOADS_TOKEN`, or an enrollment code (`upe_…`, an alternative invit when you don't have the recipient's email) can be exchanged with `uploads login --code`. Neither is the normal path for new setups. -The resulting token defaults to 90 days and `files:read` plus `files:write`. That +The resulting token defaults to 90 days and `files:read` plus `files:write`. In its +last week the CLI warns once a day on stderr; `uploads login --force` renews it. That token can delete a `gh/` object the server stamped as that user's. Files uploaded before that stamp, someone else's object, a non-`gh/` key, and running `purge-expired` still needs `files:delete`. A missing scope returns 403 diff --git a/skills/uploads-cli/SKILL.md b/skills/uploads-cli/SKILL.md index 2af166115..18d11abf5 100644 --- a/skills/uploads-cli/SKILL.md +++ b/skills/uploads-cli/SKILL.md @@ -338,7 +338,8 @@ agent as `UPLOADS_TOKEN`, or an enrollment code (`upe_…`, an alternative invit when you don't have the recipient's email) can be exchanged with `uploads login --code`. Neither is the normal path for new setups. -The resulting token defaults to 90 days and `files:read` plus `files:write`. That +The resulting token defaults to 90 days and `files:read` plus `files:write`. In its +last week the CLI warns once a day on stderr; `uploads login --force` renews it. That token can delete a `gh/` object the server stamped as that user's. Files uploaded before that stamp, someone else's object, a non-`gh/` key, and running `purge-expired` still needs `files:delete`. A missing scope returns 403