Public: anyone can read this; the crew can change it.
The claim page says when its link stops working and how to get another, in plain words #11349
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11339-the-claim-page-says-when-its-link-stops → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +166
−8
src/spawn/claim.ts +3 −1
| @@ -251,6 +251,8 @@ export interface LinkInfo { | ||
| 251 | 251 | readonly revokedAt: string | null; |
| 252 | 252 | /** When the link was spent (someone opened it and made an account), ISO; absent while unspent. */ |
| 253 | 253 | readonly usedAt?: string | null; |
| 254 | /** When the link stops working, ISO. */ | |
| 255 | readonly expiresAt?: string; | |
| 254 | 256 | } |
| 255 | 257 | |
| 256 | 258 | /** True while an ended link can still be brought back: revoked in the last minute, in date, unspent. */ |
| @@ -278,7 +280,7 @@ const stateOf = (s: Stored, now: number): LinkState => | ||
| 278 | 280 | /** The claim link in this forge's own database; null when it has none. */ |
| 279 | 281 | export function linkOf(db: Database, now = Date.now()): LinkInfo | null { |
| 280 | 282 | const found = readStored(db); |
| 281 | return found === undefined ? null : { state: stateOf(found.stored, now), revokedAt: found.stored.r ?? null, usedAt: found.stored.u }; | |
| 283 | return found === undefined ? null : { state: stateOf(found.stored, now), revokedAt: found.stored.r ?? null, usedAt: found.stored.u, expiresAt: found.stored.e }; | |
| 282 | 284 | } |
| 283 | 285 | |
| 284 | 286 | /** Replace the stored link only if nobody changed it since it was read. */ |
src/web/pages/claim.ts +48 −7
| @@ -43,6 +43,7 @@ import { createSession } from "../../core/sessions.ts"; | ||
| 43 | 43 | import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts"; |
| 44 | 44 | import { |
| 45 | 45 | burnClaim, |
| 46 | linkOf, | |
| 46 | 47 | handOver, |
| 47 | 48 | readClaim, |
| 48 | 49 | spawnedOrg, |
| @@ -71,7 +72,29 @@ interface Values { | ||
| 71 | 72 | } |
| 72 | 73 | type Errors = Partial<Record<"username" | "email" | "password" | "passkey", string>>; |
| 73 | 74 | |
| 74 | function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response { | |
| 75 | /** `Nov 12, 14:30 UTC`, from an ISO time. */ | |
| 76 | function whenWords(iso: string): string { | |
| 77 | const d = new Date(iso); | |
| 78 | const month = d.toLocaleString("en-US", { month: "short", timeZone: "UTC" }); | |
| 79 | return `${month} ${d.getUTCDate()}, ${iso.slice(11, 16)} UTC`; | |
| 80 | } | |
| 81 | ||
| 82 | /** "in 5 hours", "in 12 minutes", or "a minute ago", from a span in ms. */ | |
| 83 | export function relativeWords(ms: number): string { | |
| 84 | const mins = Math.max(1, Math.round(Math.abs(ms) / 60_000)); | |
| 85 | const [n, unit] = | |
| 86 | mins < 60 ? [mins, "minute"] : mins < 2880 ? [Math.round(mins / 60), "hour"] : [Math.round(mins / 1440), "day"]; | |
| 87 | const span = `${n} ${unit}${n === 1 ? "" : "s"}`; | |
| 88 | return ms >= 0 ? `in ${span}` : `${span} ago`; | |
| 89 | } | |
| 90 | ||
| 91 | function dead( | |
| 92 | ctx: Ctx, | |
| 93 | state: Exclude<ClaimState["state"], "ok">, | |
| 94 | sender?: string, | |
| 95 | endedAt?: string, | |
| 96 | ): Response { | |
| 97 | const ask = sender === undefined ? "whoever sent it" : sender; | |
| 75 | 98 | const says = { |
| 76 | 99 | unknown: { |
| 77 | 100 | status: 404, |
| @@ -81,17 +104,17 @@ function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response { | ||
| 81 | 104 | used: { |
| 82 | 105 | status: 410, |
| 83 | 106 | title: "This link has already been used", |
| 84 | text: "Each link works once. If it was you, sign in with the account you made.", | |
| 107 | text: `Each link works once. If it was you, sign in with the account you made; if not, ask ${ask} to send you a new one.`, | |
| 85 | 108 | }, |
| 86 | 109 | expired: { |
| 87 | 110 | status: 410, |
| 88 | 111 | title: "This link has expired", |
| 89 | text: "Links work for a day. Ask whoever sent it for a new one.", | |
| 112 | text: `It stopped working${endedAt === undefined ? "" : ` on ${whenWords(endedAt)}`}. Ask ${ask} to send you a new one.`, | |
| 90 | 113 | }, |
| 91 | 114 | revoked: { |
| 92 | 115 | status: 410, |
| 93 | 116 | title: "This link has ended", |
| 94 | text: "Whoever sent it took it back. Ask them for a new one.", | |
| 117 | text: `Whoever sent it took it back. Ask ${ask} to send you a new one.`, | |
| 95 | 118 | }, |
| 96 | 119 | owned: { |
| 97 | 120 | status: 410, |
| @@ -148,6 +171,7 @@ function claimForm( | ||
| 148 | 171 | spawner: User | undefined, |
| 149 | 172 | values: Values, |
| 150 | 173 | errors: Errors, |
| 174 | expiresAt?: string, | |
| 151 | 175 | ): Html { |
| 152 | 176 | return html`<div class="narrow"> |
| 153 | 177 | ${pageHead("This forge is yours")} |
| @@ -159,6 +183,12 @@ function claimForm( | ||
| 159 | 183 | : "a crew that builds them once you add a model key"}. Make your account to take it: this first account |
| 160 | 184 | is the forge's admin, so it can change everything here. |
| 161 | 185 | </p> |
| 186 | ${expiresAt === undefined | |
| 187 | ? null | |
| 188 | : html`<p class="mb" id="claim-expiry">This link works until | |
| 189 | <time datetime="${expiresAt}">${whenWords(expiresAt)}</time> | |
| 190 | (${relativeWords(Date.parse(expiresAt) - Date.now())}). After that, ask | |
| 191 | ${spawner?.username ?? "whoever sent it"} to send you a new one.</p>`} | |
| 162 | 192 | <p class="mb" id="claim-access">${accessSentence(spawned, spawner)}</p> |
| 163 | 193 | <form method="post" action="/claim"> |
| 164 | 194 | <div id="claim-token-field"> |
| @@ -245,11 +275,21 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> { | ||
| 245 | 275 | const spawned = await timePhaseAsync(ctx.request, "spawned", () => spawnedOrg(ctx.app)); |
| 246 | 276 | if (spawned === undefined) return timePhase(ctx.request, "render", () => dead(ctx, "unknown")); |
| 247 | 277 | const { spawner } = timePhase(ctx.request, "owners", () => owners(ctx, spawned)); |
| 278 | // The forge has one link at a time, so its state says nothing about any | |
| 279 | // particular code: a stale or made-up code only ever meets "unknown" on the POST. | |
| 280 | const link = linkOf(ctx.app.db); | |
| 281 | if (link !== null && link.state !== "open") | |
| 282 | return dead( | |
| 283 | ctx, | |
| 284 | link.state === "used" ? "used" : link.revokedAt !== null ? "revoked" : "expired", | |
| 285 | spawner?.username, | |
| 286 | link.expiresAt, | |
| 287 | ); | |
| 248 | 288 | const rendering = performance.now(); |
| 249 | 289 | const response = page({ |
| 250 | 290 | ctx, |
| 251 | 291 | title: "This forge is yours", |
| 252 | body: claimForm(ctx, spawned, spawner, {}, {}), | |
| 292 | body: claimForm(ctx, spawned, spawner, {}, {}, link?.expiresAt), | |
| 253 | 293 | scriptJs: fragmentJs + passkeyJs, |
| 254 | 294 | }); |
| 255 | 295 | notePhase(ctx.request, "render", performance.now() - rendering); |
| @@ -313,8 +353,9 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 313 | 353 | // proxies and logs would keep. |
| 314 | 354 | const token = field(form, "token"); |
| 315 | 355 | const before = readClaim(ctx.app.db, orgId, token); |
| 316 | if (before.state !== "ok") return dead(ctx, before.state); | |
| 317 | 356 | const { spawner } = owners(ctx, spawned); |
| 357 | if (before.state !== "ok") | |
| 358 | return dead(ctx, before.state, spawner?.username, linkOf(ctx.app.db)?.expiresAt); | |
| 318 | 359 | |
| 319 | 360 | const values = { |
| 320 | 361 | username: field(form, "username"), |
| @@ -326,7 +367,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 326 | 367 | { |
| 327 | 368 | ctx, |
| 328 | 369 | title: "This forge is yours", |
| 329 | body: claimForm(ctx, spawned, spawner, values, e), | |
| 370 | body: claimForm(ctx, spawned, spawner, values, e, linkOf(ctx.app.db)?.expiresAt), | |
| 330 | 371 | scriptJs: passkeyJs, |
| 331 | 372 | }, |
| 332 | 373 | 422, |
test/claim-page-words.test.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * The claim page in plain words (#11339): a good link says when it stops | |
| 3 | * working; an expired or used one answers 410 with one line saying why and | |
| 4 | * who can send another, and never a technical word. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 16 | import { relativeWords } from "../src/web/pages/claim.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | describe("the claim page in plain words", () => { | |
| 22 | let dir = ""; | |
| 23 | let parent: App; | |
| 24 | let parentServers: Servers; | |
| 25 | let child: App; | |
| 26 | let childServers: Servers; | |
| 27 | let token = ""; | |
| 28 | ||
| 29 | const get = () => fetch(`${childServers.url}/claim`); | |
| 30 | const setStored = (patch: Record<string, unknown>) => { | |
| 31 | const row = child.db.query<{ value: string }, []>("SELECT value FROM user_setting WHERE key = 'spawn_claim'").get(); | |
| 32 | child.db | |
| 33 | .query("UPDATE user_setting SET value = ?1 WHERE key = 'spawn_claim'") | |
| 34 | .run(JSON.stringify({ ...JSON.parse(row!.value), ...patch })); | |
| 35 | }; | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dir = await mkdtemp(join(tmpdir(), "forge-claim-words-")); | |
| 39 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 40 | const alice = await createUser(parent.db, { | |
| 41 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 42 | password: "a-long-claim-passphrase", isAdmin: true, | |
| 43 | }); | |
| 44 | if (!alice.ok) throw new Error("user"); | |
| 45 | const rw = createToken(parent.db, alice.value.id, "rw", "rw").token; | |
| 46 | parentServers = await serve(parent); | |
| 47 | const made = await fetch(`${parentServers.url}/api/v1/forges`, { | |
| 48 | method: "POST", | |
| 49 | headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" }, | |
| 50 | body: JSON.stringify({ name: "maria", apps: [], dryRun: false }), | |
| 51 | }); | |
| 52 | if (made.status !== 201) throw new Error(`spawn ${made.status} ${await made.text()}`); | |
| 53 | const body = (await made.json()) as { claimUrl: string; bodyId: string }; | |
| 54 | token = body.claimUrl.split("#")[1] ?? ""; | |
| 55 | child = createApp({ dataDir: childDirOf(parent, body.bodyId), port: 0 }); | |
| 56 | await firstBoot(child); | |
| 57 | childServers = await serve(child); | |
| 58 | }, 60_000); | |
| 59 | ||
| 60 | afterAll(async () => { | |
| 61 | childServers.stop(); | |
| 62 | child.close(); | |
| 63 | parentServers.stop(); | |
| 64 | parent.close(); | |
| 65 | await rm(dir, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | test("relative words", () => { | |
| 69 | expect(relativeWords(5 * 3_600_000)).toBe("in 5 hours"); | |
| 70 | expect(relativeWords(60_000)).toBe("in 1 minute"); | |
| 71 | expect(relativeWords(-3 * 86_400_000)).toBe("3 days ago"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a valid link names its expiry as a date and a relative time", async () => { | |
| 75 | const res = await get(); | |
| 76 | expect(res.status).toBe(200); | |
| 77 | const html = await res.text(); | |
| 78 | expect(html).toContain('id="claim-expiry"'); | |
| 79 | expect(html).toMatch(/works until\s*<time datetime="[^"]+">[A-Z][a-z]{2} \d{1,2}, \d\d:\d\d UTC<\/time>\s*\(in (23|24) hours\)/); | |
| 80 | expect(html).toContain("alice"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("an expired link answers 410 in plain words", async () => { | |
| 84 | setStored({ e: new Date(Date.now() - 3_600_000).toISOString() }); | |
| 85 | for (const res of [ | |
| 86 | await get(), | |
| 87 | await fetch(`${childServers.url}/claim`, { | |
| 88 | method: "POST", headers: { origin: childServers.url }, body: new URLSearchParams({ token }), | |
| 89 | }), | |
| 90 | ]) { | |
| 91 | expect(res.status).toBe(410); | |
| 92 | const text = await res.text(); | |
| 93 | expect(text).toContain("This link has expired"); | |
| 94 | expect(text).toContain("Ask alice to send you a new one"); | |
| 95 | expect(text).not.toMatch(/token|\b4\d\d\b/i); | |
| 96 | } | |
| 97 | }); | |
| 98 | ||
| 99 | test("a used link answers 410 in plain words", async () => { | |
| 100 | setStored({ e: new Date(Date.now() + 3_600_000).toISOString(), u: new Date().toISOString() }); | |
| 101 | const res = await get(); | |
| 102 | expect(res.status).toBe(410); | |
| 103 | const text = await res.text(); | |
| 104 | expect(text).toContain("This link has already been used"); | |
| 105 | expect(text).toContain("ask alice to send you a new one"); | |
| 106 | expect(text).not.toMatch(/token|\b4\d\d\b/i); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a made-up code still only meets the not-a-forge-link answer", async () => { | |
| 110 | const res = await fetch(`${childServers.url}/claim`, { | |
| 111 | method: "POST", headers: { origin: childServers.url }, body: new URLSearchParams({ token: "x".repeat(43) }), | |
| 112 | }); | |
| 113 | expect(res.status).toBe(404); | |
| 114 | }); | |
| 115 | }); |
new above
offline
Principle 1/8, minor: the expiry paragraph and the access paragraph both explain the page. Consider folding the "ask whoever sent it" sentence away, since the dead page already says it. Not blocking.