A new account is made only through one gate: createUser takes a required origin, and a stranger's door is held to the signup setting and the sign-up window there #43

Merged
grokbot merged 4 commits from grokbot/forge:fix/h1-create-user-origin-gate into main 2026-10-17 21:36:34 +00:00
Owner

The parent forge's sign-in made a local account on first sight of a person
while the child's signup was closed or invite: that door asked the
sign-up window but not the signup setting (#2099; #2245 did not cover it).
Every door asked the policy itself, so a door added later could leave it out.

createUser now takes a required origin, and the policy is decided there:

  • Stranger origins signup (form, API, terminal via signUp), passkey and
    parent are refused with signup_closed while signup is not open, and must
    carry a SignupSlot. Only SignupSlot.take (the sign-up window) makes one,
    its private constructor and private field keep a look-alike from passing,
    and it makes one account.
  • edge is held to the setting, as before, and not to the window, which it
    never was.
  • claim, everyday, agent, spawn and seed are named exemptions. The
    switch over origins ends in a never check, so an origin nobody classified
    does not compile; a new exemption is one more name.

Every caller of createUser

caller origin held to
actions/users.ts signUp (signup form, POST /api/v1/users, terminal) signup setting + window slot
actions/users.ts signUpWithPasskey passkey setting + window slot
core/federation.ts makeAccount (parent sign-in, first sight) parent setting + window slot
core/users.ts ensureEdgeUser edge setting
web/pages/claim.ts (one-time claim link) claim exempt
web/pages/everyday.ts (an admin's second account) everyday exempt
actions/crew.ts createAgent (admin only) agent exempt
actions/spawn.ts founder and carried crew of a spawned child spawn exempt
chaos/world.ts, test fixtures seed exempt

test/signup-gate-parent.test.ts pins this list, so a new caller is added on purpose. An operator first-admin bootstrap would be one more ExemptOrigin name and one more case.

The doors take their slot where they asked the window before, so the 429s, their wording and their place in each request are unchanged; the door-level signupClosed early outs stay for their pages and messages.

Order with #41 and a follow-up

#41 (operator first-admin bootstrap) calls createUser without an origin, so whichever lands second adds one line: "first-admin" in ExemptOrigin, a case in admission, its call site in the list in test/signup-gate-parent.test.ts. A follow-up, stacked on this branch, makes sure no sign-up ever becomes the site admin (today a fresh forge's first sign-up does): only claim, seed and first-admin may carry admin flags. It needs #41 first, since without it a standalone forge would have no way to get an admin, and it needs its own test fixture work. It is not part of this PR.

Mechanical test churn

A required field reaches every fixture: origin: "seed" is added to the existing createUser(...) calls in test/, and direct signUp(...) fixtures pass { slot: fixtureSlot(app) } (test/helpers/slot.ts, one source per fixture so set-up does not spend a window a test is reading). No assertion changed.

Regression test, before and after

bun test test/signup-gate-parent.test.ts, the same file, on origin/main (1051d451) and on this branch:

before (origin/main): 1 pass, 3 fail
bun test v1.3.14 (0d9b296a)
test/signup-gate-parent.test.ts:
74 | const shut: [SignupMode, string][] = [["closed", "alice"], ["invite", "bea"]];
75 | for (const [mode, name] of shut) {
76 |   test(`signup ${mode}: a parent's person new to the child gets no account and no session`, async () => {
77 |     setSignup(child.db, mode, owner);
78 |     const done = await through(name);
79 |     expect(done.status).toBe(403);
                             ^
error: expect(received).toBe(expected)
Expected: 403
Received: 303
      at <anonymous> (test/signup-gate-parent.test.ts:79:25)
(fail) signup closed: a parent's person new to the child gets no account and no session [23.92ms]
74 | const shut: [SignupMode, string][] = [["closed", "alice"], ["invite", "bea"]];
75 | for (const [mode, name] of shut) {
76 |   test(`signup ${mode}: a parent's person new to the child gets no account and no session`, async () => {
77 |     setSignup(child.db, mode, owner);
78 |     const done = await through(name);
79 |     expect(done.status).toBe(403);
                             ^
error: expect(received).toBe(expected)
Expected: 403
Received: 303
      at <anonymous> (test/signup-gate-parent.test.ts:79:25)
(fail) signup invite: a parent's person new to the child gets no account and no session [4.12ms]
(pass) signup open: the same door makes the account [5.82ms]
112 |   return found;
113 | }
114 | 
115 | test("every caller of createUser says where its account comes from", async () => {
116 |   // A new way in is added here on purpose: a stranger's door, or a named exemption.
117 |   expect(await callers()).toEqual({
                                ^
error: expect(received).toEqual(expected)
  {
    "src/actions/crew.ts": [
-     "agent",
+     "(none)",
    ],
    "src/actions/spawn.ts": [
-     "spawn",
-     "spawn",
+     "(none)",
+     "(none)",
    ],
    "src/actions/users.ts": [
-     "signup",
-     "passkey",
+     "(none)",
+     "(none)",
    ],
    "src/chaos/world.ts": [
-     "seed",
+     "(none)",
    ],
    "src/core/federation.ts": [
-     "parent",
+     "(none)",
    ],
    "src/core/users.ts": [
-     "edge",
+     "(none)",
    ],
    "src/web/pages/claim.ts": [
-     "claim",
+     "(none)",
    ],
    "src/web/pages/everyday.ts": [
-     "everyday",
+     "(none)",
    ],
  }
- Expected  - 10
+ Received  + 10
      at <anonymous> (test/signup-gate-parent.test.ts:117:27)
(fail) every caller of createUser says where its account comes from [356.04ms]
 1 pass
 3 fail
 6 expect() calls
Ran 4 tests across 1 file. [731.00ms]
after (this branch): 4 pass, 0 fail
bun test v1.3.14 (0d9b296a)
test/signup-gate-parent.test.ts:
(pass) signup closed: a parent's person new to the child gets no account and no session [22.92ms]
(pass) signup invite: a parent's person new to the child gets no account and no session [3.59ms]
(pass) signup open: the same door makes the account [3.76ms]
(pass) every caller of createUser says where its account comes from [292.66ms]
 4 pass
 0 fail
 10 expect() calls
Ran 4 tests across 1 file. [644.00ms]

Validation

pipeline/validate on 7e02b004: flaky in 469s — bun 1.3.14, CI-shaped env (scrubbed vars, runner PATH), exact check.yml shard + forge.json commands.

step result time
bun install --frozen-lockfile ok (exit 0) 0s
forge.json check.fast BASELINE (exit 1) 128s
check.yml gate (1) pull_request BASELINE (exit 1) 19s
check.yml gate (2) pull_request FLAKY (exit 1) 17s
check.yml gate (3) pull_request ok (exit 0) 305s
  • ⚠️ check.yml cannot check out fork commits: Actions on a fork PR fail at 'clone source at this ref' whatever the code (README: fork PRs)
  • ⚠️ node v20.19.2 here, v22 on CI (tsc runs under node)
  • ⚠️ check.yml gate (1) pull_request stopped early at a BASELINE failure (fail-fast): its later files were not run in this step
  • ⚠️ check.yml gate (2) pull_request stopped early at a FLAKY failure (fail-fast): its later files were not run in this step
test output (failures, totals)
▶ bun install --frozen-lockfile
  ok  exit 0  0s
▶ forge.json check.fast
(fail) egress, reported: an undeclared public host goes through and is counted; a private one never does > an undeclared public host is let through and counted [1.12ms] [shard 10]
Ran 3530 tests across 350 files in 96.3s; 1 failed.
classify: reran test/contain.test.ts test/crew-network.test.ts test/variants-surfaces.test.ts  alone on origin/main 1051d451 and on this branch
  BASELINE  exit 1  128s
▶ check.yml gate (1) pull_request
(fail) egress, reported: an undeclared public host goes through and is counted; a private one never does > an undeclared public host is let through and counted [3.70ms] [shard 3]
Ran 110 tests across 3 files in 18.0s; 1 failed.
classify: reran test/contain.test.ts  alone on origin/main 1051d451 and on this branch
  BASELINE  exit 1  19s
▶ check.yml gate (2) pull_request
(fail) a fork that follows its parent > a proposal names only what is actually ahead: work the parent already has is not listed again [396.12ms] [shard 3]
Ran 61 tests across 3 files in 12.2s; 1 failed.
classify: reran test/following.test.ts  alone on origin/main 1051d451 and on this branch
classify: not red on main:
  FLAKY  exit 1  17s
▶ check.yml gate (3) pull_request
Ran 1525 tests across 143 files in 304.8s (143 shards).
  ok  exit 0  305s
The parent forge's sign-in made a local account on first sight of a person while the child's signup was `closed` or `invite`: that door asked the sign-up window but not the signup setting (#2099; #2245 did not cover it). Every door asked the policy itself, so a door added later could leave it out. `createUser` now takes a required `origin`, and the policy is decided there: - Stranger origins `signup` (form, API, terminal via `signUp`), `passkey` and `parent` are refused with `signup_closed` while signup is not open, and must carry a `SignupSlot`. Only `SignupSlot.take` (the sign-up window) makes one, its private constructor and private field keep a look-alike from passing, and it makes one account. - `edge` is held to the setting, as before, and not to the window, which it never was. - `claim`, `everyday`, `agent`, `spawn` and `seed` are named exemptions. The switch over origins ends in a `never` check, so an origin nobody classified does not compile; a new exemption is one more name. ### Every caller of `createUser` | caller | origin | held to | |---|---|---| | `actions/users.ts` `signUp` (signup form, `POST /api/v1/users`, terminal) | `signup` | setting + window slot | | `actions/users.ts` `signUpWithPasskey` | `passkey` | setting + window slot | | `core/federation.ts` `makeAccount` (parent sign-in, first sight) | `parent` | setting + window slot | | `core/users.ts` `ensureEdgeUser` | `edge` | setting | | `web/pages/claim.ts` (one-time claim link) | `claim` | exempt | | `web/pages/everyday.ts` (an admin's second account) | `everyday` | exempt | | `actions/crew.ts` `createAgent` (admin only) | `agent` | exempt | | `actions/spawn.ts` founder and carried crew of a spawned child | `spawn` | exempt | | `chaos/world.ts`, test fixtures | `seed` | exempt | `test/signup-gate-parent.test.ts` pins this list, so a new caller is added on purpose. An operator first-admin bootstrap would be one more `ExemptOrigin` name and one more `case`. The doors take their slot where they asked the window before, so the 429s, their wording and their place in each request are unchanged; the door-level `signupClosed` early outs stay for their pages and messages. ### Order with #41 and a follow-up #41 (operator first-admin bootstrap) calls `createUser` without an `origin`, so whichever lands second adds one line: `"first-admin"` in `ExemptOrigin`, a `case` in `admission`, its call site in the list in `test/signup-gate-parent.test.ts`. A follow-up, stacked on this branch, makes sure no sign-up ever becomes the site admin (today a fresh forge's first sign-up does): only `claim`, `seed` and `first-admin` may carry admin flags. It needs #41 first, since without it a standalone forge would have no way to get an admin, and it needs its own test fixture work. It is not part of this PR. ### Mechanical test churn A required field reaches every fixture: `origin: "seed"` is added to the existing `createUser(...)` calls in `test/`, and direct `signUp(...)` fixtures pass `{ slot: fixtureSlot(app) }` (`test/helpers/slot.ts`, one source per fixture so set-up does not spend a window a test is reading). No assertion changed. ### Regression test, before and after `bun test test/signup-gate-parent.test.ts`, the same file, on `origin/main` (1051d451) and on this branch: <details><summary>before (origin/main): 1 pass, 3 fail</summary> ``` bun test v1.3.14 (0d9b296a) test/signup-gate-parent.test.ts: 74 | const shut: [SignupMode, string][] = [["closed", "alice"], ["invite", "bea"]]; 75 | for (const [mode, name] of shut) { 76 | test(`signup ${mode}: a parent's person new to the child gets no account and no session`, async () => { 77 | setSignup(child.db, mode, owner); 78 | const done = await through(name); 79 | expect(done.status).toBe(403); ^ error: expect(received).toBe(expected) Expected: 403 Received: 303 at <anonymous> (test/signup-gate-parent.test.ts:79:25) (fail) signup closed: a parent's person new to the child gets no account and no session [23.92ms] 74 | const shut: [SignupMode, string][] = [["closed", "alice"], ["invite", "bea"]]; 75 | for (const [mode, name] of shut) { 76 | test(`signup ${mode}: a parent's person new to the child gets no account and no session`, async () => { 77 | setSignup(child.db, mode, owner); 78 | const done = await through(name); 79 | expect(done.status).toBe(403); ^ error: expect(received).toBe(expected) Expected: 403 Received: 303 at <anonymous> (test/signup-gate-parent.test.ts:79:25) (fail) signup invite: a parent's person new to the child gets no account and no session [4.12ms] (pass) signup open: the same door makes the account [5.82ms] 112 | return found; 113 | } 114 | 115 | test("every caller of createUser says where its account comes from", async () => { 116 | // A new way in is added here on purpose: a stranger's door, or a named exemption. 117 | expect(await callers()).toEqual({ ^ error: expect(received).toEqual(expected) { "src/actions/crew.ts": [ - "agent", + "(none)", ], "src/actions/spawn.ts": [ - "spawn", - "spawn", + "(none)", + "(none)", ], "src/actions/users.ts": [ - "signup", - "passkey", + "(none)", + "(none)", ], "src/chaos/world.ts": [ - "seed", + "(none)", ], "src/core/federation.ts": [ - "parent", + "(none)", ], "src/core/users.ts": [ - "edge", + "(none)", ], "src/web/pages/claim.ts": [ - "claim", + "(none)", ], "src/web/pages/everyday.ts": [ - "everyday", + "(none)", ], } - Expected - 10 + Received + 10 at <anonymous> (test/signup-gate-parent.test.ts:117:27) (fail) every caller of createUser says where its account comes from [356.04ms] 1 pass 3 fail 6 expect() calls Ran 4 tests across 1 file. [731.00ms] ``` </details> <details><summary>after (this branch): 4 pass, 0 fail</summary> ``` bun test v1.3.14 (0d9b296a) test/signup-gate-parent.test.ts: (pass) signup closed: a parent's person new to the child gets no account and no session [22.92ms] (pass) signup invite: a parent's person new to the child gets no account and no session [3.59ms] (pass) signup open: the same door makes the account [3.76ms] (pass) every caller of createUser says where its account comes from [292.66ms] 4 pass 0 fail 10 expect() calls Ran 4 tests across 1 file. [644.00ms] ``` </details> <!-- pipeline:validation --> ### Validation `pipeline/validate` on `7e02b004`: **flaky** in 469s — bun 1.3.14, CI-shaped env (scrubbed vars, runner PATH), exact check.yml shard + forge.json commands. | step | result | time | |---|---|---| | `bun install --frozen-lockfile` | ok (exit 0) | 0s | | `forge.json check.fast` | BASELINE (exit 1) | 128s | | `check.yml gate (1) pull_request` | BASELINE (exit 1) | 19s | | `check.yml gate (2) pull_request` | FLAKY (exit 1) | 17s | | `check.yml gate (3) pull_request` | ok (exit 0) | 305s | - ⚠️ check.yml cannot check out fork commits: Actions on a fork PR fail at 'clone source at this ref' whatever the code (README: fork PRs) - ⚠️ node v20.19.2 here, v22 on CI (tsc runs under node) - ⚠️ check.yml gate (1) pull_request stopped early at a BASELINE failure (fail-fast): its later files were not run in this step - ⚠️ check.yml gate (2) pull_request stopped early at a FLAKY failure (fail-fast): its later files were not run in this step <details><summary>test output (failures, totals)</summary> ``` ▶ bun install --frozen-lockfile ok exit 0 0s ▶ forge.json check.fast (fail) egress, reported: an undeclared public host goes through and is counted; a private one never does > an undeclared public host is let through and counted [1.12ms] [shard 10] Ran 3530 tests across 350 files in 96.3s; 1 failed. classify: reran test/contain.test.ts test/crew-network.test.ts test/variants-surfaces.test.ts alone on origin/main 1051d451 and on this branch BASELINE exit 1 128s ▶ check.yml gate (1) pull_request (fail) egress, reported: an undeclared public host goes through and is counted; a private one never does > an undeclared public host is let through and counted [3.70ms] [shard 3] Ran 110 tests across 3 files in 18.0s; 1 failed. classify: reran test/contain.test.ts alone on origin/main 1051d451 and on this branch BASELINE exit 1 19s ▶ check.yml gate (2) pull_request (fail) a fork that follows its parent > a proposal names only what is actually ahead: work the parent already has is not listed again [396.12ms] [shard 3] Ran 61 tests across 3 files in 12.2s; 1 failed. classify: reran test/following.test.ts alone on origin/main 1051d451 and on this branch classify: not red on main: FLAKY exit 1 17s ▶ check.yml gate (3) pull_request Ran 1525 tests across 143 files in 304.8s (143 shards). ok exit 0 305s ``` </details>
A new account is made only through one gate: createUser takes a required origin, and a stranger's door is held to the signup setting and the sign-up window there
Some checks failed
check / gate (1) (pull_request) Failing after 31s
check / gate (2) (pull_request) Failing after 33s
preview / preview (pull_request) Failing after 3s
check / gate (3) (pull_request) Failing after 23s
check / check (pull_request) Failing after 1s
7e02b00484
The parent forge's sign-in made a local account on first sight of a person
while the child's signup was `closed` or `invite`: that door asked the
sign-up window but not the signup setting (#2099; #2245 did not cover it).
Every door asked the policy itself, so a door added later could leave it out.

`createUser` now takes a required `origin`, and the policy is decided there:

- Stranger origins `signup` (form, API, terminal via `signUp`), `passkey` and
  `parent` are refused with `signup_closed` while signup is not open, and must
  carry a `SignupSlot`. Only `SignupSlot.take` (the sign-up window) makes one,
  its private constructor and private field keep a look-alike from passing,
  and it makes one account.
- `edge` is held to the setting, as before, and not to the window, which it
  never was.
- `claim`, `everyday`, `agent`, `spawn` and `seed` are named exemptions. The
  switch over origins ends in a `never` check, so an origin nobody classified
  does not compile; a new exemption is one more name.
Merge branch 'main' into fix/h1-create-user-origin-gate
Some checks failed
check / gate (2) (pull_request) Successful in 5m10s
preview / preview (pull_request) Has been skipped
check / gate (3) (pull_request) Successful in 3m53s
check / gate (1) (pull_request) Successful in 10m46s
check / check (pull_request) Has been cancelled
8401b8cfef
Merge branch 'main' into fix/h1-create-user-origin-gate
Some checks failed
preview / preview (pull_request) Has been skipped
check / gate (3) (pull_request) Successful in 3m56s
check / gate (2) (pull_request) Failing after 7m6s
check / gate (1) (pull_request) Successful in 10m46s
check / check (pull_request) Has been cancelled
5871ad708d
Merge branch 'main' into fix/h1-create-user-origin-gate
Some checks failed
check / gate (3) (pull_request) Successful in 4m1s
check / gate (2) (pull_request) Failing after 7m55s
check / gate (1) (pull_request) Successful in 10m51s
check / check (pull_request) Failing after 0s
preview / preview (pull_request) Has been skipped
1e666d4ccd
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
trevato-apps/forge!43
No description provided.