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

A setting turns open-platform.sh sign-in off, and the forge works without it #2602

Merged opened this

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

Merged

scout/forge:scout/2595-a-setting-turns-open-platform-sh-sign-in → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/2595-a-setting-turns-open-platform-sh-sign-in was merged into main as 72ec03b.

Review

Sign in to review this change.
13 files changed +161 −17
README.md +9 −1
@@ -330,7 +330,15 @@ adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks),
330330 `FORGE_ORIGIN_ALLOW` (hosts an operator vouches for on a LAN). Behind a
331331 platform edge that signs people in for every app it serves, `FORGE_EDGE_TOKEN`
332332 is the edge's secret: a request carrying it and `X-Plat-User` is that person,
333 signed in by the platform, with an account made on first sight. Mail out
333 signed in by the platform, with an account made on first sight. Setting
334 `signin.edge` to `off` (Settings → Capacity → "Sign in through the platform
335 edge", boot default `FORGE_SIGNIN_EDGE`, also `/api/v1/admin/capacity` and the
336 MCP `capacity` tool) makes the forge ignore `X-Plat-User` even when the secret
337 matches; people then sign in with a password or a passkey. An admin with
338 neither is refused, so no one locks themselves out. Accounts the edge made
339 cannot sign in while it is off until they add a passkey (a password-less
340 account cannot set a password: `/settings/password` asks for the current one).
341 Mail out
334342 (the link that proves a newcomer's email) is SMTP submission over TLS,
335343 configured by the forge-level secrets `MAIL_SMTP_URL`
336344 (`smtps://user:pass@host:465`) and `MAIL_FROM`, set live under Settings →
docs/THREATS.md +1 −1
@@ -132,7 +132,7 @@ How each part proves itself to the next, today.
132132 | runner → forge | the run's scoped token (`FORGE_TOKEN`) and a few-minute passport (`FORGE_PASSPORT`) | `test/passport-edge.test.ts` |
133133 | git hook → forge | per-boot secret in `x-forge-hook-secret` on the internal listener; a call without it is refused and counted | none yet |
134134 | cell → proxy | the proxy's Unix socket bound into the cell's own network namespace; no credential, the namespace is the identity | `test/contain.test.ts` |
135 | edge → pod | `X-Plat-Edge-Token` equal to `FORGE_EDGE_TOKEN`, compared as fixed-width digests; without it `X-Plat-User` is ignored | `test/edge-auth.test.ts` |
135 | edge → pod | `X-Plat-Edge-Token` equal to `FORGE_EDGE_TOKEN`, compared as fixed-width digests; without it `X-Plat-User` is ignored; the `signin.edge` setting (admin, refused for an admin with no password or passkey) turns the edge's identity off on every door | `test/edge-auth.test.ts` |
136136 | child → parent | none at call time: `parent.json` names the parent's address and the child pulls over public git; the claim link (sha256 kept, 24 hours, single use) hands the child to its person | `test/spawn-first-boot.test.ts` |
137137
138138 Gaps: edge → pod and runner → forge ride a shared secret, not a per-call
src/actions/capacity.ts +15 −1
@@ -21,6 +21,7 @@ import {
2121 type CapacityChange,
2222 type CapacityKey,
2323 } from "../core/capacity.ts";
24 import { passkeyIds } from "../core/passkeys.ts";
2425 import type { User } from "../core/users.ts";
2526 import { err, ok, type Result } from "../kernel/result.ts";
2627
@@ -61,6 +62,17 @@ export function listCapacity(
6162 });
6263 }
6364
65 /**
66 * Whether the account can sign in without the platform edge: a password or
67 * a passkey. Turning the edge off for an admin who has neither locks them out.
68 */
69 function hasOwnCredential(app: App, userId: string): boolean {
70 const row = app.db
71 .query<{ password_hash: string }, [string]>("SELECT password_hash FROM user WHERE id = ?1")
72 .get(userId);
73 return (row?.password_hash ?? "") !== "" || passkeyIds(app.db, userId).length > 0;
74 }
75
6476 /** Set `key` to `value`; `null` or `""` goes back to the boot default. */
6577 export function changeCapacity(
6678 app: App,
@@ -69,7 +81,7 @@ export function changeCapacity(
6981 value: string | null,
7082 ): Result<
7183 { key: CapacityKey; from: string; to: string },
72 "not_found" | "no_such_setting" | "invalid_value"
84 "not_found" | "no_such_setting" | "invalid_value" | "no_credential"
7385 > {
7486 const who = admin(actor);
7587 if (!who.ok) return who;
@@ -77,6 +89,8 @@ export function changeCapacity(
7789 const next = value === null ? null : value.trim();
7890 if (next !== null && next !== "" && !validCapacity(key, next))
7991 return err("invalid_value");
92 if (key === "signin.edge" && next === "off" && !hasOwnCredential(app, who.value.id))

Minor: this only checks next === "off". If the boot default (FORGE_SIGNIN_EDGE) is off, clearing the value to "" gets the same result and skips the guard. You could check the value that will actually be in force after the change. Not blocking, since only an operator can set that env var.

93 return err("no_credential");
8094 const changed = setCapacity(
8195 app.db,
8296 key,
src/core/capacity.ts +14 −1
@@ -21,7 +21,8 @@ export type CapacityKey =
2121 | "checks.shards"
2222 | "children.max"
2323 | "cells.egress"
24 | "guard_new_accounts";
24 | "guard_new_accounts"
25 | "signin.edge";
2526
2627 interface Definition {
2728 /** What the settings page and the API call it. */
@@ -84,6 +85,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
8485 fallback: "off",
8586 help: "on refuses repository creation and forks for accounts younger than 7 days (agents and admins are exempt); off leaves it to the usual permissions. Takes effect immediately. Replaces the hand-installed database trigger that used to do this with no toggle.",
8687 },
88 "signin.edge": {
89 label: "Sign in through the platform edge",
90 env: "FORGE_SIGNIN_EDGE",
91 kind: { type: "choice", values: ["on", "off"] },
92 fallback: "on",
93 help: "on reads the platform edge's identity headers (when the forge has an edge secret) and offers its sign-in; off ignores them on every door, so people sign in with the forge's own methods — password and passkey. Accounts the edge made keep their names but have no password until they set one. Takes effect on the next request.",

High: the help text says edge-made accounts "have no password until they set one", but nothing stops the admin who flips this from being one of them. They are signed out on the next request. The stored value then outranks FORGE_SIGNIN_EDGE, so a restart does not bring them back either. Guard the change in changeCapacity: refuse off when the actor has no password and no passkey, and test that refusal.

94 },
8795 };
8896
8997 export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[];
@@ -277,6 +285,11 @@ export function egressSetting(db: Database): "enforce" | "report" {
277285 return capacityValue(db, "cells.egress") === "report" ? "report" : "enforce";
278286 }
279287
288 /** Whether the platform edge's identity is read at all; on every request. */
289 export function edgeSigninOn(db: Database): boolean {
290 return capacityValue(db, "signin.edge") !== "off";
291 }
292
280293 /**
281294 * What a check's commands and a crew shell are told: the shard count in
282295 * force. An admin's value or the environment's is passed as it is; the
src/http/edge.ts +11
@@ -9,6 +9,17 @@
99 */
1010
1111 import { createHash, timingSafeEqual } from "node:crypto";
12 import type { App } from "../app.ts";
13 import { edgeSigninOn } from "../core/capacity.ts";
14
15 /**
16 * The edge secret in force: the configured one while the `signin.edge`
17 * setting is on, nothing while it is off — so every door, the reading of
18 * the headers and the buttons that lead to the edge, goes quiet together.
19 */
20 export function activeEdgeToken(app: App): string | undefined {

Good: one choke point for every door. Two small asks. Mention this in docs/THREATS.md's edge → pod row. And if you add an env override for recovery, this is the place to apply it.

21 return edgeSigninOn(app.db) ? app.config.edgeToken : undefined;
22 }
1223
1324 /** The account the edge vouches for on this request, if it does. */
1425 export function edgeIdentity(
src/http/mcp.ts +2 −2
@@ -2566,13 +2566,13 @@ const TOOLS: readonly Tool[] = [
25662566 {
25672567 name: "capacity",
25682568 description:
2569 "The forge's capacity and guards, live: crew runs at once (crew.concurrency), checks at once (checks.concurrency), test shards per check (checks.shards), whether a cell's undeclared egress is refused or reported (cells.egress), and whether new accounts may create repositories and forks (guard_new_accounts: on refuses accounts younger than 7 days, off is the default). Without `key`, each setting, its value and where it comes from, and the recent changes. With `key` and `value` (\"\" goes back to the boot default), the change it would make; with dryRun false, made, by a forge admin with an admin-scope token, never an agent. A change is honoured by the next run, check or cell, never by a restart. The same as Settings → Capacity and /api/v1/admin/capacity.",
2569 "The forge's capacity and guards, live: crew runs at once (crew.concurrency), checks at once (checks.concurrency), test shards per check (checks.shards), whether a cell's undeclared egress is refused or reported (cells.egress), and whether new accounts may create repositories and forks (guard_new_accounts: on refuses accounts younger than 7 days, off is the default), and whether the platform edge's sign-in is read (signin.edge: on is the default, off ignores the edge's identity on every door). Without `key`, each setting, its value and where it comes from, and the recent changes. With `key` and `value` (\"\" goes back to the boot default), the change it would make; with dryRun false, made, by a forge admin with an admin-scope token, never an agent. A change is honoured by the next run, check or cell, never by a restart. The same as Settings → Capacity and /api/v1/admin/capacity.",
25702570 inputSchema: {
25712571 type: "object",
25722572 properties: {
25732573 key: {
25742574 type: "string",
2575 enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts"],
2575 enum: ["crew.concurrency", "checks.concurrency", "checks.shards", "cells.egress", "guard_new_accounts", "signin.edge"],
25762576 },
25772577 value: { type: "string", maxLength: 16 },
25782578 dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." },
src/http/server.ts +2 −2
@@ -63,7 +63,7 @@ import {
6363 isActive,
6464 verifyPasswordThrottled,
6565 } from "../core/users.ts";
66 import { edgeIdentity } from "./edge.ts";
66 import { activeEdgeToken, edgeIdentity } from "./edge.ts";
6767 import { signupClosed } from "../actions/users.ts";
6868 import { liveResponse, readyResponse } from "./health.ts";
6969 import { running } from "../kernel/version.ts";
@@ -498,7 +498,7 @@ const NO_AUTH: Auth = { user: undefined, scope: undefined, via: "none" };
498498 async function resolveAuth(app: App, request: Request): Promise<Auth> {
499499 // The platform edge, when there is one, has already signed this person
500500 // in; its word outranks a cookie of ours.
501 const login = edgeIdentity(request, app.config.edgeToken);
501 const login = edgeIdentity(request, activeEdgeToken(app));
502502 if (login !== undefined) {
503503 const existed = getUserByName(app.db, login) !== undefined;
504504 // Existing accounts sign in; a new one is made only while signup allows.
src/web/chrome.ts +2 −2
@@ -13,7 +13,7 @@ import { mayDirectCrew } from "../crew/direct.ts";
1313 import { mayReadRuns } from "../actions/runs.ts";
1414 import { getAppByRepo } from "../core/apps.ts";
1515 import { audienceFacts, audienceOf } from "../core/audience.ts";
16 import { edgeLoginUrl } from "../http/edge.ts";
16 import { activeEdgeToken, edgeLoginUrl } from "../http/edge.ts";
1717 import {
1818 canAdmin,
1919 canRead,
@@ -94,7 +94,7 @@ export function signInHref(ctx: Ctx): string {
9494 const next = ctx.url.pathname + ctx.url.search;
9595 // Behind a platform edge the edge is how people sign in; an account it
9696 // made has no password here, so the password form would only refuse them.
97 if (ctx.app.config.edgeToken !== undefined) return edgeLoginUrl(next);
97 if (activeEdgeToken(ctx.app) !== undefined) return edgeLoginUrl(next);
9898 return `/signin?next=${encodeURIComponent(next)}`;
9999 }
100100
src/web/layout.ts +2 −2
@@ -13,7 +13,7 @@ import { appsForRepos } from "../core/apps.ts";
1313 import { listReposVisibleTo } from "../core/repos.ts";
1414 import type { User } from "../core/users.ts";
1515 import type { Ctx } from "../http/context.ts";
16 import { edgeLoginUrl, edgeLogoutUrl } from "../http/edge.ts";
16 import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl } from "../http/edge.ts";
1717 import { json } from "../http/respond.ts";
1818 import { sha256 } from "../kernel/hash.ts";
1919 import { secret } from "../kernel/id.ts";
@@ -485,7 +485,7 @@ function topbar(
485485 )}`
486486 : btn("Sign in", {
487487 href:
488 ctx.app.config.edgeToken === undefined
488 activeEdgeToken(ctx.app) === undefined
489489 ? "/signin"
490490 : edgeLoginUrl(ctx.url.pathname + ctx.url.search),
491491 })}
src/web/pages/capacity.ts +2
@@ -184,6 +184,8 @@ export function capacityPage(ctx: Ctx): Response {
184184 const ERRORS: Record<string, string> = {
185185 no_such_setting: "There is no such setting.",
186186 invalid_value: "That value is outside what this setting takes.",
187 no_credential:
188 "Add a passkey (or a password) to your own account first: with the edge off, you would be signed out for good.",
187189 };
188190
189191 // POST /settings/capacity
src/web/pages/site.ts +3 −3
@@ -25,7 +25,7 @@ import { isThemeName } from "../../design/themes.ts";
2525 import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts";
2626 import { studioDoor, studioHome } from "../studio/home.ts";
2727 import { homeTop } from "../studio/asks_page.ts";
28 import { edgeLoginUrl } from "../../http/edge.ts";
28 import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts";
2929 import { sshCommand } from "../../ssh/command.ts";
3030 import { hostOf, pageHead, signInFirst } from "../chrome.ts";
3131 import { SignupSlot, signupWindow } from "../../actions/signup-window.ts";
@@ -248,7 +248,7 @@ function authForm(
248248 // password form kept for the accounts that have one — an account the edge
249249 // made has none, and the note says so before anyone types.
250250 const edge =
251 ctx.app.config.edgeToken === undefined
251 activeEdgeToken(ctx.app) === undefined
252252 ? undefined
253253 : (ctx.app.config.edgeName ?? "the platform");
254254 const throughEdge = when(
@@ -503,7 +503,7 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> {
503503 values: { username },
504504 errors: {
505505 password:
506 ctx.app.config.edgeToken === undefined
506 activeEdgeToken(ctx.app) === undefined
507507 ? "That username and password don't match."
508508 : `That username and password don't match. An account made through ${ctx.app.config.edgeName ?? "the platform"} has no password here — use the button above.`,
509509 },
src/web/studio/home.ts +2 −2
@@ -22,7 +22,7 @@ import { LOOK_SIZES } from "../../core/looks.ts";
2222 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
2323 import { type User } from "../../core/users.ts";
2424 import type { Ctx } from "../../http/context.ts";
25 import { edgeLoginUrl } from "../../http/edge.ts";
25 import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts";
2626 import { sshCommand } from "../../ssh/command.ts";
2727 import { repoBase, signInFirst } from "../chrome.ts";
2828 import { field, readForm, type Form } from "../form.ts";
@@ -488,7 +488,7 @@ export function studioDoor(ctx: Ctx): Response {
488488 <div class="bar">
489489 ${ctx.auth.user !== undefined
490490 ? null
491 : ctx.app.config.edgeToken === undefined
491 : activeEdgeToken(ctx.app) === undefined
492492 ? html`${btn("Create an account", {
493493 href: `/signup${nextQuery}`,
494494 variant: "primary",
test/signin-edge.test.ts added +96
@@ -0,0 +1,96 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { changeCapacity } from "../src/actions/capacity.ts";
7 import { capacityChanges, capacityValue } from "../src/core/capacity.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import type { User } from "../src/core/users.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11
12 const TOKEN = "edge-secret-0123456789";
13
14 describe("signin.edge", () => {
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18 let base: string;
19 let olive: User;
20 const asAda = { "x-plat-user": "ada", "x-plat-edge-token": TOKEN };
21 const asOlive = { "x-plat-user": "olive", "x-plat-edge-token": TOKEN };
22
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-signin-edge-"));
25 app = createApp({ dataDir, port: 0, url: "https://forge.example", edgeToken: TOKEN });
26 servers = await serve(app);
27 base = servers.url;
28 const made = await seedAdmin(app, {
29 username: "olive",
30 email: "[email protected]",
31 password: "correct horse battery",
32 });
33 if (!made.ok) throw new Error(made.error);
34 olive = made.value;
35 });
36
37 afterAll(async () => {
38 servers.stop();
39 app.close();
40 await rm(dataDir, { recursive: true, force: true });
41 });
42
43 const who = (headers: Record<string, string>) =>
44 fetch(`${base}/api/v1/user`, { headers });
45
46 test("on by default; the edge makes an account and signs it in", async () => {
47 expect(capacityValue(app.db, "signin.edge")).toBe("on");
48 expect((await who(asAda)).status).toBe(200);
49 const page = await fetch(`${base}/`).then((r) => r.text());
50 expect(page).toContain("/_plat/login");
51 });
52
53 test("an admin with no password or passkey cannot turn it off", () => {
54 const ada = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'ada'").get();
55 if (ada === null) throw new Error("ada was not made");
56 const edgeAdmin: User = { ...olive, id: ada.id, username: "ada" };
57 const refused = changeCapacity(app, edgeAdmin, "signin.edge", "off");
58 expect(refused).toEqual({ ok: false, error: "no_credential" });
59 expect(capacityValue(app.db, "signin.edge")).toBe("on");
60 expect(changeCapacity(app, edgeAdmin, "signin.edge", "on").ok).toBe(true);
61 });
62
63 test("off: the edge's identity is ignored, even for an account it made", async () => {
64 const changed = changeCapacity(app, olive, "signin.edge", "off");
65 expect(changed.ok).toBe(true);
66 expect(capacityChanges(app.db, 1)[0]).toMatchObject({ key: "signin.edge", from: "on", to: "off" });
67
68 expect((await who(asAda)).status).toBe(401);
69 expect((await who(asOlive)).status).toBe(401);
70 const page = await fetch(`${base}/`, { headers: asAda }).then((r) => r.text());
71 expect(page).not.toContain("Sign out");
72 expect(page).not.toContain("/_plat/login");
73 expect(page).toContain('href="/signin"');
74 const gate = await fetch(`${base}/new`, { redirect: "manual", headers: asAda });
75 expect(gate.headers.get("location") ?? "").not.toContain("/_plat/");
76 });
77
78 test("off: password and passkey sign-in still work", async () => {
79 const token = await fetch(`${base}/api/v1/tokens`, {
80 method: "POST",
81 body: JSON.stringify({ adminConfirm: true, username: "olive", password: "correct horse battery", name: "t", scope: "rw" }),
82 });
83 expect(token.status).toBe(201);
84 const options = await fetch(`${base}/signin/passkey/options`, {
85 method: "POST",
86 headers: { origin: "https://forge.example", "content-type": "application/json" },
87 body: "{}",
88 });
89 expect(options.status).toBe(200);
90 });
91
92 test("back on: the edge is read again", async () => {
93 expect(changeCapacity(app, olive, "signin.edge", "on").ok).toBe(true);
94 expect((await who(asAda)).status).toBe(200);
95 });
96 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs