@onesub/server 0.18.3 → 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
@@ -458,7 +458,10 @@ async function validateAppleReceipt(receipt, config) {
458
458
  purchasedAt: new Date(purchasedAt).toISOString(),
459
459
  willRenew: status === shared.SUBSCRIPTION_STATUS.ACTIVE,
460
460
  // refined by renewal info in webhook
461
- ...appAccountToken ? { boundAccountId: 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 } : {}
462
465
  };
463
466
  }
464
467
  async function validateAppleConsumableReceipt(signedTransaction, config, expectedProductId) {
@@ -1262,6 +1265,21 @@ function peekAppleBundleId(jws) {
1262
1265
  }
1263
1266
  }
1264
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
+
1265
1283
  // src/routes/validate.ts
1266
1284
  var NO_SUB = { valid: false, subscription: null };
1267
1285
  var validateSchema = zod.z.object({
@@ -1329,6 +1347,13 @@ function createValidateRouter(config, store) {
1329
1347
  );
1330
1348
  return;
1331
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
+ }
1332
1357
  sub.userId = userId;
1333
1358
  await store.save(sub);
1334
1359
  if (platform === "google" && appConfig.google) {
@@ -2358,6 +2383,42 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
2358
2383
  sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "sync error");
2359
2384
  }
2360
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
+ });
2361
2422
  if (webhookQueue?.listDeadLetters) {
2362
2423
  router.get("/onesub/admin/webhook-deadletters", async (_req, res) => {
2363
2424
  try {
@@ -3521,6 +3582,54 @@ var ONESUB_OPENAPI = {
3521
3582
  }
3522
3583
  },
3523
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
+ },
3524
3633
  [shared.ROUTES.ADMIN_SUBSCRIPTIONS]: {
3525
3634
  get: {
3526
3635
  summary: "Filtered, paginated subscription list (admin).",