@mpgd/adapter-capacitor 0.7.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,17 +64,128 @@ The source tests and installed-tarball consumer validate composition, types,
64
64
  error paths, and fallback behavior. They do not establish that any optional
65
65
  SDK works on a physical device or that a store/ad setup is release-ready.
66
66
 
67
+ ## Commerce v2 providers
68
+
69
+ Commerce v2 ([RFC](../../docs/specs/commerce/commerce-v2-rfc.md), PR B1) adds
70
+ a second commerce surface next to the v1 `commerce.*` bridge methods. A store
71
+ plugin exposes an in-process `StoreSdk`, the adapter turns it into a
72
+ `CommerceProvider` from `@mpgd/platform/commerce`, and the provider registry
73
+ serves both the six `commerce.v2.*` bridge methods and the four v1 methods
74
+ from it.
75
+
76
+ `StoreSdk` is shaped like the OpenIAP Client Protocol so a future Capacitor
77
+ IAP plugin over the OpenIAP cores is a drop-in: `fetchProducts({ skus, type })`,
78
+ `requestPurchase({ sku, offerId?, quantity?, accountBinding? })` answering
79
+ `launched`, `purchased`, `cancelled`, or `failed`, `getAvailablePurchases`
80
+ listing unfinished store purchases, optional `finishTransaction` and
81
+ `restorePurchases`, `addListener('purchaseUpdated' | 'purchaseError')`, an
82
+ optional `getAvailability`, and a static `capabilities` declaration (purchase
83
+ kinds, quantity, pending, restore, finish owner). The SDK speaks store SKUs
84
+ and its own error codes only. B2 adapts `@mpgd/capacitor-play-billing` and B3
85
+ adapts `@mpgd/capacitor-storekit` to this interface; B1 ships no plugin
86
+ change.
87
+
88
+ ```ts
89
+ import {
90
+ createCapacitorPlatformGateway,
91
+ createCommerceServiceProvider,
92
+ createStoreCommerceProvider,
93
+ type StoreSdk,
94
+ } from '@mpgd/adapter-capacitor';
95
+
96
+ export function createStoreGateway(sdk: StoreSdk) {
97
+ const commerce = createStoreCommerceProvider({
98
+ sdk,
99
+ products: [
100
+ { logicalId: 'gems_small', storeSku: 'gems.small', kind: 'consumable' },
101
+ { logicalId: 'remove_ads', storeSku: 'remove.ads', kind: 'durable' },
102
+ ],
103
+ evidenceSchema: 'google-play.product-purchase.v2',
104
+ evidencePayload: (purchase) => ({ purchaseToken: purchase.token }),
105
+ accountBinding: async () => ({ kind: 'obfuscated-account-id', value: 'player-1' }),
106
+ authority: { evidence: 'client-token', accountBinding: 'required' },
107
+ mapError: { PLAY_BILLING_OFFER_REQUIRED: 'CONFIGURATION_REQUIRED' },
108
+ });
109
+ return createCapacitorPlatformGateway({
110
+ target: 'android',
111
+ appVersion: '1.0.0',
112
+ buildId: 'game-build',
113
+ providers: [createCommerceServiceProvider({ id: 'play-billing', commerce })],
114
+ });
115
+ }
116
+ ```
117
+
118
+ `createStoreCommerceProvider` maps logical ids to store SKUs both ways (an
119
+ unknown SKU is still reported, with `logicalId` undefined; an unknown logical
120
+ id fails with `ITEM_UNAVAILABLE`), rejects unsupported kinds and quantities
121
+ with `assertRequestPurchaseSupported` before any store UI opens, refuses to
122
+ open the store while `getAvailability` is not `available`, builds every
123
+ `StorePurchase` with the store `token` and the `{ schema: evidenceSchema,
124
+ payload }` envelope, buffers SDK events until the first `subscribe` with
125
+ `createCommerceEventBuffer`, composes `CommerceCapabilities` from the SDK
126
+ declaration plus `capabilities` overrides and a fresh `availability` read
127
+ (three-second timeout, degraded to `temporarily-unavailable`), validates every
128
+ SDK answer with the platform typia asserts (`INVALID_RESPONSE` otherwise), and
129
+ normalizes every SDK failure through `mapError` into a `CommerceError` whose
130
+ `storeCode` keeps the store's own code. It never grants and never sets
131
+ `settlement`; the backend ledger decides.
132
+
133
+ Two failure classes are not terminal. When `requestPurchase` throws or
134
+ returns a code that normalizes to `OUTCOME_UNKNOWN` (for example
135
+ `PLAY_BILLING_TIMEOUT`, `PLAY_BILLING_INTERRUPTED`,
136
+ `STOREKIT_PURCHASE_UNCERTAIN`) or `ALREADY_OWNED`, an unfinished purchase may
137
+ exist, so the provider answers `{ outcome: 'launched' }` instead of `failed`
138
+ and does not forward a `purchaseError` event carrying those codes
139
+ (`recoverableErrorCodes`). The attempt stays open and the reconciler resolves
140
+ it from `getAvailablePurchases`; the store UI is never reopened blindly. A
141
+ `capabilities.lifecycle` override may hide `restore` or `finish`, but it
142
+ cannot advertise one the SDK lacks: `restore: true` without
143
+ `sdk.restorePurchases`, like `finish: 'client' | 'host'` without
144
+ `sdk.finishTransaction`, is rejected when the provider is created.
145
+
146
+ `createCommerceServiceProvider` wraps the provider as a `CapacitorServiceProvider`
147
+ whose `commerce` field the registry recognizes: the ten commerce bridge
148
+ methods are implied (declaring a v1 `commerce.*` method alongside is an
149
+ error), `nativeIap` and `subscriptionIap` readiness derive from
150
+ `commerce.getCapabilities()`, and the in-process bridge delivers events as
151
+ `{ protocol: 'mpgd.commerce.v2', sequence, event }` with a per-session
152
+ sequence starting at 0. v1 callers keep working: `gateway.commerce` is served
153
+ by `toCommerceAdapter(provider)`, so a `launched` purchase is `pending`, a
154
+ synchronous purchase is `completed` with evidence and no entitlements, and
155
+ `restore` returns no entitlements. The gateway exposes the v2 surface as
156
+ `gateway.commerceV2` (served through the same validated bridge path, which
157
+ the conformance test exercises for every store vector) and adds a `commerce`
158
+ block to `getCapabilities()` from which `nativeIap` and `subscriptionIap` are
159
+ derived. The block comes from the registry's one bounded read per snapshot
160
+ (`getCapabilitySnapshot`, three-second timeout, shape validated): a stalled,
161
+ failing, or invalid provider yields a degraded block
162
+ (`createDegradedCommerceCapabilities`: `availability:
163
+ 'temporarily-unavailable'`, nothing sold, `nativeIap: false`) and never
164
+ rejects the capability read. A `launched` v2 purchase keeps
165
+ `beginExternalActivity()` open after the request returns, since the store UI
166
+ is still up: the gateway releases it when the provider reports that product
167
+ (`purchaseUpdated` or `purchaseError`), after
168
+ `launchedPurchaseActivityTimeoutMs` (two minutes), or on `lifecycle.dispose`;
169
+ the v1 `commerce.purchase` path has no event to wait for and releases when
170
+ its request returns. Without a v2 provider nothing changes: `commerceV2` is
171
+ absent and a `commerce.v2.*` request still answers `COMMERCE_V2_UNAVAILABLE`
172
+ as the transport left it.
173
+
67
174
  ## App Store server-side transaction signatures
68
175
 
69
- For an iOS game backend, install the optional
70
- `@apple/app-store-server-library@3.1.0` peer and import
71
- `createAppleSignedTransactionVerifier` from
72
- `@mpgd/adapter-capacitor/app-store-server`. Supply DER-encoded Apple root
73
- certificates from the [Apple PKI](https://www.apple.com/certificateauthority/),
74
- the game's bundle ID, environment, and production App Apple ID. Use the
75
- result as `signedTransactionVerifier` in
76
- `createAppStoreGameServicesEvidenceVerifier`. Keep App Store API credentials,
77
- trust roots, and this verifier on the backend, not in the Capacitor game.
176
+ The server-side verifier moved to `@mpgd/game-services/app-store-server`
177
+ (see the [game-services README](../../packages/game-services/README.md)):
178
+ install the optional `@apple/app-store-server-library@3.1.0` peer next to
179
+ `@mpgd/game-services` and import `createAppleSignedTransactionVerifier` from
180
+ there. `@mpgd/adapter-capacitor/app-store-server` is deprecated: it re-exports
181
+ the same function and options type for one release and is removed in the next
182
+ major version. Supply DER-encoded Apple root certificates from the
183
+ [Apple PKI](https://www.apple.com/certificateauthority/), the game's bundle
184
+ ID, environment, and production App Apple ID. Use the result as
185
+ `signedTransactionVerifier` in `createAppStoreGameServicesEvidenceVerifier`.
186
+ Keep App Store API credentials, trust roots, and this verifier on the backend,
187
+ not in the Capacitor game; the adapter itself no longer peers on Apple's
188
+ library.
78
189
 
79
190
  The server fetches the transaction from Apple's API, verifies its JWS, checks
80
191
  the product, bundle, environment, account token and transaction ID, then
@@ -1,12 +1,12 @@
1
- import { type AppStoreEnvironment, type AppStoreSignedTransactionVerifier } from '@mpgd/game-services/app-store-verifier';
2
- export interface CreateAppleSignedTransactionVerifierOptions {
3
- /** DER-encoded Apple root certificates, supplied by the server operator. */
4
- readonly rootCertificates: readonly Uint8Array[];
5
- readonly environment: AppStoreEnvironment;
6
- readonly bundleId: string;
7
- /** Required by Apple's verifier in production. */
8
- readonly appAppleId?: number;
9
- readonly enableOnlineChecks?: boolean;
10
- }
11
- /** Verify the App Store JWS before exposing any decoded purchase fields. */
12
- export declare function createAppleSignedTransactionVerifier(options: CreateAppleSignedTransactionVerifierOptions): AppStoreSignedTransactionVerifier;
1
+ /**
2
+ * @deprecated Moved to `@mpgd/game-services/app-store-server` in A4 of the
3
+ * commerce v2 plan: server-side App Store verification does not belong in the
4
+ * client adapter. This subpath re-exports the same symbols for one release and
5
+ * will be removed in the next major version. Install
6
+ * `@apple/app-store-server-library` next to `@mpgd/game-services`.
7
+ */
8
+ import { createAppleSignedTransactionVerifier as createAppleSignedTransactionVerifierFromGameServices, type CreateAppleSignedTransactionVerifierOptions as GameServicesAppleVerifierOptions } from '@mpgd/game-services/app-store-server';
9
+ /** @deprecated Import `createAppleSignedTransactionVerifier` from `@mpgd/game-services/app-store-server`. */
10
+ export declare const createAppleSignedTransactionVerifier: typeof createAppleSignedTransactionVerifierFromGameServices;
11
+ /** @deprecated Import `CreateAppleSignedTransactionVerifierOptions` from `@mpgd/game-services/app-store-server`. */
12
+ export type CreateAppleSignedTransactionVerifierOptions = GameServicesAppleVerifierOptions;
@@ -1,119 +1,10 @@
1
- import { Environment, SignedDataVerifier, VerificationException, VerificationStatus, } from '@apple/app-store-server-library';
2
- import { AppStoreDependencyUnavailableError, } from '@mpgd/game-services/app-store-verifier';
3
- /** Verify the App Store JWS before exposing any decoded purchase fields. */
4
- export function createAppleSignedTransactionVerifier(options) {
5
- if (!Array.isArray(options.rootCertificates)
6
- || options.rootCertificates.length === 0
7
- || options.rootCertificates.some((certificate) => !(certificate instanceof Uint8Array)
8
- || certificate.length === 0)) {
9
- throw new TypeError('Apple root certificates are required.');
10
- }
11
- if (options.environment !== 'Production' && options.environment !== 'Sandbox') {
12
- throw new TypeError('App Store environment must be Production or Sandbox.');
13
- }
14
- if (options.bundleId.trim() === '') {
15
- throw new TypeError('App Store bundle ID is required.');
16
- }
17
- if (options.environment === 'Production'
18
- && (!Number.isSafeInteger(options.appAppleId) || (options.appAppleId ?? 0) <= 0)) {
19
- throw new TypeError('Production App Store verification requires appAppleId.');
20
- }
21
- if (options.environment === 'Production' && options.enableOnlineChecks === false) {
22
- throw new TypeError('Production App Store verification requires online certificate checks.');
23
- }
24
- const verifier = new SignedDataVerifier(options.rootCertificates.map((certificate) => Buffer.from(certificate)), options.enableOnlineChecks ?? true, options.environment === 'Production' ? Environment.PRODUCTION : Environment.SANDBOX, options.bundleId, options.appAppleId);
25
- return {
26
- async verifyAndDecode(input) {
27
- if (input.environment !== options.environment || input.bundleId !== options.bundleId) {
28
- return { status: 'rejected', reason: 'APP_STORE_VERIFIER_SCOPE_MISMATCH' };
29
- }
30
- if (typeof input.signedTransaction !== 'string'
31
- || input.signedTransaction.length === 0
32
- || input.signedTransaction.length > 512 * 1024) {
33
- return { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' };
34
- }
35
- input.signal.throwIfAborted();
36
- let decoded;
37
- try {
38
- decoded = await waitForVerification(verifier.verifyAndDecodeTransaction(input.signedTransaction), input.signal);
39
- input.signal.throwIfAborted();
40
- }
41
- catch (error) {
42
- if (input.signal.aborted) {
43
- throw input.signal.reason;
44
- }
45
- if (error instanceof VerificationException) {
46
- if (error.status === VerificationStatus.RETRYABLE_VERIFICATION_FAILURE) {
47
- throw new AppStoreDependencyUnavailableError('Apple signature checks are temporarily unavailable.', error);
48
- }
49
- return { status: 'rejected', reason: 'APP_STORE_SIGNATURE_INVALID' };
50
- }
51
- // Only Apple's explicit retryable status is a transient dependency
52
- // failure. Unexpected adapter/library faults must reach the caller.
53
- throw error;
54
- }
55
- const payload = decodeOneTimeTransaction(decoded);
56
- return payload === undefined
57
- ? { status: 'rejected', reason: 'APP_STORE_SIGNED_TRANSACTION_INVALID' }
58
- : { status: 'verified', payload };
59
- },
60
- };
61
- }
62
- async function waitForVerification(operation, signal) {
63
- signal.throwIfAborted();
64
- let onAbort;
65
- const aborted = new Promise((_resolve, reject) => {
66
- onAbort = () => reject(signal.reason ?? new Error('Verification was aborted.'));
67
- signal.addEventListener('abort', onAbort, { once: true });
68
- if (signal.aborted) {
69
- onAbort();
70
- }
71
- });
72
- try {
73
- return await Promise.race([operation, aborted]);
74
- }
75
- finally {
76
- if (onAbort !== undefined) {
77
- signal.removeEventListener('abort', onAbort);
78
- }
79
- }
80
- }
81
- function decodeOneTimeTransaction(decoded) {
82
- if (!nonEmpty(decoded.transactionId)
83
- || !nonEmpty(decoded.originalTransactionId)
84
- || !nonEmpty(decoded.bundleId)
85
- || !nonEmpty(decoded.productId)
86
- || !validTimestamp(decoded.purchaseDate)
87
- || !validTimestamp(decoded.signedDate)
88
- || (decoded.environment !== 'Production' && decoded.environment !== 'Sandbox')
89
- || (decoded.type !== 'Consumable' && decoded.type !== 'Non-Consumable')
90
- || (decoded.quantity !== undefined
91
- && (!Number.isSafeInteger(decoded.quantity) || decoded.quantity <= 0))
92
- || (decoded.appAccountToken !== undefined && !nonEmpty(decoded.appAccountToken))
93
- || (decoded.revocationDate !== undefined && !validTimestamp(decoded.revocationDate))
94
- || (decoded.expiresDate !== undefined && !validTimestamp(decoded.expiresDate))
95
- || (decoded.isUpgraded !== undefined && typeof decoded.isUpgraded !== 'boolean')) {
96
- return undefined;
97
- }
98
- return {
99
- transactionId: decoded.transactionId,
100
- originalTransactionId: decoded.originalTransactionId,
101
- bundleId: decoded.bundleId,
102
- productId: decoded.productId,
103
- purchaseDate: decoded.purchaseDate,
104
- signedDate: decoded.signedDate,
105
- environment: decoded.environment,
106
- type: decoded.type,
107
- ...(decoded.quantity === undefined ? {} : { quantity: decoded.quantity }),
108
- ...(decoded.appAccountToken === undefined ? {} : { appAccountToken: decoded.appAccountToken }),
109
- ...(decoded.revocationDate === undefined ? {} : { revocationDate: decoded.revocationDate }),
110
- ...(decoded.expiresDate === undefined ? {} : { expiresDate: decoded.expiresDate }),
111
- ...(decoded.isUpgraded === undefined ? {} : { isUpgraded: decoded.isUpgraded }),
112
- };
113
- }
114
- function nonEmpty(value) {
115
- return typeof value === 'string' && value.trim() !== '';
116
- }
117
- function validTimestamp(value) {
118
- return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
119
- }
1
+ /**
2
+ * @deprecated Moved to `@mpgd/game-services/app-store-server` in A4 of the
3
+ * commerce v2 plan: server-side App Store verification does not belong in the
4
+ * client adapter. This subpath re-exports the same symbols for one release and
5
+ * will be removed in the next major version. Install
6
+ * `@apple/app-store-server-library` next to `@mpgd/game-services`.
7
+ */
8
+ import { createAppleSignedTransactionVerifier as createAppleSignedTransactionVerifierFromGameServices, } from '@mpgd/game-services/app-store-server';
9
+ /** @deprecated Import `createAppleSignedTransactionVerifier` from `@mpgd/game-services/app-store-server`. */
10
+ export const createAppleSignedTransactionVerifier = createAppleSignedTransactionVerifierFromGameServices;
@@ -0,0 +1,59 @@
1
+ import { type BridgeCommerceV2Method, type BridgeEvent, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
+ import { type PlatformProviderFeature } from '@mpgd/platform';
3
+ import { type CommerceBridgeMethods, type CommerceProvider, type StoreId } from '@mpgd/platform/commerce';
4
+ import type { CapacitorCommerceServiceProvider } from './providers.js';
5
+ /** Bridge error for a commerce v2 payload the typia assert rejected. */
6
+ export declare const commerceV2InvalidPayloadBridgeErrorCode: 'COMMERCE_V2_INVALID_PAYLOAD';
7
+ /** Bridge error for a v2 method the provider does not implement (`finishTransaction`). */
8
+ export declare const commerceV2MethodUnsupportedBridgeErrorCode: 'COMMERCE_V2_METHOD_UNSUPPORTED';
9
+ export interface CommerceProviderBridge {
10
+ request(input: BridgeRequest): Promise<BridgeResponse>;
11
+ /** Delivers `{ protocol: 'mpgd.commerce.v2', sequence, event }`; `sequence` starts at 0 per bridge. */
12
+ subscribe(listener: (event: BridgeEvent) => void): () => void;
13
+ }
14
+ export interface CreateCommerceProviderBridgeInput {
15
+ readonly provider: CommerceProvider;
16
+ /** Serves the methods the provider does not own, for example a plugin's ad methods. */
17
+ readonly fallback?: {
18
+ request(input: BridgeRequest): Promise<BridgeResponse>;
19
+ };
20
+ }
21
+ /**
22
+ * In-process `NativeBridge` over a commerce v2 provider: the six
23
+ * `commerce.v2.*` methods validate their payload and result with the
24
+ * `@mpgd/platform/commerce` asserts, the four v1 `commerce.*` methods are
25
+ * served by `toCommerceAdapter(provider)`, and events carry the bridge envelope
26
+ * with a per-session sequence. A thrown failure crosses the bridge as
27
+ * `{ code, message, retryable }`; a `cancelled` or `failed` purchase result
28
+ * keeps its `storeCode` because it is data, not an error.
29
+ */
30
+ export declare function createCommerceProviderBridge(input: CreateCommerceProviderBridgeInput): CommerceProviderBridge;
31
+ export type CapacitorCommerceFeature = Extract<PlatformProviderFeature, 'nativeIap' | 'subscriptionIap'>;
32
+ export interface CreateCommerceServiceProviderInput {
33
+ readonly id: string;
34
+ readonly commerce: CommerceProvider;
35
+ /** Commerce features the provider serves. Defaults to `['nativeIap']`. */
36
+ readonly features?: readonly CapacitorCommerceFeature[];
37
+ }
38
+ /**
39
+ * Registry entry for a commerce v2 provider: the implied commerce bridge
40
+ * methods are served in process by `createCommerceProviderBridge`, and the
41
+ * registry derives `nativeIap` and `subscriptionIap` readiness from
42
+ * `commerce.getCapabilities()`.
43
+ */
44
+ export declare function createCommerceServiceProvider(input: CreateCommerceServiceProviderInput): CapacitorCommerceServiceProvider;
45
+ /** Validates a commerce v2 result; a provider answer the contract rejects never reaches the caller. */
46
+ export declare function assertCommerceBridgeResult<TMethod extends BridgeCommerceV2Method>(method: TMethod, data: unknown): CommerceBridgeMethods[TMethod]['result'];
47
+ export interface CreateBridgeCommerceProviderInput {
48
+ readonly store: StoreId;
49
+ /** Sends one validated bridge request; the gateway's `request` fits. */
50
+ readonly request: <TData>(method: BridgeCommerceV2Method, payload: unknown) => Promise<TData>;
51
+ readonly subscribe: (listener: (event: BridgeEvent) => void) => () => void;
52
+ }
53
+ /**
54
+ * `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
55
+ * the event envelope. Results are validated again on this side, so a transport
56
+ * that forgets to is still fail-closed; bridge errors become `CommerceError`s
57
+ * with the bridge code kept as `storeCode` when it is not already a v2 code.
58
+ */
59
+ export declare function createBridgeCommerceProvider(input: CreateBridgeCommerceProviderInput): CommerceProvider;
@@ -0,0 +1,265 @@
1
+ import { assertBridgeEvent, bridgeEventProtocol, createBridgeError, isBridgeCommerceV2Method, } from '@mpgd/bridge';
2
+ import { PlatformOperationError, } from '@mpgd/platform';
3
+ import { assertCommerceBridgeFetchProductsPayload, assertCommerceBridgeFetchProductsResult, assertCommerceBridgeFinishTransactionPayload, assertCommerceBridgeFinishTransactionResult, assertCommerceBridgeGetAvailablePurchasesPayload, assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesPayload, assertCommerceBridgeGetCapabilitiesResult, assertCommerceBridgeRequestPurchasePayload, assertCommerceBridgeRequestPurchaseResult, assertCommerceBridgeRestorePurchasesPayload, assertCommerceBridgeRestorePurchasesResult, CommerceError, commerceErrorInfoOf, commerceProtocol, commerceProtocolVersion, toCommerceAdapter, } from '@mpgd/platform/commerce';
4
+ import { normalizeStoreSdkError } from './store-sdk.js';
5
+ /** Bridge error for a commerce v2 payload the typia assert rejected. */
6
+ export const commerceV2InvalidPayloadBridgeErrorCode = 'COMMERCE_V2_INVALID_PAYLOAD';
7
+ /** Bridge error for a v2 method the provider does not implement (`finishTransaction`). */
8
+ export const commerceV2MethodUnsupportedBridgeErrorCode = 'COMMERCE_V2_METHOD_UNSUPPORTED';
9
+ const v1CommerceMethods = Object.freeze([
10
+ 'commerce.getProducts',
11
+ 'commerce.purchase',
12
+ 'commerce.restore',
13
+ 'commerce.getEntitlements',
14
+ ]);
15
+ /**
16
+ * In-process `NativeBridge` over a commerce v2 provider: the six
17
+ * `commerce.v2.*` methods validate their payload and result with the
18
+ * `@mpgd/platform/commerce` asserts, the four v1 `commerce.*` methods are
19
+ * served by `toCommerceAdapter(provider)`, and events carry the bridge envelope
20
+ * with a per-session sequence. A thrown failure crosses the bridge as
21
+ * `{ code, message, retryable }`; a `cancelled` or `failed` purchase result
22
+ * keeps its `storeCode` because it is data, not an error.
23
+ */
24
+ export function createCommerceProviderBridge(input) {
25
+ const { provider } = input;
26
+ const adapter = toCommerceAdapter(provider);
27
+ const listeners = new Set();
28
+ let sequence = 0;
29
+ let detach;
30
+ const deliver = (event) => {
31
+ const envelope = { protocol: commerceProtocol, sequence, event };
32
+ sequence += 1;
33
+ for (const listener of [...listeners]) {
34
+ listener(envelope);
35
+ }
36
+ };
37
+ const serve = async (request) => {
38
+ const { payload } = request;
39
+ switch (request.method) {
40
+ case 'commerce.v2.getCapabilities': {
41
+ assertCommerceBridgeGetCapabilitiesPayload(payload);
42
+ return assertResult('commerce.v2.getCapabilities', {
43
+ protocolVersion: commerceProtocolVersion,
44
+ store: provider.store,
45
+ capabilities: await provider.getCapabilities(),
46
+ });
47
+ }
48
+ case 'commerce.v2.fetchProducts': {
49
+ const products = await provider.fetchProducts(assertCommerceBridgeFetchProductsPayload(payload));
50
+ return assertResult('commerce.v2.fetchProducts', { products });
51
+ }
52
+ case 'commerce.v2.requestPurchase':
53
+ return assertResult('commerce.v2.requestPurchase', await provider.requestPurchase(assertCommerceBridgeRequestPurchasePayload(payload)));
54
+ case 'commerce.v2.getAvailablePurchases': {
55
+ const purchases = await provider.getAvailablePurchases(
56
+ // The payload is asserted as sent; a caller meaning the default sends `{}`.
57
+ assertCommerceBridgeGetAvailablePurchasesPayload(payload));
58
+ return assertResult('commerce.v2.getAvailablePurchases', { purchases });
59
+ }
60
+ case 'commerce.v2.finishTransaction': {
61
+ const finish = assertCommerceBridgeFinishTransactionPayload(payload);
62
+ if (provider.finishTransaction === undefined) {
63
+ throw new PlatformOperationError({
64
+ code: commerceV2MethodUnsupportedBridgeErrorCode,
65
+ message: `Store ${provider.store} does not finish transactions on the client.`,
66
+ });
67
+ }
68
+ return assertResult('commerce.v2.finishTransaction', await provider.finishTransaction(finish));
69
+ }
70
+ case 'commerce.v2.restorePurchases': {
71
+ assertCommerceBridgeRestorePurchasesPayload(payload);
72
+ if (provider.restorePurchases === undefined) {
73
+ return assertResult('commerce.v2.restorePurchases', { restored: false });
74
+ }
75
+ await provider.restorePurchases();
76
+ return assertResult('commerce.v2.restorePurchases', { restored: true });
77
+ }
78
+ case 'commerce.getProducts':
79
+ return adapter.getProducts();
80
+ case 'commerce.purchase':
81
+ return adapter.purchase(payload);
82
+ case 'commerce.restore':
83
+ if (adapter.restore === undefined) {
84
+ return { restoredEntitlements: [] };
85
+ }
86
+ return adapter.restore();
87
+ case 'commerce.getEntitlements':
88
+ return adapter.getEntitlements();
89
+ default:
90
+ throw new PlatformOperationError({
91
+ code: 'UNSUPPORTED_METHOD',
92
+ message: `${request.method} is not a commerce method.`,
93
+ });
94
+ }
95
+ };
96
+ return {
97
+ async request(request) {
98
+ const isCommerce = isBridgeCommerceV2Method(request.method)
99
+ || v1CommerceMethods.includes(request.method);
100
+ if (!isCommerce && input.fallback !== undefined) {
101
+ return input.fallback.request(request);
102
+ }
103
+ try {
104
+ return { id: request.id, ok: true, data: await serve(request) };
105
+ }
106
+ catch (error) {
107
+ return toBridgeError(request, error);
108
+ }
109
+ },
110
+ subscribe(listener) {
111
+ listeners.add(listener);
112
+ if (listeners.size === 1) {
113
+ detach = provider.subscribe(deliver);
114
+ }
115
+ return () => {
116
+ if (!listeners.delete(listener) || listeners.size > 0) {
117
+ return;
118
+ }
119
+ detach?.();
120
+ detach = undefined;
121
+ };
122
+ },
123
+ };
124
+ }
125
+ /**
126
+ * Registry entry for a commerce v2 provider: the implied commerce bridge
127
+ * methods are served in process by `createCommerceProviderBridge`, and the
128
+ * registry derives `nativeIap` and `subscriptionIap` readiness from
129
+ * `commerce.getCapabilities()`.
130
+ */
131
+ export function createCommerceServiceProvider(input) {
132
+ return {
133
+ id: input.id,
134
+ features: input.features ?? ['nativeIap'],
135
+ methods: [],
136
+ commerce: input.commerce,
137
+ bridge: createCommerceProviderBridge({ provider: input.commerce }),
138
+ async getAvailability() {
139
+ return {};
140
+ },
141
+ };
142
+ }
143
+ // Each entry is the typia assert of that method's result type; the lookup is
144
+ // typed by `assertCommerceBridgeResult`.
145
+ const resultAsserts = {
146
+ 'commerce.v2.getCapabilities': assertCommerceBridgeGetCapabilitiesResult,
147
+ 'commerce.v2.fetchProducts': assertCommerceBridgeFetchProductsResult,
148
+ 'commerce.v2.requestPurchase': assertCommerceBridgeRequestPurchaseResult,
149
+ 'commerce.v2.getAvailablePurchases': assertCommerceBridgeGetAvailablePurchasesResult,
150
+ 'commerce.v2.finishTransaction': assertCommerceBridgeFinishTransactionResult,
151
+ 'commerce.v2.restorePurchases': assertCommerceBridgeRestorePurchasesResult,
152
+ };
153
+ /** Validates a commerce v2 result; a provider answer the contract rejects never reaches the caller. */
154
+ export function assertCommerceBridgeResult(method, data) {
155
+ return resultAsserts[method](data);
156
+ }
157
+ function assertResult(method, data) {
158
+ try {
159
+ return assertCommerceBridgeResult(method, data);
160
+ }
161
+ catch (error) {
162
+ throw new PlatformOperationError({
163
+ code: 'NATIVE_PROVIDER_INVALID_RESPONSE',
164
+ message: `Commerce provider returned invalid data for ${method}: ${error instanceof Error ? error.message : String(error)}`,
165
+ });
166
+ }
167
+ }
168
+ function toBridgeError(request, error) {
169
+ if (isTypiaGuardError(error)) {
170
+ return createBridgeError(request.id, commerceV2InvalidPayloadBridgeErrorCode, `${request.method} payload is invalid: ${error.message}`);
171
+ }
172
+ if (error instanceof PlatformOperationError) {
173
+ return createBridgeError(request.id, error.code, error.message, error.retryable);
174
+ }
175
+ const info = commerceErrorInfoOf(error);
176
+ return createBridgeError(request.id, info.code, info.message ?? `${request.method} failed.`, info.retryable);
177
+ }
178
+ function isTypiaGuardError(error) {
179
+ return error instanceof Error && error.name === 'TypeGuardError';
180
+ }
181
+ /**
182
+ * `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
183
+ * the event envelope. Results are validated again on this side, so a transport
184
+ * that forgets to is still fail-closed; bridge errors become `CommerceError`s
185
+ * with the bridge code kept as `storeCode` when it is not already a v2 code.
186
+ */
187
+ export function createBridgeCommerceProvider(input) {
188
+ const send = async (method, payload) => {
189
+ let data;
190
+ try {
191
+ data = await input.request(method, payload);
192
+ }
193
+ catch (error) {
194
+ throw toCommerceError(error);
195
+ }
196
+ try {
197
+ return assertCommerceBridgeResult(method, data);
198
+ }
199
+ catch (error) {
200
+ throw new CommerceError('INVALID_RESPONSE', {
201
+ message: `Bridge returned invalid data for ${method}.`,
202
+ cause: error,
203
+ });
204
+ }
205
+ };
206
+ return {
207
+ store: input.store,
208
+ async getCapabilities() {
209
+ return (await send('commerce.v2.getCapabilities', { protocol: commerceProtocol }))
210
+ .capabilities;
211
+ },
212
+ async fetchProducts(request) {
213
+ return (await send('commerce.v2.fetchProducts', request)).products;
214
+ },
215
+ requestPurchase(request) {
216
+ return send('commerce.v2.requestPurchase', request);
217
+ },
218
+ async getAvailablePurchases(request = {}) {
219
+ return (await send('commerce.v2.getAvailablePurchases', request)).purchases;
220
+ },
221
+ finishTransaction(request) {
222
+ return send('commerce.v2.finishTransaction', request);
223
+ },
224
+ async restorePurchases() {
225
+ await send('commerce.v2.restorePurchases', { protocol: commerceProtocol });
226
+ },
227
+ subscribe(listener) {
228
+ return input.subscribe((envelope) => {
229
+ // Another protocol on a shared event channel is not ours to judge.
230
+ if (typeof envelope === 'object' && envelope !== null
231
+ && envelope.protocol !== bridgeEventProtocol) {
232
+ return;
233
+ }
234
+ let event;
235
+ try {
236
+ event = assertBridgeEvent(envelope);
237
+ }
238
+ catch (error) {
239
+ listener({
240
+ type: 'purchaseError',
241
+ error: new CommerceError('INVALID_RESPONSE', {
242
+ message: 'Bridge delivered an invalid commerce event.',
243
+ cause: error,
244
+ }).toCommerceErrorInfo(),
245
+ });
246
+ return;
247
+ }
248
+ listener(event.event);
249
+ });
250
+ },
251
+ };
252
+ }
253
+ function toCommerceError(error) {
254
+ if (error instanceof CommerceError) {
255
+ return error;
256
+ }
257
+ if (error instanceof PlatformOperationError) {
258
+ return normalizeStoreSdkError({
259
+ code: error.code,
260
+ message: error.message,
261
+ retryable: error.retryable,
262
+ });
263
+ }
264
+ return normalizeStoreSdkError(error);
265
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import { type PlatformGateway, type PlatformTarget, type ViewportAdapter } from '@mpgd/platform';
2
2
  import { type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource } from './app-events.js';
3
3
  import { type CapacitorServiceProvider, type NativeBridge } from './providers.js';
4
- export { createCapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
4
+ export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, deriveCommerceFeatureStates, type CapacitorCommerceServiceProvider, type CapacitorProviderCapabilitySnapshot, type CapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
5
+ export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, type CapacitorCommerceFeature, type CommerceProviderBridge, type CreateBridgeCommerceProviderInput, type CreateCommerceProviderBridgeInput, type CreateCommerceServiceProviderInput, } from './commerce-bridge.js';
6
+ export { createStoreCommerceProvider, recoverableErrorCodes, type CreateStoreCommerceProviderInput, type StoreCommerceCapabilityOverrides, type StoreCommerceProductMapping, type StoreCommerceProvider, } from './store-commerce-provider.js';
7
+ export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, type StoreSdk, type StoreSdkCapabilities, type StoreSdkError, type StoreSdkErrorCodeMap, type StoreSdkEvent, type StoreSdkEventName, type StoreSdkEventOf, type StoreSdkFetchProductsInput, type StoreSdkFinishTransactionInput, type StoreSdkFinishTransactionResult, type StoreSdkGetAvailablePurchasesInput, type StoreSdkProduct, type StoreSdkPurchase, type StoreSdkRequestPurchaseInput, type StoreSdkRequestPurchaseResult, } from './store-sdk.js';
5
8
  export { createCapacitorAppEvents, type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource, } from './app-events.js';
6
9
  export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, type CapacitorJsonRequest, type CapacitorJsonResponse, type CapacitorNativeHttpErrorCode, type CapacitorNativeJsonTransport, type CreateCapacitorNativeJsonTransportInput, } from './native-http.js';
7
10
  export { createCapacitorViewport, type CapacitorViewportBaseState, type CapacitorOccupiedSurfaceInput, type CapacitorViewportController, type CapacitorViewportHost, } from './viewport.js';
11
+ /**
12
+ * Upper bound on how long a `launched` commerce v2 purchase keeps the game
13
+ * paused when no purchase event for the product arrives (for example a store
14
+ * sheet dismissed without a callback).
15
+ */
16
+ export declare const launchedPurchaseActivityTimeoutMs = 120000;
8
17
  export declare function createCapacitorPlatformGateway(input: {
9
18
  readonly target: Extract<PlatformTarget, 'android' | 'ios'>;
10
19
  readonly appVersion: string;