eleven / messenger Public

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>
Paul Campbell 5dc59d1229fe0cfc9a3abf73e73e709d12af9112 parent 2c02680
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?