Skip to content

Region Invite Onramp

Status: 🚧 Partial — region OWNER mint/list/revoke is shipped (RegionInvitePanel + gameserver regional-governance routes). Gameserver POST /auth/register accepts optional invite_code (auth.py). Player-visible signup on tip 7012c53e5: /join landing (JoinInviteLanding.tsx, App.tsx:296), RegisterForm.tsx invite field, and AuthContext.register threads invite_code. (re-verified 2026-08-31 vs Sectorwars2102 7012c53e5.)

This is the invite-link path of the region-onramp problem: without it, a new player only ever lands in the operator-run Terran Space starter region. An invite code lets a region owner pull new players directly into their own region instead.

Lifecycle

  1. Mint — region owner calls POST /{region_id}/invites (owner-only, ownership re-checked server-side every call). Returns a high-entropy code (secrets.token_urlsafe(16)). max_uses defaults to 1 (one-time link); expires_at defaults to now + 7 days — there is no infinitely-reusable invite.
  2. List / manage — GET /{region_id}/invites (owner-scoped, newest first, includes the code) and POST /{region_id}/invites/{invite_id}/revoke (owner-scoped, idempotent — revoking an already-revoked invite is a success, not an error).
  3. Redeem at signup — gameserver password signup accepts optional invite_code on POST /auth/register and can lock/validate (lock_and_validate_invite) inside the signup transaction. UI ✅ tip-PRESENT: JoinInviteLanding.tsx at /join pre-fills RegisterForm invite field; AuthContext.register POSTs invite_code when supplied.
  4. Redemption audit — RegionInviteRedemption row written with hashed IP + device fingerprint (never raw), feeding the future ADR-0056 multi-account clustering.

Redeemability rules

An invite is redeemable only if all hold at redeem time (re-checked under a row lock, not just at mint time): - status == 'active' - uses < max_uses - now() < expires_at - the target region still exists - the minting owner still owns the region (re-checked — an ownership transfer after minting invalidates outstanding invites for the new redeem, not retroactively)

Status transitions: active → exhausted (uses hit max_uses) / revoked (owner-initiated) / expired (TTL passed). All auth-free — minting/listing/revoking never touches account creation; only the signup-path redemption (Max-gated, WO-IL6) actually creates a player and grants citizenship.

Schema

RegionInvite (region_invites): id, code (unique, indexed), region_id (FK → regions.id, CASCADE), created_by (FK → users.id, SET NULL), max_uses (default 1), uses (default 0), expires_at (mandatory, no default), status (active/exhausted/revoked/expired, string-enum-in-string), created_at, revoked_at. DB-level check constraints enforce the status vocabulary and uses <= max_uses.

RegionInviteRedemption (region_invite_redemptions): id, invite_id (FK, CASCADE), redeemed_by_player_id (FK → players.id, SET NULL, nullable — set after the player row exists in the same transaction), redeemed_at, ip_hash, device_fingerprint_hash (both hashed-only, never raw).

Source: services/gameserver/src/models/region_invite.py.

Endpoints

Route Auth Purpose
POST /{region_id}/invites Region owner Mint a new invite code
GET /{region_id}/invites Region owner List invites minted for this region
POST /{region_id}/invites/{invite_id}/revoke Region owner Revoke (idempotent)
POST /auth/register (invite_code optional field) Anonymous (signup) GS redeem at password signup — RegisterForm + AuthContext tip-PRESENT
OAuth callback path (invite_code threaded through) Anonymous (signup) GS can redeem — OAuth register invite threading residual

Source: services/gameserver/src/api/routes/regional_governance.py (mint/list/revoke), services/gameserver/src/api/routes/auth.py + services/gameserver/src/auth/oauth.py (redemption at signup).

Player-facing affordances

  • ✅ Region owner invite management — RegionInvitePanel.tsx mounted from RegionOwnerControls.tsx; mint / list / revoke against POST/GET /{region_id}/invites (origin/feat 7012c53e5; join-URL builder). Tests: RegionInvitePanel.test.tsx.
  • ✅ Join invite landing — JoinInviteLanding.tsx at route /join (App.tsx:296); pre-fills invite code into registration flow. Vitest JoinInviteLanding.test.tsx (LEG-3151).
  • ✅ Signup invite-code field — RegisterForm.tsx collects invite_code; AuthContext.register threads it to POST /auth/register (AuthContext.tsx:329).

Source map

Concern Target path
Owner invite panel services/player-client/src/components/governance/RegionInvitePanel.tsx
Owner controls mount services/player-client/src/components/governance/RegionOwnerControls.tsx
Invite panel tests services/player-client/src/components/governance/__tests__/RegionInvitePanel.test.tsx
/join landing (✅ tip-PRESENT) services/player-client/src/components/auth/JoinInviteLanding.tsx — route /join in App.tsx:296
Join landing tests services/player-client/src/components/auth/__tests__/JoinInviteLanding.test.tsx
Register invite field services/player-client/src/components/auth/RegisterForm.tsx
AuthContext invite threading services/player-client/src/contexts/AuthContext.tsx
Mint / list / revoke routes services/gameserver/src/api/routes/regional_governance.py
Signup redeem field services/gameserver/src/api/routes/auth.py (invite_code on register)
Invite models services/gameserver/src/models/region_invite.py

Design provenance

Design brief: audit/design-briefs/invite-link-onramp.md. Note: the model's own docstring cites a DECISIONS.md:473-475 line reference that no longer resolves against the current DECISIONS.md (396 lines total as of this writing) — likely stale after a prior file restructure. Flagging rather than propagating the dead citation into this page; the design brief itself is the reliable source.

  • ADR-0056 — multi-account detection, the eventual consumer of the hashed IP/fingerprint columns.
  • ../DATA_MODELS/migrations.md — migration table entry.