Public: anyone can read this; the crew can change it.
No account depends on the platform's sign-in: every edge-made account gets a passkey, then the edge can be switched off #9021
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8947-no-account-depends-on-the-platform-s-sig → main 5 commits fork 4 threads · 4 unresolved
docs/OPERATING.md +1 −1
| @@ -29,7 +29,7 @@ MCP `capacity` tool) makes the forge ignore `X-Plat-User` even when the secret | ||
| 29 | 29 | matches; people then sign in with a password or a passkey. An admin with |
| 30 | 30 | neither is refused, so no one locks themselves out. Accounts the edge made |
| 31 | 31 | cannot sign in while it is off until they add a passkey (a password-less |
| 32 | account cannot set a password: `/settings/password` asks for the current one). | |
| 32 | account cannot set a password: `/settings/password` asks for the current one). Before switching it off, see [](features/edge-only-accounts.md). | |
| 33 | 33 | Account settings show "open-platform.sh: linked as <login>" or "not linked": Link |
| 34 | 34 | records the login the request carries against the signed-in account, Unlink removes |
| 35 | 35 | it (same-origin POSTs). An edge sign-in finds the account by its link first, then by |
docs/features/edge-only-accounts.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Edge-only accounts | |
| 2 | ||
| 3 | An account the platform edge made (or one linked to an edge login) with no password, no passkey and no parent link can sign in only through the edge. Switching `signin.edge` off would lock it out. | |
| 4 | ||
| 5 | - **Web:** an admin's Settings → Accounts shows a card at the top naming each such account, admins first, then oldest, with a nudge to add a passkey at Settings → Passkeys. The card is gone when the list is empty. Non-admins and guests never see it. | |
| 6 | - **Terminal:** the palette entry "Edge-only accounts" (admins only) lists the same accounts, from the same `edgeOnlyAccounts`. | |
| 7 | - **"Edge-made"** is one rule: a `user.created` event whose payload `via` is `edge` (read with `json_extract`, as `parseOrigin` does), or an edge link. | |
| 8 | ||
| 9 | ## Switching the edge off | |
| 10 | ||
| 11 | 1. Wait until the card and the terminal screen are empty. | |
| 12 | 2. Turn `signin.edge` off on a preview first, with the owner present. | |
| 13 | 3. Roll back by setting it on again in Settings → Capacity. |
src/core/accounts.ts +31 −1
| @@ -90,7 +90,7 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 90 | 90 | isAdmin: r.is_admin === 1, |
| 91 | 91 | createdAt: r.created_at, |
| 92 | 92 | via: origin.via, |
| 93 | signsInThrough: origin.via === "edge" ? EDGE_METHOD : null, | |
| 93 | signsInThrough: origin.via === EDGE_VIA ? EDGE_METHOD : null, | |
| 94 | 94 | address: origin.address, |
| 95 | 95 | repos: r.repos, |
| 96 | 96 | apps: r.apps, |
| @@ -102,6 +102,9 @@ export function listAccounts(db: Database): AccountRow[] { | ||
| 102 | 102 | }); |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | /** What `user.created` records as `via` for an account the platform edge made. */ | |
| 106 | const EDGE_VIA = "edge"; | |
| 107 | ||
| 105 | 108 | function parseOrigin(payload: string | null): { |
| 106 | 109 | via: string | null; |
| 107 | 110 | address: string | null; |
| @@ -179,3 +182,30 @@ export function scrubAccount(db: Database, userId: string, at: string): void { | ||
| 179 | 182 | ).run(at, userId); |
| 180 | 183 | forgetListings(db); |
| 181 | 184 | } |
| 185 | ||
| 186 | /** | |
| 187 | * People who can sign in only through the platform edge: an account it made | |
| 188 | * or one linked to an edge login, with no password, no passkey and no parent | |
| 189 | * link. Switching `signin.edge` off would strand each of them. Admins first, | |
| 190 | * then oldest, so the longest-waiting is named first. | |
| 191 | */ | |
| 192 | export function edgeOnlyAccounts( | |
| 193 | db: Database, | |
| 194 | ): { username: string; isAdmin: boolean; createdAt: string }[] { | |
| 195 | return db | |
| 196 | .query<{ username: string; is_admin: number; created_at: string }, [string]>( | |
| 197 | `SELECT u.username, u.is_admin, u.created_at FROM user u | |
| 198 | WHERE u.kind = 'user' AND u.is_agent = 0 AND u.removed_at IS NULL | |
| 199 | AND u.password_hash = '' | |
| 200 | AND NOT EXISTS (SELECT 1 FROM passkey p WHERE p.user_id = u.id) | |
| 201 | AND NOT EXISTS (SELECT 1 FROM federated_identity f WHERE f.user_id = u.id) | |
| 202 | AND (EXISTS (SELECT 1 FROM edge_link l WHERE l.user_id = u.id) | |
outdated
Replace this LIKE on the JSON text with json_extract(e.payload,'$.via') = 'edge', or share parseOrigin's rule. Right now two places define 'edge-made' differently. |
||
| 203 | OR EXISTS (SELECT 1 FROM event e WHERE e.kind = 'user.created' | |
| 204 | AND e.actor_id = u.id | |
| 205 | AND CASE WHEN json_valid(e.payload) | |
| 206 | THEN json_extract(e.payload, '$.via') END = ?1)) | |
| 207 | ORDER BY u.is_admin DESC, u.created_at, u.id`, | |
| 208 | ) | |
| 209 | .all(EDGE_VIA) | |
| 210 | .map((r) => ({ username: r.username, isAdmin: r.is_admin === 1, createdAt: r.created_at })); | |
| 211 | } |
src/tui/screens/edgeonly.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * Edge-only accounts in the terminal, a forge admin's: the people the card at | |
| 3 | * the top of Settings → Accounts names, from the same `edgeOnlyAccounts`. | |
| 4 | * Each can sign in only through the platform edge, so switching | |
| 5 | * `signin.edge` off would lock them out until they add a passkey. | |
| 6 | */ | |
| 7 | ||
| 8 | import { edgeOnlyAccounts } from "../../core/accounts.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import { fill, len, split } from "../ui/layout.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { truncate } from "../term/width.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { emptyState } from "../ui/widgets.ts"; | |
| 16 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 17 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | ||
| 19 | const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 20 | ||
| 21 | export function edgeOnlyScreen(f: Session, ctx: Ctx): Screen { | |
| 22 | const state = new ListState(); | |
| 23 | const accounts = isAdmin(f) ? edgeOnlyAccounts(f.db) : []; | |
| 24 | return { | |
| 25 | title: "Edge-only accounts", | |
| 26 | keys: [binding(["j", "k"], "move cursor")], | |
| 27 | render(area, s) { | |
| 28 | const c = ctx.theme.c; | |
| 29 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 30 | if (accounts.length === 0) { | |
| 31 | emptyState(s, body!, c, { title: "No one is edge-only.", body: "Everyone has a password, a passkey or a parent link." }); | |
| 32 | return; | |
| 33 | } | |
| 34 | const line = "sign in only through the edge; ask each to add a passkey"; | |
| 35 | s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 36 | const rows: ListRow[] = accounts.map((a) => ({ | |
| 37 | title: a.username, | |
| 38 | secondary: a.createdAt.slice(0, 10), | |
| 39 | meta: a.isAdmin ? "admin" : "", | |
| 40 | })); | |
| 41 | drawList(s, body!, c, state, rows); | |
| 42 | }, | |
| 43 | key(e) { | |
| 44 | return state.key(e, accounts.length); | |
| 45 | }, | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | /** Who would be locked out if the platform edge sign-in went off. */ | |
| 50 | export const entry: PaletteEntry = { | |
| 51 | id: "edge-only-accounts", | |
| 52 | title: "Edge-only accounts", | |
| 53 | hint: "who would be locked out if the platform edge sign-in went off", | |
| 54 | offered: isAdmin, | |
| 55 | run: (session, ctx) => ctx.push(edgeOnlyScreen(session, ctx)), | |
| 56 | }; |
src/tui/screens/entries.ts +2
| @@ -21,6 +21,7 @@ import { entry as connections } from "./connections.ts"; | ||
| 21 | 21 | import { entry as crewHandover } from "./crewhandover.ts"; |
| 22 | 22 | import { entry as crewSplits } from "./crewsplits.ts"; |
| 23 | 23 | import { entry as domains } from "./domains.ts"; |
| 24 | import { entry as edgeOnly } from "./edgeonly.ts"; | |
| 24 | 25 | import { entry as feed } from "./feed.ts"; |
| 25 | 26 | import { entry as front } from "./front.ts"; |
| 26 | 27 | import { entry as forges } from "./forges.ts"; |
| @@ -74,6 +75,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 74 | 75 | resilience, |
| 75 | 76 | valves, |
| 76 | 77 | access, |
| 78 | edgeOnly, | |
| 77 | 79 | audit, |
| 78 | 80 | secrets, |
| 79 | 81 | crewSplits, |
src/web/pages/accounts.ts +24 −2
| @@ -20,7 +20,7 @@ import { | ||
| 20 | 20 | revokeAccountToken, |
| 21 | 21 | } from "../../actions/signins.ts"; |
| 22 | 22 | import { mailConfigured } from "../../actions/verify.ts"; |
| 23 | import { originOf, type AccountRow } from "../../core/accounts.ts"; | |
| 23 | import { edgeOnlyAccounts, originOf, type AccountRow } from "../../core/accounts.ts"; | |
| 24 | 24 | import { |
| 25 | 25 | OWNERS_TEAM, |
| 26 | 26 | ownerlessOrgs, |
| @@ -181,6 +181,28 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { | ||
| 181 | 181 | </p>`, |
| 182 | 182 | { tone: "attn" }, |
| 183 | 183 | ); |
| 184 | const stranded = timePhase(ctx.request, "edge-only", () => | |
The terminal has no equivalent of this card. Add a src/tui/ screen that lists edgeOnlyAccounts for admins and register it in ENTRIES (src/tui/screens/entries.ts). |
||
| 185 | edgeOnlyAccounts(ctx.app.db), | |
| 186 | ); | |
| 187 | const edgeOnly = | |
| 188 | stranded.length === 0 | |
| 189 | ? null | |
| 190 | : card( | |
| 191 | html`<p> | |
| 192 | ${stranded.length === 1 ? "This account signs" : "These accounts sign"} | |
| 193 | in only through the platform edge, so switching | |
| 194 | <code>signin.edge</code> off would lock | |
| 195 | ${stranded.length === 1 ? "it" : "them"} out. Ask each to add a | |
| 196 | passkey at <a href="/settings/passkeys">Settings → Passkeys</a>: | |
| 197 | ${stranded.map( | |
| 198 | (a, i) => | |
| 199 | html`${i > 0 ? ", " : ""}${userLink(a.username)}${a.isAdmin | |
| 200 | ? " (admin)" | |
| 201 | : ""}`, | |
| 202 | )}. | |
| 203 | </p>`, | |
| 204 | { tone: "attn" }, | |
| 205 | ); | |
| 184 | 206 | const sole = timePhase(ctx.request, "owned", () => |
| 185 | 207 | people.map((a) => { |
| 186 | 208 | const u = getUserByName(ctx.app.db, a.username); |
| @@ -192,7 +214,7 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { | ||
| 192 | 214 | const rendering = performance.now(); |
| 193 | 215 | const response = shell( |
| 194 | 216 | ctx, |
| 195 | html`${notice ?? null}${ownerless}${noMail}${card( | |
| 217 | html`${notice ?? null}${ownerless}${edgeOnly}${noMail}${card( | |
| 196 | 218 | people.length === 0 |
| 197 | 219 | ? empty({ icon: "users", title: "Nobody has joined yet." }) |
| 198 | 220 | : html`<ul class="rows"> |
test/accounts-edge-only.test.ts added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | /** The owner sees who would be locked out if the platform edge sign-in went off. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { edgeOnlyAccounts } from "../src/core/accounts.ts"; | |
| 8 | import { linkEdgeLogin } from "../src/core/edge_links.ts"; | |
| 9 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 10 | import { createSession } from "../src/core/sessions.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { entry as edgeOnlyEntry } from "../src/tui/screens/edgeonly.ts"; | |
| 13 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 14 | ||
| 15 | let dataDir = ""; | |
| 16 | let app: App, servers: Servers; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dataDir = await mkdtemp(join(tmpdir(), "forge-edgeonly-")); | |
| 20 | app = createApp({ dataDir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 23 | if (!a.ok) throw new Error(a.error); | |
| 24 | }); | |
| 25 | afterAll(async () => { | |
| 26 | servers.stop(); | |
| 27 | app.close(); | |
| 28 | await rm(dataDir, { recursive: true, force: true }); | |
| 29 | }); | |
| 30 | ||
| 31 | test("an account with no password or passkey that the edge made is listed, until it has a way in", async () => { | |
| 32 | const insert = (name: string, hash: string) => | |
| 33 | app.db.query("INSERT INTO user (id, username, kind, email, display_name, password_hash) VALUES (?1, ?1, 'user', ?1 || '@x.test', ?1, ?2)").run(name, hash); | |
| 34 | insert("edgy", ""); | |
| 35 | app.db.query("INSERT INTO event (id, kind, actor_id, payload) VALUES (999001, 'user.created', 'edgy', '{\"via\":\"edge\"}')").run(); | |
| 36 | insert("withpw", "x"); | |
| 37 | app.db.query("INSERT INTO event (id, kind, actor_id, payload) VALUES (999002, 'user.created', 'withpw', '{\"via\":\"edge\"}')").run(); | |
| 38 | insert("linked", ""); | |
| 39 | linkEdgeLogin(app.db, getUserByName(app.db, "linked")!, "linked-login"); | |
| 40 | insert("plain", ""); | |
| 41 | ||
| 42 | expect(edgeOnlyAccounts(app.db).map((a) => a.username).sort()).toEqual(["edgy", "linked"]); | |
| 43 | ||
| 44 | const alice = getUserByName(app.db, "alice")!; | |
| 45 | const cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 46 | const page = await (await fetch(`${servers.url}/settings/accounts`, { headers: { cookie } })).text(); | |
| 47 | expect(page).toContain("only through the platform edge"); | |
| 48 | expect(page).toContain("edgy"); | |
| 49 | ||
| 50 | app.db.query("UPDATE user SET password_hash = 'y' WHERE id IN ('edgy','linked')").run(); | |
| 51 | expect(edgeOnlyAccounts(app.db)).toEqual([]); | |
outdated
Add the missing cases: non-admin and guest see no card, passkey holder is not listed, removed account and agent are excluded, admins come first. |
||
| 52 | }); | |
| 53 | ||
| 54 | const insertUser = (name: string, extra = "", admin = 0) => | |
| 55 | app.db | |
| 56 | .query(`INSERT INTO user (id, username, kind, email, display_name, password_hash, is_admin ${extra ? ", " + extra.split("=")[0] : ""}) VALUES (?1, ?1, 'user', ?1 || '@x.test', ?1, '', ?2 ${extra ? ", " + extra.split("=")[1] : ""})`) | |
| 57 | .run(name, admin); | |
| 58 | const edgeEvent = (id: number, name: string, payload: string) => | |
| 59 | app.db.query("INSERT INTO event (id, kind, actor_id, payload) VALUES (?1, 'user.created', ?2, ?3)").run(id, name, payload); | |
| 60 | ||
| 61 | test("a spaced or reordered payload still counts; a passkey, removal or agent drops it; admins first", () => { | |
| 62 | insertUser("zspaced"); | |
| 63 | edgeEvent(999101, "zspaced", '{ "address": "a", "via": "edge" }'); | |
| 64 | insertUser("zpass"); | |
| 65 | edgeEvent(999102, "zpass", '{"via":"edge"}'); | |
| 66 | app.db.query("INSERT INTO passkey (id, user_id, credential_id, name, alg, key) VALUES ('pk1','zpass','c1','k','ES256','{}')").run(); | |
| 67 | insertUser("zgone", "removed_at='2026-01-01'"); | |
| 68 | edgeEvent(999103, "zgone", '{"via":"edge"}'); | |
| 69 | insertUser("zbot", "is_agent=1"); | |
| 70 | edgeEvent(999104, "zbot", '{"via":"edge"}'); | |
| 71 | insertUser("zother"); | |
| 72 | edgeEvent(999105, "zother", '{"via":"password"}'); | |
| 73 | insertUser("zadmin", "", 1); | |
| 74 | edgeEvent(999107, "zadmin", '{"via":"edge"}'); | |
| 75 | ||
| 76 | const names = edgeOnlyAccounts(app.db).map((a) => a.username); | |
| 77 | expect(names).toContain("zspaced"); | |
| 78 | for (const n of ["zpass", "zgone", "zbot", "zother"]) expect(names).not.toContain(n); | |
| 79 | expect(names.indexOf("zadmin")).toBeLessThan(names.indexOf("zspaced")); | |
| 80 | expect(edgeOnlyAccounts(app.db).find((a) => a.username === "zadmin")!.isAdmin).toBe(true); | |
| 81 | }); | |
| 82 | ||
| 83 | test("only an admin gets the card and the terminal entry", async () => { | |
| 84 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bobby", email: "[email protected]", password: "correct-horse-battery" }); | |
| 85 | if (!bob.ok) throw new Error(bob.error); | |
| 86 | const cookie = `forge_session=${createSession(app.db, bob.value.id).token}`; | |
| 87 | const res = await fetch(`${servers.url}/settings/accounts`, { headers: { cookie }, redirect: "manual" }); | |
| 88 | expect(await res.text()).not.toContain("only through the platform edge"); | |
| 89 | const guest = await fetch(`${servers.url}/settings/accounts`, { redirect: "manual" }); | |
| 90 | expect(await guest.text()).not.toContain("only through the platform edge"); | |
| 91 | ||
| 92 | const session = (user: unknown) => ({ user }) as never; | |
| 93 | expect(edgeOnlyEntry.offered(session(getUserByName(app.db, "alice")))).toBe(true); | |
| 94 | expect(edgeOnlyEntry.offered(session(bob.value))).toBe(false); | |
| 95 | expect(edgeOnlyEntry.offered(session(undefined))).toBe(false); | |
| 96 | }); |
test/palette-roster.test.ts +1
| @@ -120,6 +120,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 120 | 120 | ["invites", "Invite links", "make a single-use link, see which are open, revoke one"], |
| 121 | 121 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 122 | 122 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 123 | ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"], | |
| 123 | 124 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 124 | 125 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 125 | 126 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
The how-to belongs in docs/features/<name>.md. Leave a one-line pointer here instead of this long line.