Public: anyone can read this; the crew can change it.

A child named after its spawner is refused in the plan, and the spawner can claim their child as themselves #4425

opened this

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

Conflicts with main

grokbot/forge:fix/spawn-name-clash → main 34 commits fork

Combined with main · stale

The preview is built from combined/4425 (dddd07ddec): main merged into this pull's head. Merging lands that branch.

Stale: main has moved since 95969e1183, which this was built on. It recombines on the next push to the fork, and merging recombines before it lands, so what lands may differ from the preview.

The checks line says which commit the check ran on.

Review

Sign in to review this change.
5 files changed +454 −11
src/actions/spawn.ts +28 −1
@@ -176,6 +176,30 @@ async function writeLedger(app: App, ledger: Ledger): Promise<void> {
176176 await rename(`${path}.tmp`, path);
177177 }
178178
179 /**
180 * The child's founder account: the spawner's own name, or a placeholder
181 * for a stranger's child (`copyInto`).
182 */
183 export const founderNameOf = (owner: User | undefined): string => owner?.username ?? "founder";
184
185 /**
186 * A child whose organisation would take its founder's name can't be made:
187 * the founder is created first and holds the name, so the organisation
188 * would fail `name_taken` after the body was placed. Said here, so the
189 * dry run refuses what the spawn would fail on.
190 */
191 export function founderClash(
192 plan: { readonly name: string; readonly org: string },
193 owner: User | undefined,
194 ): { field: "name" | "org"; reason: string } | undefined {
195 const founder = founderNameOf(owner);
196 if (plan.org.toLowerCase() !== founder.toLowerCase()) return undefined;
197 return {
198 field: plan.org === plan.name ? "name" : "org",
199 reason: `${plan.org} is the name of the child's founder account (${owner === undefined ? "a stranger's child starts with one" : "yours"}); its organisation needs another name`,
200 };
201 }
202
179203 export async function spawnForge(
180204 app: App,
181205 actor: User | undefined,
@@ -199,6 +223,9 @@ export async function spawnForge(
199223 const planned = planSpawn(input, source);
200224 if (!planned.ok) return err({ kind: "invalid", ...planned.error });
201225 const plan = planned.value;
226 // Before anything is placed: a clash would leave a half-made child.
227 const clash = founderClash(plan, owner);
228 if (clash !== undefined) return err({ kind: "invalid", ...clash });
202229
203230 const names = new Set<string>();
204231 for (const a of plan.apps) {
@@ -331,7 +358,7 @@ async function copyInto(
331358 // The founder is a name only: no password, no email worth mailing. The
332359 // child's first boot hands the account to a person (#422). A stranger's
333360 // child gets a placeholder; the claim names the real admin.
334 const founderName = owner?.username ?? "founder";
361 const founderName = founderNameOf(owner);
335362 const founder =
336363 getUserByName(child.db, founderName) ??
337364 (await (async () => {
src/spawn/claim.ts +92 −1
@@ -28,7 +28,10 @@ import {
2828 OWNERS_TEAM,
2929 removeTeamMember,
3030 } from "../core/orgs.ts";
31 import type { User } from "../core/users.ts";
31 import type { Credential } from "../auth/webauthn.ts";
32 import { addPasskey } from "../core/passkeys.ts";
33 import { activeEdgeToken } from "../http/edge.ts";
34 import { getUserByName, isActive, type User } from "../core/users.ts";
3235 import { sha256 } from "../kernel/hash.ts";
3336
3437 const KEY = "spawn_claim";
@@ -222,6 +225,94 @@ export function unburnClaim(db: Database, orgId: string, token: string): void {
222225 ).run(JSON.stringify(back), orgId, KEY, found.raw);
223226 }
224227
228 /**
229 * The spawn-made founder a claim may take over, when the name asked for is
230 * its name: the account `copyInto` made under the spawner's own username,
231 * so the spawner could never claim their child as themselves. Only while
232 * it is untouched — the Owners team's first member, a person, active, with
233 * no password, passkey, token, SSH key, session, parent sign-in, edge
234 * link or OAuth-client grant — and only when the spawner is
235 * leaving (`keepAccess` false): an account the spawner keeps is theirs, not the
236 * claimant's to take. Never on a forge with edge sign-in on.
237 *
238 * Deliberate: whoever holds the claim link becomes the spawner's username
239 * on the child (the link is the proof, as for any claim; an admin can
240 * rename it after). The claim page says so before anyone submits.
241 */
242 export function founderToTake(
243 app: App,
244 spawned: Spawned,
245 username: string,
246 ): User | undefined {
247 if (spawned.keepAccess) return undefined;
248 // Where the platform edge signs people in, a name is a login: the
249 // spawner's edge sign-in lands on this account, so it is never handed to
250 // whoever holds the link.
251 if (activeEdgeToken(app) !== undefined) return undefined;
252 const db = app.db;
253 const user = getUserByName(db, username);
254 if (user === undefined || user.kind !== "user" || !isActive(user)) return undefined;
255 const team = listTeams(db, spawned.org.id).find((t) => t.name === OWNERS_TEAM);
256 const founder = team === undefined ? undefined : listTeamMembers(db, team.id)[0];
257 if (founder?.id !== user.id) return undefined;
258 return untouched(db, user.id) ? user : undefined;
259 }
260
261 /** No way in of its own: no password, passkey, token, SSH key, session, parent link, edge link, or OAuth-client grant. */
262 function untouched(db: Database, userId: string): boolean {
263 return (
264 db
265 .query<{ n: number }, [string]>(
266 `SELECT count(*) AS n FROM user u WHERE u.id = ?1 AND u.password_hash = '' AND u.is_agent = 0
267 AND NOT EXISTS (SELECT 1 FROM passkey WHERE user_id = ?1)
268 AND NOT EXISTS (SELECT 1 FROM token WHERE user_id = ?1)
269 AND NOT EXISTS (SELECT 1 FROM user_key WHERE user_id = ?1)
270 AND NOT EXISTS (SELECT 1 FROM session WHERE user_id = ?1)
271 AND NOT EXISTS (SELECT 1 FROM federated_identity WHERE user_id = ?1)
272 AND NOT EXISTS (SELECT 1 FROM edge_link WHERE user_id = ?1)
273 AND NOT EXISTS (SELECT 1 FROM oauth_refresh WHERE user_id = ?1)
274 AND NOT EXISTS (SELECT 1 FROM oauth_code WHERE user_id = ?1)`,
275 )
276 .get(userId)!.n === 1
277 );
278 }
279
280 /**
281 * The claimant becomes the founder: its credential (a password hash, or a
282 * passkey with an empty hash), its email, and admin, in one transaction
283 * that re-checks the account is still untouched and drops any leftover
284 * sessions so an old cookie cannot keep acting as the new admin. "gone"
285 * when it is not (or the passkey would not go on); "email_taken" when
286 * another account holds the address.
287 */
288 export function takeFounder(
289 db: Database,
290 userId: string,
291 input: { readonly hash: string; readonly email: string; readonly credential?: Credential },
292 ): "ok" | "gone" | "email_taken" {
293 const take = db.transaction((): boolean => {
294 if (!untouched(db, userId)) return false;
295 db.query("UPDATE user SET password_hash = ?2, email = ?3, is_admin = 1 WHERE id = ?1").run(
296 userId,
297 input.hash,
298 input.email,
299 );
300 if (input.credential !== undefined && !addPasskey(db, userId, "Passkey", input.credential).ok)
301 throw new PasskeyRefused();
302 db.query("DELETE FROM session WHERE user_id = ?1").run(userId);
303 return true;
304 });
305 try {
306 return take() ? "ok" : "gone";
307 } catch (error) {
308 if (error instanceof PasskeyRefused) return "gone";
309 if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) return "email_taken";
310 throw error;
311 }
312 }
313
314 class PasskeyRefused extends Error {}
315
225316 /**
226317 * Give a spawned child's organisation to its first admin: they join its
227318 * Owners and, unless the spawner asked to keep access, the spawner leaves.
src/spawn/door.ts +4 −1
@@ -8,7 +8,7 @@
88
99 import { startWatch, probeOf, type Probe } from "./live.ts";
1010 import { firstProof, reachWords, settleChild } from "./reach.ts";
11 import { reissueClaim, spawnForge, type SpawnError } from "../actions/spawn.ts";
11 import { founderClash, reissueClaim, spawnForge, type SpawnError } from "../actions/spawn.ts";
1212 import type { App } from "../app.ts";
1313 import { maxChildren } from "../core/capacity.ts";
1414 import { getAgentByName } from "../core/agents.ts";
@@ -276,6 +276,9 @@ export async function spawnDoor(
276276 if (dry) {
277277 const planned = planSpawn(input, source);
278278 if (!planned.ok) return spawnWords({ kind: "invalid", ...planned.error });
279 // What the real spawn would refuse, the plan refuses too.
280 const clash = founderClash(planned.value, user);
281 if (clash !== undefined) return spawnWords({ kind: "invalid", ...clash });
279282 return {
280283 status: 200,
281284 body: {
src/web/pages/claim.ts +57 −8
@@ -42,9 +42,11 @@ import { createSession } from "../../core/sessions.ts";
4242 import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts";
4343 import {
4444 burnClaim,
45 founderToTake,
4546 handOver,
4647 readClaim,
4748 spawnedOrg,
49 takeFounder,
4850 unburnClaim,
4951 type ClaimState,
5052 type Spawned,
@@ -136,6 +138,17 @@ function accessSentence(spawned: Spawned, spawner: User | undefined): string {
136138 : `Once you claim it, only you can see its private work: ${who}, who made it, no longer has access unless you invite them back.`;
137139 }
138140
141 /**
142 * Where the claim would take over the spawn-made founder (`founderToTake`),
143 * the page says so: claiming under the spawner's name becomes their account
144 * here, with what the spawn made under it. Deliberate, and never silent.
145 */
146 function takeover(ctx: Ctx, spawned: Spawned, spawner: User | undefined): Html | null {
147 if (spawner === undefined || founderToTake(ctx.app, spawned, spawner.username) === undefined)
148 return null;
149 return html`<p class="mb" id="claim-takeover">Claim it as ${spawner.username} and you will take over ${spawner.username}'s account on this forge, with everything the spawn made under it.</p>`;
150 }
151
139152 function claimForm(
140153 ctx: Ctx,
141154 spawned: Spawned,
@@ -152,6 +165,7 @@ function claimForm(
152165 is the forge's admin, so it can change everything here.
153166 </p>
154167 <p class="mb" id="claim-access">${accessSentence(spawned, spawner)}</p>
168 ${takeover(ctx, spawned, spawner)}
155169 <form method="post" action="/claim">
156170 <div id="claim-token-field">
157171 ${formField({
@@ -252,11 +266,19 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> {
252266 * Whether a name and address are fit for the first admin; else the words.
253267 * The address is optional unless a passkey is being made.
254268 */
255 function problems(ctx: Ctx, username: string, email: string, needEmail: boolean): Errors {
269 function problems(
270 ctx: Ctx,
271 spawned: Spawned,
272 username: string,
273 email: string,
274 needEmail: boolean,
275 ): Errors {
256276 const errors: Errors = {};
257277 if (!USERNAME.test(username))
258278 errors.username = "Usernames are letters, digits, dots, dashes.";
259 else if (getUserByName(ctx.app.db, username))
279 // The spawn-made founder holds the spawner's own name; the claim may take
280 // it over (`founderToTake`), so it is not "taken" to the claimant.
281 else if (getUserByName(ctx.app.db, username) && founderToTake(ctx.app, spawned, username) === undefined)
260282 errors.username = USERNAME_TAKEN;
261283 if (email !== "" ? !givenEmail(email) : needEmail)
262284 errors.email = "That does not look like an email address.";
@@ -280,7 +302,7 @@ export async function claimOptions(ctx: Ctx): Promise<Response> {
280302 if (state !== "ok")
281303 return refuse(state === "unknown" ? 404 : 410, state === "unknown" ? "This is not a claim link." : `This link is ${state === "owned" ? "already claimed" : state}.`);
282304 const username = str(data.username).trim();
283 const bad = problems(ctx, username, str(data.email).trim(), true);
305 const bad = problems(ctx, spawned, username, str(data.email).trim(), true);
284306 const first = bad.username ?? bad.email;
285307 if (first !== undefined) return refuse(422, first);
286308 return json(makeOptions(ctx, "claim", username));
@@ -317,7 +339,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
317339 );
318340 const password = typeof form.get("password") === "string" ? (form.get("password") as string) : "";
319341 const withKey = field(form, "attestationObject") !== "";
320 const errors = problems(ctx, values.username, values.email.trim(), withKey);
342 const errors = problems(ctx, spawned, values.username, values.email.trim(), withKey);
321343 if (!withKey && password.length < MIN_SIGNUP_PASSWORD)
322344 errors.password = `Passwords need at least ${MIN_SIGNUP_PASSWORD} characters.`;
323345 else if (!withinPasswordCap(password)) errors.password = PASSWORD_TOO_LONG;
@@ -330,12 +352,34 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
330352 credential = key.value;
331353 }
332354
355 const email = values.email.trim() === "" ? noEmail(values.username) : values.email.trim();
356 // Hashed before the link is spent: nothing is awaited between the burn
357 // and the takeover.
358 const founder = founderToTake(ctx.app, spawned, values.username);
359 const founderHash =
360 founder === undefined || credential !== undefined ? "" : await Bun.password.hash(password, "argon2id");
333361 // Spent first, in one statement: of two people on one link, one wins.
334362 const spent = burnClaim(ctx.app.db, orgId, token);
335363 if (spent.state !== "ok") return dead(ctx, spent.state);
364 if (founder !== undefined) {
365 const taken = takeFounder(ctx.app.db, founder.id, {
366 hash: founderHash,
367 email,
368 ...(credential === undefined ? {} : { credential }),
369 });
370 if (taken !== "ok") {
371 unburnClaim(ctx.app.db, orgId, token);
372 return again(
373 taken === "email_taken"
374 ? { email: EMAIL_TAKEN_MESSAGE }
375 : { username: USERNAME_TAKEN },
376 );
377 }
378 return claimed(ctx, spawned, founder);
379 }
336380 const made = await createUser(ctx.app.db, {
337381 username: values.username,
338 email: values.email.trim() === "" ? noEmail(values.username) : values.email.trim(),
382 email,
339383 ...(credential === undefined ? { password } : {}),
340384 isAdmin: true,
341385 // The one-time claim link the owner handed out is the proof.
@@ -357,14 +401,19 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> {
357401 unburnClaim(ctx.app.db, orgId, token);
358402 return again({ passkey: PASSKEY_WORDS.taken });
359403 }
404 return claimed(ctx, spawned, made.value);
405 }
406
407 /** The claimant is in: an owner, signed in, sent home. */
408 function claimed(ctx: Ctx, spawned: Spawned, user: User): Response {
360409 // As the page said: unless the spawner kept access, they leave now.
361 handOver(ctx.app.db, spawned, made.value.id);
410 handOver(ctx.app.db, spawned, user.id);
362411 ctx.app.log.info("forge claimed", {
363412 org: spawned.org.username,
364 user: made.value.username,
413 user: user.username,
365414 spawnerKeeps: spawned.keepAccess,
366415 });
367 const session = createSession(ctx.app.db, made.value.id);
416 const session = createSession(ctx.app.db, user.id);
368417 // Home: where the crew's work shows (its first-login screen is #600).
369418 return redirect(
370419 "/",
test/spawn-founder-name.test.ts added +273
@@ -0,0 +1,273 @@
1 /**
2 * A child's founder account holds the spawner's own username (`copyInto`).
3 * Two things follow, both from one forge, `grokbot`, spawning `grokbot`:
4 * - a child named after its spawner can't get its organisation (the founder
5 * holds the name): the dry run and the spawn refuse it plainly, before a
6 * body is placed, instead of a 500 `name_taken` half-way through;
7 * - the spawner could never claim their child as themselves: the claim now
8 * takes over that untouched founder account, but only when the spawner is
9 * leaving (keepAccess false) and the account has no credential of its own
10 * (no password, passkey, token, SSH key, session, parent sign-in, edge
11 * link or OAuth-client grant). That is deliberate: whoever
12 * holds the link becomes the spawner's name on the child, and the claim
13 * page says so before anyone submits.
14 */
15
16 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
17 import { existsSync } from "node:fs";
18 import { mkdtemp, rm } from "node:fs/promises";
19 import { tmpdir } from "node:os";
20 import { join } from "node:path";
21 import { childDirOf, founderClash } from "../src/actions/spawn.ts";
22 import { createApp, type App } from "../src/app.ts";
23 import { linkEdgeLogin } from "../src/core/edge_links.ts";
24 import { linkParent } from "../src/core/federation.ts";
25 import { addKey, parseAuthorizedKey } from "../src/core/keys.ts";
26 import { listPasskeys } from "../src/core/passkeys.ts";
27 import { createSession, sessionUser } from "../src/core/sessions.ts";
28 import { createToken } from "../src/core/tokens.ts";
29 import { createUser, getUserByName } from "../src/core/users.ts";
30 import { serve, type Servers } from "../src/http/server.ts";
31 import { firstBoot } from "../src/spawn/first-boot.ts";
32 import { quietLogs } from "./helpers/globals.ts";
33 import { passkeyFields } from "./helpers/passkey.ts";
34
35 quietLogs();
36
37 const PASSWORD = "a-long-claim-passphrase";
38
39 describe("a child's founder holds the spawner's name", () => {
40 let dir = "";
41 let parent: App;
42 let parentServers: Servers;
43 let adminRw = "";
44 const kids: { app: App; servers: Servers }[] = [];
45
46 const spawn = (body: Record<string, unknown>) =>
47 fetch(`${parentServers.url}/api/v1/forges`, {
48 method: "POST",
49 headers: { authorization: `Bearer ${adminRw}`, "content-type": "application/json" },
50 body: JSON.stringify({ apps: [], ...body }),
51 });
52
53 /** A real child, booted and served on its own, with its claim token. */
54 const child = async (name: string, keepAccess: boolean, edgeToken?: string) => {
55 const made = await spawn({ name, keepAccess, dryRun: false });
56 if (made.status !== 201) throw new Error(`spawn ${made.status} ${await made.text()}`);
57 const body = (await made.json()) as { claimUrl: string; bodyId: string };
58 const app = createApp({
59 dataDir: childDirOf(parent, body.bodyId),
60 port: 0,
61 ...(edgeToken === undefined ? {} : { edgeToken }),
62 });
63 await firstBoot(app);
64 const servers = await serve(app);
65 kids.push({ app, servers });
66 const claim = (form: Record<string, string>) =>
67 fetch(`${servers.url}/claim`, {
68 method: "POST",
69 headers: { origin: servers.url },
70 body: new URLSearchParams({ token: body.claimUrl.split("#")[1] ?? "", ...form }),
71 redirect: "manual",
72 });
73 return { app, servers, claim, token: body.claimUrl.split("#")[1] ?? "" };
74 };
75
76 beforeAll(async () => {
77 dir = await mkdtemp(join(tmpdir(), "forge-founder-name-"));
78 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
79 const alice = await createUser(parent.db, {
80 origin: "seed",
81 trusted: true,
82 username: "alice",
83 email: "[email protected]",
84 password: PASSWORD,
85 isAdmin: true,
86 });
87 if (!alice.ok) throw new Error("user");
88 adminRw = createToken(parent.db, alice.value.id, "rw", "rw").token;
89 parentServers = await serve(parent);
90 });
91
92 afterAll(async () => {
93 for (const k of kids) {
94 k.servers.stop();
95 k.app.close();
96 }
97 parentServers?.stop();
98 parent?.close();
99 await rm(dir, { recursive: true, force: true });
100 });
101
102 test("the dry run refuses a child named after its spawner, as the spawn would", async () => {
103 const dry = await spawn({ name: "alice", dryRun: true });
104 expect(dry.status).toBe(422);
105 const said = (await dry.json()) as { error: string; message: string };
106 expect(said.error).toBe("invalid");
107 expect(said.message).toContain("name: alice is the name of the child's founder account");
108 });
109
110 test("the spawn refuses it before anything is placed: no 500, no ledger, no body", async () => {
111 const real = await spawn({ name: "Alice".toLowerCase(), dryRun: false });
112 expect(real.status).toBe(422);
113 expect(existsSync(join(dir, "spawn", "alice.json"))).toBe(false);
114 expect(existsSync(join(dir, "children"))).toBe(false);
115 });
116
117 test("a stranger's child can't take the placeholder founder's name either", () => {
118 expect(founderClash({ name: "founder", org: "founder" }, undefined)?.field).toBe("name");
119 expect(founderClash({ name: "kid", org: "founder" }, undefined)?.field).toBe("org");
120 expect(founderClash({ name: "kid", org: "kid" }, undefined)).toBeUndefined();
121 });
122
123 test("the spawner claims their child as themselves: the untouched founder becomes theirs", async () => {
124 const kid = await child("maria", false);
125 const founder = getUserByName(kid.app.db, "alice");
126 expect(founder).toBeDefined();
127 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
128 expect(res.status).toBe(303);
129 expect(res.headers.get("set-cookie")).toContain("forge_session=");
130 const now = getUserByName(kid.app.db, "alice")!;
131 // The same account, not a second one: what the spawn made under it stays its.
132 expect(now.id).toBe(founder!.id);
133 expect(now.isAdmin).toBe(true);
134 expect(now.email).toBe("[email protected]");
135 // Its new password signs in.
136 const signin = await fetch(`${kid.servers.url}/signin`, {
137 method: "POST",
138 headers: { origin: kid.servers.url },
139 body: new URLSearchParams({ username: "alice", password: PASSWORD }),
140 redirect: "manual",
141 });
142 expect(signin.status).toBe(303);
143 // And the link is spent.
144 const again = await kid.claim({ username: "someone", email: "[email protected]", password: PASSWORD });
145 expect(again.status).not.toBe(303);
146 });
147
148 test("a passkey claim takes the founder over the same way", async () => {
149 const kid = await child("zed", false);
150 const founder = getUserByName(kid.app.db, "alice")!;
151 const form = { token: kid.token, username: "alice", email: "[email protected]" };
152 const key = await passkeyFields(kid.servers.url, "/claim/options", form);
153 const res = await kid.claim({ ...form, ...key });
154 expect(res.status).toBe(303);
155 const now = getUserByName(kid.app.db, "alice")!;
156 expect(now.id).toBe(founder.id);
157 expect(now.isAdmin).toBe(true);
158 expect(listPasskeys(kid.app.db, now.id)).toHaveLength(1);
159 });
160
161 test("when the spawner keeps access, their founder account is not the claimant's to take", async () => {
162 const kid = await child("kai", true);
163 const founder = getUserByName(kid.app.db, "alice")!;
164 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
165 expect(res.status).toBe(422);
166 expect(await res.text()).toContain("That username is taken.");
167 expect(getUserByName(kid.app.db, "alice")!.isAdmin).toBe(founder.isAdmin);
168 // The link is not spent by the refusal.
169 const other = await kid.claim({ username: "kai-owner", email: "[email protected]", password: PASSWORD });
170 expect(other.status).toBe(303);
171 });
172
173 test("a founder that already holds a credential is never taken over", async () => {
174 const kid = await child("lee", false);
175 const founder = getUserByName(kid.app.db, "alice")!;
176 createToken(kid.app.db, founder.id, "held", "rw");
177 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
178 expect(res.status).toBe(422);
179 expect(await res.text()).toContain("That username is taken.");
180 expect(getUserByName(kid.app.db, "alice")!.email).toBe(founder.email);
181 });
182
183 test("a founder with an SSH key can already push, so it is never handed over", async () => {
184 const kid = await child("sam", false);
185 const founder = getUserByName(kid.app.db, "alice")!;
186 const parsed = parseAuthorizedKey(
187 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2S7bF0R4NmuTUvVJXPPjdI1AF8XHXeYSNfr8DJ5eDo alice",
188 );
189 if (!parsed.ok) throw new Error("key");
190 expect(addKey(kid.app.db, founder.id, "laptop", parsed.value).ok).toBe(true);
191 // The page makes no takeover promise for it.
192 const page = await (await fetch(`${kid.servers.url}/claim`)).text();
193 expect(page).not.toContain('id="claim-takeover"');
194 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
195 expect(res.status).toBe(422);
196 expect(await res.text()).toContain("That username is taken.");
197 const now = getUserByName(kid.app.db, "alice")!;
198 expect(now.email).toBe(founder.email);
199 expect(now.isAdmin).toBe(founder.isAdmin);
200 });
201
202 test("the claim page says, before anyone submits, that claiming as the spawner takes over their account", async () => {
203 const kid = await child("nia", false);
204 const page = await (await fetch(`${kid.servers.url}/claim`)).text();
205 expect(page).toContain('id="claim-takeover"');
206 expect(page).toContain("Claim it as alice and you will take over alice's account on this forge");
207 // Where it does not apply (the spawner keeps access), the page makes no such promise.
208 const kept = await child("ona", true);
209 expect(await (await fetch(`${kept.servers.url}/claim`)).text()).not.toContain('id="claim-takeover"');
210 });
211
212 test("where the platform edge signs people in, the founder's name is a login, never handed over", async () => {
213 const kid = await child("eve", false, "edge-secret-for-this-test-only");
214 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
215 expect(res.status).toBe(422);
216 expect(await res.text()).toContain("That username is taken.");
217 });
218
219 test("a founder with a session can already sign in, so it is never handed over", async () => {
220 const kid = await child("ivy", false);
221 const founder = getUserByName(kid.app.db, "alice")!;
222 const sess = createSession(kid.app.db, founder.id);
223 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
224 expect(res.status).toBe(422);
225 expect(await res.text()).toContain("That username is taken.");
226 const now = getUserByName(kid.app.db, "alice")!;
227 expect(now.email).toBe(founder.email);
228 expect(now.isAdmin).toBe(founder.isAdmin);
229 // The old cookie still belongs to the untouched founder, not a new admin.
230 const who = sessionUser(kid.app.db, sess.token);
231 expect(who.ok).toBe(true);
232 if (who.ok) expect(who.value.id).toBe(founder.id);
233 });
234
235 test("a founder linked to a parent sign-in is never handed over", async () => {
236 const kid = await child("joy", false);
237 const founder = getUserByName(kid.app.db, "alice")!;
238 expect(linkParent(kid.app.db, founder.id, "https://parent.test", "alice-sub").ok).toBe(true);
239 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
240 expect(res.status).toBe(422);
241 expect(await res.text()).toContain("That username is taken.");
242 expect(getUserByName(kid.app.db, "alice")!.email).toBe(founder.email);
243 expect(getUserByName(kid.app.db, "alice")!.isAdmin).toBe(founder.isAdmin);
244 });
245
246 test("a founder with an edge login link is never handed over", async () => {
247 const kid = await child("kim", false);
248 const founder = getUserByName(kid.app.db, "alice")!;
249 expect(linkEdgeLogin(kid.app.db, founder, "alice-edge").ok).toBe(true);
250 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
251 expect(res.status).toBe(422);
252 expect(await res.text()).toContain("That username is taken.");
253 expect(getUserByName(kid.app.db, "alice")!.email).toBe(founder.email);
254 expect(getUserByName(kid.app.db, "alice")!.isAdmin).toBe(founder.isAdmin);
255 });
256
257 test("a founder with an OAuth-client grant is never handed over", async () => {
258 const kid = await child("pat", false);
259 const founder = getUserByName(kid.app.db, "alice")!;
260 // A refresh token is a live grant even with no access token row.
261 kid.app.db
262 .query(
263 `INSERT INTO oauth_refresh (token_hash, family, client_id, user_id, scope, name, lifetime_s, expires_at)
264 VALUES (?1, ?2, ?3, ?4, 'rw', 'grant', 3600, '2099-01-01T00:00:00.000Z')`,
265 )
266 .run(`rh-${founder.id}`, `fam-${founder.id}`, "client-test", founder.id);
267 const res = await kid.claim({ username: "alice", email: "[email protected]", password: PASSWORD });
268 expect(res.status).toBe(422);
269 expect(await res.text()).toContain("That username is taken.");
270 expect(getUserByName(kid.app.db, "alice")!.email).toBe(founder.email);
271 expect(getUserByName(kid.app.db, "alice")!.isAdmin).toBe(founder.isAdmin);
272 });
273 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs