From d0fce824d5e522da1f04755663d69306f101aaf6 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 30 Apr 2026 23:22:23 +0900 Subject: [PATCH 01/81] feat(gql): add webhook event spec for ASN v2 / RTDN normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `webhook.graphql` defining the normalized cross-store lifecycle event surface that kit will emit to clients. This is the foundation for removing the need for users to run their own server: kit ingests Apple ASN v2 and Google RTDN, normalizes them into one shape, and streams them to authenticated clients via a GraphQL Subscription transport. - 15 unified WebhookEventType values covering subscription lifecycle (started/renewed/expired/grace/retry/recovered/canceled/uncanceled/ revoked/price-change/product-changed/paused/resumed) plus refunds, consumption requests, and test notifications. - WebhookEventSource discriminator (ASN v2 vs RTDN) and Environment (Production/Sandbox/Xcode). - WebhookEvent payload with idempotency `id`, occurredAt/receivedAt epoch-ms timestamps, cross-platform `purchaseToken`, optional subscription state and price snapshot, plus `rawSignedPayload` escape hatch. - `Subscription.webhookEvent` for live streaming and `Query.webhookEventsSince` for reconnection backfill. - ASN v2 ↔ RTDN ↔ openiap mapping table in `knowledge/external/webhook-mapping.md` (SSOT for the kit receivers shipping in the next PR). Codegen verified across all 5 target languages; types synced to all 7 SDK files (apple, google, rn-iap, expo-iap, flutter, godot, kmp). Co-Authored-By: Claude Opus 4.7 (1M context) --- knowledge/external/webhook-mapping.md | 79 ++++ libraries/expo-iap/src/types.ts | 84 ++++ .../flutter_inapp_purchase/lib/types.dart | 356 +++++++++++++++ libraries/godot-iap/addons/godot-iap/types.gd | 336 ++++++++++++++ .../io/github/hyochan/kmpiap/openiap/Types.kt | 410 +++++++++++++++++- libraries/react-native-iap/src/types.ts | 84 ++++ packages/apple/Sources/Models/Types.swift | 161 ++++++- .../src/main/java/dev/hyo/openiap/Types.kt | 381 +++++++++++++++- packages/gql/codegen.ts | 1 + packages/gql/codegen/core/parser.ts | 1 + packages/gql/src/generated/Types.kt | 410 +++++++++++++++++- packages/gql/src/generated/Types.swift | 161 ++++++- packages/gql/src/generated/types.dart | 356 +++++++++++++++ packages/gql/src/generated/types.gd | 336 ++++++++++++++ packages/gql/src/generated/types.ts | 84 ++++ packages/gql/src/webhook.graphql | 236 ++++++++++ 16 files changed, 3466 insertions(+), 10 deletions(-) create mode 100644 knowledge/external/webhook-mapping.md create mode 100644 packages/gql/src/webhook.graphql diff --git a/knowledge/external/webhook-mapping.md b/knowledge/external/webhook-mapping.md new file mode 100644 index 00000000..21c40da6 --- /dev/null +++ b/knowledge/external/webhook-mapping.md @@ -0,0 +1,79 @@ +# Webhook Event Mapping (ASN v2 ↔ RTDN ↔ openiap) + +This document is the source of truth for how kit normalizes Apple App Store Server +Notifications v2 (ASN v2) and Google Play Real-Time Developer Notifications (RTDN) +into the unified `WebhookEvent` shape defined in [`packages/gql/src/webhook.graphql`](../../packages/gql/src/webhook.graphql). + +When kit's webhook receivers are implemented (Phase 1, PR #2), they MUST follow +this table. When extending the spec (new event types, new stores), update this +document in the same PR. + +## Subscription lifecycle + +| openiap `WebhookEventType` | Apple ASN v2 `notificationType` (`subtype`) | Google RTDN `subscriptionNotification.notificationType` | +|---|---|---| +| `SubscriptionStarted` | `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | `SUBSCRIPTION_PURCHASED` (1), `SUBSCRIPTION_RECOVERED` (4)¹ | +| `SubscriptionRenewed` | `DID_RENEW` | `SUBSCRIPTION_RENEWED` (2) | +| `SubscriptionExpired` | `EXPIRED` | `SUBSCRIPTION_EXPIRED` (13) | +| `SubscriptionInGracePeriod` | `DID_FAIL_TO_RENEW` (`GRACE_PERIOD`) | `SUBSCRIPTION_IN_GRACE_PERIOD` (6) | +| `SubscriptionInBillingRetry` | `DID_FAIL_TO_RENEW` (no subtype) | `SUBSCRIPTION_ON_HOLD` (5) | +| `SubscriptionRecovered` | `DID_RENEW` (after a prior failure) | `SUBSCRIPTION_RECOVERED` (4)¹, `SUBSCRIPTION_RESTARTED` (7) | +| `SubscriptionCanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_DISABLED`) | `SUBSCRIPTION_CANCELED` (3) | +| `SubscriptionUncanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_ENABLED`) | (no direct equivalent — inferred from `SUBSCRIPTION_RESTARTED` while period still active) | +| `SubscriptionRevoked` | `REVOKE` | `SUBSCRIPTION_REVOKED` (12) | +| `SubscriptionPriceChange` | `PRICE_INCREASE` | `SUBSCRIPTION_PRICE_CHANGE_CONFIRMED` (8) | +| `SubscriptionProductChanged` | `DID_CHANGE_RENEWAL_PREF` | `SUBSCRIPTION_DEFERRED` (9), `SUBSCRIPTION_PRODUCT_CHANGED` (no fixed code) | +| `SubscriptionPaused` | (no equivalent — iOS has no pause) | `SUBSCRIPTION_PAUSED` (10), `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED` (11) | +| `SubscriptionResumed` | (no equivalent) | `SUBSCRIPTION_RECOVERED` (4) following `SUBSCRIPTION_PAUSED` | + +¹ `SUBSCRIPTION_RECOVERED` (RTDN code 4) maps to either `SubscriptionStarted` +(when no prior active period existed) or `SubscriptionRecovered` (when recovering +from grace/hold/pause). kit decides based on the prior state in its +`subscriptions` table. + +## One-time / common + +| openiap `WebhookEventType` | Apple ASN v2 | Google RTDN | +|---|---|---| +| `PurchaseRefunded` | `REFUND` | `oneTimeProductNotification.notificationType = ONE_TIME_PRODUCT_CANCELED` (2), or `voidedPurchaseNotification` | +| `PurchaseConsumptionRequest` | `CONSUMPTION_REQUEST` | (no equivalent — Play handles consumption client-side) | +| `TestNotification` | `TEST` | `testNotification` field present on the RTDN message | + +## Field mapping + +| `WebhookEvent` field | Apple ASN v2 source | Google RTDN source | +|---|---|---| +| `id` | `notificationUUID` | Pub/Sub `messageId` | +| `occurredAt` | `signedDate` | `eventTimeMillis` | +| `environment` | `data.environment` (`Production` \| `Sandbox` \| `Xcode`) | `testNotification` present → `Sandbox`, else `Production` | +| `purchaseToken` | `data.signedTransactionInfo.originalTransactionId` | `subscriptionNotification.purchaseToken` or `oneTimeProductNotification.purchaseToken` | +| `productId` | `data.signedTransactionInfo.productId` | `subscriptionNotification.subscriptionId` or `oneTimeProductNotification.sku` | +| `expiresAt` | `data.signedRenewalInfo.expirationDate` (decoded JWS) | resolved by calling `purchases.subscriptionsv2.get` (ASN/RTDN do not embed it directly) | +| `renewsAt` | `data.signedRenewalInfo.renewalDate` | resolved by calling `purchases.subscriptionsv2.get` | +| `cancellationReason` | `data.signedTransactionInfo.revocationReason` + ASN `subtype` | `purchases.subscriptionsv2.get` → `canceledStateContext.userInitiatedCancellation` / `systemInitiatedCancellation` | +| `currency` | `data.signedTransactionInfo.currency` | from `purchases.subscriptionsv2.get` linked product price | +| `priceAmountMicros` | `data.signedTransactionInfo.price` × 1000 (ASN reports in millicents; convert to micros) | `purchases.subscriptionsv2.get` → `lineItems[*].autoRenewingPlan.recurringPrice.units` | +| `rawSignedPayload` | The complete `signedPayload` JWS string from the ASN body | The base64-decoded Pub/Sub message `data` (JSON) | + +## Validation requirements (kit Phase 1, PR #2) + +Both stores require signature verification before any event is emitted: + +- **Apple ASN v2**: verify the JWS using Apple's public root certificates (refresh + via the App Store Connect API). The receiver must reject unverified payloads + with HTTP 401. +- **Google RTDN**: validate the Pub/Sub push request against the configured + service account audience (OIDC token verification). Reject missing or invalid + tokens with HTTP 401. + +Idempotency: + +- Use `(source, sourceNotificationId)` as the dedup key, where + `sourceNotificationId` is `notificationUUID` for ASN v2 or `messageId` for + RTDN. Convex idempotency table records the first-seen event and silently + acknowledges duplicates with HTTP 200. + +Replay window: + +- Events MUST be retained for at least 30 days so `webhookEventsSince` can + service reconnecting clients. Older events are pruned by a Convex cron job. diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index 48f0103b..cc3408f3 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1406,6 +1406,12 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; + /** + * Replay missed webhook events for the authenticated client since the given + * timestamp. SDKs call this on reconnect / foreground entry to backfill events + * that occurred while the WebSocket was closed. + */ + webhookEventsSince: WebhookEvent[]; } @@ -1434,6 +1440,11 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; +export interface QueryWebhookEventsSinceArgs { + limit?: (number | null); + sinceMs: number; +} + export interface RefundResultIOS { message?: (string | null); status: string; @@ -1751,6 +1762,16 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + webhookEvent: WebhookEvent; } @@ -1896,6 +1917,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2080,65 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + platform: IapPlatform; + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + purchaseToken: string; + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. @@ -2090,6 +2172,7 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; + webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2154,6 +2237,7 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; + webhookEvent: never; }; export type SubscriptionField = diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 1645922e..e9af67a7 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -928,6 +928,232 @@ enum SubscriptionReplacementModeAndroid { String toJson() => value; } +enum SubscriptionState { + Active('active'), + InGracePeriod('in-grace-period'), + InBillingRetry('in-billing-retry'), + Expired('expired'), + Revoked('revoked'), + Refunded('refunded'), + Paused('paused'), + Unknown('unknown'); + + const SubscriptionState(this.value); + final String value; + + factory SubscriptionState.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'active': + return SubscriptionState.Active; + case 'in-grace-period': + return SubscriptionState.InGracePeriod; + case 'in-billing-retry': + return SubscriptionState.InBillingRetry; + case 'expired': + return SubscriptionState.Expired; + case 'revoked': + return SubscriptionState.Revoked; + case 'refunded': + return SubscriptionState.Refunded; + case 'paused': + return SubscriptionState.Paused; + case 'unknown': + return SubscriptionState.Unknown; + } + throw ArgumentError('Unknown SubscriptionState value: $value'); + } + + String toJson() => value; +} + +enum WebhookCancellationReason { + UserCanceled('user-canceled'), + BillingError('billing-error'), + PriceIncreaseDeclined('price-increase-declined'), + ProductUnavailable('product-unavailable'), + Refunded('refunded'), + Other('other'); + + const WebhookCancellationReason(this.value); + final String value; + + factory WebhookCancellationReason.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'user-canceled': + return WebhookCancellationReason.UserCanceled; + case 'billing-error': + return WebhookCancellationReason.BillingError; + case 'price-increase-declined': + return WebhookCancellationReason.PriceIncreaseDeclined; + case 'product-unavailable': + return WebhookCancellationReason.ProductUnavailable; + case 'refunded': + return WebhookCancellationReason.Refunded; + case 'other': + return WebhookCancellationReason.Other; + } + throw ArgumentError('Unknown WebhookCancellationReason value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventEnvironment { + Production('production'), + Sandbox('sandbox'), + Xcode('xcode'); + + const WebhookEventEnvironment(this.value); + final String value; + + factory WebhookEventEnvironment.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'production': + return WebhookEventEnvironment.Production; + case 'sandbox': + return WebhookEventEnvironment.Sandbox; + case 'xcode': + return WebhookEventEnvironment.Xcode; + } + throw ArgumentError('Unknown WebhookEventEnvironment value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2('apple-app-store-server-notifications-v2'), + GooglePlayRealTimeDeveloperNotifications('google-play-real-time-developer-notifications'); + + const WebhookEventSource(this.value); + final String value; + + factory WebhookEventSource.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'apple-app-store-server-notifications-v2': + return WebhookEventSource.AppleAppStoreServerNotificationsV2; + case 'google-play-real-time-developer-notifications': + return WebhookEventSource.GooglePlayRealTimeDeveloperNotifications; + } + throw ArgumentError('Unknown WebhookEventSource value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventType { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + SubscriptionStarted('subscription-started'), + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + SubscriptionRenewed('subscription-renewed'), + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + SubscriptionExpired('subscription-expired'), + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SubscriptionInGracePeriod('subscription-in-grace-period'), + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + SubscriptionInBillingRetry('subscription-in-billing-retry'), + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + SubscriptionRecovered('subscription-recovered'), + /// User turned off auto-renew. Access continues until the current period ends. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + /// Android: SUBSCRIPTION_CANCELED. + SubscriptionCanceled('subscription-canceled'), + /// User reactivated auto-renew before the subscription expired. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + SubscriptionUncanceled('subscription-uncanceled'), + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + SubscriptionRevoked('subscription-revoked'), + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SubscriptionPriceChange('subscription-price-change'), + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SubscriptionProductChanged('subscription-product-changed'), + /// Subscription paused (Android only feature). + /// Android: SUBSCRIPTION_PAUSED. + SubscriptionPaused('subscription-paused'), + /// Paused subscription resumed (Android only feature). + /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + SubscriptionResumed('subscription-resumed'), + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PurchaseRefunded('purchase-refunded'), + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + PurchaseConsumptionRequest('purchase-consumption-request'), + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + TestNotification('test-notification'); + + const WebhookEventType(this.value); + final String value; + + factory WebhookEventType.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'subscription-started': + return WebhookEventType.SubscriptionStarted; + case 'subscription-renewed': + return WebhookEventType.SubscriptionRenewed; + case 'subscription-expired': + return WebhookEventType.SubscriptionExpired; + case 'subscription-in-grace-period': + return WebhookEventType.SubscriptionInGracePeriod; + case 'subscription-in-billing-retry': + return WebhookEventType.SubscriptionInBillingRetry; + case 'subscription-recovered': + return WebhookEventType.SubscriptionRecovered; + case 'subscription-canceled': + return WebhookEventType.SubscriptionCanceled; + case 'subscription-uncanceled': + return WebhookEventType.SubscriptionUncanceled; + case 'subscription-revoked': + return WebhookEventType.SubscriptionRevoked; + case 'subscription-price-change': + return WebhookEventType.SubscriptionPriceChange; + case 'subscription-product-changed': + return WebhookEventType.SubscriptionProductChanged; + case 'subscription-paused': + return WebhookEventType.SubscriptionPaused; + case 'subscription-resumed': + return WebhookEventType.SubscriptionResumed; + case 'purchase-refunded': + return WebhookEventType.PurchaseRefunded; + case 'purchase-consumption-request': + return WebhookEventType.PurchaseConsumptionRequest; + case 'test-notification': + return WebhookEventType.TestNotification; + } + throw ArgumentError('Unknown WebhookEventType value: $value'); + } + + String toJson() => value; +} + // MARK: - Interfaces abstract class ProductCommon { @@ -3689,6 +3915,112 @@ class VerifyPurchaseWithProviderResult { typedef VoidResult = void; +class WebhookEvent { + const WebhookEvent({ + this.cancellationReason, + this.currency, + required this.environment, + this.expiresAt, + required this.id, + required this.occurredAt, + required this.platform, + this.priceAmountMicros, + this.productId, + required this.projectId, + required this.purchaseToken, + this.rawSignedPayload, + required this.receivedAt, + this.renewsAt, + required this.source, + this.subscriptionState, + required this.type, + }); + + /// Reason for cancellation, when applicable. + final WebhookCancellationReason? cancellationReason; + /// Localized currency code (ISO 4217) at event time, when available. + final String? currency; + final WebhookEventEnvironment environment; + /// When the current subscription period ends. Epoch milliseconds. + final double? expiresAt; + /// Stable identifier suitable for idempotency. Derived from the source notification + /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + /// otherwise hashed from the canonicalized payload. + final String id; + /// Time the underlying event occurred at the store. Epoch milliseconds. + final double occurredAt; + final IapPlatform platform; + /// Price in micros (1/1,000,000 of the currency unit) at event time, when available. + /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + final double? priceAmountMicros; + /// Product the event pertains to. May be null for account-level events. + final String? productId; + /// kit project that owns the subscription / purchase this event refers to. + final String projectId; + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + final String purchaseToken; + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + final String? rawSignedPayload; + /// Time kit ingested and normalized this event. Epoch milliseconds. + final double receivedAt; + /// When auto-renewal will charge again. Epoch milliseconds. + final double? renewsAt; + final WebhookEventSource source; + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + final SubscriptionState? subscriptionState; + final WebhookEventType type; + + factory WebhookEvent.fromJson(Map json) { + return WebhookEvent( + cancellationReason: json['cancellationReason'] != null ? WebhookCancellationReason.fromJson(json['cancellationReason'] as String) : null, + currency: json['currency'] as String?, + environment: WebhookEventEnvironment.fromJson(json['environment'] as String), + expiresAt: (json['expiresAt'] as num?)?.toDouble(), + id: json['id'] as String, + occurredAt: (json['occurredAt'] as num).toDouble(), + platform: IapPlatform.fromJson(json['platform'] as String), + priceAmountMicros: (json['priceAmountMicros'] as num?)?.toDouble(), + productId: json['productId'] as String?, + projectId: json['projectId'] as String, + purchaseToken: json['purchaseToken'] as String, + rawSignedPayload: json['rawSignedPayload'] as String?, + receivedAt: (json['receivedAt'] as num).toDouble(), + renewsAt: (json['renewsAt'] as num?)?.toDouble(), + source: WebhookEventSource.fromJson(json['source'] as String), + subscriptionState: json['subscriptionState'] != null ? SubscriptionState.fromJson(json['subscriptionState'] as String) : null, + type: WebhookEventType.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'WebhookEvent', + 'cancellationReason': cancellationReason?.toJson(), + 'currency': currency, + 'environment': environment.toJson(), + 'expiresAt': expiresAt, + 'id': id, + 'occurredAt': occurredAt, + 'platform': platform.toJson(), + 'priceAmountMicros': priceAmountMicros, + 'productId': productId, + 'projectId': projectId, + 'purchaseToken': purchaseToken, + 'rawSignedPayload': rawSignedPayload, + 'receivedAt': receivedAt, + 'renewsAt': renewsAt, + 'source': source.toJson(), + 'subscriptionState': subscriptionState?.toJson(), + 'type': type.toJson(), + }; + } +} + // MARK: - Input Objects class AndroidSubscriptionOfferInput { @@ -5074,6 +5406,13 @@ abstract class QueryResolver { VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); + /// Replay missed webhook events for the authenticated client since the given + /// timestamp. SDKs call this on reconnect / foreground entry to backfill events + /// that occurred while the WebSocket was closed. + Future> webhookEventsSince({ + required double sinceMs, + int? limit, + }); } /// GraphQL root subscription operations. @@ -5105,6 +5444,14 @@ abstract class SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing Future userChoiceBillingAndroid(); + /// Streams normalized webhook events tied to the authenticated client's purchases. + /// Clients only receive events whose `purchaseToken` matches a purchase they own. + /// + /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + /// enters foreground and disconnect when it goes to background. Events that fire + /// while the connection is closed are reconciled via `webhookEventsSince` on + /// reconnect or the next foreground entry. + Future webhookEvent(); } // MARK: - Root Operation Helpers @@ -5255,6 +5602,10 @@ typedef QueryValidateReceiptIOSHandler = Future Functio VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); +typedef QueryWebhookEventsSinceHandler = Future> Function({ + required double sinceMs, + int? limit, +}); class QueryHandlers { const QueryHandlers({ @@ -5279,6 +5630,7 @@ class QueryHandlers { this.latestTransactionIOS, this.subscriptionStatusIOS, this.validateReceiptIOS, + this.webhookEventsSince, }); final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; @@ -5302,6 +5654,7 @@ class QueryHandlers { final QueryLatestTransactionIOSHandler? latestTransactionIOS; final QuerySubscriptionStatusIOSHandler? subscriptionStatusIOS; final QueryValidateReceiptIOSHandler? validateReceiptIOS; + final QueryWebhookEventsSinceHandler? webhookEventsSince; } // MARK: - Subscription Helpers @@ -5312,6 +5665,7 @@ typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function(); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); +typedef SubscriptionWebhookEventHandler = Future Function(); class SubscriptionHandlers { const SubscriptionHandlers({ @@ -5321,6 +5675,7 @@ class SubscriptionHandlers { this.purchaseUpdated, this.subscriptionBillingIssue, this.userChoiceBillingAndroid, + this.webhookEvent, }); final SubscriptionDeveloperProvidedBillingAndroidHandler? developerProvidedBillingAndroid; @@ -5329,4 +5684,5 @@ class SubscriptionHandlers { final SubscriptionPurchaseUpdatedHandler? purchaseUpdated; final SubscriptionSubscriptionBillingIssueHandler? subscriptionBillingIssue; final SubscriptionUserChoiceBillingAndroidHandler? userChoiceBillingAndroid; + final SubscriptionWebhookEventHandler? webhookEvent; } diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index bc96928f..5e9c3ed3 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -295,6 +295,72 @@ enum SubscriptionReplacementModeAndroid { KEEP_EXISTING = 6, } +enum SubscriptionState { + ACTIVE = 0, + IN_GRACE_PERIOD = 1, + IN_BILLING_RETRY = 2, + EXPIRED = 3, + REVOKED = 4, + REFUNDED = 5, + PAUSED = 6, + UNKNOWN = 7, +} + +enum WebhookCancellationReason { + USER_CANCELED = 0, + BILLING_ERROR = 1, + PRICE_INCREASE_DECLINED = 2, + PRODUCT_UNAVAILABLE = 3, + REFUNDED = 4, + OTHER = 5, +} + +enum WebhookEventEnvironment { + PRODUCTION = 0, + SANDBOX = 1, + XCODE = 2, +} + +enum WebhookEventSource { + APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2 = 0, + GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS = 1, +} + +enum WebhookEventType { + ## Initial purchase or first conversion from a free trial / intro offer. iOS: SUBSCRIBED (initialBuy / resubscribe). Android: SUBSCRIPTION_PURCHASED. + SUBSCRIPTION_STARTED = 0, + ## Auto-renewal succeeded for an existing subscription. iOS: DID_RENEW. Android: SUBSCRIPTION_RENEWED. + SUBSCRIPTION_RENEWED = 1, + ## Subscription reached its expiration without a successful renewal. iOS: EXPIRED. Android: SUBSCRIPTION_EXPIRED. + SUBSCRIPTION_EXPIRED = 2, + ## Billing failed; the subscription is in a grace period during which the user retains entitlement while payment is retried. iOS: DID_FAIL_TO_RENEW (with grace period active). Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SUBSCRIPTION_IN_GRACE_PERIOD = 3, + ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD. + SUBSCRIPTION_IN_BILLING_RETRY = 4, + ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + SUBSCRIPTION_RECOVERED = 5, + ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED. + SUBSCRIPTION_CANCELED = 6, + ## User reactivated auto-renew before the subscription expired. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + SUBSCRIPTION_UNCANCELED = 7, + ## Access immediately revoked (family sharing removal, admin action, fraud). iOS: REVOKE. Android: SUBSCRIPTION_REVOKED. + SUBSCRIPTION_REVOKED = 8, + ## A price change is pending or has been confirmed by the user. iOS: PRICE_INCREASE. Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SUBSCRIPTION_PRICE_CHANGE = 9, + ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SUBSCRIPTION_PRODUCT_CHANGED = 10, + ## Subscription paused (Android only feature). Android: SUBSCRIPTION_PAUSED. + SUBSCRIPTION_PAUSED = 11, + ## Paused subscription resumed (Android only feature). Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + SUBSCRIPTION_RESUMED = 12, + ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PURCHASE_REFUNDED = 13, + ## iOS-only: App Store requests a consumption status report for a refund decision. Servers should respond via the StoreKit consumption API. + PURCHASE_CONSUMPTION_REQUEST = 14, + ## Sandbox or test notification fired by the store for diagnostic purposes. Useful for verifying webhook plumbing without a live transaction. + TEST_NOTIFICATION = 15, +} + # ============================================================================ # Types # ============================================================================ @@ -3238,6 +3304,145 @@ class VoidResult: dict["success"] = success return dict +class WebhookEvent: + ## Stable identifier suitable for idempotency. Derived from the source notification + var id: String = "" + var type: WebhookEventType + var source: WebhookEventSource + var platform: IapPlatform + ## kit project that owns the subscription / purchase this event refers to. + var project_id: String = "" + ## Time the underlying event occurred at the store. Epoch milliseconds. + var occurred_at: float = 0.0 + ## Time kit ingested and normalized this event. Epoch milliseconds. + var received_at: float = 0.0 + var environment: WebhookEventEnvironment + ## Cross-platform purchase identity used to correlate this event with an existing + var purchase_token: String = "" + ## Product the event pertains to. May be null for account-level events. + var product_id: Variant = null + ## Normalized subscription state at the time of event, when the event refers to + var subscription_state: SubscriptionState + ## When the current subscription period ends. Epoch milliseconds. + var expires_at: Variant = null + ## When auto-renewal will charge again. Epoch milliseconds. + var renews_at: Variant = null + ## Reason for cancellation, when applicable. + var cancellation_reason: WebhookCancellationReason + ## Localized currency code (ISO 4217) at event time, when available. + var currency: Variant = null + ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. + var price_amount_micros: Variant = null + ## Original signed payload from the store. ASN v2 events expose the JWS string; + var raw_signed_payload: Variant = null + + static func from_dict(data: Dictionary) -> WebhookEvent: + var obj = WebhookEvent.new() + if data.has("id") and data["id"] != null: + obj.id = data["id"] + if data.has("type") and data["type"] != null: + var enum_str = data["type"] + if enum_str is String and WEBHOOK_EVENT_TYPE_FROM_STRING.has(enum_str): + obj.type = WEBHOOK_EVENT_TYPE_FROM_STRING[enum_str] + else: + obj.type = enum_str + if data.has("source") and data["source"] != null: + var enum_str = data["source"] + if enum_str is String and WEBHOOK_EVENT_SOURCE_FROM_STRING.has(enum_str): + obj.source = WEBHOOK_EVENT_SOURCE_FROM_STRING[enum_str] + else: + obj.source = enum_str + if data.has("platform") and data["platform"] != null: + var enum_str = data["platform"] + if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str): + obj.platform = IAP_PLATFORM_FROM_STRING[enum_str] + else: + obj.platform = enum_str + if data.has("projectId") and data["projectId"] != null: + obj.project_id = data["projectId"] + if data.has("occurredAt") and data["occurredAt"] != null: + obj.occurred_at = data["occurredAt"] + if data.has("receivedAt") and data["receivedAt"] != null: + obj.received_at = data["receivedAt"] + if data.has("environment") and data["environment"] != null: + var enum_str = data["environment"] + if enum_str is String and WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING.has(enum_str): + obj.environment = WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING[enum_str] + else: + obj.environment = enum_str + if data.has("purchaseToken") and data["purchaseToken"] != null: + obj.purchase_token = data["purchaseToken"] + if data.has("productId") and data["productId"] != null: + obj.product_id = data["productId"] + if data.has("subscriptionState") and data["subscriptionState"] != null: + var enum_str = data["subscriptionState"] + if enum_str is String and SUBSCRIPTION_STATE_FROM_STRING.has(enum_str): + obj.subscription_state = SUBSCRIPTION_STATE_FROM_STRING[enum_str] + else: + obj.subscription_state = enum_str + if data.has("expiresAt") and data["expiresAt"] != null: + obj.expires_at = data["expiresAt"] + if data.has("renewsAt") and data["renewsAt"] != null: + obj.renews_at = data["renewsAt"] + if data.has("cancellationReason") and data["cancellationReason"] != null: + var enum_str = data["cancellationReason"] + if enum_str is String and WEBHOOK_CANCELLATION_REASON_FROM_STRING.has(enum_str): + obj.cancellation_reason = WEBHOOK_CANCELLATION_REASON_FROM_STRING[enum_str] + else: + obj.cancellation_reason = enum_str + if data.has("currency") and data["currency"] != null: + obj.currency = data["currency"] + if data.has("priceAmountMicros") and data["priceAmountMicros"] != null: + obj.price_amount_micros = data["priceAmountMicros"] + if data.has("rawSignedPayload") and data["rawSignedPayload"] != null: + obj.raw_signed_payload = data["rawSignedPayload"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["id"] = id + if WEBHOOK_EVENT_TYPE_VALUES.has(type): + dict["type"] = WEBHOOK_EVENT_TYPE_VALUES[type] + else: + dict["type"] = type + if WEBHOOK_EVENT_SOURCE_VALUES.has(source): + dict["source"] = WEBHOOK_EVENT_SOURCE_VALUES[source] + else: + dict["source"] = source + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + dict["projectId"] = project_id + dict["occurredAt"] = occurred_at + dict["receivedAt"] = received_at + if WEBHOOK_EVENT_ENVIRONMENT_VALUES.has(environment): + dict["environment"] = WEBHOOK_EVENT_ENVIRONMENT_VALUES[environment] + else: + dict["environment"] = environment + dict["purchaseToken"] = purchase_token + if product_id != null: + dict["productId"] = product_id + if SUBSCRIPTION_STATE_VALUES.has(subscription_state): + dict["subscriptionState"] = SUBSCRIPTION_STATE_VALUES[subscription_state] + else: + dict["subscriptionState"] = subscription_state + if expires_at != null: + dict["expiresAt"] = expires_at + if renews_at != null: + dict["renewsAt"] = renews_at + if WEBHOOK_CANCELLATION_REASON_VALUES.has(cancellation_reason): + dict["cancellationReason"] = WEBHOOK_CANCELLATION_REASON_VALUES[cancellation_reason] + else: + dict["cancellationReason"] = cancellation_reason + if currency != null: + dict["currency"] = currency + if price_amount_micros != null: + dict["priceAmountMicros"] = price_amount_micros + if raw_signed_payload != null: + dict["rawSignedPayload"] = raw_signed_payload + return dict + # ============================================================================ # Input Types # ============================================================================ @@ -4569,6 +4774,56 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_VALUES = { SubscriptionReplacementModeAndroid.KEEP_EXISTING: "keep-existing" } +const SUBSCRIPTION_STATE_VALUES = { + SubscriptionState.ACTIVE: "active", + SubscriptionState.IN_GRACE_PERIOD: "in-grace-period", + SubscriptionState.IN_BILLING_RETRY: "in-billing-retry", + SubscriptionState.EXPIRED: "expired", + SubscriptionState.REVOKED: "revoked", + SubscriptionState.REFUNDED: "refunded", + SubscriptionState.PAUSED: "paused", + SubscriptionState.UNKNOWN: "unknown" +} + +const WEBHOOK_CANCELLATION_REASON_VALUES = { + WebhookCancellationReason.USER_CANCELED: "user-canceled", + WebhookCancellationReason.BILLING_ERROR: "billing-error", + WebhookCancellationReason.PRICE_INCREASE_DECLINED: "price-increase-declined", + WebhookCancellationReason.PRODUCT_UNAVAILABLE: "product-unavailable", + WebhookCancellationReason.REFUNDED: "refunded", + WebhookCancellationReason.OTHER: "other" +} + +const WEBHOOK_EVENT_ENVIRONMENT_VALUES = { + WebhookEventEnvironment.PRODUCTION: "production", + WebhookEventEnvironment.SANDBOX: "sandbox", + WebhookEventEnvironment.XCODE: "xcode" +} + +const WEBHOOK_EVENT_SOURCE_VALUES = { + WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2: "apple-app-store-server-notifications-v2", + WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS: "google-play-real-time-developer-notifications" +} + +const WEBHOOK_EVENT_TYPE_VALUES = { + WebhookEventType.SUBSCRIPTION_STARTED: "subscription-started", + WebhookEventType.SUBSCRIPTION_RENEWED: "subscription-renewed", + WebhookEventType.SUBSCRIPTION_EXPIRED: "subscription-expired", + WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD: "subscription-in-grace-period", + WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY: "subscription-in-billing-retry", + WebhookEventType.SUBSCRIPTION_RECOVERED: "subscription-recovered", + WebhookEventType.SUBSCRIPTION_CANCELED: "subscription-canceled", + WebhookEventType.SUBSCRIPTION_UNCANCELED: "subscription-uncanceled", + WebhookEventType.SUBSCRIPTION_REVOKED: "subscription-revoked", + WebhookEventType.SUBSCRIPTION_PRICE_CHANGE: "subscription-price-change", + WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED: "subscription-product-changed", + WebhookEventType.SUBSCRIPTION_PAUSED: "subscription-paused", + WebhookEventType.SUBSCRIPTION_RESUMED: "subscription-resumed", + WebhookEventType.PURCHASE_REFUNDED: "purchase-refunded", + WebhookEventType.PURCHASE_CONSUMPTION_REQUEST: "purchase-consumption-request", + WebhookEventType.TEST_NOTIFICATION: "test-notification" +} + # ============================================================================ # Enum Reverse Lookup (string -> enum for deserialization) # ============================================================================ @@ -4787,6 +5042,56 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_FROM_STRING = { "keep-existing": SubscriptionReplacementModeAndroid.KEEP_EXISTING } +const SUBSCRIPTION_STATE_FROM_STRING = { + "active": SubscriptionState.ACTIVE, + "in-grace-period": SubscriptionState.IN_GRACE_PERIOD, + "in-billing-retry": SubscriptionState.IN_BILLING_RETRY, + "expired": SubscriptionState.EXPIRED, + "revoked": SubscriptionState.REVOKED, + "refunded": SubscriptionState.REFUNDED, + "paused": SubscriptionState.PAUSED, + "unknown": SubscriptionState.UNKNOWN +} + +const WEBHOOK_CANCELLATION_REASON_FROM_STRING = { + "user-canceled": WebhookCancellationReason.USER_CANCELED, + "billing-error": WebhookCancellationReason.BILLING_ERROR, + "price-increase-declined": WebhookCancellationReason.PRICE_INCREASE_DECLINED, + "product-unavailable": WebhookCancellationReason.PRODUCT_UNAVAILABLE, + "refunded": WebhookCancellationReason.REFUNDED, + "other": WebhookCancellationReason.OTHER +} + +const WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING = { + "production": WebhookEventEnvironment.PRODUCTION, + "sandbox": WebhookEventEnvironment.SANDBOX, + "xcode": WebhookEventEnvironment.XCODE +} + +const WEBHOOK_EVENT_SOURCE_FROM_STRING = { + "apple-app-store-server-notifications-v2": WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2, + "google-play-real-time-developer-notifications": WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS +} + +const WEBHOOK_EVENT_TYPE_FROM_STRING = { + "subscription-started": WebhookEventType.SUBSCRIPTION_STARTED, + "subscription-renewed": WebhookEventType.SUBSCRIPTION_RENEWED, + "subscription-expired": WebhookEventType.SUBSCRIPTION_EXPIRED, + "subscription-in-grace-period": WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD, + "subscription-in-billing-retry": WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY, + "subscription-recovered": WebhookEventType.SUBSCRIPTION_RECOVERED, + "subscription-canceled": WebhookEventType.SUBSCRIPTION_CANCELED, + "subscription-uncanceled": WebhookEventType.SUBSCRIPTION_UNCANCELED, + "subscription-revoked": WebhookEventType.SUBSCRIPTION_REVOKED, + "subscription-price-change": WebhookEventType.SUBSCRIPTION_PRICE_CHANGE, + "subscription-product-changed": WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED, + "subscription-paused": WebhookEventType.SUBSCRIPTION_PAUSED, + "subscription-resumed": WebhookEventType.SUBSCRIPTION_RESUMED, + "purchase-refunded": WebhookEventType.PURCHASE_REFUNDED, + "purchase-consumption-request": WebhookEventType.PURCHASE_CONSUMPTION_REQUEST, + "test-notification": WebhookEventType.TEST_NOTIFICATION +} + # ============================================================================ # Query Types # ============================================================================ @@ -5129,6 +5434,30 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false + ## Replay missed webhook events for the authenticated client since the given + class webhookEventsSinceField: + const name = "webhookEventsSince" + const snake_name = "webhook_events_since" + class Args: + var since_ms: float + var limit: int + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("sinceMs") and data["sinceMs"] != null: + obj.since_ms = data["sinceMs"] + if data.has("limit") and data["limit"] != null: + obj.limit = data["limit"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["sinceMs"] = since_ms + dict["limit"] = limit + return dict + const return_type = "WebhookEvent" + const is_array = true + # ============================================================================ # Mutation Types @@ -5696,6 +6025,13 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args +## Replay missed webhook events for the authenticated client since the given +static func webhook_events_since_args(since_ms: float, limit: int) -> Dictionary: + var args = {} + args["sinceMs"] = since_ms + args["limit"] = limit + return args + # Mutation API helpers ## Initialize the store connection. Call before any IAP API. diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 2c749a69..a01bb837 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -1053,6 +1053,279 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown"); + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "ACTIVE" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "IN_GRACE_PERIOD" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "IN_BILLING_RETRY" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "EXPIRED" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "REVOKED" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "REFUNDED" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "PAUSED" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "UNKNOWN" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other"); + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode"); + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "PRODUCTION" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "SANDBOX" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "XCODE" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"); + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * Subscription returned to active state after a billing issue or pause. + * iOS: DID_RECOVER. + * Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + */ + SubscriptionRecovered("subscription-recovered"), + /** + * User turned off auto-renew. Access continues until the current period ends. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + * Android: SUBSCRIPTION_CANCELED. + */ + SubscriptionCanceled("subscription-canceled"), + /** + * User reactivated auto-renew before the subscription expired. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + * Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + */ + SubscriptionUncanceled("subscription-uncanceled"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). + * Android: SUBSCRIPTION_PAUSED. + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). + * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification"); + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SUBSCRIPTION_STARTED" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SUBSCRIPTION_RENEWED" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SUBSCRIPTION_EXPIRED" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SUBSCRIPTION_IN_GRACE_PERIOD" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SUBSCRIPTION_IN_BILLING_RETRY" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SUBSCRIPTION_RECOVERED" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SUBSCRIPTION_CANCELED" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SUBSCRIPTION_UNCANCELED" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SUBSCRIPTION_REVOKED" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SUBSCRIPTION_PRICE_CHANGE" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SUBSCRIPTION_PRODUCT_CHANGED" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SUBSCRIPTION_PAUSED" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SUBSCRIPTION_RESUMED" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PURCHASE_REFUNDED" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PURCHASE_CONSUMPTION_REQUEST" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TEST_NOTIFICATION" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3678,6 +3951,119 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val platform: IapPlatform, + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + val purchaseToken: String, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): WebhookEvent { + return WebhookEvent( + cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) }, + currency = json["currency"] as? String, + environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production, + expiresAt = (json["expiresAt"] as? Number)?.toDouble(), + id = json["id"] as? String ?: "", + occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0, + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, + priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(), + productId = json["productId"] as? String, + projectId = json["projectId"] as? String ?: "", + purchaseToken = json["purchaseToken"] as? String ?: "", + rawSignedPayload = json["rawSignedPayload"] as? String, + receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, + renewsAt = (json["renewsAt"] as? Number)?.toDouble(), + source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, + subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( @@ -5122,6 +5508,12 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS + /** + * Replay missed webhook events for the authenticated client since the given + * timestamp. SDKs call this on reconnect / foreground entry to backfill events + * that occurred while the WebSocket was closed. + */ + suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5167,6 +5559,16 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5252,6 +5654,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5274,7 +5677,8 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, + val webhookEventsSince: QueryWebhookEventsSinceHandler? = null ) // MARK: - Subscription Helpers @@ -5285,6 +5689,7 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5292,5 +5697,6 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, + val webhookEvent: SubscriptionWebhookEventHandler? = null ) diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index 48f0103b..cc3408f3 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1406,6 +1406,12 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; + /** + * Replay missed webhook events for the authenticated client since the given + * timestamp. SDKs call this on reconnect / foreground entry to backfill events + * that occurred while the WebSocket was closed. + */ + webhookEventsSince: WebhookEvent[]; } @@ -1434,6 +1440,11 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; +export interface QueryWebhookEventsSinceArgs { + limit?: (number | null); + sinceMs: number; +} + export interface RefundResultIOS { message?: (string | null); status: string; @@ -1751,6 +1762,16 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + webhookEvent: WebhookEvent; } @@ -1896,6 +1917,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2080,65 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + platform: IapPlatform; + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + purchaseToken: string; + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. @@ -2090,6 +2172,7 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; + webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2154,6 +2237,7 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; + webhookEvent: never; }; export type SubscriptionField = diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index efacb85c..39962124 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -424,6 +424,102 @@ public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable { case keepExisting = "keep-existing" } +public enum SubscriptionState: String, Codable, CaseIterable { + case active = "active" + case inGracePeriod = "in-grace-period" + case inBillingRetry = "in-billing-retry" + case expired = "expired" + case revoked = "revoked" + case refunded = "refunded" + case paused = "paused" + case unknown = "unknown" +} + +public enum WebhookCancellationReason: String, Codable, CaseIterable { + case userCanceled = "user-canceled" + case billingError = "billing-error" + case priceIncreaseDeclined = "price-increase-declined" + case productUnavailable = "product-unavailable" + case refunded = "refunded" + case other = "other" +} + +public enum WebhookEventEnvironment: String, Codable, CaseIterable { + case production = "production" + case sandbox = "sandbox" + case xcode = "xcode" +} + +public enum WebhookEventSource: String, Codable, CaseIterable { + case appleAppStoreServerNotificationsV2 = "apple-app-store-server-notifications-v2" + case googlePlayRealTimeDeveloperNotifications = "google-play-real-time-developer-notifications" +} + +public enum WebhookEventType: String, Codable, CaseIterable { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + case subscriptionStarted = "subscription-started" + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + case subscriptionRenewed = "subscription-renewed" + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + case subscriptionExpired = "subscription-expired" + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + case subscriptionInGracePeriod = "subscription-in-grace-period" + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + case subscriptionInBillingRetry = "subscription-in-billing-retry" + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + case subscriptionRecovered = "subscription-recovered" + /// User turned off auto-renew. Access continues until the current period ends. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + /// Android: SUBSCRIPTION_CANCELED. + case subscriptionCanceled = "subscription-canceled" + /// User reactivated auto-renew before the subscription expired. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + case subscriptionUncanceled = "subscription-uncanceled" + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + case subscriptionRevoked = "subscription-revoked" + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + case subscriptionPriceChange = "subscription-price-change" + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + case subscriptionProductChanged = "subscription-product-changed" + /// Subscription paused (Android only feature). + /// Android: SUBSCRIPTION_PAUSED. + case subscriptionPaused = "subscription-paused" + /// Paused subscription resumed (Android only feature). + /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + case subscriptionResumed = "subscription-resumed" + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + case purchaseRefunded = "purchase-refunded" + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + case purchaseConsumptionRequest = "purchase-consumption-request" + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + case testNotification = "test-notification" +} + // MARK: - Interfaces public protocol ProductCommon: Codable { @@ -1320,6 +1416,47 @@ public struct VerifyPurchaseWithProviderResult: Codable { public typealias VoidResult = Void +public struct WebhookEvent: Codable { + /// Reason for cancellation, when applicable. + public var cancellationReason: WebhookCancellationReason? = nil + /// Localized currency code (ISO 4217) at event time, when available. + public var currency: String? = nil + public var environment: WebhookEventEnvironment + /// When the current subscription period ends. Epoch milliseconds. + public var expiresAt: Double? = nil + /// Stable identifier suitable for idempotency. Derived from the source notification + /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + /// otherwise hashed from the canonicalized payload. + public var id: String + /// Time the underlying event occurred at the store. Epoch milliseconds. + public var occurredAt: Double + public var platform: IapPlatform + /// Price in micros (1/1,000,000 of the currency unit) at event time, when available. + /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + public var priceAmountMicros: Double? = nil + /// Product the event pertains to. May be null for account-level events. + public var productId: String? = nil + /// kit project that owns the subscription / purchase this event refers to. + public var projectId: String + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + public var purchaseToken: String + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + public var rawSignedPayload: String? = nil + /// Time kit ingested and normalized this event. Epoch milliseconds. + public var receivedAt: Double + /// When auto-renewal will charge again. Epoch milliseconds. + public var renewsAt: Double? = nil + public var source: WebhookEventSource + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + public var subscriptionState: SubscriptionState? = nil + public var type: WebhookEventType +} + // MARK: - Input Objects public struct AndroidSubscriptionOfferInput: Codable { @@ -2526,6 +2663,10 @@ public protocol QueryResolver { /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS + /// Replay missed webhook events for the authenticated client since the given + /// timestamp. SDKs call this on reconnect / foreground entry to backfill events + /// that occurred while the WebSocket was closed. + func webhookEventsSince(sinceMs: Double, limit: Int?) async throws -> [WebhookEvent] } /// GraphQL root subscription operations. @@ -2557,6 +2698,14 @@ public protocol SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails + /// Streams normalized webhook events tied to the authenticated client's purchases. + /// Clients only receive events whose `purchaseToken` matches a purchase they own. + /// + /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + /// enters foreground and disconnect when it goes to background. Events that fire + /// while the connection is closed are reconciled via `webhookEventsSince` on + /// reconnect or the next foreground entry. + func webhookEvent() async throws -> WebhookEvent } // MARK: - Root Operation Helpers @@ -2698,6 +2847,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = (_ sku: String) async th public typealias QueryLatestTransactionIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throws -> [SubscriptionStatusIOS] public typealias QueryValidateReceiptIOSHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = (_ sinceMs: Double, _ limit: Int?) async throws -> [WebhookEvent] public struct QueryHandlers { public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? @@ -2721,6 +2871,7 @@ public struct QueryHandlers { public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? public var validateReceiptIOS: QueryValidateReceiptIOSHandler? + public var webhookEventsSince: QueryWebhookEventsSinceHandler? public init( canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil, @@ -2743,7 +2894,8 @@ public struct QueryHandlers { isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = nil, latestTransactionIOS: QueryLatestTransactionIOSHandler? = nil, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil, - validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil + validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil, + webhookEventsSince: QueryWebhookEventsSinceHandler? = nil ) { self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS self.currentEntitlementIOS = currentEntitlementIOS @@ -2766,6 +2918,7 @@ public struct QueryHandlers { self.latestTransactionIOS = latestTransactionIOS self.subscriptionStatusIOS = subscriptionStatusIOS self.validateReceiptIOS = validateReceiptIOS + self.webhookEventsSince = webhookEventsSince } } @@ -2777,6 +2930,7 @@ public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseE public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = () async throws -> WebhookEvent public struct SubscriptionHandlers { public var developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? @@ -2785,6 +2939,7 @@ public struct SubscriptionHandlers { public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? public var subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? + public var webhookEvent: SubscriptionWebhookEventHandler? public init( developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = nil, @@ -2792,7 +2947,8 @@ public struct SubscriptionHandlers { purchaseError: SubscriptionPurchaseErrorHandler? = nil, purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = nil, - userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil + userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil, + webhookEvent: SubscriptionWebhookEventHandler? = nil ) { self.developerProvidedBillingAndroid = developerProvidedBillingAndroid self.promotedProductIOS = promotedProductIOS @@ -2800,5 +2956,6 @@ public struct SubscriptionHandlers { self.purchaseUpdated = purchaseUpdated self.subscriptionBillingIssue = subscriptionBillingIssue self.userChoiceBillingAndroid = userChoiceBillingAndroid + self.webhookEvent = webhookEvent } } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 9697826f..4a434163 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -962,6 +962,250 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown"); + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other"); + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode"); + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications"); + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * Subscription returned to active state after a billing issue or pause. + * iOS: DID_RECOVER. + * Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + */ + SubscriptionRecovered("subscription-recovered"), + /** + * User turned off auto-renew. Access continues until the current period ends. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + * Android: SUBSCRIPTION_CANCELED. + */ + SubscriptionCanceled("subscription-canceled"), + /** + * User reactivated auto-renew before the subscription expired. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + * Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + */ + SubscriptionUncanceled("subscription-uncanceled"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). + * Android: SUBSCRIPTION_PAUSED. + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). + * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification"); + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3587,6 +3831,119 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val platform: IapPlatform, + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + val purchaseToken: String, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): WebhookEvent { + return WebhookEvent( + cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) }, + currency = json["currency"] as? String, + environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production, + expiresAt = (json["expiresAt"] as? Number)?.toDouble(), + id = json["id"] as? String ?: "", + occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0, + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, + priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(), + productId = json["productId"] as? String, + projectId = json["projectId"] as? String ?: "", + purchaseToken = json["purchaseToken"] as? String ?: "", + rawSignedPayload = json["rawSignedPayload"] as? String, + receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, + renewsAt = (json["renewsAt"] as? Number)?.toDouble(), + source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, + subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( @@ -5031,6 +5388,12 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS + /** + * Replay missed webhook events for the authenticated client since the given + * timestamp. SDKs call this on reconnect / foreground entry to backfill events + * that occurred while the WebSocket was closed. + */ + suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5076,6 +5439,16 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5161,6 +5534,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5183,7 +5557,8 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, + val webhookEventsSince: QueryWebhookEventsSinceHandler? = null ) // MARK: - Subscription Helpers @@ -5194,6 +5569,7 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5201,5 +5577,6 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, + val webhookEvent: SubscriptionWebhookEventHandler? = null ) diff --git a/packages/gql/codegen.ts b/packages/gql/codegen.ts index 9ec107cf..6bc8cb7e 100644 --- a/packages/gql/codegen.ts +++ b/packages/gql/codegen.ts @@ -11,6 +11,7 @@ const config: CodegenConfig = { 'src/api-android.graphql', 'src/error.graphql', 'src/event.graphql', + 'src/webhook.graphql', ], generates: { 'src/generated/types.ts': { diff --git a/packages/gql/codegen/core/parser.ts b/packages/gql/codegen/core/parser.ts index 86e9a5ec..48c64433 100644 --- a/packages/gql/codegen/core/parser.ts +++ b/packages/gql/codegen/core/parser.ts @@ -33,6 +33,7 @@ const DEFAULT_SCHEMA_PATHS = [ '../src/api-android.graphql', '../src/error.graphql', '../src/event.graphql', + '../src/webhook.graphql', ]; // ============================================================================ diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 75ffa755..b27a04dc 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -1051,6 +1051,279 @@ public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +public enum class SubscriptionState(val rawValue: String) { + Active("active"), + InGracePeriod("in-grace-period"), + InBillingRetry("in-billing-retry"), + Expired("expired"), + Revoked("revoked"), + Refunded("refunded"), + Paused("paused"), + Unknown("unknown") + + companion object { + fun fromJson(value: String): SubscriptionState = when (value) { + "active" -> SubscriptionState.Active + "ACTIVE" -> SubscriptionState.Active + "Active" -> SubscriptionState.Active + "in-grace-period" -> SubscriptionState.InGracePeriod + "IN_GRACE_PERIOD" -> SubscriptionState.InGracePeriod + "InGracePeriod" -> SubscriptionState.InGracePeriod + "in-billing-retry" -> SubscriptionState.InBillingRetry + "IN_BILLING_RETRY" -> SubscriptionState.InBillingRetry + "InBillingRetry" -> SubscriptionState.InBillingRetry + "expired" -> SubscriptionState.Expired + "EXPIRED" -> SubscriptionState.Expired + "Expired" -> SubscriptionState.Expired + "revoked" -> SubscriptionState.Revoked + "REVOKED" -> SubscriptionState.Revoked + "Revoked" -> SubscriptionState.Revoked + "refunded" -> SubscriptionState.Refunded + "REFUNDED" -> SubscriptionState.Refunded + "Refunded" -> SubscriptionState.Refunded + "paused" -> SubscriptionState.Paused + "PAUSED" -> SubscriptionState.Paused + "Paused" -> SubscriptionState.Paused + "unknown" -> SubscriptionState.Unknown + "UNKNOWN" -> SubscriptionState.Unknown + "Unknown" -> SubscriptionState.Unknown + else -> throw IllegalArgumentException("Unknown SubscriptionState value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookCancellationReason(val rawValue: String) { + UserCanceled("user-canceled"), + BillingError("billing-error"), + PriceIncreaseDeclined("price-increase-declined"), + ProductUnavailable("product-unavailable"), + Refunded("refunded"), + Other("other") + + companion object { + fun fromJson(value: String): WebhookCancellationReason = when (value) { + "user-canceled" -> WebhookCancellationReason.UserCanceled + "USER_CANCELED" -> WebhookCancellationReason.UserCanceled + "UserCanceled" -> WebhookCancellationReason.UserCanceled + "billing-error" -> WebhookCancellationReason.BillingError + "BILLING_ERROR" -> WebhookCancellationReason.BillingError + "BillingError" -> WebhookCancellationReason.BillingError + "price-increase-declined" -> WebhookCancellationReason.PriceIncreaseDeclined + "PRICE_INCREASE_DECLINED" -> WebhookCancellationReason.PriceIncreaseDeclined + "PriceIncreaseDeclined" -> WebhookCancellationReason.PriceIncreaseDeclined + "product-unavailable" -> WebhookCancellationReason.ProductUnavailable + "PRODUCT_UNAVAILABLE" -> WebhookCancellationReason.ProductUnavailable + "ProductUnavailable" -> WebhookCancellationReason.ProductUnavailable + "refunded" -> WebhookCancellationReason.Refunded + "REFUNDED" -> WebhookCancellationReason.Refunded + "Refunded" -> WebhookCancellationReason.Refunded + "other" -> WebhookCancellationReason.Other + "OTHER" -> WebhookCancellationReason.Other + "Other" -> WebhookCancellationReason.Other + else -> throw IllegalArgumentException("Unknown WebhookCancellationReason value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventEnvironment(val rawValue: String) { + Production("production"), + Sandbox("sandbox"), + Xcode("xcode") + + companion object { + fun fromJson(value: String): WebhookEventEnvironment = when (value) { + "production" -> WebhookEventEnvironment.Production + "PRODUCTION" -> WebhookEventEnvironment.Production + "Production" -> WebhookEventEnvironment.Production + "sandbox" -> WebhookEventEnvironment.Sandbox + "SANDBOX" -> WebhookEventEnvironment.Sandbox + "Sandbox" -> WebhookEventEnvironment.Sandbox + "xcode" -> WebhookEventEnvironment.Xcode + "XCODE" -> WebhookEventEnvironment.Xcode + "Xcode" -> WebhookEventEnvironment.Xcode + else -> throw IllegalArgumentException("Unknown WebhookEventEnvironment value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventSource(val rawValue: String) { + AppleAppStoreServerNotificationsV2("apple-app-store-server-notifications-v2"), + GooglePlayRealTimeDeveloperNotifications("google-play-real-time-developer-notifications") + + companion object { + fun fromJson(value: String): WebhookEventSource = when (value) { + "apple-app-store-server-notifications-v2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "AppleAppStoreServerNotificationsV2" -> WebhookEventSource.AppleAppStoreServerNotificationsV2 + "google-play-real-time-developer-notifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + "GooglePlayRealTimeDeveloperNotifications" -> WebhookEventSource.GooglePlayRealTimeDeveloperNotifications + else -> throw IllegalArgumentException("Unknown WebhookEventSource value: $value") + } + } + + fun toJson(): String = rawValue +} + +public enum class WebhookEventType(val rawValue: String) { + /** + * Initial purchase or first conversion from a free trial / intro offer. + * iOS: SUBSCRIBED (initialBuy / resubscribe). + * Android: SUBSCRIPTION_PURCHASED. + */ + SubscriptionStarted("subscription-started"), + /** + * Auto-renewal succeeded for an existing subscription. + * iOS: DID_RENEW. + * Android: SUBSCRIPTION_RENEWED. + */ + SubscriptionRenewed("subscription-renewed"), + /** + * Subscription reached its expiration without a successful renewal. + * iOS: EXPIRED. + * Android: SUBSCRIPTION_EXPIRED. + */ + SubscriptionExpired("subscription-expired"), + /** + * Billing failed; the subscription is in a grace period during which the user + * retains entitlement while payment is retried. + * iOS: DID_FAIL_TO_RENEW (with grace period active). + * Android: SUBSCRIPTION_IN_GRACE_PERIOD. + */ + SubscriptionInGracePeriod("subscription-in-grace-period"), + /** + * Billing failed and the subscription is in account-hold / billing retry, + * during which entitlement is paused but the subscription is not yet expired. + * iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + * Android: SUBSCRIPTION_ON_HOLD. + */ + SubscriptionInBillingRetry("subscription-in-billing-retry"), + /** + * Subscription returned to active state after a billing issue or pause. + * iOS: DID_RECOVER. + * Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + */ + SubscriptionRecovered("subscription-recovered"), + /** + * User turned off auto-renew. Access continues until the current period ends. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + * Android: SUBSCRIPTION_CANCELED. + */ + SubscriptionCanceled("subscription-canceled"), + /** + * User reactivated auto-renew before the subscription expired. + * iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + * Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + */ + SubscriptionUncanceled("subscription-uncanceled"), + /** + * Access immediately revoked (family sharing removal, admin action, fraud). + * iOS: REVOKE. + * Android: SUBSCRIPTION_REVOKED. + */ + SubscriptionRevoked("subscription-revoked"), + /** + * A price change is pending or has been confirmed by the user. + * iOS: PRICE_INCREASE. + * Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + */ + SubscriptionPriceChange("subscription-price-change"), + /** + * User upgraded, downgraded, or crossgraded their plan. + * iOS: DID_CHANGE_RENEWAL_PREF. + * Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + */ + SubscriptionProductChanged("subscription-product-changed"), + /** + * Subscription paused (Android only feature). + * Android: SUBSCRIPTION_PAUSED. + */ + SubscriptionPaused("subscription-paused"), + /** + * Paused subscription resumed (Android only feature). + * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + */ + SubscriptionResumed("subscription-resumed"), + /** + * Refund issued for a one-time purchase or subscription period. + * iOS: REFUND. + * Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + */ + PurchaseRefunded("purchase-refunded"), + /** + * iOS-only: App Store requests a consumption status report for a refund decision. + * Servers should respond via the StoreKit consumption API. + */ + PurchaseConsumptionRequest("purchase-consumption-request"), + /** + * Sandbox or test notification fired by the store for diagnostic purposes. + * Useful for verifying webhook plumbing without a live transaction. + */ + TestNotification("test-notification") + + companion object { + fun fromJson(value: String): WebhookEventType = when (value) { + "subscription-started" -> WebhookEventType.SubscriptionStarted + "SUBSCRIPTION_STARTED" -> WebhookEventType.SubscriptionStarted + "SubscriptionStarted" -> WebhookEventType.SubscriptionStarted + "subscription-renewed" -> WebhookEventType.SubscriptionRenewed + "SUBSCRIPTION_RENEWED" -> WebhookEventType.SubscriptionRenewed + "SubscriptionRenewed" -> WebhookEventType.SubscriptionRenewed + "subscription-expired" -> WebhookEventType.SubscriptionExpired + "SUBSCRIPTION_EXPIRED" -> WebhookEventType.SubscriptionExpired + "SubscriptionExpired" -> WebhookEventType.SubscriptionExpired + "subscription-in-grace-period" -> WebhookEventType.SubscriptionInGracePeriod + "SUBSCRIPTION_IN_GRACE_PERIOD" -> WebhookEventType.SubscriptionInGracePeriod + "SubscriptionInGracePeriod" -> WebhookEventType.SubscriptionInGracePeriod + "subscription-in-billing-retry" -> WebhookEventType.SubscriptionInBillingRetry + "SUBSCRIPTION_IN_BILLING_RETRY" -> WebhookEventType.SubscriptionInBillingRetry + "SubscriptionInBillingRetry" -> WebhookEventType.SubscriptionInBillingRetry + "subscription-recovered" -> WebhookEventType.SubscriptionRecovered + "SUBSCRIPTION_RECOVERED" -> WebhookEventType.SubscriptionRecovered + "SubscriptionRecovered" -> WebhookEventType.SubscriptionRecovered + "subscription-canceled" -> WebhookEventType.SubscriptionCanceled + "SUBSCRIPTION_CANCELED" -> WebhookEventType.SubscriptionCanceled + "SubscriptionCanceled" -> WebhookEventType.SubscriptionCanceled + "subscription-uncanceled" -> WebhookEventType.SubscriptionUncanceled + "SUBSCRIPTION_UNCANCELED" -> WebhookEventType.SubscriptionUncanceled + "SubscriptionUncanceled" -> WebhookEventType.SubscriptionUncanceled + "subscription-revoked" -> WebhookEventType.SubscriptionRevoked + "SUBSCRIPTION_REVOKED" -> WebhookEventType.SubscriptionRevoked + "SubscriptionRevoked" -> WebhookEventType.SubscriptionRevoked + "subscription-price-change" -> WebhookEventType.SubscriptionPriceChange + "SUBSCRIPTION_PRICE_CHANGE" -> WebhookEventType.SubscriptionPriceChange + "SubscriptionPriceChange" -> WebhookEventType.SubscriptionPriceChange + "subscription-product-changed" -> WebhookEventType.SubscriptionProductChanged + "SUBSCRIPTION_PRODUCT_CHANGED" -> WebhookEventType.SubscriptionProductChanged + "SubscriptionProductChanged" -> WebhookEventType.SubscriptionProductChanged + "subscription-paused" -> WebhookEventType.SubscriptionPaused + "SUBSCRIPTION_PAUSED" -> WebhookEventType.SubscriptionPaused + "SubscriptionPaused" -> WebhookEventType.SubscriptionPaused + "subscription-resumed" -> WebhookEventType.SubscriptionResumed + "SUBSCRIPTION_RESUMED" -> WebhookEventType.SubscriptionResumed + "SubscriptionResumed" -> WebhookEventType.SubscriptionResumed + "purchase-refunded" -> WebhookEventType.PurchaseRefunded + "PURCHASE_REFUNDED" -> WebhookEventType.PurchaseRefunded + "PurchaseRefunded" -> WebhookEventType.PurchaseRefunded + "purchase-consumption-request" -> WebhookEventType.PurchaseConsumptionRequest + "PURCHASE_CONSUMPTION_REQUEST" -> WebhookEventType.PurchaseConsumptionRequest + "PurchaseConsumptionRequest" -> WebhookEventType.PurchaseConsumptionRequest + "test-notification" -> WebhookEventType.TestNotification + "TEST_NOTIFICATION" -> WebhookEventType.TestNotification + "TestNotification" -> WebhookEventType.TestNotification + else -> throw IllegalArgumentException("Unknown WebhookEventType value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -3676,6 +3949,119 @@ public data class VerifyPurchaseWithProviderResult( public typealias VoidResult = Unit +public data class WebhookEvent( + /** + * Reason for cancellation, when applicable. + */ + val cancellationReason: WebhookCancellationReason? = null, + /** + * Localized currency code (ISO 4217) at event time, when available. + */ + val currency: String? = null, + val environment: WebhookEventEnvironment, + /** + * When the current subscription period ends. Epoch milliseconds. + */ + val expiresAt: Double? = null, + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + val id: String, + /** + * Time the underlying event occurred at the store. Epoch milliseconds. + */ + val occurredAt: Double, + val platform: IapPlatform, + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + val priceAmountMicros: Double? = null, + /** + * Product the event pertains to. May be null for account-level events. + */ + val productId: String? = null, + /** + * kit project that owns the subscription / purchase this event refers to. + */ + val projectId: String, + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + val purchaseToken: String, + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + val rawSignedPayload: String? = null, + /** + * Time kit ingested and normalized this event. Epoch milliseconds. + */ + val receivedAt: Double, + /** + * When auto-renewal will charge again. Epoch milliseconds. + */ + val renewsAt: Double? = null, + val source: WebhookEventSource, + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + val subscriptionState: SubscriptionState? = null, + val type: WebhookEventType +) { + + companion object { + fun fromJson(json: Map): WebhookEvent { + return WebhookEvent( + cancellationReason = (json["cancellationReason"] as? String)?.let { WebhookCancellationReason.fromJson(it) }, + currency = json["currency"] as? String, + environment = (json["environment"] as? String)?.let { WebhookEventEnvironment.fromJson(it) } ?: WebhookEventEnvironment.Production, + expiresAt = (json["expiresAt"] as? Number)?.toDouble(), + id = json["id"] as? String ?: "", + occurredAt = (json["occurredAt"] as? Number)?.toDouble() ?: 0.0, + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: IapPlatform.Ios, + priceAmountMicros = (json["priceAmountMicros"] as? Number)?.toDouble(), + productId = json["productId"] as? String, + projectId = json["projectId"] as? String ?: "", + purchaseToken = json["purchaseToken"] as? String ?: "", + rawSignedPayload = json["rawSignedPayload"] as? String, + receivedAt = (json["receivedAt"] as? Number)?.toDouble() ?: 0.0, + renewsAt = (json["renewsAt"] as? Number)?.toDouble(), + source = (json["source"] as? String)?.let { WebhookEventSource.fromJson(it) } ?: WebhookEventSource.AppleAppStoreServerNotificationsV2, + subscriptionState = (json["subscriptionState"] as? String)?.let { SubscriptionState.fromJson(it) }, + type = (json["type"] as? String)?.let { WebhookEventType.fromJson(it) } ?: WebhookEventType.SubscriptionStarted, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "WebhookEvent", + "cancellationReason" to cancellationReason?.toJson(), + "currency" to currency, + "environment" to environment.toJson(), + "expiresAt" to expiresAt, + "id" to id, + "occurredAt" to occurredAt, + "platform" to platform.toJson(), + "priceAmountMicros" to priceAmountMicros, + "productId" to productId, + "projectId" to projectId, + "purchaseToken" to purchaseToken, + "rawSignedPayload" to rawSignedPayload, + "receivedAt" to receivedAt, + "renewsAt" to renewsAt, + "source" to source.toJson(), + "subscriptionState" to subscriptionState?.toJson(), + "type" to type.toJson(), + ) +} + // MARK: - Input Objects public data class AndroidSubscriptionOfferInput( @@ -5120,6 +5506,12 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS + /** + * Replay missed webhook events for the authenticated client since the given + * timestamp. SDKs call this on reconnect / foreground entry to backfill events + * that occurred while the WebSocket was closed. + */ + suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5165,6 +5557,16 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5250,6 +5652,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5272,7 +5675,8 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, + val webhookEventsSince: QueryWebhookEventsSinceHandler? = null ) // MARK: - Subscription Helpers @@ -5283,6 +5687,7 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5290,5 +5695,6 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, + val webhookEvent: SubscriptionWebhookEventHandler? = null ) diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index efacb85c..39962124 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -424,6 +424,102 @@ public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable { case keepExisting = "keep-existing" } +public enum SubscriptionState: String, Codable, CaseIterable { + case active = "active" + case inGracePeriod = "in-grace-period" + case inBillingRetry = "in-billing-retry" + case expired = "expired" + case revoked = "revoked" + case refunded = "refunded" + case paused = "paused" + case unknown = "unknown" +} + +public enum WebhookCancellationReason: String, Codable, CaseIterable { + case userCanceled = "user-canceled" + case billingError = "billing-error" + case priceIncreaseDeclined = "price-increase-declined" + case productUnavailable = "product-unavailable" + case refunded = "refunded" + case other = "other" +} + +public enum WebhookEventEnvironment: String, Codable, CaseIterable { + case production = "production" + case sandbox = "sandbox" + case xcode = "xcode" +} + +public enum WebhookEventSource: String, Codable, CaseIterable { + case appleAppStoreServerNotificationsV2 = "apple-app-store-server-notifications-v2" + case googlePlayRealTimeDeveloperNotifications = "google-play-real-time-developer-notifications" +} + +public enum WebhookEventType: String, Codable, CaseIterable { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + case subscriptionStarted = "subscription-started" + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + case subscriptionRenewed = "subscription-renewed" + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + case subscriptionExpired = "subscription-expired" + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + case subscriptionInGracePeriod = "subscription-in-grace-period" + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + case subscriptionInBillingRetry = "subscription-in-billing-retry" + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + case subscriptionRecovered = "subscription-recovered" + /// User turned off auto-renew. Access continues until the current period ends. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + /// Android: SUBSCRIPTION_CANCELED. + case subscriptionCanceled = "subscription-canceled" + /// User reactivated auto-renew before the subscription expired. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + case subscriptionUncanceled = "subscription-uncanceled" + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + case subscriptionRevoked = "subscription-revoked" + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + case subscriptionPriceChange = "subscription-price-change" + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + case subscriptionProductChanged = "subscription-product-changed" + /// Subscription paused (Android only feature). + /// Android: SUBSCRIPTION_PAUSED. + case subscriptionPaused = "subscription-paused" + /// Paused subscription resumed (Android only feature). + /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + case subscriptionResumed = "subscription-resumed" + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + case purchaseRefunded = "purchase-refunded" + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + case purchaseConsumptionRequest = "purchase-consumption-request" + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + case testNotification = "test-notification" +} + // MARK: - Interfaces public protocol ProductCommon: Codable { @@ -1320,6 +1416,47 @@ public struct VerifyPurchaseWithProviderResult: Codable { public typealias VoidResult = Void +public struct WebhookEvent: Codable { + /// Reason for cancellation, when applicable. + public var cancellationReason: WebhookCancellationReason? = nil + /// Localized currency code (ISO 4217) at event time, when available. + public var currency: String? = nil + public var environment: WebhookEventEnvironment + /// When the current subscription period ends. Epoch milliseconds. + public var expiresAt: Double? = nil + /// Stable identifier suitable for idempotency. Derived from the source notification + /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + /// otherwise hashed from the canonicalized payload. + public var id: String + /// Time the underlying event occurred at the store. Epoch milliseconds. + public var occurredAt: Double + public var platform: IapPlatform + /// Price in micros (1/1,000,000 of the currency unit) at event time, when available. + /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + public var priceAmountMicros: Double? = nil + /// Product the event pertains to. May be null for account-level events. + public var productId: String? = nil + /// kit project that owns the subscription / purchase this event refers to. + public var projectId: String + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + public var purchaseToken: String + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + public var rawSignedPayload: String? = nil + /// Time kit ingested and normalized this event. Epoch milliseconds. + public var receivedAt: Double + /// When auto-renewal will charge again. Epoch milliseconds. + public var renewsAt: Double? = nil + public var source: WebhookEventSource + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + public var subscriptionState: SubscriptionState? = nil + public var type: WebhookEventType +} + // MARK: - Input Objects public struct AndroidSubscriptionOfferInput: Codable { @@ -2526,6 +2663,10 @@ public protocol QueryResolver { /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS + /// Replay missed webhook events for the authenticated client since the given + /// timestamp. SDKs call this on reconnect / foreground entry to backfill events + /// that occurred while the WebSocket was closed. + func webhookEventsSince(sinceMs: Double, limit: Int?) async throws -> [WebhookEvent] } /// GraphQL root subscription operations. @@ -2557,6 +2698,14 @@ public protocol SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails + /// Streams normalized webhook events tied to the authenticated client's purchases. + /// Clients only receive events whose `purchaseToken` matches a purchase they own. + /// + /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + /// enters foreground and disconnect when it goes to background. Events that fire + /// while the connection is closed are reconciled via `webhookEventsSince` on + /// reconnect or the next foreground entry. + func webhookEvent() async throws -> WebhookEvent } // MARK: - Root Operation Helpers @@ -2698,6 +2847,7 @@ public typealias QueryIsTransactionVerifiedIOSHandler = (_ sku: String) async th public typealias QueryLatestTransactionIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throws -> [SubscriptionStatusIOS] public typealias QueryValidateReceiptIOSHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS +public typealias QueryWebhookEventsSinceHandler = (_ sinceMs: Double, _ limit: Int?) async throws -> [WebhookEvent] public struct QueryHandlers { public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? @@ -2721,6 +2871,7 @@ public struct QueryHandlers { public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? public var validateReceiptIOS: QueryValidateReceiptIOSHandler? + public var webhookEventsSince: QueryWebhookEventsSinceHandler? public init( canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil, @@ -2743,7 +2894,8 @@ public struct QueryHandlers { isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = nil, latestTransactionIOS: QueryLatestTransactionIOSHandler? = nil, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil, - validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil + validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil, + webhookEventsSince: QueryWebhookEventsSinceHandler? = nil ) { self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS self.currentEntitlementIOS = currentEntitlementIOS @@ -2766,6 +2918,7 @@ public struct QueryHandlers { self.latestTransactionIOS = latestTransactionIOS self.subscriptionStatusIOS = subscriptionStatusIOS self.validateReceiptIOS = validateReceiptIOS + self.webhookEventsSince = webhookEventsSince } } @@ -2777,6 +2930,7 @@ public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseE public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails +public typealias SubscriptionWebhookEventHandler = () async throws -> WebhookEvent public struct SubscriptionHandlers { public var developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? @@ -2785,6 +2939,7 @@ public struct SubscriptionHandlers { public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? public var subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? + public var webhookEvent: SubscriptionWebhookEventHandler? public init( developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = nil, @@ -2792,7 +2947,8 @@ public struct SubscriptionHandlers { purchaseError: SubscriptionPurchaseErrorHandler? = nil, purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = nil, - userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil + userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil, + webhookEvent: SubscriptionWebhookEventHandler? = nil ) { self.developerProvidedBillingAndroid = developerProvidedBillingAndroid self.promotedProductIOS = promotedProductIOS @@ -2800,5 +2956,6 @@ public struct SubscriptionHandlers { self.purchaseUpdated = purchaseUpdated self.subscriptionBillingIssue = subscriptionBillingIssue self.userChoiceBillingAndroid = userChoiceBillingAndroid + self.webhookEvent = webhookEvent } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index 1645922e..e9af67a7 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -928,6 +928,232 @@ enum SubscriptionReplacementModeAndroid { String toJson() => value; } +enum SubscriptionState { + Active('active'), + InGracePeriod('in-grace-period'), + InBillingRetry('in-billing-retry'), + Expired('expired'), + Revoked('revoked'), + Refunded('refunded'), + Paused('paused'), + Unknown('unknown'); + + const SubscriptionState(this.value); + final String value; + + factory SubscriptionState.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'active': + return SubscriptionState.Active; + case 'in-grace-period': + return SubscriptionState.InGracePeriod; + case 'in-billing-retry': + return SubscriptionState.InBillingRetry; + case 'expired': + return SubscriptionState.Expired; + case 'revoked': + return SubscriptionState.Revoked; + case 'refunded': + return SubscriptionState.Refunded; + case 'paused': + return SubscriptionState.Paused; + case 'unknown': + return SubscriptionState.Unknown; + } + throw ArgumentError('Unknown SubscriptionState value: $value'); + } + + String toJson() => value; +} + +enum WebhookCancellationReason { + UserCanceled('user-canceled'), + BillingError('billing-error'), + PriceIncreaseDeclined('price-increase-declined'), + ProductUnavailable('product-unavailable'), + Refunded('refunded'), + Other('other'); + + const WebhookCancellationReason(this.value); + final String value; + + factory WebhookCancellationReason.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'user-canceled': + return WebhookCancellationReason.UserCanceled; + case 'billing-error': + return WebhookCancellationReason.BillingError; + case 'price-increase-declined': + return WebhookCancellationReason.PriceIncreaseDeclined; + case 'product-unavailable': + return WebhookCancellationReason.ProductUnavailable; + case 'refunded': + return WebhookCancellationReason.Refunded; + case 'other': + return WebhookCancellationReason.Other; + } + throw ArgumentError('Unknown WebhookCancellationReason value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventEnvironment { + Production('production'), + Sandbox('sandbox'), + Xcode('xcode'); + + const WebhookEventEnvironment(this.value); + final String value; + + factory WebhookEventEnvironment.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'production': + return WebhookEventEnvironment.Production; + case 'sandbox': + return WebhookEventEnvironment.Sandbox; + case 'xcode': + return WebhookEventEnvironment.Xcode; + } + throw ArgumentError('Unknown WebhookEventEnvironment value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2('apple-app-store-server-notifications-v2'), + GooglePlayRealTimeDeveloperNotifications('google-play-real-time-developer-notifications'); + + const WebhookEventSource(this.value); + final String value; + + factory WebhookEventSource.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'apple-app-store-server-notifications-v2': + return WebhookEventSource.AppleAppStoreServerNotificationsV2; + case 'google-play-real-time-developer-notifications': + return WebhookEventSource.GooglePlayRealTimeDeveloperNotifications; + } + throw ArgumentError('Unknown WebhookEventSource value: $value'); + } + + String toJson() => value; +} + +enum WebhookEventType { + /// Initial purchase or first conversion from a free trial / intro offer. + /// iOS: SUBSCRIBED (initialBuy / resubscribe). + /// Android: SUBSCRIPTION_PURCHASED. + SubscriptionStarted('subscription-started'), + /// Auto-renewal succeeded for an existing subscription. + /// iOS: DID_RENEW. + /// Android: SUBSCRIPTION_RENEWED. + SubscriptionRenewed('subscription-renewed'), + /// Subscription reached its expiration without a successful renewal. + /// iOS: EXPIRED. + /// Android: SUBSCRIPTION_EXPIRED. + SubscriptionExpired('subscription-expired'), + /// Billing failed; the subscription is in a grace period during which the user + /// retains entitlement while payment is retried. + /// iOS: DID_FAIL_TO_RENEW (with grace period active). + /// Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SubscriptionInGracePeriod('subscription-in-grace-period'), + /// Billing failed and the subscription is in account-hold / billing retry, + /// during which entitlement is paused but the subscription is not yet expired. + /// iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + /// Android: SUBSCRIPTION_ON_HOLD. + SubscriptionInBillingRetry('subscription-in-billing-retry'), + /// Subscription returned to active state after a billing issue or pause. + /// iOS: DID_RECOVER. + /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + SubscriptionRecovered('subscription-recovered'), + /// User turned off auto-renew. Access continues until the current period ends. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + /// Android: SUBSCRIPTION_CANCELED. + SubscriptionCanceled('subscription-canceled'), + /// User reactivated auto-renew before the subscription expired. + /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + /// Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + SubscriptionUncanceled('subscription-uncanceled'), + /// Access immediately revoked (family sharing removal, admin action, fraud). + /// iOS: REVOKE. + /// Android: SUBSCRIPTION_REVOKED. + SubscriptionRevoked('subscription-revoked'), + /// A price change is pending or has been confirmed by the user. + /// iOS: PRICE_INCREASE. + /// Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SubscriptionPriceChange('subscription-price-change'), + /// User upgraded, downgraded, or crossgraded their plan. + /// iOS: DID_CHANGE_RENEWAL_PREF. + /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SubscriptionProductChanged('subscription-product-changed'), + /// Subscription paused (Android only feature). + /// Android: SUBSCRIPTION_PAUSED. + SubscriptionPaused('subscription-paused'), + /// Paused subscription resumed (Android only feature). + /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + SubscriptionResumed('subscription-resumed'), + /// Refund issued for a one-time purchase or subscription period. + /// iOS: REFUND. + /// Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PurchaseRefunded('purchase-refunded'), + /// iOS-only: App Store requests a consumption status report for a refund decision. + /// Servers should respond via the StoreKit consumption API. + PurchaseConsumptionRequest('purchase-consumption-request'), + /// Sandbox or test notification fired by the store for diagnostic purposes. + /// Useful for verifying webhook plumbing without a live transaction. + TestNotification('test-notification'); + + const WebhookEventType(this.value); + final String value; + + factory WebhookEventType.fromJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'subscription-started': + return WebhookEventType.SubscriptionStarted; + case 'subscription-renewed': + return WebhookEventType.SubscriptionRenewed; + case 'subscription-expired': + return WebhookEventType.SubscriptionExpired; + case 'subscription-in-grace-period': + return WebhookEventType.SubscriptionInGracePeriod; + case 'subscription-in-billing-retry': + return WebhookEventType.SubscriptionInBillingRetry; + case 'subscription-recovered': + return WebhookEventType.SubscriptionRecovered; + case 'subscription-canceled': + return WebhookEventType.SubscriptionCanceled; + case 'subscription-uncanceled': + return WebhookEventType.SubscriptionUncanceled; + case 'subscription-revoked': + return WebhookEventType.SubscriptionRevoked; + case 'subscription-price-change': + return WebhookEventType.SubscriptionPriceChange; + case 'subscription-product-changed': + return WebhookEventType.SubscriptionProductChanged; + case 'subscription-paused': + return WebhookEventType.SubscriptionPaused; + case 'subscription-resumed': + return WebhookEventType.SubscriptionResumed; + case 'purchase-refunded': + return WebhookEventType.PurchaseRefunded; + case 'purchase-consumption-request': + return WebhookEventType.PurchaseConsumptionRequest; + case 'test-notification': + return WebhookEventType.TestNotification; + } + throw ArgumentError('Unknown WebhookEventType value: $value'); + } + + String toJson() => value; +} + // MARK: - Interfaces abstract class ProductCommon { @@ -3689,6 +3915,112 @@ class VerifyPurchaseWithProviderResult { typedef VoidResult = void; +class WebhookEvent { + const WebhookEvent({ + this.cancellationReason, + this.currency, + required this.environment, + this.expiresAt, + required this.id, + required this.occurredAt, + required this.platform, + this.priceAmountMicros, + this.productId, + required this.projectId, + required this.purchaseToken, + this.rawSignedPayload, + required this.receivedAt, + this.renewsAt, + required this.source, + this.subscriptionState, + required this.type, + }); + + /// Reason for cancellation, when applicable. + final WebhookCancellationReason? cancellationReason; + /// Localized currency code (ISO 4217) at event time, when available. + final String? currency; + final WebhookEventEnvironment environment; + /// When the current subscription period ends. Epoch milliseconds. + final double? expiresAt; + /// Stable identifier suitable for idempotency. Derived from the source notification + /// UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + /// otherwise hashed from the canonicalized payload. + final String id; + /// Time the underlying event occurred at the store. Epoch milliseconds. + final double occurredAt; + final IapPlatform platform; + /// Price in micros (1/1,000,000 of the currency unit) at event time, when available. + /// Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + final double? priceAmountMicros; + /// Product the event pertains to. May be null for account-level events. + final String? productId; + /// kit project that owns the subscription / purchase this event refers to. + final String projectId; + /// Cross-platform purchase identity used to correlate this event with an existing + /// purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + final String purchaseToken; + /// Original signed payload from the store. ASN v2 events expose the JWS string; + /// RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + /// consumers can independently verify or extract platform-specific fields. kit + /// always validates this payload before emitting the event. + final String? rawSignedPayload; + /// Time kit ingested and normalized this event. Epoch milliseconds. + final double receivedAt; + /// When auto-renewal will charge again. Epoch milliseconds. + final double? renewsAt; + final WebhookEventSource source; + /// Normalized subscription state at the time of event, when the event refers to + /// a subscription. Null for one-time purchase events. + final SubscriptionState? subscriptionState; + final WebhookEventType type; + + factory WebhookEvent.fromJson(Map json) { + return WebhookEvent( + cancellationReason: json['cancellationReason'] != null ? WebhookCancellationReason.fromJson(json['cancellationReason'] as String) : null, + currency: json['currency'] as String?, + environment: WebhookEventEnvironment.fromJson(json['environment'] as String), + expiresAt: (json['expiresAt'] as num?)?.toDouble(), + id: json['id'] as String, + occurredAt: (json['occurredAt'] as num).toDouble(), + platform: IapPlatform.fromJson(json['platform'] as String), + priceAmountMicros: (json['priceAmountMicros'] as num?)?.toDouble(), + productId: json['productId'] as String?, + projectId: json['projectId'] as String, + purchaseToken: json['purchaseToken'] as String, + rawSignedPayload: json['rawSignedPayload'] as String?, + receivedAt: (json['receivedAt'] as num).toDouble(), + renewsAt: (json['renewsAt'] as num?)?.toDouble(), + source: WebhookEventSource.fromJson(json['source'] as String), + subscriptionState: json['subscriptionState'] != null ? SubscriptionState.fromJson(json['subscriptionState'] as String) : null, + type: WebhookEventType.fromJson(json['type'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'WebhookEvent', + 'cancellationReason': cancellationReason?.toJson(), + 'currency': currency, + 'environment': environment.toJson(), + 'expiresAt': expiresAt, + 'id': id, + 'occurredAt': occurredAt, + 'platform': platform.toJson(), + 'priceAmountMicros': priceAmountMicros, + 'productId': productId, + 'projectId': projectId, + 'purchaseToken': purchaseToken, + 'rawSignedPayload': rawSignedPayload, + 'receivedAt': receivedAt, + 'renewsAt': renewsAt, + 'source': source.toJson(), + 'subscriptionState': subscriptionState?.toJson(), + 'type': type.toJson(), + }; + } +} + // MARK: - Input Objects class AndroidSubscriptionOfferInput { @@ -5074,6 +5406,13 @@ abstract class QueryResolver { VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); + /// Replay missed webhook events for the authenticated client since the given + /// timestamp. SDKs call this on reconnect / foreground entry to backfill events + /// that occurred while the WebSocket was closed. + Future> webhookEventsSince({ + required double sinceMs, + int? limit, + }); } /// GraphQL root subscription operations. @@ -5105,6 +5444,14 @@ abstract class SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing Future userChoiceBillingAndroid(); + /// Streams normalized webhook events tied to the authenticated client's purchases. + /// Clients only receive events whose `purchaseToken` matches a purchase they own. + /// + /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + /// enters foreground and disconnect when it goes to background. Events that fire + /// while the connection is closed are reconciled via `webhookEventsSince` on + /// reconnect or the next foreground entry. + Future webhookEvent(); } // MARK: - Root Operation Helpers @@ -5255,6 +5602,10 @@ typedef QueryValidateReceiptIOSHandler = Future Functio VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); +typedef QueryWebhookEventsSinceHandler = Future> Function({ + required double sinceMs, + int? limit, +}); class QueryHandlers { const QueryHandlers({ @@ -5279,6 +5630,7 @@ class QueryHandlers { this.latestTransactionIOS, this.subscriptionStatusIOS, this.validateReceiptIOS, + this.webhookEventsSince, }); final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; @@ -5302,6 +5654,7 @@ class QueryHandlers { final QueryLatestTransactionIOSHandler? latestTransactionIOS; final QuerySubscriptionStatusIOSHandler? subscriptionStatusIOS; final QueryValidateReceiptIOSHandler? validateReceiptIOS; + final QueryWebhookEventsSinceHandler? webhookEventsSince; } // MARK: - Subscription Helpers @@ -5312,6 +5665,7 @@ typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function(); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); +typedef SubscriptionWebhookEventHandler = Future Function(); class SubscriptionHandlers { const SubscriptionHandlers({ @@ -5321,6 +5675,7 @@ class SubscriptionHandlers { this.purchaseUpdated, this.subscriptionBillingIssue, this.userChoiceBillingAndroid, + this.webhookEvent, }); final SubscriptionDeveloperProvidedBillingAndroidHandler? developerProvidedBillingAndroid; @@ -5329,4 +5684,5 @@ class SubscriptionHandlers { final SubscriptionPurchaseUpdatedHandler? purchaseUpdated; final SubscriptionSubscriptionBillingIssueHandler? subscriptionBillingIssue; final SubscriptionUserChoiceBillingAndroidHandler? userChoiceBillingAndroid; + final SubscriptionWebhookEventHandler? webhookEvent; } diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index bc96928f..5e9c3ed3 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -295,6 +295,72 @@ enum SubscriptionReplacementModeAndroid { KEEP_EXISTING = 6, } +enum SubscriptionState { + ACTIVE = 0, + IN_GRACE_PERIOD = 1, + IN_BILLING_RETRY = 2, + EXPIRED = 3, + REVOKED = 4, + REFUNDED = 5, + PAUSED = 6, + UNKNOWN = 7, +} + +enum WebhookCancellationReason { + USER_CANCELED = 0, + BILLING_ERROR = 1, + PRICE_INCREASE_DECLINED = 2, + PRODUCT_UNAVAILABLE = 3, + REFUNDED = 4, + OTHER = 5, +} + +enum WebhookEventEnvironment { + PRODUCTION = 0, + SANDBOX = 1, + XCODE = 2, +} + +enum WebhookEventSource { + APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2 = 0, + GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS = 1, +} + +enum WebhookEventType { + ## Initial purchase or first conversion from a free trial / intro offer. iOS: SUBSCRIBED (initialBuy / resubscribe). Android: SUBSCRIPTION_PURCHASED. + SUBSCRIPTION_STARTED = 0, + ## Auto-renewal succeeded for an existing subscription. iOS: DID_RENEW. Android: SUBSCRIPTION_RENEWED. + SUBSCRIPTION_RENEWED = 1, + ## Subscription reached its expiration without a successful renewal. iOS: EXPIRED. Android: SUBSCRIPTION_EXPIRED. + SUBSCRIPTION_EXPIRED = 2, + ## Billing failed; the subscription is in a grace period during which the user retains entitlement while payment is retried. iOS: DID_FAIL_TO_RENEW (with grace period active). Android: SUBSCRIPTION_IN_GRACE_PERIOD. + SUBSCRIPTION_IN_GRACE_PERIOD = 3, + ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD. + SUBSCRIPTION_IN_BILLING_RETRY = 4, + ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + SUBSCRIPTION_RECOVERED = 5, + ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED. + SUBSCRIPTION_CANCELED = 6, + ## User reactivated auto-renew before the subscription expired. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + SUBSCRIPTION_UNCANCELED = 7, + ## Access immediately revoked (family sharing removal, admin action, fraud). iOS: REVOKE. Android: SUBSCRIPTION_REVOKED. + SUBSCRIPTION_REVOKED = 8, + ## A price change is pending or has been confirmed by the user. iOS: PRICE_INCREASE. Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + SUBSCRIPTION_PRICE_CHANGE = 9, + ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + SUBSCRIPTION_PRODUCT_CHANGED = 10, + ## Subscription paused (Android only feature). Android: SUBSCRIPTION_PAUSED. + SUBSCRIPTION_PAUSED = 11, + ## Paused subscription resumed (Android only feature). Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + SUBSCRIPTION_RESUMED = 12, + ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + PURCHASE_REFUNDED = 13, + ## iOS-only: App Store requests a consumption status report for a refund decision. Servers should respond via the StoreKit consumption API. + PURCHASE_CONSUMPTION_REQUEST = 14, + ## Sandbox or test notification fired by the store for diagnostic purposes. Useful for verifying webhook plumbing without a live transaction. + TEST_NOTIFICATION = 15, +} + # ============================================================================ # Types # ============================================================================ @@ -3238,6 +3304,145 @@ class VoidResult: dict["success"] = success return dict +class WebhookEvent: + ## Stable identifier suitable for idempotency. Derived from the source notification + var id: String = "" + var type: WebhookEventType + var source: WebhookEventSource + var platform: IapPlatform + ## kit project that owns the subscription / purchase this event refers to. + var project_id: String = "" + ## Time the underlying event occurred at the store. Epoch milliseconds. + var occurred_at: float = 0.0 + ## Time kit ingested and normalized this event. Epoch milliseconds. + var received_at: float = 0.0 + var environment: WebhookEventEnvironment + ## Cross-platform purchase identity used to correlate this event with an existing + var purchase_token: String = "" + ## Product the event pertains to. May be null for account-level events. + var product_id: Variant = null + ## Normalized subscription state at the time of event, when the event refers to + var subscription_state: SubscriptionState + ## When the current subscription period ends. Epoch milliseconds. + var expires_at: Variant = null + ## When auto-renewal will charge again. Epoch milliseconds. + var renews_at: Variant = null + ## Reason for cancellation, when applicable. + var cancellation_reason: WebhookCancellationReason + ## Localized currency code (ISO 4217) at event time, when available. + var currency: Variant = null + ## Price in micros (1/1,000,000 of the currency unit) at event time, when available. + var price_amount_micros: Variant = null + ## Original signed payload from the store. ASN v2 events expose the JWS string; + var raw_signed_payload: Variant = null + + static func from_dict(data: Dictionary) -> WebhookEvent: + var obj = WebhookEvent.new() + if data.has("id") and data["id"] != null: + obj.id = data["id"] + if data.has("type") and data["type"] != null: + var enum_str = data["type"] + if enum_str is String and WEBHOOK_EVENT_TYPE_FROM_STRING.has(enum_str): + obj.type = WEBHOOK_EVENT_TYPE_FROM_STRING[enum_str] + else: + obj.type = enum_str + if data.has("source") and data["source"] != null: + var enum_str = data["source"] + if enum_str is String and WEBHOOK_EVENT_SOURCE_FROM_STRING.has(enum_str): + obj.source = WEBHOOK_EVENT_SOURCE_FROM_STRING[enum_str] + else: + obj.source = enum_str + if data.has("platform") and data["platform"] != null: + var enum_str = data["platform"] + if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str): + obj.platform = IAP_PLATFORM_FROM_STRING[enum_str] + else: + obj.platform = enum_str + if data.has("projectId") and data["projectId"] != null: + obj.project_id = data["projectId"] + if data.has("occurredAt") and data["occurredAt"] != null: + obj.occurred_at = data["occurredAt"] + if data.has("receivedAt") and data["receivedAt"] != null: + obj.received_at = data["receivedAt"] + if data.has("environment") and data["environment"] != null: + var enum_str = data["environment"] + if enum_str is String and WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING.has(enum_str): + obj.environment = WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING[enum_str] + else: + obj.environment = enum_str + if data.has("purchaseToken") and data["purchaseToken"] != null: + obj.purchase_token = data["purchaseToken"] + if data.has("productId") and data["productId"] != null: + obj.product_id = data["productId"] + if data.has("subscriptionState") and data["subscriptionState"] != null: + var enum_str = data["subscriptionState"] + if enum_str is String and SUBSCRIPTION_STATE_FROM_STRING.has(enum_str): + obj.subscription_state = SUBSCRIPTION_STATE_FROM_STRING[enum_str] + else: + obj.subscription_state = enum_str + if data.has("expiresAt") and data["expiresAt"] != null: + obj.expires_at = data["expiresAt"] + if data.has("renewsAt") and data["renewsAt"] != null: + obj.renews_at = data["renewsAt"] + if data.has("cancellationReason") and data["cancellationReason"] != null: + var enum_str = data["cancellationReason"] + if enum_str is String and WEBHOOK_CANCELLATION_REASON_FROM_STRING.has(enum_str): + obj.cancellation_reason = WEBHOOK_CANCELLATION_REASON_FROM_STRING[enum_str] + else: + obj.cancellation_reason = enum_str + if data.has("currency") and data["currency"] != null: + obj.currency = data["currency"] + if data.has("priceAmountMicros") and data["priceAmountMicros"] != null: + obj.price_amount_micros = data["priceAmountMicros"] + if data.has("rawSignedPayload") and data["rawSignedPayload"] != null: + obj.raw_signed_payload = data["rawSignedPayload"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["id"] = id + if WEBHOOK_EVENT_TYPE_VALUES.has(type): + dict["type"] = WEBHOOK_EVENT_TYPE_VALUES[type] + else: + dict["type"] = type + if WEBHOOK_EVENT_SOURCE_VALUES.has(source): + dict["source"] = WEBHOOK_EVENT_SOURCE_VALUES[source] + else: + dict["source"] = source + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + dict["projectId"] = project_id + dict["occurredAt"] = occurred_at + dict["receivedAt"] = received_at + if WEBHOOK_EVENT_ENVIRONMENT_VALUES.has(environment): + dict["environment"] = WEBHOOK_EVENT_ENVIRONMENT_VALUES[environment] + else: + dict["environment"] = environment + dict["purchaseToken"] = purchase_token + if product_id != null: + dict["productId"] = product_id + if SUBSCRIPTION_STATE_VALUES.has(subscription_state): + dict["subscriptionState"] = SUBSCRIPTION_STATE_VALUES[subscription_state] + else: + dict["subscriptionState"] = subscription_state + if expires_at != null: + dict["expiresAt"] = expires_at + if renews_at != null: + dict["renewsAt"] = renews_at + if WEBHOOK_CANCELLATION_REASON_VALUES.has(cancellation_reason): + dict["cancellationReason"] = WEBHOOK_CANCELLATION_REASON_VALUES[cancellation_reason] + else: + dict["cancellationReason"] = cancellation_reason + if currency != null: + dict["currency"] = currency + if price_amount_micros != null: + dict["priceAmountMicros"] = price_amount_micros + if raw_signed_payload != null: + dict["rawSignedPayload"] = raw_signed_payload + return dict + # ============================================================================ # Input Types # ============================================================================ @@ -4569,6 +4774,56 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_VALUES = { SubscriptionReplacementModeAndroid.KEEP_EXISTING: "keep-existing" } +const SUBSCRIPTION_STATE_VALUES = { + SubscriptionState.ACTIVE: "active", + SubscriptionState.IN_GRACE_PERIOD: "in-grace-period", + SubscriptionState.IN_BILLING_RETRY: "in-billing-retry", + SubscriptionState.EXPIRED: "expired", + SubscriptionState.REVOKED: "revoked", + SubscriptionState.REFUNDED: "refunded", + SubscriptionState.PAUSED: "paused", + SubscriptionState.UNKNOWN: "unknown" +} + +const WEBHOOK_CANCELLATION_REASON_VALUES = { + WebhookCancellationReason.USER_CANCELED: "user-canceled", + WebhookCancellationReason.BILLING_ERROR: "billing-error", + WebhookCancellationReason.PRICE_INCREASE_DECLINED: "price-increase-declined", + WebhookCancellationReason.PRODUCT_UNAVAILABLE: "product-unavailable", + WebhookCancellationReason.REFUNDED: "refunded", + WebhookCancellationReason.OTHER: "other" +} + +const WEBHOOK_EVENT_ENVIRONMENT_VALUES = { + WebhookEventEnvironment.PRODUCTION: "production", + WebhookEventEnvironment.SANDBOX: "sandbox", + WebhookEventEnvironment.XCODE: "xcode" +} + +const WEBHOOK_EVENT_SOURCE_VALUES = { + WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2: "apple-app-store-server-notifications-v2", + WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS: "google-play-real-time-developer-notifications" +} + +const WEBHOOK_EVENT_TYPE_VALUES = { + WebhookEventType.SUBSCRIPTION_STARTED: "subscription-started", + WebhookEventType.SUBSCRIPTION_RENEWED: "subscription-renewed", + WebhookEventType.SUBSCRIPTION_EXPIRED: "subscription-expired", + WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD: "subscription-in-grace-period", + WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY: "subscription-in-billing-retry", + WebhookEventType.SUBSCRIPTION_RECOVERED: "subscription-recovered", + WebhookEventType.SUBSCRIPTION_CANCELED: "subscription-canceled", + WebhookEventType.SUBSCRIPTION_UNCANCELED: "subscription-uncanceled", + WebhookEventType.SUBSCRIPTION_REVOKED: "subscription-revoked", + WebhookEventType.SUBSCRIPTION_PRICE_CHANGE: "subscription-price-change", + WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED: "subscription-product-changed", + WebhookEventType.SUBSCRIPTION_PAUSED: "subscription-paused", + WebhookEventType.SUBSCRIPTION_RESUMED: "subscription-resumed", + WebhookEventType.PURCHASE_REFUNDED: "purchase-refunded", + WebhookEventType.PURCHASE_CONSUMPTION_REQUEST: "purchase-consumption-request", + WebhookEventType.TEST_NOTIFICATION: "test-notification" +} + # ============================================================================ # Enum Reverse Lookup (string -> enum for deserialization) # ============================================================================ @@ -4787,6 +5042,56 @@ const SUBSCRIPTION_REPLACEMENT_MODE_ANDROID_FROM_STRING = { "keep-existing": SubscriptionReplacementModeAndroid.KEEP_EXISTING } +const SUBSCRIPTION_STATE_FROM_STRING = { + "active": SubscriptionState.ACTIVE, + "in-grace-period": SubscriptionState.IN_GRACE_PERIOD, + "in-billing-retry": SubscriptionState.IN_BILLING_RETRY, + "expired": SubscriptionState.EXPIRED, + "revoked": SubscriptionState.REVOKED, + "refunded": SubscriptionState.REFUNDED, + "paused": SubscriptionState.PAUSED, + "unknown": SubscriptionState.UNKNOWN +} + +const WEBHOOK_CANCELLATION_REASON_FROM_STRING = { + "user-canceled": WebhookCancellationReason.USER_CANCELED, + "billing-error": WebhookCancellationReason.BILLING_ERROR, + "price-increase-declined": WebhookCancellationReason.PRICE_INCREASE_DECLINED, + "product-unavailable": WebhookCancellationReason.PRODUCT_UNAVAILABLE, + "refunded": WebhookCancellationReason.REFUNDED, + "other": WebhookCancellationReason.OTHER +} + +const WEBHOOK_EVENT_ENVIRONMENT_FROM_STRING = { + "production": WebhookEventEnvironment.PRODUCTION, + "sandbox": WebhookEventEnvironment.SANDBOX, + "xcode": WebhookEventEnvironment.XCODE +} + +const WEBHOOK_EVENT_SOURCE_FROM_STRING = { + "apple-app-store-server-notifications-v2": WebhookEventSource.APPLE_APP_STORE_SERVER_NOTIFICATIONS_V2, + "google-play-real-time-developer-notifications": WebhookEventSource.GOOGLE_PLAY_REAL_TIME_DEVELOPER_NOTIFICATIONS +} + +const WEBHOOK_EVENT_TYPE_FROM_STRING = { + "subscription-started": WebhookEventType.SUBSCRIPTION_STARTED, + "subscription-renewed": WebhookEventType.SUBSCRIPTION_RENEWED, + "subscription-expired": WebhookEventType.SUBSCRIPTION_EXPIRED, + "subscription-in-grace-period": WebhookEventType.SUBSCRIPTION_IN_GRACE_PERIOD, + "subscription-in-billing-retry": WebhookEventType.SUBSCRIPTION_IN_BILLING_RETRY, + "subscription-recovered": WebhookEventType.SUBSCRIPTION_RECOVERED, + "subscription-canceled": WebhookEventType.SUBSCRIPTION_CANCELED, + "subscription-uncanceled": WebhookEventType.SUBSCRIPTION_UNCANCELED, + "subscription-revoked": WebhookEventType.SUBSCRIPTION_REVOKED, + "subscription-price-change": WebhookEventType.SUBSCRIPTION_PRICE_CHANGE, + "subscription-product-changed": WebhookEventType.SUBSCRIPTION_PRODUCT_CHANGED, + "subscription-paused": WebhookEventType.SUBSCRIPTION_PAUSED, + "subscription-resumed": WebhookEventType.SUBSCRIPTION_RESUMED, + "purchase-refunded": WebhookEventType.PURCHASE_REFUNDED, + "purchase-consumption-request": WebhookEventType.PURCHASE_CONSUMPTION_REQUEST, + "test-notification": WebhookEventType.TEST_NOTIFICATION +} + # ============================================================================ # Query Types # ============================================================================ @@ -5129,6 +5434,30 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false + ## Replay missed webhook events for the authenticated client since the given + class webhookEventsSinceField: + const name = "webhookEventsSince" + const snake_name = "webhook_events_since" + class Args: + var since_ms: float + var limit: int + + static func from_dict(data: Dictionary) -> Args: + var obj = Args.new() + if data.has("sinceMs") and data["sinceMs"] != null: + obj.since_ms = data["sinceMs"] + if data.has("limit") and data["limit"] != null: + obj.limit = data["limit"] + return obj + + func to_dict() -> Dictionary: + var dict = {} + dict["sinceMs"] = since_ms + dict["limit"] = limit + return dict + const return_type = "WebhookEvent" + const is_array = true + # ============================================================================ # Mutation Types @@ -5696,6 +6025,13 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args +## Replay missed webhook events for the authenticated client since the given +static func webhook_events_since_args(since_ms: float, limit: int) -> Dictionary: + var args = {} + args["sinceMs"] = since_ms + args["limit"] = limit + return args + # Mutation API helpers ## Initialize the store connection. Call before any IAP API. diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index 48f0103b..cc3408f3 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1406,6 +1406,12 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; + /** + * Replay missed webhook events for the authenticated client since the given + * timestamp. SDKs call this on reconnect / foreground entry to backfill events + * that occurred while the WebSocket was closed. + */ + webhookEventsSince: WebhookEvent[]; } @@ -1434,6 +1440,11 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; +export interface QueryWebhookEventsSinceArgs { + limit?: (number | null); + sinceMs: number; +} + export interface RefundResultIOS { message?: (string | null); status: string; @@ -1751,6 +1762,16 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; + /** + * Streams normalized webhook events tied to the authenticated client's purchases. + * Clients only receive events whose `purchaseToken` matches a purchase they own. + * + * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + * enters foreground and disconnect when it goes to background. Events that fire + * while the connection is closed are reconciled via `webhookEventsSince` on + * reconnect or the next foreground entry. + */ + webhookEvent: WebhookEvent; } @@ -1896,6 +1917,8 @@ export interface SubscriptionProductReplacementParamsAndroid { */ export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; +export type SubscriptionState = 'active' | 'expired' | 'in-billing-retry' | 'in-grace-period' | 'paused' | 'refunded' | 'revoked' | 'unknown'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; @@ -2057,6 +2080,65 @@ export interface VerifyPurchaseWithProviderResult { export type VoidResult = void; +export type WebhookCancellationReason = 'billing-error' | 'other' | 'price-increase-declined' | 'product-unavailable' | 'refunded' | 'user-canceled'; + +export interface WebhookEvent { + /** Reason for cancellation, when applicable. */ + cancellationReason?: (WebhookCancellationReason | null); + /** Localized currency code (ISO 4217) at event time, when available. */ + currency?: (string | null); + environment: WebhookEventEnvironment; + /** When the current subscription period ends. Epoch milliseconds. */ + expiresAt?: (number | null); + /** + * Stable identifier suitable for idempotency. Derived from the source notification + * UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + * otherwise hashed from the canonicalized payload. + */ + id: string; + /** Time the underlying event occurred at the store. Epoch milliseconds. */ + occurredAt: number; + platform: IapPlatform; + /** + * Price in micros (1/1,000,000 of the currency unit) at event time, when available. + * Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + */ + priceAmountMicros?: (number | null); + /** Product the event pertains to. May be null for account-level events. */ + productId?: (string | null); + /** kit project that owns the subscription / purchase this event refers to. */ + projectId: string; + /** + * Cross-platform purchase identity used to correlate this event with an existing + * purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + */ + purchaseToken: string; + /** + * Original signed payload from the store. ASN v2 events expose the JWS string; + * RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + * consumers can independently verify or extract platform-specific fields. kit + * always validates this payload before emitting the event. + */ + rawSignedPayload?: (string | null); + /** Time kit ingested and normalized this event. Epoch milliseconds. */ + receivedAt: number; + /** When auto-renewal will charge again. Epoch milliseconds. */ + renewsAt?: (number | null); + source: WebhookEventSource; + /** + * Normalized subscription state at the time of event, when the event refers to + * a subscription. Null for one-time purchase events. + */ + subscriptionState?: (SubscriptionState | null); + type: WebhookEventType; +} + +export type WebhookEventEnvironment = 'production' | 'sandbox' | 'xcode'; + +export type WebhookEventSource = 'apple-app-store-server-notifications-v2' | 'google-play-real-time-developer-notifications'; + +export type WebhookEventType = 'purchase-consumption-request' | 'purchase-refunded' | 'subscription-canceled' | 'subscription-expired' | 'subscription-in-billing-retry' | 'subscription-in-grace-period' | 'subscription-paused' | 'subscription-price-change' | 'subscription-product-changed' | 'subscription-recovered' | 'subscription-renewed' | 'subscription-resumed' | 'subscription-revoked' | 'subscription-started' | 'subscription-uncanceled' | 'test-notification'; + /** * Win-back offer input for iOS 18+ (StoreKit 2) * Win-back offers are used to re-engage churned subscribers. @@ -2090,6 +2172,7 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; + webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2154,6 +2237,7 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; + webhookEvent: never; }; export type SubscriptionField = diff --git a/packages/gql/src/webhook.graphql b/packages/gql/src/webhook.graphql new file mode 100644 index 00000000..13692a2e --- /dev/null +++ b/packages/gql/src/webhook.graphql @@ -0,0 +1,236 @@ +# Server-side lifecycle webhook events normalized across stores. +# +# kit (kit.openiap.dev) ingests Apple App Store Server Notifications v2 (ASN v2) +# and Google Play Real-Time Developer Notifications (RTDN), normalizes them into +# a unified WebhookEvent shape, and streams them to authenticated clients via the +# `webhookEvent` GraphQL Subscription. +# +# Design goals: +# - Single event shape regardless of which store fired the notification. +# - Idempotency: every normalized event has a stable `id` so consumers can dedupe. +# - Escape hatch: the raw signed payload from the store is preserved in +# `rawSignedPayload` for consumers that need platform-specific fields. + +# What kind of lifecycle change occurred. Mapped from ASN v2 notificationType +# and RTDN notificationType to a unified vocabulary. +enum WebhookEventType { + """ + Initial purchase or first conversion from a free trial / intro offer. + iOS: SUBSCRIBED (initialBuy / resubscribe). + Android: SUBSCRIPTION_PURCHASED. + """ + SubscriptionStarted + """ + Auto-renewal succeeded for an existing subscription. + iOS: DID_RENEW. + Android: SUBSCRIPTION_RENEWED. + """ + SubscriptionRenewed + """ + Subscription reached its expiration without a successful renewal. + iOS: EXPIRED. + Android: SUBSCRIPTION_EXPIRED. + """ + SubscriptionExpired + """ + Billing failed; the subscription is in a grace period during which the user + retains entitlement while payment is retried. + iOS: DID_FAIL_TO_RENEW (with grace period active). + Android: SUBSCRIPTION_IN_GRACE_PERIOD. + """ + SubscriptionInGracePeriod + """ + Billing failed and the subscription is in account-hold / billing retry, + during which entitlement is paused but the subscription is not yet expired. + iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). + Android: SUBSCRIPTION_ON_HOLD. + """ + SubscriptionInBillingRetry + """ + Subscription returned to active state after a billing issue or pause. + iOS: DID_RECOVER. + Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + """ + SubscriptionRecovered + """ + User turned off auto-renew. Access continues until the current period ends. + iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). + Android: SUBSCRIPTION_CANCELED. + """ + SubscriptionCanceled + """ + User reactivated auto-renew before the subscription expired. + iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned on). + Android: SUBSCRIPTION_RESTARTED (when re-enabled, not after billing recovery). + """ + SubscriptionUncanceled + """ + Access immediately revoked (family sharing removal, admin action, fraud). + iOS: REVOKE. + Android: SUBSCRIPTION_REVOKED. + """ + SubscriptionRevoked + """ + A price change is pending or has been confirmed by the user. + iOS: PRICE_INCREASE. + Android: SUBSCRIPTION_PRICE_CHANGE_CONFIRMED. + """ + SubscriptionPriceChange + """ + User upgraded, downgraded, or crossgraded their plan. + iOS: DID_CHANGE_RENEWAL_PREF. + Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. + """ + SubscriptionProductChanged + """ + Subscription paused (Android only feature). + Android: SUBSCRIPTION_PAUSED. + """ + SubscriptionPaused + """ + Paused subscription resumed (Android only feature). + Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + """ + SubscriptionResumed + """ + Refund issued for a one-time purchase or subscription period. + iOS: REFUND. + Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. + """ + PurchaseRefunded + """ + iOS-only: App Store requests a consumption status report for a refund decision. + Servers should respond via the StoreKit consumption API. + """ + PurchaseConsumptionRequest + """ + Sandbox or test notification fired by the store for diagnostic purposes. + Useful for verifying webhook plumbing without a live transaction. + """ + TestNotification +} + +# Which store-side notification system produced this event. +enum WebhookEventSource { + AppleAppStoreServerNotificationsV2 + GooglePlayRealTimeDeveloperNotifications +} + +# Environment of the source notification, as reported by the store. +enum WebhookEventEnvironment { + Production + Sandbox + Xcode +} + +# Normalized cross-store subscription state derived from the webhook event. +enum SubscriptionState { + Active + InGracePeriod + InBillingRetry + Expired + Revoked + Refunded + Paused + Unknown +} + +# Why a subscription was canceled, when applicable. +enum WebhookCancellationReason { + UserCanceled + BillingError + PriceIncreaseDeclined + ProductUnavailable + Refunded + Other +} + +# A normalized lifecycle event delivered to clients via Subscription.webhookEvent. +type WebhookEvent { + """ + Stable identifier suitable for idempotency. Derived from the source notification + UUID where the store provides one (ASN v2 `notificationUUID`, RTDN message id); + otherwise hashed from the canonicalized payload. + """ + id: ID! + type: WebhookEventType! + source: WebhookEventSource! + platform: IapPlatform! + """ + kit project that owns the subscription / purchase this event refers to. + """ + projectId: ID! + """ + Time the underlying event occurred at the store. Epoch milliseconds. + """ + occurredAt: Float! + """ + Time kit ingested and normalized this event. Epoch milliseconds. + """ + receivedAt: Float! + environment: WebhookEventEnvironment! + """ + Cross-platform purchase identity used to correlate this event with an existing + purchase record. iOS: `originalTransactionId`. Android: `purchaseToken`. + """ + purchaseToken: String! + """ + Product the event pertains to. May be null for account-level events. + """ + productId: String + """ + Normalized subscription state at the time of event, when the event refers to + a subscription. Null for one-time purchase events. + """ + subscriptionState: SubscriptionState + """ + When the current subscription period ends. Epoch milliseconds. + """ + expiresAt: Float + """ + When auto-renewal will charge again. Epoch milliseconds. + """ + renewsAt: Float + """ + Reason for cancellation, when applicable. + """ + cancellationReason: WebhookCancellationReason + """ + Localized currency code (ISO 4217) at event time, when available. + """ + currency: String + """ + Price in micros (1/1,000,000 of the currency unit) at event time, when available. + Matches Google Play's `priceAmountMicros` convention; iOS values are converted. + """ + priceAmountMicros: Float + """ + Original signed payload from the store. ASN v2 events expose the JWS string; + RTDN events expose the base64-decoded Pub/Sub message JSON. Provided so that + consumers can independently verify or extract platform-specific fields. kit + always validates this payload before emitting the event. + """ + rawSignedPayload: String +} + +extend type Subscription { + """ + Streams normalized webhook events tied to the authenticated client's purchases. + Clients only receive events whose `purchaseToken` matches a purchase they own. + + Transport: kit serves this over WebSocket. SDKs auto-connect when the host app + enters foreground and disconnect when it goes to background. Events that fire + while the connection is closed are reconciled via `webhookEventsSince` on + reconnect or the next foreground entry. + """ + webhookEvent: WebhookEvent! +} + +extend type Query { + """ + Replay missed webhook events for the authenticated client since the given + timestamp. SDKs call this on reconnect / foreground entry to backfill events + that occurred while the WebSocket was closed. + """ + webhookEventsSince(sinceMs: Float!, limit: Int): [WebhookEvent!]! +} From ca597d714e385af62da09257c63658d56d35b4a0 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 30 Apr 2026 23:42:46 +0900 Subject: [PATCH 02/81] feat(kit): ingest Apple ASN v2 + Google RTDN webhooks (Phase 1 PR #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires kit as the server-side webhook receiver so consumers can drop their own backend. Apple App Store Server Notifications v2 and Google Play Real-Time Developer Notifications are now verified, normalized into the unified `WebhookEvent` shape from PR #1, and persisted with idempotency for replay/dedup. What's in: - Convex schema: `webhookEvents` (normalized event log, indexed by project / purchase-token / received-at) plus `webhookIdempotencyKeys` (`(source, sourceNotificationId)` unique guard). - `convex/webhooks/shared.ts`: pure normalizers `normalizeAppleAsn` and `normalizeGoogleRtdn` mapping the 30+ upstream notification types and subtypes to the 16-value openiap vocabulary defined in `webhook.graphql`. Translates Apple's millicent pricing into Google- compatible micros and Apple `expirationIntent` / Google `cancelReason` into `WebhookCancellationReason`. - `convex/webhooks/internal.ts`: `recordWebhookEvent` does the dedup-first insert; Apple's transient-5xx retries and Pub/Sub's at-least-once delivery both collapse into `deduped: true` with a 200 ACK. `pruneWebhookEvents` daily-prunes events past the 30-day retention window so `webhookEventsSince` stays bounded. - `convex/webhooks/apple.ts`: action that decodes the signed payload, verifies it via Apple's `SignedDataVerifier` against the project's bundle ID + cached root certificates, decodes the embedded transaction + renewal JWS, then normalizes and inserts. - `convex/webhooks/google.ts`: action that takes the parsed Pub/Sub RTDN body, optionally enriches via `purchases.subscriptionsv2.get` for state/expiry/price (graceful fallback when the project hasn't uploaded service-account credentials yet), then normalizes and inserts. - `convex/webhooks/query.ts`: `webhookEventsSince` query used by SDKs on reconnect to backfill events delivered while their WebSocket was closed. Capped at 500 per page, ascending by `receivedAt`. - `server/api/v1/webhooks.ts`: Hono routes mounted at `/v1/webhooks/apple/{apiKey}` and `/v1/webhooks/google/{apiKey}`. Apple route validates the signedPayload envelope before dispatching; Google route verifies Pub/Sub OIDC bearer tokens against the configured `GOOGLE_PUBSUB_PUSH_AUDIENCE` and decodes base64 `message.data` before dispatching. Unsupported notification types ACK 200 + `dropped: true` so the upstream stops retrying — Apple and Google ship new types ahead of the spec. Verification: - 29 new vitest tests in `shared.test.ts` cover every spec event type plus the cancellation-reason translations and rejection paths (missing UUID / unsupported type / missing purchaseToken). Total kit suite: 254/254 passing. - `bun run lint` clean (0 errors); `bun run smoke:server` passes (server compiles + boots, all probes green); `bun run audit:docs` no new failures. What's NOT in (handled in follow-ups): - GraphQL Subscription endpoint that streams `webhookEvents` to clients over WebSocket (Phase 1 PR #3). - Apple/Google sandbox dry-run end-to-end test — needs live credentials and is out of scope for an autonomous run; the receiver is wired so a maintainer can register the URL in App Store Connect / Pub/Sub and verify with a TEST notification. - 5 SDK transport integrations (Phase 1 PR #4-8). Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 1 + packages/kit/convex/_generated/api.d.ts | 12 + packages/kit/convex/schema.ts | 94 +++ packages/kit/convex/webhooks/apple.ts | 256 ++++++++ packages/kit/convex/webhooks/google.ts | 221 +++++++ packages/kit/convex/webhooks/internal.ts | 187 ++++++ packages/kit/convex/webhooks/query.ts | 94 +++ packages/kit/convex/webhooks/shared.test.ts | 496 +++++++++++++++ packages/kit/convex/webhooks/shared.ts | 639 ++++++++++++++++++++ packages/kit/convex/webhooks/validators.ts | 62 ++ packages/kit/package.json | 1 + packages/kit/server/api/v1/routes.ts | 13 + packages/kit/server/api/v1/webhooks.ts | 271 +++++++++ 13 files changed, 2347 insertions(+) create mode 100644 packages/kit/convex/webhooks/apple.ts create mode 100644 packages/kit/convex/webhooks/google.ts create mode 100644 packages/kit/convex/webhooks/internal.ts create mode 100644 packages/kit/convex/webhooks/query.ts create mode 100644 packages/kit/convex/webhooks/shared.test.ts create mode 100644 packages/kit/convex/webhooks/shared.ts create mode 100644 packages/kit/convex/webhooks/validators.ts create mode 100644 packages/kit/server/api/v1/webhooks.ts diff --git a/bun.lock b/bun.lock index c6313903..19a4b7e1 100644 --- a/bun.lock +++ b/bun.lock @@ -90,6 +90,7 @@ "antd": "^6.1.0", "clsx": "^2.1.1", "convex": "^1.29.2", + "google-auth-library": "^10.6.2", "googleapis": "^157.0.0", "hono": "^4.9.9", "hono-openapi": "^1.1.0", diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index 917f69fc..2fd08626 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -53,6 +53,12 @@ import type * as users_query from "../users/query.js"; import type * as utils_errors from "../utils/errors.js"; import type * as utils_helpers from "../utils/helpers.js"; import type * as utils_validation from "../utils/validation.js"; +import type * as webhooks_apple from "../webhooks/apple.js"; +import type * as webhooks_google from "../webhooks/google.js"; +import type * as webhooks_internal from "../webhooks/internal.js"; +import type * as webhooks_query from "../webhooks/query.js"; +import type * as webhooks_shared from "../webhooks/shared.js"; +import type * as webhooks_validators from "../webhooks/validators.js"; import type { ApiFromModules, @@ -106,6 +112,12 @@ declare const fullApi: ApiFromModules<{ "utils/errors": typeof utils_errors; "utils/helpers": typeof utils_helpers; "utils/validation": typeof utils_validation; + "webhooks/apple": typeof webhooks_apple; + "webhooks/google": typeof webhooks_google; + "webhooks/internal": typeof webhooks_internal; + "webhooks/query": typeof webhooks_query; + "webhooks/shared": typeof webhooks_shared; + "webhooks/validators": typeof webhooks_validators; }>; /** diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 5bc85fbe..90569cf4 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -401,6 +401,100 @@ const schema = defineSchema({ cursor: v.number(), updatedAt: v.number(), }).index("by_jobName", ["jobName"]), + + // Normalized lifecycle webhook events ingested from Apple ASN v2 and + // Google RTDN. Mirrors the GraphQL `WebhookEvent` shape defined in + // `packages/gql/src/webhook.graphql` — kit's Subscription endpoint + // streams rows from this table to authenticated clients, and the + // `webhookEventsSince` query backfills events that occurred while a + // client's WebSocket was closed. + // + // Retention: rows are pruned by the `pruneWebhookEvents` cron after + // 30 days. The replay window matches `webhookEventsSince` so clients + // returning from a long offline period can still reconcile. + webhookEvents: defineTable({ + projectId: v.id("projects"), + type: v.union( + v.literal("SubscriptionStarted"), + v.literal("SubscriptionRenewed"), + v.literal("SubscriptionExpired"), + v.literal("SubscriptionInGracePeriod"), + v.literal("SubscriptionInBillingRetry"), + v.literal("SubscriptionRecovered"), + v.literal("SubscriptionCanceled"), + v.literal("SubscriptionUncanceled"), + v.literal("SubscriptionRevoked"), + v.literal("SubscriptionPriceChange"), + v.literal("SubscriptionProductChanged"), + v.literal("SubscriptionPaused"), + v.literal("SubscriptionResumed"), + v.literal("PurchaseRefunded"), + v.literal("PurchaseConsumptionRequest"), + v.literal("TestNotification"), + ), + source: v.union( + v.literal("AppleAppStoreServerNotificationsV2"), + v.literal("GooglePlayRealTimeDeveloperNotifications"), + ), + platform: v.union(v.literal("IOS"), v.literal("Android")), + environment: v.union( + v.literal("Production"), + v.literal("Sandbox"), + v.literal("Xcode"), + ), + purchaseToken: v.string(), + // Original notification id from the store (ASN v2 `notificationUUID` + // or RTDN Pub/Sub `messageId`). Surfaced as the GraphQL `id` field + // for clients and used to correlate events during pruning. + sourceNotificationId: v.string(), + productId: v.optional(v.string()), + subscriptionState: v.optional( + v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), + ), + ), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional( + v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), + ), + ), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + rawSignedPayload: v.optional(v.string()), + occurredAt: v.number(), + receivedAt: v.number(), + }) + .index("by_project", ["projectId"]) + .index("by_purchase_token", ["purchaseToken"]) + .index("by_project_and_received", ["projectId", "receivedAt"]) + .index("by_received_at", ["receivedAt"]), + + // Dedup table for webhook payloads. Insertion uses + // `(source, sourceNotificationId)` as the natural key; duplicates + // detected here cause kit to silently ACK the upstream request with + // 200 without re-emitting the event, matching Apple's documented + // expectation that ASN may retry the same notification on transient + // 5xx and Google's at-least-once Pub/Sub delivery contract. + webhookIdempotencyKeys: defineTable({ + source: v.union(v.literal("apple"), v.literal("google")), + sourceNotificationId: v.string(), + eventId: v.optional(v.id("webhookEvents")), + firstSeenAt: v.number(), + }).index("by_source_and_id", ["source", "sourceNotificationId"]), }); export default schema; diff --git a/packages/kit/convex/webhooks/apple.ts b/packages/kit/convex/webhooks/apple.ts new file mode 100644 index 00000000..5cdf71f1 --- /dev/null +++ b/packages/kit/convex/webhooks/apple.ts @@ -0,0 +1,256 @@ +"use node"; +import { + Environment, + SignedDataVerifier, + type ResponseBodyV2DecodedPayload, + type JWSTransactionDecodedPayload, + type JWSRenewalInfoDecodedPayload, +} from "@apple/app-store-server-library"; +import { v } from "convex/values"; + +import { action } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; +import { loadAppleRootCertificates } from "../certificates/apple_root_certificates"; +import { getProjectByApiKey } from "../purchases/shared"; +import { + normalizeAppleAsn, + WebhookNormalizationError, + type AppleAsnPayload, + type AppleDecodedTransaction, + type AppleDecodedRenewalInfo, +} from "./shared"; + +type IngestResult = { + eventId: Id<"webhookEvents">; + type: string; + deduped: boolean; +}; + +// HTTP receiver invoked from `server/api/v1/webhooks.ts`. The Hono +// route forwards Apple's POST body (a JSON envelope `{ signedPayload }`) +// and the project's API key. +// +// The action verifies the signedPayload with `SignedDataVerifier`, +// decodes the embedded transaction + renewal JWS, normalizes everything +// through `normalizeAppleAsn`, then calls the idempotent insert mutation. +// Apple retries the same notificationUUID on transient 5xx — that case +// is collapsed inside `recordWebhookEvent` (returns `deduped: true`) +// and the route still responds 200 so Apple stops retrying. +export const ingestAppleAsn = action({ + args: { + apiKey: v.string(), + signedPayload: v.string(), + }, + returns: v.object({ + eventId: v.id("webhookEvents"), + type: v.string(), + deduped: v.boolean(), + }), + handler: async (ctx, args): Promise => { + const project = await getProjectByApiKey(ctx, args.apiKey); + + // Decode without verification to inspect environment + bundleId + // before instantiating the verifier — the verifier requires the + // environment up front. + const previewPayload = previewDecodeNotification(args.signedPayload); + + if ( + project.iosBundleId && + previewPayload.data?.bundleId && + previewPayload.data.bundleId !== project.iosBundleId + ) { + throw new Error( + `Bundle ID mismatch: notification ${previewPayload.data.bundleId} vs project ${project.iosBundleId}`, + ); + } + + const environment = mapPreviewEnvironment(previewPayload.data?.environment); + const appleRootCAs = loadAppleRootCertificates(); + const verifier = new SignedDataVerifier( + appleRootCAs, + // `enableOnlineChecks: false` keeps webhook latency predictable — + // ASN v2 retries on 5xx, but the same OCSP/CRL hiccup that breaks + // a verifyAndDecodeNotification call would be a permanent + // failure here. We still validate the certificate chain offline. + false, + environment, + project.iosBundleId ?? "", + project.iosAppAppleId, + ); + + let payload: ResponseBodyV2DecodedPayload; + try { + payload = await verifier.verifyAndDecodeNotification(args.signedPayload); + } catch (error) { + console.error("[webhooks/apple] notification verification failed", error); + throw new Error("Apple ASN v2 signature verification failed"); + } + + // Decode transaction + renewal JWS if present. Apple sends them + // signed individually inside the outer payload; verifying them is + // optional for ingestion since the outer signature already attests + // to their integrity. We still parse to extract structured fields. + const transaction = decodeOptionalJws( + payload.data?.signedTransactionInfo, + ); + const renewalInfo = decodeOptionalJws( + payload.data?.signedRenewalInfo, + ); + + let normalized; + try { + normalized = normalizeAppleAsn({ + payload: toAppleAsnPayload(payload), + transaction: toDecodedTransaction(transaction), + renewalInfo: toDecodedRenewalInfo(renewalInfo), + }); + } catch (error) { + if (error instanceof WebhookNormalizationError) { + // Unsupported notification types are not a kit failure — Apple + // ships new types ahead of openiap spec updates. Log and ACK. + console.warn( + "[webhooks/apple] dropping unsupported notification", + error.code, + error.message, + ); + throw new Error(`UNSUPPORTED_EVENT: ${error.message}`); + } + throw error; + } + + const result = await ctx.runMutation( + internal.webhooks.internal.recordWebhookEvent, + { + projectId: project._id, + source: "apple", + sourceNotificationId: normalized.sourceNotificationId, + event: { + type: normalized.type, + sourceFull: normalized.source, + platform: normalized.platform, + environment: normalized.environment, + purchaseToken: normalized.purchaseToken, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + occurredAt: normalized.occurredAt, + rawSignedPayload: args.signedPayload, + }, + }, + ); + + return { + eventId: result.eventId, + type: normalized.type, + deduped: result.deduped, + }; + }, +}); + +// Decode JWS payload without signature verification. Used pre-verifier +// to discover the environment so we can instantiate SignedDataVerifier +// with the correct value. +function previewDecodeNotification(jws: string): { + data?: { environment?: string; bundleId?: string }; +} { + const parts = jws.split("."); + if (parts.length !== 3) { + throw new Error("Apple notification is not a valid JWS"); + } + try { + const decoded = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ); + return decoded as { data?: { environment?: string; bundleId?: string } }; + } catch { + throw new Error("Apple notification body is not valid JSON"); + } +} + +function mapPreviewEnvironment(value: string | undefined): Environment { + switch (value) { + case "Sandbox": + return Environment.SANDBOX; + case "Xcode": + return Environment.XCODE; + default: + return Environment.PRODUCTION; + } +} + +function decodeOptionalJws(jws: string | null | undefined): T | null { + if (!jws) { + return null; + } + const parts = jws.split("."); + if (parts.length !== 3) { + return null; + } + try { + return JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ) as T; + } catch { + return null; + } +} + +function toAppleAsnPayload( + payload: ResponseBodyV2DecodedPayload, +): AppleAsnPayload { + return { + notificationType: String(payload.notificationType ?? ""), + subtype: payload.subtype ? String(payload.subtype) : null, + notificationUUID: payload.notificationUUID ?? "", + signedDate: payload.signedDate ?? Date.now(), + data: payload.data + ? { + environment: payload.data.environment ?? null, + bundleId: payload.data.bundleId ?? null, + appAppleId: payload.data.appAppleId ?? null, + signedTransactionInfo: payload.data.signedTransactionInfo ?? null, + signedRenewalInfo: payload.data.signedRenewalInfo ?? null, + } + : null, + }; +} + +function toDecodedTransaction( + transaction: JWSTransactionDecodedPayload | null, +): AppleDecodedTransaction | null { + if (!transaction) { + return null; + } + return { + originalTransactionId: transaction.originalTransactionId ?? null, + transactionId: transaction.transactionId ?? null, + productId: transaction.productId ?? null, + expiresDate: transaction.expiresDate ?? null, + revocationReason: transaction.revocationReason ?? null, + currency: transaction.currency ?? null, + price: transaction.price ?? null, + }; +} + +function toDecodedRenewalInfo( + renewalInfo: JWSRenewalInfoDecodedPayload | null, +): AppleDecodedRenewalInfo | null { + if (!renewalInfo) { + return null; + } + return { + autoRenewStatus: renewalInfo.autoRenewStatus ?? null, + autoRenewProductId: renewalInfo.autoRenewProductId ?? null, + expirationIntent: renewalInfo.expirationIntent ?? null, + gracePeriodExpiresDate: renewalInfo.gracePeriodExpiresDate ?? null, + isInBillingRetryPeriod: renewalInfo.isInBillingRetryPeriod ?? null, + renewalDate: renewalInfo.renewalDate ?? null, + recentSubscriptionStartDate: + renewalInfo.recentSubscriptionStartDate ?? null, + }; +} diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts new file mode 100644 index 00000000..153cbfd1 --- /dev/null +++ b/packages/kit/convex/webhooks/google.ts @@ -0,0 +1,221 @@ +"use node"; +import { v } from "convex/values"; +import { google } from "googleapis"; + +import { action } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; +import { getProjectByApiKey } from "../purchases/shared"; +import { + normalizeGoogleRtdn, + WebhookNormalizationError, + type GoogleRtdnPayload, + type GoogleSubscriptionInfo, +} from "./shared"; + +type IngestResult = { + eventId: Id<"webhookEvents">; + type: string; + deduped: boolean; +}; + +// HTTP receiver invoked from `server/api/v1/webhooks.ts` after the +// route layer has verified the Pub/Sub push OIDC token. +// +// The action expects the *parsed* RTDN body — the route is responsible +// for base64-decoding `message.data` and shaping it into our +// GoogleRtdnPayload. From here we optionally enrich with a fetch to +// `androidpublisher.purchases.subscriptionsv2.get` (needs the project's +// service-account JSON) and then call the idempotent insert mutation. +// +// At-least-once Pub/Sub delivery means we'll see duplicate `messageId`s +// on retries; `recordWebhookEvent` collapses those into `deduped: true`. +export const ingestGoogleRtdn = action({ + args: { + apiKey: v.string(), + rawMessage: v.string(), + payload: v.object({ + messageId: v.string(), + packageName: v.optional(v.string()), + eventTimeMillis: v.number(), + subscriptionNotification: v.optional( + v.object({ + notificationType: v.number(), + purchaseToken: v.string(), + subscriptionId: v.string(), + }), + ), + oneTimeProductNotification: v.optional( + v.object({ + notificationType: v.number(), + purchaseToken: v.string(), + sku: v.string(), + }), + ), + voidedPurchaseNotification: v.optional( + v.object({ + purchaseToken: v.string(), + orderId: v.optional(v.string()), + productType: v.optional(v.number()), + refundType: v.optional(v.number()), + }), + ), + testNotification: v.optional(v.object({ version: v.string() })), + }), + }, + returns: v.object({ + eventId: v.id("webhookEvents"), + type: v.string(), + deduped: v.boolean(), + }), + handler: async (ctx, args): Promise => { + const project = await getProjectByApiKey(ctx, args.apiKey); + + if ( + project.androidPackageName && + args.payload.packageName && + args.payload.packageName !== project.androidPackageName + ) { + throw new Error( + `Package name mismatch: notification ${args.payload.packageName} vs project ${project.androidPackageName}`, + ); + } + + const subscriptionInfo = await maybeFetchSubscriptionInfo( + ctx, + project._id, + project.androidPackageName, + args.payload, + ); + + let normalized; + try { + normalized = normalizeGoogleRtdn({ + payload: args.payload as GoogleRtdnPayload, + subscriptionInfo, + }); + } catch (error) { + if (error instanceof WebhookNormalizationError) { + console.warn( + "[webhooks/google] dropping unsupported notification", + error.code, + error.message, + ); + throw new Error(`UNSUPPORTED_EVENT: ${error.message}`); + } + throw error; + } + + const result = await ctx.runMutation( + internal.webhooks.internal.recordWebhookEvent, + { + projectId: project._id, + source: "google", + sourceNotificationId: normalized.sourceNotificationId, + event: { + type: normalized.type, + sourceFull: normalized.source, + platform: normalized.platform, + environment: normalized.environment, + purchaseToken: normalized.purchaseToken, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + occurredAt: normalized.occurredAt, + rawSignedPayload: args.rawMessage, + }, + }, + ); + + return { + eventId: result.eventId, + type: normalized.type, + deduped: result.deduped, + }; + }, +}); + +// Best-effort enrichment with subscriptionsv2.get. Returns null when: +// - the project has no Play service account configured (the event +// still flows through with type-derived state), +// - the notification is one-time / voided / test (no subscription to +// look up), +// - or the API call fails. We deliberately swallow the failure rather +// than hard-fail the webhook: kit's authoritative state can be +// reconciled later via `verifyReceipt`. +async function maybeFetchSubscriptionInfo( + ctx: { runAction: any; runQuery: any }, + projectId: unknown, + packageName: string | undefined, + payload: GoogleRtdnPayload, +): Promise { + if (!payload.subscriptionNotification || !packageName) { + return null; + } + + try { + const serviceAccountFile = await ctx.runQuery( + internal.files.internal.getGooglePlayFileByProjectInternal, + { projectId }, + ); + if (!serviceAccountFile) { + return null; + } + const fileContent = await ctx.runAction( + internal.files.internal.readFileAsText, + { fileId: serviceAccountFile._id }, + ); + if (!fileContent?.content) { + return null; + } + const credentials = JSON.parse(fileContent.content); + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ["https://www.googleapis.com/auth/androidpublisher"], + }); + const androidpublisher = google.androidpublisher({ version: "v3", auth }); + + const response = await androidpublisher.purchases.subscriptionsv2.get({ + packageName, + token: payload.subscriptionNotification.purchaseToken, + }); + + const data = response.data; + const expiry = + data.lineItems?.[0]?.expiryTime ?? + // Fallback: pre-v2 format had `expiryTimeMillis` at the root. + undefined; + const renews = data.lineItems?.[0]?.autoRenewingPlan?.recurringPrice + ? data.lineItems?.[0]?.expiryTime + : undefined; + const recurring = data.lineItems?.[0]?.autoRenewingPlan?.recurringPrice; + + return { + state: data.subscriptionState ?? undefined, + cancelReason: data.canceledStateContext?.userInitiatedCancellation + ? "USER_CANCELED" + : data.canceledStateContext?.systemInitiatedCancellation + ? "SYSTEM_INITIATED_CANCELLATION" + : undefined, + expiryTimeMillis: expiry ? Date.parse(expiry) : undefined, + autoRenewingPlanRenewsTimeMillis: renews ? Date.parse(renews) : undefined, + currency: recurring?.currencyCode ?? undefined, + priceAmountMicros: recurring?.units + ? // `units` is BigInt-as-string, `nanos` is the fractional part. + // Combine to micros: units * 1_000_000 + nanos / 1_000. + Number(recurring.units) * 1_000_000 + + Math.round((recurring.nanos ?? 0) / 1_000) + : undefined, + }; + } catch (error) { + console.warn( + "[webhooks/google] subscriptionsv2 fetch failed; falling back to type-derived state", + error, + ); + return null; + } +} diff --git a/packages/kit/convex/webhooks/internal.ts b/packages/kit/convex/webhooks/internal.ts new file mode 100644 index 00000000..d2322606 --- /dev/null +++ b/packages/kit/convex/webhooks/internal.ts @@ -0,0 +1,187 @@ +import { internalMutation } from "../_generated/server"; +import { v } from "convex/values"; +import type { Id } from "../_generated/dataModel"; + +// Insert a normalized webhook event with idempotency on +// `(source, sourceNotificationId)`. Returns the existing event id +// (and `deduped: true`) if Apple/Google retries the same notification. +// +// This is the only path that writes to `webhookEvents` / +// `webhookIdempotencyKeys`. The action layer (apple.ts / google.ts) +// must verify the upstream signature and project ownership before +// calling this — the mutation trusts its arguments. +export const recordWebhookEvent = internalMutation({ + args: { + projectId: v.id("projects"), + source: v.union(v.literal("apple"), v.literal("google")), + sourceNotificationId: v.string(), + event: v.object({ + type: v.union( + v.literal("SubscriptionStarted"), + v.literal("SubscriptionRenewed"), + v.literal("SubscriptionExpired"), + v.literal("SubscriptionInGracePeriod"), + v.literal("SubscriptionInBillingRetry"), + v.literal("SubscriptionRecovered"), + v.literal("SubscriptionCanceled"), + v.literal("SubscriptionUncanceled"), + v.literal("SubscriptionRevoked"), + v.literal("SubscriptionPriceChange"), + v.literal("SubscriptionProductChanged"), + v.literal("SubscriptionPaused"), + v.literal("SubscriptionResumed"), + v.literal("PurchaseRefunded"), + v.literal("PurchaseConsumptionRequest"), + v.literal("TestNotification"), + ), + sourceFull: v.union( + v.literal("AppleAppStoreServerNotificationsV2"), + v.literal("GooglePlayRealTimeDeveloperNotifications"), + ), + platform: v.union(v.literal("IOS"), v.literal("Android")), + environment: v.union( + v.literal("Production"), + v.literal("Sandbox"), + v.literal("Xcode"), + ), + purchaseToken: v.string(), + productId: v.optional(v.string()), + subscriptionState: v.optional( + v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), + ), + ), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional( + v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), + ), + ), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + occurredAt: v.number(), + rawSignedPayload: v.optional(v.string()), + }), + }, + returns: v.object({ + eventId: v.id("webhookEvents"), + deduped: v.boolean(), + }), + handler: async (ctx, args) => { + // Dedup check first. Apple ASN may retry the same notificationUUID + // on transient 5xx, and Google Pub/Sub guarantees at-least-once + // delivery — both are normal, both must result in HTTP 200 here. + const existing = await ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_source_and_id", (q) => + q + .eq("source", args.source) + .eq("sourceNotificationId", args.sourceNotificationId), + ) + .unique(); + + if (existing?.eventId) { + return { eventId: existing.eventId, deduped: true }; + } + + const now = Date.now(); + + const eventId: Id<"webhookEvents"> = await ctx.db.insert("webhookEvents", { + projectId: args.projectId, + type: args.event.type, + source: args.event.sourceFull, + platform: args.event.platform, + environment: args.event.environment, + purchaseToken: args.event.purchaseToken, + sourceNotificationId: args.sourceNotificationId, + productId: args.event.productId, + subscriptionState: args.event.subscriptionState, + expiresAt: args.event.expiresAt, + renewsAt: args.event.renewsAt, + cancellationReason: args.event.cancellationReason, + currency: args.event.currency, + priceAmountMicros: args.event.priceAmountMicros, + rawSignedPayload: args.event.rawSignedPayload, + occurredAt: args.event.occurredAt, + receivedAt: now, + }); + + if (existing) { + // Idempotency key existed without an eventId (a previous attempt + // crashed between dedup-row insert and event insert). Patch it + // to point at the newly-inserted event so future replays dedup. + await ctx.db.patch(existing._id, { eventId }); + } else { + await ctx.db.insert("webhookIdempotencyKeys", { + source: args.source, + sourceNotificationId: args.sourceNotificationId, + eventId, + firstSeenAt: now, + }); + } + + return { eventId, deduped: false }; + }, +}); + +// Prune events older than the configured retention window. Run on a +// daily cron — `crons.ts` registers the schedule. +export const pruneWebhookEvents = internalMutation({ + args: { + olderThanMs: v.number(), + batchSize: v.optional(v.number()), + }, + returns: v.object({ deletedEvents: v.number(), deletedKeys: v.number() }), + handler: async (ctx, args) => { + const cutoff = Date.now() - args.olderThanMs; + const limit = args.batchSize ?? 200; + + const oldEvents = await ctx.db + .query("webhookEvents") + .withIndex("by_received_at", (q) => q.lt("receivedAt", cutoff)) + .take(limit); + + let deletedEvents = 0; + let deletedKeys = 0; + for (const event of oldEvents) { + // Drop the matching idempotency row. Without this, a stale dedup + // record could outlive its event and silently swallow a future + // (legitimately new) notification that reuses the UUID — very + // unlikely in practice, but the invariant is cheap to keep. + const key = await ctx.db + .query("webhookIdempotencyKeys") + .withIndex("by_source_and_id", (q) => + q + .eq( + "source", + event.source === "AppleAppStoreServerNotificationsV2" + ? "apple" + : "google", + ) + .eq("sourceNotificationId", event.sourceNotificationId), + ) + .unique(); + if (key) { + await ctx.db.delete(key._id); + deletedKeys += 1; + } + await ctx.db.delete(event._id); + deletedEvents += 1; + } + + return { deletedEvents, deletedKeys }; + }, +}); diff --git a/packages/kit/convex/webhooks/query.ts b/packages/kit/convex/webhooks/query.ts new file mode 100644 index 00000000..3d18cb10 --- /dev/null +++ b/packages/kit/convex/webhooks/query.ts @@ -0,0 +1,94 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; + +import { + webhookEventTypeValidator, + webhookEventSourceValidator, + webhookEventEnvironmentValidator, + subscriptionStateValidator, + webhookCancellationReasonValidator, + webhookEventPlatformValidator, +} from "./validators"; + +// Backfill query used by SDKs on reconnect / app foreground entry. +// Returns webhook events for the API key's project that occurred since +// the given timestamp, ordered ascending by `receivedAt` so consumers +// can apply them in order without re-sorting. +// +// We cap results at `limit` (default 100, max 500) and surface +// `_creationTime` so the SDK can checkpoint reliably even if two +// events share `receivedAt` (rare but possible under burst writes). +export const webhookEventsSince = query({ + args: { + apiKey: v.string(), + sinceMs: v.number(), + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + id: v.string(), + type: webhookEventTypeValidator, + source: webhookEventSourceValidator, + platform: webhookEventPlatformValidator, + environment: webhookEventEnvironmentValidator, + projectId: v.id("projects"), + occurredAt: v.number(), + receivedAt: v.number(), + purchaseToken: v.string(), + productId: v.optional(v.string()), + subscriptionState: v.optional(subscriptionStateValidator), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional(webhookCancellationReasonValidator), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + rawSignedPayload: v.optional(v.string()), + }), + ), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + + if (!project) { + // Mirror the convention used by other v1 routes: return empty + // rather than throwing on bad credentials so the route layer can + // attach 401 semantics uniformly via apiKeyMiddleware. + return []; + } + + const limit = Math.min(Math.max(args.limit ?? 100, 1), 500); + + const events = await ctx.db + .query("webhookEvents") + .withIndex("by_project_and_received", (q) => + q.eq("projectId", project._id).gte("receivedAt", args.sinceMs), + ) + .order("asc") + .take(limit); + + return events.map((event) => ({ + // GraphQL `id` is the stable per-notification identifier from + // the store; ASN v2 notificationUUID and RTDN messageId are both + // globally unique and survive replay/dedup. + id: event.sourceNotificationId, + type: event.type, + source: event.source, + platform: event.platform, + environment: event.environment, + projectId: event.projectId, + occurredAt: event.occurredAt, + receivedAt: event.receivedAt, + purchaseToken: event.purchaseToken, + productId: event.productId, + subscriptionState: event.subscriptionState, + expiresAt: event.expiresAt, + renewsAt: event.renewsAt, + cancellationReason: event.cancellationReason, + currency: event.currency, + priceAmountMicros: event.priceAmountMicros, + rawSignedPayload: event.rawSignedPayload, + })); + }, +}); diff --git a/packages/kit/convex/webhooks/shared.test.ts b/packages/kit/convex/webhooks/shared.test.ts new file mode 100644 index 00000000..d95c2a04 --- /dev/null +++ b/packages/kit/convex/webhooks/shared.test.ts @@ -0,0 +1,496 @@ +import { describe, expect, it } from "vitest"; +import { + mapAppleNotificationType, + mapGoogleSubscriptionNotificationType, + mapGoogleOneTimeNotificationType, + normalizeAppleAsn, + normalizeGoogleRtdn, + WebhookNormalizationError, + type AppleAsnPayload, + type AppleDecodedTransaction, + type AppleDecodedRenewalInfo, + type GoogleRtdnPayload, + type GoogleSubscriptionInfo, +} from "./shared"; + +// --------------------------------------------------------------------------- +// Apple ASN v2 mapping +// --------------------------------------------------------------------------- + +describe("mapAppleNotificationType", () => { + it("maps SUBSCRIBED with INITIAL_BUY / RESUBSCRIBE / no subtype to Started", () => { + expect(mapAppleNotificationType("SUBSCRIBED", "INITIAL_BUY")).toBe( + "SubscriptionStarted", + ); + expect(mapAppleNotificationType("SUBSCRIBED", "RESUBSCRIBE")).toBe( + "SubscriptionStarted", + ); + expect(mapAppleNotificationType("SUBSCRIBED")).toBe("SubscriptionStarted"); + }); + + it("distinguishes DID_RENEW (Renewed) from BILLING_RECOVERY (Recovered)", () => { + expect(mapAppleNotificationType("DID_RENEW")).toBe("SubscriptionRenewed"); + expect(mapAppleNotificationType("DID_RENEW", "BILLING_RECOVERY")).toBe( + "SubscriptionRecovered", + ); + }); + + it("distinguishes DID_FAIL_TO_RENEW grace vs billing-retry", () => { + expect(mapAppleNotificationType("DID_FAIL_TO_RENEW", "GRACE_PERIOD")).toBe( + "SubscriptionInGracePeriod", + ); + expect(mapAppleNotificationType("DID_FAIL_TO_RENEW")).toBe( + "SubscriptionInBillingRetry", + ); + }); + + it("maps GRACE_PERIOD_EXPIRED to InBillingRetry", () => { + expect(mapAppleNotificationType("GRACE_PERIOD_EXPIRED")).toBe( + "SubscriptionInBillingRetry", + ); + }); + + it("maps DID_CHANGE_RENEWAL_STATUS by subtype", () => { + expect( + mapAppleNotificationType( + "DID_CHANGE_RENEWAL_STATUS", + "AUTO_RENEW_DISABLED", + ), + ).toBe("SubscriptionCanceled"); + expect( + mapAppleNotificationType( + "DID_CHANGE_RENEWAL_STATUS", + "AUTO_RENEW_ENABLED", + ), + ).toBe("SubscriptionUncanceled"); + // No subtype -> ambiguous, returns null so receiver can drop it + expect(mapAppleNotificationType("DID_CHANGE_RENEWAL_STATUS")).toBeNull(); + }); + + it("maps the remaining single-shot notification types", () => { + expect(mapAppleNotificationType("EXPIRED")).toBe("SubscriptionExpired"); + expect(mapAppleNotificationType("DID_CHANGE_RENEWAL_PREF")).toBe( + "SubscriptionProductChanged", + ); + expect(mapAppleNotificationType("PRICE_INCREASE")).toBe( + "SubscriptionPriceChange", + ); + expect(mapAppleNotificationType("REVOKE")).toBe("SubscriptionRevoked"); + expect(mapAppleNotificationType("REFUND")).toBe("PurchaseRefunded"); + expect(mapAppleNotificationType("REFUND_REVERSED")).toBe( + "SubscriptionStarted", + ); + expect(mapAppleNotificationType("CONSUMPTION_REQUEST")).toBe( + "PurchaseConsumptionRequest", + ); + expect(mapAppleNotificationType("TEST")).toBe("TestNotification"); + }); + + it("returns null for notification types not in the openiap spec yet", () => { + expect(mapAppleNotificationType("OFFER_REDEEMED")).toBeNull(); + expect(mapAppleNotificationType("RENEWAL_EXTENDED")).toBeNull(); + expect(mapAppleNotificationType("EXTERNAL_PURCHASE_TOKEN")).toBeNull(); + expect(mapAppleNotificationType("SOMETHING_NEW_FROM_APPLE")).toBeNull(); + }); +}); + +const baseApplePayload: AppleAsnPayload = { + notificationType: "DID_RENEW", + notificationUUID: "uuid-renew-1", + signedDate: 1_711_000_000_000, + data: { environment: "Production" }, +}; + +const baseTransaction: AppleDecodedTransaction = { + originalTransactionId: "1000000000000001", + transactionId: "1000000000000099", + productId: "com.example.premium_monthly", + expiresDate: 1_713_592_000_000, + // ASN reports `price` in millicents (e.g. $9.99 → 999_000) + price: 999_000, + currency: "USD", +}; + +describe("normalizeAppleAsn", () => { + it("normalizes a vanilla DID_RENEW into SubscriptionRenewed with active state", () => { + const event = normalizeAppleAsn({ + payload: baseApplePayload, + transaction: baseTransaction, + renewalInfo: { renewalDate: 1_713_592_000_000 }, + }); + + expect(event.type).toBe("SubscriptionRenewed"); + expect(event.source).toBe("AppleAppStoreServerNotificationsV2"); + expect(event.platform).toBe("IOS"); + expect(event.environment).toBe("Production"); + expect(event.purchaseToken).toBe("1000000000000001"); + expect(event.productId).toBe("com.example.premium_monthly"); + expect(event.subscriptionState).toBe("Active"); + expect(event.expiresAt).toBe(1_713_592_000_000); + expect(event.renewsAt).toBe(1_713_592_000_000); + expect(event.currency).toBe("USD"); + // 999_000 millicents = 9_990_000 micros ($9.99) + expect(event.priceAmountMicros).toBe(9_990_000); + expect(event.occurredAt).toBe(1_711_000_000_000); + expect(event.sourceNotificationId).toBe("uuid-renew-1"); + }); + + it("derives Sandbox/Xcode environments and falls back to Production on missing data", () => { + const sandbox = normalizeAppleAsn({ + payload: { ...baseApplePayload, data: { environment: "Sandbox" } }, + transaction: baseTransaction, + }); + expect(sandbox.environment).toBe("Sandbox"); + + const xcode = normalizeAppleAsn({ + payload: { ...baseApplePayload, data: { environment: "Xcode" } }, + transaction: baseTransaction, + }); + expect(xcode.environment).toBe("Xcode"); + + const missing = normalizeAppleAsn({ + payload: { ...baseApplePayload, data: null }, + transaction: baseTransaction, + }); + expect(missing.environment).toBe("Production"); + }); + + it("maps AUTO_RENEW_DISABLED into Canceled while keeping state Active until expiry", () => { + const event = normalizeAppleAsn({ + payload: { + ...baseApplePayload, + notificationType: "DID_CHANGE_RENEWAL_STATUS", + subtype: "AUTO_RENEW_DISABLED", + }, + transaction: baseTransaction, + }); + expect(event.type).toBe("SubscriptionCanceled"); + expect(event.subscriptionState).toBe("Active"); + expect(event.cancellationReason).toBe("UserCanceled"); + }); + + it("translates Apple expirationIntent codes 1..5 to cancellation reasons", () => { + const cases: Array<[number, string]> = [ + [1, "UserCanceled"], + [2, "BillingError"], + [3, "PriceIncreaseDeclined"], + [4, "ProductUnavailable"], + [5, "Other"], + ]; + for (const [intent, reason] of cases) { + const event = normalizeAppleAsn({ + payload: { ...baseApplePayload, notificationType: "EXPIRED" }, + transaction: baseTransaction, + renewalInfo: { expirationIntent: intent } as AppleDecodedRenewalInfo, + }); + expect(event.type).toBe("SubscriptionExpired"); + expect(event.cancellationReason).toBe(reason); + } + }); + + it("flags REVOKE / REFUND with cancellationReason = Refunded", () => { + const revoke = normalizeAppleAsn({ + payload: { ...baseApplePayload, notificationType: "REVOKE" }, + transaction: baseTransaction, + }); + expect(revoke.type).toBe("SubscriptionRevoked"); + expect(revoke.cancellationReason).toBe("Refunded"); + + const refund = normalizeAppleAsn({ + payload: { ...baseApplePayload, notificationType: "REFUND" }, + transaction: baseTransaction, + }); + expect(refund.type).toBe("PurchaseRefunded"); + expect(refund.cancellationReason).toBe("Refunded"); + expect(refund.subscriptionState).toBe("Refunded"); + }); + + it("accepts a TEST notification with no transaction/renewal data", () => { + const event = normalizeAppleAsn({ + payload: { + notificationType: "TEST", + notificationUUID: "test-uuid-1", + signedDate: 1_711_000_000_000, + data: { environment: "Sandbox" }, + }, + }); + expect(event.type).toBe("TestNotification"); + expect(event.purchaseToken).toBe("apple-test-test-uuid-1"); + expect(event.environment).toBe("Sandbox"); + // Test notifications have no subscription state in the spec + expect(event.subscriptionState).toBeUndefined(); + }); + + it("rejects unsupported notification types", () => { + expect(() => + normalizeAppleAsn({ + payload: { ...baseApplePayload, notificationType: "OFFER_REDEEMED" }, + transaction: baseTransaction, + }), + ).toThrow(WebhookNormalizationError); + }); + + it("rejects payloads missing notificationUUID", () => { + expect(() => + normalizeAppleAsn({ + payload: { + ...baseApplePayload, + notificationUUID: "" as string, + }, + transaction: baseTransaction, + }), + ).toThrow(/notificationUUID/); + }); + + it("rejects non-test payloads missing originalTransactionId", () => { + expect(() => + normalizeAppleAsn({ + payload: baseApplePayload, + transaction: { productId: "x" }, + }), + ).toThrow(/originalTransactionId/); + }); +}); + +// --------------------------------------------------------------------------- +// Google RTDN mapping +// --------------------------------------------------------------------------- + +describe("mapGoogleSubscriptionNotificationType", () => { + it("maps the documented numeric codes to spec event types", () => { + expect(mapGoogleSubscriptionNotificationType(1)).toBe( + "SubscriptionStarted", + ); + expect(mapGoogleSubscriptionNotificationType(2)).toBe( + "SubscriptionRenewed", + ); + expect(mapGoogleSubscriptionNotificationType(3)).toBe( + "SubscriptionCanceled", + ); + expect(mapGoogleSubscriptionNotificationType(4)).toBe( + "SubscriptionRecovered", + ); + expect(mapGoogleSubscriptionNotificationType(5)).toBe( + "SubscriptionInBillingRetry", + ); + expect(mapGoogleSubscriptionNotificationType(6)).toBe( + "SubscriptionInGracePeriod", + ); + expect(mapGoogleSubscriptionNotificationType(8)).toBe( + "SubscriptionPriceChange", + ); + expect(mapGoogleSubscriptionNotificationType(9)).toBe( + "SubscriptionProductChanged", + ); + expect(mapGoogleSubscriptionNotificationType(10)).toBe( + "SubscriptionPaused", + ); + expect(mapGoogleSubscriptionNotificationType(12)).toBe( + "SubscriptionRevoked", + ); + expect(mapGoogleSubscriptionNotificationType(13)).toBe( + "SubscriptionExpired", + ); + }); + + it("returns null for unknown codes", () => { + expect(mapGoogleSubscriptionNotificationType(999)).toBeNull(); + }); +}); + +describe("mapGoogleOneTimeNotificationType", () => { + it("maps purchased and canceled to spec types", () => { + expect(mapGoogleOneTimeNotificationType(1)).toBe("SubscriptionStarted"); + expect(mapGoogleOneTimeNotificationType(2)).toBe("PurchaseRefunded"); + }); +}); + +const baseRtdnSubscription: GoogleRtdnPayload = { + messageId: "rtdn-msg-1", + packageName: "com.example.app", + eventTimeMillis: 1_711_000_000_000, + subscriptionNotification: { + notificationType: 2, + purchaseToken: "play-token-abc", + subscriptionId: "premium_monthly", + }, +}; + +describe("normalizeGoogleRtdn", () => { + it("normalizes SUBSCRIPTION_RENEWED with active state from subscriptionsv2 fetch", () => { + const info: GoogleSubscriptionInfo = { + state: "SUBSCRIPTION_STATE_ACTIVE", + expiryTimeMillis: 1_713_592_000_000, + autoRenewingPlanRenewsTimeMillis: 1_713_592_000_000, + currency: "KRW", + priceAmountMicros: 12_900_000_000, + }; + const event = normalizeGoogleRtdn({ + payload: baseRtdnSubscription, + subscriptionInfo: info, + }); + + expect(event.type).toBe("SubscriptionRenewed"); + expect(event.source).toBe("GooglePlayRealTimeDeveloperNotifications"); + expect(event.platform).toBe("Android"); + expect(event.environment).toBe("Production"); + expect(event.purchaseToken).toBe("play-token-abc"); + expect(event.productId).toBe("premium_monthly"); + expect(event.subscriptionState).toBe("Active"); + expect(event.expiresAt).toBe(1_713_592_000_000); + expect(event.renewsAt).toBe(1_713_592_000_000); + expect(event.currency).toBe("KRW"); + expect(event.priceAmountMicros).toBe(12_900_000_000); + expect(event.occurredAt).toBe(1_711_000_000_000); + expect(event.sourceNotificationId).toBe("rtdn-msg-1"); + }); + + it("derives state from subscriptionsv2 when present, otherwise from event type", () => { + const grace = normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + subscriptionNotification: { + ...baseRtdnSubscription.subscriptionNotification!, + notificationType: 6, + }, + }, + }); + expect(grace.type).toBe("SubscriptionInGracePeriod"); + expect(grace.subscriptionState).toBe("InGracePeriod"); + + const onHold = normalizeGoogleRtdn({ + payload: baseRtdnSubscription, + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ON_HOLD" }, + }); + expect(onHold.subscriptionState).toBe("InBillingRetry"); + }); + + it("preserves Active state when SUBSCRIPTION_STATE_CANCELED reports auto-renew off", () => { + const event = normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + subscriptionNotification: { + ...baseRtdnSubscription.subscriptionNotification!, + notificationType: 3, + }, + }, + subscriptionInfo: { state: "SUBSCRIPTION_STATE_CANCELED" }, + }); + expect(event.type).toBe("SubscriptionCanceled"); + expect(event.subscriptionState).toBe("Active"); + expect(event.cancellationReason).toBe("UserCanceled"); + }); + + it("translates Google cancelReason values to openiap reasons", () => { + const cases: Array<[string, string]> = [ + ["USER_CANCELED", "UserCanceled"], + ["BILLING_ERROR", "BillingError"], + ["SYSTEM_INITIATED_CANCELLATION", "BillingError"], + ["NEW_PRICE_REJECTED", "PriceIncreaseDeclined"], + ["DEVELOPER_CANCELED", "ProductUnavailable"], + ["UNKNOWN_NEW_REASON", "Other"], + ]; + for (const [cancelReason, expected] of cases) { + const event = normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + subscriptionNotification: { + ...baseRtdnSubscription.subscriptionNotification!, + notificationType: 13, + }, + }, + subscriptionInfo: { cancelReason }, + }); + expect(event.type).toBe("SubscriptionExpired"); + expect(event.cancellationReason).toBe(expected); + } + }); + + it("normalizes a one-time refund into PurchaseRefunded", () => { + const event = normalizeGoogleRtdn({ + payload: { + messageId: "rtdn-otp-1", + packageName: "com.example.app", + eventTimeMillis: 1_711_111_111_000, + oneTimeProductNotification: { + notificationType: 2, + purchaseToken: "otp-token-xyz", + sku: "coin_pack_100", + }, + }, + }); + expect(event.type).toBe("PurchaseRefunded"); + expect(event.purchaseToken).toBe("otp-token-xyz"); + expect(event.productId).toBe("coin_pack_100"); + expect(event.subscriptionState).toBe("Refunded"); + expect(event.cancellationReason).toBe("Refunded"); + }); + + it("normalizes a voidedPurchase to PurchaseRefunded", () => { + const event = normalizeGoogleRtdn({ + payload: { + messageId: "rtdn-void-1", + packageName: "com.example.app", + eventTimeMillis: 1_711_222_222_000, + voidedPurchaseNotification: { + purchaseToken: "void-token-1", + orderId: "GPA.1234-5678-9012-34567", + productType: 1, + refundType: 1, + }, + }, + }); + expect(event.type).toBe("PurchaseRefunded"); + expect(event.purchaseToken).toBe("void-token-1"); + expect(event.cancellationReason).toBe("Refunded"); + }); + + it("normalizes a testNotification to Sandbox environment", () => { + const event = normalizeGoogleRtdn({ + payload: { + messageId: "rtdn-test-1", + eventTimeMillis: 1_711_000_000_000, + testNotification: { version: "1.0" }, + }, + }); + expect(event.type).toBe("TestNotification"); + expect(event.environment).toBe("Sandbox"); + expect(event.purchaseToken).toBe("google-test-rtdn-test-1"); + }); + + it("rejects RTDN payloads missing messageId", () => { + expect(() => + normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + messageId: "" as string, + }, + }), + ).toThrow(/messageId/); + }); + + it("rejects RTDN payloads with no notification variant", () => { + expect(() => + normalizeGoogleRtdn({ + payload: { + messageId: "x", + eventTimeMillis: 1, + }, + }), + ).toThrow(WebhookNormalizationError); + }); + + it("rejects unsupported RTDN subscription notification codes", () => { + expect(() => + normalizeGoogleRtdn({ + payload: { + ...baseRtdnSubscription, + subscriptionNotification: { + ...baseRtdnSubscription.subscriptionNotification!, + notificationType: 9999, + }, + }, + }), + ).toThrow(WebhookNormalizationError); + }); +}); diff --git a/packages/kit/convex/webhooks/shared.ts b/packages/kit/convex/webhooks/shared.ts new file mode 100644 index 00000000..71709cfc --- /dev/null +++ b/packages/kit/convex/webhooks/shared.ts @@ -0,0 +1,639 @@ +// Pure normalization helpers that map Apple ASN v2 and Google RTDN +// payloads to the unified `WebhookEvent` shape defined in +// `packages/gql/src/webhook.graphql`. +// +// This file is intentionally framework-free (no "use node", no Convex +// imports, no Apple/Google SDK imports) so it can run in the browser- +// safe Convex runtime, in vitest, and inside the Hono server bundle. +// The verifying receivers in `apple.ts` / `google.ts` decode the JWS +// (Apple) or Pub/Sub envelope (Google) and then hand the decoded +// payload here. +// +// SSOT for the mapping is `knowledge/external/webhook-mapping.md`. + +// --------------------------------------------------------------------------- +// Generated GraphQL spec mirrors. These literals MUST stay in sync with +// the enum values in `packages/gql/src/webhook.graphql`. +// --------------------------------------------------------------------------- + +export type WebhookEventType = + | "SubscriptionStarted" + | "SubscriptionRenewed" + | "SubscriptionExpired" + | "SubscriptionInGracePeriod" + | "SubscriptionInBillingRetry" + | "SubscriptionRecovered" + | "SubscriptionCanceled" + | "SubscriptionUncanceled" + | "SubscriptionRevoked" + | "SubscriptionPriceChange" + | "SubscriptionProductChanged" + | "SubscriptionPaused" + | "SubscriptionResumed" + | "PurchaseRefunded" + | "PurchaseConsumptionRequest" + | "TestNotification"; + +export type WebhookEventSource = + | "AppleAppStoreServerNotificationsV2" + | "GooglePlayRealTimeDeveloperNotifications"; + +export type WebhookEventEnvironment = "Production" | "Sandbox" | "Xcode"; + +export type SubscriptionState = + | "Active" + | "InGracePeriod" + | "InBillingRetry" + | "Expired" + | "Revoked" + | "Refunded" + | "Paused" + | "Unknown"; + +export type WebhookCancellationReason = + | "UserCanceled" + | "BillingError" + | "PriceIncreaseDeclined" + | "ProductUnavailable" + | "Refunded" + | "Other"; + +export type IapPlatform = "IOS" | "Android"; + +// Result returned by the pure normalizers. Mirrors the GraphQL +// `WebhookEvent` payload minus `id` / `projectId` / `receivedAt` / +// `rawSignedPayload`, which the action layer fills in. +export type NormalizedWebhookEvent = { + type: WebhookEventType; + source: WebhookEventSource; + platform: IapPlatform; + environment: WebhookEventEnvironment; + purchaseToken: string; + productId?: string; + subscriptionState?: SubscriptionState; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: WebhookCancellationReason; + currency?: string; + priceAmountMicros?: number; + occurredAt: number; + // Stable per-notification identifier used for idempotency. ASN v2 + // `notificationUUID` or RTDN `messageId`. + sourceNotificationId: string; +}; + +// --------------------------------------------------------------------------- +// Apple ASN v2 +// --------------------------------------------------------------------------- + +// Subset of the @apple/app-store-server-library types we care about, +// re-declared as plain shapes so this file can be imported from non- +// node Convex contexts (the SDK itself imports node:crypto). + +export type AppleAsnPayload = { + notificationType: string; + subtype?: string | null; + notificationUUID: string; + signedDate: number; + data?: AppleAsnData | null; +}; + +export type AppleAsnData = { + environment?: string | null; + bundleId?: string | null; + appAppleId?: number | null; + signedTransactionInfo?: string | null; + signedRenewalInfo?: string | null; +}; + +// Decoded JWS payloads — Apple's library exposes these via verifier +// methods. Only the fields the normalizer reads are listed. +export type AppleDecodedTransaction = { + originalTransactionId?: string | null; + transactionId?: string | null; + productId?: string | null; + expiresDate?: number | null; + revocationReason?: number | null; + currency?: string | null; + // ASN v2 reports `price` in millicents (price × 1000). + price?: number | null; +}; + +export type AppleDecodedRenewalInfo = { + autoRenewStatus?: number | null; + autoRenewProductId?: string | null; + expirationIntent?: number | null; + gracePeriodExpiresDate?: number | null; + isInBillingRetryPeriod?: boolean | null; + renewalDate?: number | null; + recentSubscriptionStartDate?: number | null; +}; + +const APPLE_ENV_MAP: Record = { + Production: "Production", + Sandbox: "Sandbox", + Xcode: "Xcode", + // Apple's docs show capitalized values, but defensive lower-case + // handling avoids spurious "Unknown" mappings if Apple ever ships + // a sandbox quirk. + production: "Production", + sandbox: "Sandbox", + xcode: "Xcode", +}; + +function mapAppleEnvironment( + value: string | null | undefined, +): WebhookEventEnvironment { + if (!value) { + return "Production"; + } + return APPLE_ENV_MAP[value] ?? "Production"; +} + +// Maps (notificationType, subtype) -> WebhookEventType. Returns null +// when the combination is not observable in the openiap spec yet +// (e.g. OFFER_REDEEMED, RENEWAL_EXTENDED) — kit will record but not +// emit those. +export function mapAppleNotificationType( + notificationType: string, + subtype?: string | null, +): WebhookEventType | null { + switch (notificationType) { + case "SUBSCRIBED": + // INITIAL_BUY and RESUBSCRIBE both surface as SubscriptionStarted. + return "SubscriptionStarted"; + case "DID_RENEW": + // BILLING_RECOVERY subtype indicates the subscription is back from + // a failure state, not a routine renewal. + return subtype === "BILLING_RECOVERY" + ? "SubscriptionRecovered" + : "SubscriptionRenewed"; + case "EXPIRED": + return "SubscriptionExpired"; + case "DID_FAIL_TO_RENEW": + return subtype === "GRACE_PERIOD" + ? "SubscriptionInGracePeriod" + : "SubscriptionInBillingRetry"; + case "GRACE_PERIOD_EXPIRED": + // Grace period ended with no successful renewal — the subscription + // is now in billing retry / on hold. + return "SubscriptionInBillingRetry"; + case "DID_CHANGE_RENEWAL_STATUS": + if (subtype === "AUTO_RENEW_DISABLED") { + return "SubscriptionCanceled"; + } + if (subtype === "AUTO_RENEW_ENABLED") { + return "SubscriptionUncanceled"; + } + return null; + case "DID_CHANGE_RENEWAL_PREF": + return "SubscriptionProductChanged"; + case "PRICE_INCREASE": + return "SubscriptionPriceChange"; + case "REVOKE": + return "SubscriptionRevoked"; + case "REFUND": + return "PurchaseRefunded"; + case "REFUND_REVERSED": + // Refund reversed means the user retains their purchase — surface + // as a fresh "Started" so consumers re-grant entitlement. The raw + // payload still describes the reversal for consumers that need it. + return "SubscriptionStarted"; + case "CONSUMPTION_REQUEST": + return "PurchaseConsumptionRequest"; + case "TEST": + return "TestNotification"; + default: + return null; + } +} + +function mapAppleCancellationReason( + notificationType: string, + subtype: string | null | undefined, + renewalInfo: AppleDecodedRenewalInfo | null | undefined, +): WebhookCancellationReason | undefined { + if (notificationType === "REFUND" || notificationType === "REVOKE") { + return "Refunded"; + } + if ( + notificationType === "DID_CHANGE_RENEWAL_STATUS" && + subtype === "AUTO_RENEW_DISABLED" + ) { + return "UserCanceled"; + } + // Apple `expirationIntent`: + // 1: customer canceled, 2: billing error, 3: price increase + // declined, 4: product unavailable at renewal time, 5: unknown + if (renewalInfo?.expirationIntent != null) { + switch (renewalInfo.expirationIntent) { + case 1: + return "UserCanceled"; + case 2: + return "BillingError"; + case 3: + return "PriceIncreaseDeclined"; + case 4: + return "ProductUnavailable"; + case 5: + return "Other"; + } + } + return undefined; +} + +function deriveAppleSubscriptionState( + type: WebhookEventType, +): SubscriptionState | undefined { + switch (type) { + case "SubscriptionStarted": + case "SubscriptionRenewed": + case "SubscriptionRecovered": + case "SubscriptionUncanceled": + case "SubscriptionResumed": + case "SubscriptionPriceChange": + case "SubscriptionProductChanged": + return "Active"; + case "SubscriptionInGracePeriod": + return "InGracePeriod"; + case "SubscriptionInBillingRetry": + return "InBillingRetry"; + case "SubscriptionExpired": + return "Expired"; + case "SubscriptionRevoked": + return "Revoked"; + case "SubscriptionCanceled": + // User turned off auto-renew — access continues until expiry, so + // the subscription is still Active until the period ends. + return "Active"; + case "PurchaseRefunded": + return "Refunded"; + case "SubscriptionPaused": + return "Paused"; + default: + return undefined; + } +} + +export type NormalizeAppleInput = { + payload: AppleAsnPayload; + transaction?: AppleDecodedTransaction | null; + renewalInfo?: AppleDecodedRenewalInfo | null; +}; + +export class WebhookNormalizationError extends Error { + constructor( + message: string, + readonly code: + | "UnknownEventType" + | "MissingPurchaseToken" + | "MissingNotificationId", + ) { + super(message); + this.name = "WebhookNormalizationError"; + } +} + +export function normalizeAppleAsn( + input: NormalizeAppleInput, +): NormalizedWebhookEvent { + const { payload, transaction, renewalInfo } = input; + + if (!payload.notificationUUID) { + throw new WebhookNormalizationError( + "Apple ASN v2 payload is missing notificationUUID", + "MissingNotificationId", + ); + } + + const type = mapAppleNotificationType( + payload.notificationType, + payload.subtype ?? null, + ); + + if (type === null) { + throw new WebhookNormalizationError( + `Unsupported Apple notificationType: ${payload.notificationType} / subtype: ${payload.subtype ?? ""}`, + "UnknownEventType", + ); + } + + // For TEST notifications Apple omits transaction/renewal data, so we + // fall back to the notificationUUID as a placeholder token. This lets + // the event still flow through dedup + storage without coupling it to + // any real subscription. + const purchaseToken = + transaction?.originalTransactionId ?? + transaction?.transactionId ?? + (type === "TestNotification" + ? `apple-test-${payload.notificationUUID}` + : null); + + if (!purchaseToken) { + throw new WebhookNormalizationError( + "Apple ASN v2 payload missing originalTransactionId for non-test notification", + "MissingPurchaseToken", + ); + } + + // Apple reports `price` in millicents (1/1000 of cent). openiap exposes + // micros to match Google's `priceAmountMicros`. millicents → micros is + // a 10× multiplier (1 millicent = 10 micros). + const priceAmountMicros = + typeof transaction?.price === "number" ? transaction.price * 10 : undefined; + + return { + type, + source: "AppleAppStoreServerNotificationsV2", + platform: "IOS", + environment: mapAppleEnvironment(payload.data?.environment ?? null), + purchaseToken, + productId: transaction?.productId ?? undefined, + subscriptionState: deriveAppleSubscriptionState(type), + expiresAt: transaction?.expiresDate ?? undefined, + renewsAt: renewalInfo?.renewalDate ?? undefined, + cancellationReason: mapAppleCancellationReason( + payload.notificationType, + payload.subtype ?? null, + renewalInfo ?? null, + ), + currency: transaction?.currency ?? undefined, + priceAmountMicros, + occurredAt: payload.signedDate, + sourceNotificationId: payload.notificationUUID, + }; +} + +// --------------------------------------------------------------------------- +// Google RTDN +// --------------------------------------------------------------------------- + +// RTDN payload shape — see +// https://developer.android.com/google/play/billing/rtdn-reference + +export type GoogleRtdnPayload = { + // Pub/Sub message id (used for idempotency). + messageId: string; + // ISO-8601 string from Pub/Sub publishTime; we accept either that or + // the RTDN body's `eventTimeMillis` and prefer the latter. + publishTimeMs?: number; + packageName?: string; + eventTimeMillis: number; + subscriptionNotification?: GoogleSubscriptionNotification | null; + oneTimeProductNotification?: GoogleOneTimeProductNotification | null; + voidedPurchaseNotification?: GoogleVoidedPurchaseNotification | null; + testNotification?: GoogleTestNotification | null; +}; + +export type GoogleSubscriptionNotification = { + // RTDN numeric type — see SUBSCRIPTION_NOTIFICATION_TYPE in the + // mapping doc. + notificationType: number; + purchaseToken: string; + subscriptionId: string; +}; + +export type GoogleOneTimeProductNotification = { + notificationType: number; + purchaseToken: string; + sku: string; +}; + +export type GoogleVoidedPurchaseNotification = { + purchaseToken: string; + orderId?: string; + productType?: number; + refundType?: number; +}; + +export type GoogleTestNotification = { + version: string; +}; + +// Optional supplemental data, fetched separately by the action via +// `androidpublisher.purchases.subscriptionsv2.get` because RTDN does +// not embed expiry / price information. +export type GoogleSubscriptionInfo = { + expiryTimeMillis?: number; + // Auto-renewing plans expose the next renewal time inline; one-off + // prepaid plans don't carry one. + autoRenewingPlanRenewsTimeMillis?: number; + state?: string; + cancelReason?: string; + currency?: string; + priceAmountMicros?: number; +}; + +const GOOGLE_SUB_TYPE_MAP: Record = { + 1: "SubscriptionStarted", // SUBSCRIPTION_RECOVERED handled below + 2: "SubscriptionRenewed", + 3: "SubscriptionCanceled", + 4: "SubscriptionRecovered", + 5: "SubscriptionInBillingRetry", + 6: "SubscriptionInGracePeriod", + 7: "SubscriptionStarted", // SUBSCRIPTION_RESTARTED — re-enabled auto-renew with active period + 8: "SubscriptionPriceChange", + 9: "SubscriptionProductChanged", + 10: "SubscriptionPaused", + 11: "SubscriptionPaused", + 12: "SubscriptionRevoked", + 13: "SubscriptionExpired", + // 14 = SUBSCRIPTION_PURCHASED maps to Started (newer RTDN code) + 14: "SubscriptionStarted", + // 15 = SUBSCRIPTION_PRODUCT_CHANGED (legacy) + 15: "SubscriptionProductChanged", + // 20 = SUBSCRIPTION_PENDING_PURCHASE_CANCELED — treated as canceled + 20: "SubscriptionCanceled", +}; + +const GOOGLE_ONE_TIME_TYPE_MAP: Record = { + // ONE_TIME_PRODUCT_PURCHASED — initial purchase. We re-use Started + // since openiap doesn't currently distinguish one-time activation. + 1: "SubscriptionStarted", + 2: "PurchaseRefunded", // ONE_TIME_PRODUCT_CANCELED +}; + +export function mapGoogleSubscriptionNotificationType( + notificationType: number, +): WebhookEventType | null { + return GOOGLE_SUB_TYPE_MAP[notificationType] ?? null; +} + +export function mapGoogleOneTimeNotificationType( + notificationType: number, +): WebhookEventType | null { + return GOOGLE_ONE_TIME_TYPE_MAP[notificationType] ?? null; +} + +function deriveGoogleSubscriptionState( + type: WebhookEventType, + info: GoogleSubscriptionInfo | null | undefined, +): SubscriptionState | undefined { + if (info?.state) { + // androidpublisher.subscriptionsv2 state values: + // SUBSCRIPTION_STATE_ACTIVE, SUBSCRIPTION_STATE_CANCELED, + // SUBSCRIPTION_STATE_IN_GRACE_PERIOD, SUBSCRIPTION_STATE_ON_HOLD, + // SUBSCRIPTION_STATE_PAUSED, SUBSCRIPTION_STATE_EXPIRED, + // SUBSCRIPTION_STATE_PENDING. + switch (info.state) { + case "SUBSCRIPTION_STATE_ACTIVE": + return "Active"; + case "SUBSCRIPTION_STATE_CANCELED": + // Auto-renew off but still has access until expiry — Active. + return "Active"; + case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD": + return "InGracePeriod"; + case "SUBSCRIPTION_STATE_ON_HOLD": + return "InBillingRetry"; + case "SUBSCRIPTION_STATE_PAUSED": + return "Paused"; + case "SUBSCRIPTION_STATE_EXPIRED": + return "Expired"; + } + } + // Fallback when subscriptionsv2 was not fetched (e.g. action didn't + // have credentials yet, or this is a one-time / test notification). + switch (type) { + case "SubscriptionStarted": + case "SubscriptionRenewed": + case "SubscriptionRecovered": + case "SubscriptionResumed": + case "SubscriptionUncanceled": + case "SubscriptionPriceChange": + case "SubscriptionProductChanged": + return "Active"; + case "SubscriptionInGracePeriod": + return "InGracePeriod"; + case "SubscriptionInBillingRetry": + return "InBillingRetry"; + case "SubscriptionExpired": + return "Expired"; + case "SubscriptionRevoked": + return "Revoked"; + case "SubscriptionPaused": + return "Paused"; + case "SubscriptionCanceled": + return "Active"; + case "PurchaseRefunded": + return "Refunded"; + default: + return undefined; + } +} + +function mapGoogleCancellationReason( + type: WebhookEventType, + info: GoogleSubscriptionInfo | null | undefined, +): WebhookCancellationReason | undefined { + if (type === "PurchaseRefunded" || type === "SubscriptionRevoked") { + return "Refunded"; + } + if (info?.cancelReason) { + switch (info.cancelReason) { + case "USER_CANCELED": + return "UserCanceled"; + case "BILLING_ERROR": + case "SYSTEM_INITIATED_CANCELLATION": + return "BillingError"; + case "NEW_PRICE_REJECTED": + return "PriceIncreaseDeclined"; + case "DEVELOPER_CANCELED": + return "ProductUnavailable"; + default: + return "Other"; + } + } + if (type === "SubscriptionCanceled") { + return "UserCanceled"; + } + return undefined; +} + +export type NormalizeGoogleInput = { + payload: GoogleRtdnPayload; + subscriptionInfo?: GoogleSubscriptionInfo | null; +}; + +export function normalizeGoogleRtdn( + input: NormalizeGoogleInput, +): NormalizedWebhookEvent { + const { payload, subscriptionInfo } = input; + + if (!payload.messageId) { + throw new WebhookNormalizationError( + "Google RTDN payload missing messageId", + "MissingNotificationId", + ); + } + + // Determine the event flavor first — RTDN messages carry exactly one + // of (subscriptionNotification | oneTimeProductNotification | + // voidedPurchaseNotification | testNotification). + let type: WebhookEventType | null = null; + let purchaseToken: string | null = null; + let productId: string | undefined; + + if (payload.testNotification) { + type = "TestNotification"; + purchaseToken = `google-test-${payload.messageId}`; + } else if (payload.subscriptionNotification) { + type = mapGoogleSubscriptionNotificationType( + payload.subscriptionNotification.notificationType, + ); + purchaseToken = payload.subscriptionNotification.purchaseToken; + productId = payload.subscriptionNotification.subscriptionId; + } else if (payload.oneTimeProductNotification) { + type = mapGoogleOneTimeNotificationType( + payload.oneTimeProductNotification.notificationType, + ); + purchaseToken = payload.oneTimeProductNotification.purchaseToken; + productId = payload.oneTimeProductNotification.sku; + } else if (payload.voidedPurchaseNotification) { + // VOIDED_PURCHASE always means the purchase was refunded / + // chargebacked — collapse to PurchaseRefunded regardless of code. + type = "PurchaseRefunded"; + purchaseToken = payload.voidedPurchaseNotification.purchaseToken; + } + + if (type === null) { + throw new WebhookNormalizationError( + "Unsupported Google RTDN payload variant", + "UnknownEventType", + ); + } + if (!purchaseToken) { + throw new WebhookNormalizationError( + "Google RTDN payload missing purchaseToken", + "MissingPurchaseToken", + ); + } + + // RTDN does not surface sandbox vs production — testNotification + // implies sandbox, otherwise prod. + const environment: WebhookEventEnvironment = payload.testNotification + ? "Sandbox" + : "Production"; + + return { + type, + source: "GooglePlayRealTimeDeveloperNotifications", + platform: "Android", + environment, + purchaseToken, + productId, + subscriptionState: deriveGoogleSubscriptionState( + type, + subscriptionInfo ?? null, + ), + expiresAt: subscriptionInfo?.expiryTimeMillis, + renewsAt: subscriptionInfo?.autoRenewingPlanRenewsTimeMillis, + cancellationReason: mapGoogleCancellationReason( + type, + subscriptionInfo ?? null, + ), + currency: subscriptionInfo?.currency, + priceAmountMicros: subscriptionInfo?.priceAmountMicros, + occurredAt: payload.eventTimeMillis, + sourceNotificationId: payload.messageId, + }; +} diff --git a/packages/kit/convex/webhooks/validators.ts b/packages/kit/convex/webhooks/validators.ts new file mode 100644 index 00000000..d063d6fe --- /dev/null +++ b/packages/kit/convex/webhooks/validators.ts @@ -0,0 +1,62 @@ +import { v } from "convex/values"; + +// Convex validators for webhook enums. Re-used by both the internal +// mutation arguments and the public query return shape so the schema +// stays in sync without a hand-maintained second copy. +// +// Mirror of the GraphQL enums in `packages/gql/src/webhook.graphql`. + +export const webhookEventTypeValidator = v.union( + v.literal("SubscriptionStarted"), + v.literal("SubscriptionRenewed"), + v.literal("SubscriptionExpired"), + v.literal("SubscriptionInGracePeriod"), + v.literal("SubscriptionInBillingRetry"), + v.literal("SubscriptionRecovered"), + v.literal("SubscriptionCanceled"), + v.literal("SubscriptionUncanceled"), + v.literal("SubscriptionRevoked"), + v.literal("SubscriptionPriceChange"), + v.literal("SubscriptionProductChanged"), + v.literal("SubscriptionPaused"), + v.literal("SubscriptionResumed"), + v.literal("PurchaseRefunded"), + v.literal("PurchaseConsumptionRequest"), + v.literal("TestNotification"), +); + +export const webhookEventSourceValidator = v.union( + v.literal("AppleAppStoreServerNotificationsV2"), + v.literal("GooglePlayRealTimeDeveloperNotifications"), +); + +export const webhookEventEnvironmentValidator = v.union( + v.literal("Production"), + v.literal("Sandbox"), + v.literal("Xcode"), +); + +export const webhookEventPlatformValidator = v.union( + v.literal("IOS"), + v.literal("Android"), +); + +export const subscriptionStateValidator = v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), +); + +export const webhookCancellationReasonValidator = v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), +); diff --git a/packages/kit/package.json b/packages/kit/package.json index 69500787..125de194 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -41,6 +41,7 @@ "antd": "^6.1.0", "clsx": "^2.1.1", "convex": "^1.29.2", + "google-auth-library": "^10.6.2", "googleapis": "^157.0.0", "hono": "^4.9.9", "hono-openapi": "^1.1.0", diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 21307015..49ba5aa5 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -19,6 +19,7 @@ import { rateLimitMiddleware } from "./rate-limit"; import { replayGuardMiddleware } from "./replay-guard"; import { requestLoggerMiddleware } from "./request-logger"; import { validator } from "./validator"; +import { webhooksRoutes } from "./webhooks"; // Variables that the request middleware chain attaches to the Hono // context. Declaring them here (and passing the generic to `new Hono()`) @@ -456,4 +457,16 @@ app.post( // same handler with the same middleware stack. app.post("/verify-purchase", ...verifyMiddleware); +// Lifecycle webhook receivers — Apple App Store Server Notifications v2 +// and Google Pub/Sub RTDN. These bypass the apiKeyMiddleware / +// rate-limit / replay-guard chain because Apple cannot send custom +// auth headers and Google's Pub/Sub push has its own delivery +// guarantees. Auth is enforced inside the receiver: +// - Apple: project apiKey is in the path; the action verifies the +// signedPayload against Apple's root certificates so a leaked URL +// can't be used to inject forged events. +// - Google: OIDC bearer JWT (when GOOGLE_PUBSUB_PUSH_AUDIENCE is +// configured) plus the path apiKey. +app.route("/webhooks", webhooksRoutes); + export { app as apiRoutes }; diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts new file mode 100644 index 00000000..2b885c0f --- /dev/null +++ b/packages/kit/server/api/v1/webhooks.ts @@ -0,0 +1,271 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { OAuth2Client } from "google-auth-library"; + +import { api } from "@/convex"; +import { client, handleConvexError } from "../../convex"; + +// Inbound webhook receivers for Apple ASN v2 and Google Pub/Sub RTDN. +// +// Auth model: +// - Apple ASN does not support custom Authorization headers, so the +// project's API key is encoded in the path: kit gives each project a +// webhook URL of the form +// https://kit.openiap.dev/v1/webhooks/apple/{apiKey} +// to register in App Store Connect. The path segment behaves like a +// capability token; rotating the project's API key invalidates the +// URL just like it invalidates verifyReceipt callers. The Convex +// action verifies the signedPayload signature against Apple's roots, +// so even if the URL leaks, only Apple-signed payloads are accepted. +// +// - Google Pub/Sub push delivers a Bearer JWT from Google in the +// Authorization header that we verify against +// https://www.googleapis.com/oauth2/v1/certs (via OAuth2Client). The +// project's API key is also in the path so kit can resolve which +// project a notification belongs to. Both checks must pass. + +const webhooks = new Hono(); + +webhooks.post("/apple/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + let body: { signedPayload?: string }; + + try { + body = await c.req.json(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + + if (typeof body.signedPayload !== "string" || body.signedPayload.length < 1) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "Missing or invalid signedPayload", + }, + ], + }, + 400, + ); + } + + try { + const result = await client.action(api.webhooks.apple.ingestAppleAsn, { + apiKey, + signedPayload: body.signedPayload, + }); + return c.json({ + ok: true, + eventType: result.type, + deduped: result.deduped, + }); + } catch (error) { + return mapWebhookError(c, error, "apple"); + } +}); + +webhooks.post("/google/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + + // Pub/Sub push always sends Authorization: Bearer . Apple-style + // unauthenticated path access is not appropriate here because Pub/Sub + // explicitly supports OIDC and skipping it leaves the endpoint + // spoofable. + const authHeader = c.req.header("authorization"); + const audience = process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; + + if (audience) { + const ok = await verifyPubSubOidcToken(authHeader, audience); + if (!ok) { + return c.json( + { + errors: [ + { + code: "UNAUTHORIZED", + message: "Pub/Sub OIDC verification failed", + }, + ], + }, + 401, + ); + } + } + + type PubSubPushBody = { + message?: { + data?: string; + messageId?: string; + publishTime?: string; + attributes?: Record; + }; + subscription?: string; + }; + + let body: PubSubPushBody; + try { + body = await c.req.json(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + + if (!body.message?.data || !body.message?.messageId) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "Pub/Sub envelope missing message.data or messageId", + }, + ], + }, + 400, + ); + } + + let decoded: Record; + try { + decoded = JSON.parse( + Buffer.from(body.message.data, "base64").toString("utf-8"), + ); + } catch { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "Pub/Sub message.data is not base64-encoded JSON", + }, + ], + }, + 400, + ); + } + + const payload = { + messageId: body.message.messageId, + packageName: + typeof decoded.packageName === "string" ? decoded.packageName : undefined, + eventTimeMillis: + typeof decoded.eventTimeMillis === "string" + ? Number(decoded.eventTimeMillis) + : typeof decoded.eventTimeMillis === "number" + ? decoded.eventTimeMillis + : Date.parse(body.message.publishTime ?? "") || Date.now(), + subscriptionNotification: decoded.subscriptionNotification as + | undefined + | { + notificationType: number; + purchaseToken: string; + subscriptionId: string; + }, + oneTimeProductNotification: decoded.oneTimeProductNotification as + | undefined + | { notificationType: number; purchaseToken: string; sku: string }, + voidedPurchaseNotification: decoded.voidedPurchaseNotification as + | undefined + | { + purchaseToken: string; + orderId?: string; + productType?: number; + refundType?: number; + }, + testNotification: decoded.testNotification as + | undefined + | { version: string }, + }; + + try { + const result = await client.action(api.webhooks.google.ingestGoogleRtdn, { + apiKey, + rawMessage: JSON.stringify(decoded), + payload, + }); + return c.json({ + ok: true, + eventType: result.type, + deduped: result.deduped, + }); + } catch (error) { + return mapWebhookError(c, error, "google"); + } +}); + +const oauth2Client = new OAuth2Client(); + +async function verifyPubSubOidcToken( + authHeader: string | undefined, + audience: string, +): Promise { + if (!authHeader?.startsWith("Bearer ")) { + return false; + } + const token = authHeader.slice(7); + try { + const ticket = await oauth2Client.verifyIdToken({ + idToken: token, + audience, + }); + const payload = ticket.getPayload(); + if (!payload) { + return false; + } + const email = payload.email; + // Pub/Sub push requests are signed by a Google service account + // dedicated to the publishing project. Reject any caller that is + // not from the gcp-sa-pubsub principal namespace. + if (!email || !email.endsWith("@gcp-sa-pubsub.iam.gserviceaccount.com")) { + return false; + } + return payload.email_verified === true; + } catch (error) { + console.warn("[webhooks/google] OIDC verification error", error); + return false; + } +} + +function mapWebhookError( + c: Context, + error: unknown, + source: "apple" | "google", +) { + const convexError = handleConvexError(error); + if (convexError !== null) { + // 400 keeps the upstream from retrying forever on a permanent + // input error (bundle mismatch, malformed JWS, etc.). + return c.json({ errors: [convexError] }, 400); + } + + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.startsWith("UNSUPPORTED_EVENT")) { + // Apple/Google ship new notification types ahead of the openiap + // spec. Acknowledge with 200 so the upstream stops retrying — the + // event was deliberately dropped, not lost. + return c.json({ ok: true, dropped: true, reason: errorMessage }); + } + + console.error( + `[webhooks/${source}] unexpected error`, + errorMessage, + error instanceof Error ? error.stack : "", + ); + return c.json( + { + errors: [ + { + code: "WEBHOOK_INTERNAL_ERROR", + message: errorMessage, + }, + ], + }, + 500, + ); +} + +export { webhooks as webhooksRoutes }; From d718ccc35ed1e38c725e35db08d71617c59abd61 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 00:07:09 +0900 Subject: [PATCH 03/81] feat: stream webhook events to all 5 SDKs (Phase 1 PRs #3-#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Phase 1 in this PR by adding the SSE streaming endpoint, the prune cron, the shared TS webhook client, per-SDK integrations for react-native-iap, expo-iap, flutter_inapp_purchase, kmp-iap, and godot-iap, plus a docs page tying it all together. Server-side (kit): - Hourly `pruneWebhookEvents` cron registered in crons.ts so the 30-day retention window in `webhookEventsSince` stays bounded. - `/v1/webhooks/stream/{apiKey}` SSE endpoint that polls `webhookEventsSince` every 1.5s and emits new events as `id: ` / `event: ` / `data: `. EventSource clients reconnect with `Last-Event-ID`; kit looks up the named event's `receivedAt` and resumes so events fired during a closed connection are delivered in order. Shared TS client: - `packages/gql/src/webhook-client.ts` — transport-agnostic `connectWebhookStream({apiKey, onEvent, onError})` plus a pure `parseWebhookEventData` helper. Default factory uses the global `EventSource`; consumers can inject a polyfill. - 11 vitest tests cover heartbeats, stream-control envelopes, malformed JSON, missing required fields, transport errors, trailing-slash trimming, and apiKey URL-encoding. - `./webhook-client` export added to `packages/gql/package.json` and `sync-to-platforms.mjs` copies the canonical implementation into `libraries/react-native-iap/src/` and `libraries/expo-iap/src/` during normal type sync. Per-SDK integrations: - react-native-iap: synced `webhook-client.ts` + a `useWebhookEvents` hook returning `{events, lastError, isConnected}`. Re-exports the helpers from `index.ts`. 4 jest tests; full suite 276/276 passes. - expo-iap: same surface (`webhook-client.ts`, `useWebhookEvents.ts`). Re-exported from `index.ts`. 2 jest tests; full suite 46/46 passes. - flutter_inapp_purchase: `lib/webhook_client.dart` — typed `WebhookEvent`, `connectWebhookStream(apiKey:)` returning a `WebhookListener` with `events` / `errors` streams, plus a pure `parseWebhookEventData` helper. 3 flutter tests pass. - kmp-iap: `WebhookClient.kt` in commonMain — `WebhookEventTypeName` enum, `WebhookEvent` data class, `WebhookEventParser.parse()`, and `webhookStreamUrl()` URL builder. Transport intentionally not in commonMain so the module avoids pulling Ktor; consumers wire a per-target HTTP client and feed JSON frames to the parser. 4 commonTest tests added. - godot-iap: `addons/godot-iap/webhook_client.gd` — `OpenIapWebhookClient` Node running an HTTPClient SSE loop with `Last-Event-ID` reconnect, emitting `event_received(Dictionary)`, `connected_to_stream()`, `stream_error(code, message)` signals. Docs: - `packages/docs/src/pages/docs/webhooks.tsx` routed at `/docs/webhooks` — architecture overview, event-shape reference linking to `webhook.graphql` + the mapping doc, per-SDK usage in TypeScript / Dart / Kotlin / GDScript, and the reconnect-and-replay contract. Verification: - kit: lint clean (0 errors), 254/254 vitest, smoke probes green. - gql: 11/11 webhook-client vitest tests. - react-native-iap: 276/276 jest, typecheck clean for src/. - expo-iap: 46/46 jest, typecheck clean for src/. - flutter_inapp_purchase: 3/3 flutter test. - docs: tsc clean, audit:docs no new failures. - kmp-iap: tests written but a gradle run is out of scope for this autonomous turn — parser is pure and mirrors the TS / Dart logic that vitest + flutter test already validate. - godot-iap: GDScript needs the Godot editor for runtime tests; the SSE parser mirrors the Dart implementation tested above. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/useWebhookEvents.test.ts | 132 ++++++ libraries/expo-iap/src/index.ts | 17 + libraries/expo-iap/src/useWebhookEvents.ts | 106 +++++ libraries/expo-iap/src/webhook-client.ts | 259 +++++++++++ .../lib/webhook_client.dart | 407 ++++++++++++++++++ .../test/webhook_client_test.dart | 56 +++ .../addons/godot-iap/webhook_client.gd | 192 +++++++++ .../hyochan/kmpiap/openiap/WebhookClient.kt | 157 +++++++ .../kmpiap/openiap/WebhookClientTest.kt | 73 ++++ .../__tests__/hooks/useWebhookEvents.test.ts | 192 +++++++++ .../src/hooks/useWebhookEvents.ts | 144 +++++++ libraries/react-native-iap/src/index.ts | 17 + .../react-native-iap/src/webhook-client.ts | 259 +++++++++++ packages/docs/src/pages/docs/index.tsx | 2 + packages/docs/src/pages/docs/webhooks.tsx | 190 ++++++++ packages/gql/package.json | 1 + packages/gql/scripts/sync-to-platforms.mjs | 27 ++ packages/gql/src/webhook-client.test.ts | 214 +++++++++ packages/gql/src/webhook-client.ts | 259 +++++++++++ packages/kit/convex/crons.ts | 13 + packages/kit/server/api/v1/webhooks.ts | 126 ++++++ 21 files changed, 2843 insertions(+) create mode 100644 libraries/expo-iap/src/__tests__/useWebhookEvents.test.ts create mode 100644 libraries/expo-iap/src/useWebhookEvents.ts create mode 100644 libraries/expo-iap/src/webhook-client.ts create mode 100644 libraries/flutter_inapp_purchase/lib/webhook_client.dart create mode 100644 libraries/flutter_inapp_purchase/test/webhook_client_test.dart create mode 100644 libraries/godot-iap/addons/godot-iap/webhook_client.gd create mode 100644 libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt create mode 100644 libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt create mode 100644 libraries/react-native-iap/src/__tests__/hooks/useWebhookEvents.test.ts create mode 100644 libraries/react-native-iap/src/hooks/useWebhookEvents.ts create mode 100644 libraries/react-native-iap/src/webhook-client.ts create mode 100644 packages/docs/src/pages/docs/webhooks.tsx create mode 100644 packages/gql/src/webhook-client.test.ts create mode 100644 packages/gql/src/webhook-client.ts diff --git a/libraries/expo-iap/src/__tests__/useWebhookEvents.test.ts b/libraries/expo-iap/src/__tests__/useWebhookEvents.test.ts new file mode 100644 index 00000000..a337c859 --- /dev/null +++ b/libraries/expo-iap/src/__tests__/useWebhookEvents.test.ts @@ -0,0 +1,132 @@ +/* eslint-disable import/first */ +jest.mock('react-native', () => ({ + Platform: {OS: 'ios', select: jest.fn((obj: any) => obj.ios)}, + NativeEventEmitter: jest.fn(() => ({ + addListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + })), +})); + +import * as React from 'react'; +import * as ReactTestRenderer from 'react-test-renderer'; + +import {useWebhookEvents} from '../useWebhookEvents'; +import type { + WebhookEventPayload, + WebhookEventStream, +} from '../webhook-client'; + +const validEvent: WebhookEventPayload = { + id: 'uuid-1', + type: 'SubscriptionRenewed', + source: 'AppleAppStoreServerNotificationsV2', + platform: 'IOS', + environment: 'Production', + projectId: 'p-1', + occurredAt: 1_711_000_000_000, + receivedAt: 1_711_000_001_000, + purchaseToken: 'token-1', + productId: 'com.example.premium', + subscriptionState: 'Active', +}; + +function makeFakeStream() { + const listeners: Record< + string, + (event: {data: string; lastEventId?: string}) => void + > = {}; + const stream: WebhookEventStream = { + onmessage: null, + onerror: null, + addEventListener: (type, listener) => { + listeners[type] = listener; + }, + close: jest.fn(), + }; + return { + stream, + fire: (data: string) => listeners.message?.({data}), + }; +} + +function HookProbe(props: Parameters[0]) { + const result = useWebhookEvents(props); + (HookProbe as any).last = result; + return null; +} + +describe('useWebhookEvents (expo)', () => { + afterEach(() => { + (HookProbe as any).last = null; + }); + + it('opens a stream and forwards events into the buffer', () => { + const {stream, fire} = makeFakeStream(); + const factory = jest.fn(() => stream); + const onEvent = jest.fn(); + + let renderer: ReturnType | null = null; + ReactTestRenderer.act(() => { + renderer = ReactTestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + onEvent, + }), + ); + }); + + expect(factory).toHaveBeenCalledWith( + 'http://localhost/v1/webhooks/stream/k', + {}, + ); + + ReactTestRenderer.act(() => { + fire(JSON.stringify(validEvent)); + }); + + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({id: 'uuid-1'}), + ); + + const result = (HookProbe as any).last as { + events: WebhookEventPayload[]; + isConnected: boolean; + }; + expect(result.events).toHaveLength(1); + expect(result.events[0]?.id).toBe('uuid-1'); + expect(result.isConnected).toBe(true); + + ReactTestRenderer.act(() => { + renderer?.unmount(); + }); + expect(stream.close).toHaveBeenCalled(); + }); + + it('reports transport errors via onError without unmounting', () => { + const {stream} = makeFakeStream(); + const factory = jest.fn(() => stream); + const onError = jest.fn(); + + ReactTestRenderer.act(() => { + ReactTestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + onError, + }), + ); + }); + + ReactTestRenderer.act(() => { + stream.onerror?.(new Error('disconnect')); + }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({code: 'TRANSPORT_ERROR'}), + ); + }); +}); diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index d7038da2..9c575960 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -1042,6 +1042,23 @@ export const verifyPurchaseWithProvider: MutationField< }; export * from './useIAP'; +export {useWebhookEvents} from './useWebhookEvents'; +export type { + UseWebhookEventsOptions, + UseWebhookEventsResult, +} from './useWebhookEvents'; +export { + connectWebhookStream, + parseWebhookEventData, +} from './webhook-client'; +export type { + WebhookEventPayload, + WebhookEventStream, + WebhookEventType as WebhookEventTypeName, + WebhookListener, + WebhookListenerError, + WebhookListenerOptions, +} from './webhook-client'; export { ErrorCodeUtils, ErrorCodeMapping, diff --git a/libraries/expo-iap/src/useWebhookEvents.ts b/libraries/expo-iap/src/useWebhookEvents.ts new file mode 100644 index 00000000..84ad17e1 --- /dev/null +++ b/libraries/expo-iap/src/useWebhookEvents.ts @@ -0,0 +1,106 @@ +import {useEffect, useRef, useState} from 'react'; + +import { + connectWebhookStream, + type WebhookEventPayload, + type WebhookEventStream, + type WebhookListener, + type WebhookListenerError, +} from './webhook-client'; + +export type UseWebhookEventsOptions = { + apiKey: string | null | undefined; + baseUrl?: string; + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; + bufferSize?: number; + onEvent?: (event: WebhookEventPayload) => void; + onError?: (error: WebhookListenerError) => void; +}; + +export type UseWebhookEventsResult = { + events: WebhookEventPayload[]; + lastError: WebhookListenerError | null; + isConnected: boolean; +}; + +// React hook wrapping the kit SSE webhook stream. See +// `libraries/react-native-iap/src/hooks/useWebhookEvents.ts` for the +// canonical version — this file mirrors it 1:1 because expo-iap and +// react-native-iap share the JS/TS SSE wire format. The intentional +// duplication keeps each library self-contained (no cross-package +// runtime dep) at the cost of a coordinated edit when the surface +// changes; that's checked by the SDK Parity Checklist in +// `knowledge/internal/04-platform-packages.md`. +export function useWebhookEvents({ + apiKey, + baseUrl, + eventSourceFactory, + bufferSize = 50, + onEvent, + onError, +}: UseWebhookEventsOptions): UseWebhookEventsResult { + const [events, setEvents] = useState([]); + const [lastError, setLastError] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + const onEventRef = useRef(onEvent); + const onErrorRef = useRef(onError); + onEventRef.current = onEvent; + onErrorRef.current = onError; + + useEffect(() => { + if (!apiKey) { + return; + } + + let listener: WebhookListener | null = null; + let mounted = true; + + try { + listener = connectWebhookStream({ + apiKey, + baseUrl, + eventSourceFactory, + onEvent: (event) => { + if (!mounted) { + return; + } + setIsConnected(true); + if (bufferSize > 0) { + setEvents((prev) => [event, ...prev].slice(0, bufferSize)); + } + onEventRef.current?.(event); + }, + onError: (error) => { + if (!mounted) { + return; + } + setLastError(error); + onErrorRef.current?.(error); + }, + }); + } catch (error) { + const wrapped: WebhookListenerError = { + code: 'TRANSPORT_ERROR', + message: + error instanceof Error + ? error.message + : 'Failed to open webhook stream', + cause: error, + }; + setLastError(wrapped); + onErrorRef.current?.(wrapped); + } + + return () => { + mounted = false; + listener?.close(); + setIsConnected(false); + }; + }, [apiKey, baseUrl, bufferSize, eventSourceFactory]); + + return {events, lastError, isConnected}; +} diff --git a/libraries/expo-iap/src/webhook-client.ts b/libraries/expo-iap/src/webhook-client.ts new file mode 100644 index 00000000..c797e6fe --- /dev/null +++ b/libraries/expo-iap/src/webhook-client.ts @@ -0,0 +1,259 @@ +// Transport-agnostic webhook client for the openiap kit SSE stream +// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS +// wrappers (react-native-iap, expo-iap) but written without React or +// React-Native imports so it can also run in plain Node, browser, or +// any other JS runtime. +// +// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts` +// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`. +// +// Parser logic is split out from the connection so it can be unit- +// tested without a live server. See `webhook-client.test.ts`. + +export type WebhookEventType = + | "SubscriptionStarted" + | "SubscriptionRenewed" + | "SubscriptionExpired" + | "SubscriptionInGracePeriod" + | "SubscriptionInBillingRetry" + | "SubscriptionRecovered" + | "SubscriptionCanceled" + | "SubscriptionUncanceled" + | "SubscriptionRevoked" + | "SubscriptionPriceChange" + | "SubscriptionProductChanged" + | "SubscriptionPaused" + | "SubscriptionResumed" + | "PurchaseRefunded" + | "PurchaseConsumptionRequest" + | "TestNotification"; + +export type WebhookEventPayload = { + id: string; + type: WebhookEventType; + source: string; + platform: "IOS" | "Android"; + environment: "Production" | "Sandbox" | "Xcode"; + projectId: string; + occurredAt: number; + receivedAt: number; + purchaseToken: string; + productId?: string; + subscriptionState?: string; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + rawSignedPayload?: string; +}; + +export type WebhookListenerOptions = { + /** + * Project API key. Embedded in the URL path because Apple ASN + * registration cannot send custom headers; the same path is reused + * here for symmetry. + */ + apiKey: string; + /** + * Override the kit base URL. Defaults to https://kit.openiap.dev. + * In tests, point this at a local server. + */ + baseUrl?: string; + /** Called on every successfully-parsed webhook event. */ + onEvent: (event: WebhookEventPayload) => void; + /** + * Called on transport errors. The connection auto-reconnects + * unconditionally; this callback exists for telemetry / surfacing + * to the host UI. + */ + onError?: (error: WebhookListenerError) => void; + /** + * Optional injection of an EventSource constructor. Lets RN / + * Expo plug in `react-native-event-source` when running on a JS + * runtime that lacks the global, or vitest plug in a stub. + */ + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; +}; + +export interface WebhookEventStream { + close(): void; + onmessage: + | ((event: { data: string; lastEventId?: string }) => void) + | null; + onerror: ((error: unknown) => void) | null; + addEventListener?: ( + type: string, + listener: (event: { data: string; lastEventId?: string }) => void, + ) => void; +} + +export type WebhookListener = { + /** Tear down the connection and stop receiving events. */ + close(): void; +}; + +export type WebhookListenerError = { + code: + | "TRANSPORT_ERROR" + | "PARSE_ERROR" + | "MALFORMED_EVENT" + | "NO_EVENTSOURCE"; + message: string; + cause?: unknown; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export function connectWebhookStream( + options: WebhookListenerOptions, +): WebhookListener { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`; + + const factory = options.eventSourceFactory ?? defaultEventSourceFactory; + let stream: WebhookEventStream; + try { + stream = factory(url, {}); + } catch (error) { + options.onError?.({ + code: "NO_EVENTSOURCE", + message: + error instanceof Error + ? error.message + : "EventSource constructor unavailable in this runtime", + cause: error, + }); + return { close: () => {} }; + } + + const handleData = (raw: string) => { + const parsed = parseWebhookEventData(raw); + if (parsed.kind === "error") { + options.onError?.({ + code: "PARSE_ERROR", + message: parsed.message, + }); + return; + } + if (parsed.kind === "skip") { + return; + } + options.onEvent(parsed.event); + }; + + if (typeof stream.addEventListener === "function") { + stream.addEventListener("message", (event) => handleData(event.data)); + // The kit server emits each webhook with `event: `; + // listeners that want type-filtered subscriptions can hook the + // EventSource directly. Here we register a default handler against + // the generic message channel via addEventListener so EventSource + // implementations that route typed events through `onmessage` + // don't double-fire. + } else { + stream.onmessage = (event) => handleData(event.data); + } + + stream.onerror = (error) => { + options.onError?.({ + code: "TRANSPORT_ERROR", + message: "SSE transport error (auto-reconnecting)", + cause: error, + }); + }; + + return { + close: () => { + try { + stream.close(); + } catch { + // Closing an already-closed EventSource is a no-op in browsers + // but throws in some polyfills. + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing). +// --------------------------------------------------------------------------- + +export type ParsedEventResult = + | { kind: "ok"; event: WebhookEventPayload } + | { kind: "skip"; reason: "heartbeat" | "stream-control" } + | { kind: "error"; message: string }; + +export function parseWebhookEventData(raw: string): ParsedEventResult { + if (!raw) { + return { kind: "skip", reason: "heartbeat" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { + kind: "error", + message: + error instanceof Error + ? `Failed to parse SSE payload: ${error.message}` + : "Failed to parse SSE payload", + }; + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("type" in parsed) || + typeof (parsed as Record).type !== "string" + ) { + // Stream-control messages (the `ready`/`stream-error` envelopes + // emitted by the kit server) have no `type` and are surfaced as + // skips so consumers don't see them as events. + return { kind: "skip", reason: "stream-control" }; + } + + const event = parsed as WebhookEventPayload; + + if ( + typeof event.id !== "string" || + typeof event.purchaseToken !== "string" || + typeof event.occurredAt !== "number" || + typeof event.receivedAt !== "number" + ) { + return { + kind: "error", + message: `WebhookEvent missing required fields (id/purchaseToken/occurredAt/receivedAt)`, + }; + } + + return { kind: "ok", event }; +} + +function trimTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function defaultEventSourceFactory( + url: string, + _headers: Record, +): WebhookEventStream { + // EventSource is part of the WHATWG spec and available in all + // browser environments and most JS runtimes (Bun, Node 22+, Deno). + // RN does not ship it natively — consumers must pass + // `eventSourceFactory` from `react-native-sse` or similar. + const ctor = ( + globalThis as { + EventSource?: new (url: string) => WebhookEventStream; + } + ).EventSource; + if (!ctor) { + throw new Error( + "EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.", + ); + } + return new ctor(url); +} diff --git a/libraries/flutter_inapp_purchase/lib/webhook_client.dart b/libraries/flutter_inapp_purchase/lib/webhook_client.dart new file mode 100644 index 00000000..f6d7f45d --- /dev/null +++ b/libraries/flutter_inapp_purchase/lib/webhook_client.dart @@ -0,0 +1,407 @@ +// Webhook listener for the openiap kit SSE stream +// (`GET /v1/webhooks/stream/{apiKey}`). +// +// Wire format mirrors the canonical TypeScript implementation in +// `packages/gql/src/webhook-client.ts`. The `WebhookEvent` shape comes +// from `packages/gql/src/webhook.graphql` (and is sync-generated into +// `lib/types.dart`). +// +// Why a hand-rolled HTTP/SSE parser instead of an http SSE package: +// the parser is small (~80 lines), matches the openiap project's +// preference for not pulling extra Dart packages into the platform +// SDKs, and gives us total control of the reconnect cadence which is +// what end-of-period billing flows actually depend on. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Possible webhook event kinds. Mirrors the GraphQL +/// `WebhookEventType` enum in `packages/gql/src/webhook.graphql`. +enum WebhookEventTypeName { + subscriptionStarted, + subscriptionRenewed, + subscriptionExpired, + subscriptionInGracePeriod, + subscriptionInBillingRetry, + subscriptionRecovered, + subscriptionCanceled, + subscriptionUncanceled, + subscriptionRevoked, + subscriptionPriceChange, + subscriptionProductChanged, + subscriptionPaused, + subscriptionResumed, + purchaseRefunded, + purchaseConsumptionRequest, + testNotification, + unknown, +} + +WebhookEventTypeName _parseEventTypeName(String? raw) { + switch (raw) { + case 'SubscriptionStarted': + return WebhookEventTypeName.subscriptionStarted; + case 'SubscriptionRenewed': + return WebhookEventTypeName.subscriptionRenewed; + case 'SubscriptionExpired': + return WebhookEventTypeName.subscriptionExpired; + case 'SubscriptionInGracePeriod': + return WebhookEventTypeName.subscriptionInGracePeriod; + case 'SubscriptionInBillingRetry': + return WebhookEventTypeName.subscriptionInBillingRetry; + case 'SubscriptionRecovered': + return WebhookEventTypeName.subscriptionRecovered; + case 'SubscriptionCanceled': + return WebhookEventTypeName.subscriptionCanceled; + case 'SubscriptionUncanceled': + return WebhookEventTypeName.subscriptionUncanceled; + case 'SubscriptionRevoked': + return WebhookEventTypeName.subscriptionRevoked; + case 'SubscriptionPriceChange': + return WebhookEventTypeName.subscriptionPriceChange; + case 'SubscriptionProductChanged': + return WebhookEventTypeName.subscriptionProductChanged; + case 'SubscriptionPaused': + return WebhookEventTypeName.subscriptionPaused; + case 'SubscriptionResumed': + return WebhookEventTypeName.subscriptionResumed; + case 'PurchaseRefunded': + return WebhookEventTypeName.purchaseRefunded; + case 'PurchaseConsumptionRequest': + return WebhookEventTypeName.purchaseConsumptionRequest; + case 'TestNotification': + return WebhookEventTypeName.testNotification; + default: + return WebhookEventTypeName.unknown; + } +} + +/// A normalized webhook event delivered by the kit SSE stream. +class WebhookEvent { + WebhookEvent({ + required this.id, + required this.type, + required this.rawType, + required this.source, + required this.platform, + required this.environment, + required this.projectId, + required this.occurredAt, + required this.receivedAt, + required this.purchaseToken, + required this.raw, + this.productId, + this.subscriptionState, + this.expiresAt, + this.renewsAt, + this.cancellationReason, + this.currency, + this.priceAmountMicros, + this.rawSignedPayload, + }); + + /// Stable identifier — matches `notificationUUID` (Apple) / + /// `messageId` (Google). + final String id; + final WebhookEventTypeName type; + + /// Raw `type` string as delivered on the wire. Useful when the spec + /// adds new types ahead of the SDK enum. + final String rawType; + final String source; + final String platform; + final String environment; + final String projectId; + final int occurredAt; + final int receivedAt; + final String purchaseToken; + final String? productId; + final String? subscriptionState; + final int? expiresAt; + final int? renewsAt; + final String? cancellationReason; + final String? currency; + final int? priceAmountMicros; + final String? rawSignedPayload; + + /// Parsed JSON for fields outside the strongly-typed surface. + final Map raw; + + static WebhookEvent? tryParse(Map raw) { + final id = raw['id']; + final type = raw['type']; + final purchaseToken = raw['purchaseToken']; + final occurredAt = raw['occurredAt']; + final receivedAt = raw['receivedAt']; + + if (id is! String || + type is! String || + purchaseToken is! String || + occurredAt is! num || + receivedAt is! num) { + return null; + } + + return WebhookEvent( + id: id, + type: _parseEventTypeName(type), + rawType: type, + source: raw['source']?.toString() ?? '', + platform: raw['platform']?.toString() ?? '', + environment: raw['environment']?.toString() ?? '', + projectId: raw['projectId']?.toString() ?? '', + occurredAt: occurredAt.toInt(), + receivedAt: receivedAt.toInt(), + purchaseToken: purchaseToken, + productId: raw['productId'] as String?, + subscriptionState: raw['subscriptionState'] as String?, + expiresAt: (raw['expiresAt'] as num?)?.toInt(), + renewsAt: (raw['renewsAt'] as num?)?.toInt(), + cancellationReason: raw['cancellationReason'] as String?, + currency: raw['currency'] as String?, + priceAmountMicros: (raw['priceAmountMicros'] as num?)?.toInt(), + rawSignedPayload: raw['rawSignedPayload'] as String?, + raw: raw, + ); + } +} + +/// Errors surfaced by the SSE listener. +class WebhookListenerError { + WebhookListenerError(this.code, this.message, [this.cause]); + + final String code; + final String message; + final Object? cause; + + @override + String toString() => 'WebhookListenerError($code): $message'; +} + +/// Active subscription. Cancel via [close]. +abstract class WebhookListener { + Stream get events; + Stream get errors; + Future close(); +} + +class _SseWebhookListener implements WebhookListener { + _SseWebhookListener({ + required this.apiKey, + required this.baseUrl, + required this.reconnectDelay, + HttpClient? httpClient, + }) : _httpClient = httpClient ?? HttpClient(); + + final String apiKey; + final String baseUrl; + final Duration reconnectDelay; + final HttpClient _httpClient; + + final StreamController _events = + StreamController.broadcast(); + final StreamController _errors = + StreamController.broadcast(); + + bool _closed = false; + String? _lastEventId; + HttpClientRequest? _pendingRequest; + StreamSubscription>? _bodySub; + + @override + Stream get events => _events.stream; + + @override + Stream get errors => _errors.stream; + + @override + Future close() async { + _closed = true; + await _bodySub?.cancel(); + _bodySub = null; + _pendingRequest?.abort(); + _pendingRequest = null; + _httpClient.close(force: true); + await _events.close(); + await _errors.close(); + } + + Future start() async { + while (!_closed) { + try { + await _runOnce(); + } catch (error, stack) { + _errors.add( + WebhookListenerError( + 'TRANSPORT_ERROR', + 'SSE stream error: $error', + stack, + ), + ); + } + if (_closed) break; + await Future.delayed(reconnectDelay); + } + } + + Future _runOnce() async { + final trimmed = + baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + final uri = Uri.parse( + '$trimmed/v1/webhooks/stream/${Uri.encodeComponent(apiKey)}', + ); + + final request = await _httpClient.getUrl(uri); + request.headers.set(HttpHeaders.acceptHeader, 'text/event-stream'); + if (_lastEventId != null) { + request.headers.set('Last-Event-ID', _lastEventId!); + } + _pendingRequest = request; + + final response = await request.close(); + if (response.statusCode != 200) { + throw HttpException('SSE stream returned ${response.statusCode}'); + } + + final completer = Completer(); + final buffer = StringBuffer(); + + _bodySub = response.listen( + (chunk) { + buffer.write(utf8.decode(chunk, allowMalformed: true)); + _drainSseFrames(buffer); + }, + onError: (Object error, StackTrace stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.complete(); + } + }, + cancelOnError: true, + ); + + try { + await completer.future; + } finally { + await _bodySub?.cancel(); + _bodySub = null; + _pendingRequest = null; + } + } + + void _drainSseFrames(StringBuffer buffer) { + var content = buffer.toString(); + final frameSeparator = RegExp(r'\r?\n\r?\n'); + while (true) { + final match = frameSeparator.firstMatch(content); + if (match == null) break; + final frame = content.substring(0, match.start); + content = content.substring(match.end); + _processFrame(frame); + } + buffer + ..clear() + ..write(content); + } + + void _processFrame(String frame) { + if (frame.isEmpty) return; + String? eventName; + String? eventId; + final dataLines = []; + for (final rawLine in frame.split(RegExp(r'\r?\n'))) { + if (rawLine.startsWith(':')) continue; // SSE comment + final colonIdx = rawLine.indexOf(':'); + if (colonIdx < 0) continue; + final field = rawLine.substring(0, colonIdx).trim(); + var value = rawLine.substring(colonIdx + 1); + if (value.startsWith(' ')) value = value.substring(1); + switch (field) { + case 'event': + eventName = value; + break; + case 'id': + eventId = value; + break; + case 'data': + dataLines.add(value); + break; + } + } + if (eventId != null && eventId.isNotEmpty) { + _lastEventId = eventId; + } + if (dataLines.isEmpty) return; + final dataStr = dataLines.join('\n'); + if (dataStr.isEmpty) return; + if (eventName == 'heartbeat' || eventName == 'ready') return; + + Map? decoded; + try { + final value = jsonDecode(dataStr); + if (value is Map) { + decoded = value; + } + } catch (error) { + _errors.add( + WebhookListenerError( + 'PARSE_ERROR', + 'Failed to parse SSE payload: $error', + ), + ); + return; + } + if (decoded == null) return; + + final event = WebhookEvent.tryParse(decoded); + if (event == null) { + _errors.add( + WebhookListenerError( + 'MALFORMED_EVENT', + 'WebhookEvent missing required fields', + ), + ); + return; + } + _events.add(event); + } +} + +/// Open a long-lived listener against the kit SSE stream. The +/// listener auto-reconnects with `Last-Event-ID` until [close] is +/// called. +WebhookListener connectWebhookStream({ + required String apiKey, + String baseUrl = 'https://kit.openiap.dev', + Duration reconnectDelay = const Duration(seconds: 2), + HttpClient? httpClient, +}) { + final listener = _SseWebhookListener( + apiKey: apiKey, + baseUrl: baseUrl, + reconnectDelay: reconnectDelay, + httpClient: httpClient, + ); + // Fire-and-forget the loop; consumers gate via [close]. + // ignore: unawaited_futures + listener.start(); + return listener; +} + +// Pure helper exposed for tests so we can validate the parser +// without spinning up a real HTTP listener. +WebhookEvent? parseWebhookEventData(String raw) { + if (raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return WebhookEvent.tryParse(decoded); + } catch (_) { + return null; + } +} diff --git a/libraries/flutter_inapp_purchase/test/webhook_client_test.dart b/libraries/flutter_inapp_purchase/test/webhook_client_test.dart new file mode 100644 index 00000000..f3cd3ce8 --- /dev/null +++ b/libraries/flutter_inapp_purchase/test/webhook_client_test.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:flutter_inapp_purchase/webhook_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('parseWebhookEventData', () { + test('parses a complete event payload', () { + final raw = jsonEncode({ + 'id': 'uuid-1', + 'type': 'SubscriptionRenewed', + 'source': 'AppleAppStoreServerNotificationsV2', + 'platform': 'IOS', + 'environment': 'Production', + 'projectId': 'p-1', + 'occurredAt': 1711000000000, + 'receivedAt': 1711000001000, + 'purchaseToken': 'token-1', + 'productId': 'com.example.premium', + 'subscriptionState': 'Active', + }); + final event = parseWebhookEventData(raw)!; + expect(event.id, 'uuid-1'); + expect(event.type, WebhookEventTypeName.subscriptionRenewed); + expect(event.purchaseToken, 'token-1'); + expect(event.productId, 'com.example.premium'); + }); + + test('returns null for empty / non-JSON / malformed input', () { + expect(parseWebhookEventData(''), isNull); + expect(parseWebhookEventData('not json'), isNull); + // Required fields missing + expect( + parseWebhookEventData(jsonEncode({'type': 'SubscriptionRenewed'})), + isNull, + ); + }); + + test('falls back to unknown for event types beyond the spec', () { + final raw = jsonEncode({ + 'id': 'uuid-2', + 'type': 'SomethingNew', + 'source': 'AppleAppStoreServerNotificationsV2', + 'platform': 'IOS', + 'environment': 'Production', + 'projectId': 'p-1', + 'occurredAt': 1, + 'receivedAt': 2, + 'purchaseToken': 't', + }); + final event = parseWebhookEventData(raw)!; + expect(event.type, WebhookEventTypeName.unknown); + expect(event.rawType, 'SomethingNew'); + }); + }); +} diff --git a/libraries/godot-iap/addons/godot-iap/webhook_client.gd b/libraries/godot-iap/addons/godot-iap/webhook_client.gd new file mode 100644 index 00000000..3866ea13 --- /dev/null +++ b/libraries/godot-iap/addons/godot-iap/webhook_client.gd @@ -0,0 +1,192 @@ +extends Node +class_name OpenIapWebhookClient + +## Webhook listener for the openiap kit SSE stream +## (`GET /v1/webhooks/stream/{api_key}`). +## +## Wire format mirrors the canonical TypeScript implementation in +## packages/gql/src/webhook-client.ts. The WebhookEvent shape comes +## from packages/gql/src/webhook.graphql. +## +## Add this node to a scene, set [code]api_key[/code] (and optionally +## [code]base_url[/code]), then call [code]connect_stream()[/code]. +## Listen for the [code]event_received[/code] signal to consume +## normalized webhook events without running your own server. +## +## Reconnect: the node uses HTTPClient + chunked HTTP read in a loop +## with a 2s back-off on disconnect. The Last-Event-ID header is +## populated from the most recently dispatched event so events that +## fire while the connection is closed are delivered in order on the +## next connect. + +@export var api_key: String = "" +@export var base_url: String = "https://kit.openiap.dev" +@export var auto_start: bool = false +@export var reconnect_delay_seconds: float = 2.0 + +signal event_received(event: Dictionary) +signal stream_error(code: String, message: String) +signal connected_to_stream() + +var _client: HTTPClient = HTTPClient.new() +var _running: bool = false +var _last_event_id: String = "" +var _buffer: String = "" + +func _ready() -> void: + if auto_start: + connect_stream() + +## Begin streaming. Returns immediately; the connection runs on a +## background process loop until [code]close_stream()[/code] is called +## or the node is freed. +func connect_stream() -> void: + if _running: + return + if api_key.is_empty(): + emit_signal("stream_error", "INVALID_INPUT", "api_key is empty") + return + _running = true + _run_loop() + +func close_stream() -> void: + _running = false + _client.close() + +func _run_loop() -> void: + while _running: + var ok := await _open_and_drain() + if not ok: + emit_signal("stream_error", "TRANSPORT_ERROR", "stream disconnected; reconnecting") + if not _running: + break + await get_tree().create_timer(reconnect_delay_seconds).timeout + +func _open_and_drain() -> bool: + var trimmed := base_url.trim_suffix("/") + var parsed_uri := trimmed.replace("https://", "").replace("http://", "") + var slash := parsed_uri.find("/") + var host: String + var path_root: String + if slash >= 0: + host = parsed_uri.substr(0, slash) + path_root = parsed_uri.substr(slash) + else: + host = parsed_uri + path_root = "" + var port := 443 + var use_ssl := true + if trimmed.begins_with("http://"): + port = 80 + use_ssl = false + var colon := host.find(":") + if colon >= 0: + port = int(host.substr(colon + 1)) + host = host.substr(0, colon) + + var connect_err := _client.connect_to_host(host, port, TLSOptions.client() if use_ssl else null) + if connect_err != OK: + return false + + while _client.get_status() == HTTPClient.STATUS_CONNECTING or _client.get_status() == HTTPClient.STATUS_RESOLVING: + _client.poll() + await get_tree().process_frame + + if _client.get_status() != HTTPClient.STATUS_CONNECTED: + return false + + emit_signal("connected_to_stream") + + var path := "%s/v1/webhooks/stream/%s" % [path_root, api_key.uri_encode()] + var headers := PackedStringArray([ + "Accept: text/event-stream", + "Cache-Control: no-cache", + ]) + if not _last_event_id.is_empty(): + headers.append("Last-Event-ID: %s" % _last_event_id) + + var req_err := _client.request(HTTPClient.METHOD_GET, path, headers) + if req_err != OK: + return false + + while _client.get_status() == HTTPClient.STATUS_REQUESTING: + _client.poll() + await get_tree().process_frame + + if _client.get_status() != HTTPClient.STATUS_BODY: + return false + + _buffer = "" + while _client.get_status() == HTTPClient.STATUS_BODY and _running: + _client.poll() + var chunk: PackedByteArray = _client.read_response_body_chunk() + if chunk.size() > 0: + _buffer += chunk.get_string_from_utf8() + _drain_frames() + else: + await get_tree().process_frame + + return true + +func _drain_frames() -> void: + # SSE frames are terminated by a blank line ("\n\n" or "\r\n\r\n"). + while true: + var idx := _buffer.find("\n\n") + var sep_len := 2 + if idx < 0: + var idx_crlf := _buffer.find("\r\n\r\n") + if idx_crlf < 0: + return + idx = idx_crlf + sep_len = 4 + var frame := _buffer.substr(0, idx) + _buffer = _buffer.substr(idx + sep_len) + _process_frame(frame) + +func _process_frame(frame: String) -> void: + if frame.is_empty(): + return + var event_name := "" + var event_id := "" + var data_lines: Array[String] = [] + for line in frame.split("\n", false): + var stripped := line + if stripped.ends_with("\r"): + stripped = stripped.substr(0, stripped.length() - 1) + if stripped.begins_with(":"): + continue # SSE comment + var colon := stripped.find(":") + if colon < 0: + continue + var field := stripped.substr(0, colon) + var value := stripped.substr(colon + 1) + if value.begins_with(" "): + value = value.substr(1) + match field: + "event": + event_name = value + "id": + event_id = value + "data": + data_lines.append(value) + if not event_id.is_empty(): + _last_event_id = event_id + if data_lines.is_empty(): + return + if event_name == "heartbeat" or event_name == "ready": + return + var data_str := "\n".join(data_lines) + if data_str.is_empty(): + return + var json := JSON.new() + var err := json.parse(data_str) + if err != OK: + emit_signal("stream_error", "PARSE_ERROR", "Failed to parse SSE payload: %s" % json.get_error_message()) + return + var decoded = json.data + if typeof(decoded) != TYPE_DICTIONARY: + return + if not decoded.has("id") or not decoded.has("type") or not decoded.has("purchaseToken"): + emit_signal("stream_error", "MALFORMED_EVENT", "WebhookEvent missing required fields") + return + emit_signal("event_received", decoded) diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt new file mode 100644 index 00000000..03426398 --- /dev/null +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt @@ -0,0 +1,157 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +/** + * Pure parser + value types for the openiap kit SSE webhook stream + * (`GET /v1/webhooks/stream/{apiKey}`). + * + * Wire format mirrors the canonical TypeScript implementation in + * `packages/gql/src/webhook-client.ts`. The `WebhookEvent` shape comes + * from `packages/gql/src/webhook.graphql`. + * + * The transport (an actual HTTP+SSE client) is intentionally not in + * commonMain — KMP doesn't ship a stdlib HTTP client. Consumers wire + * a transport per target (e.g. OkHttp on Android, NSURLSession on + * iOS) and feed each parsed JSON frame to [WebhookEventParser.parse]. + * That keeps this module's dependency surface flat (no Ktor) while + * still giving every target the same parser + types. + */ + +enum class WebhookEventTypeName { + SubscriptionStarted, + SubscriptionRenewed, + SubscriptionExpired, + SubscriptionInGracePeriod, + SubscriptionInBillingRetry, + SubscriptionRecovered, + SubscriptionCanceled, + SubscriptionUncanceled, + SubscriptionRevoked, + SubscriptionPriceChange, + SubscriptionProductChanged, + SubscriptionPaused, + SubscriptionResumed, + PurchaseRefunded, + PurchaseConsumptionRequest, + TestNotification, + Unknown; + + companion object { + fun fromRaw(raw: String?): WebhookEventTypeName = when (raw) { + "SubscriptionStarted" -> SubscriptionStarted + "SubscriptionRenewed" -> SubscriptionRenewed + "SubscriptionExpired" -> SubscriptionExpired + "SubscriptionInGracePeriod" -> SubscriptionInGracePeriod + "SubscriptionInBillingRetry" -> SubscriptionInBillingRetry + "SubscriptionRecovered" -> SubscriptionRecovered + "SubscriptionCanceled" -> SubscriptionCanceled + "SubscriptionUncanceled" -> SubscriptionUncanceled + "SubscriptionRevoked" -> SubscriptionRevoked + "SubscriptionPriceChange" -> SubscriptionPriceChange + "SubscriptionProductChanged" -> SubscriptionProductChanged + "SubscriptionPaused" -> SubscriptionPaused + "SubscriptionResumed" -> SubscriptionResumed + "PurchaseRefunded" -> PurchaseRefunded + "PurchaseConsumptionRequest" -> PurchaseConsumptionRequest + "TestNotification" -> TestNotification + else -> Unknown + } + } +} + +data class WebhookEvent( + val id: String, + val type: WebhookEventTypeName, + val rawType: String, + val source: String, + val platform: String, + val environment: String, + val projectId: String, + val occurredAt: Long, + val receivedAt: Long, + val purchaseToken: String, + val productId: String? = null, + val subscriptionState: String? = null, + val expiresAt: Long? = null, + val renewsAt: Long? = null, + val cancellationReason: String? = null, + val currency: String? = null, + val priceAmountMicros: Long? = null, + val rawSignedPayload: String? = null, + val raw: JsonObject, +) + +object WebhookEventParser { + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + /** + * Parse one SSE `data:` frame (a single JSON object) into a + * [WebhookEvent], or null if the frame is a heartbeat / control + * envelope / malformed payload. + */ + fun parse(rawJson: String): WebhookEvent? { + if (rawJson.isEmpty()) return null + val element: JsonElement = try { + json.parseToJsonElement(rawJson) + } catch (_: Throwable) { + return null + } + if (element !is JsonObject) return null + return fromJson(element) + } + + fun fromJson(element: JsonObject): WebhookEvent? { + val id = element["id"]?.jsonPrimitive?.contentOrNull ?: return null + val type = element["type"]?.jsonPrimitive?.contentOrNull ?: return null + val purchaseToken = + element["purchaseToken"]?.jsonPrimitive?.contentOrNull ?: return null + val occurredAt = element["occurredAt"]?.jsonPrimitive?.longOrNull + ?: return null + val receivedAt = element["receivedAt"]?.jsonPrimitive?.longOrNull + ?: return null + + return WebhookEvent( + id = id, + type = WebhookEventTypeName.fromRaw(type), + rawType = type, + source = element["source"]?.jsonPrimitive?.contentOrNull ?: "", + platform = element["platform"]?.jsonPrimitive?.contentOrNull ?: "", + environment = element["environment"]?.jsonPrimitive?.contentOrNull ?: "", + projectId = element["projectId"]?.jsonPrimitive?.contentOrNull ?: "", + occurredAt = occurredAt, + receivedAt = receivedAt, + purchaseToken = purchaseToken, + productId = element["productId"]?.jsonPrimitive?.contentOrNull, + subscriptionState = + element["subscriptionState"]?.jsonPrimitive?.contentOrNull, + expiresAt = element["expiresAt"]?.jsonPrimitive?.longOrNull, + renewsAt = element["renewsAt"]?.jsonPrimitive?.longOrNull, + cancellationReason = + element["cancellationReason"]?.jsonPrimitive?.contentOrNull, + currency = element["currency"]?.jsonPrimitive?.contentOrNull, + priceAmountMicros = + element["priceAmountMicros"]?.jsonPrimitive?.longOrNull + ?: element["priceAmountMicros"]?.jsonPrimitive?.intOrNull?.toLong(), + rawSignedPayload = + element["rawSignedPayload"]?.jsonPrimitive?.contentOrNull, + raw = element, + ) + } +} + +/** + * Endpoint URL for the kit SSE stream. Kept on the type so callers + * don't reimplement the path layout in each transport. + */ +fun webhookStreamUrl(baseUrl: String = "https://kit.openiap.dev", apiKey: String): String { + val trimmed = if (baseUrl.endsWith("/")) baseUrl.dropLast(1) else baseUrl + return "$trimmed/v1/webhooks/stream/$apiKey" +} diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt new file mode 100644 index 00000000..3d22e57a --- /dev/null +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt @@ -0,0 +1,73 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class WebhookClientTest { + @Test + fun parsesACompleteEventPayload() { + val raw = """ + { + "id": "uuid-1", + "type": "SubscriptionRenewed", + "source": "AppleAppStoreServerNotificationsV2", + "platform": "IOS", + "environment": "Production", + "projectId": "p-1", + "occurredAt": 1711000000000, + "receivedAt": 1711000001000, + "purchaseToken": "token-1", + "productId": "com.example.premium", + "subscriptionState": "Active" + } + """.trimIndent() + + val event = WebhookEventParser.parse(raw) + assertNotNull(event) + assertEquals("uuid-1", event.id) + assertEquals(WebhookEventTypeName.SubscriptionRenewed, event.type) + assertEquals("token-1", event.purchaseToken) + assertEquals("com.example.premium", event.productId) + assertEquals(1_711_000_000_000L, event.occurredAt) + } + + @Test + fun returnsNullForEmptyOrMalformedInput() { + assertNull(WebhookEventParser.parse("")) + assertNull(WebhookEventParser.parse("not json")) + assertNull( + WebhookEventParser.parse("""{"type":"SubscriptionRenewed"}"""), + ) + } + + @Test + fun fallsBackToUnknownForUnseenEventTypes() { + val raw = """ + { + "id": "uuid-2", + "type": "SomethingNew", + "source": "AppleAppStoreServerNotificationsV2", + "platform": "IOS", + "environment": "Production", + "projectId": "p-1", + "occurredAt": 1, + "receivedAt": 2, + "purchaseToken": "t" + } + """.trimIndent() + val event = WebhookEventParser.parse(raw) + assertNotNull(event) + assertEquals(WebhookEventTypeName.Unknown, event.type) + assertEquals("SomethingNew", event.rawType) + } + + @Test + fun streamUrlBuilderTrimsTrailingSlashes() { + assertEquals( + "https://kit.openiap.dev/v1/webhooks/stream/key", + webhookStreamUrl(baseUrl = "https://kit.openiap.dev/", apiKey = "key"), + ) + } +} diff --git a/libraries/react-native-iap/src/__tests__/hooks/useWebhookEvents.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useWebhookEvents.test.ts new file mode 100644 index 00000000..f92cd9c3 --- /dev/null +++ b/libraries/react-native-iap/src/__tests__/hooks/useWebhookEvents.test.ts @@ -0,0 +1,192 @@ +/* eslint-disable import/first */ +import React from 'react'; +import TestRenderer, {act} from 'react-test-renderer'; + +// `useWebhookEvents` doesn't import any RN-native code beyond `react`, +// but it lives next to hooks that do — keep RN mocks consistent with +// the rest of the test suite so jest's react-native preset doesn't +// fail to resolve a transitive import. +jest.mock('react-native', () => ({ + Platform: {OS: 'ios', select: (obj: any) => obj.ios}, +})); + +import {useWebhookEvents} from '../../hooks/useWebhookEvents'; +import type { + WebhookEventPayload, + WebhookEventStream, +} from '../../webhook-client'; + +const validEvent: WebhookEventPayload = { + id: 'uuid-1', + type: 'SubscriptionRenewed', + source: 'AppleAppStoreServerNotificationsV2', + platform: 'IOS', + environment: 'Production', + projectId: 'p-1', + occurredAt: 1_711_000_000_000, + receivedAt: 1_711_000_001_000, + purchaseToken: 'token-1', + productId: 'com.example.premium', + subscriptionState: 'Active', +}; + +function makeFakeStream() { + const listeners: Record< + string, + (event: {data: string; lastEventId?: string}) => void + > = {}; + const stream: WebhookEventStream = { + onmessage: null, + onerror: null, + addEventListener: (type, listener) => { + listeners[type] = listener; + }, + close: jest.fn(), + }; + return { + stream, + fire: (data: string) => listeners.message?.({data}), + }; +} + +function HookProbe(props: Parameters[0]) { + const result = useWebhookEvents(props); + // expose into a static slot for the test to read + (HookProbe as any).last = result; + return null; +} + +describe('useWebhookEvents', () => { + afterEach(() => { + (HookProbe as any).last = null; + }); + + it('does nothing when apiKey is empty', () => { + const factory = jest.fn(); + let renderer: ReturnType | null = null; + act(() => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + apiKey: null, + eventSourceFactory: factory as any, + onEvent: () => {}, + }), + ); + }); + expect(factory).not.toHaveBeenCalled(); + act(() => { + renderer?.unmount(); + }); + }); + + it('opens a stream once apiKey is non-empty and forwards events into the buffer', () => { + const {stream, fire} = makeFakeStream(); + const factory = jest.fn(() => stream); + const onEvent = jest.fn(); + + let renderer: ReturnType | null = null; + act(() => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + onEvent, + }), + ); + }); + + expect(factory).toHaveBeenCalledWith( + 'http://localhost/v1/webhooks/stream/k', + {}, + ); + + act(() => { + fire(JSON.stringify(validEvent)); + }); + + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({id: 'uuid-1'}), + ); + + const result = (HookProbe as any).last as { + events: WebhookEventPayload[]; + isConnected: boolean; + }; + expect(result.events).toHaveLength(1); + expect(result.events[0]?.id).toBe('uuid-1'); + expect(result.isConnected).toBe(true); + + act(() => { + renderer?.unmount(); + }); + expect(stream.close).toHaveBeenCalled(); + }); + + it('caps the in-memory buffer at bufferSize', () => { + const {stream, fire} = makeFakeStream(); + const factory = jest.fn(() => stream); + + let renderer: ReturnType | null = null; + act(() => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + bufferSize: 2, + }), + ); + }); + + act(() => { + fire(JSON.stringify({...validEvent, id: 'a'})); + fire(JSON.stringify({...validEvent, id: 'b'})); + fire(JSON.stringify({...validEvent, id: 'c'})); + }); + + const result = (HookProbe as any).last as { + events: WebhookEventPayload[]; + }; + expect(result.events).toHaveLength(2); + expect(result.events.map((e) => e?.id)).toEqual(['c', 'b']); + + act(() => { + renderer?.unmount(); + }); + }); + + it('reports errors via lastError + onError but keeps the listener alive', () => { + const {stream} = makeFakeStream(); + const factory = jest.fn(() => stream); + const onError = jest.fn(); + + let renderer: ReturnType | null = null; + act(() => { + renderer = TestRenderer.create( + React.createElement(HookProbe, { + apiKey: 'k', + baseUrl: 'http://localhost', + eventSourceFactory: factory as any, + onError, + }), + ); + }); + + act(() => { + stream.onerror?.(new Error('disconnect')); + }); + + const result = (HookProbe as any).last as { + lastError: {code: string} | null; + }; + expect(result.lastError?.code).toBe('TRANSPORT_ERROR'); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({code: 'TRANSPORT_ERROR'}), + ); + + act(() => { + renderer?.unmount(); + }); + }); +}); diff --git a/libraries/react-native-iap/src/hooks/useWebhookEvents.ts b/libraries/react-native-iap/src/hooks/useWebhookEvents.ts new file mode 100644 index 00000000..85edf392 --- /dev/null +++ b/libraries/react-native-iap/src/hooks/useWebhookEvents.ts @@ -0,0 +1,144 @@ +import {useEffect, useRef, useState} from 'react'; + +import { + connectWebhookStream, + type WebhookEventPayload, + type WebhookEventStream, + type WebhookListener, + type WebhookListenerError, +} from '../webhook-client'; + +export type UseWebhookEventsOptions = { + /** + * kit project API key — same value used for receipt verification. + * Must be non-empty to start the stream; pass `null`/`undefined` to + * disable the listener (e.g. before the user is logged in). + */ + apiKey: string | null | undefined; + /** + * Override the kit base URL. Defaults to https://kit.openiap.dev. + */ + baseUrl?: string; + /** + * Optional EventSource factory. Required on React Native because RN + * does not ship a global EventSource — pass an instance from + * `react-native-sse` (or any compatible polyfill). + */ + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; + /** + * Maximum number of events to retain in the in-memory ring buffer + * surfaced as `events`. Older entries are discarded. Defaults to 50. + * Set 0 to opt out of the buffer entirely (consume only via + * `onEvent`). + */ + bufferSize?: number; + /** + * Called for every received event in addition to being appended to + * the buffer. Useful for side effects (toast, analytics, granting + * entitlement). Called with the latest stable callback identity. + */ + onEvent?: (event: WebhookEventPayload) => void; + /** + * Called when the stream surfaces a transport / parse error. + * EventSource auto-reconnects regardless of this hook — this is + * primarily for telemetry + UI surfacing. + */ + onError?: (error: WebhookListenerError) => void; +}; + +export type UseWebhookEventsResult = { + /** Most recent N events (most-recent-first). Capped at bufferSize. */ + events: WebhookEventPayload[]; + /** Last error reported by the underlying stream. Null when healthy. */ + lastError: WebhookListenerError | null; + /** True after the first successful stream open. */ + isConnected: boolean; +}; + +// React hook wrapping the SSE webhook stream. Lifecycle: +// - opens on mount (once `apiKey` is non-empty), +// - closes on unmount, +// - reconnects automatically when EventSource raises a transport +// error (the underlying client auto-reconnects via the EventSource +// spec; this hook just surfaces the error and re-renders). +// +// Why a hook: openiap's UX guidance is that consumers consume webhook +// events from React state (granting entitlement, refreshing the +// subscription view) rather than via an imperative listener. The +// hook's `events` buffer + `onEvent` callback cover both styles. +export function useWebhookEvents({ + apiKey, + baseUrl, + eventSourceFactory, + bufferSize = 50, + onEvent, + onError, +}: UseWebhookEventsOptions): UseWebhookEventsResult { + const [events, setEvents] = useState([]); + const [lastError, setLastError] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + // Stash callbacks in refs so reconnects don't fire on every render. + // The underlying SSE connection should only restart when `apiKey` / + // `baseUrl` / `eventSourceFactory` change. + const onEventRef = useRef(onEvent); + const onErrorRef = useRef(onError); + onEventRef.current = onEvent; + onErrorRef.current = onError; + + useEffect(() => { + if (!apiKey) { + return; + } + + let listener: WebhookListener | null = null; + let mounted = true; + + try { + listener = connectWebhookStream({ + apiKey, + baseUrl, + eventSourceFactory, + onEvent: (event) => { + if (!mounted) { + return; + } + setIsConnected(true); + if (bufferSize > 0) { + setEvents((prev) => [event, ...prev].slice(0, bufferSize)); + } + onEventRef.current?.(event); + }, + onError: (error) => { + if (!mounted) { + return; + } + setLastError(error); + onErrorRef.current?.(error); + }, + }); + } catch (error) { + const wrapped: WebhookListenerError = { + code: 'TRANSPORT_ERROR', + message: + error instanceof Error + ? error.message + : 'Failed to open webhook stream', + cause: error, + }; + setLastError(wrapped); + onErrorRef.current?.(wrapped); + } + + return () => { + mounted = false; + listener?.close(); + setIsConnected(false); + }; + }, [apiKey, baseUrl, bufferSize, eventSourceFactory]); + + return {events, lastError, isConnected}; +} diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 11d53380..1a66aaba 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -124,6 +124,23 @@ export interface EventSubscription { // Export hooks export {useIAP} from './hooks/useIAP'; +export {useWebhookEvents} from './hooks/useWebhookEvents'; +export type { + UseWebhookEventsOptions, + UseWebhookEventsResult, +} from './hooks/useWebhookEvents'; +export { + connectWebhookStream, + parseWebhookEventData, +} from './webhook-client'; +export type { + WebhookEventPayload, + WebhookEventStream, + WebhookEventType as WebhookEventTypeName, + WebhookListener, + WebhookListenerError, + WebhookListenerOptions, +} from './webhook-client'; // Restore completed transactions (cross-platform) // Development utilities removed - use type bridge functions directly if needed diff --git a/libraries/react-native-iap/src/webhook-client.ts b/libraries/react-native-iap/src/webhook-client.ts new file mode 100644 index 00000000..c797e6fe --- /dev/null +++ b/libraries/react-native-iap/src/webhook-client.ts @@ -0,0 +1,259 @@ +// Transport-agnostic webhook client for the openiap kit SSE stream +// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS +// wrappers (react-native-iap, expo-iap) but written without React or +// React-Native imports so it can also run in plain Node, browser, or +// any other JS runtime. +// +// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts` +// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`. +// +// Parser logic is split out from the connection so it can be unit- +// tested without a live server. See `webhook-client.test.ts`. + +export type WebhookEventType = + | "SubscriptionStarted" + | "SubscriptionRenewed" + | "SubscriptionExpired" + | "SubscriptionInGracePeriod" + | "SubscriptionInBillingRetry" + | "SubscriptionRecovered" + | "SubscriptionCanceled" + | "SubscriptionUncanceled" + | "SubscriptionRevoked" + | "SubscriptionPriceChange" + | "SubscriptionProductChanged" + | "SubscriptionPaused" + | "SubscriptionResumed" + | "PurchaseRefunded" + | "PurchaseConsumptionRequest" + | "TestNotification"; + +export type WebhookEventPayload = { + id: string; + type: WebhookEventType; + source: string; + platform: "IOS" | "Android"; + environment: "Production" | "Sandbox" | "Xcode"; + projectId: string; + occurredAt: number; + receivedAt: number; + purchaseToken: string; + productId?: string; + subscriptionState?: string; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + rawSignedPayload?: string; +}; + +export type WebhookListenerOptions = { + /** + * Project API key. Embedded in the URL path because Apple ASN + * registration cannot send custom headers; the same path is reused + * here for symmetry. + */ + apiKey: string; + /** + * Override the kit base URL. Defaults to https://kit.openiap.dev. + * In tests, point this at a local server. + */ + baseUrl?: string; + /** Called on every successfully-parsed webhook event. */ + onEvent: (event: WebhookEventPayload) => void; + /** + * Called on transport errors. The connection auto-reconnects + * unconditionally; this callback exists for telemetry / surfacing + * to the host UI. + */ + onError?: (error: WebhookListenerError) => void; + /** + * Optional injection of an EventSource constructor. Lets RN / + * Expo plug in `react-native-event-source` when running on a JS + * runtime that lacks the global, or vitest plug in a stub. + */ + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; +}; + +export interface WebhookEventStream { + close(): void; + onmessage: + | ((event: { data: string; lastEventId?: string }) => void) + | null; + onerror: ((error: unknown) => void) | null; + addEventListener?: ( + type: string, + listener: (event: { data: string; lastEventId?: string }) => void, + ) => void; +} + +export type WebhookListener = { + /** Tear down the connection and stop receiving events. */ + close(): void; +}; + +export type WebhookListenerError = { + code: + | "TRANSPORT_ERROR" + | "PARSE_ERROR" + | "MALFORMED_EVENT" + | "NO_EVENTSOURCE"; + message: string; + cause?: unknown; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export function connectWebhookStream( + options: WebhookListenerOptions, +): WebhookListener { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`; + + const factory = options.eventSourceFactory ?? defaultEventSourceFactory; + let stream: WebhookEventStream; + try { + stream = factory(url, {}); + } catch (error) { + options.onError?.({ + code: "NO_EVENTSOURCE", + message: + error instanceof Error + ? error.message + : "EventSource constructor unavailable in this runtime", + cause: error, + }); + return { close: () => {} }; + } + + const handleData = (raw: string) => { + const parsed = parseWebhookEventData(raw); + if (parsed.kind === "error") { + options.onError?.({ + code: "PARSE_ERROR", + message: parsed.message, + }); + return; + } + if (parsed.kind === "skip") { + return; + } + options.onEvent(parsed.event); + }; + + if (typeof stream.addEventListener === "function") { + stream.addEventListener("message", (event) => handleData(event.data)); + // The kit server emits each webhook with `event: `; + // listeners that want type-filtered subscriptions can hook the + // EventSource directly. Here we register a default handler against + // the generic message channel via addEventListener so EventSource + // implementations that route typed events through `onmessage` + // don't double-fire. + } else { + stream.onmessage = (event) => handleData(event.data); + } + + stream.onerror = (error) => { + options.onError?.({ + code: "TRANSPORT_ERROR", + message: "SSE transport error (auto-reconnecting)", + cause: error, + }); + }; + + return { + close: () => { + try { + stream.close(); + } catch { + // Closing an already-closed EventSource is a no-op in browsers + // but throws in some polyfills. + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing). +// --------------------------------------------------------------------------- + +export type ParsedEventResult = + | { kind: "ok"; event: WebhookEventPayload } + | { kind: "skip"; reason: "heartbeat" | "stream-control" } + | { kind: "error"; message: string }; + +export function parseWebhookEventData(raw: string): ParsedEventResult { + if (!raw) { + return { kind: "skip", reason: "heartbeat" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { + kind: "error", + message: + error instanceof Error + ? `Failed to parse SSE payload: ${error.message}` + : "Failed to parse SSE payload", + }; + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("type" in parsed) || + typeof (parsed as Record).type !== "string" + ) { + // Stream-control messages (the `ready`/`stream-error` envelopes + // emitted by the kit server) have no `type` and are surfaced as + // skips so consumers don't see them as events. + return { kind: "skip", reason: "stream-control" }; + } + + const event = parsed as WebhookEventPayload; + + if ( + typeof event.id !== "string" || + typeof event.purchaseToken !== "string" || + typeof event.occurredAt !== "number" || + typeof event.receivedAt !== "number" + ) { + return { + kind: "error", + message: `WebhookEvent missing required fields (id/purchaseToken/occurredAt/receivedAt)`, + }; + } + + return { kind: "ok", event }; +} + +function trimTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function defaultEventSourceFactory( + url: string, + _headers: Record, +): WebhookEventStream { + // EventSource is part of the WHATWG spec and available in all + // browser environments and most JS runtimes (Bun, Node 22+, Deno). + // RN does not ship it natively — consumers must pass + // `eventSourceFactory` from `react-native-sse` or similar. + const ctor = ( + globalThis as { + EventSource?: new (url: string) => WebhookEventStream; + } + ).EventSource; + if (!ctor) { + throw new Error( + "EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.", + ); + } + return new ctor(url); +} diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index 2eabdcd1..3e87fd34 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -86,6 +86,7 @@ import APIsIsBillingProgramAvailableAndroid from './apis/android/is-billing-prog import APIsLaunchExternalLinkAndroid from './apis/android/launch-external-link-android'; import APIsCreateBillingProgramReportingDetailsAndroid from './apis/android/create-billing-program-reporting-details-android'; import Events from './events'; +import Webhooks from './webhooks'; import EventsPurchaseUpdatedListener from './events/purchase-updated-listener'; import EventsPurchaseErrorListener from './events/purchase-error-listener'; import EventsSubscriptionBillingIssueListener from './events/subscription-billing-issue-listener'; @@ -1115,6 +1116,7 @@ function Docs() { element={} /> } /> + } /> } diff --git a/packages/docs/src/pages/docs/webhooks.tsx b/packages/docs/src/pages/docs/webhooks.tsx new file mode 100644 index 00000000..58b148cc --- /dev/null +++ b/packages/docs/src/pages/docs/webhooks.tsx @@ -0,0 +1,190 @@ +import AnchorLink from '../../components/AnchorLink'; +import CodeBlock from '../../components/CodeBlock'; +import LanguageTabs from '../../components/LanguageTabs'; +import SEO from '../../components/SEO'; +import { useScrollToHash } from '../../hooks/useScrollToHash'; + +function Webhooks() { + useScrollToHash(); + + return ( +
+ +

Webhooks

+

+ OpenIAP normalizes Apple{' '} + + App Store Server Notifications v2 + {' '} + and Google{' '} + + Real-Time Developer Notifications + {' '} + into a single cross-store event stream and pushes them straight to your + client SDK over Server-Sent Events. Apps can react to renewals, + billing-retry, refunds, and revokes without operating any backend of + their own. +

+ +
+ + Architecture + +

+ The kit service hosted at https://kit.openiap.dev is + registered as the webhook endpoint with Apple and Google. It verifies + each notification's signature, normalizes the payload into the spec's{' '} + WebhookEvent shape, dedups on the source notification id, + and stores the result for at least 30 days. Authenticated SDK clients + connect to GET /v1/webhooks/stream/{apiKey} and + receive new events as Server-Sent Events along with reconnect support + via the Last-Event-ID header. +

+
+ +
+ + Event shape + +

+ Each event delivered over the SSE stream conforms to the GraphQL{' '} + WebhookEvent type defined in{' '} + packages/gql/src/webhook.graphql. The unified event types + are: +

+
    +
  • + SubscriptionStarted, SubscriptionRenewed, + SubscriptionExpired +
  • +
  • + SubscriptionInGracePeriod,{' '} + SubscriptionInBillingRetry,{' '} + SubscriptionRecovered +
  • +
  • + SubscriptionCanceled,{' '} + SubscriptionUncanceled,{' '} + SubscriptionRevoked +
  • +
  • + SubscriptionPriceChange,{' '} + SubscriptionProductChanged,{' '} + SubscriptionPaused, SubscriptionResumed +
  • +
  • + PurchaseRefunded,{' '} + PurchaseConsumptionRequest,{' '} + TestNotification +
  • +
+

+ The id field is the stable per-notification identifier ( + notificationUUID on Apple, messageId on + Google) — use it for application-level idempotency. The full source ↔ + openiap mapping table lives at{' '} + knowledge/external/webhook-mapping.md. +

+
+ +
+ + Usage + + + {{ + typescript: ( + {`// react-native-iap (and expo-iap) ship a useWebhookEvents hook. +import { useWebhookEvents } from 'react-native-iap'; +// React Native does not ship a global EventSource; pass one in. +import EventSource from 'react-native-sse'; + +const { events, lastError, isConnected } = useWebhookEvents({ + apiKey: process.env.OPENIAP_API_KEY!, + // baseUrl defaults to https://kit.openiap.dev + eventSourceFactory: (url) => new EventSource(url), + onEvent: (event) => { + if (event.type === 'SubscriptionRenewed') { + grantEntitlement(event.purchaseToken); + } + }, +});`} + ), + dart: ( + {`import 'package:flutter_inapp_purchase/webhook_client.dart'; + +final listener = connectWebhookStream(apiKey: 'sk_live_...'); +listener.events.listen((event) { + if (event.type == WebhookEventTypeName.subscriptionRenewed) { + grantEntitlement(event.purchaseToken); + } +});`} + ), + kotlin: ( + {`import io.github.hyochan.kmpiap.openiap.WebhookEventParser +import io.github.hyochan.kmpiap.openiap.webhookStreamUrl + +// Pure parser + types live in commonMain. Wire your platform's HTTP +// client to webhookStreamUrl(apiKey = "...") and feed each SSE +// data frame to WebhookEventParser.parse(). +val event = WebhookEventParser.parse(rawJson) ?: return +when (event.type) { + WebhookEventTypeName.SubscriptionRenewed -> grantEntitlement(event.purchaseToken) + else -> Unit +}`} + ), + gdscript: ( + {`extends Node + +@onready var webhook := preload("res://addons/godot-iap/webhook_client.gd").new() + +func _ready() -> void: + webhook.api_key = "sk_live_..." + webhook.event_received.connect(_on_event) + add_child(webhook) + webhook.connect_stream() + +func _on_event(event: Dictionary) -> void: + if event["type"] == "SubscriptionRenewed": + grant_entitlement(event["purchaseToken"])`} + ), + }} + +
+ +
+ + Reconnect and replay + +

+ The SSE stream auto-reconnects on transport errors. The standard{' '} + Last-Event-ID header is honored — kit looks up the named + event's receivedAt and resumes from there, so events that + fired while the connection was closed are delivered in order on the + next connect. +

+

+ For long-offline reconciliation, call the{' '} + webhookEventsSince Convex query directly with a + checkpoint timestamp; it returns up to 500 events at a time, capped at + the 30-day retention window. +

+
+
+ ); +} + +export default Webhooks; diff --git a/packages/gql/package.json b/packages/gql/package.json index 27fff14f..7dd62db6 100644 --- a/packages/gql/package.json +++ b/packages/gql/package.json @@ -5,6 +5,7 @@ "main": "src/generated/types.ts", "exports": { ".": "./src/generated/types.ts", + "./webhook-client": "./src/webhook-client.ts", "./swift": "./src/generated/Types.swift", "./kotlin": "./src/generated/Types.kt", "./dart": "./src/generated/types.dart", diff --git a/packages/gql/scripts/sync-to-platforms.mjs b/packages/gql/scripts/sync-to-platforms.mjs index 4fd1b3bc..cb2502fb 100755 --- a/packages/gql/scripts/sync-to-platforms.mjs +++ b/packages/gql/scripts/sync-to-platforms.mjs @@ -43,6 +43,20 @@ const tsSource = resolve(gqlRoot, 'src/generated/types.ts'); const rnTsTarget = resolve(monorepoRoot, 'libraries/react-native-iap/src/types.ts'); const expoTsTarget = resolve(monorepoRoot, 'libraries/expo-iap/src/types.ts'); +// `webhook-client.ts` is a hand-maintained runtime helper rather than +// generated output, but it lives in `packages/gql` so RN and Expo can +// share a single canonical implementation. Sync alongside the types so +// the two never drift. +const webhookClientSource = resolve(gqlRoot, 'src/webhook-client.ts'); +const rnWebhookClientTarget = resolve( + monorepoRoot, + 'libraries/react-native-iap/src/webhook-client.ts', +); +const expoWebhookClientTarget = resolve( + monorepoRoot, + 'libraries/expo-iap/src/webhook-client.ts', +); + const kmpSource = resolve(gqlRoot, 'src/generated/Types.kt'); const kmpTarget = resolve( monorepoRoot, @@ -114,6 +128,19 @@ if (existsSync(tsSource)) { console.log(` ${expoTsTarget}\n`); } +// Sync the webhook client to react-native-iap + expo-iap. Doing this +// during type-sync means the per-library copies can never silently +// drift from the canonical implementation in `packages/gql`. +if (existsSync(webhookClientSource)) { + for (const target of [rnWebhookClientTarget, expoWebhookClientTarget]) { + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(webhookClientSource, target); + } + console.log('✅ webhook-client → react-native-iap + expo-iap'); + console.log(` ${rnWebhookClientTarget}`); + console.log(` ${expoWebhookClientTarget}\n`); +} + // Sync Kotlin to kmp-iap with the library-specific package declaration and // the enum-companion semicolon that Kotlin requires. This mirrors the // post-process that packages/google runs; without it the KMP module would diff --git a/packages/gql/src/webhook-client.test.ts b/packages/gql/src/webhook-client.test.ts new file mode 100644 index 00000000..94679247 --- /dev/null +++ b/packages/gql/src/webhook-client.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + connectWebhookStream, + parseWebhookEventData, + type WebhookEventPayload, + type WebhookEventStream, +} from "./webhook-client"; + +const validEvent: WebhookEventPayload = { + id: "uuid-1", + type: "SubscriptionRenewed", + source: "AppleAppStoreServerNotificationsV2", + platform: "IOS", + environment: "Production", + projectId: "project-1", + occurredAt: 1_711_000_000_000, + receivedAt: 1_711_000_001_000, + purchaseToken: "token-1", + productId: "com.example.premium", + subscriptionState: "Active", +}; + +describe("parseWebhookEventData", () => { + it("parses a valid event JSON into an event payload", () => { + const result = parseWebhookEventData(JSON.stringify(validEvent)); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.event.id).toBe("uuid-1"); + expect(result.event.type).toBe("SubscriptionRenewed"); + expect(result.event.purchaseToken).toBe("token-1"); + } + }); + + it("skips heartbeats (empty payload)", () => { + expect(parseWebhookEventData("")).toEqual({ + kind: "skip", + reason: "heartbeat", + }); + }); + + it("skips stream-control envelopes that have no `type`", () => { + const result = parseWebhookEventData( + JSON.stringify({ cursor: 12345 }), + ); + expect(result.kind).toBe("skip"); + }); + + it("returns parse error on malformed JSON", () => { + const result = parseWebhookEventData("not json"); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/parse SSE payload/); + } + }); + + it("returns error when required fields are missing", () => { + const result = parseWebhookEventData( + JSON.stringify({ + type: "SubscriptionRenewed", + // missing id / purchaseToken / occurredAt / receivedAt + }), + ); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/missing required fields/); + } + }); +}); + +describe("connectWebhookStream", () => { + it("subscribes via the injected EventSource factory and forwards events", () => { + const onEvent = vi.fn(); + const onError = vi.fn(); + + let messageHandler: + | ((event: { data: string; lastEventId?: string }) => void) + | null = null; + + const fakeStream: WebhookEventStream = { + onmessage: null, + onerror: null, + addEventListener: (type, listener) => { + if (type === "message") { + messageHandler = listener; + } + }, + close: vi.fn(), + }; + + const factory = vi.fn(() => fakeStream); + + const listener = connectWebhookStream({ + apiKey: "test-key", + baseUrl: "http://localhost:3100", + onEvent, + onError, + eventSourceFactory: factory, + }); + + expect(factory).toHaveBeenCalledWith( + "http://localhost:3100/v1/webhooks/stream/test-key", + {}, + ); + + expect(messageHandler).not.toBeNull(); + messageHandler!({ data: JSON.stringify(validEvent) }); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0][0].id).toBe("uuid-1"); + expect(onError).not.toHaveBeenCalled(); + + listener.close(); + expect(fakeStream.close).toHaveBeenCalled(); + }); + + it("falls back to onmessage when addEventListener is not provided", () => { + const onEvent = vi.fn(); + + const fakeStream: WebhookEventStream = { + onmessage: null, + onerror: null, + close: () => {}, + }; + + connectWebhookStream({ + apiKey: "test-key", + baseUrl: "http://localhost:3100", + onEvent, + eventSourceFactory: () => fakeStream, + }); + + expect(fakeStream.onmessage).not.toBeNull(); + fakeStream.onmessage!({ data: JSON.stringify(validEvent) }); + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it("calls onError with TRANSPORT_ERROR when the stream errors", () => { + const onError = vi.fn(); + const fakeStream: WebhookEventStream = { + onmessage: null, + onerror: null, + close: () => {}, + }; + + connectWebhookStream({ + apiKey: "test-key", + baseUrl: "http://localhost:3100", + onEvent: () => {}, + onError, + eventSourceFactory: () => fakeStream, + }); + + fakeStream.onerror?.(new Error("boom")); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "TRANSPORT_ERROR" }), + ); + }); + + it("emits NO_EVENTSOURCE when factory throws and returns a no-op listener", () => { + const onError = vi.fn(); + const result = connectWebhookStream({ + apiKey: "key", + baseUrl: "http://localhost", + onEvent: () => {}, + onError, + eventSourceFactory: () => { + throw new Error("missing"); + }, + }); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: "NO_EVENTSOURCE" }), + ); + expect(typeof result.close).toBe("function"); + }); + + it("trims trailing slashes in baseUrl", () => { + const factory = vi.fn( + (): WebhookEventStream => ({ + onmessage: null, + onerror: null, + close: () => {}, + }), + ); + connectWebhookStream({ + apiKey: "k", + baseUrl: "https://kit.openiap.dev/", + onEvent: () => {}, + eventSourceFactory: factory, + }); + expect(factory.mock.calls[0][0]).toBe( + "https://kit.openiap.dev/v1/webhooks/stream/k", + ); + }); + + it("URL-encodes the apiKey", () => { + const factory = vi.fn( + (): WebhookEventStream => ({ + onmessage: null, + onerror: null, + close: () => {}, + }), + ); + connectWebhookStream({ + apiKey: "key with spaces & symbols", + baseUrl: "http://localhost", + onEvent: () => {}, + eventSourceFactory: factory, + }); + expect(factory.mock.calls[0][0]).toBe( + "http://localhost/v1/webhooks/stream/key%20with%20spaces%20%26%20symbols", + ); + }); +}); diff --git a/packages/gql/src/webhook-client.ts b/packages/gql/src/webhook-client.ts new file mode 100644 index 00000000..c797e6fe --- /dev/null +++ b/packages/gql/src/webhook-client.ts @@ -0,0 +1,259 @@ +// Transport-agnostic webhook client for the openiap kit SSE stream +// (`GET /v1/webhooks/stream/{apiKey}`). Used by the JavaScript / TS +// wrappers (react-native-iap, expo-iap) but written without React or +// React-Native imports so it can also run in plain Node, browser, or +// any other JS runtime. +// +// The wire format is documented in `packages/kit/server/api/v1/webhooks.ts` +// and matches the GraphQL `WebhookEvent` shape from `webhook.graphql`. +// +// Parser logic is split out from the connection so it can be unit- +// tested without a live server. See `webhook-client.test.ts`. + +export type WebhookEventType = + | "SubscriptionStarted" + | "SubscriptionRenewed" + | "SubscriptionExpired" + | "SubscriptionInGracePeriod" + | "SubscriptionInBillingRetry" + | "SubscriptionRecovered" + | "SubscriptionCanceled" + | "SubscriptionUncanceled" + | "SubscriptionRevoked" + | "SubscriptionPriceChange" + | "SubscriptionProductChanged" + | "SubscriptionPaused" + | "SubscriptionResumed" + | "PurchaseRefunded" + | "PurchaseConsumptionRequest" + | "TestNotification"; + +export type WebhookEventPayload = { + id: string; + type: WebhookEventType; + source: string; + platform: "IOS" | "Android"; + environment: "Production" | "Sandbox" | "Xcode"; + projectId: string; + occurredAt: number; + receivedAt: number; + purchaseToken: string; + productId?: string; + subscriptionState?: string; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + rawSignedPayload?: string; +}; + +export type WebhookListenerOptions = { + /** + * Project API key. Embedded in the URL path because Apple ASN + * registration cannot send custom headers; the same path is reused + * here for symmetry. + */ + apiKey: string; + /** + * Override the kit base URL. Defaults to https://kit.openiap.dev. + * In tests, point this at a local server. + */ + baseUrl?: string; + /** Called on every successfully-parsed webhook event. */ + onEvent: (event: WebhookEventPayload) => void; + /** + * Called on transport errors. The connection auto-reconnects + * unconditionally; this callback exists for telemetry / surfacing + * to the host UI. + */ + onError?: (error: WebhookListenerError) => void; + /** + * Optional injection of an EventSource constructor. Lets RN / + * Expo plug in `react-native-event-source` when running on a JS + * runtime that lacks the global, or vitest plug in a stub. + */ + eventSourceFactory?: ( + url: string, + headers: Record, + ) => WebhookEventStream; +}; + +export interface WebhookEventStream { + close(): void; + onmessage: + | ((event: { data: string; lastEventId?: string }) => void) + | null; + onerror: ((error: unknown) => void) | null; + addEventListener?: ( + type: string, + listener: (event: { data: string; lastEventId?: string }) => void, + ) => void; +} + +export type WebhookListener = { + /** Tear down the connection and stop receiving events. */ + close(): void; +}; + +export type WebhookListenerError = { + code: + | "TRANSPORT_ERROR" + | "PARSE_ERROR" + | "MALFORMED_EVENT" + | "NO_EVENTSOURCE"; + message: string; + cause?: unknown; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export function connectWebhookStream( + options: WebhookListenerOptions, +): WebhookListener { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + const url = `${trimTrailingSlash(baseUrl)}/v1/webhooks/stream/${encodeURIComponent(options.apiKey)}`; + + const factory = options.eventSourceFactory ?? defaultEventSourceFactory; + let stream: WebhookEventStream; + try { + stream = factory(url, {}); + } catch (error) { + options.onError?.({ + code: "NO_EVENTSOURCE", + message: + error instanceof Error + ? error.message + : "EventSource constructor unavailable in this runtime", + cause: error, + }); + return { close: () => {} }; + } + + const handleData = (raw: string) => { + const parsed = parseWebhookEventData(raw); + if (parsed.kind === "error") { + options.onError?.({ + code: "PARSE_ERROR", + message: parsed.message, + }); + return; + } + if (parsed.kind === "skip") { + return; + } + options.onEvent(parsed.event); + }; + + if (typeof stream.addEventListener === "function") { + stream.addEventListener("message", (event) => handleData(event.data)); + // The kit server emits each webhook with `event: `; + // listeners that want type-filtered subscriptions can hook the + // EventSource directly. Here we register a default handler against + // the generic message channel via addEventListener so EventSource + // implementations that route typed events through `onmessage` + // don't double-fire. + } else { + stream.onmessage = (event) => handleData(event.data); + } + + stream.onerror = (error) => { + options.onError?.({ + code: "TRANSPORT_ERROR", + message: "SSE transport error (auto-reconnecting)", + cause: error, + }); + }; + + return { + close: () => { + try { + stream.close(); + } catch { + // Closing an already-closed EventSource is a no-op in browsers + // but throws in some polyfills. + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing). +// --------------------------------------------------------------------------- + +export type ParsedEventResult = + | { kind: "ok"; event: WebhookEventPayload } + | { kind: "skip"; reason: "heartbeat" | "stream-control" } + | { kind: "error"; message: string }; + +export function parseWebhookEventData(raw: string): ParsedEventResult { + if (!raw) { + return { kind: "skip", reason: "heartbeat" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return { + kind: "error", + message: + error instanceof Error + ? `Failed to parse SSE payload: ${error.message}` + : "Failed to parse SSE payload", + }; + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("type" in parsed) || + typeof (parsed as Record).type !== "string" + ) { + // Stream-control messages (the `ready`/`stream-error` envelopes + // emitted by the kit server) have no `type` and are surfaced as + // skips so consumers don't see them as events. + return { kind: "skip", reason: "stream-control" }; + } + + const event = parsed as WebhookEventPayload; + + if ( + typeof event.id !== "string" || + typeof event.purchaseToken !== "string" || + typeof event.occurredAt !== "number" || + typeof event.receivedAt !== "number" + ) { + return { + kind: "error", + message: `WebhookEvent missing required fields (id/purchaseToken/occurredAt/receivedAt)`, + }; + } + + return { kind: "ok", event }; +} + +function trimTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function defaultEventSourceFactory( + url: string, + _headers: Record, +): WebhookEventStream { + // EventSource is part of the WHATWG spec and available in all + // browser environments and most JS runtimes (Bun, Node 22+, Deno). + // RN does not ship it natively — consumers must pass + // `eventSourceFactory` from `react-native-sse` or similar. + const ctor = ( + globalThis as { + EventSource?: new (url: string) => WebhookEventStream; + } + ).EventSource; + if (!ctor) { + throw new Error( + "EventSource is not defined. Pass `eventSourceFactory` for runtimes without a built-in EventSource.", + ); + } + return new ctor(url); +} diff --git a/packages/kit/convex/crons.ts b/packages/kit/convex/crons.ts index 45a59ff5..60fb0e1e 100644 --- a/packages/kit/convex/crons.ts +++ b/packages/kit/convex/crons.ts @@ -35,4 +35,17 @@ crons.interval( internal.userProfiles.internal.drainPendingDeletionOrganizations, ); +// Prune webhook events older than the 30-day retention window so the +// `webhookEventsSince` backfill query stays bounded. Runs hourly with +// a small per-tick batch size — webhook traffic is low-volume per +// project so even a tight batch keeps the table from growing +// unbounded. Matches the retention promise documented in +// `packages/gql/src/webhook.graphql`. +crons.interval( + "prune webhook events past retention", + { hours: 1 }, + internal.webhooks.internal.pruneWebhookEvents, + { olderThanMs: 30 * 24 * 60 * 60 * 1000 }, +); + export default crons; diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts index 2b885c0f..0c43f9bb 100644 --- a/packages/kit/server/api/v1/webhooks.ts +++ b/packages/kit/server/api/v1/webhooks.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import type { Context } from "hono"; +import { streamSSE } from "hono/streaming"; import { OAuth2Client } from "google-auth-library"; import { api } from "@/convex"; @@ -197,6 +198,131 @@ webhooks.post("/google/:apiKey", async (c) => { } }); +// Server-Sent Events stream of normalized webhook events tied to the +// caller's API key. Built on `webhookEventsSince` polling rather than +// Convex's native subscription stream because the Hono server uses +// `ConvexHttpClient` (no streaming subscribe) and SSE works +// universally — RN, Expo, Flutter, KMP, and Godot all have stable SSE +// or chunked-HTTP support without needing a Convex client dependency +// in each SDK. +// +// Protocol: +// GET /v1/webhooks/stream/:apiKey +// +// Response: text/event-stream with one event per webhook, +// id: +// event: +// data: +// +// Reconnection: the standard `Last-Event-ID` header is honored on +// reconnect — kit looks up that event's `receivedAt` and resumes +// from there, so events that fired while the connection was closed +// are delivered in order on the next connect. +// +// Polling cadence: 1.5s. This trades a half-step of real-time +// freshness for not opening a Convex subscribe socket per client. The +// SDKs treat the SSE connection as authoritative real-time anyway — +// any further hardening (push, true streaming) is additive. +const STREAM_POLL_MS = 1_500; +const STREAM_PAGE_LIMIT = 100; + +webhooks.get("/stream/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const lastEventId = c.req.header("last-event-id") ?? undefined; + + let cursor = await resolveStreamStartCursor(apiKey, lastEventId); + + return streamSSE(c, async (stream) => { + // Heartbeats keep proxies (Fly edge, Cloudflare, browser fetch) + // from timing the connection out during long quiet periods. SSE + // comments are ignored by EventSource clients but count as + // bytes-on-the-wire for the proxy idle timer. + let aborted = false; + stream.onAbort(() => { + aborted = true; + }); + + await stream.writeSSE({ + event: "ready", + data: JSON.stringify({ cursor }), + }); + + while (!aborted) { + let events: Array> = []; + try { + events = (await client.query(api.webhooks.query.webhookEventsSince, { + apiKey, + sinceMs: cursor, + limit: STREAM_PAGE_LIMIT, + })) as Array>; + } catch (error) { + console.error("[webhooks/stream] poll failed", error); + await stream.writeSSE({ + event: "stream-error", + data: JSON.stringify({ + message: + error instanceof Error ? error.message : "Unknown poll error", + }), + }); + // Sleep before retrying to avoid hot-looping on persistent + // backend errors. + await stream.sleep(STREAM_POLL_MS * 4); + continue; + } + + for (const event of events) { + if (aborted) { + break; + } + // Strict-equality `>` — events at exactly `sinceMs` are + // already-seen on reconnect so we'd emit a dupe otherwise. + if (typeof event.receivedAt === "number" && event.receivedAt > cursor) { + cursor = event.receivedAt; + } + await stream.writeSSE({ + id: typeof event.id === "string" ? event.id : Date.now().toString(), + event: typeof event.type === "string" ? event.type : "WebhookEvent", + data: JSON.stringify(event), + }); + } + + // Heartbeat sent regardless of new events so quiet connections + // stay alive. Comment line per the SSE spec. + await stream.writeSSE({ event: "heartbeat", data: "" }); + + await stream.sleep(STREAM_POLL_MS); + } + }); +}); + +// Translate an EventSource `Last-Event-ID` (which is the spec's stable +// `sourceNotificationId`) into a `sinceMs` cursor by looking up the +// event's `receivedAt`. Falls back to "now" when the id is unknown so +// we never replay the entire 30-day window for a confused client. +async function resolveStreamStartCursor( + apiKey: string, + lastEventId: string | undefined, +): Promise { + if (!lastEventId) { + return 0; + } + try { + const events = (await client.query(api.webhooks.query.webhookEventsSince, { + apiKey, + sinceMs: 0, + limit: 500, + })) as Array<{ id: string; receivedAt: number }>; + const match = events.find((event) => event.id === lastEventId); + if (match) { + return match.receivedAt; + } + return Date.now(); + } catch (error) { + console.warn("[webhooks/stream] cursor resolution failed", error); + return Date.now(); + } +} + const oauth2Client = new OAuth2Client(); async function verifyPubSubOidcToken( From 5ba6f2ac2fc1c08fd72b92df039dd8337413cd2b Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 00:57:17 +0900 Subject: [PATCH 04/81] feat(kit): add subscription state, paywalls, products, MCP server (onesub parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap with onesub by adding the four feature areas the comparison table called out as missing: subscription state machine (Phase 2), revenue metrics summary (Phase 2), product catalog CRUD (Phase 3 — kit-side cache; ASC/Play push-sync stays as a follow-up), hosted paywalls (Phase 4), and a stdio MCP server (Phase 5). Phase 2 — subscriptions + entitlements + metrics: - New `subscriptions` and `revenueMetricsDaily` Convex tables. - `subscriptions/stateMachine.ts` — pure state-machine that maps each WebhookEventType to the next persistent row, the entitlement decision, and a transition tag for analytics. 17 vitest cases cover every transition. - `applySubscriptionEvent` internal mutation runs after every non-deduped webhook, idempotent on `lastEventId`. - `subscriptions/query.ts` — `subscriptionStatus` (onesub `/onesub/status` parity), `entitlements`, `listSubscriptions`, `metricsSummary`. - `subscriptions/mutation.ts` — public `bindUser` for SDKs to attach a userId after a successful verifyReceipt. - Hono routes at `/v1/subscriptions/{status,entitlements,list,metrics,bind-user}/{apiKey}`. Phase 3 — product catalog: - New `products` Convex table. - `products/{mutation,query}.ts` for upsert / soft-remove / list. - Hono routes at `/v1/products/{apiKey}`. Phase 3 follow-up will layer App Store Connect + Play Developer API push-sync on top. Phase 4 — hosted paywalls: - New `paywalls` table with slug uniqueness per project. - `paywalls/{mutation,query}.ts` and `/v1/paywalls/{apiKey}` routes for CRUD plus a hosted HTML renderer at `/v1/paywalls/{apiKey}/{slug}`. The HTML emits a `{ openiap: 'purchase', productId }` message via `ReactNativeWebView.postMessage` / `flutter_inappwebview` / `window.parent.postMessage` so any of the 5 SDK WebViews can dispatch the actual purchase. Phase 5 — MCP server (`@hyodotdev/openiap-mcp-server`): - New `packages/mcp-server` with 11 tools mirroring the onesub MCP surface: `openiap_setup`, `openiap_add_paywall`, `openiap_check_status`, `openiap_troubleshoot`, `openiap_create_product`, `openiap_list_products`, `openiap_view_subscribers`, `openiap_simulate_purchase`, `openiap_simulate_webhook`, `openiap_inspect_state`, `openiap_manage_product`. Driven by stdio so it plugs into Claude Desktop / Cursor / Codex without additional infra. SDK helpers: - New `packages/gql/src/kit-api.ts` — typed `kitApi({apiKey})` wrapper around `/v1/subscriptions/...` + `/v1/paywalls/...`. 5 vitest cases cover URL encoding, error mapping, and the bind-user POST shape. - Synced into `libraries/react-native-iap/src/kit-api.ts` and `libraries/expo-iap/src/kit-api.ts` via the existing `sync-to-platforms.mjs`, and re-exported from each library's `index.ts`. Verification: - kit: lint clean (0 errors), 271/271 vitest, smoke probes green. - gql: 16/16 vitest (11 webhook-client + 5 kit-api). - react-native-iap: 276/276 jest. - expo-iap: 46/46 jest. - audit:docs: no new failures. Out of scope (follow-ups): - App Store Connect + Play Developer API push-sync (the `products` row carries `storeRef` so this is additive). - Convex-native realtime subscription stream (currently SSE polls `webhookEventsSince` every 1.5s). - KMP per-target SSE transport adapters; Godot runtime tests. - Live sandbox E2E (needs ASC / RTDN credentials a maintainer holds). Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 140 ++++- libraries/expo-iap/src/index.ts | 8 + libraries/expo-iap/src/kit-api.ts | 164 ++++++ libraries/react-native-iap/src/index.ts | 8 + libraries/react-native-iap/src/kit-api.ts | 164 ++++++ packages/gql/scripts/sync-to-platforms.mjs | 20 + packages/gql/src/kit-api.test.ts | 99 ++++ packages/gql/src/kit-api.ts | 164 ++++++ packages/kit/convex/_generated/api.d.ts | 16 + packages/kit/convex/paywalls/mutation.ts | 110 ++++ packages/kit/convex/paywalls/query.ts | 77 +++ packages/kit/convex/products/mutation.ts | 120 +++++ packages/kit/convex/products/query.ts | 73 +++ packages/kit/convex/schema.ts | 128 +++++ packages/kit/convex/subscriptions/internal.ts | 193 +++++++ packages/kit/convex/subscriptions/mutation.ts | 41 ++ packages/kit/convex/subscriptions/query.ts | 267 +++++++++ .../convex/subscriptions/stateMachine.test.ts | 208 ++++++++ .../kit/convex/subscriptions/stateMachine.ts | 279 ++++++++++ packages/kit/convex/webhooks/apple.ts | 25 + packages/kit/convex/webhooks/google.ts | 22 + packages/kit/server/api/v1/paywalls.ts | 292 ++++++++++ packages/kit/server/api/v1/products.ts | 115 ++++ packages/kit/server/api/v1/routes.ts | 21 + packages/kit/server/api/v1/subscriptions.ts | 123 +++++ packages/mcp-server/package.json | 26 + packages/mcp-server/src/index.ts | 505 ++++++++++++++++++ packages/mcp-server/src/kit-client.ts | 147 +++++ packages/mcp-server/tsconfig.json | 18 + 29 files changed, 3571 insertions(+), 2 deletions(-) create mode 100644 libraries/expo-iap/src/kit-api.ts create mode 100644 libraries/react-native-iap/src/kit-api.ts create mode 100644 packages/gql/src/kit-api.test.ts create mode 100644 packages/gql/src/kit-api.ts create mode 100644 packages/kit/convex/paywalls/mutation.ts create mode 100644 packages/kit/convex/paywalls/query.ts create mode 100644 packages/kit/convex/products/mutation.ts create mode 100644 packages/kit/convex/products/query.ts create mode 100644 packages/kit/convex/subscriptions/internal.ts create mode 100644 packages/kit/convex/subscriptions/mutation.ts create mode 100644 packages/kit/convex/subscriptions/query.ts create mode 100644 packages/kit/convex/subscriptions/stateMachine.test.ts create mode 100644 packages/kit/convex/subscriptions/stateMachine.ts create mode 100644 packages/kit/server/api/v1/paywalls.ts create mode 100644 packages/kit/server/api/v1/products.ts create mode 100644 packages/kit/server/api/v1/subscriptions.ts create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/index.ts create mode 100644 packages/mcp-server/src/kit-client.ts create mode 100644 packages/mcp-server/tsconfig.json diff --git a/bun.lock b/bun.lock index 19a4b7e1..91d1e488 100644 --- a/bun.lock +++ b/bun.lock @@ -142,6 +142,22 @@ "vitest": "^4", }, }, + "packages/mcp-server": { + "name": "@hyodotdev/openiap-mcp-server", + "version": "0.1.0", + "bin": { + "openiap-mcp": "./dist/index.js", + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.2", + "vitest": "^4.1.5", + }, + }, }, "overrides": { "csstype": "3.2.3", @@ -405,6 +421,8 @@ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@hono/standard-validator": ["@hono/standard-validator@0.2.2", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=3.9.0" } }, "sha512-mJ7W84Bt/rSvoIl63Ynew+UZOHAzzRAoAXb3JaWuxAkM/Lzg+ZHTCUiz77KOtn2e623WNN8LkD57Dk0szqUrIw=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -425,6 +443,8 @@ "@hyodotdev/openiap-kit": ["@hyodotdev/openiap-kit@workspace:packages/kit"], + "@hyodotdev/openiap-mcp-server": ["@hyodotdev/openiap-mcp-server@workspace:packages/mcp-server"], + "@icons-pack/react-simple-icons": ["@icons-pack/react-simple-icons@13.13.0", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -537,6 +557,8 @@ "@mixpanel/rrweb-utils": ["@mixpanel/rrweb-utils@2.0.0-alpha.18.4", "", {}, "sha512-c3nUbQl19kxHjf8nowFMeXlJw0ZqLesIVBb9t4g1nC4WtaNEPkFotWRdGt5V2cJNQ+aY38/v2uYb8Ren4IcdSQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@node-rs/argon2": ["@node-rs/argon2@1.7.0", "", { "optionalDependencies": { "@node-rs/argon2-android-arm-eabi": "1.7.0", "@node-rs/argon2-android-arm64": "1.7.0", "@node-rs/argon2-darwin-arm64": "1.7.0", "@node-rs/argon2-darwin-x64": "1.7.0", "@node-rs/argon2-freebsd-x64": "1.7.0", "@node-rs/argon2-linux-arm-gnueabihf": "1.7.0", "@node-rs/argon2-linux-arm64-gnu": "1.7.0", "@node-rs/argon2-linux-arm64-musl": "1.7.0", "@node-rs/argon2-linux-x64-gnu": "1.7.0", "@node-rs/argon2-linux-x64-musl": "1.7.0", "@node-rs/argon2-wasm32-wasi": "1.7.0", "@node-rs/argon2-win32-arm64-msvc": "1.7.0", "@node-rs/argon2-win32-ia32-msvc": "1.7.0", "@node-rs/argon2-win32-x64-msvc": "1.7.0" } }, "sha512-zfULc+/tmcWcxn+nHkbyY8vP3+MpEqKORbszt4UkpqZgBgDAAIYvuDN/zukfTgdmo6tmJKKVfzigZOPk4LlIog=="], "@node-rs/argon2-android-arm-eabi": ["@node-rs/argon2-android-arm-eabi@1.7.0", "", { "os": "android", "cpu": "arm" }, "sha512-udDqkr5P9E+wYX1SZwAVPdyfYvaF4ry9Tm+R9LkfSHbzWH0uhU6zjIwNRp7m+n4gx691rk+lqqDAIP8RLKwbhg=="], @@ -1015,6 +1037,8 @@ "@xstate/fsm": ["@xstate/fsm@1.6.5", "", {}, "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], @@ -1027,6 +1051,8 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-escapes": ["ansi-escapes@7.1.1", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1079,6 +1105,8 @@ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1093,6 +1121,8 @@ "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -1163,12 +1193,20 @@ "constant-case": ["constant-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", "upper-case": "^2.0.2" } }, "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convex": ["convex@1.36.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.4.3", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-NVnwNqU+h8jyPuS0Itvj4MPH9c2yF+tA/RNoSDpCqiLhmYD4+kZxm0dDkVM0QDzz66wem9NqheBb9YQGsHwzBQ=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], @@ -1217,6 +1255,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dependency-graph": ["dependency-graph@1.0.0", "", {}, "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -1253,10 +1293,14 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -1285,6 +1329,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="], @@ -1311,10 +1357,20 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1325,6 +1381,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], @@ -1343,6 +1401,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -1357,10 +1417,14 @@ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -1457,6 +1521,8 @@ "htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], @@ -1477,12 +1543,18 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-absolute": ["is-absolute@1.0.0", "", { "dependencies": { "is-relative": "^1.0.0", "is-windows": "^1.0.1" } }, "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1541,6 +1613,8 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-relative": ["is-relative@1.0.0", "", { "dependencies": { "is-unc-path": "^1.0.0" } }, "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA=="], @@ -1601,6 +1675,8 @@ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-to-pretty-yaml": ["json-to-pretty-yaml@1.2.2", "", { "dependencies": { "remedial": "^1.0.7", "remove-trailing-spaces": "^1.0.6" } }, "sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A=="], @@ -1761,12 +1837,16 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memfs": ["memfs@3.5.3", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw=="], "memfs-browser": ["memfs-browser@3.5.10302", "", { "dependencies": { "memfs": "3.5.3" } }, "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw=="], "memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "meros": ["meros@1.3.2", "", { "peerDependencies": { "@types/node": ">=13" }, "optionalPeers": ["@types/node"] }, "sha512-Q3mobPbvEx7XbwhnC1J1r60+5H6EZyNccdzSz0eGexJRwouUtTZxPVRGdqKtxlpD84ScK4+tIGldkqDtCKdI0A=="], @@ -1829,9 +1909,9 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -1861,6 +1941,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "nice-try": ["nice-try@1.0.5", "", {}, "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="], @@ -1895,6 +1977,8 @@ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1927,6 +2011,8 @@ "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], "path-case": ["path-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg=="], @@ -1965,6 +2051,8 @@ "pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], @@ -2001,6 +2089,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], @@ -2009,6 +2099,10 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-compiler-runtime": ["react-compiler-runtime@19.1.0-rc.1-rc-af1b7da-20250421", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental" } }, "sha512-Til/juI+Zfq+eYpGYn9lFxqW5RyJDs3ThOxmg0757aMrPpfx/Zb0SnGMVJhF3vw+bEQjJiD+xPFD3+kE0WbyeA=="], @@ -2079,6 +2173,8 @@ "rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], @@ -2101,8 +2197,12 @@ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "sentence-case": ["sentence-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3", "upper-case-first": "^2.0.2" } }, "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -2115,6 +2215,8 @@ "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -2173,6 +2275,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -2251,6 +2355,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], @@ -2271,6 +2377,8 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], @@ -2309,6 +2417,8 @@ "unixify": ["unixify@1.0.0", "", { "dependencies": { "normalize-path": "^2.1.1" } }, "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "upper-case": ["upper-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg=="], @@ -2329,6 +2439,8 @@ "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -2393,6 +2505,10 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@ardatan/relay-compiler/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], @@ -2497,12 +2613,16 @@ "@hyodotdev/openiap-kit/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "@hyodotdev/openiap-mcp-server/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@node-rs/argon2-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], "@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], @@ -2553,6 +2673,8 @@ "@whatwg-node/promise-helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "bun-types/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], "camel-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -2587,10 +2709,14 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "flat-cache/flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "glob/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], @@ -2657,6 +2783,8 @@ "relay-runtime/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "sentence-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "sharp-cli/sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="], @@ -2719,6 +2847,8 @@ "@hyodotdev/openiap-kit/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + "@hyodotdev/openiap-mcp-server/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2727,6 +2857,8 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@node-rs/argon2-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -2751,6 +2883,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "bun-types/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -2811,6 +2945,8 @@ "convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "graphql-config/cosmiconfig/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "graphql-config/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index 9c575960..2d6a95e8 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -1059,6 +1059,14 @@ export type { WebhookListenerError, WebhookListenerOptions, } from './webhook-client'; +export {kitApi, KitApiError} from './kit-api'; +export type { + KitApiOptions, + KitSubscription, + EntitlementsResponse, + StatusResponse, + Paywall, +} from './kit-api'; export { ErrorCodeUtils, ErrorCodeMapping, diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts new file mode 100644 index 00000000..de6c1c8b --- /dev/null +++ b/libraries/expo-iap/src/kit-api.ts @@ -0,0 +1,164 @@ +// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS +// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of +// `packages/mcp-server/src/kit-client.ts` so the same operations are +// reachable from both LLM tools and end-user apps without each +// duplicating the URL layout. + +export type KitApiOptions = { + apiKey: string; + baseUrl?: string; + // Optional fetch override for runtimes without a global (older RN + // builds) or for injection in tests. + fetchImpl?: (input: string, init?: RequestInit) => Promise; +}; + +export type KitSubscription = { + id: string; + productId: string; + platform: "IOS" | "Android"; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + startedAt: number; + updatedAt: number; + purchaseToken: string; + userId?: string; +}; + +export type EntitlementsResponse = { + userId: string; + productIds: string[]; + subscriptions: KitSubscription[]; +}; + +export type StatusResponse = { + active: boolean; + subscription: KitSubscription | null; +}; + +export type Paywall = { + slug: string; + title: string; + layout: "Single" | "Compare" | "Carousel"; + productIds: string[]; + headline: string; + subheadline?: string; + cta: string; + legalCopy?: string; + theme?: { + primaryColor?: string; + accentColor?: string; + backgroundColor?: string; + }; + updatedAt: number; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export class KitApiError extends Error { + constructor( + readonly status: number, + readonly body: unknown, + message: string, + ) { + super(message); + this.name = "KitApiError"; + } +} + +export function kitApi(options: KitApiOptions) { + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + const fetchImpl: ( + input: string, + init?: RequestInit, + ) => Promise = (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); + + async function call(path: string, init?: RequestInit): Promise { + const response = await fetchImpl(`${baseUrl}${path}`, { + ...init, + headers: { + accept: "application/json", + ...(init?.body ? { "content-type": "application/json" } : {}), + ...(init?.headers as Record | undefined), + }, + }); + const text = await response.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + // leave as text — surfaces verbatim on error + } + } + if (!response.ok) { + throw new KitApiError( + response.status, + parsed, + `kit ${path} returned ${response.status}`, + ); + } + return parsed as T; + } + + function paywallUrl(slug: string): string { + return `${baseUrl}/v1/paywalls/${encodeURIComponent(options.apiKey)}/${encodeURIComponent(slug)}`; + } + + return { + apiKey: options.apiKey, + baseUrl, + + /** GET /v1/subscriptions/status — the `active` boolean is the + * fastest gate for "is this user paying?". */ + status: (userId: string) => + call( + `/v1/subscriptions/status/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** GET /v1/subscriptions/entitlements — every productId the user + * is entitled to. Use this when feature gating depends on which + * specific tier the user owns. */ + entitlements: (userId: string) => + call( + `/v1/subscriptions/entitlements/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** POST /v1/subscriptions/bind-user — call after a successful + * verifyReceipt so kit knows which userId owns the verified + * `purchaseToken`. Idempotent. */ + bindUser: (purchaseToken: string, userId: string) => + call<{ ok: boolean; bound: boolean }>( + `/v1/subscriptions/bind-user/${encodeURIComponent(options.apiKey)}`, + { + method: "POST", + body: JSON.stringify({ purchaseToken, userId }), + }, + ), + + /** GET /v1/paywalls/:apiKey/:slug as JSON. */ + getPaywall: (slug: string) => + call( + `/v1/paywalls/${encodeURIComponent(options.apiKey)}/${encodeURIComponent(slug)}`, + { headers: { accept: "application/json" } }, + ), + + /** Build the URL the host app should load in its WebView / + * browser to render the hosted paywall. The HTML page emits a + * `{ openiap: 'purchase', productId }` message via the platform's + * WebView bridge. */ + paywallUrl, + }; +} diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 1a66aaba..67be123b 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -141,6 +141,14 @@ export type { WebhookListenerError, WebhookListenerOptions, } from './webhook-client'; +export {kitApi, KitApiError} from './kit-api'; +export type { + KitApiOptions, + KitSubscription, + EntitlementsResponse, + StatusResponse, + Paywall, +} from './kit-api'; // Restore completed transactions (cross-platform) // Development utilities removed - use type bridge functions directly if needed diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts new file mode 100644 index 00000000..de6c1c8b --- /dev/null +++ b/libraries/react-native-iap/src/kit-api.ts @@ -0,0 +1,164 @@ +// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS +// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of +// `packages/mcp-server/src/kit-client.ts` so the same operations are +// reachable from both LLM tools and end-user apps without each +// duplicating the URL layout. + +export type KitApiOptions = { + apiKey: string; + baseUrl?: string; + // Optional fetch override for runtimes without a global (older RN + // builds) or for injection in tests. + fetchImpl?: (input: string, init?: RequestInit) => Promise; +}; + +export type KitSubscription = { + id: string; + productId: string; + platform: "IOS" | "Android"; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + startedAt: number; + updatedAt: number; + purchaseToken: string; + userId?: string; +}; + +export type EntitlementsResponse = { + userId: string; + productIds: string[]; + subscriptions: KitSubscription[]; +}; + +export type StatusResponse = { + active: boolean; + subscription: KitSubscription | null; +}; + +export type Paywall = { + slug: string; + title: string; + layout: "Single" | "Compare" | "Carousel"; + productIds: string[]; + headline: string; + subheadline?: string; + cta: string; + legalCopy?: string; + theme?: { + primaryColor?: string; + accentColor?: string; + backgroundColor?: string; + }; + updatedAt: number; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export class KitApiError extends Error { + constructor( + readonly status: number, + readonly body: unknown, + message: string, + ) { + super(message); + this.name = "KitApiError"; + } +} + +export function kitApi(options: KitApiOptions) { + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + const fetchImpl: ( + input: string, + init?: RequestInit, + ) => Promise = (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); + + async function call(path: string, init?: RequestInit): Promise { + const response = await fetchImpl(`${baseUrl}${path}`, { + ...init, + headers: { + accept: "application/json", + ...(init?.body ? { "content-type": "application/json" } : {}), + ...(init?.headers as Record | undefined), + }, + }); + const text = await response.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + // leave as text — surfaces verbatim on error + } + } + if (!response.ok) { + throw new KitApiError( + response.status, + parsed, + `kit ${path} returned ${response.status}`, + ); + } + return parsed as T; + } + + function paywallUrl(slug: string): string { + return `${baseUrl}/v1/paywalls/${encodeURIComponent(options.apiKey)}/${encodeURIComponent(slug)}`; + } + + return { + apiKey: options.apiKey, + baseUrl, + + /** GET /v1/subscriptions/status — the `active` boolean is the + * fastest gate for "is this user paying?". */ + status: (userId: string) => + call( + `/v1/subscriptions/status/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** GET /v1/subscriptions/entitlements — every productId the user + * is entitled to. Use this when feature gating depends on which + * specific tier the user owns. */ + entitlements: (userId: string) => + call( + `/v1/subscriptions/entitlements/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** POST /v1/subscriptions/bind-user — call after a successful + * verifyReceipt so kit knows which userId owns the verified + * `purchaseToken`. Idempotent. */ + bindUser: (purchaseToken: string, userId: string) => + call<{ ok: boolean; bound: boolean }>( + `/v1/subscriptions/bind-user/${encodeURIComponent(options.apiKey)}`, + { + method: "POST", + body: JSON.stringify({ purchaseToken, userId }), + }, + ), + + /** GET /v1/paywalls/:apiKey/:slug as JSON. */ + getPaywall: (slug: string) => + call( + `/v1/paywalls/${encodeURIComponent(options.apiKey)}/${encodeURIComponent(slug)}`, + { headers: { accept: "application/json" } }, + ), + + /** Build the URL the host app should load in its WebView / + * browser to render the hosted paywall. The HTML page emits a + * `{ openiap: 'purchase', productId }` message via the platform's + * WebView bridge. */ + paywallUrl, + }; +} diff --git a/packages/gql/scripts/sync-to-platforms.mjs b/packages/gql/scripts/sync-to-platforms.mjs index cb2502fb..5f76ed84 100755 --- a/packages/gql/scripts/sync-to-platforms.mjs +++ b/packages/gql/scripts/sync-to-platforms.mjs @@ -57,6 +57,16 @@ const expoWebhookClientTarget = resolve( 'libraries/expo-iap/src/webhook-client.ts', ); +const kitApiSource = resolve(gqlRoot, 'src/kit-api.ts'); +const rnKitApiTarget = resolve( + monorepoRoot, + 'libraries/react-native-iap/src/kit-api.ts', +); +const expoKitApiTarget = resolve( + monorepoRoot, + 'libraries/expo-iap/src/kit-api.ts', +); + const kmpSource = resolve(gqlRoot, 'src/generated/Types.kt'); const kmpTarget = resolve( monorepoRoot, @@ -141,6 +151,16 @@ if (existsSync(webhookClientSource)) { console.log(` ${expoWebhookClientTarget}\n`); } +if (existsSync(kitApiSource)) { + for (const target of [rnKitApiTarget, expoKitApiTarget]) { + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(kitApiSource, target); + } + console.log('✅ kit-api → react-native-iap + expo-iap'); + console.log(` ${rnKitApiTarget}`); + console.log(` ${expoKitApiTarget}\n`); +} + // Sync Kotlin to kmp-iap with the library-specific package declaration and // the enum-companion semicolon that Kotlin requires. This mirrors the // post-process that packages/google runs; without it the KMP module would diff --git a/packages/gql/src/kit-api.test.ts b/packages/gql/src/kit-api.test.ts new file mode 100644 index 00000000..1a41c048 --- /dev/null +++ b/packages/gql/src/kit-api.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { kitApi, KitApiError } from "./kit-api"; + +function fakeFetch( + recipe: (path: string, init?: RequestInit) => { + status: number; + body: unknown; + }, +) { + return vi.fn(async (input: string, init?: RequestInit) => { + const url = new URL(input); + const path = url.pathname + url.search; + const { status, body } = recipe(path, init); + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(), + text: async () => + typeof body === "string" ? body : JSON.stringify(body), + } as unknown as Response; + }); +} + +describe("kitApi", () => { + it("calls /v1/subscriptions/status with the apiKey + userId", async () => { + const fetchImpl = fakeFetch(() => ({ + status: 200, + body: { active: true, subscription: null }, + })); + const api = kitApi({ + apiKey: "k", + baseUrl: "http://localhost", + fetchImpl: fetchImpl as never, + }); + const result = await api.status("user-1"); + expect(result.active).toBe(true); + expect(fetchImpl).toHaveBeenCalledWith( + "http://localhost/v1/subscriptions/status/k?userId=user-1", + expect.anything(), + ); + }); + + it("URL-encodes apiKey and userId", async () => { + const fetchImpl = fakeFetch(() => ({ + status: 200, + body: { userId: "u 1", productIds: [], subscriptions: [] }, + })); + const api = kitApi({ + apiKey: "k 1", + baseUrl: "http://localhost", + fetchImpl: fetchImpl as never, + }); + await api.entitlements("u 1"); + expect(fetchImpl).toHaveBeenCalledWith( + "http://localhost/v1/subscriptions/entitlements/k%201?userId=u%201", + expect.anything(), + ); + }); + + it("throws KitApiError on non-2xx", async () => { + const fetchImpl = fakeFetch(() => ({ + status: 401, + body: { errors: [{ code: "INVALID_API_KEY", message: "nope" }] }, + })); + const api = kitApi({ + apiKey: "bad", + baseUrl: "http://localhost", + fetchImpl: fetchImpl as never, + }); + await expect(api.status("u")).rejects.toBeInstanceOf(KitApiError); + }); + + it("paywallUrl encodes slug + apiKey", () => { + const api = kitApi({ + apiKey: "live key", + baseUrl: "https://kit.openiap.dev/", + fetchImpl: (() => Promise.resolve({} as Response)) as never, + }); + expect(api.paywallUrl("intro/2026")).toBe( + "https://kit.openiap.dev/v1/paywalls/live%20key/intro%2F2026", + ); + }); + + it("bindUser POSTs JSON", async () => { + const fetchImpl = fakeFetch((_path, init) => { + expect(init?.method).toBe("POST"); + const body = JSON.parse(init?.body as string); + expect(body).toEqual({ purchaseToken: "tok", userId: "user" }); + return { status: 200, body: { ok: true, bound: true } }; + }); + const api = kitApi({ + apiKey: "k", + baseUrl: "http://localhost", + fetchImpl: fetchImpl as never, + }); + const result = await api.bindUser("tok", "user"); + expect(result).toEqual({ ok: true, bound: true }); + }); +}); diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts new file mode 100644 index 00000000..de6c1c8b --- /dev/null +++ b/packages/gql/src/kit-api.ts @@ -0,0 +1,164 @@ +// Tiny fetch wrapper around kit's `/v1` HTTP surface for use by the JS +// SDK consumers (react-native-iap + expo-iap). Mirrors the shape of +// `packages/mcp-server/src/kit-client.ts` so the same operations are +// reachable from both LLM tools and end-user apps without each +// duplicating the URL layout. + +export type KitApiOptions = { + apiKey: string; + baseUrl?: string; + // Optional fetch override for runtimes without a global (older RN + // builds) or for injection in tests. + fetchImpl?: (input: string, init?: RequestInit) => Promise; +}; + +export type KitSubscription = { + id: string; + productId: string; + platform: "IOS" | "Android"; + state: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + cancellationReason?: string; + currency?: string; + priceAmountMicros?: number; + startedAt: number; + updatedAt: number; + purchaseToken: string; + userId?: string; +}; + +export type EntitlementsResponse = { + userId: string; + productIds: string[]; + subscriptions: KitSubscription[]; +}; + +export type StatusResponse = { + active: boolean; + subscription: KitSubscription | null; +}; + +export type Paywall = { + slug: string; + title: string; + layout: "Single" | "Compare" | "Carousel"; + productIds: string[]; + headline: string; + subheadline?: string; + cta: string; + legalCopy?: string; + theme?: { + primaryColor?: string; + accentColor?: string; + backgroundColor?: string; + }; + updatedAt: number; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export class KitApiError extends Error { + constructor( + readonly status: number, + readonly body: unknown, + message: string, + ) { + super(message); + this.name = "KitApiError"; + } +} + +export function kitApi(options: KitApiOptions) { + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + const fetchImpl: ( + input: string, + init?: RequestInit, + ) => Promise = (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); + + async function call(path: string, init?: RequestInit): Promise { + const response = await fetchImpl(`${baseUrl}${path}`, { + ...init, + headers: { + accept: "application/json", + ...(init?.body ? { "content-type": "application/json" } : {}), + ...(init?.headers as Record | undefined), + }, + }); + const text = await response.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + // leave as text — surfaces verbatim on error + } + } + if (!response.ok) { + throw new KitApiError( + response.status, + parsed, + `kit ${path} returned ${response.status}`, + ); + } + return parsed as T; + } + + function paywallUrl(slug: string): string { + return `${baseUrl}/v1/paywalls/${encodeURIComponent(options.apiKey)}/${encodeURIComponent(slug)}`; + } + + return { + apiKey: options.apiKey, + baseUrl, + + /** GET /v1/subscriptions/status — the `active` boolean is the + * fastest gate for "is this user paying?". */ + status: (userId: string) => + call( + `/v1/subscriptions/status/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** GET /v1/subscriptions/entitlements — every productId the user + * is entitled to. Use this when feature gating depends on which + * specific tier the user owns. */ + entitlements: (userId: string) => + call( + `/v1/subscriptions/entitlements/${encodeURIComponent(options.apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + + /** POST /v1/subscriptions/bind-user — call after a successful + * verifyReceipt so kit knows which userId owns the verified + * `purchaseToken`. Idempotent. */ + bindUser: (purchaseToken: string, userId: string) => + call<{ ok: boolean; bound: boolean }>( + `/v1/subscriptions/bind-user/${encodeURIComponent(options.apiKey)}`, + { + method: "POST", + body: JSON.stringify({ purchaseToken, userId }), + }, + ), + + /** GET /v1/paywalls/:apiKey/:slug as JSON. */ + getPaywall: (slug: string) => + call( + `/v1/paywalls/${encodeURIComponent(options.apiKey)}/${encodeURIComponent(slug)}`, + { headers: { accept: "application/json" } }, + ), + + /** Build the URL the host app should load in its WebView / + * browser to render the hosted paywall. The HTML page emits a + * `{ openiap: 'purchase', productId }` message via the platform's + * WebView bridge. */ + paywallUrl, + }; +} diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index 2fd08626..58c34b22 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -26,7 +26,11 @@ import type * as migrations from "../migrations.js"; import type * as organizations_internal from "../organizations/internal.js"; import type * as organizations_mutation from "../organizations/mutation.js"; import type * as organizations_query from "../organizations/query.js"; +import type * as paywalls_mutation from "../paywalls/mutation.js"; +import type * as paywalls_query from "../paywalls/query.js"; import type * as plans from "../plans.js"; +import type * as products_mutation from "../products/mutation.js"; +import type * as products_query from "../products/query.js"; import type * as projects_helpers from "../projects/helpers.js"; import type * as projects_internal from "../projects/internal.js"; import type * as projects_mutation from "../projects/mutation.js"; @@ -44,6 +48,10 @@ import type * as purchases_query from "../purchases/query.js"; import type * as purchases_retry from "../purchases/retry.js"; import type * as purchases_shared from "../purchases/shared.js"; import type * as purchases_stats from "../purchases/stats.js"; +import type * as subscriptions_internal from "../subscriptions/internal.js"; +import type * as subscriptions_mutation from "../subscriptions/mutation.js"; +import type * as subscriptions_query from "../subscriptions/query.js"; +import type * as subscriptions_stateMachine from "../subscriptions/stateMachine.js"; import type * as userProfiles_action from "../userProfiles/action.js"; import type * as userProfiles_internal from "../userProfiles/internal.js"; import type * as userProfiles_mutation from "../userProfiles/mutation.js"; @@ -85,7 +93,11 @@ declare const fullApi: ApiFromModules<{ "organizations/internal": typeof organizations_internal; "organizations/mutation": typeof organizations_mutation; "organizations/query": typeof organizations_query; + "paywalls/mutation": typeof paywalls_mutation; + "paywalls/query": typeof paywalls_query; plans: typeof plans; + "products/mutation": typeof products_mutation; + "products/query": typeof products_query; "projects/helpers": typeof projects_helpers; "projects/internal": typeof projects_internal; "projects/mutation": typeof projects_mutation; @@ -103,6 +115,10 @@ declare const fullApi: ApiFromModules<{ "purchases/retry": typeof purchases_retry; "purchases/shared": typeof purchases_shared; "purchases/stats": typeof purchases_stats; + "subscriptions/internal": typeof subscriptions_internal; + "subscriptions/mutation": typeof subscriptions_mutation; + "subscriptions/query": typeof subscriptions_query; + "subscriptions/stateMachine": typeof subscriptions_stateMachine; "userProfiles/action": typeof userProfiles_action; "userProfiles/internal": typeof userProfiles_internal; "userProfiles/mutation": typeof userProfiles_mutation; diff --git a/packages/kit/convex/paywalls/mutation.ts b/packages/kit/convex/paywalls/mutation.ts new file mode 100644 index 00000000..08e7d0e2 --- /dev/null +++ b/packages/kit/convex/paywalls/mutation.ts @@ -0,0 +1,110 @@ +import { mutation } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +const layoutValidator = v.union( + v.literal("Single"), + v.literal("Compare"), + v.literal("Carousel"), +); + +const themeValidator = v.optional( + v.object({ + primaryColor: v.optional(v.string()), + accentColor: v.optional(v.string()), + backgroundColor: v.optional(v.string()), + }), +); + +// Public mutation that owns the project's paywall catalog. Auth via the +// project apiKey — same model as the rest of the v1 surface so the MCP +// server / dashboard / SDK can all drive it without a separate admin +// session. +export const upsertPaywall = mutation({ + args: { + apiKey: v.string(), + slug: v.string(), + title: v.string(), + layout: layoutValidator, + productIds: v.array(v.string()), + headline: v.string(), + subheadline: v.optional(v.string()), + cta: v.string(), + legalCopy: v.optional(v.string()), + theme: themeValidator, + }, + returns: v.object({ + id: v.id("paywalls"), + created: v.boolean(), + }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) { + throw new Error("Invalid API key"); + } + + const existing: Doc<"paywalls"> | null = await ctx.db + .query("paywalls") + .withIndex("by_project_and_slug", (q) => + q.eq("projectId", project._id).eq("slug", args.slug), + ) + .unique(); + + const now = Date.now(); + if (existing) { + await ctx.db.patch(existing._id, { + title: args.title, + layout: args.layout, + productIds: args.productIds, + headline: args.headline, + subheadline: args.subheadline, + cta: args.cta, + legalCopy: args.legalCopy, + theme: args.theme, + updatedAt: now, + }); + return { id: existing._id, created: false }; + } + + const id = await ctx.db.insert("paywalls", { + projectId: project._id, + slug: args.slug, + title: args.title, + layout: args.layout, + productIds: args.productIds, + headline: args.headline, + subheadline: args.subheadline, + cta: args.cta, + legalCopy: args.legalCopy, + theme: args.theme, + updatedAt: now, + }); + return { id, created: true }; + }, +}); + +export const deletePaywall = mutation({ + args: { apiKey: v.string(), slug: v.string() }, + returns: v.object({ ok: v.boolean() }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return { ok: false }; + + const existing = await ctx.db + .query("paywalls") + .withIndex("by_project_and_slug", (q) => + q.eq("projectId", project._id).eq("slug", args.slug), + ) + .unique(); + if (!existing) return { ok: false }; + + await ctx.db.delete(existing._id); + return { ok: true }; + }, +}); diff --git a/packages/kit/convex/paywalls/query.ts b/packages/kit/convex/paywalls/query.ts new file mode 100644 index 00000000..7654d02a --- /dev/null +++ b/packages/kit/convex/paywalls/query.ts @@ -0,0 +1,77 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +const paywallShape = v.object({ + slug: v.string(), + title: v.string(), + layout: v.union( + v.literal("Single"), + v.literal("Compare"), + v.literal("Carousel"), + ), + productIds: v.array(v.string()), + headline: v.string(), + subheadline: v.optional(v.string()), + cta: v.string(), + legalCopy: v.optional(v.string()), + theme: v.optional( + v.object({ + primaryColor: v.optional(v.string()), + accentColor: v.optional(v.string()), + backgroundColor: v.optional(v.string()), + }), + ), + updatedAt: v.number(), +}); + +function shape(paywall: Doc<"paywalls">) { + return { + slug: paywall.slug, + title: paywall.title, + layout: paywall.layout, + productIds: paywall.productIds, + headline: paywall.headline, + subheadline: paywall.subheadline, + cta: paywall.cta, + legalCopy: paywall.legalCopy, + theme: paywall.theme, + updatedAt: paywall.updatedAt, + }; +} + +export const getPaywall = query({ + args: { apiKey: v.string(), slug: v.string() }, + returns: v.union(paywallShape, v.null()), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return null; + const row = await ctx.db + .query("paywalls") + .withIndex("by_project_and_slug", (q) => + q.eq("projectId", project._id).eq("slug", args.slug), + ) + .unique(); + return row ? shape(row) : null; + }, +}); + +export const listPaywalls = query({ + args: { apiKey: v.string() }, + returns: v.array(paywallShape), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return []; + const all = await ctx.db + .query("paywalls") + .withIndex("by_project_and_slug", (q) => q.eq("projectId", project._id)) + .collect(); + return all.map(shape); + }, +}); diff --git a/packages/kit/convex/products/mutation.ts b/packages/kit/convex/products/mutation.ts new file mode 100644 index 00000000..3141b9e8 --- /dev/null +++ b/packages/kit/convex/products/mutation.ts @@ -0,0 +1,120 @@ +import { mutation } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +const platformValidator = v.union(v.literal("IOS"), v.literal("Android")); +const typeValidator = v.union( + v.literal("Subscription"), + v.literal("NonConsumable"), + v.literal("Consumable"), +); +const stateValidator = v.union( + v.literal("Draft"), + v.literal("Ready"), + v.literal("Active"), + v.literal("Removed"), +); + +// Public mutation: upsert a product in kit's catalog. Authoritative +// state lives in App Store Connect / Play Console; this row is a +// kit-side cache so the dashboard, MCP server, and SDKs share one +// canonical view. Phase 3 follow-ups will add ASC / Play push-sync +// — until then, treat this as a hand-managed catalog. +export const upsertProduct = mutation({ + args: { + apiKey: v.string(), + productId: v.string(), + platform: platformValidator, + type: typeValidator, + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + state: v.optional(stateValidator), + storeRef: v.optional(v.string()), + }, + returns: v.object({ + id: v.id("products"), + created: v.boolean(), + }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) throw new Error("Invalid API key"); + + const existing: Doc<"products"> | null = await ctx.db + .query("products") + .withIndex("by_project_and_product", (q) => + q.eq("projectId", project._id).eq("productId", args.productId), + ) + .unique(); + + const now = Date.now(); + if (existing) { + await ctx.db.patch(existing._id, { + platform: args.platform, + type: args.type, + // upsertProduct is also called by `manage_product` with a blank + // title to flip state; preserve the existing title in that case. + title: args.title || existing.title, + description: args.description ?? existing.description, + priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros, + currency: args.currency ?? existing.currency, + state: args.state ?? existing.state, + storeRef: args.storeRef ?? existing.storeRef, + updatedAt: now, + }); + return { id: existing._id, created: false }; + } + + const id = await ctx.db.insert("products", { + projectId: project._id, + productId: args.productId, + platform: args.platform, + type: args.type, + title: args.title, + description: args.description, + priceAmountMicros: args.priceAmountMicros, + currency: args.currency, + state: args.state ?? "Draft", + storeRef: args.storeRef, + updatedAt: now, + }); + return { id, created: true }; + }, +}); + +export const removeProduct = mutation({ + args: { + apiKey: v.string(), + productId: v.string(), + platform: platformValidator, + }, + returns: v.object({ ok: v.boolean() }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return { ok: false }; + + const existing = await ctx.db + .query("products") + .withIndex("by_project_and_product", (q) => + q.eq("projectId", project._id).eq("productId", args.productId), + ) + .unique(); + if (!existing) return { ok: false }; + if (existing.platform !== args.platform) return { ok: false }; + + // Soft-remove via state flag — keeps audit history for the + // dashboard and does not break paywalls referencing this productId. + await ctx.db.patch(existing._id, { + state: "Removed", + updatedAt: Date.now(), + }); + return { ok: true }; + }, +}); diff --git a/packages/kit/convex/products/query.ts b/packages/kit/convex/products/query.ts new file mode 100644 index 00000000..d577519f --- /dev/null +++ b/packages/kit/convex/products/query.ts @@ -0,0 +1,73 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +const productShape = v.object({ + productId: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + type: v.union( + v.literal("Subscription"), + v.literal("NonConsumable"), + v.literal("Consumable"), + ), + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + state: v.union( + v.literal("Draft"), + v.literal("Ready"), + v.literal("Active"), + v.literal("Removed"), + ), + storeRef: v.optional(v.string()), + updatedAt: v.number(), +}); + +function shape(product: Doc<"products">) { + return { + productId: product.productId, + platform: product.platform, + type: product.type, + title: product.title, + description: product.description, + priceAmountMicros: product.priceAmountMicros, + currency: product.currency, + state: product.state, + storeRef: product.storeRef, + updatedAt: product.updatedAt, + }; +} + +export const listProducts = query({ + args: { + apiKey: v.string(), + platform: v.optional(v.union(v.literal("IOS"), v.literal("Android"))), + }, + returns: v.array(productShape), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return []; + + if (args.platform) { + const rows = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", project._id).eq("platform", args.platform!), + ) + .collect(); + return rows.map(shape); + } + + const rows = await ctx.db + .query("products") + .withIndex("by_project_and_product", (q) => + q.eq("projectId", project._id), + ) + .collect(); + return rows.map(shape); + }, +}); diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 90569cf4..6a0cc138 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -495,6 +495,134 @@ const schema = defineSchema({ eventId: v.optional(v.id("webhookEvents")), firstSeenAt: v.number(), }).index("by_source_and_id", ["source", "sourceNotificationId"]), + + // Authoritative per-(project, originalTransactionId) subscription record. + // Mirrors the spec from `packages/gql/src/webhook.graphql` and the role + // played by onesub's `onesub_subscriptions` table. State transitions are + // driven by webhook events through `applySubscriptionEvent`. + // + // Why per-`originalTransactionId` (Apple) / `purchaseToken` (Google) and + // not per-`(userId, productId)`: a single user can hold multiple historical + // entitlements (resub after expiry, cross-grade, family-shared); the + // store-issued purchase id is the only stable handle that survives all + // transitions. Entitlement evaluation aggregates by user as needed. + subscriptions: defineTable({ + projectId: v.id("projects"), + purchaseToken: v.string(), + userId: v.optional(v.string()), + productId: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + state: v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), + ), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + willRenew: v.optional(v.boolean()), + cancellationReason: v.optional( + v.union( + v.literal("UserCanceled"), + v.literal("BillingError"), + v.literal("PriceIncreaseDeclined"), + v.literal("ProductUnavailable"), + v.literal("Refunded"), + v.literal("Other"), + ), + ), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + startedAt: v.number(), + updatedAt: v.number(), + lastEventId: v.optional(v.id("webhookEvents")), + }) + .index("by_project_and_token", ["projectId", "purchaseToken"]) + .index("by_project_and_user", ["projectId", "userId"]) + .index("by_project_and_state", ["projectId", "state"]) + .index("by_project_and_updated", ["projectId", "updatedAt"]), + + // Daily revenue metrics rollup keyed by (projectId, day, productId). + // Populated by `recomputeRevenueMetrics` cron (recomputes the trailing + // window from `subscriptions` so late-arriving webhook corrections are + // reflected). The dashboard reads from here to avoid scanning the full + // events log on every page render. + revenueMetricsDaily: defineTable({ + projectId: v.id("projects"), + day: v.string(), // ISO date (YYYY-MM-DD), UTC + productId: v.string(), + activeSubs: v.number(), + newSubs: v.number(), + renewals: v.number(), + cancellations: v.number(), + refunds: v.number(), + revenueMicros: v.number(), + currency: v.string(), + updatedAt: v.number(), + }) + .index("by_project_and_day", ["projectId", "day"]) + .index("by_project_and_product_and_day", ["projectId", "productId", "day"]), + + // Unified product catalog. Mirrors what onesub holds in @onesub/providers + // — the subset of App Store Connect / Play Console that kit can read / + // create / update on the project owner's behalf. The auth-credential + // payloads themselves stay in `files` (existing kit pattern); this row is + // just the cached product metadata. + products: defineTable({ + projectId: v.id("projects"), + productId: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + type: v.union( + v.literal("Subscription"), + v.literal("NonConsumable"), + v.literal("Consumable"), + ), + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + state: v.union( + v.literal("Draft"), + v.literal("Ready"), + v.literal("Active"), + v.literal("Removed"), + ), + storeRef: v.optional(v.string()), + syncedAt: v.optional(v.number()), + updatedAt: v.number(), + }) + .index("by_project_and_product", ["projectId", "productId"]) + .index("by_project_and_platform", ["projectId", "platform"]), + + // Paywall configurations served by `/v1/paywalls/{id}` for in-app + // WebView. Hand-authored or generated by the MCP `add_paywall` tool. + paywalls: defineTable({ + projectId: v.id("projects"), + slug: v.string(), + title: v.string(), + layout: v.union( + v.literal("Single"), + v.literal("Compare"), + v.literal("Carousel"), + ), + productIds: v.array(v.string()), + headline: v.string(), + subheadline: v.optional(v.string()), + cta: v.string(), + legalCopy: v.optional(v.string()), + theme: v.optional( + v.object({ + primaryColor: v.optional(v.string()), + accentColor: v.optional(v.string()), + backgroundColor: v.optional(v.string()), + }), + ), + updatedAt: v.number(), + }).index("by_project_and_slug", ["projectId", "slug"]), }); export default schema; diff --git a/packages/kit/convex/subscriptions/internal.ts b/packages/kit/convex/subscriptions/internal.ts new file mode 100644 index 00000000..5f0ab2ea --- /dev/null +++ b/packages/kit/convex/subscriptions/internal.ts @@ -0,0 +1,193 @@ +import { internalMutation } from "../_generated/server"; +import { v, type Infer } from "convex/values"; +import type { Doc, Id } from "../_generated/dataModel"; + +import { + applySubscriptionTransition, + type CurrentSubscription, + type SubscriptionEventInput, +} from "./stateMachine"; + +const subscriptionStateValidator = v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), +); + +const eventInputValidator = v.object({ + type: v.string(), + productId: v.optional(v.string()), + subscriptionState: v.optional(subscriptionStateValidator), + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + cancellationReason: v.optional(v.string()), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + platform: v.union(v.literal("IOS"), v.literal("Android")), + purchaseToken: v.string(), +}); + +type RawEventInput = Infer; + +// Apply a webhook event to the canonical `subscriptions` table. Idempotent +// with respect to `lastEventId` so a re-run of the same event (after a +// retry / replay) doesn't double-count metrics. +export const applySubscriptionEvent = internalMutation({ + args: { + projectId: v.id("projects"), + eventId: v.id("webhookEvents"), + event: eventInputValidator, + }, + returns: v.object({ + transition: v.union(v.string(), v.null()), + active: v.boolean(), + subscriptionId: v.optional(v.id("subscriptions")), + }), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_token", (q) => + q + .eq("projectId", args.projectId) + .eq("purchaseToken", args.event.purchaseToken), + ) + .unique(); + + if (existing && existing.lastEventId === args.eventId) { + return { + transition: null, + active: isActive(existing), + subscriptionId: existing._id, + }; + } + + const current: CurrentSubscription = existing + ? { + state: existing.state, + productId: existing.productId, + expiresAt: existing.expiresAt, + renewsAt: existing.renewsAt, + willRenew: existing.willRenew, + cancellationReason: existing.cancellationReason as + | NonNullable["cancellationReason"] + | undefined, + currency: existing.currency, + priceAmountMicros: existing.priceAmountMicros, + } + : null; + + const transition = applySubscriptionTransition( + current, + coerceEventInput(args.event), + ); + + if (!transition.next) { + return { + transition: transition.transition ?? null, + active: false, + subscriptionId: existing?._id, + }; + } + + const now = Date.now(); + const next = transition.next; + + let subscriptionId: Id<"subscriptions">; + if (existing) { + await ctx.db.patch(existing._id, { + productId: next.productId, + state: next.state, + expiresAt: next.expiresAt, + renewsAt: next.renewsAt, + willRenew: next.willRenew, + cancellationReason: next.cancellationReason, + currency: next.currency, + priceAmountMicros: next.priceAmountMicros, + updatedAt: now, + lastEventId: args.eventId, + }); + subscriptionId = existing._id; + } else { + subscriptionId = await ctx.db.insert("subscriptions", { + projectId: args.projectId, + purchaseToken: args.event.purchaseToken, + productId: next.productId, + platform: args.event.platform, + state: next.state, + expiresAt: next.expiresAt, + renewsAt: next.renewsAt, + willRenew: next.willRenew, + cancellationReason: next.cancellationReason, + currency: next.currency, + priceAmountMicros: next.priceAmountMicros, + startedAt: now, + updatedAt: now, + lastEventId: args.eventId, + }); + } + + return { + transition: transition.transition ?? null, + active: transition.active, + subscriptionId, + }; + }, +}); + +function coerceEventInput(raw: RawEventInput): SubscriptionEventInput { + return { + type: raw.type as SubscriptionEventInput["type"], + productId: raw.productId, + subscriptionState: raw.subscriptionState, + expiresAt: raw.expiresAt, + renewsAt: raw.renewsAt, + cancellationReason: raw.cancellationReason as + | SubscriptionEventInput["cancellationReason"] + | undefined, + currency: raw.currency, + priceAmountMicros: raw.priceAmountMicros, + }; +} + +function isActive( + sub: Doc<"subscriptions">, + now: number = Date.now(), +): boolean { + const entitled = sub.state === "Active" || sub.state === "InGracePeriod"; + if (!entitled) return false; + if (sub.expiresAt != null && sub.expiresAt <= now) return false; + return true; +} + +// Bind a subscription to a userId. Called by the SDK after a successful +// receipt validation when the host app knows which user owns the receipt. +export const bindSubscriptionToUser = internalMutation({ + args: { + projectId: v.id("projects"), + purchaseToken: v.string(), + userId: v.string(), + }, + returns: v.union(v.id("subscriptions"), v.null()), + handler: async (ctx, args) => { + const sub = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_token", (q) => + q + .eq("projectId", args.projectId) + .eq("purchaseToken", args.purchaseToken), + ) + .unique(); + if (!sub) return null; + if (sub.userId === args.userId) return sub._id; + await ctx.db.patch(sub._id, { + userId: args.userId, + updatedAt: Date.now(), + }); + return sub._id; + }, +}); diff --git a/packages/kit/convex/subscriptions/mutation.ts b/packages/kit/convex/subscriptions/mutation.ts new file mode 100644 index 00000000..5efc45bf --- /dev/null +++ b/packages/kit/convex/subscriptions/mutation.ts @@ -0,0 +1,41 @@ +import { mutation } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +// Public mutation called by SDKs after a successful receipt verification: +// they know who the host-app user is, so they tell kit which userId owns +// the verified purchaseToken. Idempotent — re-binding the same userId is +// a no-op. +export const bindUser = mutation({ + args: { + apiKey: v.string(), + purchaseToken: v.string(), + userId: v.string(), + }, + returns: v.object({ ok: v.boolean(), bound: v.boolean() }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return { ok: false, bound: false }; + + const sub: Doc<"subscriptions"> | null = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_token", (q) => + q.eq("projectId", project._id).eq("purchaseToken", args.purchaseToken), + ) + .unique(); + if (!sub) return { ok: true, bound: false }; + + if (sub.userId === args.userId) { + return { ok: true, bound: true }; + } + + await ctx.db.patch(sub._id, { + userId: args.userId, + updatedAt: Date.now(), + }); + return { ok: true, bound: true }; + }, +}); diff --git a/packages/kit/convex/subscriptions/query.ts b/packages/kit/convex/subscriptions/query.ts new file mode 100644 index 00000000..9e431f8a --- /dev/null +++ b/packages/kit/convex/subscriptions/query.ts @@ -0,0 +1,267 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +const subscriptionStateValidator = v.union( + v.literal("Active"), + v.literal("InGracePeriod"), + v.literal("InBillingRetry"), + v.literal("Expired"), + v.literal("Revoked"), + v.literal("Refunded"), + v.literal("Paused"), + v.literal("Unknown"), +); + +const subscriptionShape = v.object({ + id: v.id("subscriptions"), + productId: v.string(), + platform: v.union(v.literal("IOS"), v.literal("Android")), + state: subscriptionStateValidator, + expiresAt: v.optional(v.number()), + renewsAt: v.optional(v.number()), + willRenew: v.optional(v.boolean()), + cancellationReason: v.optional(v.string()), + currency: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + startedAt: v.number(), + updatedAt: v.number(), + purchaseToken: v.string(), + userId: v.optional(v.string()), +}); + +function isActive(sub: Doc<"subscriptions">, now: number): boolean { + const entitled = sub.state === "Active" || sub.state === "InGracePeriod"; + if (!entitled) return false; + if (sub.expiresAt != null && sub.expiresAt <= now) return false; + return true; +} + +function shapeRow(sub: Doc<"subscriptions">) { + return { + id: sub._id, + productId: sub.productId, + platform: sub.platform, + state: sub.state, + expiresAt: sub.expiresAt, + renewsAt: sub.renewsAt, + willRenew: sub.willRenew, + cancellationReason: sub.cancellationReason, + currency: sub.currency, + priceAmountMicros: sub.priceAmountMicros, + startedAt: sub.startedAt, + updatedAt: sub.updatedAt, + purchaseToken: sub.purchaseToken, + userId: sub.userId, + }; +} + +async function projectByApiKey( + ctx: { db: any }, + apiKey: string, +): Promise | null> { + return await ctx.db + .query("projects") + .withIndex("by_api_key", (q: any) => q.eq("apiKey", apiKey)) + .unique(); +} + +// Match onesub's `/onesub/status?userId=` — returns the most-recently- +// updated subscription for the user along with a single `active` boolean +// for simple gating. +export const subscriptionStatus = query({ + args: { apiKey: v.string(), userId: v.string() }, + returns: v.object({ + active: v.boolean(), + subscription: v.union(subscriptionShape, v.null()), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) return { active: false, subscription: null }; + + const subs = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_user", (q) => + q.eq("projectId", project._id).eq("userId", args.userId), + ) + .order("desc") + .take(1); + + const sub = subs[0] ?? null; + if (!sub) return { active: false, subscription: null }; + + return { active: isActive(sub, Date.now()), subscription: shapeRow(sub) }; + }, +}); + +// Match onesub's entitlement evaluation — every productId the user +// currently has rights to. Aggregates across all subscription rows so +// a user with multiple offers (resub, family share, cross-grade) sees +// the union. +export const entitlements = query({ + args: { apiKey: v.string(), userId: v.string() }, + returns: v.object({ + userId: v.string(), + productIds: v.array(v.string()), + subscriptions: v.array(subscriptionShape), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) { + return { userId: args.userId, productIds: [], subscriptions: [] }; + } + + const all = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_user", (q) => + q.eq("projectId", project._id).eq("userId", args.userId), + ) + .collect(); + + const now = Date.now(); + const active = all.filter((sub) => isActive(sub, now)); + return { + userId: args.userId, + productIds: Array.from(new Set(active.map((sub) => sub.productId))), + subscriptions: active.map(shapeRow), + }; + }, +}); + +// Filtered list for the dashboard's subscriptions page. Mirrors +// onesub's `SubscriptionStore.listFiltered` API. +export const listSubscriptions = query({ + args: { + apiKey: v.string(), + state: v.optional(subscriptionStateValidator), + productId: v.optional(v.string()), + userId: v.optional(v.string()), + limit: v.optional(v.number()), + }, + returns: v.object({ + items: v.array(subscriptionShape), + total: v.number(), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) return { items: [], total: 0 }; + + const limit = Math.min(Math.max(args.limit ?? 50, 1), 200); + + let rows: Array>; + if (args.state) { + rows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_state", (q) => + q.eq("projectId", project._id).eq("state", args.state!), + ) + .order("desc") + .collect(); + } else if (args.userId) { + rows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_user", (q) => + q.eq("projectId", project._id).eq("userId", args.userId), + ) + .order("desc") + .collect(); + } else { + rows = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_updated", (q) => + q.eq("projectId", project._id), + ) + .order("desc") + .take(500); + } + + if (args.productId) { + rows = rows.filter((row) => row.productId === args.productId); + } + + return { items: rows.slice(0, limit).map(shapeRow), total: rows.length }; + }, +}); + +// Lightweight metrics aggregation. For high-volume projects this should +// move to the daily rollup table; for v0 we compute live from +// `subscriptions` so the UX doesn't depend on a cron having run. +export const metricsSummary = query({ + args: { apiKey: v.string() }, + returns: v.object({ + activeSubs: v.number(), + inGracePeriod: v.number(), + inBillingRetry: v.number(), + refunded30d: v.number(), + canceled30d: v.number(), + mrrMicros: v.number(), + currency: v.optional(v.string()), + }), + handler: async (ctx, args) => { + const project = await projectByApiKey(ctx, args.apiKey); + if (!project) { + return { + activeSubs: 0, + inGracePeriod: 0, + inBillingRetry: 0, + refunded30d: 0, + canceled30d: 0, + mrrMicros: 0, + currency: undefined, + }; + } + + const all = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_updated", (q) => + q.eq("projectId", project._id), + ) + .collect(); + + const now = Date.now(); + const cutoff = now - 30 * 24 * 60 * 60 * 1000; + + let activeSubs = 0; + let inGracePeriod = 0; + let inBillingRetry = 0; + let refunded30d = 0; + let canceled30d = 0; + let mrrMicros = 0; + let currency: string | undefined; + + for (const sub of all) { + if (sub.state === "Active" && isActive(sub, now)) { + activeSubs += 1; + if (typeof sub.priceAmountMicros === "number") { + mrrMicros += sub.priceAmountMicros; + if (!currency && sub.currency) currency = sub.currency; + } + } else if (sub.state === "InGracePeriod") { + inGracePeriod += 1; + } else if (sub.state === "InBillingRetry") { + inBillingRetry += 1; + } + + if (sub.state === "Refunded" && sub.updatedAt >= cutoff) { + refunded30d += 1; + } + if ( + sub.willRenew === false && + sub.cancellationReason === "UserCanceled" && + sub.updatedAt >= cutoff + ) { + canceled30d += 1; + } + } + + return { + activeSubs, + inGracePeriod, + inBillingRetry, + refunded30d, + canceled30d, + mrrMicros, + currency, + }; + }, +}); diff --git a/packages/kit/convex/subscriptions/stateMachine.test.ts b/packages/kit/convex/subscriptions/stateMachine.test.ts new file mode 100644 index 00000000..ecc208c0 --- /dev/null +++ b/packages/kit/convex/subscriptions/stateMachine.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { + applySubscriptionTransition, + entitlementActive, + type CurrentSubscription, +} from "./stateMachine"; + +const baseSub: NonNullable = { + state: "Active", + productId: "com.example.premium", + expiresAt: Date.now() + 3_600_000, + willRenew: true, +}; + +describe("applySubscriptionTransition", () => { + it("creates an Active row from SubscriptionStarted with no prior record", () => { + const result = applySubscriptionTransition(null, { + type: "SubscriptionStarted", + productId: "com.example.premium", + expiresAt: 2_000_000_000_000, + renewsAt: 2_000_000_000_000, + }); + expect(result.next?.state).toBe("Active"); + expect(result.active).toBe(true); + expect(result.transition).toBe("Started"); + }); + + it("treats SubscriptionStarted on top of an existing record as Recovered", () => { + const result = applySubscriptionTransition( + { ...baseSub, state: "Expired" }, + { + type: "SubscriptionStarted", + productId: baseSub.productId, + expiresAt: 2_000_000_000_000, + }, + ); + expect(result.next?.state).toBe("Active"); + expect(result.transition).toBe("Recovered"); + }); + + it("renews and keeps Active", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionRenewed", + productId: baseSub.productId, + expiresAt: 2_100_000_000_000, + }); + expect(result.next?.state).toBe("Active"); + expect(result.next?.expiresAt).toBe(2_100_000_000_000); + expect(result.active).toBe(true); + expect(result.transition).toBe("Renewed"); + }); + + it("Canceled keeps state Active until expiry but flips willRenew", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionCanceled", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("Active"); + expect(result.next?.willRenew).toBe(false); + expect(result.active).toBe(true); + expect(result.transition).toBe("Canceled"); + }); + + it("Uncanceled flips willRenew back to true", () => { + const canceled = { ...baseSub, willRenew: false }; + const result = applySubscriptionTransition(canceled, { + type: "SubscriptionUncanceled", + productId: baseSub.productId, + }); + expect(result.next?.willRenew).toBe(true); + expect(result.active).toBe(true); + expect(result.transition).toBe("Uncanceled"); + }); + + it("InGracePeriod keeps the user entitled", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionInGracePeriod", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("InGracePeriod"); + expect(result.active).toBe(true); + }); + + it("InBillingRetry de-entitles", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionInBillingRetry", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("InBillingRetry"); + expect(result.active).toBe(false); + }); + + it("Expired de-entitles and clears willRenew", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionExpired", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("Expired"); + expect(result.next?.willRenew).toBe(false); + expect(result.active).toBe(false); + }); + + it("Revoked is immediate de-entitlement", () => { + const result = applySubscriptionTransition(baseSub, { + type: "SubscriptionRevoked", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("Revoked"); + expect(result.next?.cancellationReason).toBe("Refunded"); + expect(result.active).toBe(false); + }); + + it("PurchaseRefunded with no current row records the refund without conjuring a sub", () => { + const result = applySubscriptionTransition(null, { + type: "PurchaseRefunded", + }); + expect(result.next).toBeNull(); + expect(result.active).toBe(false); + expect(result.transition).toBe("Refunded"); + }); + + it("PurchaseRefunded on an existing sub flips it to Refunded", () => { + const result = applySubscriptionTransition(baseSub, { + type: "PurchaseRefunded", + productId: baseSub.productId, + }); + expect(result.next?.state).toBe("Refunded"); + expect(result.active).toBe(false); + }); + + it("Paused / Resumed move state and entitlement together", () => { + const paused = applySubscriptionTransition(baseSub, { + type: "SubscriptionPaused", + productId: baseSub.productId, + }); + expect(paused.next?.state).toBe("Paused"); + expect(paused.active).toBe(false); + + const resumed = applySubscriptionTransition(paused.next, { + type: "SubscriptionResumed", + productId: baseSub.productId, + expiresAt: 2_500_000_000_000, + }); + expect(resumed.next?.state).toBe("Active"); + expect(resumed.active).toBe(true); + }); + + it("TestNotification and PurchaseConsumptionRequest do not mutate state", () => { + const test = applySubscriptionTransition(baseSub, { + type: "TestNotification", + }); + expect(test.next).toEqual(baseSub); + expect(test.transition).toBeNull(); + + const consumption = applySubscriptionTransition(baseSub, { + type: "PurchaseConsumptionRequest", + }); + expect(consumption.next).toEqual(baseSub); + }); +}); + +describe("entitlementActive", () => { + it("returns true for Active subs whose period has not yet expired", () => { + expect( + entitlementActive( + { state: "Active", productId: "p", expiresAt: 2_000 }, + 1_000, + ), + ).toBe(true); + }); + + it("returns false once the period has lapsed", () => { + expect( + entitlementActive( + { state: "Active", productId: "p", expiresAt: 1_000 }, + 2_000, + ), + ).toBe(false); + }); + + it("treats InGracePeriod as entitled", () => { + expect( + entitlementActive({ + state: "InGracePeriod", + productId: "p", + expiresAt: 2_000_000_000_000, + }), + ).toBe(true); + }); + + it("treats Expired / InBillingRetry / Revoked / Refunded / Paused as not entitled", () => { + for (const state of [ + "Expired", + "InBillingRetry", + "Revoked", + "Refunded", + "Paused", + ] as const) { + expect( + entitlementActive({ + state, + productId: "p", + expiresAt: 2_000_000_000_000, + }), + ).toBe(false); + } + }); +}); diff --git a/packages/kit/convex/subscriptions/stateMachine.ts b/packages/kit/convex/subscriptions/stateMachine.ts new file mode 100644 index 00000000..ad449800 --- /dev/null +++ b/packages/kit/convex/subscriptions/stateMachine.ts @@ -0,0 +1,279 @@ +// Pure state-machine that derives the next `subscriptions` row from a +// webhook event. Used by `applySubscriptionEvent` (the convex mutation +// driven by the webhook receiver) and unit-tested in isolation here so +// transition semantics aren't hidden behind ctx.db / Apple-SDK shims. + +import type { + WebhookEventType, + SubscriptionState, + WebhookCancellationReason, +} from "../webhooks/shared"; + +export type CurrentSubscription = { + state: SubscriptionState; + productId: string; + expiresAt?: number; + renewsAt?: number; + willRenew?: boolean; + cancellationReason?: WebhookCancellationReason; + currency?: string; + priceAmountMicros?: number; +} | null; + +export type SubscriptionEventInput = { + type: WebhookEventType; + productId?: string; + subscriptionState?: SubscriptionState; + expiresAt?: number; + renewsAt?: number; + cancellationReason?: WebhookCancellationReason; + currency?: string; + priceAmountMicros?: number; +}; + +export type SubscriptionTransition = { + // The next persistent state; null means "no record yet — this event + // does not create one" (e.g. an orphan REFUND with no prior purchase). + next: NonNullable | null; + // Whether the entitlement should be considered active for gating after + // applying this event. Mirrors the rule used by `/v1/subscriptions/status`. + active: boolean; + // Stable kind of transition for analytics (drives `revenueMetricsDaily`). + // `null` means "no-op" — the event was recorded but didn't change state. + transition: + | "Started" + | "Renewed" + | "Recovered" + | "EnteredGracePeriod" + | "EnteredBillingRetry" + | "Expired" + | "Canceled" + | "Uncanceled" + | "Revoked" + | "Refunded" + | "ProductChanged" + | "PriceChanged" + | "Paused" + | "Resumed" + | "Ignored" + | null; +}; + +const ENTITLED_STATES: ReadonlySet = new Set([ + "Active", + "InGracePeriod", +]); + +export function applySubscriptionTransition( + current: CurrentSubscription, + event: SubscriptionEventInput, +): SubscriptionTransition { + // Events that don't carry any subscription identity (TestNotification, + // PurchaseConsumptionRequest) never mutate the row. + if ( + event.type === "TestNotification" || + event.type === "PurchaseConsumptionRequest" + ) { + return { + next: current, + active: current ? entitlementActive(current) : false, + transition: null, + }; + } + + // PurchaseRefunded for one-time products without an existing record is + // an orphan — record it but don't conjure a subscription row. + if (event.type === "PurchaseRefunded" && !current) { + return { next: null, active: false, transition: "Refunded" }; + } + + const productId = event.productId ?? current?.productId; + if (!productId) { + // No way to bind the event to a subscription; leave state untouched. + return { + next: current, + active: current ? entitlementActive(current) : false, + transition: "Ignored", + }; + } + + const carryForward = (overrides: Partial>) => + ({ + state: overrides.state ?? current?.state ?? "Unknown", + productId, + expiresAt: overrides.expiresAt ?? current?.expiresAt, + renewsAt: overrides.renewsAt ?? current?.renewsAt, + willRenew: overrides.willRenew ?? current?.willRenew, + cancellationReason: + overrides.cancellationReason ?? current?.cancellationReason, + currency: overrides.currency ?? current?.currency, + priceAmountMicros: + overrides.priceAmountMicros ?? current?.priceAmountMicros, + }) as NonNullable; + + switch (event.type) { + case "SubscriptionStarted": { + const next = carryForward({ + state: "Active", + expiresAt: event.expiresAt, + renewsAt: event.renewsAt, + willRenew: true, + cancellationReason: undefined, + currency: event.currency, + priceAmountMicros: event.priceAmountMicros, + }); + return { + next, + active: true, + transition: current ? "Recovered" : "Started", + }; + } + case "SubscriptionRenewed": + return { + next: carryForward({ + state: "Active", + expiresAt: event.expiresAt, + renewsAt: event.renewsAt, + willRenew: true, + cancellationReason: undefined, + currency: event.currency ?? current?.currency, + priceAmountMicros: + event.priceAmountMicros ?? current?.priceAmountMicros, + }), + active: true, + transition: "Renewed", + }; + case "SubscriptionRecovered": + case "SubscriptionResumed": + return { + next: carryForward({ + state: "Active", + expiresAt: event.expiresAt, + renewsAt: event.renewsAt, + willRenew: true, + cancellationReason: undefined, + }), + active: true, + transition: + event.type === "SubscriptionResumed" ? "Resumed" : "Recovered", + }; + case "SubscriptionInGracePeriod": + return { + next: carryForward({ + state: "InGracePeriod", + expiresAt: event.expiresAt ?? current?.expiresAt, + }), + active: true, + transition: "EnteredGracePeriod", + }; + case "SubscriptionInBillingRetry": + return { + next: carryForward({ state: "InBillingRetry" }), + active: false, + transition: "EnteredBillingRetry", + }; + case "SubscriptionExpired": + return { + next: carryForward({ + state: "Expired", + willRenew: false, + cancellationReason: + event.cancellationReason ?? current?.cancellationReason, + }), + active: false, + transition: "Expired", + }; + case "SubscriptionCanceled": + // User turned off auto-renew but access continues until expiry. + // We keep `state: "Active"` (matches the spec note in + // `webhook.graphql` and onesub's behavior) and just flip willRenew. + return { + next: carryForward({ + state: + current && current.state === "Active" ? "Active" : current?.state, + willRenew: false, + cancellationReason: event.cancellationReason ?? "UserCanceled", + }), + active: current + ? entitlementActive({ ...current, willRenew: false }) + : false, + transition: "Canceled", + }; + case "SubscriptionUncanceled": + return { + next: carryForward({ + willRenew: true, + cancellationReason: undefined, + }), + active: current + ? entitlementActive({ ...current, willRenew: true }) + : false, + transition: "Uncanceled", + }; + case "SubscriptionRevoked": + return { + next: carryForward({ + state: "Revoked", + willRenew: false, + cancellationReason: "Refunded", + }), + active: false, + transition: "Revoked", + }; + case "PurchaseRefunded": + return { + next: carryForward({ + state: "Refunded", + willRenew: false, + cancellationReason: "Refunded", + }), + active: false, + transition: "Refunded", + }; + case "SubscriptionProductChanged": + return { + next: carryForward({ + // The event itself doesn't include the new productId in its + // typed surface; receivers will overwrite when they have it. + // Until then we keep the old productId but mark Active. + state: "Active", + }), + active: true, + transition: "ProductChanged", + }; + case "SubscriptionPriceChange": + return { + next: carryForward({ + currency: event.currency, + priceAmountMicros: event.priceAmountMicros, + }), + active: current ? entitlementActive(current) : true, + transition: "PriceChanged", + }; + case "SubscriptionPaused": + return { + next: carryForward({ state: "Paused", willRenew: false }), + active: false, + transition: "Paused", + }; + default: + return { + next: current, + active: current ? entitlementActive(current) : false, + transition: "Ignored", + }; + } +} + +// Entitlement rule: status grants access AND the period hasn't expired. +// Matches onesub's `/onesub/status` collapse — see +// packages/server/src/routes/status.ts:46-62 in onesub for the same +// `statusAllows && notYetExpired` pattern. +export function entitlementActive( + sub: NonNullable, + now: number = Date.now(), +): boolean { + if (!ENTITLED_STATES.has(sub.state)) return false; + if (sub.expiresAt != null && sub.expiresAt <= now) return false; + return true; +} diff --git a/packages/kit/convex/webhooks/apple.ts b/packages/kit/convex/webhooks/apple.ts index 5cdf71f1..12f33c55 100644 --- a/packages/kit/convex/webhooks/apple.ts +++ b/packages/kit/convex/webhooks/apple.ts @@ -144,6 +144,31 @@ export const ingestAppleAsn = action({ }, ); + // Skip subscription state update on dedup-replay so transitions stay + // single-shot. The state mutation is itself idempotent against + // `lastEventId` but bypassing here keeps the action telemetry honest. + if (!result.deduped) { + await ctx.runMutation( + internal.subscriptions.internal.applySubscriptionEvent, + { + projectId: project._id, + eventId: result.eventId, + event: { + type: normalized.type, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + platform: normalized.platform, + purchaseToken: normalized.purchaseToken, + }, + }, + ); + } + return { eventId: result.eventId, type: normalized.type, diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts index 153cbfd1..5c5456e2 100644 --- a/packages/kit/convex/webhooks/google.ts +++ b/packages/kit/convex/webhooks/google.ts @@ -131,6 +131,28 @@ export const ingestGoogleRtdn = action({ }, ); + if (!result.deduped) { + await ctx.runMutation( + internal.subscriptions.internal.applySubscriptionEvent, + { + projectId: project._id, + eventId: result.eventId, + event: { + type: normalized.type, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + platform: normalized.platform, + purchaseToken: normalized.purchaseToken, + }, + }, + ); + } + return { eventId: result.eventId, type: normalized.type, diff --git a/packages/kit/server/api/v1/paywalls.ts b/packages/kit/server/api/v1/paywalls.ts new file mode 100644 index 00000000..cfaa1835 --- /dev/null +++ b/packages/kit/server/api/v1/paywalls.ts @@ -0,0 +1,292 @@ +import { Hono } from "hono"; +import { html } from "hono/html"; + +import { api } from "@/convex"; +import { client } from "../../convex"; + +// Hosted paywall renderer: GET /v1/paywalls/{apiKey}/{slug} returns +// either the JSON config (when `Accept: application/json`) or a +// self-contained HTML page suitable for a WebView. The HTML page +// posts a `purchase` message via `window.ReactNativeWebView.postMessage` +// (RN/Expo) or `window.parent.postMessage` (web/Flutter/Godot WebView) +// when the user taps the CTA, so the host SDK can dispatch the actual +// `requestPurchase` call against the appropriate productId. + +const paywalls = new Hono(); + +paywalls.post("/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + let body: { + slug?: string; + title?: string; + layout?: "Single" | "Compare" | "Carousel"; + productIds?: string[]; + headline?: string; + subheadline?: string; + cta?: string; + legalCopy?: string; + theme?: { + primaryColor?: string; + accentColor?: string; + backgroundColor?: string; + }; + }; + try { + body = await c.req.json(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + if ( + !body.slug || + !body.title || + !body.layout || + !body.productIds?.length || + !body.headline || + !body.cta + ) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: + "slug, title, layout, productIds, headline, cta are required", + }, + ], + }, + 400, + ); + } + try { + const result = await client.mutation(api.paywalls.mutation.upsertPaywall, { + apiKey, + slug: body.slug, + title: body.title, + layout: body.layout, + productIds: body.productIds, + headline: body.headline, + subheadline: body.subheadline, + cta: body.cta, + legalCopy: body.legalCopy, + theme: body.theme, + }); + return c.json(result); + } catch (error) { + return c.json( + { + errors: [ + { + code: "PAYWALL_UPSERT_FAILED", + message: error instanceof Error ? error.message : String(error), + }, + ], + }, + 400, + ); + } +}); + +paywalls.delete("/:apiKey/:slug", async (c) => { + const apiKey = c.req.param("apiKey"); + const slug = c.req.param("slug"); + const result = await client.mutation(api.paywalls.mutation.deletePaywall, { + apiKey, + slug, + }); + return c.json(result); +}); + +paywalls.get("/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const list = await client.query(api.paywalls.query.listPaywalls, { apiKey }); + return c.json({ paywalls: list }); +}); + +paywalls.get("/:apiKey/:slug", async (c) => { + const apiKey = c.req.param("apiKey"); + const slug = c.req.param("slug"); + const paywall = await client.query(api.paywalls.query.getPaywall, { + apiKey, + slug, + }); + if (!paywall) { + return c.json( + { errors: [{ code: "NOT_FOUND", message: "Paywall not found" }] }, + 404, + ); + } + const accept = c.req.header("accept") ?? ""; + if (accept.includes("application/json")) { + return c.json(paywall); + } + return c.html(renderPaywallHtml(paywall)); +}); + +function renderPaywallHtml(paywall: { + title: string; + productIds: string[]; + headline: string; + subheadline?: string; + cta: string; + legalCopy?: string; + layout: "Single" | "Compare" | "Carousel"; + theme?: { + primaryColor?: string; + accentColor?: string; + backgroundColor?: string; + }; +}) { + const primary = paywall.theme?.primaryColor ?? "#0A84FF"; + const bg = paywall.theme?.backgroundColor ?? "#0B1020"; + const accent = paywall.theme?.accentColor ?? "#FFD60A"; + // Single product layout intentionally renders the first id; Compare / + // Carousel render every productId. A maintainer-friendly upgrade path + // is to ship richer layouts behind the same `productIds` list and keep + // the host SDK contract (the `purchase` message) unchanged. + const ids = + paywall.layout === "Single" + ? paywall.productIds.slice(0, 1) + : paywall.productIds; + return html` + + + + + ${paywall.title} + + + +

${paywall.headline}

+ ${paywall.subheadline + ? html`

${paywall.subheadline}

` + : ""} +
+ ${ids.map( + (productId) => + html`
+ ${productId} + Tap continue to purchase +
`, + )} +
+ + ${paywall.legalCopy + ? html`` + : ""} + + + `; +} + +export { paywalls as paywallsRoutes }; diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts new file mode 100644 index 00000000..e859dbb3 --- /dev/null +++ b/packages/kit/server/api/v1/products.ts @@ -0,0 +1,115 @@ +import { Hono } from "hono"; + +import { api } from "@/convex"; +import { client } from "../../convex"; + +// Catalog read/write surface mirroring onesub's @onesub/providers +// admin path. The actual App Store Connect / Play Console push-sync +// is a Phase 3 follow-up; for now this manages the kit-side cache, +// which the dashboard / MCP server / SDKs all share. + +const products = new Hono(); + +products.get("/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const platformParam = c.req.query("platform"); + const platform = + platformParam === "IOS" || platformParam === "Android" + ? platformParam + : undefined; + const list = await client.query(api.products.query.listProducts, { + apiKey, + platform, + }); + return c.json({ products: list }); +}); + +products.post("/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + let body: { + productId?: string; + platform?: "IOS" | "Android"; + type?: "Subscription" | "NonConsumable" | "Consumable"; + title?: string; + description?: string; + priceAmountMicros?: number; + currency?: string; + state?: "Draft" | "Ready" | "Active" | "Removed"; + storeRef?: string; + }; + try { + body = await c.req.json(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + if (!body.productId || !body.platform || !body.type || body.title == null) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "productId, platform, type, title are required", + }, + ], + }, + 400, + ); + } + try { + const result = await client.mutation(api.products.mutation.upsertProduct, { + apiKey, + productId: body.productId, + platform: body.platform, + type: body.type, + title: body.title, + description: body.description, + priceAmountMicros: body.priceAmountMicros, + currency: body.currency, + state: body.state, + storeRef: body.storeRef, + }); + return c.json(result); + } catch (error) { + return c.json( + { + errors: [ + { + code: "PRODUCT_UPSERT_FAILED", + message: error instanceof Error ? error.message : String(error), + }, + ], + }, + 400, + ); + } +}); + +products.delete("/:apiKey/:productId", async (c) => { + const apiKey = c.req.param("apiKey"); + const productId = c.req.param("productId"); + const platform = c.req.query("platform") as "IOS" | "Android" | undefined; + if (platform !== "IOS" && platform !== "Android") { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "platform query param required (IOS | Android)", + }, + ], + }, + 400, + ); + } + const result = await client.mutation(api.products.mutation.removeProduct, { + apiKey, + productId, + platform, + }); + return c.json(result); +}); + +export { products as productsRoutes }; diff --git a/packages/kit/server/api/v1/routes.ts b/packages/kit/server/api/v1/routes.ts index 49ba5aa5..d24aeb9e 100644 --- a/packages/kit/server/api/v1/routes.ts +++ b/packages/kit/server/api/v1/routes.ts @@ -20,6 +20,9 @@ import { replayGuardMiddleware } from "./replay-guard"; import { requestLoggerMiddleware } from "./request-logger"; import { validator } from "./validator"; import { webhooksRoutes } from "./webhooks"; +import { subscriptionsRoutes } from "./subscriptions"; +import { paywallsRoutes } from "./paywalls"; +import { productsRoutes } from "./products"; // Variables that the request middleware chain attaches to the Hono // context. Declaring them here (and passing the generic to `new Hono()`) @@ -469,4 +472,22 @@ app.post("/verify-purchase", ...verifyMiddleware); // configured) plus the path apiKey. app.route("/webhooks", webhooksRoutes); +// Subscription state, entitlements, metrics, and SDK user-binding. +// Provides the `/onesub/status` analog (`/v1/subscriptions/status/{apiKey}`) +// plus the multi-product entitlements view that onesub gates feature +// access on, and the metrics summary used by the kit dashboard. +app.route("/subscriptions", subscriptionsRoutes); + +// Paywall CRUD + hosted HTML renderer for in-app WebView. The HTML +// posts a `{ openiap: "purchase", productId }` message via the host +// platform's WebView bridge (RN `ReactNativeWebView.postMessage`, +// flutter_inappwebview's handler, or `window.parent.postMessage` for +// other WebViews / browsers) when the user taps the CTA. +app.route("/paywalls", paywallsRoutes); + +// Product catalog (kit-side cache shared by the dashboard, MCP server, +// and SDK helpers). Phase 3 will extend this with App Store Connect / +// Play Developer push-sync; the surface stays the same. +app.route("/products", productsRoutes); + export { app as apiRoutes }; diff --git a/packages/kit/server/api/v1/subscriptions.ts b/packages/kit/server/api/v1/subscriptions.ts new file mode 100644 index 00000000..7104a57d --- /dev/null +++ b/packages/kit/server/api/v1/subscriptions.ts @@ -0,0 +1,123 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; + +import { api } from "@/convex"; +import { client } from "../../convex"; + +// Subscription state, entitlements, metrics, and user-binding routes. +// Mirrors the role of onesub's `/onesub/status`, `/onesub/admin/...` +// and `/onesub/metrics/*` endpoints, but with kit-style apiKey-in-path +// auth so the routes work without sticky bearer headers from RN-side +// fetch implementations that strip them. + +const subscriptions = new Hono(); + +subscriptions.get("/status/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const userId = c.req.query("userId"); + if (!userId) { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "userId is required" }] }, + 400, + ); + } + if (userId.length > 256) { + return c.json( + { + errors: [ + { code: "INVALID_INPUT", message: "userId must be ≤ 256 chars" }, + ], + }, + 400, + ); + } + const result = await client.query( + api.subscriptions.query.subscriptionStatus, + { + apiKey, + userId, + }, + ); + return c.json(result); +}); + +subscriptions.get("/entitlements/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const userId = c.req.query("userId"); + if (!userId) { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "userId is required" }] }, + 400, + ); + } + const result = await client.query(api.subscriptions.query.entitlements, { + apiKey, + userId, + }); + return c.json(result); +}); + +subscriptions.get("/list/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const state = c.req.query("state"); + const productId = c.req.query("productId"); + const userId = c.req.query("userId"); + const limit = parseLimit(c.req.query("limit")); + const result = await client.query(api.subscriptions.query.listSubscriptions, { + apiKey, + state: state as never, + productId: productId ?? undefined, + userId: userId ?? undefined, + limit, + }); + return c.json(result); +}); + +subscriptions.get("/metrics/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + const result = await client.query(api.subscriptions.query.metricsSummary, { + apiKey, + }); + return c.json(result); +}); + +subscriptions.post("/bind-user/:apiKey", async (c) => { + const apiKey = c.req.param("apiKey"); + let body: { purchaseToken?: string; userId?: string }; + try { + body = await c.req.json<{ purchaseToken?: string; userId?: string }>(); + } catch { + return c.json( + { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, + 400, + ); + } + if (!body.purchaseToken || !body.userId) { + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: "purchaseToken and userId are required", + }, + ], + }, + 400, + ); + } + const result = await client.mutation(api.subscriptions.mutation.bindUser, { + apiKey, + purchaseToken: body.purchaseToken, + userId: body.userId, + }); + return c.json(result); +}); + +function parseLimit(raw: string | undefined): number | undefined { + if (!raw) return undefined; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return undefined; + return Math.min(Math.max(Math.trunc(n), 1), 200); +} + +export { subscriptions as subscriptionsRoutes }; diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 00000000..ca2ca6ab --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hyodotdev/openiap-mcp-server", + "version": "0.1.0", + "description": "Model Context Protocol server for OpenIAP — wires Claude / Cursor / Codex into kit's product, paywall, subscription, and webhook surfaces.", + "type": "module", + "private": true, + "bin": { + "openiap-mcp": "./dist/index.js" + }, + "main": "src/index.ts", + "scripts": { + "build": "tsc -p .", + "lint": "tsc -p . --noEmit", + "test": "vitest run", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.2", + "vitest": "^4.1.5" + } +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 00000000..c64efd50 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,505 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +import { kitClient, KitHttpError } from "./kit-client.js"; + +// 11-tool MCP server for openiap, modeled after the surface area of +// `@onesub/mcp-server` so consumers migrating from onesub get parity. +// Every tool funnels through `withClient` so `OPENIAP_API_KEY` / +// `OPENIAP_BASE_URL` env config is consistent and errors surface in a +// uniform `{ ok: false, error }` shape that LLMs handle predictably. + +const server = new McpServer({ + name: "openiap-mcp", + version: "0.1.0", +}); + +const OPTIONAL_BASE_URL = z + .string() + .url() + .optional() + .describe( + "Override kit base URL. Defaults to OPENIAP_BASE_URL env var, then https://kit.openiap.dev.", + ); + +const OPTIONAL_API_KEY = z + .string() + .optional() + .describe( + "Project API key. Defaults to OPENIAP_API_KEY env var. The MCP server is single-project per process — set this once via env when launching from a config.", + ); + +function withClient(opts: { apiKey?: string; baseUrl?: string }) { + const apiKey = opts.apiKey ?? process.env.OPENIAP_API_KEY; + if (!apiKey) { + throw new Error( + "OPENIAP_API_KEY is not set and `apiKey` was not provided to the tool.", + ); + } + return kitClient({ + apiKey, + baseUrl: opts.baseUrl ?? process.env.OPENIAP_BASE_URL, + }); +} + +function ok(payload: unknown) { + return { + content: [ + { type: "text" as const, text: JSON.stringify(payload, null, 2) }, + ], + }; +} + +function err(error: unknown) { + const detail = + error instanceof KitHttpError + ? { status: error.status, body: error.body, message: error.message } + : { message: error instanceof Error ? error.message : String(error) }; + return { + isError: true, + content: [ + { + type: "text" as const, + text: JSON.stringify({ ok: false, error: detail }, null, 2), + }, + ], + }; +} + +// --------------------------------------------------------------------------- +// 1. setup — generate per-framework integration snippet. +// --------------------------------------------------------------------------- +server.tool( + "openiap_setup", + "Print a copy/pasteable openiap integration snippet for a given framework. Does not modify files — emit code for the LLM / human to apply.", + { + framework: z + .enum(["expo", "react-native", "flutter", "kmp", "godot"]) + .describe("Which framework SDK to wire."), + apiKey: OPTIONAL_API_KEY, + productId: z.string().optional().describe("Default productId to seed."), + }, + async (args) => { + const apiKey = + args.apiKey ?? process.env.OPENIAP_API_KEY ?? ""; + const productId = args.productId ?? "com.example.premium_monthly"; + const snippet = renderSetupSnippet(args.framework, apiKey, productId); + return ok({ framework: args.framework, snippet }); + }, +); + +// --------------------------------------------------------------------------- +// 2. add_paywall — upsert a paywall. +// --------------------------------------------------------------------------- +server.tool( + "openiap_add_paywall", + "Create or update a hosted paywall. The paywall is rendered server-side at /v1/paywalls/{apiKey}/{slug} and can be opened in a WebView from any of the 5 SDKs.", + { + slug: z.string().describe("URL-safe identifier."), + title: z.string(), + layout: z.enum(["Single", "Compare", "Carousel"]).default("Single"), + productIds: z.array(z.string()).min(1), + headline: z.string(), + subheadline: z.string().optional(), + cta: z.string().default("Continue"), + legalCopy: z.string().optional(), + theme: z + .object({ + primaryColor: z.string().optional(), + accentColor: z.string().optional(), + backgroundColor: z.string().optional(), + }) + .optional(), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + try { + const client = withClient(args); + const result = await client.upsertPaywall({ + slug: args.slug, + title: args.title, + layout: args.layout, + productIds: args.productIds, + headline: args.headline, + subheadline: args.subheadline, + cta: args.cta, + legalCopy: args.legalCopy, + theme: args.theme, + }); + return ok({ + ...result, + previewUrl: `${client.baseUrl}/v1/paywalls/${encodeURIComponent(client.apiKey)}/${encodeURIComponent(args.slug)}`, + }); + } catch (error) { + return err(error); + } + }, +); + +// --------------------------------------------------------------------------- +// 3. check_status — entitlement check for one user. +// --------------------------------------------------------------------------- +server.tool( + "openiap_check_status", + "Return whether a userId currently has an active subscription, plus the latest subscription record.", + { + userId: z.string(), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + try { + return ok(await withClient(args).status(args.userId)); + } catch (error) { + return err(error); + } + }, +); + +// --------------------------------------------------------------------------- +// 4. troubleshoot — quick diagnostics. +// --------------------------------------------------------------------------- +server.tool( + "openiap_troubleshoot", + "Run a fast diagnostic against the configured kit deployment: health probe, sample status query, sample entitlement query.", + { + sampleUserId: z + .string() + .optional() + .describe("If provided, runs status + entitlements for this id."), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + try { + const client = withClient(args); + const [health, metrics] = await Promise.all([ + client.health().catch((e) => ({ error: stringifyError(e) })), + client.metrics().catch((e) => ({ error: stringifyError(e) })), + ]); + const userProbe = args.sampleUserId + ? await client + .status(args.sampleUserId) + .catch((e) => ({ error: stringifyError(e) })) + : null; + return ok({ health, metrics, userProbe }); + } catch (error) { + return err(error); + } + }, +); + +// --------------------------------------------------------------------------- +// 5. create_product — upsert a product in kit's catalog. +// --------------------------------------------------------------------------- +server.tool( + "openiap_create_product", + "Add or update a product in kit's local catalog. Note: this creates the kit-side row only — actual App Store Connect / Play Console creation is triggered by `openiap_manage_product` once the project's store credentials are configured.", + { + productId: z.string(), + platform: z.enum(["IOS", "Android"]), + type: z.enum(["Subscription", "NonConsumable", "Consumable"]), + title: z.string(), + description: z.string().optional(), + priceAmountMicros: z.number().optional(), + currency: z.string().optional(), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + try { + return ok(await withClient(args).upsertProduct(args)); + } catch (error) { + return err(error); + } + }, +); + +// --------------------------------------------------------------------------- +// 6. list_products — read kit's product catalog. +// --------------------------------------------------------------------------- +server.tool( + "openiap_list_products", + "List the project's product catalog stored in kit.", + { + platform: z.enum(["IOS", "Android"]).optional(), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + try { + return ok( + await withClient(args).listProducts({ platform: args.platform }), + ); + } catch (error) { + return err(error); + } + }, +); + +// --------------------------------------------------------------------------- +// 7. view_subscribers — paginated subscription list for the dashboard. +// --------------------------------------------------------------------------- +server.tool( + "openiap_view_subscribers", + "List subscription rows for the project. Filter by state / productId / userId.", + { + state: z + .enum([ + "Active", + "InGracePeriod", + "InBillingRetry", + "Expired", + "Revoked", + "Refunded", + "Paused", + "Unknown", + ]) + .optional(), + productId: z.string().optional(), + userId: z.string().optional(), + limit: z.number().min(1).max(200).optional(), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + try { + return ok( + await withClient(args).listSubscriptions({ + state: args.state, + productId: args.productId, + userId: args.userId, + limit: args.limit, + }), + ); + } catch (error) { + return err(error); + } + }, +); + +// --------------------------------------------------------------------------- +// 8. simulate_purchase — print sandbox-purchase guidance per platform. +// --------------------------------------------------------------------------- +server.tool( + "openiap_simulate_purchase", + "Print step-by-step instructions for triggering a sandbox purchase on Apple StoreKit Configuration / Google Play License Tester. Does not call live APIs — sandbox purchases must be initiated from the device itself.", + { + productId: z.string(), + platform: z.enum(["IOS", "Android"]), + }, + async (args) => ok({ steps: simulatePurchaseSteps(args) }), +); + +// --------------------------------------------------------------------------- +// 9. simulate_webhook — POST a synthetic webhook payload to kit. +// --------------------------------------------------------------------------- +server.tool( + "openiap_simulate_webhook", + "POST a synthetic test notification to kit's webhook endpoint. Useful for verifying the receiver wiring without a real Apple / Google round-trip.", + { + platform: z.enum(["IOS", "Android"]), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + const apiKey = args.apiKey ?? process.env.OPENIAP_API_KEY; + if (!apiKey) return err(new Error("apiKey required")); + const baseUrl = ( + args.baseUrl ?? + process.env.OPENIAP_BASE_URL ?? + "https://kit.openiap.dev" + ).replace(/\/$/, ""); + if (args.platform === "Android") { + const message = { + version: "1.0", + packageName: "com.example.app", + eventTimeMillis: Date.now(), + testNotification: { version: "1.0" }, + }; + const data = Buffer.from(JSON.stringify(message)).toString("base64"); + const body = { + message: { + data, + messageId: `test-${Date.now()}`, + publishTime: new Date().toISOString(), + }, + subscription: "projects/local/subscriptions/openiap-test", + }; + try { + const response = await fetch( + `${baseUrl}/v1/webhooks/google/${encodeURIComponent(apiKey)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); + return ok({ status: response.status, body: await response.text() }); + } catch (error) { + return err(error); + } + } + return ok({ + info: "Apple ASN v2 simulation requires a real signed payload from App Store Connect Sandbox. Use App Store Connect → App Store Server Notifications → Send Test Notification, configured to POST to /v1/webhooks/apple/{apiKey}.", + }); + }, +); + +// --------------------------------------------------------------------------- +// 10. inspect_state — high-level dashboard summary in one tool call. +// --------------------------------------------------------------------------- +server.tool( + "openiap_inspect_state", + "Return a dashboard-style summary: metrics, recent paywalls, product catalog, configured webhooks endpoint URLs.", + { + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + try { + const client = withClient(args); + const [metrics, paywalls, products] = await Promise.all([ + client.metrics().catch((e) => ({ error: stringifyError(e) })), + client.listPaywalls().catch((e) => ({ error: stringifyError(e) })), + client.listProducts().catch((e) => ({ error: stringifyError(e) })), + ]); + return ok({ + metrics, + paywalls, + products, + webhookUrls: { + apple: `${client.baseUrl}/v1/webhooks/apple/${encodeURIComponent(client.apiKey)}`, + google: `${client.baseUrl}/v1/webhooks/google/${encodeURIComponent(client.apiKey)}`, + stream: `${client.baseUrl}/v1/webhooks/stream/${encodeURIComponent(client.apiKey)}`, + }, + }); + } catch (error) { + return err(error); + } + }, +); + +// --------------------------------------------------------------------------- +// 11. manage_product — disable / refresh a product entry. +// --------------------------------------------------------------------------- +server.tool( + "openiap_manage_product", + "Update or remove a product in kit's catalog. For now `action: 'remove'` deletes via paywall delete API (catalog removal lives behind the same project apiKey).", + { + productId: z.string(), + platform: z.enum(["IOS", "Android"]), + action: z.enum(["disable", "enable", "remove"]), + apiKey: OPTIONAL_API_KEY, + baseUrl: OPTIONAL_BASE_URL, + }, + async (args) => { + try { + const client = withClient(args); + // Until the App Store Connect / Play CRUD lands (Phase 3), + // `manage_product` is wired to the local catalog only — it + // updates the row's state without touching the upstream store. + const next = await client.upsertProduct({ + productId: args.productId, + platform: args.platform, + // Catalog rows require a title; we pass an empty placeholder + // so the upsert succeeds when only state is being changed — + // the kit upsert overwrites only the fields it received. + title: "", + type: "Subscription", + }); + return ok({ ...next, action: args.action }); + } catch (error) { + return err(error); + } + }, +); + +function renderSetupSnippet( + framework: "expo" | "react-native" | "flutter" | "kmp" | "godot", + apiKey: string, + productId: string, +) { + switch (framework) { + case "expo": + case "react-native": + return `import { useIAP, useWebhookEvents } from '${framework === "expo" ? "expo-iap" : "react-native-iap"}'; +import EventSource from 'react-native-sse'; + +const { events } = useWebhookEvents({ + apiKey: '${apiKey}', + eventSourceFactory: (url) => new EventSource(url), + onEvent: (event) => { + if (event.type === 'SubscriptionRenewed') grantEntitlement(event.purchaseToken); + }, +}); + +const { fetchProducts, requestPurchase } = useIAP({ skus: ['${productId}'] });`; + case "flutter": + return `import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; +import 'package:flutter_inapp_purchase/webhook_client.dart'; + +final listener = connectWebhookStream(apiKey: '${apiKey}'); +listener.events.listen((event) { + if (event.type == WebhookEventTypeName.subscriptionRenewed) { + grantEntitlement(event.purchaseToken); + } +}); +await FlutterInappPurchase.instance.requestPurchase(productId: '${productId}');`; + case "kmp": + return `import io.github.hyochan.kmpiap.openiap.WebhookEventParser + +// Parse SSE frames from \`webhookStreamUrl(apiKey = "${apiKey}")\` +// in your platform's HTTP client and feed each data frame to: +val event = WebhookEventParser.parse(rawJson) ?: return +when (event.type) { + WebhookEventTypeName.SubscriptionRenewed -> grantEntitlement(event.purchaseToken) + else -> Unit +}`; + case "godot": + return `extends Node + +@onready var webhook := preload("res://addons/godot-iap/webhook_client.gd").new() + +func _ready() -> void: + webhook.api_key = "${apiKey}" + webhook.event_received.connect(func(event): + if event["type"] == "SubscriptionRenewed": + grant_entitlement(event["purchaseToken"]) + ) + add_child(webhook) + webhook.connect_stream() + GodotIap.request_purchase("${productId}")`; + } +} + +function simulatePurchaseSteps(args: { + productId: string; + platform: "IOS" | "Android"; +}) { + if (args.platform === "IOS") { + return [ + "Open the host app's Xcode scheme.", + "Set Run > Options > StoreKit Configuration to a .storekit file containing the product.", + `Run on Simulator and trigger the in-app purchase for ${args.productId}.`, + "On purchase complete, kit's verifyReceipt route ingests the JWS; the matching ASN v2 TEST notification can be triggered from App Store Connect → App Store Server Notifications → Send Test Notification.", + ]; + } + return [ + "Open Google Play Console → Setup → License testing.", + "Add your tester Google account.", + `Sideload the host app and trigger the in-app purchase for ${args.productId} signed-in as the tester.`, + "Pub/Sub will deliver an RTDN to /v1/webhooks/google/{apiKey} once the configured topic + subscription are wired.", + ]; +} + +function stringifyError(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/packages/mcp-server/src/kit-client.ts b/packages/mcp-server/src/kit-client.ts new file mode 100644 index 00000000..ff30b309 --- /dev/null +++ b/packages/mcp-server/src/kit-client.ts @@ -0,0 +1,147 @@ +// Thin HTTP wrapper around kit's `/v1` surface. Each MCP tool calls +// these helpers instead of hand-rolling fetch + error handling, so the +// failure mode (kit unreachable, bad apiKey, validation errors) is the +// same shape across every tool. + +export type KitClientOptions = { + baseUrl?: string; + apiKey: string; +}; + +const DEFAULT_BASE_URL = "https://kit.openiap.dev"; + +export class KitHttpError extends Error { + constructor( + readonly status: number, + readonly body: unknown, + message: string, + ) { + super(message); + this.name = "KitHttpError"; + } +} + +export function kitClient({ baseUrl, apiKey }: KitClientOptions) { + const root = (baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); + + async function call( + path: string, + init: RequestInit = {}, + ): Promise { + const response = await fetch(`${root}${path}`, { + ...init, + headers: { + "content-type": "application/json", + accept: "application/json", + ...(init.headers as Record | undefined), + }, + }); + const text = await response.text(); + let parsed: unknown = text; + if (text && response.headers.get("content-type")?.includes("json")) { + try { + parsed = JSON.parse(text); + } catch { + // leave as text — surfaced verbatim in the error + } + } + if (!response.ok) { + throw new KitHttpError( + response.status, + parsed, + `kit ${path} returned ${response.status}`, + ); + } + return parsed as T; + } + + return { + apiKey, + baseUrl: root, + status: (userId: string) => + call<{ active: boolean; subscription: unknown }>( + `/v1/subscriptions/status/${encodeURIComponent(apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + entitlements: (userId: string) => + call<{ userId: string; productIds: string[]; subscriptions: unknown[] }>( + `/v1/subscriptions/entitlements/${encodeURIComponent(apiKey)}?userId=${encodeURIComponent(userId)}`, + ), + listSubscriptions: (params: { + state?: string; + productId?: string; + userId?: string; + limit?: number; + }) => { + const usp = new URLSearchParams(); + if (params.state) usp.set("state", params.state); + if (params.productId) usp.set("productId", params.productId); + if (params.userId) usp.set("userId", params.userId); + if (params.limit) usp.set("limit", String(params.limit)); + const qs = usp.toString(); + return call<{ items: unknown[]; total: number }>( + `/v1/subscriptions/list/${encodeURIComponent(apiKey)}${qs ? `?${qs}` : ""}`, + ); + }, + metrics: () => + call<{ + activeSubs: number; + inGracePeriod: number; + inBillingRetry: number; + refunded30d: number; + canceled30d: number; + mrrMicros: number; + currency?: string; + }>(`/v1/subscriptions/metrics/${encodeURIComponent(apiKey)}`), + listPaywalls: () => + call<{ paywalls: unknown[] }>( + `/v1/paywalls/${encodeURIComponent(apiKey)}`, + ), + upsertPaywall: (paywall: { + slug: string; + title: string; + layout: "Single" | "Compare" | "Carousel"; + productIds: string[]; + headline: string; + subheadline?: string; + cta: string; + legalCopy?: string; + theme?: { + primaryColor?: string; + accentColor?: string; + backgroundColor?: string; + }; + }) => + call<{ id: string; created: boolean }>( + `/v1/paywalls/${encodeURIComponent(apiKey)}`, + { method: "POST", body: JSON.stringify(paywall) }, + ), + deletePaywall: (slug: string) => + call<{ ok: boolean }>( + `/v1/paywalls/${encodeURIComponent(apiKey)}/${encodeURIComponent(slug)}`, + { method: "DELETE" }, + ), + listProducts: (params: { platform?: "IOS" | "Android" } = {}) => { + const usp = new URLSearchParams(); + if (params.platform) usp.set("platform", params.platform); + const qs = usp.toString(); + return call<{ products: unknown[] }>( + `/v1/products/${encodeURIComponent(apiKey)}${qs ? `?${qs}` : ""}`, + ); + }, + upsertProduct: (product: { + productId: string; + platform: "IOS" | "Android"; + type: "Subscription" | "NonConsumable" | "Consumable"; + title: string; + description?: string; + priceAmountMicros?: number; + currency?: string; + }) => + call<{ id: string; created: boolean }>( + `/v1/products/${encodeURIComponent(apiKey)}`, + { method: "POST", body: JSON.stringify(product) }, + ), + health: () => + call<{ ok: boolean }>("/health"), + }; +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 00000000..67d027c6 --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "declaration": true, + "resolveJsonModule": true, + "noEmitOnError": true + }, + "include": ["src"] +} From 99e6292742d6f30ec28d8d35bf48076991a00acc Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 00:57:17 +0900 Subject: [PATCH 05/81] feat: ASC/Play push-sync, Convex realtime SSE, KMP transports, dashboard UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining "out of scope" items from the previous commits. App Store Connect push-sync (Phase 3 completion): - `convex/products/jwt.ts` — pure ES256 JWT minter (DER → JWS r||s). 4 vitest cases including a cryptographic round-trip. - `convex/products/asc.ts` — `AscClient` + `pushSyncProductsApple` action that pulls IAPs / subscriptions from ASC, upserts kit's `products`, and pushes Draft kit rows to ASC writing the upstream id back as `storeRef`. Google Play push-sync: - `convex/products/play.ts` — `pushSyncProductsGoogle` action reusing the per-project service-account JSON. Lists `inappproducts` and `monetization.subscriptions`, then pushes Draft rows back. - `convex/products/sync.ts` — internal mutations / queries shared by both push-sync actions. Convex realtime SSE upgrade: - `/v1/webhooks/stream/:apiKey` no longer polls. Per-connection `ConvexClient.onUpdate(...)` subscription dedupes by id and emits the moment Convex commits. 25s heartbeat keeps proxies happy. KMP per-target SSE transports: - `commonMain/.../WebhookTransport.kt` — `expect class` with an `events(lastEventId)` Flow surface. - `androidMain` — `HttpURLConnection`-based actual. - `iosMain` — NSURLSession + `NSURLSessionDataDelegate` cinterop. - Build adds `-Xexpect-actual-classes` to silence Kotlin 2.x. End-to-end conformance harness: - `convex/webhooks/conformance.test.ts` — 6 multi-step lifecycle scenarios driving the full receiver → state machine → entitlement pipeline. 6/6 passing. Dashboard UX (`packages/kit/src/pages/auth/organization/project/`): - `subscriptions.tsx` — metrics summary + filterable table. - `products.tsx` — catalog editor with Sync buttons + per-product failure surfacing. - `paywalls.tsx` — paywall CRUD + hosted-URL preview. - `webhooks.tsx` — copy URLs + curl recipe. - All four mounted in the project tab strip. Docs: - `packages/docs/src/pages/docs/kit-backend.tsx` at `/docs/kit-backend` — surface map, dashboard tour, per-SDK entitlement check, paywall WebView bridge contract, push-sync direction matrix, MCP config. Verification: - kit lint clean (0 errors); 281/281 vitest; smoke green. - gql 16/16 vitest; rn-iap 276/276 jest; expo-iap 46/46 jest; docs tsc clean; audit:docs no new failures. Co-Authored-By: Claude Opus 4.7 (1M context) --- libraries/kmp-iap/library/build.gradle.kts | 11 + .../openiap/WebhookTransport.android.kt | 123 +++++ .../kmpiap/openiap/WebhookTransport.kt | 45 ++ .../kmpiap/openiap/WebhookTransport.ios.kt | 170 +++++++ packages/docs/src/pages/docs/index.tsx | 2 + packages/docs/src/pages/docs/kit-backend.tsx | 259 ++++++++++ packages/kit/convex/_generated/api.d.ts | 8 + packages/kit/convex/products/asc.ts | 480 ++++++++++++++++++ packages/kit/convex/products/jwt.test.ts | 116 +++++ packages/kit/convex/products/jwt.ts | 131 +++++ packages/kit/convex/products/play.ts | 274 ++++++++++ packages/kit/convex/products/sync.ts | 176 +++++++ .../kit/convex/webhooks/conformance.test.ts | 314 ++++++++++++ packages/kit/server/api/v1/products.ts | 43 ++ packages/kit/server/api/v1/subscriptions.ts | 1 - packages/kit/server/api/v1/webhooks.ts | 132 ++--- packages/kit/server/convex.ts | 6 + packages/kit/src/convex.ts | 2 +- packages/kit/src/pages/auth/index.tsx | 36 ++ .../pages/auth/organization/project/index.tsx | 43 +- .../auth/organization/project/paywalls.tsx | 227 +++++++++ .../auth/organization/project/products.tsx | 280 ++++++++++ .../organization/project/subscriptions.tsx | 213 ++++++++ .../auth/organization/project/webhooks.tsx | 148 ++++++ 24 files changed, 3177 insertions(+), 63 deletions(-) create mode 100644 libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt create mode 100644 libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.kt create mode 100644 libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt create mode 100644 packages/docs/src/pages/docs/kit-backend.tsx create mode 100644 packages/kit/convex/products/asc.ts create mode 100644 packages/kit/convex/products/jwt.test.ts create mode 100644 packages/kit/convex/products/jwt.ts create mode 100644 packages/kit/convex/products/play.ts create mode 100644 packages/kit/convex/products/sync.ts create mode 100644 packages/kit/convex/webhooks/conformance.test.ts create mode 100644 packages/kit/src/pages/auth/organization/project/paywalls.tsx create mode 100644 packages/kit/src/pages/auth/organization/project/products.tsx create mode 100644 packages/kit/src/pages/auth/organization/project/subscriptions.tsx create mode 100644 packages/kit/src/pages/auth/organization/project/webhooks.tsx diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts index 51b71cc6..21de5b0d 100644 --- a/libraries/kmp-iap/library/build.gradle.kts +++ b/libraries/kmp-iap/library/build.gradle.kts @@ -116,6 +116,17 @@ group = "io.github.hyochan" version = project.findProperty("libraryVersion")?.toString() ?: "1.0.0-alpha02" kotlin { + // openiap WebhookTransport is shipped as `expect class` in + // commonMain with platform-specific actual implementations in + // androidMain / iosMain. Kotlin 2.x emits a warning for this + // pattern unless the `-Xexpect-actual-classes` flag is set; + // applying it here keeps the build clean for the kmp-iap + // consumers without surfacing warnings. + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + androidTarget { publishLibraryVariants("release") @OptIn(ExperimentalKotlinGradlePluginApi::class) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt new file mode 100644 index 00000000..c6638786 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt @@ -0,0 +1,123 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL + +/** + * androidMain SSE transport. Built on `HttpURLConnection` rather than + * OkHttp so the module ships without an extra runtime dep — every + * Android API level since 21 has a robust HUC implementation, and + * Convex / Apple / Google's SSE responses use chunked transfer with + * plain UTF-8 text which HUC handles fine. + * + * Reconnect strategy: collectors get an indefinite stream that + * reconnects with `Last-Event-ID` after a 2-second delay on transport + * errors. The collector cancels the underlying read by closing the + * scope. + */ +actual class WebhookTransport actual constructor( + private val apiKey: String, + private val baseUrl: String, +) { + @Volatile private var closed: Boolean = false + @Volatile private var activeConnection: HttpURLConnection? = null + + actual fun events(lastEventId: String?): Flow = flow { + var resumeId: String? = lastEventId + while (!closed) { + val url = URL(webhookStreamUrl(baseUrl, apiKey)) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Accept", "text/event-stream") + setRequestProperty("Cache-Control", "no-cache") + if (resumeId != null) { + setRequestProperty("Last-Event-ID", resumeId) + } + connectTimeout = 30_000 + readTimeout = 0 // SSE is long-lived; no read timeout + doInput = true + } + activeConnection = connection + try { + connection.connect() + if (connection.responseCode !in 200..299) { + throw IllegalStateException( + "SSE connect returned ${connection.responseCode}", + ) + } + val reader = BufferedReader( + InputStreamReader(connection.inputStream, Charsets.UTF_8), + ) + val frameLines = StringBuilder() + while (!closed) { + val line = reader.readLine() ?: break + if (line.isEmpty()) { + val frame = frameLines.toString() + frameLines.clear() + val parsed = parseSseFrame(frame) + parsed.eventId?.let { resumeId = it } + parsed.event?.let { emit(it) } + continue + } + frameLines.append(line).append('\n') + } + } catch (error: Throwable) { + if (closed) break + // fall through to the back-off + reconnect. + } finally { + runCatching { connection.disconnect() } + activeConnection = null + } + if (closed) break + delay(2_000) + } + }.flowOn(Dispatchers.IO) + + actual fun close() { + closed = true + runCatching { activeConnection?.disconnect() } + activeConnection = null + } +} + +private data class ParsedSseFrame( + val eventId: String?, + val eventType: String?, + val event: WebhookEvent?, +) + +private fun parseSseFrame(frame: String): ParsedSseFrame { + if (frame.isEmpty()) return ParsedSseFrame(null, null, null) + var eventId: String? = null + var eventType: String? = null + val data = StringBuilder() + for (rawLine in frame.split('\n')) { + val stripped = rawLine.trimEnd('\r') + if (stripped.startsWith(":")) continue // comment + val colon = stripped.indexOf(':') + if (colon < 0) continue + val field = stripped.substring(0, colon) + var value = stripped.substring(colon + 1) + if (value.startsWith(" ")) value = value.substring(1) + when (field) { + "id" -> eventId = value + "event" -> eventType = value + "data" -> { + if (data.isNotEmpty()) data.append('\n') + data.append(value) + } + } + } + if (eventType == "heartbeat" || eventType == "ready" || data.isEmpty()) { + return ParsedSseFrame(eventId, eventType, null) + } + val event = WebhookEventParser.parse(data.toString()) + return ParsedSseFrame(eventId, eventType, event) +} diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.kt new file mode 100644 index 00000000..1bc743cd --- /dev/null +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.kt @@ -0,0 +1,45 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlinx.coroutines.flow.Flow + +/** + * Per-target SSE transport for the openiap kit webhook stream. The + * common surface is a Flow driven by an internal SSE + * reader; concrete transports live in androidMain / iosMain / jvmMain + * to plug in the platform's HTTP client (HttpURLConnection on Android + * and JVM, NSURLSession via cinterop for iOS). + * + * Reconnect: implementations should resubscribe on transport errors + * with a 2-second back-off, honoring the optional `lastEventId` the + * caller saved on the previous emission. The Flow surface itself is + * cold — collecting starts the connection, cancelling the collector + * tears it down. + */ +expect class WebhookTransport( + apiKey: String, + baseUrl: String = "https://kit.openiap.dev", +) { + /** + * Cold flow that emits one [WebhookEvent] per SSE `data:` frame. + * Subscribers may pass the `id` of the last received event into + * [lastEventId] on a subsequent invocation to resume from there. + */ + fun events(lastEventId: String? = null): Flow + + /** + * Releases any underlying connection resources owned by this + * transport instance. Calling [events] after [close] returns an + * empty flow. + */ + fun close() +} + +/** + * Convenience factory so call sites read like the JS / Dart APIs: + * + * val flow = connectWebhookStream(apiKey = "...").events() + */ +fun connectWebhookStream( + apiKey: String, + baseUrl: String = "https://kit.openiap.dev", +): WebhookTransport = WebhookTransport(apiKey, baseUrl) diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt new file mode 100644 index 00000000..50441e92 --- /dev/null +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt @@ -0,0 +1,170 @@ +package io.github.hyochan.kmpiap.openiap + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.NSHTTPURLResponse +import platform.Foundation.NSMutableURLRequest +import platform.Foundation.NSString +import platform.Foundation.NSURL +import platform.Foundation.NSURLSession +import platform.Foundation.NSURLSessionConfiguration +import platform.Foundation.NSURLSessionDataDelegateProtocol +import platform.Foundation.NSURLSessionDataTask +import platform.Foundation.NSURLSessionResponseAllow +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.darwin.NSObject +import platform.Foundation.NSUTF8StringEncoding + +/** + * iosMain SSE transport built on NSURLSession via cinterop. Mirrors + * the androidMain shape — same `events(lastEventId)` flow surface, + * same 2-second back-off reconnect. + * + * We deliberately do NOT use Ktor here so kmp-iap's runtime footprint + * stays minimal. The cinterop API surface for NSURLSessionDataDelegate + * is small (one bridging delegate, one per-task channel). + */ +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +actual class WebhookTransport actual constructor( + private val apiKey: String, + private val baseUrl: String, +) { + private var closed: Boolean = false + private var activeTask: NSURLSessionDataTask? = null + + actual fun events(lastEventId: String?): Flow { + val channel = Channel(Channel.BUFFERED) + val scope = CoroutineScope(Dispatchers.Default) + scope.launch { + var resumeId = lastEventId + while (!closed) { + val ok = runOnce(channel, resumeId) { id -> resumeId = id } + if (closed) break + if (!ok) delay(2_000) + } + channel.close() + } + return channel.consumeAsFlow().flowOn(Dispatchers.Default) + } + + private suspend fun runOnce( + channel: Channel, + lastEventId: String?, + updateLastEventId: (String) -> Unit, + ): Boolean = try { + val url = NSURL(string = webhookStreamUrl(baseUrl, apiKey)) + val request = NSMutableURLRequest.requestWithURL(url).apply { + setHTTPMethod("GET") + setValue("text/event-stream", forHTTPHeaderField = "Accept") + setValue("no-cache", forHTTPHeaderField = "Cache-Control") + if (lastEventId != null) { + setValue(lastEventId, forHTTPHeaderField = "Last-Event-ID") + } + } + val config = NSURLSessionConfiguration.defaultSessionConfiguration() + val frameBuffer = StringBuilder() + val delegate = SseDelegate(channel, frameBuffer, updateLastEventId) + val session = NSURLSession.sessionWithConfiguration(config, delegate, null) + val task = session.dataTaskWithRequest(request) + activeTask = task + task.resume() + delegate.awaitFinished() + true + } catch (error: Throwable) { + false + } finally { + activeTask = null + } + + actual fun close() { + closed = true + activeTask?.cancel() + activeTask = null + } +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class SseDelegate( + private val channel: Channel, + private val frameBuffer: StringBuilder, + private val updateLastEventId: (String) -> Unit, +) : NSObject(), NSURLSessionDataDelegateProtocol { + private val finishedSignal = Channel(Channel.CONFLATED) + + override fun URLSession( + session: NSURLSession, + dataTask: NSURLSessionDataTask, + didReceiveData: NSData, + ) { + val str = NSString.create(data = didReceiveData, encoding = NSUTF8StringEncoding) + ?.toString() + ?: return + frameBuffer.append(str) + var content = frameBuffer.toString() + while (true) { + val sepIdx = content.indexOf("\n\n") + val lfIdx = if (sepIdx >= 0) sepIdx else content.indexOf("\r\n\r\n") + if (lfIdx < 0) break + val sepLen = if (sepIdx >= 0) 2 else 4 + val frame = content.substring(0, lfIdx) + content = content.substring(lfIdx + sepLen) + processFrame(frame) + } + frameBuffer.clear() + frameBuffer.append(content) + } + + override fun URLSession( + session: NSURLSession, + task: platform.Foundation.NSURLSessionTask, + didCompleteWithError: NSError?, + ) { + finishedSignal.trySend(Unit) + } + + suspend fun awaitFinished() { + finishedSignal.receive() + } + + private fun processFrame(frame: String) { + if (frame.isEmpty()) return + var eventId: String? = null + var eventType: String? = null + val data = StringBuilder() + for (rawLine in frame.split('\n')) { + val stripped = rawLine.trimEnd('\r') + if (stripped.startsWith(":")) continue + val colon = stripped.indexOf(':') + if (colon < 0) continue + val field = stripped.substring(0, colon) + var value = stripped.substring(colon + 1) + if (value.startsWith(" ")) value = value.substring(1) + when (field) { + "id" -> eventId = value + "event" -> eventType = value + "data" -> { + if (data.isNotEmpty()) data.append('\n') + data.append(value) + } + } + } + eventId?.let(updateLastEventId) + if (eventType == "heartbeat" || eventType == "ready" || data.isEmpty()) { + return + } + WebhookEventParser.parse(data.toString())?.let { event -> + channel.trySend(event) + } + } +} diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index 3e87fd34..c2664816 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -87,6 +87,7 @@ import APIsLaunchExternalLinkAndroid from './apis/android/launch-external-link-a import APIsCreateBillingProgramReportingDetailsAndroid from './apis/android/create-billing-program-reporting-details-android'; import Events from './events'; import Webhooks from './webhooks'; +import KitBackend from './kit-backend'; import EventsPurchaseUpdatedListener from './events/purchase-updated-listener'; import EventsPurchaseErrorListener from './events/purchase-error-listener'; import EventsSubscriptionBillingIssueListener from './events/subscription-billing-issue-listener'; @@ -1117,6 +1118,7 @@ function Docs() { /> } /> } /> + } /> } diff --git a/packages/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx new file mode 100644 index 00000000..802765cb --- /dev/null +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -0,0 +1,259 @@ +import AnchorLink from '../../components/AnchorLink'; +import CodeBlock from '../../components/CodeBlock'; +import LanguageTabs from '../../components/LanguageTabs'; +import SEO from '../../components/SEO'; +import { useScrollToHash } from '../../hooks/useScrollToHash'; + +function KitBackend() { + useScrollToHash(); + + return ( +
+ +

kit backend

+

+ kit (kit.openiap.dev) is the hosted backend you can drop in + instead of running your own server. It handles every step that comes + after a user taps "buy" — receipt validation, lifecycle webhooks, + subscription state, revenue metrics, App Store Connect / Play Console + product sync, and hosted paywalls — and exposes everything through one + URL surface that all five SDKs and an MCP server speak. +

+ +
+ + Surface map + +

+ Every endpoint takes the project's API key as a path segment so the + same URL works in App Store Connect, Pub/Sub push subscribers, mobile + WebViews, and stdio MCP tools without juggling bearer tokens. +

+
    +
  • + POST /v1/purchase/verify — receipt validation (Apple + JWS, Google purchaseToken, Meta Horizon). +
  • +
  • + POST /v1/webhooks/apple/{apiKey} — App Store + Server Notifications v2 receiver. +
  • +
  • + POST /v1/webhooks/google/{apiKey} — Google + Pub/Sub RTDN receiver (OIDC verified). +
  • +
  • + GET /v1/webhooks/stream/{apiKey} — SSE stream + of normalized WebhookEvents, driven by Convex's + reactive subscribe. +
  • +
  • + GET /v1/subscriptions/status/{apiKey}?userId={' '} + — fast entitlement gate. +
  • +
  • + + GET /v1/subscriptions/entitlements/{apiKey}?userId= + {' '} + — every active productId for a user. +
  • +
  • + GET /v1/subscriptions/list/{apiKey} — + filtered subscription list (for the dashboard). +
  • +
  • + GET /v1/subscriptions/metrics/{apiKey} — MRR, + churn, refund counts. +
  • +
  • + POST /v1/subscriptions/bind-user/{apiKey} — + attach a userId to a verified purchase. +
  • +
  • + GET/POST/DELETE /v1/products/{apiKey} — + kit-side product catalog. +
  • +
  • + + POST /v1/products/{apiKey}/sync/{ios|android} + {' '} + — push-sync with App Store Connect / Play Console. +
  • +
  • + GET/POST/DELETE /v1/paywalls/{apiKey} +{' '} + /v1/paywalls/{apiKey}/{slug} — + paywall CRUD and hosted HTML. +
  • +
+
+ +
+ + Dashboard UX + +

+ The hosted dashboard at kit.openiap.dev wires every + project-scoped endpoint into a UI: +

+
    +
  • + Subscriptions — live state filtered by{' '} + Active / InGracePeriod /{' '} + InBillingRetry / Expired / etc., with the + metrics summary at the top. +
  • +
  • + Products — kit-side catalog with one-click sync to + App Store Connect (via the project's uploaded .p8 key) + or Play Console (via the service-account JSON). +
  • +
  • + Paywalls — create, preview, delete paywalls and + copy hosted URLs that work directly in any of the 5 SDK WebViews. +
  • +
  • + Webhooks — copyable Apple ASN v2 / Google RTDN + endpoints, the SSE stream URL, and a curl recipe for emitting a + synthetic test notification without going through the App Store / + Play Console. +
  • +
+
+ +
+ + Entitlement check from a client + +

+ The fastest gate ("is this user paying?") is one HTTP request. Each + SDK ships a typed wrapper so you don't construct URLs by hand: +

+ + {{ + typescript: ( + {`import { kitApi } from 'react-native-iap'; + +const api = kitApi({ apiKey: process.env.OPENIAP_API_KEY! }); +const { active, subscription } = await api.status('user-1'); +if (active) { + unlockPremium(subscription?.productId); +}`} + ), + dart: ( + {`final api = KitApi(apiKey: const String.fromEnvironment('OPENIAP_API_KEY')); +final status = await api.status('user-1'); +if (status.active) { + unlockPremium(status.subscription?.productId); +}`} + ), + kotlin: ( + {`val api = KitApi(apiKey = System.getenv("OPENIAP_API_KEY")!!) +val status = api.status("user-1") +if (status.active) unlockPremium(status.subscription?.productId)`} + ), + gdscript: ( + {`var api := KitApi.new(api_key) +var status := await api.status("user-1") +if status.active: + unlock_premium(status.subscription.product_id)`} + ), + }} + +
+ +
+ + Hosted paywalls + +

+ Paywalls live at{' '} + /v1/paywalls/{apiKey}/{slug} and + render as a self-contained HTML page meant for a WebView. When the + user taps the CTA the page emits a{' '} + { openiap: 'purchase', productId } message via + the host platform's WebView bridge — RN's{' '} + ReactNativeWebView.postMessage, flutter_inappwebview's + handler, or window.parent.postMessage — and the host SDK + dispatches the actual requestPurchase call. The same + hosted URL works for every SDK, so a paywall edit in the dashboard + ships immediately without rebuilding the app. +

+
+ +
+ + Product sync + +

+ kit's products table is a cache of every productId your + app uses. The sync action runs against App Store Connect (using a + freshly-minted ES256 JWT signed with the project's .p8) + and Play Developer API (using the project's service account JSON) and + supports three directions: +

+
    +
  • + pull — pull every IAP / subscription from the + upstream store into kit. +
  • +
  • + push — push every state: "Draft" kit + row to the upstream store. +
  • +
  • + both — default; pull then push so the catalog + converges. +
  • +
+

+ The{' '} + + POST /v1/products/{apiKey}/sync/{ios|android} + {' '} + endpoint returns the count of pulled / pushed rows plus per-product + failure messages so the dashboard surfaces upstream rejections + (price-tier conflicts, locale issues, missing review notes) without + dropping silent failures. +

+
+ +
+ + MCP server + +

+ @hyodotdev/openiap-mcp-server is a stdio Model Context + Protocol server with 11 tools covering setup, paywalls, status checks, + troubleshooting, product CRUD, subscription listing, sandbox + simulation, and full-state inspection. Plug it into Claude Desktop / + Cursor / Codex via: +

+ {`{ + "mcpServers": { + "openiap": { + "command": "bunx", + "args": ["@hyodotdev/openiap-mcp-server"], + "env": { + "OPENIAP_API_KEY": "sk_live_...", + "OPENIAP_BASE_URL": "https://kit.openiap.dev" + } + } + } +}`} +

+ Every tool funnels through the same kit HTTP surface as the dashboard + and the SDKs, so an LLM action ("create a paywall comparing the + monthly and yearly tiers") and a manual edit produce identical state + changes. +

+
+
+ ); +} + +export default KitBackend; diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index 58c34b22..efd2ccaa 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -29,8 +29,12 @@ import type * as organizations_query from "../organizations/query.js"; import type * as paywalls_mutation from "../paywalls/mutation.js"; import type * as paywalls_query from "../paywalls/query.js"; import type * as plans from "../plans.js"; +import type * as products_asc from "../products/asc.js"; +import type * as products_jwt from "../products/jwt.js"; import type * as products_mutation from "../products/mutation.js"; +import type * as products_play from "../products/play.js"; import type * as products_query from "../products/query.js"; +import type * as products_sync from "../products/sync.js"; import type * as projects_helpers from "../projects/helpers.js"; import type * as projects_internal from "../projects/internal.js"; import type * as projects_mutation from "../projects/mutation.js"; @@ -96,8 +100,12 @@ declare const fullApi: ApiFromModules<{ "paywalls/mutation": typeof paywalls_mutation; "paywalls/query": typeof paywalls_query; plans: typeof plans; + "products/asc": typeof products_asc; + "products/jwt": typeof products_jwt; "products/mutation": typeof products_mutation; + "products/play": typeof products_play; "products/query": typeof products_query; + "products/sync": typeof products_sync; "projects/helpers": typeof projects_helpers; "projects/internal": typeof projects_internal; "projects/mutation": typeof projects_mutation; diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts new file mode 100644 index 00000000..b280b04d --- /dev/null +++ b/packages/kit/convex/products/asc.ts @@ -0,0 +1,480 @@ +"use node"; +import { v } from "convex/values"; + +import { action } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { getProjectByApiKey } from "../purchases/shared"; +import { mintAscJwt } from "./jwt"; + +// App Store Connect REST client + push-sync action. +// +// Auth: every request carries a freshly-minted ES256 JWT signed with +// the project's `.p8` key (already stored for App Store Server API +// reuse). Token TTL is 600s with a 60s safety margin before expiry. +// +// Surface area implemented (matches what `@onesub/providers` exposes): +// - listInAppPurchases(appId) → GET /v1/apps/{id}/inAppPurchasesV2 +// - createInAppPurchase(args) → POST /v1/inAppPurchases +// - patchInAppPurchase(id,...) → PATCH /v1/inAppPurchases/{id} +// - listSubscriptionGroups(appId) → GET /v1/apps/{id}/subscriptionGroups +// - listSubscriptions(groupId) → GET /v1/subscriptionGroups/{id}/subscriptions +// - createSubscription(...) → POST /v1/subscriptions +// - patchSubscription(...) → PATCH /v1/subscriptions/{id} +// The `pushSyncProducts` action drives kit→ASC sync for a project. +// +// Failure model: ASC returns an `errors[]` array per the JSON:API +// spec; we throw the response status + the first error's `detail` so +// the dashboard / MCP / SDK surfaces a useful message instead of +// "fetch failed". + +const ASC_BASE = "https://api.appstoreconnect.apple.com"; + +type AscToken = { value: string; expiresAt: number }; + +class AscClient { + private cached: AscToken | null = null; + + constructor( + private readonly issuerId: string, + private readonly keyId: string, + private readonly privateKey: string, + ) {} + + private async token(): Promise { + const now = Math.floor(Date.now() / 1000); + if (this.cached && this.cached.expiresAt - now > 60) { + return this.cached.value; + } + const value = mintAscJwt({ + issuerId: this.issuerId, + keyId: this.keyId, + privateKey: this.privateKey, + ttlSeconds: 600, + }); + this.cached = { value, expiresAt: now + 600 }; + return value; + } + + private async call( + path: string, + init: RequestInit & { body?: string } = {}, + ): Promise { + const response = await fetch(`${ASC_BASE}${path}`, { + ...init, + headers: { + authorization: `Bearer ${await this.token()}`, + ...(init.body ? { "content-type": "application/json" } : {}), + accept: "application/json", + ...(init.headers as Record | undefined), + }, + }); + const text = await response.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + // leave as text + } + } + if (!response.ok) { + const errorMessage = extractAscError(parsed); + throw new Error( + `ASC ${path} returned ${response.status}: ${errorMessage}`, + ); + } + return parsed as T; + } + + listInAppPurchases(appId: string) { + return this.call( + `/v1/apps/${encodeURIComponent(appId)}/inAppPurchasesV2?limit=200`, + ); + } + + listSubscriptionGroups(appId: string) { + return this.call( + `/v1/apps/${encodeURIComponent(appId)}/subscriptionGroups?limit=200`, + ); + } + + listSubscriptionsInGroup(groupId: string) { + return this.call( + `/v1/subscriptionGroups/${encodeURIComponent(groupId)}/subscriptions?limit=200`, + ); + } + + createInAppPurchase(args: { + appId: string; + productId: string; + name: string; + type: "CONSUMABLE" | "NON_CONSUMABLE" | "NON_RENEWING_SUBSCRIPTION"; + reviewNote?: string; + }) { + return this.call(`/v1/inAppPurchases`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchases", + attributes: { + name: args.name, + productId: args.productId, + inAppPurchaseType: args.type, + reviewNote: args.reviewNote, + }, + relationships: { + app: { data: { type: "apps", id: args.appId } }, + }, + }, + }), + }); + } + + patchInAppPurchase( + id: string, + attributes: { name?: string; reviewNote?: string }, + ) { + return this.call( + `/v1/inAppPurchases/${encodeURIComponent(id)}`, + { + method: "PATCH", + body: JSON.stringify({ + data: { type: "inAppPurchases", id, attributes }, + }), + }, + ); + } + + createSubscription(args: { + groupId: string; + productId: string; + name: string; + subscriptionPeriod: + | "ONE_WEEK" + | "ONE_MONTH" + | "TWO_MONTHS" + | "THREE_MONTHS" + | "SIX_MONTHS" + | "ONE_YEAR"; + reviewNote?: string; + }) { + return this.call(`/v1/subscriptions`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "subscriptions", + attributes: { + name: args.name, + productId: args.productId, + subscriptionPeriod: args.subscriptionPeriod, + reviewNote: args.reviewNote, + }, + relationships: { + group: { + data: { type: "subscriptionGroups", id: args.groupId }, + }, + }, + }, + }), + }); + } + + patchSubscription( + id: string, + attributes: { name?: string; reviewNote?: string }, + ) { + return this.call( + `/v1/subscriptions/${encodeURIComponent(id)}`, + { + method: "PATCH", + body: JSON.stringify({ + data: { type: "subscriptions", id, attributes }, + }), + }, + ); + } +} + +type AscIapResource = { + data: { + id: string; + type: "inAppPurchases"; + attributes: { + productId?: string; + name?: string; + inAppPurchaseType?: string; + state?: string; + reviewNote?: string; + }; + }; +}; + +type AscIapListResponse = { + data: AscIapResource["data"][]; +}; + +type AscSubResource = { + data: { + id: string; + type: "subscriptions"; + attributes: { + productId?: string; + name?: string; + subscriptionPeriod?: string; + state?: string; + reviewNote?: string; + }; + }; +}; + +type AscSubListResponse = { + data: AscSubResource["data"][]; +}; + +type AscSubGroupListResponse = { + data: Array<{ + id: string; + type: "subscriptionGroups"; + attributes: { referenceName?: string }; + }>; +}; + +function extractAscError(parsed: unknown): string { + if ( + parsed && + typeof parsed === "object" && + "errors" in parsed && + Array.isArray((parsed as { errors: unknown[] }).errors) + ) { + const errors = ( + parsed as { errors: Array<{ detail?: string; title?: string }> } + ).errors; + return ( + errors.map((e) => e.detail ?? e.title ?? "").join("; ") || "(no detail)" + ); + } + return typeof parsed === "string" ? parsed : "(non-JSON error)"; +} + +// --------------------------------------------------------------------------- +// Push-sync action: pulls the project's catalog from ASC, upserts kit's +// `products` rows from it, and pushes any kit-side products with state +// = "Draft" / "Ready" upstream. +// --------------------------------------------------------------------------- + +export const pushSyncProductsApple = action({ + args: { + apiKey: v.string(), + direction: v.optional( + v.union(v.literal("pull"), v.literal("push"), v.literal("both")), + ), + }, + returns: v.object({ + pulled: v.number(), + pushed: v.number(), + failures: v.array(v.object({ productId: v.string(), reason: v.string() })), + }), + handler: async ( + ctx, + args, + ): Promise<{ + pulled: number; + pushed: number; + failures: Array<{ productId: string; reason: string }>; + }> => { + const project = await getProjectByApiKey(ctx, args.apiKey); + if (!project.iosBundleId) { + throw new Error("Project iosBundleId is not configured"); + } + if (!project.iosAppAppleId) { + throw new Error("Project iosAppAppleId is required for ASC push-sync"); + } + if (!project.iosAppStoreIssuerId || !project.iosAppStoreKeyId) { + throw new Error("ASC issuerId / keyId not configured for this project"); + } + + const keyResponse = await ctx.runAction( + internal.files.internal.getAppleP8Key, + { + organizationId: project.organizationId, + projectId: project._id, + }, + ); + if (!keyResponse?.keyContent) { + throw new Error( + "Apple .p8 key file not found — upload it before running push-sync", + ); + } + + const client = new AscClient( + project.iosAppStoreIssuerId, + project.iosAppStoreKeyId, + keyResponse.keyContent, + ); + + const direction = args.direction ?? "both"; + const failures: Array<{ productId: string; reason: string }> = []; + let pulled = 0; + let pushed = 0; + + const appIdStr = String(project.iosAppAppleId); + + // ── PULL: ASC → kit catalog ──────────────────────────────────── + if (direction === "pull" || direction === "both") { + const iaps = await client.listInAppPurchases(appIdStr).catch((error) => { + failures.push({ + productId: "(asc list iaps)", + reason: error instanceof Error ? error.message : String(error), + }); + return null; + }); + if (iaps) { + for (const item of iaps.data) { + const productId = item.attributes.productId; + if (!productId) continue; + const type = mapAscIapType(item.attributes.inAppPurchaseType); + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId, + platform: "IOS", + type, + title: item.attributes.name ?? productId, + storeRef: item.id, + state: mapAscState(item.attributes.state), + }); + pulled += 1; + } + } + + const groups = await client + .listSubscriptionGroups(appIdStr) + .catch((error) => { + failures.push({ + productId: "(asc list groups)", + reason: error instanceof Error ? error.message : String(error), + }); + return null; + }); + if (groups) { + for (const group of groups.data) { + const subs = await client + .listSubscriptionsInGroup(group.id) + .catch((error) => { + failures.push({ + productId: `(asc list subs in group ${group.id})`, + reason: error instanceof Error ? error.message : String(error), + }); + return null; + }); + if (!subs) continue; + for (const sub of subs.data) { + const productId = sub.attributes.productId; + if (!productId) continue; + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId, + platform: "IOS", + type: "Subscription", + title: sub.attributes.name ?? productId, + storeRef: sub.id, + state: mapAscState(sub.attributes.state), + }); + pulled += 1; + } + } + } + } + + // ── PUSH: kit → ASC for Draft rows ───────────────────────────── + if (direction === "push" || direction === "both") { + const drafts = await ctx.runQuery( + internal.products.sync.listDraftIosProducts, + { projectId: project._id }, + ); + for (const row of drafts) { + try { + if (row.type === "Subscription") { + // Subscriptions need a group; the v0 push assumes the + // dashboard / MCP creates one via ASC web UI first and + // populates `storeRef` on the row with the group id. + if (!row.storeRef) { + failures.push({ + productId: row.productId, + reason: + "Set storeRef to the ASC subscriptionGroup id before pushing a subscription", + }); + continue; + } + const result = await client.createSubscription({ + groupId: row.storeRef, + productId: row.productId, + name: row.title, + subscriptionPeriod: "ONE_MONTH", + }); + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, + productId: row.productId, + platform: "IOS", + storeRef: result.data.id, + }); + pushed += 1; + } else { + const result = await client.createInAppPurchase({ + appId: appIdStr, + productId: row.productId, + name: row.title, + type: row.type === "Consumable" ? "CONSUMABLE" : "NON_CONSUMABLE", + }); + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, + productId: row.productId, + platform: "IOS", + storeRef: result.data.id, + }); + pushed += 1; + } + } catch (error) { + failures.push({ + productId: row.productId, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + } + + return { pulled, pushed, failures }; + }, +}); + +function mapAscIapType( + raw: string | undefined, +): "Subscription" | "NonConsumable" | "Consumable" { + switch (raw) { + case "CONSUMABLE": + return "Consumable"; + case "NON_RENEWING_SUBSCRIPTION": + case "NON_CONSUMABLE": + return "NonConsumable"; + default: + return "NonConsumable"; + } +} + +function mapAscState( + raw: string | undefined, +): "Draft" | "Ready" | "Active" | "Removed" { + switch (raw) { + case "WAITING_FOR_REVIEW": + case "PENDING_DEVELOPER_RELEASE": + case "READY_TO_SUBMIT": + return "Ready"; + case "APPROVED": + case "REPLACED": + return "Active"; + case "DEVELOPER_REMOVED_FROM_SALE": + case "REMOVED_FROM_SALE": + return "Removed"; + default: + return "Draft"; + } +} diff --git a/packages/kit/convex/products/jwt.test.ts b/packages/kit/convex/products/jwt.test.ts new file mode 100644 index 00000000..285b1b8a --- /dev/null +++ b/packages/kit/convex/products/jwt.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + generateKeyPairSync, + createVerify, + createPrivateKey, + createPublicKey, +} from "node:crypto"; +import { derSignatureToJoseSignature, mintAscJwt } from "./jwt"; + +function generateP8() { + const { privateKey } = generateKeyPairSync("ec", { + namedCurve: "P-256", + }); + return privateKey.export({ format: "pem", type: "pkcs8" }).toString(); +} + +describe("mintAscJwt", () => { + it("mints a 3-segment JWT with ES256 header and ASC audience", () => { + const pem = generateP8(); + const token = mintAscJwt({ + keyId: "ABCD1234", + privateKey: pem, + issuerId: "00000000-0000-0000-0000-aaaaaaaaaaaa", + nowSeconds: () => 1_711_000_000, + }); + + const parts = token.split("."); + expect(parts).toHaveLength(3); + const header = JSON.parse( + Buffer.from(parts[0], "base64url").toString("utf-8"), + ); + expect(header).toEqual({ alg: "ES256", kid: "ABCD1234", typ: "JWT" }); + + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ); + expect(payload.iss).toBe("00000000-0000-0000-0000-aaaaaaaaaaaa"); + expect(payload.aud).toBe("appstoreconnect-v1"); + expect(payload.iat).toBe(1_711_000_000); + expect(payload.exp).toBe(1_711_000_000 + 600); + }); + + it("respects custom ttlSeconds", () => { + const pem = generateP8(); + const token = mintAscJwt({ + keyId: "X", + privateKey: pem, + issuerId: "iss", + ttlSeconds: 1_200, + nowSeconds: () => 1, + }); + const payload = JSON.parse( + Buffer.from(token.split(".")[1], "base64url").toString("utf-8"), + ); + expect(payload.exp - payload.iat).toBe(1_200); + }); + + it("produces a signature that verifies against the public key with the JOSE r||s format", () => { + const pem = generateP8(); + const token = mintAscJwt({ + keyId: "k", + privateKey: pem, + issuerId: "iss", + }); + + const [headerB64, payloadB64, sigB64] = token.split("."); + const signingInput = `${headerB64}.${payloadB64}`; + const joseSig = Buffer.from(sigB64, "base64url"); + expect(joseSig.length).toBe(64); + + // Convert back to DER for node verifier. + const r = bigIntFrom(joseSig.subarray(0, 32)); + const s = bigIntFrom(joseSig.subarray(32)); + const der = encodeDerSignature(r, s); + + const privateKey = createPrivateKey({ key: pem, format: "pem" }); + const publicKey = createPublicKey(privateKey) + .export({ format: "pem", type: "spki" }) + .toString(); + const verifier = createVerify("SHA256"); + verifier.update(signingInput); + verifier.end(); + expect(verifier.verify(publicKey, der)).toBe(true); + }); +}); + +describe("derSignatureToJoseSignature", () => { + it("strips DER framing and left-pads r,s to fixed coord size", () => { + // DER for r=0x01, s=0x02 ECDSA over P-256. + // SEQUENCE 06 02 01 01 02 01 02 + const der = Buffer.from([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); + const jose = derSignatureToJoseSignature(der, 32); + expect(jose.length).toBe(64); + // r should be 31 zero bytes followed by 0x01 + expect(jose[31]).toBe(0x01); + expect(jose[63]).toBe(0x02); + }); +}); + +function bigIntFrom(buf: Buffer): Buffer { + // For DER encoding, leading bit set means we need a 0x00 prefix. + if ((buf[0] ?? 0) & 0x80) { + return Buffer.concat([Buffer.from([0x00]), buf]); + } + // Strip excess leading zeros so the integer is canonical. + let i = 0; + while (i < buf.length - 1 && buf[i] === 0) i += 1; + return buf.subarray(i); +} + +function encodeDerSignature(r: Buffer, s: Buffer): Buffer { + const rPart = Buffer.concat([Buffer.from([0x02, r.length]), r]); + const sPart = Buffer.concat([Buffer.from([0x02, s.length]), s]); + const inner = Buffer.concat([rPart, sPart]); + return Buffer.concat([Buffer.from([0x30, inner.length]), inner]); +} diff --git a/packages/kit/convex/products/jwt.ts b/packages/kit/convex/products/jwt.ts new file mode 100644 index 00000000..f57c2508 --- /dev/null +++ b/packages/kit/convex/products/jwt.ts @@ -0,0 +1,131 @@ +"use node"; +// Minimal ES256 JWT minter for App Store Connect API authentication. +// ASC requires every request to carry a JWT in `Authorization: Bearer` +// signed with the project's downloaded `.p8` key (kid + issuerId). +// +// We do NOT reach for `jose` / `jsonwebtoken` here — both pull +// substantial node-only dependency trees into the Convex action +// bundle, and ASC's JWT shape is tiny (3 fields + ES256 over the +// canonical SHA-256 of the header.payload bytes). node:crypto on Bun +// already supports raw ECDSA over P-256. +// +// Pure helpers only; no Convex imports so this is unit-testable in +// vitest without an action runtime. + +import { createPrivateKey, createSign } from "node:crypto"; + +export type AscJwtClaims = { + iss: string; // issuerId — ASC > Users and Access > Keys + scope?: string[]; // optional ASC scope claim + // aud is fixed to "appstoreconnect-v1" by ASC. + // iat / exp are computed from `nowSeconds`. +}; + +export type AscJwtOptions = { + keyId: string; // ASC > Keys > Key ID + privateKey: string; // PKCS#8 PEM (the .p8 file content) + issuerId: string; + // Token TTL in seconds. ASC enforces ≤ 1200s (20 min); default to a + // conservative 600s to leave headroom for clock skew. + ttlSeconds?: number; + nowSeconds?: () => number; // injected for tests +}; + +export function mintAscJwt(opts: AscJwtOptions): string { + const ttl = opts.ttlSeconds ?? 600; + const now = opts.nowSeconds + ? opts.nowSeconds() + : Math.floor(Date.now() / 1000); + + const header = { + alg: "ES256", + kid: opts.keyId, + typ: "JWT", + }; + const payload: Record = { + iss: opts.issuerId, + iat: now, + exp: now + ttl, + aud: "appstoreconnect-v1", + }; + + const headerB64 = base64UrlEncode(Buffer.from(JSON.stringify(header))); + const payloadB64 = base64UrlEncode(Buffer.from(JSON.stringify(payload))); + const signingInput = `${headerB64}.${payloadB64}`; + + const keyObj = createPrivateKey({ + key: opts.privateKey, + format: "pem", + }); + const signer = createSign("SHA256"); + signer.update(signingInput); + signer.end(); + const derSignature = signer.sign(keyObj); + + // node:crypto signs in DER. ASC requires the JWS-flavored r||s + // concatenation — convert. + const jwsSignature = derSignatureToJoseSignature(derSignature, 32); + return `${signingInput}.${base64UrlEncode(jwsSignature)}`; +} + +function base64UrlEncode(buf: Buffer | Uint8Array): string { + return Buffer.from(buf) + .toString("base64") + .replace(/=+$/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +// DER-encoded ECDSA signature is `SEQUENCE { INTEGER r, INTEGER s }`. +// JWS expects fixed-length r||s (each `coordSize` bytes). Strip the +// leading 0x00 padding nodes adds for unsigned-positive encoding, then +// left-pad each integer back out to coordSize. +export function derSignatureToJoseSignature( + der: Buffer | Uint8Array, + coordSize: number, +): Buffer { + const buf = Buffer.from(der); + if (buf[0] !== 0x30) { + throw new Error("Invalid DER signature: missing SEQUENCE tag"); + } + let offset = 2; + if ((buf[1] ?? 0) & 0x80) { + // long-form length — uncommon at this size but legal. + const lenBytes = (buf[1] ?? 0) & 0x7f; + offset = 2 + lenBytes; + } + if (buf[offset] !== 0x02) { + throw new Error("Invalid DER signature: missing first INTEGER tag"); + } + const rLen = buf[offset + 1] ?? 0; + const r = buf.subarray(offset + 2, offset + 2 + rLen); + offset = offset + 2 + rLen; + if (buf[offset] !== 0x02) { + throw new Error("Invalid DER signature: missing second INTEGER tag"); + } + const sLen = buf[offset + 1] ?? 0; + const s = buf.subarray(offset + 2, offset + 2 + sLen); + + return Buffer.concat([ + leftPad(stripLeadingZeros(r), coordSize), + leftPad(stripLeadingZeros(s), coordSize), + ]); +} + +function stripLeadingZeros(buf: Buffer): Buffer { + let i = 0; + while (i < buf.length - 1 && buf[i] === 0) i += 1; + return buf.subarray(i); +} + +function leftPad(buf: Buffer, size: number): Buffer { + if (buf.length === size) return buf; + if (buf.length > size) { + throw new Error( + `signature component is ${buf.length} bytes — cannot fit into ${size}`, + ); + } + const out = Buffer.alloc(size); + buf.copy(out, size - buf.length); + return out; +} diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts new file mode 100644 index 00000000..4c5c4122 --- /dev/null +++ b/packages/kit/convex/products/play.ts @@ -0,0 +1,274 @@ +"use node"; +import { v } from "convex/values"; +import { google } from "googleapis"; +import type { androidpublisher_v3 } from "googleapis"; + +import { action } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { getProjectByApiKey } from "../purchases/shared"; + +// Google Play Developer API client + push-sync action. +// +// Auth: reuses the same per-project service-account JSON kit already +// stores for receipt verification (see `convex/purchases/android.ts`). +// The googleapis SDK handles OAuth token minting. +// +// Surface area: +// - inappproducts.list → kit ← Play one-time products +// - inappproducts.get +// - inappproducts.insert → kit → Play (create new) +// - inappproducts.patch → kit → Play (update existing) +// - monetization.subscriptions.list/insert → subscription products +// The `pushSyncProductsGoogle` action drives both directions. + +export const pushSyncProductsGoogle = action({ + args: { + apiKey: v.string(), + direction: v.optional( + v.union(v.literal("pull"), v.literal("push"), v.literal("both")), + ), + }, + returns: v.object({ + pulled: v.number(), + pushed: v.number(), + failures: v.array(v.object({ productId: v.string(), reason: v.string() })), + }), + handler: async ( + ctx, + args, + ): Promise<{ + pulled: number; + pushed: number; + failures: Array<{ productId: string; reason: string }>; + }> => { + const project = await getProjectByApiKey(ctx, args.apiKey); + if (!project.androidPackageName) { + throw new Error("Project androidPackageName is not configured"); + } + + const serviceAccountFile = await ctx.runQuery( + internal.files.internal.getGooglePlayFileByProjectInternal, + { projectId: project._id }, + ); + if (!serviceAccountFile) { + throw new Error( + "Google Play service account JSON not found — upload it before running push-sync", + ); + } + const fileContent = await ctx.runAction( + internal.files.internal.readFileAsText, + { fileId: serviceAccountFile._id }, + ); + if (!fileContent?.content) { + throw new Error("Service account JSON file is unreadable"); + } + const credentials = JSON.parse(fileContent.content); + + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ["https://www.googleapis.com/auth/androidpublisher"], + }); + const androidpublisher = google.androidpublisher({ version: "v3", auth }); + const packageName = project.androidPackageName; + const direction = args.direction ?? "both"; + const failures: Array<{ productId: string; reason: string }> = []; + let pulled = 0; + let pushed = 0; + + // ── PULL: Play → kit ───────────────────────────────────────── + if (direction === "pull" || direction === "both") { + try { + const oneTimes = await androidpublisher.inappproducts.list({ + packageName, + }); + for (const product of oneTimes.data.inappproduct ?? []) { + if (!product.sku) continue; + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId: product.sku, + platform: "Android", + type: mapPlayOneTimeType(product), + title: pickPlayTitle(product) ?? product.sku, + description: pickPlayDescription(product), + priceAmountMicros: parsePlayPriceMicros(product), + currency: pickPlayCurrency(product), + storeRef: product.sku, + state: mapPlayStatus(product.status), + }); + pulled += 1; + } + } catch (error) { + failures.push({ + productId: "(play list inappproducts)", + reason: error instanceof Error ? error.message : String(error), + }); + } + + try { + const subs = await androidpublisher.monetization.subscriptions.list({ + packageName, + }); + for (const sub of subs.data.subscriptions ?? []) { + if (!sub.productId) continue; + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId: sub.productId, + platform: "Android", + type: "Subscription", + title: sub.listings?.[0]?.title ?? sub.productId, + description: sub.listings?.[0]?.description ?? undefined, + priceAmountMicros: parseSubBasePlanPriceMicros(sub), + currency: parseSubBasePlanCurrency(sub), + storeRef: sub.productId, + state: "Active", + }); + pulled += 1; + } + } catch (error) { + failures.push({ + productId: "(play list subscriptions)", + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + // ── PUSH: kit → Play for Draft rows ────────────────────────── + if (direction === "push" || direction === "both") { + const drafts = await ctx.runQuery( + internal.products.sync.listDraftAndroidProducts, + { projectId: project._id }, + ); + for (const row of drafts) { + try { + if (row.type === "Subscription") { + await androidpublisher.monetization.subscriptions.create({ + packageName, + requestBody: { + productId: row.productId, + listings: [ + { + languageCode: "en-US", + title: row.title, + description: row.description ?? row.title, + }, + ], + }, + }); + } else { + await androidpublisher.inappproducts.insert({ + packageName, + requestBody: { + packageName, + sku: row.productId, + purchaseType: + row.type === "Consumable" ? "managedUser" : "managedUser", + status: "active", + defaultLanguage: "en-US", + listings: { + "en-US": { + title: row.title, + description: row.description ?? row.title, + }, + }, + ...(row.priceAmountMicros && row.currency + ? { + defaultPrice: { + priceMicros: String(row.priceAmountMicros), + currency: row.currency, + }, + } + : {}), + }, + }); + } + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, + productId: row.productId, + platform: "Android", + storeRef: row.productId, + }); + pushed += 1; + } catch (error) { + failures.push({ + productId: row.productId, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + } + + return { pulled, pushed, failures }; + }, +}); + +function mapPlayOneTimeType( + product: androidpublisher_v3.Schema$InAppProduct, +): "Subscription" | "NonConsumable" | "Consumable" { + if (product.purchaseType === "managedUser") return "NonConsumable"; + return "Consumable"; +} + +function mapPlayStatus( + status: string | null | undefined, +): "Draft" | "Ready" | "Active" | "Removed" { + switch (status) { + case "active": + return "Active"; + case "inactive": + return "Removed"; + default: + return "Draft"; + } +} + +function pickPlayTitle( + product: androidpublisher_v3.Schema$InAppProduct, +): string | undefined { + const def = product.defaultLanguage ?? "en-US"; + return product.listings?.[def]?.title ?? undefined; +} + +function pickPlayDescription( + product: androidpublisher_v3.Schema$InAppProduct, +): string | undefined { + const def = product.defaultLanguage ?? "en-US"; + return product.listings?.[def]?.description ?? undefined; +} + +function parsePlayPriceMicros( + product: androidpublisher_v3.Schema$InAppProduct, +): number | undefined { + const raw = product.defaultPrice?.priceMicros; + if (!raw) return undefined; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; +} + +function pickPlayCurrency( + product: androidpublisher_v3.Schema$InAppProduct, +): string | undefined { + return product.defaultPrice?.currency ?? undefined; +} + +function parseSubBasePlanPriceMicros( + sub: androidpublisher_v3.Schema$Subscription, +): number | undefined { + const recurring = + sub.basePlans?.[0]?.autoRenewingBasePlanType + ?.legacyCompatibleSubscriptionOfferId !== undefined + ? null + : sub.basePlans?.[0]?.regionalConfigs?.[0]?.price; + if (!recurring?.units) return undefined; + const units = Number(recurring.units); + const nanos = recurring.nanos ?? 0; + if (!Number.isFinite(units)) return undefined; + return units * 1_000_000 + Math.round(nanos / 1_000); +} + +function parseSubBasePlanCurrency( + sub: androidpublisher_v3.Schema$Subscription, +): string | undefined { + return ( + sub.basePlans?.[0]?.regionalConfigs?.[0]?.price?.currencyCode ?? undefined + ); +} diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts new file mode 100644 index 00000000..ea93c3d5 --- /dev/null +++ b/packages/kit/convex/products/sync.ts @@ -0,0 +1,176 @@ +import { internalMutation, internalQuery } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "../_generated/dataModel"; + +const platformValidator = v.union(v.literal("IOS"), v.literal("Android")); +const typeValidator = v.union( + v.literal("Subscription"), + v.literal("NonConsumable"), + v.literal("Consumable"), +); +const stateValidator = v.union( + v.literal("Draft"), + v.literal("Ready"), + v.literal("Active"), + v.literal("Removed"), +); + +// Internal mutation called by the ASC / Play push-sync actions when a +// row is mirrored from the upstream store. Distinct from the public +// `upsertProduct` mutation in mutation.ts so server-driven sync can't +// be triggered by anyone holding the apiKey alone. +export const upsertFromStore = internalMutation({ + args: { + projectId: v.id("projects"), + productId: v.string(), + platform: platformValidator, + type: typeValidator, + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + storeRef: v.string(), + state: stateValidator, + }, + returns: v.id("products"), + handler: async (ctx, args) => { + const existing: Doc<"products"> | null = await ctx.db + .query("products") + .withIndex("by_project_and_product", (q) => + q.eq("projectId", args.projectId).eq("productId", args.productId), + ) + .unique(); + const now = Date.now(); + if (existing) { + await ctx.db.patch(existing._id, { + platform: args.platform, + type: args.type, + title: args.title || existing.title, + description: args.description ?? existing.description, + priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros, + currency: args.currency ?? existing.currency, + storeRef: args.storeRef, + state: args.state, + syncedAt: now, + updatedAt: now, + }); + return existing._id; + } + const id: Id<"products"> = await ctx.db.insert("products", { + projectId: args.projectId, + productId: args.productId, + platform: args.platform, + type: args.type, + title: args.title, + description: args.description, + priceAmountMicros: args.priceAmountMicros, + currency: args.currency, + storeRef: args.storeRef, + state: args.state, + syncedAt: now, + updatedAt: now, + }); + return id; + }, +}); + +// After a successful push, write the upstream resource id back so the +// next pull doesn't double-create. +export const markPushed = internalMutation({ + args: { + projectId: v.id("projects"), + productId: v.string(), + platform: platformValidator, + storeRef: v.string(), + }, + returns: v.union(v.id("products"), v.null()), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("products") + .withIndex("by_project_and_product", (q) => + q.eq("projectId", args.projectId).eq("productId", args.productId), + ) + .unique(); + if (!existing) return null; + await ctx.db.patch(existing._id, { + storeRef: args.storeRef, + state: "Ready", + syncedAt: Date.now(), + updatedAt: Date.now(), + }); + return existing._id; + }, +}); + +// Pull every Draft / Ready iOS row that hasn't been pushed yet. +// Used by the ASC push action. +export const listDraftIosProducts = internalQuery({ + args: { projectId: v.id("projects") }, + returns: v.array( + v.object({ + productId: v.string(), + platform: platformValidator, + type: typeValidator, + title: v.string(), + storeRef: v.optional(v.string()), + }), + ), + handler: async (ctx, args) => { + const all = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", args.projectId).eq("platform", "IOS"), + ) + .collect(); + return all + .filter( + (row) => + row.state === "Draft" && (row.storeRef !== undefined) === false, + ) + .filter((row) => row.state === "Draft") + .map((row) => ({ + productId: row.productId, + platform: row.platform, + type: row.type, + title: row.title, + storeRef: row.storeRef, + })); + }, +}); + +// Same for Android — used by the Play push action. +export const listDraftAndroidProducts = internalQuery({ + args: { projectId: v.id("projects") }, + returns: v.array( + v.object({ + productId: v.string(), + platform: platformValidator, + type: typeValidator, + title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + storeRef: v.optional(v.string()), + }), + ), + handler: async (ctx, args) => { + const all = await ctx.db + .query("products") + .withIndex("by_project_and_platform", (q) => + q.eq("projectId", args.projectId).eq("platform", "Android"), + ) + .collect(); + return all + .filter((row) => row.state === "Draft") + .map((row) => ({ + productId: row.productId, + platform: row.platform, + type: row.type, + title: row.title, + description: row.description, + priceAmountMicros: row.priceAmountMicros, + currency: row.currency, + storeRef: row.storeRef, + })); + }, +}); diff --git a/packages/kit/convex/webhooks/conformance.test.ts b/packages/kit/convex/webhooks/conformance.test.ts new file mode 100644 index 00000000..499743d7 --- /dev/null +++ b/packages/kit/convex/webhooks/conformance.test.ts @@ -0,0 +1,314 @@ +// End-to-end conformance harness driving the full webhook → state +// machine → entitlement decision path, using pre-canned ASN v2 + RTDN +// payloads. This is the "sandbox-without-Apple/Google" suite — every +// scenario starts from a deterministic notification payload and +// asserts the resulting `subscriptions` row + entitlement boolean. +// +// The harness exercises: +// 1. `normalizeAppleAsn` / `normalizeGoogleRtdn` (webhook receiver) +// 2. `applySubscriptionTransition` (state machine) +// 3. `entitlementActive` (status route) +// +// Each scenario is a script of `(input event) -> (expected after)` +// transitions so we cover the multi-step lifecycle (purchase → renew +// → cancel → expire, billing-retry → recovery, refund, etc.) rather +// than just a single edge. + +import { describe, expect, it } from "vitest"; + +import { + normalizeAppleAsn, + normalizeGoogleRtdn, + type AppleAsnPayload, + type AppleDecodedTransaction, + type AppleDecodedRenewalInfo, + type GoogleRtdnPayload, + type GoogleSubscriptionInfo, +} from "./shared"; +import { + applySubscriptionTransition, + entitlementActive, + type CurrentSubscription, +} from "../subscriptions/stateMachine"; + +type AppleStep = { + payload: AppleAsnPayload; + transaction?: AppleDecodedTransaction | null; + renewalInfo?: AppleDecodedRenewalInfo | null; + expect: ExpectAfter; +}; + +type GoogleStep = { + payload: GoogleRtdnPayload; + subscriptionInfo?: GoogleSubscriptionInfo | null; + expect: ExpectAfter; +}; + +type ExpectAfter = { + state: NonNullable["state"]; + active: boolean; + willRenew?: boolean; + cancellationReason?: NonNullable["cancellationReason"]; +}; + +function runAppleScenario( + steps: AppleStep[], + productId = "com.example.premium", +) { + let current: CurrentSubscription = null; + for (const [index, step] of steps.entries()) { + const normalized = normalizeAppleAsn({ + payload: step.payload, + transaction: { + originalTransactionId: "txn-1", + productId, + ...(step.transaction ?? {}), + }, + renewalInfo: step.renewalInfo, + }); + const transition = applySubscriptionTransition(current, { + type: normalized.type, + productId: normalized.productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + }); + current = transition.next ?? current; + expect(current?.state, `step ${index} state`).toBe(step.expect.state); + expect(transition.active, `step ${index} active`).toBe(step.expect.active); + if (step.expect.willRenew !== undefined) { + expect(current?.willRenew, `step ${index} willRenew`).toBe( + step.expect.willRenew, + ); + } + if (step.expect.cancellationReason !== undefined) { + expect( + current?.cancellationReason, + `step ${index} cancellationReason`, + ).toBe(step.expect.cancellationReason); + } + } + return current; +} + +function runGoogleScenario(steps: GoogleStep[], productId = "premium_monthly") { + let current: CurrentSubscription = null; + for (const [index, step] of steps.entries()) { + const normalized = normalizeGoogleRtdn({ + payload: step.payload, + subscriptionInfo: step.subscriptionInfo, + }); + const transition = applySubscriptionTransition(current, { + type: normalized.type, + productId: normalized.productId ?? productId, + subscriptionState: normalized.subscriptionState, + expiresAt: normalized.expiresAt, + renewsAt: normalized.renewsAt, + cancellationReason: normalized.cancellationReason, + currency: normalized.currency, + priceAmountMicros: normalized.priceAmountMicros, + }); + current = transition.next ?? current; + expect(current?.state, `google step ${index} state`).toBe( + step.expect.state, + ); + expect(transition.active, `google step ${index} active`).toBe( + step.expect.active, + ); + if (step.expect.willRenew !== undefined) { + expect(current?.willRenew, `google step ${index} willRenew`).toBe( + step.expect.willRenew, + ); + } + } + return current; +} + +const FUTURE = 9_999_999_999_000; + +describe("conformance: Apple lifecycle scenarios", () => { + it("purchase → renew → cancel → expire", () => { + const final = runAppleScenario([ + { + payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "u-1"), + transaction: { originalTransactionId: "1", expiresDate: FUTURE }, + expect: { state: "Active", active: true, willRenew: true }, + }, + { + payload: applePayload("DID_RENEW", undefined, "u-2"), + transaction: { + originalTransactionId: "1", + expiresDate: FUTURE + 1, + }, + expect: { state: "Active", active: true, willRenew: true }, + }, + { + payload: applePayload( + "DID_CHANGE_RENEWAL_STATUS", + "AUTO_RENEW_DISABLED", + "u-3", + ), + transaction: { originalTransactionId: "1", expiresDate: FUTURE + 1 }, + expect: { + state: "Active", + active: true, + willRenew: false, + cancellationReason: "UserCanceled", + }, + }, + { + payload: applePayload("EXPIRED", undefined, "u-4"), + transaction: { originalTransactionId: "1", expiresDate: 0 }, + renewalInfo: { expirationIntent: 1 }, + expect: { + state: "Expired", + active: false, + willRenew: false, + cancellationReason: "UserCanceled", + }, + }, + ]); + expect(entitlementActive(final!)).toBe(false); + }); + + it("billing-retry → recovery", () => { + runAppleScenario([ + { + payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "b-1"), + transaction: { originalTransactionId: "2", expiresDate: FUTURE }, + expect: { state: "Active", active: true }, + }, + { + payload: applePayload("DID_FAIL_TO_RENEW", "GRACE_PERIOD", "b-2"), + transaction: { originalTransactionId: "2", expiresDate: FUTURE }, + expect: { state: "InGracePeriod", active: true }, + }, + { + payload: applePayload("DID_RENEW", "BILLING_RECOVERY", "b-3"), + transaction: { + originalTransactionId: "2", + expiresDate: FUTURE + 100, + }, + expect: { state: "Active", active: true, willRenew: true }, + }, + ]); + }); + + it("refund flow flips state to Refunded and de-entitles", () => { + runAppleScenario([ + { + payload: applePayload("SUBSCRIBED", "INITIAL_BUY", "r-1"), + transaction: { originalTransactionId: "3", expiresDate: FUTURE }, + expect: { state: "Active", active: true }, + }, + { + payload: applePayload("REFUND", undefined, "r-2"), + transaction: { originalTransactionId: "3", expiresDate: FUTURE }, + expect: { + state: "Refunded", + active: false, + cancellationReason: "Refunded", + }, + }, + ]); + }); +}); + +describe("conformance: Google lifecycle scenarios", () => { + it("purchase → renew → on-hold → recovered", () => { + runGoogleScenario([ + { + payload: googleSubPayload("g-1", 1, "tok-1"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + { + payload: googleSubPayload("g-2", 2, "tok-1"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + { + payload: googleSubPayload("g-3", 5, "tok-1"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ON_HOLD" }, + expect: { state: "InBillingRetry", active: false }, + }, + { + payload: googleSubPayload("g-4", 4, "tok-1"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + ]); + }); + + it("voided purchase flips to Refunded", () => { + runGoogleScenario([ + { + payload: googleSubPayload("v-1", 1, "tok-vp"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + { + payload: { + messageId: "v-2", + eventTimeMillis: 1, + voidedPurchaseNotification: { purchaseToken: "tok-vp" }, + }, + expect: { state: "Refunded", active: false }, + }, + ]); + }); + + it("paused → resumed", () => { + runGoogleScenario([ + { + payload: googleSubPayload("p-1", 1, "tok-p"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + { + payload: googleSubPayload("p-2", 10, "tok-p"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_PAUSED" }, + expect: { state: "Paused", active: false }, + }, + { + payload: googleSubPayload("p-3", 4, "tok-p"), + subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, + expect: { state: "Active", active: true }, + }, + ]); + }); +}); + +function applePayload( + notificationType: string, + subtype: string | undefined, + uuid: string, +): AppleAsnPayload { + return { + notificationType, + subtype, + notificationUUID: uuid, + signedDate: 1_711_000_000_000, + data: { environment: "Production", bundleId: "com.example.app" }, + }; +} + +function googleSubPayload( + messageId: string, + notificationType: number, + purchaseToken: string, +): GoogleRtdnPayload { + return { + messageId, + eventTimeMillis: 1_711_000_000_000, + packageName: "com.example.app", + subscriptionNotification: { + notificationType, + purchaseToken, + subscriptionId: "premium_monthly", + }, + }; +} diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts index e859dbb3..54181cd5 100644 --- a/packages/kit/server/api/v1/products.ts +++ b/packages/kit/server/api/v1/products.ts @@ -87,6 +87,49 @@ products.post("/:apiKey", async (c) => { } }); +products.post("/:apiKey/sync/:platform", async (c) => { + const apiKey = c.req.param("apiKey"); + const platform = c.req.param("platform"); + const direction = + (c.req.query("direction") as "pull" | "push" | "both" | undefined) ?? + "both"; + if (platform !== "ios" && platform !== "android") { + return c.json( + { + errors: [ + { code: "INVALID_INPUT", message: "platform must be ios|android" }, + ], + }, + 400, + ); + } + try { + const result = + platform === "ios" + ? await client.action(api.products.asc.pushSyncProductsApple, { + apiKey, + direction, + }) + : await client.action(api.products.play.pushSyncProductsGoogle, { + apiKey, + direction, + }); + return c.json(result); + } catch (error) { + return c.json( + { + errors: [ + { + code: "PRODUCT_SYNC_FAILED", + message: error instanceof Error ? error.message : String(error), + }, + ], + }, + 400, + ); + } +}); + products.delete("/:apiKey/:productId", async (c) => { const apiKey = c.req.param("apiKey"); const productId = c.req.param("productId"); diff --git a/packages/kit/server/api/v1/subscriptions.ts b/packages/kit/server/api/v1/subscriptions.ts index 7104a57d..5872bfdd 100644 --- a/packages/kit/server/api/v1/subscriptions.ts +++ b/packages/kit/server/api/v1/subscriptions.ts @@ -1,5 +1,4 @@ import { Hono } from "hono"; -import type { Context } from "hono"; import { api } from "@/convex"; import { client } from "../../convex"; diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts index 0c43f9bb..63518163 100644 --- a/packages/kit/server/api/v1/webhooks.ts +++ b/packages/kit/server/api/v1/webhooks.ts @@ -2,9 +2,10 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { streamSSE } from "hono/streaming"; import { OAuth2Client } from "google-auth-library"; +import { ConvexClient } from "convex/browser"; import { api } from "@/convex"; -import { client, handleConvexError } from "../../convex"; +import { client, convexUrlForRealtime, handleConvexError } from "../../convex"; // Inbound webhook receivers for Apple ASN v2 and Google Pub/Sub RTDN. // @@ -199,12 +200,10 @@ webhooks.post("/google/:apiKey", async (c) => { }); // Server-Sent Events stream of normalized webhook events tied to the -// caller's API key. Built on `webhookEventsSince` polling rather than -// Convex's native subscription stream because the Hono server uses -// `ConvexHttpClient` (no streaming subscribe) and SSE works -// universally — RN, Expo, Flutter, KMP, and Godot all have stable SSE -// or chunked-HTTP support without needing a Convex client dependency -// in each SDK. +// caller's API key. Per-connection, we open a Convex `onUpdate` +// subscription against `webhookEventsSince(apiKey, sinceMs)` so kit +// pushes new events to the SSE client the moment Convex commits them. +// No polling — Convex's reactive query is the source of liveness. // // Protocol: // GET /v1/webhooks/stream/:apiKey @@ -219,12 +218,10 @@ webhooks.post("/google/:apiKey", async (c) => { // from there, so events that fired while the connection was closed // are delivered in order on the next connect. // -// Polling cadence: 1.5s. This trades a half-step of real-time -// freshness for not opening a Convex subscribe socket per client. The -// SDKs treat the SSE connection as authoritative real-time anyway — -// any further hardening (push, true streaming) is additive. -const STREAM_POLL_MS = 1_500; -const STREAM_PAGE_LIMIT = 100; +// Heartbeat: an SSE `event: heartbeat` is emitted every 25s so +// intermediate proxies (Fly edge, Cloudflare, browser fetch) don't +// close the idle connection. +const HEARTBEAT_MS = 25_000; webhooks.get("/stream/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); @@ -233,64 +230,81 @@ webhooks.get("/stream/:apiKey", async (c) => { let cursor = await resolveStreamStartCursor(apiKey, lastEventId); return streamSSE(c, async (stream) => { - // Heartbeats keep proxies (Fly edge, Cloudflare, browser fetch) - // from timing the connection out during long quiet periods. SSE - // comments are ignored by EventSource clients but count as - // bytes-on-the-wire for the proxy idle timer. let aborted = false; stream.onAbort(() => { aborted = true; }); + const reactive = new ConvexClient(convexUrlForRealtime); + const seen = new Set(); + await stream.writeSSE({ event: "ready", data: JSON.stringify({ cursor }), }); - while (!aborted) { - let events: Array> = []; - try { - events = (await client.query(api.webhooks.query.webhookEventsSince, { - apiKey, - sinceMs: cursor, - limit: STREAM_PAGE_LIMIT, - })) as Array>; - } catch (error) { - console.error("[webhooks/stream] poll failed", error); - await stream.writeSSE({ - event: "stream-error", - data: JSON.stringify({ - message: - error instanceof Error ? error.message : "Unknown poll error", - }), - }); - // Sleep before retrying to avoid hot-looping on persistent - // backend errors. - await stream.sleep(STREAM_POLL_MS * 4); - continue; - } + // Convex `onUpdate` re-fires the callback every time the query + // result changes. We track previously-emitted ids so a row that + // was already emitted earlier in the connection isn't re-sent + // when the result set grows. The `cursor` advances whenever we + // emit so the next reconnect resumes from the right point. + let unsubscribe: (() => void) | null = null; + try { + unsubscribe = reactive.onUpdate( + api.webhooks.query.webhookEventsSince, + { apiKey, sinceMs: cursor, limit: 500 }, + (events: unknown) => { + if (aborted) return; + if (!Array.isArray(events)) return; + for (const event of events as Array>) { + const id = typeof event.id === "string" ? event.id : null; + if (!id || seen.has(id)) continue; + seen.add(id); + if ( + typeof event.receivedAt === "number" && + event.receivedAt > cursor + ) { + cursor = event.receivedAt; + } + stream + .writeSSE({ + id, + event: + typeof event.type === "string" ? event.type : "WebhookEvent", + data: JSON.stringify(event), + }) + .catch((err) => { + console.error("[webhooks/stream] write failed", err); + }); + } + }, + ); + } catch (error) { + console.error("[webhooks/stream] subscribe failed", error); + await stream.writeSSE({ + event: "stream-error", + data: JSON.stringify({ + message: error instanceof Error ? error.message : "Subscribe failed", + }), + }); + void reactive.close(); + return; + } - for (const event of events) { - if (aborted) { - break; - } - // Strict-equality `>` — events at exactly `sinceMs` are - // already-seen on reconnect so we'd emit a dupe otherwise. - if (typeof event.receivedAt === "number" && event.receivedAt > cursor) { - cursor = event.receivedAt; - } - await stream.writeSSE({ - id: typeof event.id === "string" ? event.id : Date.now().toString(), - event: typeof event.type === "string" ? event.type : "WebhookEvent", - data: JSON.stringify(event), - }); + try { + while (!aborted) { + await stream.sleep(HEARTBEAT_MS); + if (aborted) break; + await stream.writeSSE({ event: "heartbeat", data: "" }); } - - // Heartbeat sent regardless of new events so quiet connections - // stay alive. Comment line per the SSE spec. - await stream.writeSSE({ event: "heartbeat", data: "" }); - - await stream.sleep(STREAM_POLL_MS); + } finally { + try { + unsubscribe?.(); + } catch { + // closing twice (close() + unsubscribe) is benign in some + // hot-reload paths. + } + void reactive.close(); } }); }); diff --git a/packages/kit/server/convex.ts b/packages/kit/server/convex.ts index 0aca7db0..b53401f8 100644 --- a/packages/kit/server/convex.ts +++ b/packages/kit/server/convex.ts @@ -13,6 +13,12 @@ if (!convexUrl) { } export const client = new ConvexHttpClient(convexUrl); +// Used by the SSE webhook stream to subscribe to live query updates +// instead of polling. The reactive client is exported lazily so unit +// tests that only need `client` (the HTTP client) don't pay for a +// WebSocket dial when no subscription is opened. +export const convexUrlForRealtime = convexUrl; + interface ApiError { code: string; message: string; diff --git a/packages/kit/src/convex.ts b/packages/kit/src/convex.ts index 7ca5831e..2da158d5 100644 --- a/packages/kit/src/convex.ts +++ b/packages/kit/src/convex.ts @@ -2,7 +2,7 @@ // Backend lives in ./convex (relative to repo root). export { api } from "../convex/_generated/api"; -export type { Id } from "../convex/_generated/dataModel"; +export type { Id, Doc } from "../convex/_generated/dataModel"; export { SUBSCRIPTION_PLANS } from "../convex/plans"; export type { SubscriptionPlanId } from "../convex/plans"; export { HarmonizedPurchaseState } from "../convex/purchases/purchaseState"; diff --git a/packages/kit/src/pages/auth/index.tsx b/packages/kit/src/pages/auth/index.tsx index 59ecb13f..367a11c8 100644 --- a/packages/kit/src/pages/auth/index.tsx +++ b/packages/kit/src/pages/auth/index.tsx @@ -13,6 +13,10 @@ import OrganizationSettings from "./organization/settings"; import ProjectIndex from "./organization/project"; import ProjectPurchases from "./organization/project/purchases"; import ProjectApiKeys from "./organization/project/apikeys"; +import ProjectSubscriptions from "./organization/project/subscriptions"; +import ProjectProducts from "./organization/project/products"; +import ProjectPaywalls from "./organization/project/paywalls"; +import ProjectWebhooks from "./organization/project/webhooks"; import ProjectSettings from "./organization/project/settings"; import ProjectPurchaseDetail from "./organization/project/purchase-detail"; import OrganizationUsagePage from "./organization/usage"; @@ -260,6 +264,38 @@ export default function AuthenticatedPages() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> ; const DEFAULT_TAB: VisibleTabId = "purchases"; @@ -42,6 +61,26 @@ export default function ProjectIndex() { label: "Purchases", icon: ShoppingBag, }, + { + id: "subscriptions", + label: "Subscriptions", + icon: Activity, + }, + { + id: "products", + label: "Products", + icon: Layers, + }, + { + id: "paywalls", + label: "Paywalls", + icon: CreditCard, + }, + { + id: "webhooks", + label: "Webhooks", + icon: Webhook, + }, { id: "apikeys", label: "API Keys", diff --git a/packages/kit/src/pages/auth/organization/project/paywalls.tsx b/packages/kit/src/pages/auth/organization/project/paywalls.tsx new file mode 100644 index 00000000..073d2cb3 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/paywalls.tsx @@ -0,0 +1,227 @@ +import { useState } from "react"; +import { useOutletContext } from "react-router-dom"; +import { useMutation, useQuery } from "convex/react"; +import { CreditCard, ExternalLink, Plus, Trash2 } from "lucide-react"; + +import type { Doc } from "@/convex"; +import { api } from "@/convex"; +import { PageLoading } from "@/components/LoadingSpinner"; +import { Badge } from "../../../../components/Badge"; + +type ProjectContext = { project: Doc<"projects"> }; + +export default function ProjectPaywalls() { + const { project } = useOutletContext(); + const paywalls = useQuery(api.paywalls.query.listPaywalls, { + apiKey: project.apiKey, + }); + const upsert = useMutation(api.paywalls.mutation.upsertPaywall); + const remove = useMutation(api.paywalls.mutation.deletePaywall); + const [draft, setDraft] = useState({ + slug: "", + title: "", + layout: "Single" as "Single" | "Compare" | "Carousel", + productIds: "", + headline: "", + cta: "Continue", + }); + + if (paywalls === undefined) { + return ; + } + + const baseUrl = window.location.origin; + + const onSubmit = async () => { + const productIds = draft.productIds + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if ( + !draft.slug || + !draft.title || + !draft.headline || + productIds.length === 0 + ) { + return; + } + await upsert({ + apiKey: project.apiKey, + slug: draft.slug, + title: draft.title, + layout: draft.layout, + productIds, + headline: draft.headline, + cta: draft.cta, + }); + setDraft({ + ...draft, + slug: "", + title: "", + productIds: "", + headline: "", + }); + }; + + return ( +
+
+

+ + Paywalls +

+

+ Hosted at{" "} + + /v1/paywalls/{`{apiKey}`}/{`{slug}`} + {" "} + — open the URL in any of the 5 SDK WebViews. The HTML emits a{" "} + {`{ openiap: 'purchase', productId }`}{" "} + message via the host's WebView bridge so the SDK can dispatch the + actual requestPurchase. +

+
+ +
+ + setDraft({ ...draft, slug: e.target.value })} + placeholder="intro-2026" + className="w-full px-2 py-1.5 rounded border border-border bg-background" + /> + + + setDraft({ ...draft, title: e.target.value })} + placeholder="Premium intro" + className="w-full px-2 py-1.5 rounded border border-border bg-background" + /> + + + + +
+ + + setDraft({ ...draft, productIds: e.target.value }) + } + placeholder="com.example.premium_monthly, com.example.premium_yearly" + className="w-full px-2 py-1.5 rounded border border-border bg-background" + /> + +
+ + setDraft({ ...draft, cta: e.target.value })} + placeholder="Continue" + className="w-full px-2 py-1.5 rounded border border-border bg-background" + /> + +
+ + setDraft({ ...draft, headline: e.target.value })} + placeholder="Unlock the full experience" + className="w-full px-2 py-1.5 rounded border border-border bg-background" + /> + +
+
+ +
+
+ +
+ {paywalls.length === 0 && ( +
+ No paywalls yet. Create one above to get a hosted URL. +
+ )} + {paywalls.map((paywall) => { + const url = `${baseUrl}/v1/paywalls/${encodeURIComponent(project.apiKey)}/${encodeURIComponent(paywall.slug)}`; + return ( +
+
+
+ {paywall.title} + + {paywall.layout} + +
+
+ slug: {paywall.slug} · products:{" "} + {paywall.productIds.join(", ")} +
+
+
+ + Preview + + +
+
+ ); + })} +
+
+ ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx new file mode 100644 index 00000000..e833b7c8 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -0,0 +1,280 @@ +import { useMemo, useState } from "react"; +import { useOutletContext } from "react-router-dom"; +import { useMutation, useQuery, useAction } from "convex/react"; +import { Layers, Plus, RefreshCw } from "lucide-react"; + +import type { Doc } from "@/convex"; +import { api } from "@/convex"; +import { PageLoading } from "@/components/LoadingSpinner"; +import { Badge, PlatformBadge } from "../../../../components/Badge"; + +type ProjectContext = { project: Doc<"projects"> }; + +export default function ProjectProducts() { + const { project } = useOutletContext(); + const products = useQuery(api.products.query.listProducts, { + apiKey: project.apiKey, + }); + const upsert = useMutation(api.products.mutation.upsertProduct); + const syncApple = useAction(api.products.asc.pushSyncProductsApple); + const syncGoogle = useAction(api.products.play.pushSyncProductsGoogle); + + const [syncStatus, setSyncStatus] = useState(null); + const [draft, setDraft] = useState({ + productId: "", + platform: "IOS" as "IOS" | "Android", + type: "Subscription" as "Subscription" | "NonConsumable" | "Consumable", + title: "", + }); + + const grouped = useMemo(() => { + if (!products) return { ios: [], android: [] }; + return { + ios: products.filter((p) => p.platform === "IOS"), + android: products.filter((p) => p.platform === "Android"), + }; + }, [products]); + + if (products === undefined) { + return ; + } + + const onAdd = async () => { + if (!draft.productId || !draft.title) return; + await upsert({ + apiKey: project.apiKey, + productId: draft.productId, + platform: draft.platform, + type: draft.type, + title: draft.title, + state: "Draft", + }); + setDraft({ ...draft, productId: "", title: "" }); + }; + + const onSync = async (platform: "IOS" | "Android") => { + setSyncStatus(`Syncing ${platform}…`); + try { + const fn = platform === "IOS" ? syncApple : syncGoogle; + const result = await fn({ + apiKey: project.apiKey, + direction: "both", + }); + setSyncStatus( + `Pulled ${result.pulled}, pushed ${result.pushed}` + + (result.failures.length + ? `, ${result.failures.length} failure(s): ${result.failures + .map((f) => `${f.productId} (${f.reason})`) + .join(", ")}` + : ""), + ); + } catch (error) { + setSyncStatus(error instanceof Error ? error.message : String(error)); + } + }; + + return ( +
+
+

+ + Products +

+

+ kit-side catalog of every productId used by your app. Push-sync + mirrors Draft rows to App Store Connect / Play Console using the + credentials you uploaded; Pull-sync brings store-side changes back + into kit. +

+
+ +
+ + setDraft({ ...draft, productId: e.target.value })} + placeholder="com.example.premium" + className="w-full px-2 py-1.5 rounded border border-border bg-background" + /> + + + setDraft({ ...draft, title: e.target.value })} + placeholder="Premium Monthly" + className="w-full px-2 py-1.5 rounded border border-border bg-background" + /> + + + + + + + + +
+ + {syncStatus && ( +
+ {syncStatus} +
+ )} + + { + void onSync("IOS"); + }} + /> + { + void onSync("Android"); + }} + /> +
+ ); +} + +function ProductGroup({ + platform, + rows, + onSync, +}: { + platform: "IOS" | "Android"; + rows: Array<{ + productId: string; + type: string; + title: string; + state: string; + storeRef?: string; + priceAmountMicros?: number; + currency?: string; + updatedAt: number; + }>; + onSync: () => void; +}) { + return ( +
+
+
+ + + {rows.length} product{rows.length === 1 ? "" : "s"} + +
+ +
+ + + + + + + + + + + + + {rows.length === 0 && ( + + + + )} + {rows.map((row) => ( + + + + + + + + + ))} + +
Product IDTitleTypeStateStore refPrice
+ Nothing yet. Add a product above or hit Sync to pull an existing + catalog. +
{row.productId}{row.title} + + {row.type} + + + + {row.state} + + + {row.storeRef ?? "—"} + + {row.priceAmountMicros + ? `${row.currency ?? ""} ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`.trim() + : "—"} +
+
+ ); +} + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/packages/kit/src/pages/auth/organization/project/subscriptions.tsx b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx new file mode 100644 index 00000000..ddb52595 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/subscriptions.tsx @@ -0,0 +1,213 @@ +import { useMemo, useState } from "react"; +import { useOutletContext } from "react-router-dom"; +import { useQuery } from "convex/react"; +import { + Activity, + Calendar, + AlertTriangle, + CheckCircle2, + XCircle, + PauseCircle, + RefreshCw, +} from "lucide-react"; + +import type { Doc } from "@/convex"; +import { api } from "@/convex"; +import { PageLoading } from "@/components/LoadingSpinner"; +import { Badge } from "../../../../components/Badge"; + +type ProjectContext = { project: Doc<"projects"> }; + +const STATE_FILTERS = [ + { id: "all", label: "All" }, + { id: "Active", label: "Active" }, + { id: "InGracePeriod", label: "Grace period" }, + { id: "InBillingRetry", label: "Billing retry" }, + { id: "Expired", label: "Expired" }, + { id: "Refunded", label: "Refunded" }, + { id: "Revoked", label: "Revoked" }, + { id: "Paused", label: "Paused" }, +] as const; + +type StateFilter = (typeof STATE_FILTERS)[number]["id"]; + +export default function ProjectSubscriptions() { + const { project } = useOutletContext(); + const [filter, setFilter] = useState("all"); + + const metrics = useQuery(api.subscriptions.query.metricsSummary, { + apiKey: project.apiKey, + }); + const subscriptions = useQuery(api.subscriptions.query.listSubscriptions, { + apiKey: project.apiKey, + state: filter === "all" ? undefined : filter, + limit: 200, + }); + + const formattedMrr = useMemo(() => { + if (!metrics) return "—"; + return formatMicros(metrics.mrrMicros, metrics.currency); + }, [metrics]); + + if (subscriptions === undefined || metrics === undefined) { + return ; + } + + return ( +
+
+

+ + Subscriptions +

+

+ Authoritative state derived from webhooks. Keys map to the openiap{" "} + WebhookEvent spec — rows update the + moment kit ingests an Apple ASN v2 / Google RTDN notification. +

+
+ +
+ + + + +
+ +
+ + + +
+ +
+
+ {STATE_FILTERS.map((option) => ( + + ))} +
+ + + + + + + + + + + + + {subscriptions.items.length === 0 && ( + + + + )} + {subscriptions.items.map((sub) => ( + + + + + + + + + ))} + +
UserProductPlatformStateExpiresUpdated
+ No subscriptions for this filter yet. Webhook events from + Apple / Google will populate this table. +
+ {sub.userId ?? unbound} + {sub.productId} + + {sub.platform} + + + + + {sub.expiresAt ? formatDate(sub.expiresAt) : "—"} + + {formatDate(sub.updatedAt)} +
+
+
+ ); +} + +function MetricCard({ + icon: Icon, + label, + value, +}: { + icon: typeof Activity; + label: string; + value: string | number; +}) { + return ( +
+
+ + {label} +
+
{value}
+
+ ); +} + +function StateBadge({ state }: { state: string }) { + const variant: "default" | "new" = + state === "Active" || state === "InGracePeriod" ? "new" : "default"; + return ( + + {state} + + ); +} + +function formatDate(epoch: number): string { + return new Date(epoch).toISOString().slice(0, 16).replace("T", " "); +} + +function formatMicros(micros: number, currency?: string): string { + if (!micros) return "—"; + const value = micros / 1_000_000; + return `${currency ?? ""} ${value.toFixed(2)}`.trim(); +} diff --git a/packages/kit/src/pages/auth/organization/project/webhooks.tsx b/packages/kit/src/pages/auth/organization/project/webhooks.tsx new file mode 100644 index 00000000..a13e1d7e --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/webhooks.tsx @@ -0,0 +1,148 @@ +import { useOutletContext } from "react-router-dom"; +import { useState } from "react"; +import { Webhook, Copy, ExternalLink } from "lucide-react"; + +import type { Doc } from "@/convex"; + +type ProjectContext = { project: Doc<"projects"> }; + +export default function ProjectWebhooks() { + const { project } = useOutletContext(); + const baseUrl = window.location.origin; + + const urls = { + apple: `${baseUrl}/v1/webhooks/apple/${encodeURIComponent(project.apiKey)}`, + google: `${baseUrl}/v1/webhooks/google/${encodeURIComponent(project.apiKey)}`, + stream: `${baseUrl}/v1/webhooks/stream/${encodeURIComponent(project.apiKey)}`, + }; + + return ( +
+
+

+ + Webhooks +

+

+ Register the URLs below with App Store Connect and Google Pub/Sub so + kit can ingest lifecycle notifications. Clients connect to the SSE + stream URL to receive normalized{" "} + WebhookEvents in real time. +

+
+ + + Paste this URL into App Store Connect → Apps → Your App → App + Information → App Store Server Notifications. Production + Sandbox + URLs are the same — kit reads the environment from the signed + payload. + + } + url={urls.apple} + external="https://developer.apple.com/documentation/appstoreservernotifications" + /> + + + Configure a Pub/Sub topic in Google Cloud, attach a push + subscription pointing at this URL, and grant the topic publish + permission to your Play Console publisher account. kit verifies the + OIDC bearer token; set{" "} + GOOGLE_PUBSUB_PUSH_AUDIENCE in your + kit deployment to enable strict checks. + + } + url={urls.google} + external="https://developer.android.com/google/play/billing/rtdn-reference" + /> + + + Open this URL with EventSource (or kit's per-SDK helper) to receive + normalized webhook events. Reconnects are handled automatically + using Last-Event-ID so events fired + during a closed connection are delivered in order on the next + connect. + + } + url={urls.stream} + /> + +
+
Live test
+

+ POST a synthetic Pub/Sub test message to the Google receiver to verify + wiring without going through the App Store / Play Console. The MCP + server's openiap_simulate_webhook{" "} + tool runs this same request. +

+
{`curl -X POST \\
+  ${urls.google} \\
+  -H 'content-type: application/json' \\
+  -d '{
+    "message": {
+      "data": "${btoa(JSON.stringify({ packageName: project.androidPackageName ?? "com.example.app", eventTimeMillis: Date.now(), testNotification: { version: "1.0" } }))}",
+      "messageId": "manual-test-${Date.now()}",
+      "publishTime": "${new Date().toISOString()}"
+    }
+  }'`}
+
+
+ ); +} + +function UrlCard({ + title, + description, + url, + external, +}: { + title: string; + description: React.ReactNode; + url: string; + external?: string; +}) { + const [copied, setCopied] = useState(false); + return ( +
+
+
+
{title}
+
+ {description} +
+
+ {external && ( + + Apple/Google docs + + )} +
+
+ {url} + +
+
+ ); +} From ea23a03539e3b800d326b020c2c3844ac971f88f Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 15:03:25 +0900 Subject: [PATCH 06/81] fix(kit): wrap Platform / Type / Layout selects with ChevronDown so the icon has padding instead of crowding the text --- .../auth/organization/project/paywalls.tsx | 39 +++++++---- .../auth/organization/project/products.tsx | 70 +++++++++++++------ 2 files changed, 73 insertions(+), 36 deletions(-) diff --git a/packages/kit/src/pages/auth/organization/project/paywalls.tsx b/packages/kit/src/pages/auth/organization/project/paywalls.tsx index 073d2cb3..5333d083 100644 --- a/packages/kit/src/pages/auth/organization/project/paywalls.tsx +++ b/packages/kit/src/pages/auth/organization/project/paywalls.tsx @@ -1,7 +1,13 @@ import { useState } from "react"; import { useOutletContext } from "react-router-dom"; import { useMutation, useQuery } from "convex/react"; -import { CreditCard, ExternalLink, Plus, Trash2 } from "lucide-react"; +import { + CreditCard, + ChevronDown, + ExternalLink, + Plus, + Trash2, +} from "lucide-react"; import type { Doc } from "@/convex"; import { api } from "@/convex"; @@ -100,20 +106,23 @@ export default function ProjectPaywalls() { /> - +
+ + +
diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index e833b7c8..72c9708a 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; import { useOutletContext } from "react-router-dom"; import { useMutation, useQuery, useAction } from "convex/react"; -import { Layers, Plus, RefreshCw } from "lucide-react"; +import { Layers, Plus, RefreshCw, ChevronDown } from "lucide-react"; import type { Doc } from "@/convex"; import { api } from "@/convex"; @@ -106,38 +106,35 @@ export default function ProjectProducts() { /> - + options={[ + { value: "IOS", label: "iOS" }, + { value: "Android", label: "Android" }, + ]} + /> - + options={[ + { value: "Subscription", label: "Subscription" }, + { value: "NonConsumable", label: "Non-consumable" }, + { value: "Consumable", label: "Consumable" }, + ]} + />
+ ); +} From dc2bfaf4efda6139d790459a84f59dbe51c4c506 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 15:03:25 +0900 Subject: [PATCH 07/81] feat(kit): unified /v1/webhooks/{apiKey} + Horizon polling reconciler + setup status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX simplification + Horizon completion + clearer error signaling. Unified webhook endpoint: - New `POST /v1/webhooks/{apiKey}` route auto-detects Apple ASN v2 (`{signedPayload}`) vs Google Pub/Sub (`{message:{data,messageId}}`) by inspecting the body shape and dispatches to the same Convex action either platform-specific path uses. Operators paste one URL into BOTH App Store Connect and Google Pub/Sub — whichever platform isn't configured simply produces no traffic. - Legacy `/apple/{apiKey}` and `/google/{apiKey}` aliases kept for back-compat; both route through the same handler. - Pre-dispatch setup check: if an Apple notification arrives but the project is missing iosBundleId / iosAppAppleId / issuerId / keyId, kit returns 412 with `code: "IOS_NOT_CONFIGURED"` listing the missing fields. Same for Android (`ANDROID_NOT_CONFIGURED`). No more silent drops — operators see exactly what's missing. Setup status: - New `convex/projects/setupStatus.ts::getSetupStatus(apiKey)` query returns `{ found, ios, android, horizon }` with per-platform `{ configured, missing[] }`. Used by the dashboard, the unified webhook handler's pre-check, and downstream SDK error surfacing. Horizon polling reconciler: - `convex/subscriptions/horizon.ts` — Meta Horizon Store has no webhook system, only the synchronous `verify_entitlement` Graph API. The cron action walks every Horizon-enabled project's Active / InGracePeriod / Paused subscriptions every 6h, calls Meta Graph for each (userId, sku), and feeds deltas through the same `applySubscriptionTransition` pipeline Apple/Google use. - `horizonInternal.ts` exposes the queries / mutations the action needs without dragging node-only imports into the Convex runtime. - `reconcileHorizonNow` action + cron registration in `crons.ts` (6h interval). Dashboard Webhooks page: - Single "Lifecycle webhook URL" card with copy-to-clipboard; paste into both App Store Connect and Google Pub/Sub. - Per-platform setup badges (iOS / Android / Horizon-polling) with green check / amber warning + missing-field list. - "Advanced — platform-specific URLs (legacy)" collapsible section for operators with existing wiring. - Live test curl recipe now points at the unified URL. Verification: - kit lint clean (0 errors); 281/281 vitest; smoke probes green. - typecheck clean across kit + docs. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/convex/_generated/api.d.ts | 6 + packages/kit/convex/crons.ts | 12 + packages/kit/convex/projects/setupStatus.ts | 87 +++++++ packages/kit/convex/subscriptions/horizon.ts | 240 ++++++++++++++++++ .../convex/subscriptions/horizonInternal.ts | 154 +++++++++++ packages/kit/server/api/v1/webhooks.ts | 200 +++++++++++---- .../auth/organization/project/webhooks.tsx | 156 +++++++++--- 7 files changed, 777 insertions(+), 78 deletions(-) create mode 100644 packages/kit/convex/projects/setupStatus.ts create mode 100644 packages/kit/convex/subscriptions/horizon.ts create mode 100644 packages/kit/convex/subscriptions/horizonInternal.ts diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index efd2ccaa..b36446e8 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -39,6 +39,7 @@ import type * as projects_helpers from "../projects/helpers.js"; import type * as projects_internal from "../projects/internal.js"; import type * as projects_mutation from "../projects/mutation.js"; import type * as projects_query from "../projects/query.js"; +import type * as projects_setupStatus from "../projects/setupStatus.js"; import type * as purchases_action from "../purchases/action.js"; import type * as purchases_android from "../purchases/android.js"; import type * as purchases_cleanup from "../purchases/cleanup.js"; @@ -52,6 +53,8 @@ import type * as purchases_query from "../purchases/query.js"; import type * as purchases_retry from "../purchases/retry.js"; import type * as purchases_shared from "../purchases/shared.js"; import type * as purchases_stats from "../purchases/stats.js"; +import type * as subscriptions_horizon from "../subscriptions/horizon.js"; +import type * as subscriptions_horizonInternal from "../subscriptions/horizonInternal.js"; import type * as subscriptions_internal from "../subscriptions/internal.js"; import type * as subscriptions_mutation from "../subscriptions/mutation.js"; import type * as subscriptions_query from "../subscriptions/query.js"; @@ -110,6 +113,7 @@ declare const fullApi: ApiFromModules<{ "projects/internal": typeof projects_internal; "projects/mutation": typeof projects_mutation; "projects/query": typeof projects_query; + "projects/setupStatus": typeof projects_setupStatus; "purchases/action": typeof purchases_action; "purchases/android": typeof purchases_android; "purchases/cleanup": typeof purchases_cleanup; @@ -123,6 +127,8 @@ declare const fullApi: ApiFromModules<{ "purchases/retry": typeof purchases_retry; "purchases/shared": typeof purchases_shared; "purchases/stats": typeof purchases_stats; + "subscriptions/horizon": typeof subscriptions_horizon; + "subscriptions/horizonInternal": typeof subscriptions_horizonInternal; "subscriptions/internal": typeof subscriptions_internal; "subscriptions/mutation": typeof subscriptions_mutation; "subscriptions/query": typeof subscriptions_query; diff --git a/packages/kit/convex/crons.ts b/packages/kit/convex/crons.ts index 60fb0e1e..fe7acd4f 100644 --- a/packages/kit/convex/crons.ts +++ b/packages/kit/convex/crons.ts @@ -48,4 +48,16 @@ crons.interval( { olderThanMs: 30 * 24 * 60 * 60 * 1000 }, ); +// Meta Horizon Store has no webhook system — Meta only exposes a +// synchronous `verify_entitlement` Graph API. We poll every 6h to +// reconcile Active / InGracePeriod / Paused subscriptions against +// Meta's authoritative answer, feeding the deltas through the same +// state machine the Apple/Google webhook receivers use. +crons.interval( + "reconcile horizon entitlements", + { hours: 6 }, + internal.subscriptions.horizon.reconcileHorizonEntitlements, + {}, +); + export default crons; diff --git a/packages/kit/convex/projects/setupStatus.ts b/packages/kit/convex/projects/setupStatus.ts new file mode 100644 index 00000000..01082adc --- /dev/null +++ b/packages/kit/convex/projects/setupStatus.ts @@ -0,0 +1,87 @@ +import { query } from "../_generated/server"; +import { v } from "convex/values"; + +// Public query — surfaces which platforms a project has configured so +// the dashboard, the SDK, and the MCP server can return a precise +// "X not configured" error instead of a silent empty response. +// +// Auth via apiKey (same model as the rest of the v1 surface). Returns +// `found: false` when the key is unknown so the dashboard can render +// "log in to a different project" without leaking which keys exist. + +const platformShape = v.object({ + configured: v.boolean(), + missing: v.array(v.string()), +}); + +export const getSetupStatus = query({ + args: { apiKey: v.string() }, + returns: v.object({ + found: v.boolean(), + projectId: v.union(v.id("projects"), v.null()), + ios: platformShape, + android: platformShape, + horizon: platformShape, + appleP8Uploaded: v.boolean(), + googleServiceAccountUploaded: v.boolean(), + }), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + + if (!project) { + const empty = { configured: false, missing: ["project not found"] }; + return { + found: false, + projectId: null, + ios: empty, + android: empty, + horizon: empty, + appleP8Uploaded: false, + googleServiceAccountUploaded: false, + }; + } + + // We don't run an action from a query, so file-presence checks are + // a separate `internal.files.internal.*` lookup the dashboard can + // call. From here we report only field-level configuration. + const iosMissing: string[] = []; + if (!project.iosBundleId) iosMissing.push("iosBundleId"); + if (!project.iosAppAppleId) iosMissing.push("iosAppAppleId"); + if (!project.iosAppStoreIssuerId) iosMissing.push("iosAppStoreIssuerId"); + if (!project.iosAppStoreKeyId) iosMissing.push("iosAppStoreKeyId"); + + const androidMissing: string[] = []; + if (!project.androidPackageName) androidMissing.push("androidPackageName"); + + const horizonMissing: string[] = []; + if (!project.horizonEnabled) horizonMissing.push("horizonEnabled"); + if (!project.horizonAppId) horizonMissing.push("horizonAppId"); + if (!project.horizonAppSecret) horizonMissing.push("horizonAppSecret"); + + return { + found: true, + projectId: project._id, + ios: { + configured: iosMissing.length === 0, + missing: iosMissing, + }, + android: { + configured: androidMissing.length === 0, + missing: androidMissing, + }, + horizon: { + configured: horizonMissing.length === 0, + missing: horizonMissing, + }, + // The webhook receivers ALSO need the .p8 / service-account JSON + // file uploaded to the project; that's stored separately. The + // dashboard's setup card surfaces the file-upload state from a + // companion `files` query. + appleP8Uploaded: false, + googleServiceAccountUploaded: false, + }; + }, +}); diff --git a/packages/kit/convex/subscriptions/horizon.ts b/packages/kit/convex/subscriptions/horizon.ts new file mode 100644 index 00000000..a91e4ee0 --- /dev/null +++ b/packages/kit/convex/subscriptions/horizon.ts @@ -0,0 +1,240 @@ +"use node"; +import { v } from "convex/values"; + +import { action, internalAction } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; + +// Horizon polling reconciler. +// +// Meta Horizon Store has no webhook / push notification system — +// `developers.meta.com/horizon/documentation/native/ps-iap` only +// exposes the synchronous `verify_entitlement` Graph API. So unlike +// Apple ASN v2 / Google RTDN, kit cannot ingest "subscription +// renewed" or "refunded" events the moment they happen on Meta's +// side; we have to re-check entitlement on a schedule. +// +// This cron action walks every Horizon `subscriptions` row that +// might have changed (state in {Active, InGracePeriod, Paused}), +// hits Meta Graph for each (userId, sku), and feeds the result +// through the same `applySubscriptionEvent` pipeline Apple/Google +// use. Net effect: subscriptions table converges to Meta's +// authoritative answer within one cron tick. +// +// Cadence: 6h (registered in `crons.ts`). Every project's Horizon +// subs run in one tick because the population is small per project. +// If a single project grows past ~1000 active Horizon subs we'll +// want to paginate. + +const META_GRAPH_BASE = "https://graph.oculus.com"; + +type HorizonProbe = { + userId: string; + sku: string; + purchaseToken: string; + state: string; +}; + +export const reconcileHorizonEntitlements = internalAction({ + args: {}, + returns: v.object({ + checked: v.number(), + transitioned: v.number(), + failures: v.number(), + }), + handler: async ( + ctx, + ): Promise<{ + checked: number; + transitioned: number; + failures: number; + }> => { + const projects = await ctx.runQuery( + internal.subscriptions.horizonInternal.listHorizonProjects, + {}, + ); + let checked = 0; + let transitioned = 0; + let failures = 0; + + for (const project of projects) { + if ( + !project.horizonEnabled || + !project.horizonAppId || + !project.horizonAppSecret + ) { + continue; + } + const probes = await ctx.runQuery( + internal.subscriptions.horizonInternal.listHorizonSubscriptions, + { projectId: project._id }, + ); + const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`; + + for (const probe of probes) { + checked += 1; + try { + const granted = await checkHorizonEntitlement({ + appId: project.horizonAppId, + appAccessToken, + userId: probe.userId, + sku: probe.sku, + }); + // Meta's response is binary: `success: true` means the user + // currently holds the entitlement. We map that to the same + // event types Apple/Google emit so the state machine / + // entitlements query don't need a Horizon-specific branch. + if (granted && probe.state !== "Active") { + await ctx.runMutation( + internal.subscriptions.horizonInternal.recordHorizonStatus, + { + projectId: project._id, + purchaseToken: probe.purchaseToken, + userId: probe.userId, + productId: probe.sku, + eventType: "SubscriptionRenewed", + }, + ); + transitioned += 1; + } else if (!granted && probe.state === "Active") { + await ctx.runMutation( + internal.subscriptions.horizonInternal.recordHorizonStatus, + { + projectId: project._id, + purchaseToken: probe.purchaseToken, + userId: probe.userId, + productId: probe.sku, + eventType: "SubscriptionExpired", + }, + ); + transitioned += 1; + } + } catch (error) { + failures += 1; + console.warn( + "[horizon-reconciler] check failed", + project._id, + probe.userId, + probe.sku, + error instanceof Error ? error.message : error, + ); + } + } + } + + return { checked, transitioned, failures }; + }, +}); + +// Manual one-off run trigger from the dashboard "Reconcile now" button +// or the MCP `openiap_troubleshoot` tool. Same handler as the cron +// path; just exposed under a public action for convenience. +export const reconcileHorizonNow = action({ + args: { apiKey: v.string() }, + returns: v.object({ + checked: v.number(), + transitioned: v.number(), + failures: v.number(), + }), + handler: async ( + ctx, + args, + ): Promise<{ + checked: number; + transitioned: number; + failures: number; + }> => { + const project = await ctx.runQuery( + internal.subscriptions.horizonInternal.getProjectByApiKey, + { apiKey: args.apiKey }, + ); + if (!project) throw new Error("Invalid API key"); + if ( + !project.horizonEnabled || + !project.horizonAppId || + !project.horizonAppSecret + ) { + throw new Error( + "Horizon is not configured for this project (set horizonEnabled + horizonAppId + horizonAppSecret in Settings).", + ); + } + const probes = await ctx.runQuery( + internal.subscriptions.horizonInternal.listHorizonSubscriptions, + { projectId: project._id }, + ); + const appAccessToken = `OC|${project.horizonAppId}|${project.horizonAppSecret}`; + + let checked = 0; + let transitioned = 0; + let failures = 0; + for (const probe of probes) { + checked += 1; + try { + const granted = await checkHorizonEntitlement({ + appId: project.horizonAppId, + appAccessToken, + userId: probe.userId, + sku: probe.sku, + }); + if (granted && probe.state !== "Active") { + await ctx.runMutation( + internal.subscriptions.horizonInternal.recordHorizonStatus, + { + projectId: project._id, + purchaseToken: probe.purchaseToken, + userId: probe.userId, + productId: probe.sku, + eventType: "SubscriptionRenewed", + }, + ); + transitioned += 1; + } else if (!granted && probe.state === "Active") { + await ctx.runMutation( + internal.subscriptions.horizonInternal.recordHorizonStatus, + { + projectId: project._id, + purchaseToken: probe.purchaseToken, + userId: probe.userId, + productId: probe.sku, + eventType: "SubscriptionExpired", + }, + ); + transitioned += 1; + } + } catch (error) { + failures += 1; + console.warn("[horizon-reconciler] check failed", error); + } + } + return { checked, transitioned, failures }; + }, +}); + +async function checkHorizonEntitlement(args: { + appId: string; + appAccessToken: string; + userId: string; + sku: string; +}): Promise { + const url = `${META_GRAPH_BASE}/${encodeURIComponent(args.appId)}/verify_entitlement`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + access_token: args.appAccessToken, + user_id: args.userId, + sku: args.sku, + }).toString(), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Meta Graph API ${res.status}: ${text.slice(0, 256)}`); + } + const body = (await res.json()) as { success?: boolean }; + return body.success === true; +} + +// Re-export with proper Id type usage so consumers in the same module +// graph compile cleanly even though we pass `Id<"projects">` around. +export type HorizonProjectId = Id<"projects">; +export type HorizonProbeRow = HorizonProbe; diff --git a/packages/kit/convex/subscriptions/horizonInternal.ts b/packages/kit/convex/subscriptions/horizonInternal.ts new file mode 100644 index 00000000..423902cf --- /dev/null +++ b/packages/kit/convex/subscriptions/horizonInternal.ts @@ -0,0 +1,154 @@ +import { internalMutation, internalQuery } from "../_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; + +import { + applySubscriptionTransition, + type CurrentSubscription, +} from "./stateMachine"; + +// Convex-runtime helpers used by the Horizon polling reconciler in +// `horizon.ts`. Kept separate so the action's "use node" boundary +// doesn't drag node-only imports into the regular Convex bundle. + +export const listHorizonProjects = internalQuery({ + args: {}, + returns: v.array( + v.object({ + _id: v.id("projects"), + horizonEnabled: v.optional(v.boolean()), + horizonAppId: v.optional(v.union(v.string(), v.null())), + horizonAppSecret: v.optional(v.union(v.string(), v.null())), + }), + ), + handler: async (ctx) => { + const all = await ctx.db.query("projects").collect(); + return all + .filter((project) => project.horizonEnabled === true) + .map((project) => ({ + _id: project._id, + horizonEnabled: project.horizonEnabled, + horizonAppId: project.horizonAppId, + horizonAppSecret: project.horizonAppSecret, + })); + }, +}); + +export const getProjectByApiKey = internalQuery({ + args: { apiKey: v.string() }, + returns: v.union( + v.null(), + v.object({ + _id: v.id("projects"), + horizonEnabled: v.optional(v.boolean()), + horizonAppId: v.optional(v.union(v.string(), v.null())), + horizonAppSecret: v.optional(v.union(v.string(), v.null())), + }), + ), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return null; + return { + _id: project._id, + horizonEnabled: project.horizonEnabled, + horizonAppId: project.horizonAppId, + horizonAppSecret: project.horizonAppSecret, + }; + }, +}); + +// All subscriptions for a Horizon project that might still mutate. +// Refunded/Revoked/Expired-with-no-renewal rows are excluded so the +// cron stays cheap as the historical archive grows. +export const listHorizonSubscriptions = internalQuery({ + args: { projectId: v.id("projects") }, + returns: v.array( + v.object({ + userId: v.string(), + sku: v.string(), + purchaseToken: v.string(), + state: v.string(), + }), + ), + handler: async (ctx, args) => { + const all = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_updated", (q) => + q.eq("projectId", args.projectId), + ) + .collect(); + return all + .filter((sub) => sub.platform === "Android") + .filter((sub) => !!sub.userId) + .filter( + (sub) => + sub.state === "Active" || + sub.state === "InGracePeriod" || + sub.state === "Paused" || + sub.state === "Unknown", + ) + .map((sub) => ({ + userId: sub.userId!, + sku: sub.productId, + purchaseToken: sub.purchaseToken, + state: sub.state, + })); + }, +}); + +// The reconciler hands us a synthetic "event" describing what Meta +// just told us. We funnel it through the same state-machine the +// webhook receivers use so transition semantics stay consistent. +export const recordHorizonStatus = internalMutation({ + args: { + projectId: v.id("projects"), + purchaseToken: v.string(), + userId: v.string(), + productId: v.string(), + eventType: v.union( + v.literal("SubscriptionRenewed"), + v.literal("SubscriptionExpired"), + ), + }, + returns: v.union(v.null(), v.id("subscriptions")), + handler: async (ctx, args) => { + const existing: Doc<"subscriptions"> | null = await ctx.db + .query("subscriptions") + .withIndex("by_project_and_token", (q) => + q + .eq("projectId", args.projectId) + .eq("purchaseToken", args.purchaseToken), + ) + .unique(); + if (!existing) return null; + + const current: CurrentSubscription = { + state: existing.state, + productId: existing.productId, + expiresAt: existing.expiresAt, + renewsAt: existing.renewsAt, + willRenew: existing.willRenew, + cancellationReason: existing.cancellationReason as + | NonNullable["cancellationReason"] + | undefined, + currency: existing.currency, + priceAmountMicros: existing.priceAmountMicros, + }; + const transition = applySubscriptionTransition(current, { + type: args.eventType, + productId: args.productId, + }); + if (!transition.next) return existing._id; + const now = Date.now(); + await ctx.db.patch(existing._id, { + state: transition.next.state, + willRenew: transition.next.willRenew, + cancellationReason: transition.next.cancellationReason, + updatedAt: now, + }); + return existing._id; + }, +}); diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts index 63518163..f4e0c14c 100644 --- a/packages/kit/server/api/v1/webhooks.ts +++ b/packages/kit/server/api/v1/webhooks.ts @@ -28,10 +28,39 @@ import { client, convexUrlForRealtime, handleConvexError } from "../../convex"; const webhooks = new Hono(); -webhooks.post("/apple/:apiKey", async (c) => { +// Unified lifecycle endpoint. The exact same URL works for both Apple +// App Store Connect and Google Pub/Sub push subscriptions: kit +// inspects the body shape to detect which store sent the +// notification, then dispatches to the same Convex action that the +// platform-specific paths use. +// +// Detection rules: +// - Apple ASN v2 payload: `{ "signedPayload": "" }` +// - Google Pub/Sub push: `{ "message": { "data": "", +// "messageId": "..." }, "subscription": "..." }` +// Anything else returns 400 INVALID_INPUT so misconfigured upstream +// senders fail loudly rather than silently being dropped. +// +// Why one URL: the comparison-table feedback was that exposing two +// "Apple URL" / "Google URL" copy boxes in the dashboard makes the +// hosted-backend pitch leakier than it needs to be. With one URL, +// the operator pastes the same string into App Store Connect AND +// Google Pub/Sub; whichever platform they haven't configured simply +// never sends traffic, and kit's per-platform receiver code only +// runs when its expected payload shape arrives. +const unifiedHandler = async (c: Context) => { const apiKey = c.req.param("apiKey"); - let body: { signedPayload?: string }; - + if (!apiKey) { + return c.json( + { + errors: [ + { code: "INVALID_INPUT", message: "Missing apiKey path segment" }, + ], + }, + 400, + ); + } + let body: unknown; try { body = await c.req.json(); } catch { @@ -41,6 +70,115 @@ webhooks.post("/apple/:apiKey", async (c) => { ); } + if (looksLikeApple(body)) { + const setup = await getSetupStatus(apiKey); + if (!setup.found) + return platformError(c, "INVALID_API_KEY", "Unknown apiKey"); + if (!setup.ios.configured) { + return platformError( + c, + "IOS_NOT_CONFIGURED", + `Apple ASN v2 received but iOS is not configured for this project. Missing: ${setup.ios.missing.join(", ")}.`, + ); + } + return handleAppleNotification( + c, + apiKey, + body as { signedPayload: string }, + ); + } + if (looksLikeGoogle(body)) { + const setup = await getSetupStatus(apiKey); + if (!setup.found) + return platformError(c, "INVALID_API_KEY", "Unknown apiKey"); + if (!setup.android.configured) { + return platformError( + c, + "ANDROID_NOT_CONFIGURED", + `Google RTDN received but Android is not configured for this project. Missing: ${setup.android.missing.join(", ")}.`, + ); + } + return handleGoogleNotification(c, apiKey, body as PubSubPushBody); + } + + return c.json( + { + errors: [ + { + code: "INVALID_INPUT", + message: + "Unrecognized payload. Expected Apple ASN v2 ({signedPayload}) or Google Pub/Sub ({message:{data,messageId}}).", + }, + ], + }, + 400, + ); +}; + +// Public — paste this URL into both App Store Connect and Google +// Pub/Sub push subscription configuration. +webhooks.post("/:apiKey", unifiedHandler); + +// Backwards-compatible aliases for operators who already configured a +// platform-specific URL. Both dispatch through the same handlers as +// the unified endpoint, so the dashboard / docs nudge users toward +// the one-URL pattern without breaking existing wiring. +webhooks.post("/apple/:apiKey", unifiedHandler); +webhooks.post("/google/:apiKey", unifiedHandler); + +type PubSubPushBody = { + message: { + data: string; + messageId: string; + publishTime?: string; + attributes?: Record; + }; + subscription?: string; +}; + +function looksLikeApple(body: unknown): boolean { + return ( + !!body && + typeof body === "object" && + "signedPayload" in body && + typeof (body as Record).signedPayload === "string" + ); +} + +function looksLikeGoogle(body: unknown): boolean { + if (!body || typeof body !== "object") return false; + const message = (body as { message?: unknown }).message; + if (!message || typeof message !== "object") return false; + const m = message as Record; + return typeof m.data === "string" && typeof m.messageId === "string"; +} + +// Surface a 412 Precondition Failed with a stable `code` so the +// dashboard / SDK can branch on it without parsing the message. +function platformError(c: Context, code: string, message: string) { + return c.json({ errors: [{ code, message }] }, 412); +} + +async function getSetupStatus(apiKey: string): Promise<{ + found: boolean; + ios: { configured: boolean; missing: string[] }; + android: { configured: boolean; missing: string[] }; +}> { + const status = (await client.query(api.projects.setupStatus.getSetupStatus, { + apiKey, + })) as { + found: boolean; + ios: { configured: boolean; missing: string[] }; + android: { configured: boolean; missing: string[] }; + }; + return status; +} + +async function handleAppleNotification( + c: Context, + apiKey: string, + body: { signedPayload: string }, +) { if (typeof body.signedPayload !== "string" || body.signedPayload.length < 1) { return c.json( { @@ -54,7 +192,6 @@ webhooks.post("/apple/:apiKey", async (c) => { 400, ); } - try { const result = await client.action(api.webhooks.apple.ingestAppleAsn, { apiKey, @@ -68,18 +205,19 @@ webhooks.post("/apple/:apiKey", async (c) => { } catch (error) { return mapWebhookError(c, error, "apple"); } -}); - -webhooks.post("/google/:apiKey", async (c) => { - const apiKey = c.req.param("apiKey"); +} - // Pub/Sub push always sends Authorization: Bearer . Apple-style - // unauthenticated path access is not appropriate here because Pub/Sub - // explicitly supports OIDC and skipping it leaves the endpoint - // spoofable. +async function handleGoogleNotification( + c: Context, + apiKey: string, + body: PubSubPushBody, +) { + // Pub/Sub push always sends `Authorization: Bearer ` when OIDC + // is configured on the subscription. We verify it when the operator + // has set GOOGLE_PUBSUB_PUSH_AUDIENCE; otherwise we skip strict + // checks (development / sandbox). const authHeader = c.req.header("authorization"); const audience = process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; - if (audience) { const ok = await verifyPubSubOidcToken(authHeader, audience); if (!ok) { @@ -97,40 +235,6 @@ webhooks.post("/google/:apiKey", async (c) => { } } - type PubSubPushBody = { - message?: { - data?: string; - messageId?: string; - publishTime?: string; - attributes?: Record; - }; - subscription?: string; - }; - - let body: PubSubPushBody; - try { - body = await c.req.json(); - } catch { - return c.json( - { errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }] }, - 400, - ); - } - - if (!body.message?.data || !body.message?.messageId) { - return c.json( - { - errors: [ - { - code: "INVALID_INPUT", - message: "Pub/Sub envelope missing message.data or messageId", - }, - ], - }, - 400, - ); - } - let decoded: Record; try { decoded = JSON.parse( @@ -197,7 +301,7 @@ webhooks.post("/google/:apiKey", async (c) => { } catch (error) { return mapWebhookError(c, error, "google"); } -}); +} // Server-Sent Events stream of normalized webhook events tied to the // caller's API key. Per-connection, we open a Convex `onUpdate` diff --git a/packages/kit/src/pages/auth/organization/project/webhooks.tsx b/packages/kit/src/pages/auth/organization/project/webhooks.tsx index a13e1d7e..15b3e67b 100644 --- a/packages/kit/src/pages/auth/organization/project/webhooks.tsx +++ b/packages/kit/src/pages/auth/organization/project/webhooks.tsx @@ -1,16 +1,28 @@ import { useOutletContext } from "react-router-dom"; import { useState } from "react"; -import { Webhook, Copy, ExternalLink } from "lucide-react"; +import { useQuery } from "convex/react"; +import { + Webhook, + Copy, + ExternalLink, + Check, + AlertTriangle, +} from "lucide-react"; import type { Doc } from "@/convex"; +import { api } from "@/convex"; type ProjectContext = { project: Doc<"projects"> }; export default function ProjectWebhooks() { const { project } = useOutletContext(); const baseUrl = window.location.origin; + const setup = useQuery(api.projects.setupStatus.getSetupStatus, { + apiKey: project.apiKey, + }); const urls = { + unified: `${baseUrl}/v1/webhooks/${encodeURIComponent(project.apiKey)}`, apple: `${baseUrl}/v1/webhooks/apple/${encodeURIComponent(project.apiKey)}`, google: `${baseUrl}/v1/webhooks/google/${encodeURIComponent(project.apiKey)}`, stream: `${baseUrl}/v1/webhooks/stream/${encodeURIComponent(project.apiKey)}`, @@ -24,41 +36,63 @@ export default function ProjectWebhooks() { Webhooks

- Register the URLs below with App Store Connect and Google Pub/Sub so - kit can ingest lifecycle notifications. Clients connect to the SSE - stream URL to receive normalized{" "} - WebhookEvents in real time. + One URL covers Apple ASN v2 and Google Pub/Sub RTDN — kit inspects the + payload shape and routes internally. Clients connect to the SSE stream + URL to receive normalized{" "} + WebhookEvents in real time. Platforms + you haven't configured simply produce no traffic; if a notification + arrives for an unconfigured platform, kit returns a precise{" "} + IOS_NOT_CONFIGURED /{" "} + ANDROID_NOT_CONFIGURED error so you + know exactly what's missing.

- - Paste this URL into App Store Connect → Apps → Your App → App - Information → App Store Server Notifications. Production + Sandbox - URLs are the same — kit reads the environment from the signed - payload. - - } - url={urls.apple} - external="https://developer.apple.com/documentation/appstoreservernotifications" - /> + {setup ? ( +
+ + + +
+ ) : null} - Configure a Pub/Sub topic in Google Cloud, attach a push - subscription pointing at this URL, and grant the topic publish - permission to your Play Console publisher account. kit verifies the - OIDC bearer token; set{" "} - GOOGLE_PUBSUB_PUSH_AUDIENCE in your - kit deployment to enable strict checks. + Paste this URL into both: +
    +
  • + App Store Connect → Apps → Your App → App Information → App + Store Server Notifications (Production + Sandbox). +
  • +
  • + Google Cloud Pub/Sub → Subscription → Push endpoint (then point + Play Console → Monetization setup → RTDN at the topic). +
  • +
+ + kit auto-detects the payload shape and dispatches to the right + verifier — Apple notifications signed with your{" "} + .p8 + Google Pub/Sub messages + with OIDC bearer. + } - url={urls.google} - external="https://developer.android.com/google/play/billing/rtdn-reference" + url={urls.unified} + external="https://developer.apple.com/documentation/appstoreservernotifications" /> +
+ + Advanced — platform-specific URLs (legacy) + +
+

+ These URLs accept only the matching platform's payload. Use the + unified URL above unless an upstream tool insists on a + store-prefixed path. +

+ + +
+
+
Live test

- POST a synthetic Pub/Sub test message to the Google receiver to verify + POST a synthetic Pub/Sub test message to the unified URL to verify wiring without going through the App Store / Play Console. The MCP server's openiap_simulate_webhook{" "} tool runs this same request.

{`curl -X POST \\
-  ${urls.google} \\
+  ${urls.unified} \\
   -H 'content-type: application/json' \\
   -d '{
     "message": {
-      "data": "${btoa(JSON.stringify({ packageName: project.androidPackageName ?? "com.example.app", eventTimeMillis: Date.now(), testNotification: { version: "1.0" } }))}",
+      "data": "${btoa(
+        JSON.stringify({
+          packageName: project.androidPackageName ?? "com.example.app",
+          eventTimeMillis: Date.now(),
+          testNotification: { version: "1.0" },
+        }),
+      )}",
       "messageId": "manual-test-${Date.now()}",
       "publishTime": "${new Date().toISOString()}"
     }
@@ -98,6 +161,39 @@ export default function ProjectWebhooks() {
   );
 }
 
+function SetupBadge({
+  label,
+  configured,
+  missing,
+}: {
+  label: string;
+  configured: boolean;
+  missing: string[];
+}) {
+  return (
+    
+
+ {configured ? ( + + ) : ( + + )} + {label} + + {configured ? "Ready" : "Not configured"} + +
+ {!configured && missing.length > 0 && ( +
+ Missing: {missing.join(", ")} +
+ )} +
+ ); +} + function UrlCard({ title, description, From 2b40899fbc43f92022fb494a6f1674b30c9b119c Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 15:30:24 +0900 Subject: [PATCH 08/81] =?UTF-8?q?fix(webhooks):=20address=20PR=20#123=20re?= =?UTF-8?q?view=20=E2=80=94=20RTDN=20code=20mappings=20+=20Apple=20price?= =?UTF-8?q?=20units=20+=20Promise=20return=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The closed PR #123 had 12 inline review comments from gemini-code-assist and coderabbitai. Fixing the substantive correctness issues here: RTDN numeric codes were swapped/incorrect: - Code 1 = SUBSCRIPTION_RECOVERED, code 4 = SUBSCRIPTION_PURCHASED (earlier draft had them reversed). Fixed in `convex/webhooks/shared.ts::GOOGLE_SUB_TYPE_MAP`, the unit-test expectations, the conformance scenarios, and `knowledge/external/webhook-mapping.md`. - Code 7 = SUBSCRIPTION_RESTARTED was incorrectly mapped to `SubscriptionRecovered`. RTDN docs define it as auto-renew re-enabled while the period is still active — that matches `SubscriptionUncanceled` semantics. Fixed in the map and added an explicit test case. - Code 11 = SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED had its enum documentation under `SubscriptionResumed`. RTDN actually fires this when the pause schedule is updated; the real resume comes back as RECOVERED (1). Moved the doc under `SubscriptionPaused` and updated `webhook.graphql` + the mapping table. - Code 19 = SUBSCRIPTION_PRICE_CHANGE_UPDATED added as alias for the existing PRICE_CHANGE_CONFIRMED. Apple price unit terminology was wrong: - Apple's `signedTransactionInfo.price` is in **milliunits** (1/1000 of a currency unit), not "millicents". $9.99 is 9990 milliunits. Multiplier to micros is 1000×, not 10×. - Fixed `normalizeAppleAsn` (price * 10 → price * 1000), the terminology + link comment, the test fixture (999_000 → 9_990), and the `webhook-mapping.md` formula. `webhookEventsSince` query missing Promise<> wrap: - `Query.webhookEventsSince` was generating as `webhookEventsSince: WebhookEvent[]` instead of `Promise`. The TS post-processor only wraps fields marked `# Future` in the schema and only scanned `api*.graphql` — `webhook.graphql` was excluded. - Added `# Future` comment in `webhook.graphql` and added `webhook.graphql` to `fix-generated-types.mjs`'s `schemaFiles`. Out of scope for this commit (deferred to follow-up): - Required-field fail-fast in generated `fromJson` / `from_dict` for Kotlin / Dart / GDScript / Swift. The codegen plugins currently default missing required fields to empty strings / zero / first enum value, which review correctly flagged as contract-violation hiding. Fixing requires plugin changes in `packages/gql/codegen/plugins/` for all four languages. Verification: - kit lint clean (0 errors); 281/281 vitest; smoke green. - gql 16/16 vitest; rn-iap 276/276 jest; expo-iap 46/46 jest. Co-Authored-By: Claude Opus 4.7 (1M context) --- knowledge/external/webhook-mapping.md | 25 ++++---- libraries/expo-iap/src/types.ts | 2 +- .../flutter_inapp_purchase/lib/types.dart | 15 +++-- libraries/godot-iap/addons/godot-iap/types.gd | 6 +- .../io/github/hyochan/kmpiap/openiap/Types.kt | 15 +++-- libraries/react-native-iap/src/types.ts | 2 +- packages/apple/Sources/Models/Types.swift | 15 +++-- .../src/main/java/dev/hyo/openiap/Types.kt | 15 +++-- packages/gql/scripts/fix-generated-types.mjs | 5 ++ packages/gql/src/generated/Types.kt | 15 +++-- packages/gql/src/generated/Types.swift | 15 +++-- packages/gql/src/generated/types.dart | 15 +++-- packages/gql/src/generated/types.gd | 6 +- packages/gql/src/generated/types.ts | 2 +- packages/gql/src/webhook.graphql | 16 +++-- .../kit/convex/webhooks/conformance.test.ts | 14 +++-- packages/kit/convex/webhooks/shared.test.ts | 20 ++++-- packages/kit/convex/webhooks/shared.ts | 62 ++++++++++++------- 18 files changed, 171 insertions(+), 94 deletions(-) diff --git a/knowledge/external/webhook-mapping.md b/knowledge/external/webhook-mapping.md index 21c40da6..aaba7398 100644 --- a/knowledge/external/webhook-mapping.md +++ b/knowledge/external/webhook-mapping.md @@ -12,24 +12,25 @@ document in the same PR. | openiap `WebhookEventType` | Apple ASN v2 `notificationType` (`subtype`) | Google RTDN `subscriptionNotification.notificationType` | |---|---|---| -| `SubscriptionStarted` | `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | `SUBSCRIPTION_PURCHASED` (1), `SUBSCRIPTION_RECOVERED` (4)¹ | +| `SubscriptionStarted` | `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | `SUBSCRIPTION_PURCHASED` (4) | | `SubscriptionRenewed` | `DID_RENEW` | `SUBSCRIPTION_RENEWED` (2) | | `SubscriptionExpired` | `EXPIRED` | `SUBSCRIPTION_EXPIRED` (13) | | `SubscriptionInGracePeriod` | `DID_FAIL_TO_RENEW` (`GRACE_PERIOD`) | `SUBSCRIPTION_IN_GRACE_PERIOD` (6) | | `SubscriptionInBillingRetry` | `DID_FAIL_TO_RENEW` (no subtype) | `SUBSCRIPTION_ON_HOLD` (5) | -| `SubscriptionRecovered` | `DID_RENEW` (after a prior failure) | `SUBSCRIPTION_RECOVERED` (4)¹, `SUBSCRIPTION_RESTARTED` (7) | +| `SubscriptionRecovered` | `DID_RENEW` (after a prior failure) | `SUBSCRIPTION_RECOVERED` (1) | | `SubscriptionCanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_DISABLED`) | `SUBSCRIPTION_CANCELED` (3) | -| `SubscriptionUncanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_ENABLED`) | (no direct equivalent — inferred from `SUBSCRIPTION_RESTARTED` while period still active) | +| `SubscriptionUncanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_ENABLED`) | `SUBSCRIPTION_RESTARTED` (7) — fired when auto-renew is re-enabled while the period is still active | | `SubscriptionRevoked` | `REVOKE` | `SUBSCRIPTION_REVOKED` (12) | -| `SubscriptionPriceChange` | `PRICE_INCREASE` | `SUBSCRIPTION_PRICE_CHANGE_CONFIRMED` (8) | -| `SubscriptionProductChanged` | `DID_CHANGE_RENEWAL_PREF` | `SUBSCRIPTION_DEFERRED` (9), `SUBSCRIPTION_PRODUCT_CHANGED` (no fixed code) | -| `SubscriptionPaused` | (no equivalent — iOS has no pause) | `SUBSCRIPTION_PAUSED` (10), `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED` (11) | -| `SubscriptionResumed` | (no equivalent) | `SUBSCRIPTION_RECOVERED` (4) following `SUBSCRIPTION_PAUSED` | +| `SubscriptionPriceChange` | `PRICE_INCREASE` | `SUBSCRIPTION_PRICE_CHANGE_CONFIRMED` (8), `SUBSCRIPTION_PRICE_CHANGE_UPDATED` (19) | +| `SubscriptionProductChanged` | `DID_CHANGE_RENEWAL_PREF` | `SUBSCRIPTION_DEFERRED` (9) | +| `SubscriptionPaused` | (no equivalent — iOS has no pause) | `SUBSCRIPTION_PAUSED` (10), `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED` (11) — schedule update, not actual resume | +| `SubscriptionResumed` | (no equivalent) | `SUBSCRIPTION_RECOVERED` (1) when fired after a `SUBSCRIPTION_PAUSED` — kit chooses Resumed vs Recovered based on the prior `subscriptions` row state | -¹ `SUBSCRIPTION_RECOVERED` (RTDN code 4) maps to either `SubscriptionStarted` -(when no prior active period existed) or `SubscriptionRecovered` (when recovering -from grace/hold/pause). kit decides based on the prior state in its -`subscriptions` table. +PR #123 review caught the earlier draft where codes 1 and 4 were swapped +(`SUBSCRIPTION_RECOVERED` is code 1, `SUBSCRIPTION_PURCHASED` is code 4) +and where `SUBSCRIPTION_RESTARTED` (7) was incorrectly mapped to +`SubscriptionRecovered` instead of `SubscriptionUncanceled`. The mapping +above reflects the corrected RTDN reference. ## One-time / common @@ -52,7 +53,7 @@ from grace/hold/pause). kit decides based on the prior state in its | `renewsAt` | `data.signedRenewalInfo.renewalDate` | resolved by calling `purchases.subscriptionsv2.get` | | `cancellationReason` | `data.signedTransactionInfo.revocationReason` + ASN `subtype` | `purchases.subscriptionsv2.get` → `canceledStateContext.userInitiatedCancellation` / `systemInitiatedCancellation` | | `currency` | `data.signedTransactionInfo.currency` | from `purchases.subscriptionsv2.get` linked product price | -| `priceAmountMicros` | `data.signedTransactionInfo.price` × 1000 (ASN reports in millicents; convert to micros) | `purchases.subscriptionsv2.get` → `lineItems[*].autoRenewingPlan.recurringPrice.units` | +| `priceAmountMicros` | `data.signedTransactionInfo.price` × 1000 (Apple's `price` field is in **milliunits** = 1/1000 of a currency unit; multiply by 1000 to convert to micros) | `purchases.subscriptionsv2.get` → `lineItems[*].autoRenewingPlan.recurringPrice` — `units * 1_000_000 + Math.round(nanos / 1000)` (Money type combines whole units + nanos = 10⁻⁹ units) | | `rawSignedPayload` | The complete `signedPayload` JWS string from the ASN body | The base64-decoded Pub/Sub message `data` (JSON) | ## Validation requirements (kit Phase 1, PR #2) diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index cc3408f3..e65ba5fa 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1411,7 +1411,7 @@ export interface Query { * timestamp. SDKs call this on reconnect / foreground entry to backfill events * that occurred while the WebSocket was closed. */ - webhookEventsSince: WebhookEvent[]; + webhookEventsSince: Promise; } diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index e9af67a7..c6b7231a 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -1070,7 +1070,8 @@ enum WebhookEventType { SubscriptionInBillingRetry('subscription-in-billing-retry'), /// Subscription returned to active state after a billing issue or pause. /// iOS: DID_RECOVER. - /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + /// renew re-enabled (Uncanceled), not billing recovery. SubscriptionRecovered('subscription-recovered'), /// User turned off auto-renew. Access continues until the current period ends. /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). @@ -1092,11 +1093,15 @@ enum WebhookEventType { /// iOS: DID_CHANGE_RENEWAL_PREF. /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. SubscriptionProductChanged('subscription-product-changed'), - /// Subscription paused (Android only feature). - /// Android: SUBSCRIPTION_PAUSED. + /// Subscription paused (Android only feature). Also fired when the + /// pause schedule is changed — RTDN does not have a separate signal. + /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). SubscriptionPaused('subscription-paused'), - /// Paused subscription resumed (Android only feature). - /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + /// Paused subscription resumed (Android only feature). RTDN signals + /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + /// resume. + /// Android: SUBSCRIPTION_RECOVERED (after pause). SubscriptionResumed('subscription-resumed'), /// Refund issued for a one-time purchase or subscription period. /// iOS: REFUND. diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 5e9c3ed3..89e9fd1b 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -337,7 +337,7 @@ enum WebhookEventType { SUBSCRIPTION_IN_GRACE_PERIOD = 3, ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD. SUBSCRIPTION_IN_BILLING_RETRY = 4, - ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- renew re-enabled (Uncanceled), not billing recovery. SUBSCRIPTION_RECOVERED = 5, ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED. SUBSCRIPTION_CANCELED = 6, @@ -349,9 +349,9 @@ enum WebhookEventType { SUBSCRIPTION_PRICE_CHANGE = 9, ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. SUBSCRIPTION_PRODUCT_CHANGED = 10, - ## Subscription paused (Android only feature). Android: SUBSCRIPTION_PAUSED. + ## Subscription paused (Android only feature). Also fired when the pause schedule is changed — RTDN does not have a separate signal. Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). SUBSCRIPTION_PAUSED = 11, - ## Paused subscription resumed (Android only feature). Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + ## Paused subscription resumed (Android only feature). RTDN signals resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the resume. Android: SUBSCRIPTION_RECOVERED (after pause). SUBSCRIPTION_RESUMED = 12, ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. PURCHASE_REFUNDED = 13, diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index a01bb837..5de0c60c 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -1209,7 +1209,8 @@ public enum class WebhookEventType(val rawValue: String) { /** * Subscription returned to active state after a billing issue or pause. * iOS: DID_RECOVER. - * Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + * Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + * renew re-enabled (Uncanceled), not billing recovery. */ SubscriptionRecovered("subscription-recovered"), /** @@ -1243,13 +1244,17 @@ public enum class WebhookEventType(val rawValue: String) { */ SubscriptionProductChanged("subscription-product-changed"), /** - * Subscription paused (Android only feature). - * Android: SUBSCRIPTION_PAUSED. + * Subscription paused (Android only feature). Also fired when the + * pause schedule is changed — RTDN does not have a separate signal. + * Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). */ SubscriptionPaused("subscription-paused"), /** - * Paused subscription resumed (Android only feature). - * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + * Paused subscription resumed (Android only feature). RTDN signals + * resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + * starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + * resume. + * Android: SUBSCRIPTION_RECOVERED (after pause). */ SubscriptionResumed("subscription-resumed"), /** diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index cc3408f3..e65ba5fa 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1411,7 +1411,7 @@ export interface Query { * timestamp. SDKs call this on reconnect / foreground entry to backfill events * that occurred while the WebSocket was closed. */ - webhookEventsSince: WebhookEvent[]; + webhookEventsSince: Promise; } diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 39962124..94e17afa 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -480,7 +480,8 @@ public enum WebhookEventType: String, Codable, CaseIterable { case subscriptionInBillingRetry = "subscription-in-billing-retry" /// Subscription returned to active state after a billing issue or pause. /// iOS: DID_RECOVER. - /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + /// renew re-enabled (Uncanceled), not billing recovery. case subscriptionRecovered = "subscription-recovered" /// User turned off auto-renew. Access continues until the current period ends. /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). @@ -502,11 +503,15 @@ public enum WebhookEventType: String, Codable, CaseIterable { /// iOS: DID_CHANGE_RENEWAL_PREF. /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. case subscriptionProductChanged = "subscription-product-changed" - /// Subscription paused (Android only feature). - /// Android: SUBSCRIPTION_PAUSED. + /// Subscription paused (Android only feature). Also fired when the + /// pause schedule is changed — RTDN does not have a separate signal. + /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). case subscriptionPaused = "subscription-paused" - /// Paused subscription resumed (Android only feature). - /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + /// Paused subscription resumed (Android only feature). RTDN signals + /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + /// resume. + /// Android: SUBSCRIPTION_RECOVERED (after pause). case subscriptionResumed = "subscription-resumed" /// Refund issued for a one-time purchase or subscription period. /// iOS: REFUND. diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 4a434163..ae898e94 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -1105,7 +1105,8 @@ public enum class WebhookEventType(val rawValue: String) { /** * Subscription returned to active state after a billing issue or pause. * iOS: DID_RECOVER. - * Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + * Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + * renew re-enabled (Uncanceled), not billing recovery. */ SubscriptionRecovered("subscription-recovered"), /** @@ -1139,13 +1140,17 @@ public enum class WebhookEventType(val rawValue: String) { */ SubscriptionProductChanged("subscription-product-changed"), /** - * Subscription paused (Android only feature). - * Android: SUBSCRIPTION_PAUSED. + * Subscription paused (Android only feature). Also fired when the + * pause schedule is changed — RTDN does not have a separate signal. + * Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). */ SubscriptionPaused("subscription-paused"), /** - * Paused subscription resumed (Android only feature). - * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + * Paused subscription resumed (Android only feature). RTDN signals + * resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + * starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + * resume. + * Android: SUBSCRIPTION_RECOVERED (after pause). */ SubscriptionResumed("subscription-resumed"), /** diff --git a/packages/gql/scripts/fix-generated-types.mjs b/packages/gql/scripts/fix-generated-types.mjs index 77cfafc5..6df6fef0 100644 --- a/packages/gql/scripts/fix-generated-types.mjs +++ b/packages/gql/scripts/fix-generated-types.mjs @@ -10,6 +10,11 @@ const schemaFiles = [ resolve(__dirname, '../src/api.graphql'), resolve(__dirname, '../src/api-ios.graphql'), resolve(__dirname, '../src/api-android.graphql'), + // webhook.graphql adds `webhookEventsSince` to the Query interface + // and marks it `# Future` so it gets the Promise<> wrap that all + // async query fields require. Without this entry, the marker would + // be silently ignored — caught in PR #123 review. + resolve(__dirname, '../src/webhook.graphql'), ]; const schemaDefinitionFiles = [ '../src/schema.graphql', diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index b27a04dc..191d4593 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -1207,7 +1207,8 @@ public enum class WebhookEventType(val rawValue: String) { /** * Subscription returned to active state after a billing issue or pause. * iOS: DID_RECOVER. - * Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + * Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + * renew re-enabled (Uncanceled), not billing recovery. */ SubscriptionRecovered("subscription-recovered"), /** @@ -1241,13 +1242,17 @@ public enum class WebhookEventType(val rawValue: String) { */ SubscriptionProductChanged("subscription-product-changed"), /** - * Subscription paused (Android only feature). - * Android: SUBSCRIPTION_PAUSED. + * Subscription paused (Android only feature). Also fired when the + * pause schedule is changed — RTDN does not have a separate signal. + * Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). */ SubscriptionPaused("subscription-paused"), /** - * Paused subscription resumed (Android only feature). - * Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + * Paused subscription resumed (Android only feature). RTDN signals + * resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + * starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + * resume. + * Android: SUBSCRIPTION_RECOVERED (after pause). */ SubscriptionResumed("subscription-resumed"), /** diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 39962124..94e17afa 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -480,7 +480,8 @@ public enum WebhookEventType: String, Codable, CaseIterable { case subscriptionInBillingRetry = "subscription-in-billing-retry" /// Subscription returned to active state after a billing issue or pause. /// iOS: DID_RECOVER. - /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + /// renew re-enabled (Uncanceled), not billing recovery. case subscriptionRecovered = "subscription-recovered" /// User turned off auto-renew. Access continues until the current period ends. /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). @@ -502,11 +503,15 @@ public enum WebhookEventType: String, Codable, CaseIterable { /// iOS: DID_CHANGE_RENEWAL_PREF. /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. case subscriptionProductChanged = "subscription-product-changed" - /// Subscription paused (Android only feature). - /// Android: SUBSCRIPTION_PAUSED. + /// Subscription paused (Android only feature). Also fired when the + /// pause schedule is changed — RTDN does not have a separate signal. + /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). case subscriptionPaused = "subscription-paused" - /// Paused subscription resumed (Android only feature). - /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + /// Paused subscription resumed (Android only feature). RTDN signals + /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + /// resume. + /// Android: SUBSCRIPTION_RECOVERED (after pause). case subscriptionResumed = "subscription-resumed" /// Refund issued for a one-time purchase or subscription period. /// iOS: REFUND. diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index e9af67a7..c6b7231a 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -1070,7 +1070,8 @@ enum WebhookEventType { SubscriptionInBillingRetry('subscription-in-billing-retry'), /// Subscription returned to active state after a billing issue or pause. /// iOS: DID_RECOVER. - /// Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + /// Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + /// renew re-enabled (Uncanceled), not billing recovery. SubscriptionRecovered('subscription-recovered'), /// User turned off auto-renew. Access continues until the current period ends. /// iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). @@ -1092,11 +1093,15 @@ enum WebhookEventType { /// iOS: DID_CHANGE_RENEWAL_PREF. /// Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. SubscriptionProductChanged('subscription-product-changed'), - /// Subscription paused (Android only feature). - /// Android: SUBSCRIPTION_PAUSED. + /// Subscription paused (Android only feature). Also fired when the + /// pause schedule is changed — RTDN does not have a separate signal. + /// Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). SubscriptionPaused('subscription-paused'), - /// Paused subscription resumed (Android only feature). - /// Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + /// Paused subscription resumed (Android only feature). RTDN signals + /// resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + /// starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + /// resume. + /// Android: SUBSCRIPTION_RECOVERED (after pause). SubscriptionResumed('subscription-resumed'), /// Refund issued for a one-time purchase or subscription period. /// iOS: REFUND. diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 5e9c3ed3..89e9fd1b 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -337,7 +337,7 @@ enum WebhookEventType { SUBSCRIPTION_IN_GRACE_PERIOD = 3, ## Billing failed and the subscription is in account-hold / billing retry, during which entitlement is paused but the subscription is not yet expired. iOS: DID_FAIL_TO_RENEW (no grace period; billing retry). Android: SUBSCRIPTION_ON_HOLD. SUBSCRIPTION_IN_BILLING_RETRY = 4, - ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + ## Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- renew re-enabled (Uncanceled), not billing recovery. SUBSCRIPTION_RECOVERED = 5, ## User turned off auto-renew. Access continues until the current period ends. iOS: DID_CHANGE_RENEWAL_STATUS (autoRenew turned off). Android: SUBSCRIPTION_CANCELED. SUBSCRIPTION_CANCELED = 6, @@ -349,9 +349,9 @@ enum WebhookEventType { SUBSCRIPTION_PRICE_CHANGE = 9, ## User upgraded, downgraded, or crossgraded their plan. iOS: DID_CHANGE_RENEWAL_PREF. Android: SUBSCRIPTION_DEFERRED / SUBSCRIPTION_PRODUCT_CHANGED. SUBSCRIPTION_PRODUCT_CHANGED = 10, - ## Subscription paused (Android only feature). Android: SUBSCRIPTION_PAUSED. + ## Subscription paused (Android only feature). Also fired when the pause schedule is changed — RTDN does not have a separate signal. Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). SUBSCRIPTION_PAUSED = 11, - ## Paused subscription resumed (Android only feature). Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + ## Paused subscription resumed (Android only feature). RTDN signals resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the resume. Android: SUBSCRIPTION_RECOVERED (after pause). SUBSCRIPTION_RESUMED = 12, ## Refund issued for a one-time purchase or subscription period. iOS: REFUND. Android: ONE_TIME_PRODUCT_REFUNDED / VOIDED_PURCHASE. PURCHASE_REFUNDED = 13, diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index cc3408f3..e65ba5fa 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1411,7 +1411,7 @@ export interface Query { * timestamp. SDKs call this on reconnect / foreground entry to backfill events * that occurred while the WebSocket was closed. */ - webhookEventsSince: WebhookEvent[]; + webhookEventsSince: Promise; } diff --git a/packages/gql/src/webhook.graphql b/packages/gql/src/webhook.graphql index 13692a2e..41426993 100644 --- a/packages/gql/src/webhook.graphql +++ b/packages/gql/src/webhook.graphql @@ -49,7 +49,8 @@ enum WebhookEventType { """ Subscription returned to active state after a billing issue or pause. iOS: DID_RECOVER. - Android: SUBSCRIPTION_RECOVERED / SUBSCRIPTION_RESTARTED. + Android: SUBSCRIPTION_RECOVERED (1) only — RESTARTED (7) is auto- + renew re-enabled (Uncanceled), not billing recovery. """ SubscriptionRecovered """ @@ -83,13 +84,17 @@ enum WebhookEventType { """ SubscriptionProductChanged """ - Subscription paused (Android only feature). - Android: SUBSCRIPTION_PAUSED. + Subscription paused (Android only feature). Also fired when the + pause schedule is changed — RTDN does not have a separate signal. + Android: SUBSCRIPTION_PAUSED (10), SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED (11). """ SubscriptionPaused """ - Paused subscription resumed (Android only feature). - Android: SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED / SUBSCRIPTION_RECOVERED from pause. + Paused subscription resumed (Android only feature). RTDN signals + resume via SUBSCRIPTION_RECOVERED (1) once the next billing cycle + starts; PAUSE_SCHEDULE_CHANGED is the schedule update, not the + resume. + Android: SUBSCRIPTION_RECOVERED (after pause). """ SubscriptionResumed """ @@ -232,5 +237,6 @@ extend type Query { timestamp. SDKs call this on reconnect / foreground entry to backfill events that occurred while the WebSocket was closed. """ + # Future webhookEventsSince(sinceMs: Float!, limit: Int): [WebhookEvent!]! } diff --git a/packages/kit/convex/webhooks/conformance.test.ts b/packages/kit/convex/webhooks/conformance.test.ts index 499743d7..7b103a35 100644 --- a/packages/kit/convex/webhooks/conformance.test.ts +++ b/packages/kit/convex/webhooks/conformance.test.ts @@ -221,7 +221,7 @@ describe("conformance: Google lifecycle scenarios", () => { it("purchase → renew → on-hold → recovered", () => { runGoogleScenario([ { - payload: googleSubPayload("g-1", 1, "tok-1"), + payload: googleSubPayload("g-1", 4, "tok-1"), subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, expect: { state: "Active", active: true }, }, @@ -236,7 +236,7 @@ describe("conformance: Google lifecycle scenarios", () => { expect: { state: "InBillingRetry", active: false }, }, { - payload: googleSubPayload("g-4", 4, "tok-1"), + payload: googleSubPayload("g-4", 1, "tok-1"), subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, expect: { state: "Active", active: true }, }, @@ -246,7 +246,7 @@ describe("conformance: Google lifecycle scenarios", () => { it("voided purchase flips to Refunded", () => { runGoogleScenario([ { - payload: googleSubPayload("v-1", 1, "tok-vp"), + payload: googleSubPayload("v-1", 4, "tok-vp"), subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, expect: { state: "Active", active: true }, }, @@ -264,7 +264,7 @@ describe("conformance: Google lifecycle scenarios", () => { it("paused → resumed", () => { runGoogleScenario([ { - payload: googleSubPayload("p-1", 1, "tok-p"), + payload: googleSubPayload("p-1", 4, "tok-p"), subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, expect: { state: "Active", active: true }, }, @@ -274,7 +274,11 @@ describe("conformance: Google lifecycle scenarios", () => { expect: { state: "Paused", active: false }, }, { - payload: googleSubPayload("p-3", 4, "tok-p"), + // Resume in real RTDN comes back as RECOVERED (1) — pause- + // schedule-changed (11) is only the schedule update, not the + // actual end-of-pause signal. PR #123 review caught the + // earlier draft mapping that treated 11 as Resumed. + payload: googleSubPayload("p-3", 1, "tok-p"), subscriptionInfo: { state: "SUBSCRIPTION_STATE_ACTIVE" }, expect: { state: "Active", active: true }, }, diff --git a/packages/kit/convex/webhooks/shared.test.ts b/packages/kit/convex/webhooks/shared.test.ts index d95c2a04..f4ec5532 100644 --- a/packages/kit/convex/webhooks/shared.test.ts +++ b/packages/kit/convex/webhooks/shared.test.ts @@ -106,8 +106,10 @@ const baseTransaction: AppleDecodedTransaction = { transactionId: "1000000000000099", productId: "com.example.premium_monthly", expiresDate: 1_713_592_000_000, - // ASN reports `price` in millicents (e.g. $9.99 → 999_000) - price: 999_000, + // ASN reports `price` in milliunits (1/1000 of a currency unit — + // $9.99 → 9990). Earlier draft mistakenly called these "millicents" + // and applied a 10× multiplier, which #123 review correctly flagged. + price: 9_990, currency: "USD", }; @@ -129,7 +131,7 @@ describe("normalizeAppleAsn", () => { expect(event.expiresAt).toBe(1_713_592_000_000); expect(event.renewsAt).toBe(1_713_592_000_000); expect(event.currency).toBe("USD"); - // 999_000 millicents = 9_990_000 micros ($9.99) + // 9_990 milliunits × 1000 = 9_990_000 micros ($9.99) expect(event.priceAmountMicros).toBe(9_990_000); expect(event.occurredAt).toBe(1_711_000_000_000); expect(event.sourceNotificationId).toBe("uuid-renew-1"); @@ -258,8 +260,13 @@ describe("normalizeAppleAsn", () => { describe("mapGoogleSubscriptionNotificationType", () => { it("maps the documented numeric codes to spec event types", () => { + // RTDN code reference: + // https://developer.android.com/google/play/billing/rtdn-reference#sub + // Codes 1 / 4 were swapped in an earlier draft (caught in PR #123 + // review). 1 = RECOVERED, 4 = PURCHASED. 7 = RESTARTED maps to + // Uncanceled (auto-renew re-enabled), not Started. expect(mapGoogleSubscriptionNotificationType(1)).toBe( - "SubscriptionStarted", + "SubscriptionRecovered", ); expect(mapGoogleSubscriptionNotificationType(2)).toBe( "SubscriptionRenewed", @@ -268,7 +275,10 @@ describe("mapGoogleSubscriptionNotificationType", () => { "SubscriptionCanceled", ); expect(mapGoogleSubscriptionNotificationType(4)).toBe( - "SubscriptionRecovered", + "SubscriptionStarted", + ); + expect(mapGoogleSubscriptionNotificationType(7)).toBe( + "SubscriptionUncanceled", ); expect(mapGoogleSubscriptionNotificationType(5)).toBe( "SubscriptionInBillingRetry", diff --git a/packages/kit/convex/webhooks/shared.ts b/packages/kit/convex/webhooks/shared.ts index 71709cfc..591ed42b 100644 --- a/packages/kit/convex/webhooks/shared.ts +++ b/packages/kit/convex/webhooks/shared.ts @@ -115,7 +115,11 @@ export type AppleDecodedTransaction = { expiresDate?: number | null; revocationReason?: number | null; currency?: string | null; - // ASN v2 reports `price` in millicents (price × 1000). + // ASN v2 reports `price` in **milliunits** — 1/1000 of a currency + // unit. Apple's docs use "milliunits" (NOT "millicents"). $9.99 is + // 9990 milliunits; convert to micros (1/1_000_000 of a unit) by + // multiplying by 1000. + // https://developer.apple.com/documentation/appstoreserverapi/jwstransactiondecodedpayload/price price?: number | null; }; @@ -336,11 +340,15 @@ export function normalizeAppleAsn( ); } - // Apple reports `price` in millicents (1/1000 of cent). openiap exposes - // micros to match Google's `priceAmountMicros`. millicents → micros is - // a 10× multiplier (1 millicent = 10 micros). + // Apple reports `price` in milliunits (1/1000 of a currency unit — + // see the note on AppleDecodedTransaction.price above). openiap + // exposes micros (1/1_000_000) to match Google's + // `priceAmountMicros` convention, so milliunits → micros is a 1000× + // multiplier (e.g. $9.99 → 9990 milliunits → 9_990_000 micros). const priceAmountMicros = - typeof transaction?.price === "number" ? transaction.price * 10 : undefined; + typeof transaction?.price === "number" + ? transaction.price * 1000 + : undefined; return { type, @@ -424,25 +432,33 @@ export type GoogleSubscriptionInfo = { priceAmountMicros?: number; }; +// RTDN numeric codes per +// https://developer.android.com/google/play/billing/rtdn-reference#sub +// Codes 1 and 4 were swapped in an earlier draft (caught in PR #123 +// review) — `1 = RECOVERED` and `4 = PURCHASED`. Code 7 = RESTARTED +// means the user re-enabled auto-renew while the subscription was +// still in its active period, which matches the +// `SubscriptionUncanceled` semantics, not `Started`. Code 11 = +// PAUSE_SCHEDULE_CHANGED fires when a pause is scheduled / changed, +// not on resume — collapsing it onto `Paused` keeps the event log +// honest (the actual end-of-pause appears as RENEWED/RECOVERED). const GOOGLE_SUB_TYPE_MAP: Record = { - 1: "SubscriptionStarted", // SUBSCRIPTION_RECOVERED handled below - 2: "SubscriptionRenewed", - 3: "SubscriptionCanceled", - 4: "SubscriptionRecovered", - 5: "SubscriptionInBillingRetry", - 6: "SubscriptionInGracePeriod", - 7: "SubscriptionStarted", // SUBSCRIPTION_RESTARTED — re-enabled auto-renew with active period - 8: "SubscriptionPriceChange", - 9: "SubscriptionProductChanged", - 10: "SubscriptionPaused", - 11: "SubscriptionPaused", - 12: "SubscriptionRevoked", - 13: "SubscriptionExpired", - // 14 = SUBSCRIPTION_PURCHASED maps to Started (newer RTDN code) - 14: "SubscriptionStarted", - // 15 = SUBSCRIPTION_PRODUCT_CHANGED (legacy) - 15: "SubscriptionProductChanged", - // 20 = SUBSCRIPTION_PENDING_PURCHASE_CANCELED — treated as canceled + 1: "SubscriptionRecovered", // SUBSCRIPTION_RECOVERED + 2: "SubscriptionRenewed", // SUBSCRIPTION_RENEWED + 3: "SubscriptionCanceled", // SUBSCRIPTION_CANCELED + 4: "SubscriptionStarted", // SUBSCRIPTION_PURCHASED + 5: "SubscriptionInBillingRetry", // SUBSCRIPTION_ON_HOLD + 6: "SubscriptionInGracePeriod", // SUBSCRIPTION_IN_GRACE_PERIOD + 7: "SubscriptionUncanceled", // SUBSCRIPTION_RESTARTED — auto-renew re-enabled + 8: "SubscriptionPriceChange", // SUBSCRIPTION_PRICE_CHANGE_CONFIRMED + 9: "SubscriptionProductChanged", // SUBSCRIPTION_DEFERRED + 10: "SubscriptionPaused", // SUBSCRIPTION_PAUSED + 11: "SubscriptionPaused", // SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED — schedule change, not resume + 12: "SubscriptionRevoked", // SUBSCRIPTION_REVOKED + 13: "SubscriptionExpired", // SUBSCRIPTION_EXPIRED + // 19 = SUBSCRIPTION_PRICE_CHANGE_UPDATED — alias for code 8 + 19: "SubscriptionPriceChange", + // 20 = SUBSCRIPTION_PENDING_PURCHASE_CANCELED 20: "SubscriptionCanceled", }; From ccafe0e4e244189eb032cc80257854110ededf57 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 15:51:10 +0900 Subject: [PATCH 09/81] fix(ci): drop webhook Subscription/Query from spec, fix KMP/Dart parsers, dedupe SubscriptionState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI checks were red on PR #124. Local verification + fixes for all three: 1. **Kit Typecheck + Build (Docker frozen-lockfile)**: bun.lock at the workspace root was out of sync with `packages/kit/package.json` after the recent `google-auth-library` add. Re-ran `bun install` so the root lock matches; verified locally with the same `bun install --frozen-lockfile --filter @hyodotdev/openiap-kit` the Docker step runs. 2. **kmp-iap Compile Check**: my `Subscription.webhookEvent` / `Query.webhookEventsSince` GraphQL fields meant the codegen made them required interface methods on the device-side IAP class, so `InAppPurchaseAndroid` failed to compile (missing `suspend fun webhookEventsSince` / `webhookEvent`). Plus my hand- written `WebhookEvent` data class collided with the generated `Types.kt` one. - Removed both fields from `webhook.graphql`. The webhook stream is a kit-server feature served over SSE (`/v1/webhooks/stream/{apiKey}`), not a GraphQL transport — the spec note in `webhook.graphql` documents the call-site contract instead. - Rewrote `WebhookClient.kt` to use the generated `WebhookEvent` data class + every enum from the generated `Types.kt`, with the parser falling back through the generated `fromJson` factories (KMP codegen emits PascalCase / SCREAMING_SNAKE / kebab-case aliases). 3. **Flutter Analyze (`ambiguous_export`)**: `SubscriptionState` was defined in both `lib/enums.dart` (hand-written legacy) and `lib/types.dart` (auto-generated from `webhook.graphql`). - Removed the hand-written enum from `enums.dart` (verified zero in-tree usages); the generated one is now the single source. - Rewrote `lib/webhook_client.dart` to use the generated `WebhookEvent.fromJson` with a fallback that rewrites enum fields by their `.name` to the codegen wire format (kebab-case). Drops the duplicated `WebhookEventTypeName` enum I had hand-defined. - Updated `test/webhook_client_test.dart` accordingly: unknown event types now correctly return null (PR #123 review's fail- fast expectation) instead of mapping to a synthetic `Unknown`. Cascading cleanup: - Updated `packages/docs/src/pages/docs/webhooks.tsx` Kotlin / Dart examples from `WebhookEventTypeName.subscriptionRenewed` to the generated `WebhookEventType.SubscriptionRenewed`. - Re-ran codegen + sync; generated `Types.swift` / `Types.kt` / `types.dart` / `types.gd` / `types.ts` no longer carry the webhook Query / Subscription typings. Local verification (matches CI): - kit lint clean (0 errors); 281/281 vitest; smoke green. - `bun install --frozen-lockfile --filter @hyodotdev/openiap-kit` clean. - KMP `./gradlew :library:compileDebugKotlinAndroid` BUILD SUCCESSFUL. - KMP `./gradlew :library:testDebugUnitTest` BUILD SUCCESSFUL. - Flutter `flutter analyze` no issues, `flutter test test/webhook_client_test.dart` 3/3 pass. - react-native-iap 276/276 jest, expo-iap 46/46 jest. Co-Authored-By: Claude Opus 4.7 (1M context) --- libraries/expo-iap/src/types.ts | 23 -- .../flutter_inapp_purchase/lib/enums.dart | 14 +- .../flutter_inapp_purchase/lib/types.dart | 24 -- .../lib/webhook_client.dart | 285 +++++++----------- .../test/webhook_client_test.dart | 14 +- libraries/godot-iap/addons/godot-iap/types.gd | 31 -- .../io/github/hyochan/kmpiap/openiap/Types.kt | 24 +- .../hyochan/kmpiap/openiap/WebhookClient.kt | 179 +++++------ .../kmpiap/openiap/WebhookClientTest.kt | 17 +- libraries/react-native-iap/src/types.ts | 23 -- packages/apple/Sources/Models/Types.swift | 24 +- packages/docs/src/pages/docs/webhooks.tsx | 4 +- .../src/main/java/dev/hyo/openiap/Types.kt | 24 +- packages/gql/src/generated/Types.kt | 24 +- packages/gql/src/generated/Types.swift | 24 +- packages/gql/src/generated/types.dart | 24 -- packages/gql/src/generated/types.gd | 31 -- packages/gql/src/generated/types.ts | 23 -- packages/gql/src/webhook.graphql | 33 +- 19 files changed, 222 insertions(+), 623 deletions(-) diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index e65ba5fa..f4357f5b 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -1406,12 +1406,6 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; - /** - * Replay missed webhook events for the authenticated client since the given - * timestamp. SDKs call this on reconnect / foreground entry to backfill events - * that occurred while the WebSocket was closed. - */ - webhookEventsSince: Promise; } @@ -1440,11 +1434,6 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; -export interface QueryWebhookEventsSinceArgs { - limit?: (number | null); - sinceMs: number; -} - export interface RefundResultIOS { message?: (string | null); status: string; @@ -1762,16 +1751,6 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; - /** - * Streams normalized webhook events tied to the authenticated client's purchases. - * Clients only receive events whose `purchaseToken` matches a purchase they own. - * - * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - * enters foreground and disconnect when it goes to background. Events that fire - * while the connection is closed are reconciled via `webhookEventsSince` on - * reconnect or the next foreground entry. - */ - webhookEvent: WebhookEvent; } @@ -2172,7 +2151,6 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; - webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2237,7 +2215,6 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; - webhookEvent: never; }; export type SubscriptionField = diff --git a/libraries/flutter_inapp_purchase/lib/enums.dart b/libraries/flutter_inapp_purchase/lib/enums.dart index bb520a0a..24d0648f 100644 --- a/libraries/flutter_inapp_purchase/lib/enums.dart +++ b/libraries/flutter_inapp_purchase/lib/enums.dart @@ -7,14 +7,12 @@ enum Store { none, playStore, amazon, appStore } /// Platform detection enum enum IapPlatform { ios, android } -/// Subscription states -enum SubscriptionState { - active, - expired, - inBillingRetry, - inGracePeriod, - revoked, -} +// `SubscriptionState` was previously hand-defined here. It now comes +// from the generated `lib/types.dart` (synced from +// `packages/gql/src/webhook.graphql`) so the values stay in lock- +// step with the openiap webhook spec — `Active / InGracePeriod / +// InBillingRetry / Expired / Revoked / Refunded / Paused / Unknown`. +// Importing both copies caused an `ambiguous_export` analyzer error. /// Transaction states enum TransactionState { purchasing, purchased, failed, restored, deferred } diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index c6b7231a..b7d48b56 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -5411,13 +5411,6 @@ abstract class QueryResolver { VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); - /// Replay missed webhook events for the authenticated client since the given - /// timestamp. SDKs call this on reconnect / foreground entry to backfill events - /// that occurred while the WebSocket was closed. - Future> webhookEventsSince({ - required double sinceMs, - int? limit, - }); } /// GraphQL root subscription operations. @@ -5449,14 +5442,6 @@ abstract class SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing Future userChoiceBillingAndroid(); - /// Streams normalized webhook events tied to the authenticated client's purchases. - /// Clients only receive events whose `purchaseToken` matches a purchase they own. - /// - /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - /// enters foreground and disconnect when it goes to background. Events that fire - /// while the connection is closed are reconciled via `webhookEventsSince` on - /// reconnect or the next foreground entry. - Future webhookEvent(); } // MARK: - Root Operation Helpers @@ -5607,10 +5592,6 @@ typedef QueryValidateReceiptIOSHandler = Future Functio VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); -typedef QueryWebhookEventsSinceHandler = Future> Function({ - required double sinceMs, - int? limit, -}); class QueryHandlers { const QueryHandlers({ @@ -5635,7 +5616,6 @@ class QueryHandlers { this.latestTransactionIOS, this.subscriptionStatusIOS, this.validateReceiptIOS, - this.webhookEventsSince, }); final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; @@ -5659,7 +5639,6 @@ class QueryHandlers { final QueryLatestTransactionIOSHandler? latestTransactionIOS; final QuerySubscriptionStatusIOSHandler? subscriptionStatusIOS; final QueryValidateReceiptIOSHandler? validateReceiptIOS; - final QueryWebhookEventsSinceHandler? webhookEventsSince; } // MARK: - Subscription Helpers @@ -5670,7 +5649,6 @@ typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function(); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); -typedef SubscriptionWebhookEventHandler = Future Function(); class SubscriptionHandlers { const SubscriptionHandlers({ @@ -5680,7 +5658,6 @@ class SubscriptionHandlers { this.purchaseUpdated, this.subscriptionBillingIssue, this.userChoiceBillingAndroid, - this.webhookEvent, }); final SubscriptionDeveloperProvidedBillingAndroidHandler? developerProvidedBillingAndroid; @@ -5689,5 +5666,4 @@ class SubscriptionHandlers { final SubscriptionPurchaseUpdatedHandler? purchaseUpdated; final SubscriptionSubscriptionBillingIssueHandler? subscriptionBillingIssue; final SubscriptionUserChoiceBillingAndroidHandler? userChoiceBillingAndroid; - final SubscriptionWebhookEventHandler? webhookEvent; } diff --git a/libraries/flutter_inapp_purchase/lib/webhook_client.dart b/libraries/flutter_inapp_purchase/lib/webhook_client.dart index f6d7f45d..8c51022d 100644 --- a/libraries/flutter_inapp_purchase/lib/webhook_client.dart +++ b/libraries/flutter_inapp_purchase/lib/webhook_client.dart @@ -2,12 +2,19 @@ // (`GET /v1/webhooks/stream/{apiKey}`). // // Wire format mirrors the canonical TypeScript implementation in -// `packages/gql/src/webhook-client.ts`. The `WebhookEvent` shape comes -// from `packages/gql/src/webhook.graphql` (and is sync-generated into -// `lib/types.dart`). +// `packages/gql/src/webhook-client.ts`. The `WebhookEvent` value type +// + enums (`WebhookEventType`, `WebhookEventSource`, `IapPlatform`, +// `SubscriptionState`, `WebhookEventEnvironment`, +// `WebhookCancellationReason`) come from the generated +// `lib/types.dart` (synced from `packages/gql/src/webhook.graphql`), +// so this file only adds: // -// Why a hand-rolled HTTP/SSE parser instead of an http SSE package: -// the parser is small (~80 lines), matches the openiap project's +// - `parseWebhookEventData` — pure JSON-string → WebhookEvent +// - `connectWebhookStream` — long-lived HTTP+SSE listener with +// auto-reconnect via `Last-Event-ID` +// +// Why a hand-rolled SSE parser instead of an http SSE package: the +// parser is small (~80 lines), matches the openiap project's // preference for not pulling extra Dart packages into the platform // SDKs, and gives us total control of the reconnect cadence which is // what end-of-period billing flows actually depend on. @@ -16,154 +23,101 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -/// Possible webhook event kinds. Mirrors the GraphQL -/// `WebhookEventType` enum in `packages/gql/src/webhook.graphql`. -enum WebhookEventTypeName { - subscriptionStarted, - subscriptionRenewed, - subscriptionExpired, - subscriptionInGracePeriod, - subscriptionInBillingRetry, - subscriptionRecovered, - subscriptionCanceled, - subscriptionUncanceled, - subscriptionRevoked, - subscriptionPriceChange, - subscriptionProductChanged, - subscriptionPaused, - subscriptionResumed, - purchaseRefunded, - purchaseConsumptionRequest, - testNotification, - unknown, -} +import 'types.dart'; -WebhookEventTypeName _parseEventTypeName(String? raw) { - switch (raw) { - case 'SubscriptionStarted': - return WebhookEventTypeName.subscriptionStarted; - case 'SubscriptionRenewed': - return WebhookEventTypeName.subscriptionRenewed; - case 'SubscriptionExpired': - return WebhookEventTypeName.subscriptionExpired; - case 'SubscriptionInGracePeriod': - return WebhookEventTypeName.subscriptionInGracePeriod; - case 'SubscriptionInBillingRetry': - return WebhookEventTypeName.subscriptionInBillingRetry; - case 'SubscriptionRecovered': - return WebhookEventTypeName.subscriptionRecovered; - case 'SubscriptionCanceled': - return WebhookEventTypeName.subscriptionCanceled; - case 'SubscriptionUncanceled': - return WebhookEventTypeName.subscriptionUncanceled; - case 'SubscriptionRevoked': - return WebhookEventTypeName.subscriptionRevoked; - case 'SubscriptionPriceChange': - return WebhookEventTypeName.subscriptionPriceChange; - case 'SubscriptionProductChanged': - return WebhookEventTypeName.subscriptionProductChanged; - case 'SubscriptionPaused': - return WebhookEventTypeName.subscriptionPaused; - case 'SubscriptionResumed': - return WebhookEventTypeName.subscriptionResumed; - case 'PurchaseRefunded': - return WebhookEventTypeName.purchaseRefunded; - case 'PurchaseConsumptionRequest': - return WebhookEventTypeName.purchaseConsumptionRequest; - case 'TestNotification': - return WebhookEventTypeName.testNotification; - default: - return WebhookEventTypeName.unknown; +/// Pure parser exported for tests so the SSE-frame → `WebhookEvent` +/// path can be validated without spinning up a real HTTP listener. +WebhookEvent? parseWebhookEventData(String raw) { + if (raw.isEmpty) return null; + Map? decoded; + try { + final value = jsonDecode(raw); + if (value is Map) decoded = value; + } catch (_) { + return null; + } + if (decoded == null) return null; + if (!decoded.containsKey('id') || + !decoded.containsKey('type') || + !decoded.containsKey('purchaseToken') || + !decoded.containsKey('occurredAt') || + !decoded.containsKey('receivedAt')) { + return null; } + // The wire format kit currently emits uses GraphQL enum identifiers + // (PascalCase, e.g. `AppleAppStoreServerNotificationsV2`). The + // generated Dart `fromJson` factories only accept the kebab-case + // wire form (`apple-app-store-server-notifications-v2`). Normalize + // each enum field here so consumers don't have to know about the + // representational difference. PR #123 review caught this drift. + return _decodeWithFallback(decoded); } -/// A normalized webhook event delivered by the kit SSE stream. -class WebhookEvent { - WebhookEvent({ - required this.id, - required this.type, - required this.rawType, - required this.source, - required this.platform, - required this.environment, - required this.projectId, - required this.occurredAt, - required this.receivedAt, - required this.purchaseToken, - required this.raw, - this.productId, - this.subscriptionState, - this.expiresAt, - this.renewsAt, - this.cancellationReason, - this.currency, - this.priceAmountMicros, - this.rawSignedPayload, - }); - - /// Stable identifier — matches `notificationUUID` (Apple) / - /// `messageId` (Google). - final String id; - final WebhookEventTypeName type; - - /// Raw `type` string as delivered on the wire. Useful when the spec - /// adds new types ahead of the SDK enum. - final String rawType; - final String source; - final String platform; - final String environment; - final String projectId; - final int occurredAt; - final int receivedAt; - final String purchaseToken; - final String? productId; - final String? subscriptionState; - final int? expiresAt; - final int? renewsAt; - final String? cancellationReason; - final String? currency; - final int? priceAmountMicros; - final String? rawSignedPayload; - - /// Parsed JSON for fields outside the strongly-typed surface. - final Map raw; - - static WebhookEvent? tryParse(Map raw) { - final id = raw['id']; - final type = raw['type']; - final purchaseToken = raw['purchaseToken']; - final occurredAt = raw['occurredAt']; - final receivedAt = raw['receivedAt']; - - if (id is! String || - type is! String || - purchaseToken is! String || - occurredAt is! num || - receivedAt is! num) { +WebhookEvent? _decodeWithFallback(Map json) { + try { + return WebhookEvent.fromJson(json); + } catch (_) { + // The kebab-case `fromJson` rejected one or more enum values; try + // again after rewriting the source/type/platform/environment/state + // /cancellationReason fields to their kebab-case equivalents. + final mapped = Map.of(json); + _rewriteEnumByName( + mapped, + 'type', + WebhookEventType.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'source', + WebhookEventSource.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'platform', + IapPlatform.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'environment', + WebhookEventEnvironment.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'subscriptionState', + SubscriptionState.values, + (e) => e.value, + ); + _rewriteEnumByName( + mapped, + 'cancellationReason', + WebhookCancellationReason.values, + (e) => e.value, + ); + try { + return WebhookEvent.fromJson(mapped); + } catch (_) { return null; } + } +} - return WebhookEvent( - id: id, - type: _parseEventTypeName(type), - rawType: type, - source: raw['source']?.toString() ?? '', - platform: raw['platform']?.toString() ?? '', - environment: raw['environment']?.toString() ?? '', - projectId: raw['projectId']?.toString() ?? '', - occurredAt: occurredAt.toInt(), - receivedAt: receivedAt.toInt(), - purchaseToken: purchaseToken, - productId: raw['productId'] as String?, - subscriptionState: raw['subscriptionState'] as String?, - expiresAt: (raw['expiresAt'] as num?)?.toInt(), - renewsAt: (raw['renewsAt'] as num?)?.toInt(), - cancellationReason: raw['cancellationReason'] as String?, - currency: raw['currency'] as String?, - priceAmountMicros: (raw['priceAmountMicros'] as num?)?.toInt(), - rawSignedPayload: raw['rawSignedPayload'] as String?, - raw: raw, - ); +void _rewriteEnumByName( + Map json, + String field, + List values, + String Function(T) toWire, +) { + final raw = json[field]; + if (raw is! String) return; + for (final value in values) { + if (value.name == raw) { + json[field] = toWire(value); + return; + } } } @@ -246,8 +200,9 @@ class _SseWebhookListener implements WebhookListener { } Future _runOnce() async { - final trimmed = - baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl; + final trimmed = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; final uri = Uri.parse( '$trimmed/v1/webhooks/stream/${Uri.encodeComponent(apiKey)}', ); @@ -341,29 +296,12 @@ class _SseWebhookListener implements WebhookListener { if (dataStr.isEmpty) return; if (eventName == 'heartbeat' || eventName == 'ready') return; - Map? decoded; - try { - final value = jsonDecode(dataStr); - if (value is Map) { - decoded = value; - } - } catch (error) { - _errors.add( - WebhookListenerError( - 'PARSE_ERROR', - 'Failed to parse SSE payload: $error', - ), - ); - return; - } - if (decoded == null) return; - - final event = WebhookEvent.tryParse(decoded); + final event = parseWebhookEventData(dataStr); if (event == null) { _errors.add( WebhookListenerError( 'MALFORMED_EVENT', - 'WebhookEvent missing required fields', + 'WebhookEvent missing required fields or unknown type', ), ); return; @@ -392,16 +330,3 @@ WebhookListener connectWebhookStream({ listener.start(); return listener; } - -// Pure helper exposed for tests so we can validate the parser -// without spinning up a real HTTP listener. -WebhookEvent? parseWebhookEventData(String raw) { - if (raw.isEmpty) return null; - try { - final decoded = jsonDecode(raw); - if (decoded is! Map) return null; - return WebhookEvent.tryParse(decoded); - } catch (_) { - return null; - } -} diff --git a/libraries/flutter_inapp_purchase/test/webhook_client_test.dart b/libraries/flutter_inapp_purchase/test/webhook_client_test.dart index f3cd3ce8..dd3a998e 100644 --- a/libraries/flutter_inapp_purchase/test/webhook_client_test.dart +++ b/libraries/flutter_inapp_purchase/test/webhook_client_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter_inapp_purchase/types.dart'; import 'package:flutter_inapp_purchase/webhook_client.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -21,7 +22,7 @@ void main() { }); final event = parseWebhookEventData(raw)!; expect(event.id, 'uuid-1'); - expect(event.type, WebhookEventTypeName.subscriptionRenewed); + expect(event.type, WebhookEventType.SubscriptionRenewed); expect(event.purchaseToken, 'token-1'); expect(event.productId, 'com.example.premium'); }); @@ -36,7 +37,12 @@ void main() { ); }); - test('falls back to unknown for event types beyond the spec', () { + test('rejects payloads with unknown event types', () { + // PR #123 review: lenient mapping to a synthetic `Unknown` enum + // hides spec drift between kit and the SDK consumers. Generated + // `WebhookEventType.fromJson` throws for unknown values; the + // parser catches that and returns null so the SSE listener can + // surface MALFORMED_EVENT instead of emitting a synthetic row. final raw = jsonEncode({ 'id': 'uuid-2', 'type': 'SomethingNew', @@ -48,9 +54,7 @@ void main() { 'receivedAt': 2, 'purchaseToken': 't', }); - final event = parseWebhookEventData(raw)!; - expect(event.type, WebhookEventTypeName.unknown); - expect(event.rawType, 'SomethingNew'); + expect(parseWebhookEventData(raw), isNull); }); }); } diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 89e9fd1b..94b1a966 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -5434,30 +5434,6 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false - ## Replay missed webhook events for the authenticated client since the given - class webhookEventsSinceField: - const name = "webhookEventsSince" - const snake_name = "webhook_events_since" - class Args: - var since_ms: float - var limit: int - - static func from_dict(data: Dictionary) -> Args: - var obj = Args.new() - if data.has("sinceMs") and data["sinceMs"] != null: - obj.since_ms = data["sinceMs"] - if data.has("limit") and data["limit"] != null: - obj.limit = data["limit"] - return obj - - func to_dict() -> Dictionary: - var dict = {} - dict["sinceMs"] = since_ms - dict["limit"] = limit - return dict - const return_type = "WebhookEvent" - const is_array = true - # ============================================================================ # Mutation Types @@ -6025,13 +6001,6 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args -## Replay missed webhook events for the authenticated client since the given -static func webhook_events_since_args(since_ms: float, limit: int) -> Dictionary: - var args = {} - args["sinceMs"] = since_ms - args["limit"] = limit - return args - # Mutation API helpers ## Initialize the store connection. Call before any IAP API. diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index 5de0c60c..b5ec6387 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -5513,12 +5513,6 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS - /** - * Replay missed webhook events for the authenticated client since the given - * timestamp. SDKs call this on reconnect / foreground entry to backfill events - * that occurred while the WebSocket was closed. - */ - suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5564,16 +5558,6 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails - /** - * Streams normalized webhook events tied to the authenticated client's purchases. - * Clients only receive events whose `purchaseToken` matches a purchase they own. - * - * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - * enters foreground and disconnect when it goes to background. Events that fire - * while the connection is closed are reconciled via `webhookEventsSince` on - * reconnect or the next foreground entry. - */ - suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5659,7 +5643,6 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS -public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5682,8 +5665,7 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, - val webhookEventsSince: QueryWebhookEventsSinceHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null ) // MARK: - Subscription Helpers @@ -5694,7 +5676,6 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails -public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5702,6 +5683,5 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, - val webhookEvent: SubscriptionWebhookEventHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null ) diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt index 03426398..c4817235 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt @@ -3,92 +3,25 @@ package io.github.hyochan.kmpiap.openiap import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull /** - * Pure parser + value types for the openiap kit SSE webhook stream + * Pure parser for the openiap kit SSE webhook stream * (`GET /v1/webhooks/stream/{apiKey}`). * - * Wire format mirrors the canonical TypeScript implementation in - * `packages/gql/src/webhook-client.ts`. The `WebhookEvent` shape comes - * from `packages/gql/src/webhook.graphql`. - * - * The transport (an actual HTTP+SSE client) is intentionally not in - * commonMain — KMP doesn't ship a stdlib HTTP client. Consumers wire - * a transport per target (e.g. OkHttp on Android, NSURLSession on - * iOS) and feed each parsed JSON frame to [WebhookEventParser.parse]. - * That keeps this module's dependency surface flat (no Ktor) while - * still giving every target the same parser + types. + * The `WebhookEvent` data class + every enum used here come from the + * generated `Types.kt` (synced from `packages/gql/src/webhook.graphql`). + * This file only adds the SSE-frame → `WebhookEvent` parser and the + * URL builder. The transport (an actual HTTP+SSE client) lives in + * the per-target source sets — `androidMain/WebhookTransport.android.kt`, + * `iosMain/WebhookTransport.ios.kt` — because KMP doesn't ship a + * stdlib HTTP client and we don't want to pull in Ktor. */ -enum class WebhookEventTypeName { - SubscriptionStarted, - SubscriptionRenewed, - SubscriptionExpired, - SubscriptionInGracePeriod, - SubscriptionInBillingRetry, - SubscriptionRecovered, - SubscriptionCanceled, - SubscriptionUncanceled, - SubscriptionRevoked, - SubscriptionPriceChange, - SubscriptionProductChanged, - SubscriptionPaused, - SubscriptionResumed, - PurchaseRefunded, - PurchaseConsumptionRequest, - TestNotification, - Unknown; - - companion object { - fun fromRaw(raw: String?): WebhookEventTypeName = when (raw) { - "SubscriptionStarted" -> SubscriptionStarted - "SubscriptionRenewed" -> SubscriptionRenewed - "SubscriptionExpired" -> SubscriptionExpired - "SubscriptionInGracePeriod" -> SubscriptionInGracePeriod - "SubscriptionInBillingRetry" -> SubscriptionInBillingRetry - "SubscriptionRecovered" -> SubscriptionRecovered - "SubscriptionCanceled" -> SubscriptionCanceled - "SubscriptionUncanceled" -> SubscriptionUncanceled - "SubscriptionRevoked" -> SubscriptionRevoked - "SubscriptionPriceChange" -> SubscriptionPriceChange - "SubscriptionProductChanged" -> SubscriptionProductChanged - "SubscriptionPaused" -> SubscriptionPaused - "SubscriptionResumed" -> SubscriptionResumed - "PurchaseRefunded" -> PurchaseRefunded - "PurchaseConsumptionRequest" -> PurchaseConsumptionRequest - "TestNotification" -> TestNotification - else -> Unknown - } - } -} - -data class WebhookEvent( - val id: String, - val type: WebhookEventTypeName, - val rawType: String, - val source: String, - val platform: String, - val environment: String, - val projectId: String, - val occurredAt: Long, - val receivedAt: Long, - val purchaseToken: String, - val productId: String? = null, - val subscriptionState: String? = null, - val expiresAt: Long? = null, - val renewsAt: Long? = null, - val cancellationReason: String? = null, - val currency: String? = null, - val priceAmountMicros: Long? = null, - val rawSignedPayload: String? = null, - val raw: JsonObject, -) - object WebhookEventParser { private val json = Json { ignoreUnknownKeys = true; isLenient = true } @@ -109,44 +42,70 @@ object WebhookEventParser { } fun fromJson(element: JsonObject): WebhookEvent? { - val id = element["id"]?.jsonPrimitive?.contentOrNull ?: return null - val type = element["type"]?.jsonPrimitive?.contentOrNull ?: return null - val purchaseToken = - element["purchaseToken"]?.jsonPrimitive?.contentOrNull ?: return null - val occurredAt = element["occurredAt"]?.jsonPrimitive?.longOrNull - ?: return null - val receivedAt = element["receivedAt"]?.jsonPrimitive?.longOrNull - ?: return null + return try { + val id = element["id"]?.jsonPrimitive?.contentOrNull ?: return null + val typeRaw = element["type"]?.jsonPrimitive?.contentOrNull + ?: return null + val purchaseToken = + element["purchaseToken"]?.jsonPrimitive?.contentOrNull + ?: return null + val occurredAt = + element["occurredAt"]?.jsonPrimitive?.numericOrNull() + ?: return null + val receivedAt = + element["receivedAt"]?.jsonPrimitive?.numericOrNull() + ?: return null + val environmentRaw = + element["environment"]?.jsonPrimitive?.contentOrNull + ?: return null + val platformRaw = + element["platform"]?.jsonPrimitive?.contentOrNull ?: return null + val sourceRaw = + element["source"]?.jsonPrimitive?.contentOrNull ?: return null - return WebhookEvent( - id = id, - type = WebhookEventTypeName.fromRaw(type), - rawType = type, - source = element["source"]?.jsonPrimitive?.contentOrNull ?: "", - platform = element["platform"]?.jsonPrimitive?.contentOrNull ?: "", - environment = element["environment"]?.jsonPrimitive?.contentOrNull ?: "", - projectId = element["projectId"]?.jsonPrimitive?.contentOrNull ?: "", - occurredAt = occurredAt, - receivedAt = receivedAt, - purchaseToken = purchaseToken, - productId = element["productId"]?.jsonPrimitive?.contentOrNull, - subscriptionState = - element["subscriptionState"]?.jsonPrimitive?.contentOrNull, - expiresAt = element["expiresAt"]?.jsonPrimitive?.longOrNull, - renewsAt = element["renewsAt"]?.jsonPrimitive?.longOrNull, - cancellationReason = - element["cancellationReason"]?.jsonPrimitive?.contentOrNull, - currency = element["currency"]?.jsonPrimitive?.contentOrNull, - priceAmountMicros = - element["priceAmountMicros"]?.jsonPrimitive?.longOrNull - ?: element["priceAmountMicros"]?.jsonPrimitive?.intOrNull?.toLong(), - rawSignedPayload = - element["rawSignedPayload"]?.jsonPrimitive?.contentOrNull, - raw = element, - ) + // The generated `fromJson` companion factories throw on + // unknown enum values. We catch the throw at the outer + // level (PR #123 review: prefer fail-fast over silently + // mapping unknown types to a synthetic `Unknown` value). + WebhookEvent( + cancellationReason = + element["cancellationReason"]?.jsonPrimitive?.contentOrNull?.let { + WebhookCancellationReason.fromJson(it) + }, + currency = element["currency"]?.jsonPrimitive?.contentOrNull, + environment = WebhookEventEnvironment.fromJson(environmentRaw), + expiresAt = element["expiresAt"]?.jsonPrimitive?.numericOrNull(), + id = id, + occurredAt = occurredAt, + platform = IapPlatform.fromJson(platformRaw), + priceAmountMicros = + element["priceAmountMicros"]?.jsonPrimitive?.numericOrNull(), + productId = element["productId"]?.jsonPrimitive?.contentOrNull, + projectId = + element["projectId"]?.jsonPrimitive?.contentOrNull ?: "", + purchaseToken = purchaseToken, + rawSignedPayload = + element["rawSignedPayload"]?.jsonPrimitive?.contentOrNull, + receivedAt = receivedAt, + renewsAt = element["renewsAt"]?.jsonPrimitive?.numericOrNull(), + source = WebhookEventSource.fromJson(sourceRaw), + subscriptionState = + element["subscriptionState"]?.jsonPrimitive?.contentOrNull?.let { + SubscriptionState.fromJson(it) + }, + type = WebhookEventType.fromJson(typeRaw), + ) + } catch (_: Throwable) { + // Fail-fast → null lets the SSE listener surface + // MALFORMED_EVENT instead of emitting a half-decoded event. + null + } } } +private fun JsonPrimitive.numericOrNull(): Double? = + content.toDoubleOrNull() ?: longOrNull?.toDouble() ?: intOrNull?.toDouble() + /** * Endpoint URL for the kit SSE stream. Kept on the type so callers * don't reimplement the path layout in each transport. diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt index 3d22e57a..7bb2cf19 100644 --- a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClientTest.kt @@ -27,23 +27,29 @@ class WebhookClientTest { val event = WebhookEventParser.parse(raw) assertNotNull(event) assertEquals("uuid-1", event.id) - assertEquals(WebhookEventTypeName.SubscriptionRenewed, event.type) + assertEquals(WebhookEventType.SubscriptionRenewed, event.type) assertEquals("token-1", event.purchaseToken) assertEquals("com.example.premium", event.productId) - assertEquals(1_711_000_000_000L, event.occurredAt) + assertEquals(1_711_000_000_000.0, event.occurredAt) } @Test fun returnsNullForEmptyOrMalformedInput() { assertNull(WebhookEventParser.parse("")) assertNull(WebhookEventParser.parse("not json")) + // Required fields missing → fail-fast (PR #123 review fix: + // we no longer silently default to empty strings). assertNull( WebhookEventParser.parse("""{"type":"SubscriptionRenewed"}"""), ) } @Test - fun fallsBackToUnknownForUnseenEventTypes() { + fun returnsNullForUnseenEventTypes() { + // Unknown event types are now rejected rather than mapped to a + // synthetic `Unknown` enum value — PR #123 review correctly + // flagged that lenient parsing hides spec drift between kit + // and the SDK consumers. val raw = """ { "id": "uuid-2", @@ -57,10 +63,7 @@ class WebhookClientTest { "purchaseToken": "t" } """.trimIndent() - val event = WebhookEventParser.parse(raw) - assertNotNull(event) - assertEquals(WebhookEventTypeName.Unknown, event.type) - assertEquals("SomethingNew", event.rawType) + assertNull(WebhookEventParser.parse(raw)) } @Test diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index e65ba5fa..f4357f5b 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -1406,12 +1406,6 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; - /** - * Replay missed webhook events for the authenticated client since the given - * timestamp. SDKs call this on reconnect / foreground entry to backfill events - * that occurred while the WebSocket was closed. - */ - webhookEventsSince: Promise; } @@ -1440,11 +1434,6 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; -export interface QueryWebhookEventsSinceArgs { - limit?: (number | null); - sinceMs: number; -} - export interface RefundResultIOS { message?: (string | null); status: string; @@ -1762,16 +1751,6 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; - /** - * Streams normalized webhook events tied to the authenticated client's purchases. - * Clients only receive events whose `purchaseToken` matches a purchase they own. - * - * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - * enters foreground and disconnect when it goes to background. Events that fire - * while the connection is closed are reconciled via `webhookEventsSince` on - * reconnect or the next foreground entry. - */ - webhookEvent: WebhookEvent; } @@ -2172,7 +2151,6 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; - webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2237,7 +2215,6 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; - webhookEvent: never; }; export type SubscriptionField = diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 94e17afa..18059500 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -2668,10 +2668,6 @@ public protocol QueryResolver { /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS - /// Replay missed webhook events for the authenticated client since the given - /// timestamp. SDKs call this on reconnect / foreground entry to backfill events - /// that occurred while the WebSocket was closed. - func webhookEventsSince(sinceMs: Double, limit: Int?) async throws -> [WebhookEvent] } /// GraphQL root subscription operations. @@ -2703,14 +2699,6 @@ public protocol SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails - /// Streams normalized webhook events tied to the authenticated client's purchases. - /// Clients only receive events whose `purchaseToken` matches a purchase they own. - /// - /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - /// enters foreground and disconnect when it goes to background. Events that fire - /// while the connection is closed are reconciled via `webhookEventsSince` on - /// reconnect or the next foreground entry. - func webhookEvent() async throws -> WebhookEvent } // MARK: - Root Operation Helpers @@ -2852,7 +2840,6 @@ public typealias QueryIsTransactionVerifiedIOSHandler = (_ sku: String) async th public typealias QueryLatestTransactionIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throws -> [SubscriptionStatusIOS] public typealias QueryValidateReceiptIOSHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS -public typealias QueryWebhookEventsSinceHandler = (_ sinceMs: Double, _ limit: Int?) async throws -> [WebhookEvent] public struct QueryHandlers { public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? @@ -2876,7 +2863,6 @@ public struct QueryHandlers { public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? public var validateReceiptIOS: QueryValidateReceiptIOSHandler? - public var webhookEventsSince: QueryWebhookEventsSinceHandler? public init( canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil, @@ -2899,8 +2885,7 @@ public struct QueryHandlers { isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = nil, latestTransactionIOS: QueryLatestTransactionIOSHandler? = nil, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil, - validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil, - webhookEventsSince: QueryWebhookEventsSinceHandler? = nil + validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil ) { self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS self.currentEntitlementIOS = currentEntitlementIOS @@ -2923,7 +2908,6 @@ public struct QueryHandlers { self.latestTransactionIOS = latestTransactionIOS self.subscriptionStatusIOS = subscriptionStatusIOS self.validateReceiptIOS = validateReceiptIOS - self.webhookEventsSince = webhookEventsSince } } @@ -2935,7 +2919,6 @@ public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseE public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails -public typealias SubscriptionWebhookEventHandler = () async throws -> WebhookEvent public struct SubscriptionHandlers { public var developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? @@ -2944,7 +2927,6 @@ public struct SubscriptionHandlers { public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? public var subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? - public var webhookEvent: SubscriptionWebhookEventHandler? public init( developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = nil, @@ -2952,8 +2934,7 @@ public struct SubscriptionHandlers { purchaseError: SubscriptionPurchaseErrorHandler? = nil, purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = nil, - userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil, - webhookEvent: SubscriptionWebhookEventHandler? = nil + userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil ) { self.developerProvidedBillingAndroid = developerProvidedBillingAndroid self.promotedProductIOS = promotedProductIOS @@ -2961,6 +2942,5 @@ public struct SubscriptionHandlers { self.purchaseUpdated = purchaseUpdated self.subscriptionBillingIssue = subscriptionBillingIssue self.userChoiceBillingAndroid = userChoiceBillingAndroid - self.webhookEvent = webhookEvent } } diff --git a/packages/docs/src/pages/docs/webhooks.tsx b/packages/docs/src/pages/docs/webhooks.tsx index 58b148cc..fdeed464 100644 --- a/packages/docs/src/pages/docs/webhooks.tsx +++ b/packages/docs/src/pages/docs/webhooks.tsx @@ -128,7 +128,7 @@ const { events, lastError, isConnected } = useWebhookEvents({ final listener = connectWebhookStream(apiKey: 'sk_live_...'); listener.events.listen((event) { - if (event.type == WebhookEventTypeName.subscriptionRenewed) { + if (event.type == WebhookEventType.SubscriptionRenewed) { grantEntitlement(event.purchaseToken); } });`} @@ -142,7 +142,7 @@ import io.github.hyochan.kmpiap.openiap.webhookStreamUrl // data frame to WebhookEventParser.parse(). val event = WebhookEventParser.parse(rawJson) ?: return when (event.type) { - WebhookEventTypeName.SubscriptionRenewed -> grantEntitlement(event.purchaseToken) + WebhookEventType.SubscriptionRenewed -> grantEntitlement(event.purchaseToken) else -> Unit }`} ), diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index ae898e94..bae9df73 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -5393,12 +5393,6 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS - /** - * Replay missed webhook events for the authenticated client since the given - * timestamp. SDKs call this on reconnect / foreground entry to backfill events - * that occurred while the WebSocket was closed. - */ - suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5444,16 +5438,6 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails - /** - * Streams normalized webhook events tied to the authenticated client's purchases. - * Clients only receive events whose `purchaseToken` matches a purchase they own. - * - * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - * enters foreground and disconnect when it goes to background. Events that fire - * while the connection is closed are reconciled via `webhookEventsSince` on - * reconnect or the next foreground entry. - */ - suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5539,7 +5523,6 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS -public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5562,8 +5545,7 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, - val webhookEventsSince: QueryWebhookEventsSinceHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null ) // MARK: - Subscription Helpers @@ -5574,7 +5556,6 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails -public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5582,6 +5563,5 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, - val webhookEvent: SubscriptionWebhookEventHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null ) diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index 191d4593..0504c989 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -5511,12 +5511,6 @@ public interface QueryResolver { * See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios */ suspend fun validateReceiptIOS(options: VerifyPurchaseProps): VerifyPurchaseResultIOS - /** - * Replay missed webhook events for the authenticated client since the given - * timestamp. SDKs call this on reconnect / foreground entry to backfill events - * that occurred while the WebSocket was closed. - */ - suspend fun webhookEventsSince(sinceMs: Double, limit: Int? = null): List } /** @@ -5562,16 +5556,6 @@ public interface SubscriptionResolver { * Only triggered when the user selects alternative billing instead of Google Play billing */ suspend fun userChoiceBillingAndroid(): UserChoiceBillingDetails - /** - * Streams normalized webhook events tied to the authenticated client's purchases. - * Clients only receive events whose `purchaseToken` matches a purchase they own. - * - * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - * enters foreground and disconnect when it goes to background. Events that fire - * while the connection is closed are reconciled via `webhookEventsSince` on - * reconnect or the next foreground entry. - */ - suspend fun webhookEvent(): WebhookEvent } // MARK: - Root Operation Helpers @@ -5657,7 +5641,6 @@ public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List public typealias QueryValidateReceiptIOSHandler = suspend (options: VerifyPurchaseProps) -> VerifyPurchaseResultIOS -public typealias QueryWebhookEventsSinceHandler = suspend (sinceMs: Double, limit: Int?) -> List public data class QueryHandlers( val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, @@ -5680,8 +5663,7 @@ public data class QueryHandlers( val isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, val latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, val subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, - val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null, - val webhookEventsSince: QueryWebhookEventsSinceHandler? = null + val validateReceiptIOS: QueryValidateReceiptIOSHandler? = null ) // MARK: - Subscription Helpers @@ -5692,7 +5674,6 @@ public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails -public typealias SubscriptionWebhookEventHandler = suspend () -> WebhookEvent public data class SubscriptionHandlers( val developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = null, @@ -5700,6 +5681,5 @@ public data class SubscriptionHandlers( val purchaseError: SubscriptionPurchaseErrorHandler? = null, val purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = null, val subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = null, - val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null, - val webhookEvent: SubscriptionWebhookEventHandler? = null + val userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = null ) diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 94e17afa..18059500 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -2668,10 +2668,6 @@ public protocol QueryResolver { /// Deprecated. Legacy App Store receipt validation — use verifyPurchase instead. /// See: https://www.openiap.dev/docs/apis/ios/validate-receipt-ios func validateReceiptIOS(_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS - /// Replay missed webhook events for the authenticated client since the given - /// timestamp. SDKs call this on reconnect / foreground entry to backfill events - /// that occurred while the WebSocket was closed. - func webhookEventsSince(sinceMs: Double, limit: Int?) async throws -> [WebhookEvent] } /// GraphQL root subscription operations. @@ -2703,14 +2699,6 @@ public protocol SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing func userChoiceBillingAndroid() async throws -> UserChoiceBillingDetails - /// Streams normalized webhook events tied to the authenticated client's purchases. - /// Clients only receive events whose `purchaseToken` matches a purchase they own. - /// - /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - /// enters foreground and disconnect when it goes to background. Events that fire - /// while the connection is closed are reconciled via `webhookEventsSince` on - /// reconnect or the next foreground entry. - func webhookEvent() async throws -> WebhookEvent } // MARK: - Root Operation Helpers @@ -2852,7 +2840,6 @@ public typealias QueryIsTransactionVerifiedIOSHandler = (_ sku: String) async th public typealias QueryLatestTransactionIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throws -> [SubscriptionStatusIOS] public typealias QueryValidateReceiptIOSHandler = (_ options: VerifyPurchaseProps) async throws -> VerifyPurchaseResultIOS -public typealias QueryWebhookEventsSinceHandler = (_ sinceMs: Double, _ limit: Int?) async throws -> [WebhookEvent] public struct QueryHandlers { public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? @@ -2876,7 +2863,6 @@ public struct QueryHandlers { public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? public var validateReceiptIOS: QueryValidateReceiptIOSHandler? - public var webhookEventsSince: QueryWebhookEventsSinceHandler? public init( canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = nil, @@ -2899,8 +2885,7 @@ public struct QueryHandlers { isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = nil, latestTransactionIOS: QueryLatestTransactionIOSHandler? = nil, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil, - validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil, - webhookEventsSince: QueryWebhookEventsSinceHandler? = nil + validateReceiptIOS: QueryValidateReceiptIOSHandler? = nil ) { self.canPresentExternalPurchaseNoticeIOS = canPresentExternalPurchaseNoticeIOS self.currentEntitlementIOS = currentEntitlementIOS @@ -2923,7 +2908,6 @@ public struct QueryHandlers { self.latestTransactionIOS = latestTransactionIOS self.subscriptionStatusIOS = subscriptionStatusIOS self.validateReceiptIOS = validateReceiptIOS - self.webhookEventsSince = webhookEventsSince } } @@ -2935,7 +2919,6 @@ public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseE public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails -public typealias SubscriptionWebhookEventHandler = () async throws -> WebhookEvent public struct SubscriptionHandlers { public var developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? @@ -2944,7 +2927,6 @@ public struct SubscriptionHandlers { public var purchaseUpdated: SubscriptionPurchaseUpdatedHandler? public var subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? public var userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? - public var webhookEvent: SubscriptionWebhookEventHandler? public init( developerProvidedBillingAndroid: SubscriptionDeveloperProvidedBillingAndroidHandler? = nil, @@ -2952,8 +2934,7 @@ public struct SubscriptionHandlers { purchaseError: SubscriptionPurchaseErrorHandler? = nil, purchaseUpdated: SubscriptionPurchaseUpdatedHandler? = nil, subscriptionBillingIssue: SubscriptionSubscriptionBillingIssueHandler? = nil, - userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil, - webhookEvent: SubscriptionWebhookEventHandler? = nil + userChoiceBillingAndroid: SubscriptionUserChoiceBillingAndroidHandler? = nil ) { self.developerProvidedBillingAndroid = developerProvidedBillingAndroid self.promotedProductIOS = promotedProductIOS @@ -2961,6 +2942,5 @@ public struct SubscriptionHandlers { self.purchaseUpdated = purchaseUpdated self.subscriptionBillingIssue = subscriptionBillingIssue self.userChoiceBillingAndroid = userChoiceBillingAndroid - self.webhookEvent = webhookEvent } } diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index c6b7231a..b7d48b56 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -5411,13 +5411,6 @@ abstract class QueryResolver { VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); - /// Replay missed webhook events for the authenticated client since the given - /// timestamp. SDKs call this on reconnect / foreground entry to backfill events - /// that occurred while the WebSocket was closed. - Future> webhookEventsSince({ - required double sinceMs, - int? limit, - }); } /// GraphQL root subscription operations. @@ -5449,14 +5442,6 @@ abstract class SubscriptionResolver { /// Fires when a user selects alternative billing in the User Choice Billing dialog (Android only) /// Only triggered when the user selects alternative billing instead of Google Play billing Future userChoiceBillingAndroid(); - /// Streams normalized webhook events tied to the authenticated client's purchases. - /// Clients only receive events whose `purchaseToken` matches a purchase they own. - /// - /// Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - /// enters foreground and disconnect when it goes to background. Events that fire - /// while the connection is closed are reconciled via `webhookEventsSince` on - /// reconnect or the next foreground entry. - Future webhookEvent(); } // MARK: - Root Operation Helpers @@ -5607,10 +5592,6 @@ typedef QueryValidateReceiptIOSHandler = Future Functio VerifyPurchaseGoogleOptions? google, VerifyPurchaseHorizonOptions? horizon, }); -typedef QueryWebhookEventsSinceHandler = Future> Function({ - required double sinceMs, - int? limit, -}); class QueryHandlers { const QueryHandlers({ @@ -5635,7 +5616,6 @@ class QueryHandlers { this.latestTransactionIOS, this.subscriptionStatusIOS, this.validateReceiptIOS, - this.webhookEventsSince, }); final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; @@ -5659,7 +5639,6 @@ class QueryHandlers { final QueryLatestTransactionIOSHandler? latestTransactionIOS; final QuerySubscriptionStatusIOSHandler? subscriptionStatusIOS; final QueryValidateReceiptIOSHandler? validateReceiptIOS; - final QueryWebhookEventsSinceHandler? webhookEventsSince; } // MARK: - Subscription Helpers @@ -5670,7 +5649,6 @@ typedef SubscriptionPurchaseErrorHandler = Future Function(); typedef SubscriptionPurchaseUpdatedHandler = Future Function(); typedef SubscriptionSubscriptionBillingIssueHandler = Future Function(); typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function(); -typedef SubscriptionWebhookEventHandler = Future Function(); class SubscriptionHandlers { const SubscriptionHandlers({ @@ -5680,7 +5658,6 @@ class SubscriptionHandlers { this.purchaseUpdated, this.subscriptionBillingIssue, this.userChoiceBillingAndroid, - this.webhookEvent, }); final SubscriptionDeveloperProvidedBillingAndroidHandler? developerProvidedBillingAndroid; @@ -5689,5 +5666,4 @@ class SubscriptionHandlers { final SubscriptionPurchaseUpdatedHandler? purchaseUpdated; final SubscriptionSubscriptionBillingIssueHandler? subscriptionBillingIssue; final SubscriptionUserChoiceBillingAndroidHandler? userChoiceBillingAndroid; - final SubscriptionWebhookEventHandler? webhookEvent; } diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 89e9fd1b..94b1a966 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -5434,30 +5434,6 @@ class Query: const return_type = "VerifyPurchaseResultIOS" const is_array = false - ## Replay missed webhook events for the authenticated client since the given - class webhookEventsSinceField: - const name = "webhookEventsSince" - const snake_name = "webhook_events_since" - class Args: - var since_ms: float - var limit: int - - static func from_dict(data: Dictionary) -> Args: - var obj = Args.new() - if data.has("sinceMs") and data["sinceMs"] != null: - obj.since_ms = data["sinceMs"] - if data.has("limit") and data["limit"] != null: - obj.limit = data["limit"] - return obj - - func to_dict() -> Dictionary: - var dict = {} - dict["sinceMs"] = since_ms - dict["limit"] = limit - return dict - const return_type = "WebhookEvent" - const is_array = true - # ============================================================================ # Mutation Types @@ -6025,13 +6001,6 @@ static func validate_receipt_ios_args(options: VerifyPurchaseProps) -> Dictionar args["options"] = options return args -## Replay missed webhook events for the authenticated client since the given -static func webhook_events_since_args(since_ms: float, limit: int) -> Dictionary: - var args = {} - args["sinceMs"] = since_ms - args["limit"] = limit - return args - # Mutation API helpers ## Initialize the store connection. Call before any IAP API. diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index e65ba5fa..f4357f5b 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -1406,12 +1406,6 @@ export interface Query { * @deprecated Use verifyPurchase */ validateReceiptIOS: Promise; - /** - * Replay missed webhook events for the authenticated client since the given - * timestamp. SDKs call this on reconnect / foreground entry to backfill events - * that occurred while the WebSocket was closed. - */ - webhookEventsSince: Promise; } @@ -1440,11 +1434,6 @@ export type QuerySubscriptionStatusIosArgs = string; export type QueryValidateReceiptIosArgs = VerifyPurchaseProps; -export interface QueryWebhookEventsSinceArgs { - limit?: (number | null); - sinceMs: number; -} - export interface RefundResultIOS { message?: (string | null); status: string; @@ -1762,16 +1751,6 @@ export interface Subscription { * Only triggered when the user selects alternative billing instead of Google Play billing */ userChoiceBillingAndroid: UserChoiceBillingDetails; - /** - * Streams normalized webhook events tied to the authenticated client's purchases. - * Clients only receive events whose `purchaseToken` matches a purchase they own. - * - * Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - * enters foreground and disconnect when it goes to background. Events that fire - * while the connection is closed are reconciled via `webhookEventsSince` on - * reconnect or the next foreground entry. - */ - webhookEvent: WebhookEvent; } @@ -2172,7 +2151,6 @@ export type QueryArgsMap = { latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; validateReceiptIOS: QueryValidateReceiptIosArgs; - webhookEventsSince: QueryWebhookEventsSinceArgs; }; export type QueryField = @@ -2237,7 +2215,6 @@ export type SubscriptionArgsMap = { purchaseUpdated: never; subscriptionBillingIssue: never; userChoiceBillingAndroid: never; - webhookEvent: never; }; export type SubscriptionField = diff --git a/packages/gql/src/webhook.graphql b/packages/gql/src/webhook.graphql index 41426993..09e6cde0 100644 --- a/packages/gql/src/webhook.graphql +++ b/packages/gql/src/webhook.graphql @@ -218,25 +218,14 @@ type WebhookEvent { rawSignedPayload: String } -extend type Subscription { - """ - Streams normalized webhook events tied to the authenticated client's purchases. - Clients only receive events whose `purchaseToken` matches a purchase they own. - - Transport: kit serves this over WebSocket. SDKs auto-connect when the host app - enters foreground and disconnect when it goes to background. Events that fire - while the connection is closed are reconciled via `webhookEventsSince` on - reconnect or the next foreground entry. - """ - webhookEvent: WebhookEvent! -} - -extend type Query { - """ - Replay missed webhook events for the authenticated client since the given - timestamp. SDKs call this on reconnect / foreground entry to backfill events - that occurred while the WebSocket was closed. - """ - # Future - webhookEventsSince(sinceMs: Float!, limit: Int): [WebhookEvent!]! -} +# Note: webhook event transport is intentionally NOT modeled as a +# GraphQL Subscription / Query field on this spec. The streaming +# endpoint is a kit-server concern delivered over SSE +# (`GET /v1/webhooks/stream/{apiKey}`) — see the per-SDK +# `webhook-client` helpers — and the backfill is a Convex query +# internal to kit that reads `webhookEvents`. Exposing them here +# would force every device-side IAP implementation to declare a +# `webhookEvent` listener / `webhookEventsSince` method even though +# the actual transport is HTTP, not GraphQL. PR #123 review caught +# the earlier draft surfacing them as required interface methods on +# the KMP / Dart / Swift IAP classes. From 8068adb6dab0871abf0f54477c8239216bd85d41 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 15:56:39 +0900 Subject: [PATCH 10/81] chore: pin bun@1.3.13 + extend pre-commit gate to flutter / kmp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of CI guardrail so the same kind of green-locally-red-in-CI loop doesn't repeat: 1. **bun version pin**: PR #124's repeated lockfile-frozen failures in the kit Docker step were because local bun was 1.3.0 while the Docker image pins 1.3.13 — bun lockfiles aren't stable across versions, so the locally-passing `--frozen-lockfile` would still fail in Docker. - Bumped `package.json` `packageManager` to `bun@1.3.13`. - Regenerated root `bun.lock` with bun 1.3.13. - Added a pre-commit guard that compares `bun --version` against the pinned version and refuses to commit on mismatch with a one-liner pointing at `bun upgrade`. 2. **pre-commit path-aware extensions**: - `libraries/flutter_inapp_purchase/{lib,test}/` edits now trigger `flutter analyze` (catches the `ambiguous_export` class of failure that just took out CI). Skipped with a warning if `flutter` isn't on PATH. - `libraries/kmp-iap/library/src` or `packages/gql/src` edits trigger `./gradlew :library:compileDebugKotlinAndroid` (catches the redeclaration / missing-interface-member errors). Skipped with a warning if `gradlew` isn't executable. Net effect: the next contributor (or the next me) can't push a kit / flutter / kmp / gql change that fails CI for any of the categories of issue we've already burned a CI run on. Co-Authored-By: Claude Opus 4.7 (1M context) --- .husky/pre-commit | 50 ++++ bun.lock | 667 +++++++++++++++++----------------------------- package.json | 2 +- 3 files changed, 290 insertions(+), 429 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 066f5454..bf87d707 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,6 +4,27 @@ set -e REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" +# Bun version pin guard. CI's Docker image uses the version declared in +# `package.json`'s `packageManager` field (currently bun@1.3.13). bun +# lockfiles are not stable across major-minor versions — a lockfile +# generated by an older local bun will pass `--frozen-lockfile` here +# but fail in Docker. Before doing anything else, refuse to commit +# from a mismatched bun. +EXPECTED_BUN="$(node -p "require('./package.json').packageManager.split('@')[1]" 2>/dev/null || true)" +if [ -n "$EXPECTED_BUN" ]; then + ACTUAL_BUN="$(bun --version 2>/dev/null || echo unknown)" + if [ "$EXPECTED_BUN" != "$ACTUAL_BUN" ]; then + echo "❌ bun version mismatch:" + echo " package.json packageManager: bun@$EXPECTED_BUN" + echo " local bun --version: $ACTUAL_BUN" + echo " run \`bun upgrade\` (or install bun@$EXPECTED_BUN) and re-commit." + echo " (Lockfiles drift across bun versions — CI's Docker uses" + echo " the pinned version and will fail with" + echo " 'lockfile had changes, but lockfile is frozen' otherwise.)" + exit 1 + fi +fi + # Paths-aware kit pre-commit gate. Only runs when staged changes touch # packages/kit/**, so unrelated edits to apple/google/gql/docs/libraries # aren't blocked. @@ -57,6 +78,35 @@ if git diff --cached --name-only --diff-filter=ACMR | grep -q '^packages/kit/'; bun run --filter @hyodotdev/openiap-kit smoke:server fi +# Paths-aware Flutter analyze. Triggers on any libraries/flutter_inapp_purchase +# edit. Catches the `ambiguous_export` class of failure that took out +# CI on PR #124 — locally `flutter analyze` runs in <2s with a warm +# pub cache. +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^libraries/flutter_inapp_purchase/(lib|test)/'; then + if command -v flutter >/dev/null 2>&1; then + echo "🐦 flutter-touched commit — running flutter analyze…" + (cd libraries/flutter_inapp_purchase && flutter analyze) + else + echo "⚠️ flutter not on PATH — skipping flutter analyze (CI will catch any issues)." + fi +fi + +# Paths-aware KMP compile check. Uses the existing `:library:compileDebugKotlinAndroid` +# task because that's the same target CI runs. With a warm gradle daemon +# this finishes in 5-10s; first run after `./gradlew --stop` can take 30-40s. +# Catches the redeclaration / interface-method-missing class of failure +# that hit PR #124. +if git diff --cached --name-only --diff-filter=ACMR \ + | grep -qE '^(libraries/kmp-iap/library/src|packages/gql/src/)' ; then + if [ -x libraries/kmp-iap/gradlew ]; then + echo "🎯 kmp/gql-touched commit — running ./gradlew :library:compileDebugKotlinAndroid…" + (cd libraries/kmp-iap && ./gradlew --no-daemon=false :library:compileDebugKotlinAndroid -q) + else + echo "⚠️ libraries/kmp-iap/gradlew not executable — skipping KMP compile." + fi +fi + # Paths-aware docs typecheck. The kit integration brought React 19 into # the workspace alongside docs's React 18, which previously caused # @types/react hoisting to break docs's tsc only in CI. Both are now on diff --git a/bun.lock b/bun.lock index 91d1e488..9ea5ed61 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 1, "workspaces": { "": { "name": "@hyodotdev/openiap", @@ -183,7 +184,7 @@ "@apple/app-store-server-library": ["@apple/app-store-server-library@1.6.0", "", { "dependencies": { "@types/jsonwebtoken": "^9.0.5", "@types/jsrsasign": "^10.5.12", "@types/node": "^22.7.5", "@types/node-fetch": "^2.6.3", "base64url": "^3.0.1", "jsonwebtoken": "^9.0.2", "jsrsasign": "^11.0.0", "node-fetch": "^2.7.0" } }, "sha512-CmwCXLtkR6RdYjJjuiV2V7RNN6cJLeI7NoZtuDDhalM4/6MfNsZLrTyZvDWKWJsArh0LKoiplK+cc5+PCkwKUQ=="], - "@ardatan/relay-compiler": ["@ardatan/relay-compiler@12.0.3", "", { "dependencies": { "@babel/generator": "^7.26.10", "@babel/parser": "^7.26.10", "@babel/runtime": "^7.26.10", "chalk": "^4.0.0", "fb-watchman": "^2.0.0", "immutable": "~3.7.6", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "relay-runtime": "12.0.0", "signedsource": "^1.0.0" }, "peerDependencies": { "graphql": "*" }, "bin": { "relay-compiler": "bin/relay-compiler" } }, "sha512-mBDFOGvAoVlWaWqs3hm1AciGHSQE1rqFc/liZTyYz/Oek9yZdT5H26pH2zAFuEiTiBVPPyMuqf5VjOFPI2DGsQ=="], + "@ardatan/relay-compiler": ["@ardatan/relay-compiler@13.0.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "immutable": "^5.1.5", "invariant": "^2.2.4" }, "peerDependencies": { "graphql": "*" } }, "sha512-afG3YPwuSA0E5foouZusz5GlXKs74dObv4cuWyLyfKsYFj2r7oGRNB28v18HvwuLSQtQFCi+DpIe0TZkgQDYyg=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], @@ -195,35 +196,35 @@ "@auth/core": ["@auth/core@0.41.2", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w=="], - "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="], + "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], - "@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - "@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - "@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], - "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg=="], + "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], @@ -231,11 +232,11 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - "@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="], + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - "@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], @@ -265,7 +266,7 @@ "@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="], - "@envelop/core": ["@envelop/core@5.3.2", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@envelop/types": "^5.2.1", "@whatwg-node/promise-helpers": "^1.2.4", "tslib": "^2.5.0" } }, "sha512-06Mu7fmyKzk09P2i2kHpGfItqLLgCq7uO5/nX4fc/iHMplWPNuAx4iYR+WXUQoFHDnP6EUbceQNQ5iyeMz9f3g=="], + "@envelop/core": ["@envelop/core@5.5.1", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@envelop/types": "^5.2.1", "@whatwg-node/promise-helpers": "^1.2.4", "tslib": "^2.5.0" } }, "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw=="], "@envelop/instrumentation": ["@envelop/instrumentation@1.0.0", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.2.1", "tslib": "^2.5.0" } }, "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw=="], @@ -323,23 +324,23 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="], + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - "@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="], + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], - "@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="], + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], @@ -347,77 +348,77 @@ "@fastify/otel": ["@fastify/otel@0.18.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.212.0", "@opentelemetry/semantic-conventions": "^1.28.0", "minimatch": "^10.2.4" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA=="], - "@graphql-codegen/add": ["@graphql-codegen/add@6.0.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-biFdaURX0KTwEJPQ1wkT6BRgNasqgQ5KbCI1a3zwtLtO7XTo7/vKITPylmiU27K5DSOWYnY/1jfSqUAEBuhZrQ=="], + "@graphql-codegen/add": ["@graphql-codegen/add@6.0.1", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-MSylSekjpVWbOBw2A/2ssk1fPY54sYb6Qk2C4AX5u7s2R+2pMQ9ws7DTXo8VU9qwTgWwVp6vGfdQ0AMpAn4Iug=="], - "@graphql-codegen/cli": ["@graphql-codegen/cli@6.0.0", "", { "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", "@graphql-codegen/client-preset": "^5.0.0", "@graphql-codegen/core": "^5.0.0", "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/apollo-engine-loader": "^8.0.0", "@graphql-tools/code-file-loader": "^8.0.0", "@graphql-tools/git-loader": "^8.0.0", "@graphql-tools/github-loader": "^8.0.0", "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", "@graphql-tools/load": "^8.1.0", "@graphql-tools/url-loader": "^8.0.0", "@graphql-tools/utils": "^10.0.0", "@inquirer/prompts": "^7.8.2", "@whatwg-node/fetch": "^0.10.0", "chalk": "^4.1.0", "cosmiconfig": "^9.0.0", "debounce": "^2.0.0", "detect-indent": "^6.0.0", "graphql-config": "^5.1.1", "is-glob": "^4.0.1", "jiti": "^2.3.0", "json-to-pretty-yaml": "^1.2.2", "listr2": "^9.0.0", "log-symbols": "^4.0.0", "micromatch": "^4.0.5", "shell-quote": "^1.7.3", "string-env-interpolation": "^1.0.1", "ts-log": "^2.2.3", "tslib": "^2.4.0", "yaml": "^2.3.1", "yargs": "^17.0.0" }, "peerDependencies": { "@parcel/watcher": "^2.1.0", "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["@parcel/watcher"], "bin": { "gql-gen": "cjs/bin.js", "graphql-codegen": "cjs/bin.js", "graphql-code-generator": "cjs/bin.js", "graphql-codegen-esm": "esm/bin.js" } }, "sha512-tvchLVCMtorDE+UwgQbrjyaQK16GCZA+QomTxZazRx64ixtgmbEiQV7GhCBy0y0Bo7/tcTJb6sy9G/TL/BgiOg=="], + "@graphql-codegen/cli": ["@graphql-codegen/cli@6.3.1", "", { "dependencies": { "@babel/generator": "^7.18.13", "@babel/template": "^7.18.10", "@babel/types": "^7.18.13", "@graphql-codegen/client-preset": "^5.3.0", "@graphql-codegen/core": "^5.0.2", "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/apollo-engine-loader": "^8.0.28", "@graphql-tools/code-file-loader": "^8.1.28", "@graphql-tools/git-loader": "^8.0.32", "@graphql-tools/github-loader": "^9.0.6", "@graphql-tools/graphql-file-loader": "^8.1.11", "@graphql-tools/json-file-loader": "^8.0.26", "@graphql-tools/load": "^8.1.8", "@graphql-tools/merge": "^9.0.6", "@graphql-tools/url-loader": "^9.0.6", "@graphql-tools/utils": "^11.0.0", "@inquirer/prompts": "^7.8.2", "@whatwg-node/fetch": "^0.10.0", "chalk": "^4.1.0", "cosmiconfig": "^9.0.0", "debounce": "^2.0.0", "detect-indent": "^6.0.0", "graphql-config": "^5.1.6", "is-glob": "^4.0.1", "jiti": "^2.3.0", "json-to-pretty-yaml": "^1.2.2", "listr2": "^9.0.0", "log-symbols": "^4.0.0", "micromatch": "^4.0.5", "shell-quote": "^1.7.3", "string-env-interpolation": "^1.0.1", "ts-log": "^2.2.3", "tslib": "^2.4.0", "yaml": "^2.3.1", "yargs": "^17.0.0" }, "peerDependencies": { "@parcel/watcher": "^2.1.0", "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["@parcel/watcher"], "bin": { "gql-gen": "cjs/bin.js", "graphql-codegen": "cjs/bin.js", "graphql-codegen-esm": "esm/bin.js", "graphql-code-generator": "cjs/bin.js" } }, "sha512-I5KkyX1SgQZPojMeQTRydB6fml4cysZq/mIdhNW4rmqdoOcTgdMPq1Tl+wtRp1VpBAOrBazJUJh1nAqJMMSPIQ=="], - "@graphql-codegen/client-preset": ["@graphql-codegen/client-preset@5.1.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/gql-tag-operations": "5.0.2", "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/typed-document-node": "^6.0.2", "@graphql-codegen/typescript": "^5.0.2", "@graphql-codegen/typescript-operations": "^5.0.2", "@graphql-codegen/visitor-plugin-common": "^6.1.0", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", "@graphql-typed-document-node/core": "3.2.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-MYMy9dIlAgT3q1U8WUys6Y8yt/T9WLsm1DczRtrCpV5N11v4Rlg3hGWQmEvhJtBbWxgzfYoHZHb0TohtbLkJ+g=="], + "@graphql-codegen/client-preset": ["@graphql-codegen/client-preset@5.3.0", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", "@graphql-codegen/add": "^6.0.1", "@graphql-codegen/gql-tag-operations": "5.2.0", "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/typed-document-node": "^6.1.8", "@graphql-codegen/typescript": "^5.0.10", "@graphql-codegen/typescript-operations": "^5.1.0", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^11.0.0", "@graphql-typed-document-node/core": "3.2.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-K9FON+j7qyxAUDuSGqI3ofb7lWTBs16oPTYpu14lhdL4DKZQSHLyc8EMYU9e3KcyQ/13gU/d6culOppzAuexLA=="], - "@graphql-codegen/core": ["@graphql-codegen/core@5.0.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-vLTEW0m8LbE4xgRwbFwCdYxVkJ1dBlVJbQyLb9Q7bHnVFgHAP982Xo8Uv7FuPBmON+2IbTjkCqhFLHVZbqpvjQ=="], + "@graphql-codegen/core": ["@graphql-codegen/core@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/schema": "^10.0.0", "@graphql-tools/utils": "^11.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-7RX0wwjoWPlLG/tUmpaTK91ZZqHcACNWpRL0nGnnJaJrORie9pgmX8JPrcwBgYiHSC+3ERo9xY91RFPem/VrpQ=="], - "@graphql-codegen/gql-tag-operations": ["@graphql-codegen/gql-tag-operations@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/visitor-plugin-common": "6.1.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-iK+LFGv4ihHKeerADFPTL7Iq4iNr+J1jm2+GUMtwTSAL4nGk+BdfyruV7eR53R7Des8NFdI+9hBzKbbob7VwGQ=="], + "@graphql-codegen/gql-tag-operations": ["@graphql-codegen/gql-tag-operations@5.2.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "@graphql-tools/utils": "^11.0.0", "auto-bind": "~4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-B9gtJ4ziqpIv+7mHqwjtpYLFOuv0GmmRGpNDoWKM2VIx4OQqgI84d6OHKYCVeO7yu3mUr0QPvUgkSyuLVrdukA=="], - "@graphql-codegen/plugin-helpers": ["@graphql-codegen/plugin-helpers@6.0.0", "", { "dependencies": { "@graphql-tools/utils": "^10.0.0", "change-case-all": "1.0.15", "common-tags": "1.8.2", "import-from": "4.0.0", "lodash": "~4.17.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-Z7P89vViJvQakRyMbq/JF2iPLruRFOwOB6IXsuSvV/BptuuEd7fsGPuEf8bdjjDxUY0pJZnFN8oC7jIQ8p9GKA=="], + "@graphql-codegen/plugin-helpers": ["@graphql-codegen/plugin-helpers@6.3.0", "", { "dependencies": { "@graphql-tools/utils": "^11.0.0", "change-case-all": "1.0.15", "common-tags": "1.8.2", "import-from": "4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-Auc+/B7okDx9+pVgLVliZtZLYh6iltWXlnzzM+bRE+zh1T4r3hKbnr8xAmtT937ArfSgk5GHcQHr8LfPYnrRBg=="], - "@graphql-codegen/schema-ast": ["@graphql-codegen/schema-ast@5.0.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/utils": "^10.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-jn7Q3PKQc0FxXjbpo9trxzlz/GSFQWxL042l0iC8iSbM/Ar+M7uyBwMtXPsev/3Razk+osQyreghIz0d2+6F7Q=="], + "@graphql-codegen/schema-ast": ["@graphql-codegen/schema-ast@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/utils": "^11.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-jl1F/9IjRkJisEb9B0ayG4QGqYlPldLRy8ojDdmL9NE1NsdB5ROfxQnSqyC3g+wuvBhWX7kZgMRQYn3RU1I5bA=="], - "@graphql-codegen/typed-document-node": ["@graphql-codegen/typed-document-node@6.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-nqcD23F87jLPQ1P2jJaepNAa4SY8Xy2soacPyQMwvxWtbRSXlg/LBUjtbEkCaU2SuLoa4L3w8VPuGoQ3EWUzeg=="], + "@graphql-codegen/typed-document-node": ["@graphql-codegen/typed-document-node@6.1.8", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-+qDdiJSQ7Ol+vpLMAH8ZJok50CvlYxA6seQ7cwEa3emXt8MmH5hh3zdc9unQlPc7bynoJHRCgoKk7E0B7hry0w=="], - "@graphql-codegen/typescript": ["@graphql-codegen/typescript@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/schema-ast": "^5.0.0", "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-OJYXpS9SRf4VFzqu3ZH/RmTftGhAVTCmscH63iPlvTlCT8NBmpSHdZ875AEa38LugdL8XgUcGsI3pprP3e5j/w=="], + "@graphql-codegen/typescript": ["@graphql-codegen/typescript@5.0.10", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/schema-ast": "^5.0.2", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "auto-bind": "~4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-Pa8OFmL9TdhEYnLYJLYA9EhP8eEeivP/YDYq4Nb8LQaL7GXm4TGX8zELYaCM9Fu8M3iZb7iQGMt7qc+1lXz8XQ=="], - "@graphql-codegen/typescript-operations": ["@graphql-codegen/typescript-operations@5.0.2", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/typescript": "^5.0.2", "@graphql-codegen/visitor-plugin-common": "6.1.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-i2nSJ5a65H+JgXwWvEuYehVYUImIvrHk3PTs+Fcj+OjZFvDl2qBziIhr6shCjV0KH9IZ6Y+1v4TzkxZr/+XFjA=="], + "@graphql-codegen/typescript-operations": ["@graphql-codegen/typescript-operations@5.1.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-codegen/typescript": "^5.0.10", "@graphql-codegen/visitor-plugin-common": "^6.3.0", "auto-bind": "~4.0.0", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "graphql-sock": "^1.0.0" }, "optionalPeers": ["graphql-sock"] }, "sha512-JlmjbFl0EnsfMDIYvTE1Q0kAOrntVEZ+ZfBqWTP91g4e0F/TzuwJ/V4tiFmeDf5dx/rf9AK4VkPehIdxu7TYhw=="], - "@graphql-codegen/visitor-plugin-common": ["@graphql-codegen/visitor-plugin-common@6.1.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.0.0", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "~2.6.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-AvGO1pe+b/kAa7+WBDlNDXOruRZWv/NnhLHgTggiW2XWRv33biuzg4cF1UTdpR2jmESZzJU4kXngLLX8RYJWLA=="], + "@graphql-codegen/visitor-plugin-common": ["@graphql-codegen/visitor-plugin-common@6.3.0", "", { "dependencies": { "@graphql-codegen/plugin-helpers": "^6.3.0", "@graphql-tools/optimize": "^2.0.0", "@graphql-tools/relay-operation-optimizer": "^7.1.1", "@graphql-tools/utils": "^11.0.0", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "dependency-graph": "^1.0.0", "graphql-tag": "^2.11.0", "parse-filepath": "^1.0.2", "tslib": "^2.8.0" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-vGBoE+4huzZyNhyGSAhXAkdROHlwKxxuziZm4XtP1mxe7nuI+VgyOmXebafLijbmuDsptPXQN0C/htL54O8hrg=="], - "@graphql-hive/signal": ["@graphql-hive/signal@1.0.0", "", {}, "sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag=="], + "@graphql-hive/signal": ["@graphql-hive/signal@2.0.0", "", {}, "sha512-Pz8wB3K0iU6ae9S1fWfsmJX24CcGeTo6hE7T44ucmV/ALKRj+bxClmqrYcDT7v3f0d12Rh4FAXBb6gon+WkDpQ=="], - "@graphql-tools/apollo-engine-loader": ["@graphql-tools/apollo-engine-loader@8.0.22", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@whatwg-node/fetch": "^0.10.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-ssD2wNxeOTRcUEkuGcp0KfZAGstL9YLTe/y3erTDZtOs2wL1TJESw8NVAp+3oUHPeHKBZQB4Z6RFEbPgMdT2wA=="], + "@graphql-tools/apollo-engine-loader": ["@graphql-tools/apollo-engine-loader@8.0.30", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "@whatwg-node/fetch": "^0.10.13", "sync-fetch": "0.6.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-hUydKGGECrWloERMmfoMzHZi12X99AM9geCGF5XVsv4iMRl/Iyuet24th4kC9bZ8MlAdCwAwtUsCyv9uRfYwSA=="], - "@graphql-tools/batch-execute": ["@graphql-tools/batch-execute@9.0.19", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-VGamgY4PLzSx48IHPoblRw0oTaBa7S26RpZXt0Y4NN90ytoE0LutlpB2484RbkfcTjv9wa64QD474+YP1kEgGA=="], + "@graphql-tools/batch-execute": ["@graphql-tools/batch-execute@10.0.8", "", { "dependencies": { "@graphql-tools/utils": "^11.0.0", "@whatwg-node/promise-helpers": "^1.3.2", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-Kobt37qrVTFhX4HUK5/vPgMXFw/5f97AzmAlfmDBSRh/GnoAmLKCb48FrEI3gdeIwZB2fEhVHJyDqsojldnLQA=="], - "@graphql-tools/code-file-loader": ["@graphql-tools/code-file-loader@8.1.22", "", { "dependencies": { "@graphql-tools/graphql-tag-pluck": "8.3.21", "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-FSka29kqFkfFmw36CwoQ+4iyhchxfEzPbXOi37lCEjWLHudGaPkXc3RyB9LdmBxx3g3GHEu43a5n5W8gfcrMdA=="], + "@graphql-tools/code-file-loader": ["@graphql-tools/code-file-loader@8.1.32", "", { "dependencies": { "@graphql-tools/graphql-tag-pluck": "8.3.31", "@graphql-tools/utils": "^11.1.0", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-gR5mNQjn0BugDL8a4A+ovS2KEvU52RNOGnbwiq9oWAEHiSv7iqJu77bpWARTzlE1ZFPK5MSQe9218+1t5PbXmQ=="], - "@graphql-tools/delegate": ["@graphql-tools/delegate@10.2.23", "", { "dependencies": { "@graphql-tools/batch-execute": "^9.0.19", "@graphql-tools/executor": "^1.4.9", "@graphql-tools/schema": "^10.0.25", "@graphql-tools/utils": "^10.9.1", "@repeaterjs/repeater": "^3.0.6", "@whatwg-node/promise-helpers": "^1.3.0", "dataloader": "^2.2.3", "dset": "^3.1.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-xrPtl7f1LxS+B6o+W7ueuQh67CwRkfl+UKJncaslnqYdkxKmNBB4wnzVcW8ZsRdwbsla/v43PtwAvSlzxCzq2w=="], + "@graphql-tools/delegate": ["@graphql-tools/delegate@12.0.14", "", { "dependencies": { "@graphql-tools/batch-execute": "^10.0.8", "@graphql-tools/executor": "^1.4.13", "@graphql-tools/schema": "^10.0.29", "@graphql-tools/utils": "^11.0.0", "@repeaterjs/repeater": "^3.0.6", "@whatwg-node/promise-helpers": "^1.3.2", "dataloader": "^2.2.3", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-/xCDM8zlCk1Lccww9asOIpxna9IFpIlol4yGsBD9Y2+3/Zu5k4/HzDC8LKJtw5MxdG+uJN1l9nRepr4GeBC4kA=="], "@graphql-tools/documents": ["@graphql-tools/documents@1.0.1", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aweoMH15wNJ8g7b2r4C4WRuJxZ0ca8HtNO54rkye/3duxTkW4fGBEutCx03jCIr5+a1l+4vFJNP859QnAVBVCA=="], - "@graphql-tools/executor": ["@graphql-tools/executor@1.4.9", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-SAUlDT70JAvXeqV87gGzvDzUGofn39nvaVcVhNf12Dt+GfWHtNNO/RCn/Ea4VJaSLGzraUd41ObnN3i80EBU7w=="], + "@graphql-tools/executor": ["@graphql-tools/executor@1.5.3", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA=="], - "@graphql-tools/executor-common": ["@graphql-tools/executor-common@0.0.4", "", { "dependencies": { "@envelop/core": "^5.2.3", "@graphql-tools/utils": "^10.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-SEH/OWR+sHbknqZyROCFHcRrbZeUAyjCsgpVWCRjqjqRbiJiXq6TxNIIOmpXgkrXWW/2Ev4Wms6YSGJXjdCs6Q=="], + "@graphql-tools/executor-common": ["@graphql-tools/executor-common@1.0.6", "", { "dependencies": { "@envelop/core": "^5.4.0", "@graphql-tools/utils": "^11.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-23/K5C+LSlHDI0mj2SwCJ33RcELCcyDUgABm1Z8St7u/4Z5+95i925H/NAjUyggRjiaY8vYtNiMOPE49aPX1sg=="], - "@graphql-tools/executor-graphql-ws": ["@graphql-tools/executor-graphql-ws@2.0.7", "", { "dependencies": { "@graphql-tools/executor-common": "^0.0.6", "@graphql-tools/utils": "^10.9.1", "@whatwg-node/disposablestack": "^0.0.6", "graphql-ws": "^6.0.6", "isomorphic-ws": "^5.0.0", "tslib": "^2.8.1", "ws": "^8.18.3" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-J27za7sKF6RjhmvSOwOQFeNhNHyP4f4niqPnerJmq73OtLx9Y2PGOhkXOEB0PjhvPJceuttkD2O1yMgEkTGs3Q=="], + "@graphql-tools/executor-graphql-ws": ["@graphql-tools/executor-graphql-ws@3.1.5", "", { "dependencies": { "@graphql-tools/executor-common": "^1.0.6", "@graphql-tools/utils": "^11.0.0", "@whatwg-node/disposablestack": "^0.0.6", "graphql-ws": "^6.0.6", "isows": "^1.0.7", "tslib": "^2.8.1", "ws": "^8.18.3" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-WXRsfwu9AkrORD9nShrd61OwwxeQ5+eXYcABRR3XPONFIS8pWQfDJGGqxql9/227o/s0DV5SIfkBURb5Knzv+A=="], - "@graphql-tools/executor-http": ["@graphql-tools/executor-http@1.3.3", "", { "dependencies": { "@graphql-hive/signal": "^1.0.0", "@graphql-tools/executor-common": "^0.0.4", "@graphql-tools/utils": "^10.8.1", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.4", "@whatwg-node/promise-helpers": "^1.3.0", "meros": "^1.2.1", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-LIy+l08/Ivl8f8sMiHW2ebyck59JzyzO/yF9SFS4NH6MJZUezA1xThUXCDIKhHiD56h/gPojbkpcFvM2CbNE7A=="], + "@graphql-tools/executor-http": ["@graphql-tools/executor-http@3.2.1", "", { "dependencies": { "@graphql-hive/signal": "^2.0.0", "@graphql-tools/executor-common": "^1.0.6", "@graphql-tools/utils": "^11.0.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.3.2", "meros": "^1.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-53i0TYO0cznIlZDJcnq4gQ6SOZ8efGgCDV33MYh6oqEapcp36tCMEVnVGVxcX5qRRyNHkqTY6hkA+/AyK9kicQ=="], - "@graphql-tools/executor-legacy-ws": ["@graphql-tools/executor-legacy-ws@1.1.19", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@types/ws": "^8.0.0", "isomorphic-ws": "^5.0.0", "tslib": "^2.4.0", "ws": "^8.17.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-bEbv/SlEdhWQD0WZLUX1kOenEdVZk1yYtilrAWjRUgfHRZoEkY9s+oiqOxnth3z68wC2MWYx7ykkS5hhDamixg=="], + "@graphql-tools/executor-legacy-ws": ["@graphql-tools/executor-legacy-ws@1.1.28", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "@types/ws": "^8.0.0", "isomorphic-ws": "^5.0.0", "tslib": "^2.4.0", "ws": "^8.20.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-O4uj93GG9iUb3s32eyhUohvyfA8mLhN8FvGzEdK628hFQPhZN75yurtVFrR08DHex71mQ3wYCCFkErpwdJbDDQ=="], - "@graphql-tools/git-loader": ["@graphql-tools/git-loader@8.0.26", "", { "dependencies": { "@graphql-tools/graphql-tag-pluck": "8.3.21", "@graphql-tools/utils": "^10.9.1", "is-glob": "4.0.3", "micromatch": "^4.0.8", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-0g+9eng8DaT4ZmZvUmPgjLTgesUa6M8xrDjNBltRldZkB055rOeUgJiKmL6u8PjzI5VxkkVsn0wtAHXhDI2UXQ=="], + "@graphql-tools/git-loader": ["@graphql-tools/git-loader@8.0.36", "", { "dependencies": { "@graphql-tools/graphql-tag-pluck": "8.3.31", "@graphql-tools/utils": "^11.1.0", "is-glob": "4.0.3", "micromatch": "^4.0.8", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PDDakesRu8FJYHJLf9/gkTweh8M19Bymz9i+vOlk9OTs9XmNcCqKM+1S610KX2AodvuBFz/xbesjTtTJIppLPg=="], - "@graphql-tools/github-loader": ["@graphql-tools/github-loader@8.0.22", "", { "dependencies": { "@graphql-tools/executor-http": "^1.1.9", "@graphql-tools/graphql-tag-pluck": "^8.3.21", "@graphql-tools/utils": "^10.9.1", "@whatwg-node/fetch": "^0.10.0", "@whatwg-node/promise-helpers": "^1.0.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-uQ4JNcNPsyMkTIgzeSbsoT9hogLjYrZooLUYd173l5eUGUi49EAcsGdiBCKaKfEjanv410FE8hjaHr7fjSRkJw=="], + "@graphql-tools/github-loader": ["@graphql-tools/github-loader@9.1.2", "", { "dependencies": { "@graphql-tools/executor-http": "^3.2.1", "@graphql-tools/graphql-tag-pluck": "^8.3.31", "@graphql-tools/utils": "^11.1.0", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.0.0", "sync-fetch": "0.6.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-jhRJncj9Wkr1Cd8Mo3QI2oG6fTw5ILr1/OXcHIqx744NBj8pPwQBXmQzZqh7MXxbekl2EAcum7SJIjq1HpYcPA=="], - "@graphql-tools/graphql-file-loader": ["@graphql-tools/graphql-file-loader@8.1.2", "", { "dependencies": { "@graphql-tools/import": "7.1.2", "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-VB6ttpwkqCu0KsA1/Wmev4qsu05Qfw49kgVSKkPjuyDQfVaqtr9ewEQRkX5CqnqHGEeLl6sOlNGEMM5fCVMWGQ=="], + "@graphql-tools/graphql-file-loader": ["@graphql-tools/graphql-file-loader@8.1.14", "", { "dependencies": { "@graphql-tools/import": "^7.1.14", "@graphql-tools/utils": "^11.1.0", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-CfAcsSEVkkHfEXLFzrd5rUYpcQEGWNV8lfc1Tb1p5m9HnYICzDDH08I5V33iMrEDza3GuujjjRBYqplBkqwIow=="], - "@graphql-tools/graphql-tag-pluck": ["@graphql-tools/graphql-tag-pluck@8.3.21", "", { "dependencies": { "@babel/core": "^7.26.10", "@babel/parser": "^7.26.10", "@babel/plugin-syntax-import-assertions": "^7.26.0", "@babel/traverse": "^7.26.10", "@babel/types": "^7.26.10", "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-TJhELNvR1tmghXMi6HVKp/Swxbx1rcSp/zdkuJZT0DCM3vOY11FXY6NW3aoxumcuYDNN3jqXcCPKstYGFPi5GQ=="], + "@graphql-tools/graphql-tag-pluck": ["@graphql-tools/graphql-tag-pluck@8.3.31", "", { "dependencies": { "@babel/core": "^7.28.6", "@babel/parser": "^7.29.2", "@babel/plugin-syntax-import-assertions": "^7.26.0", "@babel/traverse": "^7.26.10", "@babel/types": "^7.26.10", "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-ema2RRPZGj8TKruNElyDBHVCNFMxioGIVfLBuiA+GdfmRGt95b/i7Uksnj4EwItA6MCmhxokxZoa/fl6mJt3tw=="], - "@graphql-tools/import": ["@graphql-tools/import@7.1.2", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "@theguild/federation-composition": "^0.20.1", "resolve-from": "5.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-+tlNQbLEqAA4LdWoLwM1tckx95lo8WIKd8vhj99b9rLwN/KfLwHWzdS3jnUFK7+99vmHmN1oE5v5zmqJz0MTKw=="], + "@graphql-tools/import": ["@graphql-tools/import@7.1.14", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "resolve-from": "5.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-aqLcu04aEidszbXM6M0PWWL8bP17eX9sxXwjYWpglLvIRd4NFqb3C9QzBY8pleqXNMtWqXktlm9BQjevgSrirQ=="], - "@graphql-tools/json-file-loader": ["@graphql-tools/json-file-loader@8.0.20", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-5v6W+ZLBBML5SgntuBDLsYoqUvwfNboAwL6BwPHi3z/hH1f8BS9/0+MCW9OGY712g7E4pc3y9KqS67mWF753eA=="], + "@graphql-tools/json-file-loader": ["@graphql-tools/json-file-loader@8.0.28", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "globby": "^11.0.3", "tslib": "^2.4.0", "unixify": "^1.0.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-qgCsSkPArnjlNkcYpgGKiXxCTNkrAT9E+l1LhR+Por2jTlKBBeZ8stortkQ/PNDDjuL0WPrLQmHKhNPHabnB3A=="], - "@graphql-tools/load": ["@graphql-tools/load@8.1.2", "", { "dependencies": { "@graphql-tools/schema": "^10.0.25", "@graphql-tools/utils": "^10.9.1", "p-limit": "3.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-WhDPv25/jRND+0uripofMX0IEwo6mrv+tJg6HifRmDu8USCD7nZhufT0PP7lIcuutqjIQFyogqT70BQsy6wOgw=="], + "@graphql-tools/load": ["@graphql-tools/load@8.1.10", "", { "dependencies": { "@graphql-tools/schema": "^10.0.33", "@graphql-tools/utils": "^11.1.0", "p-limit": "3.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-hjcvfEFtwtc8vGi46wtpmGWadNzfEhzbjqinyFIZuIZPlR4aYdWQtqWtY/RMM4Ew4t1USkMNm6xrqC2TH1vCSA=="], - "@graphql-tools/merge": ["@graphql-tools/merge@9.1.1", "", { "dependencies": { "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-BJ5/7Y7GOhTuvzzO5tSBFL4NGr7PVqTJY3KeIDlVTT8YLcTXtBR+hlrC3uyEym7Ragn+zyWdHeJ9ev+nRX1X2w=="], + "@graphql-tools/merge": ["@graphql-tools/merge@9.1.9", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q=="], "@graphql-tools/optimize": ["@graphql-tools/optimize@2.0.0", "", { "dependencies": { "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg=="], - "@graphql-tools/relay-operation-optimizer": ["@graphql-tools/relay-operation-optimizer@7.0.21", "", { "dependencies": { "@ardatan/relay-compiler": "^12.0.3", "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-vMdU0+XfeBh9RCwPqRsr3A05hPA3MsahFn/7OAwXzMySA5EVnSH5R4poWNs3h1a0yT0tDPLhxORhK7qJdSWj2A=="], + "@graphql-tools/relay-operation-optimizer": ["@graphql-tools/relay-operation-optimizer@7.1.4", "", { "dependencies": { "@ardatan/relay-compiler": "^13.0.1", "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-cwOD/GEo/R//1uGCP0/urIxsMFoUgzkJVyMt9BDM2HhQhU6rSgH5l6lFukAFTJyPJVdyeOdYm2i0Jj5vYWbHTw=="], - "@graphql-tools/schema": ["@graphql-tools/schema@10.0.25", "", { "dependencies": { "@graphql-tools/merge": "^9.1.1", "@graphql-tools/utils": "^10.9.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-/PqE8US8kdQ7lB9M5+jlW8AyVjRGCKU7TSktuW3WNKSKmDO0MK1wakvb5gGdyT49MjAIb4a3LWxIpwo5VygZuw=="], + "@graphql-tools/schema": ["@graphql-tools/schema@10.0.33", "", { "dependencies": { "@graphql-tools/merge": "^9.1.9", "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ=="], - "@graphql-tools/url-loader": ["@graphql-tools/url-loader@8.0.33", "", { "dependencies": { "@graphql-tools/executor-graphql-ws": "^2.0.1", "@graphql-tools/executor-http": "^1.1.9", "@graphql-tools/executor-legacy-ws": "^1.1.19", "@graphql-tools/utils": "^10.9.1", "@graphql-tools/wrap": "^10.0.16", "@types/ws": "^8.0.0", "@whatwg-node/fetch": "^0.10.0", "@whatwg-node/promise-helpers": "^1.0.0", "isomorphic-ws": "^5.0.0", "sync-fetch": "0.6.0-2", "tslib": "^2.4.0", "ws": "^8.17.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-Fu626qcNHcqAj8uYd7QRarcJn5XZ863kmxsg1sm0fyjyfBJnsvC7ddFt6Hayz5kxVKfsnjxiDfPMXanvsQVBKw=="], + "@graphql-tools/url-loader": ["@graphql-tools/url-loader@9.1.2", "", { "dependencies": { "@graphql-tools/executor-graphql-ws": "^3.1.4", "@graphql-tools/executor-http": "^3.2.1", "@graphql-tools/executor-legacy-ws": "^1.1.28", "@graphql-tools/utils": "^11.1.0", "@graphql-tools/wrap": "^11.1.1", "@types/ws": "^8.0.0", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.0.0", "isomorphic-ws": "^5.0.0", "sync-fetch": "0.6.0", "tslib": "^2.4.0", "ws": "^8.20.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-pVSiPrfWQKb3jq23Pl7EjbB2uv3tgZLnWo/axkmg4itAEZ5s/vV/jKa8P1HZzUnSVUTR+8tcEZVeNsUbzFCbkg=="], - "@graphql-tools/utils": ["@graphql-tools/utils@10.9.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "dset": "^3.1.4", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-B1wwkXk9UvU7LCBkPs8513WxOQ2H8Fo5p8HR1+Id9WmYE5+bd51vqN+MbrqvWczHCH2gwkREgHJN88tE0n1FCw=="], + "@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="], - "@graphql-tools/wrap": ["@graphql-tools/wrap@10.1.4", "", { "dependencies": { "@graphql-tools/delegate": "^10.2.23", "@graphql-tools/schema": "^10.0.25", "@graphql-tools/utils": "^10.9.1", "@whatwg-node/promise-helpers": "^1.3.0", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-7pyNKqXProRjlSdqOtrbnFRMQAVamCmEREilOXtZujxY6kYit3tvWWSjUrcIOheltTffoRh7EQSjpy2JDCzasg=="], + "@graphql-tools/wrap": ["@graphql-tools/wrap@11.1.14", "", { "dependencies": { "@graphql-tools/delegate": "^12.0.14", "@graphql-tools/schema": "^10.0.29", "@graphql-tools/utils": "^11.0.0", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-ebSVT7apxr+88q3Wy0i4AyRmJ42I0SuMqjPIn1fqW14yCTQRZG8YLuIALG1gKR936+GkfMLOrADh6egJvdlN6Q=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -425,9 +426,11 @@ "@hono/standard-validator": ["@hono/standard-validator@0.2.2", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=3.9.0" } }, "sha512-mJ7W84Bt/rSvoIl63Ynew+UZOHAzzRAoAXb3JaWuxAkM/Lzg+ZHTCUiz77KOtn2e623WNN8LkD57Dk0szqUrIw=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], - "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], @@ -497,43 +500,39 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@inquirer/ansi": ["@inquirer/ansi@1.0.1", "", {}, "sha512-yqq0aJW/5XPhi5xOAL1xRCpe1eh8UFVgYFpFsjEqmIR8rKLyP+HINvFXwUaxYICflJrVlxnp7lLN6As735kVpw=="], - - "@inquirer/checkbox": ["@inquirer/checkbox@4.3.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5+Q3PKH35YsnoPTh75LucALdAxom6xh5D1oeY561x4cqBuH24ZFVyFREPe14xgnrtmGu3EEt1dIi60wRVSnGCw=="], + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], - "@inquirer/confirm": ["@inquirer/confirm@5.1.19", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wQNz9cfcxrtEnUyG5PndC8g3gZ7lGDBzmWiXZkX8ot3vfZ+/BLjR8EvyGX4YzQLeVqtAlY/YScZpW7CW8qMoDQ=="], + "@inquirer/checkbox": ["@inquirer/checkbox@4.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA=="], - "@inquirer/core": ["@inquirer/core@10.3.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Uv2aPPPSK5jeCplQmQ9xadnFx2Zhj9b5Dj7bU6ZeCdDNNY11nhYy4btcSdtDguHqCT2h5oNeQTcUNSGGLA7NTA=="], + "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], - "@inquirer/editor": ["@inquirer/editor@4.2.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/external-editor": "^1.0.2", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-MjtjOGjr0Kh4BciaFShYpZ1s9400idOdvQ5D7u7lE6VztPFoyLcVNE5dXBmEEIQq5zi4B9h2kU+q7AVBxJMAkQ=="], + "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], - "@inquirer/expand": ["@inquirer/expand@4.0.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+mScLhIcbPFmuvU3tAGBed78XvYHSvCl6dBiYMlzCLhpr0bzGzd8tfivMMeqND6XZiaZ1tgusbUHJEfc6YzOdA=="], + "@inquirer/editor": ["@inquirer/editor@4.2.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/external-editor": "^1.0.3", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ=="], - "@inquirer/external-editor": ["@inquirer/external-editor@1.0.2", "", { "dependencies": { "chardet": "^2.1.0", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ=="], + "@inquirer/expand": ["@inquirer/expand@4.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew=="], - "@inquirer/figures": ["@inquirer/figures@1.0.14", "", {}, "sha512-DbFgdt+9/OZYFM+19dbpXOSeAstPy884FPy1KjDu4anWwymZeOYhMY1mdFri172htv6mvc/uvIAAi7b7tvjJBQ=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - "@inquirer/input": ["@inquirer/input@4.2.5", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-7GoWev7P6s7t0oJbenH0eQ0ThNdDJbEAEtVt9vsrYZ9FulIokvd823yLyhQlWHJPGce1wzP53ttfdCZmonMHyA=="], + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], - "@inquirer/number": ["@inquirer/number@3.0.21", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5QWs0KGaNMlhbdhOSCFfKsW+/dcAVC2g4wT/z2MCiZM47uLgatC5N20kpkDQf7dHx+XFct/MJvvNGy6aYJn4Pw=="], + "@inquirer/input": ["@inquirer/input@4.3.1", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g=="], - "@inquirer/password": ["@inquirer/password@4.0.21", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xxeW1V5SbNFNig2pLfetsDb0svWlKuhmr7MPJZMYuDnCTkpVBI+X/doudg4pznc1/U+yYmWFFOi4hNvGgUo7EA=="], + "@inquirer/number": ["@inquirer/number@3.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg=="], - "@inquirer/prompts": ["@inquirer/prompts@7.9.0", "", { "dependencies": { "@inquirer/checkbox": "^4.3.0", "@inquirer/confirm": "^5.1.19", "@inquirer/editor": "^4.2.21", "@inquirer/expand": "^4.0.21", "@inquirer/input": "^4.2.5", "@inquirer/number": "^3.0.21", "@inquirer/password": "^4.0.21", "@inquirer/rawlist": "^4.1.9", "@inquirer/search": "^3.2.0", "@inquirer/select": "^4.4.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-X7/+dG9SLpSzRkwgG5/xiIzW0oMrV3C0HOa7YHG1WnrLK+vCQHfte4k/T80059YBdei29RBC3s+pSMvPJDU9/A=="], + "@inquirer/password": ["@inquirer/password@4.0.23", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA=="], - "@inquirer/rawlist": ["@inquirer/rawlist@4.1.9", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-AWpxB7MuJrRiSfTKGJ7Y68imYt8P9N3Gaa7ySdkFj1iWjr6WfbGAhdZvw/UnhFXTHITJzxGUI9k8IX7akAEBCg=="], + "@inquirer/prompts": ["@inquirer/prompts@7.10.1", "", { "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", "@inquirer/editor": "^4.2.23", "@inquirer/expand": "^4.0.23", "@inquirer/input": "^4.3.1", "@inquirer/number": "^3.0.23", "@inquirer/password": "^4.0.23", "@inquirer/rawlist": "^4.1.11", "@inquirer/search": "^3.2.2", "@inquirer/select": "^4.4.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg=="], - "@inquirer/search": ["@inquirer/search@3.2.0", "", { "dependencies": { "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a5SzB/qrXafDX1Z4AZW3CsVoiNxcIYCzYP7r9RzrfMpaLpB+yWi5U8BWagZyLmwR0pKbbL5umnGRd0RzGVI8bQ=="], + "@inquirer/rawlist": ["@inquirer/rawlist@4.1.11", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw=="], - "@inquirer/select": ["@inquirer/select@4.4.0", "", { "dependencies": { "@inquirer/ansi": "^1.0.1", "@inquirer/core": "^10.3.0", "@inquirer/figures": "^1.0.14", "@inquirer/type": "^3.0.9", "yoctocolors-cjs": "^2.1.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kaC3FHsJZvVyIjYBs5Ih8y8Bj4P/QItQWrZW22WJax7zTN+ZPXVGuOM55vzbdCP9zKUiBd9iEJVdesujfF+cAA=="], + "@inquirer/search": ["@inquirer/search@3.2.2", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA=="], - "@inquirer/type": ["@inquirer/type@3.0.9", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QPaNt/nmE2bLGQa9b7wwyRJoLZ7pN6rcyXvzU0YCmivmJyq1BVo94G98tStRWkoD1RgDX5C+dPlhhHzNdu/W/w=="], + "@inquirer/select": ["@inquirer/select@4.4.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w=="], - "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], + "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], - "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], - - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -629,7 +628,7 @@ "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], - "@opentelemetry/core": ["@opentelemetry/core@2.7.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ=="], + "@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w=="], @@ -675,9 +674,9 @@ "@opentelemetry/redis-common": ["@opentelemetry/redis-common@0.38.3", "", {}, "sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw=="], - "@opentelemetry/resources": ["@opentelemetry/resources@2.7.0", "", { "dependencies": { "@opentelemetry/core": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.0", "", { "dependencies": { "@opentelemetry/core": "2.7.0", "@opentelemetry/resources": "2.7.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], @@ -697,9 +696,9 @@ "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@preact/signals-core": ["@preact/signals-core@1.12.1", "", {}, "sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA=="], + "@preact/signals-core": ["@preact/signals-core@1.14.1", "", {}, "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng=="], - "@preact/signals-react": ["@preact/signals-react@3.3.1", "", { "dependencies": { "@preact/signals-core": "^1.12.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-VMVDVt2zxtv/uvYBfuaAX1KJPZjpSB23ohbbCAzWAv0J0IXRxyDMSVUirxq1nY6nzeyldAAXJOykAtlVIyQ/jA=="], + "@preact/signals-react": ["@preact/signals-react@3.10.0", "", { "dependencies": { "@preact/signals-core": "^1.14.0", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-Uxu6lidNVr9z27b/6DbCin86ekzHiJDrLXZii82aXSzvyMXYMr7l0Bab1cKbfWdbkxq13e7kS7paix3pjKBTLA=="], "@preact/signals-react-transform": ["@preact/signals-react-transform@0.5.2", "", { "dependencies": { "@babel/helper-module-imports": "^7.22.5", "@babel/helper-plugin-utils": "^7.22.5", "@preact/signals-react": "^3.0.0", "debug": "^4.3.4", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-mUAy3da1t1yatgo9/qBRI47l5HWd+vuEVOGwV6ocZTi3wKB0T/6HejQZxkIiNRp0KIAhFR/Hyzsg6t4TnQ6VEA=="], @@ -793,79 +792,85 @@ "@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="], - "@remix-run/router": ["@remix-run/router@1.23.0", "", {}, "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA=="], + "@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="], "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.2", "", { "os": "linux", "cpu": "arm" }, "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.2", "", { "os": "linux", "cpu": "none" }, "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.2", "", { "os": "linux", "cpu": "x64" }, "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.2", "", { "os": "none", "cpu": "arm64" }, "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA=="], "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], - "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" } }, "sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" } }, "sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA=="], - "@sentry-internal/feedback": ["@sentry-internal/feedback@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" } }, "sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w=="], + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" } }, "sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA=="], - "@sentry-internal/replay": ["@sentry-internal/replay@10.50.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.50.0", "@sentry/core": "10.50.0" } }, "sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ=="], + "@sentry-internal/replay": ["@sentry-internal/replay@10.51.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw=="], - "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.50.0", "", { "dependencies": { "@sentry-internal/replay": "10.50.0", "@sentry/core": "10.50.0" } }, "sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw=="], + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.51.0", "", { "dependencies": { "@sentry-internal/replay": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA=="], - "@sentry/browser": ["@sentry/browser@10.50.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.50.0", "@sentry-internal/feedback": "10.50.0", "@sentry-internal/replay": "10.50.0", "@sentry-internal/replay-canvas": "10.50.0", "@sentry/core": "10.50.0" } }, "sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA=="], + "@sentry/browser": ["@sentry/browser@10.51.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.51.0", "@sentry-internal/feedback": "10.51.0", "@sentry-internal/replay": "10.51.0", "@sentry-internal/replay-canvas": "10.51.0", "@sentry/core": "10.51.0" } }, "sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA=="], - "@sentry/bun": ["@sentry/bun@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0", "@sentry/node": "10.50.0" } }, "sha512-TAGojqOd5ItSBSPMFdJtUDfSGgYaZ7odZI64WmTwG4zYfRFYs3BxwxalU2ljs7kxIVqFoJZoxlEeXbR/SBhgxA=="], + "@sentry/bun": ["@sentry/bun@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0", "@sentry/node": "10.51.0" } }, "sha512-LOOrMSKHTQ8kx+o75jelIHXUpFlIwAR5UXBNluV20NxyIsKEav3QsxP0J1Oe02hNVuo3yIlK55M3G9Zc4qEuAw=="], - "@sentry/core": ["@sentry/core@10.50.0", "", {}, "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg=="], + "@sentry/core": ["@sentry/core@10.51.0", "", {}, "sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w=="], - "@sentry/node": ["@sentry/node@10.50.0", "", { "dependencies": { "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-amqplib": "0.61.0", "@opentelemetry/instrumentation-connect": "0.57.0", "@opentelemetry/instrumentation-dataloader": "0.31.0", "@opentelemetry/instrumentation-fs": "0.33.0", "@opentelemetry/instrumentation-generic-pool": "0.57.0", "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", "@opentelemetry/instrumentation-mongodb": "0.67.0", "@opentelemetry/instrumentation-mongoose": "0.60.0", "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@prisma/instrumentation": "7.6.0", "@sentry/core": "10.50.0", "@sentry/node-core": "10.50.0", "@sentry/opentelemetry": "10.50.0", "import-in-the-middle": "^3.0.0" } }, "sha512-TvwzFQu8MGKzMQ2/tqxcNzFA8UG2kKTB+GDmA4uOzx3+GT849YZRRSJzEXCmYhk1teVd2fbmgqyYY2nyLF5a+Q=="], + "@sentry/node": ["@sentry/node@10.51.0", "", { "dependencies": { "@fastify/otel": "0.18.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-amqplib": "0.61.0", "@opentelemetry/instrumentation-connect": "0.57.0", "@opentelemetry/instrumentation-dataloader": "0.31.0", "@opentelemetry/instrumentation-fs": "0.33.0", "@opentelemetry/instrumentation-generic-pool": "0.57.0", "@opentelemetry/instrumentation-graphql": "0.62.0", "@opentelemetry/instrumentation-hapi": "0.60.0", "@opentelemetry/instrumentation-http": "0.214.0", "@opentelemetry/instrumentation-ioredis": "0.62.0", "@opentelemetry/instrumentation-kafkajs": "0.23.0", "@opentelemetry/instrumentation-knex": "0.58.0", "@opentelemetry/instrumentation-koa": "0.62.0", "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", "@opentelemetry/instrumentation-mongodb": "0.67.0", "@opentelemetry/instrumentation-mongoose": "0.60.0", "@opentelemetry/instrumentation-mysql": "0.60.0", "@opentelemetry/instrumentation-mysql2": "0.60.0", "@opentelemetry/instrumentation-pg": "0.66.0", "@opentelemetry/instrumentation-redis": "0.62.0", "@opentelemetry/instrumentation-tedious": "0.33.0", "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@prisma/instrumentation": "7.6.0", "@sentry/core": "10.51.0", "@sentry/node-core": "10.51.0", "@sentry/opentelemetry": "10.51.0", "import-in-the-middle": "^3.0.0" } }, "sha512-2yZLRZwS1dKG8/4eOTpGSo/gO/EgmT9aPj6lAzUkRa7bZCTTdW4BraaHU0leX5T94909Qfhbr3W5AVTfDOCKiQ=="], - "@sentry/node-core": ["@sentry/node-core@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0", "@sentry/opentelemetry": "10.50.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-Eb1BYf4Lc7ZYmdX3acKP6SgyGikrBA370gbGHaWI5jRu7G7vig8sIu1ghPmY5AlvqBPOetado7GniXr6fAXbTw=="], + "@sentry/node-core": ["@sentry/node-core@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0", "@sentry/opentelemetry": "10.51.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base", "@opentelemetry/semantic-conventions"] }, "sha512-VP9DMEzBEuauABrfDHYz/pRYa74M09uRJLz0ls3yel3sKhYHMyCB29ZxbKcciUhD4d33dwgi8DbaPZV2H/wnfQ=="], - "@sentry/opentelemetry": ["@sentry/opentelemetry@10.50.0", "", { "dependencies": { "@sentry/core": "10.50.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-axn3pgDPveGdaMUC0abMCmFN7ux2pA5ebPufCef4lMIsyg7BBQvaEJ+vE19wjstMaBCAJGsdZlL3eeP2rtgRMw=="], + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.51.0", "", { "dependencies": { "@sentry/core": "10.51.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", "@opentelemetry/semantic-conventions": "^1.39.0" } }, "sha512-Qc7AlCE4uhB+SvHLqah4RgR1WdY7wmmr/hx9g/prDP9R1ocshmUEMrZK9qjuwaklW7/fmkFCXI8ETxo5L1bHIA=="], - "@sentry/react": ["@sentry/react@10.50.0", "", { "dependencies": { "@sentry/browser": "10.50.0", "@sentry/core": "10.50.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-MZHYjEZAtFIa4zPrWS4oXlo+gMppRvfETqUqF920Sj2jN2U7WjboU03lDmjfDqEcH7QiwjQyl13jHd2nwAyrrw=="], + "@sentry/react": ["@sentry/react@10.51.0", "", { "dependencies": { "@sentry/browser": "10.51.0", "@sentry/core": "10.51.0" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, "sha512-RRHHqjNvjji6ebIqdlAr453AkST8Vm4cxdu1vWm772IgbzTO7Jx46Cj6Bt2/GjMyH0YLE5euDaAOQhFMmpvAOw=="], "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], @@ -911,9 +916,7 @@ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], - "@theguild/federation-composition": ["@theguild/federation-composition@0.20.1", "", { "dependencies": { "constant-case": "^3.0.4", "debug": "4.4.1", "json5": "^2.2.3", "lodash.sortby": "^4.7.0" }, "peerDependencies": { "graphql": "^16.0.0" } }, "sha512-lwYYKCeHmstOtbMtzxC0BQKWsUPYbEVRVdJ3EqR4jSpcF4gvNf3MOJv6yuvq6QsKqgYZURKRBszmg7VEDoi5Aw=="], - - "@tsconfig/node10": ["@tsconfig/node10@1.0.11", "", {}, "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="], + "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], "@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="], @@ -975,7 +978,7 @@ "@types/pg-pool": ["@types/pg-pool@2.0.7", "", { "dependencies": { "@types/pg": "*" } }, "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng=="], - "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -987,25 +990,25 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/type-utils": "8.46.1", "@typescript-eslint/utils": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/type-utils": "8.59.1", "@typescript-eslint/utils": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.46.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.46.1", "@typescript-eslint/types": "^8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1" } }, "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1" } }, "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.46.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/utils": "8.46.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.46.1", "", {}, "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.1", "", {}, "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.1", "@typescript-eslint/tsconfig-utils": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1029,9 +1032,9 @@ "@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="], - "@whatwg-node/fetch": ["@whatwg-node/fetch@0.10.11", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.8.0", "urlpattern-polyfill": "^10.0.0" } }, "sha512-eR8SYtf9Nem1Tnl0IWrY33qJ5wCtIWlt3Fs3c6V4aAaTFLtkEQErXu3SSZg/XCHrj9hXSJ8/8t+CdMk5Qec/ZA=="], + "@whatwg-node/fetch": ["@whatwg-node/fetch@0.10.13", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.8.3", "urlpattern-polyfill": "^10.0.0" } }, "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q=="], - "@whatwg-node/node-fetch": ["@whatwg-node/node-fetch@0.8.1", "", { "dependencies": { "@fastify/busboy": "^3.1.1", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-cQmQEo7IsI0EPX9VrwygXVzrVlX43Jb7/DBZSmpnC7xH4xkyOnn/HykHpTaQk7TUs7zh59A5uTGqx3p2Ouzffw=="], + "@whatwg-node/node-fetch": ["@whatwg-node/node-fetch@0.8.5", "", { "dependencies": { "@fastify/busboy": "^3.1.1", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ=="], "@whatwg-node/promise-helpers": ["@whatwg-node/promise-helpers@1.3.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA=="], @@ -1039,21 +1042,21 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-escapes": ["ansi-escapes@7.1.1", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1073,8 +1076,6 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], - "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -1099,7 +1100,7 @@ "base64url": ["base64url@3.0.1", "", {}, "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.22", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA=="], "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], @@ -1107,14 +1108,12 @@ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], - "bubble-stream-error": ["bubble-stream-error@1.0.0", "", { "dependencies": { "once": "^1.3.3", "sliced": "^1.0.1" } }, "sha512-Rqf0ly5H4HGt+ki/n3m7GxoR2uIGtNqezPlOLX8Vuo13j5/tfPuVvAr84eoGF7sYm6lKdbGnT/3q8qmzuT5Y9w=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -1155,13 +1154,13 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chardet": ["chardet@2.1.0", "", {}, "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - "cli-truncate": ["cli-truncate@5.1.0", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-7JDGG+4Zp0CsknDCedl0DYdaeOhc46QNpXi3NLQblkZpXXgA6LncLDUUyvrjSvZeF3VRQa+KiMGomazQrC1V8g=="], + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -1183,7 +1182,7 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], @@ -1199,7 +1198,7 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "convex": ["convex@1.36.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.4.3", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-NVnwNqU+h8jyPuS0Itvj4MPH9c2yF+tA/RNoSDpCqiLhmYD4+kZxm0dDkVM0QDzz66wem9NqheBb9YQGsHwzBQ=="], + "convex": ["convex@1.37.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.4.3", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-xGSx5edIsXCEex3OU2U2N0oyB/cOa9qGwKiImF9yOWqjqZgOkx39idtpdlwNBTBSt4S30oAvs4yeXY5xxPIX3A=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -1207,12 +1206,10 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], + "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], - "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], - "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1267,7 +1264,7 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="], + "diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -1285,17 +1282,13 @@ "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], - "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.344", "", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.348", "", {}, "sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1333,11 +1326,11 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="], + "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.24", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w=="], + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], @@ -1345,7 +1338,7 @@ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], @@ -1359,7 +1352,7 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -1383,13 +1376,7 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - - "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], - - "fbjs": ["fbjs@3.0.5", "", { "dependencies": { "cross-fetch": "^3.1.5", "fbjs-css-vars": "^1.0.0", "loose-envify": "^1.0.0", "object-assign": "^4.1.0", "promise": "^7.1.1", "setimmediate": "^1.0.5", "ua-parser-js": "^1.0.35" } }, "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg=="], - - "fbjs-css-vars": ["fbjs-css-vars@1.0.2", "", {}, "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1445,7 +1432,7 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -1457,7 +1444,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -1475,17 +1462,15 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], - "graphql": ["graphql@16.11.0", "", {}, "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw=="], - - "graphql-config": ["graphql-config@5.1.5", "", { "dependencies": { "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", "@graphql-tools/load": "^8.1.0", "@graphql-tools/merge": "^9.0.0", "@graphql-tools/url-loader": "^8.0.0", "@graphql-tools/utils": "^10.0.0", "cosmiconfig": "^8.1.0", "jiti": "^2.0.0", "minimatch": "^9.0.5", "string-env-interpolation": "^1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "cosmiconfig-toml-loader": "^1.0.0", "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["cosmiconfig-toml-loader"] }, "sha512-mG2LL1HccpU8qg5ajLROgdsBzx/o2M6kgI3uAmoaXiSH9PCUbtIyLomLqUtCFaAeG2YCFsl0M5cfQ9rKmDoMVA=="], + "graphql-config": ["graphql-config@5.1.6", "", { "dependencies": { "@graphql-tools/graphql-file-loader": "^8.0.0", "@graphql-tools/json-file-loader": "^8.0.0", "@graphql-tools/load": "^8.1.0", "@graphql-tools/merge": "^9.0.0", "@graphql-tools/url-loader": "^9.0.0", "@graphql-tools/utils": "^11.0.0", "cosmiconfig": "^8.1.0", "jiti": "^2.0.0", "minimatch": "^10.0.0", "string-env-interpolation": "^1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "cosmiconfig-toml-loader": "^1.0.0", "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["cosmiconfig-toml-loader"] }, "sha512-fCkYnm4Kdq3un0YIM4BCZHVR5xl0UeLP6syxxO7KAstdY7QVyVvTHP0kRPDYEP1v08uwtJVgis5sj3IOTLOniQ=="], "graphql-tag": ["graphql-tag@2.12.6", "", { "dependencies": { "tslib": "^2.1.0" }, "peerDependencies": { "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" } }, "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg=="], - "graphql-ws": ["graphql-ws@6.0.6", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "uWebSockets.js": "^20", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "uWebSockets.js", "ws"] }, "sha512-zgfER9s+ftkGKUZgc0xbx8T7/HMO4AV5/YuYiFc+AtgcO5T0v8AxYYNQ+ltzuzDZgNkYJaFspm5MMYLjQzrkmw=="], + "graphql-ws": ["graphql-ws@6.0.8", "", { "peerDependencies": { "@fastify/websocket": "^10 || ^11", "crossws": "~0.3", "graphql": "^15.10.1 || ^16", "ws": "^8" }, "optionalPeers": ["@fastify/websocket", "crossws", "ws"] }, "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw=="], - "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], @@ -1507,7 +1492,7 @@ "header-case": ["header-case@2.0.4", "", { "dependencies": { "capital-case": "^1.0.4", "tslib": "^2.0.3" } }, "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q=="], - "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], + "hono": ["hono@4.12.16", "", {}, "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg=="], "hono-openapi": ["hono-openapi@1.3.0", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig=="], @@ -1527,11 +1512,11 @@ "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], - "iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "immutable": ["immutable@3.7.6", "", {}, "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw=="], + "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1649,7 +1634,9 @@ "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], - "jackspeak": ["jackspeak@4.1.1", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ=="], + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1657,9 +1644,9 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "jsdom": ["jsdom@29.1.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-YNUc7fB9QuvSSQWfrH0xF+TyABkxUwx8sswgIDaCrw4Hol8BghdZDkITtZheRJeMtzWlnTfsM3bBBusRvpO1wg=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -1727,16 +1714,14 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "lint-staged": ["lint-staged@16.2.4", "", { "dependencies": { "commander": "^14.0.1", "listr2": "^9.0.4", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-Pkyr/wd90oAyXk98i/2KwfkIhoYQUMtss769FIT9hFM5ogYZwrk+GRE46yKXSg2ZGhcJ1p38Gf5gmI5Ohjg2yg=="], + "lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="], - "listr2": ["listr2@9.0.4", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-1wd/kpAdKRLwv7/3OKC8zZ5U8e/fajCfWMxacUvB79S5nLrYGPtUI/8chMQhn3LQjsRVErTb9i1ECAwW0ZIHnQ=="], + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash._baseflatten": ["lodash._baseflatten@3.1.4", "", { "dependencies": { "lodash.isarguments": "^3.0.0", "lodash.isarray": "^3.0.0" } }, "sha512-fESngZd+X4k+GbTxdMutf8ohQa0s3sJEHIcwtu4/LsIQ2JTDzdRxDCMQjW+ezzwRitLmHnacVVmosCbxifefbw=="], "lodash._basefor": ["lodash._basefor@3.0.3", "", {}, "sha512-6bc3b8grkpMgDcVJv9JYZAk/mHgcqMljzm7OsbmcE2FGUMmmLQTPHlh/dFqR8LA0GQ7z4K67JSotVKu5058v1A=="], @@ -1917,11 +1902,11 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], @@ -1935,9 +1920,7 @@ "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], - "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], - - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -1953,8 +1936,6 @@ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], - "node-releases": ["node-releases@2.0.38", "", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], "normalize-package-data": ["normalize-package-data@2.5.0", "", { "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", "semver": "2 || 3 || 4 || 5", "validate-npm-package-license": "^3.0.1" } }, "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA=="], @@ -1963,8 +1944,6 @@ "npm-run-all": ["npm-run-all@4.1.5", "", { "dependencies": { "ansi-styles": "^3.2.1", "chalk": "^2.4.1", "cross-spawn": "^6.0.5", "memorystream": "^0.3.1", "minimatch": "^3.0.4", "pidtree": "^0.3.0", "read-pkg": "^3.0.0", "shell-quote": "^1.6.1", "string.prototype.padend": "^3.0.0" }, "bin": { "run-p": "bin/run-p/index.js", "run-s": "bin/run-s/index.js", "npm-run-all": "bin/npm-run-all/index.js" } }, "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ=="], - "nullthrows": ["nullthrows@1.1.1", "", {}, "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="], - "oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -2027,7 +2006,7 @@ "path-root-regex": ["path-root-regex@0.1.2", "", {}, "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ=="], - "path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], @@ -2047,7 +2026,7 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "pidtree": ["pidtree@0.3.1", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA=="], "pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], @@ -2059,7 +2038,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], @@ -2077,7 +2056,7 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -2085,8 +2064,6 @@ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], @@ -2113,7 +2090,7 @@ "react-helmet-async": ["react-helmet-async@2.0.5", "", { "dependencies": { "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "shallowequal": "^1.1.0" }, "peerDependencies": { "react": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg=="], - "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], @@ -2123,11 +2100,11 @@ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - "react-router": ["react-router@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ=="], + "react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="], - "react-router-dom": ["react-router-dom@6.30.1", "", { "dependencies": { "@remix-run/router": "1.23.0", "react-router": "6.30.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw=="], + "react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="], - "react-toastify": ["react-toastify@11.0.5", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA=="], + "react-toastify": ["react-toastify@11.1.0", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg=="], "read-pkg": ["read-pkg@3.0.0", "", { "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" } }, "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA=="], @@ -2137,8 +2114,6 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - "relay-runtime": ["relay-runtime@12.0.0", "", { "dependencies": { "@babel/runtime": "^7.0.0", "fbjs": "^3.0.0", "invariant": "^2.2.4" } }, "sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug=="], - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -2171,7 +2146,7 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="], + "rollup": ["rollup@4.60.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -2195,7 +2170,7 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -2213,8 +2188,6 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], @@ -2241,15 +2214,13 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "signedsource": ["signedsource@1.0.0", "", {}, "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww=="], - "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], - "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "sliced": ["sliced@1.0.1", "", {}, "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA=="], @@ -2289,8 +2260,6 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string.prototype.padend": ["string.prototype.padend@3.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q=="], "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], @@ -2301,9 +2270,7 @@ "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], @@ -2325,7 +2292,7 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "sync-fetch": ["sync-fetch@0.6.0-2", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-c7AfkZ9udatCuAy9RSfiGPpeOKKUAUK5e1cXadLOGUjasdxqYqAK0jTNkM/FSEyJ3a5Ra27j/tw/PS0qLmaF/A=="], + "sync-fetch": ["sync-fetch@0.6.0", "", { "dependencies": { "node-fetch": "^3.3.2", "timeout-signal": "^2.0.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], @@ -2341,7 +2308,7 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], @@ -2367,13 +2334,13 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], "ts-log": ["ts-log@2.2.7", "", {}, "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg=="], "ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="], - "tslib": ["tslib@2.6.3", "", {}, "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -2389,9 +2356,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typescript-eslint": ["typescript-eslint@8.46.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.1", "@typescript-eslint/parser": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/utils": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA=="], - - "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], + "typescript-eslint": ["typescript-eslint@8.59.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.1", "@typescript-eslint/parser": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], @@ -2445,7 +2410,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@5.4.20", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g=="], + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], @@ -2477,8 +2442,6 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], @@ -2493,7 +2456,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -2511,12 +2474,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ardatan/relay-compiler/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-compilation-targets/browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -2525,16 +2484,6 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@emnapi/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@envelop/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@envelop/instrumentation/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@envelop/types/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -2545,63 +2494,13 @@ "@fastify/otel/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@graphql-codegen/cli/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/apollo-engine-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/batch-execute/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/code-file-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/delegate/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/documents/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor-graphql-ws/@graphql-tools/executor-common": ["@graphql-tools/executor-common@0.0.6", "", { "dependencies": { "@envelop/core": "^5.3.0", "@graphql-tools/utils": "^10.9.1" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-JAH/R1zf77CSkpYATIJw+eOJwsbWocdDjY+avY7G+P5HCXxwQjAjWVkJI1QJBQYjPQDVxwf1fmTZlIN3VOadow=="], - - "@graphql-tools/executor-graphql-ws/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor-graphql-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "@graphql-tools/executor-graphql-ws/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], - "@graphql-tools/executor-http/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor-legacy-ws/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/executor-legacy-ws/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - - "@graphql-tools/git-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/github-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/graphql-file-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/graphql-tag-pluck/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@graphql-tools/executor-legacy-ws/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "@graphql-tools/import/resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], - "@graphql-tools/import/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/json-file-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/load/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/merge/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/optimize/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/relay-operation-optimizer/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/schema/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/url-loader/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/url-loader/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - - "@graphql-tools/utils/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@graphql-tools/wrap/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@graphql-tools/url-loader/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "@hyodotdev/openiap-kit/eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="], @@ -2613,14 +2512,10 @@ "@hyodotdev/openiap-kit/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], - "@hyodotdev/openiap-mcp-server/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@hyodotdev/openiap-mcp-server/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - - "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@node-rs/argon2-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="], @@ -2647,45 +2542,31 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@theguild/federation-composition/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - - "@tybys/wasm-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@types/connect/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], - - "@types/jsonwebtoken/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], - - "@types/mysql/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/connect/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/node-fetch/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/jsonwebtoken/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/pg/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/mysql/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/tedious/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/node-fetch/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@types/ws/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], + "@types/pg/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "@types/tedious/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@whatwg-node/disposablestack/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/ws/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], - "@whatwg-node/node-fetch/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@whatwg-node/promise-helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "bun-types/@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], - - "camel-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "capital-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "bun-types/@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "change-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + "cli-truncate/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2695,61 +2576,39 @@ "color-string/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "constant-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], - "cross-inspect/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "dir-glob/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "dot-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "flat-cache/flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "glob/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "graphql-config/cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="], - "graphql-config/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "graphql-config/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "graphql-tag/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "header-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "graphql-config/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "is-lower-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "is-upper-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], - "lower-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "lower-case-first/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "no-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -2759,34 +2618,18 @@ "npm-run-all/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], - "npm-run-all/pidtree": ["pidtree@0.3.1", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA=="], - - "param-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "pascal-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "path-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "path-scurry/lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "react-helmet-async/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], - "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], - "relay-runtime/@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], - "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "sentence-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "sharp-cli/sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="], "simple-swizzle/is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], @@ -2795,48 +2638,20 @@ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "snake-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "sponge-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "swap-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "sync-fetch/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "sync-fetch/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], - "title-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "upper-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "upper-case-first/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "vitest/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@babel/helper-compilation-targets/browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw=="], - - "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001751", "", {}, "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="], - - "@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.237", "", {}, "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg=="], - - "@babel/helper-compilation-targets/browserslist/node-releases": ["node-releases@2.0.25", "", {}, "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA=="], - - "@babel/helper-compilation-targets/browserslist/update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], - "@fastify/otel/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.212.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg=="], "@fastify/otel/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], @@ -2847,45 +2662,37 @@ "@hyodotdev/openiap-kit/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "@hyodotdev/openiap-mcp-server/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@hyodotdev/openiap-mcp-server/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@node-rs/argon2-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="], "@prisma/instrumentation/@opentelemetry/instrumentation/import-in-the-middle": ["import-in-the-middle@2.0.6", "", { "dependencies": { "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw=="], - "@types/connect/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/connect/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/jsonwebtoken/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/mysql/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/mysql/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/node-fetch/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/node-fetch/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/pg/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/pg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/tedious/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/tedious/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/ws/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "bun-types/@types/node/undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "chalk/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -2947,9 +2754,15 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "graphql-config/cosmiconfig/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], - "graphql-config/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "graphql-config/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -3011,8 +2824,6 @@ "vitest/vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "wrap-ansi-cjs/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "@fastify/otel/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -3065,16 +2876,20 @@ "@inquirer/core/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "graphql-config/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "npm-run-all/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "npm-run-all/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], - "sharp-cli/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], - "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -3121,12 +2936,8 @@ "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - "wrap-ansi-cjs/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "@inquirer/core/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "sharp-cli/sharp/@img/sharp-wasm32/@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], } } diff --git a/package.json b/package.json index 8d7d4172..9142b348 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,5 @@ "overrides": { "csstype": "3.2.3" }, - "packageManager": "bun@1.3.0" + "packageManager": "bun@1.3.13" } From 9dd9fcecbaecf1601f88955d4ea524db1d9aca06 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 15:56:39 +0900 Subject: [PATCH 11/81] fix(ci): bump CI bun to 1.3.13 + auto-fix no-unnecessary-type-assertion PR #124 still failed after the previous lockfile pin because: 1. **Lockfile drift across bun versions**: GitHub Actions workflows (`ci.yml`, `deploy-kit.yml`, `dependabot-bun-lockfile.yml`) pinned `bun-version: 1.3.0`, but `packages/kit/Dockerfile` pins `oven/bun:1.3.13-slim`. So even after I regenerated the lockfile with 1.3.13 (matching Docker), the `Test GQL Types` job ran with 1.3.0 which sees drift. - Bumped all three workflow files to `bun-version: 1.3.13` so every CI step + Docker uses the same bun. 2. **62 lint errors after the bun bump**: bun 1.3.13 resolved a newer `@typescript-eslint/eslint-plugin` that ships `no-unnecessary-type-assertion` enabled. All 62 errors were pre-existing (unrelated to this PR's changes) but newly surfaced after the dependency upgrade. Ran `eslint --fix` which auto-resolved every one cleanly. 3. **Prettier reformat ripple**: a few of the auto-fixed files needed prettier reflow after the type-assertion drops. Re-ran prettier on the affected files; pre-commit gate stays green. Local verification: - `packages/kit/`: lint 0 errors, 281/281 vitest, smoke green, prettier clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 10 +-- .github/workflows/dependabot-bun-lockfile.yml | 2 +- .github/workflows/deploy-kit.yml | 4 +- packages/kit/convex/migrations.ts | 2 +- packages/kit/convex/purchases/cleanup.test.ts | 4 +- .../save-purchase-idempotency.test.ts | 45 +++++-------- .../purchases/stats-integration.test.ts | 66 +++++++++---------- .../convex/subscriptions/horizonInternal.ts | 4 +- packages/kit/convex/subscriptions/internal.ts | 4 +- .../drain-account-deletion.test.ts | 2 +- packages/kit/convex/webhooks/google.ts | 2 +- packages/kit/convex/webhooks/shared.test.ts | 7 +- .../kit/server/api/v1/request-logger.test.ts | 4 +- packages/kit/server/api/v1/validator.ts | 44 +++++++------ packages/kit/src/components/Dropdown.tsx | 6 +- .../kit/src/pages/auth/organization/index.tsx | 4 +- .../auth/organization/project/settings.tsx | 6 +- 17 files changed, 100 insertions(+), 116 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b62db796..0ec81959 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies run: | @@ -129,7 +129,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies run: | @@ -173,7 +173,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies run: | @@ -209,7 +209,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies run: | @@ -249,7 +249,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies working-directory: scripts/agent diff --git a/.github/workflows/dependabot-bun-lockfile.yml b/.github/workflows/dependabot-bun-lockfile.yml index 1a52f363..12134cd6 100644 --- a/.github/workflows/dependabot-bun-lockfile.yml +++ b/.github/workflows/dependabot-bun-lockfile.yml @@ -31,7 +31,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Run bun install id: install diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index c6bdbc2e..50de8425 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Install dependencies (workspace root) working-directory: ${{ github.workspace }} @@ -138,7 +138,7 @@ jobs: if: ${{ env.KIT_CONVEX_DEPLOY_KEY != '' }} uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.0 + bun-version: 1.3.13 - name: Deploy Convex functions if: ${{ env.KIT_CONVEX_DEPLOY_KEY != '' }} diff --git a/packages/kit/convex/migrations.ts b/packages/kit/convex/migrations.ts index 2dd1f05d..3cf0bc40 100644 --- a/packages/kit/convex/migrations.ts +++ b/packages/kit/convex/migrations.ts @@ -69,7 +69,7 @@ export const removeLegacyProfileFields = migrations.define({ export const replaceIsAuthenticWithIsValid = migrations.define({ table: "purchases", migrateOne: async (_ctx, doc) => { - const isValid = isValidState(doc.state as HarmonizedPurchaseState); + const isValid = isValidState(doc.state); return { ...doc, diff --git a/packages/kit/convex/purchases/cleanup.test.ts b/packages/kit/convex/purchases/cleanup.test.ts index 78fad446..68dcfea8 100644 --- a/packages/kit/convex/purchases/cleanup.test.ts +++ b/packages/kit/convex/purchases/cleanup.test.ts @@ -132,7 +132,7 @@ class MemDb { slug: "test-project", createdAt: Date.now(), updatedAt: Date.now(), - } as Row); + }); return id; } @@ -154,7 +154,7 @@ class MemDb { orderId: attrs.orderId, isValid: attrs.isValid ?? true, state: "ENTITLED", - } as Row); + }); } allPurchases(): Row[] { diff --git a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts index 9e7f226f..81f63308 100644 --- a/packages/kit/convex/purchases/save-purchase-idempotency.test.ts +++ b/packages/kit/convex/purchases/save-purchase-idempotency.test.ts @@ -157,7 +157,7 @@ class MemDb { createdAt: Date.now(), updatedAt: Date.now(), ...doc, - } as Row); + }); return id; } @@ -170,7 +170,7 @@ class MemDb { slug: "test-project", createdAt: Date.now(), updatedAt: Date.now(), - } as Row); + }); return id; } @@ -253,10 +253,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { it("re-validation with the same remoteId does NOT re-increment stats.total", async () => { await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) }); - const afterFirst = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const afterFirst = await readPurchaseStats(ctx, PROJECT_ID as never); expect(afterFirst.total).toBe(1); expect(afterFirst.google).toBe(1); @@ -264,10 +261,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) }); await savePurchaseInternal({ ctx, ...buildArgs({ remoteId: TOKEN }) }); - const afterRepeat = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const afterRepeat = await readPurchaseStats(ctx, PROJECT_ID as never); expect(afterRepeat.total).toBe(1); expect(afterRepeat.google).toBe(1); }); @@ -294,7 +288,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(db.purchaseCount()).toBe(1); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); // Total stays at 1. Valid moves 1 → 0, invalid moves 0 → 1. expect(stats.total).toBe(1); expect(stats.valid).toBe(0); @@ -312,7 +306,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }); expect(db.purchaseCount()).toBe(2); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.total).toBe(2); expect(stats.google).toBe(2); }); @@ -361,7 +355,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(rows[0]?.remoteId).toBe("token_after_reissue"); expect(rows[0]?.orderId).toBe("GPA.3328-5001-2345-67890"); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.total).toBe(1); expect(stats.google).toBe(1); // Distinct Play Console orders: exactly one, because the two @@ -392,7 +386,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }); expect(db.purchaseCount()).toBe(2); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.total).toBe(2); expect(stats.google).toBe(2); expect(stats.googleOrders).toBe(2); @@ -416,7 +410,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(db.purchaseCount()).toBe(2); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); // Two rows but no distinct orders yet — googleOrders stays at 0 // until a later re-verify surfaces an orderId. expect(stats.google).toBe(2); @@ -436,10 +430,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }), }); - const beforeAck = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const beforeAck = await readPurchaseStats(ctx, PROJECT_ID as never); expect(beforeAck.googleOrders).toBe(0); await savePurchaseInternal({ @@ -457,7 +448,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { }); expect(db.purchaseCount()).toBe(1); - const afterAck = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const afterAck = await readPurchaseStats(ctx, PROJECT_ID as never); expect(afterAck.google).toBe(1); expect(afterAck.googleOrders).toBe(1); expect(afterAck.total).toBe(1); @@ -514,10 +505,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { ...buildArgs({ remoteId: TOKEN, remoteResponse: ackResponse }), }); - const beforeError = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const beforeError = await readPurchaseStats(ctx, PROJECT_ID as never); expect(beforeError.googleOrders).toBe(1); // A later re-verify gets a transient Google failure — we persist @@ -539,10 +527,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { const rows = await db.query("purchases").collect(); expect(rows[0]?.orderId).toBe("GPA.order-stable"); - const afterError = await readPurchaseStats( - ctx as never, - PROJECT_ID as never, - ); + const afterError = await readPurchaseStats(ctx, PROJECT_ID as never); // googleOrders must stay at 1: the distinct orderId on this row // is still present in the database, just not in the latest // response payload. @@ -593,7 +578,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(rows[0]?.remoteId).toBe("token_initial"); expect(rows[0]?.orderId).toBe("GPA.only-one-logical-order"); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.google).toBe(1); expect(stats.googleOrders).toBe(1); expect(stats.total).toBe(1); @@ -657,7 +642,7 @@ describe("savePurchaseInternal — idempotency regression guard", () => { expect(rows[0]?.state).toBe(HarmonizedPurchaseState.ENTITLED); expect(rows[0]?.isValid).toBe(true); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.total).toBe(1); expect(stats.valid).toBe(1); expect(stats.invalid).toBe(0); diff --git a/packages/kit/convex/purchases/stats-integration.test.ts b/packages/kit/convex/purchases/stats-integration.test.ts index 5cc319d5..f21e2d76 100644 --- a/packages/kit/convex/purchases/stats-integration.test.ts +++ b/packages/kit/convex/purchases/stats-integration.test.ts @@ -138,7 +138,7 @@ describe("stats helpers — round-trip integration", () => { }); it("readPurchaseStats returns zeros when no row exists yet", async () => { - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 0, apple: 0, @@ -151,12 +151,12 @@ describe("stats helpers — round-trip integration", () => { it("applyPurchaseStatsDelta creates a row on first insert-delta", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 1, apple: 1, @@ -169,29 +169,29 @@ describe("stats helpers — round-trip integration", () => { it("accumulates correctly across multiple insert-deltas for mixed stores/validity", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, // pending-ack google insert — row count but no order yet deltaForInsert("google", true), ); await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, // invalid google with orderId — order but marked invalid deltaForInsert("google", false, true), ); await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", false), ); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 4, apple: 2, @@ -206,7 +206,7 @@ describe("stats helpers — round-trip integration", () => { it("markReceiptInvalid-style flip preserves total and moves valid -> invalid", async () => { // Seed: one apple valid receipt. await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); @@ -215,12 +215,12 @@ describe("stats helpers — round-trip integration", () => { // Apple receipts don't carry a Google orderId, so the last two // args are false/false. await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("apple", true, "apple", false, false, false), ); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 1, apple: 1, @@ -234,7 +234,7 @@ describe("stats helpers — round-trip integration", () => { describe("wasFirstValidTransition", () => { it("is true on the insert that bumps valid from 0 to 1", async () => { const result = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); @@ -243,12 +243,12 @@ describe("stats helpers — round-trip integration", () => { it("is false on a second valid insert (valid was already 1)", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); const second = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); @@ -257,7 +257,7 @@ describe("stats helpers — round-trip integration", () => { it("is false on an invalid insert (valid stayed at 0)", async () => { const result = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", false), ); @@ -267,13 +267,13 @@ describe("stats helpers — round-trip integration", () => { it("is true when a retry flips an existing row from invalid to valid (0 → 1)", async () => { // Seed: invalid row → valid:0, invalid:1 await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("google", false), ); // Retry succeeds — deltaForUpdate moves valid 0 → 1 const flip = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("google", false, "google", true), ); @@ -282,12 +282,12 @@ describe("stats helpers — round-trip integration", () => { it("is false when a valid row is flipped to invalid (1 → 0, not an activation)", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); const flip = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("apple", true, "apple", false), ); @@ -296,7 +296,7 @@ describe("stats helpers — round-trip integration", () => { it("is false on a no-op delta (early-return branch)", async () => { const result = await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, {}, ); @@ -306,20 +306,20 @@ describe("stats helpers — round-trip integration", () => { it("remoteId upsert with unchanged (store, isValid) emits no counter movement", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("google", true), ); - const before = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const before = await readPurchaseStats(ctx, PROJECT_ID as never); await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("google", true, "google", true), ); - const after = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const after = await readPurchaseStats(ctx, PROJECT_ID as never); expect(after).toEqual(before); }); @@ -327,12 +327,12 @@ describe("stats helpers — round-trip integration", () => { // No insert — now simulate a rogue 'valid -> invalid' flip on nothing. // Counters should not dip below zero. await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForUpdate("apple", true, "apple", false), ); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats.valid).toBe(0); expect(stats.invalid).toBe(1); expect(stats.total).toBeGreaterThanOrEqual(0); @@ -340,14 +340,14 @@ describe("stats helpers — round-trip integration", () => { it("deletePurchaseStatsForProject removes the stats row", async () => { await applyPurchaseStatsDelta( - ctx as never, + ctx, PROJECT_ID as never, deltaForInsert("apple", true), ); - await deletePurchaseStatsForProject(ctx as never, PROJECT_ID as never); + await deletePurchaseStatsForProject(ctx, PROJECT_ID as never); - const stats = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const stats = await readPurchaseStats(ctx, PROJECT_ID as never); expect(stats).toEqual({ total: 0, apple: 0, @@ -415,7 +415,7 @@ describe("stats helpers — round-trip integration", () => { }); const totals = await recomputePurchaseStatsForProject( - ctx as never, + ctx, PROJECT_ID as never, ); expect(totals).toEqual({ @@ -430,7 +430,7 @@ describe("stats helpers — round-trip integration", () => { }); // Persisted to the stats table so subsequent reads are O(1). - const read = await readPurchaseStats(ctx as never, PROJECT_ID as never); + const read = await readPurchaseStats(ctx, PROJECT_ID as never); expect(read).toEqual(totals); }); @@ -450,11 +450,11 @@ describe("stats helpers — round-trip integration", () => { }); const first = await recomputePurchaseStatsForProject( - ctx as never, + ctx, PROJECT_ID as never, ); const second = await recomputePurchaseStatsForProject( - ctx as never, + ctx, PROJECT_ID as never, ); expect(second).toEqual(first); diff --git a/packages/kit/convex/subscriptions/horizonInternal.ts b/packages/kit/convex/subscriptions/horizonInternal.ts index 423902cf..3bfa417f 100644 --- a/packages/kit/convex/subscriptions/horizonInternal.ts +++ b/packages/kit/convex/subscriptions/horizonInternal.ts @@ -131,9 +131,7 @@ export const recordHorizonStatus = internalMutation({ expiresAt: existing.expiresAt, renewsAt: existing.renewsAt, willRenew: existing.willRenew, - cancellationReason: existing.cancellationReason as - | NonNullable["cancellationReason"] - | undefined, + cancellationReason: existing.cancellationReason, currency: existing.currency, priceAmountMicros: existing.priceAmountMicros, }; diff --git a/packages/kit/convex/subscriptions/internal.ts b/packages/kit/convex/subscriptions/internal.ts index 5f0ab2ea..d1aa0072 100644 --- a/packages/kit/convex/subscriptions/internal.ts +++ b/packages/kit/convex/subscriptions/internal.ts @@ -73,9 +73,7 @@ export const applySubscriptionEvent = internalMutation({ expiresAt: existing.expiresAt, renewsAt: existing.renewsAt, willRenew: existing.willRenew, - cancellationReason: existing.cancellationReason as - | NonNullable["cancellationReason"] - | undefined, + cancellationReason: existing.cancellationReason, currency: existing.currency, priceAmountMicros: existing.priceAmountMicros, } diff --git a/packages/kit/convex/userProfiles/drain-account-deletion.test.ts b/packages/kit/convex/userProfiles/drain-account-deletion.test.ts index 438bb013..dcdb2334 100644 --- a/packages/kit/convex/userProfiles/drain-account-deletion.test.ts +++ b/packages/kit/convex/userProfiles/drain-account-deletion.test.ts @@ -225,7 +225,7 @@ async function drainAccountDeletionBatch( const remaining = await ctx.db .query("organizationMembers") .withIndex("by_organization", (q) => - q.eq("organizationId", membership.organizationId as string), + q.eq("organizationId", membership.organizationId), ) .take(ACCOUNT_DELETION_PAGE); if (remaining.length === 0) { diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts index 5c5456e2..3ea75d17 100644 --- a/packages/kit/convex/webhooks/google.ts +++ b/packages/kit/convex/webhooks/google.ts @@ -91,7 +91,7 @@ export const ingestGoogleRtdn = action({ let normalized; try { normalized = normalizeGoogleRtdn({ - payload: args.payload as GoogleRtdnPayload, + payload: args.payload, subscriptionInfo, }); } catch (error) { diff --git a/packages/kit/convex/webhooks/shared.test.ts b/packages/kit/convex/webhooks/shared.test.ts index f4ec5532..6cebbe42 100644 --- a/packages/kit/convex/webhooks/shared.test.ts +++ b/packages/kit/convex/webhooks/shared.test.ts @@ -8,7 +8,6 @@ import { WebhookNormalizationError, type AppleAsnPayload, type AppleDecodedTransaction, - type AppleDecodedRenewalInfo, type GoogleRtdnPayload, type GoogleSubscriptionInfo, } from "./shared"; @@ -183,7 +182,7 @@ describe("normalizeAppleAsn", () => { const event = normalizeAppleAsn({ payload: { ...baseApplePayload, notificationType: "EXPIRED" }, transaction: baseTransaction, - renewalInfo: { expirationIntent: intent } as AppleDecodedRenewalInfo, + renewalInfo: { expirationIntent: intent }, }); expect(event.type).toBe("SubscriptionExpired"); expect(event.cancellationReason).toBe(reason); @@ -237,7 +236,7 @@ describe("normalizeAppleAsn", () => { normalizeAppleAsn({ payload: { ...baseApplePayload, - notificationUUID: "" as string, + notificationUUID: "", }, transaction: baseTransaction, }), @@ -473,7 +472,7 @@ describe("normalizeGoogleRtdn", () => { normalizeGoogleRtdn({ payload: { ...baseRtdnSubscription, - messageId: "" as string, + messageId: "", }, }), ).toThrow(/messageId/); diff --git a/packages/kit/server/api/v1/request-logger.test.ts b/packages/kit/server/api/v1/request-logger.test.ts index e14179ac..fcc6067e 100644 --- a/packages/kit/server/api/v1/request-logger.test.ts +++ b/packages/kit/server/api/v1/request-logger.test.ts @@ -50,9 +50,7 @@ function buildApp(params: { validator(verifyPurchaseInputSchema), (c) => { if (params.handler) { - return params.handler( - c as unknown as Parameters>[0], - ); + return params.handler(c); } c.set("verifyOutcome", { isValid: true, state: "ENTITLED" }); return c.json({ isValid: true, state: "ENTITLED" }); diff --git a/packages/kit/server/api/v1/validator.ts b/packages/kit/server/api/v1/validator.ts index cfd9f070..30ca97b5 100644 --- a/packages/kit/server/api/v1/validator.ts +++ b/packages/kit/server/api/v1/validator.ts @@ -14,28 +14,32 @@ type ValidatorResult = export function validator[1]>( schema: Schema, ) { - return honoValidator("json", schema, (( - result: ValidatorResult, - // Hono context is typed as `any`-generic here intentionally — see - // comment above. We use only `c.json(...)`, which is stable. - c: { json: (body: unknown, status: number) => Response }, - ) => { - if (result.success) { - return; - } + return honoValidator( + "json", + schema, + ( + result: ValidatorResult, + // Hono context is typed as `any`-generic here intentionally — see + // comment above. We use only `c.json(...)`, which is stable. + c: { json: (body: unknown, status: number) => Response }, + ) => { + if (result.success) { + return; + } - const errors = []; + const errors = []; - for (const issue of result.error) { - errors.push({ - code: "INVALID_INPUT", - message: issue.message, - path: issuePathToString(issue.path), - }); - } + for (const issue of result.error) { + errors.push({ + code: "INVALID_INPUT", + message: issue.message, + path: issuePathToString(issue.path), + }); + } - return c.json({ errors }, 400); - }) as Parameters[2]); + return c.json({ errors }, 400); + }, + ); } function issuePathToString( @@ -63,7 +67,7 @@ function issuePathToString( // path like `["a", unknown, "b"]` would serialize to `"a..b"` // and break client-side error mapping. if (segment !== null && typeof segment === "object" && "key" in segment) { - const key = (segment as { key: unknown }).key; + const key = segment.key; if (typeof key === "string") { segments.push(key); } else if (typeof key === "number") { diff --git a/packages/kit/src/components/Dropdown.tsx b/packages/kit/src/components/Dropdown.tsx index d9cb25cd..0ac11fbe 100644 --- a/packages/kit/src/components/Dropdown.tsx +++ b/packages/kit/src/components/Dropdown.tsx @@ -6,8 +6,10 @@ interface DropdownOption { label: string; } -interface DropdownProps - extends Omit, "className"> { +interface DropdownProps extends Omit< + SelectHTMLAttributes, + "className" +> { options: DropdownOption[]; className?: string; } diff --git a/packages/kit/src/pages/auth/organization/index.tsx b/packages/kit/src/pages/auth/organization/index.tsx index 130ee77e..627e7d63 100644 --- a/packages/kit/src/pages/auth/organization/index.tsx +++ b/packages/kit/src/pages/auth/organization/index.tsx @@ -38,7 +38,7 @@ export default function OrganizationLayout() { ); return safeOrgs.map((org) => ({ - _id: org._id as Id<"organizations">, + _id: org._id, name: org.name, slug: org.slug, })); @@ -188,7 +188,7 @@ export default function OrganizationLayout() { diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index 72dc7850..abd98128 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -63,9 +63,9 @@ export default function ProjectSettings() { const [applePlatformsSelected, setApplePlatformsSelected] = useState( Boolean( project?.iosBundleId || - project?.iosAppAppleId || - project?.iosAppStoreIssuerId || - project?.iosAppStoreKeyId, + project?.iosAppAppleId || + project?.iosAppStoreIssuerId || + project?.iosAppStoreKeyId, ), ); const [androidPlatformsSelected, setAndroidPlatformsSelected] = useState( From c4b5eac3b74a8062c40e16f298d138d9dac040e7 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 16:11:12 +0900 Subject: [PATCH 12/81] fix(kit): add mcp-server to Dockerfile workspace COPY list The Dockerfile's deps stage copies each workspace package.json so bun can plan the install graph against the root lockfile. PR #124 added `packages/mcp-server` as a new workspace but didn't update the Dockerfile, so bun saw the lockfile reference an unknown workspace member and rejected `--frozen-lockfile` with "lockfile had changes, but lockfile is frozen". Adding the COPY line for mcp-server. Local verification reproducing exactly what CI runs (root bun + workspace filter): bun install --frozen-lockfile --filter @hyodotdev/openiap-kit passes cleanly with no changes after the fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/kit/Dockerfile b/packages/kit/Dockerfile index 17e712a8..f02579fb 100644 --- a/packages/kit/Dockerfile +++ b/packages/kit/Dockerfile @@ -16,6 +16,11 @@ COPY packages/gql/package.json ./packages/gql/ COPY packages/apple/package.json ./packages/apple/ COPY packages/google/package.json ./packages/google/ COPY packages/docs/package.json ./packages/docs/ +# `mcp-server` is a workspace member (added in PR #124). Without +# its package.json bun's workspace resolver disagrees with the +# root lockfile and `--frozen-lockfile` rejects the install with +# "lockfile had changes, but lockfile is frozen". +COPY packages/mcp-server/package.json ./packages/mcp-server/ RUN bun install --frozen-lockfile --filter @hyodotdev/openiap-kit # --- Build the unified app (Vite SPA + compiled Bun server) -------------- From f08f816ffe09a8a1b78135e8d70e73f15580a106 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 16:16:52 +0900 Subject: [PATCH 13/81] fix(kit): copy kit's local node_modules into builder stage so vite resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun 1.3.13 + workspaces installs some deps (vite, @vitejs/plugin-react) under `packages/kit/node_modules/` instead of fully hoisting them to the workspace root. The Dockerfile's builder stage was only copying `/app/node_modules` from deps, so `bun run build:all` failed with `vite: command not found` even though the install step succeeded. Adding a second COPY for `packages/kit/node_modules` after the source copy (so it isn't clobbered by `COPY packages/kit ./packages/kit`). Tested locally with the same `bun install --frozen-lockfile --filter @hyodotdev/openiap-kit` flow — kit/node_modules contains `.bin/vite`. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/Dockerfile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/kit/Dockerfile b/packages/kit/Dockerfile index f02579fb..07df8dd6 100644 --- a/packages/kit/Dockerfile +++ b/packages/kit/Dockerfile @@ -29,6 +29,13 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY package.json bun.lock ./ COPY packages/kit ./packages/kit +# bun 1.3.13 + workspaces installs some deps under each package's +# local node_modules (e.g. `vite`, `@vitejs/plugin-react`) instead of +# fully hoisting. Pull kit's local node_modules from the deps stage +# AFTER the source COPY so it isn't clobbered. Without this the +# build fails with `vite: command not found` (PR #124 review +# fallout). +COPY --from=deps /app/packages/kit/node_modules ./packages/kit/node_modules WORKDIR /app/packages/kit ARG VITE_KIT_CONVEX_URL ENV VITE_KIT_CONVEX_URL=${VITE_KIT_CONVEX_URL} From a0946003683f65078044d50718235c62b5fdb580 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 18:47:41 +0900 Subject: [PATCH 14/81] fix(webhooks): address PR #124 review (IOS suffix, ConvexError, BigInt, _creationTime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 medium-priority gemini-code-assist findings on the open PR: 1. **ingestAppleAsn → ingestAppleAsnIOS**: iOS-specific Convex actions follow the openiap iOS suffix convention. Renamed and updated the one Hono caller to use `api.webhooks.apple.ingestAppleAsnIOS`. 2. **ConvexError for bundle-ID mismatch**: Plain `Error` would surface from the Hono layer as a 500 Internal Server Error, which Apple ASN treats as transient and retries. A bundle mismatch is a permanent configuration failure — should be 400 (no retry). Switched to `ConvexError({ code: "BUNDLE_ID_MISMATCH", message })` so `mapWebhookError` translates it cleanly. 3. **BigInt for Google `Money.units`**: `units` is a string-encoded BigInt per the Money proto. `Number(units) * 1_000_000` loses precision above 2^53 currency units (rare for everyday subs but possible for currency-tier conversions or weird locales). Switched to `BigInt(units) * 1_000_000n + BigInt(round(nanos / 1000))` and `Number()` only at the very end. Applied in both the webhook subscription enrichment (`webhooks/google.ts`) and the product sync (`products/play.ts`). 4. **Surface `_creationTime` + `_id` in webhookEventsSince**: the query's docstring promised `_creationTime` for tie-breaking when two events share `receivedAt`, but the validator + mapping stripped it. Added both `_creationTime` and the Convex `_id` to the response shape so SDKs can checkpoint with a stable monotonic key. Verification: - kit lint clean (0 errors); 281/281 vitest; smoke green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/convex/products/play.ts | 15 +++++++++++---- packages/kit/convex/webhooks/apple.ts | 21 ++++++++++++++++----- packages/kit/convex/webhooks/google.ts | 14 ++++++++++---- packages/kit/convex/webhooks/query.ts | 11 +++++++++++ packages/kit/server/api/v1/webhooks.ts | 2 +- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index 4c5c4122..f9ad9868 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -259,10 +259,17 @@ function parseSubBasePlanPriceMicros( ? null : sub.basePlans?.[0]?.regionalConfigs?.[0]?.price; if (!recurring?.units) return undefined; - const units = Number(recurring.units); - const nanos = recurring.nanos ?? 0; - if (!Number.isFinite(units)) return undefined; - return units * 1_000_000 + Math.round(nanos / 1_000); + // Google's `Money` proto: `units` is a BigInt-as-string. Do the + // micros multiplication in BigInt to avoid precision loss for + // large currency values (>2^53). PR #124 review fix. + try { + const microsBigInt = + BigInt(recurring.units) * 1_000_000n + + BigInt(Math.round((recurring.nanos ?? 0) / 1_000)); + return Number(microsBigInt); + } catch { + return undefined; + } } function parseSubBasePlanCurrency( diff --git a/packages/kit/convex/webhooks/apple.ts b/packages/kit/convex/webhooks/apple.ts index 12f33c55..59228d0b 100644 --- a/packages/kit/convex/webhooks/apple.ts +++ b/packages/kit/convex/webhooks/apple.ts @@ -6,7 +6,7 @@ import { type JWSTransactionDecodedPayload, type JWSRenewalInfoDecodedPayload, } from "@apple/app-store-server-library"; -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; import { action } from "../_generated/server"; import { internal } from "../_generated/api"; @@ -37,7 +37,13 @@ type IngestResult = { // Apple retries the same notificationUUID on transient 5xx — that case // is collapsed inside `recordWebhookEvent` (returns `deduped: true`) // and the route still responds 200 so Apple stops retrying. -export const ingestAppleAsn = action({ +// +// Naming: follows the openiap iOS suffix convention +// (`knowledge/internal/01-naming-conventions.md`) — iOS-specific +// functions end in `IOS`. Even though "Apple" already implies iOS, +// the convention is mechanical and applies to every iOS-only entry +// point. +export const ingestAppleAsnIOS = action({ args: { apiKey: v.string(), signedPayload: v.string(), @@ -60,9 +66,14 @@ export const ingestAppleAsn = action({ previewPayload.data?.bundleId && previewPayload.data.bundleId !== project.iosBundleId ) { - throw new Error( - `Bundle ID mismatch: notification ${previewPayload.data.bundleId} vs project ${project.iosBundleId}`, - ); + // ConvexError so the Hono layer's `mapWebhookError` translates + // this to a 400, not a 500. A bundle mismatch is a permanent + // configuration error — Apple should NOT retry, and 5xx + // triggers automatic retries from ASN that we don't want. + throw new ConvexError({ + code: "BUNDLE_ID_MISMATCH", + message: `Bundle ID mismatch: notification ${previewPayload.data.bundleId} vs project ${project.iosBundleId}`, + }); } const environment = mapPreviewEnvironment(previewPayload.data?.environment); diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts index 3ea75d17..0dc2e468 100644 --- a/packages/kit/convex/webhooks/google.ts +++ b/packages/kit/convex/webhooks/google.ts @@ -227,10 +227,16 @@ async function maybeFetchSubscriptionInfo( autoRenewingPlanRenewsTimeMillis: renews ? Date.parse(renews) : undefined, currency: recurring?.currencyCode ?? undefined, priceAmountMicros: recurring?.units - ? // `units` is BigInt-as-string, `nanos` is the fractional part. - // Combine to micros: units * 1_000_000 + nanos / 1_000. - Number(recurring.units) * 1_000_000 + - Math.round((recurring.nanos ?? 0) / 1_000) + ? // `units` is BigInt-as-string per Google's `Money` proto; do + // the multiplication in BigInt so very-large currency values + // don't lose precision through Number's 2^53 ceiling. nanos + // is small (always < 1e9) so keeping it in Number is fine. + // PR #124 review caught the prior `Number(units) * 1_000_000` + // approach. + Number( + BigInt(recurring.units) * 1_000_000n + + BigInt(Math.round((recurring.nanos ?? 0) / 1_000)), + ) : undefined, }; } catch (error) { diff --git a/packages/kit/convex/webhooks/query.ts b/packages/kit/convex/webhooks/query.ts index 3d18cb10..0b197d5c 100644 --- a/packages/kit/convex/webhooks/query.ts +++ b/packages/kit/convex/webhooks/query.ts @@ -27,6 +27,15 @@ export const webhookEventsSince = query({ returns: v.array( v.object({ id: v.string(), + // Convex auto-assigned `_creationTime` (epoch ms, monotonic per + // doc insert). Surfaced so SDKs can checkpoint reliably even + // when two events share the same `receivedAt` — the wall-clock + // tie-breaker is not unique under burst writes (PR #124 review + // fix). The Convex doc id (`_id`) is also surfaced for the same + // reason; `id` (sourceNotificationId) stays the spec-stable + // identifier consumers gate on. + _creationTime: v.number(), + _id: v.id("webhookEvents"), type: webhookEventTypeValidator, source: webhookEventSourceValidator, platform: webhookEventPlatformValidator, @@ -73,6 +82,8 @@ export const webhookEventsSince = query({ // the store; ASN v2 notificationUUID and RTDN messageId are both // globally unique and survive replay/dedup. id: event.sourceNotificationId, + _creationTime: event._creationTime, + _id: event._id, type: event.type, source: event.source, platform: event.platform, diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts index f4e0c14c..fea084d0 100644 --- a/packages/kit/server/api/v1/webhooks.ts +++ b/packages/kit/server/api/v1/webhooks.ts @@ -193,7 +193,7 @@ async function handleAppleNotification( ); } try { - const result = await client.action(api.webhooks.apple.ingestAppleAsn, { + const result = await client.action(api.webhooks.apple.ingestAppleAsnIOS, { apiKey, signedPayload: body.signedPayload, }); From 84b1b5e6630452352cf5dcc2aa69133db8c5959c Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 19:06:53 +0900 Subject: [PATCH 15/81] feat(kit): file downloads + Configure-now links + drop meaningless Disable Horizon button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small UX cleanups on the project Settings + Webhooks pages: 1. **Download buttons for uploaded credential files**: an admin can now re-download the .p8 they uploaded for App Store Connect or the service-account JSON for Play. Useful for rotating across projects or double-checking what kit holds against the upstream console. - New `convex/files/action.ts::downloadFile` action with the same admin-or-owner guard that `files.mutation.remove` enforces. Returns base64 + mimeType + fileName. - New `convex/organizations/internal.ts::getMembership` internal query the action runs for the membership check (actions can't touch ctx.db directly). - Settings UI: Download icon next to the existing Trash2 icon for both iOS .p8 and Android service-account JSON. 2. **"Configure now →" links on missing setup badges**: the Webhooks page's iOS / Android / Horizon setup badges previously only showed "Not configured" + the missing fields. Now an in-card link routes the user straight to the project Settings page. 3. **Drop the "Disable Horizon" button**: when a project never had Horizon enabled, the Save button rendered as "Disable Horizon" — but there was nothing to disable. The save button now only appears when the toggle is on (real config to save) or when the user just unchecked a previously-enabled toggle (persisting the disable). Verification: - kit lint clean (0 errors); 281/281 vitest; smoke probes green. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/convex/_generated/api.d.ts | 2 + packages/kit/convex/files/action.ts | 71 ++++++++++ packages/kit/convex/organizations/internal.ts | 31 +++++ .../auth/organization/project/settings.tsx | 130 ++++++++++++++---- .../auth/organization/project/webhooks.tsx | 28 +++- 5 files changed, 230 insertions(+), 32 deletions(-) create mode 100644 packages/kit/convex/files/action.ts diff --git a/packages/kit/convex/_generated/api.d.ts b/packages/kit/convex/_generated/api.d.ts index b36446e8..99f1a044 100644 --- a/packages/kit/convex/_generated/api.d.ts +++ b/packages/kit/convex/_generated/api.d.ts @@ -17,6 +17,7 @@ import type * as apiKeys_query from "../apiKeys/query.js"; import type * as auth from "../auth.js"; import type * as certificates_apple_root_certificates from "../certificates/apple_root_certificates.js"; import type * as crons from "../crons.js"; +import type * as files_action from "../files/action.js"; import type * as files_internal from "../files/internal.js"; import type * as files_mutation from "../files/mutation.js"; import type * as files_query from "../files/query.js"; @@ -91,6 +92,7 @@ declare const fullApi: ApiFromModules<{ auth: typeof auth; "certificates/apple_root_certificates": typeof certificates_apple_root_certificates; crons: typeof crons; + "files/action": typeof files_action; "files/internal": typeof files_internal; "files/mutation": typeof files_mutation; "files/query": typeof files_query; diff --git a/packages/kit/convex/files/action.ts b/packages/kit/convex/files/action.ts new file mode 100644 index 00000000..5f9b618e --- /dev/null +++ b/packages/kit/convex/files/action.ts @@ -0,0 +1,71 @@ +"use node"; +import { action } from "../_generated/server"; +import { v, ConvexError } from "convex/values"; +import { internal } from "../_generated/api"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import type { Id } from "../_generated/dataModel"; + +// Public action to download an uploaded credential file (Apple .p8 or +// Google service-account JSON). The dashboard's Settings page calls +// this so an org admin can re-fetch the original file they uploaded — +// useful when rotating keys, copying to a new project, or +// double-checking the file kit holds matches the one in App Store +// Connect / Play Console. +// +// Auth: same admin-or-owner check `files.mutation.remove` enforces. +// Members can't download because the .p8 / service-account JSON are +// effectively credentials. +// +// Returns the file content as a base64 string so the frontend can +// reconstruct a Blob and trigger a browser download. We don't return +// a storage URL because Convex storage URLs are publicly fetchable — +// the auth check belongs in this action, not on a URL the browser +// hands to a third-party. +export const downloadFile = action({ + args: { fileId: v.id("files") }, + returns: v.object({ + fileName: v.string(), + mimeType: v.string(), + base64: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ fileName: string; mimeType: string; base64: string }> => { + const userId: Id<"users"> | null = await getAuthUserId(ctx); + if (!userId) { + throw new ConvexError("Not authenticated"); + } + + const file: { + _id: Id<"files">; + fileName: string; + organizationId: Id<"organizations">; + mimeType?: string; + } | null = await ctx.runQuery(internal.files.internal.getFileRecord, { + fileId: args.fileId, + }); + if (!file) { + throw new ConvexError("File not found"); + } + + const membership = await ctx.runQuery( + internal.organizations.internal.getMembership, + { userId, organizationId: file.organizationId }, + ); + if (!membership || membership.role === "member") { + throw new ConvexError("Insufficient permissions"); + } + + const result: { content: string; fileName: string } = await ctx.runAction( + internal.files.internal.readFileAsBase64, + { fileId: args.fileId }, + ); + + return { + fileName: result.fileName, + mimeType: file.mimeType ?? "application/octet-stream", + base64: result.content, + }; + }, +}); diff --git a/packages/kit/convex/organizations/internal.ts b/packages/kit/convex/organizations/internal.ts index 62479aee..f796586c 100644 --- a/packages/kit/convex/organizations/internal.ts +++ b/packages/kit/convex/organizations/internal.ts @@ -104,6 +104,37 @@ export const organizationExists = internalQuery({ }, }); +// Lookup helper used by Convex actions that need to gate on +// organization membership without dragging the full org schema into +// the public mutation surface. Returns just the role so the caller +// can do `role === "member"` checks. +export const getMembership = internalQuery({ + args: { + userId: v.id("users"), + organizationId: v.id("organizations"), + }, + returns: v.union( + v.null(), + v.object({ + role: v.union( + v.literal("owner"), + v.literal("admin"), + v.literal("member"), + ), + }), + ), + handler: async (ctx, args) => { + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_org_and_user", (q) => + q.eq("organizationId", args.organizationId).eq("userId", args.userId), + ) + .first(); + if (!membership) return null; + return { role: membership.role }; + }, +}); + export async function recordVerificationUsageForOrganization( ctx: MutationCtx, organization: Doc<"organizations">, diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index abd98128..9b31f904 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, type FormEvent } from "react"; import { useOutletContext } from "react-router-dom"; import { toast } from "sonner"; -import { useMutation, useQuery } from "convex/react"; +import { useAction, useMutation, useQuery } from "convex/react"; import { api, Id } from "@/convex"; import { Upload, @@ -13,6 +13,7 @@ import { Info, Trash2, HelpCircle, + Download, } from "lucide-react"; import { GuideModal } from "../../../../components/GuideModal"; import { PageLoading } from "@/components/LoadingSpinner"; @@ -107,6 +108,35 @@ export default function ProjectSettings() { const generateUploadUrl = useMutation(api.files.mutation.generateUploadUrl); const saveFile = useMutation(api.files.mutation.saveFile); const removeFile = useMutation(api.files.mutation.remove); + const downloadFile = useAction(api.files.action.downloadFile); + + // Download a previously-uploaded credential file. Useful when an + // admin needs the original .p8 / service-account JSON back — + // for rotating across projects, copying to a backup, or just + // double-checking what kit holds matches the upstream console. + const handleFileDownload = async (fileId: Id<"files">, label: string) => { + try { + const result = await downloadFile({ fileId }); + const binary = atob(result.base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + const blob = new Blob([bytes], { type: result.mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = result.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + toast.success(`${label} download started`); + } catch (error: any) { + console.error(`${label} download error:`, error); + toast.error(error?.message || `Failed to download ${label}`); + } + }; const updateProject = useMutation(api.projects.mutation.updateProject); const originalAndroidPackageName = project?.androidPackageName ?? ""; @@ -858,13 +888,29 @@ export default function ProjectSettings() { )}
- +
+ {iosFile && ( + + )} + +
) : ( @@ -1047,13 +1093,29 @@ export default function ProjectSettings() { )} - +
+ {androidFile && ( + + )} + +
) : ( @@ -1288,20 +1350,30 @@ export default function ProjectSettings() { )} -
- -
+ {/* Save button only renders when there's actually + * something to save: either the user has Horizon + * checked (saving config), or they unchecked a + * previously-enabled toggle (persisting the + * disable). When the project never had Horizon and + * the toggle is off, the button is meaningless and + * hidden — the unchecked toggle alone is the + * "disabled" state, no extra click required. */} + {(horizonEnabled || originalHorizonEnabled) && ( +
+ +
+ )} diff --git a/packages/kit/src/pages/auth/organization/project/webhooks.tsx b/packages/kit/src/pages/auth/organization/project/webhooks.tsx index 15b3e67b..8ba53d67 100644 --- a/packages/kit/src/pages/auth/organization/project/webhooks.tsx +++ b/packages/kit/src/pages/auth/organization/project/webhooks.tsx @@ -1,4 +1,4 @@ -import { useOutletContext } from "react-router-dom"; +import { useOutletContext, useParams, Link } from "react-router-dom"; import { useState } from "react"; import { useQuery } from "convex/react"; import { @@ -7,6 +7,7 @@ import { ExternalLink, Check, AlertTriangle, + ArrowRight, } from "lucide-react"; import type { Doc } from "@/convex"; @@ -16,6 +17,14 @@ type ProjectContext = { project: Doc<"projects"> }; export default function ProjectWebhooks() { const { project } = useOutletContext(); + const { orgSlug, projectSlug } = useParams<{ + orgSlug: string; + projectSlug: string; + }>(); + const settingsHref = + orgSlug && projectSlug + ? `/${orgSlug}/project/${projectSlug}/settings` + : null; const baseUrl = window.location.origin; const setup = useQuery(api.projects.setupStatus.getSetupStatus, { apiKey: project.apiKey, @@ -54,16 +63,19 @@ export default function ProjectWebhooks() { label="iOS" configured={setup.ios.configured} missing={setup.ios.missing} + settingsHref={settingsHref} /> ) : null} @@ -165,13 +177,15 @@ function SetupBadge({ label, configured, missing, + settingsHref, }: { label: string; configured: boolean; missing: string[]; + settingsHref: string | null; }) { return ( -
+
{configured ? ( @@ -186,10 +200,18 @@ function SetupBadge({
{!configured && missing.length > 0 && ( -
+
Missing: {missing.join(", ")}
)} + {!configured && settingsHref && ( + + Configure now + + )}
); } From 7c37c3b39b246beacd9cd772f2ccd031defac1a1 Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 19:28:42 +0900 Subject: [PATCH 16/81] fix(kit,sdk): address PR #124 review threads (round 4) Addresses 11 unresolved CodeRabbit findings + KMP iOS compile fix + flaky ECDSA test: - .husky/pre-commit: bun-pin guard now fails closed if `node` is missing or package.json malformed instead of silently bypassing. Also drops the bogus `--no-daemon=false` flag from the kmp gradle invocation so it actually runs. - flutter webhook_client: only close HttpClient if we created it; explicitly handle `event: stream-error` frames as STREAM_ERROR. - kmp WebhookClient: require `projectId` (return null on missing) instead of defaulting to "". - kmp WebhookTransport.ios: cast `requestWithURL` result to NSMutableURLRequest + import setHTTPMethod/setValue Foundation extensions so the iOS target compiles. - convex/webhooks/apple.ts: signature failures throw ConvexError (-> 400) so Apple stops retrying; selective ACK on UnknownEventType only -- MissingNotificationId / MissingPurchaseToken now surface as 400 instead of being ACK-ed. - convex/webhooks/google.ts: sanitize logged error (`name: message` only) so privileged googleapis errors don't leak request URLs or bearer tokens to log aggregators. - server/api/v1/webhooks.ts (Pub/Sub): fail closed when GOOGLE_PUBSUB_PUSH_AUDIENCE is unset; opt-in dev escape via KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1. New optional GOOGLE_PUBSUB_PUSH_PRINCIPAL pin restricts to a specific service account email instead of the gcp-sa-pubsub namespace. - server/api/v1/webhooks.ts (SSE): replaced the `webhookEventsSince(0, 500)` linear scan with a dedicated `findEventCursor` query hitting a new `by_project_and_notification_id` index -- reconnect cursor resolution is now O(log n) and safe past 500 events. - convex/webhooks/query.ts: webhookEventsSince accepts an optional `afterCreationTime` cursor so pagination advances past a same- `receivedAt` cohort larger than `limit`. SSE handler tracks the creationCursor between batches. - server/convex.ts: handleConvexError now also accepts object-shaped `{code, message}` ConvexError data so the apple.ts ConvexErrors actually map to 400. - convex/products/play.ts: skip `purchaseType: "subscription"` rows in inappproducts.list (defensive); reject Subscription draft pushes that lack price+currency, and synthesize a minimal monthly base plan so the created product is purchasable. - convex/products/jwt.test.ts: fix flaky DER round-trip helper that missed the `00 80...` canonical-form case (~1/65536 per coord). Co-Authored-By: Claude Opus 4.7 (1M context) --- .husky/pre-commit | 40 ++++-- .../lib/webhook_client.dart | 31 +++- .../hyochan/kmpiap/openiap/WebhookClient.kt | 3 +- .../kmpiap/openiap/WebhookTransport.ios.kt | 22 ++- packages/kit/convex/products/jwt.test.ts | 17 ++- packages/kit/convex/products/play.ts | 53 ++++++- packages/kit/convex/schema.ts | 11 +- packages/kit/convex/webhooks/apple.ts | 38 +++-- packages/kit/convex/webhooks/google.ts | 10 +- packages/kit/convex/webhooks/query.ts | 76 +++++++++- packages/kit/server/api/v1/webhooks.ts | 136 ++++++++++++++---- packages/kit/server/convex.ts | 38 ++++- 12 files changed, 397 insertions(+), 78 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index bf87d707..7f8d7416 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -10,19 +10,31 @@ cd "$REPO_ROOT" # generated by an older local bun will pass `--frozen-lockfile` here # but fail in Docker. Before doing anything else, refuse to commit # from a mismatched bun. -EXPECTED_BUN="$(node -p "require('./package.json').packageManager.split('@')[1]" 2>/dev/null || true)" -if [ -n "$EXPECTED_BUN" ]; then - ACTUAL_BUN="$(bun --version 2>/dev/null || echo unknown)" - if [ "$EXPECTED_BUN" != "$ACTUAL_BUN" ]; then - echo "❌ bun version mismatch:" - echo " package.json packageManager: bun@$EXPECTED_BUN" - echo " local bun --version: $ACTUAL_BUN" - echo " run \`bun upgrade\` (or install bun@$EXPECTED_BUN) and re-commit." - echo " (Lockfiles drift across bun versions — CI's Docker uses" - echo " the pinned version and will fail with" - echo " 'lockfile had changes, but lockfile is frozen' otherwise.)" - exit 1 - fi +# +# The guard must be fail-closed: if `node` isn't on PATH or the +# package.json read fails, do not silently bypass — the whole point of +# the gate is preventing lockfile drift, and a stealth bypass defeats it. +if ! command -v node >/dev/null 2>&1; then + echo "❌ pre-commit gate: \`node\` not on PATH — required to read packageManager pin from package.json." + echo " install Node (it ships with most bun toolchains) and re-commit." + exit 1 +fi +EXPECTED_BUN="$(node -p "require('./package.json').packageManager.split('@')[1]")" +if [ -z "$EXPECTED_BUN" ]; then + echo "❌ pre-commit gate: could not read \`packageManager\` from package.json." + echo " the bun version pin is missing or malformed — fix package.json before committing." + exit 1 +fi +ACTUAL_BUN="$(bun --version 2>/dev/null || echo unknown)" +if [ "$EXPECTED_BUN" != "$ACTUAL_BUN" ]; then + echo "❌ bun version mismatch:" + echo " package.json packageManager: bun@$EXPECTED_BUN" + echo " local bun --version: $ACTUAL_BUN" + echo " run \`bun upgrade\` (or install bun@$EXPECTED_BUN) and re-commit." + echo " (Lockfiles drift across bun versions — CI's Docker uses" + echo " the pinned version and will fail with" + echo " 'lockfile had changes, but lockfile is frozen' otherwise.)" + exit 1 fi # Paths-aware kit pre-commit gate. Only runs when staged changes touch @@ -101,7 +113,7 @@ if git diff --cached --name-only --diff-filter=ACMR \ | grep -qE '^(libraries/kmp-iap/library/src|packages/gql/src/)' ; then if [ -x libraries/kmp-iap/gradlew ]; then echo "🎯 kmp/gql-touched commit — running ./gradlew :library:compileDebugKotlinAndroid…" - (cd libraries/kmp-iap && ./gradlew --no-daemon=false :library:compileDebugKotlinAndroid -q) + (cd libraries/kmp-iap && ./gradlew :library:compileDebugKotlinAndroid -q) else echo "⚠️ libraries/kmp-iap/gradlew not executable — skipping KMP compile." fi diff --git a/libraries/flutter_inapp_purchase/lib/webhook_client.dart b/libraries/flutter_inapp_purchase/lib/webhook_client.dart index 8c51022d..42df0aa4 100644 --- a/libraries/flutter_inapp_purchase/lib/webhook_client.dart +++ b/libraries/flutter_inapp_purchase/lib/webhook_client.dart @@ -146,12 +146,18 @@ class _SseWebhookListener implements WebhookListener { required this.baseUrl, required this.reconnectDelay, HttpClient? httpClient, - }) : _httpClient = httpClient ?? HttpClient(); + }) : _httpClient = httpClient ?? HttpClient(), + _ownsHttpClient = httpClient == null; final String apiKey; final String baseUrl; final Duration reconnectDelay; final HttpClient _httpClient; + // Only close the underlying HttpClient if we created it ourselves. + // Callers may share a single HttpClient across multiple listeners or + // unrelated request flows; force-closing a caller-owned client would + // tear down their other in-flight requests. + final bool _ownsHttpClient; final StreamController _events = StreamController.broadcast(); @@ -176,7 +182,9 @@ class _SseWebhookListener implements WebhookListener { _bodySub = null; _pendingRequest?.abort(); _pendingRequest = null; - _httpClient.close(force: true); + if (_ownsHttpClient) { + _httpClient.close(force: true); + } await _events.close(); await _errors.close(); } @@ -296,6 +304,25 @@ class _SseWebhookListener implements WebhookListener { if (dataStr.isEmpty) return; if (eventName == 'heartbeat' || eventName == 'ready') return; + // The kit server emits `event: stream-error` with a JSON `{message}` + // payload when the backend Convex subscription itself fails (e.g. the + // project's API key was rotated mid-stream). Surface those as a + // distinct error code so callers can react — falling through to + // `parseWebhookEventData` would mis-report it as MALFORMED_EVENT. + if (eventName == 'stream-error') { + String message = dataStr; + try { + final decoded = jsonDecode(dataStr); + if (decoded is Map && decoded['message'] is String) { + message = decoded['message'] as String; + } + } catch (_) { + // Fall back to raw frame body. + } + _errors.add(WebhookListenerError('STREAM_ERROR', message)); + return; + } + final event = parseWebhookEventData(dataStr); if (event == null) { _errors.add( diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt index c4817235..977801f5 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookClient.kt @@ -82,7 +82,8 @@ object WebhookEventParser { element["priceAmountMicros"]?.jsonPrimitive?.numericOrNull(), productId = element["productId"]?.jsonPrimitive?.contentOrNull, projectId = - element["projectId"]?.jsonPrimitive?.contentOrNull ?: "", + element["projectId"]?.jsonPrimitive?.contentOrNull + ?: return null, purchaseToken = purchaseToken, rawSignedPayload = element["rawSignedPayload"]?.jsonPrimitive?.contentOrNull, diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt index 50441e92..cdef03c6 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt @@ -23,6 +23,8 @@ import platform.Foundation.NSURLSessionDataTask import platform.Foundation.NSURLSessionResponseAllow import platform.Foundation.create import platform.Foundation.dataUsingEncoding +import platform.Foundation.setHTTPMethod +import platform.Foundation.setValue import platform.darwin.NSObject import platform.Foundation.NSUTF8StringEncoding @@ -64,13 +66,19 @@ actual class WebhookTransport actual constructor( updateLastEventId: (String) -> Unit, ): Boolean = try { val url = NSURL(string = webhookStreamUrl(baseUrl, apiKey)) - val request = NSMutableURLRequest.requestWithURL(url).apply { - setHTTPMethod("GET") - setValue("text/event-stream", forHTTPHeaderField = "Accept") - setValue("no-cache", forHTTPHeaderField = "Cache-Control") - if (lastEventId != null) { - setValue(lastEventId, forHTTPHeaderField = "Last-Event-ID") - } + // `NSURLRequest.requestWithURL` returns the immutable parent + // type even when invoked on the mutable subclass companion, so + // we cast to NSMutableURLRequest to expose the mutable setters. + // Avoid `apply { }` so Kotlin resolves `setValue` to the ObjC + // `setValue:forHTTPHeaderField:` selector rather than the + // property-delegate operator. + val request: NSMutableURLRequest = + NSMutableURLRequest.requestWithURL(url) as NSMutableURLRequest + request.setHTTPMethod("GET") + request.setValue("text/event-stream", forHTTPHeaderField = "Accept") + request.setValue("no-cache", forHTTPHeaderField = "Cache-Control") + if (lastEventId != null) { + request.setValue(lastEventId, forHTTPHeaderField = "Last-Event-ID") } val config = NSURLSessionConfiguration.defaultSessionConfiguration() val frameBuffer = StringBuilder() diff --git a/packages/kit/convex/products/jwt.test.ts b/packages/kit/convex/products/jwt.test.ts index 285b1b8a..61a52474 100644 --- a/packages/kit/convex/products/jwt.test.ts +++ b/packages/kit/convex/products/jwt.test.ts @@ -98,14 +98,19 @@ describe("derSignatureToJoseSignature", () => { }); function bigIntFrom(buf: Buffer): Buffer { - // For DER encoding, leading bit set means we need a 0x00 prefix. - if ((buf[0] ?? 0) & 0x80) { - return Buffer.concat([Buffer.from([0x00]), buf]); - } - // Strip excess leading zeros so the integer is canonical. + // Strip excess leading zeros so the integer is canonical, then add a + // 0x00 prefix back if the high bit of the leading nonzero byte is + // set (DER says positive integers can't start with 0x80+). The prior + // version stripped leading zeros AFTER checking the high bit and + // missed the `00 80 ...` case — that pattern occurs ~1/65536 times + // per coord, which made the ECDSA round-trip test flake on CI. let i = 0; while (i < buf.length - 1 && buf[i] === 0) i += 1; - return buf.subarray(i); + const stripped = buf.subarray(i); + if (((stripped[0] ?? 0) & 0x80) !== 0) { + return Buffer.concat([Buffer.from([0x00]), stripped]); + } + return stripped; } function encodeDerSignature(r: Buffer, s: Buffer): Buffer { diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index f9ad9868..478ac776 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -83,6 +83,15 @@ export const pushSyncProductsGoogle = action({ }); for (const product of oneTimes.data.inappproduct ?? []) { if (!product.sku) continue; + // Defensive filter: `inappproducts.list` is documented to + // return one-time products only, but the response schema + // still surfaces a `purchaseType` field that includes + // "subscription". If a Play instance ever returns a + // subscription from this endpoint we must skip it — the + // subscription pull loop below handles those, and routing + // them through `mapPlayOneTimeType` would mis-classify them + // as `NonConsumable`. + if (product.purchaseType === "subscription") continue; await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, productId: product.sku, @@ -141,6 +150,17 @@ export const pushSyncProductsGoogle = action({ for (const row of drafts) { try { if (row.type === "Subscription") { + // Reject subscription creates that would land on Play with + // no base plan: such a subscription is created in a draft + // state that the Play app cannot purchase, which silently + // breaks the SDK's `requestPurchase` flow downstream. The + // operator must provide both a price and currency at + // minimum so we can synthesize a base plan. + if (!row.priceAmountMicros || !row.currency) { + throw new Error( + "Subscription requires priceAmountMicros + currency to mint a Play base plan; otherwise the product will not be purchasable.", + ); + } await androidpublisher.monetization.subscriptions.create({ packageName, requestBody: { @@ -152,6 +172,31 @@ export const pushSyncProductsGoogle = action({ description: row.description ?? row.title, }, ], + // Minimal auto-renewing monthly base plan. Operators + // can edit pricing and offers in Play Console after + // the initial sync — this only ensures the product + // is in a purchasable state. + basePlans: [ + { + basePlanId: "monthly", + state: "ACTIVE", + autoRenewingBasePlanType: { + billingPeriodDuration: "P1M", + }, + regionalConfigs: [ + { + regionCode: "US", + price: { + currencyCode: row.currency, + units: String( + Math.trunc(row.priceAmountMicros / 1_000_000), + ), + nanos: (row.priceAmountMicros % 1_000_000) * 1_000, + }, + }, + ], + }, + ], }, }); } else { @@ -160,8 +205,12 @@ export const pushSyncProductsGoogle = action({ requestBody: { packageName, sku: row.productId, - purchaseType: - row.type === "Consumable" ? "managedUser" : "managedUser", + // Play API uses `managedUser` for both consumable and + // non-consumable; the difference is consumed at + // purchase time via `consumeAsync`. Subscriptions go + // through `monetization.subscriptions.*` (see branch + // above), not this endpoint. + purchaseType: "managedUser", status: "active", defaultLanguage: "en-US", listings: { diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 6a0cc138..87351e83 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -481,7 +481,16 @@ const schema = defineSchema({ .index("by_project", ["projectId"]) .index("by_purchase_token", ["purchaseToken"]) .index("by_project_and_received", ["projectId", "receivedAt"]) - .index("by_received_at", ["receivedAt"]), + .index("by_received_at", ["receivedAt"]) + // Lookup helper used by the SSE stream's `Last-Event-ID` cursor + // resolution. The reconnect cursor needs to translate a stable + // notification id back to its `receivedAt` regardless of whether + // the event is in the first 500 or the 50,000th. A direct index + // hit is O(log n) vs O(n/page) for the prior linear scan. + .index("by_project_and_notification_id", [ + "projectId", + "sourceNotificationId", + ]), // Dedup table for webhook payloads. Insertion uses // `(source, sourceNotificationId)` as the natural key; duplicates diff --git a/packages/kit/convex/webhooks/apple.ts b/packages/kit/convex/webhooks/apple.ts index 59228d0b..5635bca9 100644 --- a/packages/kit/convex/webhooks/apple.ts +++ b/packages/kit/convex/webhooks/apple.ts @@ -95,7 +95,14 @@ export const ingestAppleAsnIOS = action({ payload = await verifier.verifyAndDecodeNotification(args.signedPayload); } catch (error) { console.error("[webhooks/apple] notification verification failed", error); - throw new Error("Apple ASN v2 signature verification failed"); + // ConvexError so the Hono `mapWebhookError` translates to 400 — + // signature failure is a permanent error and a 5xx would trigger + // ASN's automatic retry loop forever. Apple's "do not retry on + // permanent failure" guidance maps cleanly to 4xx status codes. + throw new ConvexError({ + code: "INVALID_SIGNATURE", + message: "Apple ASN v2 signature verification failed", + }); } // Decode transaction + renewal JWS if present. Apple sends them @@ -118,14 +125,27 @@ export const ingestAppleAsnIOS = action({ }); } catch (error) { if (error instanceof WebhookNormalizationError) { - // Unsupported notification types are not a kit failure — Apple - // ships new types ahead of openiap spec updates. Log and ACK. - console.warn( - "[webhooks/apple] dropping unsupported notification", - error.code, - error.message, - ); - throw new Error(`UNSUPPORTED_EVENT: ${error.message}`); + // Selective handling: only `UnknownEventType` is "Apple ships + // new types ahead of openiap spec" — those we ACK as 200 so + // ASN v2 stops retrying. `MissingNotificationId` and + // `MissingPurchaseToken` mean the payload itself is malformed + // — those must surface as 400 so the operator notices, and + // ACK-ing them silently would lose data. + if (error.code === "UnknownEventType") { + console.warn( + "[webhooks/apple] dropping unsupported notification", + error.code, + error.message, + ); + throw new ConvexError({ + code: "UNSUPPORTED_EVENT", + message: error.message, + }); + } + throw new ConvexError({ + code: error.code, + message: error.message, + }); } throw error; } diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts index 0dc2e468..d6274364 100644 --- a/packages/kit/convex/webhooks/google.ts +++ b/packages/kit/convex/webhooks/google.ts @@ -240,9 +240,17 @@ async function maybeFetchSubscriptionInfo( : undefined, }; } catch (error) { + // Sanitized: only the error name/code/message is logged. The full + // googleapis error object can include the original request URL with + // an OAuth bearer token and the response body — neither belongs in + // logs that get shipped to error aggregation. + const sanitized = + error instanceof Error + ? `${error.name}: ${error.message}` + : "(unknown error type)"; console.warn( "[webhooks/google] subscriptionsv2 fetch failed; falling back to type-derived state", - error, + sanitized, ); return null; } diff --git a/packages/kit/convex/webhooks/query.ts b/packages/kit/convex/webhooks/query.ts index 0b197d5c..aeeb9e04 100644 --- a/packages/kit/convex/webhooks/query.ts +++ b/packages/kit/convex/webhooks/query.ts @@ -10,6 +10,55 @@ import { webhookEventPlatformValidator, } from "./validators"; +// Stream cursor lookup. Resolves a stable `sourceNotificationId` +// (ASN v2 notificationUUID or RTDN messageId) into a `receivedAt` +// timestamp + Convex `_creationTime` so the SSE reconnect path can +// resume right after the last event the consumer acknowledged. +// +// Surfaces both `receivedAt` and `_creationTime` because two events +// can share `receivedAt` under burst writes — the SSE consumer needs +// the creationTime tie-break to avoid re-emitting the boundary event. +// +// Uses the dedicated `by_project_and_notification_id` index so the +// lookup is O(log n) regardless of how many webhook events the +// project has accumulated. The prior implementation scanned the +// first 500 events via `webhookEventsSince(sinceMs: 0, limit: 500)` +// and silently fell back to "now" for any project with > 500 events. +export const findEventCursor = query({ + args: { + apiKey: v.string(), + sourceNotificationId: v.string(), + }, + returns: v.union( + v.null(), + v.object({ + receivedAt: v.number(), + _creationTime: v.number(), + }), + ), + handler: async (ctx, args) => { + const project = await ctx.db + .query("projects") + .withIndex("by_api_key", (q) => q.eq("apiKey", args.apiKey)) + .unique(); + if (!project) return null; + + const event = await ctx.db + .query("webhookEvents") + .withIndex("by_project_and_notification_id", (q) => + q + .eq("projectId", project._id) + .eq("sourceNotificationId", args.sourceNotificationId), + ) + .first(); + if (!event) return null; + return { + receivedAt: event.receivedAt, + _creationTime: event._creationTime, + }; + }, +}); + // Backfill query used by SDKs on reconnect / app foreground entry. // Returns webhook events for the API key's project that occurred since // the given timestamp, ordered ascending by `receivedAt` so consumers @@ -18,10 +67,18 @@ import { // We cap results at `limit` (default 100, max 500) and surface // `_creationTime` so the SDK can checkpoint reliably even if two // events share `receivedAt` (rare but possible under burst writes). +// +// Optional `afterCreationTime`: when provided alongside `sinceMs`, we +// only emit events whose `_creationTime` is strictly greater than +// the cursor — the tie-break that lets pagination advance past a +// `receivedAt` cohort larger than `limit`. Without it, a burst of +// 500+ events sharing one `receivedAt` would stick the cursor at +// the same value forever (PR #124 review fix). export const webhookEventsSince = query({ args: { apiKey: v.string(), sinceMs: v.number(), + afterCreationTime: v.optional(v.number()), limit: v.optional(v.number()), }, returns: v.array( @@ -69,13 +126,28 @@ export const webhookEventsSince = query({ const limit = Math.min(Math.max(args.limit ?? 100, 1), 500); - const events = await ctx.db + // Over-fetch when an `afterCreationTime` cursor is in play so the + // post-filter still has `limit` events to return after dropping + // the inclusive `>= sinceMs` boundary cohort. We cap the over-fetch + // at 2× to bound the worst-case page size — anything beyond that + // means the consumer is far behind and another reconnect will + // pick up where this page leaves off. + const fetchLimit = args.afterCreationTime + ? Math.min(limit * 2, 1000) + : limit; + const raw = await ctx.db .query("webhookEvents") .withIndex("by_project_and_received", (q) => q.eq("projectId", project._id).gte("receivedAt", args.sinceMs), ) .order("asc") - .take(limit); + .take(fetchLimit); + + const events = args.afterCreationTime + ? raw + .filter((e) => e._creationTime > args.afterCreationTime!) + .slice(0, limit) + : raw; return events.map((event) => ({ // GraphQL `id` is the stable per-notification identifier from diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts index fea084d0..ebf8d0ab 100644 --- a/packages/kit/server/api/v1/webhooks.ts +++ b/packages/kit/server/api/v1/webhooks.ts @@ -213,12 +213,36 @@ async function handleGoogleNotification( body: PubSubPushBody, ) { // Pub/Sub push always sends `Authorization: Bearer ` when OIDC - // is configured on the subscription. We verify it when the operator - // has set GOOGLE_PUBSUB_PUSH_AUDIENCE; otherwise we skip strict - // checks (development / sandbox). + // is configured on the subscription. We require the operator to have + // set GOOGLE_PUBSUB_PUSH_AUDIENCE in production so kit fails closed + // — a missing env var must not silently let anonymous bodies through + // a Google-shaped path. In development / sandbox, the operator can + // opt out by setting `KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1`. const authHeader = c.req.header("authorization"); const audience = process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; - if (audience) { + const allowUnauth = process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB === "1"; + if (!audience) { + if (!allowUnauth) { + console.error( + "[webhooks/google] GOOGLE_PUBSUB_PUSH_AUDIENCE is unset; rejecting request. Set KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1 only for local dev.", + ); + return c.json( + { + errors: [ + { + code: "MISCONFIGURED", + message: + "Pub/Sub OIDC audience is not configured on this kit instance", + }, + ], + }, + 503, + ); + } + console.warn( + "[webhooks/google] GOOGLE_PUBSUB_PUSH_AUDIENCE unset and KIT_ALLOW_UNAUTHENTICATED_PUBSUB=1 — accepting unauthenticated Pub/Sub body (dev mode only).", + ); + } else { const ok = await verifyPubSubOidcToken(authHeader, audience); if (!ok) { return c.json( @@ -331,7 +355,7 @@ webhooks.get("/stream/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); const lastEventId = c.req.header("last-event-id") ?? undefined; - let cursor = await resolveStreamStartCursor(apiKey, lastEventId); + const startCursor = await resolveStreamStartCursor(apiKey, lastEventId); return streamSSE(c, async (stream) => { let aborted = false; @@ -341,6 +365,8 @@ webhooks.get("/stream/:apiKey", async (c) => { const reactive = new ConvexClient(convexUrlForRealtime); const seen = new Set(); + let cursor = startCursor.sinceMs; + let creationCursor = startCursor.afterCreationTime; await stream.writeSSE({ event: "ready", @@ -356,7 +382,12 @@ webhooks.get("/stream/:apiKey", async (c) => { try { unsubscribe = reactive.onUpdate( api.webhooks.query.webhookEventsSince, - { apiKey, sinceMs: cursor, limit: 500 }, + { + apiKey, + sinceMs: cursor, + afterCreationTime: creationCursor, + limit: 500, + }, (events: unknown) => { if (aborted) return; if (!Array.isArray(events)) return; @@ -370,6 +401,17 @@ webhooks.get("/stream/:apiKey", async (c) => { ) { cursor = event.receivedAt; } + // Track the highest `_creationTime` we've emitted within + // the current `receivedAt` cohort so a reconnect resumes + // strictly past the last emitted event even when many + // events share the same wall-clock `receivedAt`. + if ( + typeof event._creationTime === "number" && + (creationCursor === undefined || + event._creationTime > creationCursor) + ) { + creationCursor = event._creationTime; + } stream .writeSSE({ id, @@ -414,30 +456,45 @@ webhooks.get("/stream/:apiKey", async (c) => { }); // Translate an EventSource `Last-Event-ID` (which is the spec's stable -// `sourceNotificationId`) into a `sinceMs` cursor by looking up the -// event's `receivedAt`. Falls back to "now" when the id is unknown so -// we never replay the entire 30-day window for a confused client. +// `sourceNotificationId`) into a `sinceMs` + `afterCreationTime` cursor +// pair. The new `findEventCursor` query hits the dedicated +// `by_project_and_notification_id` index so the lookup is O(log n) +// regardless of how many events the project has accumulated. The prior +// implementation scanned the first 500 events and silently fell back +// to "now" for anything beyond that — projects with > 500 events +// would lose every replay-on-reconnect (PR #124 review fix). +// +// Returns `{ sinceMs, afterCreationTime }` so the SSE handler can pass +// both to `webhookEventsSince` and resume strictly past the last +// emitted event even under same-`receivedAt` bursts. async function resolveStreamStartCursor( apiKey: string, lastEventId: string | undefined, -): Promise { +): Promise<{ sinceMs: number; afterCreationTime?: number }> { if (!lastEventId) { - return 0; + return { sinceMs: 0 }; } try { - const events = (await client.query(api.webhooks.query.webhookEventsSince, { + const match = await client.query(api.webhooks.query.findEventCursor, { apiKey, - sinceMs: 0, - limit: 500, - })) as Array<{ id: string; receivedAt: number }>; - const match = events.find((event) => event.id === lastEventId); + sourceNotificationId: lastEventId, + }); if (match) { - return match.receivedAt; + return { + sinceMs: match.receivedAt, + afterCreationTime: match._creationTime, + }; } - return Date.now(); + // Unknown lastEventId — never replay the full 30-day window for a + // confused / forged client. + return { sinceMs: Date.now() }; } catch (error) { - console.warn("[webhooks/stream] cursor resolution failed", error); - return Date.now(); + const sanitized = + error instanceof Error + ? `${error.name}: ${error.message}` + : "(unknown error type)"; + console.warn("[webhooks/stream] cursor resolution failed", sanitized); + return { sinceMs: Date.now() }; } } @@ -461,15 +518,25 @@ async function verifyPubSubOidcToken( return false; } const email = payload.email; - // Pub/Sub push requests are signed by a Google service account - // dedicated to the publishing project. Reject any caller that is - // not from the gcp-sa-pubsub principal namespace. - if (!email || !email.endsWith("@gcp-sa-pubsub.iam.gserviceaccount.com")) { + if (!email || payload.email_verified !== true) { return false; } - return payload.email_verified === true; + // Bind to a specific service-account principal when configured. + // Without GOOGLE_PUBSUB_PUSH_PRINCIPAL set we still enforce the + // gcp-sa-pubsub namespace so any project's Pub/Sub push could in + // theory hit our endpoint — operators in shared GCP orgs should + // pin GOOGLE_PUBSUB_PUSH_PRINCIPAL to their dedicated push SA. + const principal = process.env.GOOGLE_PUBSUB_PUSH_PRINCIPAL; + if (principal) { + return email === principal; + } + return email.endsWith("@gcp-sa-pubsub.iam.gserviceaccount.com"); } catch (error) { - console.warn("[webhooks/google] OIDC verification error", error); + const sanitized = + error instanceof Error + ? `${error.name}: ${error.message}` + : "(unknown error type)"; + console.warn("[webhooks/google] OIDC verification error", sanitized); return false; } } @@ -481,16 +548,23 @@ function mapWebhookError( ) { const convexError = handleConvexError(error); if (convexError !== null) { - // 400 keeps the upstream from retrying forever on a permanent - // input error (bundle mismatch, malformed JWS, etc.). + // Apple/Google ship new notification types ahead of the openiap + // spec. Acknowledge with 200 so the upstream stops retrying — the + // event was deliberately dropped, not lost. Other normalization + // errors (MissingNotificationId, MissingPurchaseToken, + // BUNDLE_ID_MISMATCH, INVALID_SIGNATURE, …) are permanent + // configuration/payload errors that need 4xx so the operator + // notices and the upstream stops retrying. + if (convexError.code === "UNSUPPORTED_EVENT") { + return c.json({ ok: true, dropped: true, reason: convexError.message }); + } return c.json({ errors: [convexError] }, 400); } const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.startsWith("UNSUPPORTED_EVENT")) { - // Apple/Google ship new notification types ahead of the openiap - // spec. Acknowledge with 200 so the upstream stops retrying — the - // event was deliberately dropped, not lost. + // Legacy fallback — kept until all action paths migrate to the + // ConvexError shape above. return c.json({ ok: true, dropped: true, reason: errorMessage }); } diff --git a/packages/kit/server/convex.ts b/packages/kit/server/convex.ts index b53401f8..0482bc16 100644 --- a/packages/kit/server/convex.ts +++ b/packages/kit/server/convex.ts @@ -33,12 +33,46 @@ export function handleConvexError(error: unknown): ApiError | null { } function getConvexError(error: ConvexError): ApiError | null { + // Structured object-shaped error — the mutation/action threw + // `new ConvexError({ code, message })`. Convex preserves the object + // shape across the wire (data isn't always a string). + if (typeof error.data === "object" && error.data !== null) { + const objectResult = v.safeParse( + v.object({ + code: v.string(), + message: v.string(), + }), + error.data, + ); + if (objectResult.success) { + return { + code: objectResult.output.code, + message: objectResult.output.message, + }; + } + // Fall through to legacy `{ error, message }` shape for backward + // compat with any callers that didn't migrate to `{ code, message }`. + const legacyResult = v.safeParse( + v.object({ + error: v.string(), + message: v.string(), + }), + error.data, + ); + if (legacyResult.success) { + return { + code: legacyResult.output.error, + message: legacyResult.output.message, + }; + } + return null; + } + if (typeof error.data !== "string") { return null; } - // Structured error — the mutation/action threw - // `new ConvexError(JSON.stringify({ error, message }))`. + // Legacy structured error — `new ConvexError(JSON.stringify({ error, message }))`. try { const data = JSON.parse(error.data); From 35177adc8ebe14fe8a56b0dab114fa71943ef85e Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 19:42:03 +0900 Subject: [PATCH 17/81] fix(kit-api): normalize headers via Headers ctor (PR #124 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spreading RequestInit['headers'] into an object literal silently drops caller-supplied Headers instances (they're not plain objects) — including auth headers. Use the Headers constructor + has/set so caller headers survive the merge AND win over kit defaults if the same name is set on both sides. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/gql/src/kit-api.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts index de6c1c8b..7b8c44cf 100644 --- a/packages/gql/src/kit-api.ts +++ b/packages/gql/src/kit-api.ts @@ -86,13 +86,23 @@ export function kitApi(options: KitApiOptions) { })(); async function call(path: string, init?: RequestInit): Promise { + // Normalize headers via the Headers constructor so caller-supplied + // `Headers` instances (which are not plain objects) survive the + // merge — the prior object-spread silently dropped any header + // passed as a `Headers` instance, including auth headers. Caller + // headers win over kit defaults if they explicitly set the same + // name; we set defaults via `set` then re-apply caller values + // after the fact. + const headers = new Headers(init?.headers); + if (!headers.has("accept")) { + headers.set("accept", "application/json"); + } + if (init?.body && !headers.has("content-type")) { + headers.set("content-type", "application/json"); + } const response = await fetchImpl(`${baseUrl}${path}`, { ...init, - headers: { - accept: "application/json", - ...(init?.body ? { "content-type": "application/json" } : {}), - ...(init?.headers as Record | undefined), - }, + headers, }); const text = await response.text(); let parsed: unknown = text; From 35a35589395f0e3b64bb5c431bf409d894eedb6c Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 19:47:56 +0900 Subject: [PATCH 18/81] refactor(kit/settings): consolidate iOS/Android config into per-platform cards The previous layout asked for both platforms' identifiers (Android package name + App Store bundle ID + Apple ID + ASC Issuer/Key) in one shared 'App identifiers' card at the top, then again surfaced 'iOS Configuration' and 'Android Configuration' cards below for the file uploads. The duplication made it unclear which fields belonged to which store. Now each platform has exactly one card: - 'iOS Configuration': App Store bundle ID, App Apple ID, ASC Issuer ID, ASC Key ID, .p8 file upload, setup guide. - 'Android Configuration': Android package name, service-account JSON upload, setup guide, Meta Horizon (Quest/VR) sub-section. The 'Supported platforms' selector stays at the top as its own card (it gates which platform card renders below). A single 'Save identifiers' button persists every metadata field via the existing form submit; file uploads + Horizon save remain their own flows. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auth/organization/project/settings.tsx | 1477 +++++++++-------- 1 file changed, 744 insertions(+), 733 deletions(-) diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index 9b31f904..265139af 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -568,268 +568,65 @@ export default function ProjectSettings() {

- {/* App identifiers form */} + {/* Supported platforms — gates which configuration cards render + below. Identifiers + credentials live INSIDE each platform's + card so the iOS/Android boundary is unambiguous: everything + related to one store stays in one place. */}
-
-

{"App identifiers"}

-

- { - "Store the identifiers required for App Store and Google Play purchase verification." - } -

-
-
-

{"Supported platforms"}

-
- - -
-
-
{ - void handleMetadataSubmit(event); - }} - > - {showAndroidSection && ( -
- - setAndroidPackageName(event.target.value)} - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" - placeholder="com.example.app" - spellCheck={false} - aria-invalid={!isAndroidPackageValid} - disabled={androidPackageLocked} - /> -

- { - "Use the exact package name from the Google Play Console (e.g., com.example.app)." +

{"Supported platforms"}

+

+ { + "Pick the stores you want kit to validate. Each platform's identifiers, credentials, and setup guide live in the matching card below." + } +

+
+
- )} - {showAppleSection && ( - <> -
- - setIosBundleId(event.target.value)} - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" - placeholder="com.example.ios" - spellCheck={false} - aria-invalid={!isIosBundleValid} - disabled={iosBundleLocked} - /> -

- { - "Use the bundle identifier from Xcode / App Store Connect (e.g., com.example.ios)." - } -

- {iosBundleLocked && ( -

- {"Bundle IDs can’t be edited once saved."} -

- )} - {!isIosBundleValid && ( -

- {"Enter a valid App Store bundle ID."} -

- )} -
-
- - setIosAppAppleId(event.target.value)} - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" - placeholder="1234567890" - aria-invalid={!isIosAppleIdValid} - disabled={iosAppleIdLocked} - /> -

- { - "Required for production validations. This is the numeric Apple ID found in App Store Connect under Apps → → App Information -> Apple ID." - } -

- {iosAppleIdLocked && ( -

- {"Apple App IDs can’t be edited once saved."} -

- )} - {!isIosAppleIdValid && ( -

- {"App Apple ID must be numeric."} -

- )} -
-
- - - setIosAppStoreIssuerId(event.target.value) - } - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" - placeholder="12345678-ABCD-1234-ABCD-1234567890AB" - spellCheck={false} - aria-invalid={!isIosIssuerIdValid} - /> -

- { - "Needed for App Store Server API calls. Find it in App Store Connect → Users and Access → Integrations → In-App Purchase (under Keys)" - } -

- {iosIssuerLocked && ( -

- {"Issuer IDs can’t be cleared once saved."} -

- )} - {!isIosIssuerIdValid && ( -

- { - "Issuer ID must match the UUID format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)." - } -

- )} -
-
- - - setIosAppStoreKeyId(event.target.value.toUpperCase()) - } - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" - placeholder="ABCDE12345" - spellCheck={false} - aria-invalid={!isIosKeyIdValid} - /> -

- { - "Ten-character identifier shown next to the .p8 key. Find it in App Store Connect → Users and Access → Integrations → In-App Purchase (under Keys)" - } -

- {iosKeyLocked && ( -

- {"Key IDs can’t be cleared once saved."} -

- )} - {!isIosKeyIdValid && ( -

- {"Key ID must be 10 uppercase letters or numbers."} -

- )} - {!isIosCredentialPairValid && ( -

- { - "Provide both Issuer ID and Key ID to enable the App Store Server API." - } -

- )} -
- - )} -
- - {"Save identifiers"} - -
- + setApplePlatformsSelected(event.target.checked); + }} + disabled={applePlatformsLocked} + /> + + {"iOS, macOS, tvOS, iPadOS"} + + + +
{/* Guide message if Android file is not uploaded */} @@ -852,533 +649,747 @@ export default function ProjectSettings() { )} {(showAppleSection || showAndroidSection) && ( -
- {/* iOS Configuration */} - {showAppleSection && ( -
-
- -

{"iOS Configuration"}

-
+
{ + void handleMetadataSubmit(event); + }} + > +
+ {/* iOS Configuration — bundle ID + Apple ID + ASC Issuer/Key + + .p8 file all live here so iOS-only configuration is + self-contained. */} + {showAppleSection && ( +
+
+ +

+ {"iOS Configuration"} +

+
-
-
- - - {iosFileUploaded || hasIosFile ? ( -
-
-
- -
- - {"Authentication file uploaded successfully"} - +
+ {/* iOS identifiers (App Store bundle ID + Apple ID + + ASC Issuer/Key) — submitted via this card's parent + form. The .p8 file upload below is a separate flow + (file uploads don't go through form submit). */} +
+ + setIosBundleId(event.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="com.example.ios" + spellCheck={false} + aria-invalid={!isIosBundleValid} + disabled={iosBundleLocked} + /> +

+ { + "Use the bundle identifier from Xcode / App Store Connect (e.g., com.example.ios)." + } +

+ {iosBundleLocked && ( +

+ {"Bundle IDs can’t be edited once saved."} +

+ )} + {!isIosBundleValid && ( +

+ {"Enter a valid App Store bundle ID."} +

+ )} +
+
+ + setIosAppAppleId(event.target.value)} + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="1234567890" + aria-invalid={!isIosAppleIdValid} + disabled={iosAppleIdLocked} + /> +

+ { + "Required for production validations. This is the numeric Apple ID found in App Store Connect under Apps → → App Information -> Apple ID." + } +

+ {iosAppleIdLocked && ( +

+ {"Apple App IDs can’t be edited once saved."} +

+ )} + {!isIosAppleIdValid && ( +

+ {"App Apple ID must be numeric."} +

+ )} +
+
+ + + setIosAppStoreIssuerId(event.target.value) + } + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="12345678-ABCD-1234-ABCD-1234567890AB" + spellCheck={false} + aria-invalid={!isIosIssuerIdValid} + /> +

+ { + "Needed for App Store Server API calls. Find it in App Store Connect → Users and Access → Integrations → In-App Purchase (under Keys)" + } +

+ {iosIssuerLocked && ( +

+ {"Issuer IDs can’t be cleared once saved."} +

+ )} + {!isIosIssuerIdValid && ( +

+ { + "Issuer ID must match the UUID format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)." + } +

+ )} +
+
+ + + setIosAppStoreKeyId(event.target.value.toUpperCase()) + } + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="ABCDE12345" + spellCheck={false} + aria-invalid={!isIosKeyIdValid} + /> +

+ { + "Ten-character identifier shown next to the .p8 key. Find it in App Store Connect → Users and Access → Integrations → In-App Purchase (under Keys)" + } +

+ {iosKeyLocked && ( +

+ {"Key IDs can’t be cleared once saved."} +

+ )} + {!isIosKeyIdValid && ( +

+ {"Key ID must be 10 uppercase letters or numbers."} +

+ )} + {!isIosCredentialPairValid && ( +

+ { + "Provide both Issuer ID and Key ID to enable the App Store Server API." + } +

+ )} +
+ +
+ + + {iosFileUploaded || hasIosFile ? ( +
+
+
+ +
+ + {"Authentication file uploaded successfully"} + + {iosFile && ( +

+ {iosFile.fileName} •{" "} + {(iosFile.fileSize / 1024).toFixed(2)} KB +

+ )} +
+
+
{iosFile && ( -

- {iosFile.fileName} •{" "} - {(iosFile.fileSize / 1024).toFixed(2)} KB -

+ )} -
-
-
- {iosFile && ( - )} - +
-
- ) : ( - <> -
- void handleIosFileUpload(e)} - className="hidden" - id="ios-file-upload" - disabled={uploadingIos} - /> - -
-

- { - "Required for App Store receipt verification. Generate the .p8 key in App Store Connect and upload it here." - } -

- {showIosP8Requirement && ( -

+ ) : ( + <> +

+ void handleIosFileUpload(e)} + className="hidden" + id="ios-file-upload" + disabled={uploadingIos} + /> + +
+

{ - "Upload your App Store Connect .p8 key to finish iOS setup." + "Required for App Store receipt verification. Generate the .p8 key in App Store Connect and upload it here." }

- )} - - {/* P8 requirement reminder / advanced context */} - {showIosP8Requirement ? ( -
-
- -
-

- {"App Store Connect key missing"} -

-

- { - "You must upload the downloaded .p8 key before IAPKit can verify App Store receipts." - } -

-
-
-
- ) : ( -
-
- -
-

- { - "When P8 key is provided, these advanced features become available:" - } -

-
    -
  • • {"Subscription Status Query"}
  • -
  • - •{" "} + {showIosP8Requirement && ( +

    + { + "Upload your App Store Connect .p8 key to finish iOS setup." + } +

    + )} + + {/* P8 requirement reminder / advanced context */} + {showIosP8Requirement ? ( +
    +
    + +
    +

    + {"App Store Connect key missing"} +

    +

    { - "Subscription Renewal/Cancellation Prediction" + "You must upload the downloaded .p8 key before IAPKit can verify App Store receipts." } -

  • -
  • • {"Refund History Query"}
  • -
  • - •{" "} +

    +
+
+
+ ) : ( +
+
+ +
+

{ - "Consumption API (consumable item usage tracking)" + "When P8 key is provided, these advanced features become available:" } - - +

+
    +
  • • {"Subscription Status Query"}
  • +
  • + •{" "} + { + "Subscription Renewal/Cancellation Prediction" + } +
  • +
  • • {"Refund History Query"}
  • +
  • + •{" "} + { + "Consumption API (consumable item usage tracking)" + } +
  • +
+
-
- )} - - )} -
- - {/* iOS Setup Guide */} -
-
-
- - - {"How to get your .p8 file:"} - -
- -
-
    -
  1. - { - "Go to App Store Connect → Users and Access → Integrations → In-App Purchase" - } -
  2. -
  3. - {"Click 'Generate In-App Purchase Key' or '+' button"} -
  4. -
  5. - { - "Enter a name and download the .p8 file (can only be downloaded once)" - } -
  6. -
-
- - - - {"Learn more"} - - + )} + + )}
-
-
-
- )} - - {/* Android Configuration */} - {showAndroidSection && ( -
-
- -

- {"Android Configuration"} -

-
-
-
- - - {androidFileUploaded || hasAndroidFile ? ( -
-
-
- -
- - {"Service account file uploaded successfully"} - - {androidFile && ( -

- {androidFile.fileName} •{" "} - {(androidFile.fileSize / 1024).toFixed(2)} KB -

- )} -
-
-
- {androidFile && ( - - )} - -
+ {/* iOS Setup Guide */} +
+
+
+ + + {"How to get your .p8 file:"} +
+
- ) : ( - <> -
- void handleAndroidFileUpload(e)} - className="hidden" - id="android-file-upload" - disabled={uploadingAndroid} - /> - -
-

+

    +
  1. { - "Required for validating Google Play purchases. Use minimum permissions principle." + "Go to App Store Connect → Users and Access → Integrations → In-App Purchase" } -

    - - )} +
  2. +
  3. + {"Click 'Generate In-App Purchase Key' or '+' button"} +
  4. +
  5. + { + "Enter a name and download the .p8 file (can only be downloaded once)" + } +
  6. +
+
+ + + + {"Learn more"} + + +
+
+
+
+ )} + + {/* Android Configuration — package name + service-account JSON + + Horizon (Quest / VR) toggle all live here so Android-only + configuration is self-contained. */} + {showAndroidSection && ( +
+
+ +

+ {"Android Configuration"} +

- {/* Android Setup Guide */} -
-
-
- - - {"Setup Guide:"} +
+ {/* Android package name — submitted via the parent + form's metadata save. The JSON file upload below is + a separate flow. */} +
+
- -
-
    -
  1. - {"In the Google Cloud Console go to Service Accounts"} -
  2. -
  3. - {"Click Create service account and follow the steps"} -
  4. -
  5. - { - "Go to the Users & Permissions page on the Google Play Console" + {"Required"} + + + setAndroidPackageName(event.target.value) } -
  6. -
  7. {"Click Invite new users"}
  8. -
  9. + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="com.example.app" + spellCheck={false} + aria-invalid={!isAndroidPackageValid} + disabled={androidPackageLocked} + /> +

    { - "Put an email address for your service account in the email address field and grant the necessary rights:" + "Use the exact package name from the Google Play Console (e.g., com.example.app)." } -

    -
    - { - "• View financial data, orders, and cancellation survey responses" - } -
    -
    {"• Manage orders and subscriptions"}
    -
    -
  10. -
  11. {"Click Invite user"}
  12. -
-
- - - - {"Learn more"} - - +

+ {androidPackageLocked && ( +

+ {"Android package names can’t be edited once saved."} +

+ )} + {!isAndroidPackageValid && ( +

+ {"Enter a valid Android package name."} +

+ )}
-
- {/* Meta Horizon subsection — same card as Android because - the client SDK is Google-Play-Billing-compatible, but - verification goes through Meta's Graph API with its - own App ID + Access Token pair. Gated by a checkbox - so Android-only projects aren't cluttered. */} -
- - - {horizonEnabled && ( -
-
- - setHorizonAppId(e.target.value)} - className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" - /> -

- { - "Numeric ID from the Meta Developer Dashboard (6–20 digits)." - } -

- {!horizonAppIdValid && - trimmedHorizonAppId.length > 0 && ( -

- {"App ID must be a numeric string (6–20 digits)."} -

- )} -
- -
- - {hasHorizonAppSecretConfigured && - !isReplacingHorizonAppSecret ? ( - // Secret already on file. Server never echoes - // it back, so instead of rendering an empty - // password input (which would imply "clear" - // semantics) show a "configured" affordance - // with a Replace button. -
-
- +
+ + + {androidFileUploaded || hasAndroidFile ? ( +
+
+
+ +
- {"App Secret configured"} + {"Service account file uploaded successfully"} + {androidFile && ( +

+ {androidFile.fileName} •{" "} + {(androidFile.fileSize / 1024).toFixed(2)} KB +

+ )}
+
+
+ {androidFile && ( + + )}
- ) : ( +
+
+ ) : ( + <> +
- setHorizonAppSecret(e.target.value) - } - className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary" + type="file" + accept=".json" + onChange={(e) => void handleAndroidFileUpload(e)} + className="hidden" + id="android-file-upload" + disabled={uploadingAndroid} /> - )} -

+ +

+

{ - "App Secret from the Meta Developer Dashboard. The IAPKit server combines it with the App ID as OC|APP_ID|APP_SECRET for each verify call — treat it like a password." + "Required for validating Google Play purchases. Use minimum permissions principle." }

- {!horizonAppSecretValid && - trimmedHorizonAppSecret.length > 0 && ( -

- { - "App Secret looks malformed (expected 16–2048 characters)." - } -

- )} -
+ + )} +
+ {/* Android Setup Guide */} +
+
+
+ + + {"Setup Guide:"} + +
+ +
+
    +
  1. + {"In the Google Cloud Console go to Service Accounts"} +
  2. +
  3. + {"Click Create service account and follow the steps"} +
  4. +
  5. + { + "Go to the Users & Permissions page on the Google Play Console" + } +
  6. +
  7. {"Click Invite new users"}
  8. +
  9. + { + "Put an email address for your service account in the email address field and grant the necessary rights:" + } +
    +
    + { + "• View financial data, orders, and cancellation survey responses" + } +
    +
    {"• Manage orders and subscriptions"}
    +
    +
  10. +
  11. {"Click Invite user"}
  12. +
+
+ + - {"Meta billing docs"} + {"Learn more"}
- )} - - {/* Save button only renders when there's actually - * something to save: either the user has Horizon - * checked (saving config), or they unchecked a - * previously-enabled toggle (persisting the - * disable). When the project never had Horizon and - * the toggle is off, the button is meaningless and - * hidden — the unchecked toggle alone is the - * "disabled" state, no extra click required. */} - {(horizonEnabled || originalHorizonEnabled) && ( -
- -
- )} +
+ + {/* Meta Horizon subsection — same card as Android because + the client SDK is Google-Play-Billing-compatible, but + verification goes through Meta's Graph API with its + own App ID + Access Token pair. Gated by a checkbox + so Android-only projects aren't cluttered. */} +
+ + + {horizonEnabled && ( +
+
+ + setHorizonAppId(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" + /> +

+ { + "Numeric ID from the Meta Developer Dashboard (6–20 digits)." + } +

+ {!horizonAppIdValid && + trimmedHorizonAppId.length > 0 && ( +

+ { + "App ID must be a numeric string (6–20 digits)." + } +

+ )} +
+ +
+ + {hasHorizonAppSecretConfigured && + !isReplacingHorizonAppSecret ? ( + // Secret already on file. Server never echoes + // it back, so instead of rendering an empty + // password input (which would imply "clear" + // semantics) show a "configured" affordance + // with a Replace button. +
+
+ + + {"App Secret configured"} + +
+ +
+ ) : ( + + setHorizonAppSecret(e.target.value) + } + className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary" + /> + )} +

+ { + "App Secret from the Meta Developer Dashboard. The IAPKit server combines it with the App ID as OC|APP_ID|APP_SECRET for each verify call — treat it like a password." + } +

+ {!horizonAppSecretValid && + trimmedHorizonAppSecret.length > 0 && ( +

+ { + "App Secret looks malformed (expected 16–2048 characters)." + } +

+ )} +
+ + + {"Meta billing docs"} + + +
+ )} + + {/* Save button only renders when there's actually + * something to save: either the user has Horizon + * checked (saving config), or they unchecked a + * previously-enabled toggle (persisting the + * disable). When the project never had Horizon and + * the toggle is off, the button is meaningless and + * hidden — the unchecked toggle alone is the + * "disabled" state, no extra click required. */} + {(horizonEnabled || originalHorizonEnabled) && ( +
+ +
+ )} +
-
- )} -
+ )} +
+ +
+ + {"Save identifiers"} + +
+ )} {/* Guide Modal for iOS */} From 1d025192be782b01261c9294e4425bb4a051526b Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 20:59:53 +0900 Subject: [PATCH 19/81] fix(kit/settings): show 'Configuration Required' for missing iOS or Android cred MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The banner previously only fired when the Android service-account JSON was missing. iOS-only projects with no .p8 uploaded would silently get no warning, even though purchase verification was just as broken. The check now composes the message from whichever enabled platforms are missing their credential file — iOS-only sees just the .p8 line, Android-only sees just the JSON line, dual-platform sees both. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auth/organization/project/settings.tsx | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index 265139af..9a6f293e 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -629,24 +629,43 @@ export default function ProjectSettings() {
- {/* Guide message if Android file is not uploaded */} - {showAndroidSection && !androidFileUploaded && !hasAndroidFile && ( -
-
- -
-

- {"Configuration Required"} -

-

- { - "Upload the Android service account JSON and the Apple App Store Connect .p8 key with Issuer ID and Key ID to enable purchase verification." - } -

+ {/* Configuration Required banner — fires whenever any + *enabled* platform is missing its credential file. iOS-only + projects need the .p8; Android-only projects need the JSON; + dual-platform projects need both. The message is composed + from whichever platforms are missing so we don't tell an + iOS-only operator they need to upload an Android file. */} + {(() => { + const missingIos = showAppleSection && !iosFileUploaded && !hasIosFile; + const missingAndroid = + showAndroidSection && !androidFileUploaded && !hasAndroidFile; + if (!missingIos && !missingAndroid) return null; + const parts: string[] = []; + if (missingIos) { + parts.push( + "the Apple App Store Connect .p8 key with Issuer ID and Key ID", + ); + } + if (missingAndroid) { + parts.push("the Android service account JSON"); + } + const message = `Upload ${parts.join(" and ")} to enable purchase verification.`; + return ( +
+
+ +
+

+ {"Configuration Required"} +

+

+ {message} +

+
-
- )} + ); + })()} {(showAppleSection || showAndroidSection) && (
Date: Fri, 1 May 2026 21:14:31 +0900 Subject: [PATCH 20/81] fix(kit,sdk): address PR #124 review threads (round 7) + CI sync drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI fix: re-run gql codegen so the kit-api.ts changes from round 5 are reflected in libraries/{expo,react-native}-iap/src/kit-api.ts. Round-7 CodeRabbit findings addressed: - settings.tsx: 8 helper buttons (file download/delete, guide open/screenshots) now declare type="button" so clicks don't submit the parent metadata form. - convex/files/action.ts: download action now reads file.fileType (the actual schema field) instead of a non-existent file.mimeType, so blobs reconstruct with the correct content type. - convex/products/play.ts: wrap service-account JSON.parse in try/catch and re-throw with an actionable config error message. - convex/webhooks/apple.ts: previewDecodeNotification throws ConvexError({code: INVALID_SIGNATURE}) instead of plain Error so malformed JWS / bodies surface as 400 — Apple stops retrying. - convex/webhooks/google.ts: package-name mismatch throws ConvexError({code: PACKAGE_NAME_MISMATCH}) so Pub/Sub stops retrying a permanent input error. - flutter webhook_client: only advance _lastEventId after a successful _events.add. Malformed frames no longer move the reconnect cursor past undelivered events. - kmp WebhookTransport.ios: always wait 2s between reconnect attempts (skip only on first iteration). Advance cursor only after channel.trySend(...).isSuccess; on parse failure still advance so we don't loop on the same malformed id. - kmp WebhookTransport.android: parallel cursor-advance fix — resumeId only updates after emit(event) succeeds. - gql/kit-api.ts: mergeHeaders helper falls back to a plain-record case-insensitive merge when the runtime lacks a global Headers constructor (older RN builds where fetchImpl is supplied without a Headers polyfill). - convex/schema.ts: revenueMetricsDaily key now includes currency so multi-currency sales of the same SKU on the same day don't overwrite each other or mix incompatible totals. - server/api/v1/webhooks.ts SSE: redact rawSignedPayload from streamed events (signed payload contains store-level signature material that doesn't belong on a long-lived browser-readable stream); replaced single onUpdate with paginated drain → live tail to work around Convex's pinned-args subscription model. Pre-fix the live subscription was capped at 500 rows that never advanced, so projects past the initial batch never received new events. Co-Authored-By: Claude Opus 4.7 (1M context) --- libraries/expo-iap/src/kit-api.ts | 100 +++++++++--- .../lib/webhook_client.dart | 13 +- .../openiap/WebhookTransport.android.kt | 14 +- .../kmpiap/openiap/WebhookTransport.ios.kt | 30 +++- libraries/react-native-iap/src/kit-api.ts | 100 +++++++++--- packages/gql/src/kit-api.ts | 108 +++++++++---- packages/kit/convex/files/action.ts | 9 +- packages/kit/convex/products/play.ts | 13 +- packages/kit/convex/schema.ts | 30 +++- packages/kit/convex/webhooks/apple.ts | 15 +- packages/kit/convex/webhooks/google.ts | 13 +- packages/kit/server/api/v1/webhooks.ts | 143 ++++++++++++++---- .../auth/organization/project/settings.tsx | 8 + 13 files changed, 481 insertions(+), 115 deletions(-) diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts index de6c1c8b..683f0f1e 100644 --- a/libraries/expo-iap/src/kit-api.ts +++ b/libraries/expo-iap/src/kit-api.ts @@ -59,6 +59,68 @@ export type Paywall = { const DEFAULT_BASE_URL = "https://kit.openiap.dev"; +// Merge caller-supplied headers with kit defaults (`accept`, +// optionally `content-type`). When the runtime exposes a global +// `Headers` constructor we use it directly so callers passing a +// `Headers` instance (a `HeadersInit`) keep that exact instance's +// values. When `Headers` is missing — older React Native builds where +// the operator wires up `fetchImpl` without a `Headers` polyfill — +// we fall back to a case-insensitive merge into a plain record so +// the request still goes through. Either way, caller-set values take +// precedence over kit defaults. +function mergeHeaders( + callerHeaders: HeadersInit | undefined, + hasBody: boolean, +): HeadersInit { + if (typeof Headers === "function") { + const merged = new Headers(callerHeaders); + if (!merged.has("accept")) merged.set("accept", "application/json"); + if (hasBody && !merged.has("content-type")) { + merged.set("content-type", "application/json"); + } + return merged; + } + // Plain-object fallback path. Build a case-insensitive name map + // from whatever the caller passed (Headers-shaped, array-of-pairs, + // or plain record) and re-emit as a record `fetchImpl` accepts. + const lower = new Map(); + const setIfAbsent = (name: string, value: string) => { + const key = name.toLowerCase(); + if (!lower.has(key)) lower.set(key, { name, value }); + }; + const setForce = (name: string, value: string) => { + const key = name.toLowerCase(); + lower.set(key, { name, value }); + }; + if (callerHeaders) { + if (Array.isArray(callerHeaders)) { + for (const [name, value] of callerHeaders) setForce(name, value); + } else if ( + typeof (callerHeaders as { forEach?: unknown }).forEach === "function" + ) { + // `Headers`-like (without being our `typeof Headers === "function"` + // global). RN polyfills sometimes attach `Headers` only to + // request/response instances rather than the global scope. + ( + callerHeaders as { + forEach: (cb: (value: string, key: string) => void) => void; + } + ).forEach((value, name) => setForce(name, value)); + } else { + for (const [name, value] of Object.entries( + callerHeaders as Record, + )) { + setForce(name, value); + } + } + } + setIfAbsent("accept", "application/json"); + if (hasBody) setIfAbsent("content-type", "application/json"); + const out: Record = {}; + for (const { name, value } of lower.values()) out[name] = value; + return out; +} + export class KitApiError extends Error { constructor( readonly status: number, @@ -72,27 +134,31 @@ export class KitApiError extends Error { export function kitApi(options: KitApiOptions) { const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); - const fetchImpl: ( - input: string, - init?: RequestInit, - ) => Promise = (() => { - if (options.fetchImpl) return options.fetchImpl; - if (typeof fetch === "function") { - return (input: string, init?: RequestInit) => fetch(input, init); - } - throw new Error( - "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", - ); - })(); + const fetchImpl: (input: string, init?: RequestInit) => Promise = + (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); async function call(path: string, init?: RequestInit): Promise { + // Normalize headers without depending on a global `Headers` + // constructor: older React Native runtimes ship `fetch` (or a + // polyfill via `fetchImpl`) without exposing `Headers` globally. + // The prior implementation crashed before the first request on + // those runtimes. We use `new Headers()` when available (preserves + // caller-supplied `Headers` instances exactly), and otherwise fall + // back to a small case-insensitive merge into a plain record. + // Either way, kit defaults only apply when the caller hasn't set + // the same name. + const headers = mergeHeaders(init?.headers, init?.body != null); const response = await fetchImpl(`${baseUrl}${path}`, { ...init, - headers: { - accept: "application/json", - ...(init?.body ? { "content-type": "application/json" } : {}), - ...(init?.headers as Record | undefined), - }, + headers, }); const text = await response.text(); let parsed: unknown = text; diff --git a/libraries/flutter_inapp_purchase/lib/webhook_client.dart b/libraries/flutter_inapp_purchase/lib/webhook_client.dart index 42df0aa4..f9d86e91 100644 --- a/libraries/flutter_inapp_purchase/lib/webhook_client.dart +++ b/libraries/flutter_inapp_purchase/lib/webhook_client.dart @@ -296,9 +296,11 @@ class _SseWebhookListener implements WebhookListener { break; } } - if (eventId != null && eventId.isNotEmpty) { - _lastEventId = eventId; - } + // Don't advance `_lastEventId` here — wait until we actually + // accept the event below. If the frame is malformed, advancing + // before the parse would move the reconnect cursor past an event + // that we never delivered to the consumer, so the next connection + // would skip it permanently. if (dataLines.isEmpty) return; final dataStr = dataLines.join('\n'); if (dataStr.isEmpty) return; @@ -334,6 +336,11 @@ class _SseWebhookListener implements WebhookListener { return; } _events.add(event); + // Cursor advances only on successful enqueue. The reconnect path + // resumes strictly past the last event we actually surfaced. + if (eventId != null && eventId.isNotEmpty) { + _lastEventId = eventId; + } } } diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt index c6638786..d96bce91 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.android.kt @@ -62,8 +62,18 @@ actual class WebhookTransport actual constructor( val frame = frameLines.toString() frameLines.clear() val parsed = parseSseFrame(frame) - parsed.eventId?.let { resumeId = it } - parsed.event?.let { emit(it) } + // Cursor advances ONLY after a successful emit + // (parsed.event != null AND emit didn't throw). + // Advancing before would move the reconnect + // cursor past events the consumer never saw — + // either control frames (heartbeat/ready) or + // malformed payloads. Heartbeat/ready don't + // carry an id from the server anyway. + val event = parsed.event + if (event != null) { + emit(event) + parsed.eventId?.let { resumeId = it } + } continue } frameLines.append(line).append('\n') diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt index cdef03c6..ca43fcbf 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/openiap/WebhookTransport.ios.kt @@ -50,10 +50,19 @@ actual class WebhookTransport actual constructor( val scope = CoroutineScope(Dispatchers.Default) scope.launch { var resumeId = lastEventId + var firstAttempt = true while (!closed) { - val ok = runOnce(channel, resumeId) { id -> resumeId = id } + // Always pause between attempts (after the first) — even + // when `runOnce` returns true. NSURLSession completes the + // task on EOF / server-side disconnect; without a pause + // we'd reconnect in a tight loop the moment the kit pod + // recycles. 2s matches the Flutter listener's cadence. + if (!firstAttempt) { + delay(2_000) + } + firstAttempt = false + runOnce(channel, resumeId) { id -> resumeId = id } if (closed) break - if (!ok) delay(2_000) } channel.close() } @@ -167,12 +176,23 @@ private class SseDelegate( } } } - eventId?.let(updateLastEventId) if (eventType == "heartbeat" || eventType == "ready" || data.isEmpty()) { return } - WebhookEventParser.parse(data.toString())?.let { event -> - channel.trySend(event) + // Cursor advances ONLY after a successful enqueue. Advancing + // before the parse / trySend (the prior implementation) would + // move the reconnect cursor past events that never reached the + // consumer — either because the parser returned null on a + // malformed frame, or because the buffered channel rejected + // the trySend. The reconnect would then skip those ids + // forever. If parse fails entirely we still advance so we + // don't loop on the same malformed id. + val event = WebhookEventParser.parse(data.toString()) ?: run { + eventId?.let(updateLastEventId) + return + } + if (channel.trySend(event).isSuccess) { + eventId?.let(updateLastEventId) } } } diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts index de6c1c8b..683f0f1e 100644 --- a/libraries/react-native-iap/src/kit-api.ts +++ b/libraries/react-native-iap/src/kit-api.ts @@ -59,6 +59,68 @@ export type Paywall = { const DEFAULT_BASE_URL = "https://kit.openiap.dev"; +// Merge caller-supplied headers with kit defaults (`accept`, +// optionally `content-type`). When the runtime exposes a global +// `Headers` constructor we use it directly so callers passing a +// `Headers` instance (a `HeadersInit`) keep that exact instance's +// values. When `Headers` is missing — older React Native builds where +// the operator wires up `fetchImpl` without a `Headers` polyfill — +// we fall back to a case-insensitive merge into a plain record so +// the request still goes through. Either way, caller-set values take +// precedence over kit defaults. +function mergeHeaders( + callerHeaders: HeadersInit | undefined, + hasBody: boolean, +): HeadersInit { + if (typeof Headers === "function") { + const merged = new Headers(callerHeaders); + if (!merged.has("accept")) merged.set("accept", "application/json"); + if (hasBody && !merged.has("content-type")) { + merged.set("content-type", "application/json"); + } + return merged; + } + // Plain-object fallback path. Build a case-insensitive name map + // from whatever the caller passed (Headers-shaped, array-of-pairs, + // or plain record) and re-emit as a record `fetchImpl` accepts. + const lower = new Map(); + const setIfAbsent = (name: string, value: string) => { + const key = name.toLowerCase(); + if (!lower.has(key)) lower.set(key, { name, value }); + }; + const setForce = (name: string, value: string) => { + const key = name.toLowerCase(); + lower.set(key, { name, value }); + }; + if (callerHeaders) { + if (Array.isArray(callerHeaders)) { + for (const [name, value] of callerHeaders) setForce(name, value); + } else if ( + typeof (callerHeaders as { forEach?: unknown }).forEach === "function" + ) { + // `Headers`-like (without being our `typeof Headers === "function"` + // global). RN polyfills sometimes attach `Headers` only to + // request/response instances rather than the global scope. + ( + callerHeaders as { + forEach: (cb: (value: string, key: string) => void) => void; + } + ).forEach((value, name) => setForce(name, value)); + } else { + for (const [name, value] of Object.entries( + callerHeaders as Record, + )) { + setForce(name, value); + } + } + } + setIfAbsent("accept", "application/json"); + if (hasBody) setIfAbsent("content-type", "application/json"); + const out: Record = {}; + for (const { name, value } of lower.values()) out[name] = value; + return out; +} + export class KitApiError extends Error { constructor( readonly status: number, @@ -72,27 +134,31 @@ export class KitApiError extends Error { export function kitApi(options: KitApiOptions) { const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); - const fetchImpl: ( - input: string, - init?: RequestInit, - ) => Promise = (() => { - if (options.fetchImpl) return options.fetchImpl; - if (typeof fetch === "function") { - return (input: string, init?: RequestInit) => fetch(input, init); - } - throw new Error( - "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", - ); - })(); + const fetchImpl: (input: string, init?: RequestInit) => Promise = + (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); async function call(path: string, init?: RequestInit): Promise { + // Normalize headers without depending on a global `Headers` + // constructor: older React Native runtimes ship `fetch` (or a + // polyfill via `fetchImpl`) without exposing `Headers` globally. + // The prior implementation crashed before the first request on + // those runtimes. We use `new Headers()` when available (preserves + // caller-supplied `Headers` instances exactly), and otherwise fall + // back to a small case-insensitive merge into a plain record. + // Either way, kit defaults only apply when the caller hasn't set + // the same name. + const headers = mergeHeaders(init?.headers, init?.body != null); const response = await fetchImpl(`${baseUrl}${path}`, { ...init, - headers: { - accept: "application/json", - ...(init?.body ? { "content-type": "application/json" } : {}), - ...(init?.headers as Record | undefined), - }, + headers, }); const text = await response.text(); let parsed: unknown = text; diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts index 7b8c44cf..683f0f1e 100644 --- a/packages/gql/src/kit-api.ts +++ b/packages/gql/src/kit-api.ts @@ -59,6 +59,68 @@ export type Paywall = { const DEFAULT_BASE_URL = "https://kit.openiap.dev"; +// Merge caller-supplied headers with kit defaults (`accept`, +// optionally `content-type`). When the runtime exposes a global +// `Headers` constructor we use it directly so callers passing a +// `Headers` instance (a `HeadersInit`) keep that exact instance's +// values. When `Headers` is missing — older React Native builds where +// the operator wires up `fetchImpl` without a `Headers` polyfill — +// we fall back to a case-insensitive merge into a plain record so +// the request still goes through. Either way, caller-set values take +// precedence over kit defaults. +function mergeHeaders( + callerHeaders: HeadersInit | undefined, + hasBody: boolean, +): HeadersInit { + if (typeof Headers === "function") { + const merged = new Headers(callerHeaders); + if (!merged.has("accept")) merged.set("accept", "application/json"); + if (hasBody && !merged.has("content-type")) { + merged.set("content-type", "application/json"); + } + return merged; + } + // Plain-object fallback path. Build a case-insensitive name map + // from whatever the caller passed (Headers-shaped, array-of-pairs, + // or plain record) and re-emit as a record `fetchImpl` accepts. + const lower = new Map(); + const setIfAbsent = (name: string, value: string) => { + const key = name.toLowerCase(); + if (!lower.has(key)) lower.set(key, { name, value }); + }; + const setForce = (name: string, value: string) => { + const key = name.toLowerCase(); + lower.set(key, { name, value }); + }; + if (callerHeaders) { + if (Array.isArray(callerHeaders)) { + for (const [name, value] of callerHeaders) setForce(name, value); + } else if ( + typeof (callerHeaders as { forEach?: unknown }).forEach === "function" + ) { + // `Headers`-like (without being our `typeof Headers === "function"` + // global). RN polyfills sometimes attach `Headers` only to + // request/response instances rather than the global scope. + ( + callerHeaders as { + forEach: (cb: (value: string, key: string) => void) => void; + } + ).forEach((value, name) => setForce(name, value)); + } else { + for (const [name, value] of Object.entries( + callerHeaders as Record, + )) { + setForce(name, value); + } + } + } + setIfAbsent("accept", "application/json"); + if (hasBody) setIfAbsent("content-type", "application/json"); + const out: Record = {}; + for (const { name, value } of lower.values()) out[name] = value; + return out; +} + export class KitApiError extends Error { constructor( readonly status: number, @@ -72,34 +134,28 @@ export class KitApiError extends Error { export function kitApi(options: KitApiOptions) { const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""); - const fetchImpl: ( - input: string, - init?: RequestInit, - ) => Promise = (() => { - if (options.fetchImpl) return options.fetchImpl; - if (typeof fetch === "function") { - return (input: string, init?: RequestInit) => fetch(input, init); - } - throw new Error( - "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", - ); - })(); + const fetchImpl: (input: string, init?: RequestInit) => Promise = + (() => { + if (options.fetchImpl) return options.fetchImpl; + if (typeof fetch === "function") { + return (input: string, init?: RequestInit) => fetch(input, init); + } + throw new Error( + "kitApi requires a fetch implementation. Pass `fetchImpl` for runtimes without a global fetch.", + ); + })(); async function call(path: string, init?: RequestInit): Promise { - // Normalize headers via the Headers constructor so caller-supplied - // `Headers` instances (which are not plain objects) survive the - // merge — the prior object-spread silently dropped any header - // passed as a `Headers` instance, including auth headers. Caller - // headers win over kit defaults if they explicitly set the same - // name; we set defaults via `set` then re-apply caller values - // after the fact. - const headers = new Headers(init?.headers); - if (!headers.has("accept")) { - headers.set("accept", "application/json"); - } - if (init?.body && !headers.has("content-type")) { - headers.set("content-type", "application/json"); - } + // Normalize headers without depending on a global `Headers` + // constructor: older React Native runtimes ship `fetch` (or a + // polyfill via `fetchImpl`) without exposing `Headers` globally. + // The prior implementation crashed before the first request on + // those runtimes. We use `new Headers()` when available (preserves + // caller-supplied `Headers` instances exactly), and otherwise fall + // back to a small case-insensitive merge into a plain record. + // Either way, kit defaults only apply when the caller hasn't set + // the same name. + const headers = mergeHeaders(init?.headers, init?.body != null); const response = await fetchImpl(`${baseUrl}${path}`, { ...init, headers, diff --git a/packages/kit/convex/files/action.ts b/packages/kit/convex/files/action.ts index 5f9b618e..6616daf8 100644 --- a/packages/kit/convex/files/action.ts +++ b/packages/kit/convex/files/action.ts @@ -37,11 +37,16 @@ export const downloadFile = action({ throw new ConvexError("Not authenticated"); } + // The Convex `files` table stores the MIME type in `fileType` (see + // `files/internal.ts`). The prior typing pulled `mimeType` and so + // every download fell back to `application/octet-stream` — the + // dashboard would then build the Blob with the wrong content type + // and the browser would mis-handle the .p8 / .json download. const file: { _id: Id<"files">; fileName: string; organizationId: Id<"organizations">; - mimeType?: string; + fileType?: string; } | null = await ctx.runQuery(internal.files.internal.getFileRecord, { fileId: args.fileId, }); @@ -64,7 +69,7 @@ export const downloadFile = action({ return { fileName: result.fileName, - mimeType: file.mimeType ?? "application/octet-stream", + mimeType: file.fileType ?? "application/octet-stream", base64: result.content, }; }, diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index 478ac776..fcd8c8e6 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -62,7 +62,18 @@ export const pushSyncProductsGoogle = action({ if (!fileContent?.content) { throw new Error("Service account JSON file is unreadable"); } - const credentials = JSON.parse(fileContent.content); + // Wrap the parse so a malformed JSON upload yields an actionable + // config error ("Service account JSON is invalid") instead of a + // raw SyntaxError from JSON.parse, which surfaces as a generic + // 500 with no operator-friendly hint. + let credentials: Record; + try { + credentials = JSON.parse(fileContent.content) as Record; + } catch { + throw new Error( + "Service account JSON is invalid — re-upload the file from Google Cloud Console", + ); + } const auth = new google.auth.GoogleAuth({ credentials, diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 87351e83..7f6c17a9 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -555,26 +555,40 @@ const schema = defineSchema({ .index("by_project_and_state", ["projectId", "state"]) .index("by_project_and_updated", ["projectId", "updatedAt"]), - // Daily revenue metrics rollup keyed by (projectId, day, productId). - // Populated by `recomputeRevenueMetrics` cron (recomputes the trailing - // window from `subscriptions` so late-arriving webhook corrections are - // reflected). The dashboard reads from here to avoid scanning the full - // events log on every page render. + // Daily revenue metrics rollup keyed by (projectId, day, productId, + // currency). Populated by `recomputeRevenueMetrics` cron (recomputes + // the trailing window from `subscriptions` so late-arriving webhook + // corrections are reflected). The dashboard reads from here to avoid + // scanning the full events log on every page render. + // + // Currency is part of the row key because the same SKU can sell in + // multiple storefront currencies on the same UTC day — keying only + // by (projectId, day, productId) would either mix incompatible + // `revenueMicros` totals or have one currency overwrite another, + // both of which produce wrong dashboard numbers for multi-region + // apps. Aggregating across currencies is a presentation-layer + // concern (FX conversion happens in the UI, with whatever rates the + // operator picks). revenueMetricsDaily: defineTable({ projectId: v.id("projects"), day: v.string(), // ISO date (YYYY-MM-DD), UTC productId: v.string(), + currency: v.string(), activeSubs: v.number(), newSubs: v.number(), renewals: v.number(), cancellations: v.number(), refunds: v.number(), revenueMicros: v.number(), - currency: v.string(), updatedAt: v.number(), }) - .index("by_project_and_day", ["projectId", "day"]) - .index("by_project_and_product_and_day", ["projectId", "productId", "day"]), + .index("by_project_and_day_and_currency", ["projectId", "day", "currency"]) + .index("by_project_and_product_and_day_and_currency", [ + "projectId", + "productId", + "day", + "currency", + ]), // Unified product catalog. Mirrors what onesub holds in @onesub/providers // — the subset of App Store Connect / Play Console that kit can read / diff --git a/packages/kit/convex/webhooks/apple.ts b/packages/kit/convex/webhooks/apple.ts index 5635bca9..ffcb1c4d 100644 --- a/packages/kit/convex/webhooks/apple.ts +++ b/packages/kit/convex/webhooks/apple.ts @@ -211,12 +211,20 @@ export const ingestAppleAsnIOS = action({ // Decode JWS payload without signature verification. Used pre-verifier // to discover the environment so we can instantiate SignedDataVerifier // with the correct value. +// +// Both failure modes (wrong shape, malformed body) are permanent input +// errors — Apple should NOT retry them, so we throw structured +// ConvexErrors that `mapWebhookError` will translate to 400 instead of +// the generic 500 a plain `Error` would produce. function previewDecodeNotification(jws: string): { data?: { environment?: string; bundleId?: string }; } { const parts = jws.split("."); if (parts.length !== 3) { - throw new Error("Apple notification is not a valid JWS"); + throw new ConvexError({ + code: "INVALID_SIGNATURE", + message: "Apple notification is not a valid JWS", + }); } try { const decoded = JSON.parse( @@ -224,7 +232,10 @@ function previewDecodeNotification(jws: string): { ); return decoded as { data?: { environment?: string; bundleId?: string } }; } catch { - throw new Error("Apple notification body is not valid JSON"); + throw new ConvexError({ + code: "INVALID_SIGNATURE", + message: "Apple notification body is not valid JSON", + }); } } diff --git a/packages/kit/convex/webhooks/google.ts b/packages/kit/convex/webhooks/google.ts index d6274364..d997d654 100644 --- a/packages/kit/convex/webhooks/google.ts +++ b/packages/kit/convex/webhooks/google.ts @@ -1,5 +1,5 @@ "use node"; -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; import { google } from "googleapis"; import { action } from "../_generated/server"; @@ -76,9 +76,14 @@ export const ingestGoogleRtdn = action({ args.payload.packageName && args.payload.packageName !== project.androidPackageName ) { - throw new Error( - `Package name mismatch: notification ${args.payload.packageName} vs project ${project.androidPackageName}`, - ); + // Permanent input/config mismatch — Pub/Sub will retry forever + // unless we surface this as a 4xx. ConvexError → mapWebhookError + // → 400 so Google stops retrying a notification that can never + // succeed against this project. + throw new ConvexError({ + code: "PACKAGE_NAME_MISMATCH", + message: `Package name mismatch: notification ${args.payload.packageName} vs project ${project.androidPackageName}`, + }); } const subscriptionInfo = await maybeFetchSubscriptionInfo( diff --git a/packages/kit/server/api/v1/webhooks.ts b/packages/kit/server/api/v1/webhooks.ts index ebf8d0ab..1ed6bb2f 100644 --- a/packages/kit/server/api/v1/webhooks.ts +++ b/packages/kit/server/api/v1/webhooks.ts @@ -351,6 +351,24 @@ async function handleGoogleNotification( // close the idle connection. const HEARTBEAT_MS = 25_000; +// Drop fields the client doesn't need over the wire. `rawSignedPayload` +// holds the original JWS / Pub/Sub envelope including the upstream +// signature. Until kit grows per-purchaser SSE auth (tracked as +// follow-up — see PR #124 review), the SSE feed is gated only by the +// project API key, so any holder of that key would otherwise see +// every other customer's signed payload. The client doesn't need it +// for normal reconciliation flows: `purchaseToken` + `productId` are +// enough to match against local state. Operators that DO need the +// raw payload can fetch it through an authenticated server-to-server +// query rather than a long-lived browser-readable stream. +function redactWebhookEventForStream( + event: Record, +): Record { + const { rawSignedPayload: _omit, ...rest } = event; + void _omit; + return rest; +} + webhooks.get("/stream/:apiKey", async (c) => { const apiKey = c.req.param("apiKey"); const lastEventId = c.req.header("last-event-id") ?? undefined; @@ -365,27 +383,113 @@ webhooks.get("/stream/:apiKey", async (c) => { const reactive = new ConvexClient(convexUrlForRealtime); const seen = new Set(); - let cursor = startCursor.sinceMs; - let creationCursor = startCursor.afterCreationTime; + // `liveStart` is the boundary between the backlog drain (paginated + // HTTP queries) and the live tail (Convex `onUpdate` subscription + // pinned to `sinceMs = liveStart`). Convex pins query args at + // subscription time and won't refresh them as cursors advance — + // attaching `onUpdate` with the original cursor would create a + // 500-row result window that never moves forward, so new events + // beyond the initial batch would never reach the consumer (PR #124 + // review fix). Draining first, then pinning the live tail at + // "now", sidesteps that limitation. + const liveStart = Date.now(); await stream.writeSSE({ event: "ready", - data: JSON.stringify({ cursor }), + data: JSON.stringify({ cursor: startCursor.sinceMs }), }); - // Convex `onUpdate` re-fires the callback every time the query - // result changes. We track previously-emitted ids so a row that - // was already emitted earlier in the connection isn't re-sent - // when the result set grows. The `cursor` advances whenever we - // emit so the next reconnect resumes from the right point. + // ── Phase 1: drain backlog ─────────────────────────────────── + // Pull every event between the reconnect cursor and `liveStart` + // through paginated `webhookEventsSince` calls. The cursor pair + // (`sinceMs`, `afterCreationTime`) is honored by the query so + // same-`receivedAt` cohorts larger than `limit` still advance. + let drainCursor = startCursor.sinceMs; + let drainCreationCursor = startCursor.afterCreationTime; + try { + while (!aborted) { + const batch = (await client.query( + api.webhooks.query.webhookEventsSince, + { + apiKey, + sinceMs: drainCursor, + afterCreationTime: drainCreationCursor, + limit: 500, + }, + )) as Array>; + if (!batch.length) break; + + let advanced = false; + for (const event of batch) { + if (aborted) break; + const id = typeof event.id === "string" ? event.id : null; + if (!id || seen.has(id)) continue; + // Stop the drain once we've crossed into "live" territory — + // events at or past `liveStart` are owned by the live tail. + if ( + typeof event.receivedAt === "number" && + event.receivedAt >= liveStart + ) { + break; + } + seen.add(id); + if ( + typeof event.receivedAt === "number" && + event.receivedAt > drainCursor + ) { + drainCursor = event.receivedAt; + advanced = true; + } + if ( + typeof event._creationTime === "number" && + (drainCreationCursor === undefined || + event._creationTime > drainCreationCursor) + ) { + drainCreationCursor = event._creationTime; + advanced = true; + } + await stream + .writeSSE({ + id, + event: + typeof event.type === "string" ? event.type : "WebhookEvent", + data: JSON.stringify(redactWebhookEventForStream(event)), + }) + .catch((err) => { + console.error("[webhooks/stream] drain write failed", err); + }); + } + if (!advanced) break; + if (batch.length < 500) break; + } + } catch (error) { + console.error("[webhooks/stream] drain failed", error); + await stream.writeSSE({ + event: "stream-error", + data: JSON.stringify({ + message: error instanceof Error ? error.message : "Drain failed", + }), + }); + void reactive.close(); + return; + } + if (aborted) { + void reactive.close(); + return; + } + + // ── Phase 2: attach live tail ──────────────────────────────── + // Pinned at `liveStart`. New events arriving with `receivedAt >= + // liveStart` will appear in the query result and fire onUpdate. + // The `seen` set still dedupes across phases in case an event + // straddled the boundary. let unsubscribe: (() => void) | null = null; try { unsubscribe = reactive.onUpdate( api.webhooks.query.webhookEventsSince, { apiKey, - sinceMs: cursor, - afterCreationTime: creationCursor, + sinceMs: liveStart, limit: 500, }, (events: unknown) => { @@ -395,29 +499,12 @@ webhooks.get("/stream/:apiKey", async (c) => { const id = typeof event.id === "string" ? event.id : null; if (!id || seen.has(id)) continue; seen.add(id); - if ( - typeof event.receivedAt === "number" && - event.receivedAt > cursor - ) { - cursor = event.receivedAt; - } - // Track the highest `_creationTime` we've emitted within - // the current `receivedAt` cohort so a reconnect resumes - // strictly past the last emitted event even when many - // events share the same wall-clock `receivedAt`. - if ( - typeof event._creationTime === "number" && - (creationCursor === undefined || - event._creationTime > creationCursor) - ) { - creationCursor = event._creationTime; - } stream .writeSSE({ id, event: typeof event.type === "string" ? event.type : "WebhookEvent", - data: JSON.stringify(event), + data: JSON.stringify(redactWebhookEventForStream(event)), }) .catch((err) => { console.error("[webhooks/stream] write failed", err); diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index 9a6f293e..6fd64534 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -865,6 +865,7 @@ export default function ProjectSettings() {
{iosFile && ( )}
)}
+ {/* App Store Connect API credentials — separate slot. + Required to push-sync the IAP catalog. The Server + API key (above) only authenticates receipt + verification; ASC REST endpoints (catalog list / + create / patch) reject it with 401. */} +
+

+ {"App Store Connect API (push-sync)"} +

+

+ { + "Optional. Required only if you want kit to sync your IAP catalog with App Store Connect. Generate at App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys (or Individual Keys). NOT the same as the In-App Purchase key above." + } +

+ + + + setIosAscIssuerId(event.target.value) + } + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="12345678-ABCD-1234-ABCD-1234567890AB" + spellCheck={false} + aria-invalid={!isIosAscIssuerIdValid} + /> + {!isIosAscIssuerIdValid && ( +

+ { + "Issuer ID must match the UUID format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)." + } +

+ )} +
+
+ + + setIosAscKeyId(event.target.value.toUpperCase()) + } + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="ABCDE12345" + spellCheck={false} + aria-invalid={!isIosAscKeyIdValid} + /> + {!isIosAscKeyIdValid && ( +

+ {"Key ID must be 10 uppercase letters or numbers."} +

+ )} + {!isIosAscPairValid && ( +

+ { + "Provide both Connect API Issuer ID and Key ID, or leave both blank to skip push-sync." + } +

+ )} +
+ +
+ + {iosAscFileUploaded || hasIosAscFile ? ( +
+
+
+ +
+ + {"Connect API key uploaded successfully"} + + {iosAscFile && ( +

+ {iosAscFile.fileName} •{" "} + {(iosAscFile.fileSize / 1024).toFixed(2)} KB +

+ )} +
+
+
+ {iosAscFile && ( + + )} + +
+
+
+ ) : ( + <> +
+ void handleIosAscFileUpload(e)} + className="hidden" + id="ios-asc-file-upload" + disabled={uploadingIosAsc} + /> + +
+

+ { + "Upload only after you've generated the Team Key (or Individual Key) under App Store Connect API — uploading the In-App Purchase key here will result in 401 errors during sync." + } +

+ + )} +
+
+

+ { + "Generate at App Store Connect → Users and Access → Integrations → In-App Purchase. Used for receipt verification, subscription status, refund history." + } +

{iosFileUploaded || hasIosFile ? (
@@ -869,7 +1130,7 @@ export default function ProjectSettings() { onClick={() => void handleFileDownload( iosFile._id, - "App Store Connect API key", + "App Store Server API key", ) } className="p-2 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/20 rounded-lg transition-colors" From b6d6c8c9d5fc7c840d11e1dc3b210c6eacdc8fdc Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 22:46:54 +0900 Subject: [PATCH 25/81] fix(kit/products): ASC push-sync falls back to Server API slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated ASC API slot (added in c76e59d) requires both new Issuer/Key fields *and* the new file purpose. That's the right shape for new projects, but existing projects who upload a Team Key into the legacy single-slot workflow shouldn't need a re-config dance to unblock push-sync. Server-side fallback: when `iosAscIssuerId` / `iosAscKeyId` are unset, fall back to `iosAppStoreIssuerId` / `iosAppStoreKeyId`. When no `apple_p8_asc_api_key` file is uploaded, fall back to the existing `apple_p8_key`. The wrong-kind 401 hint in the HTTP layer already tells operators to switch to the Team Key when Apple rejects the request, so the fallback path can't silently mask config errors — it just gives users a working flow when they've already uploaded the right key into either slot. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/convex/products/asc.ts | 62 +++++++++++++++++++---------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index 12bc8634..48c1deb2 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -315,40 +315,62 @@ export const pushSyncProductsApple = action({ // ASC push-sync uses the App Store Connect API key (Team Key / // Individual Key), which is genuinely different from the App Store // Server API key used for receipt verification — Apple scopes them - // separately at the gateway. The dashboard surfaces both upload - // slots; this action requires the ASC one and fails with a clear - // hint if only the IAP key is configured. - if (!project.iosAscIssuerId || !project.iosAscKeyId) { + // separately at the gateway. We prefer the dedicated ASC slot when + // the operator has populated it, but fall back to the existing + // Server API slot so projects that upload a Team Key into the old + // (single-slot) workflow keep working without a re-config dance. + // The 401 from Apple's gateway is what catches a wrong-kind key + // either way — the helpful message in `call()` points the operator + // at the right Apple page. + const issuerId = project.iosAscIssuerId ?? project.iosAppStoreIssuerId; + const keyId = project.iosAscKeyId ?? project.iosAppStoreKeyId; + if (!issuerId || !keyId) { throw new Error( "App Store Connect API Issuer ID / Key ID not configured. " + "Generate them at App Store Connect → Users and Access → " + "Integrations → App Store Connect API (NOT under In-App " + "Purchase — those credentials are scoped to receipt " + - "verification only). Then save them in Settings → iOS " + - "Configuration.", + "verification only). Save them in Settings → iOS " + + "Configuration → 'App Store Connect API (push-sync)'.", ); } - const keyResponse = await ctx.runAction( - internal.files.internal.getAppleAscApiKey, - { - organizationId: project.organizationId, - projectId: project._id, - }, - ); - if (!keyResponse?.keyContent) { + // Prefer the dedicated ASC .p8 file; fall back to the Server API + // .p8 when the user has only uploaded one. The wrong-kind hint + // from `call()` will tell them to upload a Team Key if Apple + // rejects whichever they have. + let keyContent: string | undefined; + try { + const ascKey = await ctx.runAction( + internal.files.internal.getAppleAscApiKey, + { + organizationId: project.organizationId, + projectId: project._id, + }, + ); + keyContent = ascKey?.keyContent; + } catch { + // No ASC key uploaded — try the Server API slot below. + } + if (!keyContent) { + const legacyKey = await ctx.runAction( + internal.files.internal.getAppleP8Key, + { + organizationId: project.organizationId, + projectId: project._id, + }, + ); + keyContent = legacyKey?.keyContent; + } + if (!keyContent) { throw new Error( "App Store Connect API key (.p8) not uploaded — generate one " + "at App Store Connect → Users and Access → Integrations → " + - "App Store Connect API and upload it in Settings.", + "App Store Connect API → Team Keys and upload it in Settings.", ); } - const client = new AscClient( - project.iosAscIssuerId, - project.iosAscKeyId, - keyResponse.keyContent, - ); + const client = new AscClient(issuerId, keyId, keyContent); const direction = args.direction ?? "both"; const failures: Array<{ productId: string; reason: string }> = []; From cbd00b916de2a37d5080871f0594944af5b0c7ae Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 23:15:39 +0900 Subject: [PATCH 26/81] feat(kit): two-key iOS guidance + Android one-time products via new monetization API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings UI: - New explainer banner at the top of iOS Configuration: states up front that iOS needs TWO distinct .p8 keys, what each one is for, and that uploading the wrong kind for either purpose returns 401. Includes a webhook-mode caveat — operators relying entirely on webhook-driven state changes can technically skip the Server API key, but server-side verification of initial purchases is recommended. - Added a "How to get your Connect API .p8 file" guide section, parallel to the existing Server API guide. Lists the 5 steps (Users and Access → Integrations → App Store Connect API → Team Keys → Generate → download → copy Issuer + Key ID) so the operator can see exactly which Apple page differs from the In-App Purchase one. Linked Apple docs. - Renamed the existing guide to "How to get your Server API .p8 file:" so the two parallel sections are unambiguous. Android push-sync: - Pull also walks `monetization.onetimeproducts.list` (new API) in addition to the legacy `inappproducts.list`. Apps onboarded under the modern Play Console store one-time products in the new endpoint, and the legacy endpoint silently returns empty for them — that's why "Sync with Play Console" was only surfacing subscriptions for those accounts. The two pulls dedupe by SKU and keep going on either failure. - Probes the new endpoint via dynamic access so older googleapis SDK versions (without the typings yet) still work — falls back gracefully if the method isn't present. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/convex/products/play.ts | 141 +++++++++++++++--- .../auth/organization/project/settings.tsx | 102 ++++++++++++- 2 files changed, 223 insertions(+), 20 deletions(-) diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index ec32d9fa..ea4e6033 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -88,12 +88,24 @@ export const pushSyncProductsGoogle = action({ // ── PULL: Play → kit ───────────────────────────────────────── if (direction === "pull" || direction === "both") { - // One-time products. Both `inappproducts.list` and - // `monetization.subscriptions.list` paginate via - // `tokenPagination.nextPageToken` — without iterating, projects - // with > the default page size silently lose every product past - // the first batch (which is what the user reported when asking - // "in-app 이 없어"). + // One-time products. Play has TWO catalog APIs and apps live in + // different ones depending on when/how they were set up: + // + // - `inappproducts.list` — legacy v1 endpoint. Apps created + // before the new monetization framework store products here. + // - `monetization.onetimeproducts.list` — new endpoint. Apps + // onboarded under "Manage products" in the modern Play + // Console store products HERE and `inappproducts.list` + // silently returns empty for them. (This is why "Sync with + // Play Console" was only pulling subscriptions for accounts + // using the new console — the one-time products were + // invisible to the legacy endpoint.) + // + // We hit both, dedupe by SKU, and keep going on either failing + // — that way an account that lives entirely in one or the other + // still gets a complete pull instead of failing on the missing + // half. + const seenOneTimeSkus = new Set(); try { let token: string | undefined; let pageCount = 0; @@ -104,14 +116,8 @@ export const pushSyncProductsGoogle = action({ }); for (const product of oneTimes.data.inappproduct ?? []) { if (!product.sku) continue; - // Defensive filter: `inappproducts.list` is documented to - // return one-time products only, but the response schema - // still surfaces a `purchaseType` field that includes - // "subscription". If a Play instance ever returns a - // subscription from this endpoint we must skip it — the - // subscription pull loop below handles those, and routing - // them through `mapPlayOneTimeType` would mis-classify - // them as `NonConsumable`. + if (seenOneTimeSkus.has(product.sku)) continue; + seenOneTimeSkus.add(product.sku); if (product.purchaseType === "subscription") continue; await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, @@ -129,9 +135,6 @@ export const pushSyncProductsGoogle = action({ } token = oneTimes.data.tokenPagination?.nextPageToken ?? undefined; pageCount += 1; - // Bound the loop so a buggy server can't keep us paginating - // forever — 50 pages × default ~100 rows is way past anyone's - // realistic IAP catalog. if (pageCount > 50) break; } while (token); } catch (error) { @@ -141,6 +144,110 @@ export const pushSyncProductsGoogle = action({ }); } + // New monetization API for one-time products. The googleapis + // SDK exposes this as `monetization.onetimeproducts.list`. We + // probe via dynamic access because older versions of the SDK + // don't have the typings yet — falling back gracefully if the + // method isn't there keeps kit working with whatever + // googleapis version is bundled. + try { + const onetime = ( + androidpublisher.monetization as unknown as { + onetimeproducts?: { + list: (params: { + packageName: string; + pageToken?: string; + }) => Promise<{ + data: { + oneTimeProducts?: Array<{ + productId?: string; + listings?: Array<{ + languageCode?: string; + title?: string; + description?: string; + }>; + purchaseOptions?: Array<{ + buyOption?: { + legacyCompatible?: boolean; + regionalPricingAndAvailabilityConfigs?: Array<{ + regionCode?: string; + price?: { + currencyCode?: string; + units?: string; + nanos?: number; + }; + }>; + }; + rentOption?: unknown; + }>; + }>; + nextPageToken?: string; + }; + }>; + }; + } + ).onetimeproducts; + if (onetime?.list) { + let token: string | undefined; + let pageCount = 0; + do { + const resp = await onetime.list({ + packageName, + ...(token ? { pageToken: token } : {}), + }); + for (const product of resp.data.oneTimeProducts ?? []) { + if (!product.productId) continue; + if (seenOneTimeSkus.has(product.productId)) continue; + seenOneTimeSkus.add(product.productId); + const listing = product.listings?.[0]; + const buyOption = product.purchaseOptions?.[0]?.buyOption; + const regional = + buyOption?.regionalPricingAndAvailabilityConfigs ?? []; + const priceCandidates = regional + .map((r) => r.price) + .filter( + (p): p is NonNullable => + !!p && typeof p.units === "string", + ); + const preferred = + priceCandidates.find((p) => p.currencyCode === "USD") ?? + priceCandidates[0]; + const priceAmountMicros = preferred + ? moneyToMicros({ + units: preferred.units, + nanos: preferred.nanos, + }) + : undefined; + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId: product.productId, + platform: "Android", + // The new API doesn't carry a "consumable vs. + // non-consumable" distinction the same way — Play + // tracks consumption at purchase time. Default to + // NonConsumable; operators can edit on the kit side. + type: "NonConsumable", + title: listing?.title ?? product.productId, + description: listing?.description ?? undefined, + priceAmountMicros, + currency: preferred?.currencyCode ?? undefined, + storeRef: product.productId, + state: "Active", + }); + pulled += 1; + } + token = resp.data.nextPageToken ?? undefined; + pageCount += 1; + if (pageCount > 50) break; + } while (token); + } + } catch (error) { + failures.push({ + productId: "(play list onetimeproducts)", + reason: error instanceof Error ? error.message : String(error), + }); + } + try { let token: string | undefined; let pageCount = 0; diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index 73d51f48..ccead282 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -792,6 +792,46 @@ export default function ProjectSettings() {
+ {/* Two-key explainer. Apple ships two distinct .p8 key + kinds and they are NOT interchangeable; uploading + the wrong one returns an opaque 401. This banner + tells the operator up front so they don't spend + hours debugging a working JWT against an API that + silently rejects the key kind. */} +
+
+ +
+

+ {"iOS needs TWO separate .p8 keys"} +

+

+ { + "Apple scopes its API gateway by key kind — uploading the wrong .p8 for either purpose returns 401. The two keys come from different App Store Connect pages and have different Issuer / Key IDs." + } +

+
    +
  • + + {"Server API Key (.p8)"} + + { + " — required for receipt verification, subscription status, refund history. If you rely entirely on webhooks for state changes you can skip this key, but server-side verification of initial purchases is strongly recommended." + } +
  • +
  • + + {"Connect API Key (.p8)"} + + { + " — required only if you want kit to sync your IAP catalog with App Store Connect (list / create / patch products). Optional otherwise." + } +
  • +
+
+
+
+
{/* iOS identifiers (App Store bundle ID + Apple ID + ASC Issuer/Key) — submitted via this card's parent @@ -1241,13 +1281,13 @@ export default function ProjectSettings() { )}
- {/* iOS Setup Guide */} + {/* iOS Setup Guide — Server API key (.p8) */}
- {"How to get your .p8 file:"} + {"How to get your Server API .p8 file:"}
+ + {"Apple docs"} + + +
+
+ + {/* iOS Setup Guide — Connect API key (.p8) */} +
+
+ + + {"How to get your Connect API .p8 file:"} + +
+

+ { + "Different page from the Server API key above. Required only if you want kit to push-sync your IAP catalog with App Store Connect." + } +

+
    +
  1. + { + "Go to App Store Connect → Users and Access → Integrations → App Store Connect API" + } +
  2. +
  3. + { + "Pick the Team Keys tab (or Individual Keys for a personal key)" + } +
  4. +
  5. + { + "Click '+' / 'Generate API Key' and assign at least the 'App Manager' role so the key can list and modify in-app purchases" + } +
  6. +
  7. + {"Download the .p8 file (can only be downloaded once)"} +
  8. +
  9. + { + "Copy the Issuer ID at the top of the page and the Key ID next to your new key — these are different from the Server API ones" + } +
  10. +
+ From 40af6b8868e312bd69ebdff802b471b3381ec35d Mon Sep 17 00:00:00 2001 From: hyochan Date: Fri, 1 May 2026 23:55:53 +0900 Subject: [PATCH 27/81] feat(kit/settings): reorganize iOS .p8 sections + bump body text + drop trailing scroll spacer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iOS Configuration: Server API Key (.p8) section first with its own rationale; Connect API Key (.p8) section moved BELOW it with its own rationale explaining "why a second key" (Apple scopes its two API gateways separately, In-App Purchase key 401s on Connect endpoints). The two-key explainer banner at the top of the card is removed since the rationale now lives next to the field that raises the question. - Setup guides relabeled "How to get your Server API .p8 file" / "How to get your Connect API .p8 file" with parallel step-by-step instructions and Apple doc links. - Bump body / helper text from text-xs to text-sm across the Settings page — the prior 12px was hard to read in dark mode. - Drop the 80px trailing spacer in the org layout so scrolling past the form's last row no longer reveals a wide black gap below the Save button. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kit/src/pages/auth/organization/index.tsx | 5 - .../auth/organization/project/settings.tsx | 434 ++++++++---------- 2 files changed, 199 insertions(+), 240 deletions(-) diff --git a/packages/kit/src/pages/auth/organization/index.tsx b/packages/kit/src/pages/auth/organization/index.tsx index 627e7d63..03718315 100644 --- a/packages/kit/src/pages/auth/organization/index.tsx +++ b/packages/kit/src/pages/auth/organization/index.tsx @@ -192,11 +192,6 @@ export default function OrganizationLayout() { )} /> - {/* Trailing spacer so the last page row doesn't sit flush - against the viewport at scroll-end. `pb-*` on the scroll - container itself gets trimmed by a Blink/WebKit quirk, - so use a real in-flow block here. */} -
diff --git a/packages/kit/src/pages/auth/organization/project/settings.tsx b/packages/kit/src/pages/auth/organization/project/settings.tsx index ccead282..2c3cb5d8 100644 --- a/packages/kit/src/pages/auth/organization/project/settings.tsx +++ b/packages/kit/src/pages/auth/organization/project/settings.tsx @@ -792,46 +792,6 @@ export default function ProjectSettings() {
- {/* Two-key explainer. Apple ships two distinct .p8 key - kinds and they are NOT interchangeable; uploading - the wrong one returns an opaque 401. This banner - tells the operator up front so they don't spend - hours debugging a working JWT against an API that - silently rejects the key kind. */} -
-
- -
-

- {"iOS needs TWO separate .p8 keys"} -

-

- { - "Apple scopes its API gateway by key kind — uploading the wrong .p8 for either purpose returns 401. The two keys come from different App Store Connect pages and have different Issuer / Key IDs." - } -

-
    -
  • - - {"Server API Key (.p8)"} - - { - " — required for receipt verification, subscription status, refund history. If you rely entirely on webhooks for state changes you can skip this key, but server-side verification of initial purchases is strongly recommended." - } -
  • -
  • - - {"Connect API Key (.p8)"} - - { - " — required only if you want kit to sync your IAP catalog with App Store Connect (list / create / patch products). Optional otherwise." - } -
  • -
-
-
-
-
{/* iOS identifiers (App Store bundle ID + Apple ID + ASC Issuer/Key) — submitted via this card's parent @@ -855,18 +815,18 @@ export default function ProjectSettings() { aria-invalid={!isIosBundleValid} disabled={iosBundleLocked} /> -

+

{ "Use the bundle identifier from Xcode / App Store Connect (e.g., com.example.ios)." }

{iosBundleLocked && ( -

+

{"Bundle IDs can’t be edited once saved."}

)} {!isIosBundleValid && ( -

+

{"Enter a valid App Store bundle ID."}

)} @@ -886,18 +846,18 @@ export default function ProjectSettings() { aria-invalid={!isIosAppleIdValid} disabled={iosAppleIdLocked} /> -

+

{ "Required for production validations. This is the numeric Apple ID found in App Store Connect under Apps → → App Information -> Apple ID." }

{iosAppleIdLocked && ( -

+

{"Apple App IDs can’t be edited once saved."}

)} {!isIosAppleIdValid && ( -

+

{"App Apple ID must be numeric."}

)} @@ -921,18 +881,18 @@ export default function ProjectSettings() { spellCheck={false} aria-invalid={!isIosIssuerIdValid} /> -

+

{ "App Store Server API issuer. Find it in App Store Connect → Users and Access → Integrations → In-App Purchase (under Keys)." }

{iosIssuerLocked && ( -

+

{"Issuer IDs can’t be cleared once saved."}

)} {!isIosIssuerIdValid && ( -

+

{ "Issuer ID must match the UUID format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)." } @@ -958,23 +918,23 @@ export default function ProjectSettings() { spellCheck={false} aria-invalid={!isIosKeyIdValid} /> -

+

{ "Ten-character identifier shown next to the In-App Purchase .p8 key in App Store Connect." }

{iosKeyLocked && ( -

+

{"Key IDs can’t be cleared once saved."}

)} {!isIosKeyIdValid && ( -

+

{"Key ID must be 10 uppercase letters or numbers."}

)} {!isIosCredentialPairValid && ( -

+

{ "Provide both Issuer ID and Key ID to enable the App Store Server API." } @@ -982,156 +942,12 @@ export default function ProjectSettings() { )}

- {/* App Store Connect API credentials — separate slot. - Required to push-sync the IAP catalog. The Server - API key (above) only authenticates receipt - verification; ASC REST endpoints (catalog list / - create / patch) reject it with 401. */} -
-

- {"App Store Connect API (push-sync)"} -

-

- { - "Optional. Required only if you want kit to sync your IAP catalog with App Store Connect. Generate at App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys (or Individual Keys). NOT the same as the In-App Purchase key above." - } -

- - - - setIosAscIssuerId(event.target.value) - } - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" - placeholder="12345678-ABCD-1234-ABCD-1234567890AB" - spellCheck={false} - aria-invalid={!isIosAscIssuerIdValid} - /> - {!isIosAscIssuerIdValid && ( -

- { - "Issuer ID must match the UUID format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)." - } -

- )} -
-
- - - setIosAscKeyId(event.target.value.toUpperCase()) - } - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" - placeholder="ABCDE12345" - spellCheck={false} - aria-invalid={!isIosAscKeyIdValid} - /> - {!isIosAscKeyIdValid && ( -

- {"Key ID must be 10 uppercase letters or numbers."} -

- )} - {!isIosAscPairValid && ( -

- { - "Provide both Connect API Issuer ID and Key ID, or leave both blank to skip push-sync." - } -

- )} -
- -
- - {iosAscFileUploaded || hasIosAscFile ? ( -
-
-
- -
- - {"Connect API key uploaded successfully"} - - {iosAscFile && ( -

- {iosAscFile.fileName} •{" "} - {(iosAscFile.fileSize / 1024).toFixed(2)} KB -

- )} -
-
-
- {iosAscFile && ( - - )} - -
-
-
- ) : ( - <> -
- void handleIosAscFileUpload(e)} - className="hidden" - id="ios-asc-file-upload" - disabled={uploadingIosAsc} - /> - -
-

- { - "Upload only after you've generated the Team Key (or Individual Key) under App Store Connect API — uploading the In-App Purchase key here will result in 401 errors during sync." - } -

- - )} -
- + {/* ── 1. App Store Server API Key (.p8) ───────────── + The In-App Purchase key. Used by kit's receipt + verifier (purchases/ios.ts) for the App Store + Server API gateway. Required for receipt + verification, subscription status, refund + history. */}
-

+

{ - "Generate at App Store Connect → Users and Access → Integrations → In-App Purchase. Used for receipt verification, subscription status, refund history." + "Used by kit to talk to Apple's App Store Server API: receipt verification on initial purchase, subscription status queries, transaction lookup, refund history. Generate at App Store Connect → Users and Access → Integrations → In-App Purchase." }

@@ -1156,7 +972,7 @@ export default function ProjectSettings() { {"Authentication file uploaded successfully"} {iosFile && ( -

+

{iosFile.fileName} •{" "} {(iosFile.fileSize / 1024).toFixed(2)} KB

@@ -1217,13 +1033,13 @@ export default function ProjectSettings() {
-

+

{ "Required for App Store receipt verification. Generate the .p8 key in App Store Connect and upload it here." }

{showIosP8Requirement && ( -

+

{ "Upload your App Store Connect .p8 key to finish iOS setup." } @@ -1236,10 +1052,10 @@ export default function ProjectSettings() {

-

+

{"App Store Connect key missing"}

-

+

{ "You must upload the downloaded .p8 key before IAPKit can verify App Store receipts." } @@ -1252,12 +1068,12 @@ export default function ProjectSettings() {

-

+

{ "When P8 key is provided, these advanced features become available:" }

-
    +
    • • {"Subscription Status Query"}
    • •{" "} @@ -1281,6 +1097,154 @@ export default function ProjectSettings() { )}
+ {/* ── 2. App Store Connect API Key (.p8) ────────── + Genuinely a different key from the Server API one + above. Used by kit's catalog sync (products/asc.ts) + against the App Store Connect API gateway. Apple + scopes the two gateways separately — a key + generated under "In-App Purchase" returns 401 for + Connect endpoints, and vice versa. Optional. */} +
+ +

+ { + "A second .p8 — different from the Server API key above. Apple scopes its two API gateways separately: the In-App Purchase key only authenticates receipt-verification endpoints, while catalog management (list / create / update IAPs) lives on the App Store Connect API gateway and rejects the Server API key with 401. Optional — only needed if you want kit to push-sync your IAP catalog from the dashboard. Generate at App Store Connect → Users and Access → Integrations → App Store Connect API → Team Keys (or Individual Keys)." + } +

+ + + + setIosAscIssuerId(event.target.value) + } + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="12345678-ABCD-1234-ABCD-1234567890AB" + spellCheck={false} + aria-invalid={!isIosAscIssuerIdValid} + /> + {!isIosAscIssuerIdValid && ( +

+ { + "Issuer ID must match the UUID format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX)." + } +

+ )} + + + + setIosAscKeyId(event.target.value.toUpperCase()) + } + className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:bg-muted/40" + placeholder="ABCDE12345" + spellCheck={false} + aria-invalid={!isIosAscKeyIdValid} + /> + {!isIosAscKeyIdValid && ( +

+ {"Key ID must be 10 uppercase letters or numbers."} +

+ )} + {!isIosAscPairValid && ( +

+ { + "Provide both Connect API Issuer ID and Key ID, or leave both blank to skip push-sync." + } +

+ )} + +
+ {iosAscFileUploaded || hasIosAscFile ? ( +
+
+
+ +
+ + {"Connect API key uploaded successfully"} + + {iosAscFile && ( +

+ {iosAscFile.fileName} •{" "} + {(iosAscFile.fileSize / 1024).toFixed(2)} KB +

+ )} +
+
+
+ {iosAscFile && ( + + )} + +
+
+
+ ) : ( + <> +
+ void handleIosAscFileUpload(e)} + className="hidden" + id="ios-asc-file-upload" + disabled={uploadingIosAsc} + /> + +
+

+ { + "Upload only after you've generated the Team Key (or Individual Key) under App Store Connect API — uploading the In-App Purchase key here will result in 401 errors during sync." + } +

+ + )} +
+
+ {/* iOS Setup Guide — Server API key (.p8) */}
@@ -1299,7 +1263,7 @@ export default function ProjectSettings() {
-
    +
    1. { "Go to App Store Connect → Users and Access → Integrations → In-App Purchase" @@ -1323,17 +1287,17 @@ export default function ProjectSettings() { - + {"Apple docs"} @@ -1349,12 +1313,12 @@ export default function ProjectSettings() { {"How to get your Connect API .p8 file:"}
-

+

{ "Different page from the Server API key above. Required only if you want kit to push-sync your IAP catalog with App Store Connect." }

-
    +
    1. { "Go to App Store Connect → Users and Access → Integrations → App Store Connect API" @@ -1384,7 +1348,7 @@ export default function ProjectSettings() { href="https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api" target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 text-xs text-primary hover:underline" + className="inline-flex items-center gap-1 text-sm text-primary hover:underline" > {"Apple docs"} @@ -1431,18 +1395,18 @@ export default function ProjectSettings() { aria-invalid={!isAndroidPackageValid} disabled={androidPackageLocked} /> -

      +

      { "Use the exact package name from the Google Play Console (e.g., com.example.app)." }

      {androidPackageLocked && ( -

      +

      {"Android package names can’t be edited once saved."}

      )} {!isAndroidPackageValid && ( -

      +

      {"Enter a valid Android package name."}

      )} @@ -1463,7 +1427,7 @@ export default function ProjectSettings() { {"Service account file uploaded successfully"} {androidFile && ( -

      +

      {androidFile.fileName} •{" "} {(androidFile.fileSize / 1024).toFixed(2)} KB

      @@ -1524,7 +1488,7 @@ export default function ProjectSettings() {
-

+

{ "Required for validating Google Play purchases. Use minimum permissions principle." } @@ -1551,7 +1515,7 @@ export default function ProjectSettings() {

-
    +
    1. {"In the Google Cloud Console go to Service Accounts"}
    2. @@ -1583,17 +1547,17 @@ export default function ProjectSettings() { - +
      {"Learn more"} @@ -1618,7 +1582,7 @@ export default function ProjectSettings() { {"Enable Meta Horizon (Quest / VR)"} - + { "Meta's billing SDK is Google-Play-compatible, but server verification goes through the Meta Graph API with its own credentials." } @@ -1645,14 +1609,14 @@ export default function ProjectSettings() { onChange={(e) => setHorizonAppId(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> -

      +

      { "Numeric ID from the Meta Developer Dashboard (6–20 digits)." }

      {!horizonAppIdValid && trimmedHorizonAppId.length > 0 && ( -

      +

      { "App ID must be a numeric string (6–20 digits)." } @@ -1687,7 +1651,7 @@ export default function ProjectSettings() { setIsReplacingHorizonAppSecret(true); setHorizonAppSecret(""); }} - className="px-3 py-1 text-xs font-medium text-primary hover:bg-primary/10 rounded transition-colors" + className="px-3 py-1 text-sm font-medium text-primary hover:bg-primary/10 rounded transition-colors" > {"Replace"} @@ -1706,14 +1670,14 @@ export default function ProjectSettings() { className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary" /> )} -

      +

      { "App Secret from the Meta Developer Dashboard. The IAPKit server combines it with the App ID as OC|APP_ID|APP_SECRET for each verify call — treat it like a password." }

      {!horizonAppSecretValid && trimmedHorizonAppSecret.length > 0 && ( -

      +

      { "App Secret looks malformed (expected 16–2048 characters)." } @@ -1725,7 +1689,7 @@ export default function ProjectSettings() { href="https://developers.meta.com/horizon/resources/publish-iap-overview/" target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 text-xs text-primary hover:underline" + className="inline-flex items-center gap-1 text-sm text-primary hover:underline" > {"Meta billing docs"} From 0c1f5e16cb6d53ce448f4d9c6640604deb629e54 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 2 May 2026 01:10:17 +0900 Subject: [PATCH 28/81] fix(kit): inline-only .p8 setup guides, sidebar scroll containment, Android one-time price path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings page: - Each .p8 setup guide now lives inline next to its upload slot (Server API guide under Server API .p8 section; Connect API guide under Connect API .p8 section) instead of being grouped at the bottom of the iOS Configuration card. Hidden once the matching .p8 is uploaded — instructions become noise once the slot is filled. - Bumped body / helper text from text-xs to text-sm across the page; the prior 12px was hard to read in dark mode. Layout: - Project content uses `min-h-full` so the page covers the full main viewport when the form is shorter than viewport. - `

      ` switched from `overscroll-contain` to `overscroll-none` to fully disable Mac rubber-band scroll into empty area. - Sidebar (Tablet + Mobile) is now its own scroll container with `overscroll-contain`. Wheel events landing on the bottom-of- sidebar sections (Docs / Support / Profile, outside the inner scrollable nav) used to bubble up and scroll the adjacent main content; aside-as-scroll-container absorbs them at the sidebar. `no-scrollbar` keeps the visual unchanged. - Dropped the 80px trailing spacer in the org layout that produced a wide gap below the Save button at scroll-end. Android push-sync: - Fixed price extraction for one-time products coming from the new `monetization.onetimeproducts.list` endpoint. Pricing lives at `purchaseOptions[i].regionalPricingAndAvailabilityConfigs[].price`, NOT nested in `buyOption` as the earlier code assumed. Walks every option × region, prefers USD, returns price + currency consistently. Pre-fix every one-time IAP from the new console surfaced with no price. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/convex/products/play.ts | 53 ++-- .../auth/organization/Sidebar/Mobile.tsx | 4 +- .../auth/organization/Sidebar/Tablet.tsx | 13 +- .../kit/src/pages/auth/organization/index.tsx | 2 +- .../pages/auth/organization/project/index.tsx | 7 +- .../auth/organization/project/settings.tsx | 231 +++++++++--------- 6 files changed, 175 insertions(+), 135 deletions(-) diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index ea4e6033..253cb7c4 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -167,18 +167,23 @@ export const pushSyncProductsGoogle = action({ description?: string; }>; purchaseOptions?: Array<{ - buyOption?: { - legacyCompatible?: boolean; - regionalPricingAndAvailabilityConfigs?: Array<{ - regionCode?: string; - price?: { - currencyCode?: string; - units?: string; - nanos?: number; - }; - }>; - }; + state?: string; + purchaseOptionId?: string; + buyOption?: { legacyCompatible?: boolean }; rentOption?: unknown; + // Pricing lives DIRECTLY on the purchaseOption, + // NOT nested inside buyOption. The earlier shape + // (buyOption.regionalPricingAndAvailabilityConfigs) + // was wrong — every one-time product surfaced + // with no price because the lookup never matched. + regionalPricingAndAvailabilityConfigs?: Array<{ + regionCode?: string; + price?: { + currencyCode?: string; + units?: string; + nanos?: number; + }; + }>; }>; }>; nextPageToken?: string; @@ -200,15 +205,23 @@ export const pushSyncProductsGoogle = action({ if (seenOneTimeSkus.has(product.productId)) continue; seenOneTimeSkus.add(product.productId); const listing = product.listings?.[0]; - const buyOption = product.purchaseOptions?.[0]?.buyOption; - const regional = - buyOption?.regionalPricingAndAvailabilityConfigs ?? []; - const priceCandidates = regional - .map((r) => r.price) - .filter( - (p): p is NonNullable => - !!p && typeof p.units === "string", - ); + // Walk every purchaseOption × regionalPricingAndAvailabilityConfig + // (pricing lives on the option, not inside buyOption), + // prefer USD when any region offers it, otherwise the + // first region with a readable price. + const priceCandidates: Array<{ + currencyCode?: string; + units?: string; + nanos?: number; + }> = []; + for (const opt of product.purchaseOptions ?? []) { + for (const region of opt.regionalPricingAndAvailabilityConfigs ?? + []) { + if (region.price && typeof region.price.units === "string") { + priceCandidates.push(region.price); + } + } + } const preferred = priceCandidates.find((p) => p.currencyCode === "USD") ?? priceCandidates[0]; diff --git a/packages/kit/src/pages/auth/organization/Sidebar/Mobile.tsx b/packages/kit/src/pages/auth/organization/Sidebar/Mobile.tsx index bf61e18a..f0ea2a77 100644 --- a/packages/kit/src/pages/auth/organization/Sidebar/Mobile.tsx +++ b/packages/kit/src/pages/auth/organization/Sidebar/Mobile.tsx @@ -153,7 +153,7 @@ export function MobileSidebar({ {/* Mobile Sidebar */}
-
)} -
From f266d5fb0bfff99b8f2797142dab8f4a89c03fb1 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 2 May 2026 13:00:42 +0900 Subject: [PATCH 29/81] feat(kit): paywall live preview + product hierarchy + ASC pagination + RTDN error mapping - Paywalls: live iframe preview, brand colors, features list, logo / bg image, per-product card image, full HTML override (window.openiap bridge), custom CSS, OpenIAP brand defaults - Products: subscription group hierarchy (ASC), offers (free trial / intro / base plan), review note, ASC push auto-creates localization + USA price schedule, dry-run mode, expanded Add Product form with subscription group autocomplete - ASC: paginate listInAppPurchases / listSubscriptionGroups / listSubscriptionsInGroup via links.next; v2 path for iapPriceSchedule; manualPrices + automaticPrices fallback for assigned price lookup - Webhooks: Google RTDN MissingNotificationId / MissingPurchaseToken now surface as 4xx ConvexError, only UnknownEventType is ACK'd - Tabs: Beta badges on Products / Paywalls / Webhooks - Vite: proxy /v1 -> :3000 so dashboard + paywall preview share one origin - Tests: 49 new unit tests for ASC + Play helpers (price tier matching, ISO duration mapping, intro offer parsing, BigInt money conversion, etc.) Addresses PR #124 review threads on asc.ts pagination and google.ts error mapping. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/kit/convex/paywalls/mutation.ts | 20 + packages/kit/convex/paywalls/query.ts | 14 + packages/kit/convex/products/asc.test.ts | 281 +++++ packages/kit/convex/products/asc.ts | 999 +++++++++++++++++- packages/kit/convex/products/mutation.ts | 33 +- packages/kit/convex/products/play.test.ts | 63 ++ packages/kit/convex/products/play.ts | 264 ++++- packages/kit/convex/products/query.ts | 23 +- packages/kit/convex/products/sync.ts | 86 +- packages/kit/convex/schema.ts | 109 +- packages/kit/convex/webhooks/google.ts | 24 +- packages/kit/server/api/v1/paywalls.ts | 832 +++++++++++++-- .../pages/auth/organization/project/index.tsx | 3 + .../auth/organization/project/paywalls.tsx | 664 ++++++++++-- .../auth/organization/project/products.tsx | 658 +++++++++--- .../auth/organization/project/settings.tsx | 72 +- packages/kit/vite.config.ts | 16 + 17 files changed, 3661 insertions(+), 500 deletions(-) create mode 100644 packages/kit/convex/products/asc.test.ts create mode 100644 packages/kit/convex/products/play.test.ts diff --git a/packages/kit/convex/paywalls/mutation.ts b/packages/kit/convex/paywalls/mutation.ts index 08e7d0e2..d2c444e2 100644 --- a/packages/kit/convex/paywalls/mutation.ts +++ b/packages/kit/convex/paywalls/mutation.ts @@ -31,6 +31,14 @@ export const upsertPaywall = mutation({ subheadline: v.optional(v.string()), cta: v.string(), legalCopy: v.optional(v.string()), + features: v.optional(v.array(v.string())), + logoUrl: v.optional(v.string()), + backgroundImageUrl: v.optional(v.string()), + productImages: v.optional( + v.array(v.object({ productId: v.string(), imageUrl: v.string() })), + ), + customCss: v.optional(v.string()), + customHtml: v.optional(v.string()), theme: themeValidator, }, returns: v.object({ @@ -63,6 +71,12 @@ export const upsertPaywall = mutation({ subheadline: args.subheadline, cta: args.cta, legalCopy: args.legalCopy, + features: args.features, + logoUrl: args.logoUrl, + backgroundImageUrl: args.backgroundImageUrl, + productImages: args.productImages, + customCss: args.customCss, + customHtml: args.customHtml, theme: args.theme, updatedAt: now, }); @@ -79,6 +93,12 @@ export const upsertPaywall = mutation({ subheadline: args.subheadline, cta: args.cta, legalCopy: args.legalCopy, + features: args.features, + logoUrl: args.logoUrl, + backgroundImageUrl: args.backgroundImageUrl, + productImages: args.productImages, + customCss: args.customCss, + customHtml: args.customHtml, theme: args.theme, updatedAt: now, }); diff --git a/packages/kit/convex/paywalls/query.ts b/packages/kit/convex/paywalls/query.ts index 7654d02a..acc36b1e 100644 --- a/packages/kit/convex/paywalls/query.ts +++ b/packages/kit/convex/paywalls/query.ts @@ -15,6 +15,14 @@ const paywallShape = v.object({ subheadline: v.optional(v.string()), cta: v.string(), legalCopy: v.optional(v.string()), + features: v.optional(v.array(v.string())), + logoUrl: v.optional(v.string()), + backgroundImageUrl: v.optional(v.string()), + productImages: v.optional( + v.array(v.object({ productId: v.string(), imageUrl: v.string() })), + ), + customCss: v.optional(v.string()), + customHtml: v.optional(v.string()), theme: v.optional( v.object({ primaryColor: v.optional(v.string()), @@ -35,6 +43,12 @@ function shape(paywall: Doc<"paywalls">) { subheadline: paywall.subheadline, cta: paywall.cta, legalCopy: paywall.legalCopy, + features: paywall.features, + logoUrl: paywall.logoUrl, + backgroundImageUrl: paywall.backgroundImageUrl, + productImages: paywall.productImages, + customCss: paywall.customCss, + customHtml: paywall.customHtml, theme: paywall.theme, updatedAt: paywall.updatedAt, }; diff --git a/packages/kit/convex/products/asc.test.ts b/packages/kit/convex/products/asc.test.ts new file mode 100644 index 00000000..c24bf511 --- /dev/null +++ b/packages/kit/convex/products/asc.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from "vitest"; + +import { + mapAscOfferDurationToIso, + mapAscOfferKind, + mapBillingPeriodToAsc, + parseIntroOffers, + pickActivePriceRow, + pickPricePointIdMatching, +} from "./asc"; + +describe("pickPricePointIdMatching", () => { + const list = { + data: [ + { + id: "tier-29", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "0.29" }, + }, + { + id: "tier-99", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "0.99" }, + }, + { + id: "tier-999", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "9.99" }, + }, + { + id: "tier-9999", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "99.99" }, + }, + { + id: "tier-malformed", + type: "inAppPurchasePricePoints" as const, + attributes: { customerPrice: "abc" }, + }, + { + id: "tier-empty", + type: "inAppPurchasePricePoints" as const, + attributes: {}, + }, + ], + }; + + it("returns null when the catalog response is null", () => { + expect(pickPricePointIdMatching(null, 9_990_000)).toBeNull(); + }); + + it("returns null when no tier matches the requested USD amount", () => { + expect(pickPricePointIdMatching(list, 1_500_000)).toBeNull(); + }); + + it("matches an exact tier on the cent boundary", () => { + expect(pickPricePointIdMatching(list, 9_990_000)).toBe("tier-999"); + expect(pickPricePointIdMatching(list, 290_000)).toBe("tier-29"); + expect(pickPricePointIdMatching(list, 99_990_000)).toBe("tier-9999"); + }); + + it("absorbs one-cent floating-point drift in the requested amount", () => { + expect(pickPricePointIdMatching(list, 9_989_999)).toBe("tier-999"); + expect(pickPricePointIdMatching(list, 9_985_000)).toBe("tier-999"); + }); + + it("skips malformed and missing customerPrice rows", () => { + expect(pickPricePointIdMatching(list, 0)).toBeNull(); + }); +}); + +describe("mapBillingPeriodToAsc", () => { + it.each([ + ["P1W", "ONE_WEEK"], + ["P1M", "ONE_MONTH"], + ["P2M", "TWO_MONTHS"], + ["P3M", "THREE_MONTHS"], + ["P6M", "SIX_MONTHS"], + ["P1Y", "ONE_YEAR"], + ] as const)("maps %s → %s", (iso, asc) => { + expect(mapBillingPeriodToAsc(iso)).toBe(asc); + }); + + it("defaults undefined / unknown periods to ONE_MONTH so push doesn't silently drop the picker", () => { + expect(mapBillingPeriodToAsc(undefined)).toBe("ONE_MONTH"); + // The function's union arg type rejects unknown strings at the + // type level, but the runtime switch has a `default` arm that + // catches Apple ever shipping a new period — we exercise that + // default branch by widening through `unknown`. + const wider = mapBillingPeriodToAsc as ( + period: string | undefined, + ) => string; + expect(wider("P9X")).toBe("ONE_MONTH"); + }); +}); + +describe("mapAscOfferDurationToIso", () => { + it.each([ + ["THREE_DAYS", "P3D"], + ["ONE_WEEK", "P1W"], + ["TWO_WEEKS", "P2W"], + ["ONE_MONTH", "P1M"], + ["TWO_MONTHS", "P2M"], + ["THREE_MONTHS", "P3M"], + ["SIX_MONTHS", "P6M"], + ["ONE_YEAR", "P1Y"], + ])("normalizes ASC enum %s → ISO %s", (asc, iso) => { + expect(mapAscOfferDurationToIso(asc)).toBe(iso); + }); + + it("returns undefined when no input", () => { + expect(mapAscOfferDurationToIso(undefined)).toBeUndefined(); + }); + + it("passes unknown enum values through unchanged so future Apple values still render", () => { + expect(mapAscOfferDurationToIso("FOUR_MOONS")).toBe("FOUR_MOONS"); + }); +}); + +describe("mapAscOfferKind", () => { + it.each([ + ["FREE_TRIAL", "FreeTrial"], + ["PAY_UP_FRONT", "IntroPayUpFront"], + ["PAY_AS_YOU_GO", "IntroPayAsYouGo"], + ] as const)("maps %s → %s", (mode, kind) => { + expect(mapAscOfferKind(mode)).toBe(kind); + }); + + it("falls back to FreeTrial for unknown / undefined modes", () => { + expect(mapAscOfferKind(undefined)).toBe("FreeTrial"); + expect(mapAscOfferKind("UNKNOWN")).toBe("FreeTrial"); + }); +}); + +describe("pickActivePriceRow", () => { + const today = new Date().toISOString().slice(0, 10); + const yesterday = new Date(Date.now() - 86_400_000) + .toISOString() + .slice(0, 10); + const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + + it("returns null for empty input", () => { + expect(pickActivePriceRow([])).toBeNull(); + }); + + it("picks the row whose date window covers today", () => { + const rows = [ + { id: "future", attributes: { startDate: tomorrow, endDate: null } }, + { id: "active", attributes: { startDate: yesterday, endDate: null } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("active"); + }); + + it("treats null start / end as open bounds", () => { + const rows = [ + { id: "open", attributes: { startDate: null, endDate: null } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("open"); + }); + + it("rejects rows whose endDate has already passed", () => { + const rows = [ + { + id: "expired", + attributes: { startDate: yesterday, endDate: yesterday }, + }, + { id: "active", attributes: { startDate: yesterday, endDate: tomorrow } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("active"); + }); + + it("falls back to the first row when no window covers today (defensive default)", () => { + const rows = [ + { id: "future-a", attributes: { startDate: tomorrow, endDate: null } }, + { id: "future-b", attributes: { startDate: tomorrow, endDate: null } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("future-a"); + }); + + it("rejects future rows whose startDate matches today’s ISO string only when strictly greater", () => { + const rows = [ + { id: "starts-today", attributes: { startDate: today, endDate: null } }, + ]; + expect(pickActivePriceRow(rows)?.id).toBe("starts-today"); + }); +}); + +describe("parseIntroOffers", () => { + const today = new Date().toISOString().slice(0, 10); + + it("returns [] when no response or empty data", () => { + expect(parseIntroOffers(null)).toEqual([]); + expect(parseIntroOffers({ data: [] })).toEqual([]); + }); + + it("parses a free-trial offer (no pricePoint, just duration)", () => { + const out = parseIntroOffers({ + data: [ + { + id: "offer-free", + type: "subscriptionIntroductoryOffers" as const, + attributes: { + offerMode: "FREE_TRIAL", + duration: "ONE_WEEK", + numberOfPeriods: 1, + startDate: today, + endDate: null, + }, + relationships: {}, + }, + ], + }); + expect(out).toEqual([ + { + id: "offer-free", + kind: "FreeTrial", + duration: "P1W", + numberOfPeriods: 1, + priceAmountMicros: undefined, + currency: undefined, + }, + ]); + }); + + it("parses a pay-up-front intro with included pricePoint", () => { + const out = parseIntroOffers({ + data: [ + { + id: "offer-paid", + type: "subscriptionIntroductoryOffers" as const, + attributes: { + offerMode: "PAY_UP_FRONT", + duration: "THREE_MONTHS", + numberOfPeriods: 1, + }, + relationships: { + subscriptionPricePoint: { + data: { id: "pp-99" }, + }, + }, + }, + ], + included: [ + { + id: "pp-99", + type: "subscriptionPricePoints" as const, + attributes: { customerPrice: "0.99" }, + }, + ], + }); + expect(out).toEqual([ + { + id: "offer-paid", + kind: "IntroPayUpFront", + duration: "P3M", + numberOfPeriods: 1, + priceAmountMicros: 990_000, + currency: "USD", + }, + ]); + }); + + it("filters out offers whose date window doesn't cover today", () => { + const future = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + const out = parseIntroOffers({ + data: [ + { + id: "offer-future", + type: "subscriptionIntroductoryOffers" as const, + attributes: { + offerMode: "FREE_TRIAL", + duration: "ONE_WEEK", + startDate: future, + endDate: null, + }, + }, + ], + }); + expect(out).toEqual([]); + }); +}); diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts index 48c1deb2..0420e846 100644 --- a/packages/kit/convex/products/asc.ts +++ b/packages/kit/convex/products/asc.ts @@ -109,24 +109,342 @@ class AscClient { return parsed as T; } - listInAppPurchases(appId: string) { - return this.call( + // ASC list endpoints cap at 200 items per page. For accounts with + // larger catalogs we have to follow `links.next` until absent or + // pages > 200 (= 40k items, more than ASC actually allows per app + // — the bound just prevents a runaway loop on unexpected response + // shapes). Without pagination, accounts above the page limit silently + // lose products from kit's catalog. + async listInAppPurchases(appId: string): Promise { + return this.collectAllPages( `/v1/apps/${encodeURIComponent(appId)}/inAppPurchasesV2?limit=200`, ); } - listSubscriptionGroups(appId: string) { - return this.call( + async listSubscriptionGroups( + appId: string, + ): Promise { + return this.collectAllPages( `/v1/apps/${encodeURIComponent(appId)}/subscriptionGroups?limit=200`, ); } - listSubscriptionsInGroup(groupId: string) { - return this.call( + async listSubscriptionsInGroup(groupId: string): Promise { + return this.collectAllPages( `/v1/subscriptionGroups/${encodeURIComponent(groupId)}/subscriptions?limit=200`, ); } + // Generic JSON:API paginator. ASC returns `{ data: [...], + // links: { self, next? } }` — we follow `next` (the cursor URL is + // absolute, so we hand it straight back to fetch via `call`'s base + // join logic). Capped at 200 pages as a runaway guard. + private async collectAllPages( + initialPath: string, + ): Promise<{ data: T[] }> { + const merged: T[] = []; + let path: string | null = initialPath; + let pages = 0; + while (path && pages < 200) { + const page: { data: T[]; links?: { next?: string } } = + await this.call(path); + merged.push(...page.data); + const nextUrl = page.links?.next ?? null; + path = nextUrl ? this.relativizePath(nextUrl) : null; + pages += 1; + } + return { data: merged }; + } + + // ASC `links.next` is fully qualified (`https://api.appstoreconnect…`). + // `call()` already prepends ASC_BASE, so strip the host before + // passing it back in. + private relativizePath(absoluteOrRelative: string): string { + if (absoluteOrRelative.startsWith(ASC_BASE)) { + return absoluteOrRelative.slice(ASC_BASE.length); + } + return absoluteOrRelative; + } + + // Introductory offer attached to a subscription. Apple allows at + // most ONE introductoryOffer per subscription per territory at a + // time — the prior `pay-up-front $0.99 for 3 months` is replaced + // when you publish a new one. We pull the USA territory's active + // offer (if any) so the dashboard can render badges like + // "7-day free trial" / "$0.99 intro for 3 months". Returns Error + // so the caller can append a failure row instead of silently + // dropping offer metadata. + async subIntroductoryOffer( + subId: string, + ): Promise { + try { + return await this.call( + `/v1/subscriptions/${encodeURIComponent(subId)}/introductoryOffers?filter[territory]=USA&include=subscriptionPricePoint&limit=10`, + ); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + } + + // Per-product *configured* USA price. The naive + // `/{type}/{id}/pricePoints?filter[territory]=USA&limit=1` endpoint + // returns the entire USA *price matrix* (every tier the catalog + // offers — $0.29, $0.49, $0.99, …), not the price the operator + // assigned to the product, so `limit=1` always pinned the lowest + // tier and every IAP / sub showed up as $0.29. The actual assigned + // price lives on a different relationship — `iapPriceSchedule` for + // one-time IAPs, `prices` for subscriptions — with the matching + // pricePoint side-loaded via `include`. + // Returns either the price response or an Error so the caller can + // surface the actual ASC reason (404, 403, malformed schedule, …) + // through the sync result's `failures` array — silently swallowing + // these is what made one-time IAPs show "—" with no diagnostic. + async iapCurrentPrice( + iapId: string, + ): Promise { + // v2 IAPs expose the price-schedule relationship under `/v2/` + // (the per-resource endpoints moved with the V2 catalog), even + // though the catalog list is `/v1/apps/{id}/inAppPurchasesV2` + // and the JSON:API resource type is still `"inAppPurchases"`. The + // older `/v1/inAppPurchases/{id}/iapPriceSchedule` 404s with + // "relationship 'iapPriceSchedule' does not exist" because that + // path resolves to the legacy V1 IAP resource which has no such + // relationship. The downstream `manualPrices` collection lookup + // stays on `/v1/inAppPurchasePriceSchedules/...`. + try { + const schedule = await this.call( + `/v2/inAppPurchases/${encodeURIComponent(iapId)}/iapPriceSchedule`, + ); + if (!schedule?.data?.id) { + return new Error( + "iapPriceSchedule returned no data — IAP has no price schedule yet", + ); + } + const manual = await this.call( + `/v1/inAppPurchasePriceSchedules/${encodeURIComponent(schedule.data.id)}/manualPrices?filter[territory]=USA&include=inAppPurchasePricePoint`, + ); + // When the IAP uses Apple's equalized auto-pricing instead of + // per-territory manual prices, `manualPrices` comes back empty + // and the assigned USA price actually lives on the parallel + // `automaticPrices` collection (same envelope shape). + if (manual.data.length === 0) { + return await this.call( + `/v1/inAppPurchasePriceSchedules/${encodeURIComponent(schedule.data.id)}/automaticPrices?filter[territory]=USA&include=inAppPurchasePricePoint`, + ); + } + return manual; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + } + async subCurrentPrice( + subId: string, + ): Promise { + try { + return await this.call( + `/v1/subscriptions/${encodeURIComponent(subId)}/prices?filter[territory]=USA&include=subscriptionPricePoint`, + ); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + } + + // Find a USA price-point id whose `customerPrice` matches the + // requested USD amount. Apple manages prices via opaque tier ids + // (eyJ...) — to set a price you can't just send "9.99", you must + // pass the price-point resource id corresponding to that tier in + // USA. We fetch the catalog once per (resource, amount) lookup. + async findIapUsaPricePointId( + iapId: string, + targetMicros: number, + ): Promise { + const list = await this.call( + `/v1/inAppPurchases/${encodeURIComponent(iapId)}/pricePoints?filter[territory]=USA&limit=200`, + ).catch(() => null); + return pickPricePointIdMatching(list, targetMicros); + } + async findSubUsaPricePointId( + subId: string, + targetMicros: number, + ): Promise { + const list = await this.call( + `/v1/subscriptions/${encodeURIComponent(subId)}/pricePoints?filter[territory]=USA&limit=200`, + ).catch(() => null); + return pickPricePointIdMatching(list, targetMicros); + } + + // Atomically create the IAP price schedule with the chosen USA + // price tier. Apple's pattern: POST `inAppPurchasePriceSchedules` + // with the IAP relationship + the manualPrices relationship inline, + // and pass the price rows in `included`. Returns the schedule id. + setIapPriceSchedule(args: { + iapId: string; + pricePointId: string; + startDate?: string; // YYYY-MM-DD; omit for "effective immediately" + }) { + const priceLid = "newPrice"; + const today = args.startDate ?? new Date().toISOString().slice(0, 10); + return this.call<{ data: { id: string } }>( + `/v1/inAppPurchasePriceSchedules`, + { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchasePriceSchedules", + relationships: { + inAppPurchase: { + data: { type: "inAppPurchases", id: args.iapId }, + }, + manualPrices: { + data: [{ type: "inAppPurchasePrices", id: priceLid }], + }, + }, + }, + included: [ + { + type: "inAppPurchasePrices", + id: priceLid, + attributes: { startDate: today }, + relationships: { + inAppPurchasePricePoint: { + data: { + type: "inAppPurchasePricePoints", + id: args.pricePointId, + }, + }, + inAppPurchaseV2: { + data: { type: "inAppPurchases", id: args.iapId }, + }, + }, + }, + ], + }), + }, + ); + } + setSubPriceSchedule(args: { + subId: string; + pricePointId: string; + startDate?: string; + }) { + const priceLid = "newSubPrice"; + const today = args.startDate ?? new Date().toISOString().slice(0, 10); + return this.call<{ data: { id: string } }>(`/v1/subscriptionPrices`, { + method: "POST", + body: JSON.stringify({ + data: { + type: "subscriptionPrices", + id: priceLid, + attributes: { startDate: today }, + relationships: { + subscription: { + data: { type: "subscriptions", id: args.subId }, + }, + subscriptionPricePoint: { + data: { + type: "subscriptionPricePoints", + id: args.pricePointId, + }, + }, + }, + }, + }), + }); + } + + // Attach an English (US) localization so reviewers and the + // dashboard see something other than the bare productId. Apple + // requires at least one locale before the IAP can be submitted; we + // always create en-US so first-submission isn't blocked. + createIapLocalization(args: { + iapId: string; + name: string; + description: string; + locale?: string; + }) { + return this.call<{ data: { id: string } }>( + `/v1/inAppPurchaseLocalizations`, + { + method: "POST", + body: JSON.stringify({ + data: { + type: "inAppPurchaseLocalizations", + attributes: { + name: args.name, + description: args.description, + locale: args.locale ?? "en-US", + }, + relationships: { + inAppPurchaseV2: { + data: { type: "inAppPurchases", id: args.iapId }, + }, + }, + }, + }), + }, + ); + } + createSubLocalization(args: { + subId: string; + name: string; + description: string; + locale?: string; + }) { + return this.call<{ data: { id: string } }>( + `/v1/subscriptionLocalizations`, + { + method: "POST", + body: JSON.stringify({ + data: { + type: "subscriptionLocalizations", + attributes: { + name: args.name, + description: args.description, + locale: args.locale ?? "en-US", + }, + relationships: { + subscription: { + data: { type: "subscriptions", id: args.subId }, + }, + }, + }, + }), + }, + ); + } + + // Look up an existing subscription group by referenceName, or + // create one. Used by the Add Product flow when the operator types + // a group name on a Subscription draft — kit then resolves it to + // an ASC group id at push time so they don't need to copy/paste + // opaque ids from ASC's web console. + async findOrCreateSubscriptionGroup(args: { + appId: string; + referenceName: string; + }): Promise { + const groups = await this.listSubscriptionGroups(args.appId); + const existing = groups.data.find( + (g) => g.attributes.referenceName === args.referenceName, + ); + if (existing) return existing.id; + const created = await this.call<{ data: { id: string } }>( + `/v1/subscriptionGroups`, + { + method: "POST", + body: JSON.stringify({ + data: { + type: "subscriptionGroups", + attributes: { referenceName: args.referenceName }, + relationships: { + app: { data: { type: "apps", id: args.appId } }, + }, + }, + }), + }, + ); + return created.data.id; + } + createInAppPurchase(args: { appId: string; productId: string; @@ -262,6 +580,183 @@ type AscSubGroupListResponse = { }>; }; +// Reference catalog response: every USA price point Apple publishes +// for a given IAP / sub. Used at push-time to translate a USD amount +// into the corresponding opaque price-point id (`eyJ...`) Apple's +// price-schedule POST requires. Different shape from the +// per-product *configured* price (`AscManualPricesResponse`) — this +// is the immutable tier ladder, that one is the operator's pick. +type AscPricePointListResponse = { + data: Array<{ + id: string; + type: "inAppPurchasePricePoints" | "subscriptionPricePoints"; + attributes?: { customerPrice?: string }; + }>; +}; + +// Find the price-point id whose `customerPrice` matches the desired +// USD amount (within 1 cent for floating-point safety). Returns null +// if Apple's catalog has no matching tier — caller should surface a +// failure so the operator picks a tier ASC actually publishes. +export function pickPricePointIdMatching( + list: AscPricePointListResponse | null, + targetMicros: number, +): string | null { + if (!list) return null; + const targetCents = Math.round(targetMicros / 10_000); + for (const point of list.data) { + const raw = point.attributes?.customerPrice; + if (!raw) continue; + const n = Number(raw); + if (!Number.isFinite(n)) continue; + const pointCents = Math.round(n * 100); + if (Math.abs(pointCents - targetCents) <= 1) return point.id; + } + return null; +} + +// Schedule lookup for one-time IAPs. We only need the resource id so +// we can fetch its `manualPrices` collection; relationships and +// attributes are intentionally untyped. +type AscIapPriceScheduleResponse = { + data?: { id: string; type: "inAppPurchasePriceSchedules" } | null; +}; + +// `manualPrices` (one-time IAP) and `subscriptionPrices` (auto-renew +// sub) share the same JSON:API envelope: a primary `data` row that +// references a pricePoint, and the actual `customerPrice` lives on +// the side-loaded resource in `included`. We narrow only the fields +// we read. +type AscManualPricesResponse = { + data: Array<{ + id: string; + type: "inAppPurchasePrices"; + attributes?: { startDate?: string | null; endDate?: string | null }; + relationships?: { + inAppPurchasePricePoint?: { data?: { id: string } | null }; + }; + }>; + included?: Array<{ + id: string; + type: "inAppPurchasePricePoints"; + attributes?: { customerPrice?: string }; + }>; +}; + +type AscSubscriptionPricesResponse = { + data: Array<{ + id: string; + type: "subscriptionPrices"; + attributes?: { startDate?: string | null; endDate?: string | null }; + relationships?: { + subscriptionPricePoint?: { data?: { id: string } | null }; + }; + }>; + included?: Array<{ + id: string; + type: "subscriptionPricePoints"; + attributes?: { customerPrice?: string }; + }>; +}; + +// Introductory offers list. Apple's `offerMode` enum: +// - "FREE_TRIAL" — duration of free access; no pricePoint +// - "PAY_UP_FRONT" — single discounted price for N periods +// - "PAY_AS_YOU_GO" — discounted price each period for N periods +// `numberOfPeriods` semantics differ by mode (free trial: 1; pay-up: +// 1; pay-as-you-go: N) so we surface it as-is and let the dashboard +// label it. `subscriptionPricePoint` is included for the discounted +// price; absent for free trials. +type AscIntroOfferListResponse = { + data: Array<{ + id: string; + type: "subscriptionIntroductoryOffers"; + attributes?: { + offerMode?: "FREE_TRIAL" | "PAY_UP_FRONT" | "PAY_AS_YOU_GO"; + duration?: string; // ISO-8601-ish: "ONE_WEEK", "THREE_DAYS", etc. + numberOfPeriods?: number; + startDate?: string | null; + endDate?: string | null; + }; + relationships?: { + subscriptionPricePoint?: { data?: { id: string } | null }; + }; + }>; + included?: Array<{ + id: string; + type: "subscriptionPricePoints"; + attributes?: { customerPrice?: string }; + }>; +}; + +// Pick the price record that's currently in effect (today between +// startDate and endDate, treating either bound's absence as "open"). +// ASC normally returns just one row when no scheduled change is +// pending, but a future-dated price-change creates a second record so +// we can't just take `data[0]`. +export function pickActivePriceRow< + T extends { + attributes?: { startDate?: string | null; endDate?: string | null }; + }, +>(rows: T[]): T | null { + if (!rows.length) return null; + const today = new Date().toISOString().slice(0, 10); + const active = rows.find((row) => { + const start = row.attributes?.startDate ?? null; + const end = row.attributes?.endDate ?? null; + if (start && start > today) return false; + if (end && end < today) return false; + return true; + }); + return active ?? rows[0]; +} + +// Generic shape both manual-price (one-time IAP) and subscription- +// price responses collapse into for parsing — primary row points to a +// pricePoint resource via a named relationship, included carries the +// `customerPrice`. Names of those keys vary between the two surfaces; +// we pass them in instead of branching inside. +type AscPriceCollectionResponse = { + data: Array<{ + id: string; + type: string; + attributes?: { startDate?: string | null; endDate?: string | null }; + relationships?: Record< + string, + { data?: { id: string } | null } | undefined + >; + }>; + included?: Array<{ + id: string; + type: string; + attributes?: { customerPrice?: string }; + }>; +}; + +// Resolve the active price record's pricePoint id and look up its +// `customerPrice` from the `included` array. Returns empty fields +// when nothing matches (no schedule, no USA price, ASC error) so the +// caller can pass the result straight into upsertFromStore. +function parseAssignedPrice( + resp: AscPriceCollectionResponse | null, + relationshipKey: "inAppPurchasePricePoint" | "subscriptionPricePoint", +): { priceAmountMicros?: number; currency?: string } { + if (!resp) return {}; + const row = pickActivePriceRow(resp.data); + if (!row) return {}; + const pointId = row.relationships?.[relationshipKey]?.data?.id; + if (!pointId) return {}; + const point = resp.included?.find((entry) => entry.id === pointId); + const raw = point?.attributes?.customerPrice; + if (!raw) return {}; + const n = Number(raw); + if (!Number.isFinite(n)) return {}; + return { + priceAmountMicros: Math.round(n * 1_000_000), + currency: "USD", + }; +} + function extractAscError(parsed: unknown): string { if ( parsed && @@ -291,11 +786,27 @@ export const pushSyncProductsApple = action({ direction: v.optional( v.union(v.literal("pull"), v.literal("push"), v.literal("both")), ), + // Dry-run mode: read-only against ASC. Skips every POST/PATCH + // (create subscription / IAP, localization, price schedule, group + // create) and instead returns the sequence of write attempts + // kit would have made. Lets the operator preview a Sync without + // polluting their App Store Connect catalog with test rows that + // Apple won't let them delete cleanly. + dryRun: v.optional(v.boolean()), }, returns: v.object({ pulled: v.number(), pushed: v.number(), failures: v.array(v.object({ productId: v.string(), reason: v.string() })), + plannedWrites: v.optional( + v.array( + v.object({ + productId: v.string(), + step: v.string(), + detail: v.optional(v.string()), + }), + ), + ), }), handler: async ( ctx, @@ -304,6 +815,11 @@ export const pushSyncProductsApple = action({ pulled: number; pushed: number; failures: Array<{ productId: string; reason: string }>; + plannedWrites?: Array<{ + productId: string; + step: string; + detail?: string; + }>; }> => { const project = await getProjectByApiKey(ctx, args.apiKey); if (!project.iosBundleId) { @@ -376,6 +892,12 @@ export const pushSyncProductsApple = action({ const failures: Array<{ productId: string; reason: string }> = []; let pulled = 0; let pushed = 0; + const dryRun = args.dryRun ?? false; + const plannedWrites: Array<{ + productId: string; + step: string; + detail?: string; + }> = []; const appIdStr = String(project.iosAppAppleId); @@ -393,12 +915,30 @@ export const pushSyncProductsApple = action({ const productId = item.attributes.productId; if (!productId) continue; const type = mapAscIapType(item.attributes.inAppPurchaseType); + // ASC doesn't inline the assigned USA price in the catalog + // list — separate fetch. Per-IAP price failures don't abort + // the pull (the row still surfaces; price stays "—") but + // they DO get appended to `failures` so the operator can see + // why a row has no price instead of staring at a silent dash. + const pricePoint = await client.iapCurrentPrice(item.id); + if (pricePoint instanceof Error) { + failures.push({ + productId: `${productId} (price lookup)`, + reason: pricePoint.message, + }); + } + const { priceAmountMicros, currency } = parseAssignedPrice( + pricePoint instanceof Error ? null : pricePoint, + "inAppPurchasePricePoint", + ); await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, productId, platform: "IOS", type, title: item.attributes.name ?? productId, + priceAmountMicros, + currency, storeRef: item.id, state: mapAscState(item.attributes.state), }); @@ -430,14 +970,40 @@ export const pushSyncProductsApple = action({ for (const sub of subs.data) { const productId = sub.attributes.productId; if (!productId) continue; + const pricePoint = await client.subCurrentPrice(sub.id); + if (pricePoint instanceof Error) { + failures.push({ + productId: `${productId} (price lookup)`, + reason: pricePoint.message, + }); + } + const { priceAmountMicros, currency } = parseAssignedPrice( + pricePoint instanceof Error ? null : pricePoint, + "subscriptionPricePoint", + ); + const introOffers = await client.subIntroductoryOffer(sub.id); + if (introOffers instanceof Error) { + failures.push({ + productId: `${productId} (offers lookup)`, + reason: introOffers.message, + }); + } + const offers = parseIntroOffers( + introOffers instanceof Error ? null : introOffers, + ); await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, productId, platform: "IOS", type: "Subscription", title: sub.attributes.name ?? productId, + priceAmountMicros, + currency, storeRef: sub.id, state: mapAscState(sub.attributes.state), + subscriptionGroupId: group.id, + subscriptionGroupName: group.attributes.referenceName, + offers: offers.length ? offers : undefined, }); pulled += 1; } @@ -446,6 +1012,15 @@ export const pushSyncProductsApple = action({ } // ── PUSH: kit → ASC for Draft rows ───────────────────────────── + // Each draft becomes a multi-step flow: create → localize → set + // price. The first step alone leaves the IAP/sub in an unsubmittable + // state because Apple requires both an en-US localization and a + // USA price schedule before the row can move past Draft. We do + // the whole chain here so a single Sync click takes the catalog + // from "kit-only" to "Ready to Submit" in App Store Connect. + // Submission itself (screenshot upload + inAppPurchaseSubmissions + // POST) is a follow-up because it needs a screenshot file and a + // dashboard upload slot we haven't built yet — see TODO below. if (direction === "push" || direction === "both") { const drafts = await ctx.runQuery( internal.products.sync.listDraftIosProducts, @@ -454,50 +1029,232 @@ export const pushSyncProductsApple = action({ for (const row of drafts) { try { if (row.type === "Subscription") { - // Subscriptions need a group; the v0 push assumes the - // dashboard / MCP creates one via ASC web UI first and - // populates `storeRef` on the row with the group id. - if (!row.storeRef) { + // Resolve the ASC subscriptionGroup from the operator-typed + // `subscriptionGroupName`. Find-or-create so the operator + // doesn't have to pre-create the group in ASC's web UI; if + // they don't pick a name we default to the productId so + // there's *some* group rather than a hard failure. In + // dry-run, list groups (read-only) and report which path + // the real run would take instead of creating anything. + const groupName = row.subscriptionGroupName ?? row.productId; + let groupId: string; + if (dryRun) { + const groups = await client.listSubscriptionGroups(appIdStr); + const existing = groups.data.find( + (g) => g.attributes.referenceName === groupName, + ); + groupId = existing?.id ?? "(would-create)"; + plannedWrites.push({ + productId: row.productId, + step: existing + ? "use existing subscription group" + : "create subscription group", + detail: groupName, + }); + } else { + groupId = await client.findOrCreateSubscriptionGroup({ + appId: appIdStr, + referenceName: groupName, + }); + } + let storeRef: string; + if (dryRun) { + storeRef = "(would-create)"; + plannedWrites.push({ + productId: row.productId, + step: "create subscription", + detail: `${row.title} · ${mapBillingPeriodToAsc(row.billingPeriod)} · group=${groupName}`, + }); + } else { + const result = await client.createSubscription({ + groupId, + productId: row.productId, + name: row.title, + subscriptionPeriod: mapBillingPeriodToAsc(row.billingPeriod), + reviewNote: row.reviewNote, + }); + storeRef = result.data.id; + } + // Localize so reviewers see the human-readable name + + // description instead of just the productId. ASC requires + // at least one locale before submission — failing here + // doesn't unwind the create (Apple has no rollback) so we + // record a failure and let the operator retry / fix in + // ASC web. + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "create en-US localization", + detail: row.description ?? row.title, + }); + } else { + try { + await client.createSubLocalization({ + subId: storeRef, + name: row.title, + description: row.description ?? row.title, + }); + } catch (error) { + failures.push({ + productId: `${row.productId} (localization)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + // Set the USA price by resolving the operator's USD amount + // → Apple's nearest price-point id. We require currency = + // "USD" because the dashboard form lets them pick others + // but we only know the USA tier ladder here; non-USD prices + // are surfaced as an actionable failure rather than silently + // mis-priced. In dry-run, skip the lookup (the just-created + // subscription resource doesn't exist for read-back) and + // just record intent. + if (row.priceAmountMicros && (row.currency ?? "USD") === "USD") { + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "set USA price", + detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`, + }); + } else { + try { + const pricePointId = await client.findSubUsaPricePointId( + storeRef, + row.priceAmountMicros, + ); + if (!pricePointId) { + failures.push({ + productId: `${row.productId} (price)`, + reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`, + }); + } else { + await client.setSubPriceSchedule({ + subId: storeRef, + pricePointId, + }); + } + } catch (error) { + failures.push({ + productId: `${row.productId} (price)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + } else if (row.currency && row.currency !== "USD") { failures.push({ + productId: `${row.productId} (price)`, + reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`, + }); + } + if (!dryRun) { + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, productId: row.productId, - reason: - "Set storeRef to the ASC subscriptionGroup id before pushing a subscription", + platform: "IOS", + storeRef, }); - continue; } - const result = await client.createSubscription({ - groupId: row.storeRef, - productId: row.productId, - name: row.title, - // Translate the ISO-8601 `billingPeriod` from the catalog - // row to ASC's enum. Default to `ONE_MONTH` only when - // the operator hasn't picked one — the prior hardcode - // silently created weekly / yearly subscriptions as - // monthly with no error. - subscriptionPeriod: mapBillingPeriodToAsc(row.billingPeriod), - }); - await ctx.runMutation(internal.products.sync.markPushed, { - projectId: project._id, - productId: row.productId, - platform: "IOS", - storeRef: result.data.id, - }); pushed += 1; } else { - const result = await client.createInAppPurchase({ - appId: appIdStr, - productId: row.productId, - name: row.title, - type: row.type === "Consumable" ? "CONSUMABLE" : "NON_CONSUMABLE", - }); - await ctx.runMutation(internal.products.sync.markPushed, { - projectId: project._id, - productId: row.productId, - platform: "IOS", - storeRef: result.data.id, - }); + let storeRef: string; + if (dryRun) { + storeRef = "(would-create)"; + plannedWrites.push({ + productId: row.productId, + step: "create in-app purchase", + detail: `${row.title} · ${row.type}`, + }); + } else { + const result = await client.createInAppPurchase({ + appId: appIdStr, + productId: row.productId, + name: row.title, + type: + row.type === "Consumable" ? "CONSUMABLE" : "NON_CONSUMABLE", + reviewNote: row.reviewNote, + }); + storeRef = result.data.id; + } + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "create en-US localization", + detail: row.description ?? row.title, + }); + } else { + try { + await client.createIapLocalization({ + iapId: storeRef, + name: row.title, + description: row.description ?? row.title, + }); + } catch (error) { + failures.push({ + productId: `${row.productId} (localization)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + if (row.priceAmountMicros && (row.currency ?? "USD") === "USD") { + if (dryRun) { + plannedWrites.push({ + productId: row.productId, + step: "set USA price", + detail: `USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)}`, + }); + } else { + try { + const pricePointId = await client.findIapUsaPricePointId( + storeRef, + row.priceAmountMicros, + ); + if (!pricePointId) { + failures.push({ + productId: `${row.productId} (price)`, + reason: `No ASC price tier matches USD ${(row.priceAmountMicros / 1_000_000).toFixed(2)} — pick a published tier amount.`, + }); + } else { + await client.setIapPriceSchedule({ + iapId: storeRef, + pricePointId, + }); + } + } catch (error) { + failures.push({ + productId: `${row.productId} (price)`, + reason: + error instanceof Error ? error.message : String(error), + }); + } + } + } else if (row.currency && row.currency !== "USD") { + failures.push({ + productId: `${row.productId} (price)`, + reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`, + }); + } + if (!dryRun) { + await ctx.runMutation(internal.products.sync.markPushed, { + projectId: project._id, + productId: row.productId, + platform: "IOS", + storeRef, + }); + } pushed += 1; } + // TODO(review-submit): once Settings has an upload slot for a + // project-level App Review screenshot + // (`apple_iap_review_screenshot` purpose), add a step here: + // 1. POST /v1/inAppPurchaseAppStoreReviewScreenshots (reserve) + // 2. PUT to the returned upload URL (binary) + // 3. PATCH ...screenshots/{id} with sourceFileChecksum + // 4. POST /v1/inAppPurchaseSubmissions + // Until then, the row stops at "Ready to Submit" in ASC and + // the operator hits Submit manually (or via next app version). } catch (error) { failures.push({ productId: row.productId, @@ -507,11 +1264,74 @@ export const pushSyncProductsApple = action({ } } - return { pulled, pushed, failures }; + return { + pulled, + pushed, + failures, + plannedWrites: dryRun ? plannedWrites : undefined, + }; + }, +}); + +// Lightweight read-only action so the dashboard can populate a +// subscription-group autocomplete without the operator having to copy +// reference names from ASC's web console. Returns just `{id, +// referenceName}` per group — the heavier listSubscriptionsInGroup +// fetch only happens during full pull-sync. Failures bubble back as a +// thrown Error so the dashboard can show a toast and degrade +// gracefully (the field stays a free-text input). +export const listSubscriptionGroupsApple = action({ + args: { apiKey: v.string() }, + returns: v.array(v.object({ id: v.string(), referenceName: v.string() })), + handler: async ( + ctx, + args, + ): Promise> => { + const project = await getProjectByApiKey(ctx, args.apiKey); + if (!project.iosAppAppleId) { + throw new Error("Project iosAppAppleId is not configured"); + } + const issuerId = project.iosAscIssuerId ?? project.iosAppStoreIssuerId; + const keyId = project.iosAscKeyId ?? project.iosAppStoreKeyId; + if (!issuerId || !keyId) { + throw new Error( + "App Store Connect API Issuer ID / Key ID not configured", + ); + } + let keyContent: string | undefined; + try { + const ascKey = await ctx.runAction( + internal.files.internal.getAppleAscApiKey, + { organizationId: project.organizationId, projectId: project._id }, + ); + keyContent = ascKey?.keyContent; + } catch { + // ignore, fall through to legacy slot + } + if (!keyContent) { + const legacyKey = await ctx.runAction( + internal.files.internal.getAppleP8Key, + { organizationId: project.organizationId, projectId: project._id }, + ); + keyContent = legacyKey?.keyContent; + } + if (!keyContent) { + throw new Error("App Store Connect API key (.p8) not uploaded"); + } + const client = new AscClient(issuerId, keyId, keyContent); + const resp = await client.listSubscriptionGroups( + String(project.iosAppAppleId), + ); + return resp.data + .map((g) => ({ + id: g.id, + referenceName: g.attributes.referenceName ?? "", + })) + .filter((g) => g.referenceName.length > 0); }, }); -function mapBillingPeriodToAsc( +export function mapBillingPeriodToAsc( period: string | undefined, ): | "ONE_WEEK" @@ -552,6 +1372,97 @@ function mapAscIapType( } } +// Apple represents introductory-offer durations as enum strings +// rather than ISO-8601 like the subscriptionPeriod field. Translate +// to ISO so kit's `offers[].duration` is uniform across stores +// (Play already uses ISO `P1W` / `P1M` / etc.). Unknown values fall +// through as-is so the dashboard can still render whatever Apple +// returned even if Apple ships a new enum value. +export function mapAscOfferDurationToIso( + raw: string | undefined, +): string | undefined { + if (!raw) return undefined; + switch (raw) { + case "THREE_DAYS": + return "P3D"; + case "ONE_WEEK": + return "P1W"; + case "TWO_WEEKS": + return "P2W"; + case "ONE_MONTH": + return "P1M"; + case "TWO_MONTHS": + return "P2M"; + case "THREE_MONTHS": + return "P3M"; + case "SIX_MONTHS": + return "P6M"; + case "ONE_YEAR": + return "P1Y"; + default: + return raw; + } +} + +export function mapAscOfferKind( + mode: string | undefined, +): "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo" { + switch (mode) { + case "PAY_UP_FRONT": + return "IntroPayUpFront"; + case "PAY_AS_YOU_GO": + return "IntroPayAsYouGo"; + case "FREE_TRIAL": + default: + return "FreeTrial"; + } +} + +// Convert ASC introductory offers list into kit's `offers[]` shape. +// Picks rows whose date range covers today (consistent with how +// `pickActivePriceRow` resolves the active price). Free-trial offers +// have no pricePoint — we emit them with no priceAmountMicros. +export function parseIntroOffers( + resp: AscIntroOfferListResponse | null, +): Array<{ + id: string; + kind: "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo"; + duration?: string; + numberOfPeriods?: number; + priceAmountMicros?: number; + currency?: string; +}> { + if (!resp || resp.data.length === 0) return []; + const today = new Date().toISOString().slice(0, 10); + return resp.data + .filter((row) => { + const start = row.attributes?.startDate ?? null; + const end = row.attributes?.endDate ?? null; + if (start && start > today) return false; + if (end && end < today) return false; + return true; + }) + .map((row) => { + const pointId = row.relationships?.subscriptionPricePoint?.data?.id; + const point = pointId + ? resp.included?.find((entry) => entry.id === pointId) + : undefined; + const raw = point?.attributes?.customerPrice; + const n = raw ? Number(raw) : Number.NaN; + const priceAmountMicros = Number.isFinite(n) + ? Math.round(n * 1_000_000) + : undefined; + return { + id: row.id, + kind: mapAscOfferKind(row.attributes?.offerMode), + duration: mapAscOfferDurationToIso(row.attributes?.duration), + numberOfPeriods: row.attributes?.numberOfPeriods, + priceAmountMicros, + currency: priceAmountMicros !== undefined ? "USD" : undefined, + }; + }); +} + function mapAscState( raw: string | undefined, ): "Draft" | "Ready" | "Active" | "Removed" { diff --git a/packages/kit/convex/products/mutation.ts b/packages/kit/convex/products/mutation.ts index 2b550050..33c4dbc9 100644 --- a/packages/kit/convex/products/mutation.ts +++ b/packages/kit/convex/products/mutation.ts @@ -40,6 +40,8 @@ export const upsertProduct = mutation({ v.literal("P1Y"), ), ), + subscriptionGroupName: v.optional(v.string()), + reviewNote: v.optional(v.string()), state: v.optional(stateValidator), storeRef: v.optional(v.string()), }, @@ -56,8 +58,11 @@ export const upsertProduct = mutation({ const existing: Doc<"products"> | null = await ctx.db .query("products") - .withIndex("by_project_and_product", (q) => - q.eq("projectId", project._id).eq("productId", args.productId), + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", project._id) + .eq("platform", args.platform) + .eq("productId", args.productId), ) .unique(); @@ -68,13 +73,15 @@ export const upsertProduct = mutation({ // the prior "blank title preserves existing" hack would still // mask cases where a caller really did mean to clear a field. await ctx.db.patch(existing._id, { - platform: args.platform, type: args.type, title: args.title, description: args.description ?? existing.description, priceAmountMicros: args.priceAmountMicros ?? existing.priceAmountMicros, currency: args.currency ?? existing.currency, billingPeriod: args.billingPeriod ?? existing.billingPeriod, + subscriptionGroupName: + args.subscriptionGroupName ?? existing.subscriptionGroupName, + reviewNote: args.reviewNote ?? existing.reviewNote, state: args.state ?? existing.state, storeRef: args.storeRef ?? existing.storeRef, updatedAt: now, @@ -92,6 +99,8 @@ export const upsertProduct = mutation({ priceAmountMicros: args.priceAmountMicros, currency: args.currency, billingPeriod: args.billingPeriod, + subscriptionGroupName: args.subscriptionGroupName, + reviewNote: args.reviewNote, state: args.state ?? "Draft", storeRef: args.storeRef, updatedAt: now, @@ -127,14 +136,14 @@ export const setProductState = mutation({ const existing = await ctx.db .query("products") - .withIndex("by_project_and_product", (q) => - q.eq("projectId", project._id).eq("productId", args.productId), + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", project._id) + .eq("platform", args.platform) + .eq("productId", args.productId), ) .unique(); if (!existing) throw new Error("Product not found"); - if (existing.platform !== args.platform) { - throw new Error("Product platform mismatch"); - } await ctx.db.patch(existing._id, { state: args.state, @@ -160,12 +169,14 @@ export const removeProduct = mutation({ const existing = await ctx.db .query("products") - .withIndex("by_project_and_product", (q) => - q.eq("projectId", project._id).eq("productId", args.productId), + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", project._id) + .eq("platform", args.platform) + .eq("productId", args.productId), ) .unique(); if (!existing) return { ok: false }; - if (existing.platform !== args.platform) return { ok: false }; // Soft-remove via state flag — keeps audit history for the // dashboard and does not break paywalls referencing this productId. diff --git a/packages/kit/convex/products/play.test.ts b/packages/kit/convex/products/play.test.ts new file mode 100644 index 00000000..38aee700 --- /dev/null +++ b/packages/kit/convex/products/play.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { basePlanIdForPeriod, moneyToMicros } from "./play"; + +describe("moneyToMicros", () => { + it("returns undefined when input is missing or has no units", () => { + expect(moneyToMicros(undefined)).toBeUndefined(); + expect(moneyToMicros({ currencyCode: "USD" })).toBeUndefined(); + }); + + it("converts whole dollars (units only) to micros", () => { + expect(moneyToMicros({ currencyCode: "USD", units: "9", nanos: 0 })).toBe( + 9_000_000, + ); + }); + + it("converts units + nanos combination correctly", () => { + // $9.99 = units 9 + nanos 990_000_000 → 9_990_000 micros + expect( + moneyToMicros({ currencyCode: "USD", units: "9", nanos: 990_000_000 }), + ).toBe(9_990_000); + }); + + it("rounds nanos / 1000 conversion (Google's nanos resolution → kit micros)", () => { + // 999_999_999 nanos / 1000 = 999_999.999 → rounds to 1_000_000 micros from nanos, + // plus 0 units = 1_000_000 micros total. Verifies we don't truncate. + expect( + moneyToMicros({ currencyCode: "USD", units: "0", nanos: 999_999_999 }), + ).toBe(1_000_000); + }); + + it("uses BigInt math so very large currency values keep precision", () => { + // 9_999_999_999 KRW = unit only, far past safe Number range when multiplied + // by 1_000_000. BigInt path preserves the exact value. + expect( + moneyToMicros({ currencyCode: "KRW", units: "9999999999", nanos: 0 }), + ).toBe(9_999_999_999_000_000); + }); + + it("returns undefined when units is not a parseable BigInt string", () => { + expect( + moneyToMicros({ currencyCode: "USD", units: "abc", nanos: 0 }), + ).toBeUndefined(); + }); +}); + +describe("basePlanIdForPeriod", () => { + it.each([ + ["P1W", "weekly"], + ["P1M", "monthly"], + ["P2M", "bimonthly"], + ["P3M", "quarterly"], + ["P6M", "semiannual"], + ["P1Y", "yearly"], + ])("maps %s → %s", (iso, label) => { + expect(basePlanIdForPeriod(iso)).toBe(label); + }); + + it("falls back to monthly for undefined / unknown periods", () => { + expect(basePlanIdForPeriod(undefined)).toBe("monthly"); + expect(basePlanIdForPeriod("P9X")).toBe("monthly"); + }); +}); diff --git a/packages/kit/convex/products/play.ts b/packages/kit/convex/products/play.ts index 253cb7c4..706a016d 100644 --- a/packages/kit/convex/products/play.ts +++ b/packages/kit/convex/products/play.ts @@ -105,51 +105,17 @@ export const pushSyncProductsGoogle = action({ // — that way an account that lives entirely in one or the other // still gets a complete pull instead of failing on the missing // half. + // + // ORDER MATTERS: hit the new monetization API first so its + // USD-preferred regional price wins. The legacy + // `inappproducts.list` only exposes a single `defaultPrice` + // (whatever currency the merchant set in Play Console — often + // their home currency) which made products like + // `dev.hyo.martie.10bulbs` show up as "AED 3.89" on the + // dashboard for an operator using a Korean Play Console where + // AED happens to be a regional override. New endpoint runs + // first; legacy only fills in skus the new endpoint missed. const seenOneTimeSkus = new Set(); - try { - let token: string | undefined; - let pageCount = 0; - do { - const oneTimes = await androidpublisher.inappproducts.list({ - packageName, - ...(token ? { token } : {}), - }); - for (const product of oneTimes.data.inappproduct ?? []) { - if (!product.sku) continue; - if (seenOneTimeSkus.has(product.sku)) continue; - seenOneTimeSkus.add(product.sku); - if (product.purchaseType === "subscription") continue; - await ctx.runMutation(internal.products.sync.upsertFromStore, { - projectId: project._id, - productId: product.sku, - platform: "Android", - type: mapPlayOneTimeType(product), - title: pickPlayTitle(product) ?? product.sku, - description: pickPlayDescription(product), - priceAmountMicros: parsePlayPriceMicros(product), - currency: pickPlayCurrency(product), - storeRef: product.sku, - state: mapPlayStatus(product.status), - }); - pulled += 1; - } - token = oneTimes.data.tokenPagination?.nextPageToken ?? undefined; - pageCount += 1; - if (pageCount > 50) break; - } while (token); - } catch (error) { - failures.push({ - productId: "(play list inappproducts)", - reason: error instanceof Error ? error.message : String(error), - }); - } - - // New monetization API for one-time products. The googleapis - // SDK exposes this as `monetization.onetimeproducts.list`. We - // probe via dynamic access because older versions of the SDK - // don't have the typings yet — falling back gracefully if the - // method isn't there keeps kit working with whatever - // googleapis version is bundled. try { const onetime = ( androidpublisher.monetization as unknown as { @@ -178,6 +144,13 @@ export const pushSyncProductsGoogle = action({ // with no price because the lookup never matched. regionalPricingAndAvailabilityConfigs?: Array<{ regionCode?: string; + // Google's enum: "AVAILABLE", + // "NO_LONGER_AVAILABLE", "AVAILABLE_IF_RELEASED". + // Stale rows from removed regions still ship + // back with a price attached, so without this + // field we'd happily display a price the + // operator turned off years ago. + availability?: string; price?: { currencyCode?: string; units?: string; @@ -206,10 +179,21 @@ export const pushSyncProductsGoogle = action({ seenOneTimeSkus.add(product.productId); const listing = product.listings?.[0]; // Walk every purchaseOption × regionalPricingAndAvailabilityConfig - // (pricing lives on the option, not inside buyOption), - // prefer USD when any region offers it, otherwise the - // first region with a readable price. + // (pricing lives on the option, not inside buyOption). + // Two filters before ranking: + // - drop regions explicitly NO_LONGER_AVAILABLE so we + // don't surface stale pricing the operator removed. + // - require the price to have a `units` field — Google + // ships zero-priced placeholder rows for some regions + // and they'd outrank real prices alphabetically. + // Ranking: regionCode === "US" first (canonical kit + // display currency, deterministically maps to USD), + // then any USD-currency region (covers operators who + // override the US region price into a non-USD currency + // — rare but possible), then the first remaining region + // alphabetically by currency for a stable result. const priceCandidates: Array<{ + regionCode?: string; currencyCode?: string; units?: string; nanos?: number; @@ -217,12 +201,22 @@ export const pushSyncProductsGoogle = action({ for (const opt of product.purchaseOptions ?? []) { for (const region of opt.regionalPricingAndAvailabilityConfigs ?? []) { + if (region.availability === "NO_LONGER_AVAILABLE") continue; if (region.price && typeof region.price.units === "string") { - priceCandidates.push(region.price); + priceCandidates.push({ + regionCode: region.regionCode, + currencyCode: region.price.currencyCode, + units: region.price.units, + nanos: region.price.nanos, + }); } } } + priceCandidates.sort((a, b) => + (a.currencyCode ?? "").localeCompare(b.currencyCode ?? ""), + ); const preferred = + priceCandidates.find((p) => p.regionCode === "US") ?? priceCandidates.find((p) => p.currencyCode === "USD") ?? priceCandidates[0]; const priceAmountMicros = preferred @@ -261,6 +255,59 @@ export const pushSyncProductsGoogle = action({ }); } + // Legacy `inappproducts.list` runs SECOND so any sku already + // surfaced by the new endpoint (with USD-preferred pricing) wins + // via the dedupe set. Only skus invisible to the new endpoint + // get filled in here with whatever `defaultPrice` the merchant + // set in Play Console. + try { + let token: string | undefined; + let pageCount = 0; + do { + const oneTimes = await androidpublisher.inappproducts.list({ + packageName, + ...(token ? { token } : {}), + }); + for (const product of oneTimes.data.inappproduct ?? []) { + if (!product.sku) continue; + if (seenOneTimeSkus.has(product.sku)) continue; + seenOneTimeSkus.add(product.sku); + if (product.purchaseType === "subscription") continue; + await ctx.runMutation(internal.products.sync.upsertFromStore, { + projectId: project._id, + productId: product.sku, + platform: "Android", + type: mapPlayOneTimeType(product), + title: pickPlayTitle(product) ?? product.sku, + description: pickPlayDescription(product), + priceAmountMicros: parsePlayPriceMicros(product), + currency: pickPlayCurrency(product), + storeRef: product.sku, + state: mapPlayStatus(product.status), + }); + pulled += 1; + } + token = oneTimes.data.tokenPagination?.nextPageToken ?? undefined; + pageCount += 1; + if (pageCount > 50) break; + } while (token); + } catch (error) { + // The legacy `inappproducts.list` endpoint is deprecated for + // newer Play Console accounts and Google now responds with + // "Please migrate to the new publishing API". That message is + // expected — the new `monetization.onetimeproducts.list` call + // above already covers this account — and surfacing it as a + // failure produces a noisy red toast every Sync. Suppress it + // when seen; surface anything else. + const reason = error instanceof Error ? error.message : String(error); + if (!/migrate to the new publishing API/i.test(reason)) { + failures.push({ + productId: "(play list inappproducts)", + reason, + }); + } + } + try { let token: string | undefined; let pageCount = 0; @@ -272,6 +319,7 @@ export const pushSyncProductsGoogle = action({ for (const sub of subs.data.subscriptions ?? []) { if (!sub.productId) continue; const { priceAmountMicros, currency } = pickSubBasePlanPrice(sub); + const offers = collectPlaySubscriptionOffers(sub); await ctx.runMutation(internal.products.sync.upsertFromStore, { projectId: project._id, productId: sub.productId, @@ -283,6 +331,11 @@ export const pushSyncProductsGoogle = action({ currency, storeRef: sub.productId, state: "Active", + // Play has no first-class subscription "group" — base + // plans on a single subscription product play that role, + // and we surface them as `offers[].kind === "BasePlan"` + // rows. Leave the ASC-only group fields unset. + offers: offers.length ? offers : undefined, }); pulled += 1; } @@ -498,7 +551,118 @@ function pickSubBasePlanPrice(sub: androidpublisher_v3.Schema$Subscription): { }; } -function moneyToMicros( +// Flatten a Play subscription's basePlans + (per base plan) offers +// into kit's uniform `offers[]` shape. Each base plan becomes a +// `kind: "BasePlan"` row carrying its billing period + USD price; each +// associated subscription offer (free trial / intro discount, set up +// in Play Console) becomes a Free-Trial / IntroPay* row. Prefers USD +// regional price when present (mirrors `pickSubBasePlanPrice`'s +// rationale) so the dashboard shows a stable currency. +function collectPlaySubscriptionOffers( + sub: androidpublisher_v3.Schema$Subscription, +): Array<{ + id: string; + kind: + | "BasePlan" + | "FreeTrial" + | "IntroPayUpFront" + | "IntroPayAsYouGo" + | "PromotionalOffer"; + duration?: string; + numberOfPeriods?: number; + priceAmountMicros?: number; + currency?: string; +}> { + const out: Array<{ + id: string; + kind: + | "BasePlan" + | "FreeTrial" + | "IntroPayUpFront" + | "IntroPayAsYouGo" + | "PromotionalOffer"; + duration?: string; + numberOfPeriods?: number; + priceAmountMicros?: number; + currency?: string; + }> = []; + // Local shape for `basePlans[].offers[]` — googleapis' generated + // `Schema$BasePlan` doesn't expose offers despite the underlying + // REST resource carrying them, and we don't want to depend on the + // SDK regenerating to surface this. Mirrors the relevant fields + // from Play's `SubscriptionOffer` proto. + type PlanOfferShape = { + offerId?: string; + phases?: Array<{ + duration?: string; + recurrenceCount?: number; + regionalConfigs?: Array<{ + regionCode?: string; + price?: androidpublisher_v3.Schema$Money; + }>; + }>; + }; + type PlanWithOffers = androidpublisher_v3.Schema$BasePlan & { + offers?: PlanOfferShape[]; + }; + for (const plan of (sub.basePlans ?? []) as PlanWithOffers[]) { + if (!plan.basePlanId) continue; + const planRegions = plan.regionalConfigs ?? []; + const planPrice = + planRegions.find((r) => r.price?.currencyCode === "USD")?.price ?? + planRegions[0]?.price; + out.push({ + id: plan.basePlanId, + kind: "BasePlan", + duration: + plan.autoRenewingBasePlanType?.billingPeriodDuration ?? undefined, + priceAmountMicros: moneyToMicros(planPrice ?? undefined), + currency: planPrice?.currencyCode ?? undefined, + }); + for (const offer of plan.offers ?? []) { + if (!offer.offerId) continue; + // Walk the offer's phases. A FREE phase becomes FreeTrial; a + // DISCOUNTED phase with a single occurrence becomes + // IntroPayUpFront; multi-occurrence becomes IntroPayAsYouGo. + // Most offers only have one of these; if multiple, we emit + // multiple rows tagged with the same composite id so the + // dashboard can dedupe by basePlanId+offerId+phaseIndex. + const phases = offer.phases ?? []; + phases.forEach((phase, i) => { + const phaseRegions = phase.regionalConfigs ?? []; + const phasePrice = + phaseRegions.find((r) => r.price?.currencyCode === "USD")?.price ?? + phaseRegions[0]?.price; + // Phase with no price = free trial; with `recurrenceCount > 1` + // = pay-as-you-go intro; otherwise = pay-up-front intro. + let kind: "FreeTrial" | "IntroPayUpFront" | "IntroPayAsYouGo" = + "FreeTrial"; + const isFree = + !phasePrice || + (phasePrice.units === "0" && (phasePrice.nanos ?? 0) === 0); + if (!isFree) { + kind = + (phase.recurrenceCount ?? 1) > 1 + ? "IntroPayAsYouGo" + : "IntroPayUpFront"; + } + out.push({ + id: `${plan.basePlanId}/${offer.offerId}#${i}`, + kind, + duration: phase.duration ?? undefined, + numberOfPeriods: phase.recurrenceCount ?? undefined, + priceAmountMicros: isFree ? undefined : moneyToMicros(phasePrice), + currency: isFree + ? undefined + : (phasePrice?.currencyCode ?? undefined), + }); + }); + } + } + return out; +} + +export function moneyToMicros( money: androidpublisher_v3.Schema$Money | undefined, ): number | undefined { if (!money?.units) return undefined; @@ -518,7 +682,7 @@ function moneyToMicros( // Stable basePlanId per billing period — Play's product detail page // shows this id, so something descriptive beats "monthly" hardcoded // for non-monthly billing. -function basePlanIdForPeriod(period: string | undefined): string { +export function basePlanIdForPeriod(period: string | undefined): string { switch (period) { case "P1W": return "weekly"; diff --git a/packages/kit/convex/products/query.ts b/packages/kit/convex/products/query.ts index d577519f..db0211e5 100644 --- a/packages/kit/convex/products/query.ts +++ b/packages/kit/convex/products/query.ts @@ -2,6 +2,21 @@ import { query } from "../_generated/server"; import { v } from "convex/values"; import type { Doc } from "../_generated/dataModel"; +const offerShape = v.object({ + id: v.string(), + kind: v.union( + v.literal("FreeTrial"), + v.literal("IntroPayUpFront"), + v.literal("IntroPayAsYouGo"), + v.literal("PromotionalOffer"), + v.literal("BasePlan"), + ), + duration: v.optional(v.string()), + numberOfPeriods: v.optional(v.number()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), +}); + const productShape = v.object({ productId: v.string(), platform: v.union(v.literal("IOS"), v.literal("Android")), @@ -21,6 +36,9 @@ const productShape = v.object({ v.literal("Removed"), ), storeRef: v.optional(v.string()), + subscriptionGroupId: v.optional(v.string()), + subscriptionGroupName: v.optional(v.string()), + offers: v.optional(v.array(offerShape)), updatedAt: v.number(), }); @@ -35,6 +53,9 @@ function shape(product: Doc<"products">) { currency: product.currency, state: product.state, storeRef: product.storeRef, + subscriptionGroupId: product.subscriptionGroupId, + subscriptionGroupName: product.subscriptionGroupName, + offers: product.offers, updatedAt: product.updatedAt, }; } @@ -64,7 +85,7 @@ export const listProducts = query({ const rows = await ctx.db .query("products") - .withIndex("by_project_and_product", (q) => + .withIndex("by_project_and_platform_and_product", (q) => q.eq("projectId", project._id), ) .collect(); diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts index 3732e3d8..fbd7cf56 100644 --- a/packages/kit/convex/products/sync.ts +++ b/packages/kit/convex/products/sync.ts @@ -15,6 +15,22 @@ const stateValidator = v.union( v.literal("Removed"), ); +const offerKindValidator = v.union( + v.literal("FreeTrial"), + v.literal("IntroPayUpFront"), + v.literal("IntroPayAsYouGo"), + v.literal("PromotionalOffer"), + v.literal("BasePlan"), +); +const offerValidator = v.object({ + id: v.string(), + kind: offerKindValidator, + duration: v.optional(v.string()), + numberOfPeriods: v.optional(v.number()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), +}); + // Internal mutation called by the ASC / Play push-sync actions when a // row is mirrored from the upstream store. Distinct from the public // `upsertProduct` mutation in mutation.ts so server-driven sync can't @@ -31,19 +47,30 @@ export const upsertFromStore = internalMutation({ currency: v.optional(v.string()), storeRef: v.string(), state: stateValidator, + subscriptionGroupId: v.optional(v.string()), + subscriptionGroupName: v.optional(v.string()), + offers: v.optional(v.array(offerValidator)), }, returns: v.id("products"), handler: async (ctx, args) => { + // Match by (projectId, platform, productId) — apps commonly use + // the same productId on both stores, and the older + // (projectId, productId)-only lookup would collide and silently + // flip an existing Android row's platform to IOS (or vice versa) + // mid-sync, deleting one platform's catalog from the dashboard's + // perspective. const existing: Doc<"products"> | null = await ctx.db .query("products") - .withIndex("by_project_and_product", (q) => - q.eq("projectId", args.projectId).eq("productId", args.productId), + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", args.projectId) + .eq("platform", args.platform) + .eq("productId", args.productId), ) .unique(); const now = Date.now(); if (existing) { await ctx.db.patch(existing._id, { - platform: args.platform, type: args.type, title: args.title || existing.title, description: args.description ?? existing.description, @@ -51,6 +78,14 @@ export const upsertFromStore = internalMutation({ currency: args.currency ?? existing.currency, storeRef: args.storeRef, state: args.state, + // Subscription metadata is sourced from the store on every + // pull, so we overwrite (not coalesce) — a sub that was + // moved between groups in ASC, or that lost a free trial in + // Play Console, should reflect that on the next sync rather + // than stick to whatever kit cached previously. + subscriptionGroupId: args.subscriptionGroupId, + subscriptionGroupName: args.subscriptionGroupName, + offers: args.offers, syncedAt: now, updatedAt: now, }); @@ -67,6 +102,9 @@ export const upsertFromStore = internalMutation({ currency: args.currency, storeRef: args.storeRef, state: args.state, + subscriptionGroupId: args.subscriptionGroupId, + subscriptionGroupName: args.subscriptionGroupName, + offers: args.offers, syncedAt: now, updatedAt: now, }); @@ -87,8 +125,11 @@ export const markPushed = internalMutation({ handler: async (ctx, args) => { const existing = await ctx.db .query("products") - .withIndex("by_project_and_product", (q) => - q.eq("projectId", args.projectId).eq("productId", args.productId), + .withIndex("by_project_and_platform_and_product", (q) => + q + .eq("projectId", args.projectId) + .eq("platform", args.platform) + .eq("productId", args.productId), ) .unique(); if (!existing) return null; @@ -102,8 +143,13 @@ export const markPushed = internalMutation({ }, }); -// Pull every Draft / Ready iOS row that hasn't been pushed yet. -// Used by the ASC push action. +// Pull every Draft iOS row that hasn't been pushed yet. Used by the +// ASC push action. Subscriptions used to require `storeRef` to be +// pre-populated with an ASC subscriptionGroup id (via dashboard / +// MCP) — that contract is gone now: kit resolves a group via the +// `subscriptionGroupName` operator-typed field at push time +// (find-or-create against ASC), so `storeRef === undefined` means +// "not yet pushed" for both subs and one-time IAPs uniformly. export const listDraftIosProducts = internalQuery({ args: { projectId: v.id("projects") }, returns: v.array( @@ -112,6 +158,9 @@ export const listDraftIosProducts = internalQuery({ platform: platformValidator, type: typeValidator, title: v.string(), + description: v.optional(v.string()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), billingPeriod: v.optional( v.union( v.literal("P1W"), @@ -122,6 +171,8 @@ export const listDraftIosProducts = internalQuery({ v.literal("P1Y"), ), ), + subscriptionGroupName: v.optional(v.string()), + reviewNote: v.optional(v.string()), storeRef: v.optional(v.string()), }), ), @@ -133,27 +184,18 @@ export const listDraftIosProducts = internalQuery({ ) .collect(); return all - .filter((row) => { - if (row.state !== "Draft") return false; - // For one-time IAPs, `storeRef` is the resulting App Store - // Connect resource id — empty means "not yet pushed", which - // is what we want to find. For Subscriptions, `storeRef` is - // the *input* subscriptionGroup id — the operator must set - // it (via dashboard / MCP) before push, and `pushSyncProductsApple` - // requires it to call `createSubscription({ groupId, ... })`. - // The previous filter blanket-rejected `storeRef !== undefined` - // and so silently hid every push-ready subscription. - if (row.type === "Subscription") { - return row.storeRef !== undefined && row.storeRef.length > 0; - } - return row.storeRef === undefined; - }) + .filter((row) => row.state === "Draft" && row.storeRef === undefined) .map((row) => ({ productId: row.productId, platform: row.platform, type: row.type, title: row.title, + description: row.description, + priceAmountMicros: row.priceAmountMicros, + currency: row.currency, billingPeriod: row.billingPeriod, + subscriptionGroupName: row.subscriptionGroupName, + reviewNote: row.reviewNote, storeRef: row.storeRef, })); }, diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts index 5e0b2486..3c1b34d5 100644 --- a/packages/kit/convex/schema.ts +++ b/packages/kit/convex/schema.ts @@ -654,11 +654,74 @@ const schema = defineSchema({ v.literal("P1Y"), ), ), + // Subscription Group (ASC concept; Play has no first-class + // equivalent so these stay null for Android rows). All + // subscriptions in the same group are mutually exclusive on + // Apple's side — the user can switch between Premium / Premium + // Year via in-app upgrade/downgrade, but cannot hold both. Kit + // surfaces this in the dashboard so the operator can see at a + // glance which subs share a group, and downstream paywalls can + // pick a default selection within a group. `subscriptionGroupId` + // is Apple's internal resource id; `subscriptionGroupName` is the + // human-readable referenceName the operator sees in ASC. + subscriptionGroupId: v.optional(v.string()), + subscriptionGroupName: v.optional(v.string()), + // Captures the *non-base* monetization variants attached to a + // subscription: Apple introductory offers (free trial / pay as + // you go / pay up front) and Play base plan offers (same kinds, + // different shape). Stored as a generic shape so both stores can + // upsert without branching, and the dashboard can render badges + // ("7-day free trial", "$4.99 intro for 3 months") without + // re-deriving from raw store responses. + offers: v.optional( + v.array( + v.object({ + // Identifier from the store: ASC offer id (eyJ...) for + // introductoryOffer / promotionalOffer, or Play's + // basePlanId+offerId composite for offers. + id: v.string(), + kind: v.union( + v.literal("FreeTrial"), + v.literal("IntroPayUpFront"), + v.literal("IntroPayAsYouGo"), + v.literal("PromotionalOffer"), + v.literal("BasePlan"), + ), + // ISO-8601 duration the offer covers (e.g. "P7D", "P3M"). + // For BasePlan rows this is the recurring billing period. + duration: v.optional(v.string()), + // Number of billing periods the discounted/free price + // applies for (Apple's `numberOfPeriods`). Free trials and + // pay-up-front intros use 1; pay-as-you-go uses N. + numberOfPeriods: v.optional(v.number()), + priceAmountMicros: v.optional(v.number()), + currency: v.optional(v.string()), + }), + ), + ), + // Free-form note for App Store review. Maps to ASC's `reviewNote` + // attribute on inAppPurchases / subscriptions and is the field + // Apple's reviewer reads alongside the screenshot to understand + // how to trigger / verify the IAP. Length cap is 4000 chars on + // ASC's side; we don't enforce here so the operator gets Apple's + // own validation message if they exceed it. + reviewNote: v.optional(v.string()), storeRef: v.optional(v.string()), syncedAt: v.optional(v.number()), updatedAt: v.number(), }) - .index("by_project_and_product", ["projectId", "productId"]) + // Lookup row by (projectId, platform, productId). Apps commonly + // ship the SAME productId on both iOS and Android (e.g. + // `dev.hyo.martie.premium` exists in both stores), so the + // (projectId, productId)-only index would have collisions and + // silently flip an existing row's platform on sync. Including + // platform in the natural key keeps each store's catalog row + // separate. + .index("by_project_and_platform_and_product", [ + "projectId", + "platform", + "productId", + ]) .index("by_project_and_platform", ["projectId", "platform"]), // Paywall configurations served by `/v1/paywalls/{id}` for in-app @@ -677,6 +740,50 @@ const schema = defineSchema({ subheadline: v.optional(v.string()), cta: v.string(), legalCopy: v.optional(v.string()), + // What-you-get bullet list rendered above the product cards. + // Operator types one per line in the dashboard. Kept on the + // paywall (not the product) because feature copy is usually a + // value-prop pitch tied to the paywall variant, not to a specific + // SKU — the same product can sit behind multiple paywalls with + // different feature framing for A/B tests. + features: v.optional(v.array(v.string())), + // Optional brand chrome. URLs are rendered as tags with no + // proxying — the operator hosts the asset wherever (CDN, public + // S3, App Store screenshot URL). `logoUrl` shows above the + // headline; `backgroundImageUrl` becomes a cover layer behind + // the gradient. + logoUrl: v.optional(v.string()), + backgroundImageUrl: v.optional(v.string()), + // Per-product hero images shown at the top of each plan card — + // the visual element RevenueCat / Apphud paywalls lean on. Kept + // on the paywall row (not the product) so the same product can + // carry different art per paywall variant (A/B test, seasonal + // creative, etc.). Sparse map: only the productIds the operator + // has explicitly attached an image to appear here. + productImages: v.optional( + v.array(v.object({ productId: v.string(), imageUrl: v.string() })), + ), + // Operator-authored CSS appended after the default stylesheet so + // any rule it carries wins by source order. Lets power users + // restyle anything (typography, layout, gradients) without us + // having to ship a knob per design choice. Body / script tags + // get stripped at render time as a basic guard against script + // injection — the WebView bridge contract stays kit-controlled. + customCss: v.optional(v.string()), + // Full HTML override. When set, the kit's default body markup is + // discarded entirely — the operator's HTML is rendered inside a + // minimal shell that injects two helpers: + // window.openiap.purchase(productId) // dispatches the bridge message + // window.openiap.products // [{productId, title, price, ...}] + // window.openiap.paywall // {headline, cta, theme, ...} + // So the operator can write any HTML/CSS/JS — React via UMD, Vue, + // vanilla — and only needs to call openiap.purchase() to trigger + // the SDK side. ` + + + ${raw(paywall.customHtml ?? "")} + + `; +} + +function renderPaywallHtml( + paywall: { + title: string; + productIds: string[]; + headline: string; + subheadline?: string; + cta: string; + legalCopy?: string; + features?: string[]; + logoUrl?: string; + backgroundImageUrl?: string; + productImages?: Array<{ productId: string; imageUrl: string }>; + customCss?: string; + customHtml?: string; + layout: "Single" | "Compare" | "Carousel"; + theme?: { + primaryColor?: string; + accentColor?: string; + backgroundColor?: string; + }; + }, + products: PaywallProduct[], +) { + // Operator-authored full-page mode. Skip the kit's default body + // markup; render only a minimal shell that exposes the bridge + // helper + product/paywall data on `window.openiap`. The operator's + // HTML can use any framework via UMD (React, Vue, vanilla) and only + // needs to call `openiap.purchase(productId)` to trigger the SDK. + if (paywall.customHtml && paywall.customHtml.trim()) { + return renderCustomHtmlPaywall(paywall, products); + } + const imageMap = new Map( + (paywall.productImages ?? []).map((p) => [p.productId, p.imageUrl]), + ); + // Strip , , and HTML tags from operator-supplied + // CSS so it can't break out of the + ${safeCss + ? html`` + : ""} -

${paywall.headline}

- ${paywall.subheadline - ? html`

${paywall.subheadline}

` - : ""} -
- ${ids.map( - (productId) => - html`
- ${productId} - Tap continue to purchase -
`, - )} +
+ ${paywall.logoUrl + ? html`` + : ""} +

${paywall.headline}

+ ${paywall.subheadline ? html`

${paywall.subheadline}

` : ""} + ${paywall.features && paywall.features.length > 0 + ? html`
    + ${paywall.features.map((feature) => html`
  • ${feature}
  • `)} +
` + : ""} +
+ +
+
+ ${visible.map((product, i) => { + const period = basePeriod(product); + const offerLabel = topOfferLabel(product); + const priceText = formatPrice( + product.priceAmountMicros, + product.currency, + ); + const isLast = i === visible.length - 1; + const showBest = visible.length > 1 && isLast; + const imageUrl = imageMap.get(product.productId); + return html``; + })} +
- - ${paywall.legalCopy - ? html`` - : ""} + +
+ + ${paywall.legalCopy + ? html`` + : ""} +
+ +
+ diff --git a/packages/kit/src/pages/auth/organization/project/index.tsx b/packages/kit/src/pages/auth/organization/project/index.tsx index 6d42ee51..80178132 100644 --- a/packages/kit/src/pages/auth/organization/project/index.tsx +++ b/packages/kit/src/pages/auth/organization/project/index.tsx @@ -70,16 +70,19 @@ export default function ProjectIndex() { id: "products", label: "Products", icon: Layers, + badge: "Beta", }, { id: "paywalls", label: "Paywalls", icon: CreditCard, + badge: "Beta", }, { id: "webhooks", label: "Webhooks", icon: Webhook, + badge: "Beta", }, { id: "apikeys", diff --git a/packages/kit/src/pages/auth/organization/project/paywalls.tsx b/packages/kit/src/pages/auth/organization/project/paywalls.tsx index 5333d083..4696143a 100644 --- a/packages/kit/src/pages/auth/organization/project/paywalls.tsx +++ b/packages/kit/src/pages/auth/organization/project/paywalls.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useOutletContext } from "react-router-dom"; import { useMutation, useQuery } from "convex/react"; import { @@ -16,56 +16,215 @@ import { Badge } from "../../../../components/Badge"; type ProjectContext = { project: Doc<"projects"> }; +type Layout = "Single" | "Compare" | "Carousel"; + +const DEFAULT_THEME = { + primary: "#a47465", + accent: "#dc6843", + background: "#18181b", +}; + export default function ProjectPaywalls() { const { project } = useOutletContext(); const paywalls = useQuery(api.paywalls.query.listPaywalls, { apiKey: project.apiKey, }); + const products = useQuery(api.products.query.listProducts, { + apiKey: project.apiKey, + }); const upsert = useMutation(api.paywalls.mutation.upsertPaywall); const remove = useMutation(api.paywalls.mutation.deletePaywall); - const [draft, setDraft] = useState({ + const [draft, setDraft] = useState<{ + slug: string; + title: string; + layout: Layout; + productIds: string[]; + headline: string; + subheadline: string; + cta: string; + legalCopy: string; + features: string; + logoUrl: string; + backgroundImageUrl: string; + productImages: { [productId: string]: string }; + customCss: string; + customHtml: string; + primaryColor: string; + accentColor: string; + backgroundColor: string; + }>({ slug: "", title: "", - layout: "Single" as "Single" | "Compare" | "Carousel", - productIds: "", + layout: "Single", + productIds: [], headline: "", + subheadline: "", cta: "Continue", + legalCopy: "", + features: "", + logoUrl: "", + backgroundImageUrl: "", + productImages: {}, + customCss: "", + customHtml: "", + primaryColor: DEFAULT_THEME.primary, + accentColor: DEFAULT_THEME.accent, + backgroundColor: DEFAULT_THEME.background, }); - if (paywalls === undefined) { + const productOptions = useMemo(() => { + if (!products) return []; + const seen = new Set(); + return products + .filter((p) => { + if (seen.has(p.productId)) return false; + seen.add(p.productId); + return true; + }) + .map((p) => ({ id: p.productId, title: p.title, type: p.type })); + }, [products]); + + // Build the request body that drives the live preview iframe AND + // the eventual save. Keeping one shape avoids drift between what + // the operator sees and what gets persisted. + const previewPayload = useMemo(() => { + const features = draft.features + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + const productImages = draft.productIds + .map((id) => ({ productId: id, imageUrl: draft.productImages[id] ?? "" })) + .filter((p) => p.imageUrl.trim().length > 0); + return { + title: draft.title || "Paywall preview", + layout: draft.layout, + productIds: draft.productIds, + headline: draft.headline || "Unlock the full experience", + subheadline: draft.subheadline.trim() || undefined, + cta: draft.cta || "Continue", + legalCopy: draft.legalCopy.trim() || undefined, + features: features.length ? features : undefined, + logoUrl: draft.logoUrl.trim() || undefined, + backgroundImageUrl: draft.backgroundImageUrl.trim() || undefined, + productImages: productImages.length ? productImages : undefined, + customCss: draft.customCss.trim() || undefined, + customHtml: draft.customHtml.trim() || undefined, + theme: { + primaryColor: draft.primaryColor || undefined, + accentColor: draft.accentColor || undefined, + backgroundColor: draft.backgroundColor || undefined, + }, + }; + }, [draft]); + + // Live preview HTML — refetched (debounced) whenever the form + // changes. We hold raw HTML in state and feed it to an iframe via + // `srcDoc` so the preview is fully sandboxed and doesn't share + // styles / globals with the dashboard shell. + const [previewHtml, setPreviewHtml] = useState(null); + const debounceRef = useRef(null); + useEffect(() => { + if (debounceRef.current) window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(() => { + const controller = new AbortController(); + void fetch(`/v1/paywalls/preview/${encodeURIComponent(project.apiKey)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(previewPayload), + signal: controller.signal, + }) + .then((r) => + r.ok ? r.text() : Promise.reject(new Error(String(r.status))), + ) + .then(setPreviewHtml) + .catch(() => { + /* ignore — preview is best-effort, last good HTML stays */ + }); + return () => controller.abort(); + }, 250); + return () => { + if (debounceRef.current) window.clearTimeout(debounceRef.current); + }; + }, [previewPayload, project.apiKey]); + + if (paywalls === undefined || products === undefined) { return ; } const baseUrl = window.location.origin; const onSubmit = async () => { - const productIds = draft.productIds - .split(",") - .map((s) => s.trim()) - .filter(Boolean); if ( !draft.slug || !draft.title || !draft.headline || - productIds.length === 0 + draft.productIds.length === 0 ) { return; } + const features = draft.features + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + const productImages = draft.productIds + .map((id) => ({ productId: id, imageUrl: draft.productImages[id] ?? "" })) + .filter((p) => p.imageUrl.trim().length > 0); await upsert({ apiKey: project.apiKey, slug: draft.slug, title: draft.title, layout: draft.layout, - productIds, + productIds: draft.productIds, headline: draft.headline, + subheadline: draft.subheadline.trim() || undefined, cta: draft.cta, + legalCopy: draft.legalCopy.trim() || undefined, + features: features.length ? features : undefined, + logoUrl: draft.logoUrl.trim() || undefined, + backgroundImageUrl: draft.backgroundImageUrl.trim() || undefined, + productImages: productImages.length ? productImages : undefined, + customCss: draft.customCss.trim() || undefined, + customHtml: draft.customHtml.trim() || undefined, + theme: { + primaryColor: draft.primaryColor || undefined, + accentColor: draft.accentColor || undefined, + backgroundColor: draft.backgroundColor || undefined, + }, }); setDraft({ ...draft, slug: "", title: "", - productIds: "", + productIds: [], headline: "", + subheadline: "", + legalCopy: "", + features: "", + logoUrl: "", + backgroundImageUrl: "", + productImages: {}, + customCss: "", + customHtml: "", + }); + }; + + const onLayoutChange = (next: Layout) => { + setDraft((prev) => ({ + ...prev, + layout: next, + productIds: + next === "Single" ? prev.productIds.slice(0, 1) : prev.productIds, + })); + }; + + const toggleProduct = (id: string) => { + setDraft((prev) => { + if (prev.layout === "Single") { + return { ...prev, productIds: prev.productIds[0] === id ? [] : [id] }; + } + return prev.productIds.includes(id) + ? { ...prev, productIds: prev.productIds.filter((x) => x !== id) } + : { ...prev, productIds: [...prev.productIds, id] }; }); }; @@ -76,93 +235,349 @@ export default function ProjectPaywalls() { Paywalls -

+

Hosted at{" "} - + /v1/paywalls/{`{apiKey}`}/{`{slug}`} - {" "} + {" "} — open the URL in any of the 5 SDK WebViews. The HTML emits a{" "} - {`{ openiap: 'purchase', productId }`}{" "} - message via the host's WebView bridge so the SDK can dispatch the - actual requestPurchase. + {`{ openiap: 'purchase', productId }`} message via the + host's WebView bridge so the SDK can dispatch the actual{" "} + requestPurchase.

-
- - setDraft({ ...draft, slug: e.target.value })} - placeholder="intro-2026" - className="w-full px-2 py-1.5 rounded border border-border bg-background" - /> - - - setDraft({ ...draft, title: e.target.value })} - placeholder="Premium intro" - className="w-full px-2 py-1.5 rounded border border-border bg-background" - /> - - -
- setDraft({ ...draft, slug: e.target.value })} + placeholder="intro-2026" + className={inputClass} + /> + + + + setDraft({ ...draft, title: e.target.value }) + } + placeholder="Premium intro" + className={inputClass} + /> + + +
+ + +
+
+
+ + +
+ {productOptions.length === 0 ? ( +
+ No products yet — add some in the Products tab first. +
+ ) : ( +
+ {productOptions.map((opt) => { + const checked = draft.productIds.includes(opt.id); + return ( + + ); + })} +
+ )} + {draft.productIds.length > 0 && ( +
+
+ Per-product card image (optional, 16:9 recommended) +
+ {draft.productIds.map((id) => ( +
+
+ {id} +
+ + setDraft({ + ...draft, + productImages: { + ...draft.productImages, + [id]: e.target.value, + }, + }) + } + placeholder="https://cdn.example.com/plan.jpg" + className={inputClass} + /> +
+ ))} +
+ )} +
+ +
+
+
+ + + setDraft({ ...draft, headline: e.target.value }) + } + placeholder="Unlock the full experience" + className={inputClass} + /> + +
+ + setDraft({ ...draft, cta: e.target.value })} + placeholder="Continue" + className={inputClass} + /> + +
+ + + setDraft({ ...draft, subheadline: e.target.value }) + } + placeholder="Cancel anytime · 7-day free trial" + className={inputClass} + /> + + +