docs: rework badge auth, cancel, and recovery model

This commit is contained in:
shum
2026-07-23 07:17:40 +00:00
parent 4defe642d2
commit 017b53555f
5 changed files with 127 additions and 47 deletions
@@ -17,10 +17,11 @@ Payment verification creates a provider-neutral `ServiceGrant`. Badge issuance f
- [5. Persistence and `CallState` pattern](#5-persistence-and-callstate-pattern)
- [6. Reconciliation and errors](#6-reconciliation-and-errors)
- [7. Provider rules](#7-provider-rules)
- [8. Security and concurrency](#8-security-and-concurrency)
- [9. Delivery and tests](#9-delivery-and-tests)
- [10. API references](#10-api-references)
- [11. Open question](#11-open-question)
- [8. Recovery](#8-recovery)
- [9. Security and concurrency](#9-security-and-concurrency)
- [10. Delivery and tests](#10-delivery-and-tests)
- [11. API references](#11-api-references)
- [12. Open questions](#12-open-questions)
## 1. Architecture
@@ -34,7 +35,7 @@ flowchart LR
| Component | Owns | Must not own |
|---|---|---|
| Client payment | BBS master key, capability, purchase UI, cached status, retry schedule | bot/provider truth |
| Client payment | BBS master key, purchase UI, cached status, retry schedule | bot/provider truth |
| Client badge | credential receipt and installation | billing state |
| Payment service | BBS owner commitment, proof verification, billing state, grant schedule | raw master key, credential |
| Service grant | product and eligible monthly grant period | provider proof, credential |
@@ -51,7 +52,7 @@ Treat these as separate programs with typed interfaces.
4. Only `GrantReady` plus the matching raw master key enters badge signing.
5. Grant fulfillment and cached issuance result are atomic/idempotent.
6. Payment never activates perks; verified credential does.
7. RPC has no caller identity or bot push. Capability authorizes each payment request.
7. RPC has no caller identity, no bot-issued token, and no bot push. Each request carries its own credential — a fresh provider proof (Apple/Google) or a `BadgeMasterKey` possession proof (Stripe) — verified against the payment binding.
8. Duplicate RPCs/events return the same result. Unknown states preserve prior state.
9. Provider dates create eligibility; retry/request time never changes badge expiry.
@@ -112,7 +113,7 @@ These names are canonical. Every transition is validated against the current con
| State | Meaning |
|---|---|
| `BPPrepared` | payment/capability/binding stored |
| `BPPrepared` | payment/commitment/binding stored |
| `BPCheckoutOpen` | Stripe Session stored |
| `BPAwaitingPayment` | provider not complete |
| `BPVerifying` | reconciliation lease active |
@@ -166,12 +167,22 @@ data ServiceCall = ServiceCall
data PaymentInput
= Prepare Provider ServiceProductId PurchaseKind BadgeKeyCommitment
| AppleEvidence PaymentId Capability SignedTransactionJWS
| GoogleEvidence PaymentId Capability PurchaseToken
| ExistingPayment PaymentId Capability
| CancelSubscription PaymentId Capability
| CreatePortal PaymentId Capability
| AppleEvidence BadgeKeyCommitment SignedTransactionJWS
| GoogleEvidence BadgeKeyCommitment PurchaseToken
| StripeStatus BadgeKeyProof
| StripeCancel BadgeKeyProof
| StripePortal BadgeKeyProof
-- BadgeKeyProof is the raw BadgeMasterKey, revealed over the E2E channel;
-- the bot re-derives BadgeKeyCommitment = SHA-256(domain || key) and matches.
-- This reuses issuance, which already sends the key, so it exposes nothing new.
--
-- No PaymentId on the wire: each Prepare uses a fresh BadgeMasterKey, so
-- BadgeKeyCommitment is 1:1 with the payment and selects it. Apple/Google
-- carry the commitment and authenticate by fresh store proof; Stripe calls
-- carry a BadgeKeyProof, which both selects (via commitment) and authenticates.
-- PaymentId (below) is an internal primary/foreign key only.
type BadgeKeyProof = BadgeMasterKey
data ServiceRequest = IssueBadge BadgeMasterKey (Maybe GrantId)
data ServiceResponse = ServiceResponse
@@ -185,13 +196,16 @@ data ServiceResponse = ServiceResponse
Rules:
- Each `Prepare` uses a fresh `BadgeMasterKey`; renewals of the same subscription reuse it without a new `Prepare`. So `BadgeKeyCommitment` is unique per payment and is the wire selector; `PaymentId` never appears on the wire.
- Client calls `generateMasterKey` once before `Prepare`, persists it encrypted, computes `BadgeKeyCommitment`, and reuses the key for all renewal badges.
- `Prepare` stores `BadgeKeyCommitment` but cannot issue a badge.
- Apple/Google evidence may include `IssueBadge`.
- Stripe prepare returns Checkout data. The client then sends `IssueBadge`; while payment is pending the bot holds that call and sends no response.
- The waiting call responds once after verified payment and issuance, or with a terminal payment error. Other retryable operations may still return `retryAfter`.
- Capability and `BadgeKeyCommitment` never enter Stripe metadata or a return URL.
- The `BadgeMasterKey`, its `BadgeKeyProof`, and `BadgeKeyCommitment` never enter Stripe metadata or a return URL.
- `grantId` selects only; bot rechecks payment, owner, product, and eligibility.
- `StripeCancel`/`StripePortal` return a portal URL in the payment snapshot (like the Checkout URL); the bot never cancels silently. `StripeCancel` deep-links to the cancel flow; `StripePortal` opens general management. When the request carries no valid `BadgeKeyProof` (customer cannot be identified), the bot returns the account-wide login page instead of a session.
- There is no bot-issued authorization token and no `PaymentId` on the wire. The payment is selected by `BadgeKeyCommitment` (Apple/Google carry it explicitly; a Stripe `BadgeKeyProof` references it). Each RPC also authenticates: Apple/Google by fresh store proof, Stripe by the `BadgeKeyProof`. The bot resolves and verifies before acting.
- Before signing, orchestrator recomputes `BadgeKeyCommitment` from `BadgeMasterKey`; mismatch returns `ownership_conflict` without fulfilling grant.
### Internal interface
@@ -203,7 +217,7 @@ fulfillBadge :: ServiceGrant -> BadgeRequest -> Transaction BadgeResult
Order:
1. authorize capability;
1. select the payment by `BadgeKeyCommitment` and authenticate the request credential (store proof, or `BadgeKeyProof` against that commitment);
2. resolve/verify payment;
3. commit payment and create/load due grant;
4. verify the request key matches the payment `BadgeKeyCommitment`;
@@ -217,7 +231,7 @@ Order:
- Transport replay dedupe is separate and shorter-lived.
- Stripe mutation idempotency key derives from request ID + operation.
- Developer Tools → Chat Console records start/result, request ID, method, payment suffix, before/after states, retry class, and duration.
- Redact capability, JWS/token, Checkout query/return token, master key, credential, and provider/customer IDs.
- Redact JWS/token, `BadgeKeyProof`, Checkout query/return token, master key, credential, and provider/customer IDs.
## 4. Provider flows
@@ -302,11 +316,11 @@ sequenceDiagram
B->>S: Create Checkout Session
S-->>B: Session ID + URL
Note over B: BPCheckoutOpen
B-->>C: RPC payment ID + capability + URL
B-->>C: RPC Checkout URL
Note over C: CPCheckoutReady
C->>S: Open Checkout
Note over C: CPAwaitingPayment
C->>B: RPC IssueBadge(requestId, paymentId, key)
C->>B: RPC IssueBadge(requestId, key)
Note over C: CPAwaitingPayment + CBRequesting
B->>B: Register waiter and recheck payment under lock
Note over B: BPAwaitingPayment<br/>No response yet
@@ -351,7 +365,7 @@ sequenceDiagram
B-->>C: Credential immediately if ready<br/>otherwise hold this call
```
The client persists `requestId`, payment capability, and `BadgeMasterKey` before opening Checkout. It retries only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
The client persists `requestId` and `BadgeMasterKey` before opening Checkout. It retries only after an interrupted exchange, foreground, or explicit user action—never on a polling timer. A deep link is optional UX; no localhost listener is used.
### Cancellation
@@ -359,9 +373,18 @@ The client persists `requestId`, payment capability, and `BadgeMasterKey` before
|---|---|---|---|
| Apple | open Apple management UI; status RPC on return | App Store Server API status | `BPEndsAtPeriodEnd` |
| Google | open Play management UI; status RPC on return | `subscriptionsv2.get` | `BPEndsAtPeriodEnd` |
| Stripe | `CancelSubscription` RPC | set `cancel_at_period_end=true`, retrieve Subscription | `BPEndsAtPeriodEnd` |
| Stripe | open a browser Customer Portal from a bot-provided link | return a portal link (session or login page); the portal performs the cancel, reconciled via `customer.subscription.updated` webhook | `BPEndsAtPeriodEnd` |
Failure preserves previous state; client shows Retry and still says **Renews on**. “Already canceled” is success. Stripe Portal cancellation is disabled.
Failure preserves previous state; client shows Retry and still says **Renews on**. “Already canceled” is success. The bot never cancels a Stripe subscription itself: the hosted Customer Portal calls `cancel_at_period_end`, and the bot reconciles it from the webhook.
**Stripe cancel-link selection.** Cancellation is always in the browser portal; the bot chooses which link it returns based on whether the request identifies the customer:
| Client presents | Portal link the bot returns |
|---|---|
| valid `BadgeKeyProof` (matches stored `BadgeKeyCommitment`) | authenticated `billing_portal.Session` with `flow_data.type=subscription_cancel` — opens straight to the cancel flow, no email code |
| no valid proof (master key lost with the app) | the account-wide hosted portal **login page** (`prefilled_email` when the customer email is known), authenticated by email OTP |
The authenticated session link is short-lived and per-customer; the login page is the operator-config account-wide URL and returns no per-customer secret. The bot carries whichever link applies in Stripe status responses so a cancel path is always reachable. Because possession of the `BadgeMasterKey` is the sole client credential, there is no intermediate "capability lost" state: the client either can prove key possession (session) or cannot (login page).
## 5. Persistence and `CallState` pattern
@@ -382,7 +405,7 @@ Define five separate sums: client payment, client badge, bot payment, grant, bot
### Client tables
`badge_payments`: provider/product/plan, payment state payload, encrypted capability and `BadgeMasterKey`, `BadgeKeyCommitment`, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version.
`badge_payments`: provider/product/plan, payment state payload, encrypted `BadgeMasterKey`, `BadgeKeyCommitment`, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version.
`badges`: payment/grant/grant-period/key hash, badge state payload, cached credential, expiry, attempt/error, version.
@@ -408,7 +431,7 @@ Provider calls/signing run outside long transactions. Leases and compare-and-swa
Triggers: launch, foreground, profile switch, network restore, store update, Stripe browser return, manual retry, six-hour jittered timer, and date boundaries.
```text
reconcile(paymentId):
reconcile(payment):
coalesce to one worker
render cached payment + installed badge
submit unseen Apple/Google evidence
@@ -438,7 +461,7 @@ Every input is one of:
| deadline/restart/lost response | retry on foreground | preserve state; repeat same ID/body | remove waiter; return cached result or wait again |
| duplicate event/request | idempotent | accept same state/result | preserve state; dedupe/re-fetch |
| ID reused with new body | reject | preserve state; new ID only for new action | preserve state; telemetry |
| invalid capability/binding | reject | preserve state; restore/support | preserve state; rate-limit |
| invalid proof/binding | reject | preserve state; restore/support | preserve state; rate-limit |
| invalid proof/product | reject | `CPPaymentProblem`; no blind retry | preserve `BP…`; quarantine/alert |
| unknown provider state | quarantine | `CPPaymentProblem`; retry later | preserve `BP…`; re-fetch, never guess |
| `GrantReady` | apply | `CBNeeded``CBRequesting` | `BBRequested` when requested |
@@ -471,7 +494,7 @@ Stable codes: `bad_request`, `unsupported_version`, `payment_pending`, `payment_
|---|---|---|---|---|
| Apple | offline signed initial transaction; server API later | subscription: original transaction + renewal transaction | Notifications V2 → re-fetch | store UI |
| Google | products v2 / subscriptions v2 GET | linked token chain + order/period | RTDN → re-fetch | Play UI |
| Stripe | retrieve Session/Intent/Invoice/Subscription | one-time intent/session; subscription paid invoice | signed webhook → re-fetch | bot RPC |
| Stripe | retrieve Session/Intent/Invoice/Subscription | one-time intent/session; subscription paid invoice | signed webhook → re-fetch | browser portal (bot-provided link) |
Provider-state mapping:
@@ -495,15 +518,60 @@ Google linked-token replacement changes subscription identity/period data, then
Rules:
- Google initial subscription acknowledgement and one-time consumption run from durable outbox.
- Stripe uses server-selected Price, mode, Customer, `client_reference_id=paymentId`, metadata, and redirect URLs.
- Stripe uses server-selected Price, mode, Customer, `client_reference_id=paymentId`, metadata, redirect URLs, and collects customer email so the hosted portal login works.
- Stripe subscription grant requires a paid invoice, not merely active Subscription status.
- Webhook/status/completion page use one reconciliation function; redirects never fulfill.
- Portal is for invoices/payment methods only. Apple/Google normal cancellation is store UI.
- All Stripe cancellation, invoices, and payment methods go through the browser Customer Portal — an authenticated `billing_portal.Session` when the customer is identifiable, else the account-wide login page (email OTP) which is also the app-removed path; the bot reconciles portal cancellation from the webhook. Apple/Google normal cancellation is store UI.
## 8. Security and concurrency
## 8. Recovery
Recovery re-establishes payment control and the badge after reinstall, device transfer, or local data loss. There is no bot-issued token and no caller identity, so a reinstalled client is a new contact; it re-attaches by presenting a credential the bot matches to a stored payment.
### State ownership
| Side | Durable | Lost on client wipe without backup |
|---|---|---|
| Bot | payment (keyed by `BadgeKeyCommitment`), provider bindings, `sub_`/`cus_`, grants, cached credential | — |
| Client | — | `BadgeMasterKey` (commitment re-derives from it), cached credential, installed badge |
The bot never loses the payment. `BadgeMasterKey` is the only client secret to protect; all other client state is re-derivable.
### Restore from backup
SimpleX encrypted-profile backup or migration restores `BadgeMasterKey`, `BadgeKeyCommitment`, and the cached badge. No recovery RPC runs. This is the primary path.
### Re-attach when only the master key survives
With `BadgeMasterKey` retained, the client re-derives `BadgeKeyCommitment` and re-attaches by presenting a provider credential (the store proof for Apple/Google, a key-possession proof for Stripe):
| Provider | Credential | Bot match | Result |
|---|---|---|---|
| Apple | signed transaction (`Transaction.currentEntitlements`) | original-transaction binding | re-attached; status/grant refreshed |
| Google | purchase token (`queryPurchases`) | linked-token/order binding | re-attached; status/grant refreshed |
| Stripe | `BadgeKeyProof` | `BadgeKeyCommitment``payments` row | re-attached; status/grant refreshed (portal session if requested) |
### Badge re-issuance
Re-attaching does not install a badge. Reconciliation then issues from the eligible or fulfilled grant. Issuance is idempotent on `(grant, master-key hash)`: the same `BadgeMasterKey` returns the same cached credential — no new charge, no duplicate. A one-time grant already `GrantFulfilled` returns the cached credential.
### Lost master key
If `BadgeMasterKey` is lost with no backup, the payment cannot be re-attached:
- Apple/Google: unaffected; the client re-presents store proof and cancellation is store-side.
- Stripe: cancellation falls back to the hosted portal login page (email OTP); billing continues until canceled there or the card lapses.
- Installed badges remain valid to signed expiry; no new issuance against the lost key.
### Abuse controls
- Verify the credential before re-attaching; never re-attach on an unauthenticated selector such as a bare `BadgeKeyCommitment`.
- Rate-limit attempts per `BadgeKeyCommitment` and per provider binding; the `BadgeKeyProof` nonce prevents replay.
- Re-attaching changes only the client association; it never mutates provider or billing state.
## 9. Security and concurrency
- Verify provider signatures/objects server-side; never trust decoded client/redirect fields.
- Hash capabilities; encrypt retained proofs/provider IDs; rotate keys.
- Encrypt retained proofs/provider IDs; rotate keys. Select the payment by `BadgeKeyCommitment` and authorize only on a verified proof (store proof or `BadgeKeyProof`); never act on an unauthenticated selector.
- Keep raw `BadgeMasterKey` client-encrypted and bot-memory-only during ownership verification/signing; persist only domain-separated `BadgeKeyCommitment`.
- Allowlist product, app/package, environment, currency/price, and account binding.
- Rate-limit operation/payment and cap payload sizes.
@@ -511,11 +579,11 @@ Rules:
- Use outbox for provider actions/events. Alert on stale leases, acknowledgement deadline, webhook lag, and signing failures.
- Trust client-shipped issuer keys; unknown key/protocol requires update.
## 9. Delivery and tests
## 10. Delivery and tests
1. **Schema/protocol:** five sums/codecs, migrations, grant boundary, request ledger, Chat Console audit, core install API.
2. **Apple/Google:** bindings, verification/status, Notifications V2/RTDN, acknowledge/consume, native UI.
3. **Stripe:** Checkout, waiting `IssueBadge`, webhook wake-up, reconciliation, cancel RPC, restricted Portal.
3. **Stripe:** Checkout, waiting `IssueBadge`, webhook wake-up, reconciliation, portal link (authenticated session + login-page fallback), portal cancellation + webhook reconciliation.
4. **UX/hardening:** scheduler, all Product states, rollout compatibility, telemetry, cleanup.
Tests:
@@ -527,7 +595,7 @@ Tests:
- Stripe async payment, invoice renewal, cancellation, closed app/browser, delayed/duplicate/reordered webhook;
- monthly/yearly grant periods and 21 July → 31 August expiry;
- crash/replay at every side-effect boundary;
- capability/grant/BBS-owner isolation and wrong-`BadgeMasterKey` rejection;
- `BadgeKeyProof`/grant/BBS-owner isolation and wrong-`BadgeMasterKey` rejection;
- Chat Console coverage and redaction snapshots.
Release gates: provider sandbox E2E, webhook signature/replay, schema rollback, store-policy review, complete error handling, operational dashboards.
@@ -548,15 +616,15 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback,
| `badge-service/stripe_api.py` | Checkout/webhook/status/cancel/Portal |
| `badge-service/wire.py` | versioned call/response; keep existing badge request compatibility during rollout |
## 10. API references
## 11. API references
| Provider | References |
|---|---|
| Apple | [StoreKit](https://developer.apple.com/storekit/), [subscription statuses](https://developer.apple.com/documentation/appstoreserverapi/get-all-subscription-statuses), Notifications V2 |
| Google | [Play Billing](https://developer.android.com/google/play/billing/integrate), [`productsv2.getproductpurchasev2`](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.productsv2/getproductpurchasev2), [`subscriptionsv2.get`](https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get), RTDN |
| Stripe | [Checkout](https://docs.stripe.com/api/checkout/sessions/create), [fulfillment](https://docs.stripe.com/checkout/fulfillment), [webhooks](https://docs.stripe.com/webhooks), [subscription events](https://docs.stripe.com/billing/subscriptions/webhooks), [cancel](https://docs.stripe.com/billing/subscriptions/cancel), [Portal](https://docs.stripe.com/customer-management/integrate-customer-portal) |
| Stripe | [Checkout](https://docs.stripe.com/api/checkout/sessions/create), [fulfillment](https://docs.stripe.com/checkout/fulfillment), [webhooks](https://docs.stripe.com/webhooks), [subscription events](https://docs.stripe.com/billing/subscriptions/webhooks), [cancel](https://docs.stripe.com/billing/subscriptions/cancel), [Portal](https://docs.stripe.com/customer-management/integrate-customer-portal), [hosted portal login](https://docs.stripe.com/customer-management/activate-no-code-customer-portal) |
| RPC | [`simplexmq` service RPC RFC](https://github.com/simplex-chat/simplexmq/blob/rpc/rfcs/2026-07-11-service-rpc.md) |
## 11. Open question
## 12. Open questions
**Capability recovery:** if the client still has its BBS `BadgeMasterKey` but loses the payment capability after reinstall or device transfer, should key possession recover/rotate that capability? Define the recovery RPC, provider re-verification, rate limits, and user confirmation before implementation. Until then, do not automatically reassign a payment.
**Lost key.** Recovery §8 defines the behavior when `BadgeMasterKey` is lost with no backup (Stripe cancellation falls back to the portal login page; badge expires normally; Apple/Google unaffected). Decision: accept this, or add an optional user-held recovery code as a second Stripe recovery credential.
@@ -25,7 +25,7 @@ Payment and badge are separate: payment creates a service grant for an eligible
|---|---|---|
| iOS | StoreKit | Apple subscription UI |
| Android Play | Play Billing | Google Play subscription UI |
| F-Droid / desktop | Stripe Checkout | cancel RPC; Customer Portal for invoices/payment methods |
| F-Droid / desktop | Stripe Checkout | browser Customer Portal — authenticated session, or login page (email code) after app removal |
Choices: **One-time**, **Monthly**, **Yearly**. There is no Extend action.
@@ -52,7 +52,7 @@ Show **Badge valid until** separately from **Renews on** or **Subscription ends
- Store state and Stripe redirects are hints only.
- Each client RPC call receives exactly one bot response; the bot never initiates a call.
- Before payment, the client generates one 32-byte BBS `BadgeMasterKey`. The payment and every badge issued from it are bound to that key; renewals reuse it.
- Payment capability authorizes bot requests. The raw BBS key, capability, and provider proofs are redacted.
- There is no bot-issued token. Each request carries its own credential — a fresh store proof (Apple/Google) or a `BadgeMasterKey` possession proof (Stripe). The raw BBS key and provider proofs are redacted.
## 2. UX states
@@ -210,7 +210,7 @@ sequenceDiagram
participant S as Stripe
C->>B: RPC Prepare Stripe
B->>S: Create Checkout Session
B-->>C: RPC Checkout URL + capability
B-->>C: RPC Checkout URL
C->>S: Open Checkout
Note over C: CPAwaitingPayment
C->>B: RPC IssueBadge
@@ -301,16 +301,28 @@ sequenceDiagram
sequenceDiagram
participant C as Client
participant B as Bot
participant P as Stripe Portal
participant S as Stripe API
C->>B: Cancel RPC
B->>S: Cancel at period end
S-->>B: Renewal off + end date
B-->>C: Updated status
C->>B: Request cancel link
B-->>C: Portal URL (session or login page)
C->>P: Open portal, confirm cancel
P->>S: Cancel at period end
S-->>B: Signed webhook (renewal off + end date)
B-->>C: Updated status on next check
Note over C: Canceled, active until end date
```
Never show canceled until the bot confirms renewal is off.
Cancellation also works after the app is removed: Apple/Google via the store subscription UI; Stripe via the hosted Customer Portal login page (`billing.stripe.com/p/login/…`), where the user signs in with the email they paid with. The bot reconciles the resulting cancellation from the provider webhook/status.
Stripe cancellation always happens in the browser Customer Portal (the portal cancels; the bot reconciles from the webhook). The bot only chooses which link it sends, based on what the client can still prove:
| Client still holds | Cancel link the bot sends |
|---|---|
| the master key (proves ownership) | authenticated portal session — opens straight to the cancel flow, no email code |
| nothing (key lost with the app) | generic hosted portal login page — user signs in with the email they paid with (email code) |
## 5. Refresh and errors
Refresh on launch, foreground, profile switch, network restore, store update, Stripe browser return, manual retry, six-hour jittered timer, and payment/badge date boundaries.
@@ -341,6 +353,6 @@ Errors preserve the last payment snapshot and installed badge. The implementatio
- Payment verification creates a provider-neutral service grant; badge service has no provider logic.
- Client and bot payment/badge states are separate.
- RPC is client-request/bot-response only and idempotent.
- Stripe needs no localhost/deep-link success and cancels through bot RPC.
- Stripe needs no localhost/deep-link success; cancellation is always via the browser Customer Portal — an authenticated session, or the login page when the client cannot identify the payment.
- Every error category has an owner, state-preserving action, and retry/final result.
- RPC attempts/results appear redacted in Developer Tools → Chat Console.
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="430" viewBox="0 0 1000 430"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:14px;font-weight:700}.b{font-size:12px}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5;rx:9}.yes{fill:#ecfdf5;stroke:#10b981}.ui{fill:#fffbeb;stroke:#f59e0b}</style><rect width="1000" height="430" fill="#f8fafc"/><text x="500" y="38" text-anchor="middle" class="title">Provider capability map</text><text x="135" y="82" text-anchor="middle" class="h">Provider</text><text x="350" y="82" text-anchor="middle" class="h">Purchase</text><text x="570" y="82" text-anchor="middle" class="h">Canonical status</text><text x="790" y="82" text-anchor="middle" class="h">Cancel renewal</text><g><rect class="box" x="35" y="100" width="200" height="80"/><text x="135" y="145" text-anchor="middle" class="h">Apple</text><rect class="box yes" x="250" y="100" width="200" height="80"/><text x="350" y="138" text-anchor="middle" class="b">StoreKit 2</text><text x="350" y="158" text-anchor="middle" class="b">signed JWS</text><rect class="box yes" x="470" y="100" width="200" height="80"/><text x="570" y="138" text-anchor="middle" class="b">StoreKit hint +</text><text x="570" y="158" text-anchor="middle" class="b">App Store Server API</text><rect class="box ui" x="690" y="100" width="270" height="80"/><text x="825" y="138" text-anchor="middle" class="b">Apple management UI</text><text x="825" y="158" text-anchor="middle" class="b">(no app cancel API)</text><rect class="box" x="35" y="200" width="200" height="80"/><text x="135" y="245" text-anchor="middle" class="h">Google</text><rect class="box yes" x="250" y="200" width="200" height="80"/><text x="350" y="238" text-anchor="middle" class="b">Play Billing</text><text x="350" y="258" text-anchor="middle" class="b">purchase token</text><rect class="box yes" x="470" y="200" width="200" height="80"/><text x="570" y="238" text-anchor="middle" class="b">subscriptionsv2.get</text><text x="570" y="258" text-anchor="middle" class="b">RTDN triggers re-fetch</text><rect class="box yes" x="690" y="200" width="270" height="80"/><text x="825" y="238" text-anchor="middle" class="b">Play subscriptions UI</text><text x="825" y="258" text-anchor="middle" class="b">API for operator recovery</text><rect class="box" x="35" y="300" width="200" height="80"/><text x="135" y="345" text-anchor="middle" class="h">Stripe</text><rect class="box yes" x="250" y="300" width="200" height="80"/><text x="350" y="338" text-anchor="middle" class="b">Checkout Session</text><text x="350" y="358" text-anchor="middle" class="b">hosted browser</text><rect class="box yes" x="470" y="300" width="200" height="80"/><text x="570" y="338" text-anchor="middle" class="b">signed webhooks +</text><text x="570" y="358" text-anchor="middle" class="b">server API refresh</text><rect class="box yes" x="690" y="300" width="270" height="80"/><text x="825" y="338" text-anchor="middle" class="b">cancel_at_period_end</text><text x="825" y="358" text-anchor="middle" class="b">bot RPC only</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="430" viewBox="0 0 1000 430"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:14px;font-weight:700}.b{font-size:12px}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5;rx:9}.yes{fill:#ecfdf5;stroke:#10b981}.ui{fill:#fffbeb;stroke:#f59e0b}</style><rect width="1000" height="430" fill="#f8fafc"/><text x="500" y="38" text-anchor="middle" class="title">Provider capability map</text><text x="135" y="82" text-anchor="middle" class="h">Provider</text><text x="350" y="82" text-anchor="middle" class="h">Purchase</text><text x="570" y="82" text-anchor="middle" class="h">Canonical status</text><text x="790" y="82" text-anchor="middle" class="h">Cancel renewal</text><g><rect class="box" x="35" y="100" width="200" height="80"/><text x="135" y="145" text-anchor="middle" class="h">Apple</text><rect class="box yes" x="250" y="100" width="200" height="80"/><text x="350" y="138" text-anchor="middle" class="b">StoreKit 2</text><text x="350" y="158" text-anchor="middle" class="b">signed JWS</text><rect class="box yes" x="470" y="100" width="200" height="80"/><text x="570" y="138" text-anchor="middle" class="b">StoreKit hint +</text><text x="570" y="158" text-anchor="middle" class="b">App Store Server API</text><rect class="box ui" x="690" y="100" width="270" height="80"/><text x="825" y="138" text-anchor="middle" class="b">Apple management UI</text><text x="825" y="158" text-anchor="middle" class="b">(no app cancel API)</text><rect class="box" x="35" y="200" width="200" height="80"/><text x="135" y="245" text-anchor="middle" class="h">Google</text><rect class="box yes" x="250" y="200" width="200" height="80"/><text x="350" y="238" text-anchor="middle" class="b">Play Billing</text><text x="350" y="258" text-anchor="middle" class="b">purchase token</text><rect class="box yes" x="470" y="200" width="200" height="80"/><text x="570" y="238" text-anchor="middle" class="b">subscriptionsv2.get</text><text x="570" y="258" text-anchor="middle" class="b">RTDN triggers re-fetch</text><rect class="box yes" x="690" y="200" width="270" height="80"/><text x="825" y="238" text-anchor="middle" class="b">Play subscriptions UI</text><text x="825" y="258" text-anchor="middle" class="b">API for operator recovery</text><rect class="box" x="35" y="300" width="200" height="80"/><text x="135" y="345" text-anchor="middle" class="h">Stripe</text><rect class="box yes" x="250" y="300" width="200" height="80"/><text x="350" y="338" text-anchor="middle" class="b">Checkout Session</text><text x="350" y="358" text-anchor="middle" class="b">hosted browser</text><rect class="box yes" x="470" y="300" width="200" height="80"/><text x="570" y="338" text-anchor="middle" class="b">signed webhooks +</text><text x="570" y="358" text-anchor="middle" class="b">server API refresh</text><rect class="box yes" x="690" y="300" width="270" height="80"/><text x="825" y="338" text-anchor="middle" class="b">hosted Customer Portal</text><text x="825" y="358" text-anchor="middle" class="b">browser, bot link</text></g></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1100" height="500" viewBox="0 0 1100 500"><style>text{font-family:Inter,Arial,sans-serif;fill:#172033}.title{font-size:22px;font-weight:700}.h{font-size:15px;font-weight:700}.b{font-size:12px}.s{font-size:11px}.muted{fill:#596579}.box{fill:#fff;stroke:#94a3b8;stroke-width:1.5}.client{fill:#eff6ff;stroke:#3b82f6}.pay{fill:#fffbeb;stroke:#f59e0b}.grant{fill:#f5f3ff;stroke:#8b5cf6}.badge{fill:#ecfdf5;stroke:#10b981}.provider{fill:#fff1f2;stroke:#f43f5e}.line{stroke:#64748b;stroke-width:2;fill:none;marker-end:url(#a)}.dash{stroke:#64748b;stroke-width:1.5;stroke-dasharray:5 4;fill:none;marker-end:url(#a)}</style><defs><marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="#64748b"/></marker></defs><rect width="1100" height="500" fill="#f8fafc"/><text x="550" y="36" text-anchor="middle" class="title">Responsibility and trust boundaries</text>
<rect class="box client" rx="10" x="35" y="75" width="230" height="300"/><text x="150" y="106" text-anchor="middle" class="h">CLIENT ORCHESTRATOR</text><text x="55" y="143" class="b">• separate payment + badge machines</text><text x="55" y="172" class="b">• native store / hosted browser UI</text><text x="55" y="201" class="b">• owns capability + master key</text><text x="55" y="230" class="b">• asks through one-off RPC</text><text x="55" y="259" class="b">• core verifies and installs badge</text><text x="55" y="288" class="b">• reconciliation and local audit</text>
<rect class="box pay" rx="10" x="330" y="75" width="210" height="220"/><text x="435" y="106" text-anchor="middle" class="h">PAYMENT SERVICE</text><text x="350" y="143" class="b">capability authorization</text><text x="350" y="172" class="b">• provider verification/status</text><text x="350" y="201" class="b">• billing and grant schedule</text><text x="350" y="230" class="b">• cancellation where supported</text><text x="350" y="259" class="b">• never sees badge master key</text>
<rect class="box client" rx="10" x="35" y="75" width="230" height="300"/><text x="150" y="106" text-anchor="middle" class="h">CLIENT ORCHESTRATOR</text><text x="55" y="143" class="b">• separate payment + badge machines</text><text x="55" y="172" class="b">• native store / hosted browser UI</text><text x="55" y="201" class="b">• owns master key (sole credential)</text><text x="55" y="230" class="b">• asks through one-off RPC</text><text x="55" y="259" class="b">• core verifies and installs badge</text><text x="55" y="288" class="b">• reconciliation and local audit</text>
<rect class="box pay" rx="10" x="330" y="75" width="210" height="220"/><text x="435" y="106" text-anchor="middle" class="h">PAYMENT SERVICE</text><text x="350" y="143" class="b">store proof / key-possession auth</text><text x="350" y="172" class="b">• provider verification/status</text><text x="350" y="201" class="b">• billing and grant schedule</text><text x="350" y="230" class="b">• cancellation where supported</text><text x="350" y="259" class="b">• never sees badge master key</text>
<rect class="box grant" rx="10" x="595" y="120" width="180" height="130"/><text x="685" y="151" text-anchor="middle" class="h">SERVICE GRANT</text><text x="615" y="188" class="b">• provider-neutral authorization</text><text x="615" y="217" class="b">• one monthly grant period</text>
<rect class="box badge" rx="10" x="830" y="75" width="235" height="220"/><text x="947" y="106" text-anchor="middle" class="h">BADGE SERVICE</text><text x="850" y="143" class="b">• accepts grant + badge request</text><text x="850" y="172" class="b">• signs and caches credential</text><text x="850" y="201" class="b">• fulfills grant atomically</text><text x="850" y="230" class="b">• never imports provider logic</text><text x="850" y="259" class="b">• never changes payment state</text>
<rect class="box provider" rx="10" x="330" y="350" width="210" height="90"/><text x="435" y="381" text-anchor="middle" class="h">PROVIDER ADAPTERS</text><text x="435" y="407" text-anchor="middle" class="b">Apple · Google · Stripe</text>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

+1 -1
View File
@@ -25,5 +25,5 @@
<!-- button -->
<rect x="36" y="372" width="248" height="44" rx="10" fill="#ffffff" stroke="#dc2626" stroke-width="2"/>
<text x="160" y="399" text-anchor="middle" font-size="14" font-weight="700" fill="#dc2626">Cancel subscription</text>
<text x="160" y="436" text-anchor="middle" font-size="10" fill="#94a3b8">Apple/Google: store · Stripe: bot RPC</text>
<text x="160" y="436" text-anchor="middle" font-size="10" fill="#94a3b8">Apple/Google: store · Stripe: browser portal</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB