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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8947-no-account-depends-on-the-platform-s-sig → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/8947-no-account-depends-on-the-platform-s-sig was merged into main as 205b604.

Review

Sign in to review this change.
8 files changed +224 −4
docs/OPERATING.md +1 −1
@@ -29,7 +29,7 @@ MCP `capacity` tool) makes the forge ignore `X-Plat-User` even when the secret
2929 matches; people then sign in with a password or a passkey. An admin with
3030 neither is refused, so no one locks themselves out. Accounts the edge made
3131 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 [edge-only accounts](features/edge-only-accounts.md).
outdated

The how-to belongs in docs/features/<name>.md. Leave a one-line pointer here instead of this long line.

3333 Account settings show "open-platform.sh: linked as <login>" or "not linked": Link
3434 records the login the request carries against the signed-in account, Unlink removes
3535 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[] {
9090 isAdmin: r.is_admin === 1,
9191 createdAt: r.created_at,
9292 via: origin.via,
93 signsInThrough: origin.via === "edge" ? EDGE_METHOD : null,
93 signsInThrough: origin.via === EDGE_VIA ? EDGE_METHOD : null,
9494 address: origin.address,
9595 repos: r.repos,
9696 apps: r.apps,
@@ -102,6 +102,9 @@ export function listAccounts(db: Database): AccountRow[] {
102102 });
103103 }
104104
105 /** What `user.created` records as `via` for an account the platform edge made. */
106 const EDGE_VIA = "edge";
107
105108 function parseOrigin(payload: string | null): {
106109 via: string | null;
107110 address: string | null;
@@ -179,3 +182,30 @@ export function scrubAccount(db: Database, userId: string, at: string): void {
179182 ).run(at, userId);
180183 forgetListings(db);
181184 }
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";
2121 import { entry as crewHandover } from "./crewhandover.ts";
2222 import { entry as crewSplits } from "./crewsplits.ts";
2323 import { entry as domains } from "./domains.ts";
24 import { entry as edgeOnly } from "./edgeonly.ts";
2425 import { entry as feed } from "./feed.ts";
2526 import { entry as front } from "./front.ts";
2627 import { entry as forges } from "./forges.ts";
@@ -74,6 +75,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
7475 resilience,
7576 valves,
7677 access,
78 edgeOnly,
7779 audit,
7880 secrets,
7981 crewSplits,
src/web/pages/accounts.ts +24 −2
@@ -20,7 +20,7 @@ import {
2020 revokeAccountToken,
2121 } from "../../actions/signins.ts";
2222 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";
2424 import {
2525 OWNERS_TEAM,
2626 ownerlessOrgs,
@@ -181,6 +181,28 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
181181 </p>`,
182182 { tone: "attn" },
183183 );
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 );
184206 const sole = timePhase(ctx.request, "owned", () =>
185207 people.map((a) => {
186208 const u = getUserByName(ctx.app.db, a.username);
@@ -192,7 +214,7 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
192214 const rendering = performance.now();
193215 const response = shell(
194216 ctx,
195 html`${notice ?? null}${ownerless}${noMail}${card(
217 html`${notice ?? null}${ownerless}${edgeOnly}${noMail}${card(
196218 people.length === 0
197219 ? empty({ icon: "users", title: "Nobody has joined yet." })
198220 : 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", () => {
120120 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
121121 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
122122 ["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"],
123124 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
124125 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
125126 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs