Skip to content

[club] Clubs as a first-class entity: seats, comped entitlements, invites (closes #31) - #42

Merged
Harrolee merged 1 commit into
mainfrom
club/first-class-entity
Aug 16, 2026
Merged

Harrolee merged 1 commit into
mainfrom
club/first-class-entity

Conversation

@Harrolee

Copy link
Copy Markdown
Owner

Closes #31.

Stacked on #41. Base branch is security/grant-rls-sweep, not main — #31 asks to follow the grant discipline from #25, and both touch the probe suite. Merge #41 first and this retargets to main cleanly.

The schema choice, and why

A new clubs table, not a flag on creator_profiles.

creator_profiles models a seller: revenue_share_bps, payout_provider, payout_account_id, all guarded by protect_creator_platform_fields(). A club is a buyer. Overloading the row leaves those three columns meaning nothing-or-something depending on a flag, and every existing creator query grows a "but is it a club" branch.

A club does own coaches — so it points at a creator_profiles row via creator_id rather than becoming one.

Three tables: clubs, club_members (with role ∈ owner/coach/member), club_coaches (explicit, so a club can pilot with one coach without exposing the creator's whole catalogue).

Entitlement gets no second mechanism

Seats are coach_subscriptions rows with source='creator_comp'. has_coach_access() remains the single gate every read path already calls — nothing routes around it.

The one addition is coach_subscriptions.club_id, so revocation can target exactly the club-granted rows and nothing else.

Revocation closes every path

The issue says "make sure there is no path where a removed member keeps talking to the coach." Three paths, asserted separately:

  • flipping a member to status='removed' → seats revoked
  • DELETEing the membership row outright → seats revoked (a BEFORE DELETE trigger; easy to omit, and omitting it leaves a removed member fully entitled)
  • the club going lapsed/suspended → every seat revoked

Re-activating a club deliberately does not silently re-grant.

The awkward case, handled explicitly

A member may already be paying for the same coach out of their own pocket. A club seat must never overwrite that — revoking the seat later would cancel a subscription the member bought. The upsert only takes over free_tier / creator_comp / promo rows and leaves apple_iap / google_play / stripe alone. Asserted both ways: the paid row survives, and it is not marked club-granted.

Privacy

A member may confirm their own membership and see the club's name. A member may not enumerate the squad — who trains where is not ours to publish, and it is the same instinct #32 is built around.

The commercial columns (seats, plan, billing_email, external_billing_ref) are not granted to authenticated at all, so no policy mistake can leak them. An owner reads them through club_billing(), which embeds its own ownership check.

Seat granting is backend-only. If grant_club_seat were reachable by authenticated, any signed-in member could mint themselves a comped entitlement to any coach — the entitlement system's own bypass. Asserted 42501 for both anon and a signed-in member.

is_club_owner() / is_club_member() are SECURITY DEFINER for a structural reason, not convenience: the RLS policy on club_members needs to ask "is the caller an owner of this club", which is itself a question about club_members. Through a policy-bound query that recurses.

Verification

New mobile/e2e/club-probe.mjs, 46/0, creating and tearing down its own fixtures (the other suites assert on the exact seeded roster). Covers the acceptance criteria directly: club created → coach attached → ten members added in one club_add_members() call (6 existing accounts granted, 4 invited), invite claimed on signup, removal revokes immediately proven through has_coach_access(), cross-club isolation, and the negatives.

Other suites unchanged: rls 77/0, flow 46/0, sms 55/0, account-deletion 64/0, viz-realtime 33/0. Migration is idempotent and the full chain cold-applies against the shim. tsc --noEmit and terraform validate clean.

The #25 drift detector picks up the six new SECURITY DEFINER functions automatically and still reports empty — which is the point of it.

Note

viz-realtime-probe.mjs remains flaky (29/4 then 33/0 twice, no changes between). Websocket subscription race, not related to this work. I'm fixing it under #40.

🤖 Generated with Claude Code

…31)

A new `clubs` table rather than a flag on creator_profiles. creator_profiles
models a seller -- revenue_share_bps, payout_provider, payout_account_id, all
protected by protect_creator_platform_fields(). A club is a buyer. Overloading
the row would leave those columns meaning nothing-or-something depending on a
flag, and every creator query would grow a "but is it a club" branch. A club
still owns coaches, so it points at a creator row via creator_id rather than
becoming one.

Entitlement gets no second mechanism. Seats are coach_subscriptions rows with
source='creator_comp', so has_coach_access() stays the single gate every read
path already calls. What is new is coach_subscriptions.club_id, which records
that a seat came from a club so revocation can find exactly those rows.

Revocation closes every path, not just the obvious one: flipping a member to
'removed', deleting the membership row outright, and the club lapsing all
revoke. The probe asserts each separately -- a DELETE trigger is easy to forget
and leaves a removed member still talking to the coach.

The awkward case, handled explicitly: a member may already be paying for the
same coach themselves. A club seat never overwrites a paid entitlement, because
revoking the seat later would then cancel a subscription the member bought. The
upsert only takes over free_tier / creator_comp / promo rows.

is_club_owner() and is_club_member() are SECURITY DEFINER for a structural
reason: the RLS policies on club_members need to ask "is the caller an owner of
this club", which is itself a question about club_members. Asking it through a
policy-bound query recurses.

Privacy: a member may confirm their own membership and see the club name, but
cannot enumerate the squad -- who trains where is not theirs to publish. The
commercial columns are not granted to authenticated at all, so no policy mistake
can leak them; an owner reads them through club_billing().

Seat granting is backend-only. If grant_club_seat were reachable by
authenticated, any member could mint themselves a comped entitlement to any
coach.

club-probe.mjs: 46 checks, creating and tearing down its own fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[club] Clubs as a first-class entity: seats, comped entitlements, member invites

1 participant