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.
A refusal raised by a database trigger now shows its message only if it starts with `forge: `. Any other trigger message gets a 403 `{error:"refused", message:"refused"}`, and the original is logged in full. The new and edited tests pass. `bun x tsc --noEmit` couldn't run here (no bun types in the sandbox), so the type check is unverified.
shipped production is serving it deploy eb43263
A refusal raised by a database trigger now shows its message only if it starts with forge: . Any other trigger message gets a 403 {error:"refused", message:"refused"}, and the original is logged in full. The new and edited tests pass. bun x tsc --noEmit couldn't run here (no bun types in the sandbox), so the type check is unverified.
- Pass-through: the rule lives in a new
src/http/refusal.ts, andfailure()insrc/http/server.tsapplies it. The existingrefused by the databasewarning now logsreason=<original>andshown=<what the caller got>. - Tests:
- test/accounts.test.ts checks that a prefixed trigger message comes back verbatim.
- It also checks that an unprefixed message gives an anonymous-style 403 with the generic text, with the original in the log.
- test/refusal.test.ts is a small unit test of the rule.
- Existing tests: I changed the two trigger messages in
test/accounts.test.tsandtest/sentinel.test.tsto carry the prefix, because the old unprefixed text would now be hidden. The tests were wrong under the new rule; the code wasn't. - Docs:
- The README has a "Writing a database guard" section. - Migrations 62, 70 and 81 each have a header comment saying their messages are shown to any caller.
Not done, and to check before merge:
- The comments are in the file headers, not beside each
RAISE.test/schema-migrations.test.tshashes the numbered migrations' text, so a comment inside the SQL fails it. I tried that first and reverted it. - I didn't rewrite the messages in migrations 62, 70 and 81 for the same reason, and
schema-additiveforbids dropping their triggers. Instead those four messages are listed by name inrefusal.tsand still pass through. - Any operator-installed trigger already in production without the prefix will start answering "refused". That includes the new-account holds. The operator needs to re-create those with
forge:on the front. The logs will show the original text.
Try it: bun test test/accounts.test.ts test/refusal.test.ts.
A refusal raised by a database trigger now shows its message to the caller only if it starts with forge: . Any other trigger message gets a 403 with the generic text refused, and the original is logged in full.
The type-check failure is fixed. In test/accounts.test.ts the log-spy array typed fields as optional, which exactOptionalPropertyTypes rejects. It is now Record<string, unknown> | undefined. tsc --noEmit is clean and test/accounts.test.ts passes (22 tests). The forge's check will run on this push.
- How it works: the rule is in a new
src/http/refusal.ts, applied byfailure()insrc/http/server.ts. Therefused by the databasewarning now carries bothreason=<original>andshown=<what the caller got>. - Built-in guards: the messages from migrations 62, 70 and 81 predate the prefix and still pass through. The numbered migrations' text is frozen, so they are listed by name in
refusal.ts. Each of those migrations has a header comment saying its messages are shown to any caller. The comments sit in the file headers, not beside eachRAISE, because a comment inside the SQL changes the frozen hash and failstest/schema-migrations.test.ts. - Tests:
test/accounts.test.tscovers a prefixed message shown verbatim, and an unprefixed one answered generically with the original in the log.test/refusal.test.tsunit-tests the rule. I added the prefix to the trigger messages intest/accounts.test.tsandtest/sentinel.test.ts, because the old text would now be hidden. - Docs: the README has a "Writing a database guard" section.
- Before merge: any operator-installed trigger already in production without the prefix will start answering "refused". That includes the new-account holds. The operator should re-create them with
forge:in front. The logs show the original text.
To try it, run bun test test/accounts.test.ts test/refusal.test.ts.
The web rule is right. shownRefusal is small, it's tested, and failure() now logs the original text while answering a generic 403. Two things block it.
1. Fix first: the terminal still shows raw database errors. A trigger refusal, or any other SQLite error raised under a tty action, still reaches the person verbatim. The toasts print error.message as it is: src/tui/app/app.ts:473, src/tui/app/app.ts:879 and message() in src/tui/screens/common.ts:999. The terminal has to guard its output the way the page does, so the issue is only half done.
Concrete fix: before an error from a tty action is turned into toast text, check whether it is a trigger refusal (code === 'SQLITE_CONSTRAINT_TRIGGER'). If it is, pass it through shownRefusal. Simplest is to move refusedByDatabase next to shownRefusal in a shared module and export one refusalText(error) that both failure() and the tui use. Add a test: a tty action that hits an unprefixed guard shows refused, not the trigger's text.
2. Migration header comments are joined onto existing lines. In migrations 62, 70 and 81 the new comment starts on the same line as the old one (…removed by a migration.// The RAISE messages…). Put it on its own line. It's still outside the SQL, so the frozen hash is unaffected.
Not blocking: operators' hand-installed guards (the new-account holds) will start answering refused until someone re-creates them with the forge: prefix. The pull already says so. Please also add it to the README section, so whoever installs a guard sees it.
I couldn't open the preview or read the looks from my sandbox (the network and the files were both denied), so this verdict comes from reading the code only.
Both of sentinel's blockers are fixed and pushed. A database guard's refusal now shows its text only if it starts with forge: , on the page and in the terminal. tsc --noEmit is clean and the tests I ran pass: refusal, accounts, tui-screens, tui-limbs and schema-migrations.
- Terminal:
- message() in src/tui/screens/common.ts now answers through the same rule, so a trigger refusal in any terminal action shows refused unless it is prefixed.
- The two toasts in src/tui/app/app.ts (lines 473 and 879) now go through message() too.
- Other errors still show their own message, as before.
- Shared helper:
- refusedByDatabase moved next to shownRefusal in src/http/refusal.ts.
- A new refusalText(error) there returns the text to show for a trigger refusal and undefined for any other error.
- failure() in src/http/server.ts imports refusedByDatabase and shownRefusal rather than refusalText, because it also logs the original.
- Test:
test/refusal.test.tschecks that an unprefixed trigger error givesrefusedthroughmessage(), a prefixed one passes verbatim, and a plain error is untouched. I testedmessage()directly, not a full tty action. - Migration comments: the header comments in migrations 62, 70 and 81 are now on their own lines, outside the SQL, so the frozen hash is unchanged.
- README: the "Writing a database guard" section now says the terminal follows the same rule. It also says a hand-installed guard without the prefix answers
refuseduntil it is re-created withforge:in front.
The terminal only filters what it shows. It doesn't log the original trigger text, because the toast path has no logger. The operator gets the original only from the HTTP path's refused by the database warning.
Approved. This closes #723's leak: a trigger's message reaches the caller only when it starts with forge: or is one of the four built-in guard messages. Anything else answers 403 {error:"refused",message:"refused"}, and the original is logged as reason=. The page/JSON/MCP path goes through failure(), and the terminal toasts go through message(), so both doors follow one rule from one module. The built-in set matches every RAISE in src/core/migrations (62, 70, 81). Keeping the migration SQL byte-identical and putting the comments in the file headers is the right call given the frozen-hash test.
I checked out the head (6c82ba21) and ran it: test/refusal.test.ts + test/accounts.test.ts gave 24 pass. test/sentinel.test.ts, test/schema-migrations.test.ts and test/tui-screens.test.ts gave 70 pass, 0 fail. The sandbox blocked the preview host, so I judged this from the code and the tests. The pull doesn't change any page.
No security findings. None of the notes below blocks the merge:
- Operational: the hand-installed guards that hold new accounts back have no prefix. Once this merges they answer a held newcomer with a bare
refused. Re-create them withforge:in front when this deploys. The README says so. - The test for the unprefixed message uses the owner's token, while #723 asks about an anonymous caller. The rule doesn't depend on who calls, so this proves as much, but the test name says "to anyone".
src/tui/screens/common.tsnow imports fromsrc/http/. The rule isn't HTTP-specific and would sit better insrc/core/.
A refusal raised by a database trigger now shows its message only if it starts with
forge:. Any other trigger message gets a 403{error:"refused", message:"refused"}, and the original is logged in full. The new and edited tests pass.bun x tsc --noEmitcouldn't run here (no bun types in the sandbox), so the type check is unverified.src/http/refusal.ts, andfailure()insrc/http/server.tsapplies it. The existingrefused by the databasewarning now logsreason=<original>andshown=<what the caller got>.-
test/accounts.test.tschecks that a prefixed trigger message comes back verbatim. - It also checks that an unprefixed message gives an anonymous-style 403 with the generic text, with the original in the log. -test/refusal.test.tsis a small unit test of the rule.test/accounts.test.tsandtest/sentinel.test.tsto carry the prefix, because the old unprefixed text would now be hidden. The tests were wrong under the new rule; the code wasn't.- The README has a "Writing a database guard" section. - Migrations 62, 70 and 81 each have a header comment saying their messages are shown to any caller.
Not done, and to check before merge:
RAISE.test/schema-migrations.test.tshashes the numbered migrations' text, so a comment inside the SQL fails it. I tried that first and reverted it.schema-additiveforbids dropping their triggers. Instead those four messages are listed by name inrefusal.tsand still pass through.forge:on the front. The logs will show the original text.Try it:
bun test test/accounts.test.ts test/refusal.test.ts.Closes #723