mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 19:23:36 +00:00
docs: rework badge plans to order-centric protocol
This commit is contained in:
1 parent
6f641166db
commit
1c9ede7323
4 files changed
+233
-196
No files matched your search
@@ -4,7 +4,7 @@
|
||||
**Status:** implementation-ready
|
||||
**Companion:** [Product and UX plan](2026-07-20-supporter-badges-v2-product.md)
|
||||
|
||||
Payment verification creates a provider-neutral `ServiceGrant`. Badge issuance fulfills it. Payment and badge are separate state machines on client and bot.
|
||||
An **order** (`orderId` = client UUID, authenticated by an Ed25519 `orderKey`) carries a `product` and a `payment`. Payment verification creates a provider-neutral `ServiceGrant`; badge issuance fulfills it. Payment and badge are separate state machines on client and bot. Order / product(badge) / payment are decoupled and product-extensible.
|
||||
|
||||

|
||||
|
||||
@@ -29,15 +29,15 @@ Payment verification creates a provider-neutral `ServiceGrant`. Badge issuance f
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C[Client] <-->|Service RPC| B[Badge bot]
|
||||
C[Client] <-->|Signed order RPC| B[Badge bot]
|
||||
B <-->|Provider API / webhook| S[Apple / Google / Stripe]
|
||||
```
|
||||
|
||||
| Component | Owns | Must not own |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| Client order | `orderId`, `orderSk` key, purchase UI, cached status, retry schedule | bot/provider truth |
|
||||
| Client badge | BBS master key, credential receipt and installation | billing state |
|
||||
| Order service | order key registration, product pin, proof verification, billing state, grant schedule | badge master key, credential |
|
||||
| Service grant | product and eligible monthly grant period | provider proof, credential |
|
||||
| Badge service | signing and idempotent credential cache | provider/billing logic |
|
||||
| Core | signature verification and installed badge | payment status |
|
||||
@@ -47,13 +47,13 @@ Treat these as separate programs with typed interfaces.
|
||||
### Invariants
|
||||
|
||||
1. Provider verification changes payment state only.
|
||||
2. Before `Prepare`, client generates one `BadgeMasterKey` with `generateMasterKey`; it is 32 random bytes and BBS message 0.
|
||||
3. Payment stores `BadgeKeyCommitment = SHA-256("SimpleX badge key commitment v1" || BadgeMasterKey)` and every later issue must match it.
|
||||
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, 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.
|
||||
2. An order is identified by a client-generated `orderId` (UUID) and authenticated by its Ed25519 `orderKey`, registered on the create request and verified on every request (external envelope). No caller identity, no bot-issued token, no bot push.
|
||||
3. The `product` is immutable per order: pinned on the first request; a differing product later is rejected.
|
||||
4. For a badge product, the client supplies only the SKU (`plan`) and `BadgeMasterKey`; the bot derives tier/expiry and assembles `BadgeInfo`. The declared SKU must equal the SKU verified in `payment`.
|
||||
5. `BadgeMasterKey` (32 random bytes, BBS message 0 via `generateMasterKey`) lives only inside the badge product; it signs the badge and is never an identifier or an auth credential.
|
||||
6. Only `GrantReady` plus the badge product's master key enters badge signing; grant fulfillment and cached issuance are atomic/idempotent.
|
||||
7. Payment never activates perks; the verified credential does.
|
||||
8. Duplicate requests/events return the same result. Unknown states preserve prior state.
|
||||
9. Provider dates create eligibility; retry/request time never changes badge expiry.
|
||||
|
||||
### Time and grant
|
||||
@@ -63,18 +63,18 @@ data ServiceGrantState = GrantReady | GrantFulfilled | GrantRevoked
|
||||
|
||||
data ServiceGrant = ServiceGrant
|
||||
{ grantId :: GrantId
|
||||
, paymentId :: PaymentId
|
||||
, orderId :: OrderId
|
||||
, productId :: ServiceProductId
|
||||
, grantPeriodStart :: UTCTime
|
||||
, grantState :: ServiceGrantState
|
||||
}
|
||||
```
|
||||
|
||||
- One-time: one grant at verified purchase time. Reject another one-time prepare while its prior one-time service period is active.
|
||||
- One-time: one grant at verified purchase time. Reject another one-time order for the same entitlement while its prior one-time service period is active.
|
||||
- Subscription: `grantPeriodStart(n) = addCalendarMonths n verifiedAnchor` when `grantPeriodStart(n) <= now < paidThrough`.
|
||||
- Monthly and yearly plans both expose one grant per eligible month.
|
||||
- Badge service computes expiry as the start of the month two months after `grantPeriodStart`.
|
||||
- Unique grant: `(payment_id, product_id, grant_period_start)`.
|
||||
- Unique grant: `(order_id, product_id, grant_period_start)`.
|
||||
- Example: 21 July grant period → badge expires 1 September; monthly billing renews 21 August.
|
||||
|
||||
Grant eligibility by payment state:
|
||||
@@ -91,7 +91,7 @@ Grant eligibility by payment state:
|
||||
|
||||
## 2. State machines
|
||||
|
||||
These names are canonical. Every transition is validated against the current constructor.
|
||||
These names are canonical. Every transition is validated against the current constructor. All five machines hang off one **order** (`orderId`): the bot order aggregates a payment sub-state, a service grant, and a badge sub-state; the client mirrors payment and badge. "Issue" below is the badge-fulfilling step of a `Purchase` operation, not a separate call.
|
||||
|
||||
### Client payment
|
||||
|
||||
@@ -113,7 +113,7 @@ These names are canonical. Every transition is validated against the current con
|
||||
|
||||
| State | Meaning |
|
||||
|---|---|
|
||||
| `BPPrepared` | payment/commitment/binding stored |
|
||||
| `BPPrepared` | order/key/binding stored |
|
||||
| `BPCheckoutOpen` | Stripe Session stored |
|
||||
| `BPAwaitingPayment` | provider not complete |
|
||||
| `BPVerifying` | reconciliation lease active |
|
||||
@@ -222,9 +222,9 @@ Bot payment:
|
||||
|
||||
| From | On | To |
|
||||
|---|---|---|
|
||||
| — | `Prepare` | `BPPrepared` |
|
||||
| — | `Purchase` (create order) | `BPPrepared` |
|
||||
| `BPPrepared` | Stripe Session created | `BPCheckoutOpen` |
|
||||
| `BPPrepared` | Apple/Google evidence | `BPVerifying` |
|
||||
| `BPPrepared` | Apple/Google receipt | `BPVerifying` |
|
||||
| `BPCheckoutOpen` | webhook: paid | `BPVerifying` |
|
||||
| `BPCheckoutOpen` | webhook: async pending | `BPAwaitingPayment` |
|
||||
| `BPCheckoutOpen` | Checkout expired | `BPExpired` |
|
||||
@@ -257,7 +257,7 @@ Bot badge:
|
||||
|
||||
| From | On | To |
|
||||
|---|---|---|
|
||||
| — | `IssueBadge` on `GrantReady`, key matches commitment | `BBRequested` |
|
||||
| — | `Purchase` issues on `GrantReady`, badge product master key present | `BBRequested` |
|
||||
| `BBRequested` | claim signing lease | `BBSigning` |
|
||||
| `BBSigning` | signed + cached (marks `GrantFulfilled`) | `BBIssued` |
|
||||
| `BBSigning` | signing unavailable | `BBRetryableFailure` |
|
||||
@@ -276,7 +276,7 @@ Client payment:
|
||||
| `CPStoreReady` | store pending | `CPAwaitingPayment` |
|
||||
| `CPStoreReady` | store canceled | prior state |
|
||||
| `CPCheckoutReady` | checkout opened | `CPAwaitingPayment` |
|
||||
| `CPAwaitingPayment` | send evidence / `IssueBadge` | `CPVerifying` |
|
||||
| `CPAwaitingPayment` | send `Purchase` (paid evidence) | `CPVerifying` |
|
||||
| `CPVerifying` | bot paid | `CPEntitled` |
|
||||
| `CPVerifying` | bot renewal-off | `CPEndsAtPeriodEnd` |
|
||||
| `CPVerifying` | bot grace/hold/paused/error | `CPPaymentProblem` |
|
||||
@@ -297,7 +297,7 @@ Client badge:
|
||||
|---|---|---|
|
||||
| `CBNone` | grant available | `CBNeeded` |
|
||||
| `CBInstalled` | new grant period | `CBNeeded` |
|
||||
| `CBNeeded` | send `IssueBadge` | `CBRequesting` |
|
||||
| `CBNeeded` | send `Purchase` (issue) | `CBRequesting` |
|
||||
| `CBRequesting` | credential returned | `CBReceived` |
|
||||
| `CBRequesting` | retryable | `CBRetryableFailure` |
|
||||
| `CBRequesting` | final | `CBFinalFailure` |
|
||||
@@ -313,9 +313,9 @@ Bot payment:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> BPPrepared: Prepare
|
||||
[*] --> BPPrepared: Purchase create
|
||||
BPPrepared --> BPCheckoutOpen: stripe session
|
||||
BPPrepared --> BPVerifying: apple or google evidence
|
||||
BPPrepared --> BPVerifying: apple or google receipt
|
||||
BPCheckoutOpen --> BPVerifying: webhook paid
|
||||
BPCheckoutOpen --> BPAwaitingPayment: async pending
|
||||
BPCheckoutOpen --> BPExpired: checkout expired
|
||||
@@ -346,7 +346,7 @@ stateDiagram-v2
|
||||
state "BP paid: OneTime or SubscriptionActive or Grace or EndsAtPeriodEnd" as BPpaid
|
||||
[*] --> BPpaid: payment verified
|
||||
BPpaid --> GrantReady: create due grant
|
||||
GrantReady --> BBRequested: IssueBadge, key matches
|
||||
GrantReady --> BBRequested: Purchase issues, key present
|
||||
BBRequested --> BBSigning: claim lease
|
||||
BBSigning --> BBIssued: sign and cache
|
||||
BBIssued --> GrantFulfilled: mark fulfilled
|
||||
@@ -392,7 +392,7 @@ stateDiagram-v2
|
||||
[*] --> CBNone
|
||||
CBNone --> CBNeeded: grant available
|
||||
CBInstalled --> CBNeeded: new grant period
|
||||
CBNeeded --> CBRequesting: send IssueBadge
|
||||
CBNeeded --> CBRequesting: send Purchase
|
||||
CBRequesting --> CBReceived: credential returned
|
||||
CBRequesting --> CBRetryableFailure: retryable
|
||||
CBRequesting --> CBFinalFailure: final
|
||||
@@ -405,82 +405,118 @@ stateDiagram-v2
|
||||
|
||||
## 3. Contracts
|
||||
|
||||
### RPC payload
|
||||
An order is `product + payment`, identified by a client `orderId` and authenticated by an external Ed25519 signature. The command layer never reasons about auth.
|
||||
|
||||
### Envelope (external auth layer)
|
||||
|
||||
Mirrors the SMP signed transmission — **entity id, signature, body**.
|
||||
|
||||
```haskell
|
||||
data ServiceCall = ServiceCall
|
||||
{ requestId :: RequestId
|
||||
, payment :: PaymentInput
|
||||
, request :: Maybe ServiceRequest
|
||||
newtype OrderId = OrderId UUID -- client-generated, e.g. "b1e2…-uuid"
|
||||
newtype OrderKey = OrderKey C.PublicKeyEd25519 -- order public key, registered on create
|
||||
type Signature = C.Signature 'C.Ed25519 -- over (orderId ‖ body), made with orderSk
|
||||
|
||||
data ServiceEnvelope = ServiceEnvelope
|
||||
{ orderId :: OrderId
|
||||
, signature :: Signature
|
||||
, body :: ServiceRequest
|
||||
}
|
||||
```
|
||||
|
||||
Signing (keygen → sign → verify):
|
||||
|
||||
1. On create, the client generates `(orderSk, orderPk)` and the `orderId` UUID, stores `orderSk` encrypted (backed up with the profile), and puts `orderPk` in `body.orderKey`.
|
||||
2. The bot, seeing no order for `orderId`, treats it as create: stores `orderPk`, verifies the signature.
|
||||
3. Every request signs `signature = sign(orderSk, orderId ‖ body)`. Later requests may omit `orderKey`; if present it must equal the stored `orderPk`. Mismatch ⇒ `order_auth_invalid`.
|
||||
|
||||
No `corrId`: `orderId` already correlates request↔response and keys idempotency (the SimpleX transport handles message delivery). Replay is benign — operations are idempotent by `orderId`, and the signature binds to `orderId` so a signed body cannot move to another order. Signing reuses the SMP `authTransmission` Ed25519 path. `orderSk` (order auth) and `BadgeMasterKey` (badge signing) are two distinct secrets.
|
||||
|
||||
### Request / response body
|
||||
|
||||
Every referenced type is defined here; existing `Simplex.Chat.Badges` types are reused, not redefined.
|
||||
|
||||
```haskell
|
||||
data ServiceRequest = ServiceRequest
|
||||
{ orderKey :: Maybe OrderKey -- required on create; must equal the stored key thereafter
|
||||
, operation :: Operation
|
||||
, product :: Product
|
||||
, payment :: Payment
|
||||
}
|
||||
|
||||
data PaymentInput
|
||||
= Prepare Provider ServiceProductId PurchaseKind BadgeKeyCommitment
|
||||
| AppleEvidence BadgeKeyCommitment SignedTransactionJWS
|
||||
| GoogleEvidence BadgeKeyCommitment PurchaseToken
|
||||
| StripeStatus BadgeKeyProof
|
||||
| StripeCancel BadgeKeyProof
|
||||
| StripePortal BadgeKeyProof
|
||||
data Operation = Purchase | Cancel | Status -- billing period is in the SKU, not here
|
||||
|
||||
-- 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)
|
||||
newtype ServiceProductId = ServiceProductId Text -- SKU, e.g. "supporter_monthly", "legend_onetime"
|
||||
|
||||
data ServiceResponse = ServiceResponse
|
||||
{ requestId :: RequestId
|
||||
, payment :: PaymentSnapshot
|
||||
, grant :: Maybe ServiceGrantSummary
|
||||
, service :: Maybe (Either ServiceError BadgeCredential)
|
||||
, retryAfter :: Maybe NominalDiffTime
|
||||
data Product = ProductBadge BadgeProduct -- | ProductName NameProduct ... (future)
|
||||
data BadgeProduct = BadgeProduct
|
||||
{ plan :: ServiceProductId -- SKU → tier × period (server-authoritative via badge_types[plan])
|
||||
, masterKey :: BadgeMasterKey -- BBS delivery key; confined here, consumed at issuance
|
||||
}
|
||||
|
||||
data Payment = PaymentApple AppleOp | PaymentGoogle GoogleOp | PaymentStripe StripeOp
|
||||
newtype AppleOp = AppleReceipt SignedTransactionJWS
|
||||
newtype GoogleOp = GoogleReceipt PurchaseToken
|
||||
data StripeOp = StripeInvoice | StripePaid InvoiceId | StripeManage -- Manage = cancel/status
|
||||
newtype InvoiceId = InvoiceId Text
|
||||
|
||||
data ServiceResponse
|
||||
= RspInvoice InvoiceRef -- Stripe Checkout URL, or Apple/Google store binding
|
||||
| RspCredential BadgeCredential -- reuse Simplex.Chat.Badges.BadgeCredential
|
||||
| RspStatus OrderStatus
|
||||
| RspPortal Url -- Stripe cancel-flow / management portal link
|
||||
| RspError ServiceError
|
||||
newtype InvoiceRef = InvoiceRef Text
|
||||
newtype Url = Url Text
|
||||
|
||||
data OrderStatus = OrderStatus
|
||||
{ orderState :: BotPaymentState, badgeState :: Maybe BotBadgeState
|
||||
, paidThrough :: Maybe UTCTime, willRenew :: Bool }
|
||||
|
||||
data ServiceError = ServiceError { code :: ErrorCode, message :: Text }
|
||||
data ErrorCode
|
||||
= OrderAuthInvalid | ProductChanged | ProductMismatch
|
||||
| PaymentPending | PaymentNotEntitled | ProviderUnavailable | ProviderRateLimited
|
||||
| UnsupportedVersion | BadRequest | InternalError
|
||||
|
||||
-- The bot assembles the internal BadgeRequest { masterKey, badgeInfo } where
|
||||
-- badgeInfo = BadgeInfo { badgeType = badge_types[plan]
|
||||
-- , badgeExpiry = Just end_of_next_month, badgeExtra = "" }.
|
||||
```
|
||||
|
||||
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`.
|
||||
- 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.
|
||||
- **Invoice-or-badge, one order.** A `Purchase` with `StripeInvoice` returns `RspInvoice`; the same `orderId` with `StripePaid`/`AppleReceipt`/`GoogleReceipt` verifies and returns `RspCredential`. Renewals are later `Purchase`/`Status` requests on the same order.
|
||||
- **Product is pinned per order.** The bot fixes `product` on the first request; a later differing product ⇒ `product_changed` ("invoice for A then claim paid on B ⇒ get lost").
|
||||
- **Tier/period/expiry are server-derived; only `masterKey` is client-authoritative.** The bot resolves the tier from `badge_types[plan]`, sets `badgeExpiry = end_of_next_month`, `badgeExtra = ""`, and assembles the internal `BadgeRequest`. `badgeType` is never on the wire.
|
||||
- **Declared SKU must equal the verified SKU** proven by `Payment` (Apple/Google receipt `productId`, Stripe `plan`); divergence ⇒ `product_mismatch`.
|
||||
- Stripe stays event-driven: a `Purchase` with `StripePaid` may hold the call until the webhook confirms; it responds once after verified payment + issuance, or a terminal payment error.
|
||||
- Stripe `Cancel`/`Status` (`StripeManage`) return a portal URL in `RspPortal`; the bot never cancels silently. When the order can't be identified, it returns the account-wide portal login page.
|
||||
- The `BadgeMasterKey` never enters Stripe metadata or a return URL.
|
||||
|
||||
### Internal interface
|
||||
|
||||
```haskell
|
||||
resolvePayment :: PaymentInput -> Transaction PaymentDecision
|
||||
fulfillBadge :: ServiceGrant -> BadgeRequest -> Transaction BadgeResult
|
||||
resolveOrder :: OrderId -> ServiceRequest -> Transaction OrderDecision
|
||||
fulfillBadge :: ServiceGrant -> BadgeRequest -> Transaction BadgeResult
|
||||
```
|
||||
|
||||
Order:
|
||||
|
||||
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`;
|
||||
5. pass only grant + request to badge service;
|
||||
1. verify the envelope signature against the order's `orderKey` (create registers it);
|
||||
2. load/pin the order's product; reject on `product_changed`;
|
||||
3. resolve/verify payment; check the verified SKU equals the declared SKU (`product_mismatch`);
|
||||
4. commit payment and create/load the due grant;
|
||||
5. assemble `BadgeRequest` (masterKey + server-derived `badgeInfo`) and pass grant + request to the badge service;
|
||||
6. cache issuance and mark grant fulfilled atomically;
|
||||
7. return the single response.
|
||||
|
||||
### Idempotency and audit
|
||||
|
||||
- `requestId` binds to canonical request hash. Same body returns stored response; different body returns `idempotency_mismatch`.
|
||||
- `orderId` keys idempotency: the same operation on the same order returns the stored response; a changed product returns `product_changed`.
|
||||
- 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 JWS/token, `BadgeKeyProof`, Checkout query/return token, master key, credential, and provider/customer IDs.
|
||||
- Stripe mutation idempotency key derives from `orderId` + operation.
|
||||
- Developer Tools → Chat Console records start/result, order id suffix, operation, before/after states, retry class, and duration.
|
||||
- Redact the Ed25519 signature, JWS/token, Checkout query/return token, `BadgeMasterKey`, credential, and provider/customer IDs.
|
||||
|
||||
## 4. Provider flows
|
||||
|
||||
@@ -493,11 +529,11 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant B as Bot
|
||||
Note over C: CPVerifying + CBRequesting
|
||||
C->>B: RPC payment status + IssueBadge
|
||||
C->>B: Purchase (paid evidence), signed order
|
||||
Note over B: Payment: BPPaidOneTime / BPSubscriptionActive / BPGrace / BPEndsAtPeriodEnd
|
||||
B->>B: Create or load eligible grant period
|
||||
Note over B: Grant: GrantReady
|
||||
B->>B: Verify BadgeMasterKey matches BadgeKeyCommitment
|
||||
B->>B: Verify SKU, assemble BadgeRequest from product master key
|
||||
B->>B: Create issuance
|
||||
Note over B: Badge: BBRequested
|
||||
B->>B: Claim signing
|
||||
@@ -522,7 +558,7 @@ sequenceDiagram
|
||||
participant B as Bot
|
||||
participant A as Apple
|
||||
Note over C: CPVerifying
|
||||
C->>B: RPC AppleEvidence + optional IssueBadge
|
||||
C->>B: Purchase (AppleReceipt), signed order
|
||||
Note over B: Payment: BPVerifying
|
||||
B->>B: Verify signed transaction offline
|
||||
Note over B: Payment: BPPaidOneTime or BPSubscriptionActive
|
||||
@@ -541,7 +577,7 @@ sequenceDiagram
|
||||
participant B as Bot
|
||||
participant G as Google
|
||||
Note over C: CPVerifying
|
||||
C->>B: RPC GoogleEvidence + optional IssueBadge
|
||||
C->>B: Purchase (GoogleReceipt), signed order
|
||||
Note over B: Payment: BPVerifying
|
||||
B->>G: Verify purchase token
|
||||
G-->>B: Canonical purchase
|
||||
@@ -552,7 +588,7 @@ sequenceDiagram
|
||||
|
||||
Commit entitlement before outbox acknowledgement/consume. RTDN triggers provider GET; never create a service grant from the notification payload.
|
||||
|
||||
### Stripe Checkout and waiting `IssueBadge`
|
||||
### Stripe Checkout and waiting `Purchase`
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -560,16 +596,16 @@ sequenceDiagram
|
||||
participant B as Bot
|
||||
participant S as Stripe
|
||||
Note over C: CPPreparing
|
||||
C->>B: RPC Prepare Stripe
|
||||
C->>B: Purchase (StripeInvoice), create order
|
||||
Note over B: BPPrepared
|
||||
B->>S: Create Checkout Session
|
||||
S-->>B: Session ID + URL
|
||||
Note over B: BPCheckoutOpen
|
||||
B-->>C: RPC Checkout URL
|
||||
B-->>C: RspInvoice (Checkout URL)
|
||||
Note over C: CPCheckoutReady
|
||||
C->>S: Open Checkout
|
||||
Note over C: CPAwaitingPayment
|
||||
C->>B: RPC IssueBadge(requestId, key)
|
||||
C->>B: Purchase (StripePaid), same orderId
|
||||
Note over C: CPAwaitingPayment + CBRequesting
|
||||
B->>B: Register waiter and recheck payment under lock
|
||||
Note over B: BPAwaitingPayment<br/>No response yet
|
||||
@@ -586,11 +622,11 @@ sequenceDiagram
|
||||
Note over C: CBInstalled
|
||||
```
|
||||
|
||||
The second RPC has exactly one response. The bot sends it only after verified payment allows issuance, or after a terminal event such as Checkout expiry. Register-and-recheck under the payment lock prevents a webhook/request race. If the webhook completed first, `IssueBadge` responds immediately.
|
||||
The second `Purchase` (`StripePaid`) has exactly one response. The bot sends it only after verified payment allows issuance, or after a terminal event such as Checkout expiry. Register-and-recheck under the order lock prevents a webhook/request race. If the webhook completed first, the `Purchase` responds immediately.
|
||||
|
||||
If Stripe retrieval fails transiently after the webhook, the bot keeps the call open and retries internally. It does not send an intermediate response; only the RPC deadline or a terminal payment result ends the wait.
|
||||
|
||||
Persist request ID, hash, and result state; keep the live waiter and raw `BadgeMasterKey` only in memory. Webhook commit wakes live waiters after `GrantReady` is durable. After bot restart, the repeated request rechecks persisted payment/grant state and either returns immediately or installs a new waiter.
|
||||
Persist `orderId`, request hash, and result state; keep the live waiter and raw `BadgeMasterKey` only in memory. Webhook commit wakes live waiters after `GrantReady` is durable. After bot restart, the repeated `Purchase` rechecks persisted payment/grant state and either returns immediately or installs a new waiter.
|
||||
|
||||
### Stripe wait interruption
|
||||
|
||||
@@ -600,7 +636,7 @@ sequenceDiagram
|
||||
participant B as Bot
|
||||
participant S as Stripe
|
||||
Note over C: CPAwaitingPayment + CBRequesting
|
||||
C->>B: RPC IssueBadge with stable requestId
|
||||
C->>B: Purchase (StripePaid), stable orderId
|
||||
B->>B: Register waiter, payment still pending
|
||||
Note over B: Hold call, no response
|
||||
Note over C: RPC deadline / app restart
|
||||
@@ -609,12 +645,12 @@ sequenceDiagram
|
||||
Note over B: Remove waiter, preserve payment state
|
||||
S-->>B: Webhook may complete payment later
|
||||
Note over B: Persist payment + GrantReady
|
||||
C->>B: Repeat same IssueBadge on foreground
|
||||
C->>B: Repeat same Purchase on foreground
|
||||
Note over C: CPAwaitingPayment + CBRequesting
|
||||
B-->>C: Credential immediately if ready<br/>otherwise hold this call
|
||||
```
|
||||
|
||||
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.
|
||||
The client persists `orderId`, `orderSk`, and `BadgeMasterKey` before opening Checkout. It retries the same signed `Purchase` 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
|
||||
|
||||
@@ -630,10 +666,10 @@ Failure preserves previous state; client shows Retry and still says **Renews on*
|
||||
|
||||
| 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 |
|
||||
| a `Cancel` (`StripeManage`) on a known order (valid `orderKey` signature) | authenticated `billing_portal.Session` with `flow_data.type=subscription_cancel` — opens straight to the cancel flow, no email code |
|
||||
| no identifiable order (total loss — `orderId`/`orderSk` gone) | 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).
|
||||
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 `RspStatus` so a cancel path is always reachable. The `orderKey` signature is the sole client credential: the client either can sign for the order (session) or cannot (login page).
|
||||
|
||||
## 5. Persistence and `CallState` pattern
|
||||
|
||||
@@ -654,20 +690,21 @@ Define five separate sums (the `ClientPaymentState` / `BotPaymentState` / `Servi
|
||||
|
||||
### Client tables
|
||||
|
||||
`badge_payments`: provider/product/plan, payment state payload, encrypted `BadgeMasterKey`, `BadgeKeyCommitment`, binding/proof reference, `paidThrough`, `willRenew`, checked/retry time, version.
|
||||
`orders`: `order_id` (client UUID), encrypted `order_sk` + `order_key`, provider/product/plan, payment state payload, `provider_ref`, `paid_through`, `will_renew`, checked/retry time, version.
|
||||
|
||||
`badges`: payment/grant/grant-period/key hash, badge state payload, cached credential, expiry, attempt/error, version.
|
||||
`badges`: order id + grant-period + key hash, badge state payload, encrypted `badge_master_key`, cached credential, expiry, attempt/error, version.
|
||||
|
||||
Join by payment/grant ID only. Update active profile only after core installation.
|
||||
Join by order id and grant period only (the bot's `GrantId` is internal). Update active profile only after core installation.
|
||||
|
||||
### Bot tables
|
||||
|
||||
| Table | Unique key / purpose |
|
||||
|---|---|
|
||||
| `payments` | provider-object ownership + `BadgeKeyCommitment`; canonical payment sum |
|
||||
| `service_grants` | payment + product + grant period |
|
||||
| `orders` | `order_id` (client UUID); `order_key`, pinned product SKU; canonical payment sum |
|
||||
| `entitlements` | provider-object binding (`sub_`/`cus_` \| original-transaction \| token) → order; per-period re-issue counter |
|
||||
| `service_grants` | order + product + grant period |
|
||||
| `badge_issuances` | grant + master-key hash; cached credential |
|
||||
| `rpc_requests` | request ID; pending waiter/result + request hash + response |
|
||||
| `rpc_requests` | order id + operation; pending waiter/result + request hash + response |
|
||||
| `provider_events` | provider event ID; dedupe/result |
|
||||
| `outbox` | acknowledge, consume, reconciliation, cleanup |
|
||||
|
||||
@@ -678,40 +715,39 @@ Provider calls/signing run outside long transactions. Leases and compare-and-swa
|
||||
Shared identifiers and enums:
|
||||
|
||||
```haskell
|
||||
newtype PaymentId = PaymentId Int64
|
||||
newtype GrantId = GrantId Int64
|
||||
newtype RequestId = RequestId ByteString
|
||||
newtype BadgeKeyCommitment = BadgeKeyCommitment ByteString -- 32-byte SHA-256
|
||||
newtype BadgeMasterKey = BadgeMasterKey ByteString -- 32 random bytes
|
||||
type BadgeKeyProof = BadgeMasterKey
|
||||
newtype OrderId = OrderId UUID
|
||||
newtype GrantId = GrantId Int64
|
||||
newtype ServiceProductId = ServiceProductId Text -- SKU
|
||||
newtype BadgeMasterKey = BadgeMasterKey ByteString -- 32 random bytes; badge product only
|
||||
|
||||
data Provider = Apple | Google | Stripe
|
||||
data PurchaseKind = OneTime | Monthly | Yearly
|
||||
data Provider = Apple | Google | Stripe
|
||||
```
|
||||
|
||||
Bot tables (**PK** bold, → foreign key, ⊤ unique). The `state` column is a tag plus state-specific fields for the sum named in brackets.
|
||||
|
||||
- **`payments`** — one row per purchase. `payment_id` **PK** · `provider` · `product_id` · `purchase_kind` · `badge_key_commitment` ⊤ · `provider_ref` (`sub_`/`cus_` | original-transaction | token; encrypted) ⊤(`provider`,`provider_ref`) · `state` [`BotPaymentState`] · `paid_through` · `will_renew` · `version`
|
||||
- **`service_grants`** — `grant_id` **PK** · `payment_id` → `payments` · `product_id` · `grant_period_start` · `state` [`ServiceGrantState`] · ⊤(`payment_id`,`product_id`,`grant_period_start`)
|
||||
- **`orders`** — one row per order. `order_id` **PK** (client UUID) · `order_key` (Ed25519 pub) · `provider` · `product_sku` (pinned) · `master_key` (badge product, encrypted) · `state` [`BotPaymentState`] · `paid_through` · `will_renew` · `version`
|
||||
- **`entitlements`** — `entitlement_id` **PK** · `provider` · `provider_ref` (encrypted) ⊤(`provider`,`provider_ref`) · `order_id` → `orders` · `reissue_count` per (`provider_ref`, period) — bounds total-loss re-binds
|
||||
- **`service_grants`** — `grant_id` **PK** · `order_id` → `orders` · `product_id` · `grant_period_start` · `state` [`ServiceGrantState`] · ⊤(`order_id`,`product_id`,`grant_period_start`)
|
||||
- **`badge_issuances`** — `issuance_id` **PK** · `grant_id` → `service_grants` · `master_key_hash` · `state` [`BotBadgeState`] · `credential` (cached) · ⊤(`grant_id`,`master_key_hash`)
|
||||
- **`rpc_requests`** — `request_id` **PK** · `request_hash` · `state` (pending waiter | result) · `response` (cached)
|
||||
- **`rpc_requests`** — (`order_id`,`operation`) **PK** · `request_hash` · `state` (pending waiter | result) · `response` (cached)
|
||||
- **`provider_events`** — `event_id` **PK** · `kind` · `result` (dedupe)
|
||||
- **`outbox`** — `id` **PK** · `kind` (ack | consume | reconcile | cleanup) · `payload` · `status` · `attempts`
|
||||
|
||||
Client tables (local ids; never the bot's `PaymentId`):
|
||||
Client tables (local; the bot's `order_id` is the shared key):
|
||||
|
||||
- **`badge_payments`** — `id` **PK** · `provider` · `product_id` · `plan` · `state` [`ClientPaymentState`] · `badge_key_commitment` · `badge_master_key` (encrypted) · `provider_ref` · `paid_through` · `will_renew` · `checked_at` · `retry_at` · `version`
|
||||
- **`badges`** — `id` **PK** · `payment_id` → `badge_payments` · `grant_id` · `grant_period_start` · `master_key_hash` · `state` [`ClientBadgeState`] · `credential` (cached) · `expiry` · `attempt` · `error` · `version`
|
||||
- **`orders`** — `order_id` **PK** · `order_sk` (encrypted) · `order_key` · `provider` · `product_sku` · `state` [`ClientPaymentState`] · `provider_ref` · `paid_through` · `will_renew` · `checked_at` · `retry_at` · `version`
|
||||
- **`badges`** — `id` **PK** · `order_id` → `orders` · `grant_period_start` · `master_key_hash` · `state` [`ClientBadgeState`] · `badge_master_key` (encrypted) · `credential` (cached) · `expiry` · `attempt` · `error` · `version` — the client keys a badge by `(order_id, grant_period_start)`; the bot's `GrantId` never crosses the wire
|
||||
|
||||
The client encrypts `badge_master_key` at rest; the bot stores only `badge_key_commitment` and never the key.
|
||||
The client encrypts `order_sk` and `badge_master_key` at rest; the bot stores the `order_key` (public) and the `master_key` only as badge-signing input, never as an identifier.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
payments ||--o{ service_grants : has
|
||||
orders ||--o{ entitlements : binds
|
||||
orders ||--o{ service_grants : has
|
||||
service_grants ||--o| badge_issuances : "fulfilled by"
|
||||
```
|
||||
|
||||
`rpc_requests`, `provider_events`, and `outbox` are keyed by their own ids and reference a payment only where relevant.
|
||||
`rpc_requests`, `provider_events`, and `outbox` are keyed by their own ids and reference an order only where relevant.
|
||||
|
||||
## 6. Reconciliation and errors
|
||||
|
||||
@@ -720,13 +756,13 @@ erDiagram
|
||||
Triggers: launch, foreground, profile switch, network restore, store update, Stripe browser return, manual retry, six-hour jittered timer, and date boundaries.
|
||||
|
||||
```text
|
||||
reconcile(payment):
|
||||
reconcile(order):
|
||||
coalesce to one worker
|
||||
render cached payment + installed badge
|
||||
submit unseen Apple/Google evidence
|
||||
for pending Stripe Checkout: ensure one IssueBadge call is waiting
|
||||
otherwise request status for nonterminal payment
|
||||
if GrantReady and no badge covers its grant period: CBNeeded -> request IssueBadge
|
||||
submit a signed Purchase for unseen Apple/Google receipts
|
||||
for pending Stripe Checkout: ensure one Purchase(StripePaid) is waiting
|
||||
otherwise send Status for a nonterminal order
|
||||
if GrantReady and no badge covers its grant period: CBNeeded -> Purchase (issue)
|
||||
if credential returned: CBReceived -> CBInstalling -> CBInstalled
|
||||
schedule next check
|
||||
```
|
||||
@@ -744,14 +780,14 @@ Every input is one of:
|
||||
|
||||
| Input/result | Class | Client | Bot |
|
||||
|---|---|---|---|
|
||||
| Stripe awaiting webhook | wait | `CPAwaitingPayment` + `CBRequesting` | hold `IssueBadge`; no response |
|
||||
| Stripe awaiting webhook | wait | `CPAwaitingPayment` + `CBRequesting` | hold `Purchase`; no response |
|
||||
| timeout/429/5xx in a non-waiting operation | retry | `CPPaymentProblem` with prior snapshot | preserve current `BP…`; return `retryAfter` |
|
||||
| Stripe verification timeout/429/5xx while `IssueBadge` waits | wait; retry internally | `CPAwaitingPayment` + `CBRequesting` | preserve canonical payment state; retry Stripe; send no response |
|
||||
| deadline/restart/lost response | retry on foreground | preserve state; repeat same ID/body | remove waiter; return cached result or wait again |
|
||||
| Stripe verification timeout/429/5xx while `Purchase` waits | wait; retry internally | `CPAwaitingPayment` + `CBRequesting` | preserve canonical payment state; retry Stripe; send no response |
|
||||
| deadline/restart/lost response | retry on foreground | preserve state; repeat same signed order/op | 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 proof/binding | reject | preserve state; restore/support | preserve state; rate-limit |
|
||||
| invalid proof/product | reject | `CPPaymentProblem`; no blind retry | preserve `BP…`; quarantine/alert |
|
||||
| product changed for an order | reject | preserve state; a new product needs a new order | `product_changed`; preserve state; telemetry |
|
||||
| invalid order signature | reject | preserve state; restore/support | `order_auth_invalid`; preserve state; rate-limit |
|
||||
| declared SKU ≠ verified SKU | reject | `CPPaymentProblem`; no blind retry | `product_mismatch`; preserve `BP…`; quarantine/alert |
|
||||
| unknown provider state | quarantine | `CPPaymentProblem`; retry later | preserve `BP…`; re-fetch, never guess |
|
||||
| `GrantReady` | apply | `CBNeeded` → `CBRequesting` | `BBRequested` when requested |
|
||||
| `GrantFulfilled` | idempotent | `CBReceived` → install | return cached `BBIssued` |
|
||||
@@ -765,12 +801,12 @@ Every input is one of:
|
||||
| refund/revocation | apply | `CPExpired`; signed badge survives to expiry | `BPRefunded`/`BPRevoked`; mark unused grants `GrantRevoked` |
|
||||
| webhook DB failure | retry delivery | no transition | no transition; non-2xx |
|
||||
|
||||
Stable codes: `bad_request`, `unsupported_version`, `payment_pending`, `payment_not_entitled`, `ownership_conflict`, `proof_invalid`, `provider_rate_limited`, `provider_unavailable`, `idempotency_mismatch`, `badge_already_issued`, `signing_failed`, `internal_error`.
|
||||
Stable codes: `bad_request`, `unsupported_version`, `order_auth_invalid`, `product_changed`, `product_mismatch`, `payment_pending`, `payment_not_entitled`, `provider_rate_limited`, `provider_unavailable`, `badge_already_issued`, `signing_failed`, `internal_error`.
|
||||
|
||||
### Crash recovery
|
||||
|
||||
- Before provider call: repeat request.
|
||||
- Waiting `IssueBadge` lost on deadline/restart: remove waiter; repeat the same logical request on foreground.
|
||||
- Waiting `Purchase` lost on deadline/restart: remove waiter; repeat the same signed order request on foreground.
|
||||
- Provider succeeds before commit: retrieve by idempotency key/object binding.
|
||||
- Payment committed before issuance: `GrantReady` remains unchanged.
|
||||
- Credential cached before response loss: repeat returns it.
|
||||
@@ -807,61 +843,55 @@ 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, redirect URLs, and collects customer email so the hosted portal login works.
|
||||
- Stripe uses server-selected Price (from `badge_types`/`stripe.plans[plan]`), mode, Customer, `client_reference_id=orderId`, 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.
|
||||
- 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. 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.
|
||||
Recovery re-establishes order 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. There are two tiers, by what survives.
|
||||
|
||||
### 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 |
|
||||
| Bot | order (keyed by `order_id`), `order_key`, entitlement bindings, `sub_`/`cus_`, grants, cached credential | — |
|
||||
| Client | — | `order_id`, `order_sk`, `BadgeMasterKey`, 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.
|
||||
The bot never loses the order. The client's durable secrets are `order_sk` (order auth) and `BadgeMasterKey` (badge); both belong in the profile backup.
|
||||
|
||||
### Restore from backup
|
||||
### Tier 1 — restore from backup (normal path)
|
||||
|
||||
SimpleX encrypted-profile backup or migration restores `BadgeMasterKey`, `BadgeKeyCommitment`, and the cached badge. No recovery RPC runs. This is the primary path.
|
||||
SimpleX encrypted-profile backup or migration restores `order_id`, `order_sk`, `BadgeMasterKey`, and the cached badge. The client re-attaches to the same order by signing a `Status` request with `order_sk`; if the master key also survived, the cached badge/grant re-issues to it. Nothing new is minted.
|
||||
|
||||
### Re-attach when only the master key survives
|
||||
### Tier 2 — total loss (no backup)
|
||||
|
||||
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):
|
||||
`order_id`/`order_sk`/`BadgeMasterKey` are all gone. **Apple/Google are still recoverable** because the entitlement lives in the store:
|
||||
|
||||
| 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) |
|
||||
1. The client makes a **fresh order** (new `order_id`/`order_sk`/`BadgeMasterKey`).
|
||||
2. It re-queries StoreKit `Transaction.currentEntitlements` / Play `queryPurchases` for a fresh receipt of the active subscription.
|
||||
3. It sends a signed `Purchase` with that receipt → the bot verifies and issues a badge bound to the new master key. The abandoned order/badge expires unused.
|
||||
|
||||
This requires the bot to let a *new* order claim an entitlement a prior order already bound. The `entitlements` table binds each provider object to an order but permits re-bind to a new order on a fresh verified receipt, **capped by the per-period `reissue_count` and rate-limited** (BBS badges can't be revoked, so bound the over-issue).
|
||||
|
||||
**Stripe has no tier-2 badge recovery** — there is no client-side re-presentable receipt. The hosted portal login (email OTP) can only cancel; a new badge requires a new order/purchase.
|
||||
|
||||
### 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.
|
||||
Issuance is idempotent on `(grant, master-key hash)`: within one order the same `BadgeMasterKey` returns the same cached credential — no new charge, no duplicate. A one-time grant already `GrantFulfilled` returns the cached credential.
|
||||
|
||||
### 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.
|
||||
- Verify the order signature (tier 1) or the provider receipt (tier 2) before acting; never act on an unsigned request.
|
||||
- Rate-limit re-binds per entitlement and per provider object; enforce the per-period `reissue_count` cap.
|
||||
- Re-binding changes only which order owns an entitlement; it never mutates provider or billing state.
|
||||
|
||||
## 9. Security and concurrency
|
||||
|
||||
- Verify provider signatures/objects server-side; never trust decoded client/redirect fields.
|
||||
- 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`.
|
||||
- Encrypt retained proofs/provider IDs; rotate keys. Authenticate every request by the order's Ed25519 signature and verify provider proof server-side; never act on an unsigned request.
|
||||
- Keep raw `BadgeMasterKey` client-encrypted and bot-memory-only during signing; the bot stores the `order_key` (public) and never keeps the master key as an identifier.
|
||||
- Allowlist product, app/package, environment, currency/price, and account binding.
|
||||
- Rate-limit operation/payment and cap payload sizes.
|
||||
- Serialize payment mutations with lock/version; events and RPC use the same transitions.
|
||||
@@ -872,7 +902,7 @@ If `BadgeMasterKey` is lost with no backup, the payment cannot be re-attached:
|
||||
|
||||
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, portal link (authenticated session + login-page fallback), portal cancellation + webhook reconciliation.
|
||||
3. **Stripe:** Checkout, waiting `Purchase(StripePaid)`, 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:
|
||||
@@ -884,7 +914,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;
|
||||
- `BadgeKeyProof`/grant/BBS-owner isolation and wrong-`BadgeMasterKey` rejection;
|
||||
- order-signature/grant/BBS-owner isolation, product-pin/SKU-match checks, 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.
|
||||
@@ -893,7 +923,7 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback,
|
||||
|
||||
| Location | Change |
|
||||
|---|---|
|
||||
| new `Simplex.Chat.Badges.Lifecycle` | client sums/transitions/reconciliation; call `generateMasterKey` before prepare |
|
||||
| new `Simplex.Chat.Badges.Lifecycle` | client sums/transitions/reconciliation; generate order key + `generateMasterKey` before order create |
|
||||
| `Library.Commands.addUserBadge` | non-CLI verified install API |
|
||||
| RPC/controller/console | calls, response handling, redacted audit |
|
||||
| client store/migrations | separate payment and badge stores |
|
||||
@@ -916,4 +946,6 @@ Release gates: provider sandbox E2E, webhook signature/replay, schema rollback,
|
||||
|
||||
## 12. Open questions
|
||||
|
||||
**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.
|
||||
**Stripe total loss.** Recovery §8 tier 2 shows Apple/Google recover after a full wipe (store re-presents the entitlement) but Stripe cannot — the hosted portal login only cancels, and a new badge needs a new order. Decision: accept this, or add an optional user-held recovery code as a second Stripe order-recovery credential.
|
||||
|
||||
**Re-issue cap value.** The per-entitlement, per-period `reissue_count` bounds tier-2 over-issue. Decision: pick the cap (e.g. 2–3 per period) and the rate-limit window.
|
||||
@@ -4,7 +4,7 @@
|
||||
**Status:** implementation-ready
|
||||
**Companion:** [Implementation plan](2026-07-20-supporter-badges-v2-implementation.md)
|
||||
|
||||
Payment and badge are separate: payment creates a service grant for an eligible monthly grant period; the grant authorizes one badge issuance; core verifies and installs it.
|
||||
An **order** carries a plan (the product) and a payment. Payment creates a service grant for an eligible monthly grant period; the grant authorizes one badge issuance; core verifies and installs it. Order, payment, and badge are separate.
|
||||
|
||||

|
||||
|
||||
@@ -35,6 +35,10 @@ Choices: **One-time**, **Monthly**, **Yearly**. There is no Extend action.
|
||||
- Cancellation stops renewal. It does not shorten an issued badge.
|
||||
- Stripe uses the system browser. No localhost service is required.
|
||||
|
||||
### Tiers
|
||||
|
||||
Supporter and investor grant the same functional perk (2 GB file uploads); legend raises it to 5 GB; investor is otherwise cosmetic (badge color). The tier is fixed by the plan purchased (its SKU), not chosen afterward. Badge expiry is a shared end-of-month boundary, so everyone renewing in a month shares one anonymity set.
|
||||
|
||||
### Dates
|
||||
|
||||
| Payment event | Billing | Badge |
|
||||
@@ -50,9 +54,9 @@ Show **Badge valid until** separately from **Renews on** or **Subscription ends
|
||||
- Core signature and expiry decide badge validity.
|
||||
- Bot/provider verification decides payment status and grant eligibility.
|
||||
- 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.
|
||||
- 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.
|
||||
- Each client request receives exactly one bot response; the bot never initiates a call.
|
||||
- Before buying, the client creates an order: a client-generated id, an Ed25519 order key whose signature authenticates every request, and one 32-byte BBS `BadgeMasterKey` for the badge. Renewals reuse the same order and keys.
|
||||
- There is no bot-issued token and no caller identity. Every request is signed by the order key; payment additionally carries a fresh store proof (Apple/Google) or is confirmed by webhook (Stripe). The order key, BBS key, and provider proofs are redacted.
|
||||
|
||||
## 2. UX states
|
||||
|
||||
@@ -103,13 +107,13 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant A as StoreKit
|
||||
participant B as Bot
|
||||
C->>B: Prepare Apple payment
|
||||
B-->>C: Account binding
|
||||
C->>B: Purchase (Apple), signed order
|
||||
B-->>C: Order created + binding
|
||||
Note over C: Store ready
|
||||
C->>A: Purchase
|
||||
A-->>C: Signed transaction
|
||||
Note over C: Verifying
|
||||
C->>B: Transaction + badge request
|
||||
C->>B: Purchase (Apple receipt)
|
||||
Note over B: Payment entitled, badge issued
|
||||
B-->>C: Status + badge
|
||||
Note over C: Entitled, badge installed
|
||||
@@ -122,8 +126,8 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant A as StoreKit
|
||||
participant B as Bot
|
||||
C->>B: Prepare Apple payment
|
||||
B-->>C: Account binding
|
||||
C->>B: Purchase (Apple), signed order
|
||||
B-->>C: Order created + binding
|
||||
C->>A: Purchase
|
||||
A-->>C: Pending
|
||||
Note over C: Payment pending, badge unchanged
|
||||
@@ -137,8 +141,8 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant A as StoreKit
|
||||
participant B as Bot
|
||||
C->>B: Prepare Apple payment
|
||||
B-->>C: Account binding
|
||||
C->>B: Purchase (Apple), signed order
|
||||
B-->>C: Order created + binding
|
||||
C->>A: Purchase
|
||||
A-->>C: User canceled
|
||||
Note over C: Previous state
|
||||
@@ -156,12 +160,12 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as Google Play
|
||||
participant B as Bot
|
||||
C->>B: Prepare Google payment
|
||||
B-->>C: Account binding
|
||||
C->>B: Purchase (Google), signed order
|
||||
B-->>C: Order created + binding
|
||||
C->>G: Purchase
|
||||
G-->>C: Purchase token
|
||||
Note over C: Verifying
|
||||
C->>B: Token + badge request
|
||||
C->>B: Purchase (Google receipt)
|
||||
B->>G: Verify with Publisher API
|
||||
G-->>B: Paid period
|
||||
Note over B: Payment entitled, badge issued
|
||||
@@ -176,8 +180,8 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as Google Play
|
||||
participant B as Bot
|
||||
C->>B: Prepare Google payment
|
||||
B-->>C: Account binding
|
||||
C->>B: Purchase (Google), signed order
|
||||
B-->>C: Order created + binding
|
||||
C->>G: Purchase
|
||||
G-->>C: Pending
|
||||
Note over C: Payment pending, badge unchanged
|
||||
@@ -191,8 +195,8 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as Google Play
|
||||
participant B as Bot
|
||||
C->>B: Prepare Google payment
|
||||
B-->>C: Account binding
|
||||
C->>B: Purchase (Google), signed order
|
||||
B-->>C: Order created + binding
|
||||
C->>G: Purchase
|
||||
G-->>C: User canceled
|
||||
Note over C: Previous state
|
||||
@@ -208,18 +212,18 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant B as Bot
|
||||
participant S as Stripe
|
||||
C->>B: RPC Prepare Stripe
|
||||
C->>B: Purchase (StripeInvoice), create order
|
||||
B->>S: Create Checkout Session
|
||||
B-->>C: RPC Checkout URL
|
||||
B-->>C: RspInvoice (Checkout URL)
|
||||
C->>S: Open Checkout
|
||||
Note over C: CPAwaitingPayment
|
||||
C->>B: RPC IssueBadge
|
||||
C->>B: Purchase (StripePaid), same order
|
||||
Note over B: Hold call, no response yet
|
||||
S-->>B: Signed webhook
|
||||
B->>S: Retrieve and verify payment
|
||||
S-->>B: Paid
|
||||
Note over B: GrantReady → badge issued
|
||||
B-->>C: RPC badge credential
|
||||
B-->>C: RspCredential
|
||||
Note over C: CPEntitled + CBReceived
|
||||
C->>C: Verify and install
|
||||
Note over C: CBInstalled
|
||||
@@ -231,13 +235,13 @@ sequenceDiagram
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant B as Bot
|
||||
C->>B: RPC IssueBadge
|
||||
C->>B: Purchase (StripePaid)
|
||||
B->>B: Persisted payment is still pending
|
||||
Note over B: Hold call, no response
|
||||
Note over C: RPC deadline / app closes
|
||||
C-xB: Cancel waiting call
|
||||
Note over C: CPAwaitingPayment + CBRetryableFailure
|
||||
C->>B: Repeat same IssueBadge on foreground
|
||||
C->>B: Repeat same Purchase on foreground
|
||||
Note over C: CPAwaitingPayment + CBRequesting
|
||||
Note over B: Respond immediately if webhook already completed<br/>otherwise wait again
|
||||
```
|
||||
@@ -249,15 +253,15 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant B as Bot
|
||||
participant S as Stripe
|
||||
C->>B: RPC IssueBadge
|
||||
C->>B: Purchase (StripePaid)
|
||||
Note over B: Hold call
|
||||
S-->>B: Signed Checkout expired event
|
||||
Note over B: BPExpired, no grant
|
||||
B-->>C: RPC checkout expired
|
||||
B-->>C: RspError checkout expired
|
||||
Note over C: CPExpired, new Checkout requires user action
|
||||
```
|
||||
|
||||
There is no payment polling. The pending `IssueBadge` call is the completion signal. A deep link may return the user to the app but is not required and is never payment proof.
|
||||
There is no payment polling. The pending `Purchase` call is the completion signal. A deep link may return the user to the app but is not required and is never payment proof.
|
||||
|
||||
### Cancel subscription
|
||||
|
||||
@@ -320,8 +324,8 @@ Stripe cancellation always happens in the browser Customer Portal (the portal ca
|
||||
|
||||
| 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) |
|
||||
| the order (can sign with its order key) | authenticated portal session — opens straight to the cancel flow, no email code |
|
||||
| nothing (order lost with the app) | generic hosted portal login page — user signs in with the email they paid with (email code) |
|
||||
|
||||
## 5. Refresh and errors
|
||||
|
||||
@@ -332,12 +336,12 @@ If `GrantReady` exists without the current badge, request issuance. Cache the re
|
||||
| Condition | Client action |
|
||||
|---|---|
|
||||
| Store canceled | restore previous screen |
|
||||
| Payment pending | keep `IssueBadge` waiting; retry the same call after deadline/restart |
|
||||
| Payment pending | keep `Purchase` waiting; retry the same signed call after deadline/restart |
|
||||
| Network/provider failure | keep cached state and active badge; retry |
|
||||
| Paid, issuance failed | show “Payment confirmed. Badge is being prepared”; retry |
|
||||
| Cancel failed | keep **Renews on**; retry |
|
||||
| Payment issue | show Fix payment / Manage |
|
||||
| Ownership/proof failure | restore/support; no sensitive details |
|
||||
| Order signature / proof failure | restore/support; no sensitive details |
|
||||
| Unknown issuer/protocol | require update |
|
||||
| Invalid credential | reject; retain old badge; retry/support |
|
||||
| Duplicate/lost response | repeat same request; no duplicate charge/badge |
|
||||
@@ -352,7 +356,8 @@ Errors preserve the last payment snapshot and installed badge. The implementatio
|
||||
- Apple, Google, and Stripe have separate linear outcomes.
|
||||
- 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.
|
||||
- Requests are client-signed (order key), one bot response each, idempotent by order id.
|
||||
- Tier and billing period are fixed by the plan (SKU); the client cannot request a higher tier or longer life than it paid for.
|
||||
- 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.
|
||||
@@ -2,5 +2,5 @@
|
||||
<text x="28" y="91" class="h">CLIENT</text><rect class="box client" rx="10" x="125" y="60" width="180" height="70"/><text x="215" y="88" text-anchor="middle" class="h">Payment state</text><text x="215" y="111" text-anchor="middle" class="b">prepare · pending · refresh</text><rect class="box client" rx="10" x="795" y="60" width="150" height="70"/><text x="870" y="88" text-anchor="middle" class="h">Badge state</text><text x="870" y="111" text-anchor="middle" class="b">receive · install</text>
|
||||
<text x="28" y="221" class="h">BOT</text><rect class="box pay" rx="10" x="125" y="180" width="180" height="80"/><text x="215" y="210" text-anchor="middle" class="h">Payment service</text><text x="215" y="233" text-anchor="middle" class="b">verify provider entitlement</text><rect class="box grant" rx="10" x="405" y="180" width="180" height="80"/><text x="495" y="210" text-anchor="middle" class="h">ServiceGrant</text><text x="495" y="233" text-anchor="middle" class="b">ready · fulfilled · revoked</text><rect class="box badge" rx="10" x="685" y="180" width="180" height="80"/><text x="775" y="210" text-anchor="middle" class="h">Badge service</text><text x="775" y="233" text-anchor="middle" class="b">request · sign · cache</text>
|
||||
<text x="28" y="349" class="h">PROVIDER</text><rect class="box provider" rx="10" x="125" y="310" width="180" height="70"/><text x="215" y="338" text-anchor="middle" class="h">Apple / Google / Stripe</text><text x="215" y="361" text-anchor="middle" class="b">charge · renew · refund</text>
|
||||
<path class="line" d="M215 130 V180"/><text x="225" y="158" class="s">RPC evidence/status</text><path class="line" d="M215 310 V260"/><text x="225" y="291" class="s">API / webhook</text><path class="line" d="M305 220 H405"/><text x="355" y="207" text-anchor="middle" class="s">verified grant period</text><path class="line" d="M585 220 H685"/><text x="635" y="207" text-anchor="middle" class="s">internal contract</text><path class="line" d="M775 180 V132 H795"/><text x="780" y="151" class="s">RPC result</text><path class="line" d="M945 95 H1015 V220 H865"/><text x="1000" y="154" text-anchor="middle" class="s">next refresh</text>
|
||||
<path class="line" d="M215 130 V180"/><text x="225" y="158" class="s">signed order (Purchase/Status)</text><path class="line" d="M215 310 V260"/><text x="225" y="291" class="s">API / webhook</text><path class="line" d="M305 220 H405"/><text x="355" y="207" text-anchor="middle" class="s">verified grant period</text><path class="line" d="M585 220 H685"/><text x="635" y="207" text-anchor="middle" class="s">internal contract</text><path class="line" d="M775 180 V132 H795"/><text x="780" y="151" class="s">order response</text><path class="line" d="M945 95 H1015 V220 H865"/><text x="1000" y="154" text-anchor="middle" class="s">next refresh</text>
|
||||
<text x="550" y="414" text-anchor="middle" class="muted b">Payment never installs a badge. Badge issuance never interprets provider state.</text></svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -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 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 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 order id + order 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">• order signature (Ed25519) 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 |
Reference in new issue
Block a user