| 1 | # π€ Community, tiers & paid memberships β design |
| 2 | |
| 3 | Status: **design, pre-implementation.** This documents the plan for three |
| 4 | stacked features β open "community" groups, membership tiers, and paid |
| 5 | subscriptions (Stripe Connect, optionally the app stores) β and pins the |
| 6 | decisions so each layer can land as its own small PR. Nothing here is built |
| 7 | yet; when a layer ships, move its section toward present tense and note the |
| 8 | PR. |
| 9 | |
| 10 | The one-line test that shaped the whole design: **each layer must ship real |
| 11 | value even if the layers above it never land.** Open groups are useful with |
| 12 | no tiers; tiers are useful with no billing; billing is a bolt-on assigner of |
| 13 | tiers, never a rewrite. |
| 14 | |
| 15 | ## Why this fits the E2E rule |
| 16 | |
| 17 | Billing state is roster metadata β the same class as the admin token and |
| 18 | branding: *who* is a member, *what* they've paid, *until when*. None of it |
| 19 | touches content keys, and the server already knows membership. Message |
| 20 | content, screen names, and file bytes stay opaque ciphertext throughout. |
| 21 | |
| 22 | What's genuinely new is a disclosed exception (alongside link previews and |
| 23 | GIF search, CLAUDE.md Β§ the one rule): **Stripe β and therefore the admin β |
| 24 | sees a paying member's real payment identity, and the server learns |
| 25 | uid β tier β paid-until.** Tier names and prices are plaintext by necessity: |
| 26 | they must render at checkout and on join screens where no key exists yet. |
| 27 | That's the branding carve-out (admin-set, never user content), applied to |
| 28 | commerce config. |
| 29 | |
| 30 | ## Roles are not tiers |
| 31 | |
| 32 | The roster already has `users.role` (`member`/`admin`), and its migration |
| 33 | carries a deliberate invariant (`db.go`): *"Authorisation only β a role |
| 34 | grants no key material."* Keep that. |
| 35 | |
| 36 | | | **Roles** (exist today) | **Tiers** (new) | |
| 37 | |---|---|---| |
| 38 | | Govern | what you can **do** (admin API, announcements) | what you can **open** (which groups decrypt) | |
| 39 | | Key material | none, by invariant | exactly one **tier key** each | |
| 40 | | Cardinality | one per member | a member holds a **set** | |
| 41 | | Examples | `admin`, later `moderator` | `silver`, `gold`, `founding-member` | |
| 42 | |
| 43 | Don't map one onto the other. `admin` is not a tier you subscribe to; |
| 44 | `silver` is not a permission set. A subscription is one *assigner* of a tier |
| 45 | among several (manual, invite-type, Stripe, app store) β which is what keeps |
| 46 | billing out of the crypto layer entirely. |
| 47 | |
| 48 | ## Layer 1 β Community: open groups |
| 49 | |
| 50 | An **open group** is a thread any member of the space can discover and join |
| 51 | without being invited. People outside the space still see nothing β "open" |
| 52 | means open *to members*. |
| 53 | |
| 54 | **Mechanism.** Today every thread key is wrapped per-member (`thread_keys`, |
| 55 | one row per member, granted by a *client* wrapping to the joiner's pubkey). |
| 56 | An open group instead wraps its AES key **under the instance key** β which |
| 57 | every member already holds (`instance_keys`, the all-members key used for |
| 58 | profile photos). One wrapped blob per open thread, decryptable by the whole |
| 59 | roster by construction: |
| 60 | |
| 61 | - **Discovery.** A token-gated directory endpoint lists open groups. Names/ |
| 62 | descriptions are encrypted under the instance key, so every member's |
| 63 | client can render the directory; the server still can't read them. |
| 64 | - **Join.** Self-serve and instant β nobody else needs to be online, because |
| 65 | the joiner already holds the instance key. Joining just registers |
| 66 | membership (fan-out, push, unread); the key was already theirs to unwrap. |
| 67 | - **Leave/kick.** Membership rows only. (A kicked member still holds the |
| 68 | instance key, so open groups are honest about their trust level: open to |
| 69 | every member, always. Content that needs excludability belongs in an |
| 70 | ordinary invited thread.) |
| 71 | |
| 72 | Zero new cryptography β it's the instance-key pattern applied to a thread |
| 73 | key, plus a `visibility` flag and a Discover surface. Web, iOS and Mac land |
| 74 | together (iOS first, per convention); the wrap/unwrap path is client crypto |
| 75 | in `web/static/crypto.js` + `LChatCore`. |
| 76 | |
| 77 | ## Layer 2 β Tiers |
| 78 | |
| 79 | A **tier** generalises the instance key: a named key, wrapped to each |
| 80 | *entitled* member instead of to everyone. |
| 81 | |
| 82 | - Tables: `tiers` (id, name, ordering, later a price hook) and `user_tiers` |
| 83 | (member β tier, plus how they got it and until when β see grace, below). |
| 84 | - **Tier keys** reuse the instance-key machinery verbatim: one wrapped blob |
| 85 | per entitled member, granted by an online key-holder's client when the |
| 86 | server prods it (the `instance_key_granted` pattern in `main.go`). Same |
| 87 | "waiting for your key" UX when no holder is online; the bot sidecar can |
| 88 | act as an always-on key steward where the admin has one (same scoped, |
| 89 | disclosed trust choice as adding a bot to a thread β `docs/bots.md`). |
| 90 | - **Tier-gated groups.** An open group may wrap its thread key under a tier |
| 91 | key instead of the instance key. "Create a group for all silver members" |
| 92 | is exactly that β and auto-membership is *inherent, not a sync job*: the |
| 93 | moment someone holds the silver key they can open every silver-gated |
| 94 | group. Joining is just registering for fan-out and push. |
| 95 | - **Assignment is manual first.** The Admin app grows a Tiers section: |
| 96 | create tiers, assign/revoke members by hand. This ships as a complete, |
| 97 | billing-free feature (curated member groups) and proves every mechanism |
| 98 | billing later drives. |
| 99 | |
| 100 | ## Revocation & the grace period |
| 101 | |
| 102 | Entitlements carry `paid_until` (or nothing, for hand-assigned tiers, which |
| 103 | never lapse). Lapse β the standard **30-day grace** β revocation: |
| 104 | |
| 105 | - The server deletes the member's tier-key row and their membership rows in |
| 106 | tier-gated groups, and stops serving those threads' ciphertext to them. |
| 107 | - This is **service-level revocation, the same strength as removing a member |
| 108 | today** (`removeUser` deletes tokens + key rows; it does not rekey). Be |
| 109 | honest in member-facing copy: a lapsed member keeps what their device |
| 110 | already downloaded β true of every E2EE system. |
| 111 | - **Later, optional:** lazy epoch rotation β a client mints `silver-key-v2`, |
| 112 | rewraps to current holders, and new groups/wraps use v2. An upgrade, not a |
| 113 | prerequisite; nothing in layers 1β3 depends on it. |
| 114 | |
| 115 | ## Layer 3 β Paid subscriptions |
| 116 | |
| 117 | ### One entitlement, pluggable sources |
| 118 | |
| 119 | The instance stores one source-agnostic record per paying member: |
| 120 | `uid β tier, status, paid_until, grace_deadline, source`. Three writers, |
| 121 | identical machinery below the line: |
| 122 | |
| 123 | | source | how entitlement changes arrive | |
| 124 | |---|---| |
| 125 | | `stripe` | Stripe Connect webhooks, relayed by the broker | |
| 126 | | `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 | |
| 127 | | `google` | Play Real-Time Developer Notifications (Pub/Sub), verified with the Play Developer API β later, same shape | |
| 128 | |
| 129 | A member who subscribes on iPhone is entitled on web and Mac too: |
| 130 | entitlement lives on the instance, not the device. |
| 131 | |
| 132 | ### Stripe Connect (the primary path) |
| 133 | |
| 134 | - **Onboarding rides the connect broker** (`connect.elevenmessenger.com`, |
| 135 | the `docs/integrations.md` pattern): the Stripe *platform* secret lives |
| 136 | only on the broker; instances hold just their connected-account id |
| 137 | (`acct_β¦`, not a secret) and a bearer token β exactly the push-relay |
| 138 | shape (`docs/push-relay.md`), for the same reason. |
| 139 | - **Standard connected accounts, direct charges.** The admin is merchant of |
| 140 | record: money lands in their Stripe account, tax/VAT/refunds are theirs |
| 141 | (Stripe Tax on their account). That's the right liability position for |
| 142 | the platform. |
| 143 | - **Tiers become Products/Prices** on the connected account. Subscribe = |
| 144 | Stripe Checkout (hosted β card data never touches us) for a subscription |
| 145 | with `application_fee_percent: 10`. |
| 146 | - **Commission: 10%**, market rate (Substack 10%, Patreon 8β12%). Stripe's |
| 147 | ~2.9% + 30Β’ sits underneath and the admin pays it; show both numbers |
| 148 | honestly in the admin UI. |
| 149 | - **Webhooks** land at one platform-level Connect endpoint on the broker and |
| 150 | are relayed to the instance as minimal entitlement changes. |
| 151 | |
| 152 | ### Our own space plans: Stripe-only, no IAP (decided 2026-07-13) |
| 153 | |
| 154 | The section above is about *creator* tiers β members paying a space's admin. |
| 155 | For **Eleven's own space plans** (Family/Community/Business, sold by us) the |
| 156 | decision is simpler and firmer: **Stripe Checkout from the home page is the |
| 157 | only purchase path; we do not offer IAP for space plans.** Why: |
| 158 | |
| 159 | - **Wrong identity spine.** The buyer is a home account (passkey) upgrading a |
| 160 | *space* whose benefits reach every member on every platform. IAP binds the |
| 161 | entitlement to an Apple ID β reconciling that back into the cloud ledger |
| 162 | means receipt validation, App Store Server Notifications, and refund sync: a |
| 163 | second source of billing truth beside the one Stripe webhook we have. |
| 164 | - **The fee scales with price.** Apple's 15% is β¬0.75 on a β¬5/y Family space |
| 165 | but ~β¬150 on a β¬999/y Business space; Stripe is ~β¬0.33 and ~β¬15. |
| 166 | - **The link-out era removed the main upside.** Since the May 2025 US ruling, |
| 167 | the iOS app can link to web checkout in the US storefront with no fee and no |
| 168 | entitlement; the EU allows it under fee-and-reporting terms we deliberately |
| 169 | don't take (below); other storefronts stay quiet. |
| 170 | |
| 171 | **Steering posture** (what renders where β this is the part that keeps us |
| 172 | clean under App Store rule 3.1.1 and the EU DMA fee terms): |
| 173 | |
| 174 | - **Per-client UI can gate by platform/storefront**, and does: the space |
| 175 | menu's Upgrade entry shows on the web, and on iOS only where a storefront |
| 176 | is in the allowlist (US to start β the clean no-fee, no-reporting regime; |
| 177 | the EU stays off it so we owe Apple neither commission nor monthly |
| 178 | transaction reporting). |
| 179 | - **Chat content cannot gate** β a message renders on every device the member |
| 180 | owns, so a payment link posted in chat reaches storefronts where steering |
| 181 | is still restricted. Therefore **Eleven and the other first-party bots |
| 182 | never post checkout/payment links in chat, and never proactively upsell**. |
| 183 | A bot we ship is *our* content β "a bot said it" is still the app steering, |
| 184 | and E2EE hiding the message from Apple is not a compliance posture (our |
| 185 | prompts and Eleven's knowledge file are published). When a member asks how |
| 186 | to upgrade, Eleven describes the path β the home page β without a link. |
| 187 | - **Proactive upsell belongs to channels Apple has no claim on**: the home |
| 188 | page, the web shell, email. |
| 189 | |
| 190 | ### App-store billing (optional, beside Stripe β never instead) |
| 191 | |
| 192 | IAP products belong to *our* bundle ID, so this path is inherently central |
| 193 | and optional per admin. Structural differences to be clear-eyed about: |
| 194 | |
| 195 | - **Apple is merchant of record.** Apple collects, handles VAT/refunds/ |
| 196 | chargebacks, takes 15β30% (15% under the Small Business Program and on |
| 197 | year-2+ subscriptions), and remits to Eleven Messenger ~30β45 days later. |
| 198 | We then owe the admin a payout. |
| 199 | - **Payout rail = the same Stripe connected account.** IAP proceeds go out |
| 200 | as Stripe `Transfer`s to the admin's `acct_β¦`, minus commission. One |
| 201 | onboarding, one dashboard, two inflows. (Patreon's model β and Patreon is |
| 202 | the App Review precedent that creator subscriptions via IAP are an |
| 203 | accepted category.) |
| 204 | - **Price ladder, generic products.** Admins set arbitrary prices; Apple |
| 205 | wants pre-registered products. Mint a lattice of generic subscriptions |
| 206 | ("Membership β $4.99/mo", "$9.99/mo", β¦) once via the App Store Connect |
| 207 | API and snap each tier to the nearest rung. Generic names are also the |
| 208 | privacy answer: IAP products are per-app and visible in receipts, so |
| 209 | "Membership $9.99" leaks nothing about which space or tier. |
| 210 | - **The math is worse and we say so.** Stripe path: admin nets ~87%. IAP |
| 211 | path: ~60β75% after Apple + commission. Offer a "+ app-store fee" |
| 212 | auto-markup toggle (default on) so in-app prices compensate, and show the |
| 213 | admin both nets. |
| 214 | - **Link-out softens it.** Under the US Epic injunction and the EU DMA, the |
| 215 | iOS app can link to web checkout with no Apple commission in those |
| 216 | regions. IAP then becomes what it should be β the frictionless option for |
| 217 | members who prefer it, not a toll gate. |
| 218 | |
| 219 | The broker grows into the **billing hub**: Stripe platform keys + App Store |
| 220 | Server API key (+ Play service account, later) live there, all webhooks and |
| 221 | notifications land there, and instances receive only entitlement changes |
| 222 | over their bearer-token channel. |
| 223 | |
| 224 | ### Self-hosters |
| 225 | |
| 226 | BYO Stripe keys, 0% commission β the self-host escape hatch is explicit and |
| 227 | embraced, not fought. The broker path is what hosted/cloud spaces get as |
| 228 | zero-config convenience, and it's where the commission applies. IAP is only |
| 229 | meaningful through our app-store listings, so it inherently routes through |
| 230 | the hub; a self-hosted instance simply doesn't offer it. |
| 231 | |
| 232 | ## What each party learns |
| 233 | |
| 234 | | party | learns | never learns | |
| 235 | |---|---|---| |
| 236 | | the server | uid β tier β paid-until; tier names/prices; which uids are in which open groups | message content, screen names, file bytes β unchanged | |
| 237 | | the admin | who subscribes, real payment identity (their Stripe dashboard) | anything beyond threads they're in β unchanged | |
| 238 | | Stripe | the member's payment identity, the admin's account, amounts | which space beyond the admin's account metadata; nothing about content | |
| 239 | | Apple/Google | that a user of the app bought "Membership $X" (generic product) | which space, which tier name, anything about content | |
| 240 | |
| 241 | ## Build order |
| 242 | |
| 243 | > **Status (2026-07): 1β5 SHIPPED.** Open groups, default groups, tiers |
| 244 | > ("access levels"), level-gated groups, cohort invites, and the visibility |
| 245 | > policy are on main and in production. **Stripe Connect member subscriptions |
| 246 | > (5) are built** (`subscriptions.go`): the entitlement engine on `user_tiers`, |
| 247 | > the `PayProvider` seam + `stripeConnect` (10% application fee, hand-rolled |
| 248 | > like `billing.go`), member subscribe/change/cancel/portal + the Membership |
| 249 | > UI, admin price + account linking, the webhook, and the 30-day graceβrevoke |
| 250 | > sweep. **Dormant** until `LCHAT_STRIPE_CONNECT_KEY` + `_WEBHOOK_SECRET` are |
| 251 | > set and a space links a connected account. The **member** Membership surface |
| 252 | > ships on all three clients β web (`showMembership` in shell.js) and native |
| 253 | > (`MembershipView` in `LChatCore`, wired into iOS Settings + the Mac Settings |
| 254 | > window). **Account linking is OAuth** (decided 2026-07-15 β no pasted |
| 255 | > `acct_β¦` ids): the admin's "Connect Stripe account" button rides the connect |
| 256 | > broker's consent trip with a `billing.`-marked state; the broker exchanges |
| 257 | > the code itself, keeps nothing, and forwards only the `stripe_user_id`, |
| 258 | > which the admin's own browser session links via the tierAdmin-gated |
| 259 | > `POST /api/admin/billing/account`. No token ever reaches the browser, the |
| 260 | > instance, or rests on the broker β direct charges need only the platform |
| 261 | > key + the account id. Deferred: moving the Stripe platform secret onto the |
| 262 | > *broker* (the hub is instance-env for now β a provider swap), Stripe-hosted |
| 263 | > Connect onboarding for admins without a Stripe account, **native admin |
| 264 | > level management** (create/price/link β web-only for now; the native app |
| 265 | > tracks no admin role), and (6) app-store IAP. |
| 266 | |
| 267 | 1. **This doc** β pin the decisions. |
| 268 | 2. **Open groups** β `visibility` flag, instance-key-wrapped thread key, |
| 269 | directory endpoint + Discover surface, self-join. iOS + Mac + web |
| 270 | together. Complete feature, no money. |
| 271 | 3. **Tiers** β `tiers`/`user_tiers`, tier keys on the instance-key grant |
| 272 | pattern, Admin app management UI, tier-gated groups. Complete feature, |
| 273 | no money. |
| 274 | 4. **Grace/lapse** β `paid_until` + 30-day grace + revoke, generic over how |
| 275 | the tier was assigned. Possibly folded into 3. |
| 276 | 5. **Billing hub: Stripe Connect** β broker onboarding, checkout, webhooks, |
| 277 | 10% application fee. |
| 278 | 6. **App-store source** β Apple first, Google later. |
| 279 | |
| 280 | ## Open questions |
| 281 | |
| 282 | - Does the 10% apply flat on IAP revenue (on top of Apple's cut) or drop to |
| 283 | soften the stack? |
| 284 | - Tier-gated *groups* only at first β when (if ever) can a whole *space* be |
| 285 | subscription-gated? Start with groups; space-level paywalls compose with |
| 286 | free members existing and can come later behind a flag. |
| 287 | - Is the sidecar-as-key-steward default-on for subscription tiers? It makes |
| 288 | grants instant instead of waiting for a human key-holder's client, at the |
| 289 | cost of one more disclosed key holder. |
| 290 | - Payout ledger UX for the IAP path ("Apple revenue clears in ~40 days") |
| 291 | needs a surface eventually β where? |
| 292 | |