building vodou.

My account gate was a modal, and the chat path never asked it

A login modal and a server check that define 'has an account' separately will drift. How mine drifted, the one-definition fix, and a 5-minute check for yours.

Chad Priest / / 9 min read

If your AI app has a sign-in wall, open devtools and look at where the wall actually is. On most local-first agent stacks I have read, it’s a component in the browser. The chat endpoint behind it checks one thing: is there a model provider to call. The UI asks whether the user has an account. The server asks whether it has an API key. Those are two different questions, and nobody decided they should be.

That was my setup. I built the account requirement into Vodou, a local-first AI system with persistent memory, MCP tool orchestration and agents. The requirement was a modal. The client showed it after calling /api/onboarding/status. The chat path never called anything. If you dismissed the modal, or went straight to a conversation URL, you got the same chat as a signed-in user.

My own planning doc had the fairest verdict on it: “Either it is free without an account, say so and drop the modal, or it is gated, and the gate lives server-side. The current state is the worst of both: it annoys honest users and stops nobody.”

The modal and the chat path had two definitions of “signed in”

The fix I shipped first did not decide whether Vodou needs an account. That was a product call, and I didn’t want code to make it by accident. It took away the refactor that stood between the decision and the code.

“Does this install have an account?” got exactly one definition: hasVodouAccount(), exported from MCP-servers/Vodou-Console/src/api/onboarding.ts. It’s the same module that answers the status endpoint the modal already calls. MCP-servers/Vodou-Console/src/llm.ts imports it and consults it at the top of chat(), before any other work happens. If the gate is on and there is no account, the turn ends with one clear sentence telling the user where to sign in. If the gate is off, nothing changes.

Two questionsmodal asks: has an account?chat path asks: has a provider?close the modal, keep chattingOne definitionhasVodouAccount() lives in onboarding.tsmodal status endpoint calls itchat() calls it before doing any work

The switch defaults to off, and it reads strictly. Only 1 or true (in any case) turn it on. 0, false, an empty string, yes, maybe and off all leave it disabled, and a test loops over every one of those. A paygate that switches itself on because someone typed “yes” into a config file is worse than one nobody set.

Two tests failed first, and both failures were fair

I wanted a test to pin the seam, not just the switch. The claim was that llm.ts imports the shared function and calls it once, from the refusal helper. It must never grow a second copy. A second copy is how the modal and the server drifted apart in the first place.

The first version of that test counted hasVodouAccount( in the source and got 2, not 1. The second hit was inside a comment explaining the function. The test was failing for the wrong reason. The fix was to strip comment lines before counting:

const codeLines = src
  .split('\n')
  .filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l));
const uses = codeLines.filter((l) => l.includes('hasVodouAccount(')).length;
expect(uses).toBe(1);

Then my pre-commit guard refused the commit. The test asserted on the literal import line, from './api/onboarding.js'. The guard reads a relative import string in a staged file as an import from that file’s own directory, and there is no api/ folder under __tests__/. The guard was right about what the string looked like, so I built the string from parts instead.

The default-off test also went red on my machine for weeks while passing in QA. The config module calls dotenv.config() on import. My developer .env turns the gate on on purpose, so it was already in process.env before the first assertion ran. The test that meant “with nothing set, is the gate off?” was really reading my local override. The fix was to clear the variable in both beforeEach and afterEach. A test that fails on one machine and passes on another is a failure nobody believes and a signal nobody reads.

The console suite finished at 123 files and 1,130 tests.

The gate I shipped at 15:33 was replaced at 16:08

Thirty-five minutes after that commit, the product decision arrived: using Vodou requires an account. That exposed the weak spot in what I had just built. The switch was TypeScript reading a .env file. Anyone with the install could set it to 0, or edit the compiled dist/llm.js. It asked honestly, and that counts for something. It was not a gate.

beforemodal only; chat pathnever asks15:33one definition, oneswitch, default off15:33switch lives in aneditable config file16:08gate compiled into thememory door

Before moving it, I mapped every route that reaches memory. There were four: the gateway chat, the IDE hooks for Cursor and Claude Code, the memory MCP server, and attached MCP clients. The map changed the design. The gateway never opens the memory database for recall. It’s a client of the daemon’s socket, just like the hooks. The attached-client route runs inside the engine. So there was one door, the engine’s memory verbs plus MCP server startup, and the gate belonged there, compiled in with no flag.

daemon socketdaemon socketin-processCLI, not yet gatedGateway chatIDE hooksMemory MCP servershells out to the CLIAttached MCP clientsEngine memory dooraccount verdict cached 15minGate the door every route passes, not each room.
Where the gate had to live

Two design calls there carry over to other stacks. First, the engine caches the verdict for 15 minutes. The obvious once-per-process check would have put a 5-second license call in front of every prompt, which is slow for the user and abusive to the license server. Second, the gate fails open when the license server can’t be reached, so someone on a plane keeps working. A good install is also never downgraded by one bad check. Get that wrong and every memory lane on the machine goes dark. Only the memory-serving verbs are gated. Status and diagnostics keep working, because a gate that hides the way out of itself is a trap.

I’m not calling this a security control. The gateway binds to 127.0.0.1 and the person running it owns the machine. Moving the gate took the bypass from “edit one config line” to “patch the binary”. That’s the honest ceiling on hardware the user owns, and I’d rather say so than claim a lock.

Invariant: one predicate for “allowed”, called on the server path, before any work

Here is the property I now check for, stated so you can test it: every access predicate the UI shows has exactly one definition, and that same definition is called on the server path of every route that reaches the protected resource, before that route does any work.

A codebase either meets that or it doesn’t. Mine failed it two ways. There were two definitions, “has an account” in the client and “has a provider” on the server. And the protected resource, memory, had four routes, only one of which a person ever saw the modal on.

Check your own gate in five minutes

First, find every definition of your access predicate. Swap in your own names:

grep -rnE "(isSignedIn|hasAccount|isAuthenticated|requireAuth|isPaid|hasSubscription)\s*[(=:]" \
  --include='*.ts' --include='*.tsx' --include='*.js' --include='*.py' . \
  | grep -v node_modules | grep -v -E '__tests__|\.test\.|\.spec\.'

Passing looks like one definition, with UI files calling it or calling an endpoint backed by it. Failing looks like a useAuth() hook in the client and a separate check in a server handler (or no server check at all). Two definitions will drift eventually.

Second, go around the UI. Take the request your frontend sends for a chat turn, strip the session cookie or token, and send it directly:

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:3000/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"message":"ping"}'

Passing is a 401 or 403 with a readable reason, returned before any model call. Check your provider dashboard to confirm no tokens were spent. Failing is a 200, or a 200 that streams a reply. If your stack uses WebSockets, do the same with websocat and the first message your client sends.

Third, list every route that reaches the resource you’re protecting: HTTP, sockets, MCP servers, CLI subcommands, cron jobs. Repeat step two for each one. My failure was on the route I hadn’t listed.

What the gateway-and-policy writing leaves out

There’s a lot of good writing on putting a deterministic layer between an agent and its tools. The GATE framework states as its core invariant that “agent runtimes never call tools or memory directly,” and routes every side effect through enforcement. agentgate and jlov7/agentgate both wrap MCP servers so that every tools/call passes a policy check. That’s the right idea for tool calls.

What these frameworks assume is that you’ve already found every door. A policy proxy in front of your MCP server does nothing for a route that shells out to a CLI next to it. They also say little about the quieter failure: the same predicate defined twice, once where users see it and once where it’s enforced. Anthropic’s advice to find the simplest solution possible applies here as well. One function, called in one place per route, is simpler than a policy engine, and it’s the part that has to be right before a policy engine helps.

Still open: one memory route shells out around the gate

The memory MCP server reaches memory by shelling out to the CLI’s memory subcommands. At the time of writing those subcommands weren’t gated. It’s the one edge marked bad in the diagram above. I found it by drawing the map, which is the argument for drawing the map. I haven’t closed it yet.

Two negative paths were tested but not run live. With the gate on and no account, the chat refusal is covered by unit tests. Proving it live would have meant deleting the token from a working install. The engine’s refusal was proven through MCP server startup, which exits with a logged reason. It wasn’t proven through a daemon restart without credentials, because that would have left every other session on the machine without memory. There’s one more gap: an MCP client can’t see the refusal at all, because stderr goes to a log file to keep the protocol stream clean.

If you’re building an agent that keeps memory across tools and wants one place that decides who gets to read it, that’s what vodou.ai is: one memory, one door, and every route through it mapped.