@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.
@@ -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
@@ -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
- list.push(purchase);
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
- let platform;
1374
- let receipt;
1375
- let userId;
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
- let body;
1989
- try {
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.getPurchasesByUserId(userId)).find(
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
- let query;
2126
- try {
2127
- query = purchaseStatusQuerySchema.parse(req.query);
2128
- } catch (err2) {
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
- let purchases = await purchaseStore.getPurchasesByUserId(userId);
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
- let userId;
2193
- let id;
2194
- try {
2195
- userId = userIdSchema.parse(req.query["userId"]);
2196
- id = entitlementIdSchema.parse(req.query["id"]);
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
- let userId;
2217
- try {
2218
- userId = userIdSchema.parse(req.query["userId"]);
2219
- } catch {
2220
- sendError(res, 400, shared.ONESUB_ERROR_CODE.INVALID_INPUT, "userId is required");
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
- let params;
2272
- try {
2273
- params = resetParamsSchema.parse(req.params);
2274
- } catch {
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
- let body;
2287
- try {
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
- let body;
2318
- try {
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
- let query;
2359
- try {
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
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "list error");
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
- let params;
2386
- try {
2387
- params = detailParamsSchema.parse(req.params);
2388
- } catch {
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
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "detail error");
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
- let params;
2408
- try {
2409
- params = customerParamsSchema.parse(req.params);
2410
- } catch {
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
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "customer error");
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
- let params;
2443
- try {
2444
- params = syncAppleParamsSchema.parse(req.params);
2445
- } catch {
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
- sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "sync error");
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
- let params;
2477
- let body;
2478
- try {
2479
- params = overrideParamsSchema.parse(req.params);
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
- let params;
2497
- try {
2498
- params = overrideParamsSchema.parse(req.params);
2499
- } catch {
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
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "list error");
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
- let params;
2520
- try {
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
- sendError(res, 500, shared.ONESUB_ERROR_CODE.STORE_ERROR, err2.message ?? "replay error");
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
- let body;
2790
- try {
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
- sendError(res, 500, shared.ONESUB_ERROR_CODE.INTERNAL_ERROR, err2.message ?? "Offer signing failed");
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;