# 🤖 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.

### Our own space plans: Stripe-only, no IAP (decided 2026-07-13)

The section above is about *creator* tiers — members paying a space's admin.
For **Eleven's own space plans** (Family/Community/Business, sold by us) the
decision is simpler and firmer: **Stripe Checkout from the home page is the
only purchase path; we do not offer IAP for space plans.** Why:

- **Wrong identity spine.** The buyer is a home account (passkey) upgrading a
  *space* whose benefits reach every member on every platform. IAP binds the
  entitlement to an Apple ID — reconciling that back into the cloud ledger
  means receipt validation, App Store Server Notifications, and refund sync: a
  second source of billing truth beside the one Stripe webhook we have.
- **The fee scales with price.** Apple's 15% is €0.75 on a €5/y Family space
  but ~€150 on a €999/y Business space; Stripe is ~€0.33 and ~€15.
- **The link-out era removed the main upside.** Since the May 2025 US ruling,
  the iOS app can link to web checkout in the US storefront with no fee and no
  entitlement; the EU allows it under fee-and-reporting terms we deliberately
  don't take (below); other storefronts stay quiet.

**Steering posture** (what renders where — this is the part that keeps us
clean under App Store rule 3.1.1 and the EU DMA fee terms):

- **Per-client UI can gate by platform/storefront**, and does: the space
  menu's Upgrade entry shows on the web, and on iOS only where a storefront
  is in the allowlist (US to start — the clean no-fee, no-reporting regime;
  the EU stays off it so we owe Apple neither commission nor monthly
  transaction reporting).
- **Chat content cannot gate** — a message renders on every device the member
  owns, so a payment link posted in chat reaches storefronts where steering
  is still restricted. Therefore **Eleven and the other first-party bots
  never post checkout/payment links in chat, and never proactively upsell**.
  A bot we ship is *our* content — "a bot said it" is still the app steering,
  and E2EE hiding the message from Apple is not a compliance posture (our
  prompts and Eleven's knowledge file are published). When a member asks how
  to upgrade, Eleven describes the path — the home page — without a link.
- **Proactive upsell belongs to channels Apple has no claim on**: the home
  page, the web shell, email.

### 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

> **Status (2026-07): 1–5 SHIPPED.** Open groups, default groups, tiers
> ("access levels"), level-gated groups, cohort invites, and the visibility
> policy are on main and in production. **Stripe Connect member subscriptions
> (5) are built** (`subscriptions.go`): the entitlement engine on `user_tiers`,
> the `PayProvider` seam + `stripeConnect` (10% application fee, hand-rolled
> like `billing.go`), member subscribe/change/cancel/portal + the Membership
> UI, admin price + account linking, the webhook, and the 30-day grace→revoke
> sweep. **Dormant** until `LCHAT_STRIPE_CONNECT_KEY` + `_WEBHOOK_SECRET` are
> set and a space links a connected account. The **member** Membership surface
> ships on all three clients — web (`showMembership` in shell.js) and native
> (`MembershipView` in `LChatCore`, wired into iOS Settings + the Mac Settings
> window). **Account linking is OAuth** (decided 2026-07-15 — no pasted
> `acct_…` ids): the admin's "Connect Stripe account" button rides the connect
> broker's consent trip with a `billing.`-marked state; the broker exchanges
> the code itself, keeps nothing, and forwards only the `stripe_user_id`,
> which the admin's own browser session links via the tierAdmin-gated
> `POST /api/admin/billing/account`. No token ever reaches the browser, the
> instance, or rests on the broker — direct charges need only the platform
> key + the account id. Deferred: moving the Stripe platform secret onto the
> *broker* (the hub is instance-env for now — a provider swap), Stripe-hosted
> Connect onboarding for admins without a Stripe account, **native admin
> level management** (create/price/link — web-only for now; the native app
> tracks no admin role), and (6) app-store IAP.

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?
