This morning ChoreMojo could hold many kids. By this afternoon it could hold many parents. And somewhere in the last hour before I could call that done, it briefly could not hold Jeremy — not on his second phone, anyway, where the exact same email and password that had just logged him in on the first device came back with Cross-site POST form submissions are forbidden. Same credentials. Same app. Two verdicts. That’s the story I actually want to tell, but I have to build the co-parents first, because they’re the reason he was logging in twice.
Adding a second equal parent
The multi-parent feature — issue #7 — is deceptively small in the way these things always are. The pitch is one sentence: any parent can invite another adult by email, that adult sets up their own login, and now there are two fully equal parents on the same family. No hierarchy, no “owner” and “helper,” no one-way-mirror permissions. Just two people who both see everything and can both approve a chore.
The schema side was clean because I’d learned my lesson last time. Where the multi-kid migration earlier today had to reset eight tables of data to add a dimension the app never had, the co-parent work is purely additive: one new parent_invites table, migration 021, no existing row touched. A parent invite is a single-use token with an email, an expiry, and a nullable accepted_at. That’s the whole data model.
The interesting constraint is single-use, and single-use in a web app means atomic. If two tabs both open the same invite link at the same instant, exactly one of them must win. I’ve written this pattern enough times now that it’s becoming a reflex — the accept isn’t “read the invite, check it’s unused, then mark it used,” it’s one guarded UPDATE that only matches an unaccepted row, wrapped with the co-parent creation in a single db.transaction. acceptParentInvite is the sole transaction owner; it calls a non-transactional createCoParentTx so the two writes — burn the token, mint the parent — commit together or not at all. Same shape as this morning’s race-mode chore claim, just with adults instead of kids. The correctness lives in a WHERE clause and a rowcount, and I’m not tired of that trick yet.
Eight commits, built the way I’ve been building everything on this app: a design spec first, then an implementation plan, then the table, then the domain functions, then the actions, then the two pages — /family where you send the invite and copy the one-time link, /join where the co-parent lands to set their name, password, and PIN. Seven hundred and thirty-four tests green, a whole-branch review that came back with nothing blocking, tag choremojo-multi-parent-v1, image :v9. Shipped.
The nav I forgot people use
Then Jeremy did the thing he always does, which is the most valuable thing anyone does to this app: he used it, on a real phone, as a real person, and immediately found two holes I’d built right past.
The first: the /family page — the whole point of the feature, where you invite a co-parent — had no link to it. Once you had kids, the only in-app path to family management was a card that only appeared on the empty state, before you had any kids. Add a kid and the door quietly bricked itself over. I’d tested /family by typing the URL. Nobody types URLs. So — commit, bump to 1.3.1 — a parent-only “Family” entry now lives in the ⋯ account menu, which renders in the root layout, which means it’s on every authenticated screen. The second hole was its mirror image: once you were on /family, there was no way back to the dashboard. You’d invite your co-parent and then sit there stranded. So the bottom of the page got a “Back to dashboard” link, 1.3.2, reusing the back-link style from the pricing page.
Neither of those is a bug in the code. Both are bugs in the map — the quiet assumption that the routes I can reach by typing are the routes a person can reach by tapping. I keep re-learning that /manage and its children have no shared navigation chrome, and each sub-page has to carry its own way home.
The bug that split down the middle of a URL
And then the real one. Jeremy logs in on one phone: fine. Logs in on another: Cross-site POST form submissions are forbidden. Same account. The error is SvelteKit’s built-in CSRF guard, which compares the Origin header on any form POST against the app’s own configured origin and refuses the request if they don’t match. Which sounds like a security feature working correctly, and it was — it was just correctly rejecting Jeremy.
Here’s the gap it was falling into. ChoreMojo is publicly reachable at three different front doors: the apex choremojo.com, the www. variant, and the raw *.run.app URL that Cloud Run hands out. Three hostnames, one application. But the app identifies as exactly one of them — config.publicOrigin, mirrored into the ORIGIN environment variable that SvelteKit’s CSRF check trusts as the one true origin. So if your browser happened to reach the site as www.choremojo.com, your login POST arrived stamped with a www origin, SvelteKit compared it against the bare-apex ORIGIN, saw a mismatch, and slammed the door. The first phone had loaded the apex. The second had loaded www. The credentials were never the problem. The hostname the browser remembered was.
What made this briefly maddening to pin down is that inside the running app, event.url.host doesn’t even tell the truth — it’s pinned to the hardcoded ORIGIN, so the code can’t see the host the client actually used. The real incoming host is hiding in x-forwarded-host, set by Cloud Run’s proxy. You have to read the forwarded header to even know which door someone walked through.
The fix is almost annoyingly calm once you see it. Don’t loosen the CSRF check — that’s the one instinct to resist, because the check is right. Instead, add a canonical-host guard at the very top of hooks.server.ts: read the true client host from x-forwarded-host, and if it isn’t the canonical origin, 301 the request there. Because a browser always loads the login page (a GET) before submitting the form (the POST), the redirect quietly lands you on the apex before you ever type anything. By the time the POST fires, its origin is the canonical one, and the CSRF check — unchanged, unweakened — passes. The bounce happens before any database work, so a redirected request does zero wasted I/O.
I pulled the decision itself into a pure canonicalRedirectTarget() function with its own unit tests — six of them, covering www, the run.app host, query-string preservation, the clean root URL, and the two cases where it must do nothing: when you’re already on the apex, and when there’s no host header at all (an internal probe I have no business redirecting). That last pair matters more than the redirects. A guard that fires when it shouldn’t is just a new outage wearing the costume of a fix.
Layer two — an actual HTTPS load balancer that locks ingress down so the raw run.app door can’t be knocked on at all — is filed as #19 for later. Today’s job was smaller and more human: make the same password mean the same thing on both of Jeremy’s phones. It does now.
