Test plan: the invite gate is enforced server-side (#887) โ
Change under test: account creation validates and consumes a single-use invite inside the same transaction that writes the user doc, and /auth/user/invite/consume checks expiresAt.
Surfaces: services/api/auth/src/routes/phoneCreateUser.js, services/api/auth/src/routes/userInvite.js, services/api/auth/src/lib/inviteGate.js, apps/web/src/screens/auth/PhonePinSignup.jsx.
The trap this plan is built around โ
Signup refuses an anonymous caller for four reasons that predate the invite gate: App Check, the phone-verification HMAC the server mints only after a real OTP round trip, ban enforcement, and IP rate limiting. A test that watched an uninvited request fail could be passing on any of those. It would stay green if the invite check were deleted tomorrow.
Every scenario below therefore satisfies all four older defences first, then varies only the invite. The integration suite calls createUserHandler directly with an authenticated req and a genuinely valid signed proof bound to a live server-side marker, so the invite is the only thing left that can refuse.
Scenarios โ
| # | Scenario | Expected | Result |
|---|---|---|---|
| 1 | Create an account with a valid proof and NO invite | 403 INVITE_REQUIRED, no user doc | Pass |
| 2 | Create with a live invite | 200, user doc written, token shows usedBy = caller | Pass |
| 3 | Reuse a burned token for a second account | 403 INVITE_INVALID reason already_used, no second doc | Pass |
| 4 | Use a token past expiresAt | 403, reason expired, no user doc | Pass |
| 5 | Use a token whose expiresAt is missing or unparseable | 403, reason expired (fails closed) | Pass |
| 6 | Two concurrent creations racing on ONE token | exactly one 200 and one 403, exactly one account | Pass |
| 7 | Admin-provisioned caller (admin custom claim) creates with no invite | 200, pre-existing doc fields preserved | Pass |
| 8 | Caller whose users doc says role: admin but whose Auth claim does not | 403 INVITE_REQUIRED, never provisioned | Pass |
| 9 | /consume on an expired token | 403, token not burned | Pass |
| 10 | /consume by the uid that already burned the token, now expired | 200 (idempotent) | Pass |
Run: npm run test:invite-gate:emulator (Admin SDK against a real Firestore emulator; skips without FIRESTORE_EMULATOR_HOST). 10 passed, 2026-08-17.
Scenario 8 is the one that keeps the exemption honest. firestore.rules lets a client create its own users doc, so role in that doc is caller-controlled. The exemption reads the Firebase Auth custom claim instead, which only the Admin SDK can set.
Proof that these tests can fail โ
Passing tests are not evidence until the thing they guard has been broken on purpose. Two mutations were applied to phoneCreateUser.js, each run against the same emulator, source restored after each.
| Mutation | Went red | Stayed green |
|---|---|---|
A: delete the INVITE_REQUIRED branch | scenarios 1, 8 | the other 8 |
B: delete the in-transaction inviteFailure checks (both paths) | scenarios 3, 4, 5, 6 | the other 6 |
The two red sets are disjoint and neither mutation took the suite with it, which is what distinguishes a real A/B from a mutation that simply crashed everything. Scenario 6 going red under B is the specific evidence that the transaction is what serializes two callers on one token, rather than test timing.
Reproduce: tooling/e2e has no harness for this; the mutations are the two anchors quoted in the table, applied by hand.
Not covered here โ
- A live browser signup on deployed dev. Needs a fresh invite from the admin portal and burns a phone number from the test pool. Deferred to the post-merge deployed pass.
- The other ways into the
userscollection. Closed separately in the same PR under #901, which found four client routes rather than one. See../901-server-only-account-creation/README.md. This plan covers the creation endpoint, which is what #887 scoped. - Rollout window. A client on a cached bundle that predates this change sends no
inviteTokenand will be refused withINVITE_REQUIRED. The signup screen renders "This invite link is no longer valid. Open a fresh invite link to sign up." Dev only at present.