@onesub/server 0.20.0 → 0.21.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.
- package/dist/__tests__/errors.test.d.ts +12 -0
- package/dist/__tests__/errors.test.d.ts.map +1 -0
- package/dist/__tests__/purchase-product-lookup.test.d.ts +17 -0
- package/dist/__tests__/purchase-product-lookup.test.d.ts.map +1 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.cjs +133 -161
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +133 -161
- package/dist/index.js.map +1 -1
- package/dist/routes/admin.d.ts.map +1 -1
- package/dist/routes/apple-offer.d.ts.map +1 -1
- package/dist/routes/entitlements.d.ts.map +1 -1
- package/dist/routes/purchase.d.ts.map +1 -1
- package/dist/routes/validate.d.ts.map +1 -1
- package/dist/store.d.ts +22 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/postgres.d.ts +10 -0
- package/dist/stores/postgres.d.ts.map +1 -1
- package/dist/stores/redis.d.ts +12 -0
- package/dist/stores/redis.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error-response helpers, and the guarantee that a 500 does not describe the
|
|
3
|
+
* server's internals to the caller.
|
|
4
|
+
*
|
|
5
|
+
* `parseOrSend` replaced three hand-rolled input-validation shapes across the
|
|
6
|
+
* routes. The parts worth pinning down are the ones a caller can observe: the
|
|
7
|
+
* status, the `errorCode`, whether the route's response shape survives on the
|
|
8
|
+
* error path, and — for the admin routes, which used to forward
|
|
9
|
+
* `(err as Error).message` verbatim — that internal failure text stays server-side.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=errors.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/errors.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product-scoped purchase lookup — `PurchaseStore.getPurchasesForProduct`.
|
|
3
|
+
*
|
|
4
|
+
* Non-consumable validation used to read a user's entire purchase history and
|
|
5
|
+
* filter it in process, so an account with a long consumable history paid for
|
|
6
|
+
* every one of those rows on each lifetime-product purchase. The optional
|
|
7
|
+
* store method pushes the filter into an index instead.
|
|
8
|
+
*
|
|
9
|
+
* Covered here:
|
|
10
|
+
* - the store contract, including the most-recent-first ordering all three
|
|
11
|
+
* built-in stores must agree on
|
|
12
|
+
* - that the route actually uses the index-backed method when a store has one
|
|
13
|
+
* (responses are identical either way, so only a call count shows it)
|
|
14
|
+
* - that a store without the method still works, since it is optional
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=purchase-product-lookup.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"purchase-product-lookup.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/purchase-product-lookup.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
|
package/dist/errors.d.ts
CHANGED
|
@@ -14,4 +14,38 @@ export declare function sendError(res: Response, status: number, code: OneSubErr
|
|
|
14
14
|
* are joined for the human-readable `error` field.
|
|
15
15
|
*/
|
|
16
16
|
export declare function sendZodError(res: Response, err: z.ZodError, extra?: Record<string, unknown>): void;
|
|
17
|
+
export interface ParseOrSendOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Route-specific fields to merge into the error body, so a 400 keeps the
|
|
20
|
+
* shape the route's success response has (`subscription: null`,
|
|
21
|
+
* `purchases: []`, and so on).
|
|
22
|
+
*/
|
|
23
|
+
extra?: Record<string, unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* Send this one message instead of the per-issue zod detail. Use it where the
|
|
26
|
+
* route deliberately does not echo the input shape back — URL params, and the
|
|
27
|
+
* entitlement routes. Omit it to get the field-level detail.
|
|
28
|
+
*/
|
|
29
|
+
message?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse `input` with `schema`, or send a 400 and return `undefined`.
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* const body = parseOrSend(res, validateSchema, req.body, { extra: NO_SUB });
|
|
36
|
+
* if (!body) return;
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* Every route was hand-rolling this, and in three different shapes: a
|
|
40
|
+
* `try/catch` that re-threw non-zod errors, a `try/catch {}` that swallowed
|
|
41
|
+
* everything into one generic message, and `safeParse`. The first is correct but
|
|
42
|
+
* five lines per call site; the second silently turns a genuine bug inside the
|
|
43
|
+
* schema — a bad refinement, a throwing transform — into "400 bad input", which
|
|
44
|
+
* is the wrong status and hides the cause.
|
|
45
|
+
*
|
|
46
|
+
* This keeps the correct behaviour and makes it the short one: zod failures
|
|
47
|
+
* become a 400, and anything else propagates to the route's own error handling
|
|
48
|
+
* rather than being reported as the caller's fault.
|
|
49
|
+
*/
|
|
50
|
+
export declare function parseOrSend<S extends z.ZodType>(res: Response, schema: S, input: unknown, opts?: ParseOrSendOptions): z.output<S> | undefined;
|
|
17
51
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAGtD;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,QAAQ,EACb,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,eAAe,EACrB,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,IAAI,CAEN;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,QAAQ,EACb,GAAG,EAAE,CAAC,CAAC,QAAQ,EACf,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,IAAI,CAQN"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAGtD;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,GAAG,EAAE,QAAQ,EACb,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,eAAe,EACrB,KAAK,EAAE,MAAM,EACb,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,IAAI,CAEN;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,QAAQ,EACb,GAAG,EAAE,CAAC,CAAC,QAAQ,EACf,KAAK,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAClC,IAAI,CAQN;AAED,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAC7C,GAAG,EAAE,QAAQ,EACb,MAAM,EAAE,CAAC,EACT,KAAK,EAAE,OAAO,EACd,IAAI,GAAE,kBAAuB,GAC5B,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,SAAS,CAUzB"}
|
package/dist/index.cjs
CHANGED
|
@@ -91,11 +91,17 @@ var InMemoryPurchaseStore = class {
|
|
|
91
91
|
}
|
|
92
92
|
this.byTransactionId.set(purchase.transactionId, purchase);
|
|
93
93
|
const list = this.byUserId.get(purchase.userId) ?? [];
|
|
94
|
-
|
|
94
|
+
const at = Date.parse(purchase.purchasedAt);
|
|
95
|
+
const idx = list.findIndex((p) => Date.parse(p.purchasedAt) < at);
|
|
96
|
+
if (idx === -1) list.push(purchase);
|
|
97
|
+
else list.splice(idx, 0, purchase);
|
|
95
98
|
this.byUserId.set(purchase.userId, list);
|
|
96
99
|
}
|
|
97
100
|
async getPurchasesByUserId(userId) {
|
|
98
|
-
return this.byUserId.get(userId) ?? [];
|
|
101
|
+
return [...this.byUserId.get(userId) ?? []];
|
|
102
|
+
}
|
|
103
|
+
async getPurchasesForProduct(userId, productId) {
|
|
104
|
+
return (this.byUserId.get(userId) ?? []).filter((p) => p.productId === productId);
|
|
99
105
|
}
|
|
100
106
|
async getPurchaseByTransactionId(txId) {
|
|
101
107
|
return this.byTransactionId.get(txId) ?? null;
|
|
@@ -1272,6 +1278,16 @@ function sendZodError(res, err2, extra = {}) {
|
|
|
1272
1278
|
extra
|
|
1273
1279
|
);
|
|
1274
1280
|
}
|
|
1281
|
+
function parseOrSend(res, schema, input, opts = {}) {
|
|
1282
|
+
const result = schema.safeParse(input);
|
|
1283
|
+
if (result.success) return result.data;
|
|
1284
|
+
if (opts.message !== void 0) {
|
|
1285
|
+
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, opts.message, opts.extra ?? {});
|
|
1286
|
+
} else {
|
|
1287
|
+
sendZodError(res, result.error, opts.extra ?? {});
|
|
1288
|
+
}
|
|
1289
|
+
return void 0;
|
|
1290
|
+
}
|
|
1275
1291
|
|
|
1276
1292
|
// src/apps.ts
|
|
1277
1293
|
function appName(app) {
|
|
@@ -1370,20 +1386,9 @@ function createValidateRouter(config, store) {
|
|
|
1370
1386
|
const router = express.Router();
|
|
1371
1387
|
const registry = getAppRegistry(config);
|
|
1372
1388
|
router.post(shared.ROUTES.VALIDATE, async (req, res) => {
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
let productId;
|
|
1377
|
-
let appId;
|
|
1378
|
-
try {
|
|
1379
|
-
({ platform, receipt, userId, productId, appId } = validateSchema.parse(req.body));
|
|
1380
|
-
} catch (err2) {
|
|
1381
|
-
if (err2 instanceof zod.z.ZodError) {
|
|
1382
|
-
sendZodError(res, err2, NO_SUB);
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
throw err2;
|
|
1386
|
-
}
|
|
1389
|
+
const body = parseOrSend(res, validateSchema, req.body, { extra: NO_SUB });
|
|
1390
|
+
if (!body) return;
|
|
1391
|
+
const { platform, receipt, userId, productId, appId } = body;
|
|
1387
1392
|
try {
|
|
1388
1393
|
let sub = null;
|
|
1389
1394
|
const appConfig = registry.configFor({
|
|
@@ -1981,20 +1986,19 @@ var purchaseStatusQuerySchema = zod.z.object({
|
|
|
1981
1986
|
userId: zod.z.string().min(1).max(256),
|
|
1982
1987
|
productId: zod.z.string().min(1).max(256).optional()
|
|
1983
1988
|
});
|
|
1989
|
+
async function purchasesForProduct(purchaseStore, userId, productId) {
|
|
1990
|
+
if (purchaseStore.getPurchasesForProduct) {
|
|
1991
|
+
return purchaseStore.getPurchasesForProduct(userId, productId);
|
|
1992
|
+
}
|
|
1993
|
+
const all = await purchaseStore.getPurchasesByUserId(userId);
|
|
1994
|
+
return all.filter((p) => p.productId === productId);
|
|
1995
|
+
}
|
|
1984
1996
|
function createPurchaseRouter(config, purchaseStore) {
|
|
1985
1997
|
const router = express.Router();
|
|
1986
1998
|
const registry = getAppRegistry(config);
|
|
1987
1999
|
router.post(shared.ROUTES.VALIDATE_PURCHASE, async (req, res) => {
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
body = validatePurchaseSchema.parse(req.body);
|
|
1991
|
-
} catch (err2) {
|
|
1992
|
-
if (err2 instanceof zod.z.ZodError) {
|
|
1993
|
-
sendZodError(res, err2, NO_PURCHASE);
|
|
1994
|
-
return;
|
|
1995
|
-
}
|
|
1996
|
-
throw err2;
|
|
1997
|
-
}
|
|
2000
|
+
const body = parseOrSend(res, validatePurchaseSchema, req.body, { extra: NO_PURCHASE });
|
|
2001
|
+
if (!body) return;
|
|
1998
2002
|
const { platform, receipt, userId, productId, type, appId } = body;
|
|
1999
2003
|
const appConfig = registry.configFor({
|
|
2000
2004
|
appId,
|
|
@@ -2002,9 +2006,7 @@ function createPurchaseRouter(config, purchaseStore) {
|
|
|
2002
2006
|
});
|
|
2003
2007
|
try {
|
|
2004
2008
|
if (type === shared.PURCHASE_TYPE.NON_CONSUMABLE) {
|
|
2005
|
-
const owned = (await purchaseStore
|
|
2006
|
-
(p) => p.productId === productId
|
|
2007
|
-
);
|
|
2009
|
+
const owned = (await purchasesForProduct(purchaseStore, userId, productId))[0];
|
|
2008
2010
|
if (owned) {
|
|
2009
2011
|
const response2 = {
|
|
2010
2012
|
valid: true,
|
|
@@ -2122,22 +2124,13 @@ function createPurchaseRouter(config, purchaseStore) {
|
|
|
2122
2124
|
}
|
|
2123
2125
|
});
|
|
2124
2126
|
router.get(shared.ROUTES.PURCHASE_STATUS, async (req, res) => {
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
if (err2 instanceof zod.z.ZodError) {
|
|
2130
|
-
sendZodError(res, err2, { purchases: [] });
|
|
2131
|
-
return;
|
|
2132
|
-
}
|
|
2133
|
-
throw err2;
|
|
2134
|
-
}
|
|
2127
|
+
const query = parseOrSend(res, purchaseStatusQuerySchema, req.query, {
|
|
2128
|
+
extra: { purchases: [] }
|
|
2129
|
+
});
|
|
2130
|
+
if (!query) return;
|
|
2135
2131
|
const { userId, productId } = query;
|
|
2136
2132
|
try {
|
|
2137
|
-
|
|
2138
|
-
if (productId !== void 0) {
|
|
2139
|
-
purchases = purchases.filter((p) => p.productId === productId);
|
|
2140
|
-
}
|
|
2133
|
+
const purchases = productId !== void 0 ? await purchasesForProduct(purchaseStore, userId, productId) : await purchaseStore.getPurchasesByUserId(userId);
|
|
2141
2134
|
const response = { purchases };
|
|
2142
2135
|
res.status(200).json(response);
|
|
2143
2136
|
} catch (err2) {
|
|
@@ -2182,6 +2175,8 @@ async function evaluateEntitlement(userId, entitlement, store, purchaseStore) {
|
|
|
2182
2175
|
}
|
|
2183
2176
|
var userIdSchema = zod.z.string().min(1).max(256);
|
|
2184
2177
|
var entitlementIdSchema = zod.z.string().min(1).max(128);
|
|
2178
|
+
var entitlementQuerySchema = zod.z.object({ userId: userIdSchema, id: entitlementIdSchema });
|
|
2179
|
+
var entitlementsQuerySchema = zod.z.object({ userId: userIdSchema });
|
|
2185
2180
|
function createEntitlementRouter(config, store, purchaseStore) {
|
|
2186
2181
|
if (!config.entitlements || Object.keys(config.entitlements).length === 0) {
|
|
2187
2182
|
return null;
|
|
@@ -2189,15 +2184,11 @@ function createEntitlementRouter(config, store, purchaseStore) {
|
|
|
2189
2184
|
const entitlements = config.entitlements;
|
|
2190
2185
|
const router = express.Router();
|
|
2191
2186
|
router.get(shared.ROUTES.ENTITLEMENT, async (req, res) => {
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
} catch {
|
|
2198
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId and id are required");
|
|
2199
|
-
return;
|
|
2200
|
-
}
|
|
2187
|
+
const query = parseOrSend(res, entitlementQuerySchema, req.query, {
|
|
2188
|
+
message: "userId and id are required"
|
|
2189
|
+
});
|
|
2190
|
+
if (!query) return;
|
|
2191
|
+
const { userId, id } = query;
|
|
2201
2192
|
const entitlement = entitlements[id];
|
|
2202
2193
|
if (!entitlement) {
|
|
2203
2194
|
sendError(res, 404, shared.ONESUB_ERROR_CODE.ENTITLEMENT_NOT_FOUND, `Unknown entitlement: ${id}`);
|
|
@@ -2213,13 +2204,11 @@ function createEntitlementRouter(config, store, purchaseStore) {
|
|
|
2213
2204
|
}
|
|
2214
2205
|
});
|
|
2215
2206
|
router.get(shared.ROUTES.ENTITLEMENTS, async (req, res) => {
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
return;
|
|
2222
|
-
}
|
|
2207
|
+
const query = parseOrSend(res, entitlementsQuerySchema, req.query, {
|
|
2208
|
+
message: "userId is required"
|
|
2209
|
+
});
|
|
2210
|
+
if (!query) return;
|
|
2211
|
+
const { userId } = query;
|
|
2223
2212
|
try {
|
|
2224
2213
|
const [subs, purchases] = await Promise.all([
|
|
2225
2214
|
store.getAllByUserId(userId),
|
|
@@ -2268,13 +2257,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2268
2257
|
productId: zod.z.string().min(1).max(256)
|
|
2269
2258
|
});
|
|
2270
2259
|
router.delete("/onesub/purchase/admin/:userId/:productId", async (req, res) => {
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId and productId required");
|
|
2276
|
-
return;
|
|
2277
|
-
}
|
|
2260
|
+
const params = parseOrSend(res, resetParamsSchema, req.params, {
|
|
2261
|
+
message: "userId and productId required"
|
|
2262
|
+
});
|
|
2263
|
+
if (!params) return;
|
|
2278
2264
|
const deleted = await purchaseStore.deletePurchases(params.userId, params.productId);
|
|
2279
2265
|
res.json({ ok: true, deleted });
|
|
2280
2266
|
});
|
|
@@ -2283,16 +2269,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2283
2269
|
newUserId: zod.z.string().min(1).max(256)
|
|
2284
2270
|
});
|
|
2285
2271
|
router.post("/onesub/purchase/admin/transfer", async (req, res) => {
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
body = transferSchema.parse(req.body);
|
|
2289
|
-
} catch (err2) {
|
|
2290
|
-
if (err2 instanceof zod.z.ZodError) {
|
|
2291
|
-
sendZodError(res, err2);
|
|
2292
|
-
return;
|
|
2293
|
-
}
|
|
2294
|
-
throw err2;
|
|
2295
|
-
}
|
|
2272
|
+
const body = parseOrSend(res, transferSchema, req.body);
|
|
2273
|
+
if (!body) return;
|
|
2296
2274
|
const existing = await purchaseStore.getPurchaseByTransactionId(body.transactionId);
|
|
2297
2275
|
if (!existing) {
|
|
2298
2276
|
sendError(res, 404, shared.ONESUB_ERROR_CODE.TRANSACTION_NOT_FOUND, "TRANSACTION_NOT_FOUND");
|
|
@@ -2314,16 +2292,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2314
2292
|
transactionId: zod.z.string().min(1).max(256).optional()
|
|
2315
2293
|
});
|
|
2316
2294
|
router.post("/onesub/purchase/admin/grant", async (req, res) => {
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
body = grantSchema.parse(req.body);
|
|
2320
|
-
} catch (err2) {
|
|
2321
|
-
if (err2 instanceof zod.z.ZodError) {
|
|
2322
|
-
sendZodError(res, err2);
|
|
2323
|
-
return;
|
|
2324
|
-
}
|
|
2325
|
-
throw err2;
|
|
2326
|
-
}
|
|
2295
|
+
const body = parseOrSend(res, grantSchema, req.body);
|
|
2296
|
+
if (!body) return;
|
|
2327
2297
|
const transactionId = body.transactionId ?? `admin_grant_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
2328
2298
|
const purchase = {
|
|
2329
2299
|
transactionId,
|
|
@@ -2355,16 +2325,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2355
2325
|
offset: zod.z.coerce.number().int().nonnegative().optional()
|
|
2356
2326
|
});
|
|
2357
2327
|
router.get(shared.ROUTES.ADMIN_SUBSCRIPTIONS, async (req, res) => {
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
query = listQuerySchema.parse(req.query);
|
|
2361
|
-
} catch (err2) {
|
|
2362
|
-
if (err2 instanceof zod.z.ZodError) {
|
|
2363
|
-
sendZodError(res, err2);
|
|
2364
|
-
return;
|
|
2365
|
-
}
|
|
2366
|
-
throw err2;
|
|
2367
|
-
}
|
|
2328
|
+
const query = parseOrSend(res, listQuerySchema, req.query);
|
|
2329
|
+
if (!query) return;
|
|
2368
2330
|
try {
|
|
2369
2331
|
const result = await store.listFiltered(query);
|
|
2370
2332
|
const response = {
|
|
@@ -2375,20 +2337,18 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2375
2337
|
};
|
|
2376
2338
|
res.status(200).json(response);
|
|
2377
2339
|
} catch (err2) {
|
|
2378
|
-
|
|
2340
|
+
log.error("[onesub/admin/subscriptions] list error:", err2);
|
|
2341
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
|
|
2379
2342
|
}
|
|
2380
2343
|
});
|
|
2381
2344
|
const detailParamsSchema = zod.z.object({
|
|
2382
2345
|
transactionId: zod.z.string().min(1).max(256)
|
|
2383
2346
|
});
|
|
2384
2347
|
router.get("/onesub/admin/subscriptions/:transactionId", async (req, res) => {
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "transactionId required");
|
|
2390
|
-
return;
|
|
2391
|
-
}
|
|
2348
|
+
const params = parseOrSend(res, detailParamsSchema, req.params, {
|
|
2349
|
+
message: "transactionId required"
|
|
2350
|
+
});
|
|
2351
|
+
if (!params) return;
|
|
2392
2352
|
try {
|
|
2393
2353
|
const sub = await store.getByTransactionId(params.transactionId);
|
|
2394
2354
|
if (!sub) {
|
|
@@ -2397,20 +2357,18 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2397
2357
|
}
|
|
2398
2358
|
res.status(200).json(sub);
|
|
2399
2359
|
} catch (err2) {
|
|
2400
|
-
|
|
2360
|
+
log.error("[onesub/admin/subscriptions/:transactionId] detail error:", err2);
|
|
2361
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
|
|
2401
2362
|
}
|
|
2402
2363
|
});
|
|
2403
2364
|
const customerParamsSchema = zod.z.object({
|
|
2404
2365
|
userId: zod.z.string().min(1).max(256)
|
|
2405
2366
|
});
|
|
2406
2367
|
router.get("/onesub/admin/customers/:userId", async (req, res) => {
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId required");
|
|
2412
|
-
return;
|
|
2413
|
-
}
|
|
2368
|
+
const params = parseOrSend(res, customerParamsSchema, req.params, {
|
|
2369
|
+
message: "userId required"
|
|
2370
|
+
});
|
|
2371
|
+
if (!params) return;
|
|
2414
2372
|
try {
|
|
2415
2373
|
const [subscriptions, purchases] = await Promise.all([
|
|
2416
2374
|
store.getAllByUserId(params.userId),
|
|
@@ -2432,20 +2390,18 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2432
2390
|
};
|
|
2433
2391
|
res.status(200).json(response);
|
|
2434
2392
|
} catch (err2) {
|
|
2435
|
-
|
|
2393
|
+
log.error("[onesub/admin/customers/:userId] error:", err2);
|
|
2394
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
|
|
2436
2395
|
}
|
|
2437
2396
|
});
|
|
2438
2397
|
const syncAppleParamsSchema = zod.z.object({
|
|
2439
2398
|
originalTransactionId: zod.z.string().min(1).max(256)
|
|
2440
2399
|
});
|
|
2441
2400
|
router.post(shared.ROUTES.ADMIN_SYNC_APPLE, async (req, res) => {
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "originalTransactionId required");
|
|
2447
|
-
return;
|
|
2448
|
-
}
|
|
2401
|
+
const params = parseOrSend(res, syncAppleParamsSchema, req.params, {
|
|
2402
|
+
message: "originalTransactionId required"
|
|
2403
|
+
});
|
|
2404
|
+
if (!params) return;
|
|
2449
2405
|
if (!config.apple?.issuerId || !config.apple?.keyId || !config.apple?.privateKey) {
|
|
2450
2406
|
sendError(res, 400, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple API credentials not configured");
|
|
2451
2407
|
return;
|
|
@@ -2464,7 +2420,8 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2464
2420
|
await store.save(fresh);
|
|
2465
2421
|
res.status(200).json({ ok: true, subscription: fresh });
|
|
2466
2422
|
} catch (err2) {
|
|
2467
|
-
|
|
2423
|
+
log.error("[onesub/admin/sync-apple] error:", err2);
|
|
2424
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, "Internal server error");
|
|
2468
2425
|
}
|
|
2469
2426
|
});
|
|
2470
2427
|
const overrideParamsSchema = zod.z.object({ userId: zod.z.string().min(1).max(256) });
|
|
@@ -2473,19 +2430,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2473
2430
|
res.json({ overrides: listTestOverrides() });
|
|
2474
2431
|
});
|
|
2475
2432
|
router.put(shared.ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
body = overrideBodySchema.parse(req.body);
|
|
2481
|
-
} catch (err2) {
|
|
2482
|
-
if (err2 instanceof zod.z.ZodError) {
|
|
2483
|
-
sendZodError(res, err2);
|
|
2484
|
-
} else {
|
|
2485
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId and { entitled: boolean } required");
|
|
2486
|
-
}
|
|
2487
|
-
return;
|
|
2488
|
-
}
|
|
2433
|
+
const params = parseOrSend(res, overrideParamsSchema, req.params);
|
|
2434
|
+
if (!params) return;
|
|
2435
|
+
const body = parseOrSend(res, overrideBodySchema, req.body);
|
|
2436
|
+
if (!body) return;
|
|
2489
2437
|
setTestOverride(params.userId, body.entitled);
|
|
2490
2438
|
log.warn(
|
|
2491
2439
|
`[onesub/admin] sandbox test override set for ${params.userId}: entitled=${body.entitled}`
|
|
@@ -2493,13 +2441,10 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2493
2441
|
res.json({ ok: true, userId: params.userId, entitled: body.entitled, sandboxOnly: true });
|
|
2494
2442
|
});
|
|
2495
2443
|
router.delete(shared.ROUTES.ADMIN_TEST_OVERRIDE, (req, res) => {
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId required");
|
|
2501
|
-
return;
|
|
2502
|
-
}
|
|
2444
|
+
const params = parseOrSend(res, overrideParamsSchema, req.params, {
|
|
2445
|
+
message: "userId required"
|
|
2446
|
+
});
|
|
2447
|
+
if (!params) return;
|
|
2503
2448
|
const cleared = clearTestOverride(params.userId);
|
|
2504
2449
|
res.json({ ok: true, userId: params.userId, cleared });
|
|
2505
2450
|
});
|
|
@@ -2509,25 +2454,22 @@ function createAdminRouter(config, purchaseStore, store, webhookQueue) {
|
|
|
2509
2454
|
const items = await webhookQueue.listDeadLetters();
|
|
2510
2455
|
res.status(200).json({ items });
|
|
2511
2456
|
} catch (err2) {
|
|
2512
|
-
|
|
2457
|
+
log.error("[onesub/admin/webhook-deadletters] error:", err2);
|
|
2458
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
|
|
2513
2459
|
}
|
|
2514
2460
|
});
|
|
2515
2461
|
}
|
|
2516
2462
|
if (webhookQueue?.replayDeadLetter) {
|
|
2517
2463
|
const replayParamsSchema = zod.z.object({ id: zod.z.string().min(1).max(256) });
|
|
2518
2464
|
router.post("/onesub/admin/webhook-replay/:id", async (req, res) => {
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
params = replayParamsSchema.parse(req.params);
|
|
2522
|
-
} catch {
|
|
2523
|
-
sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "id required");
|
|
2524
|
-
return;
|
|
2525
|
-
}
|
|
2465
|
+
const params = parseOrSend(res, replayParamsSchema, req.params, { message: "id required" });
|
|
2466
|
+
if (!params) return;
|
|
2526
2467
|
try {
|
|
2527
2468
|
await webhookQueue.replayDeadLetter(params.id);
|
|
2528
2469
|
res.status(200).json({ ok: true });
|
|
2529
2470
|
} catch (err2) {
|
|
2530
|
-
|
|
2471
|
+
log.error("[onesub/admin/webhook-replay] error:", err2);
|
|
2472
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, "Internal server error");
|
|
2531
2473
|
}
|
|
2532
2474
|
});
|
|
2533
2475
|
}
|
|
@@ -2786,16 +2728,8 @@ function createAppleOfferRouter(config) {
|
|
|
2786
2728
|
return;
|
|
2787
2729
|
}
|
|
2788
2730
|
}
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
body = offerBodySchema.parse(req.body);
|
|
2792
|
-
} catch (err2) {
|
|
2793
|
-
if (err2 instanceof zod.z.ZodError) {
|
|
2794
|
-
sendZodError(res, err2);
|
|
2795
|
-
return;
|
|
2796
|
-
}
|
|
2797
|
-
throw err2;
|
|
2798
|
-
}
|
|
2731
|
+
const body = parseOrSend(res, offerBodySchema, req.body);
|
|
2732
|
+
if (!body) return;
|
|
2799
2733
|
if (!apple.bundleId) {
|
|
2800
2734
|
sendError(res, 400, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "config.apple.bundleId is required for offer signing");
|
|
2801
2735
|
return;
|
|
@@ -2812,7 +2746,8 @@ function createAppleOfferRouter(config) {
|
|
|
2812
2746
|
);
|
|
2813
2747
|
res.status(200).json(result);
|
|
2814
2748
|
} catch (err2) {
|
|
2815
|
-
|
|
2749
|
+
log.error("[onesub/apple/offer] signing error:", err2);
|
|
2750
|
+
sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, "Offer signing failed");
|
|
2816
2751
|
}
|
|
2817
2752
|
});
|
|
2818
2753
|
return router;
|
|
@@ -3113,6 +3048,26 @@ var PostgresPurchaseStore = class {
|
|
|
3113
3048
|
);
|
|
3114
3049
|
return result.rows.map(rowToPurchaseInfo);
|
|
3115
3050
|
}
|
|
3051
|
+
/**
|
|
3052
|
+
* Purchases for one user + product, most-recent-first.
|
|
3053
|
+
*
|
|
3054
|
+
* Same shape and ordering as `getPurchasesByUserId`, with `product_id` pushed
|
|
3055
|
+
* into the WHERE clause instead of filtered in process. Non-consumables also
|
|
3056
|
+
* have a partial unique index on `(user_id, product_id)`, so the ownership
|
|
3057
|
+
* check this backs is a single index lookup rather than a full read of the
|
|
3058
|
+
* user's purchase history.
|
|
3059
|
+
*/
|
|
3060
|
+
async getPurchasesForProduct(userId, productId) {
|
|
3061
|
+
const pool = await this.getPool();
|
|
3062
|
+
const result = await pool.query(
|
|
3063
|
+
`SELECT *
|
|
3064
|
+
FROM onesub_purchases
|
|
3065
|
+
WHERE user_id = $1 AND product_id = $2
|
|
3066
|
+
ORDER BY purchased_at DESC`,
|
|
3067
|
+
[userId, productId]
|
|
3068
|
+
);
|
|
3069
|
+
return result.rows.map(rowToPurchaseInfo);
|
|
3070
|
+
}
|
|
3116
3071
|
async getPurchaseByTransactionId(txId) {
|
|
3117
3072
|
const pool = await this.getPool();
|
|
3118
3073
|
const result = await pool.query(
|
|
@@ -3323,6 +3278,23 @@ var RedisPurchaseStore = class {
|
|
|
3323
3278
|
const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
|
|
3324
3279
|
return raws.filter((r) => r != null).map((r) => JSON.parse(r));
|
|
3325
3280
|
}
|
|
3281
|
+
/**
|
|
3282
|
+
* Purchases for one user + product, most-recent-first.
|
|
3283
|
+
*
|
|
3284
|
+
* Served from the `user_product` set that `savePurchase` already maintains for
|
|
3285
|
+
* `hasPurchased`, so this reads only the rows for this product rather than the
|
|
3286
|
+
* user's whole purchase history.
|
|
3287
|
+
*
|
|
3288
|
+
* That set is unordered — unlike the per-user sorted set — so the
|
|
3289
|
+
* most-recent-first contract is restored by an explicit sort here. The row
|
|
3290
|
+
* count is per-product, so this is small.
|
|
3291
|
+
*/
|
|
3292
|
+
async getPurchasesForProduct(userId, productId) {
|
|
3293
|
+
const ids = await this.redis.smembers(PUR_USER_PRODUCT_PREFIX + userId + ":" + productId);
|
|
3294
|
+
if (ids.length === 0) return [];
|
|
3295
|
+
const raws = await this.redis.mget(...ids.map((id) => PUR_TX_PREFIX + id));
|
|
3296
|
+
return raws.filter((r) => r != null).map((r) => JSON.parse(r)).sort((a, b) => Date.parse(b.purchasedAt) - Date.parse(a.purchasedAt));
|
|
3297
|
+
}
|
|
3326
3298
|
async getPurchaseByTransactionId(txId) {
|
|
3327
3299
|
const raw = await this.redis.get(PUR_TX_PREFIX + txId);
|
|
3328
3300
|
return raw ? JSON.parse(raw) : null;
|