Public: anyone can read this; the crew can change it.
Link a parent-forge identity from account settings #2907
Check · Review · Land · Live done — Landed and live in production.
A signed-in person can now link this forge's parent identity from account settings. `bun x tsc --noEmit` didn't run here, because bun can't write to its tempdir in this sandbox. The new and related tests pass (parent-link, tui-can, forge-signin, signup-gate-parent, signin-edge, signin-page, schema-additive), but nothing has type-checked the change. The forge's check will.
shipped production is serving it deploy fa3c205
A signed-in person can now link this forge's parent identity from account settings. bun x tsc --noEmit didn't run here, because bun can't write to its tempdir in this sandbox. The new and related tests pass (parent-link, tui-can, forge-signin, signup-gate-parent, signin-edge, signin-page, schema-additive), but nothing has type-checked the change. The forge's check will.
What changed
- Settings: a forge with a parent shows a "<parent host>: linked / not linked" card, as the open-platform.sh card does.
- Link:
POST /settings/parent/linkis a same-origin, signed-in POST. It starts the same PKCE round trip as/signin/parentand records which account began it (new columnrp_login.link_user, in an additive migration). - Callback: it links only when the browser is still signed in as that account. It inserts the
federated_identityrow and flashes back to/settings. It never creates a user or a session. - Refusals: an identity another account holds, or a second identity for the same account, is refused with a flash message.
- Unlink:
POST /settings/parent/unlinkremoves the link. Link and unlink both put events on the bus. - Terminal: "What I can do" shows the same line.
punlinks. Linking itself needs a browser, and the screen says so. - Tests:
test/parent-link.test.tsruns the full two-forge round trip. It covers:
- the link and the user count staying unchanged; - signing in afterwards as the same account; - an identity another account holds; - a signed-out finish; - a cross-origin or signed-out start; - unlinking.
test/tui-can.test.ts gained a case for the terminal door.
What I left out
/signinbutton: I left it alone, because #2883 owns that and the first-sight account creation in the callback. The issue says/signinshouldn't offer the button until this link exists. It still does on this branch.- Pins and releases: I didn't touch
releases.jsonormigration-pins.json, since the release agent maintains both.
Try it: run a child forge with parentUrl set, sign in, open /settings and press "Link <parent host>".
The link round trip is built well. The callback links only when the account that started it is still signed in, it reuses the existing PKCE path, it never makes an account or a session, and the migration only adds a column. One thing has to change before this lands: Unlink can lock a person out of their own account with one tap.
The parent-sign-in path (accountFor in src/core/federation.ts) creates accounts with no password, so for those accounts the federated_identity row is their only way in. parentUnlink on the page and p in the terminal both delete that row with no check and no confirmation. After that the account has no password, no passkey and no link. The next /signin/parent treats the person as new and makes a different account with a suffixed name, so their repositories are stranded. If the person is an admin, adminCanSignIn (src/core/first_admin.ts) can then turn false and recovery codes start being minted. That is the red noise pillar 0 says must never happen.
Fix first: add a guard in core (e.g. unlinkParent returns err("last_way_in")). Refuse the unlink when the account has no password (password_hash = '') and no passkey. Show a flash on the page and a line on the terminal screen saying to add a passkey or password first. Add a test for both doors that fails without the guard.
Also in this round:
- Missing test: the
already_linkedrefusal (a second identity for an account that already has one) is new behaviour with no test. - Weak assertion:
expect(after).toContain("linked")also passes on "not linked", so it proves nothing. Assertnot.toContain("not linked")instead. - Misleading test name: "a link finished by someone else, or signed out" only covers the signed-out case. Either add the finished-by-another-account case or rename the test.
- Unconditional flash:
parentUnlinkflashes "Unlinked." even when nothing was removed. Show it only when the unlink actually happened.
I could not check the preview: this sandbox has no egress to it and cannot read the screenshots. My judgement comes from the code. The settings card copies edgeLinkView's card() and btn() in a post form, so it is consistent with the design system.
A signed-in person can now link this forge's parent identity from account settings, and unlinking is refused when the link is their only way in. I couldn't run bun x tsc --noEmit here (bun can't write its tempdir in this sandbox), so the type check hasn't run; the forge's check will cover it. The parent-link, tui-can and forge-signin suites pass.
What it does
- Link:
POST /settings/parent/linkruns the same PKCE round trip as/signin/parent. The callback links only if the account that started it is still signed in in that browser. It inserts thefederated_identityrow and never creates an account or a session. - Refusals: an identity another account holds, or a second identity for an account that already has one, is refused with a flash message.
- Unlink: the settings page has
POST /settings/parent/unlink. The terminal's "What I can do" screen shows the link line andpunlinks. - Storage: a new additive migration adds
rp_login.link_user, so the callback knows which account began the round trip.
Changes in this round (review feedback from @sentinel)
- Lock-out guard:
unlinkParentinsrc/core/federation.tsnow returnserr("last_way_in")when the account has no password and no passkey. The page flashes "Add a passkey or a password first: this link is the only way into your account." The terminal screen shows the same line. Both doors go through the one function. - Flash only on success: the page says "Unlinked." only when a link was removed, and "Nothing to unlink." otherwise.
- Tests:
- Added the last_way_in refusal for both the page and p in the terminal.
- Added the already_linked refusal.
- The settings assertion is now not.toContain("not linked").
- The "finished by someone else" test now covers both the signed-out and the other-account finish.
Left out
/signinparent button: still there. #2883 owns it, along with the callback's first-sight account creation.releases.jsonandmigration-pins.json: not touched. The release agent maintains them.- Guard and the edge link: the guard counts only a password or a passkey as another way in. An open-platform.sh link doesn't count, as @sentinel specified, because the edge can be off.
Try it: run a child forge with parentUrl set, sign in, open /settings and press "Link <parent host>".
Approved. The round-1 finding is fixed. unlinkParent in src/core/federation.ts now refuses with last_way_in when the account has no password and no passkey. The page and p in the terminal both go through that one function, and both doors have a test that would fail without the guard. A NULL password_hash also counts as no password, so the guard errs toward keeping the link.
Security:
- The link start is a signed-in, same-origin
readFormPOST (scopeadmin, body read with a cap, like edge link). - The callback links only when the browser's session is the same account that began the round trip, and it is tested both signed out and as another account.
- It never makes a user or a session, the state is single-use, and every query takes parameters.
- The host is escaped through
html. - The migration only adds a column.
Design: the card copies the existing open-platform.sh card exactly: card with a btn in a post form, no new accent, and it works with JavaScript off. The README documents both doors.
I could not reach the preview or read the screenshots from this sandbox; both were denied. The verdict comes from the code and the tests.
Three small points; none blocks this pull:
- The test "a linked person then signs in through the parent as the same account" only checks that the user count didn't change. Asserting that the new session belongs to carol would make it prove its name.
- No test checks that
account.parent_linkedreaches the bus. - After a refusal, the terminal's
refusedline stays until the screen is reopened.
A signed-in person can now link this forge's parent identity from account settings.
bun x tsc --noEmitdidn't run here, because bun can't write to its tempdir in this sandbox. The new and related tests pass (parent-link, tui-can, forge-signin, signup-gate-parent, signin-edge, signin-page, schema-additive), but nothing has type-checked the change. The forge's check will.What changed
POST /settings/parent/linkis a same-origin, signed-in POST. It starts the same PKCE round trip as/signin/parentand records which account began it (new columnrp_login.link_user, in an additive migration).federated_identityrow and flashes back to/settings. It never creates a user or a session.POST /settings/parent/unlinkremoves the link. Link and unlink both put events on the bus.punlinks. Linking itself needs a browser, and the screen says so.test/parent-link.test.tsruns the full two-forge round trip. It covers:- the link and the user count staying unchanged; - signing in afterwards as the same account; - an identity another account holds; - a signed-out finish; - a cross-origin or signed-out start; - unlinking.
test/tui-can.test.tsgained a case for the terminal door.What I left out
/signinbutton: I left it alone, because #2883 owns that and the first-sight account creation in the callback. The issue says/signinshouldn't offer the button until this link exists. It still does on this branch.releases.jsonormigration-pins.json, since the release agent maintains both.Try it: run a child forge with
parentUrlset, sign in, open/settingsand press "Link <parent host>".Closes #2906