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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2595-a-setting-turns-open-platform-sh-sign-in → main 3 commits fork 3 threads · 3 unresolved
README.md +9 −1
| @@ -330,7 +330,15 @@ adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks), | ||
| 330 | 330 | `FORGE_ORIGIN_ALLOW` (hosts an operator vouches for on a LAN). Behind a |
| 331 | 331 | platform edge that signs people in for every app it serves, `FORGE_EDGE_TOKEN` |
| 332 | 332 | 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 | |
| 334 | 342 | (the link that proves a newcomer's email) is SMTP submission over TLS, |
| 335 | 343 | configured by the forge-level secrets `MAIL_SMTP_URL` |
| 336 | 344 | (`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. | ||
| 132 | 132 | | runner → forge | the run's scoped token (`FORGE_TOKEN`) and a few-minute passport (`FORGE_PASSPORT`) | `test/passport-edge.test.ts` | |
| 133 | 133 | | 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 | |
| 134 | 134 | | 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` | | |
| 136 | 136 | | 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` | |
| 137 | 137 | |
| 138 | 138 | 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 { | ||
| 21 | 21 | type CapacityChange, |
| 22 | 22 | type CapacityKey, |
| 23 | 23 | } from "../core/capacity.ts"; |
| 24 | import { passkeyIds } from "../core/passkeys.ts"; | |
| 24 | 25 | import type { User } from "../core/users.ts"; |
| 25 | 26 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 26 | 27 | |
| @@ -61,6 +62,17 @@ export function listCapacity( | ||
| 61 | 62 | }); |
| 62 | 63 | } |
| 63 | 64 | |
| 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 | ||
| 64 | 76 | /** Set `key` to `value`; `null` or `""` goes back to the boot default. */ |
| 65 | 77 | export function changeCapacity( |
| 66 | 78 | app: App, |
| @@ -69,7 +81,7 @@ export function changeCapacity( | ||
| 69 | 81 | value: string | null, |
| 70 | 82 | ): Result< |
| 71 | 83 | { key: CapacityKey; from: string; to: string }, |
| 72 | "not_found" | "no_such_setting" | "invalid_value" | |
| 84 | "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | |
| 73 | 85 | > { |
| 74 | 86 | const who = admin(actor); |
| 75 | 87 | if (!who.ok) return who; |
| @@ -77,6 +89,8 @@ export function changeCapacity( | ||
| 77 | 89 | const next = value === null ? null : value.trim(); |
| 78 | 90 | if (next !== null && next !== "" && !validCapacity(key, next)) |
| 79 | 91 | return err("invalid_value"); |
| 92 | if (key === "signin.edge" && next === "off" && !hasOwnCredential(app, who.value.id)) | |
| 93 | return err("no_credential"); | |
| 80 | 94 | const changed = setCapacity( |
| 81 | 95 | app.db, |
| 82 | 96 | key, |
src/core/capacity.ts +14 −1
| @@ -21,7 +21,8 @@ export type CapacityKey = | ||
| 21 | 21 | | "checks.shards" |
| 22 | 22 | | "children.max" |
| 23 | 23 | | "cells.egress" |
| 24 | | "guard_new_accounts"; | |
| 24 | | "guard_new_accounts" | |
| 25 | | "signin.edge"; | |
| 25 | 26 | |
| 26 | 27 | interface Definition { |
| 27 | 28 | /** What the settings page and the API call it. */ |
| @@ -84,6 +85,13 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 84 | 85 | fallback: "off", |
| 85 | 86 | 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.", |
| 86 | 87 | }, |
| 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 |
||
| 94 | }, | |
| 87 | 95 | }; |
| 88 | 96 | |
| 89 | 97 | export const CAPACITY_KEYS = Object.keys(CAPACITY) as CapacityKey[]; |
| @@ -277,6 +285,11 @@ export function egressSetting(db: Database): "enforce" | "report" { | ||
| 277 | 285 | return capacityValue(db, "cells.egress") === "report" ? "report" : "enforce"; |
| 278 | 286 | } |
| 279 | 287 | |
| 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 | ||
| 280 | 293 | /** |
| 281 | 294 | * What a check's commands and a crew shell are told: the shard count in |
| 282 | 295 | * force. An admin's value or the environment's is passed as it is; the |
src/http/edge.ts +11
| @@ -9,6 +9,17 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | 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 | } | |
| 12 | 23 | |
| 13 | 24 | /** The account the edge vouches for on this request, if it does. */ |
| 14 | 25 | export function edgeIdentity( |
src/http/mcp.ts +2 −2
| @@ -2566,13 +2566,13 @@ const TOOLS: readonly Tool[] = [ | ||
| 2566 | 2566 | { |
| 2567 | 2567 | name: "capacity", |
| 2568 | 2568 | 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.", | |
| 2570 | 2570 | inputSchema: { |
| 2571 | 2571 | type: "object", |
| 2572 | 2572 | properties: { |
| 2573 | 2573 | key: { |
| 2574 | 2574 | 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"], | |
| 2576 | 2576 | }, |
| 2577 | 2577 | value: { type: "string", maxLength: 16 }, |
| 2578 | 2578 | dryRun: { type: "boolean", description: "Plan only (the default). Send false to change it." }, |
src/http/server.ts +2 −2
| @@ -63,7 +63,7 @@ import { | ||
| 63 | 63 | isActive, |
| 64 | 64 | verifyPasswordThrottled, |
| 65 | 65 | } from "../core/users.ts"; |
| 66 | import { edgeIdentity } from "./edge.ts"; | |
| 66 | import { activeEdgeToken, edgeIdentity } from "./edge.ts"; | |
| 67 | 67 | import { signupClosed } from "../actions/users.ts"; |
| 68 | 68 | import { liveResponse, readyResponse } from "./health.ts"; |
| 69 | 69 | import { running } from "../kernel/version.ts"; |
| @@ -498,7 +498,7 @@ const NO_AUTH: Auth = { user: undefined, scope: undefined, via: "none" }; | ||
| 498 | 498 | async function resolveAuth(app: App, request: Request): Promise<Auth> { |
| 499 | 499 | // The platform edge, when there is one, has already signed this person |
| 500 | 500 | // in; its word outranks a cookie of ours. |
| 501 | const login = edgeIdentity(request, app.config.edgeToken); | |
| 501 | const login = edgeIdentity(request, activeEdgeToken(app)); | |
| 502 | 502 | if (login !== undefined) { |
| 503 | 503 | const existed = getUserByName(app.db, login) !== undefined; |
| 504 | 504 | // 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"; | ||
| 13 | 13 | import { mayReadRuns } from "../actions/runs.ts"; |
| 14 | 14 | import { getAppByRepo } from "../core/apps.ts"; |
| 15 | 15 | import { audienceFacts, audienceOf } from "../core/audience.ts"; |
| 16 | import { edgeLoginUrl } from "../http/edge.ts"; | |
| 16 | import { activeEdgeToken, edgeLoginUrl } from "../http/edge.ts"; | |
| 17 | 17 | import { |
| 18 | 18 | canAdmin, |
| 19 | 19 | canRead, |
| @@ -94,7 +94,7 @@ export function signInHref(ctx: Ctx): string { | ||
| 94 | 94 | const next = ctx.url.pathname + ctx.url.search; |
| 95 | 95 | // Behind a platform edge the edge is how people sign in; an account it |
| 96 | 96 | // 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); | |
| 98 | 98 | return `/signin?next=${encodeURIComponent(next)}`; |
| 99 | 99 | } |
| 100 | 100 |
src/web/layout.ts +2 −2
| @@ -13,7 +13,7 @@ import { appsForRepos } from "../core/apps.ts"; | ||
| 13 | 13 | import { listReposVisibleTo } from "../core/repos.ts"; |
| 14 | 14 | import type { User } from "../core/users.ts"; |
| 15 | 15 | import type { Ctx } from "../http/context.ts"; |
| 16 | import { edgeLoginUrl, edgeLogoutUrl } from "../http/edge.ts"; | |
| 16 | import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl } from "../http/edge.ts"; | |
| 17 | 17 | import { json } from "../http/respond.ts"; |
| 18 | 18 | import { sha256 } from "../kernel/hash.ts"; |
| 19 | 19 | import { secret } from "../kernel/id.ts"; |
| @@ -485,7 +485,7 @@ function topbar( | ||
| 485 | 485 | )}` |
| 486 | 486 | : btn("Sign in", { |
| 487 | 487 | href: |
| 488 | ctx.app.config.edgeToken === undefined | |
| 488 | activeEdgeToken(ctx.app) === undefined | |
| 489 | 489 | ? "/signin" |
| 490 | 490 | : edgeLoginUrl(ctx.url.pathname + ctx.url.search), |
| 491 | 491 | })} |
src/web/pages/capacity.ts +2
| @@ -184,6 +184,8 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 184 | 184 | const ERRORS: Record<string, string> = { |
| 185 | 185 | no_such_setting: "There is no such setting.", |
| 186 | 186 | 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.", | |
| 187 | 189 | }; |
| 188 | 190 | |
| 189 | 191 | // POST /settings/capacity |
src/web/pages/site.ts +3 −3
| @@ -25,7 +25,7 @@ import { isThemeName } from "../../design/themes.ts"; | ||
| 25 | 25 | import { cookieValue, crossOrigin, type Ctx } from "../../http/context.ts"; |
| 26 | 26 | import { studioDoor, studioHome } from "../studio/home.ts"; |
| 27 | 27 | import { homeTop } from "../studio/asks_page.ts"; |
| 28 | import { edgeLoginUrl } from "../../http/edge.ts"; | |
| 28 | import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts"; | |
| 29 | 29 | import { sshCommand } from "../../ssh/command.ts"; |
| 30 | 30 | import { hostOf, pageHead, signInFirst } from "../chrome.ts"; |
| 31 | 31 | import { SignupSlot, signupWindow } from "../../actions/signup-window.ts"; |
| @@ -248,7 +248,7 @@ function authForm( | ||
| 248 | 248 | // password form kept for the accounts that have one — an account the edge |
| 249 | 249 | // made has none, and the note says so before anyone types. |
| 250 | 250 | const edge = |
| 251 | ctx.app.config.edgeToken === undefined | |
| 251 | activeEdgeToken(ctx.app) === undefined | |
| 252 | 252 | ? undefined |
| 253 | 253 | : (ctx.app.config.edgeName ?? "the platform"); |
| 254 | 254 | const throughEdge = when( |
| @@ -503,7 +503,7 @@ export async function signinSubmit(ctx: Ctx): Promise<Response> { | ||
| 503 | 503 | values: { username }, |
| 504 | 504 | errors: { |
| 505 | 505 | password: |
| 506 | ctx.app.config.edgeToken === undefined | |
| 506 | activeEdgeToken(ctx.app) === undefined | |
| 507 | 507 | ? "That username and password don't match." |
| 508 | 508 | : `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.`, |
| 509 | 509 | }, |
src/web/studio/home.ts +2 −2
| @@ -22,7 +22,7 @@ import { LOOK_SIZES } from "../../core/looks.ts"; | ||
| 22 | 22 | import { canRead, getRepo, type Repo } from "../../core/repos.ts"; |
| 23 | 23 | import { type User } from "../../core/users.ts"; |
| 24 | 24 | import type { Ctx } from "../../http/context.ts"; |
| 25 | import { edgeLoginUrl } from "../../http/edge.ts"; | |
| 25 | import { activeEdgeToken, edgeLoginUrl } from "../../http/edge.ts"; | |
| 26 | 26 | import { sshCommand } from "../../ssh/command.ts"; |
| 27 | 27 | import { repoBase, signInFirst } from "../chrome.ts"; |
| 28 | 28 | import { field, readForm, type Form } from "../form.ts"; |
| @@ -488,7 +488,7 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 488 | 488 | <div class="bar"> |
| 489 | 489 | ${ctx.auth.user !== undefined |
| 490 | 490 | ? null |
| 491 | : ctx.app.config.edgeToken === undefined | |
| 491 | : activeEdgeToken(ctx.app) === undefined | |
| 492 | 492 | ? html`${btn("Create an account", { |
| 493 | 493 | href: `/signup${nextQuery}`, |
| 494 | 494 | 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 | }); |
Minor: this only checks
next === "off". If the boot default (FORGE_SIGNIN_EDGE) isoff, 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.