PullMD Part 11: The Multi-User Pivot
At the end of Part 9, Issue #5 sat at the top of the list: “Architecture: Phase 1 - Multi-user system with sessions + API keys.” I hadn’t planned that. Not for this week.
Why Multi-User Became Phase 1, Not Phase 2
The real insight didn’t come from the issue itself, but from the two issues after it. Issue #6 wanted OAuth for the claude.ai web connector. Issue #10 asked how to authenticate Claude Desktop against a self-hosted PullMD. With the note that Claude Desktop’s UI only supports OAuth.
Neither issue was solvable without a user model. No user model means no OAuth. No OAuth means no web-connector-capable self-host.
So the ordering wasn’t a decision between priorities, it was a question of dependencies. I couldn’t tackle #6 and #10 without solving #5.
Plan: Three Auth Modes
The design point that mattered most to me was that the update must not be breaking. Anyone who had deployed PullMD since the Reddit post on April 28 should not suddenly be locked out after a docker compose pull. According to GitHub Insights that was 320 unique cloners in two weeks.
Three modes:
disabled(default): behaves identically to v1.x. No login, no sessions, no new configuration needed.single-admin: one user, no self-signup. For hobby self-hosters running their own PullMD.multi-user: several accounts with self-managed passwords, for shared instances.
Anyone who changes nothing in their .env gets disabled. The migration is additive: new tables, one new column in conversions, but nothing breaks. Anyone who wants v2 with auth sets three lines in the .env and restarts.
Implementation: 30 Commits Between v1.2.0 and v2.0.0
The git log between the two tags looks long, but it isn’t: 30 commits, a large chunk of them tests and documentation. The actual auth system is a middleware layer and four new tables.
The tables are called users, sessions, api_keys and user_fetches; on top of that comes a user_id column in conversions.
sessions also holds a flash_data column. That is how a message gets to the browser after login without ending up in the address bar.
Passwords sit in the database as Argon2id hashes. The procedure is deliberately slow and memory-hungry, so that guessing your way through gets expensive.
The parameters t=3, m=64 MiB, p=4 are the OWASP recommendation for production use: three passes over 64 megabytes of memory, four of them in parallel.
If someone signs in with an account that doesn’t exist at all, the service verifies a dummy hash anyway. Otherwise the answer would come back measurably faster and would give away which accounts exist.
After login a cookie sits with the user, HttpOnly and SameSite=Lax, so that no foreign JavaScript can get at it. The token inside it is randomBytes(32).toString('hex'), so 256 bits of randomness.
It is valid for seven days, counted on a sliding basis. Whoever uses the service stays signed in; whoever leaves it alone for two weeks signs in again.
For agents and scripts there are API keys instead, in the format pmd_<32-base62>. Only the SHA-256 hash of them is stored.
What stays visible is the prefix. That is how you can tell in the list which key is which, without having it lying around in plain text anywhere.
When an account is deleted, the database cleans up on its own, with PRAGMA foreign_keys = ON and CASCADE on the dependent rows.
Only conversions.user_id is on SET NULL. So the cache entry stays put when its user leaves, because the page that was fetched is still the same page.
On top of that a small command-line tool: node scripts/admin.js reset-password, list-users, make-admin. You need it on exactly one day, namely the one where someone has mislaid the admin password.
Over all of it lies a thin layer that turns the three routes into the same thing: session cookie, API key and the old token from v1 all end up in req.user.
So every route only asks who is there, not how they got in.
That is why OAuth from Issue #6 can be hung next to it later as a fourth route. The existing routes for API and MCP don’t have to be touched for it.
REVIEW-FINDINGS.md Published Publicly
Before the tag I did a security review pass of my own: subagent-driven, with Claude Code as security reviewer in the critic’s role. The result: 1 CRITICAL and 2 SHOULD-FIX, which were fixed before the tag.
The CRITICAL was a real bug: a newly generated API key was handed over via the redirect URL. POST /api/keys redirected to /settings?new_key=pmd_.... The key thereby ended up in the browser history, in server logs and potentially in Referer headers. Fix: flash message via the flash_data column in the session, redirect without query parameters.
The two SHOULD-FIX items were an open redirect via ?next=/\evil.com in the login flow (browsers normalise \ to /) and a missing admin check on DELETE /api/cache* in multi-user mode.
After the fix, 6 NICE-TO-HAVE items stayed open: modulo bias in key generation, a missing FK constraint on user_fetches.cache_id, share-link IDs with 32-bit entropy (an existing v1 problem), no rate limit on login/signup, X-Forwarded-Proto trust without a proxy allowlist, legacy token without a minimum-entropy recommendation.
All six stayed documented: in REVIEW-FINDINGS.md, which I deliberately put into the repo in public. The file is machine-readable and greppable. Anyone who wants to self-host and has no gut feeling for their own threat model can check it against their own setup.
Instead of quietly fixing the points or hiding them: anyone self-hosting PullMD should be able to decide for themselves whether they matter for their own threat model.
Privacy Slip in the Release Notes
While writing the v2.0 release notes, Claude let an internal staging hostname slip through: a test address from my home network that had no business being in a public release text.
gh release edit was the hotfix, within minutes of publishing.
I had no automatic privacy lint in the release workflow. That would be worth a pre-push hook. It’s on the backlog.
No great damage, but a clean example of the fact that release texts need the same pre-flight attention as code commits.
:latest Stays on v1, For Two Weeks
The decision not to push the :latest Docker tag to v2.0 automatically was deliberate.
Anyone with image: aeternalabshq/pullmd:latest in their docker-compose.yml who runs docker compose pull still gets v1. The new auth system doesn’t show up overnight on an instance someone runs as a single-URL tool without auth.
Anyone who explicitly wants v2 pins to :2. A scheduled agent flips the :latest pointer on May 16 via PR: review-gated, no auto-merge.
Commit 2bf9cb0 disables the corresponding CI line; the agent comments it back in.
I wanted to stop myself from recommending the new version too early.
A Migration Bug and Three Wrong CSS Iterations
In the last hours before the tag, two more things went wrong.
The first: /api/history was empty after the migration, even though the cache was full. All the entries were sitting there, and the list stayed blank anyway.
The backfill had set cache.user_id correctly, but had left out user_fetches. That is the table which records who fetched which cache entry, and that is exactly what /api/history reads from.
A classic “worked locally because my test dataset was clean” problem. Anyone setting up fresh has the table filled from the start. Anyone upgrading from v1 doesn’t.
The fix in commit 808990a writes the missing rows afterwards, via LEFT JOIN ... WHERE uf.id IS NULL. That way the migration can run more than once without creating duplicates.
Smaller drama, different register: the login page.
I turned the wrong screw three times. First more padding, then flex: 1, then flex: 1 back out again.
The culprit was a button { width: 100% } from the old stylesheet. The rule applied globally and dragged the whole card layout along with it.
Commit 69ccc60 clears that up with a .card-head flex pattern, plus an explicit width: auto for the language switcher. I threw the three failed attempts out of the history with a force push, which was fine here for once, because the branch was still only local.
The Actual Lesson
The pivot was less code than expected. Argon2id plus sessions plus API keys are not a big architectural rebuild: that’s four tables and an auth middleware layer. Four days from v1.2.0 to v2.0.0, including security review, tests and documentation.
The actual lesson isn’t the auth system itself: the security review belongs in the release. If six NICE-TO-HAVE items stay open, the self-hosters should know that, not me alone.
Cliffhanger
v2.0 shipped on the evening of May 2. The next logical phase: OAuth for claude.ai web, Issue #6. The branch was ready as feat/oauth.
What happened then was one of the most frustrating debugging experiences in a long time. And the frustrating part was that I spent a long time looking at the wrong end.
Part 11 of 15 in the PullMD series.
← PullMD Part 10: Locking It Down, Properly | PullMD Part 12: Spec-Compliance Isn’t Enough →