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
6 changes: 6 additions & 0 deletions .changeset/token-expiry-warning.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions apps/api/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions apps/api/test/routes-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/content/docs/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ Authorization: Bearer up_<workspace>_…
```

- **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_<workspace>_…` token in
the workspace settings. It is not tied to a member.

Expand Down
15 changes: 11 additions & 4 deletions packages/uploads/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -102,18 +102,25 @@ 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,
token: globals.token,
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,
};
Expand Down
5 changes: 5 additions & 0 deletions packages/uploads/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
40 changes: 40 additions & 0 deletions packages/uploads/src/expired-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
};
}
53 changes: 52 additions & 1 deletion packages/uploads/test/client-expired-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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([]);
});
});
22 changes: 22 additions & 0 deletions plugins/claude/uploads/hooks/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions plugins/claude/uploads/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
Expand Down
3 changes: 2 additions & 1 deletion plugins/claude/uploads/skills/uploads-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion skills/uploads-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading