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
5 changes: 5 additions & 0 deletions .changeset/never-expiring-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@buildinternet/uploads": patch
---

`POST /v1/tokens` accepts `ttlSeconds: null` for a workspace token that does not expire. `/account/developers` offers that as **No expiry**. Revoke is the only off switch.
18 changes: 18 additions & 0 deletions apps/api/src/routes/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ describe("POST /v1/tokens request validation", () => {
error: { code: "invalid_ttl" },
});
});

it("400s on ttlSeconds 0", async () => {
const res = await post(stubEnv(), { ...oneGrant, ttlSeconds: 0 });
expect(res.status).toBe(400);
expect((await res.json()) as { error: { code: string } }).toMatchObject({
error: { code: "invalid_ttl" },
});
});
});

describe("GET /v1/tokens (workspace listing)", () => {
Expand Down Expand Up @@ -307,6 +315,16 @@ describe("POST /v1/tokens mint", () => {
expect(cap.insert?.[1]).toBe("acme");
});

it("mints a never-expiring token when ttlSeconds is null", async () => {
const cap = captureDb();
const res = await post(stubEnv({ db: cap.db }), { ...oneGrant, ttlSeconds: null });
expect(res.status).toBe(201);
const body = (await res.json()) as { expiresAt: string | null };
expect(body.expiresAt).toBeNull();
// expires_at is the 7th INSERT bind (index 6).
expect(cap.insert?.[6]).toBeNull();
});

it("defaults scopes to read+write when the grant omits them", async () => {
const res = await post(stubEnv(), { grants: [{ workspace: "acme" }] });
expect(res.status).toBe(201);
Expand Down
16 changes: 10 additions & 6 deletions apps/api/src/routes/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ interface RawGrant {
function parseMintRequest(parsed: unknown): {
grant: RawGrant;
label?: string;
ttlSeconds: number;
/** `null` means never expire. Omit in the request to get the 90-day default. */
ttlSeconds: number | null;
} {
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new ValidationError("request body must be a JSON object", { code: "invalid_request" });
Expand Down Expand Up @@ -122,22 +123,25 @@ function parseMintRequest(parsed: unknown): {
label = trimmed || undefined;
}

let ttlSeconds = DEFAULT_TOKEN_SECONDS;
let ttlSeconds: number | null = DEFAULT_TOKEN_SECONDS;
if (body.ttlSeconds !== undefined) {
if (
if (body.ttlSeconds === null) {
ttlSeconds = null;
} else if (
typeof body.ttlSeconds !== "number" ||
!Number.isInteger(body.ttlSeconds) ||
body.ttlSeconds < 1 ||
body.ttlSeconds > MAX_TOKEN_SECONDS
) {
throw new ValidationError(
`ttlSeconds must be an integer between 1 and ${MAX_TOKEN_SECONDS}`,
`ttlSeconds must be null or an integer between 1 and ${MAX_TOKEN_SECONDS}`,
{
code: "invalid_ttl",
},
);
} else {
ttlSeconds = body.ttlSeconds;
}
ttlSeconds = body.ttlSeconds;
}

return { grant: { workspace, rawScopes: grantObj.scopes }, label, ttlSeconds };
Expand Down Expand Up @@ -234,7 +238,7 @@ export const tokens = new Hono<SessionVars>()
throw new RateLimitedError("token minting rate limit exceeded");
}

const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
const expiresAt = ttlSeconds === null ? undefined : new Date(Date.now() + ttlSeconds * 1000);
const { token, record: tokenRecord } = await createToken(c.env.DB, {
workspace: grant.workspace,
label,
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/components/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,7 @@ const COLUMNS: FooterColumn[] = [
{
title: "Project",
links: [
// "Source", not "GitHub" — it sits a column away from "GitHub App" and
// the two read as the same destination otherwise. Matches the compact
// variant below, which already says "source".
{ label: "Source", href: REPO },
{ label: "GitHub", href: REPO },
{ label: "Status", href: STATUS_URL },
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
Expand All @@ -62,7 +59,7 @@ const COLUMNS: FooterColumn[] = [
compact ? (
<footer class="site-footer compact">
<div class="site-footer__inner">
a <a href="https://buildinternet.com">Build Internet</a> project · <a href={REPO}>source</a>{" "}
a <a href="https://buildinternet.com">Build Internet</a> project · <a href={REPO}>GitHub</a>{" "}
· <a href="/docs">docs</a> · <a href={STATUS_URL}>status</a> · <a href="/terms">terms</a> ·{" "}
<a href="/privacy">privacy</a>
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,7 +1205,7 @@ export async function listIssuedWorkspaceTokens(
/** POST /v1/tokens — mint a `up_<workspace>_` token. Secret is returned once. */
export async function mintWorkspaceToken(
apiOrigin: string,
input: { workspace: string; label?: string; ttlSeconds?: number },
input: { workspace: string; label?: string; ttlSeconds?: number | null },
): Promise<MintWorkspaceTokenResult> {
const result = await fetchWithTimeout(`${trimOrigin(apiOrigin)}/v1/tokens`, {
method: "POST",
Expand All @@ -1215,7 +1215,7 @@ export async function mintWorkspaceToken(
body: JSON.stringify({
grants: [{ workspace: input.workspace }],
...(input.label ? { label: input.label } : {}),
...(input.ttlSeconds ? { ttlSeconds: input.ttlSeconds } : {}),
...(input.ttlSeconds !== undefined ? { ttlSeconds: input.ttlSeconds } : {}),
}),
});
if (result.kind === "unavailable") {
Expand Down
40 changes: 6 additions & 34 deletions apps/web/src/pages/account/developers.astro
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
---
import { env } from "cloudflare:workers";
import AccountLayout from "../../layouts/AccountLayout.astro";
import { renderDetailListPlaceholderHtml } from "../../lib/workspace-ui";
import { resolveShowConsoleLinks } from "../../lib/signed-in-page";

export const prerender = false;

const showConsoleLinks = await resolveShowConsoleLinks(env);
---

<AccountLayout section="developers">
Expand Down Expand Up @@ -63,10 +59,10 @@ const showConsoleLinks = await resolveShowConsoleLinks(env);

<section class="card settings-page" id="workspace-tokens">
<div class="settings-section">
<h2>Workspace tokens</h2>
<h2>API Tokens</h2>
<p class="settings-note muted">
For curl, CI, and scripts. Tokens start with <code>up_&lt;workspace&gt;_</code> and last 90 days,
or 1 year. The CLI reads the workspace from the token.
1 year, or until you revoke them. The CLI reads the workspace from the token.
</p>

<p class="muted cli-note" id="token-empty" hidden>
Expand Down Expand Up @@ -96,6 +92,7 @@ const showConsoleLinks = await resolveShowConsoleLinks(env);
<select name="ttl" class="ul-select" id="token-ttl">
<option value="7776000">90 days</option>
<option value="31536000">1 year</option>
<option value="never">No expiry</option>
</select>
</label>
<button type="submit" class="input-group__action" id="token-create">Create</button>
Expand Down Expand Up @@ -126,31 +123,6 @@ const showConsoleLinks = await resolveShowConsoleLinks(env);
</div>
</section>

<section class="card settings-page dev-page">
<div class="settings-section">
<h2>Developers</h2>
<p class="settings-note muted">API and CI. Day to day starts with the CLI setup above.</p>
<ul class="dev-links">
{
showConsoleLinks ? (
<li>
<a href="/console">Console</a>
<span class="slug">token-based ops</span>
</li>
) : null
}
<li>
<a href="/docs">API docs</a>
<span class="slug">endpoints &amp; auth</span>
</li>
<li>
<a href="https://github.com/buildinternet/uploads">GitHub</a>
<span class="slug">source &amp; issues</span>
</li>
</ul>
</div>
</section>

<style>
.cli-upgrade {
margin: 10px 0 0;
Expand Down Expand Up @@ -351,7 +323,7 @@ const showConsoleLinks = await resolveShowConsoleLinks(env);
list.setAttribute("aria-busy", tokens === null ? "true" : "false");
if (tokens === null) {
status.hidden = false;
status.textContent = "Couldn’t load workspace tokens.";
status.textContent = "Couldn’t load API tokens.";
list.replaceChildren();
return;
}
Expand Down Expand Up @@ -415,8 +387,8 @@ const showConsoleLinks = await resolveShowConsoleLinks(env);
const workspace =
workspaceInput instanceof HTMLSelectElement ? workspaceInput.value.trim() : "";
const label = labelInput instanceof HTMLInputElement ? labelInput.value.trim() : "";
const ttlSeconds =
ttlInput instanceof HTMLSelectElement ? Number(ttlInput.value) : undefined;
const ttlRaw = ttlInput instanceof HTMLSelectElement ? ttlInput.value : "";
const ttlSeconds = ttlRaw === "never" ? null : ttlRaw ? Number(ttlRaw) : undefined;
if (!workspace || !label) return;
const button = requireElement<HTMLButtonElement>("#token-create", "developers");
const error = requireElement<HTMLElement>("#token-error", "developers");
Expand Down
2 changes: 2 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
All `/v1` routes require `Authorization: Bearer <token>`. That is a workspace
token (`up_<workspace>_…`) from `uploads login` or `/account/developers`.
The workspace is always in the URL path. The CLI infers it from the token.
`POST /v1/tokens` defaults to 90 days; `ttlSeconds: null` mints a token that
does not expire.

Unknown workspaces and bad tokens are indistinguishable (both 401).

Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ with `uploads setup --token <token>`, or into `.env` or user config.

You can also mint a workspace token from `/account/developers` (no device
login). Those tokens start with `up_<workspace>_` and last 90 days by default,
or 1 year. The CLI reads the workspace from the token.
1 year, or until you revoke them. The CLI reads the workspace from the token.

Two things go stale independently: the npm package that provides the `uploads`
binary, and the agent skills plus the MCP registration that `uploads install`
Expand Down
7 changes: 4 additions & 3 deletions docs/enrollment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ Nobody needs the API's `ADMIN_TOKEN` to sign in.
## Workspace tokens (no device login)

`/account/developers` mints the same `up_<workspace>_` token `uploads login`
does. Pick a workspace, a label, and 90 days or 1 year. The secret is shown
once. The CLI reads the workspace from the token. Curl still puts the
workspace in the path (`/v1/:workspace/…`).
does. Pick a workspace, a label, and 90 days, 1 year, or no expiry. The
secret is shown once. A token with no expiry lives until you revoke it. The
CLI reads the workspace from the token. Curl still puts the workspace in the
path (`/v1/:workspace/…`).

## Everyday login (device flow)

Expand Down
4 changes: 2 additions & 2 deletions packages/uploads/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@ export function mintWorkspaceToken(
workspace: string;
scopes?: Array<TokenScope>;
label?: string;
ttlSeconds?: number;
ttlSeconds?: number | null;
},
): Promise<MintTokenResult> {
return jsonRequest(`${apiUrl.replace(/\/$/, "")}/v1/tokens`, {
Expand All @@ -772,7 +772,7 @@ export function mintWorkspaceToken(
body: JSON.stringify({
grants: [{ workspace: input.workspace, ...(input.scopes ? { scopes: input.scopes } : {}) }],
...(input.label ? { label: input.label } : {}),
...(input.ttlSeconds ? { ttlSeconds: input.ttlSeconds } : {}),
...(input.ttlSeconds !== undefined ? { ttlSeconds: input.ttlSeconds } : {}),
}),
});
}
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 @@ -261,7 +261,8 @@ uploads doctor --json

Workspace tokens encode their workspace (`up_<workspace>_…`), so the CLI infers
`--workspace` when you don't set it. `/account/developers` mints the same
token shape. Legacy administrator-minted tokens remain valid.
token shape and can skip expiry (revoke is then the only off switch). Legacy
administrator-minted tokens remain valid.
See "Config commands" for setting put defaults (default repo, prefix, image
width) once instead of per-command.

Expand Down
Loading