Public: anyone can read this; the crew can change it.

With `signin.edge` off the owner signs in by passkey, and Settings → Capacity turns it back on #9059

Merged opened this

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

Merged

scout/forge:scout/9048-with-signin-edge-off-the-owner-signs-in → main 1 commit fork

  • Merged. scout/9048-with-signin-edge-off-the-owner-signs-in was merged into main as 131ab60.

Review

Sign in to review this change.
2 files changed +131 −0
docs/OPERATING.md +10
@@ -30,6 +30,16 @@ 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
3232 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).
33 To switch it off on a preview: sign in there as an admin with a password or a
34 passkey (Settings → Passkeys adds one), open Settings → Capacity, set "Sign in
35 through the platform edge" to `off`, then sign out and back in with the passkey
36 to prove the way in. To roll back, set the same row to `on` (or Put back, which
37 returns to `FORGE_SIGNIN_EDGE`, `on` when unset); with no browser,
38 `PUT /api/v1/admin/capacity/signin.edge` with `{"value":"on"}` and an admin
39 token (`POST /api/v1/tokens` with the password still mints one while it is
40 off). A stored value outranks `FORGE_SIGNIN_EDGE`, so changing the environment
41 alone does not roll back. Switching production off is the owner's to do, with
42 the owner present. `test/signin-edge-off.test.ts` walks this round trip.
3343 Account settings show "open-platform.sh: linked as <login>" or "not linked": Link
3444 records the login the request carries against the signed-in account, Unlink removes
3545 it (same-origin POSTs). An edge sign-in finds the account by its link first, then by
test/signin-edge-off.test.ts added +121
@@ -0,0 +1,121 @@
1 /**
2 * `signin.edge` off, end to end through the doors a person uses (#9048):
3 * an edge-only admin is refused on Settings → Capacity, the owner turns it
4 * off there, the edge's headers then sign no one in, the owner signs in by
5 * passkey and by password, and turns it back on from the same page.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { capacityChanges, capacityValue } from "../src/core/capacity.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { seedAdmin } from "./helpers/admin.ts";
16 import { device, type Device } from "./helpers/passkey.ts";
17
18 const TOKEN = "edge-secret-0123456789";
19 const PASSWORD = "correct horse battery";
20
21 describe("signin.edge off: the owner signs in by passkey and turns it back on", () => {
22 let dataDir: string;
23 let app: App;
24 let servers: Servers;
25 let base: string;
26 let owner = "";
27 let key: Device;
28 const asAda = { "x-plat-user": "ada", "x-plat-edge-token": TOKEN };
29 const asOlive = { "x-plat-user": "olive", "x-plat-edge-token": TOKEN };
30
31 const json = (path: string, body: unknown, headers: Record<string, string> = {}) =>
32 fetch(`${base}${path}`, {
33 method: "POST",
34 redirect: "manual",
35 headers: { "content-type": "application/json", origin: base, ...headers },
36 body: JSON.stringify(body),
37 });
38 const setEdge = (value: string, headers: Record<string, string>) =>
39 fetch(`${base}/settings/capacity`, {
40 method: "POST",
41 redirect: "manual",
42 headers: { "content-type": "application/x-www-form-urlencoded", origin: base, ...headers },
43 body: new URLSearchParams({ key: "signin.edge", value }),
44 });
45 const cookieOf = (res: Response) => (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
46 const who = (headers: Record<string, string>) => fetch(`${base}/api/v1/user`, { headers });
47 const passwordSignin = () =>
48 fetch(`${base}/signin`, {
49 method: "POST",
50 redirect: "manual",
51 headers: { "content-type": "application/x-www-form-urlencoded", origin: base },
52 body: new URLSearchParams({ username: "olive", password: PASSWORD }),
53 });
54
55 beforeAll(async () => {
56 dataDir = await mkdtemp(join(tmpdir(), "forge-signin-edge-off-"));
57 app = createApp({ dataDir, port: 0, edgeToken: TOKEN });
58 servers = await serve(app);
59 base = servers.url;
60 const olive = await seedAdmin(app, { username: "olive", email: "[email protected]", password: PASSWORD });
61 if (!olive.ok) throw new Error(olive.error);
62 // An admin the edge made: no password, no passkey.
63 const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" });
64 if (!ada.ok) throw new Error(ada.error);
65
66 owner = cookieOf(await passwordSignin());
67 key = await device(new URL(base).origin);
68 const opts = (await (await json("/settings/passkeys/options", {}, { cookie: owner })).json()) as { challenge: string };
69 const enrolled = await json("/settings/passkeys", { name: "laptop", challenge: opts.challenge, ...(await key.create(opts.challenge)) }, { cookie: owner });
70 expect(enrolled.status).toBe(200);
71 });
72
73 afterAll(async () => {
74 servers.stop();
75 app.close();
76 await rm(dataDir, { recursive: true, force: true });
77 });
78
79 test("Settings → Capacity shows signin.edge, on", async () => {
80 expect(capacityValue(app.db, "signin.edge")).toBe("on");
81 const page = await (await fetch(`${base}/settings/capacity`, { headers: { cookie: owner } })).text();
82 expect(page).toContain('name="key" value="signin.edge"');
83 expect((await who(asAda)).status).toBe(200);
84 });
85
86 test("an edge-only admin is refused on the page: it would lock them out", async () => {
87 const refused = await setEdge("off", asAda);
88 expect(refused.status).toBe(303);
89 expect(capacityValue(app.db, "signin.edge")).toBe("on");
90 });
91
92 test("the owner turns it off on the page; the edge's headers sign no one in", async () => {
93 expect((await setEdge("off", { cookie: owner })).status).toBe(303);
94 expect(capacityValue(app.db, "signin.edge")).toBe("off");
95 expect(capacityChanges(app.db, 1)[0]).toMatchObject({ key: "signin.edge", from: "on", to: "off" });
96 expect((await who(asAda)).status).toBe(401);
97 expect((await who(asOlive)).status).toBe(401);
98 // Nor can the edge, now ignored, turn it back on.
99 await setEdge("on", asAda);
100 expect(capacityValue(app.db, "signin.edge")).toBe("off");
101 });
102
103 test("off: the owner signs in by password and by passkey", async () => {
104 const byPassword = cookieOf(await passwordSignin());
105 expect(byPassword).toStartWith("forge_session=");
106 const o = (await (await json("/signin/passkey/options", {})).json()) as { challenge: string };
107 const signed = await json("/signin/passkey", { challenge: o.challenge, ...(await key.get(o.challenge)) });
108 expect(signed.status).toBeLessThan(400);
109 owner = cookieOf(signed);
110 expect(owner).toStartWith("forge_session=");
111 const me = await fetch(`${base}/settings/capacity`, { headers: { cookie: owner } });
112 expect(me.status).toBe(200);
113 });
114
115 test("the passkey session turns it back on from the page, and the edge is read again", async () => {
116 expect((await setEdge("on", { cookie: owner })).status).toBe(303);
117 expect(capacityValue(app.db, "signin.edge")).toBe("on");
118 expect(capacityChanges(app.db, 1)[0]).toMatchObject({ key: "signin.edge", from: "off", to: "on" });
119 expect((await who(asAda)).status).toBe(200);
120 });
121 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs