eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Community, tiers & paid memberships β€” design
2
3Status: **design, pre-implementation.** This documents the plan for three
4stacked features β€” open "community" groups, membership tiers, and paid
5subscriptions (Stripe Connect, optionally the app stores) β€” and pins the
6decisions so each layer can land as its own small PR. Nothing here is built
7yet; when a layer ships, move its section toward present tense and note the
8PR.
9
10The one-line test that shaped the whole design: **each layer must ship real
11value even if the layers above it never land.** Open groups are useful with
12no tiers; tiers are useful with no billing; billing is a bolt-on assigner of
13tiers, never a rewrite.
14
15## Why this fits the E2E rule
16
17Billing state is roster metadata β€” the same class as the admin token and
18branding: *who* is a member, *what* they've paid, *until when*. None of it
19touches content keys, and the server already knows membership. Message
20content, screen names, and file bytes stay opaque ciphertext throughout.
21
22What's genuinely new is a disclosed exception (alongside link previews and
23GIF search, CLAUDE.md Β§ the one rule): **Stripe β€” and therefore the admin β€”
24sees a paying member's real payment identity, and the server learns
25uid ↔ tier ↔ paid-until.** Tier names and prices are plaintext by necessity:
26they must render at checkout and on join screens where no key exists yet.
27That's the branding carve-out (admin-set, never user content), applied to
28commerce config.
29
30## Roles are not tiers
31
32The roster already has `users.role` (`member`/`admin`), and its migration
33carries a deliberate invariant (`db.go`): *"Authorisation only β€” a role
34grants 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
43Don'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
45among several (manual, invite-type, Stripe, app store) β€” which is what keeps
46billing out of the crypto layer entirely.
47
48## Layer 1 β€” Community: open groups
49
50An **open group** is a thread any member of the space can discover and join
51without being invited. People outside the space still see nothing β€” "open"
52means open *to members*.
53
54**Mechanism.** Today every thread key is wrapped per-member (`thread_keys`,
55one row per member, granted by a *client* wrapping to the joiner's pubkey).
56An open group instead wraps its AES key **under the instance key** β€” which
57every member already holds (`instance_keys`, the all-members key used for
58profile photos). One wrapped blob per open thread, decryptable by the whole
59roster 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
72Zero new cryptography β€” it's the instance-key pattern applied to a thread
73key, plus a `visibility` flag and a Discover surface. Web, iOS and Mac land
74together (iOS first, per convention); the wrap/unwrap path is client crypto
75in `web/static/crypto.js` + `LChatCore`.
76
77## Layer 2 β€” Tiers
78
79A **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
102Entitlements carry `paid_until` (or nothing, for hand-assigned tiers, which
103never 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
119The instance stores one source-agnostic record per paying member:
120`uid β†’ tier, status, paid_until, grace_deadline, source`. Three writers,
121identical 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
129A member who subscribes on iPhone is entitled on web and Mac too:
130entitlement 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
154The section above is about *creator* tiers β€” members paying a space's admin.
155For **Eleven's own space plans** (Family/Community/Business, sold by us) the
156decision is simpler and firmer: **Stripe Checkout from the home page is the
157only 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
172clean 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
192IAP products belong to *our* bundle ID, so this path is inherently central
193and 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
219The broker grows into the **billing hub**: Stripe platform keys + App Store
220Server API key (+ Play service account, later) live there, all webhooks and
221notifications land there, and instances receive only entitlement changes
222over their bearer-token channel.
223
224### Self-hosters
225
226BYO Stripe keys, 0% commission β€” the self-host escape hatch is explicit and
227embraced, not fought. The broker path is what hosted/cloud spaces get as
228zero-config convenience, and it's where the commission applies. IAP is only
229meaningful through our app-store listings, so it inherently routes through
230the 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
2671. **This doc** β€” pin the decisions.
2682. **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.
2713. **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.
2744. **Grace/lapse** β€” `paid_until` + 30-day grace + revoke, generic over how
275 the tier was assigned. Possibly folded into 3.
2765. **Billing hub: Stripe Connect** β€” broker onboarding, checkout, webhooks,
277 10% application fee.
2786. **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