@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/README.md +60 -1
- package/dist/__tests__/apps.test.d.ts +2 -0
- package/dist/__tests__/apps.test.d.ts.map +1 -0
- package/dist/apps.d.ts +41 -0
- package/dist/apps.d.ts.map +1 -0
- package/dist/index.cjs +145 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +145 -44
- package/dist/index.js.map +1 -1
- package/dist/routes/purchase.d.ts.map +1 -1
- package/dist/routes/validate.d.ts.map +1 -1
- package/dist/routes/webhook-apple.d.ts.map +1 -1
- package/dist/routes/webhook-google.d.ts.map +1 -1
- package/dist/stores/redis.d.ts +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ app.use(createOneSubMiddleware({
|
|
|
44
44
|
store: new PostgresSubscriptionStore(process.env.DATABASE_URL),
|
|
45
45
|
purchaseStore: new PostgresPurchaseStore(process.env.DATABASE_URL),
|
|
46
46
|
// Optional:
|
|
47
|
-
adminSecret: process.env.ADMIN_SECRET, // enables
|
|
47
|
+
adminSecret: process.env.ADMIN_SECRET, // enables admin + metrics routes
|
|
48
48
|
logger: require('pino')(), // any { info, warn, error } logger
|
|
49
49
|
refundPolicy: 'immediate', // 'immediate' (default) | 'until_expiry'
|
|
50
50
|
}));
|
|
@@ -52,6 +52,33 @@ app.use(createOneSubMiddleware({
|
|
|
52
52
|
app.listen(4100);
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
+
### Serving multiple apps
|
|
56
|
+
|
|
57
|
+
Keep the top-level `apple`/`google` fields for a single app. To validate several bundle IDs and
|
|
58
|
+
package names from one process, configure `apps` and send `appId` in validation requests:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
app.use(createOneSubMiddleware({
|
|
62
|
+
database: { url: process.env.DATABASE_URL },
|
|
63
|
+
apps: [
|
|
64
|
+
{ id: 'coffee', apple: { bundleId: 'gg.pryzm.coffee' } },
|
|
65
|
+
{
|
|
66
|
+
id: 'penguinrun',
|
|
67
|
+
apple: { bundleId: 'gg.pryzm.penguinrun' },
|
|
68
|
+
google: {
|
|
69
|
+
packageName: 'gg.pryzm.penguinrun',
|
|
70
|
+
serviceAccountKey: process.env.PENGUINRUN_GOOGLE_SERVICE_ACCOUNT_KEY,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
defaultAppId: 'coffee',
|
|
75
|
+
}));
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Apple receipts carry their bundle ID and can be resolved automatically. Google purchase tokens do
|
|
79
|
+
not carry a package name, so requests for a non-default Google app must include `appId`. Unknown
|
|
80
|
+
explicit app IDs fail closed and are never validated with another app's credentials.
|
|
81
|
+
|
|
55
82
|
## Endpoints
|
|
56
83
|
|
|
57
84
|
| Route | Purpose |
|
|
@@ -65,8 +92,19 @@ app.listen(4100);
|
|
|
65
92
|
| `DELETE /onesub/purchase/admin/:userId/:productId` | Wipe a non-consumable (requires `adminSecret`) |
|
|
66
93
|
| `POST /onesub/purchase/admin/grant` | Manually grant a purchase (requires `adminSecret`) |
|
|
67
94
|
| `POST /onesub/purchase/admin/transfer` | Reassign a `transactionId` to a new `userId` (requires `adminSecret`) |
|
|
95
|
+
| `GET /onesub/admin/subscriptions` | Filter and paginate subscriptions (requires `adminSecret`) |
|
|
96
|
+
| `GET /onesub/admin/subscriptions/:transactionId` | Get subscription detail (requires `adminSecret`) |
|
|
97
|
+
| `GET /onesub/admin/customers/:userId` | Get a customer profile (requires `adminSecret`) |
|
|
98
|
+
| `POST /onesub/admin/sync-apple/:originalTransactionId` | Refresh a subscription from Apple (requires `adminSecret`) |
|
|
68
99
|
| `GET /onesub/admin/webhook-deadletters` | List failed webhook jobs in DLQ (requires `adminSecret` + BullMQ) |
|
|
69
100
|
| `POST /onesub/admin/webhook-replay/:id` | Replay a dead-letter job (requires `adminSecret` + BullMQ) |
|
|
101
|
+
| `GET /onesub/entitlement?userId=&id=` | Evaluate one configured entitlement |
|
|
102
|
+
| `GET /onesub/entitlements?userId=` | Evaluate all configured entitlements |
|
|
103
|
+
| `GET /onesub/metrics/active` | Current entitled counts (requires `adminSecret`) |
|
|
104
|
+
| `GET /onesub/metrics/started` | Subscriptions started in a range (requires `adminSecret`) |
|
|
105
|
+
| `GET /onesub/metrics/expired` | Subscriptions ended in a range (requires `adminSecret`) |
|
|
106
|
+
| `GET /onesub/metrics/purchases/started` | Non-consumables started in a range (requires `adminSecret`) |
|
|
107
|
+
| `POST /onesub/apple/offer-signature` | Sign an Apple promotional offer when offer credentials are configured |
|
|
70
108
|
| `GET /openapi.json` | OpenAPI 3.1 spec (opt-in via `openapiHandler()`) |
|
|
71
109
|
|
|
72
110
|
## Lifecycle states (`0.4.0+`)
|
|
@@ -111,6 +149,20 @@ const sub = await fetchAppleSubscriptionStatus(originalTxId, config.apple, { san
|
|
|
111
149
|
// sub: SubscriptionInfo | null — null on missing creds / 404 / network failure
|
|
112
150
|
```
|
|
113
151
|
|
|
152
|
+
### Transaction History API client
|
|
153
|
+
|
|
154
|
+
`fetchAppleTransactionHistory()` follows Apple's revision pagination and returns verified transaction
|
|
155
|
+
records. It is a low-level recovery/migration primitive; the host decides how to reconcile returned
|
|
156
|
+
records with its stores.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { fetchAppleTransactionHistory } from '@onesub/server';
|
|
160
|
+
|
|
161
|
+
const transactions = await fetchAppleTransactionHistory(originalTxId, config.apple, {
|
|
162
|
+
sandbox: false,
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
114
166
|
### CONSUMPTION_REQUEST response hook
|
|
115
167
|
|
|
116
168
|
When Apple sends a `CONSUMPTION_REQUEST` notification (consumable refund review), without a hook Apple has no usage signal and tends to grant the refund. Provide a hook to PUT consumption info back to `/inApps/v1/transactions/consumption/{txId}`:
|
|
@@ -130,6 +182,13 @@ apple: {
|
|
|
130
182
|
|
|
131
183
|
Fire-and-forget; failures are logged but don't block the webhook 200.
|
|
132
184
|
|
|
185
|
+
### Promotional offer signing
|
|
186
|
+
|
|
187
|
+
Configure the separate subscription-offer key as `apple.offerKeyId` and `apple.offerPrivateKey` to
|
|
188
|
+
mount `POST /onesub/apple/offer-signature`. If `adminSecret` is configured, callers must send the
|
|
189
|
+
same value in `X-Onesub-Offer-Secret`. The endpoint returns StoreKit's `keyId`, `nonce`, `timestamp`,
|
|
190
|
+
and `signature`; never expose the private key to the client.
|
|
191
|
+
|
|
133
192
|
## Optional Google hooks (`0.8.0+`)
|
|
134
193
|
|
|
135
194
|
```ts
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apps.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/apps.test.ts"],"names":[],"mappings":""}
|
package/dist/apps.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { OneSubAppConfig, OneSubServerConfig } from '@onesub/shared';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves which app an incoming receipt belongs to, so one onesub instance can
|
|
4
|
+
* serve N bundles.
|
|
5
|
+
*
|
|
6
|
+
* A single-app deployment configures `apple`/`google` at the top level and never
|
|
7
|
+
* touches `apps` — that config stays the default and nothing here changes its
|
|
8
|
+
* behaviour.
|
|
9
|
+
*/
|
|
10
|
+
export interface AppRegistry {
|
|
11
|
+
readonly apps: OneSubAppConfig[];
|
|
12
|
+
readonly defaultApp: OneSubAppConfig | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Returns the server config with `apple`/`google` swapped to the matched app's
|
|
15
|
+
* credentials, so downstream providers keep reading `config.apple` /
|
|
16
|
+
* `config.google` without knowing about multi-app.
|
|
17
|
+
*/
|
|
18
|
+
configFor(hint: AppHint): OneSubServerConfig;
|
|
19
|
+
}
|
|
20
|
+
export interface AppHint {
|
|
21
|
+
/** `appId` from the request body, when the client sends one. */
|
|
22
|
+
appId?: string | null | undefined;
|
|
23
|
+
/** Bundle id read out of an Apple receipt — the receipt names its own app. */
|
|
24
|
+
bundleId?: string | null | undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Registry for this config, built once. Routes and webhook handlers all resolve
|
|
28
|
+
* through the same instance so the multi-app summary is logged a single time.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getAppRegistry(config: OneSubServerConfig): AppRegistry;
|
|
31
|
+
export declare function buildAppRegistry(config: OneSubServerConfig): AppRegistry;
|
|
32
|
+
/**
|
|
33
|
+
* Reads the bundleId out of an Apple JWS **without verifying its signature**,
|
|
34
|
+
* only to decide which app's credentials to validate it with.
|
|
35
|
+
*
|
|
36
|
+
* Trusting this to pick the app is safe: the chosen app's validator then verifies
|
|
37
|
+
* the signature against Apple's roots and re-checks the bundleId, so a forged
|
|
38
|
+
* payload claiming another app's bundle fails there.
|
|
39
|
+
*/
|
|
40
|
+
export declare function peekAppleBundleId(jws: string): string | undefined;
|
|
41
|
+
//# sourceMappingURL=apps.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apps.d.ts","sourceRoot":"","sources":["../src/apps.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAG1E;;;;;;;GAOG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,eAAe,EAAE,CAAC;IACjC,QAAQ,CAAC,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC;IACjD;;;;OAIG;IACH,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG,kBAAkB,CAAC;CAC9C;AAED,MAAM,WAAW,OAAO;IACtB,gEAAgE;IAChE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAClC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACtC;AAQD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,kBAAkB,GAAG,WAAW,CAMtE;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,kBAAkB,GAAG,WAAW,CA8DxE;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUjE"}
|
package/dist/index.cjs
CHANGED
|
@@ -1173,23 +1173,95 @@ function sendZodError(res, err2, extra = {}) {
|
|
|
1173
1173
|
);
|
|
1174
1174
|
}
|
|
1175
1175
|
|
|
1176
|
+
// src/apps.ts
|
|
1177
|
+
function appName(app) {
|
|
1178
|
+
return app.id || app.apple?.bundleId || app.google?.packageName || "default";
|
|
1179
|
+
}
|
|
1180
|
+
var registryCache = /* @__PURE__ */ new WeakMap();
|
|
1181
|
+
function getAppRegistry(config) {
|
|
1182
|
+
const cached = registryCache.get(config);
|
|
1183
|
+
if (cached) return cached;
|
|
1184
|
+
const registry = buildAppRegistry(config);
|
|
1185
|
+
registryCache.set(config, registry);
|
|
1186
|
+
return registry;
|
|
1187
|
+
}
|
|
1188
|
+
function buildAppRegistry(config) {
|
|
1189
|
+
const apps = [...config.apps ?? []];
|
|
1190
|
+
if (config.apple || config.google) {
|
|
1191
|
+
const id = config.defaultAppId || config.apple?.bundleId || config.google?.packageName || "default";
|
|
1192
|
+
if (!apps.some((a) => a.id === id)) {
|
|
1193
|
+
apps.unshift({ id, apple: config.apple, google: config.google });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const defaultApp = apps.find((a) => a.id === config.defaultAppId) ?? // No explicit default: fall back to the top-level config, which unshifted to
|
|
1197
|
+
// the front above. Never silently pick an arbitrary app when several exist —
|
|
1198
|
+
// that would validate one app's receipt against another's credentials.
|
|
1199
|
+
(config.apple || config.google || apps.length === 1 ? apps[0] : void 0);
|
|
1200
|
+
if (apps.length > 1) {
|
|
1201
|
+
log.info("[onesub] Multi-app mode:", apps.map(appName).join(", "), "| default:", defaultApp ? appName(defaultApp) : "(none)");
|
|
1202
|
+
}
|
|
1203
|
+
function match(hint) {
|
|
1204
|
+
if (hint.appId) {
|
|
1205
|
+
const byId = apps.find(
|
|
1206
|
+
(a) => a.id === hint.appId || a.apple?.bundleId === hint.appId || a.google?.packageName === hint.appId
|
|
1207
|
+
);
|
|
1208
|
+
if (byId) return byId;
|
|
1209
|
+
log.warn("[onesub] Unknown appId:", hint.appId);
|
|
1210
|
+
return void 0;
|
|
1211
|
+
}
|
|
1212
|
+
if (hint.bundleId) {
|
|
1213
|
+
const byBundle = apps.find((a) => a.apple?.bundleId === hint.bundleId);
|
|
1214
|
+
if (byBundle) return byBundle;
|
|
1215
|
+
log.warn("[onesub] No app configured for bundleId:", hint.bundleId);
|
|
1216
|
+
return void 0;
|
|
1217
|
+
}
|
|
1218
|
+
return defaultApp;
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
apps,
|
|
1222
|
+
defaultApp,
|
|
1223
|
+
configFor(hint) {
|
|
1224
|
+
const app = match(hint);
|
|
1225
|
+
if (!app) {
|
|
1226
|
+
return { ...config, apple: void 0, google: void 0 };
|
|
1227
|
+
}
|
|
1228
|
+
return { ...config, apple: app.apple, google: app.google };
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
function peekAppleBundleId(jws) {
|
|
1233
|
+
try {
|
|
1234
|
+
const payload = jws.split(".")[1];
|
|
1235
|
+
if (!payload) return void 0;
|
|
1236
|
+
const json = Buffer.from(payload, "base64url").toString("utf-8");
|
|
1237
|
+
const claims = JSON.parse(json);
|
|
1238
|
+
return typeof claims.bundleId === "string" ? claims.bundleId : void 0;
|
|
1239
|
+
} catch {
|
|
1240
|
+
return void 0;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1176
1244
|
// src/routes/validate.ts
|
|
1177
1245
|
var NO_SUB = { valid: false, subscription: null };
|
|
1178
1246
|
var validateSchema = zod.z.object({
|
|
1179
1247
|
platform: zod.z.enum(["apple", "google"]),
|
|
1180
1248
|
receipt: zod.z.string().min(1).max(1e4),
|
|
1181
1249
|
userId: zod.z.string().min(1).max(256),
|
|
1182
|
-
productId: zod.z.string().min(1).max(256)
|
|
1250
|
+
productId: zod.z.string().min(1).max(256),
|
|
1251
|
+
/** Which app this receipt belongs to. Optional — see OneSubServerConfig.apps. */
|
|
1252
|
+
appId: zod.z.string().min(1).max(256).optional()
|
|
1183
1253
|
});
|
|
1184
1254
|
function createValidateRouter(config, store) {
|
|
1185
1255
|
const router = express.Router();
|
|
1256
|
+
const registry = getAppRegistry(config);
|
|
1186
1257
|
router.post(shared.ROUTES.VALIDATE, async (req, res) => {
|
|
1187
1258
|
let platform;
|
|
1188
1259
|
let receipt;
|
|
1189
1260
|
let userId;
|
|
1190
1261
|
let productId;
|
|
1262
|
+
let appId;
|
|
1191
1263
|
try {
|
|
1192
|
-
({ platform, receipt, userId, productId } = validateSchema.parse(req.body));
|
|
1264
|
+
({ platform, receipt, userId, productId, appId } = validateSchema.parse(req.body));
|
|
1193
1265
|
} catch (err2) {
|
|
1194
1266
|
if (err2 instanceof zod.z.ZodError) {
|
|
1195
1267
|
sendZodError(res, err2, NO_SUB);
|
|
@@ -1199,18 +1271,22 @@ function createValidateRouter(config, store) {
|
|
|
1199
1271
|
}
|
|
1200
1272
|
try {
|
|
1201
1273
|
let sub = null;
|
|
1274
|
+
const appConfig = registry.configFor({
|
|
1275
|
+
appId,
|
|
1276
|
+
bundleId: platform === "apple" ? peekAppleBundleId(receipt) : void 0
|
|
1277
|
+
});
|
|
1202
1278
|
if (platform === "apple") {
|
|
1203
|
-
if (!
|
|
1279
|
+
if (!appConfig.apple) {
|
|
1204
1280
|
sendError(res, 500, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple configuration not provided", NO_SUB);
|
|
1205
1281
|
return;
|
|
1206
1282
|
}
|
|
1207
|
-
sub = await validateAppleReceipt(receipt,
|
|
1283
|
+
sub = await validateAppleReceipt(receipt, appConfig.apple);
|
|
1208
1284
|
} else {
|
|
1209
|
-
if (!
|
|
1285
|
+
if (!appConfig.google) {
|
|
1210
1286
|
sendError(res, 500, shared.ONESUB_ERROR_CODE.GOOGLE_CONFIG_MISSING, "Google configuration not provided", NO_SUB);
|
|
1211
1287
|
return;
|
|
1212
1288
|
}
|
|
1213
|
-
sub = await validateGoogleReceipt(receipt, productId,
|
|
1289
|
+
sub = await validateGoogleReceipt(receipt, productId, appConfig.google);
|
|
1214
1290
|
}
|
|
1215
1291
|
if (!sub) {
|
|
1216
1292
|
sendError(res, 422, shared.ONESUB_ERROR_CODE.RECEIPT_VALIDATION_FAILED, "Receipt validation failed", NO_SUB);
|
|
@@ -1234,8 +1310,8 @@ function createValidateRouter(config, store) {
|
|
|
1234
1310
|
}
|
|
1235
1311
|
sub.userId = userId;
|
|
1236
1312
|
await store.save(sub);
|
|
1237
|
-
if (platform === "google" &&
|
|
1238
|
-
void acknowledgeGoogleSubscription(receipt, productId,
|
|
1313
|
+
if (platform === "google" && appConfig.google) {
|
|
1314
|
+
void acknowledgeGoogleSubscription(receipt, productId, appConfig.google);
|
|
1239
1315
|
}
|
|
1240
1316
|
const response = { valid: true, subscription: sub };
|
|
1241
1317
|
res.status(200).json(response);
|
|
@@ -1359,11 +1435,12 @@ async function processAppleNotification(work, config, store, purchaseStore) {
|
|
|
1359
1435
|
appAccountToken,
|
|
1360
1436
|
inAppOwnershipType
|
|
1361
1437
|
} = work.decoded;
|
|
1438
|
+
const appleConfigForApp = getAppRegistry(config).configFor({ bundleId }).apple;
|
|
1362
1439
|
const mapped = mapAppleNotificationStatus(notificationType, subtype);
|
|
1363
1440
|
const finalStatus = mapped ?? status;
|
|
1364
|
-
if (notificationType === "CONSUMPTION_REQUEST" &&
|
|
1365
|
-
const provider =
|
|
1366
|
-
const appleConfig =
|
|
1441
|
+
if (notificationType === "CONSUMPTION_REQUEST" && appleConfigForApp?.consumptionInfoProvider && transactionId && productId) {
|
|
1442
|
+
const provider = appleConfigForApp.consumptionInfoProvider;
|
|
1443
|
+
const appleConfig = appleConfigForApp;
|
|
1367
1444
|
void (async () => {
|
|
1368
1445
|
try {
|
|
1369
1446
|
const info = await provider({
|
|
@@ -1412,8 +1489,8 @@ async function processAppleNotification(work, config, store, purchaseStore) {
|
|
|
1412
1489
|
expiresAt: expiresAt ?? existing.expiresAt
|
|
1413
1490
|
};
|
|
1414
1491
|
await store.save(updated);
|
|
1415
|
-
} else if (
|
|
1416
|
-
const fresh = await fetchAppleSubscriptionStatus(originalTransactionId,
|
|
1492
|
+
} else if (appleConfigForApp?.issuerId && appleConfigForApp?.keyId && appleConfigForApp?.privateKey) {
|
|
1493
|
+
const fresh = await fetchAppleSubscriptionStatus(originalTransactionId, appleConfigForApp, {
|
|
1417
1494
|
sandbox: environment === "Sandbox"
|
|
1418
1495
|
});
|
|
1419
1496
|
if (fresh) {
|
|
@@ -1468,8 +1545,9 @@ async function handleAppleWebhook(req, res, config, store, purchaseStore, webhoo
|
|
|
1468
1545
|
res.status(200).json({ received: true });
|
|
1469
1546
|
return;
|
|
1470
1547
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1548
|
+
const notifiedApp = getAppRegistry(config).configFor({ bundleId: decoded.bundleId });
|
|
1549
|
+
if (decoded.bundleId && !notifiedApp.apple) {
|
|
1550
|
+
log.warn("[onesub/webhook/apple] No app configured for bundleId:", decoded.bundleId);
|
|
1473
1551
|
sendError(res, 400, shared.ONESUB_ERROR_CODE.BUNDLE_ID_MISMATCH, "Bundle ID mismatch");
|
|
1474
1552
|
return;
|
|
1475
1553
|
}
|
|
@@ -1546,7 +1624,17 @@ var GOOGLE_FAILURE_MESSAGES = {
|
|
|
1546
1624
|
message: "Failed to process notification"
|
|
1547
1625
|
}
|
|
1548
1626
|
};
|
|
1627
|
+
function googleResolver(config) {
|
|
1628
|
+
const registry = getAppRegistry(config);
|
|
1629
|
+
const restricted = registry.apps.some((app) => !!app.google?.packageName);
|
|
1630
|
+
return {
|
|
1631
|
+
registry,
|
|
1632
|
+
serves: (packageName) => !restricted || !!registry.configFor({ appId: packageName }).google,
|
|
1633
|
+
googleFor: (packageName) => restricted ? registry.configFor({ appId: packageName }).google : registry.defaultApp?.google ?? config.google
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1549
1636
|
async function processGoogleNotification(work, config, store, purchaseStore) {
|
|
1637
|
+
const { googleFor } = googleResolver(config);
|
|
1550
1638
|
if (work.kind === "voided") {
|
|
1551
1639
|
const { voided } = work;
|
|
1552
1640
|
if (voided.productType === 1) {
|
|
@@ -1569,8 +1657,9 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
|
|
|
1569
1657
|
const { notificationType: notificationType2, purchaseToken: purchaseToken2, sku } = work.oneTimeProduct;
|
|
1570
1658
|
if (notificationType2 === 1) {
|
|
1571
1659
|
log.info("[onesub/webhook/google] oneTimeProduct PURCHASED:", sku);
|
|
1572
|
-
|
|
1573
|
-
|
|
1660
|
+
const googleCfg = googleFor(work.oneTimeProduct.packageName);
|
|
1661
|
+
if (googleCfg?.serviceAccountKey && googleCfg.packageName) {
|
|
1662
|
+
void acknowledgeGoogleProduct(purchaseToken2, sku, googleCfg).catch(
|
|
1574
1663
|
(err2) => log.warn(`[onesub/webhook/google] oneTimeProduct ack failed for SKU ${sku} \u2014 3-day auto-refund risk:`, err2)
|
|
1575
1664
|
);
|
|
1576
1665
|
}
|
|
@@ -1599,14 +1688,15 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
|
|
|
1599
1688
|
} else {
|
|
1600
1689
|
finalStatus = existing?.status ?? shared.SUBSCRIPTION_STATUS.ACTIVE;
|
|
1601
1690
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1691
|
+
const subGoogleCfg = googleFor(packageName);
|
|
1692
|
+
if (isGooglePriceChangeConfirmedNotification(notificationType) && subGoogleCfg?.onPriceChangeConfirmed) {
|
|
1693
|
+
const hook = subGoogleCfg.onPriceChangeConfirmed;
|
|
1604
1694
|
void Promise.resolve().then(() => hook({ purchaseToken, subscriptionId, packageName })).catch((err2) => log.warn("[onesub/webhook/google] onPriceChangeConfirmed hook failed:", err2));
|
|
1605
1695
|
}
|
|
1606
1696
|
if (existing) {
|
|
1607
1697
|
let updated = { ...existing, status: finalStatus };
|
|
1608
|
-
if (
|
|
1609
|
-
const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId,
|
|
1698
|
+
if (subGoogleCfg?.serviceAccountKey) {
|
|
1699
|
+
const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, subGoogleCfg);
|
|
1610
1700
|
if (fresh) {
|
|
1611
1701
|
const preserveNotificationStatus = isGoogleGracePeriodNotification(notificationType) || isGoogleOnHoldNotification(notificationType);
|
|
1612
1702
|
updated = {
|
|
@@ -1621,8 +1711,8 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
|
|
|
1621
1711
|
}
|
|
1622
1712
|
await store.save(updated);
|
|
1623
1713
|
} else {
|
|
1624
|
-
if (
|
|
1625
|
-
const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId,
|
|
1714
|
+
if (subGoogleCfg?.serviceAccountKey) {
|
|
1715
|
+
const fresh = await validateGoogleReceipt(purchaseToken, subscriptionId, subGoogleCfg);
|
|
1626
1716
|
if (fresh) {
|
|
1627
1717
|
const boundAccountId = fresh.boundAccountId;
|
|
1628
1718
|
delete fresh.boundAccountId;
|
|
@@ -1643,12 +1733,15 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
|
|
|
1643
1733
|
}
|
|
1644
1734
|
}
|
|
1645
1735
|
async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue) {
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1736
|
+
const pushIdentities = getAppRegistry(config).apps.map((app) => app.google).filter((g) => !!g?.pushAudience);
|
|
1737
|
+
if (pushIdentities.length > 0) {
|
|
1738
|
+
let authenticated = false;
|
|
1739
|
+
for (const google of pushIdentities) {
|
|
1740
|
+
if (await verifyGooglePushToken(req, google.pushAudience, google.pushServiceAccountEmail)) {
|
|
1741
|
+
authenticated = true;
|
|
1742
|
+
break;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1652
1745
|
if (!authenticated) {
|
|
1653
1746
|
sendError(res, 401, shared.ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
|
|
1654
1747
|
return;
|
|
@@ -1670,10 +1763,11 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
|
|
|
1670
1763
|
markedEventId = body.message.messageId;
|
|
1671
1764
|
}
|
|
1672
1765
|
let work;
|
|
1766
|
+
const { serves: servesPackage } = googleResolver(config);
|
|
1673
1767
|
const voided = decodeGoogleVoidedNotification(body);
|
|
1674
1768
|
if (voided) {
|
|
1675
|
-
if (
|
|
1676
|
-
log.warn("[onesub/webhook/google] voided package name
|
|
1769
|
+
if (!servesPackage(voided.packageName)) {
|
|
1770
|
+
log.warn("[onesub/webhook/google] voided package name not served:", voided.packageName);
|
|
1677
1771
|
sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
|
|
1678
1772
|
return;
|
|
1679
1773
|
}
|
|
@@ -1681,8 +1775,8 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
|
|
|
1681
1775
|
} else {
|
|
1682
1776
|
const oneTimeProduct = decodeGoogleOneTimeProductNotification(body);
|
|
1683
1777
|
if (oneTimeProduct) {
|
|
1684
|
-
if (
|
|
1685
|
-
log.warn("[onesub/webhook/google] oneTimeProduct package name
|
|
1778
|
+
if (!servesPackage(oneTimeProduct.packageName)) {
|
|
1779
|
+
log.warn("[onesub/webhook/google] oneTimeProduct package name not served:", oneTimeProduct.packageName);
|
|
1686
1780
|
sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
|
|
1687
1781
|
return;
|
|
1688
1782
|
}
|
|
@@ -1693,8 +1787,8 @@ async function handleGoogleWebhook(req, res, config, store, purchaseStore, webho
|
|
|
1693
1787
|
res.status(200).json({ received: true });
|
|
1694
1788
|
return;
|
|
1695
1789
|
}
|
|
1696
|
-
if (
|
|
1697
|
-
log.warn("[onesub/webhook/google] Package name
|
|
1790
|
+
if (!servesPackage(notification.packageName)) {
|
|
1791
|
+
log.warn("[onesub/webhook/google] Package name not served:", notification.packageName);
|
|
1698
1792
|
sendError(res, 400, shared.ONESUB_ERROR_CODE.PACKAGE_NAME_MISMATCH, "Package name mismatch");
|
|
1699
1793
|
return;
|
|
1700
1794
|
}
|
|
@@ -1757,7 +1851,9 @@ var validatePurchaseSchema = zod.z.object({
|
|
|
1757
1851
|
receipt: zod.z.string().min(1).max(1e4),
|
|
1758
1852
|
userId: zod.z.string().min(1).max(256),
|
|
1759
1853
|
productId: zod.z.string().min(1).max(256),
|
|
1760
|
-
type: zod.z.enum([shared.PURCHASE_TYPE.CONSUMABLE, shared.PURCHASE_TYPE.NON_CONSUMABLE])
|
|
1854
|
+
type: zod.z.enum([shared.PURCHASE_TYPE.CONSUMABLE, shared.PURCHASE_TYPE.NON_CONSUMABLE]),
|
|
1855
|
+
/** Which app this receipt belongs to. Optional — see OneSubServerConfig.apps. */
|
|
1856
|
+
appId: zod.z.string().min(1).max(256).optional()
|
|
1761
1857
|
});
|
|
1762
1858
|
var purchaseStatusQuerySchema = zod.z.object({
|
|
1763
1859
|
userId: zod.z.string().min(1).max(256),
|
|
@@ -1765,6 +1861,7 @@ var purchaseStatusQuerySchema = zod.z.object({
|
|
|
1765
1861
|
});
|
|
1766
1862
|
function createPurchaseRouter(config, purchaseStore) {
|
|
1767
1863
|
const router = express.Router();
|
|
1864
|
+
const registry = getAppRegistry(config);
|
|
1768
1865
|
router.post(shared.ROUTES.VALIDATE_PURCHASE, async (req, res) => {
|
|
1769
1866
|
let body;
|
|
1770
1867
|
try {
|
|
@@ -1776,7 +1873,11 @@ function createPurchaseRouter(config, purchaseStore) {
|
|
|
1776
1873
|
}
|
|
1777
1874
|
throw err2;
|
|
1778
1875
|
}
|
|
1779
|
-
const { platform, receipt, userId, productId, type } = body;
|
|
1876
|
+
const { platform, receipt, userId, productId, type, appId } = body;
|
|
1877
|
+
const appConfig = registry.configFor({
|
|
1878
|
+
appId,
|
|
1879
|
+
bundleId: platform === "apple" ? peekAppleBundleId(receipt) : void 0
|
|
1880
|
+
});
|
|
1780
1881
|
try {
|
|
1781
1882
|
if (type === shared.PURCHASE_TYPE.NON_CONSUMABLE) {
|
|
1782
1883
|
const owned = (await purchaseStore.getPurchasesByUserId(userId)).find(
|
|
@@ -1796,25 +1897,25 @@ function createPurchaseRouter(config, purchaseStore) {
|
|
|
1796
1897
|
let purchasedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1797
1898
|
let boundAccountId = null;
|
|
1798
1899
|
if (platform === "apple") {
|
|
1799
|
-
if (!
|
|
1900
|
+
if (!appConfig.apple) {
|
|
1800
1901
|
sendError(res, 500, shared.ONESUB_ERROR_CODE.APPLE_CONFIG_MISSING, "Apple configuration not provided", NO_PURCHASE);
|
|
1801
1902
|
return;
|
|
1802
1903
|
}
|
|
1803
|
-
const result = await validateAppleConsumableReceipt(receipt,
|
|
1904
|
+
const result = await validateAppleConsumableReceipt(receipt, appConfig.apple, productId);
|
|
1804
1905
|
if (result) {
|
|
1805
1906
|
transactionId = result.transactionId;
|
|
1806
1907
|
purchasedAt = result.purchasedAt;
|
|
1807
1908
|
boundAccountId = result.appAccountToken ?? null;
|
|
1808
1909
|
}
|
|
1809
1910
|
} else {
|
|
1810
|
-
if (!
|
|
1911
|
+
if (!appConfig.google) {
|
|
1811
1912
|
sendError(res, 500, shared.ONESUB_ERROR_CODE.GOOGLE_CONFIG_MISSING, "Google configuration not provided", NO_PURCHASE);
|
|
1812
1913
|
return;
|
|
1813
1914
|
}
|
|
1814
1915
|
const result = await validateGoogleProductReceipt(
|
|
1815
1916
|
receipt,
|
|
1816
1917
|
productId,
|
|
1817
|
-
|
|
1918
|
+
appConfig.google,
|
|
1818
1919
|
type === shared.PURCHASE_TYPE.CONSUMABLE ? "consumable" : "non_consumable"
|
|
1819
1920
|
);
|
|
1820
1921
|
if (result) {
|
|
@@ -1880,11 +1981,11 @@ function createPurchaseRouter(config, purchaseStore) {
|
|
|
1880
1981
|
quantity: 1
|
|
1881
1982
|
};
|
|
1882
1983
|
await purchaseStore.savePurchase(purchase);
|
|
1883
|
-
if (platform === "google" &&
|
|
1984
|
+
if (platform === "google" && appConfig.google) {
|
|
1884
1985
|
if (type === shared.PURCHASE_TYPE.CONSUMABLE) {
|
|
1885
|
-
void consumeGoogleProductReceipt(receipt, productId,
|
|
1986
|
+
void consumeGoogleProductReceipt(receipt, productId, appConfig.google);
|
|
1886
1987
|
} else {
|
|
1887
|
-
void acknowledgeGoogleProduct(receipt, productId,
|
|
1988
|
+
void acknowledgeGoogleProduct(receipt, productId, appConfig.google);
|
|
1888
1989
|
}
|
|
1889
1990
|
}
|
|
1890
1991
|
const response = {
|