@onesub/server 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,6 +96,9 @@ explicit app IDs fail closed and are never validated with another app's credenti
96
96
  | `GET /onesub/admin/subscriptions/:transactionId` | Get subscription detail (requires `adminSecret`) |
97
97
  | `GET /onesub/admin/customers/:userId` | Get a customer profile (requires `adminSecret`) |
98
98
  | `POST /onesub/admin/sync-apple/:originalTransactionId` | Refresh a subscription from Apple (requires `adminSecret`) |
99
+ | `GET /onesub/admin/test-overrides` | List sandbox-only entitlement overrides (requires `adminSecret`) |
100
+ | `PUT /onesub/admin/test-overrides/:userId` | Force an entitlement verdict for one user — body `{ "entitled": false }`. Honoured **only for Sandbox receipts**, so production customers are unaffected. Exists because Apple cannot cancel a sandbox subscription bought with a real Apple Account, which otherwise locks a tester out of the purchase flow. Process-local and non-persistent |
101
+ | `DELETE /onesub/admin/test-overrides/:userId` | Clear that user's override (requires `adminSecret`) |
99
102
  | `GET /onesub/admin/webhook-deadletters` | List failed webhook jobs in DLQ (requires `adminSecret` + BullMQ) |
100
103
  | `POST /onesub/admin/webhook-replay/:id` | Replay a dead-letter job (requires `adminSecret` + BullMQ) |
101
104
  | `GET /onesub/entitlement?userId=&id=` | Evaluate one configured entitlement |
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Sandbox-only entitlement overrides.
3
+ *
4
+ * Apple cannot cancel a sandbox subscription bought with a real Apple Account
5
+ * through TestFlight, so a tester who subscribes once stays entitled and can
6
+ * never re-run the purchase flow. That is not a hypothetical: it hid a paywall
7
+ * entry point from App Review (PenguinRun 2.0.6, Guideline 2.1(b)) because the
8
+ * reviewer had subscribed in an earlier round.
9
+ *
10
+ * The guarantee under test is the safety one — an override must be incapable of
11
+ * touching a paying customer. It is honoured only when the receipt just
12
+ * validated came from Sandbox.
13
+ */
14
+ export {};
15
+ //# sourceMappingURL=test-overrides.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-overrides.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/test-overrides.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"}
package/dist/index.cjs CHANGED
@@ -332,6 +332,10 @@ function mockValidateGoogleProduct(receipt, productId) {
332
332
  }
333
333
 
334
334
  // src/providers/apple.ts
335
+ var EMPTY_APP_ACCOUNT_TOKEN = "00000000-0000-0000-0000-000000000000";
336
+ function normalizeAppleAppAccountToken(token) {
337
+ return token === EMPTY_APP_ACCOUNT_TOKEN ? void 0 : token;
338
+ }
335
339
  function derBase64ToPem(der) {
336
340
  return "-----BEGIN CERTIFICATE-----\n" + (der.match(/.{1,64}/g) ?? []).join("\n") + "\n-----END CERTIFICATE-----";
337
341
  }
@@ -407,7 +411,15 @@ function deriveStatus(tx, renewal) {
407
411
  return shared.SUBSCRIPTION_STATUS.EXPIRED;
408
412
  }
409
413
  async function validateAppleReceipt(receipt, config) {
410
- if (config.mockMode) return mockValidateAppleSubscription(receipt);
414
+ if (config.mockMode) {
415
+ const mock = mockValidateAppleSubscription(receipt);
416
+ if (mock) {
417
+ const boundAccountId = normalizeAppleAppAccountToken(mock.boundAccountId);
418
+ if (boundAccountId) mock.boundAccountId = boundAccountId;
419
+ else delete mock.boundAccountId;
420
+ }
421
+ return mock;
422
+ }
411
423
  let tx;
412
424
  try {
413
425
  tx = await decodeJws(receipt, config.skipJwsVerification);
@@ -434,6 +446,7 @@ async function validateAppleReceipt(receipt, config) {
434
446
  }
435
447
  const status = deriveStatus(tx, null);
436
448
  const purchasedAt = tx.originalPurchaseDate ?? tx.purchaseDate ?? Date.now();
449
+ const appAccountToken = normalizeAppleAppAccountToken(tx.appAccountToken);
437
450
  return {
438
451
  userId: "",
439
452
  // caller fills this in from the request body
@@ -445,11 +458,22 @@ async function validateAppleReceipt(receipt, config) {
445
458
  purchasedAt: new Date(purchasedAt).toISOString(),
446
459
  willRenew: status === shared.SUBSCRIPTION_STATUS.ACTIVE,
447
460
  // refined by renewal info in webhook
448
- ...tx.appAccountToken ? { boundAccountId: tx.appAccountToken } : {}
461
+ ...appAccountToken ? { boundAccountId: appAccountToken } : {},
462
+ // Transient, like boundAccountId: the validate route uses it to decide
463
+ // whether a sandbox-only test override may apply, then strips it.
464
+ ...tx.environment === "Sandbox" ? { sandbox: true } : {}
449
465
  };
450
466
  }
451
467
  async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
452
- if (config.mockMode) return mockValidateAppleProduct(signedTransaction, expectedProductId);
468
+ if (config.mockMode) {
469
+ const mock = mockValidateAppleProduct(signedTransaction, expectedProductId);
470
+ if (mock) {
471
+ const appAccountToken = normalizeAppleAppAccountToken(mock.appAccountToken);
472
+ if (appAccountToken) mock.appAccountToken = appAccountToken;
473
+ else delete mock.appAccountToken;
474
+ }
475
+ return mock;
476
+ }
453
477
  let tx;
454
478
  try {
455
479
  tx = await decodeJws(signedTransaction, config.skipJwsVerification);
@@ -502,7 +526,7 @@ async function validateAppleConsumableReceipt(signedTransaction, config, expecte
502
526
  transactionId,
503
527
  productId: tx.productId,
504
528
  purchasedAt: tx.purchaseDate ? new Date(tx.purchaseDate).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
505
- appAccountToken: tx.appAccountToken ?? void 0
529
+ appAccountToken: normalizeAppleAppAccountToken(tx.appAccountToken)
506
530
  };
507
531
  }
508
532
  async function decodeAppleNotification(payload, skipJwsVerification = false) {
@@ -534,7 +558,7 @@ async function decodeAppleNotification(payload, skipJwsVerification = false) {
534
558
  status,
535
559
  willRenew,
536
560
  expiresAt: tx.expiresDate ? new Date(tx.expiresDate).toISOString() : null,
537
- appAccountToken: tx.appAccountToken ?? null,
561
+ appAccountToken: normalizeAppleAppAccountToken(tx.appAccountToken) ?? null,
538
562
  inAppOwnershipType: tx.inAppOwnershipType ?? null
539
563
  };
540
564
  }
@@ -724,7 +748,7 @@ async function fetchAppleTransactionHistory(originalTransactionId, config, optio
724
748
  type: tx.type ?? "Unknown",
725
749
  purchasedAt: tx.purchaseDate ? new Date(tx.purchaseDate).toISOString() : (/* @__PURE__ */ new Date()).toISOString(),
726
750
  expiresAt: tx.expiresDate ? new Date(tx.expiresDate).toISOString() : null,
727
- appAccountToken: tx.appAccountToken ?? null,
751
+ appAccountToken: normalizeAppleAppAccountToken(tx.appAccountToken) ?? null,
728
752
  inAppOwnershipType: tx.inAppOwnershipType ?? null,
729
753
  revocationDate: tx.revocationDate ? new Date(tx.revocationDate).toISOString() : null
730
754
  });
@@ -1241,6 +1265,21 @@ function peekAppleBundleId(jws) {
1241
1265
  }
1242
1266
  }
1243
1267
 
1268
+ // src/test-overrides.ts
1269
+ var overrides = /* @__PURE__ */ new Map();
1270
+ function setTestOverride(userId, entitled) {
1271
+ overrides.set(userId, entitled);
1272
+ }
1273
+ function clearTestOverride(userId) {
1274
+ return overrides.delete(userId);
1275
+ }
1276
+ function getTestOverride(userId) {
1277
+ return overrides.get(userId);
1278
+ }
1279
+ function listTestOverrides() {
1280
+ return [...overrides].map(([userId, entitled]) => ({ userId, entitled }));
1281
+ }
1282
+
1244
1283
  // src/routes/validate.ts
1245
1284
  var NO_SUB = { valid: false, subscription: null };
1246
1285
  var validateSchema = zod.z.object({
@@ -1308,6 +1347,13 @@ function createValidateRouter(config, store) {
1308
1347
  );
1309
1348
  return;
1310
1349
  }
1350
+ const isSandbox = sub.sandbox === true;
1351
+ delete sub.sandbox;
1352
+ if (isSandbox && getTestOverride(userId) === false) {
1353
+ log.warn(`[onesub/validate] sandbox test override active for ${userId}: forcing not-entitled`);
1354
+ sub.status = shared.SUBSCRIPTION_STATUS.EXPIRED;
1355
+ sub.willRenew = false;
1356
+ }
1311
1357
  sub.userId = userId;
1312
1358
  await store.save(sub);
1313
1359
  if (platform === "google" && appConfig.google) {
@@ -2337,6 +2383,42 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2337
2383
  sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "sync error");
2338
2384
  }
2339
2385
  });
2386
+ const overrideParamsSchema = zod.z.object({ userId: zod.z.string().min(1).max(256) });
2387
+ const overrideBodySchema = zod.z.object({ entitled: zod.z.boolean() });
2388
+ router.get(shared.ROUTES.ADMIN_TEST_OVERRIDES, (_req, res) => {
2389
+ res.json({ overrides: listTestOverrides() });
2390
+ });
2391
+ router.put(shared.ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
2392
+ let params;
2393
+ let body;
2394
+ try {
2395
+ params = overrideParamsSchema.parse(req.params);
2396
+ body = overrideBodySchema.parse(req.body);
2397
+ } catch (err2) {
2398
+ if (err2 instanceof zod.z.ZodError) {
2399
+ sendZodError(res, err2);
2400
+ } else {
2401
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId and { entitled: boolean } required");
2402
+ }
2403
+ return;
2404
+ }
2405
+ setTestOverride(params.userId, body.entitled);
2406
+ log.warn(
2407
+ `[onesub/admin] sandbox test override set for ${params.userId}: entitled=${body.entitled}`
2408
+ );
2409
+ res.json({ ok: true, userId: params.userId, entitled: body.entitled, sandboxOnly: true });
2410
+ });
2411
+ router.delete(shared.ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
2412
+ let params;
2413
+ try {
2414
+ params = overrideParamsSchema.parse(req.params);
2415
+ } catch {
2416
+ sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId required");
2417
+ return;
2418
+ }
2419
+ const cleared = clearTestOverride(params.userId);
2420
+ res.json({ ok: true, userId: params.userId, cleared });
2421
+ });
2340
2422
  if (webhookQueue?.listDeadLetters) {
2341
2423
  router.get("/onesub/admin/webhook-deadletters", async (_req, res) => {
2342
2424
  try {
@@ -3500,6 +3582,54 @@ var ONESUB_OPENAPI = {
3500
3582
  }
3501
3583
  },
3502
3584
  // ── admin (mounted when config.adminSecret is set) ───────────────────────
3585
+ [shared.ROUTES.ADMIN_TEST_OVERRIDES]: {
3586
+ get: {
3587
+ summary: "List sandbox-only entitlement overrides (admin).",
3588
+ parameters: [ADMIN_SECRET_PARAM],
3589
+ responses: {
3590
+ 200: { description: "OK" },
3591
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3592
+ }
3593
+ }
3594
+ },
3595
+ "/onesub/admin/test-overrides/{userId}": {
3596
+ put: {
3597
+ summary: "Force an entitlement verdict for one userId. Honoured ONLY for Sandbox receipts \u2014 a Production receipt ignores it. Exists because Apple cannot cancel a sandbox subscription bought with a real Apple Account, which otherwise locks a tester in.",
3598
+ parameters: [
3599
+ ADMIN_SECRET_PARAM,
3600
+ { in: "path", name: "userId", required: true, schema: { type: "string" } }
3601
+ ],
3602
+ requestBody: {
3603
+ required: true,
3604
+ content: {
3605
+ "application/json": {
3606
+ schema: {
3607
+ type: "object",
3608
+ required: ["entitled"],
3609
+ properties: { entitled: { type: "boolean" } }
3610
+ }
3611
+ }
3612
+ }
3613
+ },
3614
+ responses: {
3615
+ 200: { description: "OK" },
3616
+ 400: err("Invalid input (INVALID_INPUT)."),
3617
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3618
+ }
3619
+ },
3620
+ delete: {
3621
+ summary: "Clear the sandbox entitlement override for one userId (admin).",
3622
+ parameters: [
3623
+ ADMIN_SECRET_PARAM,
3624
+ { in: "path", name: "userId", required: true, schema: { type: "string" } }
3625
+ ],
3626
+ responses: {
3627
+ 200: { description: "OK" },
3628
+ 400: err("Invalid input (INVALID_INPUT)."),
3629
+ 401: err("Invalid admin secret (INVALID_ADMIN_SECRET).")
3630
+ }
3631
+ }
3632
+ },
3503
3633
  [shared.ROUTES.ADMIN_SUBSCRIPTIONS]: {
3504
3634
  get: {
3505
3635
  summary: "Filtered, paginated subscription list (admin).",