@tumbaland/backend-core 1.23.0 → 1.25.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/app/createBaseApp.d.ts +24 -0
- package/dist/app/createBaseApp.d.ts.map +1 -1
- package/dist/app/createBaseApp.js +20 -1
- package/dist/app/createBaseApp.js.map +1 -1
- package/dist/entitlements/UsageMeter.d.ts +30 -0
- package/dist/entitlements/UsageMeter.d.ts.map +1 -0
- package/dist/entitlements/UsageMeter.js +27 -0
- package/dist/entitlements/UsageMeter.js.map +1 -0
- package/dist/entitlements/client.d.ts +17 -0
- package/dist/entitlements/client.d.ts.map +1 -0
- package/dist/entitlements/client.js +142 -0
- package/dist/entitlements/client.js.map +1 -0
- package/dist/entitlements/definitions.d.ts +124 -0
- package/dist/entitlements/definitions.d.ts.map +1 -0
- package/dist/entitlements/definitions.js +210 -0
- package/dist/entitlements/definitions.js.map +1 -0
- package/dist/entitlements/index.d.ts +12 -0
- package/dist/entitlements/index.d.ts.map +1 -0
- package/dist/entitlements/index.js +43 -0
- package/dist/entitlements/index.js.map +1 -0
- package/dist/entitlements/middleware.d.ts +54 -0
- package/dist/entitlements/middleware.d.ts.map +1 -0
- package/dist/entitlements/middleware.js +109 -0
- package/dist/entitlements/middleware.js.map +1 -0
- package/dist/entitlements/mode.d.ts +10 -0
- package/dist/entitlements/mode.d.ts.map +1 -0
- package/dist/entitlements/mode.js +32 -0
- package/dist/entitlements/mode.js.map +1 -0
- package/dist/entitlements/types.d.ts +29 -0
- package/dist/entitlements/types.d.ts.map +1 -0
- package/dist/entitlements/types.js +3 -0
- package/dist/entitlements/types.js.map +1 -0
- package/dist/entitlements/usage.d.ts +56 -0
- package/dist/entitlements/usage.d.ts.map +1 -0
- package/dist/entitlements/usage.js +159 -0
- package/dist/entitlements/usage.js.map +1 -0
- package/dist/errors/HttpError.d.ts +30 -1
- package/dist/errors/HttpError.d.ts.map +1 -1
- package/dist/errors/HttpError.js +20 -2
- package/dist/errors/HttpError.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/errorHandler.d.ts.map +1 -1
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/middleware/internalServiceAuth.d.ts +44 -0
- package/dist/middleware/internalServiceAuth.d.ts.map +1 -0
- package/dist/middleware/internalServiceAuth.js +86 -0
- package/dist/middleware/internalServiceAuth.js.map +1 -0
- package/package.json +2 -1
- package/src/app/createBaseApp.test.ts +31 -0
- package/src/app/createBaseApp.ts +28 -1
- package/src/entitlements/UsageMeter.ts +49 -0
- package/src/entitlements/client.test.ts +170 -0
- package/src/entitlements/client.ts +162 -0
- package/src/entitlements/definitions.test.ts +161 -0
- package/src/entitlements/definitions.ts +261 -0
- package/src/entitlements/index.ts +40 -0
- package/src/entitlements/middleware.test.ts +179 -0
- package/src/entitlements/middleware.ts +136 -0
- package/src/entitlements/mode.test.ts +50 -0
- package/src/entitlements/mode.ts +28 -0
- package/src/entitlements/types.ts +30 -0
- package/src/entitlements/usage.test.ts +271 -0
- package/src/entitlements/usage.ts +189 -0
- package/src/errors/HttpError.test.ts +39 -1
- package/src/errors/HttpError.ts +35 -1
- package/src/index.ts +13 -1
- package/src/middleware/errorHandler.test.ts +43 -1
- package/src/middleware/errorHandler.ts +6 -1
- package/src/middleware/internalServiceAuth.test.ts +173 -0
- package/src/middleware/internalServiceAuth.ts +96 -0
package/src/app/createBaseApp.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface CreateBaseAppOptions {
|
|
|
25
25
|
tracing?: boolean;
|
|
26
26
|
/** Use requestLoggerWithMetrics (adds Prometheus histograms) instead of the plain requestLogger. Default true. */
|
|
27
27
|
metrics?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Value for Express's `trust proxy`. Defaults to trusting private/loopback
|
|
30
|
+
* addresses — see the note on tumbaland-proxy below. Pass `false` for a
|
|
31
|
+
* service exposed directly to clients with no proxy in front of it.
|
|
32
|
+
*/
|
|
33
|
+
trustProxy?: Parameters<Express['set']>[1];
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -47,6 +53,24 @@ export interface CreateBaseAppOptions {
|
|
|
47
53
|
* Services with non-standard body parsing (a webhook needing the raw body,
|
|
48
54
|
* or conditional parsing per-route) should pass `parseJson`/`parseCookies:
|
|
49
55
|
* false` and mount those themselves at the exact point they need to.
|
|
56
|
+
*
|
|
57
|
+
* `trust proxy` is on by default because nothing here is reached directly:
|
|
58
|
+
* every service sits behind tumbaland-proxy, which sets `X-Forwarded-For`.
|
|
59
|
+
* Left at Express's default (`false`) that header is ignored, which
|
|
60
|
+
* express-rate-limit reports as `ERR_ERL_UNEXPECTED_X_FORWARDED_FOR` — and
|
|
61
|
+
* it is right to: the limiter would then key every request in the cluster on
|
|
62
|
+
* the proxy's own container IP, so one busy client could exhaust the window
|
|
63
|
+
* for everyone.
|
|
64
|
+
*
|
|
65
|
+
* The value is `loopback, linklocal, uniquelocal` (private ranges) rather
|
|
66
|
+
* than a hop count, because the chain length differs per ingress path: a
|
|
67
|
+
* direct hit arrives as `client` + the proxy's own socket address, while
|
|
68
|
+
* traffic through the Cloudflare tunnel arrives as `client, 127.0.0.1` (the
|
|
69
|
+
* local cloudflared) + that same socket address. Express walks the chain
|
|
70
|
+
* right-to-left and stops at the first address it does not trust, so both
|
|
71
|
+
* paths land on the real client; a fixed number would be wrong for one of
|
|
72
|
+
* them. All the intermediaries are private addresses and the client is not,
|
|
73
|
+
* which is exactly the split this preset encodes.
|
|
50
74
|
*/
|
|
51
75
|
export function createBaseApp(options: CreateBaseAppOptions = {}): Express {
|
|
52
76
|
const {
|
|
@@ -55,11 +79,14 @@ export function createBaseApp(options: CreateBaseAppOptions = {}): Express {
|
|
|
55
79
|
parseJson = true,
|
|
56
80
|
parseCookies = true,
|
|
57
81
|
tracing = true,
|
|
58
|
-
metrics = true
|
|
82
|
+
metrics = true,
|
|
83
|
+
trustProxy = 'loopback, linklocal, uniquelocal'
|
|
59
84
|
} = options;
|
|
60
85
|
|
|
61
86
|
const app = express();
|
|
62
87
|
|
|
88
|
+
app.set('trust proxy', trustProxy);
|
|
89
|
+
|
|
63
90
|
app.get('/health/live', healthLive);
|
|
64
91
|
|
|
65
92
|
app.use(securityHeaders);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Schema, model, models, Document, Model } from 'mongoose';
|
|
2
|
+
import { MeterKey, MeterPeriod, meterPeriod } from './definitions';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One counter per (subject, meter, period). Lives in the shared database, so
|
|
6
|
+
* album-service can write `storageBytes` while payment-service reads it back
|
|
7
|
+
* for the pricing page without another round trip.
|
|
8
|
+
*
|
|
9
|
+
* `subjectId` is whoever the usage is billed to — an email for personal
|
|
10
|
+
* meters, the group owner's email for pooled ones. It is deliberately not a
|
|
11
|
+
* user ObjectId: every service already has the email on the JWT, and none of
|
|
12
|
+
* them share a users collection.
|
|
13
|
+
*/
|
|
14
|
+
export interface IUsageMeter extends Document {
|
|
15
|
+
subjectId: string;
|
|
16
|
+
meter: MeterKey;
|
|
17
|
+
/** `ALL` for cumulative meters, `YYYY-MM` for monthly ones. */
|
|
18
|
+
period: string;
|
|
19
|
+
count: number;
|
|
20
|
+
createdAt: Date;
|
|
21
|
+
updatedAt: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const UsageMeterSchema = new Schema<IUsageMeter>(
|
|
25
|
+
{
|
|
26
|
+
subjectId: { type: String, required: true, lowercase: true, trim: true },
|
|
27
|
+
meter: { type: String, required: true },
|
|
28
|
+
period: { type: String, required: true, default: 'ALL' },
|
|
29
|
+
count: { type: Number, required: true, default: 0 }
|
|
30
|
+
},
|
|
31
|
+
{ timestamps: true }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
UsageMeterSchema.index({ subjectId: 1, meter: 1, period: 1 }, { unique: true });
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Registered through `models` first: several services import this module into
|
|
38
|
+
* the same mongoose singleton during tests, and a second `model()` call with
|
|
39
|
+
* the same name throws `OverwriteModelError`.
|
|
40
|
+
*/
|
|
41
|
+
export const UsageMeter: Model<IUsageMeter> =
|
|
42
|
+
(models.UsageMeter as Model<IUsageMeter>) || model<IUsageMeter>('UsageMeter', UsageMeterSchema);
|
|
43
|
+
|
|
44
|
+
/** The period bucket a meter counts into right now. Monthly meters roll over at 00:00 UTC on the 1st. */
|
|
45
|
+
export function currentPeriod(meter: MeterKey, now: Date = new Date()): string {
|
|
46
|
+
const period: MeterPeriod = meterPeriod(meter);
|
|
47
|
+
if (period === 'ALL') return 'ALL';
|
|
48
|
+
return now.toISOString().slice(0, 7); // YYYY-MM
|
|
49
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
jest.mock('../logging/logger', () => ({
|
|
2
|
+
__esModule: true,
|
|
3
|
+
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
4
|
+
}));
|
|
5
|
+
|
|
6
|
+
import logger from '../logging/logger';
|
|
7
|
+
import { clearEntitlementsCache, getEntitlements, invalidateEntitlements } from './client';
|
|
8
|
+
import { DEFAULT_PLAN_LIMITS } from './definitions';
|
|
9
|
+
|
|
10
|
+
const ORIGINAL_ENV = process.env;
|
|
11
|
+
const ORIGINAL_FETCH = global.fetch;
|
|
12
|
+
|
|
13
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
14
|
+
return { ok: status >= 200 && status < 300, status, json: async () => body } as Response;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function paidResponse() {
|
|
18
|
+
return jsonResponse({
|
|
19
|
+
success: true,
|
|
20
|
+
planCode: 'personal',
|
|
21
|
+
status: 'active',
|
|
22
|
+
limits: { meters: { storageBytes: 12345 } },
|
|
23
|
+
upgradeTo: 'family'
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
process.env = {
|
|
29
|
+
...ORIGINAL_ENV,
|
|
30
|
+
PAYMENT_API_URL: 'http://payment-service:5005',
|
|
31
|
+
INTERNAL_SERVICE_TOKEN: 'test-internal-token',
|
|
32
|
+
SERVICE_NAME: 'album-service'
|
|
33
|
+
};
|
|
34
|
+
clearEntitlementsCache();
|
|
35
|
+
global.fetch = jest.fn();
|
|
36
|
+
(logger.error as jest.Mock).mockClear();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
process.env = ORIGINAL_ENV;
|
|
41
|
+
global.fetch = ORIGINAL_FETCH;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getEntitlements', () => {
|
|
45
|
+
it('calls the internal endpoint with the service token and normalizes the answer', async () => {
|
|
46
|
+
(global.fetch as jest.Mock).mockResolvedValue(paidResponse());
|
|
47
|
+
|
|
48
|
+
const result = await getEntitlements('User@Example.com');
|
|
49
|
+
|
|
50
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
51
|
+
'http://payment-service:5005/internal/entitlements?email=user%40example.com',
|
|
52
|
+
expect.objectContaining({
|
|
53
|
+
headers: expect.objectContaining({ 'x-internal-token': 'test-internal-token', 'x-service-id': 'album-service' })
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
expect(result.planCode).toBe('personal');
|
|
57
|
+
expect(result.upgradeTo).toBe('family');
|
|
58
|
+
expect(result.limits.meters.storageBytes).toBe(12345);
|
|
59
|
+
// Keys the payload omitted are filled from that plan's defaults rather than
|
|
60
|
+
// arriving undefined and comparing as NaN inside a quota check.
|
|
61
|
+
expect(result.limits.meters.seats).toBe(DEFAULT_PLAN_LIMITS.personal.meters.seats);
|
|
62
|
+
expect(result.stale).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('serves the cached answer within the TTL and refetches after it', async () => {
|
|
66
|
+
jest.useFakeTimers();
|
|
67
|
+
try {
|
|
68
|
+
(global.fetch as jest.Mock).mockResolvedValue(paidResponse());
|
|
69
|
+
|
|
70
|
+
await getEntitlements('user@example.com');
|
|
71
|
+
await getEntitlements('user@example.com');
|
|
72
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
73
|
+
|
|
74
|
+
jest.advanceTimersByTime(60_001);
|
|
75
|
+
await getEntitlements('user@example.com');
|
|
76
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
77
|
+
} finally {
|
|
78
|
+
jest.useRealTimers();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('treats casing and surrounding whitespace as the same user', async () => {
|
|
83
|
+
(global.fetch as jest.Mock).mockResolvedValue(paidResponse());
|
|
84
|
+
|
|
85
|
+
await getEntitlements('user@example.com');
|
|
86
|
+
await getEntitlements(' User@Example.COM ');
|
|
87
|
+
|
|
88
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('collapses a concurrent burst for one user into a single request', async () => {
|
|
92
|
+
(global.fetch as jest.Mock).mockResolvedValue(paidResponse());
|
|
93
|
+
|
|
94
|
+
await Promise.all(Array.from({ length: 10 }, () => getEntitlements('user@example.com')));
|
|
95
|
+
|
|
96
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('drops the cached answer on invalidate', async () => {
|
|
100
|
+
(global.fetch as jest.Mock).mockResolvedValue(paidResponse());
|
|
101
|
+
|
|
102
|
+
await getEntitlements('user@example.com');
|
|
103
|
+
invalidateEntitlements('User@example.com');
|
|
104
|
+
await getEntitlements('user@example.com');
|
|
105
|
+
|
|
106
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('when payment-service cannot be reached', () => {
|
|
111
|
+
it('fails closed to free limits rather than handing out the product', async () => {
|
|
112
|
+
(global.fetch as jest.Mock).mockRejectedValue(new Error('ECONNREFUSED'));
|
|
113
|
+
|
|
114
|
+
const result = await getEntitlements('user@example.com');
|
|
115
|
+
|
|
116
|
+
expect(result.planCode).toBe('free');
|
|
117
|
+
expect(result.limits).toEqual(DEFAULT_PLAN_LIMITS.free);
|
|
118
|
+
expect(result.stale).toBe(true);
|
|
119
|
+
expect(logger.error).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('fails closed on a non-2xx answer too', async () => {
|
|
123
|
+
(global.fetch as jest.Mock).mockResolvedValue(jsonResponse({ success: false }, 500));
|
|
124
|
+
|
|
125
|
+
const result = await getEntitlements('user@example.com');
|
|
126
|
+
|
|
127
|
+
expect(result.planCode).toBe('free');
|
|
128
|
+
expect(result.stale).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('never caches a fallback, so recovery is immediate rather than a minute later', async () => {
|
|
132
|
+
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue(paidResponse());
|
|
133
|
+
|
|
134
|
+
const failed = await getEntitlements('user@example.com');
|
|
135
|
+
const recovered = await getEntitlements('user@example.com');
|
|
136
|
+
|
|
137
|
+
expect(failed.stale).toBe(true);
|
|
138
|
+
expect(recovered.planCode).toBe('personal');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('fails closed without calling out at all when the channel is unconfigured', async () => {
|
|
142
|
+
delete process.env.PAYMENT_API_URL;
|
|
143
|
+
|
|
144
|
+
const result = await getEntitlements('user@example.com');
|
|
145
|
+
|
|
146
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
147
|
+
expect(result.planCode).toBe('free');
|
|
148
|
+
expect(result.stale).toBe(true);
|
|
149
|
+
expect(logger.error).toHaveBeenCalledWith('Entitlement lookup not configured', expect.anything());
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('cache bounds', () => {
|
|
154
|
+
it('evicts the oldest entries instead of growing without limit', async () => {
|
|
155
|
+
(global.fetch as jest.Mock).mockResolvedValue(paidResponse());
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < 5_001; i++) {
|
|
158
|
+
await getEntitlements(`user${i}@example.com`);
|
|
159
|
+
}
|
|
160
|
+
const callsAfterFill = (global.fetch as jest.Mock).mock.calls.length;
|
|
161
|
+
|
|
162
|
+
// The very first user was evicted by the 5001st, so this is a refetch.
|
|
163
|
+
await getEntitlements('user0@example.com');
|
|
164
|
+
expect((global.fetch as jest.Mock).mock.calls.length).toBe(callsAfterFill + 1);
|
|
165
|
+
|
|
166
|
+
// The most recent user is still cached.
|
|
167
|
+
await getEntitlements('user5000@example.com');
|
|
168
|
+
expect((global.fetch as jest.Mock).mock.calls.length).toBe(callsAfterFill + 1);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import logger from '../logging/logger';
|
|
2
|
+
import { FALLBACK_PLAN, normalizeLimits, resolveLimits } from './definitions';
|
|
3
|
+
import { Entitlements } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Entitlements are read over the internal-service channel, not carried on the
|
|
7
|
+
* JWT.
|
|
8
|
+
*
|
|
9
|
+
* The JWT is signed for 24h and no service outside auth-service checks
|
|
10
|
+
* `tokenVersion`, so a plan embedded in it would be stale in both directions:
|
|
11
|
+
* a user who upgrades waits up to a day for what they just paid for, and a user
|
|
12
|
+
* who cancels keeps paid features for up to a day. A 60-second cache in front
|
|
13
|
+
* of a live lookup bounds that to a minute, and the enforcement points are
|
|
14
|
+
* writes and dispatches — low frequency — so the network cost is negligible.
|
|
15
|
+
*/
|
|
16
|
+
const CACHE_TTL_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
/** Bounded so a service under enumeration can't grow the cache without limit. */
|
|
19
|
+
const MAX_CACHE_ENTRIES = 5_000;
|
|
20
|
+
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
22
|
+
|
|
23
|
+
interface CacheEntry {
|
|
24
|
+
value: Entitlements;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cache = new Map<string, CacheEntry>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Concurrent callers for the same email share one request. Without this, a
|
|
32
|
+
* burst of uploads from one user on a cold cache fans out into one
|
|
33
|
+
* payment-service call each.
|
|
34
|
+
*/
|
|
35
|
+
const inFlight = new Map<string, Promise<Entitlements>>();
|
|
36
|
+
|
|
37
|
+
function cacheKey(email: string): string {
|
|
38
|
+
return email.trim().toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function putInCache(key: string, value: Entitlements): void {
|
|
42
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
43
|
+
// Map iterates in insertion order, so the first key is the oldest write.
|
|
44
|
+
const oldest = cache.keys().next();
|
|
45
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
46
|
+
}
|
|
47
|
+
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fail closed: an unreachable payment-service degrades everyone to free limits
|
|
52
|
+
* rather than handing the product out for the length of the outage. The blast
|
|
53
|
+
* radius is bounded by design — reads are never gated, only writes and
|
|
54
|
+
* dispatches — so the worst case is "cannot upload for a minute", not
|
|
55
|
+
* "cannot use the app".
|
|
56
|
+
*/
|
|
57
|
+
function fallbackEntitlements(email: string): Entitlements {
|
|
58
|
+
return {
|
|
59
|
+
email,
|
|
60
|
+
planCode: FALLBACK_PLAN,
|
|
61
|
+
status: null,
|
|
62
|
+
limits: resolveLimits(FALLBACK_PLAN),
|
|
63
|
+
stale: true
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface EntitlementResponse {
|
|
68
|
+
success?: boolean;
|
|
69
|
+
planCode?: string;
|
|
70
|
+
status?: string | null;
|
|
71
|
+
limits?: Parameters<typeof normalizeLimits>[0];
|
|
72
|
+
upgradeTo?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function fetchEntitlements(email: string): Promise<Entitlements> {
|
|
76
|
+
const baseUrl = process.env.PAYMENT_API_URL;
|
|
77
|
+
const token = process.env.INTERNAL_SERVICE_TOKEN;
|
|
78
|
+
const serviceId = process.env.SERVICE_NAME || 'unknown-service';
|
|
79
|
+
|
|
80
|
+
if (!baseUrl || !token) {
|
|
81
|
+
logger.error('Entitlement lookup not configured', {
|
|
82
|
+
hasPaymentApiUrl: Boolean(baseUrl),
|
|
83
|
+
hasInternalToken: Boolean(token)
|
|
84
|
+
});
|
|
85
|
+
return fallbackEntitlements(email);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(`${baseUrl}/internal/entitlements?email=${encodeURIComponent(email)}`, {
|
|
90
|
+
method: 'GET',
|
|
91
|
+
headers: {
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
'x-internal-token': token,
|
|
94
|
+
'x-service-id': serviceId
|
|
95
|
+
},
|
|
96
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
logger.error('Entitlement lookup failed', { email, status: response.status });
|
|
101
|
+
return fallbackEntitlements(email);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const body = (await response.json()) as EntitlementResponse;
|
|
105
|
+
const planCode = body.planCode || FALLBACK_PLAN;
|
|
106
|
+
return {
|
|
107
|
+
email,
|
|
108
|
+
planCode,
|
|
109
|
+
status: body.status ?? null,
|
|
110
|
+
// Re-normalized on this side too: the payload crossed a network boundary
|
|
111
|
+
// and a partially-configured plan must not leave a meter undefined.
|
|
112
|
+
limits: normalizeLimits(body.limits, planCode),
|
|
113
|
+
upgradeTo: body.upgradeTo
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
logger.error('Entitlement lookup errored', { email, error: (err as Error)?.message });
|
|
117
|
+
return fallbackEntitlements(email);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The user's current plan and limits, cached for {@link CACHE_TTL_MS}.
|
|
123
|
+
*
|
|
124
|
+
* Failures are never cached — a fallback result would otherwise pin a paying
|
|
125
|
+
* user to free limits for a full minute after the outage ended.
|
|
126
|
+
*/
|
|
127
|
+
export async function getEntitlements(email: string): Promise<Entitlements> {
|
|
128
|
+
const key = cacheKey(email);
|
|
129
|
+
|
|
130
|
+
const cached = cache.get(key);
|
|
131
|
+
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
|
132
|
+
|
|
133
|
+
const pending = inFlight.get(key);
|
|
134
|
+
if (pending) return pending;
|
|
135
|
+
|
|
136
|
+
const request = fetchEntitlements(key)
|
|
137
|
+
.then(value => {
|
|
138
|
+
if (!value.stale) putInCache(key, value);
|
|
139
|
+
return value;
|
|
140
|
+
})
|
|
141
|
+
.finally(() => {
|
|
142
|
+
inFlight.delete(key);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
inFlight.set(key, request);
|
|
146
|
+
return request;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Drop a user's cached entitlements. Call after anything that changes their
|
|
151
|
+
* plan (checkout completed, subscription canceled) to collapse the staleness
|
|
152
|
+
* window from 60s to zero for the user who is watching.
|
|
153
|
+
*/
|
|
154
|
+
export function invalidateEntitlements(email: string): void {
|
|
155
|
+
cache.delete(cacheKey(email));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Empties the whole cache. Test seam, and a usable lever after a bulk plan edit. */
|
|
159
|
+
export function clearEntitlementsCache(): void {
|
|
160
|
+
cache.clear();
|
|
161
|
+
inFlight.clear();
|
|
162
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_PLAN_LIMITS,
|
|
3
|
+
FALLBACK_PLAN,
|
|
4
|
+
FEATURE_KEYS,
|
|
5
|
+
METER_KEYS,
|
|
6
|
+
STAFF_PLAN,
|
|
7
|
+
UNLIMITED,
|
|
8
|
+
applyLimits,
|
|
9
|
+
fitsWithin,
|
|
10
|
+
isFeatureKey,
|
|
11
|
+
isMeterKey,
|
|
12
|
+
meterPeriod,
|
|
13
|
+
normalizeLimits,
|
|
14
|
+
resolveLimits
|
|
15
|
+
} from './definitions';
|
|
16
|
+
|
|
17
|
+
describe('resolveLimits', () => {
|
|
18
|
+
it('returns the defaults for a known plan code', () => {
|
|
19
|
+
expect(resolveLimits('personal')).toEqual(DEFAULT_PLAN_LIMITS.personal);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('falls back to free for an unknown code, never to unlimited', () => {
|
|
23
|
+
expect(resolveLimits('enterprise-typo')).toEqual(DEFAULT_PLAN_LIMITS.free);
|
|
24
|
+
expect(resolveLimits(undefined)).toEqual(DEFAULT_PLAN_LIMITS[FALLBACK_PLAN]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns a copy, so a caller mutating limits cannot poison the defaults', () => {
|
|
28
|
+
const limits = resolveLimits('free');
|
|
29
|
+
limits.meters.storageBytes = 999;
|
|
30
|
+
limits.features.cleanExport = true;
|
|
31
|
+
|
|
32
|
+
expect(resolveLimits('free').meters.storageBytes).toBe(DEFAULT_PLAN_LIMITS.free.meters.storageBytes);
|
|
33
|
+
expect(resolveLimits('free').features.cleanExport).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('normalizeLimits', () => {
|
|
38
|
+
it('applies stored values over the plan defaults', () => {
|
|
39
|
+
const limits = normalizeLimits({ meters: { storageBytes: 123 }, retentionDays: 30 }, 'personal');
|
|
40
|
+
|
|
41
|
+
expect(limits.meters.storageBytes).toBe(123);
|
|
42
|
+
expect(limits.retentionDays).toBe(30);
|
|
43
|
+
// Untouched keys keep the plan's defaults rather than resetting to free.
|
|
44
|
+
expect(limits.meters.trackedTickers).toBe(DEFAULT_PLAN_LIMITS.personal.meters.trackedTickers);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('drops keys that no code enforces', () => {
|
|
48
|
+
const limits = normalizeLimits(
|
|
49
|
+
{ meters: { storageBytes: 5, wormholes: 1000 }, features: { cleanExport: true, teleport: true } },
|
|
50
|
+
'free'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(limits.meters).not.toHaveProperty('wormholes');
|
|
54
|
+
expect(limits.features).not.toHaveProperty('teleport');
|
|
55
|
+
expect(Object.keys(limits.meters).sort()).toEqual([...METER_KEYS].sort());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('normalizes any negative ceiling to UNLIMITED and floors fractions', () => {
|
|
59
|
+
const limits = normalizeLimits({ meters: { aiJobs: -5, trackedTickers: 7.9 }, retentionDays: -2 }, 'free');
|
|
60
|
+
|
|
61
|
+
expect(limits.meters.aiJobs).toBe(UNLIMITED);
|
|
62
|
+
expect(limits.meters.trackedTickers).toBe(7);
|
|
63
|
+
expect(limits.retentionDays).toBe(UNLIMITED);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('ignores non-numeric and non-boolean junk instead of producing NaN limits', () => {
|
|
67
|
+
const limits = normalizeLimits(
|
|
68
|
+
{ meters: { seats: 'lots' as unknown as number }, features: { cleanExport: 'yes' as unknown as boolean } },
|
|
69
|
+
'family'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(limits.meters.seats).toBe(DEFAULT_PLAN_LIMITS.family.meters.seats);
|
|
73
|
+
expect(limits.features.cleanExport).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns the plan defaults for an empty or missing payload', () => {
|
|
77
|
+
expect(normalizeLimits(undefined, 'family')).toEqual(DEFAULT_PLAN_LIMITS.family);
|
|
78
|
+
expect(normalizeLimits(null, 'family')).toEqual(DEFAULT_PLAN_LIMITS.family);
|
|
79
|
+
expect(normalizeLimits({}, 'family')).toEqual(DEFAULT_PLAN_LIMITS.family);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('resolves an unknown plan code to free even when limits are stored', () => {
|
|
83
|
+
const limits = normalizeLimits({ meters: { seats: 3 } }, 'gold-tier-that-never-shipped');
|
|
84
|
+
|
|
85
|
+
expect(limits.meters.seats).toBe(3);
|
|
86
|
+
expect(limits.meters.storageBytes).toBe(DEFAULT_PLAN_LIMITS.free.meters.storageBytes);
|
|
87
|
+
expect(limits.features.cleanExport).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('applyLimits', () => {
|
|
92
|
+
it('layers a partial set over a complete one without mutating the base', () => {
|
|
93
|
+
const base = resolveLimits('free');
|
|
94
|
+
|
|
95
|
+
const layered = applyLimits(base, { meters: { storageBytes: 500 } });
|
|
96
|
+
|
|
97
|
+
expect(layered.meters.storageBytes).toBe(500);
|
|
98
|
+
expect(base.meters.storageBytes).toBe(DEFAULT_PLAN_LIMITS.free.meters.storageBytes);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('stacks: defaults, then plan, then a per-user override', () => {
|
|
102
|
+
const planLimits = normalizeLimits({ meters: { storageBytes: 100, seats: 2 } }, 'free');
|
|
103
|
+
|
|
104
|
+
const withOverride = applyLimits(planLimits, { meters: { storageBytes: 5000 }, features: { cleanExport: true } });
|
|
105
|
+
|
|
106
|
+
expect(withOverride.meters.storageBytes).toBe(5000); // override wins
|
|
107
|
+
expect(withOverride.meters.seats).toBe(2); // plan wins where the override is silent
|
|
108
|
+
expect(withOverride.meters.aiJobs).toBe(DEFAULT_PLAN_LIMITS.free.meters.aiJobs); // default underneath both
|
|
109
|
+
expect(withOverride.features.cleanExport).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('the staff plan', () => {
|
|
114
|
+
it('grants every meter and feature without limit', () => {
|
|
115
|
+
const limits = resolveLimits(STAFF_PLAN);
|
|
116
|
+
|
|
117
|
+
for (const meter of METER_KEYS) {
|
|
118
|
+
expect(limits.meters[meter]).toBe(UNLIMITED);
|
|
119
|
+
}
|
|
120
|
+
expect(limits.retentionDays).toBe(UNLIMITED);
|
|
121
|
+
expect(limits.features.cleanExport).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('fitsWithin', () => {
|
|
126
|
+
it('treats UNLIMITED as always fitting', () => {
|
|
127
|
+
expect(fitsWithin(UNLIMITED, Number.MAX_SAFE_INTEGER, 1)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('allows landing exactly on the limit but not passing it', () => {
|
|
131
|
+
expect(fitsWithin(10, 9, 1)).toBe(true);
|
|
132
|
+
expect(fitsWithin(10, 10, 1)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('key registry', () => {
|
|
137
|
+
it('recognizes only registered keys', () => {
|
|
138
|
+
expect(isMeterKey('storageBytes')).toBe(true);
|
|
139
|
+
expect(isMeterKey('storagebytes')).toBe(false);
|
|
140
|
+
expect(isFeatureKey('cleanExport')).toBe(true);
|
|
141
|
+
expect(isFeatureKey('planner')).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('assigns every meter a period', () => {
|
|
145
|
+
expect(meterPeriod('aiJobs')).toBe('MONTH');
|
|
146
|
+
expect(meterPeriod('storageBytes')).toBe('ALL');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('gives every registered plan a value for every registered key', () => {
|
|
150
|
+
for (const [code, limits] of Object.entries(DEFAULT_PLAN_LIMITS)) {
|
|
151
|
+
for (const meter of METER_KEYS) {
|
|
152
|
+
expect(typeof limits.meters[meter]).toBe(`number`);
|
|
153
|
+
}
|
|
154
|
+
for (const feature of FEATURE_KEYS) {
|
|
155
|
+
expect(typeof limits.features[feature]).toBe('boolean');
|
|
156
|
+
}
|
|
157
|
+
expect(typeof limits.retentionDays).toBe(`number`);
|
|
158
|
+
expect(code).toBeTruthy();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|