@onesub/server 0.11.4 → 0.12.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.
Files changed (127) hide show
  1. package/README.md +119 -2
  2. package/dist/__tests__/cache.test.d.ts +2 -0
  3. package/dist/__tests__/cache.test.d.ts.map +1 -0
  4. package/dist/__tests__/openapi.test.d.ts +2 -0
  5. package/dist/__tests__/openapi.test.d.ts.map +1 -0
  6. package/dist/__tests__/redis-store.test.d.ts +2 -0
  7. package/dist/__tests__/redis-store.test.d.ts.map +1 -0
  8. package/dist/__tests__/webhook-apple.test.d.ts +12 -0
  9. package/dist/__tests__/webhook-apple.test.d.ts.map +1 -0
  10. package/dist/__tests__/webhook-events.test.d.ts +2 -0
  11. package/dist/__tests__/webhook-events.test.d.ts.map +1 -0
  12. package/dist/__tests__/webhook-google.test.d.ts +13 -0
  13. package/dist/__tests__/webhook-google.test.d.ts.map +1 -0
  14. package/dist/__tests__/webhook-queue.test.d.ts +2 -0
  15. package/dist/__tests__/webhook-queue.test.d.ts.map +1 -0
  16. package/dist/cache.d.ts +32 -0
  17. package/dist/cache.d.ts.map +1 -0
  18. package/dist/index.cjs +3084 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.ts +34 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3045 -118
  23. package/dist/index.js.map +1 -1
  24. package/dist/openapi.d.ts +41 -0
  25. package/dist/openapi.d.ts.map +1 -0
  26. package/dist/providers/apple.d.ts +1 -1
  27. package/dist/providers/apple.d.ts.map +1 -1
  28. package/dist/providers/google.d.ts.map +1 -1
  29. package/dist/routes/admin.d.ts +2 -1
  30. package/dist/routes/admin.d.ts.map +1 -1
  31. package/dist/routes/webhook-apple.d.ts +6 -0
  32. package/dist/routes/webhook-apple.d.ts.map +1 -0
  33. package/dist/routes/webhook-google.d.ts +7 -0
  34. package/dist/routes/webhook-google.d.ts.map +1 -0
  35. package/dist/routes/webhook.d.ts +6 -18
  36. package/dist/routes/webhook.d.ts.map +1 -1
  37. package/dist/stores/redis.d.ts +60 -0
  38. package/dist/stores/redis.d.ts.map +1 -0
  39. package/dist/stores/schema.d.ts.map +1 -1
  40. package/dist/tracing.d.ts +32 -0
  41. package/dist/tracing.d.ts.map +1 -0
  42. package/dist/webhook-events.d.ts +58 -0
  43. package/dist/webhook-events.d.ts.map +1 -0
  44. package/dist/webhook-queue.d.ts +92 -0
  45. package/dist/webhook-queue.d.ts.map +1 -0
  46. package/package.json +33 -4
  47. package/sql/schema.sql +10 -0
  48. package/dist/__tests__/admin-customer-detail.test.js +0 -146
  49. package/dist/__tests__/admin-customer-detail.test.js.map +0 -1
  50. package/dist/__tests__/admin-list-subscriptions.test.js +0 -192
  51. package/dist/__tests__/admin-list-subscriptions.test.js.map +0 -1
  52. package/dist/__tests__/admin-subscription-detail.test.js +0 -112
  53. package/dist/__tests__/admin-subscription-detail.test.js.map +0 -1
  54. package/dist/__tests__/apple-root-ca.test.js +0 -41
  55. package/dist/__tests__/apple-root-ca.test.js.map +0 -1
  56. package/dist/__tests__/apple-status-api.test.js +0 -345
  57. package/dist/__tests__/apple-status-api.test.js.map +0 -1
  58. package/dist/__tests__/entitlements.test.js +0 -268
  59. package/dist/__tests__/entitlements.test.js.map +0 -1
  60. package/dist/__tests__/error-codes.test.js +0 -168
  61. package/dist/__tests__/error-codes.test.js.map +0 -1
  62. package/dist/__tests__/fetch-hardening.test.js +0 -192
  63. package/dist/__tests__/fetch-hardening.test.js.map +0 -1
  64. package/dist/__tests__/google-paused.test.js +0 -126
  65. package/dist/__tests__/google-paused.test.js.map +0 -1
  66. package/dist/__tests__/google-price-change.test.js +0 -186
  67. package/dist/__tests__/google-price-change.test.js.map +0 -1
  68. package/dist/__tests__/google-subscriptions-v2.test.js +0 -206
  69. package/dist/__tests__/google-subscriptions-v2.test.js.map +0 -1
  70. package/dist/__tests__/lifecycle-scenarios.test.js +0 -579
  71. package/dist/__tests__/lifecycle-scenarios.test.js.map +0 -1
  72. package/dist/__tests__/lifecycle-states.test.js +0 -423
  73. package/dist/__tests__/lifecycle-states.test.js.map +0 -1
  74. package/dist/__tests__/linked-purchase-token.test.js +0 -227
  75. package/dist/__tests__/linked-purchase-token.test.js.map +0 -1
  76. package/dist/__tests__/metrics.test.js +0 -340
  77. package/dist/__tests__/metrics.test.js.map +0 -1
  78. package/dist/__tests__/mock-provider.test.js +0 -188
  79. package/dist/__tests__/mock-provider.test.js.map +0 -1
  80. package/dist/__tests__/paused-auto-resume.test.js +0 -162
  81. package/dist/__tests__/paused-auto-resume.test.js.map +0 -1
  82. package/dist/__tests__/providers.test.js +0 -321
  83. package/dist/__tests__/providers.test.js.map +0 -1
  84. package/dist/__tests__/refund-policy.test.js +0 -317
  85. package/dist/__tests__/refund-policy.test.js.map +0 -1
  86. package/dist/__tests__/schema.test.js +0 -46
  87. package/dist/__tests__/schema.test.js.map +0 -1
  88. package/dist/__tests__/store.test.js +0 -172
  89. package/dist/__tests__/store.test.js.map +0 -1
  90. package/dist/__tests__/test-utils.js +0 -30
  91. package/dist/__tests__/test-utils.js.map +0 -1
  92. package/dist/__tests__/webhook-refund.test.js +0 -313
  93. package/dist/__tests__/webhook-refund.test.js.map +0 -1
  94. package/dist/errors.js +0 -19
  95. package/dist/errors.js.map +0 -1
  96. package/dist/http.js +0 -36
  97. package/dist/http.js.map +0 -1
  98. package/dist/logger.js +0 -20
  99. package/dist/logger.js.map +0 -1
  100. package/dist/providers/apple-root-ca.js +0 -38
  101. package/dist/providers/apple-root-ca.js.map +0 -1
  102. package/dist/providers/apple.js +0 -499
  103. package/dist/providers/apple.js.map +0 -1
  104. package/dist/providers/google.js +0 -493
  105. package/dist/providers/google.js.map +0 -1
  106. package/dist/providers/mock.js +0 -93
  107. package/dist/providers/mock.js.map +0 -1
  108. package/dist/routes/admin.js +0 -251
  109. package/dist/routes/admin.js.map +0 -1
  110. package/dist/routes/entitlements.js +0 -130
  111. package/dist/routes/entitlements.js.map +0 -1
  112. package/dist/routes/metrics.js +0 -281
  113. package/dist/routes/metrics.js.map +0 -1
  114. package/dist/routes/purchase.js +0 -187
  115. package/dist/routes/purchase.js.map +0 -1
  116. package/dist/routes/status.js +0 -55
  117. package/dist/routes/status.js.map +0 -1
  118. package/dist/routes/validate.js +0 -70
  119. package/dist/routes/validate.js.map +0 -1
  120. package/dist/routes/webhook.js +0 -441
  121. package/dist/routes/webhook.js.map +0 -1
  122. package/dist/store.js +0 -145
  123. package/dist/store.js.map +0 -1
  124. package/dist/stores/postgres.js +0 -329
  125. package/dist/stores/postgres.js.map +0 -1
  126. package/dist/stores/schema.js +0 -57
  127. package/dist/stores/schema.js.map +0 -1
package/dist/index.cjs ADDED
@@ -0,0 +1,3084 @@
1
+ 'use strict';
2
+
3
+ var express = require('express');
4
+ var shared = require('@onesub/shared');
5
+ var zod = require('zod');
6
+ var jose = require('jose');
7
+ var crypto = require('crypto');
8
+
9
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
10
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
+
12
+ var express__default = /*#__PURE__*/_interopDefault(express);
13
+
14
+ // src/index.ts
15
+
16
+ // src/store.ts
17
+ var InMemorySubscriptionStore = class {
18
+ // Multiple records per user (different originalTransactionIds) — required
19
+ // for entitlement evaluation across multiple productIds. Ordering is
20
+ // last-written-first so getByUserId returns "most recent" naturally.
21
+ byUserId = /* @__PURE__ */ new Map();
22
+ byTransactionId = /* @__PURE__ */ new Map();
23
+ async save(sub) {
24
+ this.byTransactionId.set(sub.originalTransactionId, sub);
25
+ const existing = this.byUserId.get(sub.userId) ?? [];
26
+ const filtered = existing.filter((s) => s.originalTransactionId !== sub.originalTransactionId);
27
+ filtered.unshift(sub);
28
+ this.byUserId.set(sub.userId, filtered);
29
+ }
30
+ async getByUserId(userId) {
31
+ const list = this.byUserId.get(userId);
32
+ return list?.[0] ?? null;
33
+ }
34
+ async getAllByUserId(userId) {
35
+ return [...this.byUserId.get(userId) ?? []];
36
+ }
37
+ async getByTransactionId(txId) {
38
+ return this.byTransactionId.get(txId) ?? null;
39
+ }
40
+ async listAll() {
41
+ return [...this.byTransactionId.values()];
42
+ }
43
+ async listFiltered(opts) {
44
+ const limit = opts.limit ?? 50;
45
+ const offset = opts.offset ?? 0;
46
+ const all = [];
47
+ for (const list of this.byUserId.values()) {
48
+ for (const s of list) all.push(s);
49
+ }
50
+ const filtered = all.filter((s) => {
51
+ if (opts.userId && s.userId !== opts.userId) return false;
52
+ if (opts.status && s.status !== opts.status) return false;
53
+ if (opts.productId && s.productId !== opts.productId) return false;
54
+ if (opts.platform && s.platform !== opts.platform) return false;
55
+ return true;
56
+ });
57
+ return {
58
+ items: filtered.slice(offset, offset + limit),
59
+ total: filtered.length,
60
+ limit,
61
+ offset
62
+ };
63
+ }
64
+ };
65
+ var InMemoryPurchaseStore = class {
66
+ byTransactionId = /* @__PURE__ */ new Map();
67
+ byUserId = /* @__PURE__ */ new Map();
68
+ async savePurchase(purchase) {
69
+ const existing = this.byTransactionId.get(purchase.transactionId);
70
+ if (existing) {
71
+ if (existing.userId !== purchase.userId) {
72
+ const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
73
+ err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
74
+ throw err;
75
+ }
76
+ return;
77
+ }
78
+ this.byTransactionId.set(purchase.transactionId, purchase);
79
+ const list = this.byUserId.get(purchase.userId) ?? [];
80
+ list.push(purchase);
81
+ this.byUserId.set(purchase.userId, list);
82
+ }
83
+ async getPurchasesByUserId(userId) {
84
+ return this.byUserId.get(userId) ?? [];
85
+ }
86
+ async getPurchaseByTransactionId(txId) {
87
+ return this.byTransactionId.get(txId) ?? null;
88
+ }
89
+ async hasPurchased(userId, productId) {
90
+ const purchases = this.byUserId.get(userId);
91
+ if (!purchases) return false;
92
+ return purchases.some((p) => p.productId === productId);
93
+ }
94
+ async reassignPurchase(transactionId, newUserId) {
95
+ const existing = this.byTransactionId.get(transactionId);
96
+ if (!existing) return false;
97
+ const oldUserId = existing.userId;
98
+ if (oldUserId === newUserId) return true;
99
+ const updated = { ...existing, userId: newUserId };
100
+ this.byTransactionId.set(transactionId, updated);
101
+ const oldList = (this.byUserId.get(oldUserId) ?? []).filter((p) => p.transactionId !== transactionId);
102
+ if (oldList.length) this.byUserId.set(oldUserId, oldList);
103
+ else this.byUserId.delete(oldUserId);
104
+ const newList = this.byUserId.get(newUserId) ?? [];
105
+ newList.push(updated);
106
+ this.byUserId.set(newUserId, newList);
107
+ return true;
108
+ }
109
+ async deletePurchases(userId, productId) {
110
+ const list = this.byUserId.get(userId) ?? [];
111
+ const kept = list.filter((p) => p.productId !== productId);
112
+ const deleted = list.length - kept.length;
113
+ this.byUserId.set(userId, kept);
114
+ for (const p of list) {
115
+ if (p.productId === productId) this.byTransactionId.delete(p.transactionId);
116
+ }
117
+ return deleted;
118
+ }
119
+ async listAll() {
120
+ return [...this.byTransactionId.values()];
121
+ }
122
+ async deletePurchaseByTransactionId(transactionId) {
123
+ const existing = this.byTransactionId.get(transactionId);
124
+ if (!existing) return false;
125
+ this.byTransactionId.delete(transactionId);
126
+ const list = (this.byUserId.get(existing.userId) ?? []).filter(
127
+ (p) => p.transactionId !== transactionId
128
+ );
129
+ if (list.length) this.byUserId.set(existing.userId, list);
130
+ else this.byUserId.delete(existing.userId);
131
+ return true;
132
+ }
133
+ };
134
+
135
+ // src/providers/apple-root-ca.ts
136
+ var APPLE_ROOT_CA_G3_PEM = `-----BEGIN CERTIFICATE-----
137
+ MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
138
+ QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
139
+ IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
140
+ MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
141
+ b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
142
+ aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
143
+ AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
144
+ TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
145
+ IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
146
+ MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
147
+ MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
148
+ at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
149
+ 6BgD56KyKA==
150
+ -----END CERTIFICATE-----`;
151
+ var APPLE_ROOT_CA_PEMS = [APPLE_ROOT_CA_G3_PEM];
152
+
153
+ // src/logger.ts
154
+ var current = console;
155
+ function setLogger(logger) {
156
+ if (logger) current = logger;
157
+ }
158
+ var log = {
159
+ info: (...args) => current.info(...args),
160
+ warn: (...args) => current.warn(...args),
161
+ error: (...args) => current.error(...args)
162
+ };
163
+
164
+ // src/http.ts
165
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
166
+ async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
167
+ const controller = new AbortController();
168
+ const callerSignal = init?.signal;
169
+ if (callerSignal) {
170
+ if (callerSignal.aborted) controller.abort(callerSignal.reason);
171
+ else callerSignal.addEventListener("abort", () => controller.abort(callerSignal.reason), { once: true });
172
+ }
173
+ const timer = setTimeout(() => {
174
+ controller.abort(new Error(`[onesub] fetch timed out after ${timeoutMs}ms`));
175
+ }, timeoutMs);
176
+ try {
177
+ return await fetch(input, { ...init, signal: controller.signal });
178
+ } finally {
179
+ clearTimeout(timer);
180
+ }
181
+ }
182
+
183
+ // src/cache.ts
184
+ var InMemoryCacheAdapter = class {
185
+ entries = /* @__PURE__ */ new Map();
186
+ async get(key) {
187
+ const entry = this.entries.get(key);
188
+ if (!entry) return null;
189
+ if (entry.expiresAt !== 0 && entry.expiresAt < Date.now()) {
190
+ this.entries.delete(key);
191
+ return null;
192
+ }
193
+ return entry.value;
194
+ }
195
+ async set(key, value, ttlSeconds) {
196
+ const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1e3 : 0;
197
+ this.entries.set(key, { value, expiresAt });
198
+ }
199
+ async del(key) {
200
+ this.entries.delete(key);
201
+ }
202
+ /** Test helper — clear everything. Not part of the public CacheAdapter contract. */
203
+ clear() {
204
+ this.entries.clear();
205
+ }
206
+ };
207
+ var defaultAdapter = new InMemoryCacheAdapter();
208
+ function getDefaultCache() {
209
+ return defaultAdapter;
210
+ }
211
+ function setDefaultCache(adapter) {
212
+ defaultAdapter = adapter;
213
+ }
214
+ function classifyMockReceipt(receipt) {
215
+ if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.NETWORK_ERROR)) return { kind: "network-error" };
216
+ if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.REVOKED)) return { kind: "revoked" };
217
+ if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.EXPIRED)) return { kind: "expired" };
218
+ if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.INVALID) || receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.BAD_SIG)) {
219
+ return { kind: "invalid" };
220
+ }
221
+ if (receipt.startsWith(shared.MOCK_RECEIPT_PREFIX.SANDBOX)) return { kind: "sandbox" };
222
+ return { kind: "valid" };
223
+ }
224
+ function outcomePasses(outcome, tag) {
225
+ if (outcome.kind === "network-error") {
226
+ throw new Error(`[onesub/mock/${tag}] simulated upstream network error`);
227
+ }
228
+ if (outcome.kind === "valid" || outcome.kind === "sandbox") return true;
229
+ log.warn(`[onesub/mock/${tag}] receipt rejected`, { outcome: outcome.kind });
230
+ return false;
231
+ }
232
+ function deterministicTransactionId(prefix, receipt) {
233
+ const digest = crypto.createHash("sha256").update(receipt).digest("hex").slice(0, 24);
234
+ return `${prefix}_${digest}`;
235
+ }
236
+ var HOURS = 60 * 60 * 1e3;
237
+ var DAYS = 24 * HOURS;
238
+ function mockValidateAppleSubscription(receipt) {
239
+ const outcome = classifyMockReceipt(receipt);
240
+ if (!outcomePasses(outcome, "apple")) return null;
241
+ const now = Date.now();
242
+ const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
243
+ return {
244
+ userId: "",
245
+ productId: "mock_subscription",
246
+ platform: "apple",
247
+ status: shared.SUBSCRIPTION_STATUS.ACTIVE,
248
+ expiresAt: new Date(expiresAt).toISOString(),
249
+ originalTransactionId: deterministicTransactionId("mock_apple_orig", receipt),
250
+ purchasedAt: new Date(now).toISOString(),
251
+ willRenew: true
252
+ };
253
+ }
254
+ function mockValidateAppleProduct(receipt, expectedProductId) {
255
+ const outcome = classifyMockReceipt(receipt);
256
+ if (!outcomePasses(outcome, "apple")) return null;
257
+ const productId = expectedProductId ?? "mock_product";
258
+ return {
259
+ transactionId: deterministicTransactionId(`mock_apple_${productId}`, receipt),
260
+ productId,
261
+ purchasedAt: (/* @__PURE__ */ new Date()).toISOString()
262
+ };
263
+ }
264
+ function mockValidateGoogleSubscription(receipt, productId) {
265
+ const outcome = classifyMockReceipt(receipt);
266
+ if (!outcomePasses(outcome, "google")) return null;
267
+ const now = Date.now();
268
+ const expiresAt = outcome.kind === "sandbox" ? now + 1 * HOURS : now + 30 * DAYS;
269
+ return {
270
+ userId: "",
271
+ productId,
272
+ platform: "google",
273
+ status: shared.SUBSCRIPTION_STATUS.ACTIVE,
274
+ expiresAt: new Date(expiresAt).toISOString(),
275
+ originalTransactionId: deterministicTransactionId("mock_google_orig", receipt),
276
+ purchasedAt: new Date(now).toISOString(),
277
+ willRenew: true
278
+ };
279
+ }
280
+ function mockValidateGoogleProduct(receipt, productId) {
281
+ const outcome = classifyMockReceipt(receipt);
282
+ if (!outcomePasses(outcome, "google")) return null;
283
+ return {
284
+ transactionId: deterministicTransactionId(`mock_google_${productId}`, receipt),
285
+ purchasedAt: (/* @__PURE__ */ new Date()).toISOString()
286
+ };
287
+ }
288
+
289
+ // src/providers/apple.ts
290
+ var MAX_CONSUMABLE_RECEIPT_AGE_MS = 72 * 60 * 60 * 1e3;
291
+ function derBase64ToPem(der) {
292
+ return "-----BEGIN CERTIFICATE-----\n" + (der.match(/.{1,64}/g) ?? []).join("\n") + "\n-----END CERTIFICATE-----";
293
+ }
294
+ var APPLE_ROOT_CERTS = APPLE_ROOT_CA_PEMS.map((pem) => new crypto.X509Certificate(pem));
295
+ function verifyAppleCertChain(x5c) {
296
+ if (x5c.length === 0) {
297
+ throw new Error("[onesub/apple] empty x5c");
298
+ }
299
+ const chain = x5c.map((der) => new crypto.X509Certificate(derBase64ToPem(der)));
300
+ const now = /* @__PURE__ */ new Date();
301
+ for (let i = 0; i < chain.length; i++) {
302
+ const cert = chain[i];
303
+ if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) {
304
+ throw new Error(`[onesub/apple] cert[${i}] outside validity window`);
305
+ }
306
+ if (i + 1 < chain.length) {
307
+ if (!cert.checkIssued(chain[i + 1]) || !cert.verify(chain[i + 1].publicKey)) {
308
+ throw new Error(`[onesub/apple] cert[${i}] not signed by cert[${i + 1}]`);
309
+ }
310
+ }
311
+ }
312
+ const top = chain[chain.length - 1];
313
+ const topDer = top.raw.toString("base64");
314
+ const trustsRoot = APPLE_ROOT_CERTS.some((root) => {
315
+ if (root.raw.toString("base64") === topDer) return true;
316
+ if (!top.checkIssued(root)) return false;
317
+ try {
318
+ return top.verify(root.publicKey);
319
+ } catch {
320
+ return false;
321
+ }
322
+ });
323
+ if (!trustsRoot) {
324
+ throw new Error("[onesub/apple] cert chain does not terminate at a trusted Apple root");
325
+ }
326
+ return chain[0].toString();
327
+ }
328
+ async function decodeJws(jws, skipVerification = false) {
329
+ if (skipVerification) {
330
+ if (process.env["NODE_ENV"] === "production") {
331
+ log.warn(
332
+ "[onesub/apple] WARNING: skipJwsVerification is enabled in production. JWS signatures are NOT being verified. This is a security risk."
333
+ );
334
+ }
335
+ return jose.decodeJwt(jws);
336
+ }
337
+ const header = jose.decodeProtectedHeader(jws);
338
+ const x5c = header.x5c;
339
+ if (!x5c || x5c.length === 0) {
340
+ throw new Error("[onesub/apple] JWS header missing x5c certificate chain");
341
+ }
342
+ const leafPem = verifyAppleCertChain(x5c);
343
+ const alg = header.alg ?? "ES256";
344
+ const key = await jose.importX509(leafPem, alg);
345
+ const { payload } = await jose.jwtVerify(jws, key);
346
+ return payload;
347
+ }
348
+ function deriveStatus(tx, renewal) {
349
+ if (tx.revocationDate) return shared.SUBSCRIPTION_STATUS.CANCELED;
350
+ const now = Date.now();
351
+ const expires = tx.expiresDate ?? 0;
352
+ if (expires > now) return shared.SUBSCRIPTION_STATUS.ACTIVE;
353
+ if (renewal?.autoRenewStatus === 0) return shared.SUBSCRIPTION_STATUS.CANCELED;
354
+ return shared.SUBSCRIPTION_STATUS.EXPIRED;
355
+ }
356
+ async function validateAppleReceipt(receipt, config) {
357
+ if (config.mockMode) return mockValidateAppleSubscription(receipt);
358
+ let tx;
359
+ try {
360
+ tx = await decodeJws(receipt, config.skipJwsVerification);
361
+ } catch (err) {
362
+ const preview = receipt.slice(0, 60);
363
+ const parts = receipt.split(".").length;
364
+ log.warn(
365
+ "[onesub/apple] Failed to decode receipt as JWS:",
366
+ err?.message ?? err,
367
+ `| preview: "${preview}..." (len=${receipt.length}, parts=${parts})`
368
+ );
369
+ return null;
370
+ }
371
+ if (!tx.originalTransactionId || !tx.productId || !tx.expiresDate) {
372
+ return null;
373
+ }
374
+ if (!tx.bundleId || tx.bundleId !== config.bundleId) {
375
+ log.warn("[onesub/apple] Bundle ID mismatch:", tx.bundleId, "!==", config.bundleId);
376
+ return null;
377
+ }
378
+ if (process.env["NODE_ENV"] === "production" && tx.environment !== "Production" && process.env["ONESUB_ALLOW_SANDBOX"] !== "true") {
379
+ log.warn("[onesub/apple] Sandbox receipt rejected in production:", tx.environment);
380
+ return null;
381
+ }
382
+ const status = deriveStatus(tx, null);
383
+ const purchasedAt = tx.originalPurchaseDate ?? tx.purchaseDate ?? Date.now();
384
+ return {
385
+ userId: "",
386
+ // caller fills this in from the request body
387
+ productId: tx.productId,
388
+ platform: "apple",
389
+ status,
390
+ expiresAt: new Date(tx.expiresDate).toISOString(),
391
+ originalTransactionId: tx.originalTransactionId,
392
+ purchasedAt: new Date(purchasedAt).toISOString(),
393
+ willRenew: status === shared.SUBSCRIPTION_STATUS.ACTIVE
394
+ // refined by renewal info in webhook
395
+ };
396
+ }
397
+ async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
398
+ if (config.mockMode) return mockValidateAppleProduct(signedTransaction, expectedProductId);
399
+ let tx;
400
+ try {
401
+ tx = await decodeJws(signedTransaction, config.skipJwsVerification);
402
+ } catch (err) {
403
+ const preview = signedTransaction.slice(0, 60);
404
+ const parts = signedTransaction.split(".").length;
405
+ const looksLikeJws = parts === 3;
406
+ log.warn(
407
+ "[onesub/apple] Failed to decode consumable JWS:",
408
+ err?.message ?? err,
409
+ `| receipt preview: "${preview}..." (len=${signedTransaction.length}, parts=${parts}, looksLikeJws=${looksLikeJws})`
410
+ );
411
+ return null;
412
+ }
413
+ if (!tx.bundleId || tx.bundleId !== config.bundleId) {
414
+ log.warn("[onesub/apple] Bundle ID mismatch:", tx.bundleId, "!==", config.bundleId);
415
+ return null;
416
+ }
417
+ if (tx.type !== "Consumable" && tx.type !== "Non-Consumable") {
418
+ log.warn("[onesub/apple] Invalid purchase type for product validation:", tx.type);
419
+ return null;
420
+ }
421
+ if (process.env["NODE_ENV"] === "production" && tx.environment !== "Production" && process.env["ONESUB_ALLOW_SANDBOX"] !== "true") {
422
+ log.warn("[onesub/apple] Sandbox receipt rejected in production:", tx.environment);
423
+ return null;
424
+ }
425
+ if (!tx.productId) {
426
+ log.warn("[onesub/apple] No productId in transaction");
427
+ return null;
428
+ }
429
+ if (expectedProductId && tx.productId !== expectedProductId) {
430
+ log.warn("[onesub/apple] Product ID mismatch:", tx.productId, "!==", expectedProductId);
431
+ return null;
432
+ }
433
+ if (tx.revocationDate) {
434
+ log.warn("[onesub/apple] Purchase was revoked/refunded");
435
+ return null;
436
+ }
437
+ if (tx.purchaseDate && Date.now() - tx.purchaseDate > MAX_CONSUMABLE_RECEIPT_AGE_MS) {
438
+ log.warn("[onesub/apple] Consumable receipt too old (>72h)");
439
+ return null;
440
+ }
441
+ const transactionId = tx.transactionId ?? tx.originalTransactionId;
442
+ if (!transactionId) {
443
+ log.warn("[onesub/apple] No transactionId in consumable transaction");
444
+ return null;
445
+ }
446
+ return {
447
+ transactionId,
448
+ productId: tx.productId,
449
+ purchasedAt: tx.purchaseDate ? new Date(tx.purchaseDate).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
450
+ };
451
+ }
452
+ async function decodeAppleNotification(payload, skipJwsVerification = false) {
453
+ const { signedTransactionInfo, signedRenewalInfo } = payload.data;
454
+ let tx;
455
+ let renewal = null;
456
+ try {
457
+ tx = await decodeJws(signedTransactionInfo, skipJwsVerification);
458
+ } catch {
459
+ return null;
460
+ }
461
+ try {
462
+ renewal = await decodeJws(signedRenewalInfo, skipJwsVerification);
463
+ } catch {
464
+ }
465
+ if (!tx.originalTransactionId) return null;
466
+ const status = deriveStatus(tx, renewal);
467
+ const willRenew = renewal?.autoRenewStatus === 1;
468
+ const txEnv = tx.environment;
469
+ const dataEnv = payload.data.environment;
470
+ const environment = txEnv === "Sandbox" || dataEnv === "Sandbox" ? "Sandbox" : "Production";
471
+ return {
472
+ originalTransactionId: tx.originalTransactionId,
473
+ transactionId: tx.transactionId ?? null,
474
+ type: tx.type ?? null,
475
+ productId: tx.productId ?? null,
476
+ bundleId: tx.bundleId ?? payload.data.bundleId ?? null,
477
+ environment,
478
+ status,
479
+ willRenew,
480
+ expiresAt: tx.expiresDate ? new Date(tx.expiresDate).toISOString() : null
481
+ };
482
+ }
483
+ var appleJwtMintPromises = /* @__PURE__ */ new Map();
484
+ var APPLE_JWT_TTL_MS = 20 * 60 * 1e3;
485
+ var APPLE_JWT_REFRESH_BEFORE_MS = 60 * 1e3;
486
+ async function makeAppleApiJwt(config) {
487
+ const { issuerId, keyId, privateKey, bundleId } = config;
488
+ if (!issuerId || !keyId || !privateKey) {
489
+ throw new Error("[onesub/apple] App Store Server API requires issuerId, keyId, and privateKey");
490
+ }
491
+ const cache = getDefaultCache();
492
+ const cacheKey = `apple:jwt:${issuerId}|${keyId}`;
493
+ const now = Date.now();
494
+ const cached = await cache.get(cacheKey);
495
+ if (cached && cached.expiresAt - now > APPLE_JWT_REFRESH_BEFORE_MS) {
496
+ return cached.token;
497
+ }
498
+ const inflight = appleJwtMintPromises.get(cacheKey);
499
+ if (inflight) return inflight;
500
+ const promise = (async () => {
501
+ const key = await jose.importPKCS8(privateKey, "ES256");
502
+ const issuedAt = Math.floor(Date.now() / 1e3);
503
+ const token = await new jose.SignJWT({ bid: bundleId }).setProtectedHeader({ alg: "ES256", kid: keyId, typ: "JWT" }).setIssuer(issuerId).setAudience("appstoreconnect-v1").setIssuedAt(issuedAt).setExpirationTime(issuedAt + Math.floor(APPLE_JWT_TTL_MS / 1e3)).sign(key);
504
+ const expiresAt = Date.now() + APPLE_JWT_TTL_MS;
505
+ await cache.set(cacheKey, { token, expiresAt }, 19 * 60);
506
+ return token;
507
+ })().finally(() => {
508
+ appleJwtMintPromises.delete(cacheKey);
509
+ });
510
+ appleJwtMintPromises.set(cacheKey, promise);
511
+ return promise;
512
+ }
513
+ async function sendAppleConsumptionResponse(transactionId, body, config, options) {
514
+ if (config.mockMode) return;
515
+ let jwt;
516
+ try {
517
+ jwt = await makeAppleApiJwt(config);
518
+ } catch (err) {
519
+ log.warn("[onesub/apple] Cannot send consumption response \u2014 JWT mint failed:", err);
520
+ return;
521
+ }
522
+ const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
523
+ const url = `https://${host}/inApps/v1/transactions/consumption/${encodeURIComponent(transactionId)}`;
524
+ try {
525
+ const resp = await fetchWithTimeout(url, {
526
+ method: "PUT",
527
+ headers: {
528
+ Authorization: `Bearer ${jwt}`,
529
+ "Content-Type": "application/json"
530
+ },
531
+ body: JSON.stringify(body)
532
+ });
533
+ if (!resp.ok) {
534
+ const text = await resp.text();
535
+ log.warn(`[onesub/apple] Consumption response API error ${resp.status}: ${text}`);
536
+ }
537
+ } catch (err) {
538
+ log.warn("[onesub/apple] Consumption response network error:", err);
539
+ }
540
+ }
541
+ var APPLE_SUBSCRIPTION_STATUS_CODE = {
542
+ ACTIVE: 1,
543
+ EXPIRED: 2,
544
+ BILLING_RETRY: 3,
545
+ GRACE_PERIOD: 4,
546
+ REVOKED: 5
547
+ };
548
+ function mapAppleStatusCode(code) {
549
+ switch (code) {
550
+ case APPLE_SUBSCRIPTION_STATUS_CODE.ACTIVE:
551
+ return shared.SUBSCRIPTION_STATUS.ACTIVE;
552
+ case APPLE_SUBSCRIPTION_STATUS_CODE.GRACE_PERIOD:
553
+ return shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
554
+ case APPLE_SUBSCRIPTION_STATUS_CODE.BILLING_RETRY:
555
+ return shared.SUBSCRIPTION_STATUS.ON_HOLD;
556
+ case APPLE_SUBSCRIPTION_STATUS_CODE.REVOKED:
557
+ return shared.SUBSCRIPTION_STATUS.CANCELED;
558
+ case APPLE_SUBSCRIPTION_STATUS_CODE.EXPIRED:
559
+ default:
560
+ return shared.SUBSCRIPTION_STATUS.EXPIRED;
561
+ }
562
+ }
563
+ async function fetchAppleSubscriptionStatus(originalTransactionId, config, options) {
564
+ if (config.mockMode) return null;
565
+ let jwt;
566
+ try {
567
+ jwt = await makeAppleApiJwt(config);
568
+ } catch (err) {
569
+ log.warn("[onesub/apple] Cannot fetch subscription status \u2014 JWT mint failed:", err);
570
+ return null;
571
+ }
572
+ const host = options?.sandbox ? "api.storekit-sandbox.itunes.apple.com" : "api.storekit.itunes.apple.com";
573
+ const url = `https://${host}/inApps/v1/subscriptions/${encodeURIComponent(originalTransactionId)}`;
574
+ let body;
575
+ try {
576
+ const resp = await fetchWithTimeout(url, {
577
+ headers: { Authorization: `Bearer ${jwt}` }
578
+ });
579
+ if (!resp.ok) {
580
+ const text = await resp.text();
581
+ log.warn(`[onesub/apple] Status API error ${resp.status}: ${text}`);
582
+ return null;
583
+ }
584
+ body = await resp.json();
585
+ } catch (err) {
586
+ log.warn("[onesub/apple] Status API network error:", err);
587
+ return null;
588
+ }
589
+ const entry = body.data?.flatMap((g) => g.lastTransactions ?? []).find((t) => t.originalTransactionId === originalTransactionId);
590
+ if (!entry || entry.status == null || !entry.signedTransactionInfo) {
591
+ log.warn("[onesub/apple] Status API returned no matching transaction for", originalTransactionId);
592
+ return null;
593
+ }
594
+ let tx;
595
+ try {
596
+ tx = await decodeJws(entry.signedTransactionInfo, config.skipJwsVerification);
597
+ } catch (err) {
598
+ log.warn("[onesub/apple] Failed to decode signedTransactionInfo from Status API:", err);
599
+ return null;
600
+ }
601
+ let renewal = null;
602
+ if (entry.signedRenewalInfo) {
603
+ try {
604
+ renewal = await decodeJws(entry.signedRenewalInfo, config.skipJwsVerification);
605
+ } catch {
606
+ }
607
+ }
608
+ if (!tx.productId || !tx.expiresDate) {
609
+ log.warn("[onesub/apple] Status API transaction missing productId or expiresDate");
610
+ return null;
611
+ }
612
+ const status = mapAppleStatusCode(entry.status);
613
+ const purchasedAt = tx.originalPurchaseDate ?? tx.purchaseDate ?? Date.now();
614
+ return {
615
+ userId: "",
616
+ // caller fills this in
617
+ productId: tx.productId,
618
+ platform: "apple",
619
+ status,
620
+ expiresAt: new Date(tx.expiresDate).toISOString(),
621
+ originalTransactionId,
622
+ purchasedAt: new Date(purchasedAt).toISOString(),
623
+ willRenew: renewal?.autoRenewStatus === 1
624
+ };
625
+ }
626
+ var GOOGLE_NOTIFICATION_TYPE = {
627
+ SUBSCRIPTION_RECOVERED: 1,
628
+ SUBSCRIPTION_RENEWED: 2,
629
+ SUBSCRIPTION_CANCELED: 3,
630
+ SUBSCRIPTION_PURCHASED: 4,
631
+ SUBSCRIPTION_ON_HOLD: 5,
632
+ SUBSCRIPTION_IN_GRACE_PERIOD: 6,
633
+ SUBSCRIPTION_RESTARTED: 7,
634
+ SUBSCRIPTION_PRICE_CHANGE_CONFIRMED: 8,
635
+ SUBSCRIPTION_PAUSED: 10,
636
+ SUBSCRIPTION_REVOKED: 12,
637
+ SUBSCRIPTION_EXPIRED: 13
638
+ };
639
+ var MAX_PRODUCT_RECEIPT_AGE_MS = 72 * 60 * 60 * 1e3;
640
+ var refreshPromises = /* @__PURE__ */ new Map();
641
+ function googleTokenCacheKey(serviceAccountKey) {
642
+ let hash = 0;
643
+ for (let i = 0; i < serviceAccountKey.length; i++) {
644
+ hash = hash * 31 + serviceAccountKey.charCodeAt(i) | 0;
645
+ }
646
+ return `google:oauth:${hash}`;
647
+ }
648
+ async function getCachedAccessToken(serviceAccountKey) {
649
+ const cache = getDefaultCache();
650
+ const cacheKey = googleTokenCacheKey(serviceAccountKey);
651
+ const cached = await cache.get(cacheKey);
652
+ if (cached && cached.expiresAt - Date.now() > 6e4) {
653
+ return cached.token;
654
+ }
655
+ const inflight = refreshPromises.get(cacheKey);
656
+ if (inflight) return inflight;
657
+ const promise = getAccessToken(serviceAccountKey).then(async (token) => {
658
+ const expiresAt = Date.now() + 36e5;
659
+ await cache.set(cacheKey, { token, expiresAt }, 3540);
660
+ return token;
661
+ }).finally(() => {
662
+ refreshPromises.delete(cacheKey);
663
+ });
664
+ refreshPromises.set(cacheKey, promise);
665
+ return promise;
666
+ }
667
+ async function getAccessToken(serviceAccountKey) {
668
+ let key;
669
+ try {
670
+ key = JSON.parse(serviceAccountKey);
671
+ } catch {
672
+ throw new Error("[onesub/google] Invalid serviceAccountKey JSON");
673
+ }
674
+ const tokenUri = key.token_uri ?? "https://oauth2.googleapis.com/token";
675
+ const scope = "https://www.googleapis.com/auth/androidpublisher";
676
+ const now = Math.floor(Date.now() / 1e3);
677
+ const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
678
+ const payload = Buffer.from(
679
+ JSON.stringify({
680
+ iss: key.client_email,
681
+ scope,
682
+ aud: tokenUri,
683
+ iat: now,
684
+ exp: now + 3600
685
+ })
686
+ ).toString("base64url");
687
+ const signingInput = `${header}.${payload}`;
688
+ const { createSign } = await import('crypto');
689
+ const sign = createSign("RSA-SHA256");
690
+ sign.update(signingInput);
691
+ const signature = sign.sign(key.private_key, "base64url");
692
+ const assertion = `${signingInput}.${signature}`;
693
+ const resp = await fetchWithTimeout(tokenUri, {
694
+ method: "POST",
695
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
696
+ body: new URLSearchParams({
697
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
698
+ assertion
699
+ })
700
+ });
701
+ if (!resp.ok) {
702
+ throw new Error(`[onesub/google] Token request failed: ${resp.status}`);
703
+ }
704
+ const data = await resp.json();
705
+ if (!data.access_token) throw new Error("[onesub/google] No access_token in response");
706
+ return data.access_token;
707
+ }
708
+ async function fetchSubscriptionPurchaseV2(packageName, purchaseToken, accessToken) {
709
+ const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(packageName)}/purchases/subscriptionsv2/tokens/${encodeURIComponent(purchaseToken)}`;
710
+ const resp = await fetchWithTimeout(url, {
711
+ headers: { Authorization: `Bearer ${accessToken}` }
712
+ });
713
+ if (!resp.ok) {
714
+ const body = await resp.text();
715
+ throw new Error(`[onesub/google] Play API v2 error ${resp.status}: ${body}`);
716
+ }
717
+ return resp.json();
718
+ }
719
+ async function fetchProductPurchase(packageName, productId, purchaseToken, accessToken) {
720
+ const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}`;
721
+ const resp = await fetchWithTimeout(url, {
722
+ headers: { Authorization: `Bearer ${accessToken}` }
723
+ });
724
+ if (!resp.ok) {
725
+ const body = await resp.text();
726
+ throw new Error(`[onesub/google] Play Products API error ${resp.status}: ${body}`);
727
+ }
728
+ return resp.json();
729
+ }
730
+ async function acknowledgeGoogleSubscription(purchaseToken, productId, config) {
731
+ if (config.mockMode) return;
732
+ if (!config.serviceAccountKey) return;
733
+ if (!config.packageName) return;
734
+ let accessToken;
735
+ try {
736
+ accessToken = await getCachedAccessToken(config.serviceAccountKey);
737
+ } catch (err) {
738
+ log.warn("[onesub/google] Could not get access token for subscription ack:", err);
739
+ return;
740
+ }
741
+ const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/subscriptions/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
742
+ try {
743
+ const resp = await fetchWithTimeout(url, {
744
+ method: "POST",
745
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
746
+ body: "{}"
747
+ });
748
+ if (!resp.ok) {
749
+ const body = await resp.text();
750
+ log.warn(`[onesub/google] Subscription acknowledge API error ${resp.status}: ${body} \u2014 auto-refund risk`);
751
+ }
752
+ } catch (err) {
753
+ log.warn("[onesub/google] Subscription acknowledge network error \u2014 auto-refund risk:", err);
754
+ }
755
+ }
756
+ async function acknowledgeGoogleProduct(purchaseToken, productId, config) {
757
+ if (config.mockMode) return;
758
+ if (!config.serviceAccountKey) return;
759
+ if (!config.packageName) return;
760
+ let accessToken;
761
+ try {
762
+ accessToken = await getCachedAccessToken(config.serviceAccountKey);
763
+ } catch (err) {
764
+ log.warn("[onesub/google] Could not get access token for product ack:", err);
765
+ return;
766
+ }
767
+ const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
768
+ try {
769
+ const resp = await fetchWithTimeout(url, {
770
+ method: "POST",
771
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
772
+ body: "{}"
773
+ });
774
+ if (!resp.ok) {
775
+ const body = await resp.text();
776
+ log.warn(`[onesub/google] Product acknowledge API error ${resp.status}: ${body} \u2014 auto-refund risk`);
777
+ }
778
+ } catch (err) {
779
+ log.warn("[onesub/google] Product acknowledge network error \u2014 auto-refund risk:", err);
780
+ }
781
+ }
782
+ async function consumeGoogleProductReceipt(purchaseToken, productId, config) {
783
+ if (!config.serviceAccountKey) return;
784
+ if (!config.packageName) return;
785
+ let accessToken;
786
+ try {
787
+ accessToken = await getCachedAccessToken(config.serviceAccountKey);
788
+ } catch (err) {
789
+ log.warn("[onesub/google] Could not get access token for consume:", err);
790
+ return;
791
+ }
792
+ const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(config.packageName)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:consume`;
793
+ try {
794
+ const resp = await fetchWithTimeout(url, {
795
+ method: "POST",
796
+ headers: { Authorization: `Bearer ${accessToken}` }
797
+ });
798
+ if (!resp.ok) {
799
+ const body = await resp.text();
800
+ log.warn(`[onesub/google] Consume API error ${resp.status}: ${body} \u2014 auto-refund risk`);
801
+ }
802
+ } catch (err) {
803
+ log.warn("[onesub/google] Consume network error \u2014 auto-refund risk:", err);
804
+ }
805
+ }
806
+ function deriveStatusV2(state) {
807
+ switch (state) {
808
+ case "SUBSCRIPTION_STATE_ACTIVE":
809
+ return shared.SUBSCRIPTION_STATUS.ACTIVE;
810
+ case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD":
811
+ return shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
812
+ case "SUBSCRIPTION_STATE_ON_HOLD":
813
+ return shared.SUBSCRIPTION_STATUS.ON_HOLD;
814
+ case "SUBSCRIPTION_STATE_PAUSED":
815
+ return shared.SUBSCRIPTION_STATUS.PAUSED;
816
+ case "SUBSCRIPTION_STATE_CANCELED":
817
+ return shared.SUBSCRIPTION_STATUS.CANCELED;
818
+ case "SUBSCRIPTION_STATE_EXPIRED":
819
+ return shared.SUBSCRIPTION_STATUS.EXPIRED;
820
+ case "SUBSCRIPTION_STATE_PENDING":
821
+ case "SUBSCRIPTION_STATE_UNSPECIFIED":
822
+ default:
823
+ return null;
824
+ }
825
+ }
826
+ async function validateGoogleReceipt(receipt, productId, config) {
827
+ if (config.mockMode) return mockValidateGoogleSubscription(receipt, productId);
828
+ if (!config.serviceAccountKey) {
829
+ log.warn("[onesub/google] No serviceAccountKey provided \u2014 cannot call Play API");
830
+ return null;
831
+ }
832
+ if (!config.packageName) {
833
+ log.warn("[onesub/google] No packageName provided \u2014 cannot call Play API");
834
+ return null;
835
+ }
836
+ let purchase;
837
+ try {
838
+ const token = await getCachedAccessToken(config.serviceAccountKey);
839
+ purchase = await fetchSubscriptionPurchaseV2(config.packageName, receipt, token);
840
+ } catch (err) {
841
+ log.error("[onesub/google] Receipt validation failed:", err);
842
+ return null;
843
+ }
844
+ const status = deriveStatusV2(purchase.subscriptionState);
845
+ if (!status) {
846
+ log.warn(
847
+ "[onesub/google] Unrecognised or pending subscriptionState \u2014 rejecting:",
848
+ purchase.subscriptionState
849
+ );
850
+ return null;
851
+ }
852
+ const lineItem = purchase.lineItems?.find((item) => item.productId === productId);
853
+ if (!lineItem) {
854
+ log.warn(
855
+ "[onesub/google] productId not found in subscription lineItems:",
856
+ productId,
857
+ "available:",
858
+ purchase.lineItems?.map((i) => i.productId).join(", ") ?? "(none)"
859
+ );
860
+ return null;
861
+ }
862
+ const expiresAt = lineItem.expiryTime ?? (/* @__PURE__ */ new Date()).toISOString();
863
+ const purchasedAt = purchase.startTime ?? (/* @__PURE__ */ new Date()).toISOString();
864
+ const willRenew = lineItem.autoRenewingPlan?.autoRenewEnabled ?? false;
865
+ const autoResumeTime = status === shared.SUBSCRIPTION_STATUS.PAUSED ? purchase.pausedStateContext?.autoResumeTime : void 0;
866
+ return {
867
+ userId: "",
868
+ // caller fills this in
869
+ productId,
870
+ platform: "google",
871
+ status,
872
+ expiresAt,
873
+ originalTransactionId: purchase.latestOrderId ?? receipt.slice(0, 64),
874
+ purchasedAt,
875
+ willRenew,
876
+ // Surface the linked token so host apps (and our webhook userId-continuity
877
+ // logic) can follow upgrade/downgrade chains.
878
+ linkedPurchaseToken: purchase.linkedPurchaseToken,
879
+ autoResumeTime
880
+ };
881
+ }
882
+ async function validateGoogleProductReceipt(purchaseToken, productId, config, type = "non_consumable") {
883
+ if (config.mockMode) return mockValidateGoogleProduct(purchaseToken, productId);
884
+ if (!config.serviceAccountKey) {
885
+ log.warn("[onesub/google] No serviceAccountKey \u2014 cannot validate product receipt");
886
+ return null;
887
+ }
888
+ if (!config.packageName) {
889
+ log.warn("[onesub/google] No packageName \u2014 cannot validate product receipt");
890
+ return null;
891
+ }
892
+ let purchase;
893
+ try {
894
+ const token = await getCachedAccessToken(config.serviceAccountKey);
895
+ purchase = await fetchProductPurchase(config.packageName, productId, purchaseToken, token);
896
+ } catch (err) {
897
+ log.error("[onesub/google] Product receipt validation failed:", err);
898
+ return null;
899
+ }
900
+ if (purchase.purchaseState !== 0) {
901
+ log.warn("[onesub/google] Purchase not completed, state:", purchase.purchaseState);
902
+ return null;
903
+ }
904
+ if (type === "consumable" && purchase.consumptionState === 1) {
905
+ log.warn("[onesub/google] Consumable already consumed \u2014 possible replay attack");
906
+ return null;
907
+ }
908
+ if (purchase.purchaseTimeMillis) {
909
+ const purchaseTime = parseInt(purchase.purchaseTimeMillis, 10);
910
+ if (Date.now() - purchaseTime > MAX_PRODUCT_RECEIPT_AGE_MS) {
911
+ log.warn("[onesub/google] Product receipt too old (>72h)");
912
+ return null;
913
+ }
914
+ }
915
+ if (!purchase.orderId) {
916
+ log.warn("[onesub/google] No orderId in product purchase");
917
+ return null;
918
+ }
919
+ return {
920
+ transactionId: purchase.orderId,
921
+ purchasedAt: purchase.purchaseTimeMillis ? new Date(parseInt(purchase.purchaseTimeMillis, 10)).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
922
+ };
923
+ }
924
+ function decodeGoogleNotification(payload) {
925
+ let notification;
926
+ try {
927
+ const json = Buffer.from(payload.message.data, "base64").toString("utf-8");
928
+ notification = JSON.parse(json);
929
+ } catch {
930
+ return null;
931
+ }
932
+ if (!notification.subscriptionNotification) {
933
+ return null;
934
+ }
935
+ const { notificationType, purchaseToken, subscriptionId } = notification.subscriptionNotification;
936
+ return {
937
+ notificationType,
938
+ purchaseToken,
939
+ subscriptionId,
940
+ packageName: notification.packageName
941
+ };
942
+ }
943
+ function decodeGoogleVoidedNotification(payload) {
944
+ let notification;
945
+ try {
946
+ const json = Buffer.from(payload.message.data, "base64").toString("utf-8");
947
+ notification = JSON.parse(json);
948
+ } catch {
949
+ return null;
950
+ }
951
+ if (!notification.voidedPurchaseNotification) return null;
952
+ const { purchaseToken, orderId, productType, refundType } = notification.voidedPurchaseNotification;
953
+ return {
954
+ purchaseToken,
955
+ orderId,
956
+ productType,
957
+ refundType,
958
+ packageName: notification.packageName
959
+ };
960
+ }
961
+ function isGoogleActiveNotification(notificationType) {
962
+ return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_PURCHASED || notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RENEWED || notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RECOVERED || notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RESTARTED;
963
+ }
964
+ function isGoogleCanceledNotification(notificationType) {
965
+ return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_CANCELED || notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_REVOKED;
966
+ }
967
+ function isGoogleExpiredNotification(notificationType) {
968
+ return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_EXPIRED;
969
+ }
970
+ function isGoogleGracePeriodNotification(notificationType) {
971
+ return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_IN_GRACE_PERIOD;
972
+ }
973
+ function isGoogleOnHoldNotification(notificationType) {
974
+ return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_ON_HOLD;
975
+ }
976
+ function isGooglePausedNotification(notificationType) {
977
+ return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_PAUSED;
978
+ }
979
+ function isGooglePriceChangeConfirmedNotification(notificationType) {
980
+ return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_PRICE_CHANGE_CONFIRMED;
981
+ }
982
+ function sendError(res, status, code, error, extra = {}) {
983
+ res.status(status).json({ ...extra, error, errorCode: code });
984
+ }
985
+ function sendZodError(res, err, extra = {}) {
986
+ sendError(
987
+ res,
988
+ 400,
989
+ shared.ONESUB_ERROR_CODE.INVALID_INPUT,
990
+ err.issues.map((e) => e.message).join(", "),
991
+ extra
992
+ );
993
+ }
994
+
995
+ // src/routes/validate.ts
996
+ var NO_SUB = { valid: false, subscription: null };
997
+ var validateSchema = zod.z.object({
998
+ platform: zod.z.enum(["apple", "google"]),
999
+ receipt: zod.z.string().min(1).max(1e4),
1000
+ userId: zod.z.string().min(1).max(256),
1001
+ productId: zod.z.string().min(1).max(256)
1002
+ });
1003
+ function createValidateRouter(config, store) {
1004
+ const router = express.Router();
1005
+ router.post(shared.ROUTES.VALIDATE, async (req, res) => {
1006
+ let platform;
1007
+ let receipt;
1008
+ let userId;
1009
+ let productId;
1010
+ try {
1011
+ ({ platform, receipt, userId, productId } = validateSchema.parse(req.body));
1012
+ } catch (err) {
1013
+ if (err instanceof zod.z.ZodError) {
1014
+ sendZodError(res, err, NO_SUB);
1015
+ return;
1016
+ }
1017
+ throw err;
1018
+ }
1019
+ try {
1020
+ let sub = null;
1021
+ if (platform === "apple") {
1022
+ if (!config.apple) {
1023
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple configuration not provided", NO_SUB);
1024
+ return;
1025
+ }
1026
+ sub = await validateAppleReceipt(receipt, config.apple);
1027
+ } else {
1028
+ if (!config.google) {
1029
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.GOOGLE_CONFIG_MISSING, "Google configuration not provided", NO_SUB);
1030
+ return;
1031
+ }
1032
+ sub = await validateGoogleReceipt(receipt, productId, config.google);
1033
+ }
1034
+ if (!sub) {
1035
+ sendError(res, 422, shared.ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_SUB);
1036
+ return;
1037
+ }
1038
+ sub.userId = userId;
1039
+ await store.save(sub);
1040
+ if (platform === "google" && config.google) {
1041
+ void acknowledgeGoogleSubscription(receipt, productId, config.google);
1042
+ }
1043
+ const response = { valid: true, subscription: sub };
1044
+ res.status(200).json(response);
1045
+ } catch (err) {
1046
+ log.error("[onesub/validate] Unexpected error:", err);
1047
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, "Internal server error during receipt validation", NO_SUB);
1048
+ }
1049
+ });
1050
+ return router;
1051
+ }
1052
+ var NO_SUB2 = { active: false, subscription: null };
1053
+ function createStatusRouter(store) {
1054
+ const router = express.Router();
1055
+ router.get(shared.ROUTES.STATUS, async (req, res) => {
1056
+ const userId = req.query["userId"];
1057
+ if (!userId || typeof userId !== "string") {
1058
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "Missing required query param: userId", NO_SUB2);
1059
+ return;
1060
+ }
1061
+ if (userId.length > 256) {
1062
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.USER_ID_TOO_LONG, "userId must not exceed 256 characters", NO_SUB2);
1063
+ return;
1064
+ }
1065
+ try {
1066
+ const sub = await store.getByUserId(userId);
1067
+ if (!sub) {
1068
+ const response2 = { active: false, subscription: null };
1069
+ res.status(200).json(response2);
1070
+ return;
1071
+ }
1072
+ const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
1073
+ const notYetExpired = new Date(sub.expiresAt).getTime() > Date.now();
1074
+ const active = statusAllows && notYetExpired;
1075
+ const response = { active, subscription: sub };
1076
+ res.status(200).json(response);
1077
+ } catch (err) {
1078
+ log.error("[onesub/status] Store error:", err);
1079
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", NO_SUB2);
1080
+ }
1081
+ });
1082
+ return router;
1083
+ }
1084
+ var APPLE_ACTIVE_TYPES = /* @__PURE__ */ new Set(["SUBSCRIBED", "DID_RENEW", "DID_RECOVER", "OFFER_REDEEMED"]);
1085
+ var APPLE_CANCELED_TYPES = /* @__PURE__ */ new Set(["REVOKE", "REFUND", "CONSUMPTION_REQUEST"]);
1086
+ var APPLE_EXPIRED_TYPES = /* @__PURE__ */ new Set(["EXPIRED"]);
1087
+ function mapAppleNotificationStatus(notificationType, subtype) {
1088
+ if (notificationType === "DID_FAIL_TO_RENEW") {
1089
+ return subtype === "GRACE_PERIOD" ? shared.SUBSCRIPTION_STATUS.GRACE_PERIOD : shared.SUBSCRIPTION_STATUS.ON_HOLD;
1090
+ }
1091
+ if (notificationType === "GRACE_PERIOD_EXPIRED") return shared.SUBSCRIPTION_STATUS.ON_HOLD;
1092
+ if (APPLE_CANCELED_TYPES.has(notificationType)) return shared.SUBSCRIPTION_STATUS.CANCELED;
1093
+ if (APPLE_EXPIRED_TYPES.has(notificationType)) return shared.SUBSCRIPTION_STATUS.EXPIRED;
1094
+ if (APPLE_ACTIVE_TYPES.has(notificationType)) return shared.SUBSCRIPTION_STATUS.ACTIVE;
1095
+ return null;
1096
+ }
1097
+ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1098
+ const body = req.body;
1099
+ if (!body.signedPayload) {
1100
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.MISSING_SIGNED_PAYLOAD, "Missing signedPayload");
1101
+ return;
1102
+ }
1103
+ let payload;
1104
+ try {
1105
+ payload = await decodeJws(
1106
+ body.signedPayload,
1107
+ config.apple?.skipJwsVerification
1108
+ );
1109
+ } catch (err) {
1110
+ log.error("[onesub/webhook/apple] Failed to decode signedPayload:", err);
1111
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_SIGNED_PAYLOAD, "Invalid signedPayload");
1112
+ return;
1113
+ }
1114
+ if (webhookEventStore && typeof payload.notificationUUID === "string") {
1115
+ const fresh = await webhookEventStore.markIfNew("apple", payload.notificationUUID);
1116
+ if (!fresh) {
1117
+ log.info("[onesub/webhook/apple] dedupe: already processed", payload.notificationUUID);
1118
+ res.status(200).json({ received: true, deduped: true });
1119
+ return;
1120
+ }
1121
+ }
1122
+ const decoded = await decodeAppleNotification(payload, config.apple?.skipJwsVerification);
1123
+ if (!decoded) {
1124
+ res.status(200).json({ received: true });
1125
+ return;
1126
+ }
1127
+ const {
1128
+ originalTransactionId,
1129
+ transactionId,
1130
+ type,
1131
+ productId,
1132
+ bundleId,
1133
+ environment,
1134
+ status,
1135
+ willRenew,
1136
+ expiresAt
1137
+ } = decoded;
1138
+ const notificationType = payload.notificationType;
1139
+ const subtype = payload.subtype;
1140
+ const mapped = mapAppleNotificationStatus(notificationType, subtype);
1141
+ const finalStatus = mapped ?? status;
1142
+ if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
1143
+ const provider = config.apple.consumptionInfoProvider;
1144
+ const appleConfig = config.apple;
1145
+ void (async () => {
1146
+ try {
1147
+ const info = await provider({
1148
+ transactionId,
1149
+ originalTransactionId,
1150
+ productId,
1151
+ bundleId: bundleId ?? appleConfig.bundleId,
1152
+ environment
1153
+ });
1154
+ if (info) {
1155
+ await sendAppleConsumptionResponse(transactionId, info, appleConfig, {
1156
+ sandbox: environment === "Sandbox"
1157
+ });
1158
+ }
1159
+ } catch (err) {
1160
+ log.warn("[onesub/webhook/apple] consumptionInfoProvider failed:", err);
1161
+ }
1162
+ })();
1163
+ }
1164
+ const isOneTimePurchase = type === "Consumable" || type === "Non-Consumable";
1165
+ const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
1166
+ try {
1167
+ if (isOneTimePurchase && isRefundOrRevoke) {
1168
+ const lookupId = transactionId ?? originalTransactionId;
1169
+ const removed = await purchaseStore.deletePurchaseByTransactionId(lookupId);
1170
+ if (!removed) {
1171
+ log.warn("[onesub/webhook/apple] IAP refund for unknown transaction:", lookupId);
1172
+ }
1173
+ res.status(200).json({ received: true });
1174
+ return;
1175
+ }
1176
+ const existing = await store.getByTransactionId(originalTransactionId);
1177
+ if (existing) {
1178
+ const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase && notificationType !== "CONSUMPTION_REQUEST";
1179
+ const keepEntitlement = isSubscriptionRefund && config.refundPolicy === "until_expiry";
1180
+ const updated = keepEntitlement ? { ...existing, willRenew: false } : {
1181
+ ...existing,
1182
+ status: finalStatus,
1183
+ willRenew,
1184
+ expiresAt: expiresAt ?? existing.expiresAt
1185
+ };
1186
+ await store.save(updated);
1187
+ } else if (config.apple?.issuerId && config.apple?.keyId && config.apple?.privateKey) {
1188
+ const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, {
1189
+ sandbox: environment === "Sandbox"
1190
+ });
1191
+ if (fresh) {
1192
+ fresh.userId = originalTransactionId;
1193
+ await store.save(fresh);
1194
+ } else {
1195
+ log.warn(
1196
+ "[onesub/webhook/apple] Unknown transaction and Status API returned no record:",
1197
+ originalTransactionId
1198
+ );
1199
+ }
1200
+ } else {
1201
+ log.warn(
1202
+ "[onesub/webhook/apple] Received notification for unknown transaction (no API creds to recover):",
1203
+ originalTransactionId
1204
+ );
1205
+ }
1206
+ res.status(200).json({ received: true });
1207
+ } catch (err) {
1208
+ log.error("[onesub/webhook/apple] Store update error:", err);
1209
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to update subscription");
1210
+ }
1211
+ }
1212
+ var GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs";
1213
+ var googleJwks = null;
1214
+ function getGoogleJwks() {
1215
+ if (!googleJwks) googleJwks = jose.createRemoteJWKSet(new URL(GOOGLE_JWKS_URL));
1216
+ return googleJwks;
1217
+ }
1218
+ async function verifyGooglePushToken(req, expectedAudience) {
1219
+ const authHeader = req.headers["authorization"];
1220
+ if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) return false;
1221
+ try {
1222
+ await jose.jwtVerify(authHeader.slice("Bearer ".length).trim(), getGoogleJwks(), {
1223
+ audience: expectedAudience
1224
+ });
1225
+ return true;
1226
+ } catch {
1227
+ return false;
1228
+ }
1229
+ }
1230
+ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore) {
1231
+ if (config.google?.pushAudience) {
1232
+ const authenticated = await verifyGooglePushToken(req, config.google.pushAudience);
1233
+ if (!authenticated) {
1234
+ sendError(res, 401, shared.ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
1235
+ return;
1236
+ }
1237
+ }
1238
+ const body = req.body;
1239
+ if (!body.message?.data) {
1240
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.MISSING_MESSAGE_DATA, "Missing message.data");
1241
+ return;
1242
+ }
1243
+ if (webhookEventStore && typeof body.message.messageId === "string") {
1244
+ const fresh = await webhookEventStore.markIfNew("google", body.message.messageId);
1245
+ if (!fresh) {
1246
+ log.info("[onesub/webhook/google] dedupe: already processed", body.message.messageId);
1247
+ res.status(200).json({ received: true, deduped: true });
1248
+ return;
1249
+ }
1250
+ }
1251
+ const voided = decodeGoogleVoidedNotification(body);
1252
+ if (voided) {
1253
+ if (config.google?.packageName && voided.packageName !== config.google.packageName) {
1254
+ log.warn("[onesub/webhook/google] voided package name mismatch:", voided.packageName, "!==", config.google.packageName);
1255
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1256
+ return;
1257
+ }
1258
+ try {
1259
+ if (voided.productType === 1) {
1260
+ const existing = await store.getByTransactionId(voided.purchaseToken);
1261
+ if (existing) {
1262
+ const updated = config.refundPolicy === "until_expiry" ? { ...existing, willRenew: false } : { ...existing, status: shared.SUBSCRIPTION_STATUS.CANCELED };
1263
+ await store.save(updated);
1264
+ } else {
1265
+ log.warn("[onesub/webhook/google] voided subscription for unknown purchaseToken:", voided.purchaseToken);
1266
+ }
1267
+ } else {
1268
+ const removed = await purchaseStore.deletePurchaseByTransactionId(voided.orderId);
1269
+ if (!removed) {
1270
+ log.warn("[onesub/webhook/google] voided IAP for unknown orderId:", voided.orderId);
1271
+ }
1272
+ }
1273
+ res.status(200).json({ received: true });
1274
+ } catch (err) {
1275
+ log.error("[onesub/webhook/google] voided notification error:", err);
1276
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process voided notification");
1277
+ }
1278
+ return;
1279
+ }
1280
+ const notification = decodeGoogleNotification(body);
1281
+ if (!notification) {
1282
+ res.status(200).json({ received: true });
1283
+ return;
1284
+ }
1285
+ const { notificationType, purchaseToken, subscriptionId, packageName } = notification;
1286
+ if (config.google?.packageName && packageName !== config.google.packageName) {
1287
+ log.warn("[onesub/webhook/google] Package name mismatch:", packageName, "!==", config.google.packageName);
1288
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1289
+ return;
1290
+ }
1291
+ try {
1292
+ const existing = await store.getByTransactionId(purchaseToken);
1293
+ let finalStatus;
1294
+ if (isGoogleActiveNotification(notificationType)) {
1295
+ finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
1296
+ } else if (isGoogleGracePeriodNotification(notificationType)) {
1297
+ finalStatus = shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
1298
+ } else if (isGoogleOnHoldNotification(notificationType)) {
1299
+ finalStatus = shared.SUBSCRIPTION_STATUS.ON_HOLD;
1300
+ } else if (isGooglePausedNotification(notificationType)) {
1301
+ finalStatus = shared.SUBSCRIPTION_STATUS.PAUSED;
1302
+ } else if (isGooglePriceChangeConfirmedNotification(notificationType)) {
1303
+ finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
1304
+ } else if (isGoogleCanceledNotification(notificationType)) {
1305
+ finalStatus = shared.SUBSCRIPTION_STATUS.CANCELED;
1306
+ } else if (isGoogleExpiredNotification(notificationType)) {
1307
+ finalStatus = shared.SUBSCRIPTION_STATUS.EXPIRED;
1308
+ } else {
1309
+ finalStatus = shared.SUBSCRIPTION_STATUS.ACTIVE;
1310
+ }
1311
+ if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1312
+ const hook = config.google.onPriceChangeConfirmed;
1313
+ void Promise.resolve().then(() => hook({ purchaseToken, subscriptionId, packageName })).catch((err) => log.warn("[onesub/webhook/google] onPriceChangeConfirmed hook failed:", err));
1314
+ }
1315
+ if (existing) {
1316
+ let updated = { ...existing, status: finalStatus };
1317
+ if (config.google?.serviceAccountKey) {
1318
+ const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1319
+ if (fresh) {
1320
+ const preserveNotificationStatus = finalStatus === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD || finalStatus === shared.SUBSCRIPTION_STATUS.ON_HOLD;
1321
+ updated = {
1322
+ ...existing,
1323
+ status: preserveNotificationStatus ? finalStatus : fresh.status,
1324
+ expiresAt: fresh.expiresAt,
1325
+ willRenew: fresh.willRenew,
1326
+ autoResumeTime: fresh.autoResumeTime,
1327
+ linkedPurchaseToken: fresh.linkedPurchaseToken ?? existing.linkedPurchaseToken
1328
+ };
1329
+ }
1330
+ }
1331
+ await store.save(updated);
1332
+ } else {
1333
+ if (config.google?.serviceAccountKey) {
1334
+ const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1335
+ if (fresh) {
1336
+ if (fresh.linkedPurchaseToken) {
1337
+ const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
1338
+ fresh.userId = previous ? previous.userId : purchaseToken;
1339
+ if (previous) {
1340
+ log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} \u2192 new token ${purchaseToken}`);
1341
+ }
1342
+ } else {
1343
+ fresh.userId = purchaseToken;
1344
+ }
1345
+ await store.save(fresh);
1346
+ }
1347
+ } else {
1348
+ log.warn("[onesub/webhook/google] Unknown purchase token and no serviceAccountKey to re-fetch:", purchaseToken);
1349
+ }
1350
+ }
1351
+ res.status(200).json({ received: true });
1352
+ } catch (err) {
1353
+ log.error("[onesub/webhook/google] Error handling notification:", err);
1354
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, "Failed to process notification");
1355
+ }
1356
+ }
1357
+
1358
+ // src/routes/webhook.ts
1359
+ function createWebhookRouter(config, store, purchaseStore, webhookEventStore) {
1360
+ const router = express.Router();
1361
+ router.post(
1362
+ shared.ROUTES.WEBHOOK_APPLE,
1363
+ (req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore)
1364
+ );
1365
+ router.post(
1366
+ shared.ROUTES.WEBHOOK_GOOGLE,
1367
+ (req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore)
1368
+ );
1369
+ return router;
1370
+ }
1371
+ var NO_PURCHASE = { valid: false, purchase: null };
1372
+ var validatePurchaseSchema = zod.z.object({
1373
+ platform: zod.z.enum(["apple", "google"]),
1374
+ receipt: zod.z.string().min(1).max(1e4),
1375
+ userId: zod.z.string().min(1).max(256),
1376
+ productId: zod.z.string().min(1).max(256),
1377
+ type: zod.z.enum([shared.PURCHASE_TYPE.CONSUMABLE, shared.PURCHASE_TYPE.NON_CONSUMABLE])
1378
+ });
1379
+ var purchaseStatusQuerySchema = zod.z.object({
1380
+ userId: zod.z.string().min(1).max(256),
1381
+ productId: zod.z.string().min(1).max(256).optional()
1382
+ });
1383
+ function createPurchaseRouter(config, purchaseStore) {
1384
+ const router = express.Router();
1385
+ router.post(shared.ROUTES.VALIDATE_PURCHASE, async (req, res) => {
1386
+ let body;
1387
+ try {
1388
+ body = validatePurchaseSchema.parse(req.body);
1389
+ } catch (err) {
1390
+ if (err instanceof zod.z.ZodError) {
1391
+ sendZodError(res, err, NO_PURCHASE);
1392
+ return;
1393
+ }
1394
+ throw err;
1395
+ }
1396
+ const { platform, receipt, userId, productId, type } = body;
1397
+ try {
1398
+ if (type === shared.PURCHASE_TYPE.NON_CONSUMABLE) {
1399
+ const alreadyOwned = await purchaseStore.hasPurchased(userId, productId);
1400
+ if (alreadyOwned) {
1401
+ sendError(res, 409, shared.ONESUB_ERROR_CODE.NON_CONSUMABLE_ALREADY_OWNED, "NON_CONSUMABLE_ALREADY_OWNED", NO_PURCHASE);
1402
+ return;
1403
+ }
1404
+ }
1405
+ let transactionId = null;
1406
+ let purchasedAt = (/* @__PURE__ */ new Date()).toISOString();
1407
+ if (platform === "apple") {
1408
+ if (!config.apple) {
1409
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple configuration not provided", NO_PURCHASE);
1410
+ return;
1411
+ }
1412
+ const result = await validateAppleConsumableReceipt(receipt, config.apple, productId);
1413
+ if (result) {
1414
+ transactionId = result.transactionId;
1415
+ purchasedAt = result.purchasedAt;
1416
+ }
1417
+ } else {
1418
+ if (!config.google) {
1419
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.GOOGLE_CONFIG_MISSING, "Google configuration not provided", NO_PURCHASE);
1420
+ return;
1421
+ }
1422
+ const result = await validateGoogleProductReceipt(
1423
+ receipt,
1424
+ productId,
1425
+ config.google,
1426
+ type === shared.PURCHASE_TYPE.CONSUMABLE ? "consumable" : "non_consumable"
1427
+ );
1428
+ if (result) {
1429
+ transactionId = result.transactionId;
1430
+ purchasedAt = result.purchasedAt;
1431
+ }
1432
+ }
1433
+ if (!transactionId) {
1434
+ sendError(res, 422, shared.ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_PURCHASE);
1435
+ return;
1436
+ }
1437
+ const existing = await purchaseStore.getPurchaseByTransactionId(transactionId);
1438
+ let action = "new";
1439
+ if (existing) {
1440
+ action = "restored";
1441
+ if (existing.userId !== userId) {
1442
+ if (type === shared.PURCHASE_TYPE.NON_CONSUMABLE) {
1443
+ await purchaseStore.reassignPurchase(transactionId, userId);
1444
+ log.info(
1445
+ `[onesub/purchase] reassigned transaction ${transactionId} from ${existing.userId} to ${userId}`
1446
+ );
1447
+ } else {
1448
+ sendError(
1449
+ res,
1450
+ 409,
1451
+ shared.ONESUB_ERROR_CODE.TRANSACTION_BELONGS_TO_OTHER_USER,
1452
+ "TRANSACTION_BELONGS_TO_OTHER_USER",
1453
+ NO_PURCHASE
1454
+ );
1455
+ return;
1456
+ }
1457
+ }
1458
+ const response2 = {
1459
+ valid: true,
1460
+ purchase: { ...existing, userId },
1461
+ action
1462
+ };
1463
+ res.status(200).json(response2);
1464
+ return;
1465
+ }
1466
+ const purchase = {
1467
+ userId,
1468
+ productId,
1469
+ platform,
1470
+ type,
1471
+ transactionId,
1472
+ purchasedAt,
1473
+ quantity: 1
1474
+ };
1475
+ await purchaseStore.savePurchase(purchase);
1476
+ if (platform === "google" && config.google) {
1477
+ if (type === shared.PURCHASE_TYPE.CONSUMABLE) {
1478
+ void consumeGoogleProductReceipt(receipt, productId, config.google);
1479
+ } else {
1480
+ void acknowledgeGoogleProduct(receipt, productId, config.google);
1481
+ }
1482
+ }
1483
+ const response = {
1484
+ valid: true,
1485
+ purchase,
1486
+ action
1487
+ };
1488
+ res.status(200).json(response);
1489
+ } catch (err) {
1490
+ log.error("[onesub/purchase/validate] Unexpected error:", err);
1491
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, "Internal server error during purchase validation", NO_PURCHASE);
1492
+ }
1493
+ });
1494
+ router.get(shared.ROUTES.PURCHASE_STATUS, async (req, res) => {
1495
+ let query;
1496
+ try {
1497
+ query = purchaseStatusQuerySchema.parse(req.query);
1498
+ } catch (err) {
1499
+ if (err instanceof zod.z.ZodError) {
1500
+ sendZodError(res, err, { purchases: [] });
1501
+ return;
1502
+ }
1503
+ throw err;
1504
+ }
1505
+ const { userId, productId } = query;
1506
+ try {
1507
+ let purchases = await purchaseStore.getPurchasesByUserId(userId);
1508
+ if (productId !== void 0) {
1509
+ purchases = purchases.filter((p) => p.productId === productId);
1510
+ }
1511
+ const response = { purchases };
1512
+ res.status(200).json(response);
1513
+ } catch (err) {
1514
+ log.error("[onesub/purchase/status] Store error:", err);
1515
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", { purchases: [] });
1516
+ }
1517
+ });
1518
+ return router;
1519
+ }
1520
+ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
1521
+ const productIdSet = new Set(entitlement.productIds);
1522
+ const now = Date.now();
1523
+ const subs = await store.getAllByUserId(userId);
1524
+ for (const sub of subs) {
1525
+ if (!productIdSet.has(sub.productId)) continue;
1526
+ const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
1527
+ if (!statusAllows) continue;
1528
+ if (new Date(sub.expiresAt).getTime() <= now) continue;
1529
+ return {
1530
+ active: true,
1531
+ source: "subscription",
1532
+ productId: sub.productId,
1533
+ expiresAt: sub.expiresAt
1534
+ };
1535
+ }
1536
+ const purchases = await purchaseStore.getPurchasesByUserId(userId);
1537
+ for (const p of purchases) {
1538
+ if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
1539
+ if (!productIdSet.has(p.productId)) continue;
1540
+ return {
1541
+ active: true,
1542
+ source: "purchase",
1543
+ productId: p.productId
1544
+ };
1545
+ }
1546
+ return { active: false, source: null };
1547
+ }
1548
+ var userIdSchema = zod.z.string().min(1).max(256);
1549
+ var entitlementIdSchema = zod.z.string().min(1).max(128);
1550
+ function createEntitlementRouter(config, store, purchaseStore) {
1551
+ if (!config.entitlements || Object.keys(config.entitlements).length === 0) {
1552
+ return null;
1553
+ }
1554
+ const entitlements = config.entitlements;
1555
+ const router = express.Router();
1556
+ router.get(shared.ROUTES.ENTITLEMENT, async (req, res) => {
1557
+ let userId;
1558
+ let id;
1559
+ try {
1560
+ userId = userIdSchema.parse(req.query["userId"]);
1561
+ id = entitlementIdSchema.parse(req.query["id"]);
1562
+ } catch {
1563
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId and id are required");
1564
+ return;
1565
+ }
1566
+ const entitlement = entitlements[id];
1567
+ if (!entitlement) {
1568
+ sendError(res, 404, shared.ONESUB_ERROR_CODE.ENTITLEMENT_NOT_FOUND, `Unknown entitlement: ${id}`);
1569
+ return;
1570
+ }
1571
+ try {
1572
+ const status = await evaluateEntitlement(userId, entitlement, store, purchaseStore);
1573
+ const response = { id, ...status };
1574
+ res.status(200).json(response);
1575
+ } catch (err) {
1576
+ log.error("[onesub/entitlement] evaluation error:", err);
1577
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
1578
+ }
1579
+ });
1580
+ router.get(shared.ROUTES.ENTITLEMENTS, async (req, res) => {
1581
+ let userId;
1582
+ try {
1583
+ userId = userIdSchema.parse(req.query["userId"]);
1584
+ } catch {
1585
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId is required");
1586
+ return;
1587
+ }
1588
+ try {
1589
+ const entries = await Promise.all(
1590
+ Object.entries(entitlements).map(async ([id, entitlement]) => {
1591
+ const status = await evaluateEntitlement(userId, entitlement, store, purchaseStore);
1592
+ return [id, status];
1593
+ })
1594
+ );
1595
+ const response = {
1596
+ entitlements: Object.fromEntries(entries)
1597
+ };
1598
+ res.status(200).json(response);
1599
+ } catch (err) {
1600
+ log.error("[onesub/entitlements] evaluation error:", err);
1601
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error", { entitlements: {} });
1602
+ }
1603
+ });
1604
+ return router;
1605
+ }
1606
+
1607
+ // src/routes/admin.ts
1608
+ var ADMIN_SECRET_HEADER = "x-admin-secret";
1609
+ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
1610
+ if (!config.adminSecret) return null;
1611
+ const router = express.Router();
1612
+ const adminSecret = config.adminSecret;
1613
+ const adminAuth = (req, res, next) => {
1614
+ const provided = req.headers[ADMIN_SECRET_HEADER];
1615
+ if (typeof provided !== "string" || provided !== adminSecret) {
1616
+ sendError(res, 401, shared.ONESUB_ERROR_CODE.INVALID_ADMIN_SECRET, "INVALID_ADMIN_SECRET");
1617
+ return;
1618
+ }
1619
+ next();
1620
+ };
1621
+ router.use("/onesub/purchase/admin", adminAuth);
1622
+ router.use("/onesub/admin", adminAuth);
1623
+ const resetParamsSchema = zod.z.object({
1624
+ userId: zod.z.string().min(1).max(256),
1625
+ productId: zod.z.string().min(1).max(256)
1626
+ });
1627
+ router.delete("/onesub/purchase/admin/:userId/:productId", async (req, res) => {
1628
+ let params;
1629
+ try {
1630
+ params = resetParamsSchema.parse(req.params);
1631
+ } catch {
1632
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId and productId required");
1633
+ return;
1634
+ }
1635
+ const deleted = await purchaseStore.deletePurchases(params.userId, params.productId);
1636
+ res.json({ ok: true, deleted });
1637
+ });
1638
+ const transferSchema = zod.z.object({
1639
+ transactionId: zod.z.string().min(1).max(256),
1640
+ newUserId: zod.z.string().min(1).max(256)
1641
+ });
1642
+ router.post("/onesub/purchase/admin/transfer", async (req, res) => {
1643
+ let body;
1644
+ try {
1645
+ body = transferSchema.parse(req.body);
1646
+ } catch (err) {
1647
+ if (err instanceof zod.z.ZodError) {
1648
+ sendZodError(res, err);
1649
+ return;
1650
+ }
1651
+ throw err;
1652
+ }
1653
+ const existing = await purchaseStore.getPurchaseByTransactionId(body.transactionId);
1654
+ if (!existing) {
1655
+ sendError(res, 404, shared.ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
1656
+ return;
1657
+ }
1658
+ await purchaseStore.deletePurchases(existing.userId, existing.productId);
1659
+ const migrated = { ...existing, userId: body.newUserId };
1660
+ await purchaseStore.savePurchase(migrated);
1661
+ res.json({ ok: true, purchase: migrated });
1662
+ });
1663
+ const grantSchema = zod.z.object({
1664
+ userId: zod.z.string().min(1).max(256),
1665
+ productId: zod.z.string().min(1).max(256),
1666
+ platform: zod.z.enum(["apple", "google"]),
1667
+ type: zod.z.enum([shared.PURCHASE_TYPE.CONSUMABLE, shared.PURCHASE_TYPE.NON_CONSUMABLE]).default(shared.PURCHASE_TYPE.NON_CONSUMABLE),
1668
+ transactionId: zod.z.string().min(1).max(256).optional()
1669
+ });
1670
+ router.post("/onesub/purchase/admin/grant", async (req, res) => {
1671
+ let body;
1672
+ try {
1673
+ body = grantSchema.parse(req.body);
1674
+ } catch (err) {
1675
+ if (err instanceof zod.z.ZodError) {
1676
+ sendZodError(res, err);
1677
+ return;
1678
+ }
1679
+ throw err;
1680
+ }
1681
+ const transactionId = body.transactionId ?? `admin_grant_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1682
+ const purchase = {
1683
+ transactionId,
1684
+ userId: body.userId,
1685
+ productId: body.productId,
1686
+ platform: body.platform,
1687
+ type: body.type,
1688
+ quantity: 1,
1689
+ purchasedAt: (/* @__PURE__ */ new Date()).toISOString()
1690
+ };
1691
+ await purchaseStore.savePurchase(purchase);
1692
+ res.json({ ok: true, purchase });
1693
+ });
1694
+ const listQuerySchema = zod.z.object({
1695
+ userId: zod.z.string().min(1).max(256).optional(),
1696
+ status: zod.z.enum([
1697
+ shared.SUBSCRIPTION_STATUS.ACTIVE,
1698
+ shared.SUBSCRIPTION_STATUS.GRACE_PERIOD,
1699
+ shared.SUBSCRIPTION_STATUS.ON_HOLD,
1700
+ shared.SUBSCRIPTION_STATUS.PAUSED,
1701
+ shared.SUBSCRIPTION_STATUS.EXPIRED,
1702
+ shared.SUBSCRIPTION_STATUS.CANCELED,
1703
+ shared.SUBSCRIPTION_STATUS.NONE
1704
+ ]).optional(),
1705
+ productId: zod.z.string().min(1).max(256).optional(),
1706
+ platform: zod.z.enum(["apple", "google"]).optional(),
1707
+ // Cap at 200 server-side so a runaway dashboard query can't tip the DB.
1708
+ limit: zod.z.coerce.number().int().positive().max(200).optional(),
1709
+ offset: zod.z.coerce.number().int().nonnegative().optional()
1710
+ });
1711
+ router.get(shared.ROUTES.ADMIN_SUBSCRIPTIONS, async (req, res) => {
1712
+ let query;
1713
+ try {
1714
+ query = listQuerySchema.parse(req.query);
1715
+ } catch (err) {
1716
+ if (err instanceof zod.z.ZodError) {
1717
+ sendZodError(res, err);
1718
+ return;
1719
+ }
1720
+ throw err;
1721
+ }
1722
+ try {
1723
+ const result = await store.listFiltered(query);
1724
+ const response = {
1725
+ items: result.items,
1726
+ total: result.total,
1727
+ limit: result.limit,
1728
+ offset: result.offset
1729
+ };
1730
+ res.status(200).json(response);
1731
+ } catch (err) {
1732
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "list error");
1733
+ }
1734
+ });
1735
+ const detailParamsSchema = zod.z.object({
1736
+ transactionId: zod.z.string().min(1).max(256)
1737
+ });
1738
+ router.get("/onesub/admin/subscriptions/:transactionId", async (req, res) => {
1739
+ let params;
1740
+ try {
1741
+ params = detailParamsSchema.parse(req.params);
1742
+ } catch {
1743
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "transactionId required");
1744
+ return;
1745
+ }
1746
+ try {
1747
+ const sub = await store.getByTransactionId(params.transactionId);
1748
+ if (!sub) {
1749
+ sendError(res, 404, shared.ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
1750
+ return;
1751
+ }
1752
+ res.status(200).json(sub);
1753
+ } catch (err) {
1754
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "detail error");
1755
+ }
1756
+ });
1757
+ const customerParamsSchema = zod.z.object({
1758
+ userId: zod.z.string().min(1).max(256)
1759
+ });
1760
+ router.get("/onesub/admin/customers/:userId", async (req, res) => {
1761
+ let params;
1762
+ try {
1763
+ params = customerParamsSchema.parse(req.params);
1764
+ } catch {
1765
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId required");
1766
+ return;
1767
+ }
1768
+ try {
1769
+ const [subscriptions, purchases] = await Promise.all([
1770
+ store.getAllByUserId(params.userId),
1771
+ purchaseStore.getPurchasesByUserId(params.userId)
1772
+ ]);
1773
+ let entitlements;
1774
+ if (config.entitlements && Object.keys(config.entitlements).length > 0) {
1775
+ entitlements = {};
1776
+ for (const [id, def] of Object.entries(config.entitlements)) {
1777
+ entitlements[id] = await evaluateEntitlement(params.userId, def, store, purchaseStore);
1778
+ }
1779
+ }
1780
+ const response = {
1781
+ userId: params.userId,
1782
+ subscriptions,
1783
+ purchases,
1784
+ ...entitlements ? { entitlements } : {}
1785
+ };
1786
+ res.status(200).json(response);
1787
+ } catch (err) {
1788
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "customer error");
1789
+ }
1790
+ });
1791
+ if (webhookQueue?.listDeadLetters) {
1792
+ router.get("/onesub/admin/webhook-deadletters", async (_req, res) => {
1793
+ try {
1794
+ const items = await webhookQueue.listDeadLetters();
1795
+ res.status(200).json({ items });
1796
+ } catch (err) {
1797
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "list error");
1798
+ }
1799
+ });
1800
+ }
1801
+ if (webhookQueue?.replayDeadLetter) {
1802
+ const replayParamsSchema = zod.z.object({ id: zod.z.string().min(1).max(256) });
1803
+ router.post("/onesub/admin/webhook-replay/:id", async (req, res) => {
1804
+ let params;
1805
+ try {
1806
+ params = replayParamsSchema.parse(req.params);
1807
+ } catch {
1808
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "id required");
1809
+ return;
1810
+ }
1811
+ try {
1812
+ await webhookQueue.replayDeadLetter(params.id);
1813
+ res.status(200).json({ ok: true });
1814
+ } catch (err) {
1815
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err.message ?? "replay error");
1816
+ }
1817
+ });
1818
+ }
1819
+ return router;
1820
+ }
1821
+ var ADMIN_SECRET_HEADER2 = "x-admin-secret";
1822
+ function createMetricsRouter(config, store, purchaseStore) {
1823
+ if (!config.adminSecret) return null;
1824
+ const router = express.Router();
1825
+ const adminSecret = config.adminSecret;
1826
+ router.use("/onesub/metrics", (req, res, next) => {
1827
+ const provided = req.headers[ADMIN_SECRET_HEADER2];
1828
+ if (typeof provided !== "string" || provided !== adminSecret) {
1829
+ sendError(res, 401, shared.ONESUB_ERROR_CODE.INVALID_ADMIN_SECRET, "INVALID_ADMIN_SECRET");
1830
+ return;
1831
+ }
1832
+ next();
1833
+ });
1834
+ const isActiveSub = (sub, now) => {
1835
+ const statusAllows = sub.status === shared.SUBSCRIPTION_STATUS.ACTIVE || sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD;
1836
+ return statusAllows && new Date(sub.expiresAt).getTime() > now;
1837
+ };
1838
+ const bump = (map, key) => {
1839
+ map[key] = (map[key] ?? 0) + 1;
1840
+ };
1841
+ router.get(shared.ROUTES.METRICS_ACTIVE, async (_req, res) => {
1842
+ try {
1843
+ const [subs, purchases] = await Promise.all([
1844
+ store.listAll(),
1845
+ purchaseStore.listAll()
1846
+ ]);
1847
+ const now = Date.now();
1848
+ const byProduct = {};
1849
+ const byProductPurchases = {};
1850
+ const byPlatform = {};
1851
+ let activeSubscriptions = 0;
1852
+ let gracePeriodSubscriptions = 0;
1853
+ let nonConsumablePurchases = 0;
1854
+ for (const sub of subs) {
1855
+ if (!isActiveSub(sub, now)) continue;
1856
+ activeSubscriptions++;
1857
+ if (sub.status === shared.SUBSCRIPTION_STATUS.GRACE_PERIOD) {
1858
+ gracePeriodSubscriptions++;
1859
+ }
1860
+ bump(byProduct, sub.productId);
1861
+ bump(byPlatform, sub.platform);
1862
+ }
1863
+ for (const p of purchases) {
1864
+ if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
1865
+ nonConsumablePurchases++;
1866
+ bump(byProductPurchases, p.productId);
1867
+ bump(byPlatform, p.platform);
1868
+ }
1869
+ const response = {
1870
+ total: activeSubscriptions + nonConsumablePurchases,
1871
+ activeSubscriptions,
1872
+ gracePeriodSubscriptions,
1873
+ nonConsumablePurchases,
1874
+ byProduct,
1875
+ byProductPurchases,
1876
+ byPlatform
1877
+ };
1878
+ res.status(200).json(response);
1879
+ } catch (err) {
1880
+ log.error("[onesub/metrics/active] error:", err);
1881
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
1882
+ }
1883
+ });
1884
+ const rangeSchema = zod.z.object({
1885
+ from: zod.z.string().min(1),
1886
+ to: zod.z.string().min(1),
1887
+ groupBy: zod.z.enum(["none", "day"]).optional()
1888
+ });
1889
+ function parseRange(req) {
1890
+ const parsed = rangeSchema.safeParse(req.query);
1891
+ if (!parsed.success) return { error: "from and to are required (ISO 8601)" };
1892
+ const fromMs = new Date(parsed.data.from).getTime();
1893
+ const toMs = new Date(parsed.data.to).getTime();
1894
+ if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
1895
+ return { error: "from / to must be ISO 8601 timestamps" };
1896
+ }
1897
+ if (fromMs > toMs) return { error: "from must be \u2264 to" };
1898
+ return { fromMs, toMs, groupBy: parsed.data.groupBy ?? "none" };
1899
+ }
1900
+ function utcDateKey(ms) {
1901
+ const d = new Date(ms);
1902
+ const yyyy = d.getUTCFullYear();
1903
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
1904
+ const dd = String(d.getUTCDate()).padStart(2, "0");
1905
+ return `${yyyy}-${mm}-${dd}`;
1906
+ }
1907
+ function emptyDailyBuckets(fromMs, toMs) {
1908
+ const out = [];
1909
+ const start = new Date(fromMs);
1910
+ start.setUTCHours(0, 0, 0, 0);
1911
+ let cur = start.getTime();
1912
+ while (cur <= toMs) {
1913
+ out.push({ date: utcDateKey(cur), count: 0 });
1914
+ cur += 864e5;
1915
+ }
1916
+ return out;
1917
+ }
1918
+ router.get(shared.ROUTES.METRICS_STARTED, async (req, res) => {
1919
+ const range = parseRange(req);
1920
+ if ("error" in range) {
1921
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
1922
+ return;
1923
+ }
1924
+ try {
1925
+ const subs = await store.listAll();
1926
+ const byProduct = {};
1927
+ const byPlatform = {};
1928
+ let total = 0;
1929
+ const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
1930
+ const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
1931
+ for (const sub of subs) {
1932
+ const purchasedMs = new Date(sub.purchasedAt).getTime();
1933
+ if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
1934
+ total++;
1935
+ bump(byProduct, sub.productId);
1936
+ bump(byPlatform, sub.platform);
1937
+ if (buckets && bucketIndex) {
1938
+ const idx = bucketIndex.get(utcDateKey(purchasedMs));
1939
+ if (idx !== void 0) buckets[idx].count++;
1940
+ }
1941
+ }
1942
+ const response = {
1943
+ from: req.query["from"],
1944
+ to: req.query["to"],
1945
+ total,
1946
+ byProduct,
1947
+ byPlatform,
1948
+ ...buckets ? { buckets } : {}
1949
+ };
1950
+ res.status(200).json(response);
1951
+ } catch (err) {
1952
+ log.error("[onesub/metrics/started] error:", err);
1953
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
1954
+ }
1955
+ });
1956
+ router.get(shared.ROUTES.METRICS_EXPIRED, async (req, res) => {
1957
+ const range = parseRange(req);
1958
+ if ("error" in range) {
1959
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
1960
+ return;
1961
+ }
1962
+ try {
1963
+ const subs = await store.listAll();
1964
+ const byProduct = {};
1965
+ const byPlatform = {};
1966
+ let total = 0;
1967
+ const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
1968
+ const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
1969
+ for (const sub of subs) {
1970
+ if (sub.status !== shared.SUBSCRIPTION_STATUS.EXPIRED && sub.status !== shared.SUBSCRIPTION_STATUS.CANCELED) continue;
1971
+ const expiredMs = new Date(sub.expiresAt).getTime();
1972
+ if (expiredMs < range.fromMs || expiredMs > range.toMs) continue;
1973
+ total++;
1974
+ bump(byProduct, sub.productId);
1975
+ bump(byPlatform, sub.platform);
1976
+ if (buckets && bucketIndex) {
1977
+ const idx = bucketIndex.get(utcDateKey(expiredMs));
1978
+ if (idx !== void 0) buckets[idx].count++;
1979
+ }
1980
+ }
1981
+ const response = {
1982
+ from: req.query["from"],
1983
+ to: req.query["to"],
1984
+ total,
1985
+ byProduct,
1986
+ byPlatform,
1987
+ ...buckets ? { buckets } : {}
1988
+ };
1989
+ res.status(200).json(response);
1990
+ } catch (err) {
1991
+ log.error("[onesub/metrics/expired] error:", err);
1992
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
1993
+ }
1994
+ });
1995
+ router.get(shared.ROUTES.METRICS_PURCHASES_STARTED, async (req, res) => {
1996
+ const range = parseRange(req);
1997
+ if ("error" in range) {
1998
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, range.error);
1999
+ return;
2000
+ }
2001
+ try {
2002
+ const purchases = await purchaseStore.listAll();
2003
+ const byProduct = {};
2004
+ const byPlatform = {};
2005
+ let total = 0;
2006
+ const buckets = range.groupBy === "day" ? emptyDailyBuckets(range.fromMs, range.toMs) : null;
2007
+ const bucketIndex = buckets ? new Map(buckets.map((b, i) => [b.date, i])) : null;
2008
+ for (const p of purchases) {
2009
+ if (p.type !== shared.PURCHASE_TYPE.NON_CONSUMABLE) continue;
2010
+ const purchasedMs = new Date(p.purchasedAt).getTime();
2011
+ if (purchasedMs < range.fromMs || purchasedMs > range.toMs) continue;
2012
+ total++;
2013
+ bump(byProduct, p.productId);
2014
+ bump(byPlatform, p.platform);
2015
+ if (buckets && bucketIndex) {
2016
+ const idx = bucketIndex.get(utcDateKey(purchasedMs));
2017
+ if (idx !== void 0) buckets[idx].count++;
2018
+ }
2019
+ }
2020
+ const response = {
2021
+ from: req.query["from"],
2022
+ to: req.query["to"],
2023
+ total,
2024
+ byProduct,
2025
+ byPlatform,
2026
+ ...buckets ? { buckets } : {}
2027
+ };
2028
+ res.status(200).json(response);
2029
+ } catch (err) {
2030
+ log.error("[onesub/metrics/purchases/started] error:", err);
2031
+ sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
2032
+ }
2033
+ });
2034
+ return router;
2035
+ }
2036
+
2037
+ // src/stores/schema.ts
2038
+ var SUBSCRIPTIONS_SCHEMA_SQL = `
2039
+ CREATE TABLE IF NOT EXISTS onesub_subscriptions (
2040
+ original_transaction_id TEXT PRIMARY KEY,
2041
+ user_id TEXT NOT NULL,
2042
+ product_id TEXT NOT NULL,
2043
+ platform TEXT NOT NULL,
2044
+ status TEXT NOT NULL,
2045
+ expires_at TIMESTAMPTZ NOT NULL,
2046
+ purchased_at TIMESTAMPTZ NOT NULL,
2047
+ will_renew BOOLEAN NOT NULL,
2048
+ linked_purchase_token TEXT,
2049
+ auto_resume_time TIMESTAMPTZ,
2050
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
2051
+ );
2052
+
2053
+ CREATE INDEX IF NOT EXISTS idx_onesub_subscriptions_user_id
2054
+ ON onesub_subscriptions (user_id, updated_at DESC);
2055
+
2056
+ -- Filter-helper indexes for /onesub/admin/subscriptions. Each filter column
2057
+ -- is paired with updated_at DESC so the planner can serve "latest matching"
2058
+ -- without sorting the full table once row count grows.
2059
+ CREATE INDEX IF NOT EXISTS idx_onesub_subscriptions_status_updated
2060
+ ON onesub_subscriptions (status, updated_at DESC);
2061
+ CREATE INDEX IF NOT EXISTS idx_onesub_subscriptions_platform_updated
2062
+ ON onesub_subscriptions (platform, updated_at DESC);
2063
+ CREATE INDEX IF NOT EXISTS idx_onesub_subscriptions_product
2064
+ ON onesub_subscriptions (product_id, updated_at DESC);
2065
+
2066
+ -- Backfill columns for installs that already created the table from an older
2067
+ -- schema. Safe to re-run.
2068
+ ALTER TABLE onesub_subscriptions ADD COLUMN IF NOT EXISTS linked_purchase_token TEXT;
2069
+ ALTER TABLE onesub_subscriptions ADD COLUMN IF NOT EXISTS auto_resume_time TIMESTAMPTZ;
2070
+ `.trim();
2071
+ var PURCHASES_SCHEMA_SQL = `
2072
+ CREATE TABLE IF NOT EXISTS onesub_purchases (
2073
+ transaction_id TEXT PRIMARY KEY,
2074
+ user_id TEXT NOT NULL,
2075
+ product_id TEXT NOT NULL,
2076
+ platform TEXT NOT NULL,
2077
+ type TEXT NOT NULL,
2078
+ quantity INTEGER NOT NULL DEFAULT 1,
2079
+ purchased_at TIMESTAMPTZ NOT NULL,
2080
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
2081
+ );
2082
+
2083
+ CREATE INDEX IF NOT EXISTS idx_onesub_purchases_user_id
2084
+ ON onesub_purchases (user_id, purchased_at DESC);
2085
+
2086
+ CREATE INDEX IF NOT EXISTS idx_onesub_purchases_user_product
2087
+ ON onesub_purchases (user_id, product_id);
2088
+
2089
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_onesub_purchases_non_consumable
2090
+ ON onesub_purchases (user_id, product_id)
2091
+ WHERE type = 'non_consumable';
2092
+ `.trim();
2093
+
2094
+ // src/stores/postgres.ts
2095
+ var PostgresSubscriptionStore = class {
2096
+ constructor(connectionString) {
2097
+ this.connectionString = connectionString;
2098
+ }
2099
+ connectionString;
2100
+ // Lazy-loaded to avoid requiring `pg` unless this class is actually instantiated.
2101
+ poolPromise = null;
2102
+ getPool() {
2103
+ if (!this.poolPromise) {
2104
+ this.poolPromise = (async () => {
2105
+ const pg = await import('pg').catch(() => {
2106
+ throw new Error(
2107
+ "[onesub] PostgresSubscriptionStore requires the `pg` package. Run: npm install pg"
2108
+ );
2109
+ });
2110
+ const Pool = pg.default?.Pool ?? pg.Pool;
2111
+ return new Pool({ connectionString: this.connectionString, max: 10 });
2112
+ })();
2113
+ }
2114
+ return this.poolPromise;
2115
+ }
2116
+ /**
2117
+ * Creates the `onesub_subscriptions` table and indexes if they do not
2118
+ * already exist. Call this once during application startup.
2119
+ */
2120
+ async initSchema() {
2121
+ const pool = await this.getPool();
2122
+ await pool.query(SUBSCRIPTIONS_SCHEMA_SQL);
2123
+ }
2124
+ /**
2125
+ * Upserts the given subscription.
2126
+ * If a row with the same `original_transaction_id` already exists it is
2127
+ * updated in place; otherwise a new row is inserted.
2128
+ */
2129
+ async save(sub) {
2130
+ const pool = await this.getPool();
2131
+ await pool.query(
2132
+ `INSERT INTO onesub_subscriptions
2133
+ (original_transaction_id, user_id, product_id, platform, status,
2134
+ expires_at, purchased_at, will_renew, linked_purchase_token,
2135
+ auto_resume_time, updated_at)
2136
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
2137
+ ON CONFLICT (original_transaction_id) DO UPDATE SET
2138
+ user_id = EXCLUDED.user_id,
2139
+ product_id = EXCLUDED.product_id,
2140
+ platform = EXCLUDED.platform,
2141
+ status = EXCLUDED.status,
2142
+ expires_at = EXCLUDED.expires_at,
2143
+ purchased_at = EXCLUDED.purchased_at,
2144
+ will_renew = EXCLUDED.will_renew,
2145
+ linked_purchase_token = EXCLUDED.linked_purchase_token,
2146
+ auto_resume_time = EXCLUDED.auto_resume_time,
2147
+ updated_at = NOW()`,
2148
+ [
2149
+ sub.originalTransactionId,
2150
+ sub.userId,
2151
+ sub.productId,
2152
+ sub.platform,
2153
+ sub.status,
2154
+ sub.expiresAt,
2155
+ sub.purchasedAt,
2156
+ sub.willRenew,
2157
+ sub.linkedPurchaseToken ?? null,
2158
+ sub.autoResumeTime ?? null
2159
+ ]
2160
+ );
2161
+ }
2162
+ /**
2163
+ * Returns the most recently updated subscription for the given user, or
2164
+ * `null` if no record exists.
2165
+ *
2166
+ * "Most recent" is determined by `updated_at DESC` so that the latest
2167
+ * status change is always returned when a user has multiple subscriptions.
2168
+ */
2169
+ async getByUserId(userId) {
2170
+ const pool = await this.getPool();
2171
+ const result = await pool.query(
2172
+ `SELECT *
2173
+ FROM onesub_subscriptions
2174
+ WHERE user_id = $1
2175
+ ORDER BY updated_at DESC
2176
+ LIMIT 1`,
2177
+ [userId]
2178
+ );
2179
+ return result.rows.length > 0 ? rowToSubscriptionInfo(result.rows[0]) : null;
2180
+ }
2181
+ /**
2182
+ * Returns every subscription record for the user (across all productIds),
2183
+ * ordered most-recent-first. Used by entitlement evaluation, which needs to
2184
+ * see all of a user's active subscriptions to decide whether any of them
2185
+ * grants the requested entitlement.
2186
+ */
2187
+ async getAllByUserId(userId) {
2188
+ const pool = await this.getPool();
2189
+ const result = await pool.query(
2190
+ `SELECT *
2191
+ FROM onesub_subscriptions
2192
+ WHERE user_id = $1
2193
+ ORDER BY updated_at DESC`,
2194
+ [userId]
2195
+ );
2196
+ return result.rows.map(rowToSubscriptionInfo);
2197
+ }
2198
+ /**
2199
+ * Returns the subscription identified by `originalTransactionId` (the
2200
+ * table primary key), or `null` if it does not exist.
2201
+ */
2202
+ async getByTransactionId(txId) {
2203
+ const pool = await this.getPool();
2204
+ const result = await pool.query(
2205
+ `SELECT *
2206
+ FROM onesub_subscriptions
2207
+ WHERE original_transaction_id = $1`,
2208
+ [txId]
2209
+ );
2210
+ return result.rows.length > 0 ? rowToSubscriptionInfo(result.rows[0]) : null;
2211
+ }
2212
+ async listAll() {
2213
+ const pool = await this.getPool();
2214
+ const result = await pool.query(`SELECT * FROM onesub_subscriptions`);
2215
+ return result.rows.map(rowToSubscriptionInfo);
2216
+ }
2217
+ async listFiltered(opts) {
2218
+ const pool = await this.getPool();
2219
+ const limit = opts.limit ?? 50;
2220
+ const offset = opts.offset ?? 0;
2221
+ const conditions = [];
2222
+ const params = [];
2223
+ if (opts.userId) {
2224
+ params.push(opts.userId);
2225
+ conditions.push(`user_id = $${params.length}`);
2226
+ }
2227
+ if (opts.status) {
2228
+ params.push(opts.status);
2229
+ conditions.push(`status = $${params.length}`);
2230
+ }
2231
+ if (opts.productId) {
2232
+ params.push(opts.productId);
2233
+ conditions.push(`product_id = $${params.length}`);
2234
+ }
2235
+ if (opts.platform) {
2236
+ params.push(opts.platform);
2237
+ conditions.push(`platform = $${params.length}`);
2238
+ }
2239
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
2240
+ const [countResult, pageResult] = await Promise.all([
2241
+ pool.query(
2242
+ `SELECT COUNT(*)::text AS total FROM onesub_subscriptions ${where}`,
2243
+ params
2244
+ ),
2245
+ pool.query(
2246
+ `SELECT * FROM onesub_subscriptions ${where}
2247
+ ORDER BY updated_at DESC
2248
+ LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
2249
+ [...params, limit, offset]
2250
+ )
2251
+ ]);
2252
+ return {
2253
+ items: pageResult.rows.map(rowToSubscriptionInfo),
2254
+ total: parseInt(countResult.rows[0]?.total ?? "0", 10),
2255
+ limit,
2256
+ offset
2257
+ };
2258
+ }
2259
+ /** Gracefully close the underlying connection pool. */
2260
+ async close() {
2261
+ if (this.poolPromise) {
2262
+ const pool = await this.poolPromise;
2263
+ await pool.end();
2264
+ this.poolPromise = null;
2265
+ }
2266
+ }
2267
+ };
2268
+ var PostgresPurchaseStore = class {
2269
+ constructor(connectionString) {
2270
+ this.connectionString = connectionString;
2271
+ }
2272
+ connectionString;
2273
+ poolPromise = null;
2274
+ getPool() {
2275
+ if (!this.poolPromise) {
2276
+ this.poolPromise = (async () => {
2277
+ const pg = await import('pg').catch(() => {
2278
+ throw new Error(
2279
+ "[onesub] PostgresPurchaseStore requires the `pg` package. Run: npm install pg"
2280
+ );
2281
+ });
2282
+ const Pool = pg.default?.Pool ?? pg.Pool;
2283
+ return new Pool({ connectionString: this.connectionString, max: 10 });
2284
+ })();
2285
+ }
2286
+ return this.poolPromise;
2287
+ }
2288
+ /**
2289
+ * Creates the `onesub_purchases` table and indexes if they do not already exist.
2290
+ * Call this once during application startup.
2291
+ */
2292
+ async initSchema() {
2293
+ const pool = await this.getPool();
2294
+ await pool.query(PURCHASES_SCHEMA_SQL);
2295
+ }
2296
+ async savePurchase(purchase) {
2297
+ const pool = await this.getPool();
2298
+ const existing = await pool.query(
2299
+ `SELECT user_id FROM onesub_purchases WHERE transaction_id = $1`,
2300
+ [purchase.transactionId]
2301
+ );
2302
+ if (existing.rows.length > 0) {
2303
+ const owner = existing.rows[0].user_id;
2304
+ if (owner !== purchase.userId) {
2305
+ const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2306
+ err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2307
+ throw err;
2308
+ }
2309
+ return;
2310
+ }
2311
+ await pool.query(
2312
+ `INSERT INTO onesub_purchases
2313
+ (transaction_id, user_id, product_id, platform, type, quantity, purchased_at)
2314
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
2315
+ [
2316
+ purchase.transactionId,
2317
+ purchase.userId,
2318
+ purchase.productId,
2319
+ purchase.platform,
2320
+ purchase.type,
2321
+ purchase.quantity,
2322
+ purchase.purchasedAt
2323
+ ]
2324
+ );
2325
+ }
2326
+ async getPurchasesByUserId(userId) {
2327
+ const pool = await this.getPool();
2328
+ const result = await pool.query(
2329
+ `SELECT *
2330
+ FROM onesub_purchases
2331
+ WHERE user_id = $1
2332
+ ORDER BY purchased_at DESC`,
2333
+ [userId]
2334
+ );
2335
+ return result.rows.map(rowToPurchaseInfo);
2336
+ }
2337
+ async getPurchaseByTransactionId(txId) {
2338
+ const pool = await this.getPool();
2339
+ const result = await pool.query(
2340
+ `SELECT *
2341
+ FROM onesub_purchases
2342
+ WHERE transaction_id = $1`,
2343
+ [txId]
2344
+ );
2345
+ return result.rows.length > 0 ? rowToPurchaseInfo(result.rows[0]) : null;
2346
+ }
2347
+ async hasPurchased(userId, productId) {
2348
+ const pool = await this.getPool();
2349
+ const result = await pool.query(
2350
+ `SELECT EXISTS(
2351
+ SELECT 1 FROM onesub_purchases
2352
+ WHERE user_id = $1 AND product_id = $2
2353
+ ) AS exists`,
2354
+ [userId, productId]
2355
+ );
2356
+ return result.rows[0]?.exists === true;
2357
+ }
2358
+ async reassignPurchase(transactionId, newUserId) {
2359
+ const pool = await this.getPool();
2360
+ const result = await pool.query(
2361
+ `UPDATE onesub_purchases SET user_id = $2 WHERE transaction_id = $1`,
2362
+ [transactionId, newUserId]
2363
+ );
2364
+ return (result.rowCount ?? 0) > 0;
2365
+ }
2366
+ async deletePurchases(userId, productId) {
2367
+ const pool = await this.getPool();
2368
+ const result = await pool.query(
2369
+ `DELETE FROM onesub_purchases WHERE user_id = $1 AND product_id = $2`,
2370
+ [userId, productId]
2371
+ );
2372
+ return result.rowCount ?? 0;
2373
+ }
2374
+ async deletePurchaseByTransactionId(transactionId) {
2375
+ const pool = await this.getPool();
2376
+ const result = await pool.query(
2377
+ `DELETE FROM onesub_purchases WHERE transaction_id = $1`,
2378
+ [transactionId]
2379
+ );
2380
+ return (result.rowCount ?? 0) > 0;
2381
+ }
2382
+ async listAll() {
2383
+ const pool = await this.getPool();
2384
+ const result = await pool.query(`SELECT * FROM onesub_purchases`);
2385
+ return result.rows.map(rowToPurchaseInfo);
2386
+ }
2387
+ /** Gracefully close the underlying connection pool. */
2388
+ async close() {
2389
+ if (this.poolPromise) {
2390
+ const pool = await this.poolPromise;
2391
+ await pool.end();
2392
+ this.poolPromise = null;
2393
+ }
2394
+ }
2395
+ };
2396
+ function rowToSubscriptionInfo(row) {
2397
+ return {
2398
+ originalTransactionId: row.original_transaction_id,
2399
+ userId: row.user_id,
2400
+ productId: row.product_id,
2401
+ platform: row.platform,
2402
+ status: row.status,
2403
+ expiresAt: row.expires_at.toISOString(),
2404
+ purchasedAt: row.purchased_at.toISOString(),
2405
+ willRenew: row.will_renew,
2406
+ linkedPurchaseToken: row.linked_purchase_token ?? void 0,
2407
+ autoResumeTime: row.auto_resume_time?.toISOString()
2408
+ };
2409
+ }
2410
+ function rowToPurchaseInfo(row) {
2411
+ return {
2412
+ transactionId: row.transaction_id,
2413
+ userId: row.user_id,
2414
+ productId: row.product_id,
2415
+ platform: row.platform,
2416
+ type: row.type,
2417
+ quantity: row.quantity,
2418
+ purchasedAt: row.purchased_at.toISOString()
2419
+ };
2420
+ }
2421
+
2422
+ // src/stores/redis.ts
2423
+ var SUB_TX_PREFIX = "onesub:sub:tx:";
2424
+ var SUB_USER_PREFIX = "onesub:sub:user:";
2425
+ var SUB_ALL = "onesub:sub:all";
2426
+ var SUB_ALL_SORTED = "onesub:sub:all:sorted";
2427
+ var PUR_TX_PREFIX = "onesub:purchase:tx:";
2428
+ var PUR_USER_PREFIX = "onesub:purchase:user:";
2429
+ var PUR_USER_PRODUCT_PREFIX = "onesub:purchase:user_product:";
2430
+ var PUR_ALL = "onesub:purchase:all";
2431
+ var RedisSubscriptionStore = class {
2432
+ constructor(redis) {
2433
+ this.redis = redis;
2434
+ }
2435
+ redis;
2436
+ async save(sub) {
2437
+ const score = Date.now();
2438
+ const txKey = SUB_TX_PREFIX + sub.originalTransactionId;
2439
+ const userKey = SUB_USER_PREFIX + sub.userId;
2440
+ const pipeline = this.redis.multi();
2441
+ pipeline.set(txKey, JSON.stringify(sub));
2442
+ pipeline.zadd(userKey, score, sub.originalTransactionId);
2443
+ pipeline.sadd(SUB_ALL, sub.originalTransactionId);
2444
+ pipeline.zadd(SUB_ALL_SORTED, score, sub.originalTransactionId);
2445
+ await pipeline.exec();
2446
+ }
2447
+ async getByUserId(userId) {
2448
+ const userKey = SUB_USER_PREFIX + userId;
2449
+ const ids = await this.redis.zrevrange(userKey, 0, 0);
2450
+ if (ids.length === 0) return null;
2451
+ const raw = await this.redis.get(SUB_TX_PREFIX + ids[0]);
2452
+ return raw ? JSON.parse(raw) : null;
2453
+ }
2454
+ async getAllByUserId(userId) {
2455
+ const userKey = SUB_USER_PREFIX + userId;
2456
+ const ids = await this.redis.zrevrange(userKey, 0, -1);
2457
+ if (ids.length === 0) return [];
2458
+ const raws = await this.redis.mget(...ids.map((id) => SUB_TX_PREFIX + id));
2459
+ return raws.filter((r) => r != null).map((r) => JSON.parse(r));
2460
+ }
2461
+ async getByTransactionId(txId) {
2462
+ const raw = await this.redis.get(SUB_TX_PREFIX + txId);
2463
+ return raw ? JSON.parse(raw) : null;
2464
+ }
2465
+ async listAll() {
2466
+ const ids = await this.redis.zrevrange(SUB_ALL_SORTED, 0, -1);
2467
+ if (ids.length === 0) return [];
2468
+ const raws = await this.redis.mget(...ids.map((id) => SUB_TX_PREFIX + id));
2469
+ return raws.filter((r) => r != null).map((r) => JSON.parse(r));
2470
+ }
2471
+ async listFiltered(opts) {
2472
+ const limit = opts.limit ?? 50;
2473
+ const offset = opts.offset ?? 0;
2474
+ const hasSecondaryFilters = !!(opts.status || opts.productId || opts.platform);
2475
+ if (opts.userId) {
2476
+ const candidates = await this.getAllByUserId(opts.userId);
2477
+ const filtered2 = candidates.filter((s) => {
2478
+ if (opts.status && s.status !== opts.status) return false;
2479
+ if (opts.productId && s.productId !== opts.productId) return false;
2480
+ if (opts.platform && s.platform !== opts.platform) return false;
2481
+ return true;
2482
+ });
2483
+ return { items: filtered2.slice(offset, offset + limit), total: filtered2.length, limit, offset };
2484
+ }
2485
+ if (!hasSecondaryFilters) {
2486
+ const [ids, total] = await Promise.all([
2487
+ this.redis.zrevrange(SUB_ALL_SORTED, offset, offset + limit - 1),
2488
+ this.redis.zcard(SUB_ALL_SORTED)
2489
+ ]);
2490
+ if (ids.length === 0) return { items: [], total, limit, offset };
2491
+ const raws2 = await this.redis.mget(...ids.map((id) => SUB_TX_PREFIX + id));
2492
+ const items = raws2.filter((r) => r != null).map((r) => JSON.parse(r));
2493
+ return { items, total, limit, offset };
2494
+ }
2495
+ const allIds = await this.redis.zrevrange(SUB_ALL_SORTED, 0, -1);
2496
+ if (allIds.length === 0) return { items: [], total: 0, limit, offset };
2497
+ const raws = await this.redis.mget(...allIds.map((id) => SUB_TX_PREFIX + id));
2498
+ const all = raws.filter((r) => r != null).map((r) => JSON.parse(r));
2499
+ const filtered = all.filter((s) => {
2500
+ if (opts.status && s.status !== opts.status) return false;
2501
+ if (opts.productId && s.productId !== opts.productId) return false;
2502
+ if (opts.platform && s.platform !== opts.platform) return false;
2503
+ return true;
2504
+ });
2505
+ return { items: filtered.slice(offset, offset + limit), total: filtered.length, limit, offset };
2506
+ }
2507
+ };
2508
+ var RedisPurchaseStore = class {
2509
+ constructor(redis) {
2510
+ this.redis = redis;
2511
+ }
2512
+ redis;
2513
+ async savePurchase(purchase) {
2514
+ const txKey = PUR_TX_PREFIX + purchase.transactionId;
2515
+ const existing = await this.redis.get(txKey);
2516
+ if (existing) {
2517
+ const owner = JSON.parse(existing).userId;
2518
+ if (owner !== purchase.userId) {
2519
+ const err = new Error("TRANSACTION_BELONGS_TO_OTHER_USER");
2520
+ err.code = "TRANSACTION_BELONGS_TO_OTHER_USER";
2521
+ throw err;
2522
+ }
2523
+ return;
2524
+ }
2525
+ const score = Date.parse(purchase.purchasedAt) || Date.now();
2526
+ const pipeline = this.redis.multi();
2527
+ pipeline.set(txKey, JSON.stringify(purchase));
2528
+ pipeline.zadd(PUR_USER_PREFIX + purchase.userId, score, purchase.transactionId);
2529
+ pipeline.sadd(PUR_USER_PRODUCT_PREFIX + purchase.userId + ":" + purchase.productId, purchase.transactionId);
2530
+ pipeline.sadd(PUR_ALL, purchase.transactionId);
2531
+ await pipeline.exec();
2532
+ }
2533
+ async getPurchasesByUserId(userId) {
2534
+ const ids = await this.redis.zrevrange(PUR_USER_PREFIX + userId, 0, -1);
2535
+ if (ids.length === 0) return [];
2536
+ const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
2537
+ return raws.filter((r) => r != null).map((r) => JSON.parse(r));
2538
+ }
2539
+ async getPurchaseByTransactionId(txId) {
2540
+ const raw = await this.redis.get(PUR_TX_PREFIX + txId);
2541
+ return raw ? JSON.parse(raw) : null;
2542
+ }
2543
+ async hasPurchased(userId, productId) {
2544
+ const count = await this.redis.scard(PUR_USER_PRODUCT_PREFIX + userId + ":" + productId);
2545
+ return count > 0;
2546
+ }
2547
+ async reassignPurchase(transactionId, newUserId) {
2548
+ const txKey = PUR_TX_PREFIX + transactionId;
2549
+ const raw = await this.redis.get(txKey);
2550
+ if (!raw) return false;
2551
+ const existing = JSON.parse(raw);
2552
+ if (existing.userId === newUserId) return true;
2553
+ const updated = { ...existing, userId: newUserId };
2554
+ const score = Date.parse(updated.purchasedAt) || Date.now();
2555
+ const oldUserProductKey = PUR_USER_PRODUCT_PREFIX + existing.userId + ":" + existing.productId;
2556
+ const newUserProductKey = PUR_USER_PRODUCT_PREFIX + newUserId + ":" + existing.productId;
2557
+ const pipeline = this.redis.multi();
2558
+ pipeline.set(txKey, JSON.stringify(updated));
2559
+ pipeline.zrem(PUR_USER_PREFIX + existing.userId, transactionId);
2560
+ pipeline.zadd(PUR_USER_PREFIX + newUserId, score, transactionId);
2561
+ pipeline.srem(oldUserProductKey, transactionId);
2562
+ pipeline.sadd(newUserProductKey, transactionId);
2563
+ await pipeline.exec();
2564
+ return true;
2565
+ }
2566
+ async deletePurchases(userId, productId) {
2567
+ const userProductKey = PUR_USER_PRODUCT_PREFIX + userId + ":" + productId;
2568
+ const ids = await this.redis.smembers(userProductKey);
2569
+ if (ids.length === 0) return 0;
2570
+ const pipeline = this.redis.multi();
2571
+ for (const id of ids) {
2572
+ pipeline.del(PUR_TX_PREFIX + id);
2573
+ pipeline.zrem(PUR_USER_PREFIX + userId, id);
2574
+ pipeline.srem(PUR_ALL, id);
2575
+ }
2576
+ pipeline.del(userProductKey);
2577
+ await pipeline.exec();
2578
+ return ids.length;
2579
+ }
2580
+ async deletePurchaseByTransactionId(transactionId) {
2581
+ const txKey = PUR_TX_PREFIX + transactionId;
2582
+ const raw = await this.redis.get(txKey);
2583
+ if (!raw) return false;
2584
+ const existing = JSON.parse(raw);
2585
+ const pipeline = this.redis.multi();
2586
+ pipeline.del(txKey);
2587
+ pipeline.zrem(PUR_USER_PREFIX + existing.userId, transactionId);
2588
+ pipeline.srem(PUR_USER_PRODUCT_PREFIX + existing.userId + ":" + existing.productId, transactionId);
2589
+ pipeline.srem(PUR_ALL, transactionId);
2590
+ await pipeline.exec();
2591
+ return true;
2592
+ }
2593
+ async listAll() {
2594
+ const ids = await this.redis.smembers(PUR_ALL);
2595
+ if (ids.length === 0) return [];
2596
+ const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
2597
+ return raws.filter((r) => r != null).map((r) => JSON.parse(r));
2598
+ }
2599
+ };
2600
+ var RedisCacheAdapter = class {
2601
+ constructor(redis, prefix = "onesub:cache:") {
2602
+ this.redis = redis;
2603
+ this.prefix = prefix;
2604
+ }
2605
+ redis;
2606
+ prefix;
2607
+ async get(key) {
2608
+ const raw = await this.redis.get(this.prefix + key);
2609
+ return raw ? JSON.parse(raw) : null;
2610
+ }
2611
+ async set(key, value, ttlSeconds) {
2612
+ const fullKey = this.prefix + key;
2613
+ const payload = JSON.stringify(value);
2614
+ if (ttlSeconds && ttlSeconds > 0) {
2615
+ await this.redis.set(fullKey, payload, "EX", ttlSeconds);
2616
+ } else {
2617
+ await this.redis.set(fullKey, payload);
2618
+ }
2619
+ }
2620
+ async del(key) {
2621
+ await this.redis.del(this.prefix + key);
2622
+ }
2623
+ };
2624
+ var WEBHOOK_EVENT_PREFIX = "onesub:webhook:event:";
2625
+ var DEFAULT_WEBHOOK_TTL_SECONDS = 7 * 24 * 60 * 60;
2626
+ var RedisWebhookEventStore = class {
2627
+ constructor(redis, ttlSeconds = DEFAULT_WEBHOOK_TTL_SECONDS) {
2628
+ this.redis = redis;
2629
+ this.ttlSeconds = ttlSeconds;
2630
+ }
2631
+ redis;
2632
+ ttlSeconds;
2633
+ async markIfNew(provider, eventId) {
2634
+ const key = WEBHOOK_EVENT_PREFIX + provider + ":" + eventId;
2635
+ const result = await this.redis.set(key, "1", "EX", this.ttlSeconds, "NX");
2636
+ return result === "OK";
2637
+ }
2638
+ };
2639
+
2640
+ // src/webhook-events.ts
2641
+ var DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
2642
+ var InMemoryWebhookEventStore = class {
2643
+ constructor(ttlSeconds = DEFAULT_TTL_SECONDS) {
2644
+ this.ttlSeconds = ttlSeconds;
2645
+ }
2646
+ ttlSeconds;
2647
+ seen = /* @__PURE__ */ new Map();
2648
+ async markIfNew(provider, eventId) {
2649
+ this.evictExpired();
2650
+ const key = `${provider}:${eventId}`;
2651
+ if (this.seen.has(key)) return false;
2652
+ this.seen.set(key, Date.now() + this.ttlSeconds * 1e3);
2653
+ return true;
2654
+ }
2655
+ evictExpired() {
2656
+ const now = Date.now();
2657
+ for (const [key, expiresAt] of this.seen) {
2658
+ if (expiresAt < now) this.seen.delete(key);
2659
+ }
2660
+ }
2661
+ };
2662
+ var CacheWebhookEventStore = class {
2663
+ constructor(cache, ttlSeconds = DEFAULT_TTL_SECONDS) {
2664
+ this.cache = cache;
2665
+ this.ttlSeconds = ttlSeconds;
2666
+ }
2667
+ cache;
2668
+ ttlSeconds;
2669
+ async markIfNew(provider, eventId) {
2670
+ const key = `webhook:event:${provider}:${eventId}`;
2671
+ const existing = await this.cache.get(key);
2672
+ if (existing) return false;
2673
+ await this.cache.set(key, "1", this.ttlSeconds);
2674
+ return true;
2675
+ }
2676
+ };
2677
+
2678
+ // src/webhook-queue.ts
2679
+ var InProcessWebhookQueue = class {
2680
+ handler = null;
2681
+ setHandler(handler) {
2682
+ this.handler = handler;
2683
+ }
2684
+ async enqueue(job) {
2685
+ if (!this.handler) {
2686
+ throw new Error("[onesub] webhook queue has no handler registered");
2687
+ }
2688
+ await this.handler(job);
2689
+ }
2690
+ };
2691
+ var BullMQWebhookQueue = class {
2692
+ queueName;
2693
+ maxAttempts;
2694
+ backoffMs;
2695
+ concurrency;
2696
+ connection;
2697
+ // Lazy-loaded so `bullmq` doesn't have to be installed unless this class
2698
+ // is instantiated.
2699
+ queuePromise = null;
2700
+ workerPromise = null;
2701
+ handler = null;
2702
+ constructor(opts) {
2703
+ this.connection = opts.connection;
2704
+ this.queueName = opts.queueName ?? "onesub-webhooks";
2705
+ this.maxAttempts = opts.maxAttempts ?? 5;
2706
+ this.backoffMs = opts.backoffMs ?? 1e3;
2707
+ this.concurrency = opts.concurrency ?? 4;
2708
+ }
2709
+ async getBullMQ() {
2710
+ return import('bullmq').catch(() => {
2711
+ throw new Error("[onesub] BullMQWebhookQueue requires the `bullmq` package. Run: npm install bullmq");
2712
+ });
2713
+ }
2714
+ getQueue() {
2715
+ if (!this.queuePromise) {
2716
+ this.queuePromise = (async () => {
2717
+ const { Queue } = await this.getBullMQ();
2718
+ return new Queue(this.queueName, { connection: this.connection });
2719
+ })();
2720
+ }
2721
+ return this.queuePromise;
2722
+ }
2723
+ setHandler(handler) {
2724
+ this.handler = handler;
2725
+ if (!this.workerPromise) {
2726
+ this.workerPromise = (async () => {
2727
+ const { Worker } = await this.getBullMQ();
2728
+ return new Worker(
2729
+ this.queueName,
2730
+ async (job) => {
2731
+ if (!this.handler) throw new Error("[onesub] handler not set");
2732
+ await this.handler(job.data);
2733
+ },
2734
+ {
2735
+ connection: this.connection,
2736
+ concurrency: this.concurrency
2737
+ }
2738
+ );
2739
+ })();
2740
+ }
2741
+ }
2742
+ async enqueue(job) {
2743
+ const queue = await this.getQueue();
2744
+ await queue.add("webhook", job, {
2745
+ attempts: this.maxAttempts,
2746
+ backoff: { type: "exponential", delay: this.backoffMs },
2747
+ removeOnFail: false,
2748
+ removeOnComplete: { age: 24 * 60 * 60, count: 1e3 },
2749
+ jobId: `${job.provider}:${job.eventId}`
2750
+ });
2751
+ }
2752
+ async listDeadLetters() {
2753
+ const queue = await this.getQueue();
2754
+ const failed = await queue.getFailed();
2755
+ return failed.map((j) => ({
2756
+ id: String(j.id),
2757
+ job: j.data,
2758
+ attempts: j.attemptsMade,
2759
+ lastError: j.failedReason ?? "unknown",
2760
+ failedAt: new Date(j.finishedOn ?? Date.now()).toISOString()
2761
+ }));
2762
+ }
2763
+ async replayDeadLetter(id) {
2764
+ const queue = await this.getQueue();
2765
+ const job = await queue.getJob(id);
2766
+ if (!job) throw new Error(`[onesub] dead-letter job ${id} not found`);
2767
+ await job.retry();
2768
+ }
2769
+ async close() {
2770
+ if (this.workerPromise) {
2771
+ const worker = await this.workerPromise;
2772
+ await worker.close();
2773
+ }
2774
+ if (this.queuePromise) {
2775
+ const queue = await this.queuePromise;
2776
+ await queue.close();
2777
+ }
2778
+ }
2779
+ };
2780
+ var ONESUB_OPENAPI = {
2781
+ openapi: "3.1.0",
2782
+ info: {
2783
+ title: "onesub HTTP API",
2784
+ version: "1.0.0",
2785
+ description: "Receipt validation, subscription status, webhooks, and admin endpoints exposed by `createOneSubMiddleware`."
2786
+ },
2787
+ servers: [
2788
+ { url: "http://localhost:4100", description: "Local dev (examples/server)" }
2789
+ ],
2790
+ paths: {
2791
+ [shared.ROUTES.VALIDATE]: {
2792
+ post: {
2793
+ summary: "Validate an Apple/Google receipt and persist subscription state.",
2794
+ requestBody: {
2795
+ required: true,
2796
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ValidateRequest" } } }
2797
+ },
2798
+ responses: {
2799
+ 200: { description: "Validation succeeded; subscription state persisted.", content: { "application/json": { schema: { $ref: "#/components/schemas/ValidateResponse" } } } },
2800
+ 400: { description: "Bad request \u2014 missing fields, malformed receipt, package mismatch." }
2801
+ }
2802
+ }
2803
+ },
2804
+ [shared.ROUTES.STATUS]: {
2805
+ get: {
2806
+ summary: "Fetch the most recent subscription state for a user.",
2807
+ parameters: [{ in: "query", name: "userId", required: true, schema: { type: "string" } }],
2808
+ responses: {
2809
+ 200: { description: "OK", content: { "application/json": { schema: { $ref: "#/components/schemas/StatusResponse" } } } }
2810
+ }
2811
+ }
2812
+ },
2813
+ [shared.ROUTES.WEBHOOK_APPLE]: {
2814
+ post: {
2815
+ summary: "Apple App Store Server Notification V2 receiver.",
2816
+ description: "Apple POSTs `{ signedPayload }`. JWS-verified, idempotent when `webhookEventStore` is configured.",
2817
+ responses: {
2818
+ 200: { description: "Acknowledged." },
2819
+ 400: { description: "Missing or invalid signedPayload." }
2820
+ }
2821
+ }
2822
+ },
2823
+ [shared.ROUTES.WEBHOOK_GOOGLE]: {
2824
+ post: {
2825
+ summary: "Google Play Real-Time Developer Notification (Pub/Sub push) receiver.",
2826
+ responses: {
2827
+ 200: { description: "Acknowledged." },
2828
+ 400: { description: "Missing message.data or package mismatch." },
2829
+ 401: { description: "pushAudience configured and JWT verification failed." }
2830
+ }
2831
+ }
2832
+ },
2833
+ [shared.ROUTES.ENTITLEMENTS]: {
2834
+ get: {
2835
+ summary: "Evaluate every configured entitlement for a user.",
2836
+ parameters: [{ in: "query", name: "userId", required: true, schema: { type: "string" } }],
2837
+ responses: {
2838
+ 200: { description: "Map of entitlementId \u2192 { granted, source }." }
2839
+ }
2840
+ }
2841
+ },
2842
+ [shared.ROUTES.ADMIN_SUBSCRIPTIONS]: {
2843
+ get: {
2844
+ summary: "Filtered, paginated subscription list (admin).",
2845
+ parameters: [
2846
+ { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
2847
+ { in: "query", name: "userId", schema: { type: "string" } },
2848
+ { in: "query", name: "status", schema: { type: "string", enum: ["active", "grace_period", "on_hold", "paused", "expired", "canceled", "none"] } },
2849
+ { in: "query", name: "productId", schema: { type: "string" } },
2850
+ { in: "query", name: "platform", schema: { type: "string", enum: ["apple", "google"] } },
2851
+ { in: "query", name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
2852
+ { in: "query", name: "offset", schema: { type: "integer", minimum: 0 } }
2853
+ ],
2854
+ responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
2855
+ }
2856
+ },
2857
+ "/onesub/admin/customers/{userId}": {
2858
+ get: {
2859
+ summary: "Customer profile bundle: subscriptions + purchases + entitlements (admin).",
2860
+ parameters: [
2861
+ { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
2862
+ { in: "path", name: "userId", required: true, schema: { type: "string" } }
2863
+ ],
2864
+ responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
2865
+ }
2866
+ },
2867
+ "/onesub/admin/webhook-deadletters": {
2868
+ get: {
2869
+ summary: "List failed webhook jobs (when a queue with DLQ support is configured).",
2870
+ parameters: [{ in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } }],
2871
+ responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
2872
+ }
2873
+ },
2874
+ "/onesub/admin/webhook-replay/{id}": {
2875
+ post: {
2876
+ summary: "Replay a dead-letter job through the webhook handler.",
2877
+ parameters: [
2878
+ { in: "header", name: "X-Admin-Secret", required: true, schema: { type: "string" } },
2879
+ { in: "path", name: "id", required: true, schema: { type: "string" } }
2880
+ ],
2881
+ responses: { 200: { description: "OK" }, 401: { description: "Invalid admin secret" } }
2882
+ }
2883
+ }
2884
+ },
2885
+ components: {
2886
+ schemas: {
2887
+ ValidateRequest: {
2888
+ type: "object",
2889
+ required: ["userId", "platform", "productId", "receipt"],
2890
+ properties: {
2891
+ userId: { type: "string" },
2892
+ platform: { type: "string", enum: ["apple", "google"] },
2893
+ productId: { type: "string" },
2894
+ receipt: { type: "string", description: "Apple JWS or Google purchaseToken" },
2895
+ purchaseType: { type: "string", enum: ["subscription", "consumable", "non_consumable"] }
2896
+ }
2897
+ },
2898
+ ValidateResponse: {
2899
+ type: "object",
2900
+ properties: {
2901
+ valid: { type: "boolean" },
2902
+ subscription: { $ref: "#/components/schemas/SubscriptionInfo" },
2903
+ purchase: { $ref: "#/components/schemas/PurchaseInfo" }
2904
+ }
2905
+ },
2906
+ StatusResponse: {
2907
+ type: "object",
2908
+ properties: {
2909
+ active: { type: "boolean" },
2910
+ subscription: { $ref: "#/components/schemas/SubscriptionInfo" }
2911
+ }
2912
+ },
2913
+ SubscriptionInfo: {
2914
+ type: "object",
2915
+ properties: {
2916
+ originalTransactionId: { type: "string" },
2917
+ userId: { type: "string" },
2918
+ productId: { type: "string" },
2919
+ platform: { type: "string", enum: ["apple", "google"] },
2920
+ status: { type: "string", enum: ["active", "grace_period", "on_hold", "paused", "expired", "canceled", "none"] },
2921
+ expiresAt: { type: "string", format: "date-time" },
2922
+ purchasedAt: { type: "string", format: "date-time" },
2923
+ willRenew: { type: "boolean" }
2924
+ }
2925
+ },
2926
+ PurchaseInfo: {
2927
+ type: "object",
2928
+ properties: {
2929
+ transactionId: { type: "string" },
2930
+ userId: { type: "string" },
2931
+ productId: { type: "string" },
2932
+ platform: { type: "string", enum: ["apple", "google"] },
2933
+ type: { type: "string", enum: ["consumable", "non_consumable"] },
2934
+ quantity: { type: "integer" },
2935
+ purchasedAt: { type: "string", format: "date-time" }
2936
+ }
2937
+ }
2938
+ },
2939
+ securitySchemes: {
2940
+ AdminSecret: {
2941
+ type: "apiKey",
2942
+ in: "header",
2943
+ name: "X-Admin-Secret"
2944
+ }
2945
+ }
2946
+ }
2947
+ };
2948
+ function openapiHandler() {
2949
+ return (_req, res) => {
2950
+ res.json(ONESUB_OPENAPI);
2951
+ };
2952
+ }
2953
+
2954
+ // src/tracing.ts
2955
+ var NOOP_SPAN = {
2956
+ setAttribute: () => {
2957
+ },
2958
+ recordException: () => {
2959
+ },
2960
+ setStatus: () => {
2961
+ },
2962
+ end: () => {
2963
+ }
2964
+ };
2965
+ var cachedTracer = null;
2966
+ var resolved = false;
2967
+ function getTracer() {
2968
+ if (resolved) return cachedTracer;
2969
+ resolved = true;
2970
+ try {
2971
+ const req = globalThis.require;
2972
+ if (!req) return null;
2973
+ const otel = req("@opentelemetry/api");
2974
+ cachedTracer = otel.trace.getTracer("@onesub/server", "1.0.0");
2975
+ return cachedTracer;
2976
+ } catch {
2977
+ return null;
2978
+ }
2979
+ }
2980
+ async function withSpan(name, attributes, fn) {
2981
+ const tracer = getTracer();
2982
+ if (!tracer) return fn(NOOP_SPAN);
2983
+ return tracer.startActiveSpan(name, async (span) => {
2984
+ for (const [k, v] of Object.entries(attributes)) span.setAttribute(k, v);
2985
+ try {
2986
+ const result = await fn(span);
2987
+ span.setStatus({ code: 1 });
2988
+ return result;
2989
+ } catch (err) {
2990
+ span.recordException(err);
2991
+ span.setStatus({ code: 2, message: err.message });
2992
+ throw err;
2993
+ } finally {
2994
+ span.end();
2995
+ }
2996
+ });
2997
+ }
2998
+
2999
+ // src/index.ts
3000
+ function createOneSubMiddleware(config) {
3001
+ setLogger(config.logger);
3002
+ if ((config.apple?.mockMode || config.google?.mockMode) && process.env["NODE_ENV"] === "production") {
3003
+ throw new Error(
3004
+ "[onesub] apple.mockMode / google.mockMode cannot be enabled when NODE_ENV=production \u2014 these modes accept any receipt as valid."
3005
+ );
3006
+ }
3007
+ const store = config.store ?? new InMemorySubscriptionStore();
3008
+ const purchaseStore = config.purchaseStore ?? new InMemoryPurchaseStore();
3009
+ if (config.cache) setDefaultCache(config.cache);
3010
+ const router = express.Router();
3011
+ router.use(express__default.default.json({ limit: "50kb" }));
3012
+ router.use(createValidateRouter(config, store));
3013
+ router.use(createStatusRouter(store));
3014
+ router.use(createWebhookRouter(config, store, purchaseStore, config.webhookEventStore));
3015
+ router.use(createPurchaseRouter(config, purchaseStore));
3016
+ const adminRouter = createAdminRouter(config, purchaseStore, store, config.webhookQueue);
3017
+ if (adminRouter) router.use(adminRouter);
3018
+ const entitlementRouter = createEntitlementRouter(config, store, purchaseStore);
3019
+ if (entitlementRouter) router.use(entitlementRouter);
3020
+ const metricsRouter = createMetricsRouter(config, store, purchaseStore);
3021
+ if (metricsRouter) router.use(metricsRouter);
3022
+ return router;
3023
+ }
3024
+ function createOneSubServer(config) {
3025
+ const app = express__default.default();
3026
+ app.get("/health", (_req, res) => {
3027
+ res.json({ ok: true, service: "onesub" });
3028
+ });
3029
+ app.use(createOneSubMiddleware(config));
3030
+ return app;
3031
+ }
3032
+ var isMain = typeof process !== "undefined" && process.argv[1] != null && new URL((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))).pathname.endsWith(process.argv[1].replace(/\\/g, "/"));
3033
+ if (isMain) {
3034
+ const config = {
3035
+ apple: process.env["APPLE_BUNDLE_ID"] ? {
3036
+ bundleId: process.env["APPLE_BUNDLE_ID"],
3037
+ sharedSecret: process.env["APPLE_SHARED_SECRET"],
3038
+ keyId: process.env["APPLE_KEY_ID"],
3039
+ issuerId: process.env["APPLE_ISSUER_ID"],
3040
+ privateKey: process.env["APPLE_PRIVATE_KEY"]
3041
+ } : void 0,
3042
+ google: process.env["GOOGLE_PACKAGE_NAME"] ? {
3043
+ packageName: process.env["GOOGLE_PACKAGE_NAME"],
3044
+ serviceAccountKey: process.env["GOOGLE_SERVICE_ACCOUNT_KEY"]
3045
+ } : void 0,
3046
+ database: {
3047
+ url: process.env["DATABASE_URL"] ?? ""
3048
+ },
3049
+ webhookSecret: process.env["WEBHOOK_SECRET"]
3050
+ };
3051
+ const port = process.env["PORT"] ? parseInt(process.env["PORT"], 10) : shared.DEFAULT_PORT;
3052
+ createOneSubServer(config).listen(port, () => {
3053
+ console.log(`[onesub] Server listening on port ${port}`);
3054
+ });
3055
+ }
3056
+
3057
+ exports.BullMQWebhookQueue = BullMQWebhookQueue;
3058
+ exports.CacheWebhookEventStore = CacheWebhookEventStore;
3059
+ exports.InMemoryCacheAdapter = InMemoryCacheAdapter;
3060
+ exports.InMemoryPurchaseStore = InMemoryPurchaseStore;
3061
+ exports.InMemorySubscriptionStore = InMemorySubscriptionStore;
3062
+ exports.InMemoryWebhookEventStore = InMemoryWebhookEventStore;
3063
+ exports.InProcessWebhookQueue = InProcessWebhookQueue;
3064
+ exports.ONESUB_OPENAPI = ONESUB_OPENAPI;
3065
+ exports.PostgresPurchaseStore = PostgresPurchaseStore;
3066
+ exports.PostgresSubscriptionStore = PostgresSubscriptionStore;
3067
+ exports.RedisCacheAdapter = RedisCacheAdapter;
3068
+ exports.RedisPurchaseStore = RedisPurchaseStore;
3069
+ exports.RedisSubscriptionStore = RedisSubscriptionStore;
3070
+ exports.RedisWebhookEventStore = RedisWebhookEventStore;
3071
+ exports.createOneSubMiddleware = createOneSubMiddleware;
3072
+ exports.createOneSubServer = createOneSubServer;
3073
+ exports.evaluateEntitlement = evaluateEntitlement;
3074
+ exports.fetchAppleSubscriptionStatus = fetchAppleSubscriptionStatus;
3075
+ exports.getDefaultCache = getDefaultCache;
3076
+ exports.log = log;
3077
+ exports.openapiHandler = openapiHandler;
3078
+ exports.setDefaultCache = setDefaultCache;
3079
+ exports.setLogger = setLogger;
3080
+ exports.validateAppleReceipt = validateAppleReceipt;
3081
+ exports.validateGoogleReceipt = validateGoogleReceipt;
3082
+ exports.withSpan = withSpan;
3083
+ //# sourceMappingURL=index.cjs.map
3084
+ //# sourceMappingURL=index.cjs.map