@onesub/server 0.17.1 → 0.18.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/index.js CHANGED
@@ -1166,23 +1166,95 @@ function sendZodError(res, err2, extra = {}) {
1166
1166
  );
1167
1167
  }
1168
1168
 
1169
+ // src/apps.ts
1170
+ function appName(app) {
1171
+ return app.id || app.apple?.bundleId || app.google?.packageName || "default";
1172
+ }
1173
+ var registryCache = /* @__PURE__ */ new WeakMap();
1174
+ function getAppRegistry(config) {
1175
+ const cached = registryCache.get(config);
1176
+ if (cached) return cached;
1177
+ const registry = buildAppRegistry(config);
1178
+ registryCache.set(config, registry);
1179
+ return registry;
1180
+ }
1181
+ function buildAppRegistry(config) {
1182
+ const apps = [...config.apps ?? []];
1183
+ if (config.apple || config.google) {
1184
+ const id = config.defaultAppId || config.apple?.bundleId || config.google?.packageName || "default";
1185
+ if (!apps.some((a) => a.id === id)) {
1186
+ apps.unshift({ id, apple: config.apple, google: config.google });
1187
+ }
1188
+ }
1189
+ const defaultApp = apps.find((a) => a.id === config.defaultAppId) ?? // No explicit default: fall back to the top-level config, which unshifted to
1190
+ // the front above. Never silently pick an arbitrary app when several exist —
1191
+ // that would validate one app's receipt against another's credentials.
1192
+ (config.apple || config.google || apps.length === 1 ? apps[0] : void 0);
1193
+ if (apps.length > 1) {
1194
+ log.info("[onesub] Multi-app mode:", apps.map(appName).join(", "), "| default:", defaultApp ? appName(defaultApp) : "(none)");
1195
+ }
1196
+ function match(hint) {
1197
+ if (hint.appId) {
1198
+ const byId = apps.find(
1199
+ (a) => a.id === hint.appId || a.apple?.bundleId === hint.appId || a.google?.packageName === hint.appId
1200
+ );
1201
+ if (byId) return byId;
1202
+ log.warn("[onesub] Unknown appId:", hint.appId);
1203
+ return void 0;
1204
+ }
1205
+ if (hint.bundleId) {
1206
+ const byBundle = apps.find((a) => a.apple?.bundleId === hint.bundleId);
1207
+ if (byBundle) return byBundle;
1208
+ log.warn("[onesub] No app configured for bundleId:", hint.bundleId);
1209
+ return void 0;
1210
+ }
1211
+ return defaultApp;
1212
+ }
1213
+ return {
1214
+ apps,
1215
+ defaultApp,
1216
+ configFor(hint) {
1217
+ const app = match(hint);
1218
+ if (!app) {
1219
+ return { ...config, apple: void 0, google: void 0 };
1220
+ }
1221
+ return { ...config, apple: app.apple, google: app.google };
1222
+ }
1223
+ };
1224
+ }
1225
+ function peekAppleBundleId(jws) {
1226
+ try {
1227
+ const payload = jws.split(".")[1];
1228
+ if (!payload) return void 0;
1229
+ const json = Buffer.from(payload, "base64url").toString("utf-8");
1230
+ const claims = JSON.parse(json);
1231
+ return typeof claims.bundleId === "string" ? claims.bundleId : void 0;
1232
+ } catch {
1233
+ return void 0;
1234
+ }
1235
+ }
1236
+
1169
1237
  // src/routes/validate.ts
1170
1238
  var NO_SUB = { valid: false, subscription: null };
1171
1239
  var validateSchema = z.object({
1172
1240
  platform: z.enum(["apple", "google"]),
1173
1241
  receipt: z.string().min(1).max(1e4),
1174
1242
  userId: z.string().min(1).max(256),
1175
- productId: z.string().min(1).max(256)
1243
+ productId: z.string().min(1).max(256),
1244
+ /** Which app this receipt belongs to. Optional — see OneSubServerConfig.apps. */
1245
+ appId: z.string().min(1).max(256).optional()
1176
1246
  });
1177
1247
  function createValidateRouter(config, store) {
1178
1248
  const router = Router();
1249
+ const registry = getAppRegistry(config);
1179
1250
  router.post(ROUTES.VALIDATE, async (req, res) => {
1180
1251
  let platform;
1181
1252
  let receipt;
1182
1253
  let userId;
1183
1254
  let productId;
1255
+ let appId;
1184
1256
  try {
1185
- ({ platform, receipt, userId, productId } = validateSchema.parse(req.body));
1257
+ ({ platform, receipt, userId, productId, appId } = validateSchema.parse(req.body));
1186
1258
  } catch (err2) {
1187
1259
  if (err2 instanceof z.ZodError) {
1188
1260
  sendZodError(res, err2, NO_SUB);
@@ -1192,18 +1264,22 @@ function createValidateRouter(config, store) {
1192
1264
  }
1193
1265
  try {
1194
1266
  let sub = null;
1267
+ const appConfig = registry.configFor({
1268
+ appId,
1269
+ bundleId: platform === "apple" ? peekAppleBundleId(receipt) : void 0
1270
+ });
1195
1271
  if (platform === "apple") {
1196
- if (!config.apple) {
1272
+ if (!appConfig.apple) {
1197
1273
  sendError(res, 500, ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple configuration not provided", NO_SUB);
1198
1274
  return;
1199
1275
  }
1200
- sub = await validateAppleReceipt(receipt, config.apple);
1276
+ sub = await validateAppleReceipt(receipt, appConfig.apple);
1201
1277
  } else {
1202
- if (!config.google) {
1278
+ if (!appConfig.google) {
1203
1279
  sendError(res, 500, ONESUB_ERROR_CODE.GOOGLE_CONFIG_MISSING, "Google configuration not provided", NO_SUB);
1204
1280
  return;
1205
1281
  }
1206
- sub = await validateGoogleReceipt(receipt, productId, config.google);
1282
+ sub = await validateGoogleReceipt(receipt, productId, appConfig.google);
1207
1283
  }
1208
1284
  if (!sub) {
1209
1285
  sendError(res, 422, ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_SUB);
@@ -1227,8 +1303,8 @@ function createValidateRouter(config, store) {
1227
1303
  }
1228
1304
  sub.userId = userId;
1229
1305
  await store.save(sub);
1230
- if (platform === "google" && config.google) {
1231
- void acknowledgeGoogleSubscription(receipt, productId, config.google);
1306
+ if (platform === "google" && appConfig.google) {
1307
+ void acknowledgeGoogleSubscription(receipt, productId, appConfig.google);
1232
1308
  }
1233
1309
  const response = { valid: true, subscription: sub };
1234
1310
  res.status(200).json(response);
@@ -1352,11 +1428,12 @@ async function processAppleNotification(work, config, store, purchaseStore) {
1352
1428
  appAccountToken,
1353
1429
  inAppOwnershipType
1354
1430
  } = work.decoded;
1431
+ const appleConfigForApp = getAppRegistry(config).configFor({ bundleId }).apple;
1355
1432
  const mapped = mapAppleNotificationStatus(notificationType, subtype);
1356
1433
  const finalStatus = mapped ?? status;
1357
- if (notificationType === "CONSUMPTION_REQUEST" && config.apple?.consumptionInfoProvider && transactionId && productId) {
1358
- const provider = config.apple.consumptionInfoProvider;
1359
- const appleConfig = config.apple;
1434
+ if (notificationType === "CONSUMPTION_REQUEST" && appleConfigForApp?.consumptionInfoProvider && transactionId && productId) {
1435
+ const provider = appleConfigForApp.consumptionInfoProvider;
1436
+ const appleConfig = appleConfigForApp;
1360
1437
  void (async () => {
1361
1438
  try {
1362
1439
  const info = await provider({
@@ -1405,8 +1482,8 @@ async function processAppleNotification(work, config, store, purchaseStore) {
1405
1482
  expiresAt: expiresAt ?? existing.expiresAt
1406
1483
  };
1407
1484
  await store.save(updated);
1408
- } else if (config.apple?.issuerId && config.apple?.keyId && config.apple?.privateKey) {
1409
- const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, config.apple, {
1485
+ } else if (appleConfigForApp?.issuerId && appleConfigForApp?.keyId && appleConfigForApp?.privateKey) {
1486
+ const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, appleConfigForApp, {
1410
1487
  sandbox: environment === "Sandbox"
1411
1488
  });
1412
1489
  if (fresh) {
@@ -1461,8 +1538,9 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
1461
1538
  res.status(200).json({ received: true });
1462
1539
  return;
1463
1540
  }
1464
- if (config.apple?.bundleId && decoded.bundleId && decoded.bundleId !== config.apple.bundleId) {
1465
- log.warn("[onesub/webhook/apple] Bundle ID mismatch:", decoded.bundleId, "!==", config.apple.bundleId);
1541
+ const notifiedApp = getAppRegistry(config).configFor({ bundleId: decoded.bundleId });
1542
+ if (decoded.bundleId && !notifiedApp.apple) {
1543
+ log.warn("[onesub/webhook/apple] No app configured for bundleId:", decoded.bundleId);
1466
1544
  sendError(res, 400, ONESUB_ERROR_CODE.BUNDLE_ID_MISMATCH, "Bundle ID mismatch");
1467
1545
  return;
1468
1546
  }
@@ -1539,7 +1617,17 @@ var GOOGLE_FAILURE_MESSAGES = {
1539
1617
  message: "Failed to process notification"
1540
1618
  }
1541
1619
  };
1620
+ function googleResolver(config) {
1621
+ const registry = getAppRegistry(config);
1622
+ const restricted = registry.apps.some((app) => !!app.google?.packageName);
1623
+ return {
1624
+ registry,
1625
+ serves: (packageName) => !restricted || !!registry.configFor({ appId: packageName }).google,
1626
+ googleFor: (packageName) => restricted ? registry.configFor({ appId: packageName }).google : registry.defaultApp?.google ?? config.google
1627
+ };
1628
+ }
1542
1629
  async function processGoogleNotification(work, config, store, purchaseStore) {
1630
+ const { googleFor } = googleResolver(config);
1543
1631
  if (work.kind === "voided") {
1544
1632
  const { voided } = work;
1545
1633
  if (voided.productType === 1) {
@@ -1562,8 +1650,9 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
1562
1650
  const { notificationType: notificationType2, purchaseToken: purchaseToken2, sku } = work.oneTimeProduct;
1563
1651
  if (notificationType2 === 1) {
1564
1652
  log.info("[onesub/webhook/google] oneTimeProduct PURCHASED:", sku);
1565
- if (config.google?.serviceAccountKey && config.google.packageName) {
1566
- void acknowledgeGoogleProduct(purchaseToken2, sku, config.google).catch(
1653
+ const googleCfg = googleFor(work.oneTimeProduct.packageName);
1654
+ if (googleCfg?.serviceAccountKey && googleCfg.packageName) {
1655
+ void acknowledgeGoogleProduct(purchaseToken2, sku, googleCfg).catch(
1567
1656
  (err2) => log.warn(`[onesub/webhook/google] oneTimeProduct ack failed for SKU ${sku} \u2014 3-day auto-refund risk:`, err2)
1568
1657
  );
1569
1658
  }
@@ -1592,14 +1681,15 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
1592
1681
  } else {
1593
1682
  finalStatus = existing?.status ?? SUBSCRIPTION_STATUS.ACTIVE;
1594
1683
  }
1595
- if (isGooglePriceChangeConfirmedNotification(notificationType) && config.google?.onPriceChangeConfirmed) {
1596
- const hook = config.google.onPriceChangeConfirmed;
1684
+ const subGoogleCfg = googleFor(packageName);
1685
+ if (isGooglePriceChangeConfirmedNotification(notificationType) && subGoogleCfg?.onPriceChangeConfirmed) {
1686
+ const hook = subGoogleCfg.onPriceChangeConfirmed;
1597
1687
  void Promise.resolve().then(() => hook({ purchaseToken, subscriptionId, packageName })).catch((err2) => log.warn("[onesub/webhook/google] onPriceChangeConfirmed hook failed:", err2));
1598
1688
  }
1599
1689
  if (existing) {
1600
1690
  let updated = { ...existing, status: finalStatus };
1601
- if (config.google?.serviceAccountKey) {
1602
- const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1691
+ if (subGoogleCfg?.serviceAccountKey) {
1692
+ const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, subGoogleCfg);
1603
1693
  if (fresh) {
1604
1694
  const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
1605
1695
  updated = {
@@ -1614,8 +1704,8 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
1614
1704
  }
1615
1705
  await store.save(updated);
1616
1706
  } else {
1617
- if (config.google?.serviceAccountKey) {
1618
- const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, config.google);
1707
+ if (subGoogleCfg?.serviceAccountKey) {
1708
+ const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, subGoogleCfg);
1619
1709
  if (fresh) {
1620
1710
  const boundAccountId = fresh.boundAccountId;
1621
1711
  delete fresh.boundAccountId;
@@ -1636,12 +1726,15 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
1636
1726
  }
1637
1727
  }
1638
1728
  async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue) {
1639
- if (config.google?.pushAudience) {
1640
- const authenticated = await verifyGooglePushToken(
1641
- req,
1642
- config.google.pushAudience,
1643
- config.google.pushServiceAccountEmail
1644
- );
1729
+ const pushIdentities = getAppRegistry(config).apps.map((app) => app.google).filter((g) => !!g?.pushAudience);
1730
+ if (pushIdentities.length > 0) {
1731
+ let authenticated = false;
1732
+ for (const google of pushIdentities) {
1733
+ if (await verifyGooglePushToken(req, google.pushAudience, google.pushServiceAccountEmail)) {
1734
+ authenticated = true;
1735
+ break;
1736
+ }
1737
+ }
1645
1738
  if (!authenticated) {
1646
1739
  sendError(res, 401, ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
1647
1740
  return;
@@ -1663,10 +1756,11 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1663
1756
  markedEventId = body.message.messageId;
1664
1757
  }
1665
1758
  let work;
1759
+ const { serves: servesPackage } = googleResolver(config);
1666
1760
  const voided = decodeGoogleVoidedNotification(body);
1667
1761
  if (voided) {
1668
- if (config.google?.packageName && voided.packageName !== config.google.packageName) {
1669
- log.warn("[onesub/webhook/google] voided package name mismatch:", voided.packageName, "!==", config.google.packageName);
1762
+ if (!servesPackage(voided.packageName)) {
1763
+ log.warn("[onesub/webhook/google] voided package name not served:", voided.packageName);
1670
1764
  sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1671
1765
  return;
1672
1766
  }
@@ -1674,8 +1768,8 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1674
1768
  } else {
1675
1769
  const oneTimeProduct = decodeGoogleOneTimeProductNotification(body);
1676
1770
  if (oneTimeProduct) {
1677
- if (config.google?.packageName && oneTimeProduct.packageName !== config.google.packageName) {
1678
- log.warn("[onesub/webhook/google] oneTimeProduct package name mismatch:", oneTimeProduct.packageName, "!==", config.google.packageName);
1771
+ if (!servesPackage(oneTimeProduct.packageName)) {
1772
+ log.warn("[onesub/webhook/google] oneTimeProduct package name not served:", oneTimeProduct.packageName);
1679
1773
  sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1680
1774
  return;
1681
1775
  }
@@ -1686,8 +1780,8 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
1686
1780
  res.status(200).json({ received: true });
1687
1781
  return;
1688
1782
  }
1689
- if (config.google?.packageName && notification.packageName !== config.google.packageName) {
1690
- log.warn("[onesub/webhook/google] Package name mismatch:", notification.packageName, "!==", config.google.packageName);
1783
+ if (!servesPackage(notification.packageName)) {
1784
+ log.warn("[onesub/webhook/google] Package name not served:", notification.packageName);
1691
1785
  sendError(res, 400, ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
1692
1786
  return;
1693
1787
  }
@@ -1750,7 +1844,9 @@ var validatePurchaseSchema = z.object({
1750
1844
  receipt: z.string().min(1).max(1e4),
1751
1845
  userId: z.string().min(1).max(256),
1752
1846
  productId: z.string().min(1).max(256),
1753
- type: z.enum([PURCHASE_TYPE.CONSUMABLE, PURCHASE_TYPE.NON_CONSUMABLE])
1847
+ type: z.enum([PURCHASE_TYPE.CONSUMABLE, PURCHASE_TYPE.NON_CONSUMABLE]),
1848
+ /** Which app this receipt belongs to. Optional — see OneSubServerConfig.apps. */
1849
+ appId: z.string().min(1).max(256).optional()
1754
1850
  });
1755
1851
  var purchaseStatusQuerySchema = z.object({
1756
1852
  userId: z.string().min(1).max(256),
@@ -1758,6 +1854,7 @@ var purchaseStatusQuerySchema = z.object({
1758
1854
  });
1759
1855
  function createPurchaseRouter(config, purchaseStore) {
1760
1856
  const router = Router();
1857
+ const registry = getAppRegistry(config);
1761
1858
  router.post(ROUTES.VALIDATE_PURCHASE, async (req, res) => {
1762
1859
  let body;
1763
1860
  try {
@@ -1769,7 +1866,11 @@ function createPurchaseRouter(config, purchaseStore) {
1769
1866
  }
1770
1867
  throw err2;
1771
1868
  }
1772
- const { platform, receipt, userId, productId, type } = body;
1869
+ const { platform, receipt, userId, productId, type, appId } = body;
1870
+ const appConfig = registry.configFor({
1871
+ appId,
1872
+ bundleId: platform === "apple" ? peekAppleBundleId(receipt) : void 0
1873
+ });
1773
1874
  try {
1774
1875
  if (type === PURCHASE_TYPE.NON_CONSUMABLE) {
1775
1876
  const owned = (await purchaseStore.getPurchasesByUserId(userId)).find(
@@ -1789,25 +1890,25 @@ function createPurchaseRouter(config, purchaseStore) {
1789
1890
  let purchasedAt = (/* @__PURE__ */ new Date()).toISOString();
1790
1891
  let boundAccountId = null;
1791
1892
  if (platform === "apple") {
1792
- if (!config.apple) {
1893
+ if (!appConfig.apple) {
1793
1894
  sendError(res, 500, ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple configuration not provided", NO_PURCHASE);
1794
1895
  return;
1795
1896
  }
1796
- const result = await validateAppleConsumableReceipt(receipt, config.apple, productId);
1897
+ const result = await validateAppleConsumableReceipt(receipt, appConfig.apple, productId);
1797
1898
  if (result) {
1798
1899
  transactionId = result.transactionId;
1799
1900
  purchasedAt = result.purchasedAt;
1800
1901
  boundAccountId = result.appAccountToken ?? null;
1801
1902
  }
1802
1903
  } else {
1803
- if (!config.google) {
1904
+ if (!appConfig.google) {
1804
1905
  sendError(res, 500, ONESUB_ERROR_CODE.GOOGLE_CONFIG_MISSING, "Google configuration not provided", NO_PURCHASE);
1805
1906
  return;
1806
1907
  }
1807
1908
  const result = await validateGoogleProductReceipt(
1808
1909
  receipt,
1809
1910
  productId,
1810
- config.google,
1911
+ appConfig.google,
1811
1912
  type === PURCHASE_TYPE.CONSUMABLE ? "consumable" : "non_consumable"
1812
1913
  );
1813
1914
  if (result) {
@@ -1873,11 +1974,11 @@ function createPurchaseRouter(config, purchaseStore) {
1873
1974
  quantity: 1
1874
1975
  };
1875
1976
  await purchaseStore.savePurchase(purchase);
1876
- if (platform === "google" && config.google) {
1977
+ if (platform === "google" && appConfig.google) {
1877
1978
  if (type === PURCHASE_TYPE.CONSUMABLE) {
1878
- void consumeGoogleProductReceipt(receipt, productId, config.google);
1979
+ void consumeGoogleProductReceipt(receipt, productId, appConfig.google);
1879
1980
  } else {
1880
- void acknowledgeGoogleProduct(receipt, productId, config.google);
1981
+ void acknowledgeGoogleProduct(receipt, productId, appConfig.google);
1881
1982
  }
1882
1983
  }
1883
1984
  const response = {