For months, a guest could not join a hunt from its own page. The server had been willing the whole time — the client was reading the answer to a different question.
Vandeth
The founding rule of this product is that playing never requires an account. A visitor with three days in the country will not install an app, and they will not make one either. Scan a code, walk a trail, keep what you find — the account is for the things that outlive the afternoon.
So it was a bad afternoon when I opened a hunt's page signed out and it asked me to sign in with Google.
Both clients decided which door to draw by looking at the HTTP status of the hints endpoint:
It reads fine. It is even efficient — one request answering two questions. And /trail/:slug/hints sits behind a session guard, which means a signed-out reader is 401 whether or not they have ever joined anything.
So the membership branch was unreachable for exactly the person it was written for. Every signed-out visitor got the account wall, forever.
Hints narrow a token to a walkable area. They are the thing you get for signing in — the one bargain this product asks of anybody. Opens: hints.
A run is keyed by device, so a guest who scanned a poster is a player in every sense the system cares about. Opens: the station list, the wall, progress.
Written down like that, the bug is obvious. It was not obvious in the code, because the two gates were sitting behind one endpoint and one endpoint has only one status line. The client did not decide to conflate them; it inherited the conflation from the transport.
The join endpoint had no guard on it at all. It has always recorded a participant against a device id when there is no user, and its response carries a boolean called joinedAsGuest — a field that exists for precisely this person.
In production, from a hunt's page, it had never been true. Not once. The server had been ready to admit guests since the day it was written, and no client ever asked on their behalf.
If a flag exists for a case, and the case never happens, one of two things is true: the case is impossible, or nothing is reaching it. Both are worth an hour of somebody's time.
Membership is a fact about the reader, so it gets a response that states it, unguarded and never cached:
The door is now drawn from the answer to its own question, and the session gate goes back to doing the one job it was ever about. The sign-in panel still exists — it just lives under the Hints heading now, which is where it was true all along.
Something else fell out of asking properly. There is an endpoint for asking an owner to let you into an invite-only hunt, and the only caller anywhere was one screen on the phone. Reach such a hunt by its link — which is what a private hunt's link is for — and you were told you needed an invite and offered no way to ask for one. Four doors, four sentences: join, ask, you already asked, the season is over.
Every field in that payload is an affordance. Not one of them is a rule. Joining re-decides admission, leaving re-checks that you were in, and the authoring screen resolves by owner on the server and 404s for everybody else.
That is what makes it safe to answer at all. A wrongly drawn button becomes a cosmetic bug rather than an authorisation one — and a client that has to be trusted is a client that will eventually be wrong.
It also settles where isOwner may live. The public trail projection is CDN-cacheable on purpose, so a per-reader boolean on it is a manage button drawn on a stranger's hunt, or an owner's own button missing because the edge answered them with somebody else's copy. It rides the no-store payload instead. Caching policy is the security boundary; it is not a performance setting that happens to be nearby.
While in there: there was no way out of a hunt. Join one in Kampot in February and you were a member of it permanently; the only lever anybody had was closing their account, which is a spectacular price for "I am done with this one".
Leaving is a change of gate and never a deletion — the run stays, a kept token stays kept, and the pictures stay on the wall. It is a fourth participant status. And adding it exposed something that had been dormant:
A returning player has a row. So that bare insert would have collided with the unique index, done nothing at all, and returned a cheerful report that the join had succeeded. It was harmless duplication for as long as nobody could come back. All four paths go through one update-then-insert now.
There was an end-to-end test for this screen. It passed. It had always passed. It asserted:
The test encoded the bug as the specification. It was written the same week as the component, from the same misunderstanding, and then it sat there for months certifying that the wrong thing kept happening reliably.
It now asserts that an anonymous visitor is offered the hunt, and there is a new one beside it for the case the split made testable at last: a guest who has joined sees their station list, no join door, and the sign-in panel. Three assertions that can only all pass if the two gates are genuinely separate.
Tests protect you from regressions. They do not protect you from being wrong on purpose, and a green suite is not evidence that a product does what you meant.