@mpgd/adapter-capacitor 0.9.2 → 0.10.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
@@ -31,14 +31,17 @@ export function createGameGateway(providers: readonly CapacitorServiceProvider[]
31
31
  }
32
32
  ```
33
33
 
34
- `getCapabilities()` reports a fresh boolean snapshot and optional
35
- `providerAvailability` detail. The possible states are `unsupported`,
36
- `configuration-required`, `action-required`, `temporarily-unavailable`, and
37
- `available`. A target's `features` configuration is an upper bound, **not**
38
- proof that a provider is installed or ready. `nativeIap` describes one-time
39
- purchases; `subscriptionIap` is separate. Rewarded, interstitial, and banner
40
- ads and native versus remote leaderboards remain distinct. Target-config
41
- applies the configured upper bound to this live provider state.
34
+ `getCapabilities()` reports a fresh boolean snapshot, optional
35
+ `providerAvailability` detail, and the `commerce` capability block. The
36
+ possible states are `unsupported`, `configuration-required`, `action-required`,
37
+ `temporarily-unavailable`, and `available`. A target's `features` configuration
38
+ is an upper bound, **not** proof that a provider is installed or ready.
39
+ Commerce is not a provider feature: the `commerce` block (see below) is the
40
+ only commerce source, and the retired `nativeIap` and `subscriptionIap` flags
41
+ are never reported, even when a native shell built before commerce v2 still
42
+ sends them. Rewarded, interstitial, and banner ads and native versus remote
43
+ leaderboards remain distinct. Target-config applies the configured upper
44
+ bound to this live provider state.
42
45
 
43
46
  When native and remote leaderboard routes coexist, the target-config wrapper
44
47
  selects a `route` on score/open calls; direct adapter callers can request
@@ -64,14 +67,14 @@ The source tests and installed-tarball consumer validate composition, types,
64
67
  error paths, and fallback behavior. They do not establish that any optional
65
68
  SDK works on a physical device or that a store/ad setup is release-ready.
66
69
 
67
- ## Commerce v2 providers
70
+ ## Commerce providers
68
71
 
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.
72
+ Commerce ([RFC](../../docs/specs/commerce/commerce-v2-rfc.md)) has one
73
+ surface: the `CommerceProvider` from `@mpgd/platform/commerce`, exposed as
74
+ `gateway.commerce` and served over the six `commerce.v2.*` bridge methods. A
75
+ store plugin exposes an in-process `StoreSdk`, the adapter turns it into a
76
+ `CommerceProvider`, and the provider registry serves it. The four v1
77
+ `commerce.*` bridge methods and the v1 gateway object are removed.
75
78
 
76
79
  `StoreSdk` is shaped like the OpenIAP Client Protocol so a future Capacitor
77
80
  IAP plugin over the OpenIAP cores is a drop-in: `fetchProducts({ skus, type })`,
@@ -143,43 +146,72 @@ cannot advertise one the SDK lacks: `restore: true` without
143
146
  `sdk.restorePurchases`, like `finish: 'client' | 'host'` without
144
147
  `sdk.finishTransaction`, is rejected when the provider is created.
145
148
 
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.
149
+ `createCommerceServiceProvider({ id, commerce })` wraps the provider as a
150
+ `CapacitorServiceProvider` whose `commerce` field the registry recognizes. It
151
+ declares no `features` and no `methods`: the six `commerce.v2.*` bridge methods
152
+ are implied (declaring one, or a retired v1 `commerce.*` method, is an error),
153
+ the bridge must implement `subscribe`, and the in-process bridge delivers
154
+ events as `{ protocol: 'mpgd.commerce.v2', sequence, event }` with a
155
+ per-session sequence starting at 0. `CapacitorServiceProvider.features` lists
156
+ non-commerce features only (`PlatformProviderFeature` has no IAP names), and a
157
+ second commerce provider is a registration error.
158
+
159
+ The gateway exposes the provider as `gateway.commerce` (served through the same
160
+ validated bridge path, which the conformance test exercises for every store
161
+ vector) and omits the property when no provider is registered. The
162
+ `commerce` block of `getCapabilities()` is always present and is a fresh copy
163
+ on every read: the provider's validated capability block, or
164
+ `createUnsupportedCommerceCapabilities()` when no commerce provider is
165
+ registered. A native `runtime.getCapabilities` answer is only the base: its
166
+ `nativeIap` and `subscriptionIap` keys are stripped and its `commerce` block,
167
+ whatever it says, is replaced. The block comes from the registry's one bounded
168
+ read per snapshot (`getCapabilitySnapshot` returns `{ availability, commerce }`,
169
+ three-second timeout, shape validated): a stalled, failing, or invalid
170
+ provider yields a degraded block (`createDegradedCommerceCapabilities`:
171
+ `availability: 'temporarily-unavailable'`, nothing sold) and never rejects the
172
+ capability read. A `launched` purchase keeps `beginExternalActivity()` open
173
+ after the request returns, since the store UI is still up: the gateway releases
174
+ it when the provider reports that product (`purchaseUpdated` or
175
+ `purchaseError`), after `launchedPurchaseActivityTimeoutMs` (two minutes), or
176
+ on `lifecycle.dispose`. The registry does not gate a commerce method by a
177
+ snapshot: the provider reads its own `availability` and fails closed before any
178
+ store UI opens. A `commerce.v2.*` request that reaches a transport without a
179
+ provider still answers `COMMERCE_V2_UNAVAILABLE`.
180
+
181
+ Games do not call the provider from a scene: `createGameServicesClient` from
182
+ `@mpgd/game-services` purchases through `createCommerceReconciler` over
183
+ `gateway.commerce`, and nothing is granted from a client callback.
184
+
185
+ ## Notifications v2
186
+
187
+ The notifications v2 provider arrives in PR P2 of the
188
+ [notifications RFC](../../docs/specs/notifications/notifications-v2-rfc.md),
189
+ together with the registry surface through which a provider serves the
190
+ `notifications.v2.*` methods. Until then the gateway exposes no
191
+ `notificationsV2`, and its request path never forwards a `notifications.v2.*`
192
+ method to the base plugin: it answers `NOTIFICATIONS_V2_UNAVAILABLE`, which is
193
+ not retryable, before it pauses the game. A provider cannot declare those
194
+ methods in `methods`.
195
+
196
+ Once P2 lets a provider serve `notifications.v2.*` methods, these rules apply:
197
+
198
+ - `notifications.v2.requestPermission` and `notifications.v2.openSettings`
199
+ pause the game while they run, like `notifications.requestSubscription` and
200
+ `commerce.v2.requestPurchase`.
201
+ - The provider registry passes `notifications.v2.*` through `assertMethodReady`
202
+ without a readiness check, as it does `commerce.v2.*`, so the provider's own
203
+ `unsupported` or `action-required` answers reach the game as answers.
204
+ - The gateway validates each answer before the game sees it. Until P2 adds the
205
+ notifications v2 answers to that check, it rejects every one of them.
173
206
 
174
207
  ## App Store server-side transaction signatures
175
208
 
176
- The server-side verifier moved to `@mpgd/game-services/app-store-server`
209
+ The server-side verifier lives in `@mpgd/game-services/app-store-server`
177
210
  (see the [game-services README](../../packages/game-services/README.md)):
178
211
  install the optional `@apple/app-store-server-library@3.1.0` peer next to
179
212
  `@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
213
+ there. The former `@mpgd/adapter-capacitor/app-store-server` subpath, a
214
+ deprecated re-export, was removed. Supply DER-encoded Apple root certificates from the
183
215
  [Apple PKI](https://www.apple.com/certificateauthority/), the game's bundle
184
216
  ID, environment, and production App Apple ID. Use the result as
185
217
  `signedTransactionVerifier` in `createAppStoreGameServicesEvidenceVerifier`.
@@ -194,6 +226,21 @@ evidence; it never grants a product. Retryable certificate-status failures
194
226
  remain pending, while invalid signatures are rejected. Mock verification does
195
227
  not prove a real App Store purchase or device delivery.
196
228
 
229
+ ### Migrating from v1
230
+
231
+ Commerce v2 PR C4 removed the v1 surface in one major release.
232
+
233
+ | Removed | Replacement |
234
+ | --- | --- |
235
+ | `gateway.commerce.getProducts / purchase / restore / getEntitlements` (v1 `CommerceAdapter`) | `gateway.commerce` is now the `CommerceProvider` (`fetchProducts`, `requestPurchase`, `getAvailablePurchases`, `finishTransaction?`, `restorePurchases?`, `subscribe`); games purchase through `@mpgd/game-services` |
236
+ | `gateway.commerceV2` | `gateway.commerce` |
237
+ | `commerce.getProducts`, `commerce.purchase`, `commerce.restore`, `commerce.getEntitlements` bridge methods and `toCommerceAdapter` | the six `commerce.v2.*` methods |
238
+ | `nativeIap`, `subscriptionIap` in `getCapabilities()` and `providerAvailability` | `capabilities.commerce` (`purchase.consumable`, `purchase.durable`, `purchase.subscription`, `availability`) |
239
+ | `'nativeIap'` / `'subscriptionIap'` in `CapacitorServiceProvider.features`; the `features` input of `createCommerceServiceProvider`; `CapacitorCommerceFeature`; `deriveCommerceFeatureStates` | omit `features`: a provider with `commerce` declares none, and its readiness is its capability block |
240
+ | the `productType` parameter of `assertMethodReady`; `NATIVE_PROVIDER_PRODUCT_UNKNOWN`, `NATIVE_PROVIDER_PRODUCT_TYPE_REQUIRED` | none: product routing belongs to the commerce provider |
241
+ | `CapacitorProviderCapabilitySnapshot.commerce?` (optional) | always present: the provider block, the degraded block, or the unsupported block |
242
+ | `@mpgd/adapter-capacitor/app-store-server` | `@mpgd/game-services/app-store-server` |
243
+
197
244
  ## Opt-in AdMob rewarded ads
198
245
 
199
246
  Install `@capacitor-community/admob@8.1.0` in the game-owned Capacitor project,
@@ -316,6 +363,19 @@ app. Cold URLs are read through `getInitialGameUrl` or
316
363
  callback. Call `lifecycle.dispose()` when the host tears down this gateway;
317
364
  the adapter removes only its own listener handles, never every App listener.
318
365
 
366
+ A game link is untrusted input. The adapter checks the link's path, query, and
367
+ fragment with `normalizeRootRelativeRoute` from `@mpgd/platform/routes` and
368
+ discards a link that rule refuses, such as `mygame://game//evil.example/x` or
369
+ `https://game.example/%2F%2Fevil.example/x`, because a client could read its
370
+ path as another host. A delivered event carries the checked path as `route`:
371
+ `/daily` for `mygame://game/daily`, as in the example above, where the host
372
+ only names the link kind. The host is never part of `route`, so
373
+ `mygame://level/3` gives `/3`. A router that routes by path opens
374
+ `event.route` rather than parsing `event.url`. A link with no path to check,
375
+ such as `mygame://daily`, arrives without `route`. A router that builds its
376
+ route from anything else in the URL, such as the host, checks that route with
377
+ `normalizeRootRelativeRoute` and opens only what it returns.
378
+
319
379
  Save progress at checkpoints and transaction boundaries. A pause callback can
320
380
  request a best-effort save, but neither pause nor back is guaranteed to run
321
381
  before the operating system terminates a process. The lifecycle tests use an
@@ -11,7 +11,11 @@ export interface CreateCapacitorAppEventsInput {
11
11
  readonly target: 'android' | 'ios';
12
12
  readonly app?: CapacitorAppEventsApi;
13
13
  readonly visibility?: CapacitorVisibilitySource | null;
14
- /** Return null for URLs that this game does not own. Never default OAuth to game. */
14
+ /**
15
+ * Return null for URLs that this game does not own. Never default OAuth to
16
+ * game. A game link is then delivered only when its path passes the shared
17
+ * route rule (see `NativeOpenUrlEvent.route`).
18
+ */
15
19
  readonly classifyIncomingUrl?: (url: string) => CapacitorIncomingUrlKind | null;
16
20
  readonly historyBack?: () => void;
17
21
  readonly onError?: (error: unknown) => void;
@@ -1,10 +1,41 @@
1
1
  import { App } from '@capacitor/app';
2
+ import { hasRouteControlCharacter, normalizeRootRelativeRoute } from '@mpgd/platform/routes';
2
3
  const maxWarmUrlsBeforeInitial = 32;
3
4
  // Covers a near-simultaneous duplicate from getLaunchUrl and appUrlOpen, not a later tap.
4
5
  const coldUrlDedupeWindowMs = 2_000;
5
6
  function defaultVisibility() {
6
7
  return typeof document === 'undefined' ? null : document;
7
8
  }
9
+ /**
10
+ * Reads a game link the way `NativeOpenUrlEvent.route` describes: its path,
11
+ * query, and fragment as a normalized route (the host is not part of it),
12
+ * `undefined` when the URL has no path to check (`mygame:daily`,
13
+ * `mygame://daily`), or `null` when the link is not delivered: it does not
14
+ * parse, carries a control character, or has a path `normalizeRootRelativeRoute`
15
+ * refuses because a client could read it as another host.
16
+ */
17
+ function readGameLinkRoute(url) {
18
+ if (hasRouteControlCharacter(url)) {
19
+ return null;
20
+ }
21
+ let parsed;
22
+ try {
23
+ parsed = new URL(url);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ const pathname = parsed.pathname === '' && (parsed.search !== '' || parsed.hash !== '')
29
+ ? '/'
30
+ : parsed.pathname;
31
+ if (!pathname.startsWith('/')) {
32
+ return undefined;
33
+ }
34
+ return normalizeRootRelativeRoute(`${pathname}${parsed.search}${parsed.hash}`) ?? null;
35
+ }
36
+ function toOpenUrlEvent(url, source, route) {
37
+ return route === undefined ? { url, source } : { url, source, route };
38
+ }
8
39
  /**
9
40
  * Owns only handles it registers. Disposing during an awaited addListener
10
41
  * removes the eventual handle without touching another gateway's listeners.
@@ -98,6 +129,12 @@ export function createCapacitorAppEvents(input) {
98
129
  if (kind === null) {
99
130
  return;
100
131
  }
132
+ const route = kind === 'game' ? readGameLinkRoute(url) : undefined;
133
+ if (route === null) {
134
+ // A game link whose path could name another host is discarded like an
135
+ // unknown URL: no router may open it.
136
+ return;
137
+ }
101
138
  const callbacks = kind === 'game' ? gameUrlCallbacks : oauthCallbacks;
102
139
  if (!initialResolved && callbacks.size > 0) {
103
140
  // Keep enough recent entries to suppress a cold/warm duplicate without
@@ -110,7 +147,7 @@ export function createCapacitorAppEvents(input) {
110
147
  }
111
148
  warmUrlsBeforeInitial.add(url);
112
149
  }
113
- const event = { url, source: 'warm' };
150
+ const event = toOpenUrlEvent(url, 'warm', route);
114
151
  for (const callback of [...callbacks]) {
115
152
  try {
116
153
  callback(event);
@@ -239,9 +276,13 @@ export function createCapacitorAppEvents(input) {
239
276
  if (kind === null) {
240
277
  return null;
241
278
  }
279
+ const route = kind === 'game' ? readGameLinkRoute(url) : undefined;
280
+ if (route === null) {
281
+ return null;
282
+ }
242
283
  recentColdUrl = url;
243
284
  recentColdUntil = Date.now() + coldUrlDedupeWindowMs;
244
- return { kind, event: { url, source: 'cold' } };
285
+ return { kind, event: toOpenUrlEvent(url, 'cold', route) };
245
286
  }, (error) => {
246
287
  initialResolved = true;
247
288
  warmUrlsBeforeInitial.clear();
@@ -1,5 +1,4 @@
1
- import { type BridgeCommerceV2Method, type BridgeEvent, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
- import { type PlatformProviderFeature } from '@mpgd/platform';
1
+ import { type BridgeCommerceEvent, type BridgeCommerceV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
3
2
  import { type CommerceBridgeMethods, type CommerceProvider, type StoreId } from '@mpgd/platform/commerce';
4
3
  import type { CapacitorCommerceServiceProvider } from './providers.js';
5
4
  /** Bridge error for a commerce v2 payload the typia assert rejected. */
@@ -9,7 +8,7 @@ export declare const commerceV2MethodUnsupportedBridgeErrorCode: 'COMMERCE_V2_ME
9
8
  export interface CommerceProviderBridge {
10
9
  request(input: BridgeRequest): Promise<BridgeResponse>;
11
10
  /** Delivers `{ protocol: 'mpgd.commerce.v2', sequence, event }`; `sequence` starts at 0 per bridge. */
12
- subscribe(listener: (event: BridgeEvent) => void): () => void;
11
+ subscribe(listener: (event: BridgeCommerceEvent) => void): () => void;
13
12
  }
14
13
  export interface CreateCommerceProviderBridgeInput {
15
14
  readonly provider: CommerceProvider;
@@ -21,25 +20,22 @@ export interface CreateCommerceProviderBridgeInput {
21
20
  /**
22
21
  * In-process `NativeBridge` over a commerce v2 provider: the six
23
22
  * `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
23
+ * `@mpgd/platform/commerce` asserts, and events carry the bridge envelope
24
+ * with a per-session sequence. Any other method goes to `fallback`, or answers
25
+ * `UNSUPPORTED_METHOD` without one. A thrown failure crosses the bridge as
27
26
  * `{ code, message, retryable }`; a `cancelled` or `failed` purchase result
28
27
  * keeps its `storeCode` because it is data, not an error.
29
28
  */
30
29
  export declare function createCommerceProviderBridge(input: CreateCommerceProviderBridgeInput): CommerceProviderBridge;
31
- export type CapacitorCommerceFeature = Extract<PlatformProviderFeature, 'nativeIap' | 'subscriptionIap'>;
32
30
  export interface CreateCommerceServiceProviderInput {
33
31
  readonly id: string;
34
32
  readonly commerce: CommerceProvider;
35
- /** Commerce features the provider serves. Defaults to `['nativeIap']`. */
36
- readonly features?: readonly CapacitorCommerceFeature[];
37
33
  }
38
34
  /**
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()`.
35
+ * Registry entry for a commerce provider: it declares no feature and no
36
+ * method, the six `commerce.v2.*` methods are implied and served in process by
37
+ * `createCommerceProviderBridge`, and the gateway's `commerce` capability block
38
+ * is the provider's own `getCapabilities()` answer.
43
39
  */
44
40
  export declare function createCommerceServiceProvider(input: CreateCommerceServiceProviderInput): CapacitorCommerceServiceProvider;
45
41
  /** Validates a commerce v2 result; a provider answer the contract rejects never reaches the caller. */
@@ -48,7 +44,7 @@ export interface CreateBridgeCommerceProviderInput {
48
44
  readonly store: StoreId;
49
45
  /** Sends one validated bridge request; the gateway's `request` fits. */
50
46
  readonly request: <TData>(method: BridgeCommerceV2Method, payload: unknown) => Promise<TData>;
51
- readonly subscribe: (listener: (event: BridgeEvent) => void) => () => void;
47
+ readonly subscribe: (listener: (event: BridgeCommerceEvent) => void) => () => void;
52
48
  }
53
49
  /**
54
50
  * `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
@@ -1,29 +1,22 @@
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';
1
+ import { bridgeCommerceEventProtocol, 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, assertCommerceEventEnvelope, CommerceError, commerceErrorInfoOf, commerceProtocol, commerceProtocolVersion, } from '@mpgd/platform/commerce';
4
4
  import { normalizeStoreSdkError } from './store-sdk.js';
5
5
  /** Bridge error for a commerce v2 payload the typia assert rejected. */
6
6
  export const commerceV2InvalidPayloadBridgeErrorCode = 'COMMERCE_V2_INVALID_PAYLOAD';
7
7
  /** Bridge error for a v2 method the provider does not implement (`finishTransaction`). */
8
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
9
  /**
16
10
  * In-process `NativeBridge` over a commerce v2 provider: the six
17
11
  * `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
12
+ * `@mpgd/platform/commerce` asserts, and events carry the bridge envelope
13
+ * with a per-session sequence. Any other method goes to `fallback`, or answers
14
+ * `UNSUPPORTED_METHOD` without one. A thrown failure crosses the bridge as
21
15
  * `{ code, message, retryable }`; a `cancelled` or `failed` purchase result
22
16
  * keeps its `storeCode` because it is data, not an error.
23
17
  */
24
18
  export function createCommerceProviderBridge(input) {
25
19
  const { provider } = input;
26
- const adapter = toCommerceAdapter(provider);
27
20
  const listeners = new Set();
28
21
  let sequence = 0;
29
22
  let detach;
@@ -75,17 +68,6 @@ export function createCommerceProviderBridge(input) {
75
68
  await provider.restorePurchases();
76
69
  return assertResult('commerce.v2.restorePurchases', { restored: true });
77
70
  }
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
71
  default:
90
72
  throw new PlatformOperationError({
91
73
  code: 'UNSUPPORTED_METHOD',
@@ -95,9 +77,7 @@ export function createCommerceProviderBridge(input) {
95
77
  };
96
78
  return {
97
79
  async request(request) {
98
- const isCommerce = isBridgeCommerceV2Method(request.method)
99
- || v1CommerceMethods.includes(request.method);
100
- if (!isCommerce && input.fallback !== undefined) {
80
+ if (!isBridgeCommerceV2Method(request.method) && input.fallback !== undefined) {
101
81
  return input.fallback.request(request);
102
82
  }
103
83
  try {
@@ -123,15 +103,15 @@ export function createCommerceProviderBridge(input) {
123
103
  };
124
104
  }
125
105
  /**
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()`.
106
+ * Registry entry for a commerce provider: it declares no feature and no
107
+ * method, the six `commerce.v2.*` methods are implied and served in process by
108
+ * `createCommerceProviderBridge`, and the gateway's `commerce` capability block
109
+ * is the provider's own `getCapabilities()` answer.
130
110
  */
131
111
  export function createCommerceServiceProvider(input) {
132
112
  return {
133
113
  id: input.id,
134
- features: input.features ?? ['nativeIap'],
114
+ features: [],
135
115
  methods: [],
136
116
  commerce: input.commerce,
137
117
  bridge: createCommerceProviderBridge({ provider: input.commerce }),
@@ -226,14 +206,18 @@ export function createBridgeCommerceProvider(input) {
226
206
  },
227
207
  subscribe(listener) {
228
208
  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) {
209
+ // Another protocol on a shared event channel is not ours to judge; an
210
+ // envelope without a protocol is malformed and must reach the
211
+ // validator, as in the Apps in Toss provider.
212
+ const protocol = typeof envelope === 'object' && envelope !== null
213
+ ? envelope.protocol
214
+ : undefined;
215
+ if (typeof protocol === 'string' && protocol !== bridgeCommerceEventProtocol) {
232
216
  return;
233
217
  }
234
218
  let event;
235
219
  try {
236
- event = assertBridgeEvent(envelope);
220
+ event = assertCommerceEventEnvelope(envelope);
237
221
  }
238
222
  catch (error) {
239
223
  listener({
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
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, 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';
4
+ export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, type CapacitorCommerceServiceProvider, type CapacitorProviderCapabilitySnapshot, type CapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
5
+ export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, type CommerceProviderBridge, type CreateBridgeCommerceProviderInput, type CreateCommerceProviderBridgeInput, type CreateCommerceServiceProviderInput, } from './commerce-bridge.js';
6
6
  export { createStoreCommerceProvider, recoverableErrorCodes, type CreateStoreCommerceProviderInput, type StoreCommerceCapabilityOverrides, type StoreCommerceProductMapping, type StoreCommerceProvider, } from './store-commerce-provider.js';
7
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';
8
8
  export { createCapacitorAppEvents, type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource, } from './app-events.js';
package/dist/index.js CHANGED
@@ -1,26 +1,19 @@
1
- import { decodeBridgeCredentialLoadData, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
1
+ import { bridgeCommerceEventProtocol, decodeBridgeCredentialLoadData, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
2
2
  import { CapacitorGameServices } from '@mpgd/capacitor-game-services';
3
3
  import { PlatformOperationError, } from '@mpgd/platform';
4
- import { deriveLegacyCommerceCapabilities } from '@mpgd/platform/commerce';
4
+ import { withShareIntentCheck } from '@mpgd/platform/routes';
5
5
  import { createCapacitorAppEvents, } from './app-events.js';
6
6
  import { createBridgeCommerceProvider } from './commerce-bridge.js';
7
7
  import { assertProviderResponseData, createCapacitorProviderRegistry, } from './providers.js';
8
+ import { assertNotificationsV2Served, externalActivityMethods } from './request-policy.js';
8
9
  import { createCapacitorViewport } from './viewport.js';
9
- export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, deriveCommerceFeatureStates, } from './providers.js';
10
+ export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, } from './providers.js';
10
11
  export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, } from './commerce-bridge.js';
11
12
  export { createStoreCommerceProvider, recoverableErrorCodes, } from './store-commerce-provider.js';
12
13
  export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, } from './store-sdk.js';
13
14
  export { createCapacitorAppEvents, } from './app-events.js';
14
15
  export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, } from './native-http.js';
15
16
  export { createCapacitorViewport, } from './viewport.js';
16
- const externalActivityMethods = new Set([
17
- 'commerce.purchase',
18
- 'commerce.v2.requestPurchase',
19
- 'ads.showRewarded',
20
- 'ads.showInterstitial',
21
- 'identity.requestUpgrade',
22
- 'notifications.requestSubscription',
23
- ]);
24
17
  function isRecord(value) {
25
18
  return typeof value === 'object' && value !== null && !Array.isArray(value);
26
19
  }
@@ -59,15 +52,28 @@ export function createCapacitorPlatformGateway(input) {
59
52
  const adProvider = providers.byMethod.get('ads.showRewarded')?.adProvider
60
53
  ?? providers.byMethod.get('ads.showInterstitial')?.adProvider;
61
54
  const commerceProvider = providers.commerce;
62
- // The v2 surface is served through the same request path as every other
63
- // provider method, so the bridge envelope, response validation, and external
64
- // activity bookkeeping are exercised in process exactly as over a transport.
65
- const commerceV2 = commerceProvider === undefined
55
+ // The commerce provider is served through the same request path as every
56
+ // other provider method, so the bridge envelope, response validation, and
57
+ // external activity bookkeeping are exercised in process exactly as over a
58
+ // transport.
59
+ const commerce = commerceProvider === undefined
66
60
  ? undefined
67
61
  : createBridgeCommerceProvider({
68
62
  store: commerceProvider.commerce.store,
69
63
  request: (method, payload) => request(method, payload),
70
- subscribe: (listener) => commerceProvider.bridge.subscribe(listener),
64
+ // The provider's channel (`NativeBridge`) may carry other protocols.
65
+ // Only an envelope that names another protocol is dropped; anything
66
+ // else, a malformed envelope included, reaches the commerce
67
+ // provider's validator.
68
+ subscribe: (listener) => commerceProvider.bridge.subscribe((envelope) => {
69
+ const protocol = typeof envelope === 'object' && envelope !== null
70
+ ? envelope.protocol
71
+ : undefined;
72
+ if (typeof protocol === 'string' && protocol !== bridgeCommerceEventProtocol) {
73
+ return;
74
+ }
75
+ listener(envelope);
76
+ }),
71
77
  });
72
78
  const lifecycle = createCapacitorAppEvents({
73
79
  target: input.target,
@@ -100,6 +106,9 @@ export function createCapacitorPlatformGateway(input) {
100
106
  },
101
107
  };
102
108
  async function request(method, payload, leaderboardRoute) {
109
+ // Before any pause: an unserved notifications v2 method never reaches the
110
+ // base plugin, which speaks no v2.
111
+ assertNotificationsV2Served(method, providers.byMethod.has(method));
103
112
  const releaseExternalActivity = externalActivityMethods.has(method)
104
113
  ? lifecycle.beginExternalActivity?.()
105
114
  : undefined;
@@ -110,29 +119,14 @@ export function createCapacitorPlatformGateway(input) {
110
119
  ? undefined
111
120
  : providers.byMethod.get(method);
112
121
  if (provider !== undefined) {
113
- let productType;
114
- if (method === 'commerce.purchase'
115
- && provider.features.includes('nativeIap')
116
- && provider.features.includes('subscriptionIap')) {
117
- // The caller supplies only an ID. Resolve its provider-owned type before
118
- // selecting a store route; a forged or unknown ID must not bypass it.
119
- const products = await request('commerce.getProducts', {});
120
- const productId = isRecord(payload) ? payload.productId : undefined;
121
- const matches = products.filter((product) => product.id === productId);
122
- if (matches.length !== 1) {
123
- throw new PlatformOperationError({ code: 'NATIVE_PROVIDER_PRODUCT_UNKNOWN' });
124
- }
125
- productType = matches[0]?.type;
126
- }
127
122
  try {
128
- await providers.assertMethodReady(method, payload, productType);
123
+ await providers.assertMethodReady(method, payload);
129
124
  const data = await sendRequest(method, payload, provider);
130
125
  if (releaseExternalActivity !== undefined
131
126
  && method === 'commerce.v2.requestPurchase'
132
127
  && isRecord(data) && data.outcome === 'launched') {
133
128
  // The store UI is still open: stay paused until this product's
134
- // purchase event arrives or the safety timeout ends. The v1 path
135
- // has no event to wait for and releases when its request returns.
129
+ // purchase event arrives or the safety timeout ends.
136
130
  holdActivity = true;
137
131
  holdLaunchedPurchaseActivity(releaseExternalActivity, isRecord(payload) ? payload.logicalId : undefined);
138
132
  }
@@ -179,7 +173,13 @@ export function createCapacitorPlatformGateway(input) {
179
173
  if (commerceProvider === undefined) {
180
174
  return;
181
175
  }
182
- const subscription = commerceProvider.bridge.subscribe(({ event }) => {
176
+ const subscription = commerceProvider.bridge.subscribe((envelope) => {
177
+ // Only this purchase's commerce events end the hold; the channel may
178
+ // carry other protocols.
179
+ if (envelope.protocol !== bridgeCommerceEventProtocol) {
180
+ return;
181
+ }
182
+ const { event } = envelope;
183
183
  if (event.type === 'subscriptionUpdated') {
184
184
  return;
185
185
  }
@@ -245,28 +245,29 @@ export function createCapacitorPlatformGateway(input) {
245
245
  target: input.target,
246
246
  async getCapabilities() {
247
247
  const base = await request('runtime.getCapabilities', {});
248
+ // A native shell built before the commerce v2 removals may still send the
249
+ // retired boolean flags; the commerce block is the only commerce source.
250
+ const { nativeIap: _retiredNativeIap, subscriptionIap: _retiredSubscriptionIap, ...native } = (isRecord(base) ? base : {});
248
251
  // One bounded registry read serves the feature states and the commerce
249
252
  // block, so a stalled or invalid provider degrades the snapshot instead
250
- // of rejecting it, and the boolean flags always follow the block.
251
- const { availability: states, commerce } = await providers.getCapabilitySnapshot();
253
+ // of rejecting it, and a missing or invalid native `commerce` answer is
254
+ // always replaced by the provider's block or the unsupported block.
255
+ const { availability: states, commerce: commerceCapabilities } = await providers.getCapabilitySnapshot();
252
256
  const rewardedAds = states.rewardedAds === 'available';
253
257
  const interstitialAds = states.interstitialAds === 'available';
254
258
  const bannerAds = states.bannerAds === 'available';
255
- const legacy = commerce === undefined ? undefined : deriveLegacyCommerceCapabilities(commerce);
256
259
  return {
257
- ...base,
258
- nativeIap: legacy?.nativeIap ?? states.nativeIap === 'available',
259
- subscriptionIap: legacy?.subscriptionIap ?? states.subscriptionIap === 'available',
260
+ ...native,
260
261
  nativeAds: rewardedAds || interstitialAds || bannerAds,
261
262
  rewardedAds,
262
263
  interstitialAds,
263
264
  bannerAds,
264
265
  nativeLeaderboard: states.nativeLeaderboard === 'available',
265
266
  providerAvailability: states,
266
- ...(commerce === undefined ? {} : { commerce }),
267
+ commerce: commerceCapabilities,
267
268
  };
268
269
  },
269
- ...(commerceV2 === undefined ? {} : { commerceV2 }),
270
+ ...(commerce === undefined ? {} : { commerce }),
270
271
  identity: {
271
272
  getPlayer: () => request('identity.getPlayer', {}),
272
273
  getSession: () => request('identity.getSession', {}),
@@ -276,20 +277,16 @@ export function createCapacitorPlatformGateway(input) {
276
277
  getLaunchIntent: () => request('presentation.getLaunchIntent', {}),
277
278
  requestGameSurface: (payload) => request('presentation.requestGameSurface', payload),
278
279
  },
279
- sharing: {
280
+ // The bridge's host may build a share URL from the link, so it only ever
281
+ // sees an intent the ShareIntent contract accepts, with the link normalized.
282
+ sharing: withShareIntentCheck({
280
283
  share: (payload) => request('share.share', payload),
281
284
  readInboundShare: () => request('share.readInboundShare', {}),
282
- },
285
+ }),
283
286
  notifications: {
284
287
  getStatus: (topic) => request('notifications.getStatus', { topic }),
285
288
  requestSubscription: (topic) => request('notifications.requestSubscription', { topic }),
286
289
  },
287
- commerce: {
288
- getProducts: () => request('commerce.getProducts', {}),
289
- purchase: (payload) => request('commerce.purchase', payload),
290
- restore: () => request('commerce.restore', {}),
291
- getEntitlements: () => request('commerce.getEntitlements', {}),
292
- },
293
290
  ads: {
294
291
  ...(adProvider === undefined ? {} : { provider: adProvider }),
295
292
  preload: (payload) => request('ads.preload', payload),
@@ -1,12 +1,13 @@
1
1
  import { type BridgeEvent, type BridgeMethod, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import type { AdProvider } from '@mpgd/platform/ads';
3
- import { type CommerceCapabilities, type PlatformProviderAvailability, type PlatformProviderFeature, type ProductType } from '@mpgd/platform';
3
+ import { type CommerceCapabilities, type PlatformProviderAvailability, type PlatformProviderFeature } from '@mpgd/platform';
4
4
  import { type CommerceProvider } from '@mpgd/platform/commerce';
5
5
  export interface NativeBridge {
6
6
  request(input: BridgeRequest): Promise<BridgeResponse>;
7
7
  /**
8
- * Optional commerce v2 event delivery (`{ protocol: 'mpgd.commerce.v2', sequence, event }`).
9
- * B1 wires it for store providers; the base gateway works without it.
8
+ * Optional event delivery in the bridge envelopes, told apart by `protocol`
9
+ * (`{ protocol: 'mpgd.commerce.v2', sequence, event }` from store
10
+ * providers). The base gateway works without it.
10
11
  */
11
12
  subscribe?(listener: (event: BridgeEvent) => void): () => void;
12
13
  }
@@ -20,23 +21,28 @@ export interface CapacitorServiceProvider {
20
21
  readonly id: string;
21
22
  /**
22
23
  * Serves the declared `methods`. For a provider with `commerce`, it also
23
- * serves the ten commerce bridge methods and must implement `subscribe`;
24
+ * serves the six `commerce.v2.*` methods and must implement `subscribe`;
24
25
  * `createCommerceServiceProvider` builds one from a `CommerceProvider`.
25
26
  */
26
27
  readonly bridge: NativeBridge;
28
+ /**
29
+ * The non-commerce features this provider serves. A provider with `commerce`
30
+ * may declare none: commerce readiness is the provider's own capability
31
+ * block, not a feature.
32
+ */
27
33
  readonly features: readonly PlatformProviderFeature[];
28
34
  /**
29
- * Declared v1 bridge methods. A provider with `commerce` declares no
30
- * `commerce.*` method: the four v1 commerce methods and the six
31
- * `commerce.v2.*` methods are implied and owned by the v2 provider.
35
+ * Declared bridge methods, owned through `features`. A provider with
36
+ * `commerce` declares no `commerce.v2.*` method: the six are implied and
37
+ * owned by the commerce provider, so its own list may be empty.
32
38
  */
33
39
  readonly methods: readonly BridgeMethod[];
34
40
  /** Optional native event surface. The base adapter never imports its SDK. */
35
41
  readonly adProvider?: AdProvider;
36
42
  /**
37
- * Optional commerce v2 provider (`createStoreCommerceProvider`). Its
38
- * `getCapabilities()` is the readiness of the declared `nativeIap` and
39
- * `subscriptionIap` features; `getAvailability` covers the other features.
43
+ * Optional commerce provider (`createStoreCommerceProvider`). Its
44
+ * `getCapabilities()` is the gateway's commerce block; `getAvailability`
45
+ * covers the declared `features`.
40
46
  */
41
47
  readonly commerce?: CommerceProvider;
42
48
  getAvailability(): Promise<Readonly<Partial<Record<PlatformProviderFeature, PlatformProviderAvailability>>>>;
@@ -49,13 +55,14 @@ export interface CapacitorCommerceServiceProvider extends CapacitorServiceProvid
49
55
  export interface CapacitorProviderCapabilitySnapshot {
50
56
  readonly availability: Readonly<Record<PlatformProviderFeature, PlatformProviderAvailability>>;
51
57
  /**
52
- * Present when a commerce v2 provider is registered: its validated
53
- * `getCapabilities()` answer from the same bounded read that produced the
54
- * `nativeIap` and `subscriptionIap` states, or a degraded block
55
- * (`availability: 'temporarily-unavailable'`, nothing sold) when that read
56
- * stalled, failed, or returned a shape the contract rejects.
58
+ * Always present, and a fresh copy on every read: the registered commerce
59
+ * provider's validated `getCapabilities()` answer, the degraded block
60
+ * (`availability: 'temporarily-unavailable'`, nothing sold) when that bounded
61
+ * read stalled, failed, or returned a shape the contract rejects, or
62
+ * `createUnsupportedCommerceCapabilities()` when no commerce provider is
63
+ * registered.
57
64
  */
58
- readonly commerce?: CommerceCapabilities;
65
+ readonly commerce: CommerceCapabilities;
59
66
  }
60
67
  export interface CapacitorProviderRegistry {
61
68
  readonly byMethod: ReadonlyMap<BridgeMethod, CapacitorServiceProvider>;
@@ -63,16 +70,10 @@ export interface CapacitorProviderRegistry {
63
70
  readonly commerce?: CapacitorCommerceServiceProvider;
64
71
  /** Feature readiness plus the commerce block, from one bounded read per provider. */
65
72
  getCapabilitySnapshot(): Promise<CapacitorProviderCapabilitySnapshot>;
66
- assertMethodReady(method: BridgeMethod, payload?: unknown, productType?: ProductType): Promise<void>;
73
+ assertMethodReady(method: BridgeMethod, payload?: unknown): Promise<void>;
67
74
  getAvailability(): Promise<Readonly<Record<PlatformProviderFeature, PlatformProviderAvailability>>>;
68
75
  }
69
76
  export declare function createCapacitorProviderRegistry(input: readonly CapacitorServiceProvider[]): CapacitorProviderRegistry;
70
77
  export declare function assertProviderResponseData(method: BridgeMethod, data: unknown): void;
71
78
  /** The commerce block a gateway reports while its provider cannot be read. */
72
79
  export declare function createDegradedCommerceCapabilities(): CommerceCapabilities;
73
- /**
74
- * Readiness of the commerce features a v2 provider declares, from its own
75
- * capability declaration: a feature it does not sell is `unsupported`, and a
76
- * read that failed or stalled is `temporarily-unavailable`.
77
- */
78
- export declare function deriveCommerceFeatureStates(capabilities: CommerceCapabilities | undefined, features: readonly PlatformProviderFeature[]): Partial<Record<PlatformProviderFeature, PlatformProviderAvailability>>;
package/dist/providers.js CHANGED
@@ -1,21 +1,9 @@
1
- import { bridgeCommerceV2Methods, isBridgeCommerceV2Method, } from '@mpgd/bridge';
1
+ import { bridgeCommerceV2Methods, isBridgeCommerceV2Method, isBridgeNotificationsV2Method, } from '@mpgd/bridge';
2
2
  import { isAdMobClientRewardEvidence } from '@mpgd/game-services/admob-client-reward';
3
3
  import { PlatformOperationError, } from '@mpgd/platform';
4
4
  import { createUnsupportedCommerceCapabilities, isCommerceCapabilities, } from '@mpgd/platform/commerce';
5
5
  import { assertCommerceBridgeResult } from './commerce-bridge.js';
6
6
  const featureMethods = {
7
- nativeIap: [
8
- 'commerce.getProducts',
9
- 'commerce.purchase',
10
- 'commerce.restore',
11
- 'commerce.getEntitlements',
12
- ],
13
- subscriptionIap: [
14
- 'commerce.getProducts',
15
- 'commerce.purchase',
16
- 'commerce.restore',
17
- 'commerce.getEntitlements',
18
- ],
19
7
  rewardedAds: ['ads.preload', 'ads.showRewarded'],
20
8
  interstitialAds: ['ads.preload', 'ads.showInterstitial'],
21
9
  bannerAds: ['ads.mountBanner', 'ads.unmountBanner'],
@@ -25,12 +13,7 @@ const featureMethods = {
25
13
  };
26
14
  const providerFeatures = Object.keys(featureMethods);
27
15
  const providerMethodSet = new Set(Object.values(featureMethods).flat());
28
- const commerceFeatures = ['nativeIap', 'subscriptionIap'];
29
- const v1CommerceMethods = featureMethods.nativeIap;
30
- const impliedCommerceMethods = [
31
- ...v1CommerceMethods,
32
- ...bridgeCommerceV2Methods,
33
- ];
16
+ const impliedCommerceMethods = bridgeCommerceV2Methods;
34
17
  const availabilitySet = new Set([
35
18
  'unsupported',
36
19
  'configuration-required',
@@ -55,8 +38,8 @@ export function createCapacitorProviderRegistry(input) {
55
38
  throw new Error(`Capacitor provider ID is empty or registered twice: ${provider.id}`);
56
39
  }
57
40
  ids.add(provider.id);
58
- if (provider.features.length === 0
59
- || (provider.methods.length === 0 && provider.commerce === undefined)) {
41
+ if (provider.commerce === undefined
42
+ && (provider.features.length === 0 || provider.methods.length === 0)) {
60
43
  throw new Error(`Capacitor provider ${provider.id} must declare features and methods.`);
61
44
  }
62
45
  const methods = new Set(provider.methods);
@@ -67,16 +50,18 @@ export function createCapacitorProviderRegistry(input) {
67
50
  if (isBridgeCommerceV2Method(method)) {
68
51
  // A v2 provider is registered through `commerce`; its bridge methods are implied.
69
52
  throw new Error(`Capacitor provider ${provider.id} cannot declare commerce v2 method ${method}; `
70
- + 'register a v2 provider through `commerce` instead, commerce v2 is unavailable as '
71
- + 'a declared method.');
53
+ + 'register a commerce provider through `commerce` instead, commerce v2 is '
54
+ + 'unavailable as a declared method.');
55
+ }
56
+ if (isBridgeNotificationsV2Method(method)) {
57
+ // A notifications v2 provider gets its own registry surface (P2); a
58
+ // declared method would bypass the pass-through readiness rule.
59
+ throw new Error(`Capacitor provider ${provider.id} cannot declare notifications v2 method ${method}; `
60
+ + 'notifications v2 is unavailable as a declared method.');
72
61
  }
73
62
  if (!providerMethodSet.has(method)) {
74
63
  throw new Error(`Capacitor provider ${provider.id} cannot replace base method ${method}.`);
75
64
  }
76
- if (provider.commerce !== undefined && v1CommerceMethods.includes(method)) {
77
- throw new Error(`Capacitor provider ${provider.id} cannot declare both a commerce v2 provider and `
78
- + `v1 method ${method}; the registry serves v1 callers from the v2 provider.`);
79
- }
80
65
  }
81
66
  const implied = provider.commerce === undefined ? [] : impliedCommerceMethods;
82
67
  for (const method of [...methods, ...implied]) {
@@ -85,16 +70,11 @@ export function createCapacitorProviderRegistry(input) {
85
70
  }
86
71
  }
87
72
  const owned = new Set([...methods, ...implied]);
88
- if (provider.commerce !== undefined) {
89
- if (!provider.features.some((feature) => commerceFeatures.includes(feature))) {
90
- throw new Error(`Capacitor provider ${provider.id} declares a commerce v2 provider but no commerce feature.`);
91
- }
92
- if (provider.bridge.subscribe === undefined) {
93
- // Without the event surface a purchase that completes after the store
94
- // UI closed would never reach the reconciler.
95
- throw new Error(`Capacitor provider ${provider.id} declares a commerce v2 provider but its bridge has no `
96
- + 'subscribe; build it with createCommerceServiceProvider.');
97
- }
73
+ if (provider.commerce !== undefined && provider.bridge.subscribe === undefined) {
74
+ // Without the event surface a purchase that completes after the store
75
+ // UI closed would never reach the reconciler.
76
+ throw new Error(`Capacitor provider ${provider.id} declares a commerce provider but its bridge has no `
77
+ + 'subscribe; build it with createCommerceServiceProvider.');
98
78
  }
99
79
  for (const feature of provider.features) {
100
80
  const required = featureMethods[feature];
@@ -117,26 +97,27 @@ export function createCapacitorProviderRegistry(input) {
117
97
  commerce = provider;
118
98
  }
119
99
  }
120
- const readProviderStates = async (provider) => {
100
+ const readFeatureStates = async (provider) => {
121
101
  const states = {};
122
- const commerceOwned = provider.commerce === undefined
123
- ? []
124
- : provider.features.filter((feature) => commerceFeatures.includes(feature));
125
- const bridged = provider.features.filter((feature) => !commerceOwned.includes(feature));
126
- let commerceBlock;
127
- if (provider.commerce !== undefined && commerceOwned.length > 0) {
128
- commerceBlock = await readCommerceCapabilities(provider.commerce);
129
- Object.assign(states, deriveCommerceFeatureStates(commerceBlock, commerceOwned));
102
+ if (provider.features.length === 0) {
103
+ return states;
130
104
  }
131
- if (bridged.length > 0) {
132
- const reported = await readWithTimeout(Promise.resolve().then(() => provider.getAvailability()));
133
- for (const feature of bridged) {
134
- const state = isRecord(reported) ? reported[feature] : undefined;
135
- states[feature] = availabilitySet.has(state)
136
- ? state
137
- : 'temporarily-unavailable';
138
- }
105
+ const reported = await readWithTimeout(Promise.resolve().then(() => provider.getAvailability()));
106
+ for (const feature of provider.features) {
107
+ const state = isRecord(reported) ? reported[feature] : undefined;
108
+ states[feature] = availabilitySet.has(state)
109
+ ? state
110
+ : 'temporarily-unavailable';
139
111
  }
112
+ return states;
113
+ };
114
+ const readProviderStates = async (provider) => {
115
+ const [states, commerceBlock] = await Promise.all([
116
+ readFeatureStates(provider),
117
+ provider.commerce === undefined
118
+ ? Promise.resolve(undefined)
119
+ : readCommerceCapabilities(provider.commerce),
120
+ ]);
140
121
  return { states, ...(commerceBlock === undefined ? {} : { commerce: commerceBlock }) };
141
122
  };
142
123
  const getCapabilitySnapshot = async () => {
@@ -150,15 +131,15 @@ export function createCapacitorProviderRegistry(input) {
150
131
  Object.assign(states, snapshot.states);
151
132
  commerceBlock = snapshot.commerce ?? commerceBlock;
152
133
  }
153
- if (commerce !== undefined && commerceBlock === undefined) {
154
- // One bounded read serves the feature states and the block, so a stalled,
155
- // failed, or invalid provider degrades both the same way.
134
+ if (commerce === undefined) {
135
+ commerceBlock = createUnsupportedCommerceCapabilities();
136
+ }
137
+ else if (commerceBlock === undefined) {
138
+ // One bounded read serves the block, so a stalled, failed, or invalid
139
+ // provider degrades it without rejecting the snapshot.
156
140
  commerceBlock = createDegradedCommerceCapabilities();
157
141
  }
158
- return {
159
- availability: states,
160
- ...(commerceBlock === undefined ? {} : { commerce: commerceBlock }),
161
- };
142
+ return { availability: states, commerce: commerceBlock };
162
143
  };
163
144
  const getAvailability = async () => (await getCapabilitySnapshot()).availability;
164
145
  return {
@@ -166,14 +147,17 @@ export function createCapacitorProviderRegistry(input) {
166
147
  ...(commerce === undefined ? {} : { commerce }),
167
148
  getAvailability,
168
149
  getCapabilitySnapshot,
169
- async assertMethodReady(method, payload, productType) {
150
+ async assertMethodReady(method, payload) {
170
151
  const provider = byMethod.get(method);
171
152
  if (provider === undefined) {
172
153
  return;
173
154
  }
174
- if (isBridgeCommerceV2Method(method)) {
175
- // The v2 provider reads its own `availability` and fails closed before
176
- // any store UI opens; the registry adds no second, stale snapshot.
155
+ if (isBridgeCommerceV2Method(method) || isBridgeNotificationsV2Method(method)) {
156
+ // A v2 provider reads its own `availability` and answers with it
157
+ // (commerce fails closed before any store UI opens; notifications
158
+ // answer `unsupported` or `action-required` states and outcomes), so
159
+ // the registry adds no second, stale snapshot that would turn those
160
+ // answers into errors.
177
161
  return;
178
162
  }
179
163
  if (method === 'ads.unmountBanner') {
@@ -181,14 +165,10 @@ export function createCapacitorProviderRegistry(input) {
181
165
  // its serving state becomes unavailable.
182
166
  return;
183
167
  }
184
- // An unrelated provider must not delay a purchase, ad, or identity call.
185
- const { states } = await readProviderStates(provider);
168
+ // An unrelated provider must not delay an ad or identity call, and neither
169
+ // may its commerce capability read.
170
+ const states = await readFeatureStates(provider);
186
171
  let relevant = provider.features.filter((feature) => featureMethods[feature].includes(method));
187
- if (method === 'commerce.purchase' && relevant.length > 1) {
188
- relevant = relevant.filter((feature) => feature === (productType === 'subscription' ? 'subscriptionIap'
189
- : productType === 'consumable' || productType === 'non_consumable'
190
- ? 'nativeIap' : undefined));
191
- }
192
172
  const preloadFormat = method === 'ads.preload' && isRecord(payload)
193
173
  ? payload.format
194
174
  : undefined;
@@ -201,15 +181,9 @@ export function createCapacitorProviderRegistry(input) {
201
181
  relevant = feature === undefined ? [] : relevant.filter((item) => item === feature);
202
182
  }
203
183
  if (relevant.length === 0) {
204
- throw new PlatformOperationError({
205
- code: method === 'commerce.purchase'
206
- ? 'NATIVE_PROVIDER_PRODUCT_TYPE_REQUIRED'
207
- : 'NATIVE_PROVIDER_UNSUPPORTED',
208
- });
184
+ throw new PlatformOperationError({ code: 'NATIVE_PROVIDER_UNSUPPORTED' });
209
185
  }
210
- const ready = (method === 'ads.preload' && preloadFormat === undefined)
211
- || ((method === 'commerce.restore' || method === 'commerce.getEntitlements')
212
- && relevant.length > 1)
186
+ const ready = method === 'ads.preload' && preloadFormat === undefined
213
187
  ? relevant.every((feature) => states[feature] === 'available')
214
188
  : relevant.some((feature) => states[feature] === 'available');
215
189
  if (ready) {
@@ -288,41 +262,12 @@ async function readWithTimeout(read) {
288
262
  }
289
263
  }
290
264
  }
291
- /**
292
- * Readiness of the commerce features a v2 provider declares, from its own
293
- * capability declaration: a feature it does not sell is `unsupported`, and a
294
- * read that failed or stalled is `temporarily-unavailable`.
295
- */
296
- export function deriveCommerceFeatureStates(capabilities, features) {
297
- const states = {};
298
- const availability = capabilities === undefined
299
- || !availabilitySet.has(capabilities.availability)
300
- ? 'temporarily-unavailable'
301
- : capabilities.availability;
302
- for (const feature of features) {
303
- if (feature === 'nativeIap') {
304
- states.nativeIap = capabilities !== undefined
305
- && !(capabilities.purchase.consumable || capabilities.purchase.durable)
306
- ? 'unsupported'
307
- : availability;
308
- }
309
- else if (feature === 'subscriptionIap') {
310
- states.subscriptionIap = capabilities !== undefined && !capabilities.purchase.subscription
311
- ? 'unsupported'
312
- : availability;
313
- }
314
- }
315
- return states;
316
- }
317
265
  function isRecord(value) {
318
266
  return typeof value === 'object' && value !== null && !Array.isArray(value);
319
267
  }
320
268
  function isString(value) {
321
269
  return typeof value === 'string';
322
270
  }
323
- function isStringArray(value) {
324
- return Array.isArray(value) && value.every(isString);
325
- }
326
271
  function isVoidData(value) {
327
272
  return value === undefined || value === null || (isRecord(value) && Object.keys(value).length === 0);
328
273
  }
@@ -336,47 +281,13 @@ function isValidProviderData(method, value) {
336
281
  return false;
337
282
  }
338
283
  }
284
+ if (isBridgeNotificationsV2Method(method)) {
285
+ // P2 must validate these answers here, with `assertBridgeNotificationsV2Result`
286
+ // from `@mpgd/bridge/notifications`, when it lets a provider serve the
287
+ // methods. Until then every notifications v2 answer is rejected.
288
+ return false;
289
+ }
339
290
  switch (method) {
340
- case 'commerce.getProducts':
341
- return Array.isArray(value) && value.every((product) => isRecord(product)
342
- && isString(product.id)
343
- && ['consumable', 'non_consumable', 'subscription'].includes(product.type)
344
- && isString(product.title)
345
- && isString(product.description)
346
- && isRecord(product.price)
347
- && isString(product.price.formatted)
348
- && isString(product.price.currencyCode));
349
- case 'commerce.purchase':
350
- return isRecord(value)
351
- && ['completed', 'cancelled', 'pending', 'failed'].includes(value.status)
352
- && isStringArray(value.entitlementIds)
353
- && (value.transactionId === undefined || isString(value.transactionId))
354
- && (value.authoritativeGrant === undefined || (value.status === 'completed'
355
- && isString(value.transactionId)
356
- && isRecord(value.authoritativeGrant)
357
- && isString(value.authoritativeGrant.ledgerEntryId)
358
- && value.authoritativeGrant.ledgerEntryId.length > 0
359
- && (value.authoritativeGrant.alreadyProcessed === undefined
360
- || typeof value.authoritativeGrant.alreadyProcessed === 'boolean')))
361
- && (value.evidence === undefined || isEvidence(value.evidence))
362
- && (value.diagnostic === undefined || isCommerceDiagnostic(value.diagnostic));
363
- case 'commerce.restore':
364
- return isRecord(value)
365
- && Array.isArray(value.restoredEntitlements)
366
- && value.restoredEntitlements.every(isEntitlement)
367
- && (value.settledPurchases === undefined || (Array.isArray(value.settledPurchases)
368
- && value.settledPurchases.every((settlement) => isRecord(settlement)
369
- && isString(settlement.transactionId)
370
- && isString(settlement.productId)
371
- && ['granted', 'refunded'].includes(settlement.status)
372
- && (settlement.ledgerEntryId === undefined || isString(settlement.ledgerEntryId))
373
- && (settlement.alreadyProcessed === undefined
374
- || typeof settlement.alreadyProcessed === 'boolean')
375
- && (settlement.idempotencyKey === undefined
376
- || isIdempotencyKey(settlement.idempotencyKey)))))
377
- && (value.diagnostic === undefined || isCommerceDiagnostic(value.diagnostic));
378
- case 'commerce.getEntitlements':
379
- return Array.isArray(value) && value.every(isEntitlement);
380
291
  case 'ads.preload':
381
292
  case 'ads.unmountBanner':
382
293
  case 'leaderboard.open':
@@ -426,29 +337,6 @@ function isValidProviderData(method, value) {
426
337
  return false;
427
338
  }
428
339
  }
429
- function isEntitlement(value) {
430
- return isRecord(value)
431
- && isString(value.id)
432
- && ['purchase', 'promotion', 'admin'].includes(value.source)
433
- && isString(value.grantedAt)
434
- && (value.expiresAt === undefined || isString(value.expiresAt));
435
- }
436
- /** Mirrors the stable, non-sensitive code format of `PlatformOperationError`. */
437
- function isDiagnosticCode(value) {
438
- return isString(value) && value.length <= 128 && /^[A-Z][A-Z0-9_:-]*$/u.test(value);
439
- }
440
- function isCommerceDiagnostic(value) {
441
- return isRecord(value)
442
- && isDiagnosticCode(value.code)
443
- && typeof value.retryable === 'boolean'
444
- && (value.providerCode === undefined || isDiagnosticCode(value.providerCode));
445
- }
446
- function isIdempotencyKey(value) {
447
- return isString(value)
448
- && value.length > 0
449
- && value.length <= 256
450
- && !/[\p{Cc}\p{Cf}]/u.test(value);
451
- }
452
340
  function isEvidence(value) {
453
341
  return isRecord(value)
454
342
  && isString(value.schema)
@@ -0,0 +1,16 @@
1
+ import { type BridgeMethod } from '@mpgd/bridge';
2
+ /**
3
+ * Methods that put a native UI over the game, so the gateway pauses the game
4
+ * (`beginExternalActivity`) while one runs: a store sheet, an ad, a sign-in,
5
+ * a permission dialog, or the system settings that
6
+ * `notifications.v2.openSettings` leaves for (notifications RFC "Provider
7
+ * profiles", Capacitor).
8
+ */
9
+ export declare const externalActivityMethods: ReadonlySet<BridgeMethod>;
10
+ /**
11
+ * Fails closed for a notifications v2 method that no registered provider
12
+ * serves: the base game-services plugin speaks no notifications v2, so the
13
+ * request answers `NOTIFICATIONS_V2_UNAVAILABLE`, which is not retryable,
14
+ * without pausing the game or reaching the plugin.
15
+ */
16
+ export declare function assertNotificationsV2Served(method: BridgeMethod, served: boolean): void;
@@ -0,0 +1,36 @@
1
+ import { isBridgeNotificationsV2Method, notificationsV2UnavailableBridgeErrorCode, } from '@mpgd/bridge';
2
+ import { PlatformOperationError } from '@mpgd/platform';
3
+ // How the gateway's request path treats a method before routing it. Internal:
4
+ // the package entry does not export this module.
5
+ /**
6
+ * Methods that put a native UI over the game, so the gateway pauses the game
7
+ * (`beginExternalActivity`) while one runs: a store sheet, an ad, a sign-in,
8
+ * a permission dialog, or the system settings that
9
+ * `notifications.v2.openSettings` leaves for (notifications RFC "Provider
10
+ * profiles", Capacitor).
11
+ */
12
+ export const externalActivityMethods = new Set([
13
+ 'commerce.v2.requestPurchase',
14
+ 'ads.showRewarded',
15
+ 'ads.showInterstitial',
16
+ 'identity.requestUpgrade',
17
+ 'notifications.requestSubscription',
18
+ 'notifications.v2.requestPermission',
19
+ 'notifications.v2.openSettings',
20
+ ]);
21
+ /**
22
+ * Fails closed for a notifications v2 method that no registered provider
23
+ * serves: the base game-services plugin speaks no notifications v2, so the
24
+ * request answers `NOTIFICATIONS_V2_UNAVAILABLE`, which is not retryable,
25
+ * without pausing the game or reaching the plugin.
26
+ */
27
+ export function assertNotificationsV2Served(method, served) {
28
+ if (served || !isBridgeNotificationsV2Method(method)) {
29
+ return;
30
+ }
31
+ throw new PlatformOperationError({
32
+ code: notificationsV2UnavailableBridgeErrorCode,
33
+ message: `No Capacitor provider serves notifications v2; ${method} is unavailable.`,
34
+ retryable: false,
35
+ });
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-capacitor",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Capacitor platform adapter for mpgd Android and iOS shells.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,10 +32,6 @@
32
32
  "./admob": {
33
33
  "types": "./dist/admob.d.ts",
34
34
  "default": "./dist/admob.js"
35
- },
36
- "./app-store-server": {
37
- "types": "./dist/app-store-server.d.ts",
38
- "default": "./dist/app-store-server.js"
39
35
  }
40
36
  },
41
37
  "files": [
@@ -43,11 +39,11 @@
43
39
  ],
44
40
  "dependencies": {
45
41
  "@capacitor/app": "8.1.1",
46
- "@mpgd/bridge": "0.12.3",
47
- "@mpgd/game-services": "0.22.0",
48
- "@mpgd/capacitor-game-services": "0.6.5",
49
- "@mpgd/platform": "0.22.0",
50
- "@mpgd/target-config": "0.22.1"
42
+ "@mpgd/bridge": "0.13.0",
43
+ "@mpgd/capacitor-game-services": "0.7.0",
44
+ "@mpgd/platform": "0.23.0",
45
+ "@mpgd/target-config": "0.23.0",
46
+ "@mpgd/game-services": "0.24.0"
51
47
  },
52
48
  "peerDependencies": {
53
49
  "@capacitor-community/admob": "^8.1.0",
@@ -64,7 +60,7 @@
64
60
  "ttsc": "0.30.4",
65
61
  "typescript": "7.0.2",
66
62
  "vitest": "^4.0.0",
67
- "@mpgd/game-runtime": "0.6.5"
63
+ "@mpgd/game-runtime": "0.7.0"
68
64
  },
69
65
  "main": "./dist/index.js",
70
66
  "types": "./dist/index.d.ts",
@@ -1,12 +0,0 @@
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,10 +0,0 @@
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;