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
Check · Review · Land · Live — It conflicts with its base.
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.
5 files
changed +454
−11
src/actions/spawn.ts +28 −1
| @@ -176,6 +176,30 @@ async function writeLedger(app: App, ledger: Ledger): Promise<void> { | ||
| 176 | 176 | await rename(`${path}.tmp`, path); |
| 177 | 177 | } |
| 178 | 178 | |
| 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 | ||
| 179 | 203 | export async function spawnForge( |
| 180 | 204 | app: App, |
| 181 | 205 | actor: User | undefined, |
| @@ -199,6 +223,9 @@ export async function spawnForge( | ||
| 199 | 223 | const planned = planSpawn(input, source); |
| 200 | 224 | if (!planned.ok) return err({ kind: "invalid", ...planned.error }); |
| 201 | 225 | 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 }); | |
| 202 | 229 | |
| 203 | 230 | const names = new Set<string>(); |
| 204 | 231 | for (const a of plan.apps) { |
| @@ -331,7 +358,7 @@ async function copyInto( | ||
| 331 | 358 | // The founder is a name only: no password, no email worth mailing. The |
| 332 | 359 | // child's first boot hands the account to a person (#422). A stranger's |
| 333 | 360 | // child gets a placeholder; the claim names the real admin. |
| 334 | const founderName = owner?.username ?? "founder"; | |
| 361 | const founderName = founderNameOf(owner); | |
| 335 | 362 | const founder = |
| 336 | 363 | getUserByName(child.db, founderName) ?? |
| 337 | 364 | (await (async () => { |
src/spawn/claim.ts +92 −1
| @@ -28,7 +28,10 @@ import { | ||
| 28 | 28 | OWNERS_TEAM, |
| 29 | 29 | removeTeamMember, |
| 30 | 30 | } 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"; | |
| 32 | 35 | import { sha256 } from "../kernel/hash.ts"; |
| 33 | 36 | |
| 34 | 37 | const KEY = "spawn_claim"; |
| @@ -222,6 +225,94 @@ export function unburnClaim(db: Database, orgId: string, token: string): void { | ||
| 222 | 225 | ).run(JSON.stringify(back), orgId, KEY, found.raw); |
| 223 | 226 | } |
| 224 | 227 | |
| 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 | ||
| 225 | 316 | /** |
| 226 | 317 | * Give a spawned child's organisation to its first admin: they join its |
| 227 | 318 | * Owners and, unless the spawner asked to keep access, the spawner leaves. |
src/spawn/door.ts +4 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { startWatch, probeOf, type Probe } from "./live.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import type { App } from "../app.ts"; |
| 13 | 13 | import { maxChildren } from "../core/capacity.ts"; |
| 14 | 14 | import { getAgentByName } from "../core/agents.ts"; |
| @@ -276,6 +276,9 @@ export async function spawnDoor( | ||
| 276 | 276 | if (dry) { |
| 277 | 277 | const planned = planSpawn(input, source); |
| 278 | 278 | 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 }); | |
| 279 | 282 | return { |
| 280 | 283 | status: 200, |
| 281 | 284 | body: { |
src/web/pages/claim.ts +57 −8
| @@ -42,9 +42,11 @@ import { createSession } from "../../core/sessions.ts"; | ||
| 42 | 42 | import { createUser, discardFreshUser, getUserByName, type User } from "../../core/users.ts"; |
| 43 | 43 | import { |
| 44 | 44 | burnClaim, |
| 45 | founderToTake, | |
| 45 | 46 | handOver, |
| 46 | 47 | readClaim, |
| 47 | 48 | spawnedOrg, |
| 49 | takeFounder, | |
| 48 | 50 | unburnClaim, |
| 49 | 51 | type ClaimState, |
| 50 | 52 | type Spawned, |
| @@ -136,6 +138,17 @@ function accessSentence(spawned: Spawned, spawner: User | undefined): string { | ||
| 136 | 138 | : `Once you claim it, only you can see its private work: ${who}, who made it, no longer has access unless you invite them back.`; |
| 137 | 139 | } |
| 138 | 140 | |
| 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 | ||
| 139 | 152 | function claimForm( |
| 140 | 153 | ctx: Ctx, |
| 141 | 154 | spawned: Spawned, |
| @@ -152,6 +165,7 @@ function claimForm( | ||
| 152 | 165 | is the forge's admin, so it can change everything here. |
| 153 | 166 | </p> |
| 154 | 167 | <p class="mb" id="claim-access">${accessSentence(spawned, spawner)}</p> |
| 168 | ${takeover(ctx, spawned, spawner)} | |
| 155 | 169 | <form method="post" action="/claim"> |
| 156 | 170 | <div id="claim-token-field"> |
| 157 | 171 | ${formField({ |
| @@ -252,11 +266,19 @@ export async function claimFragmentPage(ctx: Ctx): Promise<Response> { | ||
| 252 | 266 | * Whether a name and address are fit for the first admin; else the words. |
| 253 | 267 | * The address is optional unless a passkey is being made. |
| 254 | 268 | */ |
| 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 { | |
| 256 | 276 | const errors: Errors = {}; |
| 257 | 277 | if (!USERNAME.test(username)) |
| 258 | 278 | 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) | |
| 260 | 282 | errors.username = USERNAME_TAKEN; |
| 261 | 283 | if (email !== "" ? !givenEmail(email) : needEmail) |
| 262 | 284 | errors.email = "That does not look like an email address."; |
| @@ -280,7 +302,7 @@ export async function claimOptions(ctx: Ctx): Promise<Response> { | ||
| 280 | 302 | if (state !== "ok") |
| 281 | 303 | return refuse(state === "unknown" ? 404 : 410, state === "unknown" ? "This is not a claim link." : `This link is ${state === "owned" ? "already claimed" : state}.`); |
| 282 | 304 | 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); | |
| 284 | 306 | const first = bad.username ?? bad.email; |
| 285 | 307 | if (first !== undefined) return refuse(422, first); |
| 286 | 308 | return json(makeOptions(ctx, "claim", username)); |
| @@ -317,7 +339,7 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 317 | 339 | ); |
| 318 | 340 | const password = typeof form.get("password") === "string" ? (form.get("password") as string) : ""; |
| 319 | 341 | 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); | |
| 321 | 343 | if (!withKey && password.length < MIN_SIGNUP_PASSWORD) |
| 322 | 344 | errors.password = `Passwords need at least ${MIN_SIGNUP_PASSWORD} characters.`; |
| 323 | 345 | else if (!withinPasswordCap(password)) errors.password = PASSWORD_TOO_LONG; |
| @@ -330,12 +352,34 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 330 | 352 | credential = key.value; |
| 331 | 353 | } |
| 332 | 354 | |
| 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"); | |
| 333 | 361 | // Spent first, in one statement: of two people on one link, one wins. |
| 334 | 362 | const spent = burnClaim(ctx.app.db, orgId, token); |
| 335 | 363 | 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 | } | |
| 336 | 380 | const made = await createUser(ctx.app.db, { |
| 337 | 381 | username: values.username, |
| 338 | email: values.email.trim() === "" ? noEmail(values.username) : values.email.trim(), | |
| 382 | email, | |
| 339 | 383 | ...(credential === undefined ? { password } : {}), |
| 340 | 384 | isAdmin: true, |
| 341 | 385 | // The one-time claim link the owner handed out is the proof. |
| @@ -357,14 +401,19 @@ export async function claimSubmit(ctx: Ctx): Promise<Response> { | ||
| 357 | 401 | unburnClaim(ctx.app.db, orgId, token); |
| 358 | 402 | return again({ passkey: PASSKEY_WORDS.taken }); |
| 359 | 403 | } |
| 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 { | |
| 360 | 409 | // 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); | |
| 362 | 411 | ctx.app.log.info("forge claimed", { |
| 363 | 412 | org: spawned.org.username, |
| 364 | user: made.value.username, | |
| 413 | user: user.username, | |
| 365 | 414 | spawnerKeeps: spawned.keepAccess, |
| 366 | 415 | }); |
| 367 | const session = createSession(ctx.app.db, made.value.id); | |
| 416 | const session = createSession(ctx.app.db, user.id); | |
| 368 | 417 | // Home: where the crew's work shows (its first-login screen is #600). |
| 369 | 418 | return redirect( |
| 370 | 419 | "/", |
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 | }); |