Public: anyone can read this; the crew can change it.
A member cannot mint an admin-scope token; ro and rw tokens stay as they are #2570
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2425-a-member-cannot-mint-an-admin-scope-toke → main 2 commits fork 2 threads · 2 unresolved
16 files
changed +134
−113
README.md +4 −1
| @@ -114,7 +114,10 @@ forge, at the sign-in limits (429 past them). The account made is in the audit | ||
| 114 | 114 | as `user.created` `via: first-admin`, and the sign-in page links `/first-admin` |
| 115 | 115 | only while the forge has no admin. Later tokens come from Settings → Tokens, or |
| 116 | 116 | `POST /api/v1/tokens` from a signed-in session with `{"name", "scope"}`; the |
| 117 | admin adds `"adminConfirm": true` to mean a token that acts as admin. | |
| 117 | admin adds `"adminConfirm": true` to mean a token that acts as admin. `ro` and | |
| 118 | `rw` are open to anyone; a member asking for `admin` gets 403 ("Only an admin | |
| 119 | can make a token with admin scope; ro and rw are open to you."), and the | |
| 120 | Settings form offers `admin` only to an admin. | |
| 118 | 121 | |
| 119 | 122 | The same token spawns a child forge (`dryRun` is true unless `false`, so |
| 120 | 123 | without it you get the plan and nothing is made). The child's admin comes |
src/actions/users.ts +3 −1
| @@ -193,9 +193,11 @@ export function createToken( | ||
| 193 | 193 | name: string, |
| 194 | 194 | scope: TokenScope, |
| 195 | 195 | options: { expiresAt?: string } = {}, |
| 196 | ): Result<{ id: string; token: string }, "unauthorized"> { | |
| 196 | ): Result<{ id: string; token: string }, "unauthorized" | "forbidden"> { | |
| 197 | 197 | const user = requireUser(actor); |
| 198 | 198 | if (!user.ok) return user; |
| 199 | // A token that acts as admin is for an admin to hold; ro and rw are anyone's. | |
| 200 | if (scope === "admin" && !user.value.isAdmin) return err("forbidden"); | |
| 199 | 201 | const minted = mintToken(app.db, user.value.id, name, scope, options); |
| 200 | 202 | app.bus.append({ |
| 201 | 203 | kind: "token.created", |
src/core/tokens.ts +3
| @@ -13,6 +13,9 @@ import { getUserById, isActive, type User } from "./users.ts"; | ||
| 13 | 13 | |
| 14 | 14 | export type TokenScope = "ro" | "rw" | "admin"; |
| 15 | 15 | |
| 16 | /** What a non-admin is told when they ask for a token that acts as admin. */ | |
| 17 | export const ADMIN_TOKEN_WORDS = "Only an admin can make a token with admin scope; ro and rw are open to you."; | |
| 18 | ||
| 16 | 19 | const rank: Record<TokenScope, number> = { ro: 0, rw: 1, admin: 2 }; |
| 17 | 20 | |
| 18 | 21 | export function scopeAllows(held: TokenScope, needed: TokenScope): boolean { |
src/http/api.ts +3 −1
| @@ -221,7 +221,7 @@ import { | ||
| 221 | 221 | } from "../core/reviews.ts"; |
| 222 | 222 | import { createSession } from "../core/sessions.ts"; |
| 223 | 223 | import { mintProposal } from "../actions/proposal_tokens.ts"; |
| 224 | import { listTokens } from "../core/tokens.ts"; | |
| 224 | import { ADMIN_TOKEN_WORDS, listTokens } from "../core/tokens.ts"; | |
| 225 | 225 | import { |
| 226 | 226 | headInNamespace, |
| 227 | 227 | PROPOSAL_BODY_MAX, |
| @@ -1854,6 +1854,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1854 | 1854 | str(input.name) || "unnamed", |
| 1855 | 1855 | scope, |
| 1856 | 1856 | ); |
| 1857 | if (!minted.ok && minted.error === "forbidden") | |
| 1858 | return apiError("forbidden", 403, ADMIN_TOKEN_WORDS); | |
| 1857 | 1859 | if (!minted.ok) return unauthorized(); |
| 1858 | 1860 | return json(minted.value, 201); |
| 1859 | 1861 | }); |
src/web/pages/account.ts +6 −2
| @@ -31,7 +31,7 @@ import { ABILITIES, roleOf, rowOf, type Ability } from "../../core/roles.ts"; | ||
| 31 | 31 | import { listKeys } from "../../core/keys.ts"; |
| 32 | 32 | import { endAccountSession } from "../../actions/signins.ts"; |
| 33 | 33 | import { listSessions, type SessionInfo } from "../../core/sessions.ts"; |
| 34 | import { listTokens, type TokenScope } from "../../core/tokens.ts"; | |
| 34 | import { ADMIN_TOKEN_WORDS, listTokens, type TokenScope } from "../../core/tokens.ts"; | |
| 35 | 35 | import type { CommitEmail, User } from "../../core/users.ts"; |
| 36 | 36 | import { cookieValue, type Ctx } from "../../http/context.ts"; |
| 37 | 37 | import { sshCommand } from "../../ssh/command.ts"; |
| @@ -539,7 +539,9 @@ function tokensView(ctx: Ctx): Html { | ||
| 539 | 539 | options: [ |
| 540 | 540 | { value: "rw", label: "read + write — clone, push, open issues" }, |
| 541 | 541 | { value: "ro", label: "read only — clone and browse" }, |
| 542 | { value: "admin", label: "admin — everything, including settings" }, | |
| 542 | ...(ctx.auth.user?.isAdmin | |
| 543 | ? [{ value: "admin", label: "admin — everything, including settings" }] | |
| 544 | : []), | |
| 543 | 545 | ], |
| 544 | 546 | hint: "A token can never mint another token, whatever its scope.", |
| 545 | 547 | })} |
| @@ -594,6 +596,8 @@ export async function tokenCreate(ctx: Ctx): Promise<Response> { | ||
| 594 | 596 | field(form, "name").trim() || "token", |
| 595 | 597 | chosen, |
outdated
This leaves the settings page for a bare plain-text page with no way back. Use |
||
| 596 | 598 | ); |
| 599 | if (!minted.ok && minted.error === "forbidden") | |
| 600 | return flashTo(ctx, "/settings/tokens", { tone: "danger", message: ADMIN_TOKEN_WORDS }); | |
| 597 | 601 | if (!minted.ok) return signInFirst(ctx); |
| 598 | 602 | // Post-redirect-get: the secret rides a one-shot flash, so a refresh shows |
| 599 | 603 | // the list rather than minting a second token. |
test/apps-api.test.ts +2 −14
| @@ -17,6 +17,7 @@ import { getUserByName } from "../src/core/users.ts"; | ||
| 17 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 18 | 18 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; |
| 19 | 19 | import { seedAdmin } from "./helpers/admin.ts"; |
| 20 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 20 | 21 | |
| 21 | 22 | /** Read from an SSE body until `predicate(buffer)` holds or we time out — |
| 22 | 23 | * never to a "done" that #284's still-live stream no longer sends. */ |
| @@ -455,20 +456,7 @@ describe("apps, runs and the crew over the API", () => { | ||
| 455 | 456 | }, |
| 456 | 457 | null, |
| 457 | 458 | ); |
| 458 | const mallory = ( | |
| 459 | (await ( | |
| 460 | await post( | |
| 461 | "/api/v1/tokens", | |
| 462 | { | |
| 463 | username: "mallory", | |
| 464 | password: "a-good-passphrase", | |
| 465 | name: "admin", | |
| 466 | scope: "admin", | |
| 467 | }, | |
| 468 | null, | |
| 469 | ) | |
| 470 | ).json()) as { token: string } | |
| 471 | ).token; | |
| 459 | const mallory = tokenFor(app, "mallory", "admin", "admin"); | |
| 472 | 460 | expect((await api(env, { token: mallory })).status).toBe(404); |
| 473 | 461 | expect((await put("GREETING", "x", mallory)).status).toBe(404); |
| 474 | 462 | expect( |
test/cross-repo-pull.test.ts +2 −12
| @@ -7,6 +7,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 7 | 7 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 8 | 8 | import { proveAll } from "./helpers/verified.ts"; |
| 9 | 9 | import { promote } from "./helpers/members.ts"; |
| 10 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 10 | 11 | |
| 11 | 12 | const gitEnv = gitClientEnv({ |
| 12 | 13 | GIT_AUTHOR_NAME: "X", |
| @@ -56,18 +57,7 @@ describe("cross-repo pull requests (fork → upstream)", () => { | ||
| 56 | 57 | redirect: "manual", |
| 57 | 58 | }); |
| 58 | 59 | const cookie = (signup.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 59 | const token = await fetch(`${base}/api/v1/tokens`, { | |
| 60 | method: "POST", | |
| 61 | headers: { "content-type": "application/json" }, | |
| 62 | body: JSON.stringify({ adminConfirm: true, | |
| 63 | username, | |
| 64 | password: "cross-repo-pass", | |
| 65 | name: "c", | |
| 66 | scope: "admin", | |
| 67 | }), | |
| 68 | }) | |
| 69 | .then((r) => r.json()) | |
| 70 | .then((j) => (j as { token: string }).token); | |
| 60 | const token = tokenFor(app, username, "admin", "c"); | |
| 71 | 61 | return { token, cookie }; |
| 72 | 62 | } |
| 73 | 63 |
test/everyday.test.ts +21
| @@ -32,6 +32,7 @@ import { createUser, getUserByName } from "../src/core/users.ts"; | ||
| 32 | 32 | import { serve, type Servers } from "../src/http/server.ts"; |
| 33 | 33 | import { quietLogs } from "./helpers/globals.ts"; |
| 34 | 34 | import { passkeyFields } from "./helpers/passkey.ts"; |
| 35 | import { ADMIN_TOKEN_WORDS } from "../src/core/tokens.ts"; | |
| 35 | 36 | |
| 36 | 37 | quietLogs(); |
| 37 | 38 | |
| @@ -241,6 +242,26 @@ describe("the everyday account", () => { | ||
| 241 | 242 | ); |
| 242 | 243 | }); |
| 243 | 244 | |
| 245 | test("the token page keeps a member on the page with the sentence for admin scope, and makes rw", async () => { | |
| 246 | const kim = await signIn("kim"); | |
| 247 | const count = () => | |
| 248 | ( | |
| 249 | app.db | |
| 250 | .query("SELECT COUNT(*) AS n FROM token WHERE user_id = ?1") | |
| 251 | .get(getUserByName(app.db, "kim")!.id) as { n: number } | |
| 252 | ).n; | |
| 253 | const before = count(); | |
| 254 | expect(await page(kim, "/settings/tokens")).not.toContain('value="admin"'); | |
| 255 | const denied = await post(kim, "/settings/tokens", { name: "big", scope: "admin" }); | |
| 256 | expect(denied.status).toBe(303); | |
| 257 | expect(new URL(denied.headers.get("location")!, servers.url).pathname).toBe("/settings/tokens"); | |
| 258 | const flash = (denied.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 259 | expect(decodeURIComponent(flash.replace(/\+/g, " "))).toContain(ADMIN_TOKEN_WORDS); | |
| 260 | expect(count()).toBe(before); | |
| 261 | await post(kim, "/settings/tokens", { name: "small", scope: "rw" }); | |
| 262 | expect(count()).toBe(before + 1); | |
| 263 | }); | |
| 264 | ||
| 244 | 265 | test("from a page's session, the API refuses an admin's token without adminConfirm", async () => { |
| 245 | 266 | const root = await signIn("ada"); |
| 246 | 267 | const mint = (body: Record<string, unknown>) => |
test/fork-flow.test.ts +2 −13
| @@ -16,6 +16,7 @@ import { | ||
| 16 | 16 | } from "./fake-origin.ts"; |
| 17 | 17 | import { proveAll } from "./helpers/verified.ts"; |
| 18 | 18 | import { promote } from "./helpers/members.ts"; |
| 19 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 19 | 20 | |
| 20 | 21 | /** |
| 21 | 22 | * The fork flow: grab a repository you cannot push to, branch, edit, and |
| @@ -100,19 +101,7 @@ describe("the fork flow", () => { | ||
| 100 | 101 | password: PASSWORD, |
| 101 | 102 | }), |
| 102 | 103 | }); |
| 103 | const token = await read<{ token: string }>( | |
| 104 | await fetch(`${server.url}/api/v1/tokens`, { | |
| 105 | method: "POST", | |
| 106 | headers: { "content-type": "application/json" }, | |
| 107 | body: JSON.stringify({ adminConfirm: true, | |
| 108 | username, | |
| 109 | password: PASSWORD, | |
| 110 | name: "t", | |
| 111 | scope: "admin", | |
| 112 | }), | |
| 113 | }), | |
| 114 | ); | |
| 115 | return token.token; | |
| 104 | return tokenFor(server === originServers ? originApp : app, username, "admin"); | |
| 116 | 105 | } |
| 117 | 106 | |
| 118 | 107 | /** Seed a public repo on the upstream forge with one file on `main`. */ |
test/helpers/tokens.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import type { App } from "../../src/app.ts"; | |
| 2 | import { createToken } from "../../src/core/tokens.ts"; | |
| 3 | import { getUserByName } from "../../src/core/users.ts"; | |
| 4 | ||
| 5 | /** | |
| 6 | * A token of a scope for an account a suite already made, minted directly: | |
| 7 | * the API refuses `admin` to a non-admin (#2425), and suites that want a | |
| 8 | * member's full-account token take it here, as the operator's tools would. | |
| 9 | */ | |
| 10 | export function tokenFor(app: App, username: string, scope: "ro" | "rw" | "admin", name = "t"): string { | |
| 11 | const user = getUserByName(app.db, username); | |
| 12 | if (!user) throw new Error(`no account ${username}`); | |
| 13 | return createToken(app.db, user.id, name, scope).token; | |
| 14 | } |
test/orgs.test.ts +4 −13
| @@ -26,6 +26,7 @@ import { createUser, getUserByName } from "../src/core/users.ts"; | ||
| 26 | 26 | import { serve, type Servers } from "../src/http/server.ts"; |
| 27 | 27 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 28 | 28 | import { seedAdmin } from "./helpers/admin.ts"; |
| 29 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 29 | 30 | |
| 30 | 31 | const gitEnv = gitClientEnv({ |
| 31 | 32 | GIT_AUTHOR_NAME: "O", |
| @@ -218,19 +219,9 @@ describe("orgs over HTTP", () => { | ||
| 218 | 219 | }); |
| 219 | 220 | return tokenOf(username); |
| 220 | 221 | } |
| 221 | async function tokenOf(username: string): Promise<string> { | |
| 222 | return fetch(`${base}/api/v1/tokens`, { | |
| 223 | method: "POST", | |
| 224 | headers: { "content-type": "application/json" }, | |
| 225 | body: JSON.stringify({ adminConfirm: true, | |
| 226 | username, | |
| 227 | password: "org-http-pass", | |
| 228 | name: "c", | |
| 229 | scope: "admin", | |
| 230 | }), | |
| 231 | }) | |
| 232 | .then((r) => r.json()) | |
| 233 | .then((j) => (j as { token: string }).token); | |
| 222 | function tokenOf(username: string): string { | |
| 223 | // An account that could not be made (a reserved name) has no token, as before. | |
| 224 | return getUserByName(app.db, username) ? tokenFor(app, username, "admin", "c") : ""; | |
| 234 | 225 | } |
| 235 | 226 | |
| 236 | 227 | beforeAll(async () => { |
test/parity.test.ts +2 −6
| @@ -19,6 +19,7 @@ import { serve, type Servers } from "../src/http/server.ts"; | ||
| 19 | 19 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 20 | 20 | import { proveAll } from "./helpers/verified.ts"; |
| 21 | 21 | import { promote } from "./helpers/members.ts"; |
| 22 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 22 | 23 | |
| 23 | 24 | const gitEnv = gitClientEnv({ |
| 24 | 25 | GIT_AUTHOR_NAME: "A", |
| @@ -98,12 +99,7 @@ describe("parity: full control through actions and the API", () => { | ||
| 98 | 99 | }; |
| 99 | 100 | } |
| 100 | 101 | |
| 101 | const mint = async (user: string, scope: string) => | |
| 102 | ( | |
| 103 | await call("POST", "/api/v1/tokens", { | |
| 104 | body: { adminConfirm: true, username: user, password: PASS, name: scope, scope }, | |
| 105 | }) | |
| 106 | ).json.token as string; | |
| 102 | const mint = async (user: string, scope: "ro" | "rw" | "admin") => tokenFor(app, user, scope, scope); | |
| 107 | 103 | |
| 108 | 104 | const kinds = () => |
| 109 | 105 | listEvents(app.db, { limit: 1000 }) |
test/reviews-states.test.ts +2 −13
| @@ -6,6 +6,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | 7 | import { listEvents } from "../src/core/events.ts"; |
| 8 | 8 | import { seedAdmin } from "./helpers/admin.ts"; |
| 9 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 9 | 10 | |
| 10 | 11 | /** |
| 11 | 12 | * Review states: the verdict that gates a merge. |
| @@ -56,19 +57,7 @@ describe("review states, and the merge they gate", () => { | ||
| 56 | 57 | } |
| 57 | 58 | |
| 58 | 59 | async function mintToken(username: string): Promise<void> { |
| 59 | const token = await read<{ token: string }>( | |
| 60 | await fetch(`${base}/api/v1/tokens`, { | |
| 61 | method: "POST", | |
| 62 | headers: { "content-type": "application/json" }, | |
| 63 | body: JSON.stringify({ adminConfirm: true, | |
| 64 | username, | |
| 65 | password: PASSWORD, | |
| 66 | name: "t", | |
| 67 | scope: "admin", | |
| 68 | }), | |
| 69 | }), | |
| 70 | ); | |
| 71 | tokens[username] = token.token; | |
| 60 | tokens[username] = tokenFor(app, username, "admin"); | |
| 72 | 61 | } |
| 73 | 62 | |
| 74 | 63 | /** The pull number of a fresh branch with one file changed on it. */ |
test/secret-store.test.ts +2 −15
| @@ -20,6 +20,7 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 20 | 20 | import { open, seal } from "../src/kernel/secret.ts"; |
| 21 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 23 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 23 | 24 | |
| 24 | 25 | quietLogs(); |
| 25 | 26 | |
| @@ -49,21 +50,7 @@ describe("forge-level secrets", () => { | ||
| 49 | 50 | "content-type": "application/json", |
| 50 | 51 | }, |
| 51 | 52 | }); |
| 52 | const mint = async (username: string): Promise<string> => | |
| 53 | ( | |
| 54 | (await ( | |
| 55 | await fetch(`${servers.url}/api/v1/tokens`, { | |
| 56 | method: "POST", | |
| 57 | headers: { "content-type": "application/json" }, | |
| 58 | body: JSON.stringify({ adminConfirm: true, | |
| 59 | username, | |
| 60 | password: "a-good-passphrase", | |
| 61 | name: "t", | |
| 62 | scope: "admin", | |
| 63 | }), | |
| 64 | }) | |
| 65 | ).json()) as { token: string } | |
| 66 | ).token; | |
| 53 | const mint = async (username: string): Promise<string> => tokenFor(app, username, "admin"); | |
| 67 | 54 | |
| 68 | 55 | beforeAll(async () => { |
| 69 | 56 | dataDir = await mkdtemp(join(tmpdir(), "forge-secret-store-")); |
test/token-admin-scope.test.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * #2425: a member cannot mint an admin-scope token (403, one plain sentence); | |
| 3 | * ro and rw are unchanged, and an admin still mints admin. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, 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 { signUp } from "../src/actions/users.ts"; | |
| 12 | import { ADMIN_TOKEN_WORDS } from "../src/core/tokens.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 16 | import { promote } from "./helpers/members.ts"; | |
| 17 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const PASS = "a-long-member-passphrase"; | |
| 22 | ||
| 23 | describe("minting an admin-scope token", () => { | |
| 24 | let dataDir = ""; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | ||
| 28 | const mint = (username: string, scope: string) => | |
| 29 | fetch(`${servers.url}/api/v1/tokens`, { | |
| 30 | method: "POST", | |
| 31 | headers: { "content-type": "application/json" }, | |
| 32 | body: JSON.stringify({ username, password: PASS, name: "t", scope, adminConfirm: true }), | |
| 33 | }); | |
| 34 | ||
| 35 | beforeAll(async () => { | |
| 36 | dataDir = await mkdtemp(join(tmpdir(), "forge-token-admin-")); | |
| 37 | app = createApp({ dataDir, port: 0, url: "https://forge.example" }); | |
| 38 | servers = await serve(app); | |
| 39 | await seedAdmin(app, { username: "olive", email: "[email protected]", password: PASS }); | |
| 40 | const made = await signUp(app, { username: "bob", email: "[email protected]", password: PASS }, { slot: fixtureSlot(app) }); | |
| 41 | if (!made.ok) throw new Error(made.error); | |
| 42 | promote(app.db, made.value); | |
| 43 | }); | |
| 44 | ||
| 45 | afterAll(async () => { | |
| 46 | await servers.stop(); | |
| 47 | await rm(dataDir, { recursive: true, force: true }); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a member gets 403 for admin and 201 for rw and ro", async () => { | |
| 51 | const denied = await mint("bob", "admin"); | |
| 52 | expect(denied.status).toBe(403); | |
| 53 | expect(((await denied.json()) as { message: string }).message).toBe(ADMIN_TOKEN_WORDS); | |
| 54 | expect((await mint("bob", "rw")).status).toBe(201); | |
| 55 | expect((await mint("bob", "ro")).status).toBe(201); | |
| 56 | }); | |
| 57 | ||
| 58 | test("an admin can still mint admin", async () => { | |
| 59 | expect((await mint("olive", "admin")).status).toBe(201); | |
| 60 | }); | |
| 61 | }); |
test/web-workflows.test.ts +3 −22
| @@ -42,6 +42,7 @@ import { quietLogs } from "./helpers/globals.ts"; | ||
| 42 | 42 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| 43 | 43 | import { proveAll } from "./helpers/verified.ts"; |
| 44 | 44 | import { promote } from "./helpers/members.ts"; |
| 45 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 45 | 46 | |
| 46 | 47 | quietLogs(); |
| 47 | 48 | |
| @@ -140,17 +141,7 @@ describe("fork at the origin, and propose a change through it", () => { | ||
| 140 | 141 | }); |
| 141 | 142 | proveAll(upApp); |
| 142 | 143 | promote(upApp.db); |
| 143 | const made = await fetch(`${upServers.url}/api/v1/tokens`, { | |
| 144 | method: "POST", | |
| 145 | headers: { "content-type": "application/json" }, | |
| 146 | body: JSON.stringify({ adminConfirm: true, | |
| 147 | username, | |
| 148 | password: PASSWORD, | |
| 149 | name: "t", | |
| 150 | scope: "admin", | |
| 151 | }), | |
| 152 | }); | |
| 153 | return ((await made.json()) as { token: string }).token; | |
| 144 | return tokenFor(upApp, username, "admin"); | |
| 154 | 145 | } |
| 155 | 146 | |
| 156 | 147 | const upstream = ( |
| @@ -577,17 +568,7 @@ describe("verdicts, threads, and the gate they open", () => { | ||
| 577 | 568 | cookies[username] = (signed.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 578 | 569 | proveAll(app); |
| 579 | 570 | promote(app.db); |
| 580 | const token = await fetch(`${base}/api/v1/tokens`, { | |
| 581 | method: "POST", | |
| 582 | headers: { "content-type": "application/json" }, | |
| 583 | body: JSON.stringify({ adminConfirm: true, | |
| 584 | username, | |
| 585 | password: PASSWORD, | |
| 586 | name: "t", | |
| 587 | scope: "admin", | |
| 588 | }), | |
| 589 | }); | |
| 590 | tokens[username] = ((await token.json()) as { token: string }).token; | |
| 571 | tokens[username] = tokenFor(app, username, "admin"); | |
| 591 | 572 | } |
| 592 | 573 | |
| 593 | 574 | /** The first line of the diff, as a review anchor form would name it. */ |
new above
offline
Good: this is the one gate, and it reads the actor's stored role. Every caller (the API, the page and recovery) gets it for free.