docs: community, tiers & paid memberships design (#614)
Pins the design for three stacked, independently-shippable layers: open community groups (thread key wrapped under the instance key), membership tiers (named keys on the instance-key grant pattern, roles stay key-free), and paid subscriptions (Stripe Connect via the broker at 10%, with optional app-store IAP as a second entitlement source paying out through the same connected account).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+229
−0
docs/community-and-tiers.md+229 −0
diff --git a/docs/community-and-tiers.md b/docs/community-and-tiers.md| new file mode 100644 |
| index 0000000..c77f3a1 |
| --- /dev/null |
| +++ b/docs/community-and-tiers.md |
| @@ -0,0 +1,229 @@ |
| +# ๐ค Community, tiers & paid memberships โ design |
| + |
| +Status: **design, pre-implementation.** This documents the plan for three |
| +stacked features โ open "community" groups, membership tiers, and paid |
| +subscriptions (Stripe Connect, optionally the app stores) โ and pins the |
| +decisions so each layer can land as its own small PR. Nothing here is built |
| +yet; when a layer ships, move its section toward present tense and note the |
| +PR. |
| + |
| +The one-line test that shaped the whole design: **each layer must ship real |
| +value even if the layers above it never land.** Open groups are useful with |
| +no tiers; tiers are useful with no billing; billing is a bolt-on assigner of |
| +tiers, never a rewrite. |
| + |
| +## Why this fits the E2E rule |
| + |
| +Billing state is roster metadata โ the same class as the admin token and |
| +branding: *who* is a member, *what* they've paid, *until when*. None of it |
| +touches content keys, and the server already knows membership. Message |
| +content, screen names, and file bytes stay opaque ciphertext throughout. |
| + |
| +What's genuinely new is a disclosed exception (alongside link previews and |
| +GIF search, CLAUDE.md ยง the one rule): **Stripe โ and therefore the admin โ |
| +sees a paying member's real payment identity, and the server learns |
| +uid โ tier โ paid-until.** Tier names and prices are plaintext by necessity: |
| +they must render at checkout and on join screens where no key exists yet. |
| +That's the branding carve-out (admin-set, never user content), applied to |
| +commerce config. |
| + |
| +## Roles are not tiers |
| + |
| +The roster already has `users.role` (`member`/`admin`), and its migration |
| +carries a deliberate invariant (`db.go`): *"Authorisation only โ a role |
| +grants no key material."* Keep that. |
| + |
| +| | **Roles** (exist today) | **Tiers** (new) | |
| +|---|---|---| |
| +| Govern | what you can **do** (admin API, announcements) | what you can **open** (which groups decrypt) | |
| +| Key material | none, by invariant | exactly one **tier key** each | |
| +| Cardinality | one per member | a member holds a **set** | |
| +| Examples | `admin`, later `moderator` | `silver`, `gold`, `founding-member` | |
| + |
| +Don't map one onto the other. `admin` is not a tier you subscribe to; |
| +`silver` is not a permission set. A subscription is one *assigner* of a tier |
| +among several (manual, invite-type, Stripe, app store) โ which is what keeps |
| +billing out of the crypto layer entirely. |
| + |
| +## Layer 1 โ Community: open groups |
| + |
| +An **open group** is a thread any member of the space can discover and join |
| +without being invited. People outside the space still see nothing โ "open" |
| +means open *to members*. |
| + |
| +**Mechanism.** Today every thread key is wrapped per-member (`thread_keys`, |
| +one row per member, granted by a *client* wrapping to the joiner's pubkey). |
| +An open group instead wraps its AES key **under the instance key** โ which |
| +every member already holds (`instance_keys`, the all-members key used for |
| +profile photos). One wrapped blob per open thread, decryptable by the whole |
| +roster by construction: |
| + |
| +- **Discovery.** A token-gated directory endpoint lists open groups. Names/ |
| + descriptions are encrypted under the instance key, so every member's |
| + client can render the directory; the server still can't read them. |
| +- **Join.** Self-serve and instant โ nobody else needs to be online, because |
| + the joiner already holds the instance key. Joining just registers |
| + membership (fan-out, push, unread); the key was already theirs to unwrap. |
| +- **Leave/kick.** Membership rows only. (A kicked member still holds the |
| + instance key, so open groups are honest about their trust level: open to |
| + every member, always. Content that needs excludability belongs in an |
| + ordinary invited thread.) |
| + |
| +Zero new cryptography โ it's the instance-key pattern applied to a thread |
| +key, plus a `visibility` flag and a Discover surface. Web, iOS and Mac land |
| +together (iOS first, per convention); the wrap/unwrap path is client crypto |
| +in `web/static/crypto.js` + `LChatCore`. |
| + |
| +## Layer 2 โ Tiers |
| + |
| +A **tier** generalises the instance key: a named key, wrapped to each |
| +*entitled* member instead of to everyone. |
| + |
| +- Tables: `tiers` (id, name, ordering, later a price hook) and `user_tiers` |
| + (member โ tier, plus how they got it and until when โ see grace, below). |
| +- **Tier keys** reuse the instance-key machinery verbatim: one wrapped blob |
| + per entitled member, granted by an online key-holder's client when the |
| + server prods it (the `instance_key_granted` pattern in `main.go`). Same |
| + "waiting for your key" UX when no holder is online; the bot sidecar can |
| + act as an always-on key steward where the admin has one (same scoped, |
| + disclosed trust choice as adding a bot to a thread โ `docs/bots.md`). |
| +- **Tier-gated groups.** An open group may wrap its thread key under a tier |
| + key instead of the instance key. "Create a group for all silver members" |
| + is exactly that โ and auto-membership is *inherent, not a sync job*: the |
| + moment someone holds the silver key they can open every silver-gated |
| + group. Joining is just registering for fan-out and push. |
| +- **Assignment is manual first.** The Admin app grows a Tiers section: |
| + create tiers, assign/revoke members by hand. This ships as a complete, |
| + billing-free feature (curated member groups) and proves every mechanism |
| + billing later drives. |
| + |
| +## Revocation & the grace period |
| + |
| +Entitlements carry `paid_until` (or nothing, for hand-assigned tiers, which |
| +never lapse). Lapse โ the standard **30-day grace** โ revocation: |
| + |
| +- The server deletes the member's tier-key row and their membership rows in |
| + tier-gated groups, and stops serving those threads' ciphertext to them. |
| +- This is **service-level revocation, the same strength as removing a member |
| + today** (`removeUser` deletes tokens + key rows; it does not rekey). Be |
| + honest in member-facing copy: a lapsed member keeps what their device |
| + already downloaded โ true of every E2EE system. |
| +- **Later, optional:** lazy epoch rotation โ a client mints `silver-key-v2`, |
| + rewraps to current holders, and new groups/wraps use v2. An upgrade, not a |
| + prerequisite; nothing in layers 1โ3 depends on it. |
| + |
| +## Layer 3 โ Paid subscriptions |
| + |
| +### One entitlement, pluggable sources |
| + |
| +The instance stores one source-agnostic record per paying member: |
| +`uid โ tier, status, paid_until, grace_deadline, source`. Three writers, |
| +identical machinery below the line: |
| + |
| +| source | how entitlement changes arrive | |
| +|---|---| |
| +| `stripe` | Stripe Connect webhooks, relayed by the broker | |
| +| `apple` | App Store Server Notifications v2 (signed JWS), verified against the App Store Server API; purchases carry an `appAccountToken` UUID we mint per uid, so attribution is exact | |
| +| `google` | Play Real-Time Developer Notifications (Pub/Sub), verified with the Play Developer API โ later, same shape | |
| + |
| +A member who subscribes on iPhone is entitled on web and Mac too: |
| +entitlement lives on the instance, not the device. |
| + |
| +### Stripe Connect (the primary path) |
| + |
| +- **Onboarding rides the connect broker** (`connect.elevenmessenger.com`, |
| + the `docs/integrations.md` pattern): the Stripe *platform* secret lives |
| + only on the broker; instances hold just their connected-account id |
| + (`acct_โฆ`, not a secret) and a bearer token โ exactly the push-relay |
| + shape (`docs/push-relay.md`), for the same reason. |
| +- **Standard connected accounts, direct charges.** The admin is merchant of |
| + record: money lands in their Stripe account, tax/VAT/refunds are theirs |
| + (Stripe Tax on their account). That's the right liability position for |
| + the platform. |
| +- **Tiers become Products/Prices** on the connected account. Subscribe = |
| + Stripe Checkout (hosted โ card data never touches us) for a subscription |
| + with `application_fee_percent: 10`. |
| +- **Commission: 10%**, market rate (Substack 10%, Patreon 8โ12%). Stripe's |
| + ~2.9% + 30ยข sits underneath and the admin pays it; show both numbers |
| + honestly in the admin UI. |
| +- **Webhooks** land at one platform-level Connect endpoint on the broker and |
| + are relayed to the instance as minimal entitlement changes. |
| + |
| +### App-store billing (optional, beside Stripe โ never instead) |
| + |
| +IAP products belong to *our* bundle ID, so this path is inherently central |
| +and optional per admin. Structural differences to be clear-eyed about: |
| + |
| +- **Apple is merchant of record.** Apple collects, handles VAT/refunds/ |
| + chargebacks, takes 15โ30% (15% under the Small Business Program and on |
| + year-2+ subscriptions), and remits to Eleven Messenger ~30โ45 days later. |
| + We then owe the admin a payout. |
| +- **Payout rail = the same Stripe connected account.** IAP proceeds go out |
| + as Stripe `Transfer`s to the admin's `acct_โฆ`, minus commission. One |
| + onboarding, one dashboard, two inflows. (Patreon's model โ and Patreon is |
| + the App Review precedent that creator subscriptions via IAP are an |
| + accepted category.) |
| +- **Price ladder, generic products.** Admins set arbitrary prices; Apple |
| + wants pre-registered products. Mint a lattice of generic subscriptions |
| + ("Membership โ $4.99/mo", "$9.99/mo", โฆ) once via the App Store Connect |
| + API and snap each tier to the nearest rung. Generic names are also the |
| + privacy answer: IAP products are per-app and visible in receipts, so |
| + "Membership $9.99" leaks nothing about which space or tier. |
| +- **The math is worse and we say so.** Stripe path: admin nets ~87%. IAP |
| + path: ~60โ75% after Apple + commission. Offer a "+ app-store fee" |
| + auto-markup toggle (default on) so in-app prices compensate, and show the |
| + admin both nets. |
| +- **Link-out softens it.** Under the US Epic injunction and the EU DMA, the |
| + iOS app can link to web checkout with no Apple commission in those |
| + regions. IAP then becomes what it should be โ the frictionless option for |
| + members who prefer it, not a toll gate. |
| + |
| +The broker grows into the **billing hub**: Stripe platform keys + App Store |
| +Server API key (+ Play service account, later) live there, all webhooks and |
| +notifications land there, and instances receive only entitlement changes |
| +over their bearer-token channel. |
| + |
| +### Self-hosters |
| + |
| +BYO Stripe keys, 0% commission โ the self-host escape hatch is explicit and |
| +embraced, not fought. The broker path is what hosted/cloud spaces get as |
| +zero-config convenience, and it's where the commission applies. IAP is only |
| +meaningful through our app-store listings, so it inherently routes through |
| +the hub; a self-hosted instance simply doesn't offer it. |
| + |
| +## What each party learns |
| + |
| +| party | learns | never learns | |
| +|---|---|---| |
| +| the server | uid โ tier โ paid-until; tier names/prices; which uids are in which open groups | message content, screen names, file bytes โ unchanged | |
| +| the admin | who subscribes, real payment identity (their Stripe dashboard) | anything beyond threads they're in โ unchanged | |
| +| Stripe | the member's payment identity, the admin's account, amounts | which space beyond the admin's account metadata; nothing about content | |
| +| Apple/Google | that a user of the app bought "Membership $X" (generic product) | which space, which tier name, anything about content | |
| + |
| +## Build order |
| + |
| +1. **This doc** โ pin the decisions. |
| +2. **Open groups** โ `visibility` flag, instance-key-wrapped thread key, |
| + directory endpoint + Discover surface, self-join. iOS + Mac + web |
| + together. Complete feature, no money. |
| +3. **Tiers** โ `tiers`/`user_tiers`, tier keys on the instance-key grant |
| + pattern, Admin app management UI, tier-gated groups. Complete feature, |
| + no money. |
| +4. **Grace/lapse** โ `paid_until` + 30-day grace + revoke, generic over how |
| + the tier was assigned. Possibly folded into 3. |
| +5. **Billing hub: Stripe Connect** โ broker onboarding, checkout, webhooks, |
| + 10% application fee. |
| +6. **App-store source** โ Apple first, Google later. |
| + |
| +## Open questions |
| + |
| +- Does the 10% apply flat on IAP revenue (on top of Apple's cut) or drop to |
| + soften the stack? |
| +- Tier-gated *groups* only at first โ when (if ever) can a whole *space* be |
| + subscription-gated? Start with groups; space-level paywalls compose with |
| + free members existing and can come later behind a flag. |
| +- Is the sidecar-as-key-steward default-on for subscription tiers? It makes |
| + grants instant instead of waiting for a human key-holder's client, at the |
| + cost of one more disclosed key holder. |
| +- Payout ledger UX for the IAP path ("Apple revenue clears in ~40 days") |
| + needs a surface eventually โ where? |