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

The forge signs people in itself; the edge's Forgejo sign-in becomes optional #9533

Merged opened this in answer to

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

Merged

mason/forge:mason/9432-the-forge-signs-people-in-itself-the-edg → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/9432-the-forge-signs-people-in-itself-the-edg was merged into main as 42b7bfb.

Review

Sign in to review this change.
12 files changed +391 −29
docs/OPERATING.md +9
@@ -48,6 +48,15 @@ token (`POST /api/v1/tokens` with the password still mints one while it is
4848 off). A stored value outranks `FORGE_SIGNIN_EDGE`, so changing the environment
4949 alone does not roll back. Switching production off is the owner's to do, with
5050 the owner present. `test/signin-edge-off.test.ts` walks this round trip.
51 **Dry run first.** Settings → Capacity has a "Dry run" button on that row (and
52 `PUT /api/v1/admin/capacity/signin.edge` with `{"dry_run":true}`): it changes
53 nothing, says how many accounts sign in without the edge, names each one that
54 would be locked out, and says so when that includes you, in which case the switch
55 is refused until you add a passkey. Settings → Accounts shows the same count.
56 With the edge off, `/_plat/login` is not linked from the sign-in page and
57 `X-Plat-User` is ignored; `--first-admin` is untouched, so the operator's link
58 still works as a break-glass. Locked out: see `docs/SECURITY-RUNBOOK.md`,
59 "Locked out after the edge went off".
5160 Account settings show "open-platform.sh: linked as <login>" or "not linked": Link
5261 records the login the request carries against the signed-in account, Unlink removes
5362 it (same-origin POSTs). An edge sign-in finds the account by its link first, then by
docs/SECURITY-RUNBOOK.md +22
@@ -144,6 +144,28 @@ Use this when the forge is too busy to answer, or the Shield isn't enough. Cloud
144144
145145 Under Attack mode also breaks anything that isn't a browser: `git` over HTTPS, API tokens, curl, and webhooks all stop working through Cloudflare. **Undo:** turn it off in the same place, as soon as the traffic has passed.
146146
147 ## Switching the forge's own sign-in on, and the edge off
148
149 The forge signs people in itself (password, passkey, recovery link); the platform
150 edge's sign-in (`signin.edge`) is optional.
151
152 - **Switch:** each person adds a passkey at Settings → Passkeys (a browser the
153 edge signed in can; the edge login is linked to the account, no new account).
154 Settings → Accounts shows how many accounts sign in without the edge. Then
155 Settings → Capacity → "Sign in through the platform edge" → **Dry run**: it
156 lists who would be locked out and refuses while you would be. Set it to `off`
157 once the list is empty or accepted.
158 - **Undo:** set the same row to `on` (Put back), or
159 `curl -X PUT -H "authorization: Bearer $TOKEN" -d '{"value":"on"}' $FORGE_URL/api/v1/admin/capacity/signin.edge`.
160 A stored value outranks `FORGE_SIGNIN_EDGE`.
161 - **Locked out (everyone, edge off):** sign in with a password or passkey; if
162 you have neither, use the recovery link (`/recover`) if mail is set up, else
163 run the forge with `--first-admin` for a one-time link; it works whatever
164 `signin.edge` says. Then turn the edge back on from Settings → Capacity.
165 - **Locked out (one person):** an admin tells them to use `/recover`, or
166 puts the edge back on (Settings → Capacity) while the person adds a passkey,
167 then runs the dry run again.
168
147169 ## After any of these
148170
149171 Write down what happened, when and what you changed, in an issue on the forge once it's calm. Then check Settings → Security again. Each section shows when it was last checked.
docs/features/edge-only-accounts.md +4
@@ -11,3 +11,7 @@ An account the platform edge made (or one linked to an edge login) with no passw
1111 1. Wait until the card and the terminal screen are empty.
1212 2. Turn `signin.edge` off on a preview first, with the owner present.
1313 3. Roll back by setting it on again in Settings → Capacity.
14
15 ## Dry run
16
17 Settings → Capacity → "Dry run" on the `signin.edge` row (or `dry_run: true` on the capacity PUT) lists who would be locked out and refuses while the admin themselves would be. Settings → Accounts shows how many accounts sign in without the edge. A passkey added from a browser the edge signed in links that login to the account (`edge_link`).
src/actions/capacity.ts +23 −9
@@ -25,7 +25,7 @@ import {
2525 import { egressTotals, npmBytesLastHour, npmTopRepoLastHour, type EgressTotal } from "../apps/egress.ts";
2626 import { getRepoById } from "../core/repos.ts";
2727 import { selfSwapState, type SelfSwap } from "../release/swap.ts";
28 import { passkeyIds } from "../core/passkeys.ts";
28 import { signinReach, signsInWithoutEdge } from "../core/accounts.ts";
2929 import type { User } from "../core/users.ts";
3030 import { err, ok, type Result } from "../kernel/result.ts";
3131
@@ -125,14 +125,28 @@ export function listCapacity(
125125 }
126126
127127 /**
128 * Whether the account can sign in without the platform edge: a password or
129 * a passkey. Turning the edge off for an admin who has neither locks them out.
128 * A dry run of switching `signin.edge` off: who could no longer sign in, and
129 * whether that includes the admin asking (which refuses the switch). Changes
130 * nothing.
130131 */
131 function hasOwnCredential(app: App, userId: string): boolean {
132 const row = app.db
133 .query<{ password_hash: string }, [string]>("SELECT password_hash FROM user WHERE id = ?1")
134 .get(userId);
135 return (row?.password_hash ?? "") !== "" || passkeyIds(app.db, userId).length > 0;
132 export function edgeOffDryRun(
133 db: Database,
134 actor: User | undefined,
135 ): Result<
136 { total: number; own: number; lockedOut: { username: string; isAdmin: boolean }[]; youLockedOut: boolean; allowed: boolean },
137 "not_found"
138 > {
139 const who = admin(actor);
140 if (!who.ok) return who;
141 const reach = signinReach(db);
142 const youLockedOut = !signsInWithoutEdge(db, who.value.id);
143 return ok({
144 total: reach.total,
145 own: reach.own,
146 lockedOut: reach.stranded.map((a) => ({ username: a.username, isAdmin: a.isAdmin })),
147 youLockedOut,
148 allowed: !youLockedOut,
149 });
136150 }
137151
138152 /** Set `key` to `value`; `null` or `""` goes back to the boot default. */
@@ -151,7 +165,7 @@ export function changeCapacity(
151165 const next = value === null ? null : value.trim();
152166 if (next !== null && next !== "" && !validCapacity(key, next))
153167 return err("invalid_value");
154 if (key === "signin.edge" && next === "off" && !hasOwnCredential(app, who.value.id))
168 if (key === "signin.edge" && next === "off" && !signsInWithoutEdge(app.db, who.value.id))
155169 return err("no_credential");
156170 const changed = setCapacity(
157171 app.db,
src/core/accounts.ts +43 −3
@@ -191,21 +191,61 @@ export function scrubAccount(db: Database, userId: string, at: string): void {
191191 */
192192 export function edgeOnlyAccounts(
193193 db: Database,
194 ): { username: string; isAdmin: boolean; createdAt: string }[] {
195 return withoutOwnCredential(db, true);
196 }
197
198 /**
199 * Active people with no password, no passkey and no parent link. With
200 * `edgeMade` only those the edge made or linked; without, everyone the
201 * edge alone could sign in, whatever made the account.
202 */
203 function withoutOwnCredential(
204 db: Database,
205 edgeMade: boolean,
206 userId = "",
194207 ): { username: string; isAdmin: boolean; createdAt: string }[] {
195208 return db
196 .query<{ username: string; is_admin: number; created_at: string }, [string]>(
209 .query<{ username: string; is_admin: number; created_at: string }, [string, number, string]>(
197210 `SELECT u.username, u.is_admin, u.created_at FROM user u
198211 WHERE u.kind = 'user' AND u.is_agent = 0 AND u.removed_at IS NULL
212 AND (?3 = '' OR u.id = ?3)
199213 AND u.password_hash = ''
200214 AND NOT EXISTS (SELECT 1 FROM passkey p WHERE p.user_id = u.id)
201215 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)
216 AND (?2 = 0 OR EXISTS (SELECT 1 FROM edge_link l WHERE l.user_id = u.id)
203217 OR EXISTS (SELECT 1 FROM event e WHERE e.kind = 'user.created'
204218 AND e.actor_id = u.id
205219 AND CASE WHEN json_valid(e.payload)
206220 THEN json_extract(e.payload, '$.via') END = ?1))
207221 ORDER BY u.is_admin DESC, u.created_at, u.id`,
208222 )
209 .all(EDGE_VIA)
223 .all(EDGE_VIA, edgeMade ? 1 : 0, userId)
210224 .map((r) => ({ username: r.username, isAdmin: r.is_admin === 1, createdAt: r.created_at }));
211225 }
226
227 /**
228 * How far the forge's own sign-in reaches: the people who can sign in
229 * without the platform edge and the ones who cannot (everyone `withoutOwnCredential` names).
230 */
231 export function signinReach(db: Database): {
232 total: number;
233 own: number;
234 stranded: { username: string; isAdmin: boolean; createdAt: string }[];
235 } {
236 const total = db
237 .query<{ n: number }, []>(
238 "SELECT COUNT(*) AS n FROM user WHERE kind = 'user' AND is_agent = 0 AND removed_at IS NULL",
239 )
240 .get()?.n ?? 0;
241 const stranded = withoutOwnCredential(db, false);
242 return { total, own: Math.max(0, total - stranded.length), stranded };
243 }
244
245 /**
246 * Whether this person can sign in without the platform edge. The one
247 * predicate behind the reach count, the lists and the admin's own refusal.
248 */
249 export function signsInWithoutEdge(db: Database, userId: string): boolean {
250 return withoutOwnCredential(db, false, userId).length === 0;
251 }
src/http/api/admin.ts +7 −1
@@ -11,7 +11,7 @@ import { kpiProbe } from "../../web/studio/kpi_probe.ts";
1111 import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts";
1212 import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts";
1313 import { ADMIN_WORDS } from "../../actions/admin_words.ts";
14 import { changeCapacity, listCapacity } from "../../actions/capacity.ts";
14 import { changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts";
1515 import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts";
1616 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
1717 import { changeLimit, listLimits } from "../../actions/limits.ts";
@@ -161,6 +161,12 @@ export function adminRoutes(router: Router<Handler>): void {
161161 const denied = accountAdmin(ctx, "admin");
162162 if (denied) return denied;
163163 const input = await body(ctx.request);
164 // A dry run changes nothing, whatever the key: only signin.edge has one.
165 if (input.dry_run !== undefined && input.dry_run !== false) {
166 if (input.dry_run !== true || ctx.params.key !== "signin.edge") return notFound();
outdated

A dry run for any key other than signin.edge falls through to changeCapacity and writes. Handle dry_run first and never fall through to a write.

167 const dry = edgeOffDryRun(ctx.app.db, ctx.auth.user);
168 return dry.ok ? json({ dry_run: true, ...dry.value }) : notFound();
169 }
164170 const value = input.value === null ? null : str(input.value);
165171 const changed = changeCapacity(ctx.app, ctx.auth.user, ctx.params.key!, value);
166172 if (changed.ok) return json(changed.value);
src/tui/screens/edgeonly.ts +14 −9
@@ -1,11 +1,12 @@
11 /**
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.
2 * Edge-only accounts in the terminal, a forge admin's: the same dry run the
3 * Capacity page and the API run (`edgeOffDryRun`): how many sign in without
4 * the platform edge, who would be locked out if `signin.edge` went off, and
5 * whether that includes the admin looking. Names the people the Accounts card
6 * names, from the same `signinReach`. Changes nothing.
67 */
78
8 import { edgeOnlyAccounts } from "../../core/accounts.ts";
9 import { edgeOffDryRun } from "../../actions/capacity.ts";
910 import type { Ctx, Screen } from "../app/app.ts";
1011 import { binding } from "../app/keys.ts";
1112 import { fill, len, split } from "../ui/layout.ts";
@@ -20,7 +21,11 @@ const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind
2021
2122 export function edgeOnlyScreen(f: Session, ctx: Ctx): Screen {
2223 const state = new ListState();
23 const accounts = isAdmin(f) ? edgeOnlyAccounts(f.db) : [];
24 const dry = edgeOffDryRun(f.db, f.user);
25 const accounts = dry.ok ? dry.value.lockedOut : [];
26 const summary = dry.ok
27 ? `${dry.value.own} of ${dry.value.total} can sign in without the edge${dry.value.youLockedOut ? "; you would be locked out too, so the switch is refused" : ""}`
28 : "";
2429 return {
2530 title: "Edge-only accounts",
2631 keys: [binding(["j", "k"], "move cursor")],
@@ -28,14 +33,14 @@ export function edgeOnlyScreen(f: Session, ctx: Ctx): Screen {
2833 const c = ctx.theme.c;
2934 const [head, body] = split(area, "v", [len(1), fill(1)]);
3035 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." });
36 emptyState(s, body!, c, { title: "Nobody would be locked out.", body: summary });
3237 return;
3338 }
34 const line = "sign in only through the edge; ask each to add a passkey";
39 const line = `${summary}; ask each to add a passkey`;
3540 s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
3641 const rows: ListRow[] = accounts.map((a) => ({
3742 title: a.username,
38 secondary: a.createdAt.slice(0, 10),
43 secondary: "",
3944 meta: a.isAdmin ? "admin" : "",
4045 }));
4146 drawList(s, body!, c, state, rows);
src/web/pages/accounts.ts +8 −6
@@ -20,7 +20,7 @@ import {
2020 revokeAccountToken,
2121 } from "../../actions/signins.ts";
2222 import { mailConfigured } from "../../actions/verify.ts";
23 import { edgeOnlyAccounts, originOf, type AccountRow } from "../../core/accounts.ts";
23 import { originOf, signinReach, type AccountRow } from "../../core/accounts.ts";
2424 import {
2525 OWNERS_TEAM,
2626 ownerlessOrgs,
@@ -181,14 +181,16 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response {
181181 </p>`,
182182 { tone: "attn" },
183183 );
184 const stranded = timePhase(ctx.request, "edge-only", () =>
185 edgeOnlyAccounts(ctx.app.db),
186 );
184 const reach = timePhase(ctx.request, "edge-only", () => signinReach(ctx.app.db));
185 const stranded = reach.stranded;
187186 const edgeOnly =
188187 stranded.length === 0
189 ? null
188 ? card(
189 html`<p id="signin-reach">All ${reach.total} accounts can sign in without the platform edge.</p>`,
190 )
190191 : card(
191 html`<p>
192 html`<p id="signin-reach">${reach.own} of ${reach.total} accounts can sign in without the platform edge.</p>
193 <p>
192194 ${stranded.length === 1 ? "This account signs" : "These accounts sign"}
193195 in only through the platform edge, so switching
194196 <code>signin.edge</code> off would lock
src/web/pages/capacity.ts +17 −1
@@ -6,7 +6,7 @@
66 * and each one is listed below the settings: who, when, from what to what.
77 */
88
9 import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
9 import { changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
1010 import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts";
1111 import { bytes } from "../../system/format.ts";
1212 import { egressSnapshot } from "../../apps/egress.ts";
@@ -111,6 +111,10 @@ export function capacityPage(ctx: Ctx): Response {
111111 variant: "primary",
112112 label: `Save ${s.label}`,
113113 })}
114 ${when(
115 s.key === "signin.edge",
116 () => html`${btn("Dry run", { size: "sm", name: "dry", value: "1", label: "List who switching the edge off would lock out" })}`,
117 )}
114118 ${when(
115119 s.source === "setting",
116120 () =>
@@ -278,6 +282,18 @@ export async function capacitySet(ctx: Ctx): Promise<Response> {
278282 const form = await readForm(ctx, "admin");
279283 if (form instanceof Response) return form;
280284 const key = field(form, "key");
285 if (field(form, "dry") === "1") {

No test covers this dry=1 path (flash text and tone). Also dry run reads youLockedOut from a different predicate than the list, so they can disagree.

286 const dry = edgeOffDryRun(ctx.app.db, ctx.auth.user);
287 if (!dry.ok) return notFoundPage(ctx);
288 const d = dry.value;
289 const names = d.lockedOut.map((a) => a.username + (a.isAdmin ? " (admin)" : "")).join(", ");
290 return flashTo(ctx, "/settings/capacity", {
291 tone: d.lockedOut.length === 0 && d.allowed ? "success" : "danger",
292 message: `${d.own} of ${d.total} accounts sign in without the edge. ${
293 d.lockedOut.length === 0 ? "Nobody would be locked out." : `Would be locked out: ${names}.`
294 }${d.youLockedOut ? " You would be too, so the switch is refused until you add a passkey." : ""}`,
295 });
296 }
281297 const reset = field(form, "reset") === "1";
282298 const changed = changeCapacity(
283299 ctx.app,
src/web/pages/passkeys.ts +15
@@ -17,6 +17,8 @@ import {
1717 passkeyRoom,
1818 PASSKEY_NAME_MAX,
1919 } from "../../core/passkeys.ts";
20 import { linkEdgeLogin } from "../../core/edge_links.ts";
21 import { activeEdgeToken, edgeIdentity } from "../../http/edge.ts";
2022 import { RateLimiter } from "../../core/ratelimit.ts";
2123 import { createSession } from "../../core/sessions.ts";
2224 import { LAST_WAY_IN, removeOwnPasskey } from "../../actions/passkeys.ts";
@@ -76,6 +78,11 @@ export function passkeysPage(ctx: Ctx): Response {
7678 A passkey signs you in with a fingerprint, face or device PIN instead of
7779 a password. Add one per device; each can be removed on its own.
7880 </p>
81 <p class="muted small mb" id="passkey-why">
82 Why: the forge signs you in itself, so your account does not depend on
83 another service being up or trusted. With a passkey, you can sign in here
84 even if the platform sign-in is switched off.
85 </p>
7986 ${keys.length === 0
8087 ? empty({ icon: "key", title: "No passkeys yet", note: "Your password still works; add a passkey now, as every new account starts with one." })
8188 : card(
@@ -152,6 +159,14 @@ export async function passkeyAdd(ctx: Ctx): Promise<Response> {
152159 409,
153160 added.error === "too_many" ? "That is as many passkeys as an account keeps." : PASSKEY_TAKEN,
154161 );
162 // A browser the edge signed in carries the login it vouched for: link it to
163 // this account, so the edge still reaches it by that login and no second
164 // account is made. A refused link (taken, malformed) leaves the passkey added.
165 if (ctx.auth.via === "edge") {
166 const login = edgeIdentity(ctx.request, activeEdgeToken(ctx.app));
167 if (login !== undefined)
168 linkEdgeLogin(ctx.app.db, ctx.auth.user!, login);
169 }
155170 return json({ ok: true });
156171 }
157172
test/signin-edge-reach.test.ts added +198
@@ -0,0 +1,198 @@
1 /**
2 * The forge's own sign-in reach (#9432): the admin sees who signs in without
3 * the edge, a dry run names who switching it off would lock out (and refuses
4 * the admin themselves), and a passkey added in a browser the edge signed in
5 * links the edge login to that account without making another.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { edgeOffDryRun } from "../src/actions/capacity.ts";
13 import { signinReach, signsInWithoutEdge } from "../src/core/accounts.ts";
14 import { capacityValue } from "../src/core/capacity.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { entry, edgeOnlyScreen } from "../src/tui/screens/edgeonly.ts";
17 import { linkedLogin } from "../src/core/edge_links.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { seedAdmin } from "./helpers/admin.ts";
21 import { device } from "./helpers/passkey.ts";
22
23 const TOKEN = "edge-secret-0123456789";
24
25 describe("signin reach", () => {
26 let dataDir: string;
27 let app: App;
28 let servers: Servers;
29 let base: string;
30 const asAda = { "x-plat-user": "ada", "x-plat-edge-token": TOKEN };
31 const post = (path: string, body: unknown, headers: Record<string, string>) =>
32 fetch(`${base}${path}`, {
33 method: "POST",
34 headers: { "content-type": "application/json", origin: base, ...headers },
35 body: JSON.stringify(body),
36 });
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-reach-"));
40 app = createApp({ dataDir, port: 0, edgeToken: TOKEN });
41 servers = await serve(app);
42 base = servers.url;
43 for (const [username, password] of [["olive", "correct horse battery"], ["ada", undefined]] as const) {
44 const made = await seedAdmin(app, { username, email: `${username}@forge.example`, ...(password ? { password } : {}) });
45 if (!made.ok) throw new Error(made.error);
46 }
47 });
48 afterAll(async () => {
49 servers.stop();
50 app.close();
51 await rm(dataDir, { recursive: true, force: true });
52 });
53
54 test("counts who signs in without the edge, and names who cannot", () => {
55 const reach = signinReach(app.db);
56 expect(reach.total).toBe(2);
57 expect(reach.own).toBe(1);
58 expect(reach.stranded.map((a) => a.username)).toEqual(["ada"]);
59 });
60
61 test("the dry run lists the lockout, refuses an admin with no way in, and changes nothing", async () => {
62 const ada = getUserByName(app.db, "ada")!;
63 const dry = edgeOffDryRun(app.db, ada);
64 expect(dry.ok && dry.value.youLockedOut).toBe(true);
65 expect(dry.ok && dry.value.lockedOut.map((a) => a.username)).toEqual(["ada"]);
66 const over = await fetch(`${base}/api/v1/admin/capacity/signin.edge`, {
67 method: "PUT",
68 headers: { "content-type": "application/json", origin: base, ...asAda },
69 body: JSON.stringify({ value: "off", dry_run: true }),
70 });
71 expect(over.status).toBe(200);
72 expect(await over.json()).toMatchObject({ dry_run: true, allowed: false, own: 1, total: 2 });
73 expect(edgeOffDryRun(app.db, getUserByName(app.db, "olive")!)).toMatchObject({ ok: true, value: { allowed: true } });
74 const stranger = edgeOffDryRun(app.db, undefined);
75 expect(stranger.ok).toBe(false);
76 });
77
78 test("the accounts page says how many sign in without the edge", async () => {
79 const page = await (await fetch(`${base}/settings/accounts`, { headers: asAda })).text();
80 expect(page).toContain("1 of 2 accounts can sign in without the platform edge");
81 });
82
83 test("a passkey added through the edge links the login, makes no account, and covers the account", async () => {
84 const page = await (await fetch(`${base}/settings/passkeys`, { headers: asAda })).text();
85 expect(page).toContain('id="passkey-why"');
86 const key = await device(new URL(base).origin);
87 const opts = (await (await post("/settings/passkeys/options", {}, asAda)).json()) as { challenge: string };
88 const added = await post("/settings/passkeys", { name: "laptop", challenge: opts.challenge, ...(await key.create(opts.challenge)) }, asAda);
89 expect(added.status).toBe(200);
90 const ada = getUserByName(app.db, "ada")!;
91 expect(linkedLogin(app.db, ada.id)).toBe("ada");
92 expect(signinReach(app.db)).toMatchObject({ total: 2, own: 2, stranded: [] });
93 });
94 });
95
96 describe("the dry run changes nothing, on every door", () => {
97 let dataDir: string;
98 let app: App;
99 let servers: Servers;
100 let base: string;
101 let adaCookie: string;
102 let bobCookie: string;
103
104 beforeAll(async () => {
105 dataDir = await mkdtemp(join(tmpdir(), "forge-dry-"));
106 app = createApp({ dataDir, port: 0 });
107 servers = await serve(app);
108 base = servers.url;
109 const ada = await seedAdmin(app, { username: "ada", email: "[email protected]" });
110 if (!ada.ok) throw new Error(ada.error);
111 const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bobby", email: "[email protected]", password: "correct horse battery" });
112 if (!bob.ok) throw new Error(bob.error);
113 adaCookie = `forge_session=${createSession(app.db, getUserByName(app.db, "ada")!.id).token}`;
114 bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`;
115 });
116 afterAll(async () => {
117 servers.stop();
118 app.close();
119 await rm(dataDir, { recursive: true, force: true });
120 });
121
122 const put = (key: string, body: unknown, cookie: string) =>
123 fetch(`${base}/api/v1/admin/capacity/${key}`, {
124 method: "PUT",
125 headers: { "content-type": "application/json", origin: base, cookie },
126 body: JSON.stringify(body),
127 });
128
129 test("dry_run on a key with no dry run is refused and writes nothing", async () => {
130 const key = "signin.edge";
131 const other = (await (await fetch(`${base}/api/v1/admin/capacity`, { headers: { cookie: adaCookie } })).json() as { items: { key: string }[] }).items.find((i) => i.key !== key)!.key;
132 const before = capacityValue(app.db, other as never);
133 const res = await put(other, { value: "1", dry_run: true }, adaCookie);
134 expect(res.status).toBe(404);
135 expect(capacityValue(app.db, other as never)).toBe(before);
136 const after = await fetch(`${base}/api/v1/admin/capacity`, { headers: { cookie: adaCookie } });
137 expect(JSON.stringify(await after.json())).not.toContain('"changed"');
138 });
139
140 test("a non-admin is refused the dry run", async () => {
141 const res = await put("signin.edge", { dry_run: true }, bobCookie);
142 expect(res.status).toBeGreaterThanOrEqual(400);
143 expect(res.status).toBeLessThan(500);
144 expect(await res.text()).not.toContain("locked");
145 });
146
147 test("the Capacity page's Dry run button flashes the verdict, and a locked-out admin cannot then switch the edge off", async () => {
148 const form = (extra: Record<string, string>) =>
149 fetch(`${base}/settings/capacity`, {
150 method: "POST",
151 headers: { origin: base, cookie: adaCookie },
152 body: new URLSearchParams({ key: "signin.edge", ...extra }),
153 redirect: "manual",
154 });
155 const dry = await form({ dry: "1", value: "off" });
156 expect(dry.status).toBe(303);
157 const seen = await fetch(new URL(dry.headers.get("location")!, base), {
158 headers: { cookie: `${adaCookie}; ${(dry.headers.get("set-cookie") ?? "").split(";")[0]}` },
159 });
160 const page = await seen.text();
161 expect(page).toContain("accounts sign in without the edge");
162 expect(page).toContain("ada (admin)");
163 expect(page).toContain("You would be too");
164 expect(page).toMatch(/danger/);
165 expect(capacityValue(app.db, "signin.edge")).not.toBe("off");
166 const refused = await form({ value: "off" });
167 expect(refused.status).not.toBe(200);
168 expect(capacityValue(app.db, "signin.edge")).not.toBe("off");
169 });
170
171 test("the refusal and the lists agree: a federated identity or a password counts as a way in", () => {
172 const ada = getUserByName(app.db, "ada")!;
173 expect(signsInWithoutEdge(app.db, ada.id)).toBe(false);
174 app.db.run("UPDATE user SET password_hash = 'x' WHERE id = ?1", [ada.id]);
175 expect(signsInWithoutEdge(app.db, ada.id)).toBe(true);
176 expect(signinReach(app.db).stranded.map((a) => a.username)).not.toContain("ada");
177 const dry = edgeOffDryRun(app.db, getUserByName(app.db, "ada")!);
178 expect(dry.ok && dry.value.youLockedOut).toBe(false);
179 });
180
181 test("the terminal screen gates admin and guest like the page, and names who the page names", () => {
182 app.db.run("UPDATE user SET password_hash = '' WHERE username = 'ada'");
183 const ada = getUserByName(app.db, "ada")!;
184 const bob = getUserByName(app.db, "bobby")!;
185 const session = (user: unknown) => ({ user, db: app.db }) as never;
186 expect(entry.offered(session(ada))).toBe(true);
187 expect(entry.offered(session(bob))).toBe(false);
188 expect(entry.offered(session(undefined))).toBe(false);
189 const ctx = { theme: { c: {} } } as never;
190 const screen = edgeOnlyScreen(session(ada), ctx);
191 expect(screen.title).toBe("Edge-only accounts");
192 const names = signinReach(app.db).stranded.map((a) => a.username);
193 const dry = edgeOffDryRun(app.db, ada);
194 expect(dry.ok && dry.value.lockedOut.map((a) => a.username)).toEqual(names);
195 expect(edgeOffDryRun(app.db, bob).ok).toBe(false);
196 expect(edgeOffDryRun(app.db, undefined).ok).toBe(false);
197 });
198 });
test/signin-window-limits.test.ts added +31
@@ -0,0 +1,31 @@
1 /** The sign-in windows hold at their defaults: twenty per account, sixty per source (#9432). */
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 { admitSignin } from "../src/core/signin-window.ts";
8
9 let dataDir = "";
10 let app: App;
11 beforeAll(async () => {
12 dataDir = await mkdtemp(join(tmpdir(), "forge-window-"));
13 app = createApp({ dataDir, port: 0 });
14 });
15 afterAll(async () => {
16 app.close();
17 await rm(dataDir, { recursive: true, force: true });
18 });
19
20 test("one account is refused after twenty attempts from one address; another address has its own window", () => {
21 for (let i = 0; i < 20; i++) expect(admitSignin(app.db, "carol", "203.0.113.1").admitted).toBe(true);
22 expect(admitSignin(app.db, "carol", "203.0.113.1").admitted).toBe(false);
23 expect(admitSignin(app.db, "carol", "203.0.113.9").admitted).toBe(true);
24 });
25
26 test("one address is refused after sixty attempts across accounts", () => {
27 for (let i = 0; i < 60; i++) expect(admitSignin(app.db, `user${i}`, "198.51.100.7").admitted).toBe(true);
28 const shut = admitSignin(app.db, "user-new", "198.51.100.7");
29 expect(shut.admitted).toBe(false);
30 expect(!shut.admitted && shut.retryAfter).toBeGreaterThan(0);
31 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs