@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 +3 -0
- package/dist/__tests__/test-overrides.test.d.ts +15 -0
- package/dist/__tests__/test-overrides.test.d.ts.map +1 -0
- package/dist/index.cjs +136 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +136 -6
- package/dist/index.js.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/providers/apple.d.ts.map +1 -1
- package/dist/routes/admin.d.ts.map +1 -1
- package/dist/routes/validate.d.ts.map +1 -1
- package/dist/test-overrides.d.ts +36 -0
- package/dist/test-overrides.d.ts.map +1 -0
- package/package.json +2 -2
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)
|
|
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
|
-
...
|
|
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)
|
|
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
|
|
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).",
|