Public: anyone can read this; the crew can change it.
A refusal raised by a database guard is shown to anyone, so its words must be written for anyone #2557
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/723-a-refusal-raised-by-a-database-guard-is → main 3 commits fork 7 threads · 7 unresolved
README.md +11
| @@ -1515,3 +1515,14 @@ Not done: the SVG route is unchanged and still the card's source of truth; the | ||
| 1515 | 1515 | rasterizer runs on the main thread (about 100 ms once per distinct card). |
| 1516 | 1516 | |
| 1517 | 1517 | Every read of a flag is counted in memory by UTC day and variant (on or off) and written in one batch every ten seconds and at exit, so a request runs no extra query. Beside each flag, Settings → Flags shows its reads per variant for the last 7 and 30 days. When one variant leads, "Make on/off the default" sets the flag to that value for everyone (user, role and percentage rules are cleared), and opens an issue on the forge's repository asking the crew to remove the losing path and the flag; if the issue cannot be opened, the flag is left as it was. The button shows only when the 30-day leader has at least 50 reads in all, the variants are not tied, and the flag's forge value is not already the leader. In the terminal the Flags screen shows the same `Uses, 7 days / 30 days` line for the flag under the cursor, and `w` does the same as the button (same admin, non-agent gate). Counts are of reads, not of people. |
| 1518 | ||
| 1519 | ## Writing a database guard | |
| 1520 | ||
| 1521 | A trigger's `RAISE(ABORT, '…')` is answered as a 403 to every caller, | |
| 1522 | anonymous ones included. Start the message with `forge: ` and write it for | |
| 1523 | anyone: that is what is shown, verbatim. Any other message is answered with | |
| 1524 | `refused` and logged in full (`refused by the database`, `reason=…`) for the | |
| 1525 | operator. (The three guards of migrations 62, 70 and 81 predate the prefix and | |
| 1526 | are known by name in `src/http/refusal.ts`.) The terminal's toasts follow the | |
| 1527 | same rule. A guard installed by hand without the prefix answers `refused` | |
| 1528 | until it is re-created with `forge: ` in front of its message. |
src/core/migrations/0062-only-an-agent-is-ever.ts +3
| @@ -4,6 +4,9 @@ | ||
| 4 | 4 | // check (`REPO_WRITABLE`'s crew clause) and `onCrew` can never drift. |
| 5 | 5 | // A person already there is reported at boot (`peopleOnCrews`), never |
| 6 | 6 | // removed by a migration. |
| 7 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 8 | // (#723); they are written for anyone, and are known by name in | |
| 9 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 7 | 10 | export default ` |
| 8 | 11 | CREATE TRIGGER repo_crew_agents_only BEFORE INSERT ON repo_crew |
| 9 | 12 | WHEN NOT EXISTS (SELECT 1 FROM user WHERE id = NEW.user_id AND is_agent = 1) |
src/core/migrations/0070-a-sealed-repository-is-never.ts +3
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | // 70 — a sealed repository is never public, said by the database (#865's |
| 2 | 2 | // review): the rule lived in `updateRepoSettings`; these triggers hold |
| 3 | 3 | // it for every writer of `repo.visibility`, now and later. |
outdated
Same here: the comment is joined onto the previous line. Put it on its own line. |
||
| 4 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 5 | // (#723); they are written for anyone, and are known by name in | |
| 6 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 4 | 7 | export default ` |
| 5 | 8 | CREATE TRIGGER repo_sealed_never_public BEFORE UPDATE OF visibility, sealed_for ON repo |
| 6 | 9 | WHEN NEW.sealed_for IS NOT NULL AND NEW.visibility = 'public' |
src/core/migrations/0081-thoughts.ts +3
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | // row per thought, appended and never changed: the triggers refuse an |
| 3 | 3 | // UPDATE or DELETE, and there is no foreign key so no cascade can remove |
| 4 | 4 | // one when an account goes. The author is an id, not a reference. |
outdated
Same here: the comment is joined onto the previous line. Put it on its own line. |
||
| 5 | // The RAISE messages below are shown to any caller, anonymous ones included | |
| 6 | // (#723); they are written for anyone, and are known by name in | |
| 7 | // src/http/refusal.ts because the numbered migrations' text is frozen. | |
| 5 | 8 | export default ` |
| 6 | 9 | CREATE TABLE thought ( |
| 7 | 10 | id TEXT PRIMARY KEY, |
src/http/refusal.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * What a database guard's refusal may say to the caller (#723). A trigger's | |
| 3 | * `RAISE(ABORT, '…')` message is answered to anyone, anonymous callers | |
| 4 | * included, so only a message that opts in is shown: it starts with | |
| 5 | * `forge: ` and is written for anyone. Any other message is answered with a | |
| 6 | * generic text and logged in full for the operator. | |
| 7 | */ | |
| 8 | ||
| 9 | /** The prefix a guard puts on a message that is meant for the person refused. */ | |
| 10 | export const REFUSAL_PREFIX = "forge: "; | |
| 11 | ||
| 12 | /** What a caller is told when a guard's message did not opt in. */ | |
| 13 | export const GENERIC_REFUSAL = "refused"; | |
| 14 | ||
| 15 | /** | |
| 16 | * The messages this repository's own triggers (migrations 62, 70, 81) raised | |
| 17 | * before the prefix existed. A migration is never rewritten once it has run, | |
| 18 | * so these stay as written and are known by name. | |
| 19 | */ | |
| 20 | const BUILT_IN = new Set([ | |
| 21 | "only an agent can be on a crew", | |
| 22 | "a sealed repository is never public", | |
| 23 | "a thought is never changed", | |
| 24 | "a thought is never deleted", | |
| 25 | ]); | |
| 26 | ||
| 27 | /** The words to show for a guard's message: its own, or the generic text. */ | |
| 28 | export function shownRefusal(message: string): string { | |
This is the right rule, but only the HTTP door applies it. The tui prints thrown |
||
| 29 | return message.startsWith(REFUSAL_PREFIX) || BUILT_IN.has(message) | |
| 30 | ? message | |
| 31 | : GENERIC_REFUSAL; | |
| 32 | } | |
| 33 | ||
| 34 | /** | |
| 35 | * The original message of a refusal a database trigger raised | |
| 36 | * (`RAISE(ABORT, '…')`), or undefined for any other error. | |
| 37 | */ | |
| 38 | export function refusedByDatabase(error: unknown): string | undefined { | |
| 39 | if (!(error instanceof Error)) return undefined; | |
| 40 | const code = (error as { code?: unknown }).code; | |
| 41 | return code === "SQLITE_CONSTRAINT_TRIGGER" ? error.message : undefined; | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * The words to show for a thrown error if a database trigger refused, else | |
| 46 | * undefined: the one door both the page and the terminal answer through. | |
| 47 | */ | |
| 48 | export function refusalText(error: unknown): string | undefined { | |
| 49 | const refused = refusedByDatabase(error); | |
| 50 | return refused === undefined ? undefined : shownRefusal(refused); | |
| 51 | } |
src/http/server.ts +6 −14
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { refusedByDatabase, shownRefusal } from "./refusal.ts"; | |
| 8 | 9 | import { NOT_A_MEMBER } from "../core/roles.ts"; |
| 9 | 10 | import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts"; |
| 10 | 11 | import { toolbarComment, toolbarPage } from "../web/studio/toolbar.ts"; |
| @@ -1857,25 +1858,16 @@ export interface Servers { | ||
| 1857 | 1858 | } |
| 1858 | 1859 | |
| 1859 | 1860 | /** |
| 1860 | * A refusal a database trigger raised (`RAISE(ABORT, '…')`): an operator's | |
| 1861 | * guard saying no, in its own words. It is the answer, not a fault, so the | |
| 1862 | * person sees the reason and a 403 rather than "internal". | |
| 1863 | */ | |
| 1864 | function refusedByDatabase(error: unknown): string | undefined { | |
| 1865 | if (!(error instanceof Error)) return undefined; | |
| 1866 | const code = (error as { code?: unknown }).code; | |
| 1867 | return code === "SQLITE_CONSTRAINT_TRIGGER" ? error.message : undefined; | |
| 1868 | } | |
| 1869 | ||
| 1870 | /** | |
| 1871 | * What a thrown handler is answered with: the guard's own words and a 403 | |
| 1861 | * What a thrown handler is answered with: the guard's words (or a generic | |
| 1862 | * "refused") and a 403 | |
| 1872 | 1863 | * when a database trigger refused, else a 500 that says nothing of the fault. |
| 1873 | 1864 | */ |
| 1874 | 1865 | function failure(app: App, error: unknown): Response { |
| 1875 | 1866 | const refused = refusedByDatabase(error); |
| 1876 | 1867 | if (refused !== undefined) { |
| 1877 | app.log.warn("refused by the database", { reason: refused }); | |
| 1878 | return securityFloor(json({ error: "refused", message: refused }, 403)); | |
| 1868 | const shown = shownRefusal(refused); | |
| 1869 | app.log.warn("refused by the database", { reason: refused, shown }); | |
| 1870 | return securityFloor(json({ error: "refused", message: shown }, 403)); | |
| 1879 | 1871 | } |
| 1880 | 1872 | app.log.error("unhandled request error", { error: String(error) }); |
| 1881 | 1873 | return securityFloor(json({ error: "internal" }, 500)); |
src/tui/app/app.ts +3 −2
| @@ -63,6 +63,7 @@ import { | ||
| 63 | 63 | type CursorPos, |
| 64 | 64 | } from "../ui/widgets.ts"; |
| 65 | 65 | import { binding, find, keyFor, type Binding } from "./keys.ts"; |
| 66 | import { message } from "../screens/common.ts"; | |
| 66 | 67 | import { Palette, type Command, type PaletteSearch } from "./palette.ts"; |
| 67 | 68 | import { truncate } from "../term/width.ts"; |
| 68 | 69 | |
| @@ -470,7 +471,7 @@ export class App { | ||
| 470 | 471 | // Every failure becomes a toast; nothing throws into the loop, and no |
| 471 | 472 | // single bad read may drop the session. |
| 472 | 473 | if (!entry.abort.signal.aborted) |
| 473 | this.toast(e instanceof Error ? e.message : String(e), "danger"); | |
| 474 | this.toast(message(e), "danger"); | |
| 474 | 475 | }) |
| 475 | 476 | .finally(() => { |
| 476 | 477 | entry.loading = false; |
| @@ -876,7 +877,7 @@ export class App { | ||
| 876 | 877 | private run(command: Command): void { |
| 877 | 878 | this.overlay = "none"; |
| 878 | 879 | void Promise.resolve(command.run()).catch((err: unknown) => |
| 879 | this.toast(err instanceof Error ? err.message : String(err), "danger"), | |
| 880 | this.toast(message(err), "danger"), | |
| 880 | 881 | ); |
| 881 | 882 | } |
| 882 | 883 |
src/tui/screens/common.ts +3
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * of them. Nothing here knows about repos or issues — that is the point. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { refusalText } from "../../http/refusal.ts"; | |
Low (layering): the terminal now depends on |
||
| 10 | 11 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding, type Binding } from "../app/keys.ts"; |
| @@ -996,6 +997,8 @@ export function errorText(code: string): string { | ||
| 996 | 997 | |
| 997 | 998 | /** Anything thrown, as a string — the toast text of last resort. */ |
| 998 | 999 | export function message(error: unknown): string { |
| 1000 | const refused = refusalText(error); | |
| 1001 | if (refused !== undefined) return refused; | |
| 999 | 1002 | return error instanceof Error ? error.message : String(error); |
| 1000 | 1003 | } |
| 1001 | 1004 |
test/accounts.test.ts +36 −2
| @@ -598,7 +598,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 598 | 598 | test("a refusal the database raises answers 403 with its reason, not 500", async () => { |
| 599 | 599 | // An operator's guard, the way production holds new accounts back. |
| 600 | 600 | app.db.exec( |
| 601 | "CREATE TRIGGER test_refuse BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, 'new repositories are paused here'); END;", | |
| 601 | "CREATE TRIGGER test_refuse BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, 'forge: new repositories are paused here'); END;", | |
| 602 | 602 | ); |
| 603 | 603 | try { |
| 604 | 604 | const res = await api("/api/v1/repos", { |
| @@ -609,7 +609,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 609 | 609 | expect(res.status).toBe(403); |
| 610 | 610 | expect(await res.json()).toEqual({ |
| 611 | 611 | error: "refused", |
| 612 | message: "new repositories are paused here", | |
| 612 | message: "forge: new repositories are paused here", | |
| 613 | 613 | }); |
| 614 | 614 | // The refusal leaves nothing on disk. |
| 615 | 615 | expect(existsSync(repoDir(app.reposRoot, "olive", "guarded"))).toBe(false); |
| @@ -618,6 +618,40 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 618 | 618 | } |
| 619 | 619 | }); |
| 620 | 620 | |
| 621 | test("a refusal without the forge: prefix is answered generically to anyone, and logged in full (#723)", async () => { | |
| 622 | const secret = "constraint on table repo_internal_x failed"; | |
| 623 | app.db.exec( | |
| 624 | `CREATE TRIGGER test_refuse_plain BEFORE INSERT ON repo BEGIN SELECT RAISE(ABORT, '${secret}'); END;`, | |
| 625 | ); | |
| 626 | const logged: { message: string; fields: Record<string, unknown> | undefined }[] = []; | |
| 627 | const log = app.log; | |
| 628 | (app as { log: typeof log }).log = { | |
| 629 | ...log, | |
| 630 | warn: (message, fields) => { | |
| 631 | logged.push({ message, fields }); | |
| 632 | log.warn(message, fields); | |
| 633 | }, | |
| 634 | }; | |
| 635 | try { | |
| 636 | const res = await api("/api/v1/repos", { | |
| 637 | method: "POST", | |
| 638 | token: ownerToken, | |
Low: #723's criterion says the generic text is shown "to an anonymous caller". This test uses the owner's token. The rule doesn't depend on who calls, so it holds, but either rename the test or add one sentence saying that. |
||
| 639 | body: JSON.stringify({ name: "guarded-plain" }), | |
| 640 | }); | |
| 641 | expect(res.status).toBe(403); | |
| 642 | expect(await res.json()).toEqual({ | |
| 643 | error: "refused", | |
| 644 | message: "refused", | |
| 645 | }); | |
| 646 | expect( | |
| 647 | logged.some((l) => l.fields?.reason === secret), | |
| 648 | ).toBe(true); | |
| 649 | } finally { | |
| 650 | (app as { log: typeof log }).log = log; | |
| 651 | app.db.exec("DROP TRIGGER test_refuse_plain"); | |
| 652 | } | |
| 653 | }); | |
| 654 | ||
| 621 | 655 | test("the owner can verify a newcomer's email, and it can create at once", async () => { |
| 622 | 656 | // Made first: the helper proves every account waiting on a link. |
| 623 | 657 | const { token: other } = await outsider("mallet"); |
test/refusal.test.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { GENERIC_REFUSAL, refusalText, shownRefusal } from "../src/http/refusal.ts"; | |
| 3 | import { message } from "../src/tui/screens/common.ts"; | |
| 4 | ||
| 5 | test("only a prefixed or built-in guard message is shown (#723)", () => { | |
| 6 | expect(shownRefusal("forge: paused for new accounts")).toBe( | |
| 7 | "forge: paused for new accounts", | |
| 8 | ); | |
| 9 | expect(shownRefusal("a thought is never changed")).toBe( | |
| 10 | "a thought is never changed", | |
| 11 | ); | |
| 12 | expect(shownRefusal("UNIQUE failed: secret_table.col")).toBe(GENERIC_REFUSAL); | |
| 13 | expect(shownRefusal("Forge: wrong case")).toBe(GENERIC_REFUSAL); | |
| 14 | }); | |
| 15 | ||
| 16 | test("a terminal action that hits a guard shows the shown text, not the trigger's (#723)", () => { | |
| 17 | const trigger = (text: string) => | |
| 18 | Object.assign(new Error(text), { code: "SQLITE_CONSTRAINT_TRIGGER" }); | |
| 19 | expect(message(trigger("secret_table.col is internal"))).toBe("refused"); | |
| 20 | expect(message(trigger("forge: paused for new accounts"))).toBe( | |
| 21 | "forge: paused for new accounts", | |
| 22 | ); | |
| 23 | expect(message(new Error("plain failure"))).toBe("plain failure"); | |
| 24 | expect(refusalText(new Error("plain failure"))).toBeUndefined(); | |
| 25 | }); |
test/sentinel.test.ts +1 −1
| @@ -159,7 +159,7 @@ describe("the faults of the day", () => { | ||
| 159 | 159 | // 5. a hand-installed guard that outlived its fix. |
| 160 | 160 | app.db.exec( |
| 161 | 161 | `CREATE TRIGGER steward_guard_repo BEFORE INSERT ON repo |
| 162 | BEGIN SELECT RAISE(ABORT, 'paused for new accounts'); END`, | |
| 162 | BEGIN SELECT RAISE(ABORT, 'forge: paused for new accounts'); END`, | |
Nit: this edit wasn't needed. The sentinel reads the trigger's SQL from |
||
| 163 | 163 | ); |
| 164 | 164 | }); |
| 165 | 165 |
The new comment is joined onto the end of the existing line (
migration.// The RAISE). Start it on its own//line.