@mpgd/adapter-capacitor 0.5.0 → 0.6.1

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
@@ -52,14 +52,107 @@ provider never silently falls back for purchase, ad, or native leaderboard
52
52
  operations; an explicit remote leaderboard route is a separate path. Errors
53
53
  retain a stable code and retry hint. The
54
54
  provider bridge must return a method-shaped response. In particular, an ad
55
- `rewardGranted: true` result requires a backend ledger entry. A native callback
56
- alone is evidence, not a grant. Game-specific product, consent, entitlement,
57
- and identity policy belongs to the consuming game and its backend.
55
+ `rewardGranted: true` result requires a backend ledger entry. The AdMob SDK
56
+ provider always returns `rewardGranted: false`, even after the SDK fires a
57
+ reward callback. Its explicitly non-authoritative evidence envelope only lets
58
+ the game-services client request a grant; the server must independently verify
59
+ a signed SSV callback before writing its ledger. A native callback alone is
60
+ never a grant. Game-specific product, consent, entitlement, and
61
+ identity policy belongs to the consuming game and its backend.
58
62
 
59
63
  The source tests and installed-tarball consumer validate composition, types,
60
64
  error paths, and fallback behavior. They do not establish that any optional
61
65
  SDK works on a physical device or that a store/ad setup is release-ready.
62
66
 
67
+ ## App Store server-side transaction signatures
68
+
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.
78
+
79
+ The server fetches the transaction from Apple's API, verifies its JWS, checks
80
+ the product, bundle, environment, account token and transaction ID, then
81
+ writes the game-services grant. A StoreKit device callback is only provisional
82
+ evidence; it never grants a product. Retryable certificate-status failures
83
+ remain pending, while invalid signatures are rejected. Mock verification does
84
+ not prove a real App Store purchase or device delivery.
85
+
86
+ ## Opt-in AdMob rewarded ads
87
+
88
+ Install `@capacitor-community/admob@8.1.0` in the game-owned Capacitor project,
89
+ configure the distinct AdMob **application ID** in AndroidManifest.xml and
90
+ Info.plist, and run `cap sync`. Use the [plugin installation and native setup
91
+ guide](https://github.com/capacitor-community/admob#installation) for the exact
92
+ Android and iOS keys, SKAdNetwork list, and tracking disclosure. The optional
93
+ `@mpgd/adapter-capacitor/admob` subpath does not load from the base adapter;
94
+ games that do not install AdMob retain their existing SDK-free gateway.
95
+ The community plugin currently targets Google Mobile Ads Android 25.4.x and
96
+ iOS 13.6.0. Its Android default uses a dynamic patch selector; set
97
+ `playServicesAdsVersion = '25.4.0'` in the game-owned `variables.gradle` to
98
+ keep release builds reproducible until the plugin adopts a newer tested SDK.
99
+
100
+ ```ts
101
+ import { createCapacitorPlatformGateway } from '@mpgd/adapter-capacitor';
102
+ import { createCapacitorAdMobRewardedProvider } from '@mpgd/adapter-capacitor/admob';
103
+
104
+ const admob = createCapacitorAdMobRewardedProvider({
105
+ adUnits: { CONTINUE_AFTER_FAIL: 'ca-app-pub-1234567890123456/1234567890' },
106
+ getPlayerId: () => authenticatedPlayerId,
107
+ });
108
+ const gateway = createCapacitorPlatformGateway({
109
+ target: 'android', appVersion: '1.0.0', buildId: 'game-build',
110
+ providers: [admob],
111
+ });
112
+ const consentAllowsAds = await admob.requestConsent();
113
+ if (!consentAllowsAds) {
114
+ // Keep the ad action disabled; never bypass the privacy choice.
115
+ }
116
+ ```
117
+
118
+ Use target-specific ad unit IDs for Android and iOS; the logical placement
119
+ ID must match the game catalog and backend config. `getPlayerId()` must return
120
+ the same authenticated player ID as `GameServicesClient`, not a device ID or
121
+ an unverified local guest label. Re-check consent when the game resumes or
122
+ the privacy choice changes, and expose `admob.showPrivacyOptions()` from game
123
+ settings where required. `getCapabilities()` reports `action-required` until
124
+ consent is ready and `temporarily-unavailable` during a show. A timed-out
125
+ native load or presentation reports non-retryable `action-required` and
126
+ quarantines that SDK instance to avoid overlapping shows
127
+ while its state is unknown. Recreating the Kit provider with the same SDK does
128
+ not clear the quarantine; restart the native app before trying again. Do not
129
+ show rewarded ads through another direct SDK caller concurrently: the plugin's
130
+ reward and dismissal events are global, not tagged with a Kit operation ID.
131
+
132
+ `gateway.ads.preload()` validates the logical placement but deliberately does
133
+ not load an ad: this SDK attaches SSV data at load time, whereas the Kit's
134
+ operation ID exists only at show time. `gateway.ads.showRewarded()` therefore
135
+ loads a fresh ad with SSV `userId` and encoded player/placement/operation
136
+ binding, waits for native dismissal, then returns SDK reward evidence. Call
137
+ `GameServicesClient.claimRewardedAd()` rather than award currency from that
138
+ result; `rewardGranted` remains `false` in that provisional platform result.
139
+ If dismissal arrives without an observed reward, the provider returns
140
+ `pending` with the same non-authoritative binding. The backend can then look
141
+ for a later signed SSV callback without replaying the ad UI; a genuinely
142
+ skipped ad may remain pending until game-owned support or expiry policy
143
+ settles it. Mediation and asynchronous bridge delivery have no safe universal
144
+ 250 ms reward-event cutoff.
145
+ The receiver and ledger described in [AdMob SSV](../../docs/ADMOB_SSV.md)
146
+ remain mandatory. A late SSV callback can leave the claim pending for
147
+ reconciliation. `isTesting: true` is for SDK test ads only; [the plugin notes
148
+ that test ads do not invoke the SSV endpoint](https://github.com/capacitor-community/admob/blob/main/docs/rewarded.md#server-side-verification),
149
+ so they cannot prove a production grant.
150
+
151
+ The Kit tests cover consent, per-operation binding, reward versus dismissal,
152
+ and bridge contracts with an injected SDK. They are not evidence of a real
153
+ AdMob account, live callback delivery, native device behavior, or store policy
154
+ approval.
155
+
63
156
  `gateway.secureCredentials` uses dedicated native Keychain/Keystore methods for
64
157
  opaque session credentials. Its absence or a native error must not be hidden by
65
158
  writing the secret into `gateway.storage`. Keys are bounded, values are
@@ -0,0 +1,26 @@
1
+ import { type AdMobPlugin } from '@capacitor-community/admob';
2
+ import type { CapacitorServiceProvider } from './providers.js';
3
+ /** Only the AdMob subpath imports the optional native SDK. */
4
+ export type RewardedAdMobSdk = Pick<AdMobPlugin, 'initialize' | 'requestConsentInfo' | 'showConsentForm' | 'showPrivacyOptionsForm' | 'prepareRewardVideoAd' | 'showRewardVideoAd' | 'addListener'>;
5
+ export interface CreateCapacitorAdMobRewardedProviderInput {
6
+ /** Logical placement IDs mapped to target-specific, game-owned AdMob ad units. */
7
+ readonly adUnits: Readonly<Record<string, string>>;
8
+ /** Must resolve to the same authenticated player ID used by GameServicesClient. */
9
+ readonly getPlayerId: () => Promise<string> | string;
10
+ /** Test ads do not send Google SSV callbacks; server grants remain pending. */
11
+ readonly isTesting?: boolean;
12
+ readonly sdk?: RewardedAdMobSdk;
13
+ readonly showTimeoutMs?: number;
14
+ }
15
+ export interface CapacitorAdMobRewardedProvider extends CapacitorServiceProvider {
16
+ /** Present UMP when required. Call before the gateway advertises ad readiness. */
17
+ requestConsent(): Promise<boolean>;
18
+ /** Game settings should expose this when the privacy message requires it. */
19
+ showPrivacyOptions(): Promise<void>;
20
+ }
21
+ /**
22
+ * Opt-in rewarded-only provider. The SDK's SSV options are attached when an
23
+ * operation is shown, never to an earlier unbound preload. A local reward
24
+ * callback is only a signal to ask the backend; it is not a ledger grant.
25
+ */
26
+ export declare function createCapacitorAdMobRewardedProvider(input: CreateCapacitorAdMobRewardedProviderInput): CapacitorAdMobRewardedProvider;
package/dist/admob.js ADDED
@@ -0,0 +1,288 @@
1
+ import { AdMob, AdmobConsentStatus, RewardAdPluginEvents, } from '@capacitor-community/admob';
2
+ import { admobClientRewardEvidenceSchema } from '@mpgd/game-services/admob-client-reward';
3
+ import { admobSsvMaximumBindingFieldLength, encodeAdMobSsvCustomData, } from '@mpgd/game-services/admob-ssv';
4
+ const unitPattern = /^ca-app-pub-\d+\/\d+$/u;
5
+ const defaultShowTimeoutMs = 180_000;
6
+ const loadTimeoutMs = 30_000;
7
+ const preflightTimeoutMs = 10_000;
8
+ const listenerCleanupTimeoutMs = 1_000;
9
+ const maximumCustomDataBytes = 1_024;
10
+ // The native plugin's rewarded event stream and prepared-ad table are global.
11
+ // Coordinate Kit providers sharing one SDK instance, even across gateways.
12
+ const activeSdk = new WeakSet();
13
+ const uncertainSdk = new WeakSet();
14
+ function response(input, result) {
15
+ return { id: input.id, ok: true, data: result };
16
+ }
17
+ function failure(input, code, retryable = false) {
18
+ return { id: input.id, ok: false, error: { code, message: code, retryable } };
19
+ }
20
+ function isRecord(value) {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
22
+ }
23
+ function isRewardItem(value) {
24
+ return isRecord(value)
25
+ && typeof value.type === 'string'
26
+ && value.type.length > 0
27
+ && typeof value.amount === 'number'
28
+ && Number.isFinite(value.amount)
29
+ && value.amount > 0;
30
+ }
31
+ class PreflightTimeoutError extends Error {
32
+ }
33
+ async function withTimeout(promise, milliseconds, onLate) {
34
+ let timer;
35
+ let expired = false;
36
+ const watched = promise.then((value) => {
37
+ if (expired && onLate !== undefined) {
38
+ void Promise.resolve().then(() => onLate(value)).catch(() => undefined);
39
+ }
40
+ return value;
41
+ });
42
+ try {
43
+ return await Promise.race([
44
+ watched,
45
+ new Promise((_resolve, reject) => {
46
+ timer = setTimeout(() => {
47
+ expired = true;
48
+ reject(new PreflightTimeoutError('AdMob preflight timed out.'));
49
+ }, milliseconds);
50
+ }),
51
+ ]);
52
+ }
53
+ finally {
54
+ if (timer !== undefined) {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+ }
59
+ /**
60
+ * Opt-in rewarded-only provider. The SDK's SSV options are attached when an
61
+ * operation is shown, never to an earlier unbound preload. A local reward
62
+ * callback is only a signal to ask the backend; it is not a ledger grant.
63
+ */
64
+ export function createCapacitorAdMobRewardedProvider(input) {
65
+ const sdk = input.sdk ?? AdMob;
66
+ const showTimeoutMs = input.showTimeoutMs ?? defaultShowTimeoutMs;
67
+ if (!Number.isSafeInteger(showTimeoutMs) || showTimeoutMs < 1_000 || showTimeoutMs > 300_000) {
68
+ throw new Error('AdMob show timeout must be between 1 and 300 seconds.');
69
+ }
70
+ const adUnits = new Map();
71
+ for (const [placementId, unit] of Object.entries(input.adUnits)) {
72
+ if (placementId.trim() === '' || !unitPattern.test(unit)) {
73
+ throw new Error('AdMob rewarded placements require non-empty IDs and production-format ad units.');
74
+ }
75
+ adUnits.set(placementId, unit);
76
+ }
77
+ if (adUnits.size === 0) {
78
+ throw new Error('AdMob rewarded provider requires at least one ad unit.');
79
+ }
80
+ let initialized;
81
+ let consentTask;
82
+ let canRequestAds = false;
83
+ let privacyOptionsRequired = false;
84
+ const initialize = async () => {
85
+ if (initialized === undefined) {
86
+ initialized = sdk.initialize().catch((error) => {
87
+ initialized = undefined;
88
+ throw error;
89
+ });
90
+ }
91
+ await initialized;
92
+ };
93
+ const requestConsent = () => {
94
+ if (consentTask === undefined) {
95
+ consentTask = (async () => {
96
+ canRequestAds = false;
97
+ await initialize();
98
+ let info = await sdk.requestConsentInfo();
99
+ if (info.status === AdmobConsentStatus.REQUIRED && info.isConsentFormAvailable) {
100
+ info = await sdk.showConsentForm();
101
+ }
102
+ canRequestAds = info.canRequestAds === true;
103
+ privacyOptionsRequired = info.privacyOptionsRequirementStatus === 'REQUIRED';
104
+ return canRequestAds;
105
+ })().finally(() => { consentTask = undefined; });
106
+ }
107
+ return consentTask;
108
+ };
109
+ const showRewarded = async (placementId, idempotencyKey) => {
110
+ const adId = adUnits.get(placementId);
111
+ if (adId === undefined || !canRequestAds || activeSdk.has(sdk) || uncertainSdk.has(sdk)) {
112
+ return { status: 'unavailable', rewardGranted: false };
113
+ }
114
+ if (idempotencyKey.trim() === ''
115
+ || idempotencyKey.length > admobSsvMaximumBindingFieldLength
116
+ || placementId.length > admobSsvMaximumBindingFieldLength) {
117
+ return { status: 'failed', rewardGranted: false };
118
+ }
119
+ activeSdk.add(sdk);
120
+ const handles = [];
121
+ let rewardEarned = false;
122
+ let timer;
123
+ let loadTimer;
124
+ let loadTimedOut = false;
125
+ let nativePreflightStarted = false;
126
+ try {
127
+ const playerId = await withTimeout(Promise.resolve().then(() => input.getPlayerId()), preflightTimeoutMs);
128
+ if (typeof playerId !== 'string' || playerId.trim() === ''
129
+ || playerId.length > admobSsvMaximumBindingFieldLength) {
130
+ return { status: 'failed', rewardGranted: false };
131
+ }
132
+ const customData = encodeAdMobSsvCustomData({ playerId, placementId, idempotencyKey });
133
+ if (new TextEncoder().encode(customData).byteLength > maximumCustomDataBytes) {
134
+ return { status: 'failed', rewardGranted: false };
135
+ }
136
+ // This plugin resolves showRewardVideoAd on reward, but on Android a
137
+ // dismissal without reward leaves that promise unresolved. Observe the
138
+ // terminal native events separately, and hold lifecycle through close.
139
+ let finish;
140
+ const terminal = new Promise((resolve) => {
141
+ finish = resolve;
142
+ });
143
+ nativePreflightStarted = true;
144
+ handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.Rewarded, (reward) => {
145
+ if (isRewardItem(reward)) {
146
+ rewardEarned = true;
147
+ }
148
+ }), preflightTimeoutMs, (handle) => handle.remove()));
149
+ handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.Dismissed, () => {
150
+ // Let a same-turn Rewarded event settle before reading the flag.
151
+ queueMicrotask(() => finish('dismissed'));
152
+ }), preflightTimeoutMs, (handle) => handle.remove()));
153
+ handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.FailedToShow, () => {
154
+ finish('failed');
155
+ }), preflightTimeoutMs, (handle) => handle.remove()));
156
+ const loaded = await Promise.race([
157
+ sdk.prepareRewardVideoAd({
158
+ adId,
159
+ ...(input.isTesting === undefined ? {} : { isTesting: input.isTesting }),
160
+ ssv: { userId: playerId, customData },
161
+ }),
162
+ new Promise((_resolve, reject) => {
163
+ loadTimer = setTimeout(() => {
164
+ loadTimedOut = true;
165
+ reject(new Error('AdMob load timed out.'));
166
+ }, loadTimeoutMs);
167
+ }),
168
+ ]);
169
+ if (loadTimer !== undefined) {
170
+ clearTimeout(loadTimer);
171
+ loadTimer = undefined;
172
+ }
173
+ if (typeof loaded.adUnitId !== 'string' || loaded.adUnitId.length === 0) {
174
+ return { status: 'failed', rewardGranted: false };
175
+ }
176
+ timer = setTimeout(() => finish('timeout'), showTimeoutMs);
177
+ // Always observe rejection, even if a Dismissed event wins the race.
178
+ const showResult = sdk.showRewardVideoAd({ adId: loaded.adUnitId });
179
+ const onReward = (reward) => {
180
+ if (isRewardItem(reward)) {
181
+ rewardEarned = true;
182
+ }
183
+ };
184
+ const onShowError = () => finish('failed');
185
+ void showResult.then(onReward, onShowError);
186
+ const outcome = await terminal;
187
+ if (outcome === 'timeout') {
188
+ // Native presentation state is unknown: do not allow another show.
189
+ uncertainSdk.add(sdk);
190
+ }
191
+ let status = 'failed';
192
+ if (rewardEarned) {
193
+ status = 'completed';
194
+ }
195
+ else if (outcome === 'dismissed') {
196
+ // Mediation may deliver reward after dismissal. Only signed SSV can
197
+ // later distinguish a true skip from an earned reward.
198
+ status = 'pending';
199
+ }
200
+ else if (outcome === 'timeout') {
201
+ status = 'pending';
202
+ }
203
+ return {
204
+ status,
205
+ rewardGranted: false,
206
+ ...(status === 'completed' || status === 'pending' ? { evidence: {
207
+ schema: admobClientRewardEvidenceSchema,
208
+ payload: { adUnitId: adId },
209
+ } } : {}),
210
+ };
211
+ }
212
+ catch (error) {
213
+ const uncertain = loadTimedOut
214
+ || (nativePreflightStarted && error instanceof PreflightTimeoutError);
215
+ if (uncertain) {
216
+ uncertainSdk.add(sdk);
217
+ }
218
+ return { status: uncertain ? 'pending' : 'failed', rewardGranted: false };
219
+ }
220
+ finally {
221
+ if (loadTimer !== undefined) {
222
+ clearTimeout(loadTimer);
223
+ }
224
+ if (timer !== undefined) {
225
+ clearTimeout(timer);
226
+ }
227
+ try {
228
+ const removals = handles.map((handle) => {
229
+ const removal = Promise.resolve().then(() => handle.remove());
230
+ return withTimeout(removal, listenerCleanupTimeoutMs);
231
+ });
232
+ const settled = await Promise.allSettled(removals);
233
+ if (settled.some((result) => result.status === 'rejected')) {
234
+ uncertainSdk.add(sdk);
235
+ }
236
+ }
237
+ finally {
238
+ activeSdk.delete(sdk);
239
+ }
240
+ }
241
+ };
242
+ return {
243
+ id: 'admob-rewarded',
244
+ features: ['rewardedAds'],
245
+ methods: ['ads.preload', 'ads.showRewarded'],
246
+ async getAvailability() {
247
+ if (uncertainSdk.has(sdk) || !canRequestAds) {
248
+ return { rewardedAds: 'action-required' };
249
+ }
250
+ return { rewardedAds: activeSdk.has(sdk) ? 'temporarily-unavailable' : 'available' };
251
+ },
252
+ requestConsent,
253
+ async showPrivacyOptions() {
254
+ if (!privacyOptionsRequired) {
255
+ return;
256
+ }
257
+ canRequestAds = false;
258
+ await initialize();
259
+ try {
260
+ await sdk.showPrivacyOptionsForm();
261
+ }
262
+ finally {
263
+ await requestConsent();
264
+ }
265
+ },
266
+ bridge: {
267
+ async request(request) {
268
+ if (request.method === 'ads.preload') {
269
+ const payload = request.payload;
270
+ if (!isRecord(payload)
271
+ || (payload.format !== undefined && payload.format !== 'rewarded')
272
+ || typeof payload.placementId !== 'string'
273
+ || !adUnits.has(payload.placementId)) {
274
+ return failure(request, 'ADMOB_PLACEMENT_INVALID');
275
+ }
276
+ // Preloading an unbound ad would omit the operation's SSV key.
277
+ return response(request, undefined);
278
+ }
279
+ if (request.method !== 'ads.showRewarded' || !isRecord(request.payload)
280
+ || typeof request.payload.placementId !== 'string'
281
+ || typeof request.payload.idempotencyKey !== 'string') {
282
+ return failure(request, 'ADMOB_REQUEST_INVALID');
283
+ }
284
+ return response(request, await showRewarded(request.payload.placementId, request.payload.idempotencyKey));
285
+ },
286
+ },
287
+ };
288
+ }
@@ -0,0 +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;
@@ -0,0 +1,119 @@
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
+ }
package/dist/providers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isAdMobClientRewardEvidence } from '@mpgd/game-services/admob-client-reward';
1
2
  import { PlatformOperationError, } from '@mpgd/platform';
2
3
  const featureMethods = {
3
4
  nativeIap: [
@@ -253,11 +254,13 @@ function isValidProviderData(method, value) {
253
254
  return isVoidData(value);
254
255
  case 'ads.showRewarded':
255
256
  return isRecord(value)
256
- && ['completed', 'skipped', 'unavailable', 'failed'].includes(value.status)
257
+ && ['completed', 'pending', 'skipped', 'unavailable', 'failed'].includes(value.status)
257
258
  && typeof value.rewardGranted === 'boolean'
258
259
  && (!value.rewardGranted || (value.status === 'completed'
259
260
  && isString(value.ledgerEntryId)
260
261
  && value.ledgerEntryId.length > 0))
262
+ && (value.status !== 'completed' || value.rewardGranted
263
+ || value.evidence === undefined || isAdMobClientRewardEvidence(value))
261
264
  && (value.evidence === undefined || isEvidence(value.evidence));
262
265
  case 'ads.showInterstitial':
263
266
  return isRecord(value)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-capacitor",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Capacitor platform adapter for mpgd Android and iOS shells.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,14 @@
28
28
  ".": {
29
29
  "types": "./dist/index.d.ts",
30
30
  "default": "./dist/index.js"
31
+ },
32
+ "./admob": {
33
+ "types": "./dist/admob.d.ts",
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"
31
39
  }
32
40
  },
33
41
  "files": [
@@ -35,20 +43,32 @@
35
43
  ],
36
44
  "dependencies": {
37
45
  "@capacitor/app": "8.1.1",
46
+ "@mpgd/bridge": "0.10.0",
47
+ "@mpgd/platform": "0.14.0",
48
+ "@mpgd/target-config": "0.17.0",
38
49
  "@mpgd/capacitor-game-services": "0.6.0",
39
- "@mpgd/platform": "0.13.0",
40
- "@mpgd/target-config": "0.16.0",
41
- "@mpgd/bridge": "0.10.0"
50
+ "@mpgd/game-services": "0.17.1"
42
51
  },
43
52
  "peerDependencies": {
53
+ "@apple/app-store-server-library": "^3.1.0",
54
+ "@capacitor-community/admob": "^8.1.0",
44
55
  "@capacitor/core": "^8.5.1"
45
56
  },
57
+ "peerDependenciesMeta": {
58
+ "@apple/app-store-server-library": {
59
+ "optional": true
60
+ },
61
+ "@capacitor-community/admob": {
62
+ "optional": true
63
+ }
64
+ },
46
65
  "devDependencies": {
66
+ "@apple/app-store-server-library": "3.1.0",
67
+ "@capacitor-community/admob": "8.1.0",
47
68
  "@capacitor/core": "8.5.2",
48
69
  "ttsc": "0.30.4",
49
70
  "typescript": "7.0.2",
50
- "vitest": "^4.0.0",
51
- "@mpgd/game-services": "0.16.0"
71
+ "vitest": "^4.0.0"
52
72
  },
53
73
  "main": "./dist/index.js",
54
74
  "types": "./dist/index.d.ts",