One guard beats fifty route checks
LibrisVault’s hosted demo serves a read-only version of a real vault. Initially, one might assume that the obvious implementation would be to visit every mutating route and add a check. The obvious implementation is also how a read-only demo easily ends up leaking writes: route lists go stale the day another endpoint is added.
Put the guarantee where every request passes
Instead, the guarantee consists of one central hook at the front door of the API:
if (ctx.config.demoMode) {
app.addHook('onRequest', async (req, reply) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return reply.code(403).send({ error: 'demo_read_only',
message: 'This hosted demo instance is read-only.' })
}
})
}
The property this buys is peace of mind and fail-closure by construction: a route added next year is protected. HTTP semantics do the classification: writes travel on non-GET verbs, the guard does not need to know what routes exist, knowing what a read looks like will suffice.
The first version of this hook knew one thing more than it needed to: it also required the raw URL to start with /api/. That single piece of route knowledge was the piece that broke. Fastify decodes percent-encoding before it matches a route, while the hook saw the URL as it arrived, so a request to /%61pi/v1/pages passed the check and reached a handler. The fix was not a second condition but the removal of the first. Nothing outside the API accepts writes, so the verb alone is the boundary.
Picture the difference between a building with one staffed entrance and a building with fifty doors, for the latter, each door only remains secure for as long as someone remembers to keep it locked.
Defense does not stop at the door. In demo mode the service also starts passive: the ingest queue never starts, the inbox watcher and the vault reconciler stay detached, the Telegram bot is not wired up, and the one scheduled job is disabled. The read-only vault watcher keeps running, because it only emits change events. A demo instance is additionally deployed without a credential, which is a deployment convention rather than something the code enforces. Even if a request should somehow manage to sneak past the guard, it would land in a process that has no agent to spawn and no credential to spend.
Testing the boundary instead of the inventory
The single-guard design changes what the tests look like. Instead of enumerating routes and asserting each one refuses writes, a list that rots like the checks it replaced, the suite asserts the boundary itself: reads pass while a matrix of write verbs across representative paths, including paths that disguise the API prefix through percent-encoding or duplicated slashes, gets its 403 with the expected error code. The invariant under test matches the invariant in the code.
The rule: it is preferable to enforce a global property at a choke point every request must cross and write the tests against the property. Fifty local checks are fifty chances to forget one whereas a single guard is one thing to get right and one thing to verify.
The pattern is old: middleware, aspect-oriented programming, database permissions all rhyme with it. What agent-era services add is the stakes: when endpoints can trigger autonomous runs which can get expensive, the difference between a verified boundary and a maintained checklist is worth the additional effort.