@ait-kit/sdk 0.2.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/dist/event-flow.d.ts +36 -0
  4. package/dist/event-flow.d.ts.map +1 -0
  5. package/dist/event-flow.js +101 -0
  6. package/dist/iap/adapter.d.ts +47 -0
  7. package/dist/iap/adapter.d.ts.map +1 -0
  8. package/dist/iap/adapter.js +175 -0
  9. package/dist/iap/grant-coordinator.d.ts +36 -0
  10. package/dist/iap/grant-coordinator.d.ts.map +1 -0
  11. package/dist/iap/grant-coordinator.js +69 -0
  12. package/dist/iap/platform-contract.d.ts +55 -0
  13. package/dist/iap/platform-contract.d.ts.map +1 -0
  14. package/dist/iap/platform-contract.js +26 -0
  15. package/dist/iap/purchase-flow.d.ts +34 -0
  16. package/dist/iap/purchase-flow.d.ts.map +1 -0
  17. package/dist/iap/purchase-flow.js +167 -0
  18. package/dist/identity/platform-contract.d.ts +44 -0
  19. package/dist/identity/platform-contract.d.ts.map +1 -0
  20. package/dist/identity/platform-contract.js +71 -0
  21. package/dist/index.d.ts +215 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +16 -0
  24. package/dist/notification/platform-contract.d.ts +43 -0
  25. package/dist/notification/platform-contract.d.ts.map +1 -0
  26. package/dist/notification/platform-contract.js +89 -0
  27. package/dist/rn/ads.d.ts +39 -0
  28. package/dist/rn/ads.d.ts.map +1 -0
  29. package/dist/rn/ads.js +226 -0
  30. package/dist/rn/framework-contract.d.ts +45 -0
  31. package/dist/rn/framework-contract.d.ts.map +1 -0
  32. package/dist/rn/framework-contract.js +7 -0
  33. package/dist/rn/framework-loader.d.ts +22 -0
  34. package/dist/rn/framework-loader.d.ts.map +1 -0
  35. package/dist/rn/framework-loader.js +26 -0
  36. package/dist/rn/iap.d.ts +19 -0
  37. package/dist/rn/iap.d.ts.map +1 -0
  38. package/dist/rn/iap.js +35 -0
  39. package/dist/rn/identity.d.ts +20 -0
  40. package/dist/rn/identity.d.ts.map +1 -0
  41. package/dist/rn/identity.js +42 -0
  42. package/dist/rn/index.d.ts +27 -0
  43. package/dist/rn/index.d.ts.map +1 -0
  44. package/dist/rn/index.js +24 -0
  45. package/dist/rn/notify-share.d.ts +42 -0
  46. package/dist/rn/notify-share.d.ts.map +1 -0
  47. package/dist/rn/notify-share.js +159 -0
  48. package/dist/rn/storage.d.ts +18 -0
  49. package/dist/rn/storage.d.ts.map +1 -0
  50. package/dist/rn/storage.js +64 -0
  51. package/dist/share/platform-contract.d.ts +41 -0
  52. package/dist/share/platform-contract.d.ts.map +1 -0
  53. package/dist/share/platform-contract.js +34 -0
  54. package/dist/storage/platform-contract.d.ts +26 -0
  55. package/dist/storage/platform-contract.d.ts.map +1 -0
  56. package/dist/storage/platform-contract.js +16 -0
  57. package/dist/web/framework-loader.d.ts +9 -0
  58. package/dist/web/framework-loader.d.ts.map +1 -0
  59. package/dist/web/framework-loader.js +32 -0
  60. package/dist/web/iap-contract.d.ts +13 -0
  61. package/dist/web/iap-contract.d.ts.map +1 -0
  62. package/dist/web/iap-contract.js +1 -0
  63. package/dist/web/identity.d.ts +30 -0
  64. package/dist/web/identity.d.ts.map +1 -0
  65. package/dist/web/identity.js +87 -0
  66. package/dist/web/index.d.ts +47 -0
  67. package/dist/web/index.d.ts.map +1 -0
  68. package/dist/web/index.js +43 -0
  69. package/dist/web/notify-share.d.ts +37 -0
  70. package/dist/web/notify-share.d.ts.map +1 -0
  71. package/dist/web/notify-share.js +154 -0
  72. package/package.json +66 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 imjlk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,236 @@
1
+ # @ait-kit/sdk
2
+
3
+ Frontend SDK adapters for Apps in Toss mini apps: shared contracts plus
4
+ per-runtime entry points.
5
+
6
+ ```bash
7
+ npm install @ait-kit/sdk
8
+ ```
9
+
10
+ ## Entry points
11
+
12
+ | Entry | Use it for |
13
+ |---|---|
14
+ | `@ait-kit/sdk` (root) | Runtime-neutral shared contracts: ad + IAP types, `SdkError`. Importable anywhere — plain Node, web, React Native — with no official SDK installed. |
15
+ | `@ait-kit/sdk/rn` | React Native adapters (full-screen ads, IAP). Requires the official `@apps-in-toss/framework`, declared as an **optional peer** and imported lazily. Never requires the web SDK. |
16
+ | `@ait-kit/sdk/web` | Web adapters (IAP). Requires the official `@apps-in-toss/web-framework`, declared as an **optional peer** and imported lazily. Never requires the React Native SDK. |
17
+
18
+ The two platform entries share one internal engine but keep completely
19
+ separate SDK connections — in JavaScript and in the shipped type
20
+ declarations — so an `/rn` consumer never installs the web package and vice
21
+ versa.
22
+
23
+ ## In-app purchases (RN + Web)
24
+
25
+ Both entries expose the same IAP surface; only the loader differs:
26
+
27
+ ```ts
28
+ import { createReactNativeIap } from "@ait-kit/sdk/rn"; // or:
29
+ import { createWebIap } from "@ait-kit/sdk/web";
30
+
31
+ const iap = createReactNativeIap({
32
+ // The ONLY place product delivery happens. Resolve only after YOUR
33
+ // server verified the order with the provider and persisted the grant
34
+ // (see @ait-kit/api-core's iapOrderStatus for server-side verification).
35
+ grant: async ({ orderId, sku }) => {
36
+ const response = await fetch("/api/iap/grant", {
37
+ method: "POST",
38
+ body: JSON.stringify({ orderId, sku })
39
+ });
40
+ if (!response.ok) {
41
+ // Resolve-only-on-success is part of the contract: never report a
42
+ // grant your server did not verify and persist.
43
+ throw new Error(`grant request failed: HTTP ${response.status}`);
44
+ }
45
+ }
46
+ });
47
+
48
+ // 1. Start a purchase.
49
+ const result = await iap.purchaseOneTime("SKU_100_COINS");
50
+ // result.status: "completed" | "canceled" | "failed"
51
+ // | "grant_failed" | "unknown"
52
+
53
+ // 2. Subscriptions work the same way.
54
+ await iap.purchaseSubscription("SKU_PREMIUM", offerId);
55
+ ```
56
+
57
+ `completed` requires **both** the platform's success event and your grant
58
+ callback having resolved for the **same order**: a success event alone never
59
+ completes a purchase, a grant for a different order ends as
60
+ `failed`/`ORDER_MISMATCH`, and a failed server grant ends as `grant_failed`
61
+ — never as purchase success. `unknown` (e.g. timeout) means the grant may
62
+ still be in progress; the client timeout does not cancel it.
63
+
64
+ ### Duplicate grant control (client-side, scoped to one adapter)
65
+
66
+ - Concurrent grants for the same order share one in-flight call — only for
67
+ identical targets (same `orderId` and `sku`; callers that do not know the
68
+ `subscriptionId`, like pending-order recovery, may join a cached
69
+ subscription grant). Explicitly conflicting duplicates reject.
70
+ - A successfully granted order is reused within the adapter's scope for the
71
+ same target (a later recovery or duplicate success does not re-run your
72
+ server call).
73
+ - Failed grants are not cached — the next attempt retries for real.
74
+ - This only reduces duplicate client work: **server-side grant idempotency
75
+ is still mandatory** (verify the order, persist exactly once).
76
+
77
+ ### Pending-order recovery (never automatic)
78
+
79
+ ```ts
80
+ const { orders } = await iap.getPendingOrders();
81
+ for (const order of orders) {
82
+ // Runs (or reuses) the server grant FIRST; the platform's
83
+ // completeProductGrant notification is sent only after it confirms.
84
+ const outcome = await iap.recoverPendingOrder(order);
85
+ // outcome.status: "completed" | "grant_failed" | "notify_failed"
86
+ }
87
+ ```
88
+
89
+ SDK initialization never grants or completes pending orders by itself.
90
+
91
+ ### Feature matrix
92
+
93
+ | Capability | `/rn` | `/web` | Notes |
94
+ |---|---|---|---|
95
+ | Product list | ✅ | ✅ | one-time + subscription together |
96
+ | One-time purchase | ✅ | ✅ | grant callback contract applies |
97
+ | Subscription purchase | ✅ | ✅ | `offerId` optional; `subscriptionId` surfaced on completion |
98
+ | Pending orders | ✅ | ✅ | recovery is consumer-driven |
99
+ | Grant completion notify | ✅ | ✅ | sent only after server grant confirms |
100
+ | Full-screen ads | ✅ | ➖ | ads are RN-only today |
101
+ | Notification agreement | ✅ | ✅ | event-based, one template per request |
102
+ | Share link / share sheet | ✅ | ✅ | `intoss://` paths; `closed` ≠ shared |
103
+ | Unsupported app version | `SdkError("UNSUPPORTED")` | same | per-function `isSupported` gates |
104
+
105
+ Notification and sharing adapters ship in both entries (see below).
106
+
107
+ ## Login, anonymous identity, and storage (RN + Web)
108
+
109
+ ```ts
110
+ import { createReactNativeIdentity, createReactNativeStorage } from "@ait-kit/sdk/rn";
111
+ // or: import { createWebIdentity, createWebStorage } from "@ait-kit/sdk/web";
112
+
113
+ const identity = createReactNativeIdentity();
114
+
115
+ // 1. Login: the adapter validates and preserves authorizationCode/referrer.
116
+ const login = await identity.login();
117
+ // Forward BOTH values to YOUR server for the token exchange
118
+ // (@ait-kit/api-core exposes the server-side endpoint) and create the
119
+ // application session there. The adapter never performs the exchange.
120
+
121
+ // 2. Anonymous key: { type: "HASH", hash } or a typed error — never a
122
+ // fabricated key.
123
+ const anon = await identity.getAnonymousKey();
124
+
125
+ // 3. Storage: string values, verbatim keys.
126
+ const storage = createReactNativeStorage();
127
+ await storage.set("cart:items", "[]");
128
+ const raw = await storage.get("cart:items"); // string | null
129
+ await storage.remove("cart:items");
130
+ ```
131
+
132
+ Contracts:
133
+
134
+ - **Login** results are validated (`authorizationCode` non-empty, `referrer`
135
+ one of the documented values) and preserved verbatim. Malformed results
136
+ reject with `SdkError("INVALID_LOGIN_RESULT")`; unsupported environments
137
+ with `UNSUPPORTED`; SDK rejections propagate unchanged.
138
+ - **Anonymous key** results must be the documented `{ type: "HASH", hash }`
139
+ shape. Anything else (including sentinel values) rejects with
140
+ `SdkError("INVALID_ANONYMOUS_KEY")` — the adapter never invents a key.
141
+ - **Storage** keys and string values pass through byte-for-byte: no
142
+ namespace prefixing, no key transformation. Compose namespaced keys
143
+ yourself (e.g. `cart:items`). `get` resolves `null` for missing keys;
144
+ `set`/`remove` rejections propagate so failures stay observable.
145
+ - **No environment guessing**: the defaults never substitute a fake login
146
+ or storage based on the runtime. For development, inject an explicit
147
+ replacement via the `framework` option.
148
+ - Session invalidation when the anonymous identifier changes, migration of
149
+ previously stored keys, and bootstrap sequencing stay with the consumer.
150
+
151
+ ## Notification agreement and sharing (RN + Web)
152
+
153
+ ```ts
154
+ import {
155
+ createReactNativeNotification,
156
+ createReactNativeShare
157
+ } from "@ait-kit/sdk/rn";
158
+ // or: import { createWebNotification, createWebShare } from "@ait-kit/sdk/web";
159
+
160
+ // 1. Notification agreement: one template, one request.
161
+ const notification = createReactNativeNotification();
162
+ const result = await notification.requestAgreement("TEMPLATE_CODE");
163
+ // result.status: "agreed" (newAgreement | alreadyAgreed) | "rejected"
164
+ // | "failed" | "timeout"
165
+ // result.templateCode and result.sourceEvent are preserved verbatim. The
166
+ // outcome describes ONLY this request — it is not the user's global
167
+ // notification setting, nor any server-persisted consent state. Syncing
168
+ // consent to your server (and any smart-message sending) is your job.
169
+
170
+ // 2. Share links: intoss:// deeplink paths, optional OG image.
171
+ const share = createReactNativeShare();
172
+ const link = await share.createLink("intoss://my-app/about", "https://cdn/og.png");
173
+
174
+ // 3. Share sheet: "closed" means the sheet flow ended — nothing more.
175
+ const uiResult = await share.sendMessage(`check this out ${link}`);
176
+ // uiResult.status: "closed" | "failed" — closed does NOT prove the user
177
+ // shared and never grants share-reward eligibility.
178
+ ```
179
+
180
+ Contracts:
181
+
182
+ - **Agreement** runs on the shared event-flow base: settle-once, duplicate/
183
+ late-event immunity, single error-swallowing cleanup, registration-throw
184
+ recovery, one overall deadline (`timeoutMs`, default 60s). SDK errors keep
185
+ their `code`/`reason`; timeouts report `timeout` with the template.
186
+ - **Share links** validate the documented `intoss://` path contract
187
+ (`INVALID_SHARE_PATH` otherwise) and pass the resolved link through
188
+ verbatim. OG image generation is the consumer's concern.
189
+ - **Share sheet** resolution is `closed`, not "completed" — reward grants
190
+ and completion tracking stay with the consumer.
191
+ - Unsupported surfaces/app versions reject with `SdkError("UNSUPPORTED")`.
192
+
193
+ ## React Native full-screen ads
194
+
195
+ ```ts
196
+ import { createReactNativeAds } from "@ait-kit/sdk/rn";
197
+
198
+ const ads = createReactNativeAds();
199
+ await ads.loadFullScreenAd("AD_GROUP_ID"); // joins an in-flight duplicate load
200
+ const result = await ads.showFullScreenAd("AD_GROUP_ID");
201
+
202
+ if (result.status === "rewarded") {
203
+ // result.reward came from the provider's userEarnedReward event — nothing
204
+ // else (load success, show success, dismissal) ever grants a reward.
205
+ // Verify against YOUR server before crediting anything.
206
+ }
207
+ ```
208
+
209
+ Behavior:
210
+
211
+ - Load state is tracked per ad type + ad unit. Concurrent duplicate loads
212
+ share one registration; failed loads clear the slot so the next attempt
213
+ retries instead of being blocked.
214
+ - Showing requires a completed load (`AD_NOT_LOADED` otherwise). The same ad
215
+ group cannot be shown concurrently (`AD_ALREADY_SHOWING`). Full-screen ads
216
+ are single-use: after a show flow ends (rewarded, dismissed, failed,
217
+ timeout), a fresh load is required.
218
+ - The whole show flow has one deadline (`showTimeoutMs`, default 60s);
219
+ `loadTimeoutMs` (default 30s) bounds loads.
220
+ - Missing or failed-to-import framework → `SdkError("SDK_UNAVAILABLE")`;
221
+ unsupported app versions → `SdkError("UNSUPPORTED")`. Load failures are
222
+ never permanently cached — a later call retries the import.
223
+ - By default the adapter never fabricates ad success or reward results:
224
+ outcomes come only from the provider's events.
225
+
226
+ ## Responsibility split
227
+
228
+ The SDK provides ad behavior and outcomes. Your application owns server-side
229
+ ad reward requests, user session checks, payout limits, and ledger updates.
230
+ (Login/anonymous-key helpers and storage arrive in later entries, as do
231
+ notification and sharing.)
232
+
233
+ ## Versioning
234
+
235
+ `@ait-kit/sdk` is versioned independently from the server API packages
236
+ (`@ait-kit/api-*`), which release in lockstep with each other.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Internal, runtime-neutral event flow used by the ad adapters (and later
3
+ * IAP flows). Not exported from any public subpath.
4
+ *
5
+ * Guarantees:
6
+ * - The promise settles exactly once: success, failure, timeout, or
7
+ * cancellation are terminal and later events are ignored.
8
+ * - Cleanup runs at most once. Cleanup exceptions never block settlement
9
+ * and never change an already-decided result.
10
+ * - SDK registration functions that invoke a callback synchronously and
11
+ * then return the cleanup function are supported: the registration
12
+ * wrapper defers cleanup attachment until register returns.
13
+ * - If the registration function itself throws, timers and any partially
14
+ * registered resources are cleaned up and the flow rejects.
15
+ */
16
+ export type EventFlowResolution<TEvent, TResult> = {
17
+ done: true;
18
+ result: TResult;
19
+ } | {
20
+ done: false;
21
+ } | undefined;
22
+ export interface EventFlowOptions<TEvent, TResult> {
23
+ /** Registers SDK listeners; receives the flow's emit function. Returns an optional cleanup. */
24
+ register: (emit: (event: TEvent) => void) => (() => void) | void;
25
+ /** Maps an emitted event to a terminal result; anything else keeps the flow running. */
26
+ reduce: (event: TEvent) => EventFlowResolution<TEvent, TResult>;
27
+ /** Produces the terminal result when timeoutMs elapses without a resolution. */
28
+ onTimeout: () => TResult;
29
+ /** Overall deadline; omit or pass 0 to disable. */
30
+ timeoutMs?: number;
31
+ /** Hooks for tests: scheduling and clock injection. */
32
+ schedule?: typeof setTimeout;
33
+ cancelSchedule?: typeof clearTimeout;
34
+ }
35
+ export declare function runEventFlow<TEvent, TResult>(options: EventFlowOptions<TEvent, TResult>): Promise<TResult>;
36
+ //# sourceMappingURL=event-flow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-flow.d.ts","sourceRoot":"","sources":["../src/event-flow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,MAAM,mBAAmB,CAAC,MAAM,EAAE,OAAO,IAC3C;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf,SAAS,CAAC;AAEd,MAAM,WAAW,gBAAgB,CAAC,MAAM,EAAE,OAAO;IAC/C,+FAA+F;IAC/F,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;IACjE,wFAAwF;IACxF,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,mBAAmB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChE,gFAAgF;IAChF,SAAS,EAAE,MAAM,OAAO,CAAC;IACzB,mDAAmD;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,OAAO,UAAU,CAAC;IAC7B,cAAc,CAAC,EAAE,OAAO,YAAY,CAAC;CACtC;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,gBAAgB,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC,CAqF1G"}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Internal, runtime-neutral event flow used by the ad adapters (and later
3
+ * IAP flows). Not exported from any public subpath.
4
+ *
5
+ * Guarantees:
6
+ * - The promise settles exactly once: success, failure, timeout, or
7
+ * cancellation are terminal and later events are ignored.
8
+ * - Cleanup runs at most once. Cleanup exceptions never block settlement
9
+ * and never change an already-decided result.
10
+ * - SDK registration functions that invoke a callback synchronously and
11
+ * then return the cleanup function are supported: the registration
12
+ * wrapper defers cleanup attachment until register returns.
13
+ * - If the registration function itself throws, timers and any partially
14
+ * registered resources are cleaned up and the flow rejects.
15
+ */
16
+ export function runEventFlow(options) {
17
+ const { register, reduce, onTimeout, timeoutMs = 0 } = options;
18
+ const schedule = options.schedule ?? setTimeout;
19
+ const cancelSchedule = options.cancelSchedule ?? clearTimeout;
20
+ return new Promise((resolve, reject) => {
21
+ let settled = false;
22
+ let cleanedUp = false;
23
+ let timer;
24
+ let registeredCleanup;
25
+ const runCleanup = () => {
26
+ if (cleanedUp)
27
+ return;
28
+ cleanedUp = true;
29
+ if (timer !== undefined) {
30
+ cancelSchedule(timer);
31
+ timer = undefined;
32
+ }
33
+ if (registeredCleanup) {
34
+ const cleanup = registeredCleanup;
35
+ registeredCleanup = undefined;
36
+ try {
37
+ cleanup();
38
+ }
39
+ catch {
40
+ // Cleanup failures must never block or alter the settled result.
41
+ }
42
+ }
43
+ };
44
+ const settle = (result) => {
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ runCleanup();
49
+ resolve(result);
50
+ };
51
+ const fail = (error) => {
52
+ if (settled)
53
+ return;
54
+ settled = true;
55
+ runCleanup();
56
+ reject(error);
57
+ };
58
+ const emit = (event) => {
59
+ if (settled)
60
+ return;
61
+ let resolution;
62
+ try {
63
+ resolution = reduce(event);
64
+ }
65
+ catch (error) {
66
+ // A throwing reducer must fail the flow instead of leaving it
67
+ // pending until the deadline (which may be disabled).
68
+ fail(error);
69
+ return;
70
+ }
71
+ if (resolution?.done) {
72
+ settle(resolution.result);
73
+ }
74
+ };
75
+ if (timeoutMs > 0) {
76
+ timer = schedule(() => {
77
+ settle(onTimeout());
78
+ }, timeoutMs);
79
+ }
80
+ try {
81
+ registeredCleanup = register(emit);
82
+ }
83
+ catch (error) {
84
+ fail(error);
85
+ return;
86
+ }
87
+ // register() may have emitted synchronously and settled the flow before
88
+ // its cleanup existed; runCleanup already fired without it, so invoke
89
+ // the returned cleanup here with the same error-swallowing guarantee.
90
+ if (settled && registeredCleanup) {
91
+ const cleanup = registeredCleanup;
92
+ registeredCleanup = undefined;
93
+ try {
94
+ cleanup();
95
+ }
96
+ catch {
97
+ // Same guarantee as runCleanup.
98
+ }
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,47 @@
1
+ import { type IapGrantCallback, type IapProduct } from "../index.js";
2
+ import type { IapPendingOrder, IapPurchaseResult } from "../index.js";
3
+ import { type IapPlatformLoader } from "./platform-contract.js";
4
+ export interface IapAdapterOptions {
5
+ grant: IapGrantCallback;
6
+ /** Loader for the platform SDK module (already normalized per entry point). */
7
+ loader: IapPlatformLoader;
8
+ /** Overall deadline per purchase flow (default 180000ms; 0 disables). */
9
+ purchaseTimeoutMs?: number;
10
+ }
11
+ export interface IapRecoveryResult {
12
+ status: "completed" | "grant_failed" | "notify_failed";
13
+ orderId: string;
14
+ reason?: string;
15
+ }
16
+ export interface IapAdapter {
17
+ /** Lists purchasable products (one-time and subscription together). */
18
+ getProductItemList(): Promise<{
19
+ products: IapProduct[];
20
+ }>;
21
+ /**
22
+ * Starts a one-time purchase. Resolves `completed` only after the
23
+ * platform success event AND your grant callback confirmed the same
24
+ * order.
25
+ */
26
+ purchaseOneTime(sku: string): Promise<IapPurchaseResult>;
27
+ /** Starts a subscription purchase (optionally with a chosen offer). */
28
+ purchaseSubscription(sku: string, offerId?: string): Promise<IapPurchaseResult>;
29
+ /** Lists orders whose payment completed but whose grant was not finished. */
30
+ getPendingOrders(): Promise<{
31
+ orders: IapPendingOrder[];
32
+ }>;
33
+ /**
34
+ * Recovers one pending order: runs (or reuses) the server grant for the
35
+ * order first, and only after it confirms calls the platform's
36
+ * grant-completion notification. Never invoked automatically — the
37
+ * consumer drives recovery.
38
+ */
39
+ recoverPendingOrder(order: IapPendingOrder): Promise<IapRecoveryResult>;
40
+ }
41
+ /**
42
+ * Platform-neutral IAP adapter used by both entry points. The consumer's
43
+ * `grant` callback is the only place product delivery happens; the adapter
44
+ * contains no backend URLs, auth, or persistence.
45
+ */
46
+ export declare function createIapAdapter(options: IapAdapterOptions): IapAdapter;
47
+ //# sourceMappingURL=adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../src/iap/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,gBAAgB,EAErB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EACV,eAAe,EACf,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,KAAK,iBAAiB,EAA8B,MAAM,wBAAwB,CAAC;AAG5F,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,gBAAgB,CAAC;IACxB,+EAA+E;IAC/E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,yEAAyE;IACzE,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,WAAW,GAAG,cAAc,GAAG,eAAe,CAAC;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,uEAAuE;IACvE,kBAAkB,IAAI,OAAO,CAAC;QAAE,QAAQ,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC,CAAC;IAC1D;;;;OAIG;IACH,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACzD,uEAAuE;IACvE,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAChF,6EAA6E;IAC7E,gBAAgB,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,eAAe,EAAE,CAAA;KAAE,CAAC,CAAC;IAC3D;;;;;OAKG;IACH,mBAAmB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACzE;AAID;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,UAAU,CA0JvE"}
@@ -0,0 +1,175 @@
1
+ import { SdkError } from "../index.js";
2
+ import { IapGrantCoordinator } from "./grant-coordinator.js";
3
+ import {} from "./platform-contract.js";
4
+ import { runPurchaseFlow } from "./purchase-flow.js";
5
+ const DEFAULT_PURCHASE_TIMEOUT_MS = 180_000;
6
+ /**
7
+ * Platform-neutral IAP adapter used by both entry points. The consumer's
8
+ * `grant` callback is the only place product delivery happens; the adapter
9
+ * contains no backend URLs, auth, or persistence.
10
+ */
11
+ export function createIapAdapter(options) {
12
+ const purchaseTimeoutMs = options.purchaseTimeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
13
+ const coordinator = new IapGrantCoordinator(options.grant);
14
+ const load = async () => {
15
+ const result = await options.loader();
16
+ if (!result.available) {
17
+ throw new SdkError("SDK_UNAVAILABLE", result.reason);
18
+ }
19
+ return result.module;
20
+ };
21
+ /**
22
+ * Per-operation capability gate: an installed framework may expose some
23
+ * IAP functions but not others, so a missing function is UNSUPPORTED only
24
+ * when that specific operation is requested — never a blanket rejection.
25
+ */
26
+ const ensureOperation = (fn, label) => {
27
+ if (typeof fn !== "function") {
28
+ throw new SdkError("UNSUPPORTED", `the installed IAP SDK does not expose ${label}`);
29
+ }
30
+ const supported = fn;
31
+ if (typeof supported.isSupported === "function" && !supported.isSupported()) {
32
+ throw new SdkError("UNSUPPORTED", `${label} is not supported on this app version`);
33
+ }
34
+ return fn;
35
+ };
36
+ return {
37
+ async getProductItemList() {
38
+ const platform = await load();
39
+ ensureOperation(platform.getProductItemList, "getProductItemList");
40
+ // Called as a member so class-instance modules keep their receiver.
41
+ return platform.getProductItemList();
42
+ },
43
+ purchaseOneTime(sku) {
44
+ return purchaseWithDeadline(async (platform, remainingMs) => {
45
+ ensureOperation(platform.createOneTimePurchaseOrder, "one-time purchases");
46
+ return await runPurchaseFlow({
47
+ startOrder: (params) => platform.createOneTimePurchaseOrder(params),
48
+ coordinator,
49
+ sku,
50
+ subscription: false,
51
+ timeoutMs: remainingMs
52
+ });
53
+ });
54
+ },
55
+ purchaseSubscription(sku, offerId) {
56
+ return purchaseWithDeadline(async (platform, remainingMs) => {
57
+ ensureOperation(platform.createSubscriptionPurchaseOrder, "subscription purchases");
58
+ return await runPurchaseFlow({
59
+ startOrder: (params) => platform.createSubscriptionPurchaseOrder(params),
60
+ coordinator,
61
+ sku,
62
+ ...(offerId !== undefined ? { offerId } : {}),
63
+ subscription: true,
64
+ timeoutMs: remainingMs
65
+ });
66
+ });
67
+ },
68
+ async getPendingOrders() {
69
+ const platform = await load();
70
+ ensureOperation(platform.getPendingOrders, "getPendingOrders");
71
+ return platform.getPendingOrders();
72
+ },
73
+ async recoverPendingOrder(order) {
74
+ const platform = await load();
75
+ ensureOperation(platform.completeProductGrant, "completeProductGrant");
76
+ try {
77
+ // Server grant confirmation first (deduped within this adapter's
78
+ // scope); the completion notification follows only after it.
79
+ await coordinator.run({ orderId: order.orderId, sku: order.sku });
80
+ }
81
+ catch (error) {
82
+ const reason = error instanceof Error ? error.message : String(error);
83
+ return { status: "grant_failed", orderId: order.orderId, reason };
84
+ }
85
+ const notified = await notifyGrantComplete(platform, order.orderId);
86
+ if (!notified.ok) {
87
+ return {
88
+ status: "notify_failed",
89
+ orderId: order.orderId,
90
+ reason: notified.reason
91
+ };
92
+ }
93
+ return { status: "completed", orderId: order.orderId };
94
+ }
95
+ };
96
+ /**
97
+ * Runs a purchase under one overall deadline that also covers platform
98
+ * loading. Only the loading phase races the outer deadline — the purchase
99
+ * flow owns the remaining budget exclusively, so its order-aware timeout
100
+ * result is never preempted by a generic one. A stalled loader cannot
101
+ * wedge checkout, and a loader resolving after the deadline never
102
+ * registers the purchase.
103
+ */
104
+ async function purchaseWithDeadline(run) {
105
+ const timedOut = () => ({
106
+ status: "unknown",
107
+ reason: "purchase flow timed out; the server grant may still be in progress — verify the order server-side and recover it via pending orders"
108
+ });
109
+ if (!(purchaseTimeoutMs > 0)) {
110
+ return run(await load(), 0);
111
+ }
112
+ const startedAt = Date.now();
113
+ let cancelled = false;
114
+ let timer;
115
+ const loadDeadline = new Promise((resolve) => {
116
+ timer = setTimeout(() => {
117
+ cancelled = true;
118
+ resolve(null);
119
+ }, purchaseTimeoutMs);
120
+ });
121
+ let platform;
122
+ try {
123
+ platform = await Promise.race([
124
+ load().then((loaded) => (cancelled ? null : loaded)),
125
+ loadDeadline
126
+ ]);
127
+ }
128
+ finally {
129
+ if (timer !== undefined)
130
+ clearTimeout(timer);
131
+ }
132
+ if (!platform || cancelled || Date.now() - startedAt >= purchaseTimeoutMs) {
133
+ // The loader may resolve as an overdue microtask before the timer
134
+ // callback runs; a fresh clock comparison is authoritative.
135
+ return timedOut();
136
+ }
137
+ const remainingMs = Math.max(1, purchaseTimeoutMs - (Date.now() - startedAt));
138
+ const result = await run(platform, remainingMs);
139
+ if (Date.now() - startedAt >= purchaseTimeoutMs) {
140
+ // A synchronous startOrder can block past the deadline and settle the
141
+ // flow before the overdue timer callback runs. The deadline is the
142
+ // deadline: preserve any order identity the result already carries
143
+ // for server-side recovery.
144
+ const identified = result;
145
+ return {
146
+ ...timedOut(),
147
+ ...(identified.orderId !== undefined ? { orderId: identified.orderId } : {}),
148
+ ...(identified.subscriptionId !== undefined
149
+ ? { subscriptionId: identified.subscriptionId }
150
+ : {})
151
+ };
152
+ }
153
+ return result;
154
+ }
155
+ }
156
+ async function notifyGrantComplete(platform, orderId) {
157
+ let notified;
158
+ try {
159
+ notified = await platform.completeProductGrant({ params: { orderId } });
160
+ }
161
+ catch (error) {
162
+ const reason = error instanceof Error ? error.message : String(error);
163
+ return {
164
+ ok: false,
165
+ reason: `completeProductGrant rejected: ${reason}; retry — the server grant is already confirmed in this scope`
166
+ };
167
+ }
168
+ if (!notified) {
169
+ return {
170
+ ok: false,
171
+ reason: "completeProductGrant returned false; retry — the server grant is already confirmed in this scope"
172
+ };
173
+ }
174
+ return { ok: true };
175
+ }
@@ -0,0 +1,36 @@
1
+ import type { IapGrantTarget } from "./platform-contract.js";
2
+ /**
3
+ * Client-side dedupe for server grant callbacks, scoped to one adapter
4
+ * instance (one "processing scope"):
5
+ *
6
+ * - Concurrent grants for the same order share the in-flight Promise, so a
7
+ * duplicate callback never re-runs the server request.
8
+ * - A successfully granted order is reused within the same scope: later
9
+ * flows (pending-order recovery, duplicate success events) resolve
10
+ * immediately instead of re-granting.
11
+ * - Failed grants are never cached: the entry is removed when the attempt
12
+ * rejects, so the next call retries for real.
13
+ *
14
+ * This only reduces duplicate client-side work. Server-side grant
15
+ * idempotency (verify the order, persist exactly once) is a separate,
16
+ * mandatory server responsibility.
17
+ */
18
+ export declare class IapGrantCoordinator {
19
+ private readonly grant;
20
+ private readonly inFlight;
21
+ private readonly granted;
22
+ constructor(grant: (target: IapGrantTarget) => Promise<void>);
23
+ /**
24
+ * Runs (or joins) the grant for the target order. Resolves when the
25
+ * consumer's callback resolves; rejects with the callback's error when it
26
+ * fails, leaving the order retryable. A duplicate joins only when it
27
+ * describes the same target: orderId and sku must match, and a caller
28
+ * that does not know the subscriptionId (pending-order recovery) may join
29
+ * a cached subscription grant. Explicitly conflicting duplicates reject
30
+ * instead of silently inheriting another flow's grant.
31
+ */
32
+ run(target: IapGrantTarget): Promise<void>;
33
+ /** True when this scope already granted the order successfully. */
34
+ isGranted(orderId: string): boolean;
35
+ }
36
+ //# sourceMappingURL=grant-coordinator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grant-coordinator.d.ts","sourceRoot":"","sources":["../../src/iap/grant-coordinator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,mBAAmB;IAIlB,OAAO,CAAC,QAAQ,CAAC,KAAK;IAHlC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAyE;IAClG,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqC;IAE7D,YAA6B,KAAK,EAAE,CAAC,MAAM,EAAE,cAAc,KAAK,OAAO,CAAC,IAAI,CAAC,EAAI;IAEjF;;;;;;;;OAQG;IACH,GAAG,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAoCzC;IAED,mEAAmE;IACnE,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAElC;CACF"}