@happyvertical/smrt-subscriptions 0.60.14 → 0.60.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +152 -0
  2. package/README.md +206 -0
  3. package/dist/billing/apply.d.ts +52 -0
  4. package/dist/billing/apply.d.ts.map +1 -0
  5. package/dist/billing/checkout-sessions.d.ts +99 -0
  6. package/dist/billing/checkout-sessions.d.ts.map +1 -0
  7. package/dist/billing/config.d.ts +64 -0
  8. package/dist/billing/config.d.ts.map +1 -0
  9. package/dist/billing/events.d.ts +49 -0
  10. package/dist/billing/events.d.ts.map +1 -0
  11. package/dist/billing/host.d.ts +3 -0
  12. package/dist/billing/host.d.ts.map +1 -0
  13. package/dist/billing/index.d.ts +4 -0
  14. package/dist/billing/index.d.ts.map +1 -0
  15. package/dist/billing/plans.d.ts +45 -0
  16. package/dist/billing/plans.d.ts.map +1 -0
  17. package/dist/billing/signing.d.ts +24 -0
  18. package/dist/billing/signing.d.ts.map +1 -0
  19. package/dist/billing/types.d.ts +265 -0
  20. package/dist/billing/types.d.ts.map +1 -0
  21. package/dist/collections/TenantUsageMetricCollection.d.ts.map +1 -1
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3230 -1123
  25. package/dist/index.js.map +1 -1
  26. package/dist/manifest.json +314 -4
  27. package/dist/models/BillingCheckoutSession.d.ts +39 -0
  28. package/dist/models/BillingCheckoutSession.d.ts.map +1 -0
  29. package/dist/models/index.d.ts +1 -0
  30. package/dist/models/index.d.ts.map +1 -1
  31. package/dist/recipes.d.ts +1 -1
  32. package/dist/services/entitlement-changes.d.ts +115 -0
  33. package/dist/services/entitlement-changes.d.ts.map +1 -0
  34. package/dist/services/hosted-ai-spend.d.ts +182 -0
  35. package/dist/services/hosted-ai-spend.d.ts.map +1 -0
  36. package/dist/services/index.d.ts +3 -0
  37. package/dist/services/index.d.ts.map +1 -1
  38. package/dist/services/subscription-feature-source.d.ts +89 -0
  39. package/dist/services/subscription-feature-source.d.ts.map +1 -0
  40. package/dist/services/subscription-resolver.d.ts +34 -1
  41. package/dist/services/subscription-resolver.d.ts.map +1 -1
  42. package/dist/smrt-knowledge.json +103 -8
  43. package/dist/smrt-knowledge.okf.md +6 -2
  44. package/dist/vault-account/actions.d.ts +24 -0
  45. package/dist/vault-account/actions.d.ts.map +1 -0
  46. package/dist/vault-account/errors.d.ts +20 -0
  47. package/dist/vault-account/errors.d.ts.map +1 -0
  48. package/dist/vault-account/index.d.ts +5 -0
  49. package/dist/vault-account/index.d.ts.map +1 -0
  50. package/dist/vault-account/relay-admin-client.d.ts +10 -0
  51. package/dist/vault-account/relay-admin-client.d.ts.map +1 -0
  52. package/dist/vault-account/types.d.ts +115 -0
  53. package/dist/vault-account/types.d.ts.map +1 -0
  54. package/package.json +9 -6
package/AGENTS.md CHANGED
@@ -86,6 +86,51 @@ that reverted to DECIMAL passes every SQLite suite.
86
86
  `retainsPaidPeriod`, derived on save from an entitled pre-cancel row.
87
87
  - **Recipe `subscriptions.plans`** (`src/recipes.ts`, help in `src/plans.recipe.md`): lists plans, subscriptions, plan versions (read-only, written by `plan-versioning.ts`) and charges (read-only, written by the usage service) for the planner and other catalog hosts. Fields it shows carry `@field({ description })` in plain words, money fields saying "in cents"; keep them when editing the models. Reseller models and pricing rules stay out of it.
88
88
 
89
+ ## Hosted AI spend caps (#4760)
90
+
91
+ `src/services/hosted-ai-spend.ts`. Plans cap platform-paid (`source:
92
+ hosted`) inference with ordinary `PlanThreshold`s on two reserved keys, so
93
+ window, amount and enforcement come from the plan (pinned version included):
94
+ `AI_HOSTED_SPEND_METRIC` (`ai.hosted.spend_micros`, the tenant) and
95
+ `AI_HOSTED_USER_SPEND_METRIC` (`ai.hosted.user_spend_micros`, each user).
96
+
97
+ - `limit` is **integer micro-dollars** (1 USD = 1,000,000; the money rule at
98
+ per-call precision). `block` blocks at or over the cap, `warn` warns,
99
+ `observe` never affects a call; `warningRatio` (default 0.8) warns early;
100
+ a 0 cap blocks every hosted call. A malformed hosted cap (fractional or
101
+ negative limit, unknown window/enforcement) throws
102
+ `HostedAiSpendPolicyError` (the gate fails closed); it never reads as "no
103
+ cap". `resolvePlanThresholds()` returns thresholds unfiltered for this
104
+ reason (the models' `getThresholds()` drop invalid entries) and throws on
105
+ non-array JSON.
106
+ - Spend: `createHostedAiSpendReader(db)` sums `_smrt_ai_usage.estimated_cost`
107
+ (USD, rounded to micro-dollars) by `tenant_id` and `created_at` in the
108
+ cap's window for rows whose `tags` hold `"source":"hosted"` (and
109
+ `"userId":"<id>"` for the per-user cap), as exact, case-sensitive JSON
110
+ fragments (`hostedAiTagFragment()`; `strpos` on the PostgreSQL dialect,
111
+ `instr` elsewhere, never `LIKE`, which folds ASCII case on SQLite and
112
+ would merge `Alice` and `alice`); errors only over-count.
113
+ `operation: 'blocked'` rows and external-agent measurements never count;
114
+ uncosted rows add nothing (give hosted models a cost rate). The `userId`
115
+ tag exists only while the hosted gate's ambient-user resolver is
116
+ registered.
117
+ - `resetsAt`: the calendar window's end; for a blocked `rolling` cap (30
118
+ days), `HostedAiSpendReader.liftsAt()`: one window-function query (newest
119
+ first running `SUM(estimated_cost)`, rounded as `summarize()` rounds)
120
+ returns the row whose expiry first brings the total below the cap, plus
121
+ 30 days (null for a 0 cap). A blocked decision names the blocking cap that
122
+ lifts last.
123
+ - `HostedAiSpendEvaluator` (`create({ db })`): plan thresholds via
124
+ `SubscriptionResolver.resolvePlanThresholds()` (entitled terms, no usage
125
+ meters; memoized per tenant for `termsTtlMs`, 30 s, failures evicted,
126
+ `invalidate()`), spend read fresh per call; per-user caps only with a
127
+ user. Errors propagate. smrt-inference's `./gate` consumes it.
128
+ - `resolveEntitlements()` / the tenant usage meter report these keys with
129
+ quantity 0: only the evaluator meters them. The meter's AI summary (and so
130
+ `ai.requests`) skips `operation: 'blocked'` rows. `SpendingPolicy` rows stay the
131
+ billed-charge (cents) policy; hosted AI caps are plan terms because the
132
+ issue's window and amount come from the plan.
133
+
89
134
  ## Reseller billing (#3059)
90
135
 
91
136
  - Billing ownership is consumed from `smrt-tenancy`'s
@@ -116,6 +161,84 @@ that reverted to DECIMAL passes every SQLite suite.
116
161
  it `currentShortfall()`, so a hook that charges asynchronously can see a
117
162
  concurrent top-up (smrt-commerce's `autoTopUpHook()`, #3139).
118
163
 
164
+ ## Billing host glue (#4786)
165
+
166
+ `src/billing/` is the reusable Stripe checkout, signed idempotent webhook and
167
+ portal wiring (`createBillingHost()`, `readBillingEnv()`); the recipe and
168
+ environment names are in the README. Invariants:
169
+
170
+ - **No direct Stripe calls.** The provider is the structural
171
+ `BillingProviderPort`; `StripeAccountingProvider` from
172
+ `@happyvertical/accounting` satisfies it (a test pins that). A missing SDK
173
+ capability is fixed in the SDK first. `@happyvertical/accounting` is a
174
+ devDependency only (tests run the real provider over a fake `fetch`).
175
+ - **Webhook order is load-bearing:** size cap, signature header, own timestamp
176
+ tolerance, SDK `verify` on the raw body, parse, handled-type filter,
177
+ completed-claim pre-check, then (checkout only) the outside-transaction
178
+ provider read-back, then `runOnce` claiming the event id *and* applying it.
179
+ All DB access inside that transaction must use the `tx` handle; a read on the
180
+ base handle deadlocks single-connection SQLite. Never claim before applying.
181
+ - **Trust comes from our own state.** Checkout metadata is HMAC-signed
182
+ (`smrt_sig`, `signing.ts`) and an unsigned or foreign-signed session never
183
+ links a tenant or plan.
184
+ - **Checkout is bound to a recorded session (#4814)**, `checkout-sessions.ts`
185
+ and `models/BillingCheckoutSession.ts` (`_smrt_billing_checkout_sessions`,
186
+ no API surface; a root export because the manifest advertises it).
187
+ `createCheckout` records the session id
188
+ before returning the URL and signs `smrt_exp`; the completion (inside the
189
+ `runOnce` transaction, after the PostgreSQL `checkout:<id>` lock) checks, in
190
+ order: record exists, status is not `completed` / `needs_reconciliation`
191
+ (no-op `ignored`), record tenant equals the signed tenant, `smrt_exp` is
192
+ well-formed, event time is not after the signed or recorded expiry. A
193
+ missing, mismatched or expired record never links and never drops: the row
194
+ becomes `needs_reconciliation` (outcome `needs_reconciliation`, error log).
195
+ Only `applied` marks a record `completed`. `reconcileCheckout()` (link) and
196
+ `dismissCheckout()` (terminal `dismissed`, for a payment that is not the
197
+ tenant's) are the only ways out and are operator-only; reconcile never skips
198
+ the apply-time conflict checks. The list shows `customerId`,
199
+ `targetTenantId` and `claimedTenantHasOwnSession`, is paged, and has a count.
200
+ The expiry is deterministic per idempotency window so a repeated click sends
201
+ identical provider parameters. Change the table shape only with owner
202
+ approval. A provider subscription or customer id links to one
203
+ tenant; a conflicting event is ignored with a reason code, not merged.
204
+ Verification accepts the signing secret plus `metadataVerificationSecrets`
205
+ (rotation); a purpose-matching session that fails them logs at error.
206
+ A price the host cannot map that differs from the recorded one is held
207
+ (`incomplete`, plan and recorded price kept, `metadata.stripe.held` set so
208
+ checkout still refuses a second subscription) with an error log, never
209
+ carried over onto the previous plan.
210
+ Ignore (2xx, logged) for security refusals; fail (5xx) for configuration or
211
+ database faults so the provider retries.
212
+ - **Only operator plans are sold by default** (`tenantPlans: 'deny'`); price
213
+ resolution never lets a tenant-owned plan outrank a global one. Checkout
214
+ metadata also signs the price and known customer, and the webhook refuses a
215
+ session whose read-back subscription differs. PostgreSQL events lock the
216
+ subscription and tenant (advisory, sorted) and re-read before deciding.
217
+ - **Order state lives on the row** (`metadata.stripe.lastEventAt`, `terminal`);
218
+ a checkout read-back stamps `max(eventCreated, readAt)` so later-delivered
219
+ older events cannot rewind it. Cancellation caps `currentPeriodEnd` at now.
220
+ - **Plan changes** from the provider use `replacePlanVersion({ policy:
221
+ 'authorized', reason: 'stripe:<eventId>' })`; the provider billed the change,
222
+ so a mid-period downgrade is honoured and audited. Do not relax the pinning
223
+ rules for any other caller.
224
+ - **No secrets out.** Responses use fixed messages; logs get ids and reason
225
+ codes only, errors pass through `safeErrorMessage()`. `readBillingEnv` keeps
226
+ secrets non-enumerable. Add a named test per new threat in
227
+ `__tests__/billing-host.test.ts`.
228
+
229
+ ## Vault account restart and supersede (#4841)
230
+
231
+ `src/vault-account/`. `supersedeVaultAccount`, `unsupersedeVaultAccount` and
232
+ `provisionRestartAccount` take injected ports (`VaultRelayAdminPort`,
233
+ `VaultAccountDirectory`, an audit sink); no HTTP server or relay is needed.
234
+ Order is load-bearing: owner of the tenant and of both accounts (`not_owner`),
235
+ then `requireRecentAuthentication` from `smrt-users/step-up`
236
+ (`step_up_required`, default 300 s), then the `authorized` audit record, then
237
+ exactly one relay call, then the outcome record. Refusals are audited and never
238
+ reach the relay; a member never learns about the step-up. Audit entries carry
239
+ ids and codes only (no session id, key, token or message text). Import only the
240
+ `@happyvertical/smrt-users/step-up` subpath, never the package root.
241
+
119
242
  ## Notes
120
243
 
121
244
  - Keep Stripe-specific fields as provider binding metadata. Runtime Stripe API
@@ -129,3 +252,32 @@ that reverted to DECIMAL passes every SQLite suite.
129
252
  current subscription and plan.
130
253
  - Subscription UI components should stay provider-neutral and receive actions
131
254
  from the host app.
255
+ - **Feature grants as flags (#4761).** `SubscriptionFeatureSource` implements
256
+ `smrt-features`' `FeatureGrantSource` structurally (no dependency either
257
+ way). Build one per request (`SubscriptionFeatureSource.create(options)` or
258
+ `new SubscriptionFeatureSource({ resolver })`; it memoizes grants per
259
+ tenant) and pass it as `grantSource`, or register an app-wide one built with
260
+ `memoize: false` through `registerSubscriptionFeatureSource()`, which writes
261
+ the features slot key `FEATURE_GRANT_SOURCE_SLOT_KEY`. It reads
262
+ `SubscriptionResolver.resolveFeatureGrants()`, which shares the
263
+ version-pinning and entitlement rules of `resolveEntitlements()` but never
264
+ evaluates thresholds, so a flag check reads no usage meters. Missing
265
+ subscription tables (`isMissingSubscriptionTableError`) and no entitled
266
+ subscription report no grants silently; every other error propagates.
267
+ `findGrantingPlan()` names the first active plan by `sortOrder` visible in
268
+ the current context.
269
+ - **Entitlement change hook (#4759).** `src/services/entitlement-changes.ts`.
270
+ The host wraps its billing-webhook write in
271
+ `trackEntitlementChange({ tenantId, resolver }, apply)`: it snapshots
272
+ `resolveFeatureGrants()` (keys plus grant `metadata`, no usage reads)
273
+ before and after, diffs `gained` / `lost` / `changed` (metadata, e.g. a
274
+ plan budget) and calls listeners registered with
275
+ `registerEntitlementChangeListener()` (a `Symbol.for` slot) or passed in.
276
+ Listeners run after `apply`; their failures are returned in `failures`,
277
+ never thrown, and never undo the write, so listeners must be idempotent.
278
+ An `apply` error propagates and notifies nobody. A redelivered webhook
279
+ diffs to no change, so a lost reaction is repaired by the consumer's
280
+ reconcile entry point (hosted inference: `createHostedInferenceReconciler()`
281
+ with `snapshotEntitlements()`), run on redelivery or a schedule. Listeners satisfy the
282
+ structural type (smrt-inference's hosted listener), so this package
283
+ depends on none of them.
package/README.md CHANGED
@@ -39,6 +39,17 @@ Create one resolver per request or application unit of work and reuse the
39
39
  loaded `EntitlementResolutionContext` when several checks need the same
40
40
  subscription and plan.
41
41
 
42
+ ### Plan grants as feature flags
43
+
44
+ `SubscriptionFeatureSource` feeds a tenant's plan grants into
45
+ `@happyvertical/smrt-features`, so `FeatureResolver` and its `requireFeature()`
46
+ gate answer from the plan. Build one per request with
47
+ `SubscriptionFeatureSource.create(dbOptions)` and pass it as `grantSource`, or
48
+ register an app-wide one (`memoize: false`) with
49
+ `registerSubscriptionFeatureSource()`. With no subscription tables or no
50
+ entitled subscription it reports no grants. A global feature override still
51
+ outranks a grant (an operator stop), and a tenant override outranks both.
52
+
42
53
  ## Core model
43
54
 
44
55
  - `SubscriptionPlan` is tenant-scoped by `(tenantId, planKey)` and stores
@@ -350,6 +361,201 @@ keeps its credit ledger (reversible in a system context), and the new parent
350
361
  creates its own balance. A parent's `retail` cap counts only what the child owes that
351
362
  parent.
352
363
 
364
+ ## Billing host glue (Stripe)
365
+
366
+ `createBillingHost()` is the reusable checkout, webhook and customer-portal
367
+ wiring for hosts that sell a `SubscriptionPlan` through Stripe (#4786). A host
368
+ configures it; it does not re-implement it. Every provider call goes through
369
+ `@happyvertical/accounting`; there are no direct Stripe calls here. Web only:
370
+ phone apps never sell a subscription in-app.
371
+
372
+ ### Host configuration
373
+
374
+ | Variable | Meaning |
375
+ | --- | --- |
376
+ | `STRIPE_SECRET_KEY` | Secret or restricted key (`sk_` / `rk_`). |
377
+ | `STRIPE_WEBHOOK_SECRET` | Webhook endpoint signing secret (`whsec_`). |
378
+ | `STRIPE_WEBHOOK_TOLERANCE` | Optional. Maximum signature age in seconds, 1 to 900, default 300. |
379
+ | `BILLING_METADATA_SECRET` | Optional. Secret that signs new Checkout metadata. Unset: the webhook secret is used, so rotating the webhook secret also rotates this. Set it to a stable value to decouple the two. |
380
+ | `BILLING_METADATA_PREVIOUS_SECRETS` | Optional, comma-separated. Retired metadata secrets still accepted when a completed Checkout is verified. |
381
+ | `STRIPE_PRICE_<PLAN_KEY>` | Price id sold for a plan, for example `STRIPE_PRICE_PRO`. Wins over `SubscriptionPlan.providerPriceId`. |
382
+ | `BILLING_SUCCESS_URL`, `BILLING_CANCEL_URL`, `BILLING_PORTAL_RETURN_URL` | Fixed absolute https destinations (plain http only for localhost). |
383
+
384
+ `readBillingEnv(process.env, { planKeys })` validates these and keeps the
385
+ secrets in non-enumerable properties, so logging the config does not print
386
+ them. Its errors name the variable, never the value.
387
+
388
+ **Rotating secrets.** A Checkout session is signed when it is created and
389
+ verified when it completes, which can be days later. If the signing secret
390
+ changes in between (by default it is the webhook secret), a paid session no
391
+ longer verifies and the purchase is dropped. Before rotating, put the outgoing
392
+ secret in `BILLING_METADATA_PREVIOUS_SECRETS` (`metadataVerificationSecrets`)
393
+ and keep it there until sessions created under it have expired. A completed
394
+ session that claims to be ours but does not verify under any accepted secret
395
+ is logged at error level (`billing.webhook.checkout_signature_invalid`), so a
396
+ dropped purchase is visible.
397
+
398
+ ### Recipe (SvelteKit)
399
+
400
+ ```ts
401
+ // src/lib/server/billing.ts
402
+ import { getAccountingProvider } from '@happyvertical/accounting';
403
+ import {
404
+ createBillingHost,
405
+ readBillingEnv,
406
+ TenantSubscriptionCollection,
407
+ } from '@happyvertical/smrt-subscriptions';
408
+
409
+ const env = readBillingEnv(process.env, { planKeys: ['pro', 'team'] });
410
+ const provider = await getAccountingProvider({
411
+ type: 'stripe',
412
+ secretKey: env.stripe.secretKey, // read explicitly: non-enumerable on purpose
413
+ webhookSecret: env.stripe.webhookSecret,
414
+ webhookTolerance: env.stripe.webhookTolerance,
415
+ });
416
+ const { db } = await TenantSubscriptionCollection.create(smrtConfig);
417
+
418
+ export const billing = createBillingHost({
419
+ provider,
420
+ db, // needs transaction(): SQLite or PostgreSQL
421
+ webhookSecret: env.stripe.webhookSecret,
422
+ metadataSigningSecret: env.metadata.signingSecret,
423
+ metadataVerificationSecrets: env.metadata.previousSecrets,
424
+ webhookToleranceSeconds: env.stripe.webhookTolerance,
425
+ urls: env.urls,
426
+ prices: env.prices,
427
+ logger, // optional; receives ids and reason codes only
428
+ });
429
+
430
+ // src/routes/api/billing/checkout/+server.ts (authenticate and authorise first)
431
+ export const POST = async ({ request, locals }) => {
432
+ const membership = await requirePermission(locals, 'billing.manage');
433
+ return billing.handlers.checkout(request, {
434
+ tenantId: membership.tenantId,
435
+ email: membership.userEmail,
436
+ });
437
+ };
438
+
439
+ // .../portal/+server.ts -> billing.handlers.portal(request, { tenantId })
440
+ // .../webhook/+server.ts -> billing.handlers.webhook(request) (no auth; signature only)
441
+ ```
442
+
443
+ The webhook route must receive the raw body. Point the Stripe endpoint at it
444
+ and subscribe it to `checkout.session.completed` and
445
+ `customer.subscription.created|updated|deleted|paused|resumed`.
446
+
447
+ ### What it guarantees
448
+
449
+ - **Checkout** takes only a plan key or id from the request. Tenant, price,
450
+ customer and URLs come from the host's authenticated tenant, server
451
+ configuration and the tenant's own subscription row. A plan must be visible
452
+ to the tenant (its own or global), active, fixed-price and priced, and the
453
+ tenant must not already hold a live Stripe subscription (use the portal to
454
+ change plans). Only operator-owned (global) plans are sold unless the host
455
+ sets `tenantPlans: 'allow'`, so a tenant that can write plan rows cannot buy
456
+ entitlements it defined itself or shadow a plan key; set `allow` only when
457
+ tenants cannot write `SubscriptionPlan`. `canPurchasePlan` adds host policy.
458
+ The session metadata is HMAC-signed and bound to the server price, an
459
+ expiry and, when known, the billing customer, so another integration on the
460
+ same Stripe account cannot forge metadata for a tenant, and signed metadata
461
+ cannot be reused for a different price or another customer's subscription.
462
+ - **First purchases are bound to the session the host created (#4814).** The
463
+ host records each Checkout session id (table
464
+ `_smrt_billing_checkout_sessions`: session id, tenant, expiry, status
465
+ `pending`, `completed` or `needs_reconciliation`) before it returns the
466
+ buyer's URL, and signs an expiry (`smrt_exp`, 25 hours, so a Stripe session's
467
+ 24 hours plus an hour) into the metadata. A paid session links a tenant only
468
+ when, in order: the signature verifies, a record exists for exactly that
469
+ session id, it is still `pending`, it names the same tenant as the signed
470
+ metadata, and the event time (not the delivery time, so late webhook retries
471
+ of an in-time payment still count) is before the signed and recorded expiry.
472
+ Metadata copied onto another integration's session is therefore never
473
+ linked, however it is paid. A completed session is single use: a second
474
+ completion with the same session id changes nothing.
475
+ - **A paid session that cannot be bound is kept, not dropped.** If the record
476
+ is missing, expired or names another tenant, the tenant is not linked, the
477
+ event is claimed (no provider retry loop) and the session is stored as
478
+ `needs_reconciliation` with a reason code (`session_record_missing`,
479
+ `session_expired`, `session_record_mismatch`). The webhook answers 200 with
480
+ `outcome: 'needs_reconciliation'` and logs
481
+ `billing.checkout.needs_reconciliation` at error level (ids and codes only).
482
+ A genuine buyer whose record was lost, or a session that was in flight when
483
+ this binding was deployed (it has no record), lands here. An operator lists
484
+ them with `host.listCheckoutsNeedingReconciliation()` and links one on
485
+ purpose with `host.reconcileCheckout({ sessionId, tenantId, operatorId })`:
486
+ server-side only (authenticate and authorise the operator first; it is not a
487
+ request handler), it re-reads the subscription from the provider, requires
488
+ it to be the subscription and customer recorded when the session was
489
+ flagged, applies the same tenant/customer conflict checks as a normal
490
+ completion, links once, and logs `billing.checkout.reconciled` with the
491
+ operator id. Each listed row carries the paying `customerId`, the tenant the
492
+ host recorded the session for (`targetTenantId`) and
493
+ `claimedTenantHasOwnSession` (true suggests a copy of that tenant's signed
494
+ metadata): confirm the customer is the tenant's own before linking. Treat a
495
+ flagged session you did not expect as another integration's payment and close
496
+ it with `host.dismissCheckout({ sessionId, operatorId, reason })` (terminal
497
+ `dismissed`, logged with the operator id and reason), so it leaves the list.
498
+ The list is paged (`{ limit, offset }`, default 200) and
499
+ `host.countCheckoutsNeedingReconciliation()` gives the total.
500
+ - **Webhook** verifies the signature on the raw body with a bounded timestamp
501
+ tolerance, then claims the event id durably in the same transaction as the
502
+ subscription write (`runOnce`, `_smrt_run_once_claims`). A replay is a 2xx
503
+ no-op; a failed apply rolls the claim back (non-2xx) so the provider's retry
504
+ runs. Out-of-order events converge: each applied event records its time on
505
+ the row and an older one is skipped, and a cancellation wins a same-second
506
+ tie. A completed checkout reads the subscription back from the provider, so
507
+ the row reflects current state however events were ordered. A plan change at
508
+ the provider is applied as an authorised plan-version replacement, so
509
+ feature keys flip within that one webhook (including a mid-period
510
+ downgrade the provider billed).
511
+ - **Linking** is by provider ids stored on `TenantSubscription`. A subscription
512
+ or customer already linked to one tenant is never re-linked to another, and
513
+ an event for an unknown subscription is acknowledged and ignored.
514
+ - **Portal** opens the portal for the customer on the caller's own
515
+ subscription row only.
516
+ - **Responses and logs** carry fixed messages, ids and reason codes. Provider
517
+ error text is never returned, and anything shaped like a key is redacted
518
+ before logging.
519
+
520
+ Operational notes: if a second Checkout for a tenant that already holds a live
521
+ subscription is paid (two tabs racing), the webhook cannot track it and logs
522
+ `billing.checkout.orphaned_subscription` at error level with the provider
523
+ subscription id; alert on it and cancel or refund that subscription at the
524
+ provider. Keep server clocks synchronised (NTP): event ordering compares the
525
+ provider's whole-second event time with this host's read-back time. A
526
+ concurrent duplicate of an event being applied answers
527
+ 409 and Stripe retries it. Plans and which price backs which plan are the
528
+ owner's input; this module only maps them. No schema change is needed: event
529
+ claims use core's `_smrt_run_once_claims`, retained for its pruning window
530
+ (30 days by default), far longer than Stripe's three-day retry horizon.
531
+
532
+ ## Hosted vault-account restart and supersede (#4841)
533
+
534
+ The hosted account layer (the service that holds the vault relay's admin
535
+ token) replaces a family's vault account after a restart. That is a takeover
536
+ of the backup line if done by the wrong person, so every action here is gated:
537
+
538
+ - `supersedeVaultAccount`, `unsupersedeVaultAccount` (relay admin `PUT` and
539
+ `DELETE .../superseded-by`) and `provisionRestartAccount` (a fresh account
540
+ linked to the tenant plus the working device's relay signing key, step 1 of
541
+ the restart in the app-runtime README).
542
+ - Each requires the caller to be the tenant **owner** (`NotOwnerError`, code
543
+ `not_owner`: a member, a device-only or layered session, no session, or an
544
+ account of another tenant) **and** a **step-up**: the owner re-authenticated
545
+ with a fresh password or passkey within `stepUpMaxAgeSeconds` (default 300;
546
+ `StepUpRequiredError`, code `step_up_required`). A signed-in session or an
547
+ unlocked device is not enough. See `smrt-users` "Step-up".
548
+ - The relay is called only after both checks pass, exactly once. Every attempt
549
+ is written to your `audit` sink (`denied` with the reason code, `authorized`
550
+ before the call, then `succeeded` or `failed`); records hold ids, codes and
551
+ times, never a password, session id, token or key. If the `authorized`
552
+ record cannot be written, nothing is done.
553
+ - Collaborators are injected: `session` (from `loadSessionContext`), `relay`
554
+ (`VaultRelayAdminPort`; `createVaultRelayAdminClient({ baseUrl, adminToken })`
555
+ speaks the admin HTTP API, and a `VaultRelay` instance fits in-process),
556
+ `directory` (which tenant owns an account, and linking a new one so
557
+ `backup.vault` resolves), and `audit`.
558
+
353
559
  ## Svelte entry point
354
560
 
355
561
  `@happyvertical/smrt-subscriptions/svelte` exports provider-neutral,
@@ -0,0 +1,52 @@
1
+ import type { DatabaseInterface } from '@happyvertical/sql';
2
+ import type { TenantSubscription } from '../models/TenantSubscription.js';
3
+ import { type SubscriptionSnapshot } from './events.js';
4
+ import type { BillingLogger } from './types.js';
5
+ /** JSON-safe result stored with the idempotency claim and replayed. */
6
+ export interface AppliedOutcome {
7
+ action: 'applied' | 'ignored' | 'needs_reconciliation';
8
+ reason?: string;
9
+ }
10
+ export interface ApplyContext {
11
+ db: DatabaseInterface;
12
+ tenantPlans: 'deny' | 'allow';
13
+ /** PostgreSQL: serialize concurrent events for one subscription or tenant. */
14
+ postgres: boolean;
15
+ prices: Record<string, string> | undefined;
16
+ logger: BillingLogger | undefined;
17
+ }
18
+ interface EventFacts {
19
+ eventId: string;
20
+ eventType: string;
21
+ /** The provider's own event time (whole seconds). */
22
+ eventCreated: Date;
23
+ /** When the snapshot was read, if it was read rather than delivered. */
24
+ observedAt?: Date;
25
+ }
26
+ /**
27
+ * Serialize applies that touch the same provider subscription or tenant on
28
+ * PostgreSQL, where two transactions can otherwise both read the row before
29
+ * either writes and let an older event overwrite a newer one. Transaction-
30
+ * scoped, taken in sorted order. Embedded engines already have one writer.
31
+ */
32
+ export declare function lockBillingKeys(ctx: Pick<ApplyContext, 'db' | 'postgres'>, keys: string[]): Promise<void>;
33
+ /**
34
+ * A row held for an unmapped price still has a live provider subscription
35
+ * (`metadata.stripe.held`): checkout must not sell a second one over it.
36
+ */
37
+ export declare function isHeldSubscription(row: TenantSubscription): boolean;
38
+ /** A `customer.subscription.*` event for an already-linked subscription. */
39
+ export declare function applySubscriptionEvent(ctx: ApplyContext, facts: EventFacts, snapshot: SubscriptionSnapshot): Promise<AppliedOutcome>;
40
+ export interface CheckoutLink {
41
+ tenantId: string;
42
+ planId: string;
43
+ checkoutSessionId: string;
44
+ }
45
+ /**
46
+ * A completed Checkout whose signed metadata names the tenant and plan. The
47
+ * snapshot is the subscription read back from the provider after the event,
48
+ * so the row reflects current state however the events were ordered.
49
+ */
50
+ export declare function applyCheckoutCompleted(ctx: ApplyContext, facts: EventFacts, link: CheckoutLink, snapshot: SubscriptionSnapshot): Promise<AppliedOutcome>;
51
+ export {};
52
+ //# sourceMappingURL=apply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/billing/apply.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AAE1E,OAAO,EAAsB,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAE5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,uEAAuE;AACvE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,sBAAsB,CAAC;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,iBAAiB,CAAC;IACtB,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9B,8EAA8E;IAC9E,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAC3C,MAAM,EAAE,aAAa,GAAG,SAAS,CAAC;CACnC;AAED,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,YAAY,EAAE,IAAI,CAAC;IACnB,wEAAwE;IACxE,UAAU,CAAC,EAAE,IAAI,CAAC;CACnB;AASD;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,UAAU,CAAC,EAC1C,IAAI,EAAE,MAAM,EAAE,GACb,OAAO,CAAC,IAAI,CAAC,CAQf;AAiDD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,kBAAkB,GAAG,OAAO,CASnE;AA6LD,4EAA4E;AAC5E,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,oBAAoB,GAC7B,OAAO,CAAC,cAAc,CAAC,CAyEzB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,YAAY,EAClB,QAAQ,EAAE,oBAAoB,GAC7B,OAAO,CAAC,cAAc,CAAC,CA4EzB"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Binding a completed Checkout to the session the host created (#4814).
3
+ *
4
+ * The signed metadata alone is copyable: another integration on the same
5
+ * Stripe account can put it on its own session and pay first. So the host
6
+ * records each session id when it creates the session, and a paid session
7
+ * links a tenant only when a pending record for exactly that id names the
8
+ * same tenant and the signed expiry has not passed at the event time.
9
+ * Anything else is never linked and never dropped: the paid session is
10
+ * recorded as `needs_reconciliation` for an operator.
11
+ */
12
+ import type { DatabaseInterface } from '@happyvertical/sql';
13
+ import { type BillingCheckoutSession } from '../models/BillingCheckoutSession.js';
14
+ import { type AppliedOutcome, type ApplyContext, applyCheckoutCompleted } from './apply.js';
15
+ import { type SubscriptionSnapshot } from './events.js';
16
+ /** Signed metadata key holding the expiry, in whole epoch seconds. */
17
+ export declare const METADATA_EXPIRY_KEY = "smrt_exp";
18
+ /**
19
+ * Stripe expires a Checkout session after 24 hours; the host allows an hour
20
+ * more. The expiry is checked against the event time, not the delivery time,
21
+ * so a late webhook retry of an in-time payment still completes.
22
+ */
23
+ export declare const CHECKOUT_SESSION_TTL_MS: number;
24
+ export type CheckoutRefusal = 'session_record_missing' | 'session_record_mismatch' | 'session_expired';
25
+ type Verdict = {
26
+ kind: 'bound';
27
+ } | {
28
+ kind: 'replay';
29
+ reason: string;
30
+ } | {
31
+ kind: 'reconcile';
32
+ reason: CheckoutRefusal;
33
+ };
34
+ /** The binding rules, in order. Pure: no I/O. */
35
+ export declare function checkBinding(record: BillingCheckoutSession | null, input: {
36
+ tenantId: string;
37
+ exp: string | undefined;
38
+ eventTime: Date;
39
+ }): Verdict;
40
+ /** Record a session the host just created. Idempotent per session id. */
41
+ export declare function recordCheckoutSession(db: DatabaseInterface, input: {
42
+ sessionId: string;
43
+ tenantId: string;
44
+ expiresAt: Date;
45
+ }): Promise<void>;
46
+ export interface ReconcileContext {
47
+ eventId: string;
48
+ eventCreated: string;
49
+ subscriptionId: string;
50
+ customerId: string;
51
+ planId: string;
52
+ claimedTenantId: string;
53
+ }
54
+ export declare function parseReconcileContext(record: BillingCheckoutSession): ReconcileContext | null;
55
+ export interface BoundCompletion {
56
+ sessionId: string;
57
+ /** From the verified signed metadata. */
58
+ tenantId: string;
59
+ planId: string;
60
+ exp: string | undefined;
61
+ eventId: string;
62
+ eventTime: Date;
63
+ }
64
+ /**
65
+ * Inside the idempotency transaction: bind the paid session to its record,
66
+ * then link the tenant. `ctx.db` is the transaction handle.
67
+ */
68
+ export declare function completeBoundCheckout(ctx: ApplyContext, input: BoundCompletion, facts: Parameters<typeof applyCheckoutCompleted>[1], snapshot: SubscriptionSnapshot): Promise<AppliedOutcome>;
69
+ /** A flagged session and the ids captured when it was flagged. */
70
+ export declare function loadFlaggedCheckout(db: DatabaseInterface, sessionId: string): Promise<{
71
+ record: BillingCheckoutSession;
72
+ flagged: ReconcileContext;
73
+ } | null>;
74
+ /**
75
+ * Operator action, inside one transaction (`ctx.db`): link a flagged paid
76
+ * session to `tenantId` on purpose. `snapshot` is the subscription read back
77
+ * from the provider before the transaction; it must still be the one recorded
78
+ * when the session was flagged. The row is re-read under the lock, so a
79
+ * session is reconciled at most once.
80
+ */
81
+ export declare function reconcileFlaggedCheckout(ctx: ApplyContext, input: {
82
+ sessionId: string;
83
+ tenantId: string;
84
+ planId?: string;
85
+ operatorId: string;
86
+ snapshot: SubscriptionSnapshot;
87
+ }): Promise<AppliedOutcome | null>;
88
+ /**
89
+ * Operator action, inside one transaction (`ctx.db`): close a flagged session
90
+ * without linking it. The row is re-read under the lock, so a session is
91
+ * dismissed or reconciled at most once, whichever comes first.
92
+ */
93
+ export declare function dismissFlaggedCheckout(ctx: ApplyContext, input: {
94
+ sessionId: string;
95
+ operatorId: string;
96
+ reason: string;
97
+ }): Promise<boolean>;
98
+ export {};
99
+ //# sourceMappingURL=checkout-sessions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkout-sessions.d.ts","sourceRoot":"","sources":["../../src/billing/checkout-sessions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EACL,KAAK,sBAAsB,EAE5B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,sBAAsB,EAEvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAsB,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAE5E,sEAAsE;AACtE,eAAO,MAAM,mBAAmB,aAAa,CAAC;AAC9C;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,QAAsB,CAAC;AAE3D,MAAM,MAAM,eAAe,GACvB,wBAAwB,GACxB,yBAAyB,GACzB,iBAAiB,CAAC;AAEtB,KAAK,OAAO,GACR;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC;AAEnD,iDAAiD;AACjD,wBAAgB,YAAY,CAC1B,MAAM,EAAE,sBAAsB,GAAG,IAAI,EACrC,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAE,GACpE,OAAO,CAsBT;AAED,yEAAyE;AACzE,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,iBAAiB,EACrB,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAE,GAC9D,OAAO,CAAC,IAAI,CAAC,CAcf;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,sBAAsB,GAC7B,gBAAgB,GAAG,IAAI,CAqBzB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC,CAAC,CAAC,EACnD,QAAQ,EAAE,oBAAoB,GAC7B,OAAO,CAAC,cAAc,CAAC,CAmEzB;AAED,kEAAkE;AAClE,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,iBAAiB,EACrB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IACT,MAAM,EAAE,sBAAsB,CAAC;IAC/B,OAAO,EAAE,gBAAgB,CAAC;CAC3B,GAAG,IAAI,CAAC,CAMR;AAED;;;;;;GAMG;AACH,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE;IACL,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,oBAAoB,CAAC;CAChC,GACA,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAyChC;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC/D,OAAO,CAAC,OAAO,CAAC,CAelB"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Host configuration surface for the billing glue (#4786).
3
+ *
4
+ * Environment variables, with the secrets held in non-enumerable properties so
5
+ * a stray `JSON.stringify(config)` or `console.log(config)` cannot print them.
6
+ * Error messages name the variable and never its value.
7
+ */
8
+ import type { BillingUrls } from './types.js';
9
+ export declare const BILLING_ENV: {
10
+ readonly secretKey: "STRIPE_SECRET_KEY";
11
+ readonly webhookSecret: "STRIPE_WEBHOOK_SECRET";
12
+ readonly webhookTolerance: "STRIPE_WEBHOOK_TOLERANCE";
13
+ readonly metadataSecret: "BILLING_METADATA_SECRET";
14
+ readonly metadataPreviousSecrets: "BILLING_METADATA_PREVIOUS_SECRETS";
15
+ readonly successUrl: "BILLING_SUCCESS_URL";
16
+ readonly cancelUrl: "BILLING_CANCEL_URL";
17
+ readonly portalReturnUrl: "BILLING_PORTAL_RETURN_URL";
18
+ };
19
+ export declare const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
20
+ export declare const MAX_WEBHOOK_TOLERANCE_SECONDS = 900;
21
+ /** The env var holding the provider price id for a plan key. */
22
+ export declare function priceEnvName(planKey: string): string;
23
+ export interface BillingEnvOptions {
24
+ /** Plan keys whose `STRIPE_PRICE_<KEY>` variables should be read. */
25
+ planKeys: readonly string[];
26
+ }
27
+ export interface BillingEnvConfig {
28
+ /**
29
+ * Provider credentials for `getAccountingProvider({ type: 'stripe', … })`.
30
+ * `secretKey` and `webhookSecret` are non-enumerable (they do not survive
31
+ * a spread, `JSON.stringify` or `console.log`), so read them by name.
32
+ */
33
+ stripe: {
34
+ readonly secretKey: string;
35
+ readonly webhookSecret: string;
36
+ webhookTolerance: number;
37
+ };
38
+ /**
39
+ * Checkout-metadata signing (#4786). `signingSecret` is unset unless
40
+ * `BILLING_METADATA_SECRET` is configured (the host then falls back to the
41
+ * webhook secret). `previousSecrets` (comma-separated
42
+ * `BILLING_METADATA_PREVIOUS_SECRETS`) are still accepted when a completed
43
+ * checkout is verified, so rotating a secret does not drop sessions created
44
+ * before the rotation. Both are non-enumerable.
45
+ */
46
+ metadata: {
47
+ readonly signingSecret: string | undefined;
48
+ readonly previousSecrets: readonly string[];
49
+ };
50
+ urls: BillingUrls;
51
+ /** Plan key to price id for every plan whose variable is set. */
52
+ prices: Record<string, string>;
53
+ }
54
+ type Env = Record<string, string | undefined>;
55
+ /**
56
+ * Validate a redirect destination: absolute, no embedded credentials, and
57
+ * https (plain http only for loopback development hosts).
58
+ */
59
+ export declare function assertBillingUrl(name: string, value: string): string;
60
+ export declare function assertWebhookTolerance(seconds: number): number;
61
+ /** Read and validate billing configuration from an environment map. */
62
+ export declare function readBillingEnv(env: Env, options: BillingEnvOptions): BillingEnvConfig;
63
+ export {};
64
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/billing/config.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,eAAO,MAAM,WAAW;;;;;;;;;CASd,CAAC;AAEX,eAAO,MAAM,iCAAiC,MAAM,CAAC;AACrD,eAAO,MAAM,6BAA6B,MAAM,CAAC;AAEjD,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,MAAM,WAAW,iBAAiB;IAChC,qEAAqE;IACrE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,MAAM,EAAE;QACN,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;QAC/B,gBAAgB,EAAE,MAAM,CAAC;KAC1B,CAAC;IACF;;;;;;;OAOG;IACH,QAAQ,EAAE;QACR,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;QAC3C,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;KAC7C,CAAC;IACF,IAAI,EAAE,WAAW,CAAC;IAClB,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC;AAED,KAAK,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAa9C;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAqBpE;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAW9D;AAED,uEAAuE;AACvE,wBAAgB,cAAc,CAC5B,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,iBAAiB,GACzB,gBAAgB,CAyElB"}