@onesub/server 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/__tests__/error-codes.test.js +8 -3
  2. package/dist/__tests__/error-codes.test.js.map +1 -1
  3. package/dist/__tests__/redis-store.test.js +58 -9
  4. package/dist/__tests__/redis-store.test.js.map +1 -1
  5. package/dist/__tests__/webhook-apple.test.js +247 -0
  6. package/dist/__tests__/webhook-apple.test.js.map +1 -0
  7. package/dist/__tests__/webhook-google.test.js +255 -0
  8. package/dist/__tests__/webhook-google.test.js.map +1 -0
  9. package/dist/cache.js +0 -4
  10. package/dist/cache.js.map +1 -1
  11. package/dist/index.cjs +36 -7
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.js +36 -7
  14. package/dist/index.js.map +1 -1
  15. package/dist/providers/apple.d.ts +8 -0
  16. package/dist/providers/apple.d.ts.map +1 -1
  17. package/dist/providers/apple.js +121 -1
  18. package/dist/providers/apple.js.map +1 -1
  19. package/dist/providers/google.d.ts +8 -0
  20. package/dist/providers/google.d.ts.map +1 -1
  21. package/dist/providers/google.js +42 -0
  22. package/dist/providers/google.js.map +1 -1
  23. package/dist/providers/mock.d.ts +2 -0
  24. package/dist/providers/mock.d.ts.map +1 -1
  25. package/dist/routes/admin.js +40 -0
  26. package/dist/routes/admin.js.map +1 -1
  27. package/dist/routes/apple-offer.js +72 -0
  28. package/dist/routes/apple-offer.js.map +1 -0
  29. package/dist/routes/purchase.d.ts.map +1 -1
  30. package/dist/routes/purchase.js +21 -4
  31. package/dist/routes/purchase.js.map +1 -1
  32. package/dist/routes/webhook-apple.js +145 -0
  33. package/dist/routes/webhook-apple.js.map +1 -0
  34. package/dist/routes/webhook-google.js +204 -0
  35. package/dist/routes/webhook-google.js.map +1 -0
  36. package/dist/routes/webhook.js +9 -451
  37. package/dist/routes/webhook.js.map +1 -1
  38. package/dist/stores/redis.js +65 -23
  39. package/dist/stores/redis.js.map +1 -1
  40. package/dist/webhook-events.js +9 -8
  41. package/dist/webhook-events.js.map +1 -1
  42. package/package.json +3 -3
@@ -1,461 +1,19 @@
1
1
  import { Router } from 'express';
2
- import { createRemoteJWKSet, jwtVerify } from 'jose';
3
- import { ROUTES, SUBSCRIPTION_STATUS, ONESUB_ERROR_CODE } from '@onesub/shared';
4
- import { decodeAppleNotification, decodeJws, sendAppleConsumptionResponse, fetchAppleSubscriptionStatus, } from '../providers/apple.js';
5
- import { decodeGoogleNotification, decodeGoogleVoidedNotification, validateGoogleReceipt, isGoogleActiveNotification, isGoogleCanceledNotification, isGoogleExpiredNotification, isGoogleGracePeriodNotification, isGoogleOnHoldNotification, isGooglePausedNotification, isGooglePriceChangeConfirmedNotification, } from '../providers/google.js';
6
- import { log } from '../logger.js';
7
- import { sendError } from '../errors.js';
8
- /**
9
- * Google's public JWKS endpoint used to verify Pub/Sub push JWT tokens.
10
- */
11
- const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
12
- // Lazily initialised — the JWKS fetch only occurs when the endpoint is first hit
13
- // and pushAudience is configured.
14
- let googleJwks = null;
15
- function getGoogleJwks() {
16
- if (!googleJwks) {
17
- googleJwks = createRemoteJWKSet(new URL(GOOGLE_JWKS_URL));
18
- }
19
- return googleJwks;
20
- }
21
- /**
22
- * Verifies the `Authorization: Bearer <token>` header as a Google-signed JWT
23
- * and checks that the `aud` claim matches `expectedAudience`.
24
- *
25
- * Returns `true` when verification succeeds, `false` otherwise.
26
- */
27
- async function verifyGooglePushToken(req, expectedAudience) {
28
- const authHeader = req.headers['authorization'];
29
- if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {
30
- return false;
31
- }
32
- const token = authHeader.slice('Bearer '.length).trim();
33
- try {
34
- await jwtVerify(token, getGoogleJwks(), { audience: expectedAudience });
35
- return true;
36
- }
37
- catch {
38
- return false;
39
- }
40
- }
41
- /**
42
- * Apple V2 notification types that indicate the subscription is now active.
43
- */
44
- const APPLE_ACTIVE_TYPES = new Set([
45
- 'SUBSCRIBED',
46
- 'DID_RENEW',
47
- 'DID_RECOVER',
48
- 'OFFER_REDEEMED',
49
- ]);
50
- /**
51
- * Apple V2 notification types that indicate cancellation.
52
- */
53
- const APPLE_CANCELED_TYPES = new Set([
54
- 'REVOKE',
55
- 'REFUND',
56
- 'CONSUMPTION_REQUEST',
57
- ]);
58
- /**
59
- * Apple V2 notification types that indicate expiry.
60
- * Pure EXPIRED only — GRACE_PERIOD_EXPIRED is handled separately as on_hold
61
- * because billing retry continues after the grace window ends.
62
- */
63
- const APPLE_EXPIRED_TYPES = new Set([
64
- 'EXPIRED',
65
- ]);
66
- /**
67
- * Map an Apple notification (notificationType + optional subtype) to a
68
- * lifecycle state. Returns null if the notification doesn't carry an explicit
69
- * lifecycle signal (in which case the caller falls back to the JWS-derived status).
70
- *
71
- * Apple references:
72
- * DID_FAIL_TO_RENEW + subtype GRACE_PERIOD → GRACE_PERIOD
73
- * DID_FAIL_TO_RENEW (no subtype) → ON_HOLD (billing retry, no grace)
74
- * GRACE_PERIOD_EXPIRED → ON_HOLD (grace ended, retry continues)
75
- * EXPIRED → EXPIRED (terminal, no further retries)
76
- * SUBSCRIBED / DID_RENEW / DID_RECOVER /
77
- * OFFER_REDEEMED → ACTIVE
78
- * REVOKE / REFUND / CONSUMPTION_REQUEST → CANCELED
79
- */
80
- function mapAppleNotificationStatus(notificationType, subtype) {
81
- if (notificationType === 'DID_FAIL_TO_RENEW') {
82
- return subtype === 'GRACE_PERIOD'
83
- ? SUBSCRIPTION_STATUS.GRACE_PERIOD
84
- : SUBSCRIPTION_STATUS.ON_HOLD;
85
- }
86
- if (notificationType === 'GRACE_PERIOD_EXPIRED')
87
- return SUBSCRIPTION_STATUS.ON_HOLD;
88
- if (APPLE_CANCELED_TYPES.has(notificationType))
89
- return SUBSCRIPTION_STATUS.CANCELED;
90
- if (APPLE_EXPIRED_TYPES.has(notificationType))
91
- return SUBSCRIPTION_STATUS.EXPIRED;
92
- if (APPLE_ACTIVE_TYPES.has(notificationType))
93
- return SUBSCRIPTION_STATUS.ACTIVE;
94
- return null;
95
- }
2
+ import { ROUTES } from '@onesub/shared';
3
+ import { handleAppleWebhook } from './webhook-apple.js';
4
+ import { handleGoogleWebhook } from './webhook-google.js';
96
5
  /**
97
6
  * Retry / durability semantics.
98
7
  *
99
- * Both Apple (App Store Server Notifications V2) and Google (Pub/Sub RTDN)
100
- * retry on any non-2xx response:
101
- * - Apple: up to 5 retries over ~3 days, exponential backoff.
102
- * - Google: per-Pub/Sub-subscription retry policy (default: retries until
103
- * the message is ack'd or the retention window expires, up to 7 days).
104
- *
105
- * This router uses that built-in retry instead of a local dead-letter queue:
106
- *
107
- * 4xx — the payload is unusable (missing signedPayload, bad signature,
108
- * package mismatch). Return 4xx so the sender does NOT retry, since
109
- * the same request would fail again.
110
- * 5xx — a transient failure on our side (DB down, network to Play API).
111
- * Return 5xx so Apple / Google retry the notification for us.
112
- * 2xx — processed, or intentionally ignored (e.g. test notification).
113
- *
114
- * If you need an explicit DLQ, wrap `store.save()` with your own error
115
- * handler before passing the store to `createOneSubMiddleware()`.
8
+ * Both Apple and Google retry on any non-2xx response.
9
+ * 4xx payload is unusable; do NOT retry.
10
+ * 5xx transient failure; source will retry.
11
+ * 2xx processed or intentionally ignored.
116
12
  */
117
13
  export function createWebhookRouter(config, store, purchaseStore, webhookEventStore) {
118
14
  const router = Router();
119
- /**
120
- * POST /onesub/webhook/apple
121
- *
122
- * Receives Apple App Store Server Notifications V2.
123
- * Apple sends a JWS-signed payload with a `signedPayload` field.
124
- */
125
- router.post(ROUTES.WEBHOOK_APPLE, async (req, res) => {
126
- // Apple sends the notification as { signedPayload: "<JWS>" }.
127
- // Only the JWS-signed path is accepted. Pre-decoded payloads are rejected
128
- // because they bypass signature verification and allow arbitrary state changes.
129
- const body = req.body;
130
- if (!body.signedPayload) {
131
- sendError(res, 400, ONESUB_ERROR_CODE.MISSING_SIGNED_PAYLOAD, 'Missing signedPayload');
132
- return;
133
- }
134
- let payload;
135
- try {
136
- payload = await decodeJws(body.signedPayload, config.apple?.skipJwsVerification);
137
- }
138
- catch (err) {
139
- log.error('[onesub/webhook/apple] Failed to decode signedPayload:', err);
140
- sendError(res, 400, ONESUB_ERROR_CODE.INVALID_SIGNED_PAYLOAD, 'Invalid signedPayload');
141
- return;
142
- }
143
- // Idempotency — Apple's notificationUUID uniquely identifies each
144
- // notification across retries. Skip if already processed.
145
- if (webhookEventStore && typeof payload.notificationUUID === 'string') {
146
- const fresh = await webhookEventStore.markIfNew('apple', payload.notificationUUID);
147
- if (!fresh) {
148
- log.info('[onesub/webhook/apple] dedupe: already processed', payload.notificationUUID);
149
- res.status(200).json({ received: true, deduped: true });
150
- return;
151
- }
152
- }
153
- const decoded = await decodeAppleNotification(payload, config.apple?.skipJwsVerification);
154
- if (!decoded) {
155
- // Could be a test notification or unsupported type — acknowledge it
156
- res.status(200).json({ received: true });
157
- return;
158
- }
159
- const { originalTransactionId, transactionId, type, productId, bundleId, environment, status, willRenew, expiresAt, } = decoded;
160
- const notificationType = payload.notificationType;
161
- const subtype = payload.subtype;
162
- // Derive final status from the notification type + subtype (overrides the
163
- // JWS-derived status when there is an explicit lifecycle signal).
164
- const mapped = mapAppleNotificationStatus(notificationType, subtype);
165
- const finalStatus = mapped ?? status;
166
- // CONSUMPTION_REQUEST — Apple is asking whether to grant a consumable
167
- // refund. If the host app provided a consumptionInfoProvider, call it and
168
- // PUT the response. Failures are logged; the webhook still 200s.
169
- if (notificationType === 'CONSUMPTION_REQUEST' &&
170
- config.apple?.consumptionInfoProvider &&
171
- transactionId &&
172
- productId) {
173
- const provider = config.apple.consumptionInfoProvider;
174
- const appleConfig = config.apple;
175
- void (async () => {
176
- try {
177
- const info = await provider({
178
- transactionId,
179
- originalTransactionId,
180
- productId,
181
- bundleId: bundleId ?? appleConfig.bundleId,
182
- environment,
183
- });
184
- if (info) {
185
- await sendAppleConsumptionResponse(transactionId, info, appleConfig, {
186
- sandbox: environment === 'Sandbox',
187
- });
188
- }
189
- }
190
- catch (err) {
191
- log.warn('[onesub/webhook/apple] consumptionInfoProvider failed:', err);
192
- }
193
- })();
194
- }
195
- // IAP refund / revoke for one-time purchases (consumable / non-consumable).
196
- // Apple sends REFUND notifications for both subscriptions and IAP — the
197
- // transaction `type` field disambiguates. Subscription refunds keep flowing
198
- // into the SubscriptionStore branch below.
199
- const isOneTimePurchase = type === 'Consumable' || type === 'Non-Consumable';
200
- const isRefundOrRevoke = APPLE_CANCELED_TYPES.has(notificationType);
201
- try {
202
- if (isOneTimePurchase && isRefundOrRevoke) {
203
- // For consumables, the refunded transactionId is unique per purchase.
204
- // For non-consumables, transactionId === originalTransactionId, so either
205
- // lookup succeeds.
206
- const lookupId = transactionId ?? originalTransactionId;
207
- const removed = await purchaseStore.deletePurchaseByTransactionId(lookupId);
208
- if (!removed) {
209
- log.warn('[onesub/webhook/apple] IAP refund for unknown transaction:', lookupId);
210
- }
211
- res.status(200).json({ received: true });
212
- return;
213
- }
214
- const existing = await store.getByTransactionId(originalTransactionId);
215
- if (existing) {
216
- // refundPolicy='until_expiry' for subscription refunds: keep status +
217
- // expiresAt untouched, only flip willRenew=false. The status route's
218
- // stale-record check will drop the user automatically once expiresAt
219
- // passes. Refer to OneSubServerConfig.refundPolicy for rationale.
220
- const isSubscriptionRefund = isRefundOrRevoke && !isOneTimePurchase && notificationType !== 'CONSUMPTION_REQUEST';
221
- const keepEntitlement = isSubscriptionRefund && config.refundPolicy === 'until_expiry';
222
- const updated = keepEntitlement
223
- ? { ...existing, willRenew: false }
224
- : {
225
- ...existing,
226
- status: finalStatus,
227
- willRenew,
228
- // expiresAt may be absent on non-subscription payloads — keep the
229
- // previously-stored value rather than overwriting with null/empty.
230
- expiresAt: expiresAt ?? existing.expiresAt,
231
- };
232
- await store.save(updated);
233
- }
234
- else if (config.apple?.issuerId && config.apple?.keyId && config.apple?.privateKey) {
235
- // No local record but App Store Server API credentials are present —
236
- // fetch the canonical state from Apple. This recovers from missed
237
- // webhooks (server downtime, queue truncation) and bootstraps
238
- // subscriptions purchased before this server existed.
239
- // userId is unknown at webhook time — store under originalTransactionId
240
- // as placeholder so a later /onesub/validate call can claim ownership.
241
- const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, {
242
- sandbox: environment === 'Sandbox',
243
- });
244
- if (fresh) {
245
- fresh.userId = originalTransactionId;
246
- await store.save(fresh);
247
- }
248
- else {
249
- log.warn('[onesub/webhook/apple] Unknown transaction and Status API returned no record:', originalTransactionId);
250
- }
251
- }
252
- else {
253
- // No local record and no API credentials to re-fetch — log and ack.
254
- log.warn('[onesub/webhook/apple] Received notification for unknown transaction (no API creds to recover):', originalTransactionId);
255
- }
256
- res.status(200).json({ received: true });
257
- }
258
- catch (err) {
259
- log.error('[onesub/webhook/apple] Store update error:', err);
260
- sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, 'Failed to update subscription');
261
- }
262
- });
263
- /**
264
- * POST /onesub/webhook/google
265
- *
266
- * Receives Google Play Real-Time Developer Notifications (RTDN) via Pub/Sub push.
267
- * The body is a standard Pub/Sub push message with base64-encoded data.
268
- */
269
- router.post(ROUTES.WEBHOOK_GOOGLE, async (req, res) => {
270
- // Verify Google-signed JWT when pushAudience is configured.
271
- // If pushAudience is not set, authentication is skipped for backward compatibility.
272
- if (config.google?.pushAudience) {
273
- const authenticated = await verifyGooglePushToken(req, config.google.pushAudience);
274
- if (!authenticated) {
275
- sendError(res, 401, ONESUB_ERROR_CODE.UNAUTHORIZED, 'Unauthorized');
276
- return;
277
- }
278
- }
279
- const body = req.body;
280
- if (!body.message?.data) {
281
- sendError(res, 400, ONESUB_ERROR_CODE.MISSING_MESSAGE_DATA, 'Missing message.data');
282
- return;
283
- }
284
- // Idempotency — Pub/Sub guarantees `messageId` uniqueness per notification.
285
- // Skip if already processed (Pub/Sub redelivers on any non-2xx).
286
- if (webhookEventStore && typeof body.message.messageId === 'string') {
287
- const fresh = await webhookEventStore.markIfNew('google', body.message.messageId);
288
- if (!fresh) {
289
- log.info('[onesub/webhook/google] dedupe: already processed', body.message.messageId);
290
- res.status(200).json({ received: true, deduped: true });
291
- return;
292
- }
293
- }
294
- // voidedPurchaseNotification — Google's refund/chargeback signal for both
295
- // subscriptions and one-time products. Routed here before the regular
296
- // subscriptionNotification decoder so it doesn't get swallowed as "unknown".
297
- const voided = decodeGoogleVoidedNotification(body);
298
- if (voided) {
299
- if (config.google?.packageName && voided.packageName !== config.google.packageName) {
300
- log.warn('[onesub/webhook/google] voided package name mismatch:', voided.packageName, '!==', config.google.packageName);
301
- sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, 'Package name mismatch');
302
- return;
303
- }
304
- try {
305
- if (voided.productType === 1) {
306
- // Subscription refund — purchaseToken is stored as originalTransactionId.
307
- const existing = await store.getByTransactionId(voided.purchaseToken);
308
- if (existing) {
309
- // refundPolicy='until_expiry' for subscription refunds: keep
310
- // status + expiresAt, only flip willRenew. See OneSubServerConfig.
311
- const updated = config.refundPolicy === 'until_expiry'
312
- ? { ...existing, willRenew: false }
313
- : { ...existing, status: SUBSCRIPTION_STATUS.CANCELED };
314
- await store.save(updated);
315
- }
316
- else {
317
- log.warn('[onesub/webhook/google] voided subscription for unknown purchaseToken:', voided.purchaseToken);
318
- }
319
- }
320
- else {
321
- // One-time product refund — orderId is stored as transactionId.
322
- // Always immediate (refundPolicy doesn't apply — IAP has no expiry).
323
- const removed = await purchaseStore.deletePurchaseByTransactionId(voided.orderId);
324
- if (!removed) {
325
- log.warn('[onesub/webhook/google] voided IAP for unknown orderId:', voided.orderId);
326
- }
327
- }
328
- res.status(200).json({ received: true });
329
- }
330
- catch (err) {
331
- log.error('[onesub/webhook/google] voided notification error:', err);
332
- sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, 'Failed to process voided notification');
333
- }
334
- return;
335
- }
336
- const notification = decodeGoogleNotification(body);
337
- if (!notification) {
338
- // Test notification or unknown type — acknowledge
339
- res.status(200).json({ received: true });
340
- return;
341
- }
342
- const { notificationType, purchaseToken, subscriptionId, packageName } = notification;
343
- // Validate the package name matches our config
344
- if (config.google?.packageName && packageName !== config.google.packageName) {
345
- log.warn('[onesub/webhook/google] Package name mismatch:', packageName, '!==', config.google.packageName);
346
- sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, 'Package name mismatch');
347
- return;
348
- }
349
- try {
350
- // Try to find the existing record by purchase token (used as originalTransactionId)
351
- const existing = await store.getByTransactionId(purchaseToken);
352
- let finalStatus;
353
- if (isGoogleActiveNotification(notificationType)) {
354
- finalStatus = SUBSCRIPTION_STATUS.ACTIVE;
355
- }
356
- else if (isGoogleGracePeriodNotification(notificationType)) {
357
- finalStatus = SUBSCRIPTION_STATUS.GRACE_PERIOD;
358
- }
359
- else if (isGoogleOnHoldNotification(notificationType)) {
360
- finalStatus = SUBSCRIPTION_STATUS.ON_HOLD;
361
- }
362
- else if (isGooglePausedNotification(notificationType)) {
363
- finalStatus = SUBSCRIPTION_STATUS.PAUSED;
364
- }
365
- else if (isGooglePriceChangeConfirmedNotification(notificationType)) {
366
- // User accepted a developer-initiated price change. Subscription stays
367
- // active; the new price kicks in at the next renewal. Host can hook
368
- // onPriceChangeConfirmed below to log/notify.
369
- finalStatus = SUBSCRIPTION_STATUS.ACTIVE;
370
- }
371
- else if (isGoogleCanceledNotification(notificationType)) {
372
- finalStatus = SUBSCRIPTION_STATUS.CANCELED;
373
- }
374
- else if (isGoogleExpiredNotification(notificationType)) {
375
- finalStatus = SUBSCRIPTION_STATUS.EXPIRED;
376
- }
377
- else {
378
- // Unhandled notification type (PAUSE_SCHEDULE_CHANGED, DEFERRED, etc.) —
379
- // re-fetch from Play API will correct the status (subscriptionsv2 returns
380
- // SUBSCRIPTION_STATE_PAUSED etc. directly).
381
- finalStatus = SUBSCRIPTION_STATUS.ACTIVE; // optimistic; re-fetch below will correct it
382
- }
383
- // Fire-and-forget hook for PRICE_CHANGE_CONFIRMED — happens before store
384
- // save so the hook still runs even if save errors out (the host's
385
- // analytics/notification side effect shouldn't block on DB issues).
386
- if (isGooglePriceChangeConfirmedNotification(notificationType) &&
387
- config.google?.onPriceChangeConfirmed) {
388
- const hook = config.google.onPriceChangeConfirmed;
389
- void Promise.resolve()
390
- .then(() => hook({ purchaseToken, subscriptionId, packageName }))
391
- .catch((err) => log.warn('[onesub/webhook/google] onPriceChangeConfirmed hook failed:', err));
392
- }
393
- if (existing) {
394
- // Re-validate against Play API to get the latest expiry date
395
- let updated = { ...existing, status: finalStatus };
396
- if (config.google?.serviceAccountKey) {
397
- const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
398
- if (fresh) {
399
- // Notification-derived grace_period/on_hold are authoritative — the
400
- // expiry-based deriveStatus() in the provider can't observe these
401
- // states (paymentState heuristics are unreliable), so trust the
402
- // RTDN signal over the API-derived status for those two cases.
403
- const preserveNotificationStatus = finalStatus === SUBSCRIPTION_STATUS.GRACE_PERIOD ||
404
- finalStatus === SUBSCRIPTION_STATUS.ON_HOLD;
405
- updated = {
406
- ...existing,
407
- status: preserveNotificationStatus ? finalStatus : fresh.status,
408
- expiresAt: fresh.expiresAt,
409
- willRenew: fresh.willRenew,
410
- // Propagate v2-only fields that fresh may have updated. Without
411
- // these, paused→active transitions left autoResumeTime stale and
412
- // upgrade chains lost their linkedPurchaseToken on subsequent
413
- // notifications. Use ?? undefined so cleared values become undefined
414
- // (e.g. resume from paused → autoResumeTime should disappear).
415
- autoResumeTime: fresh.autoResumeTime,
416
- linkedPurchaseToken: fresh.linkedPurchaseToken ?? existing.linkedPurchaseToken,
417
- };
418
- }
419
- }
420
- await store.save(updated);
421
- }
422
- else {
423
- // Unknown purchase token — attempt to create a record via Play API
424
- if (config.google?.serviceAccountKey) {
425
- const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
426
- if (fresh) {
427
- // Upgrade/downgrade continuity: if the v2 response carries a
428
- // linkedPurchaseToken pointing at a previous subscription we know
429
- // about, inherit the userId from it so the same person doesn't
430
- // appear as a brand-new placeholder after a plan change.
431
- if (fresh.linkedPurchaseToken) {
432
- const previous = await store.getByTransactionId(fresh.linkedPurchaseToken);
433
- if (previous) {
434
- fresh.userId = previous.userId;
435
- log.info(`[onesub/webhook/google] inherited userId ${previous.userId} from linkedPurchaseToken ${fresh.linkedPurchaseToken} → new token ${purchaseToken}`);
436
- }
437
- else {
438
- fresh.userId = purchaseToken;
439
- }
440
- }
441
- else {
442
- // userId is unknown at webhook time — use purchaseToken as placeholder
443
- fresh.userId = purchaseToken;
444
- }
445
- await store.save(fresh);
446
- }
447
- }
448
- else {
449
- log.warn('[onesub/webhook/google] Unknown purchase token and no serviceAccountKey to re-fetch:', purchaseToken);
450
- }
451
- }
452
- res.status(200).json({ received: true });
453
- }
454
- catch (err) {
455
- log.error('[onesub/webhook/google] Error handling notification:', err);
456
- sendError(res, 500, ONESUB_ERROR_CODE.WEBHOOK_PROCESSING_FAILED, 'Failed to process notification');
457
- }
458
- });
15
+ router.post(ROUTES.WEBHOOK_APPLE, (req, res) => handleAppleWebhook(req, res, config, store, purchaseStore, webhookEventStore));
16
+ router.post(ROUTES.WEBHOOK_GOOGLE, (req, res) => handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore));
459
17
  return router;
460
18
  }
461
19
  //# sourceMappingURL=webhook.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhook.js","sourceRoot":"","sources":["../../src/routes/webhook.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"webhook.js","sourceRoot":"","sources":["../../src/routes/webhook.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAGjC,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAGxC,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAE1D;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CACjC,MAA0B,EAC1B,KAAwB,EACxB,aAA4B,EAC5B,iBAAqC;IAErC,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC;IAExB,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,aAAa,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE,CAChE,kBAAkB,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,iBAAiB,CAAC,CAC9E,CAAC;IAEF,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE,CACjE,mBAAmB,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,iBAAiB,CAAC,CAC/E,CAAC;IAEF,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -35,6 +35,9 @@
35
35
  const SUB_TX_PREFIX = 'onesub:sub:tx:';
36
36
  const SUB_USER_PREFIX = 'onesub:sub:user:';
37
37
  const SUB_ALL = 'onesub:sub:all';
38
+ // Global sorted set (score = save timestamp ms) — enables ordered listAll and
39
+ // O(log n + limit) fast-path pagination when no secondary filters are applied.
40
+ const SUB_ALL_SORTED = 'onesub:sub:all:sorted';
38
41
  const PUR_TX_PREFIX = 'onesub:purchase:tx:';
39
42
  const PUR_USER_PREFIX = 'onesub:purchase:user:';
40
43
  const PUR_USER_PRODUCT_PREFIX = 'onesub:purchase:user_product:';
@@ -48,14 +51,11 @@ export class RedisSubscriptionStore {
48
51
  const score = Date.now();
49
52
  const txKey = SUB_TX_PREFIX + sub.originalTransactionId;
50
53
  const userKey = SUB_USER_PREFIX + sub.userId;
51
- // Multi-key write — use a pipeline so the three commands are at least
52
- // sent in one round-trip. Atomicity is best-effort; the only consistency
53
- // hazard is a crash between commands, which leaves the user index without
54
- // the new tx (recoverable: subsequent writes for the same tx fix it).
55
54
  const pipeline = this.redis.multi();
56
55
  pipeline.set(txKey, JSON.stringify(sub));
57
56
  pipeline.zadd(userKey, score, sub.originalTransactionId);
58
57
  pipeline.sadd(SUB_ALL, sub.originalTransactionId);
58
+ pipeline.zadd(SUB_ALL_SORTED, score, sub.originalTransactionId);
59
59
  await pipeline.exec();
60
60
  }
61
61
  async getByUserId(userId) {
@@ -80,30 +80,51 @@ export class RedisSubscriptionStore {
80
80
  return raw ? JSON.parse(raw) : null;
81
81
  }
82
82
  async listAll() {
83
- const ids = await this.redis.smembers(SUB_ALL);
83
+ // ZREVRANGE on the sorted set gives newest-first order (vs random SMEMBERS).
84
+ const ids = await this.redis.zrevrange(SUB_ALL_SORTED, 0, -1);
84
85
  if (ids.length === 0)
85
86
  return [];
86
87
  const raws = await this.redis.mget(...ids.map((id) => SUB_TX_PREFIX + id));
87
88
  return raws.filter((r) => r != null).map((r) => JSON.parse(r));
88
89
  }
89
90
  async listFiltered(opts) {
90
- // Redis has no secondary indexes — for listFiltered we materialise the
91
- // candidate set then apply filters in-process. Fine for small/medium data;
92
- // for >100k rows pair this with Postgres or use Redis Stack with RediSearch.
93
91
  const limit = opts.limit ?? 50;
94
92
  const offset = opts.offset ?? 0;
95
- let candidates;
93
+ const hasSecondaryFilters = !!(opts.status || opts.productId || opts.platform);
96
94
  if (opts.userId) {
97
- candidates = await this.getAllByUserId(opts.userId);
95
+ // Per-user path — already O(log n) via per-user sorted set.
96
+ const candidates = await this.getAllByUserId(opts.userId);
97
+ const filtered = candidates.filter((s) => {
98
+ if (opts.status && s.status !== opts.status)
99
+ return false;
100
+ if (opts.productId && s.productId !== opts.productId)
101
+ return false;
102
+ if (opts.platform && s.platform !== opts.platform)
103
+ return false;
104
+ return true;
105
+ });
106
+ return { items: filtered.slice(offset, offset + limit), total: filtered.length, limit, offset };
98
107
  }
99
- else {
100
- candidates = await this.listAll();
101
- // Sort newest-first by purchasedAt as a stable proxy when no per-user
102
- // sorted set is available. SubscriptionInfo doesn't carry updatedAt, so
103
- // we fall back to expiresAt for a "freshest first" ordering.
104
- candidates.sort((a, b) => Date.parse(b.expiresAt) - Date.parse(a.expiresAt));
108
+ if (!hasSecondaryFilters) {
109
+ // Fast path: pure pagination with no filter — O(log n + limit) via sorted set.
110
+ const [ids, total] = await Promise.all([
111
+ this.redis.zrevrange(SUB_ALL_SORTED, offset, offset + limit - 1),
112
+ this.redis.zcard(SUB_ALL_SORTED),
113
+ ]);
114
+ if (ids.length === 0)
115
+ return { items: [], total, limit, offset };
116
+ const raws = await this.redis.mget(...ids.map((id) => SUB_TX_PREFIX + id));
117
+ const items = raws.filter((r) => r != null).map((r) => JSON.parse(r));
118
+ return { items, total, limit, offset };
105
119
  }
106
- const filtered = candidates.filter((s) => {
120
+ // Slow path: secondary filters require full scan. Results come in
121
+ // newest-first order. For >100k rows pair with Postgres or Redis Stack.
122
+ const allIds = await this.redis.zrevrange(SUB_ALL_SORTED, 0, -1);
123
+ if (allIds.length === 0)
124
+ return { items: [], total: 0, limit, offset };
125
+ const raws = await this.redis.mget(...allIds.map((id) => SUB_TX_PREFIX + id));
126
+ const all = raws.filter((r) => r != null).map((r) => JSON.parse(r));
127
+ const filtered = all.filter((s) => {
107
128
  if (opts.status && s.status !== opts.status)
108
129
  return false;
109
130
  if (opts.productId && s.productId !== opts.productId)
@@ -112,12 +133,7 @@ export class RedisSubscriptionStore {
112
133
  return false;
113
134
  return true;
114
135
  });
115
- return {
116
- items: filtered.slice(offset, offset + limit),
117
- total: filtered.length,
118
- limit,
119
- offset,
120
- };
136
+ return { items: filtered.slice(offset, offset + limit), total: filtered.length, limit, offset };
121
137
  }
122
138
  }
123
139
  export class RedisPurchaseStore {
@@ -252,4 +268,30 @@ export class RedisCacheAdapter {
252
268
  await this.redis.del(this.prefix + key);
253
269
  }
254
270
  }
271
+ const WEBHOOK_EVENT_PREFIX = 'onesub:webhook:event:';
272
+ const DEFAULT_WEBHOOK_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days — covers Apple's 3-day retry window
273
+ /**
274
+ * Redis-backed webhook idempotency store.
275
+ *
276
+ * Uses `SET key '1' EX ttl NX` — a single atomic command that sets the key
277
+ * only if it does not already exist. Returns 'OK' (new event) or null
278
+ * (already seen), with no race between a GET and a subsequent SET.
279
+ *
280
+ * Prefer this over `CacheWebhookEventStore(new RedisCacheAdapter(...))` for
281
+ * production deployments; the cache-based variant is non-atomic under
282
+ * concurrent retries.
283
+ */
284
+ export class RedisWebhookEventStore {
285
+ redis;
286
+ ttlSeconds;
287
+ constructor(redis, ttlSeconds = DEFAULT_WEBHOOK_TTL_SECONDS) {
288
+ this.redis = redis;
289
+ this.ttlSeconds = ttlSeconds;
290
+ }
291
+ async markIfNew(provider, eventId) {
292
+ const key = WEBHOOK_EVENT_PREFIX + provider + ':' + eventId;
293
+ const result = await this.redis.set(key, '1', 'EX', this.ttlSeconds, 'NX');
294
+ return result === 'OK';
295
+ }
296
+ }
255
297
  //# sourceMappingURL=redis.js.map