@mpgd/capacitor-storekit 0.1.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/Package.swift ADDED
@@ -0,0 +1,23 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "MpgdCapacitorStoreKit",
6
+ platforms: [.iOS(.v15)],
7
+ products: [
8
+ .library(name: "MpgdCapacitorStoreKit", targets: ["MpgdCapacitorStoreKit"])
9
+ ],
10
+ dependencies: [
11
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2")
12
+ ],
13
+ targets: [
14
+ .target(
15
+ name: "MpgdCapacitorStoreKit",
16
+ dependencies: [
17
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
18
+ .product(name: "Cordova", package: "capacitor-swift-pm")
19
+ ],
20
+ path: "ios/Sources/CapacitorStoreKit"
21
+ )
22
+ ]
23
+ )
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @mpgd/capacitor-storekit
2
+
3
+ Private, opt-in StoreKit 2 one-time purchase collector for Capacitor 8 on iOS.
4
+ It does not enable native IAP in the base Capacitor shell. Install it only in a
5
+ game-owned iOS project and register `createCapacitorStoreKitProvider(...)` in
6
+ that game's provider list.
7
+
8
+ Map each logical game product to its App Store Connect product ID and provide
9
+ an authenticated, stable UUID `getAppAccountToken()` value. The game backend
10
+ must resolve the same UUID for the player. Product lookup, purchase results,
11
+ `Transaction.updates`, unfinished transactions, and current non-consumable
12
+ entitlements provide **provisional evidence**, never a wallet or entitlement
13
+ grant. A completed native purchase returns no `authoritativeGrant`.
14
+
15
+ On startup, account restoration, and after a pending or uncertain checkout,
16
+ call `recoverStoreKitPurchases({ provider, backend, playerId })`. It sends only
17
+ transactions whose `appAccountToken` matches the current authenticated game
18
+ account to the game-owned `backend.recoverPurchase` contract. That backend
19
+ must reuse the original idempotency key from the durable purchase journal, or
20
+ return the existing ledger grant for the same Apple evidence, product and
21
+ player. It must not invent a new key for an already-granted transaction: the
22
+ generic game-services verifier rejects such evidence replay. For new evidence,
23
+ the backend uses the existing App Store Server API/JWS verifier to validate
24
+ bundle, product, environment and account binding before the ledger grant.
25
+ The recovery helper calls native `finishTransaction` only after a
26
+ verified response with a ledger entry ID. If finish fails, it reports
27
+ `finishPending: true`; repeat recovery with the original purchase identity.
28
+ An unknown backend result stays pending and never triggers a
29
+ new purchase sheet automatically.
30
+
31
+ On the game server, `@mpgd/game-services/app-store-recovery` provides
32
+ `createAppStoreRecoveryBackend(...)` for this contract. Construct it only after
33
+ authenticating the game player, and bind `playerId` to that authenticated
34
+ identity. Pass the same durable `GameServicesStore` instance and
35
+ `createGameServicesBackend(...).purchases` used for checkout. Its optional
36
+ `resolveOriginalIdempotencyKey` callback must query the game's durable
37
+ checkout journal for the **original** key associated with this player and
38
+ transaction. The adapter first checks existing App Store ledger evidence and
39
+ rejects cross-player, cross-product, cross-deployment and key collisions. If
40
+ neither a matching grant nor an original journal key exists, it returns
41
+ `pending` without attempting a new grant. Do not use the in-memory store for
42
+ production recovery. The game's HTTP/RPC endpoint must authenticate the
43
+ request; this helper does not expose one or authenticate clients.
44
+ For restored non-consumables whose new transaction ID differs from the
45
+ original, configure `restoredNonConsumables` with the same catalog and official
46
+ App Store evidence verifier, bundle ID and environment used by the game backend.
47
+ When no prior grant exists, also bind `deploymentTarget` on the server-side
48
+ recovery backend. The client-supplied target is not trusted to select a product;
49
+ without a bound target this restore stays pending until the game backend is
50
+ configured for its deployment. An existing grant can supply its recorded target.
51
+ The bundle/environment identify an existing grant for its deployment target
52
+ before product lookup; they must match the verifier configuration. The helper re-verifies
53
+ the **current** Apple transaction and looks up the prior grant by its canonical
54
+ original-transaction verification ID before reusing the original key. Without
55
+ that verifier, this restore remains pending rather than trusting a client-
56
+ reported original ID. A verifier that exceeds its timeout also remains pending.
57
+ The native provider forwards both IDs and product type.
58
+ For production-scale ledgers, implement the store's indexed
59
+ `findEntitlementTransactionByPlatformEvidence` and
60
+ `findEntitlementTransactionByIdempotency` methods, plus
61
+ `findEntitlementTransactionByEvidenceVerificationId` for restored
62
+ non-consumables. Without them, the helper
63
+ uses the same full-ledger scan fallback as the generic game-services server.
64
+
65
+ An existing grant is returned by the normal backend idempotent-retry path;
66
+ that path relies on the prior verified ledger record rather than fetching
67
+ fresh Apple status. Continue to process App Store revocations separately.
68
+ Permanently rejected and account-mismatched transactions are reported as
69
+ `rejected` and are **not** finished: the helper cannot prove content delivery
70
+ for them. Persist that terminal result in the game-owned operation journal and
71
+ do not run an automatic retry loop for it. A support flow may recheck a
72
+ corrected account or backend decision later. One malformed transaction is
73
+ reported separately and cannot block valid sibling transactions.
74
+
75
+ The iOS native plugin also emits `transactionUpdated` for delayed StoreKit
76
+ transactions. Call recovery after this event and on app launch because events
77
+ can be missed while the game is closed. `commerce.restore` intentionally
78
+ invokes user-initiated `AppStore.sync()` before returning no local entitlements;
79
+ call recovery afterward. Subscriptions are not supported by this package;
80
+ they require a separate subscription status and renewal flow.
81
+
82
+ The Kit tests use mocked StoreKit responses and a syntax-only local Swift
83
+ parse. The macOS CI job compiles the optional Swift package against an iOS
84
+ Simulator SDK. Neither check proves a signed App Store purchase, sandbox
85
+ checkout, TestFlight installation, or device grant. Validate those with a
86
+ game-owned account, keys, and device before a production rollout.
@@ -0,0 +1,53 @@
1
+ import type { PluginListenerHandle } from '@capacitor/core';
2
+ export interface StoreKitProduct {
3
+ readonly productId: string;
4
+ readonly type: 'consumable' | 'non_consumable';
5
+ readonly title: string;
6
+ readonly description: string;
7
+ readonly formattedPrice: string;
8
+ readonly currencyCode: string;
9
+ }
10
+ export interface StoreKitTransaction {
11
+ readonly transactionId: string;
12
+ readonly originalTransactionId: string;
13
+ readonly productId: string;
14
+ readonly type: 'consumable' | 'non_consumable';
15
+ readonly appAccountToken?: string;
16
+ readonly purchasedAt: string;
17
+ readonly revokedAt?: string;
18
+ /** StoreKit-verified JWS; the game backend independently verifies Apple data. */
19
+ readonly signedTransaction: string;
20
+ }
21
+ export type StoreKitPurchaseOutcome = {
22
+ readonly status: 'purchased';
23
+ readonly transaction: StoreKitTransaction;
24
+ } | {
25
+ readonly status: 'pending' | 'cancelled';
26
+ };
27
+ export interface CapacitorStoreKitPlugin {
28
+ getProducts(input: {
29
+ readonly productIds: readonly string[];
30
+ }): Promise<{
31
+ readonly products: readonly StoreKitProduct[];
32
+ }>;
33
+ purchase(input: {
34
+ readonly productId: string;
35
+ readonly appAccountToken: string;
36
+ }): Promise<StoreKitPurchaseOutcome>;
37
+ /** Includes unfinished consumables and current non-consumable entitlements. */
38
+ getTransactions(): Promise<{
39
+ readonly transactions: readonly StoreKitTransaction[];
40
+ }>;
41
+ /** User-initiated App Store account synchronization before restore. */
42
+ sync(): Promise<{
43
+ readonly synced: boolean;
44
+ }>;
45
+ /** Call only after the authenticated backend confirms the ledger grant. */
46
+ finishTransaction(input: {
47
+ readonly transactionId: string;
48
+ readonly ledgerEntryId: string;
49
+ }): Promise<{
50
+ readonly finished: boolean;
51
+ }>;
52
+ addListener(eventName: 'transactionUpdated', listener: (event: StoreKitTransaction) => void): Promise<PluginListenerHandle>;
53
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from './definitions.js';
2
+ export * from './plugin.js';
3
+ export * from './provider.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './definitions.js';
2
+ export * from './plugin.js';
3
+ export * from './provider.js';
@@ -0,0 +1,2 @@
1
+ import type { CapacitorStoreKitPlugin } from './definitions.js';
2
+ export declare const CapacitorStoreKit: CapacitorStoreKitPlugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,2 @@
1
+ import { registerPlugin } from '@capacitor/core';
2
+ export const CapacitorStoreKit = registerPlugin('CapacitorStoreKit');
@@ -0,0 +1,94 @@
1
+ import type { BridgeRequest, BridgeResponse } from '@mpgd/bridge';
2
+ import type { PurchaseResult } from '@mpgd/platform';
3
+ import type { CapacitorStoreKitPlugin } from './definitions.js';
4
+ export interface StoreKitProductMapping {
5
+ readonly id: string;
6
+ readonly storeId: string;
7
+ readonly type: 'consumable' | 'non_consumable';
8
+ }
9
+ export interface StoreKitProviderOptions {
10
+ readonly products: readonly StoreKitProductMapping[];
11
+ /** Must agree with the authenticated backend's resolveAppAccountToken. */
12
+ readonly getAppAccountToken: () => string | Promise<string>;
13
+ readonly sdk?: Pick<CapacitorStoreKitPlugin, 'getProducts' | 'purchase' | 'getTransactions' | 'sync' | 'finishTransaction'>;
14
+ readonly isIos?: () => boolean;
15
+ }
16
+ export interface RecoverableStoreKitTransaction {
17
+ readonly productId: string;
18
+ readonly originalTransactionId: string;
19
+ readonly type: 'consumable' | 'non_consumable';
20
+ readonly appAccountToken?: string;
21
+ readonly purchasedAt: string;
22
+ readonly result: PurchaseResult;
23
+ }
24
+ export interface InvalidStoreKitTransaction {
25
+ readonly productId: string;
26
+ readonly transactionId?: string;
27
+ readonly reason: 'invalid-evidence' | 'revoked';
28
+ }
29
+ export interface StoreKitRecoverySnapshot {
30
+ readonly transactions: readonly RecoverableStoreKitTransaction[];
31
+ readonly invalid: readonly InvalidStoreKitTransaction[];
32
+ }
33
+ export interface CapacitorStoreKitProvider {
34
+ readonly id: 'apple-storekit';
35
+ readonly features: readonly ['nativeIap'];
36
+ readonly methods: readonly [
37
+ 'commerce.getProducts',
38
+ 'commerce.purchase',
39
+ 'commerce.restore',
40
+ 'commerce.getEntitlements'
41
+ ];
42
+ readonly bridge: {
43
+ request(input: BridgeRequest): Promise<BridgeResponse>;
44
+ };
45
+ getAvailability(): Promise<Readonly<{
46
+ nativeIap: 'available' | 'configuration-required' | 'temporarily-unavailable' | 'unsupported';
47
+ }>>;
48
+ getAppAccountToken(): Promise<string>;
49
+ getRecoverableTransactions(): Promise<StoreKitRecoverySnapshot>;
50
+ finishGrantedTransaction(input: {
51
+ readonly transactionId: string;
52
+ readonly ledgerEntryId: string;
53
+ }): Promise<boolean>;
54
+ }
55
+ export interface StoreKitPurchaseVerification {
56
+ readonly verified: boolean;
57
+ readonly disposition?: 'pending' | 'rejected';
58
+ readonly alreadyProcessed: boolean;
59
+ readonly ledgerEntryId?: string;
60
+ readonly reason?: string;
61
+ }
62
+ export interface StoreKitRecoveryBackend {
63
+ /**
64
+ * Resolve a journaled checkout with its ORIGINAL idempotency key, or return
65
+ * the already-recorded grant for matching App Store evidence and owner.
66
+ * The backend must verify Apple data before returning verified=true.
67
+ */
68
+ recoverPurchase(input: {
69
+ readonly target: 'ios';
70
+ readonly deploymentTarget?: string;
71
+ readonly playerId: string;
72
+ readonly productId: string;
73
+ readonly platformTransactionId: string;
74
+ readonly originalTransactionId: string;
75
+ readonly productType: 'consumable' | 'non_consumable';
76
+ readonly purchasedAt: string;
77
+ }): Promise<StoreKitPurchaseVerification>;
78
+ }
79
+ export interface RecoveredStoreKitPurchase {
80
+ readonly productId: string;
81
+ readonly transactionId?: string;
82
+ readonly status: 'granted' | 'pending' | 'rejected';
83
+ readonly reason?: 'invalid-evidence' | 'revoked' | 'account-mismatch';
84
+ readonly finishPending?: boolean;
85
+ readonly verification?: StoreKitPurchaseVerification;
86
+ }
87
+ /** Submit unfinished purchases to the backend before asking StoreKit to finish. */
88
+ export declare function recoverStoreKitPurchases(input: {
89
+ readonly provider: CapacitorStoreKitProvider;
90
+ readonly backend: StoreKitRecoveryBackend;
91
+ readonly playerId: string;
92
+ readonly deploymentTarget?: string;
93
+ }): Promise<readonly RecoveredStoreKitPurchase[]>;
94
+ export declare function createCapacitorStoreKitProvider(options: StoreKitProviderOptions): CapacitorStoreKitProvider;
@@ -0,0 +1,297 @@
1
+ import { Capacitor } from '@capacitor/core';
2
+ import { CapacitorStoreKit } from './plugin.js';
3
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
4
+ const productPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
5
+ const transactionPattern = /^[1-9][0-9]{0,19}$/u;
6
+ /** Submit unfinished purchases to the backend before asking StoreKit to finish. */
7
+ export async function recoverStoreKitPurchases(input) {
8
+ if (input.playerId.trim() === '') {
9
+ throw new TypeError('StoreKit recovery requires an authenticated player ID.');
10
+ }
11
+ const token = await input.provider.getAppAccountToken();
12
+ const snapshot = await input.provider.getRecoverableTransactions();
13
+ const recovered = await Promise.all(snapshot.transactions.map(async (item) => {
14
+ const transactionId = item.result.transactionId;
15
+ if (transactionId === undefined) {
16
+ return { productId: item.productId, status: 'rejected', reason: 'invalid-evidence' };
17
+ }
18
+ if (item.appAccountToken?.toLowerCase() !== token) {
19
+ // Shared Apple IDs can expose a transaction owned by another game user.
20
+ return {
21
+ productId: item.productId, transactionId, status: 'rejected', reason: 'account-mismatch',
22
+ };
23
+ }
24
+ try {
25
+ const verification = await input.backend.recoverPurchase({
26
+ target: 'ios',
27
+ ...(input.deploymentTarget === undefined ? {} : { deploymentTarget: input.deploymentTarget }),
28
+ playerId: input.playerId,
29
+ productId: item.productId,
30
+ platformTransactionId: transactionId,
31
+ originalTransactionId: item.originalTransactionId,
32
+ productType: item.type,
33
+ purchasedAt: item.purchasedAt,
34
+ });
35
+ if (!verification.verified) {
36
+ return {
37
+ productId: item.productId,
38
+ transactionId,
39
+ status: verification.disposition ?? 'pending',
40
+ verification,
41
+ };
42
+ }
43
+ if (verification.ledgerEntryId === undefined || verification.ledgerEntryId.trim() === '') {
44
+ return { productId: item.productId, transactionId, status: 'pending', verification };
45
+ }
46
+ try {
47
+ const finished = await input.provider.finishGrantedTransaction({
48
+ transactionId,
49
+ ledgerEntryId: verification.ledgerEntryId,
50
+ });
51
+ return {
52
+ productId: item.productId,
53
+ transactionId,
54
+ status: 'granted',
55
+ finishPending: !finished,
56
+ verification,
57
+ };
58
+ }
59
+ catch {
60
+ return {
61
+ productId: item.productId,
62
+ transactionId,
63
+ status: 'granted',
64
+ finishPending: true,
65
+ verification,
66
+ };
67
+ }
68
+ }
69
+ catch {
70
+ return { productId: item.productId, transactionId, status: 'pending' };
71
+ }
72
+ }));
73
+ return [
74
+ ...recovered,
75
+ ...snapshot.invalid.map((item) => ({ ...item, status: 'rejected' })),
76
+ ];
77
+ }
78
+ export function createCapacitorStoreKitProvider(options) {
79
+ const sdk = options.sdk ?? CapacitorStoreKit;
80
+ const isIos = options.isIos ?? (() => Capacitor.getPlatform() === 'ios');
81
+ const byLogicalId = new Map();
82
+ const byStoreId = new Map();
83
+ for (const product of options.products) {
84
+ if (!productPattern.test(product.id)
85
+ || !productPattern.test(product.storeId)
86
+ || byLogicalId.has(product.id)
87
+ || byStoreId.has(product.storeId)) {
88
+ throw new TypeError('StoreKit product mappings must be unique and valid.');
89
+ }
90
+ byLogicalId.set(product.id, product);
91
+ byStoreId.set(product.storeId, product);
92
+ }
93
+ if (byLogicalId.size === 0 || byLogicalId.size > 100) {
94
+ throw new TypeError('StoreKit requires 1-100 one-time product mappings.');
95
+ }
96
+ async function getAppAccountToken() {
97
+ const token = (await options.getAppAccountToken()).toLowerCase();
98
+ if (!uuidPattern.test(token)) {
99
+ throw new TypeError('StoreKit requires a UUID app account token.');
100
+ }
101
+ return token;
102
+ }
103
+ async function getRecoverableTransactions() {
104
+ if (!isIos()) {
105
+ throw new Error('StoreKit is only available on iOS.');
106
+ }
107
+ const response = await sdk.getTransactions();
108
+ const owned = [];
109
+ const invalid = [];
110
+ const seen = new Set();
111
+ for (const transaction of response.transactions) {
112
+ const mapping = byStoreId.get(transaction.productId);
113
+ if (mapping === undefined) {
114
+ continue;
115
+ }
116
+ if (mapping.type !== transaction.type || !validTransaction(transaction)) {
117
+ invalid.push({
118
+ productId: mapping.id,
119
+ transactionId: transaction.transactionId,
120
+ reason: 'invalid-evidence',
121
+ });
122
+ continue;
123
+ }
124
+ if (transaction.revokedAt !== undefined) {
125
+ invalid.push({
126
+ productId: mapping.id,
127
+ transactionId: transaction.transactionId,
128
+ reason: 'revoked',
129
+ });
130
+ continue;
131
+ }
132
+ if (seen.has(transaction.transactionId)) {
133
+ continue;
134
+ }
135
+ seen.add(transaction.transactionId);
136
+ owned.push({
137
+ productId: mapping.id,
138
+ originalTransactionId: transaction.originalTransactionId,
139
+ type: transaction.type,
140
+ ...(transaction.appAccountToken === undefined
141
+ ? {} : { appAccountToken: transaction.appAccountToken }),
142
+ purchasedAt: transaction.purchasedAt,
143
+ result: toPurchaseResult(transaction),
144
+ });
145
+ }
146
+ return { transactions: owned, invalid };
147
+ }
148
+ async function request(input) {
149
+ if (!isIos()) {
150
+ return failure(input.id, 'STOREKIT_IOS_REQUIRED');
151
+ }
152
+ try {
153
+ switch (input.method) {
154
+ case 'commerce.getProducts': {
155
+ const response = await sdk.getProducts({ productIds: [...byStoreId.keys()] });
156
+ const products = [];
157
+ for (const item of response.products) {
158
+ const mapping = byStoreId.get(item.productId);
159
+ if (mapping === undefined || mapping.type !== item.type) {
160
+ continue;
161
+ }
162
+ products.push({
163
+ id: mapping.id,
164
+ type: mapping.type,
165
+ title: item.title,
166
+ description: item.description,
167
+ price: { formatted: item.formattedPrice, currencyCode: item.currencyCode },
168
+ });
169
+ }
170
+ return success(input.id, products);
171
+ }
172
+ case 'commerce.purchase': {
173
+ const payload = input.payload;
174
+ const productId = isRecord(payload) ? payload.productId : undefined;
175
+ const mapping = typeof productId === 'string' ? byLogicalId.get(productId) : undefined;
176
+ if (mapping === undefined) {
177
+ return failure(input.id, 'STOREKIT_PRODUCT_UNKNOWN');
178
+ }
179
+ const appAccountToken = await getAppAccountToken();
180
+ const outcome = await sdk.purchase({
181
+ productId: mapping.storeId,
182
+ appAccountToken,
183
+ });
184
+ return success(input.id, convertOutcome(outcome, mapping, appAccountToken));
185
+ }
186
+ case 'commerce.restore':
187
+ await sdk.sync();
188
+ return success(input.id, { restoredEntitlements: [] });
189
+ case 'commerce.getEntitlements':
190
+ return success(input.id, []);
191
+ default:
192
+ return failure(input.id, 'STOREKIT_METHOD_UNSUPPORTED');
193
+ }
194
+ }
195
+ catch (error) {
196
+ const code = isRecord(error) ? error.code : undefined;
197
+ if (input.method === 'commerce.purchase' && code === 'STOREKIT_PURCHASE_UNCERTAIN') {
198
+ return success(input.id, { status: 'pending', entitlementIds: [] });
199
+ }
200
+ if (typeof code === 'string' && /^STOREKIT_[A-Z_]+$/u.test(code)) {
201
+ const retryable = code === 'STOREKIT_UNAVAILABLE'
202
+ || code === 'STOREKIT_PRODUCT_LOOKUP_FAILED'
203
+ || code === 'STOREKIT_SYNC_FAILED';
204
+ return failure(input.id, code, retryable);
205
+ }
206
+ if (error instanceof TypeError) {
207
+ return failure(input.id, 'STOREKIT_CONFIGURATION_ERROR');
208
+ }
209
+ return failure(input.id, 'STOREKIT_UNAVAILABLE', true);
210
+ }
211
+ }
212
+ return {
213
+ id: 'apple-storekit',
214
+ features: ['nativeIap'],
215
+ methods: [
216
+ 'commerce.getProducts',
217
+ 'commerce.purchase',
218
+ 'commerce.restore',
219
+ 'commerce.getEntitlements',
220
+ ],
221
+ bridge: { request },
222
+ async getAvailability() {
223
+ if (!isIos()) {
224
+ return { nativeIap: 'unsupported' };
225
+ }
226
+ try {
227
+ await getAppAccountToken();
228
+ const response = await sdk.getProducts({ productIds: [...byStoreId.keys()] });
229
+ const available = response.products.some((item) => {
230
+ const mapping = byStoreId.get(item.productId);
231
+ return mapping !== undefined && mapping.type === item.type;
232
+ });
233
+ return { nativeIap: available ? 'available' : 'configuration-required' };
234
+ }
235
+ catch (error) {
236
+ const code = isRecord(error) ? error.code : undefined;
237
+ return { nativeIap: code === 'STOREKIT_INVALID_PRODUCTS'
238
+ || code === 'STOREKIT_PRODUCT_UNAVAILABLE'
239
+ || error instanceof TypeError
240
+ ? 'configuration-required' : 'temporarily-unavailable' };
241
+ }
242
+ },
243
+ getAppAccountToken,
244
+ getRecoverableTransactions,
245
+ async finishGrantedTransaction(input) {
246
+ if (!isIos() || !transactionPattern.test(input.transactionId)
247
+ || input.ledgerEntryId.trim() === '') {
248
+ throw new TypeError('StoreKit finish requires a verified grant and transaction ID.');
249
+ }
250
+ const response = await sdk.finishTransaction(input);
251
+ return response.finished;
252
+ },
253
+ };
254
+ }
255
+ function convertOutcome(outcome, mapping, expectedAccountToken) {
256
+ if (outcome.status !== 'purchased') {
257
+ return { status: outcome.status, entitlementIds: [] };
258
+ }
259
+ if (outcome.transaction.productId !== mapping.storeId
260
+ || outcome.transaction.type !== mapping.type
261
+ || outcome.transaction.appAccountToken?.toLowerCase() !== expectedAccountToken
262
+ || !validTransaction(outcome.transaction)) {
263
+ throw Object.assign(new Error('StoreKit purchase evidence is invalid.'), {
264
+ code: 'STOREKIT_EVIDENCE_INVALID',
265
+ });
266
+ }
267
+ return toPurchaseResult(outcome.transaction);
268
+ }
269
+ function toPurchaseResult(transaction) {
270
+ return {
271
+ status: 'completed',
272
+ transactionId: transaction.transactionId,
273
+ entitlementIds: [],
274
+ };
275
+ }
276
+ function validTransaction(value) {
277
+ return transactionPattern.test(value.transactionId)
278
+ && /^[0-9]{1,20}$/u.test(value.originalTransactionId)
279
+ && productPattern.test(value.productId)
280
+ && (value.type === 'consumable' || value.type === 'non_consumable')
281
+ && Number.isFinite(Date.parse(value.purchasedAt))
282
+ && typeof value.signedTransaction === 'string'
283
+ && value.signedTransaction.length > 0
284
+ && value.signedTransaction.length <= 128 * 1024
285
+ && value.signedTransaction.split('.').length === 3
286
+ && (value.revokedAt === undefined || Number.isFinite(Date.parse(value.revokedAt)))
287
+ && (value.appAccountToken === undefined || uuidPattern.test(value.appAccountToken));
288
+ }
289
+ function isRecord(value) {
290
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
291
+ }
292
+ function success(id, data) {
293
+ return { id, ok: true, data };
294
+ }
295
+ function failure(id, code, retryable = false) {
296
+ return { id, ok: false, error: { code, message: code, retryable } };
297
+ }
@@ -0,0 +1,199 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import StoreKit
4
+
5
+ @objc(CapacitorStoreKitPlugin)
6
+ public class CapacitorStoreKitPlugin: CAPPlugin, CAPBridgedPlugin {
7
+ public let identifier = "CapacitorStoreKitPlugin"
8
+ public let jsName = "CapacitorStoreKit"
9
+ public let pluginMethods: [CAPPluginMethod] = [
10
+ CAPPluginMethod(name: "getProducts", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "getTransactions", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "sync", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "finishTransaction", returnType: CAPPluginReturnPromise)
15
+ ]
16
+
17
+ private var updatesTask: Task<Void, Never>?
18
+
19
+ @objc override public func load() {
20
+ super.load()
21
+ updatesTask = Task { @MainActor [weak self] in
22
+ for await result in Transaction.updates {
23
+ guard !Task.isCancelled else { return }
24
+ if let payload = Self.verifiedPayload(result) {
25
+ self?.notifyListeners("transactionUpdated", data: payload)
26
+ }
27
+ }
28
+ }
29
+ }
30
+
31
+ deinit {
32
+ updatesTask?.cancel()
33
+ }
34
+
35
+ @objc func getProducts(_ call: CAPPluginCall) {
36
+ guard let identifiers = call.options["productIds"] as? [String],
37
+ !identifiers.isEmpty, identifiers.count <= 100,
38
+ identifiers.allSatisfy({ Self.validProductId($0) }) else {
39
+ call.reject("Provide 1-100 valid product IDs.", "STOREKIT_INVALID_PRODUCTS")
40
+ return
41
+ }
42
+ Task { @MainActor in
43
+ do {
44
+ let products = try await Product.products(for: identifiers)
45
+ let values: [[String: Any]] = products.compactMap { product in
46
+ guard let type = Self.oneTimeType(product.type) else { return nil }
47
+ return [
48
+ "productId": product.id,
49
+ "type": type,
50
+ "title": product.displayName,
51
+ "description": product.description,
52
+ "formattedPrice": product.displayPrice,
53
+ "currencyCode": product.priceFormatStyle.currencyCode
54
+ ]
55
+ }
56
+ call.resolve(["products": values])
57
+ } catch {
58
+ call.reject("StoreKit product lookup failed.", "STOREKIT_UNAVAILABLE", error)
59
+ }
60
+ }
61
+ }
62
+
63
+ @objc func purchase(_ call: CAPPluginCall) {
64
+ guard let productId = call.getString("productId"), Self.validProductId(productId),
65
+ let rawAccountToken = call.getString("appAccountToken"),
66
+ let accountToken = UUID(uuidString: rawAccountToken) else {
67
+ call.reject("A product ID and app account token are required.", "STOREKIT_INVALID_PURCHASE")
68
+ return
69
+ }
70
+ Task { @MainActor in
71
+ let product: Product
72
+ do {
73
+ guard let found = try await Product.products(for: [productId]).first,
74
+ found.id == productId,
75
+ Self.oneTimeType(found.type) != nil else {
76
+ call.reject("The one-time product is unavailable.", "STOREKIT_PRODUCT_UNAVAILABLE")
77
+ return
78
+ }
79
+ product = found
80
+ } catch {
81
+ call.reject("StoreKit product lookup failed.", "STOREKIT_PRODUCT_LOOKUP_FAILED", error)
82
+ return
83
+ }
84
+ do {
85
+ let result = try await product.purchase(options: [.appAccountToken(accountToken)])
86
+ switch result {
87
+ case .success(let verification):
88
+ guard let payload = Self.verifiedPayload(verification),
89
+ payload["productId"] as? String == productId,
90
+ (payload["appAccountToken"] as? String)?.lowercased()
91
+ == accountToken.uuidString.lowercased() else {
92
+ call.reject("StoreKit transaction failed verification or account binding.",
93
+ "STOREKIT_TRANSACTION_UNVERIFIED")
94
+ return
95
+ }
96
+ // Never finish here: the game backend must commit its ledger grant first.
97
+ call.resolve(["status": "purchased", "transaction": payload])
98
+ case .pending:
99
+ call.resolve(["status": "pending"])
100
+ case .userCancelled:
101
+ call.resolve(["status": "cancelled"])
102
+ @unknown default:
103
+ call.reject("StoreKit returned an unknown purchase result.", "STOREKIT_UNKNOWN_RESULT")
104
+ }
105
+ } catch {
106
+ // The purchase sheet may have completed before an error reached us.
107
+ call.reject("StoreKit purchase result is uncertain; requery transactions.",
108
+ "STOREKIT_PURCHASE_UNCERTAIN", error)
109
+ }
110
+ }
111
+ }
112
+
113
+ @objc func getTransactions(_ call: CAPPluginCall) {
114
+ Task {
115
+ var byId: [String: [String: Any]] = [:]
116
+ for await result in Transaction.unfinished {
117
+ if let payload = Self.verifiedPayload(result),
118
+ let id = payload["transactionId"] as? String {
119
+ byId[id] = payload
120
+ }
121
+ }
122
+ for await result in Transaction.currentEntitlements {
123
+ if let payload = Self.verifiedPayload(result),
124
+ payload["type"] as? String == "non_consumable",
125
+ let id = payload["transactionId"] as? String {
126
+ byId[id] = payload
127
+ }
128
+ }
129
+ call.resolve(["transactions": Array(byId.values)])
130
+ }
131
+ }
132
+
133
+ @objc func sync(_ call: CAPPluginCall) {
134
+ Task { @MainActor in
135
+ do {
136
+ try await AppStore.sync()
137
+ call.resolve(["synced": true])
138
+ } catch {
139
+ call.reject("StoreKit account synchronization failed.", "STOREKIT_SYNC_FAILED", error)
140
+ }
141
+ }
142
+ }
143
+
144
+ @objc func finishTransaction(_ call: CAPPluginCall) {
145
+ guard let rawId = call.getString("transactionId"), let id = UInt64(rawId),
146
+ let ledgerEntryId = call.getString("ledgerEntryId"),
147
+ !ledgerEntryId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
148
+ call.reject("A transaction ID and confirmed ledger entry are required.",
149
+ "STOREKIT_FINISH_REQUIRES_GRANT")
150
+ return
151
+ }
152
+ Task {
153
+ for await result in Transaction.all {
154
+ guard case .verified(let transaction) = result,
155
+ transaction.id == id,
156
+ transaction.revocationDate == nil,
157
+ Self.oneTimeType(transaction.productType) != nil else { continue }
158
+ await transaction.finish()
159
+ call.resolve(["finished": true])
160
+ return
161
+ }
162
+ call.resolve(["finished": false])
163
+ }
164
+ }
165
+
166
+ private static func verifiedPayload(
167
+ _ result: VerificationResult<Transaction>
168
+ ) -> [String: Any]? {
169
+ guard case .verified(let transaction) = result,
170
+ let type = oneTimeType(transaction.productType) else { return nil }
171
+ var payload: [String: Any] = [
172
+ "transactionId": String(transaction.id),
173
+ "originalTransactionId": String(transaction.originalID),
174
+ "productId": transaction.productID,
175
+ "type": type,
176
+ "purchasedAt": ISO8601DateFormatter().string(from: transaction.purchaseDate),
177
+ "signedTransaction": result.jwsRepresentation
178
+ ]
179
+ if let accountToken = transaction.appAccountToken {
180
+ payload["appAccountToken"] = accountToken.uuidString.lowercased()
181
+ }
182
+ if let revocationDate = transaction.revocationDate {
183
+ payload["revokedAt"] = ISO8601DateFormatter().string(from: revocationDate)
184
+ }
185
+ return payload
186
+ }
187
+
188
+ private static func oneTimeType(_ type: Product.ProductType) -> String? {
189
+ switch type {
190
+ case .consumable: return "consumable"
191
+ case .nonConsumable: return "non_consumable"
192
+ default: return nil
193
+ }
194
+ }
195
+
196
+ private static func validProductId(_ value: String) -> Bool {
197
+ value.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", options: .regularExpression) != nil
198
+ }
199
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@mpgd/capacitor-storekit",
3
+ "version": "0.1.0",
4
+ "description": "Opt-in StoreKit 2 one-time purchase collector for mpgd Capacitor games.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/imjlk/mpgd-kit.git",
9
+ "directory": "native-plugins/capacitor-storekit"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "capacitor": {
20
+ "ios": {
21
+ "src": "ios"
22
+ }
23
+ },
24
+ "files": ["dist", "ios", "Package.swift"],
25
+ "scripts": {
26
+ "check": "ttsc --noEmit",
27
+ "lint": "ttsc --noEmit",
28
+ "test": "cd ../.. && node tools/run-ttsx.mjs native-plugins/capacitor-storekit/src/provider.test.ts"
29
+ },
30
+ "peerDependencies": {
31
+ "@capacitor/core": "^8.5.1",
32
+ "@mpgd/bridge": "^0.10.0",
33
+ "@mpgd/platform": "^0.13.0 || ^0.14.0"
34
+ },
35
+ "devDependencies": {
36
+ "@capacitor/core": "8.5.2",
37
+ "@mpgd/adapter-capacitor": "workspace:*",
38
+ "@mpgd/bridge": "workspace:*",
39
+ "@mpgd/game-services": "workspace:*",
40
+ "@mpgd/platform": "workspace:*",
41
+ "ttsc": "0.30.4",
42
+ "typescript": "7.0.2"
43
+ },
44
+ "main": "./dist/index.js",
45
+ "types": "./dist/index.d.ts",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }