[Backend+Frontend] Campaign invites with expiry, usage limits and per-campaign join approval; fix the INVITE_CODE sign-up gate #643

Closed
opened 2026-09-14 03:25:40 +00:00 by claude-bot · 3 comments
Collaborator

Phase 0 of #642. Carried over from the investigation on #534 (comment 39607), where the owner chose option D.

The problem

1. Campaign invites never expire and have no usage limit.
Each campaign has one campaigns.invite_code (secrets.token_urlsafe(8), campaign_service.py:104). Anyone can use it any number of times, forever. It stops working only when the GM regenerates it, or the campaign is archived or soft-deleted. A link pasted into a group chat once stays a way into that campaign's transcripts and summaries indefinitely.

2. With INVITE_CODE set, no new user can sign up through the UI.

  • bb77ceb removed the invite-code field from the login page, but kept the backend gate (routers/auth.py:204-211).
  • Nothing in the frontend sends invite_code any more, so the gate compares "" against the instance code and refuses everyone. This happens after the full identity-provider round trip, and throws away next_url.
  • A player following a campaign /join/<code> link is refused too: the campaign code reaches the callback only inside next_url.

Decisions already made (#642)

  • Invites get expiry and usage limits.
  • Join approval is a per-campaign GM setting.
  • Existing codes get a 7-day expiry at migration.
  • On a self-hosted instance with INVITE_CODE set, a valid, redeemable campaign invite satisfies the registration gate for that sign-up. D bounds the risk, since a leaked link expires and runs out.

Design

Data (one migration, raw SQL per the house rule)

  • campaign_invites:
    • id, campaign_id (FK, cascade)
    • code (text, unique)
    • label (nullable, e.g. "for Sam")
    • created_by (FK users, SET NULL), created_at
    • expires_at (nullable), max_uses (nullable), use_count (default 0)
    • revoked_at (nullable)
  • invite_redemptions: id, invite_id, user_id, campaign_id, redeemed_at, outcome (joined | requested). This is the audit trail: which link each member arrived through.
  • campaigns.join_requires_approval: boolean, default false.
  • Pending members live in a separate campaign_join_requests table, not as a status on campaign_members. Membership is checked in many places; a pending row in campaign_members would grant access anywhere a status filter was forgotten. A separate table fails closed.
  • Backfill: each non-null campaigns.invite_code becomes an invite row, with expires_at = now() + 7 days, max_uses NULL, and label "Original invite link". Then drop campaigns.invite_code.

Codes stay stored in plaintext, deliberately. A GM needs to copy an active link again, and a database leak already exposes everything an invite would open. With expiry, hashing costs usability and buys little. #642 floated hashing; this reverses it.

Redemption (one service function; every path uses it)

redeem_invite(db, code, user):

  1. Lock the invite row (SELECT … FOR UPDATE).
  2. Refuse if it is unknown, revoked, expired, or use_count >= max_uses.
  3. Refuse if the campaign is soft-deleted or archived.
  4. If the user is already a member, do nothing and consume no use.
  5. Otherwise increment use_count, record the redemption, then either join as player or create a join request, depending on join_requires_approval.

The dashboard Join box (POST /api/campaigns/join), the /join/<code> page, and the OIDC sign-up path all call it. Today there are two duplicated lookups (campaign_service.join_campaign, routers/invites.py:60-65); both go.

Refusals don't leak. The unauthenticated preview (GET /api/invites/{code}) returns the campaign name only for a redeemable invite. Expired, used-up and revoked invites all return one generic "this invite link is no longer valid, ask your GM for a new one", with no campaign details.

Sign-up gate

  • login: when next matches the existing /join/<code> allowlist (auth.py:61,70), store the code in the OIDC state payload in Redis DB 0 (store_pkce_state).
  • callback, new user:
    • Pass the gate if INVITE_CODE is unset, or the instance code matches, or the stored campaign code is redeemable right now.
    • Create the user and redeem in one transaction, so a refusal leaves no user row.
    • First-user-becomes-admin is unchanged.
  • Also fix:
    • Compare bytes in hmac.compare_digest; a non-ASCII str raises inside the try and surfaces as "A database error occurred".
    • Log the exception in the broad except at auth.py:254-258.
    • Change the refusal text so it no longer points at a step that doesn't exist.

GM-facing UI

  • InvitePanel becomes an invite list:
    • each row: label, link, "expires in 3 days", "2 of 5 used", Copy, Revoke
    • "New invite link" with expiry (1 day, 7 days (default), 30 days) and uses (1, 5, 10, unlimited)
    • "Regenerate" is replaced by revoke plus new
  • Campaign settings: "New members need my approval" toggle.
  • Member list: pending requests with Approve / Decline.

Docs

  • The GM guide (help/running-your-first-campaign.md:54-56) and help/joining-a-campaign.md.
  • .env.example (INVITE_CODE text).
  • docs/API.md:95,119: the gate returns a 302 to /auth-error, not the 403 it documents.
  • docs/ROADMAP-COMPLETED.md:16, which says the gate was removed.
  • ARCHITECTURE.md:152.
  • CHANGELOG, stating the behaviour change: every existing invite link stops working 7 days after upgrade.

Out of scope

  • Slots, entitlements and member caps (#642 phase 1).
  • Notifying the GM of a join request through Discord (a follow-up; the bot API is untouched here).
  • The bot never reads invite codes (checked: no occurrence under bot/), so no BOT_CONTRACT_VERSION bump.

Tests

The OIDC callback has no tests today (tests/test_auth_routes.py has two cookie tests). Seed state with store_pkce_state, and mock fetch_oidc_discovery, exchange_code_for_tokens and fetch_userinfo.

  • INVITE_CODE set, no code: refused, and no users row.
  • Instance code: user created.
  • Redeemable next=/join/<code>: user created and joined; use_count 1; redemption row written.
  • Same flow on an approval-required campaign: user created, join request created, not a member.
  • Expired, used-up, revoked, archived campaign, soft-deleted campaign: each refused with no user row.
  • Code revoked between login and callback: refused.
  • Campaign code inside a next that isn't allowlisted: ignored.
  • Existing user and first user: unchanged.
  • Race: two concurrent redemptions of a max_uses=1 invite; exactly one succeeds.
  • Already a member: no use consumed.
  • Preview does not reveal the campaign for a dead invite.
  • Migration: backfilled rows expire 7 days out; head pin moved.
  • A pending requester gets 403 on campaign, session, transcript and wiki reads.
  • Frontend: invite list (create, copy, revoke, expiry and use display), the approval toggle, and approve/decline.
  • Every guard mutation-checked: revert it and watch its test fail (#441).

Size

Large. One migration; auth, invites and campaigns routers and services; InvitePanel, CampaignDetail, JoinCampaign and the dashboard Join box; docs. One PR, because dropping campaigns.invite_code breaks the current UI in any intermediate state.

Phase 0 of #642. Carried over from the investigation on #534 (comment 39607), where the owner chose **option D**. ## The problem **1. Campaign invites never expire and have no usage limit.** Each campaign has one `campaigns.invite_code` (`secrets.token_urlsafe(8)`, `campaign_service.py:104`). Anyone can use it any number of times, forever. It stops working only when the GM regenerates it, or the campaign is archived or soft-deleted. A link pasted into a group chat once stays a way into that campaign's transcripts and summaries indefinitely. **2. With `INVITE_CODE` set, no new user can sign up through the UI.** - bb77ceb removed the invite-code field from the login page, but kept the backend gate (`routers/auth.py:204-211`). - Nothing in the frontend sends `invite_code` any more, so the gate compares `""` against the instance code and refuses everyone. This happens after the full identity-provider round trip, and throws away `next_url`. - A player following a campaign `/join/<code>` link is refused too: the campaign code reaches the callback only inside `next_url`. ## Decisions already made (#642) - Invites get **expiry and usage limits**. - **Join approval is a per-campaign GM setting.** - **Existing codes get a 7-day expiry** at migration. - On a self-hosted instance with `INVITE_CODE` set, a **valid, redeemable campaign invite satisfies the registration gate** for that sign-up. D bounds the risk, since a leaked link expires and runs out. ## Design ### Data (one migration, raw SQL per the house rule) - **`campaign_invites`:** - `id`, `campaign_id` (FK, cascade) - `code` (text, unique) - `label` (nullable, e.g. "for Sam") - `created_by` (FK users, `SET NULL`), `created_at` - `expires_at` (nullable), `max_uses` (nullable), `use_count` (default 0) - `revoked_at` (nullable) - **`invite_redemptions`:** `id`, `invite_id`, `user_id`, `campaign_id`, `redeemed_at`, `outcome` (`joined | requested`). This is the audit trail: which link each member arrived through. - **`campaigns.join_requires_approval`:** boolean, default `false`. - **Pending members live in a separate `campaign_join_requests` table**, not as a status on `campaign_members`. Membership is checked in many places; a pending row in `campaign_members` would grant access anywhere a status filter was forgotten. A separate table fails closed. - **Backfill:** each non-null `campaigns.invite_code` becomes an invite row, with `expires_at = now() + 7 days`, `max_uses` NULL, and `label` "Original invite link". Then drop `campaigns.invite_code`. **Codes stay stored in plaintext, deliberately.** A GM needs to copy an active link again, and a database leak already exposes everything an invite would open. With expiry, hashing costs usability and buys little. #642 floated hashing; this reverses it. ### Redemption (one service function; every path uses it) `redeem_invite(db, code, user)`: 1. Lock the invite row (`SELECT … FOR UPDATE`). 2. Refuse if it is unknown, revoked, expired, or `use_count >= max_uses`. 3. Refuse if the campaign is soft-deleted or archived. 4. If the user is already a member, do nothing and consume no use. 5. Otherwise increment `use_count`, record the redemption, then either join as `player` or create a join request, depending on `join_requires_approval`. The **dashboard Join box** (`POST /api/campaigns/join`), the **`/join/<code>` page**, and the **OIDC sign-up path** all call it. Today there are two duplicated lookups (`campaign_service.join_campaign`, `routers/invites.py:60-65`); both go. **Refusals don't leak.** The unauthenticated preview (`GET /api/invites/{code}`) returns the campaign name only for a redeemable invite. Expired, used-up and revoked invites all return one generic "this invite link is no longer valid, ask your GM for a new one", with no campaign details. ### Sign-up gate - **`login`:** when `next` matches the existing `/join/<code>` allowlist (`auth.py:61,70`), store the code in the OIDC state payload in Redis DB 0 (`store_pkce_state`). - **`callback`, new user:** - Pass the gate if `INVITE_CODE` is unset, or the instance code matches, or the stored campaign code is redeemable right now. - Create the user and redeem in **one transaction**, so a refusal leaves no user row. - First-user-becomes-admin is unchanged. - **Also fix:** - Compare bytes in `hmac.compare_digest`; a non-ASCII `str` raises inside the `try` and surfaces as "A database error occurred". - Log the exception in the broad `except` at `auth.py:254-258`. - Change the refusal text so it no longer points at a step that doesn't exist. ### GM-facing UI - **`InvitePanel` becomes an invite list:** - each row: label, link, "expires in 3 days", "2 of 5 used", Copy, Revoke - "New invite link" with expiry (1 day, **7 days (default)**, 30 days) and uses (1, 5, 10, unlimited) - "Regenerate" is replaced by revoke plus new - **Campaign settings:** "New members need my approval" toggle. - **Member list:** pending requests with Approve / Decline. ### Docs - The GM guide (`help/running-your-first-campaign.md:54-56`) and `help/joining-a-campaign.md`. - `.env.example` (`INVITE_CODE` text). - `docs/API.md:95,119`: the gate returns a 302 to `/auth-error`, not the 403 it documents. - `docs/ROADMAP-COMPLETED.md:16`, which says the gate was removed. - `ARCHITECTURE.md:152`. - CHANGELOG, stating the **behaviour change: every existing invite link stops working 7 days after upgrade**. ## Out of scope - Slots, entitlements and member caps (#642 phase 1). - Notifying the GM of a join request through Discord (a follow-up; the bot API is untouched here). - The bot never reads invite codes (checked: no occurrence under `bot/`), so **no `BOT_CONTRACT_VERSION` bump**. ## Tests The OIDC callback has no tests today (`tests/test_auth_routes.py` has two cookie tests). Seed state with `store_pkce_state`, and mock `fetch_oidc_discovery`, `exchange_code_for_tokens` and `fetch_userinfo`. - [ ] `INVITE_CODE` set, no code: refused, and no `users` row. - [ ] Instance code: user created. - [ ] Redeemable `next=/join/<code>`: user created and joined; `use_count` 1; redemption row written. - [ ] Same flow on an approval-required campaign: user created, join request created, **not** a member. - [ ] Expired, used-up, revoked, archived campaign, soft-deleted campaign: each refused with no user row. - [ ] Code revoked between login and callback: refused. - [ ] Campaign code inside a `next` that isn't allowlisted: ignored. - [ ] Existing user and first user: unchanged. - [ ] **Race:** two concurrent redemptions of a `max_uses=1` invite; exactly one succeeds. - [ ] Already a member: no use consumed. - [ ] Preview does not reveal the campaign for a dead invite. - [ ] Migration: backfilled rows expire 7 days out; head pin moved. - [ ] A pending requester gets 403 on campaign, session, transcript and wiki reads. - [ ] Frontend: invite list (create, copy, revoke, expiry and use display), the approval toggle, and approve/decline. - [ ] Every guard mutation-checked: revert it and watch its test fail (#441). ## Size Large. One migration; auth, invites and campaigns routers and services; `InvitePanel`, `CampaignDetail`, `JoinCampaign` and the dashboard Join box; docs. **One PR**, because dropping `campaigns.invite_code` breaks the current UI in any intermediate state.
Author
Collaborator

Interaction with #644 (campaign ownership), which can land before or after this.

  • Approving join requests: GMs approve and decline. Once #644 lands, the owner can too, even if they're a player, because membership is part of what they're accountable for. If this lands first, build the check as "GM or owner" behind one helper, so #644 only has to fill in the owner half.
  • The "New members need my approval" toggle: GMs, and the owner once #644 lands.
  • A campaign with no GM (allowed by #644): existing invites keep working, and approval-required requests wait for the owner or a newly assigned GM.
  • Invites are created by GMs (and the owner). Redemption never makes anyone a GM or an owner; it only adds players.
Interaction with #644 (campaign ownership), which can land before or after this. - **Approving join requests:** GMs approve and decline. Once #644 lands, the **owner** can too, even if they're a player, because membership is part of what they're accountable for. If this lands first, build the check as "GM or owner" behind one helper, so #644 only has to fill in the owner half. - **The "New members need my approval" toggle:** GMs, and the owner once #644 lands. - **A campaign with no GM** (allowed by #644): existing invites keep working, and approval-required requests wait for the owner or a newly assigned GM. - **Invites are created by GMs** (and the owner). Redemption never makes anyone a GM or an owner; it only adds players.
Author
Collaborator

Picking this up now (owner go-ahead 2026-09-13). Building on a branch off main d02a6d4, with migration d3e4f5a6b7c8 chained on c2d3e4f5a6b7. #644 follows once this merges, because the two rewrite the same campaign router, service, member list and migration chain.

Picking this up now (owner go-ahead 2026-09-13). Building on a branch off main d02a6d4, with migration `d3e4f5a6b7c8` chained on `c2d3e4f5a6b7`. #644 follows once this merges, because the two rewrite the same campaign router, service, member list and migration chain.
Author
Collaborator

Done in #647. CI run 1096 passed. Before merging, the overlapping test files were rerun against main with #646 merged in: 246 passed.

What shipped (details in the PR):

  • Invites with expiry, usage limits and revoke; a per-campaign approval toggle; pending requests in a separate table.
  • A single redeem_invite path with a row lock. The two-connection race test is mutation-checked.
  • The sign-up gate works again with INVITE_CODE set: a redeemable campaign invite admits a new user, and the user and redemption are created in one savepoint.
  • Migration d3e4f5a6b7c8: existing codes expire 7 days after upgrade.
  • 46 guards mutation-checked; the OIDC callback now has tests.
  • can_manage_membership is the single hook where #644 adds the owner check.

Where the build interpreted the spec (all in the PR):

  • The join endpoint returns {outcome, campaign_id, campaign_name}, not a campaign.
  • The preview gives archived campaigns the same generic 404 as any dead invite.
  • New campaigns get no automatic invite.
  • The approval toggle has its own route, so a player-owner can use it.

Small follow-ups, not filed as issues yet:

  • A user redeeming two different invites to the same campaign at the same instant gets a 500.
  • An unwrapped fetch_oidc_discovery() in the callback returns a 500 instead of the error page.
  • A test leaves committed users rows behind.
  • ARCHITECTURE.md still lists campaign_members.character_* columns.
Done in #647. CI run 1096 passed. Before merging, the overlapping test files were rerun against main with #646 merged in: 246 passed. **What shipped** (details in the PR): - Invites with expiry, usage limits and revoke; a per-campaign approval toggle; pending requests in a separate table. - A single `redeem_invite` path with a row lock. The two-connection race test is mutation-checked. - **The sign-up gate works again with `INVITE_CODE` set:** a redeemable campaign invite admits a new user, and the user and redemption are created in one savepoint. - Migration `d3e4f5a6b7c8`: existing codes expire 7 days after upgrade. - 46 guards mutation-checked; the OIDC callback now has tests. - `can_manage_membership` is the single hook where #644 adds the owner check. **Where the build interpreted the spec** (all in the PR): - The join endpoint returns `{outcome, campaign_id, campaign_name}`, not a campaign. - The preview gives archived campaigns the same generic 404 as any dead invite. - New campaigns get no automatic invite. - The approval toggle has its own route, so a player-owner can use it. **Small follow-ups, not filed as issues yet:** - A user redeeming two *different* invites to the same campaign at the same instant gets a 500. - An unwrapped `fetch_oidc_discovery()` in the callback returns a 500 instead of the error page. - A test leaves committed `users` rows behind. - `ARCHITECTURE.md` still lists `campaign_members.character_*` columns.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#643
No description provided.