PullMD Part 6: The Multi-User Pivot
At the end of Part 5, Issue #5 sat at the top of the list: “Architecture: Phase 1 — Multi-user system with sessions + API keys.” I hadn’t planned this — not for this week, not for this month. Then I was sitting in front of the issue tracker on the evening of April 29 and realized I didn’t really have any other sensible choice.
Why Multi-User Became Phase 1, Not Phase 2
The actual realization 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. Both issues were unsolvable without a user model. No user model means no OAuth. No OAuth means no web-connector-capable self-host.
What I had considered “nice to have” was actually the shared prerequisite for two other issues — and that hadn’t been clear to me before.
The ordering was therefore not a choice between priorities, but a question of dependencies. I couldn’t tackle #6 and #10 without resolving #5. So I started on the evening of April 29.
Plan: Three Auth Modes
The most important design point was that the update must not be breaking. Someone who had deployed PullMD since the Reddit post on April 28 — and according to GitHub Insights that was 320 unique cloners in two weeks — should not suddenly be locked out after a docker compose pull.
Three modes:
disabled(default) — behaves identically to v1.x. No login, no sessions, no new configuration required.single-admin— one user, no self-signup. For hobby self-hosters running their own PullMD instance.multi-user— multiple 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 their .env and restarts.
Implementation: 30 Commits Between v1.2.0 and v2.0.0
The git log between the two tags looks long, but isn’t — 30 commits, a large chunk of them tests and documentation. The actual auth system is a middleware layer and five new tables:
Schema additions: users, sessions (with a flash_data column for login messages), api_keys, user_fetches, plus a user_id column in conversions.
Password hashing: Argon2id with production parameters above the OWASP recommendation (t=3, m=64 MiB, p=4). Timing-attack protection via dummy-verify when the account doesn’t exist.
Sessions: Cookie, HttpOnly, SameSite=Lax, sliding expiry 7 days. Token as randomBytes(32).toString('hex') — 256 bits of entropy.
Per-user API keys: pmd_<32-base62> format, stored as SHA-256. Prefix visible for identification, hash never read back out. Generated via PRAGMA foreign_keys = ON, CASCADE on user-delete, SET NULL on conversions.user_id so that the cache entry itself is preserved when a user is deleted.
Admin CLI: node scripts/admin.js reset-password, list-users, make-admin — for the case where someone loses the admin password.
The req.user layer abstracts over session, API key, and legacy token: every route reads only req.user, regardless of which path the request came through. This means Phase 2 (OAuth for Issue #6) can be hooked in additively as a fourth auth path — the existing API and MCP routes don’t need to be touched for that.
REVIEW-FINDINGS.md Published Publicly
Before tagging, I ran a dedicated security review pass — subagent-driven, with Claude Code playing the security-reviewer and critic. The result: 1 CRITICAL and 2 SHOULD-FIX, both fixed before the tag.
The CRITICAL was a real bug: a newly generated API key was passed via redirect URL — POST /api/keys redirected to /settings?new_key=pmd_.... The key 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 normalize \ to /) and a missing admin check on DELETE /api/cache* in multi-user mode.
After the fixes, 6 NICE-TO-HAVE items remained open: modulo bias in key generation, 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 remained documented — in REVIEW-FINDINGS.md, which I deliberately committed publicly into the repo. The file is machine-readable and greppable: anyone who wants to self-host and doesn’t want to develop their own gut feeling for their threat model can cross-reference it against their own setup in five minutes.
Rather than quietly fixing the items or hiding them: anyone self-hosting PullMD should be able to decide for themselves whether they’re relevant to their own threat model. Transparency over polish.
Privacy Slip in the Release Notes
While writing the v2.0 release notes, an internal staging hostname accidentally slipped in — a test address from my home network that had no business showing up in a public release text.
gh release edit was the hotfix, within minutes of publishing.
I had no automated privacy lint in the release workflow. That would be worth a pre-push hook — it’s sitting as a backlog item.
No major damage, but a clean example of how release text needs the same pre-flight attention as code commits.
:latest Stays on v1.2.x — For Two Weeks
The decision not to automatically push the :latest Docker tag to v2.0 was deliberate.
Anyone who has image: aeternalabshq/pullmd:latest in their docker-compose.yml and runs docker compose pull continues to get v1.2.x. The new auth system doesn’t appear overnight on an instance someone is running 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 re-enables it.
I wanted to bind myself against the temptation to recommend it prematurely.
A Migration Bug and Three Wrong CSS Iterations
After the tag, the first admin got in touch: /api/history was empty after the migration, even though the cache was full. The backfill in the migration had set cache.user_id correctly, but had completely skipped user_fetches — the join table between cache entries and the requesting user. A classic “worked locally because my test dataset was clean” problem. Fix in commit 808990a, idempotent via LEFT JOIN ... WHERE uf.id IS NULL, so the patch can run multiple times without creating duplicates. The tag was already out, but the fix landed as a patch within the same hour.
Smaller drama, different register: the login page. Three wrong iterations — padding bump, flex with flex: 1, then dropping flex: 1 — before it turned out that a global button { width: 100% } from the old stylesheet was pulling the entire card layout. Commit 69ccc60 fixes it with a .card-head flex pattern and explicit width: auto on the language toggle. Force-push to remove the noise commits, one of the rare cases where that makes sense because the branch was still local-only. Three iterations for one line of CSS is not a defeat, but not exactly the most elegant path either.
The Actual Lesson
The pivot was less code than expected. Argon2id plus sessions plus API keys aren’t a big architectural lift — it’s five 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 is not the auth system itself: Ship the security review with the release. If six NICE-TO-HAVE items remain open, the self-hosters should know that, not just me.
Cliffhanger
v2.0 shipped on the evening of May 2. The next logical phase: OAuth for the claude.ai web connector, 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 wasn’t a bug I had made.
Part 6 of 9 in the PullMD series.
← PullMD Part 5: Reddit Day and First Issues | PullMD Part 7: Spec-Compliance Isn’t Enough →