@onesub/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/store.test.d.ts +2 -0
- package/dist/__tests__/store.test.d.ts.map +1 -0
- package/dist/__tests__/store.test.js +71 -0
- package/dist/__tests__/store.test.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/apple.d.ts +29 -0
- package/dist/providers/apple.d.ts.map +1 -0
- package/dist/providers/apple.js +111 -0
- package/dist/providers/apple.js.map +1 -0
- package/dist/providers/google.d.ts +48 -0
- package/dist/providers/google.d.ts.map +1 -0
- package/dist/providers/google.js +203 -0
- package/dist/providers/google.js.map +1 -0
- package/dist/routes/status.d.ts +4 -0
- package/dist/routes/status.d.ts.map +1 -0
- package/dist/routes/status.js +41 -0
- package/dist/routes/status.js.map +1 -0
- package/dist/routes/validate.d.ts +5 -0
- package/dist/routes/validate.d.ts.map +1 -0
- package/dist/routes/validate.js +91 -0
- package/dist/routes/validate.js.map +1 -0
- package/dist/routes/webhook.d.ts +5 -0
- package/dist/routes/webhook.d.ts.map +1 -0
- package/dist/routes/webhook.js +219 -0
- package/dist/routes/webhook.js.map +1 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +19 -0
- package/dist/store.js.map +1 -0
- package/dist/stores/postgres.d.ts +49 -0
- package/dist/stores/postgres.d.ts.map +1 -0
- package/dist/stores/postgres.js +138 -0
- package/dist/stores/postgres.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/store.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { InMemorySubscriptionStore } from '../store.js';
|
|
3
|
+
const makeSub = (overrides) => ({
|
|
4
|
+
userId: 'user_123',
|
|
5
|
+
productId: 'com.example.pro_monthly',
|
|
6
|
+
platform: 'apple',
|
|
7
|
+
status: 'active',
|
|
8
|
+
expiresAt: '2025-12-31T00:00:00.000Z',
|
|
9
|
+
originalTransactionId: 'txn_abc123',
|
|
10
|
+
purchasedAt: '2025-01-01T00:00:00.000Z',
|
|
11
|
+
willRenew: true,
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
describe('InMemorySubscriptionStore', () => {
|
|
15
|
+
let store;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
store = new InMemorySubscriptionStore();
|
|
18
|
+
});
|
|
19
|
+
it('save() and getByUserId() returns the correct subscription', async () => {
|
|
20
|
+
const sub = makeSub();
|
|
21
|
+
await store.save(sub);
|
|
22
|
+
const result = await store.getByUserId('user_123');
|
|
23
|
+
expect(result).toEqual(sub);
|
|
24
|
+
});
|
|
25
|
+
it('save() and getByTransactionId() returns the correct subscription', async () => {
|
|
26
|
+
const sub = makeSub();
|
|
27
|
+
await store.save(sub);
|
|
28
|
+
const result = await store.getByTransactionId('txn_abc123');
|
|
29
|
+
expect(result).toEqual(sub);
|
|
30
|
+
});
|
|
31
|
+
it('getByUserId() returns null for an unknown user', async () => {
|
|
32
|
+
const result = await store.getByUserId('nonexistent_user');
|
|
33
|
+
expect(result).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
it('getByTransactionId() returns null for an unknown transaction', async () => {
|
|
36
|
+
const result = await store.getByTransactionId('nonexistent_txn');
|
|
37
|
+
expect(result).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
it('save() overwrites an existing subscription for the same userId', async () => {
|
|
40
|
+
const original = makeSub({ status: 'active', expiresAt: '2025-06-01T00:00:00.000Z' });
|
|
41
|
+
await store.save(original);
|
|
42
|
+
const updated = makeSub({
|
|
43
|
+
status: 'expired',
|
|
44
|
+
expiresAt: '2025-01-15T00:00:00.000Z',
|
|
45
|
+
originalTransactionId: 'txn_abc123',
|
|
46
|
+
});
|
|
47
|
+
await store.save(updated);
|
|
48
|
+
const result = await store.getByUserId('user_123');
|
|
49
|
+
expect(result?.status).toBe('expired');
|
|
50
|
+
expect(result?.expiresAt).toBe('2025-01-15T00:00:00.000Z');
|
|
51
|
+
});
|
|
52
|
+
it('save() overwrites an existing subscription indexed by the same transactionId', async () => {
|
|
53
|
+
const original = makeSub({ willRenew: true });
|
|
54
|
+
await store.save(original);
|
|
55
|
+
const updated = makeSub({ willRenew: false, originalTransactionId: 'txn_abc123' });
|
|
56
|
+
await store.save(updated);
|
|
57
|
+
const result = await store.getByTransactionId('txn_abc123');
|
|
58
|
+
expect(result?.willRenew).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
it('stores multiple subscriptions independently', async () => {
|
|
61
|
+
const sub1 = makeSub({ userId: 'user_1', originalTransactionId: 'txn_1' });
|
|
62
|
+
const sub2 = makeSub({ userId: 'user_2', originalTransactionId: 'txn_2', platform: 'google' });
|
|
63
|
+
await store.save(sub1);
|
|
64
|
+
await store.save(sub2);
|
|
65
|
+
expect(await store.getByUserId('user_1')).toEqual(sub1);
|
|
66
|
+
expect(await store.getByUserId('user_2')).toEqual(sub2);
|
|
67
|
+
expect(await store.getByTransactionId('txn_1')).toEqual(sub1);
|
|
68
|
+
expect(await store.getByTransactionId('txn_2')).toEqual(sub2);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
//# sourceMappingURL=store.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.test.js","sourceRoot":"","sources":["../../src/__tests__/store.test.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import express, { Router } from 'express';
|
|
2
|
+
import type { OneSubServerConfig } from '@onesub/shared';
|
|
3
|
+
import type { SubscriptionStore } from './store.js';
|
|
4
|
+
/**
|
|
5
|
+
* Extended config with optional pluggable store.
|
|
6
|
+
*/
|
|
7
|
+
export interface OneSubMiddlewareConfig extends OneSubServerConfig {
|
|
8
|
+
/**
|
|
9
|
+
* Custom subscription store. Defaults to InMemorySubscriptionStore.
|
|
10
|
+
* For production, provide a PostgreSQL or Redis backed implementation.
|
|
11
|
+
*/
|
|
12
|
+
store?: SubscriptionStore;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Create an Express Router with all OneSub routes mounted.
|
|
16
|
+
*
|
|
17
|
+
* Mount this in your existing Express app:
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { createOneSubMiddleware } from '@onesub/server';
|
|
20
|
+
* app.use(createOneSubMiddleware(config));
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The routes registered are (all prefixed with nothing — mount at root or use
|
|
24
|
+
* a prefix via `app.use('/prefix', createOneSubMiddleware(config))`):
|
|
25
|
+
*
|
|
26
|
+
* POST /onesub/validate
|
|
27
|
+
* GET /onesub/status
|
|
28
|
+
* POST /onesub/webhook/apple
|
|
29
|
+
* POST /onesub/webhook/google
|
|
30
|
+
*/
|
|
31
|
+
export declare function createOneSubMiddleware(config: OneSubMiddlewareConfig): Router;
|
|
32
|
+
/**
|
|
33
|
+
* Create a standalone Express application with all OneSub routes.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { createOneSubServer } from '@onesub/server';
|
|
37
|
+
* createOneSubServer(config).listen(4100);
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare function createOneSubServer(config: OneSubMiddlewareConfig): ReturnType<typeof express>;
|
|
41
|
+
export { InMemorySubscriptionStore } from './store.js';
|
|
42
|
+
export type { SubscriptionStore } from './store.js';
|
|
43
|
+
export { PostgresSubscriptionStore } from './stores/postgres.js';
|
|
44
|
+
export { validateAppleReceipt } from './providers/apple.js';
|
|
45
|
+
export { validateGoogleReceipt } from './providers/google.js';
|
|
46
|
+
export default createOneSubMiddleware;
|
|
47
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,EAAE,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAGzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAKpD;;GAEG;AACH,MAAM,WAAW,sBAAuB,SAAQ,kBAAkB;IAChE;;;OAGG;IACH,KAAK,CAAC,EAAE,iBAAiB,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAa7E;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAW7F;AAGD,OAAO,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAC;AACvD,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AACpD,OAAO,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AAGjE,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAG9D,eAAe,sBAAsB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import express, { Router } from 'express';
|
|
2
|
+
import { DEFAULT_PORT } from '@onesub/shared';
|
|
3
|
+
import { InMemorySubscriptionStore } from './store.js';
|
|
4
|
+
import { createValidateRouter } from './routes/validate.js';
|
|
5
|
+
import { createStatusRouter } from './routes/status.js';
|
|
6
|
+
import { createWebhookRouter } from './routes/webhook.js';
|
|
7
|
+
/**
|
|
8
|
+
* Create an Express Router with all OneSub routes mounted.
|
|
9
|
+
*
|
|
10
|
+
* Mount this in your existing Express app:
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createOneSubMiddleware } from '@onesub/server';
|
|
13
|
+
* app.use(createOneSubMiddleware(config));
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* The routes registered are (all prefixed with nothing — mount at root or use
|
|
17
|
+
* a prefix via `app.use('/prefix', createOneSubMiddleware(config))`):
|
|
18
|
+
*
|
|
19
|
+
* POST /onesub/validate
|
|
20
|
+
* GET /onesub/status
|
|
21
|
+
* POST /onesub/webhook/apple
|
|
22
|
+
* POST /onesub/webhook/google
|
|
23
|
+
*/
|
|
24
|
+
export function createOneSubMiddleware(config) {
|
|
25
|
+
const store = config.store ?? new InMemorySubscriptionStore();
|
|
26
|
+
const router = Router();
|
|
27
|
+
// Parse JSON bodies for all OneSub routes (50 kb cap to prevent payload flooding)
|
|
28
|
+
router.use(express.json({ limit: '50kb' }));
|
|
29
|
+
router.use(createValidateRouter(config, store));
|
|
30
|
+
router.use(createStatusRouter(store));
|
|
31
|
+
router.use(createWebhookRouter(config, store));
|
|
32
|
+
return router;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create a standalone Express application with all OneSub routes.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { createOneSubServer } from '@onesub/server';
|
|
39
|
+
* createOneSubServer(config).listen(4100);
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function createOneSubServer(config) {
|
|
43
|
+
const app = express();
|
|
44
|
+
// Health check
|
|
45
|
+
app.get('/health', (_req, res) => {
|
|
46
|
+
res.json({ ok: true, service: 'onesub' });
|
|
47
|
+
});
|
|
48
|
+
app.use(createOneSubMiddleware(config));
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
51
|
+
// Named re-exports for consumers who want to bring their own store
|
|
52
|
+
export { InMemorySubscriptionStore } from './store.js';
|
|
53
|
+
export { PostgresSubscriptionStore } from './stores/postgres.js';
|
|
54
|
+
// Provider functions for direct (non-HTTP) usage
|
|
55
|
+
export { validateAppleReceipt } from './providers/apple.js';
|
|
56
|
+
export { validateGoogleReceipt } from './providers/google.js';
|
|
57
|
+
// Default export: the middleware factory
|
|
58
|
+
export default createOneSubMiddleware;
|
|
59
|
+
// Allow running directly: node dist/index.js
|
|
60
|
+
// Reads config from environment variables for quick local testing.
|
|
61
|
+
const isMain = typeof process !== 'undefined' &&
|
|
62
|
+
process.argv[1] != null &&
|
|
63
|
+
new URL(import.meta.url).pathname.endsWith(process.argv[1].replace(/\\/g, '/'));
|
|
64
|
+
if (isMain) {
|
|
65
|
+
const config = {
|
|
66
|
+
apple: process.env['APPLE_BUNDLE_ID']
|
|
67
|
+
? {
|
|
68
|
+
bundleId: process.env['APPLE_BUNDLE_ID'],
|
|
69
|
+
sharedSecret: process.env['APPLE_SHARED_SECRET'],
|
|
70
|
+
keyId: process.env['APPLE_KEY_ID'],
|
|
71
|
+
issuerId: process.env['APPLE_ISSUER_ID'],
|
|
72
|
+
privateKey: process.env['APPLE_PRIVATE_KEY'],
|
|
73
|
+
}
|
|
74
|
+
: undefined,
|
|
75
|
+
google: process.env['GOOGLE_PACKAGE_NAME']
|
|
76
|
+
? {
|
|
77
|
+
packageName: process.env['GOOGLE_PACKAGE_NAME'],
|
|
78
|
+
serviceAccountKey: process.env['GOOGLE_SERVICE_ACCOUNT_KEY'],
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
database: {
|
|
82
|
+
url: process.env['DATABASE_URL'] ?? '',
|
|
83
|
+
},
|
|
84
|
+
webhookSecret: process.env['WEBHOOK_SECRET'],
|
|
85
|
+
};
|
|
86
|
+
const port = process.env['PORT'] ? parseInt(process.env['PORT'], 10) : DEFAULT_PORT;
|
|
87
|
+
createOneSubServer(config).listen(port, () => {
|
|
88
|
+
console.log(`[onesub] Server listening on port ${port}`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SubscriptionInfo, AppleNotificationPayload, OneSubServerConfig } from '@onesub/shared';
|
|
2
|
+
type AppleConfig = NonNullable<OneSubServerConfig['apple']>;
|
|
3
|
+
/**
|
|
4
|
+
* Decode and verify a JWS compact serialisation using Apple's JWKS.
|
|
5
|
+
* When skipVerification is true, falls back to decodeJwt() without signature
|
|
6
|
+
* verification (useful for dev/testing environments).
|
|
7
|
+
*/
|
|
8
|
+
export declare function decodeJws<T>(jws: string, skipVerification?: boolean): Promise<T>;
|
|
9
|
+
/**
|
|
10
|
+
* Validate an Apple receipt string.
|
|
11
|
+
*
|
|
12
|
+
* StoreKit 2 receipts are JWS-encoded signed transactions (the `signedTransaction`
|
|
13
|
+
* field from the client). For legacy base64 receipts from StoreKit 1, you would
|
|
14
|
+
* POST to verifyReceipt — that path is left as a stub here because Apple is
|
|
15
|
+
* deprecating it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function validateAppleReceipt(receipt: string, config: AppleConfig): Promise<SubscriptionInfo | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Decode an Apple Server Notification V2 payload.
|
|
20
|
+
* Returns the derived subscription update to be merged into the store.
|
|
21
|
+
*/
|
|
22
|
+
export declare function decodeAppleNotification(payload: AppleNotificationPayload, skipJwsVerification?: boolean): Promise<{
|
|
23
|
+
originalTransactionId: string;
|
|
24
|
+
status: SubscriptionInfo['status'];
|
|
25
|
+
willRenew: boolean;
|
|
26
|
+
expiresAt: string;
|
|
27
|
+
} | null>;
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=apple.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apple.d.ts","sourceRoot":"","sources":["../../src/providers/apple.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAGrG,KAAK,WAAW,GAAG,WAAW,CAAC,kBAAkB,CAAC,OAAO,CAAC,CAAC,CAAC;AAwC5D;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,gBAAgB,UAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,CAepF;AAsBD;;;;;;;GAOG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAkClC;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,wBAAwB,EACjC,mBAAmB,UAAQ,GAC1B,OAAO,CAAC;IAAE,qBAAqB,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAC,QAAQ,CAAC,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CA6B9H"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { decodeJwt, createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
+
import { SUBSCRIPTION_STATUS } from '@onesub/shared';
|
|
3
|
+
/**
|
|
4
|
+
* Module-level JWKS fetcher for Apple's public keys.
|
|
5
|
+
* Cached automatically by jose's createRemoteJWKSet.
|
|
6
|
+
*/
|
|
7
|
+
const appleJWKS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
|
|
8
|
+
/**
|
|
9
|
+
* Decode and verify a JWS compact serialisation using Apple's JWKS.
|
|
10
|
+
* When skipVerification is true, falls back to decodeJwt() without signature
|
|
11
|
+
* verification (useful for dev/testing environments).
|
|
12
|
+
*/
|
|
13
|
+
export async function decodeJws(jws, skipVerification = false) {
|
|
14
|
+
if (skipVerification) {
|
|
15
|
+
if (process.env['NODE_ENV'] === 'production') {
|
|
16
|
+
console.warn('[onesub/apple] WARNING: skipJwsVerification is enabled in production. ' +
|
|
17
|
+
'JWS signatures are NOT being verified. This is a security risk. ' +
|
|
18
|
+
'Disable skipJwsVerification before going live.');
|
|
19
|
+
}
|
|
20
|
+
// Dev/test path: decode payload only, no signature check
|
|
21
|
+
return decodeJwt(jws);
|
|
22
|
+
}
|
|
23
|
+
const { payload } = await jwtVerify(jws, appleJWKS);
|
|
24
|
+
return payload;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Derive a SubscriptionStatus from the transaction + renewal payloads.
|
|
28
|
+
*/
|
|
29
|
+
function deriveStatus(tx, renewal) {
|
|
30
|
+
if (tx.revocationDate)
|
|
31
|
+
return SUBSCRIPTION_STATUS.CANCELED;
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const expires = tx.expiresDate ?? 0;
|
|
34
|
+
if (expires > now)
|
|
35
|
+
return SUBSCRIPTION_STATUS.ACTIVE;
|
|
36
|
+
// Expired — check if it was voluntarily canceled
|
|
37
|
+
if (renewal?.autoRenewStatus === 0)
|
|
38
|
+
return SUBSCRIPTION_STATUS.CANCELED;
|
|
39
|
+
return SUBSCRIPTION_STATUS.EXPIRED;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate an Apple receipt string.
|
|
43
|
+
*
|
|
44
|
+
* StoreKit 2 receipts are JWS-encoded signed transactions (the `signedTransaction`
|
|
45
|
+
* field from the client). For legacy base64 receipts from StoreKit 1, you would
|
|
46
|
+
* POST to verifyReceipt — that path is left as a stub here because Apple is
|
|
47
|
+
* deprecating it.
|
|
48
|
+
*/
|
|
49
|
+
export async function validateAppleReceipt(receipt, config) {
|
|
50
|
+
let tx;
|
|
51
|
+
try {
|
|
52
|
+
// StoreKit 2: receipt is a signed JWS transaction
|
|
53
|
+
tx = await decodeJws(receipt, config.skipJwsVerification);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
console.warn('[onesub/apple] Failed to decode receipt as JWS. Falling back to null.');
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!tx.originalTransactionId || !tx.productId || !tx.expiresDate) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
// Validate bundle ID if provided
|
|
63
|
+
if (tx.bundleId && tx.bundleId !== config.bundleId) {
|
|
64
|
+
console.warn('[onesub/apple] Bundle ID mismatch:', tx.bundleId, '!==', config.bundleId);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const status = deriveStatus(tx, null);
|
|
68
|
+
const purchasedAt = tx.originalPurchaseDate ?? tx.purchaseDate ?? Date.now();
|
|
69
|
+
return {
|
|
70
|
+
userId: '', // caller fills this in from the request body
|
|
71
|
+
productId: tx.productId,
|
|
72
|
+
platform: 'apple',
|
|
73
|
+
status,
|
|
74
|
+
expiresAt: new Date(tx.expiresDate).toISOString(),
|
|
75
|
+
originalTransactionId: tx.originalTransactionId,
|
|
76
|
+
purchasedAt: new Date(purchasedAt).toISOString(),
|
|
77
|
+
willRenew: status === SUBSCRIPTION_STATUS.ACTIVE, // refined by renewal info in webhook
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Decode an Apple Server Notification V2 payload.
|
|
82
|
+
* Returns the derived subscription update to be merged into the store.
|
|
83
|
+
*/
|
|
84
|
+
export async function decodeAppleNotification(payload, skipJwsVerification = false) {
|
|
85
|
+
const { signedTransactionInfo, signedRenewalInfo } = payload.data;
|
|
86
|
+
let tx;
|
|
87
|
+
let renewal = null;
|
|
88
|
+
try {
|
|
89
|
+
tx = await decodeJws(signedTransactionInfo, skipJwsVerification);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
renewal = await decodeJws(signedRenewalInfo, skipJwsVerification);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// renewal info is optional for some notification types
|
|
99
|
+
}
|
|
100
|
+
if (!tx.originalTransactionId || !tx.expiresDate)
|
|
101
|
+
return null;
|
|
102
|
+
const status = deriveStatus(tx, renewal);
|
|
103
|
+
const willRenew = renewal?.autoRenewStatus === 1;
|
|
104
|
+
return {
|
|
105
|
+
originalTransactionId: tx.originalTransactionId,
|
|
106
|
+
status,
|
|
107
|
+
willRenew,
|
|
108
|
+
expiresAt: new Date(tx.expiresDate).toISOString(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=apple.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apple.js","sourceRoot":"","sources":["../../src/providers/apple.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SubscriptionInfo, GoogleNotificationPayload, OneSubServerConfig } from '@onesub/shared';
|
|
2
|
+
type GoogleConfig = NonNullable<OneSubServerConfig['google']>;
|
|
3
|
+
/**
|
|
4
|
+
* Google RTDN notification types.
|
|
5
|
+
* https://developer.android.com/google/play/billing/rtdn-reference
|
|
6
|
+
*/
|
|
7
|
+
declare const GOOGLE_NOTIFICATION_TYPE: {
|
|
8
|
+
readonly SUBSCRIPTION_RECOVERED: 1;
|
|
9
|
+
readonly SUBSCRIPTION_RENEWED: 2;
|
|
10
|
+
readonly SUBSCRIPTION_CANCELED: 3;
|
|
11
|
+
readonly SUBSCRIPTION_PURCHASED: 4;
|
|
12
|
+
readonly SUBSCRIPTION_ON_HOLD: 5;
|
|
13
|
+
readonly SUBSCRIPTION_IN_GRACE_PERIOD: 6;
|
|
14
|
+
readonly SUBSCRIPTION_RESTARTED: 7;
|
|
15
|
+
readonly SUBSCRIPTION_PRICE_CHANGE_CONFIRMED: 8;
|
|
16
|
+
readonly SUBSCRIPTION_DEFERRED: 9;
|
|
17
|
+
readonly SUBSCRIPTION_PAUSED: 10;
|
|
18
|
+
readonly SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED: 11;
|
|
19
|
+
readonly SUBSCRIPTION_REVOKED: 12;
|
|
20
|
+
readonly SUBSCRIPTION_EXPIRED: 13;
|
|
21
|
+
};
|
|
22
|
+
type GoogleNotificationType = (typeof GOOGLE_NOTIFICATION_TYPE)[keyof typeof GOOGLE_NOTIFICATION_TYPE];
|
|
23
|
+
/**
|
|
24
|
+
* Validate a Google Play purchase token.
|
|
25
|
+
*
|
|
26
|
+
* @param receipt - The purchaseToken from the client (Google Play Billing)
|
|
27
|
+
* @param productId - The subscription product ID
|
|
28
|
+
* @param config - Google config with packageName and optional serviceAccountKey
|
|
29
|
+
*/
|
|
30
|
+
export declare function validateGoogleReceipt(receipt: string, productId: string, config: GoogleConfig): Promise<SubscriptionInfo | null>;
|
|
31
|
+
/**
|
|
32
|
+
* Decode a Google RTDN Pub/Sub notification.
|
|
33
|
+
* Returns the extracted subscription notification data, or null for test pings.
|
|
34
|
+
*/
|
|
35
|
+
export declare function decodeGoogleNotification(payload: GoogleNotificationPayload): {
|
|
36
|
+
notificationType: GoogleNotificationType;
|
|
37
|
+
purchaseToken: string;
|
|
38
|
+
subscriptionId: string;
|
|
39
|
+
packageName: string;
|
|
40
|
+
} | null;
|
|
41
|
+
/**
|
|
42
|
+
* Determine if a Google RTDN notification type represents an active subscription.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isGoogleActiveNotification(notificationType: GoogleNotificationType): boolean;
|
|
45
|
+
export declare function isGoogleCanceledNotification(notificationType: GoogleNotificationType): boolean;
|
|
46
|
+
export declare function isGoogleExpiredNotification(notificationType: GoogleNotificationType): boolean;
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=google.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google.d.ts","sourceRoot":"","sources":["../../src/providers/google.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAGtG,KAAK,YAAY,GAAG,WAAW,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAC,CAAC;AAuB9D;;;GAGG;AACH,QAAA,MAAM,wBAAwB;;;;;;;;;;;;;;CAcpB,CAAC;AAEX,KAAK,sBAAsB,GAAG,CAAC,OAAO,wBAAwB,CAAC,CAAC,MAAM,OAAO,wBAAwB,CAAC,CAAC;AAyJvG;;;;;;GAMG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CA6BlC;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG;IAC5E,gBAAgB,EAAE,sBAAsB,CAAC;IACzC,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,IAAI,CAuBP;AAED;;GAEG;AACH,wBAAgB,0BAA0B,CAAC,gBAAgB,EAAE,sBAAsB,GAAG,OAAO,CAQ5F;AAED,wBAAgB,4BAA4B,CAAC,gBAAgB,EAAE,sBAAsB,GAAG,OAAO,CAK9F;AAED,wBAAgB,2BAA2B,CAAC,gBAAgB,EAAE,sBAAsB,GAAG,OAAO,CAE7F"}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { SUBSCRIPTION_STATUS } from '@onesub/shared';
|
|
2
|
+
/**
|
|
3
|
+
* Google RTDN notification types.
|
|
4
|
+
* https://developer.android.com/google/play/billing/rtdn-reference
|
|
5
|
+
*/
|
|
6
|
+
const GOOGLE_NOTIFICATION_TYPE = {
|
|
7
|
+
SUBSCRIPTION_RECOVERED: 1,
|
|
8
|
+
SUBSCRIPTION_RENEWED: 2,
|
|
9
|
+
SUBSCRIPTION_CANCELED: 3,
|
|
10
|
+
SUBSCRIPTION_PURCHASED: 4,
|
|
11
|
+
SUBSCRIPTION_ON_HOLD: 5,
|
|
12
|
+
SUBSCRIPTION_IN_GRACE_PERIOD: 6,
|
|
13
|
+
SUBSCRIPTION_RESTARTED: 7,
|
|
14
|
+
SUBSCRIPTION_PRICE_CHANGE_CONFIRMED: 8,
|
|
15
|
+
SUBSCRIPTION_DEFERRED: 9,
|
|
16
|
+
SUBSCRIPTION_PAUSED: 10,
|
|
17
|
+
SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED: 11,
|
|
18
|
+
SUBSCRIPTION_REVOKED: 12,
|
|
19
|
+
SUBSCRIPTION_EXPIRED: 13,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Module-level token cache. Keyed by the raw serviceAccountKey string so that
|
|
23
|
+
* different service accounts (rare but possible) are cached independently.
|
|
24
|
+
*/
|
|
25
|
+
let cachedToken = null;
|
|
26
|
+
let refreshPromise = null;
|
|
27
|
+
/**
|
|
28
|
+
* Obtain a Google OAuth2 access token, returning a cached token if it has more
|
|
29
|
+
* than 60 seconds of remaining validity. Google tokens are valid for 3600 seconds,
|
|
30
|
+
* so this avoids a network round-trip on every API call.
|
|
31
|
+
*
|
|
32
|
+
* Promise deduplication prevents a thundering herd: concurrent callers that
|
|
33
|
+
* arrive while the token is being refreshed all await the same in-flight
|
|
34
|
+
* request instead of each issuing their own.
|
|
35
|
+
*/
|
|
36
|
+
async function getCachedAccessToken(serviceAccountKey) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (cachedToken && cachedToken.key === serviceAccountKey && cachedToken.expiresAt - now > 60_000) {
|
|
39
|
+
return cachedToken.token;
|
|
40
|
+
}
|
|
41
|
+
if (!refreshPromise) {
|
|
42
|
+
refreshPromise = getAccessToken(serviceAccountKey)
|
|
43
|
+
.then((token) => {
|
|
44
|
+
cachedToken = { token, expiresAt: Date.now() + 3_600_000, key: serviceAccountKey };
|
|
45
|
+
return token;
|
|
46
|
+
})
|
|
47
|
+
.finally(() => { refreshPromise = null; });
|
|
48
|
+
}
|
|
49
|
+
return refreshPromise;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Obtain a Google OAuth2 access token using a service account key (JWT assertion flow).
|
|
53
|
+
* The serviceAccountKey is expected to be a JSON string of the service account credentials.
|
|
54
|
+
*/
|
|
55
|
+
async function getAccessToken(serviceAccountKey) {
|
|
56
|
+
let key;
|
|
57
|
+
try {
|
|
58
|
+
key = JSON.parse(serviceAccountKey);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw new Error('[onesub/google] Invalid serviceAccountKey JSON');
|
|
62
|
+
}
|
|
63
|
+
const tokenUri = key.token_uri ?? 'https://oauth2.googleapis.com/token';
|
|
64
|
+
const scope = 'https://www.googleapis.com/auth/androidpublisher';
|
|
65
|
+
const now = Math.floor(Date.now() / 1000);
|
|
66
|
+
// Build a JWT assertion
|
|
67
|
+
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
|
|
68
|
+
const payload = Buffer.from(JSON.stringify({
|
|
69
|
+
iss: key.client_email,
|
|
70
|
+
scope,
|
|
71
|
+
aud: tokenUri,
|
|
72
|
+
iat: now,
|
|
73
|
+
exp: now + 3600,
|
|
74
|
+
})).toString('base64url');
|
|
75
|
+
const signingInput = `${header}.${payload}`;
|
|
76
|
+
// Sign with the private key using the native crypto module
|
|
77
|
+
const { createSign } = await import('crypto');
|
|
78
|
+
const sign = createSign('RSA-SHA256');
|
|
79
|
+
sign.update(signingInput);
|
|
80
|
+
const signature = sign.sign(key.private_key, 'base64url');
|
|
81
|
+
const assertion = `${signingInput}.${signature}`;
|
|
82
|
+
const resp = await fetch(tokenUri, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
85
|
+
body: new URLSearchParams({
|
|
86
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
87
|
+
assertion,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
if (!resp.ok) {
|
|
91
|
+
throw new Error(`[onesub/google] Token request failed: ${resp.status}`);
|
|
92
|
+
}
|
|
93
|
+
const data = (await resp.json());
|
|
94
|
+
if (!data.access_token)
|
|
95
|
+
throw new Error('[onesub/google] No access_token in response');
|
|
96
|
+
return data.access_token;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Fetch a subscription purchase from the Google Play Developer API.
|
|
100
|
+
*/
|
|
101
|
+
async function fetchSubscriptionPurchase(packageName, productId, purchaseToken, accessToken) {
|
|
102
|
+
const url = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(packageName)}/purchases/subscriptions/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}`;
|
|
103
|
+
const resp = await fetch(url, {
|
|
104
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
105
|
+
});
|
|
106
|
+
if (!resp.ok) {
|
|
107
|
+
const body = await resp.text();
|
|
108
|
+
throw new Error(`[onesub/google] Play API error ${resp.status}: ${body}`);
|
|
109
|
+
}
|
|
110
|
+
return resp.json();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Derive a SubscriptionStatus from a Google Play purchase resource.
|
|
114
|
+
*/
|
|
115
|
+
function deriveStatus(purchase) {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const expiryMs = purchase.expiryTimeMillis ? parseInt(purchase.expiryTimeMillis, 10) : 0;
|
|
118
|
+
if (expiryMs > now) {
|
|
119
|
+
// paymentState 0 = pending (grace period treated as active)
|
|
120
|
+
return SUBSCRIPTION_STATUS.ACTIVE;
|
|
121
|
+
}
|
|
122
|
+
if (purchase.cancelReason !== undefined)
|
|
123
|
+
return SUBSCRIPTION_STATUS.CANCELED;
|
|
124
|
+
return SUBSCRIPTION_STATUS.EXPIRED;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Validate a Google Play purchase token.
|
|
128
|
+
*
|
|
129
|
+
* @param receipt - The purchaseToken from the client (Google Play Billing)
|
|
130
|
+
* @param productId - The subscription product ID
|
|
131
|
+
* @param config - Google config with packageName and optional serviceAccountKey
|
|
132
|
+
*/
|
|
133
|
+
export async function validateGoogleReceipt(receipt, productId, config) {
|
|
134
|
+
if (!config.serviceAccountKey) {
|
|
135
|
+
console.warn('[onesub/google] No serviceAccountKey provided — cannot call Play API');
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
let purchase;
|
|
139
|
+
try {
|
|
140
|
+
const token = await getCachedAccessToken(config.serviceAccountKey);
|
|
141
|
+
purchase = await fetchSubscriptionPurchase(config.packageName, productId, receipt, token);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
console.error('[onesub/google] Receipt validation failed:', err);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const status = deriveStatus(purchase);
|
|
148
|
+
const expiryMs = purchase.expiryTimeMillis ? parseInt(purchase.expiryTimeMillis, 10) : Date.now();
|
|
149
|
+
const startMs = purchase.startTimeMillis ? parseInt(purchase.startTimeMillis, 10) : Date.now();
|
|
150
|
+
return {
|
|
151
|
+
userId: '', // caller fills this in
|
|
152
|
+
productId,
|
|
153
|
+
platform: 'google',
|
|
154
|
+
status,
|
|
155
|
+
expiresAt: new Date(expiryMs).toISOString(),
|
|
156
|
+
originalTransactionId: purchase.orderId ?? receipt.slice(0, 64),
|
|
157
|
+
purchasedAt: new Date(startMs).toISOString(),
|
|
158
|
+
willRenew: purchase.autoRenewing ?? false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Decode a Google RTDN Pub/Sub notification.
|
|
163
|
+
* Returns the extracted subscription notification data, or null for test pings.
|
|
164
|
+
*/
|
|
165
|
+
export function decodeGoogleNotification(payload) {
|
|
166
|
+
let notification;
|
|
167
|
+
try {
|
|
168
|
+
const json = Buffer.from(payload.message.data, 'base64').toString('utf-8');
|
|
169
|
+
notification = JSON.parse(json);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (!notification.subscriptionNotification) {
|
|
175
|
+
// test notification or unsupported type
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const { notificationType, purchaseToken, subscriptionId } = notification.subscriptionNotification;
|
|
179
|
+
return {
|
|
180
|
+
notificationType,
|
|
181
|
+
purchaseToken,
|
|
182
|
+
subscriptionId,
|
|
183
|
+
packageName: notification.packageName,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Determine if a Google RTDN notification type represents an active subscription.
|
|
188
|
+
*/
|
|
189
|
+
export function isGoogleActiveNotification(notificationType) {
|
|
190
|
+
return (notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_PURCHASED ||
|
|
191
|
+
notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RENEWED ||
|
|
192
|
+
notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RECOVERED ||
|
|
193
|
+
notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_RESTARTED ||
|
|
194
|
+
notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_IN_GRACE_PERIOD);
|
|
195
|
+
}
|
|
196
|
+
export function isGoogleCanceledNotification(notificationType) {
|
|
197
|
+
return (notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_CANCELED ||
|
|
198
|
+
notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_REVOKED);
|
|
199
|
+
}
|
|
200
|
+
export function isGoogleExpiredNotification(notificationType) {
|
|
201
|
+
return notificationType === GOOGLE_NOTIFICATION_TYPE.SUBSCRIPTION_EXPIRED;
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=google.js.map
|