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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11339-the-claim-page-says-when-its-link-stops → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/11339-the-claim-page-says-when-its-link-stops was merged into main as e023cba.

Review

Sign in to review this change.
3 files changed +166 −8
src/spawn/claim.ts +3 −1
@@ -251,6 +251,8 @@ export interface LinkInfo {
251251 readonly revokedAt: string | null;
252252 /** When the link was spent (someone opened it and made an account), ISO; absent while unspent. */
253253 readonly usedAt?: string | null;
254 /** When the link stops working, ISO. */
255 readonly expiresAt?: string;
254256 }
255257
256258 /** 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 =>
278280 /** The claim link in this forge's own database; null when it has none. */
279281 export function linkOf(db: Database, now = Date.now()): LinkInfo | null {
280282 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 };
282284 }
283285
284286 /** 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";
4343 import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts";
4444 import {
4545 burnClaim,
46 linkOf,
4647 handOver,
4748 readClaim,
4849 spawnedOrg,
@@ -71,7 +72,29 @@ interface Values {
7172 }
7273 type Errors = Partial<Record<"username" | "email" | "password" | "passkey", string>>;
7374
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;
7598 const says = {
7699 unknown: {
77100 status: 404,
@@ -81,17 +104,17 @@ function dead(ctx: Ctx, state: Exclude<ClaimState["state"], "ok">): Response {
81104 used: {
82105 status: 410,
83106 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.`,
85108 },
86109 expired: {
87110 status: 410,
88111 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.`,
90113 },
91114 revoked: {
92115 status: 410,
93116 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.`,
95118 },
96119 owned: {
97120 status: 410,
@@ -148,6 +171,7 @@ function claimForm(
148171 spawner: User | undefined,
149172 values: Values,
150173 errors: Errors,
174 expiresAt?: string,
151175 ): Html {
152176 return html`<div class="narrow">
153177 ${pageHead("This forge is yours")}
@@ -159,6 +183,12 @@ function claimForm(
159183 : "a crew that builds them once you add a model key"}. Make your account to take it: this first account
160184 is the forge's admin, so it can change everything here.
161185 </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

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.

191 ${spawner?.username ?? "whoever sent it"} to send you a new one.</p>`}
162192 <p class="mb" id="claim-access">${accessSentence(spawned, spawner)}</p>
163193 <form method="post" action="/claim">
164194 <div id="claim-token-field">
@@ -245,11 +275,21 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> {
245275 const spawned = await timePhaseAsync(ctx.request, "spawned", () => spawnedOrg(ctx.app));
246276 if (spawned === undefined) return timePhase(ctx.request, "render", () => dead(ctx, "unknown"));
247277 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 );
248288 const rendering = performance.now();
249289 const response = page({
250290 ctx,
251291 title: "This forge is yours",
252 body: claimForm(ctx, spawned, spawner, {}, {}),
292 body: claimForm(ctx, spawned, spawner, {}, {}, link?.expiresAt),
253293 scriptJs: fragmentJs + passkeyJs,
254294 });
255295 notePhase(ctx.request, "render", performance.now() - rendering);
@@ -313,8 +353,9 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
313353 // proxies and logs would keep.
314354 const token = field(form, "token");
315355 const before = readClaim(ctx.app.db, orgId, token);
316 if (before.state !== "ok") return dead(ctx, before.state);
317356 const { spawner } = owners(ctx, spawned);
357 if (before.state !== "ok")
358 return dead(ctx, before.state, spawner?.username, linkOf(ctx.app.db)?.expiresAt);
318359
319360 const values = {
320361 username: field(form, "username"),
@@ -326,7 +367,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
326367 {
327368 ctx,
328369 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),
330371 scriptJs: passkeyJs,
331372 },
332373 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs