Repository navigation
[club] Clubs as a first-class entity: seats, comped entitlements, invites (closes #31) - #42
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #31.
The schema choice, and why
A new
clubstable, not a flag oncreator_profiles.creator_profilesmodels a seller:revenue_share_bps,payout_provider,payout_account_id, all guarded byprotect_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_profilesrow viacreator_idrather than becoming one.Three tables:
clubs,club_members(withrole∈ 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_subscriptionsrows withsource='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:
status='removed'→ seats revokedDELETEing the membership row outright → seats revoked (aBEFORE DELETEtrigger; easy to omit, and omitting it leaves a removed member fully entitled)lapsed/suspended→ every seat revokedRe-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/promorows and leavesapple_iap/google_play/stripealone. 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 toauthenticatedat all, so no policy mistake can leak them. An owner reads them throughclub_billing(), which embeds its own ownership check.Seat granting is backend-only. If
grant_club_seatwere reachable byauthenticated, any signed-in member could mint themselves a comped entitlement to any coach — the entitlement system's own bypass. Asserted42501for both anon and a signed-in member.is_club_owner()/is_club_member()areSECURITY DEFINERfor a structural reason, not convenience: the RLS policy onclub_membersneeds to ask "is the caller an owner of this club", which is itself a question aboutclub_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 oneclub_add_members()call (6 existing accounts granted, 4 invited), invite claimed on signup, removal revokes immediately proven throughhas_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 --noEmitandterraform validateclean.The #25 drift detector picks up the six new
SECURITY DEFINERfunctions automatically and still reports empty — which is the point of it.Note
viz-realtime-probe.mjsremains 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