@tumbaland/backend-core 1.22.0 → 1.24.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/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/corsMiddleware.d.ts.map +1 -1
- package/dist/middleware/corsMiddleware.js +5 -1
- package/dist/middleware/corsMiddleware.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/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/corsMiddleware.test.ts +38 -0
- package/src/middleware/corsMiddleware.ts +5 -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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entitlement *vocabulary*: which meters and features exist, what they
|
|
3
|
+
* mean, and what a plan gets when nothing else says otherwise.
|
|
4
|
+
*
|
|
5
|
+
* The actual per-plan numbers are NOT here — they live on the `Plan` document
|
|
6
|
+
* in Mongo and are editable by an admin at runtime (see payment-service's
|
|
7
|
+
* `Plan.limits` and `PUT /api/plans/:code/limits`). Shipping a code change to
|
|
8
|
+
* move a limit from 50 GB to 75 GB would be absurd, and a hardcoded table
|
|
9
|
+
* would immediately drift from what the pricing page renders.
|
|
10
|
+
*
|
|
11
|
+
* What code still owns:
|
|
12
|
+
* - the set of valid keys, so a typo in an admin form can't invent a meter
|
|
13
|
+
* that nothing enforces (`normalizeLimits` drops unknown keys);
|
|
14
|
+
* - `DEFAULT_PLAN_LIMITS`, the values used when a Plan document has no
|
|
15
|
+
* `limits` yet (fresh install, newly created plan) or when payment-service
|
|
16
|
+
* is unreachable and the client has to fail closed;
|
|
17
|
+
* - `ENTITLEMENT_CATALOG`, the metadata an admin UI renders its form from,
|
|
18
|
+
* so adding a meter needs no frontend change.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type MeterKey = 'storageBytes' | 'aiJobs' | 'trackedTickers' | 'seats';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Deliberately small. The portfolio planner is NOT a feature flag — it is gated
|
|
25
|
+
* by `retentionDays` via the price-history range, because the planner runs
|
|
26
|
+
* entirely client-side and a boolean flag would be bypassable from devtools.
|
|
27
|
+
*/
|
|
28
|
+
export type FeatureKey = 'cleanExport';
|
|
29
|
+
|
|
30
|
+
/** `ALL` = counts up forever (storage, seats). `MONTH` = resets each calendar month (UTC). */
|
|
31
|
+
export type MeterPeriod = 'ALL' | 'MONTH';
|
|
32
|
+
|
|
33
|
+
/** Sentinel for "no ceiling". Chosen over `Infinity` because it survives JSON and BSON. */
|
|
34
|
+
export const UNLIMITED = -1;
|
|
35
|
+
|
|
36
|
+
export interface MeterDefinition {
|
|
37
|
+
key: MeterKey;
|
|
38
|
+
label: string;
|
|
39
|
+
/** Drives formatting in the admin form and in `<UsageMeter>`: bytes get humanized, counts don't. */
|
|
40
|
+
unit: 'bytes' | 'count';
|
|
41
|
+
period: MeterPeriod;
|
|
42
|
+
description: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FeatureDefinition {
|
|
46
|
+
key: FeatureKey;
|
|
47
|
+
label: string;
|
|
48
|
+
description: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const METER_DEFINITIONS: readonly MeterDefinition[] = [
|
|
52
|
+
{
|
|
53
|
+
key: 'storageBytes',
|
|
54
|
+
label: 'Storage',
|
|
55
|
+
unit: 'bytes',
|
|
56
|
+
period: 'ALL',
|
|
57
|
+
description: 'Total bytes of uploaded photos and files held for the user.'
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: 'aiJobs',
|
|
61
|
+
label: 'AI analyses',
|
|
62
|
+
unit: 'count',
|
|
63
|
+
period: 'MONTH',
|
|
64
|
+
description: 'Segmentation and object-detection jobs dispatched per calendar month.'
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: 'trackedTickers',
|
|
68
|
+
label: 'Tracked tickers',
|
|
69
|
+
unit: 'count',
|
|
70
|
+
period: 'ALL',
|
|
71
|
+
description: 'Instruments followed for price history. Each one spends the deployment-wide market-data budget.'
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: 'seats',
|
|
75
|
+
label: 'Group seats',
|
|
76
|
+
unit: 'count',
|
|
77
|
+
period: 'ALL',
|
|
78
|
+
description: 'Members that may join groups owned by the user, including the owner.'
|
|
79
|
+
}
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
export const FEATURE_DEFINITIONS: readonly FeatureDefinition[] = [
|
|
83
|
+
{
|
|
84
|
+
key: 'cleanExport',
|
|
85
|
+
label: 'Clean export',
|
|
86
|
+
description: 'Exports and downloads without a watermark.'
|
|
87
|
+
}
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
export const METER_KEYS: readonly MeterKey[] = METER_DEFINITIONS.map(m => m.key);
|
|
91
|
+
export const FEATURE_KEYS: readonly FeatureKey[] = FEATURE_DEFINITIONS.map(f => f.key);
|
|
92
|
+
|
|
93
|
+
const METER_PERIODS = Object.fromEntries(
|
|
94
|
+
METER_DEFINITIONS.map(m => [m.key, m.period])
|
|
95
|
+
) as Record<MeterKey, MeterPeriod>;
|
|
96
|
+
|
|
97
|
+
export function meterPeriod(meter: MeterKey): MeterPeriod {
|
|
98
|
+
return METER_PERIODS[meter] ?? 'ALL';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isMeterKey(value: string): value is MeterKey {
|
|
102
|
+
return (METER_KEYS as readonly string[]).includes(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isFeatureKey(value: string): value is FeatureKey {
|
|
106
|
+
return (FEATURE_KEYS as readonly string[]).includes(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface PlanLimits {
|
|
110
|
+
/** `-1` (UNLIMITED) means no ceiling. */
|
|
111
|
+
meters: Record<MeterKey, number>;
|
|
112
|
+
features: Record<FeatureKey, boolean>;
|
|
113
|
+
/** How far back reads may reach. `-1` (UNLIMITED) means the full history. */
|
|
114
|
+
retentionDays: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What an admin form or an API caller may send: any subset, in any order. */
|
|
118
|
+
export interface PartialPlanLimits {
|
|
119
|
+
meters?: Partial<Record<string, number>>;
|
|
120
|
+
features?: Partial<Record<string, boolean>>;
|
|
121
|
+
retentionDays?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const GB = 1024 * 1024 * 1024;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Bootstrap values only — a Plan document's own `limits` wins over anything
|
|
128
|
+
* here. These are what a plan gets before an admin has ever touched it, and
|
|
129
|
+
* what the entitlement client falls back to when payment-service is down.
|
|
130
|
+
*
|
|
131
|
+
* Keep `free` conservative for exactly that reason: an outage degrades paying
|
|
132
|
+
* users to these numbers, and the alternative (fail-open) gives the product
|
|
133
|
+
* away for the length of the outage.
|
|
134
|
+
*/
|
|
135
|
+
export const DEFAULT_PLAN_LIMITS: Readonly<Record<string, PlanLimits>> = {
|
|
136
|
+
free: {
|
|
137
|
+
meters: { storageBytes: 1 * GB, aiJobs: 25, trackedTickers: 3, seats: 1 },
|
|
138
|
+
features: { cleanExport: false },
|
|
139
|
+
retentionDays: 90
|
|
140
|
+
},
|
|
141
|
+
personal: {
|
|
142
|
+
meters: { storageBytes: 50 * GB, aiJobs: 200, trackedTickers: 25, seats: 1 },
|
|
143
|
+
features: { cleanExport: true },
|
|
144
|
+
retentionDays: UNLIMITED
|
|
145
|
+
},
|
|
146
|
+
family: {
|
|
147
|
+
meters: { storageBytes: 250 * GB, aiJobs: 600, trackedTickers: 100, seats: 5 },
|
|
148
|
+
features: { cleanExport: true },
|
|
149
|
+
retentionDays: UNLIMITED
|
|
150
|
+
},
|
|
151
|
+
/**
|
|
152
|
+
* Everything unlimited, for the maintainers and for comped friends. Held as a
|
|
153
|
+
* real plan with a real subscription rather than an exemption branch in the
|
|
154
|
+
* code, so the people running the product go through the identical
|
|
155
|
+
* enforcement path as a paying customer — an `if (isStaff) skipEverything`
|
|
156
|
+
* flag would mean the paywall is never exercised by anyone who could notice
|
|
157
|
+
* it was broken.
|
|
158
|
+
*
|
|
159
|
+
* Kept off the public catalog through `Plan.isPublic = false`.
|
|
160
|
+
*/
|
|
161
|
+
staff: {
|
|
162
|
+
meters: { storageBytes: UNLIMITED, aiJobs: UNLIMITED, trackedTickers: UNLIMITED, seats: UNLIMITED },
|
|
163
|
+
features: { cleanExport: true },
|
|
164
|
+
retentionDays: UNLIMITED
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** Plan code the seeded staff/comp plan uses. */
|
|
169
|
+
export const STAFF_PLAN = 'staff';
|
|
170
|
+
|
|
171
|
+
export const FALLBACK_PLAN = 'free';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Code defaults for a plan code, with unknown codes resolving to free.
|
|
175
|
+
*
|
|
176
|
+
* That fallback is deliberate: a typo'd or newly-added plan code must never
|
|
177
|
+
* silently grant unlimited. Callers that want the admin-configured numbers
|
|
178
|
+
* read them from the Plan document and pass them through `normalizeLimits`;
|
|
179
|
+
* this function is the floor underneath that.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveLimits(planCode?: string): PlanLimits {
|
|
182
|
+
const limits = DEFAULT_PLAN_LIMITS[planCode ?? FALLBACK_PLAN] ?? DEFAULT_PLAN_LIMITS[FALLBACK_PLAN];
|
|
183
|
+
return cloneLimits(limits);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function cloneLimits(limits: PlanLimits): PlanLimits {
|
|
187
|
+
return {
|
|
188
|
+
meters: { ...limits.meters },
|
|
189
|
+
features: { ...limits.features },
|
|
190
|
+
retentionDays: limits.retentionDays
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeCeiling(value: unknown, fallback: number): number {
|
|
195
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
196
|
+
// Anything negative means "unlimited"; nothing else is a meaningful ceiling.
|
|
197
|
+
if (value < 0) return UNLIMITED;
|
|
198
|
+
return Math.floor(value);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Layers a partial set of limits over a complete one.
|
|
203
|
+
*
|
|
204
|
+
* Entitlements resolve through three layers, each one partial and each one
|
|
205
|
+
* overriding the last: code defaults → the Plan document's `limits` → a
|
|
206
|
+
* per-user `EntitlementOverride`. This is the single step all three share.
|
|
207
|
+
*
|
|
208
|
+
* - unknown meter/feature keys are dropped — no store can invent enforcement;
|
|
209
|
+
* - missing keys keep the base value, so adding a new meter in code works
|
|
210
|
+
* everywhere before any stored document mentions it;
|
|
211
|
+
* - negative numbers normalize to `UNLIMITED`, fractions floor.
|
|
212
|
+
*/
|
|
213
|
+
export function applyLimits(base: PlanLimits, input?: PartialPlanLimits | null): PlanLimits {
|
|
214
|
+
const result = cloneLimits(base);
|
|
215
|
+
if (!input) return result;
|
|
216
|
+
|
|
217
|
+
for (const key of METER_KEYS) {
|
|
218
|
+
result.meters[key] = normalizeCeiling(input.meters?.[key], result.meters[key]);
|
|
219
|
+
}
|
|
220
|
+
for (const key of FEATURE_KEYS) {
|
|
221
|
+
const value = input.features?.[key];
|
|
222
|
+
result.features[key] = typeof value === 'boolean' ? value : result.features[key];
|
|
223
|
+
}
|
|
224
|
+
result.retentionDays = normalizeCeiling(input.retentionDays, result.retentionDays);
|
|
225
|
+
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Turns whatever is stored on a Plan document (or arrived from an admin form)
|
|
231
|
+
* into a complete, valid `PlanLimits`, filling the gaps from that plan code's
|
|
232
|
+
* code defaults.
|
|
233
|
+
*/
|
|
234
|
+
export function normalizeLimits(input?: PartialPlanLimits | null, planCode?: string): PlanLimits {
|
|
235
|
+
return applyLimits(resolveLimits(planCode), input);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** True when `used + amount` fits under `limit`. `UNLIMITED` always fits. */
|
|
239
|
+
export function fitsWithin(limit: number, used: number, amount = 0): boolean {
|
|
240
|
+
if (limit === UNLIMITED || limit < 0) return true;
|
|
241
|
+
return used + amount <= limit;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Everything an admin UI needs to render a limits editor without hardcoding a
|
|
246
|
+
* single meter name — new keys appear in the form as soon as they exist here.
|
|
247
|
+
*/
|
|
248
|
+
export const ENTITLEMENT_CATALOG = {
|
|
249
|
+
unlimited: UNLIMITED,
|
|
250
|
+
meters: METER_DEFINITIONS,
|
|
251
|
+
features: FEATURE_DEFINITIONS,
|
|
252
|
+
retentionDays: {
|
|
253
|
+
key: 'retentionDays' as const,
|
|
254
|
+
label: 'History retention (days)',
|
|
255
|
+
unit: 'days' as const,
|
|
256
|
+
description:
|
|
257
|
+
'How far back reads may reach. Gates relationship history and, through the price-history range, the portfolio planner.'
|
|
258
|
+
},
|
|
259
|
+
defaults: DEFAULT_PLAN_LIMITS,
|
|
260
|
+
fallbackPlan: FALLBACK_PLAN
|
|
261
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export {
|
|
2
|
+
UNLIMITED,
|
|
3
|
+
FALLBACK_PLAN,
|
|
4
|
+
STAFF_PLAN,
|
|
5
|
+
applyLimits,
|
|
6
|
+
METER_KEYS,
|
|
7
|
+
FEATURE_KEYS,
|
|
8
|
+
METER_DEFINITIONS,
|
|
9
|
+
FEATURE_DEFINITIONS,
|
|
10
|
+
DEFAULT_PLAN_LIMITS,
|
|
11
|
+
ENTITLEMENT_CATALOG,
|
|
12
|
+
resolveLimits,
|
|
13
|
+
normalizeLimits,
|
|
14
|
+
meterPeriod,
|
|
15
|
+
isMeterKey,
|
|
16
|
+
isFeatureKey,
|
|
17
|
+
fitsWithin
|
|
18
|
+
} from './definitions';
|
|
19
|
+
export type {
|
|
20
|
+
MeterKey,
|
|
21
|
+
FeatureKey,
|
|
22
|
+
MeterPeriod,
|
|
23
|
+
MeterDefinition,
|
|
24
|
+
FeatureDefinition,
|
|
25
|
+
PlanLimits,
|
|
26
|
+
PartialPlanLimits
|
|
27
|
+
} from './definitions';
|
|
28
|
+
|
|
29
|
+
export type { Entitlements, EntitlementsMode } from './types';
|
|
30
|
+
export { getEntitlementsMode, resetEntitlementsModeWarning } from './mode';
|
|
31
|
+
|
|
32
|
+
export { UsageMeter, currentPeriod } from './UsageMeter';
|
|
33
|
+
export type { IUsageMeter } from './UsageMeter';
|
|
34
|
+
export { getUsage, getUsageSnapshot, checkQuota, consumeQuota, releaseQuota, setUsage } from './usage';
|
|
35
|
+
export type { QuotaRequest } from './usage';
|
|
36
|
+
|
|
37
|
+
export { getEntitlements, invalidateEntitlements, clearEntitlementsCache } from './client';
|
|
38
|
+
|
|
39
|
+
export { loadEntitlements, requireEntitlement, requireQuota, retentionFloor } from './middleware';
|
|
40
|
+
export type { EntitlementOptions, QuotaOptions, SubjectResolver } from './middleware';
|