feat(webapp): campaign invites with expiry, usage limits and join approval (#643) #647

Merged
claude-bot merged 2 commits from feat/643-campaign-invites into main 2026-09-14 05:09:38 +00:00
Collaborator

Implements #643, including its interaction comment with #644. It fixes two problems:

  • A campaign's invite code never expired and had no usage limit.
  • With INVITE_CODE set, nobody could sign up through the UI. bb77ceb removed the login field but kept the gate.

⚠️ Behaviour changes for operators

  • Every existing invite link stops working 7 days after upgrade. The migration turns each campaigns.invite_code into an invite expiring now() + 7 days, then drops the column. GMs create new links from the member list.
  • New campaigns no longer get an invite automatically. The member-search panel offers "Create an invite link".
  • POST /api/campaigns/join response changed, from a CampaignResponse to {outcome: joined|requested|already_member, campaign_id, campaign_name}. The old response included the webhook URL and share token, which a user waiting for approval must not receive. "Already a member" is now a 200 with already_member instead of a 400. The bot never calls this endpoint (grep: no invite under bot/), so there is no BOT_CONTRACT_VERSION bump.
  • The public preview no longer reports archived. An archived campaign's invite gets the same generic 404 as any dead one, which reverses #387's rule. "This campaign is archived" would name a campaign to anyone holding an old link.
  • Ordinary members no longer see the invite code; it's gone from CampaignResponse. Only people who manage membership can list invites.

Design

  • One way in: invite_service.redeem_invite. The Join box, the /join/<code> page and the sign-up callback all call it.
    • It locks the invite row with SELECT … FOR UPDATE and populate_existing, so a stale copy already in the session can't slip past the lock.
    • It refuses revoked, expired, used-up, trashed and archived invites, all with one message.
    • An existing member, or someone whose request is already pending, consumes no use.
    • It never commits and only ever adds players.
  • Pending members are held in campaign_join_requests, not as a status on campaign_members, so every existing membership check refuses them by default.
  • Sign-up gate (routers/auth.py):
    • login takes the campaign code only from a next that passes the existing /join/<code> allowlist, and stores it in the OIDC state.
    • callback lets a new user in if INVITE_CODE is unset or matches, or if that invite is redeemable now.
    • User creation and redemption share a savepoint, so a refused invite leaves no account.
    • The code is compared as bytes (a non-ASCII str used to crash into "A database error occurred"), and the broad except now logs the exception.
    • After redemption the user is redirected to the campaign, or to /join/<code>?requested=1 if approval is pending. A plain /join/<code> would say "no longer valid" if they had taken the last use.
    • /auth/login?invite_code=… still works; it's how a fresh install's first admin gets in (.env.example says so).
  • "GM or owner" in one place: can_manage_membership gates listing, creating and revoking invites, the approval toggle, and approving and declining requests. It is GM-only today; #644 adds the owner check there. The toggle has its own route (PUT /campaigns/{id}/join-approval), because the campaign PATCH is require_gm and would block a player-owner.
  • Decline deletes the request. There is no "declined" state; the consumed use and the redemption row remain as the record.

UI

  • InvitePanel is now an invite list: label, link, "expires in…", "x of y used", Copy and Revoke. "New invite link" offers 1 / 7 / 30 days and 1 / 5 / 10 / unlimited uses. It also has the "New members need my approval" toggle.
  • The member list shows pending requests with Approve / Decline.
  • The JoinCampaign page and the Dashboard Join box handle "waiting for approval" and the generic dead-invite message. The Join box also accepts a pasted full link.

Migration d3e4f5a6b7c8 (on c2d3e4f5a6b7)

  • Raw DDL, with CHECK constraints instead of Postgres enums.
  • Round trip verified on a scratch database seeded at c2d3e4f5a6b7 with two campaigns that had codes and one without:
    • Upgrade: 2 invite rows, 7.000 days left, column dropped.
    • Downgrade: after revoking one invite, only the un-revoked code came back.
    • Upgrade again: re-backfills with 7 days.
  • The head pin moved to this revision; webapp/CLAUDE.md's migration chain now lists it.

Tests

  • New:
    • test_campaign_invites.py: service, routes, migration, and the race, where two connections redeem a max_uses=1 invite and exactly one succeeds.
    • test_auth_callback.py: the OIDC callback had no tests at all before this.
  • 46 guards mutation-checked (30 backend, 16 frontend), each seen to fail. For example, dropping FOR UPDATE makes the race test see ['joined','joined'].
  • Results:
    • Backend full suite: 3548 passed, 13 skipped.
    • Frontend: 67 files / 893 passed; eslint clean.
    • ruff 0.4.4 and version-sync clean.

Known, not fixed here

  • If one user redeems two different invites to the same campaign at the same instant, the lock is per invite, so the second insert hits the member primary key and returns a 500. It's harmless (they're already in), just an ugly error.
  • fetch_oidc_discovery() in the callback is unwrapped, so an unreachable provider at that step returns a 500 instead of the error page.
  • Some test leaves committed users rows behind, making "first user" tests depend on run order. The one affected test was made independent of that.

Closes #643

🤖 Generated with Claude Code

Implements #643, including its interaction comment with #644. It fixes two problems: - A campaign's invite code never expired and had no usage limit. - **With `INVITE_CODE` set, nobody could sign up through the UI.** bb77ceb removed the login field but kept the gate. ## ⚠️ Behaviour changes for operators - **Every existing invite link stops working 7 days after upgrade.** The migration turns each `campaigns.invite_code` into an invite expiring `now() + 7 days`, then drops the column. GMs create new links from the member list. - **New campaigns no longer get an invite automatically.** The member-search panel offers "Create an invite link". - **`POST /api/campaigns/join` response changed**, from a `CampaignResponse` to `{outcome: joined|requested|already_member, campaign_id, campaign_name}`. The old response included the webhook URL and share token, which a user waiting for approval must not receive. "Already a member" is now a 200 with `already_member` instead of a 400. The bot never calls this endpoint (grep: no `invite` under `bot/`), so there is **no `BOT_CONTRACT_VERSION` bump**. - **The public preview no longer reports `archived`.** An archived campaign's invite gets the same generic 404 as any dead one, which reverses #387's rule. "This campaign is archived" would name a campaign to anyone holding an old link. - **Ordinary members no longer see the invite code**; it's gone from `CampaignResponse`. Only people who manage membership can list invites. ## Design - **One way in: `invite_service.redeem_invite`.** The Join box, the `/join/<code>` page and the sign-up callback all call it. - It locks the invite row with `SELECT … FOR UPDATE` and `populate_existing`, so a stale copy already in the session can't slip past the lock. - It refuses revoked, expired, used-up, trashed and archived invites, all with one message. - An existing member, or someone whose request is already pending, consumes no use. - It never commits and only ever adds players. - **Pending members are held in `campaign_join_requests`**, not as a status on `campaign_members`, so every existing membership check refuses them by default. - **Sign-up gate (`routers/auth.py`):** - `login` takes the campaign code only from a `next` that passes the existing `/join/<code>` allowlist, and stores it in the OIDC state. - `callback` lets a new user in if `INVITE_CODE` is unset or matches, or if that invite is redeemable now. - **User creation and redemption share a savepoint**, so a refused invite leaves no account. - The code is compared as bytes (a non-ASCII `str` used to crash into "A database error occurred"), and the broad except now logs the exception. - After redemption the user is redirected to the campaign, or to `/join/<code>?requested=1` if approval is pending. A plain `/join/<code>` would say "no longer valid" if they had taken the last use. - `/auth/login?invite_code=…` still works; it's how a fresh install's first admin gets in (`.env.example` says so). - **"GM or owner" in one place:** `can_manage_membership` gates listing, creating and revoking invites, the approval toggle, and approving and declining requests. It is GM-only today; #644 adds the owner check there. The toggle has its own route (`PUT /campaigns/{id}/join-approval`), because the campaign PATCH is `require_gm` and would block a player-owner. - **Decline deletes the request.** There is no "declined" state; the consumed use and the redemption row remain as the record. ## UI - `InvitePanel` is now an invite list: label, link, "expires in…", "x of y used", Copy and Revoke. "New invite link" offers 1 / **7** / 30 days and 1 / 5 / 10 / unlimited uses. It also has the "New members need my approval" toggle. - The member list shows pending requests with Approve / Decline. - The `JoinCampaign` page and the Dashboard Join box handle "waiting for approval" and the generic dead-invite message. The Join box also accepts a pasted full link. ## Migration `d3e4f5a6b7c8` (on `c2d3e4f5a6b7`) - Raw DDL, with CHECK constraints instead of Postgres enums. - Round trip verified on a scratch database seeded at `c2d3e4f5a6b7` with two campaigns that had codes and one without: - **Upgrade:** 2 invite rows, 7.000 days left, column dropped. - **Downgrade:** after revoking one invite, only the un-revoked code came back. - **Upgrade again:** re-backfills with 7 days. - The head pin moved to this revision; `webapp/CLAUDE.md`'s migration chain now lists it. ## Tests - **New:** - `test_campaign_invites.py`: service, routes, migration, and the **race**, where two connections redeem a `max_uses=1` invite and exactly one succeeds. - `test_auth_callback.py`: the OIDC callback had no tests at all before this. - **46 guards mutation-checked (30 backend, 16 frontend), each seen to fail.** For example, dropping `FOR UPDATE` makes the race test see `['joined','joined']`. - **Results:** - Backend full suite: **3548 passed, 13 skipped**. - Frontend: **67 files / 893 passed**; eslint clean. - `ruff` 0.4.4 and version-sync clean. ## Known, not fixed here - If one user redeems two *different* invites to the same campaign at the same instant, the lock is per invite, so the second insert hits the member primary key and returns a 500. It's harmless (they're already in), just an ugly error. - `fetch_oidc_discovery()` in the callback is unwrapped, so an unreachable provider at that step returns a 500 instead of the error page. - Some test leaves committed `users` rows behind, making "first user" tests depend on run order. The one affected test was made independent of that. Closes #643 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A campaign had one invite_code that anyone could use any number of times,
for ever. And with INVITE_CODE set, nobody could sign up through the UI:
the login page lost its code field but the callback gate stayed, so it
compared "" against the instance code and refused everyone.

Data (migration d3e4f5a6b7c8, raw SQL):
- campaign_invites: plaintext code (a GM re-copies live links; expiry,
  not hashing, bounds a leak), label, created_by SET NULL, expires_at,
  max_uses, use_count, revoked_at.
- invite_redemptions: the audit trail, outcome TEXT + CHECK.
- campaign_join_requests: pending members kept OUT of campaign_members,
  so no membership check can admit them by missing a status filter.
- campaigns.join_requires_approval.
- Backfill: every invite_code becomes "Original invite link", no use
  limit, expiring now() + 7 days; then the column is dropped. The
  downgrade restores each campaign's newest live code.

One way in: invite_service.redeem_invite locks the invite row (FOR
UPDATE), refuses unknown/revoked/expired/used-up invites and trashed or
archived campaigns with one generic message, consumes no use for an
existing member or pending requester, then joins as a player or queues
a request. The dashboard Join box, /join/<code> and the OIDC sign-up all
use it; the two duplicated lookups are gone. POST /api/campaigns/join
now answers {outcome, campaign_id, campaign_name} instead of a
CampaignResponse, because a requester is not a member. The
unauthenticated preview reveals a campaign only for a redeemable invite
and drops its `archived` flag.

Sign-up gate: login lifts the code from an allowlisted /join/<code> next
into the OIDC state. A new user passes if INVITE_CODE is unset or
matched, or that campaign invite is redeemable now; the user row and the
redemption share a savepoint, so a refusal leaves no account. Bytes in
hmac.compare_digest, the broad except logs, and the refusal text no
longer points at a field that does not exist. A player admitted by an
invite goes straight to the campaign, or to /join/<code>?requested=1.

"GM or owner": invites, the approval toggle and approve/decline all
authorise through invite_service.can_manage_membership (via
require_membership_manager). GM-only today; #644 fills in the owner half
there. The toggle is its own PUT route rather than a PATCH field for the
same reason.

UI: InvitePanel is an invite list (label, link, expiry, "x of y used",
Copy, Revoke; new links 1/7/30 days, 1/5/10/unlimited uses) with the
approval toggle; Regenerate is gone. The member list shows requests
waiting for approval with Approve/Decline. JoinCampaign and the dashboard
Join box handle "requested" and the generic dead-invite message.

Bot untouched (no invite code under bot/), so no contract bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs(webapp): bring the CLAUDE.md migration chain up to d3e4f5a6b7c8 (#643)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 46s
CI / Docker image build (pull_request) Successful in 49s
CI / Backend lint (ruff) (pull_request) Successful in 55s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m31s
CI / Bot tests and audit (pull_request) Successful in 2m26s
CI / Frontend tests, audit, and build (pull_request) Successful in 3m7s
CI / Backend migration, tests, and audit (pull_request) Successful in 9m15s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 17m15s
a2f4e2096d
The list stopped at a0f1a2b3c4d5 and still named it head; #580, #534
and #643 each added one since.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/643-campaign-invites 2026-09-14 05:09:38 +00:00
Sign in to join this conversation.
No description provided.