PullMD Part 11: The Multi-User Pivot – Simply Explained
At the end of the last chapter, one item sat at the top of my to-do list: make the tool handle more than one user. Logins, passwords, separate accounts. I had not planned to build that this week.
Why This Jumped to the Front
The push came not from that item itself, but from the two items right below it. One wanted a way for the claude.ai web connector to log in through a standard handshake called OAuth (a protocol that lets you sign in to one service using your account at another). The other asked how Claude Desktop could connect to a self-hosted copy of PullMD. And Claude Desktop only supports that same OAuth handshake.
Both requests had the same prerequisite: the tool needs to know who is using it. Without user accounts, there is no OAuth. Without OAuth, neither request can be answered. So the ordering was not a matter of priority. It was a chain of dependencies. I could not tackle the two interesting problems without solving the boring one first.
The Plan: Three Modes
The most important rule for the update: do not break anyone who is already running this thing. According to GitHub Insights, over 300 people had downloaded their own copy in the two weeks since the Reddit post. If they pulled the newest version, they should not suddenly face a login screen they never asked for.
So I built three modes:
- Off (the default). Works exactly like before. No login, nothing to configure. If you change nothing, you notice nothing.
- Single admin. One user, one password, no public sign-up. For someone running their own private copy.
- Multi-user. Several accounts with self-managed passwords, for shared installations.
If you change nothing in your configuration, you get “Off”. If you want the login system, you write three lines in a config file and restart. Everything existing stays intact: new tables are added, but nothing old breaks.
Four Days, Thirty Commits
The list of changes between the old version and the new one looks long. It is not: most of it is tests and documentation. The real login system is a gatekeeper layer that checks every request, plus four new database tables for accounts, sessions, access keys, and fetch assignments. On top of that, one new column links each cached page to the user who fetched it.
Passwords are stored using a modern method called Argon2id. The special thing about it: it is deliberately slow and memory-hungry. That makes it extremely expensive to guess passwords by brute force. The settings follow the official recommendation from OWASP (an international organisation for web security). There is also a small trick against guessing account names: if someone tries to log in with a name that does not exist at all, the service pretends to check a password anyway. Otherwise an attacker could tell from the response time which accounts are real.
Sessions work through a small cookie in the browser. It lasts seven days and quietly extends itself as long as you keep using the tool. Inside the cookie is just a long random string. No personal data.
Access keys for programs. If you want to connect PullMD to other tools or scripts, you can generate special keys. These are never stored in plain text in the database. Only the first few characters stay visible, so you can tell which key is which in the list without the full key lying around anywhere.
Rescue tool. If someone forgets their admin password, there is a small command-line helper to reset it.
The whole system is designed so that every login path ends up at the same point. Whether someone comes in through a browser session, through an access key, or through the old token from version 1: the actual features only ask “who are you?”, never “how did you get in?”. That is why OAuth can later be plugged in as a fourth path without rewriting everything that already works.
Security Review, Published on Purpose
Before I tagged the release, I ran a security review: a separate Claude agent in the role of critic, picking the code apart. The result: one critical bug and two medium issues, all three fixed before the tag.
The critical bug was real. When the tool generated a new access key, it passed the key through the web address. That sounds harmless, but it is not. Web addresses end up everywhere: in browser history, in server logs, in referrer headers from other websites. The key could have surfaced in places where it should not be. Fix: the key now travels through a secure flash message inside the session. The address bar stays clean.
After those fixes, six smaller items stayed open. Not urgent, but worth knowing about. And here is the part I handled differently from the usual approach: I put all six items into a file inside the public repository, readable by anyone. Instead of quietly fixing some points and hiding the rest, anyone who installs PullMD on their own server can read through the list and decide for themselves whether any of those points matter for their own setup.
A Privacy Slip in the Release Notes
While writing the release notes for v2.0, Claude let an internal staging hostname slip through: a test address from my home network that had no business appearing in a public text. I caught it within minutes of publishing and corrected it with a quick edit command.
No real damage. But a clean example of the fact that release texts need the same pre-flight attention as code commits. I had no automatic check that scans for private addresses before a release goes out. That is now on the backlog.
The “Latest” Label Stayed Put
Software packages often have a “latest” label. If you use that label, every update gives you the newest version automatically. I deliberately did not move that label to v2.0.
Anyone whose setup grabs “latest” kept getting the old version for two more weeks. The new login system does not appear overnight on someone’s quiet little installation that they had been running without any login at all. If you actively wanted the new version, you could pin to a specific number. A scheduled agent moves the “latest” label on a fixed date, and only after a manual review.
A History Bug and Three Wrong CSS Attempts
In the last hours before the tag, two more things went wrong.
The first: the history page was empty, even though the cache was full. All the entries were sitting there, and the list stayed blank anyway. The migration script had set the right user link in one table but had completely skipped the fetch-assignment table. That table records who fetched which cached page, and the history view reads from exactly that table. A classic “worked on my machine because my test data was clean” problem. Anyone setting up fresh has the table filled from the start. Anyone upgrading from version 1 does not. The fix writes the missing rows after the fact, and it can run more than once without creating duplicates.
Smaller drama, different register: the login page. I turned the wrong knob three times. First more padding, then a layout property in, then back out again. The culprit was an old styling rule from a completely different part of the codebase that stretched every button to full width. That is why the whole login card kept collapsing. Three failed attempts for a single line.
The Real Lesson
The pivot was less code than expected. A login system is not some gigantic rebuild: four tables and a gatekeeper. Four days from version 1.2 to version 2.0, including the security review, tests, and documentation.
The actual lesson is somewhere else: the security review belongs in the release. If six smaller items stay open, the people running this tool on their own servers should know about them. Not just me.
What Comes Next
v2.0 shipped on the evening of May 2. The next logical step: the OAuth handshake for the claude.ai web connector. That was, after all, the reason I had started this whole detour.
What happened next was one of the most frustrating debugging stretches 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 →