Public: anyone can read this; the crew can change it.
The forge signs people in with no forwardAuth edge in front of it, and people who came through the edge keep their accounts #11809
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11802-the-forge-signs-people-in-with-no-forwar → main 6 commits fork 3 threads · 3 unresolved
17 files
changed +727
−9
docs/features/edge-only-accounts.md +16
| @@ -15,3 +15,19 @@ An account the platform edge made (or one linked to an edge login) with no passw | ||
| 15 | 15 | ## Dry run |
| 16 | 16 | |
| 17 | 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`). |
| 18 | ||
| 19 | ## When the edge is gone (#11802) | |
| 20 | ||
| 21 | Every account the edge made is mapped once to the login it forwarded, in `edge_link` (migration `20261112180000-edge-handover`), and an account the edge signs in by name is mapped on that sign-in. So the record of who came through the edge is a row, not an inference. | |
| 22 | ||
| 23 | An edge-made account has a noreply address, so email recovery cannot reach it. For a mapped account that is not an admin and has no way in of its own, the Accounts card offers **Way-back link for <name>**: an admin presses it and the link is shown once on the page (`Cache-Control: no-store`). Hand it over by a channel you trust. It works once, for 7 days, and opens the `/recover` step: register a passkey, and the person is signed in with a session of the forge's own. Making another link replaces the first. In the terminal, the palette's **Way-back links** (admins only) lists the same accounts; Enter makes the link with the same action and shows it once on a screen of its own, `y` copies it. Only its SHA-256 hash is stored (`edge_handover`). An admin who signs in only through the edge adds a passkey while the edge still answers. Switching `signin.edge` off already refuses to strand them. | |
| 24 | ||
| 25 | ## Removing the middleware from the Ingress | |
| 26 | ||
| 27 | The forwardAuth middleware lives in the platform's gitops repository (`apps/forge/prod/manifests.yaml`, walked in #8948), not here. A person applies the change: | |
| 28 | ||
| 29 | 1. Wait until the card and the terminal screen are empty, or until each person left has been handed a way-back link. | |
| 30 | 2. On the forge's Ingress, delete the `traefik.ingress.kubernetes.io/router.middlewares` entry that names plat-auth. Keep any other middleware in that annotation. Then let the standby reconcile apply it, or apply it by hand. | |
| 31 | 3. Check: `curl -sI https://<forge>/healthz` answers 200, and so does `/` signed out. Signing in with a passkey works. | |
| 32 | 4. Unset `FORGE_EDGE_TOKEN` (or turn `signin.edge` off). The edge's headers are then a claim anyone could write, and they are ignored. | |
| 33 | 5. Delete the `plat-auth` row from `FOOTING` in `src/system/footing.ts` and regenerate the table in `docs/OPERATING.md`. |
src/actions/edge_handover.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** | |
| 2 | * Who may mint a way-back link (`core/edge_handover.ts`) and for whom: a | |
| 3 | * forge admin, a person (not an agent), for an active account that is not | |
| 4 | * an admin, is mapped to a platform edge login, and has no way in of its | |
| 5 | * own. Anyone else is told the account is not there, like every admin | |
| 6 | * surface (`actions/signins.ts`). An admin who signs in only through the | |
| 7 | * edge adds a passkey while the edge still answers: switching the edge off | |
| 8 | * already refuses to strand them (`actions/capacity.ts`). | |
| 9 | * | |
| 10 | * Opening the link is the recovery step (`actions/verify.ts` | |
| 11 | * `recoveryAccount`, `spendRecoveryLink`): a new passkey, then a session. | |
| 12 | */ | |
| 13 | ||
| 14 | import type { App } from "../app.ts"; | |
| 15 | import { signinReach, signsInWithoutEdge } from "../core/accounts.ts"; | |
| 16 | import { linkedLogin } from "../core/edge_links.ts"; | |
| 17 | import { HANDOVER_TTL_MS, issueHandover } from "../core/edge_handover.ts"; | |
| 18 | import { getUserByName, isActive, type User } from "../core/users.ts"; | |
| 19 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 20 | import { requireUser } from "./access.ts"; | |
| 21 | ||
| 22 | export interface WayBack { | |
| 23 | readonly username: string; | |
| 24 | /** The edge login the account is mapped to. */ | |
| 25 | readonly login: string; | |
| 26 | /** The link to hand over, absolute when the forge knows its address. */ | |
| 27 | readonly url: string; | |
| 28 | readonly expiresAt: string; | |
| 29 | } | |
| 30 | ||
| 31 | export type WayBackRefusal = "not_found" | "has_way_in"; | |
| 32 | ||
| 33 | /** An account a way-back link can be made for, and the edge login it is mapped to. */ | |
| 34 | export interface Stranded { | |
| 35 | readonly username: string; | |
| 36 | readonly login: string; | |
| 37 | } | |
| 38 | ||
| 39 | const mayMint = (actor: User | undefined): User | undefined => { | |
| 40 | const asker = requireUser(actor); | |
| 41 | return asker.ok && asker.value.isAdmin && asker.value.kind === "user" ? asker.value : undefined; | |
| 42 | }; | |
| 43 | ||
| 44 | /** | |
| 45 | * The accounts a way-back link can be made for, in the order Accounts names | |
| 46 | * them: the ones `mintWayBack` would mint for. The page and the terminal | |
| 47 | * screen (`tui/screens/wayback.ts`) offer the same list behind the same gate. | |
| 48 | */ | |
| 49 | export function wayBackAccounts(app: App, actor: User | undefined): Result<Stranded[], "not_found"> { | |
| 50 | if (mayMint(actor) === undefined) return err("not_found"); | |
| 51 | const out: Stranded[] = []; | |
| 52 | for (const a of signinReach(app.db).stranded) { | |
| 53 | if (a.isAdmin) continue; | |
| 54 | const user = getUserByName(app.db, a.username); | |
| 55 | if (user === undefined || user.kind !== "user" || !isActive(user)) continue; | |
| 56 | const login = linkedLogin(app.db, user.id); | |
| 57 | if (login !== undefined) out.push({ username: user.username, login }); | |
| 58 | } | |
| 59 | return ok(out); | |
| 60 | } | |
| 61 | ||
| 62 | /** Mint (or replace) the way-back link for `username`. */ | |
| 63 | export function mintWayBack( | |
| 64 | app: App, | |
| 65 | actor: User | undefined, | |
| 66 | username: string, | |
| 67 | now: number = Date.now(), | |
| 68 | ): Result<WayBack, WayBackRefusal> { | |
| 69 | const asker = mayMint(actor); | |
| 70 | if (asker === undefined) return err("not_found"); | |
| 71 | const user = getUserByName(app.db, username); | |
| 72 | if (user === undefined || user.kind !== "user" || user.isAdmin || !isActive(user)) return err("not_found"); | |
| 73 | const login = linkedLogin(app.db, user.id); | |
| 74 | if (login === undefined) return err("not_found"); | |
| 75 | if (signsInWithoutEdge(app.db, user.id)) return err("has_way_in"); | |
| 76 | const token = issueHandover(app.db, user.id, asker.id, now); | |
| 77 | app.bus.append({ kind: "account.way_back_minted", actorId: asker.id, payload: { userId: user.id } }); | |
| 78 | return ok({ | |
| 79 | username: user.username, | |
| 80 | login, | |
| 81 | url: wayBackUrl(app, token), | |
| 82 | expiresAt: new Date(now + HANDOVER_TTL_MS).toISOString(), | |
| 83 | }); | |
| 84 | } | |
| 85 | ||
| 86 | function wayBackUrl(app: App, token: string): string { | |
| 87 | const path = `/recover?token=${encodeURIComponent(token)}`; | |
| 88 | return app.config.url === undefined ? path : new URL(path, app.config.url).toString(); | |
| 89 | } |
src/actions/verify.ts +10 −3
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | spendRecovery, |
| 18 | 18 | resetVerification, |
| 19 | 19 | } from "../core/verify.ts"; |
| 20 | import { handoverOwner, spendHandover } from "../core/edge_handover.ts"; | |
| 20 | 21 | import { listSecretNames, resolveSecrets } from "../core/secret_store.ts"; |
| 21 | 22 | import { getUserById, isActive, type User } from "../core/users.ts"; |
| 22 | 23 | import { sendMail, type Mailer } from "../kernel/mail.ts"; |
| @@ -209,16 +210,22 @@ export function requestRecovery( | ||
| 209 | 210 | }, now).catch(() => undefined); |
| 210 | 211 | } |
| 211 | 212 | |
| 212 | /** The account a recovery link is for, if it still works; nothing is spent. */ | |
| 213 | /** | |
| 214 | * The account a recovery link is for, if it still works; nothing is spent. | |
| 215 | * A way-back link an admin minted for an edge account (`core/edge_handover.ts`) | |
| 216 | * opens the same step. | |
| 217 | */ | |
| 213 | 218 | export function recoveryAccount(app: App, token: string): User | undefined { |
| 214 | const id = recoveryOwner(app.db, token, Date.now()); | |
| 219 | const now = Date.now(); | |
| 220 | const id = recoveryOwner(app.db, token, now) ?? handoverOwner(app.db, token, now); | |
| 215 | 221 | const user = id === undefined ? undefined : getUserById(app.db, id); |
| 216 | 222 | return user !== undefined && isActive(user) ? user : undefined; |
| 217 | 223 | } |
| 218 | 224 | |
| 219 | 225 | /** Spend the link: the account to sign in, or `invalid` for a used or stale one. */ |
| 220 | 226 | export function spendRecoveryLink(app: App, token: string): Result<User, "invalid"> { |
| 221 | const id = spendRecovery(app.db, token, Date.now()); | |
| 227 | const now = Date.now(); | |
| 228 | const id = spendRecovery(app.db, token, now) ?? spendHandover(app.db, token, now); | |
| 222 | 229 | const user = id === undefined ? undefined : getUserById(app.db, id); |
| 223 | 230 | if (user === undefined || !isActive(user)) return err("invalid"); |
| 224 | 231 | app.bus.append({ kind: "account.recovered", actorId: user.id }); |
src/core/edge_handover.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | /** | |
| 2 | * The way back for a person the platform edge signed in, once the edge is | |
| 3 | * gone (#11802). An account the edge made has a noreply address, so email | |
| 4 | * recovery cannot reach it; an admin mints a way-back link for it and hands | |
| 5 | * it over, and opening it registers a passkey and signs the person in, the | |
| 6 | * same step a recovery link leads to (`/recover`). | |
| 7 | * | |
| 8 | * Only an account mapped to an edge login (`edge_link`) that has no way in | |
| 9 | * of its own — no password, no passkey, no parent link — gets one. One link | |
| 10 | * per account: minting again replaces it. The secret is stored only as its | |
| 11 | * hash, works once, and expires after `HANDOVER_TTL_MS`. Pure data: the | |
| 12 | * action decides who may mint (`actions/edge_handover.ts`). | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import { sha256 } from "../kernel/hash.ts"; | |
| 17 | ||
| 18 | /** How long a way-back link works: a week, since it is handed over by a person, not mailed. */ | |
| 19 | export const HANDOVER_TTL_MS = 7 * 24 * 3_600_000; | |
| 20 | ||
| 21 | const secret = (): string => | |
| 22 | Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url"); | |
| 23 | ||
| 24 | /** Mint (or replace) the account's way-back link; the plain secret, once. */ | |
| 25 | export function issueHandover( | |
| 26 | db: Database, | |
| 27 | userId: string, | |
| 28 | byId: string, | |
| 29 | now: number, | |
| 30 | ): string { | |
| 31 | const token = secret(); | |
| 32 | db.query( | |
| 33 | `INSERT INTO edge_handover (user_id, token_hash, expires_at, by_id) VALUES (?1, ?2, ?3, ?4) | |
| 34 | ON CONFLICT(user_id) DO UPDATE SET token_hash = excluded.token_hash, | |
| 35 | expires_at = excluded.expires_at, by_id = excluded.by_id, | |
| 36 | at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, | |
| 37 | ).run(userId, sha256(token), new Date(now + HANDOVER_TTL_MS).toISOString(), byId); | |
| 38 | return token; | |
| 39 | } | |
| 40 | ||
| 41 | /** Whose way-back link this is, if it still works; nothing is spent. */ | |
| 42 | export function handoverOwner( | |
| 43 | db: Database, | |
| 44 | token: string, | |
| 45 | now: number, | |
| 46 | ): string | undefined { | |
| 47 | if (token === "") return undefined; | |
| 48 | return ( | |
| 49 | db | |
| 50 | .query<{ user_id: string }, [string, string]>( | |
| 51 | "SELECT user_id FROM edge_handover WHERE token_hash = ?1 AND expires_at > ?2", | |
| 52 | ) | |
| 53 | .get(sha256(token), new Date(now).toISOString())?.user_id ?? undefined | |
| 54 | ); | |
| 55 | } | |
| 56 | ||
| 57 | /** Spend a way-back link: the account it was for, once; undefined after. */ | |
| 58 | export function spendHandover( | |
| 59 | db: Database, | |
| 60 | token: string, | |
| 61 | now: number, | |
| 62 | ): string | undefined { | |
| 63 | if (token === "") return undefined; | |
| 64 | return ( | |
| 65 | db | |
| 66 | .query<{ user_id: string }, [string, string]>( | |
| 67 | "DELETE FROM edge_handover WHERE token_hash = ?1 AND expires_at > ?2 RETURNING user_id", | |
| 68 | ) | |
| 69 | .get(sha256(token), new Date(now).toISOString())?.user_id ?? undefined | |
| 70 | ); | |
| 71 | } |
src/core/event_audience.ts +2
| @@ -236,6 +236,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 236 | 236 | // Who is trusted to fold in, and the switch that stops it, are the owner's (`actions/trusted_sources.ts`). |
| 237 | 237 | kind.startsWith("trust.") || |
| 238 | 238 | kind.startsWith("foldin.") || |
| 239 | // A way-back link minted for an account the platform edge signed in (#11802). | |
| 240 | kind === "account.way_back_minted" || | |
| 239 | 241 | // The owner's planner switch is the admins' to hear (#11604). |
| 240 | 242 | kind === "planner.paused" || |
| 241 | 243 | kind === "planner.resumed" || |
src/core/migration-pins/20261112180000-edge-handover.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 197cf75c99b41e445854a2ceac8c1360ff3c87907eadf99c06bffba8291237d4 |
src/core/migrations/20261112180000-edge-handover.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | // The platform's forwardAuth edge goes (#11802). Every account the edge made | |
| 2 | // is mapped once to the login it forwarded (its username then), in | |
| 3 | // `edge_link`, so the mapping is a row and not an inference from an event. | |
| 4 | // A login already linked to another account, or an account already linked, | |
| 5 | // is left as it is (INSERT OR IGNORE; the unique user_id is checked first). | |
| 6 | // `edge_handover` holds one way-back link per account, minted by an admin | |
| 7 | // for a mapped account with no password, passkey or parent link: its secret | |
| 8 | // stored only as a hash, single-use, expiring. Additive. | |
| 9 | export default ` | |
| 10 | INSERT OR IGNORE INTO edge_link (login, user_id) | |
| 11 | SELECT u.username, u.id FROM user u | |
| 12 | WHERE u.kind = 'user' AND u.is_agent = 0 | |
| 13 | AND NOT EXISTS (SELECT 1 FROM edge_link l WHERE l.user_id = u.id) | |
| 14 | AND EXISTS (SELECT 1 FROM event e WHERE e.kind = 'user.created' | |
| 15 | AND e.actor_id = u.id | |
| 16 | AND CASE WHEN json_valid(e.payload) | |
| 17 | THEN json_extract(e.payload, '$.via') END = 'edge'); | |
| 18 | CREATE TABLE edge_handover ( | |
| 19 | user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE, | |
| 20 | token_hash TEXT NOT NULL UNIQUE, | |
| 21 | expires_at TEXT NOT NULL, | |
| 22 | by_id TEXT REFERENCES user(id) ON DELETE SET NULL, | |
| 23 | at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 24 | ); | |
| 25 | `; |
src/core/users.ts +12 −4
| @@ -523,10 +523,18 @@ export function ensureEdgeUser(db: Database, login: string): User | undefined { | ||
| 523 | 523 | const link = db |
| 524 | 524 | .query<{ user_id: string }, [string]>("SELECT user_id FROM edge_link WHERE login = ?") |
| 525 | 525 | .get(login); |
| 526 | const existing = (link === null ? undefined : getUserById(db, link.user_id)) ?? getUserByName(db, login); | |
| 527 | return existing && existing.kind === "user" && isActive(existing) | |
| 528 | ? existing | |
| 529 | : undefined; | |
| 526 | const linked = link === null ? undefined : getUserById(db, link.user_id); | |
| 527 | const existing = linked ?? getUserByName(db, login); | |
| 528 | if (!(existing && existing.kind === "user" && isActive(existing))) return undefined; | |
| 529 | // Found by name: map the login to the account once, so the mapping | |
| 530 | // outlives the edge (#11802). An account linked to another login, or a | |
| 531 | // login another account holds, keeps what it has. | |
| 532 | if (linked === undefined) | |
| 533 | db.query( | |
| 534 | `INSERT OR IGNORE INTO edge_link (login, user_id) | |
| 535 | SELECT ?1, ?2 WHERE NOT EXISTS (SELECT 1 FROM edge_link WHERE user_id = ?2)`, | |
| 536 | ).run(login, existing.id); | |
| 537 | return existing; | |
| 530 | 538 | } |
| 531 | 539 | |
| 532 | 540 | /** Why a password was not accepted: wrong, or tried too often to check. */ |
src/http/settings_routes.ts +2
| @@ -37,6 +37,7 @@ import { | ||
| 37 | 37 | retireAct, |
| 38 | 38 | retirePage, |
| 39 | 39 | accountsPage, |
| 40 | wayBackAct, | |
| 40 | 41 | personSignInsPage, |
| 41 | 42 | signInAct, |
| 42 | 43 | } from "../web/pages/accounts.ts"; |
| @@ -297,6 +298,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 297 | 298 | router.add("GET", "/settings/accounts/:username/retire", retirePage); |
| 298 | 299 | router.add("POST", "/settings/accounts/:username/retire", retireAct); |
| 299 | 300 | router.add("POST", "/settings/accounts/:username/verify", accountAct("verify")); |
| 301 | router.add("POST", "/settings/accounts/:username/way-back", wayBackAct); | |
| 300 | 302 | router.add("GET", "/settings/crew", crewPage); |
| 301 | 303 | router.add("POST", "/settings/crew/knobs", knobSet); |
| 302 | 304 | router.add("POST", "/settings/crew", agentCreate); |
src/tui/screens/entries.ts +2
| @@ -36,6 +36,7 @@ import { entry as displays } from "./displays.ts"; | ||
| 36 | 36 | import { entry as disks } from "./disks.ts"; |
| 37 | 37 | import { entry as domains } from "./domains.ts"; |
| 38 | 38 | import { entry as edgeOnly } from "./edgeonly.ts"; |
| 39 | import { entry as wayBack } from "./wayback.ts"; | |
| 39 | 40 | import { entry as traffic } from "./trafficview.ts"; |
| 40 | 41 | import { entry as funnelEntry } from "./funnel.ts"; |
| 41 | 42 | import { entry as featureReview } from "./features.ts"; |
| @@ -139,6 +140,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 139 | 140 | valves, |
| 140 | 141 | access, |
| 141 | 142 | edgeOnly, |
| 143 | wayBack, | |
| 142 | 144 | traffic, |
| 143 | 145 | funnelEntry, |
| 144 | 146 | audit, |
src/tui/screens/signins.ts +1 −1
| @@ -95,7 +95,7 @@ export function signInsOf(f: Session): SignIn[] { | ||
| 95 | 95 | } |
| 96 | 96 | |
| 97 | 97 | /** Cut into rows of `width` characters: wrapped, never truncated, so a hand-copied line is the whole line. */ |
| 98 | function hardWrap(text: string, width: number): string[] { | |
| 98 | export function hardWrap(text: string, width: number): string[] { | |
| 99 | 99 | const w = Math.max(1, width); |
| 100 | 100 | const rows: string[] = []; |
| 101 | 101 | for (let i = 0; i < text.length; i += w) rows.push(text.slice(i, i + w)); |
src/tui/screens/wayback.ts added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | /** | |
| 2 | * Way-back links in the terminal, a forge admin's (#11802): the accounts the | |
| 3 | * platform edge made that have no way in of their own, as Settings → | |
| 4 | * Accounts lists them (`wayBackAccounts`). Enter mints the link with the same | |
| 5 | * action the page posts to (`mintWayBack`), behind the same gate, and shows | |
| 6 | * it once on a screen of its own; nothing keeps it once that is popped. | |
| 7 | */ | |
| 8 | ||
| 9 | import { mintWayBack, wayBackAccounts, type Stranded } from "../../actions/edge_handover.ts"; | |
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import { binding } from "../app/keys.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { truncate } from "../term/width.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { fill, len, split } from "../ui/layout.ts"; | |
| 16 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 17 | import type { PaletteEntry } from "./entries.ts"; | |
| 18 | import { hardWrap } from "./signins.ts"; | |
| 19 | ||
| 20 | const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind === "user"; | |
| 21 | ||
| 22 | export function wayBackScreen(f: Session, ctx: Ctx): Screen { | |
| 23 | const state = new ListState(); | |
| 24 | let accounts: Stranded[] = []; | |
| 25 | let allowed = false; | |
| 26 | let note = ""; | |
| 27 | const read = (): void => { | |
| 28 | const listed = wayBackAccounts(f.app, f.user); | |
| 29 | allowed = listed.ok; | |
| 30 | accounts = listed.ok ? listed.value : []; | |
| 31 | state.clamp(accounts.length); | |
| 32 | }; | |
| 33 | read(); | |
| 34 | const mint = (): void => { | |
| 35 | const picked = accounts[state.index]; | |
| 36 | if (picked === undefined) return; | |
| 37 | const minted = mintWayBack(f.app, f.user, picked.username); | |
| 38 | if (!minted.ok) { | |
| 39 | note = | |
| 40 | minted.error === "has_way_in" | |
| 41 | ? `${picked.username} already signs in without the edge; no link was made.` | |
| 42 | : `${picked.username} is not there.`; | |
| 43 | read(); | |
| 44 | ctx.invalidate(); | |
| 45 | return; | |
| 46 | } | |
| 47 | note = ""; | |
| 48 | const w = minted.value; | |
| 49 | ctx.push(linkScreen(ctx, w.username, w.login, w.url, w.expiresAt)); | |
| 50 | }; | |
| 51 | return { | |
| 52 | title: "Way-back links", | |
| 53 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "make the link"), binding(["r"], "refresh")], | |
| 54 | render(area, s) { | |
| 55 | const c = ctx.theme.c; | |
| 56 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 57 | const text = !allowed | |
| 58 | ? "Not found." | |
| 59 | : note !== "" | |
| 60 | ? note | |
| 61 | : accounts.length === 0 | |
| 62 | ? "Every account the edge made can sign in without it." | |
| 63 | : "Accounts the edge made with no way in of their own; enter makes one a way-back link."; | |
| 64 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 65 | const rows: ListRow[] = accounts.map((a) => ({ title: a.username, secondary: `edge login ${a.login}`, meta: "" })); | |
| 66 | drawList(s, list!, c, state, rows); | |
| 67 | }, | |
| 68 | key(e) { | |
| 69 | if (e.ctrl || e.alt) return state.key(e, accounts.length); | |
| 70 | if (e.key === "enter") { | |
| 71 | if (allowed) mint(); | |
| 72 | return true; | |
| 73 | } | |
| 74 | if (e.key === "r") { | |
| 75 | note = ""; | |
| 76 | read(); | |
| 77 | return true; | |
| 78 | } | |
| 79 | return state.key(e, accounts.length); | |
| 80 | }, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | /** The link, alone on a screen, wrapped whole so a hand-copied line is the whole line. */ | |
| 85 | function linkScreen(ctx: Ctx, username: string, login: string, url: string, expiresAt: string): Screen { | |
| 86 | let copied = ""; | |
| 87 | const until = `${expiresAt.slice(0, 16).replace("T", " ")} UTC`; | |
| 88 | return { | |
| 89 | title: `Way-back link for ${username}`, | |
| 90 | keys: [binding(["y"], "copy it"), binding(["esc", "q"], "done")], | |
| 91 | render(area, s) { | |
| 92 | const c = ctx.theme.c; | |
| 93 | const end = area.x + area.w; | |
| 94 | const lead = `Hand this to ${username} (edge login ${login}) by a channel you trust: it works once, until ${until}, and is not shown again.`; | |
| 95 | const said = hardWrap(lead, area.w); | |
| 96 | said.forEach((line, i) => s.text(area.x, area.y + i, line, style(c.mutedFg, c.bg), end)); | |
| 97 | const top = area.y + said.length + 1; | |
| 98 | const lines = hardWrap(url, area.w); | |
| 99 | lines.forEach((line, i) => s.text(area.x, top + i, line, style(c.fg, c.bg), end)); | |
| 100 | const foot = copied === "" ? "y copies it, esc is done." : copied; | |
| 101 | s.text(area.x, top + lines.length + 1, truncate(foot, area.w), style(c.mutedFg, c.bg), end); | |
| 102 | }, | |
| 103 | key(e) { | |
| 104 | if (e.ctrl || e.alt) return true; | |
| 105 | if (e.key === "y") { | |
| 106 | ctx.copy(url); | |
| 107 | copied = "Copied."; | |
| 108 | } else if (e.key === "esc" || e.key === "q") { | |
| 109 | ctx.pop(); | |
| 110 | } | |
| 111 | return true; | |
| 112 | }, | |
| 113 | }; | |
| 114 | } | |
| 115 | ||
| 116 | /** Settings → Accounts' way-back links: one per account the edge made with no way in. */ | |
| 117 | export const entry: PaletteEntry = { | |
| 118 | id: "way-back-links", | |
| 119 | title: "Way-back links", | |
| 120 | hint: "a link that signs in an account the edge made, once the edge is gone", | |
| 121 | after: ["edge-only-accounts"], | |
| 122 | offered: isAdmin, | |
| 123 | run: (session, ctx) => ctx.push(wayBackScreen(session, ctx)), | |
| 124 | }; |
src/web/pages/accounts.ts +52
| @@ -29,6 +29,7 @@ import { | ||
| 29 | 29 | soleOwnedOrgs, |
| 30 | 30 | } from "../../core/orgs.ts"; |
| 31 | 31 | import { getUserByName, hasPassword, isStandInEmail } from "../../core/users.ts"; |
| 32 | import { mintWayBack, wayBackAccounts } from "../../actions/edge_handover.ts"; | |
| 32 | 33 | import type { Ctx } from "../../http/context.ts"; |
| 33 | 34 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 34 | 35 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| @@ -39,6 +40,7 @@ import { | ||
| 39 | 40 | badge, |
| 40 | 41 | btn, |
| 41 | 42 | confirmAction, |
| 43 | copyField, | |
| 42 | 44 | field as formField, |
| 43 | 45 | row, |
| 44 | 46 | time, |
| @@ -117,6 +119,55 @@ function personRow(a: AccountRow, sole: readonly string[]): Html { | ||
| 117 | 119 | }); |
| 118 | 120 | } |
| 119 | 121 | |
| 122 | /** | |
| 123 | * Once the edge is gone, a person it signed in who added no passkey gets in | |
| 124 | * by a way-back link an admin hands over (#11802): one button per account | |
| 125 | * `wayBackAccounts` names. An admin is not offered one; they add a passkey. | |
| 126 | */ | |
| 127 | function wayBackForms(ctx: Ctx): Html | null { | |
| 128 | const listed = wayBackAccounts(ctx.app, ctx.auth.user); | |
| 129 | if (!listed.ok || listed.value.length === 0) return null; | |
| 130 | return html`<div class="form-actions" id="way-back"> | |
| 131 | ${listed.value.map( | |
outdated
Principle 1: this paragraph explains what the button and the result section already say. One confident sentence, said once. |
||
| 132 | (a) => html`<form method="post" action="/settings/accounts/${encodeURIComponent(a.username)}/way-back"> | |
| 133 | ${btn(`Way-back link for ${a.username}`, { type: "submit", size: "sm" })} | |
| 134 | </form>`, | |
| 135 | )} | |
| 136 | </div>`; | |
| 137 | } | |
| 138 | ||
| 139 | // POST /settings/accounts/:username/way-back — the link, shown once on the page. | |
| 140 | export async function wayBackAct(ctx: Ctx): Promise<Response> { | |
| 141 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 142 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 143 | const form = await readForm(ctx, "admin"); | |
| 144 | if (form instanceof Response) return form; | |
| 145 | const username = ctx.params.username ?? ""; | |
| 146 | const minted = mintWayBack(ctx.app, ctx.auth.user, username); | |
| 147 | if (!minted.ok) { | |
| 148 | if (minted.error === "not_found") return notFoundPage(ctx); | |
| 149 | return flashTo(ctx, "/settings/accounts", { | |
| 150 | message: `${username} already signs in without the edge; no link was made.`, | |
| 151 | tone: "danger", | |
| 152 | }); | |
| 153 | } | |
| 154 | const w = minted.value; | |
| 155 | const response = accountsView( | |
| 156 | ctx, | |
| 157 | html`<section class="settings-block" id="way-back-link"> | |
| 158 | <h2>Way-back link for ${w.username}</h2> | |
| 159 | <p> | |
| 160 | Hand this to ${w.username} (edge login <code>${w.login}</code>) by a | |
| 161 | channel you trust: it works once, until ${time(w.expiresAt)}, and is | |
| 162 | not shown again. | |
| 163 | </p> | |
| 164 | ${copyField("way-back-url", w.url, `Way-back link for ${w.username}`, "Copy the link")} | |
| 165 | </section>`, | |
| 166 | ); | |
| 167 | response.headers.set("cache-control", "no-store"); | |
| 168 | return response; | |
| 169 | } | |
| 170 | ||
| 120 | 171 | // GET /settings/accounts |
| 121 | 172 | export function accountsPage(ctx: Ctx): Response { |
| 122 | 173 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -192,6 +243,7 @@ function accountsView(ctx: Ctx, notice?: Html, status = 200): Response { | ||
| 192 | 243 | : ""}`, |
| 193 | 244 | )}. |
| 194 | 245 | </p> |
| 246 | ${wayBackForms(ctx)} | |
| 195 | 247 | </section>`; |
| 196 | 248 | const sole = timePhase(ctx.request, "owned", () => |
| 197 | 249 | people.map((a) => { |
test/edge-gone.test.ts added +204
| @@ -0,0 +1,204 @@ | ||
| 1 | /** | |
| 2 | * The platform's forwardAuth edge goes (#11802). Every account it signed in | |
| 3 | * is mapped once to its login (`edge_link`); with the edge gone an account | |
| 4 | * it made — a noreply address, no password, no passkey — gets in by a | |
| 5 | * way-back link an admin hands over, which registers a passkey and signs | |
| 6 | * in. `/healthz` and public pages answer with no edge headers at all. | |
| 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 { b64u } from "../src/auth/webauthn.ts"; | |
| 14 | import { edgeOnlyAccounts } from "../src/core/accounts.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 17 | import { HANDOVER_TTL_MS } from "../src/core/edge_handover.ts"; | |
| 18 | import { mintWayBack } from "../src/actions/edge_handover.ts"; | |
| 19 | import { sha256 } from "../src/kernel/hash.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import migration from "../src/core/migrations/20261112180000-edge-handover.ts"; | |
| 22 | ||
| 23 | const te = new TextEncoder(); | |
| 24 | const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d)); | |
| 25 | const cat = (...p: Uint8Array[]): Uint8Array<ArrayBuffer> => { const o: Uint8Array<ArrayBuffer> = new Uint8Array(p.reduce((n, a) => n + a.length, 0)); let i = 0; for (const a of p) { o.set(a, i); i += a.length; } return o; }; | |
| 26 | function head(major: number, n: number): Uint8Array { | |
| 27 | if (n < 24) return Uint8Array.of((major << 5) | n); | |
| 28 | if (n < 256) return Uint8Array.of((major << 5) | 24, n); | |
| 29 | return Uint8Array.of((major << 5) | 25, n >> 8, n & 255); | |
| 30 | } | |
| 31 | type V = number | string | Uint8Array | [V, V][]; | |
| 32 | function enc(v: V): Uint8Array { | |
| 33 | if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v); | |
| 34 | if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); } | |
| 35 | if (v instanceof Uint8Array) return cat(head(2, v.length), v); | |
| 36 | return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)])); | |
| 37 | } | |
| 38 | ||
| 39 | /** A software authenticator that can only register. */ | |
| 40 | async function register(origin: string, rp: string, challenge: string) { | |
| 41 | const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair; | |
| 42 | const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); | |
| 43 | const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url")); | |
| 44 | const cose: [number, V][] = [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]]; | |
| 45 | const id = crypto.getRandomValues(new Uint8Array(32)); | |
| 46 | const authData = cat(await sha(te.encode(rp)), Uint8Array.of(0x45), new Uint8Array(4), new Uint8Array(16), Uint8Array.of(0, id.length), id, enc(cose as V)); | |
| 47 | return { | |
| 48 | clientDataJSON: b64u(te.encode(JSON.stringify({ type: "webauthn.create", challenge, origin }))), | |
| 49 | attestationObject: b64u(enc([["fmt", "none"], ["attStmt", []], ["authData", authData]])), | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | const EDGE = "edge-secret"; | |
| 54 | ||
| 55 | describe("the forge with its forwardAuth edge gone", () => { | |
| 56 | let dataDir: string; | |
| 57 | let app: App; | |
| 58 | let servers: Servers; | |
| 59 | let base: string; | |
| 60 | let ownerCookie: string; | |
| 61 | ||
| 62 | const linkOf = (userId: string) => | |
| 63 | app.db.query<{ login: string }, [string]>("SELECT login FROM edge_link WHERE user_id = ?1").get(userId)?.login; | |
| 64 | const whoAmI = async (headers: Record<string, string>) => { | |
| 65 | const res = await fetch(`${base}/api/v1/user`, { headers }); | |
| 66 | return res.status === 200 ? ((await res.json()) as { username: string }).username : res.status; | |
| 67 | }; | |
| 68 | const post = (path: string, body: unknown) => | |
| 69 | fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json", origin: base }, body: JSON.stringify(body) }); | |
| 70 | ||
| 71 | beforeAll(async () => { | |
| 72 | dataDir = await mkdtemp(join(tmpdir(), "forge-edge-gone-")); | |
| 73 | // While the edge still stands in front of the forge. | |
| 74 | app = createApp({ dataDir, port: 0, edgeToken: EDGE }); | |
| 75 | servers = await serve(app); | |
| 76 | base = servers.url; | |
| 77 | const owner = await createUser(app.db, { origin: "seed", trusted: true, username: "olga", email: "[email protected]", password: "owner-passphrase" }); | |
| 78 | if (!owner.ok) throw new Error(owner.error); | |
| 79 | app.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(owner.value.id); | |
| 80 | }); | |
| 81 | ||
| 82 | afterAll(async () => { | |
| 83 | servers.stop(); | |
| 84 | app.close(); | |
| 85 | await rm(dataDir, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | test("every account the edge signed in is mapped once to its login", async () => { | |
| 89 | // ada: an account the edge made, as it did before #2779 (a noreply address). | |
| 90 | const ada = await createUser(app.db, { origin: "edge", username: "ada", email: "[email protected]" }); | |
| 91 | if (!ada.ok) throw new Error(ada.error); | |
| 92 | app.bus.append({ kind: "user.created", actorId: ada.value.id, payload: { via: "edge" } }); | |
| 93 | // bea: an account of the forge's own the edge signed in by name. | |
| 94 | const bea = await createUser(app.db, { origin: "seed", trusted: true, username: "bea", email: "[email protected]", password: "bea-passphrase" }); | |
| 95 | if (!bea.ok) throw new Error(bea.error); | |
| 96 | expect(linkOf(ada.value.id)).toBeUndefined(); | |
| 97 | ||
| 98 | // The migration's backfill maps what the edge made. | |
| 99 | app.db.exec("DROP TABLE edge_handover"); | |
| 100 | app.db.exec(migration); | |
| 101 | expect(linkOf(ada.value.id)).toBe("ada"); | |
| 102 | expect(linkOf(bea.value.id)).toBeUndefined(); | |
| 103 | ||
| 104 | // A sign-in through the edge by name maps that account, once. | |
| 105 | expect(await whoAmI({ "x-plat-user": "bea", "x-plat-edge-token": EDGE })).toBe("bea"); | |
| 106 | expect(linkOf(bea.value.id)).toBe("bea"); | |
| 107 | expect(await whoAmI({ "x-plat-user": "bea", "x-plat-edge-token": EDGE })).toBe("bea"); | |
| 108 | expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM edge_link").get()!.n).toBe(2); | |
| 109 | expect(await whoAmI({ "x-plat-user": "ada", "x-plat-edge-token": EDGE })).toBe("ada"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("with the edge gone, an edge account walks back in by a way-back link", async () => { | |
| 113 | // The edge goes: the forge restarts with no edge secret. | |
| 114 | servers.stop(); | |
| 115 | app.close(); | |
| 116 | app = createApp({ dataDir, port: 0 }); | |
| 117 | servers = await serve(app); | |
| 118 | base = servers.url; | |
| 119 | const owner = getUserByName(app.db, "olga")!; | |
| 120 | ownerCookie = `forge_session=${createSession(app.db, owner.id).token}`; | |
| 121 | ||
| 122 | // The edge's headers are now a claim anyone could write: nobody. | |
| 123 | expect(await whoAmI({ "x-plat-user": "ada", "x-plat-edge-token": EDGE })).toBe(401); | |
| 124 | expect(edgeOnlyAccounts(app.db).map((a) => a.username)).toEqual(["ada"]); | |
| 125 | ||
| 126 | // The owner sees ada stranded and makes her link. | |
| 127 | const card = await (await fetch(`${base}/settings/accounts`, { headers: { cookie: ownerCookie } })).text(); | |
| 128 | expect(card).toContain(`action="/settings/accounts/ada/way-back"`); | |
| 129 | expect(card).not.toContain(`action="/settings/accounts/bea/way-back"`); | |
| 130 | const made = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: ownerCookie, origin: base } }); | |
| 131 | expect(made.status).toBe(200); | |
| 132 | expect(made.headers.get("cache-control")).toBe("no-store"); | |
| 133 | const token = /\/recover\?token=([\w-]+)/.exec(await made.text())![1]!; | |
| 134 | ||
| 135 | // Only its hash is kept, for a week. | |
| 136 | const row = app.db.query<{ token_hash: string; expires_at: string }, []>("SELECT token_hash, expires_at FROM edge_handover").get()!; | |
| 137 | expect(row.token_hash).toBe(sha256(token)); | |
| 138 | const life = Date.parse(row.expires_at) - Date.now(); | |
| 139 | expect(life).toBeGreaterThan(HANDOVER_TTL_MS - 60_000); | |
| 140 | expect(life).toBeLessThanOrEqual(HANDOVER_TTL_MS); | |
| 141 | ||
| 142 | // ada opens it: a new passkey, then a session of the forge's own. | |
| 143 | expect((await fetch(`${base}/recover?token=${token}`)).status).toBe(200); | |
| 144 | const o = await post("/recover/options", { token }); | |
| 145 | expect(o.status).toBe(200); | |
| 146 | const opts = (await o.json()) as { challenge: string; user: { name: string } }; | |
| 147 | expect(opts.user.name).toBe("ada"); | |
| 148 | const reg = await register(new URL(base).origin, new URL(base).hostname, opts.challenge); | |
| 149 | const done = await post("/recover/passkey", { token, name: "Laptop", challenge: opts.challenge, ...reg }); | |
| 150 | expect(done.status).toBe(200); | |
| 151 | const cookie = done.headers.get("set-cookie")!.split(";")[0]!; | |
| 152 | expect(await whoAmI({ cookie })).toBe("ada"); | |
| 153 | expect(edgeOnlyAccounts(app.db)).toEqual([]); | |
| 154 | ||
| 155 | // The link works once. | |
| 156 | expect((await post("/recover/options", { token })).status).toBe(400); | |
| 157 | }); | |
| 158 | ||
| 159 | test("only an admin mints one, only for a mapped account with no way in", async () => { | |
| 160 | const owner = getUserByName(app.db, "olga")!; | |
| 161 | const bea = getUserByName(app.db, "bea")!; | |
| 162 | // bea has a password: no link. | |
| 163 | expect(mintWayBack(app, owner, "bea")).toEqual({ ok: false, error: "has_way_in" }); | |
| 164 | // Not for an account nobody mapped, not by a non-admin, not for an admin. | |
| 165 | const cy = await createUser(app.db, { origin: "seed", trusted: true, username: "cy", email: "[email protected]" }); | |
| 166 | if (!cy.ok) throw new Error(cy.error); | |
| 167 | expect(mintWayBack(app, owner, "cy")).toEqual({ ok: false, error: "not_found" }); | |
| 168 | expect(mintWayBack(app, bea, "ada")).toEqual({ ok: false, error: "not_found" }); | |
| 169 | expect(mintWayBack(app, owner, "olga")).toEqual({ ok: false, error: "not_found" }); | |
| 170 | const beaCookie = `forge_session=${createSession(app.db, bea.id).token}`; | |
| 171 | const refused = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: beaCookie, origin: base } }); | |
| 172 | expect(refused.status).toBe(404); | |
| 173 | // From another site: refused before anything is made. | |
| 174 | const crossed = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: ownerCookie, origin: "https://elsewhere.example" } }); | |
| 175 | expect(crossed.status).toBe(403); | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 179 | describe("no other service up", () => { | |
| 180 | let dataDir: string; | |
| 181 | let app: App; | |
| 182 | let servers: Servers; | |
| 183 | ||
| 184 | beforeAll(async () => { | |
| 185 | dataDir = await mkdtemp(join(tmpdir(), "forge-no-edge-")); | |
| 186 | // An edge secret configured and nothing in front: no request carries its headers. | |
| 187 | app = createApp({ dataDir, port: 0, edgeToken: EDGE }); | |
| 188 | servers = await serve(app); | |
| 189 | }); | |
| 190 | ||
| 191 | afterAll(async () => { | |
| 192 | servers.stop(); | |
| 193 | app.close(); | |
| 194 | await rm(dataDir, { recursive: true, force: true }); | |
| 195 | }); | |
| 196 | ||
| 197 | test("/healthz and the public pages answer with no edge headers at all", async () => { | |
| 198 | for (const path of ["/healthz", "/", "/signin", "/explore", "/recover"]) { | |
| 199 | const res = await fetch(`${servers.url}${path}`, { redirect: "manual" }); | |
| 200 | expect({ path, status: res.status }).toEqual({ path, status: 200 }); | |
| 201 | await res.arrayBuffer(); | |
| 202 | } | |
| 203 | }); | |
| 204 | }); |
test/palette-roster.test.ts +1
| @@ -173,6 +173,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 173 | 173 | ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"], |
| 174 | 174 | ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"], |
| 175 | 175 | ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"], |
| 176 | ["way-back-links", "Way-back links", "a link that signs in an account the edge made, once the edge is gone"], | |
| 176 | 177 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], |
| 177 | 178 | ["from-visit-to-paid", "From visit to paid", "the last 7 days: home visits, plans seen, plan clicks, forges made, paid"], |
| 178 | 179 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
test/pull-quiet.test.ts +2 −1
| @@ -68,7 +68,8 @@ const bytes = (t: string) => new TextEncoder().encode(t).length; | ||
| 68 | 68 | |
| 69 | 69 | // Share tags with the full name (#11654): 31,983 pull, 25,359 list, budgets that plus ~170. |
| 70 | 70 | // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. With the Your-dotfiles palette entry (#10548) the list measures 25,260, budget that plus ~170. The palette now also carries the people, three settings rows and the Tell-the-crew action (#10659): 31,869 measured on the pull page, so the pull budget is that plus ~160. |
| 71 | const BUDGET = { pull: 32_160, list: 25_530 }; | |
| 71 | // The admin palette's Way-back links entry (#11802) adds ~110 B: 32,097 measured on the pull page and 25,478 on the list, each budget that plus ~170. | |
| 72 | const BUDGET = { pull: 32_270, list: 25_650 }; | |
| 72 | 73 | |
| 73 | 74 | test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => { |
| 74 | 75 | const text = await get(`/alice/r/pulls/${number}`); |
test/tui-way-back.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** | |
| 2 | * Both doors for the way-back link (#11802): the terminal screen lists the | |
| 3 | * accounts the page offers a button for, Enter mints the link with the same | |
| 4 | * action and shows it once, and anyone but an admin is told it is not there. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { linkEdgeLogin } from "../src/core/edge_links.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { sha256 } from "../src/kernel/hash.ts"; | |
| 14 | import type { Ctx } from "../src/tui/app/app.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { wayBackScreen, entry } from "../src/tui/screens/wayback.ts"; | |
| 17 | import type { Session } from "../src/tui/session.ts"; | |
| 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 19 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | let dir = ""; | |
| 26 | let app: App; | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-tui-way-back-")); | |
| 30 | app = createApp({ dataDir: dir, port: 0 }); | |
| 31 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 32 | if (!a.ok) throw new Error(a.error); | |
| 33 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: false }); | |
| 34 | if (!b.ok) throw new Error(b.error); | |
| 35 | const insert = (name: string) => | |
| 36 | app.db | |
| 37 | .query("INSERT INTO user (id, username, kind, email, display_name, password_hash) VALUES (?1, ?1, 'user', ?1 || '@users.noreply.test', ?1, '')") | |
| 38 | .run(name); | |
| 39 | insert("stranded"); | |
| 40 | linkEdgeLogin(app.db, getUserByName(app.db, "stranded")!, "stranded-login"); | |
| 41 | insert("unmapped"); | |
| 42 | }); | |
| 43 | afterAll(async () => { | |
| 44 | app.close(); | |
| 45 | await rm(dir, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | function boot(username: string) { | |
| 49 | const user = getUserByName(app.db, username)!; | |
| 50 | const { app: tui } = createForgeApp( | |
| 51 | app, | |
| 52 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 53 | { terminal: headlessTerminal(140, 32), exitHooks: false }, | |
| 54 | ); | |
| 55 | tui.start(); | |
| 56 | const key = async (k: string, ctrl = false) => { | |
| 57 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 58 | await settleApp(tui); | |
| 59 | }; | |
| 60 | return { tui, key, text: () => tui.screen.toText() }; | |
| 61 | } | |
| 62 | ||
| 63 | const handover = () => | |
| 64 | app.db.query<{ token_hash: string; by_id: string }, [string]>("SELECT token_hash, by_id FROM edge_handover WHERE user_id = ?1").get("stranded"); | |
| 65 | ||
| 66 | test("an admin makes the way-back link in the terminal; it is the one the recover step spends", async () => { | |
| 67 | const t = boot("alice"); | |
| 68 | await t.key("k", true); | |
| 69 | for (const ch of "Way-back links") await t.key(ch); | |
| 70 | await t.key("enter"); | |
| 71 | expect(t.text()).toContain("stranded"); | |
| 72 | expect(t.text()).toContain("edge login stranded-login"); | |
| 73 | expect(t.text()).not.toContain("unmapped"); | |
| 74 | expect(handover()).toBeNull(); | |
| 75 | ||
| 76 | await t.key("enter"); | |
| 77 | const shown = t.text(); | |
| 78 | expect(shown).toContain("Hand this to stranded (edge login stranded-login)"); | |
| 79 | expect(shown).toContain("not shown again"); | |
| 80 | const token = /\/recover\?token=([A-Za-z0-9_-]+)/.exec(shown)?.[1]; | |
| 81 | expect(token).toBeDefined(); | |
| 82 | const row = handover()!; | |
| 83 | expect(row.token_hash).toBe(sha256(token!)); | |
| 84 | expect(row.by_id).toBe(getUserByName(app.db, "alice")!.id); | |
| 85 | ||
| 86 | // Gone once the screen is: back on the list, the token is not on screen. | |
| 87 | await t.key("esc"); | |
| 88 | expect(t.text()).toContain("edge login stranded-login"); | |
| 89 | expect(t.text()).not.toContain(token!); | |
| 90 | t.tui.stop(); | |
| 91 | }); | |
| 92 | ||
| 93 | test("anyone but an admin is not offered it, and built directly it mints nothing", async () => { | |
| 94 | const before = handover(); | |
| 95 | const bob = getUserByName(app.db, "bob")!; | |
| 96 | expect(entry.offered({ user: bob } as unknown as Session)).toBe(false); | |
| 97 | expect(entry.offered({ user: getUserByName(app.db, "alice")! } as unknown as Session)).toBe(true); | |
| 98 | ||
| 99 | const t = boot("bob"); | |
| 100 | await t.key("k", true); | |
| 101 | for (const ch of "Way-back links") await t.key(ch); | |
| 102 | expect(t.text()).not.toContain("a link that signs in an account the edge made"); | |
| 103 | t.tui.stop(); | |
| 104 | ||
| 105 | const pushed: unknown[] = []; | |
| 106 | const screen = wayBackScreen( | |
| 107 | { db: app.db, app, user: bob } as unknown as Session, | |
| 108 | { push: (s: unknown) => pushed.push(s), invalidate() {} } as unknown as Ctx, | |
| 109 | ); | |
| 110 | screen.key({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 111 | expect(pushed).toEqual([]); | |
| 112 | expect(handover()).toEqual(before); | |
| 113 | }); |
new above
offline
Both doors: mintWayBack has only the web caller. Add the terminal screen in src/tui/ calling this same action, with a test.