diff --git a/.changeset/never-expiring-tokens.md b/.changeset/never-expiring-tokens.md new file mode 100644 index 00000000..6253015e --- /dev/null +++ b/.changeset/never-expiring-tokens.md @@ -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. diff --git a/apps/api/src/routes/tokens.test.ts b/apps/api/src/routes/tokens.test.ts index 6ecce92e..1c0e1d5d 100644 --- a/apps/api/src/routes/tokens.test.ts +++ b/apps/api/src/routes/tokens.test.ts @@ -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)", () => { @@ -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); diff --git a/apps/api/src/routes/tokens.ts b/apps/api/src/routes/tokens.ts index 98c17310..f3a7bcca 100644 --- a/apps/api/src/routes/tokens.ts +++ b/apps/api/src/routes/tokens.ts @@ -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" }); @@ -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 }; @@ -234,7 +238,7 @@ export const tokens = new Hono() 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, diff --git a/apps/web/src/components/Footer.astro b/apps/web/src/components/Footer.astro index 7ab131a3..a550816a 100644 --- a/apps/web/src/components/Footer.astro +++ b/apps/web/src/components/Footer.astro @@ -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" }, @@ -62,7 +59,7 @@ const COLUMNS: FooterColumn[] = [ compact ? (