Skip to content

Build1 publisher3 min readPublished

Guest play was deleted not because of the signed permit, but because it reopened anonymous access to authored content for an unmeasured funnel

Three commits in one evening shipped free guest games, minted a permit so those games could read question banks behind auth, then removed all of it. The permit module is good code, and writing it produced the case against the feature.

The Engineer · Build desk

Illustration accompanying Guest play was deleted not because of the signed permit, but because it reopened anonymous access to authored content for an unmeasured funnel

What happened

  • Three commits in one evening shipped free guest play, taught guest games to fetch their question bank with a signed permit, then removed guest play and required an account for game access.
  • The question banks sit behind an authenticated API route because the team had deliberately moved them out of the public directory a few days before guest play shipped.
  • A signed-out visitor starting a free game therefore got a 401 on the bank fetch and a spinner, so every bank-backed free game was broken from the moment guest play went live.
  • The deletion took out guest play, the claim endpoint, the permit module, the landing-intent plumbing and about 350 lines of tests.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Putting authored content behind a session closes off cheap anonymous funnels; any later feature that wants one must either reopen the whole library or carry a capability token per request.
  • decision Rebuilding free play here now starts with a conversion number, because the design cost of the anonymous path is known and the funnel's value is not.
  • precedent Fail-closed secrets and re-validation at verify time become the house pattern for capability tokens, and the price is that a missing config silently removes guests' free games.
  • cost Learning that the feature was unjustified cost an evening of design, implementation and test work, paid by the developer who wrote the careful version of it.

The permit is a payload and a signature. `mintGuestPass` takes a `gameId`, calls `getGuestPlayableGame` and returns null when the game has no free tier, then builds `gameId.expiresAt` and appends a signature over that string [13]. The caller has already resolved that the game is guest-playable, and the module checks a second time, so the module and not its callers decides what a permit may be issued for [14]. "Capability minting should not be a function that trusts its argument," the author wrote [15].

Two defaults in there are worth copying. `secret()` returns null when nothing is configured, every verification then fails closed, and no literal default key exists for deployments to share [16]. A hardcoded fallback is a shared key with a friendlier name. Verification re-checks the rule as well, rejecting a permit that is malformed, expired, signed with the wrong key, or names a game that is not guest-playable now, because a game can lose its free tier after a permit was minted [18]. Comparison goes through `timingSafeEqual`, which throws on a length mismatch, so lengths are compared first [19].

The cheaper fixes were worse. Dropping the auth check on the bank route would undo the move out of the public directory for the entire library in order to serve one funnel [9]. Special-casing the free games' banks needs a game-to-bank map that does not reliably exist: bank ids live as literals inside each engine, several engines take the bank as a prop so one component serves many games, and several banks legitimately back more than one game [10]. A map built by inspection gets those cases wrong, and a wrong map returns an error to a paying customer mid-test [11].

Twenty-six minutes separated the commit that shipped guest play from the commit that made its bank fetches work [23]. The claim path on the other side of signup was careful in the same way as the permit: after authentication a `claim:<gameId>` cookie returned the visitor to the game route, and the parked state was posted, scored on the server and saved through the same code as any normal session [4], which is why the play budget and the free-score rule applied unchanged [6].

The permit module lasted thirty minutes [20]. "The permit was not the reason. The permit was the symptom," the author wrote [21]. Writing it forced out a sentence nobody had said: "we are adding an anonymous path to the authored content, three days after deliberately closing one, to support a funnel we had not measured" [22].

Two conditions have to hold for that to be the right call elsewhere. The content behind the session has to be the product itself, which here it is, since the question banks are the authored content [7]. And the funnel on the other side has to be unpriced. Where a team has a measured conversion rate for the free-score screen, a thirty-minute permit module is cheap and it stays; by the author's own account, this funnel had not been measured [22].

What to watch

  • Whether the banks-behind-auth decision holds the next time a feature wants anonymous access, such as a shareable results page.
  • Whether the permit module comes back for a funnel with a measured conversion rate, since the code and its 350 lines of tests remain in git history.
  • What a signed-out visitor now sees on a bank-backed game route: a locked screen, a signup prompt, or the old spinner.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories