@tumbaland/backend-core 1.25.0 → 1.26.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.
Files changed (59) hide show
  1. package/dist/entitlements/client.d.ts.map +1 -1
  2. package/dist/entitlements/client.js +17 -1
  3. package/dist/entitlements/client.js.map +1 -1
  4. package/dist/entitlements/index.d.ts +3 -2
  5. package/dist/entitlements/index.d.ts.map +1 -1
  6. package/dist/entitlements/index.js +5 -4
  7. package/dist/entitlements/index.js.map +1 -1
  8. package/dist/entitlements/middleware.d.ts.map +1 -1
  9. package/dist/entitlements/middleware.js +8 -19
  10. package/dist/entitlements/middleware.js.map +1 -1
  11. package/dist/entitlements/reconcile.d.ts +74 -0
  12. package/dist/entitlements/reconcile.d.ts.map +1 -0
  13. package/dist/entitlements/reconcile.js +297 -0
  14. package/dist/entitlements/reconcile.js.map +1 -0
  15. package/dist/entitlements/types.d.ts +0 -8
  16. package/dist/entitlements/types.d.ts.map +1 -1
  17. package/dist/entitlements/usage.d.ts +1 -1
  18. package/dist/entitlements/usage.d.ts.map +1 -1
  19. package/dist/entitlements/usage.js +11 -20
  20. package/dist/entitlements/usage.js.map +1 -1
  21. package/dist/groups/client.d.ts +27 -0
  22. package/dist/groups/client.d.ts.map +1 -0
  23. package/dist/groups/client.js +153 -0
  24. package/dist/groups/client.js.map +1 -0
  25. package/dist/groups/index.d.ts +5 -0
  26. package/dist/groups/index.d.ts.map +1 -0
  27. package/dist/groups/index.js +10 -0
  28. package/dist/groups/index.js.map +1 -0
  29. package/dist/groups/subject.d.ts +30 -0
  30. package/dist/groups/subject.d.ts.map +1 -0
  31. package/dist/groups/subject.js +52 -0
  32. package/dist/groups/subject.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/entitlements/client.test.ts +30 -0
  39. package/src/entitlements/client.ts +18 -1
  40. package/src/entitlements/index.ts +4 -2
  41. package/src/entitlements/middleware.test.ts +9 -30
  42. package/src/entitlements/middleware.ts +8 -19
  43. package/src/entitlements/reconcile.test.ts +333 -0
  44. package/src/entitlements/reconcile.ts +384 -0
  45. package/src/entitlements/types.ts +0 -9
  46. package/src/entitlements/usage.test.ts +27 -31
  47. package/src/entitlements/usage.ts +12 -22
  48. package/src/groups/client.test.ts +215 -0
  49. package/src/groups/client.ts +182 -0
  50. package/src/groups/index.ts +5 -0
  51. package/src/groups/subject.test.ts +85 -0
  52. package/src/groups/subject.ts +50 -0
  53. package/src/index.ts +3 -0
  54. package/dist/entitlements/mode.d.ts +0 -10
  55. package/dist/entitlements/mode.d.ts.map +0 -1
  56. package/dist/entitlements/mode.js +0 -32
  57. package/dist/entitlements/mode.js.map +0 -1
  58. package/src/entitlements/mode.test.ts +0 -50
  59. package/src/entitlements/mode.ts +0 -28
@@ -0,0 +1,215 @@
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 { clearGroupOwnerCache, getGroupOwner, invalidateGroupOwner } from './client';
8
+
9
+ const ORIGINAL_ENV = process.env;
10
+ const ORIGINAL_FETCH = global.fetch;
11
+
12
+ function jsonResponse(body: unknown, status = 200): Response {
13
+ return { ok: status >= 200 && status < 300, status, json: async () => body } as Response;
14
+ }
15
+
16
+ function ownerResponse(overrides: Record<string, unknown> = {}) {
17
+ return jsonResponse({
18
+ success: true,
19
+ groupId: 'group-1',
20
+ ownerEmail: 'Owner@Example.com',
21
+ memberCount: 3,
22
+ deleted: false,
23
+ ...overrides
24
+ });
25
+ }
26
+
27
+ beforeEach(() => {
28
+ process.env = {
29
+ ...ORIGINAL_ENV,
30
+ GROUP_API_URL: 'http://group-service:5006',
31
+ INTERNAL_SERVICE_TOKEN: 'test-internal-token',
32
+ SERVICE_NAME: 'album-service'
33
+ };
34
+ clearGroupOwnerCache();
35
+ global.fetch = jest.fn();
36
+ (logger.error as jest.Mock).mockClear();
37
+ (logger.warn as jest.Mock).mockClear();
38
+ });
39
+
40
+ afterAll(() => {
41
+ process.env = ORIGINAL_ENV;
42
+ global.fetch = ORIGINAL_FETCH;
43
+ });
44
+
45
+ describe('getGroupOwner', () => {
46
+ it('calls the internal endpoint with the service token and normalizes the owner', async () => {
47
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
48
+
49
+ const result = await getGroupOwner('group-1');
50
+
51
+ expect(global.fetch).toHaveBeenCalledWith(
52
+ 'http://group-service:5006/internal/groups/group-1/owner',
53
+ expect.objectContaining({
54
+ headers: expect.objectContaining({ 'x-internal-token': 'test-internal-token', 'x-service-id': 'album-service' })
55
+ })
56
+ );
57
+ // Lowercased on arrival: it becomes a UsageMeter subjectId, which is
58
+ // lowercase, so an owner recorded with capitals would open a second meter.
59
+ expect(result?.ownerEmail).toBe('owner@example.com');
60
+ expect(result?.memberCount).toBe(3);
61
+ expect(result?.deleted).toBe(false);
62
+ });
63
+
64
+ it('resolves a soft-deleted group rather than dropping its storage', async () => {
65
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse({ deleted: true }));
66
+
67
+ const result = await getGroupOwner('group-1');
68
+
69
+ expect(result?.ownerEmail).toBe('owner@example.com');
70
+ expect(result?.deleted).toBe(true);
71
+ });
72
+
73
+ it('serves the cached answer within the TTL and refetches after it', async () => {
74
+ jest.useFakeTimers();
75
+ try {
76
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
77
+
78
+ await getGroupOwner('group-1');
79
+ await getGroupOwner('group-1');
80
+ expect(global.fetch).toHaveBeenCalledTimes(1);
81
+
82
+ jest.advanceTimersByTime(60_001);
83
+ await getGroupOwner('group-1');
84
+ expect(global.fetch).toHaveBeenCalledTimes(2);
85
+ } finally {
86
+ jest.useRealTimers();
87
+ }
88
+ });
89
+
90
+ it('collapses a concurrent burst for one group into a single request', async () => {
91
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
92
+
93
+ await Promise.all(Array.from({ length: 10 }, () => getGroupOwner('group-1')));
94
+
95
+ expect(global.fetch).toHaveBeenCalledTimes(1);
96
+ });
97
+
98
+ it('drops the cached owner on invalidate, so a transferred group rebills at once', async () => {
99
+ (global.fetch as jest.Mock)
100
+ .mockResolvedValueOnce(ownerResponse())
101
+ .mockResolvedValue(ownerResponse({ ownerEmail: 'new-owner@example.com' }));
102
+
103
+ const before = await getGroupOwner('group-1');
104
+ invalidateGroupOwner('group-1');
105
+ const after = await getGroupOwner('group-1');
106
+
107
+ expect(before?.ownerEmail).toBe('owner@example.com');
108
+ expect(after?.ownerEmail).toBe('new-owner@example.com');
109
+ });
110
+
111
+ it('returns null without calling out on a blank group id', async () => {
112
+ expect(await getGroupOwner(' ')).toBeNull();
113
+ expect(global.fetch).not.toHaveBeenCalled();
114
+ });
115
+ });
116
+
117
+ describe('when the group cannot be resolved', () => {
118
+ it('returns null on a 404 and warns rather than erroring', async () => {
119
+ (global.fetch as jest.Mock).mockResolvedValue(jsonResponse({ success: false }, 404));
120
+
121
+ expect(await getGroupOwner('missing')).toBeNull();
122
+ expect(logger.warn).toHaveBeenCalled();
123
+ expect(logger.error).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('returns null and errors when group-service is unreachable', async () => {
127
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('ECONNREFUSED'));
128
+
129
+ expect(await getGroupOwner('group-1')).toBeNull();
130
+ expect(logger.error).toHaveBeenCalled();
131
+ });
132
+
133
+ it('returns null when the answer carries no owner', async () => {
134
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse({ ownerEmail: undefined }));
135
+
136
+ expect(await getGroupOwner('group-1')).toBeNull();
137
+ expect(logger.error).toHaveBeenCalled();
138
+ });
139
+
140
+ it('returns null without calling out at all when the channel is unconfigured', async () => {
141
+ delete process.env.GROUP_API_URL;
142
+
143
+ expect(await getGroupOwner('group-1')).toBeNull();
144
+ expect(global.fetch).not.toHaveBeenCalled();
145
+ expect(logger.error).toHaveBeenCalledWith('Group owner lookup not configured', expect.anything());
146
+ });
147
+ });
148
+
149
+ describe('base URL selection', () => {
150
+ it('prefers the container-network address over the public hostname', async () => {
151
+ process.env.GROUP_INTERNAL_URL = 'http://group-service:5006';
152
+ process.env.GROUP_API_URL = 'https://group-api.tumbaland.eu';
153
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
154
+
155
+ await getGroupOwner('group-1');
156
+
157
+ // The public variable is what the frontends hand to the browser, so it can
158
+ // never be pointed inward — this is the server-side half of that split.
159
+ expect(global.fetch).toHaveBeenCalledWith(
160
+ 'http://group-service:5006/internal/groups/group-1/owner',
161
+ expect.anything()
162
+ );
163
+ });
164
+
165
+ it('falls back to the public URL, so local dev and un-migrated deploys still work', async () => {
166
+ delete process.env.GROUP_INTERNAL_URL;
167
+ process.env.GROUP_API_URL = 'https://group-api.tumbaland.eu';
168
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
169
+
170
+ await getGroupOwner('group-1');
171
+
172
+ expect(global.fetch).toHaveBeenCalledWith(
173
+ 'https://group-api.tumbaland.eu/internal/groups/group-1/owner',
174
+ expect.anything()
175
+ );
176
+ });
177
+
178
+ it('never caches a failure, so recovery is immediate rather than a minute later', async () => {
179
+ (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue(ownerResponse());
180
+
181
+ const failed = await getGroupOwner('group-1');
182
+ const recovered = await getGroupOwner('group-1');
183
+
184
+ expect(failed).toBeNull();
185
+ expect(recovered?.ownerEmail).toBe('owner@example.com');
186
+ });
187
+
188
+ it('never caches a 404 either — a bad id must not spend a cache slot', async () => {
189
+ (global.fetch as jest.Mock).mockResolvedValue(jsonResponse({ success: false }, 404));
190
+
191
+ await getGroupOwner('missing');
192
+ await getGroupOwner('missing');
193
+
194
+ expect(global.fetch).toHaveBeenCalledTimes(2);
195
+ });
196
+ });
197
+
198
+ describe('cache bounds', () => {
199
+ it('evicts the oldest entries instead of growing without limit', async () => {
200
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
201
+
202
+ for (let i = 0; i < 5_001; i++) {
203
+ await getGroupOwner(`group-${i}`);
204
+ }
205
+ const callsAfterFill = (global.fetch as jest.Mock).mock.calls.length;
206
+
207
+ // The very first group was evicted by the 5001st, so this is a refetch.
208
+ await getGroupOwner('group-0');
209
+ expect((global.fetch as jest.Mock).mock.calls.length).toBe(callsAfterFill + 1);
210
+
211
+ // The most recent group is still cached.
212
+ await getGroupOwner('group-5000');
213
+ expect((global.fetch as jest.Mock).mock.calls.length).toBe(callsAfterFill + 1);
214
+ });
215
+ });
@@ -0,0 +1,182 @@
1
+ import logger from '../logging/logger';
2
+
3
+ /**
4
+ * Who a group's pooled usage bills to, read over the internal-service channel.
5
+ *
6
+ * The JWT carries `groups` — the ids a user belongs to — but never says who
7
+ * owns them, and no service outside group-service has the collection. So a
8
+ * chokepoint that needs the *quota subject* for a group album or a group ticker
9
+ * has to ask. See MONETIZATION.md's note on why that subject is the owner and
10
+ * not the uploader.
11
+ *
12
+ * Same 60s TTL as the entitlement client, for a weaker reason: ownership moves
13
+ * far more rarely than a plan does, so the cache could be much longer, but a
14
+ * stale owner bills the wrong person and one shared number is one fewer thing
15
+ * to reason about. The enforcement points are writes, so the traffic is low
16
+ * either way.
17
+ */
18
+ const CACHE_TTL_MS = 60_000;
19
+
20
+ /** Bounded so a service under enumeration can't grow the cache without limit. */
21
+ const MAX_CACHE_ENTRIES = 5_000;
22
+
23
+ const REQUEST_TIMEOUT_MS = 3_000;
24
+
25
+ export interface GroupOwner {
26
+ groupId: string;
27
+ ownerEmail: string;
28
+ memberCount: number;
29
+ /** Soft-deleted groups still resolve: their photos still occupy storage. */
30
+ deleted: boolean;
31
+ /** True when group-service could not be reached and nothing was resolved. */
32
+ stale?: boolean;
33
+ }
34
+
35
+ interface CacheEntry {
36
+ value: GroupOwner;
37
+ expiresAt: number;
38
+ }
39
+
40
+ const cache = new Map<string, CacheEntry>();
41
+
42
+ /**
43
+ * Concurrent callers for one group share a request — a burst of uploads into
44
+ * the same shared album is the expected shape of this traffic.
45
+ */
46
+ const inFlight = new Map<string, Promise<GroupOwner | null>>();
47
+
48
+ function putInCache(groupId: string, value: GroupOwner): void {
49
+ if (cache.size >= MAX_CACHE_ENTRIES) {
50
+ // Map iterates in insertion order, so the first key is the oldest write.
51
+ const oldest = cache.keys().next();
52
+ if (!oldest.done) cache.delete(oldest.value);
53
+ }
54
+ cache.set(groupId, { value, expiresAt: Date.now() + CACHE_TTL_MS });
55
+ }
56
+
57
+ interface GroupOwnerResponse {
58
+ success?: boolean;
59
+ groupId?: string;
60
+ ownerEmail?: string;
61
+ memberCount?: number;
62
+ deleted?: boolean;
63
+ }
64
+
65
+ /**
66
+ * Prefers the container-network address over the public hostname.
67
+ *
68
+ * `GROUP_API_URL` cannot simply be pointed inward: the frontend containers
69
+ * template that same variable into the `config.json` the *browser* downloads,
70
+ * so a Docker-internal value there breaks every group call in the UI. One
71
+ * variable genuinely has two consumers with incompatible needs, so this is the
72
+ * server-side half of it.
73
+ *
74
+ * Falling back to the public URL keeps local dev (where only `GROUP_API_URL` is
75
+ * set) and any un-migrated deployment working — it just takes the long way
76
+ * round, out through the proxy and back, carrying the internal token across the
77
+ * public edge.
78
+ */
79
+ function internalGroupUrl(): string | undefined {
80
+ return process.env.GROUP_INTERNAL_URL || process.env.GROUP_API_URL;
81
+ }
82
+
83
+ async function fetchGroupOwner(groupId: string): Promise<GroupOwner | null> {
84
+ const baseUrl = internalGroupUrl();
85
+ const token = process.env.INTERNAL_SERVICE_TOKEN;
86
+ const serviceId = process.env.SERVICE_NAME || 'unknown-service';
87
+
88
+ if (!baseUrl || !token) {
89
+ logger.error('Group owner lookup not configured', {
90
+ hasGroupApiUrl: Boolean(baseUrl),
91
+ hasInternalToken: Boolean(token)
92
+ });
93
+ return null;
94
+ }
95
+
96
+ try {
97
+ const response = await fetch(`${baseUrl}/internal/groups/${encodeURIComponent(groupId)}/owner`, {
98
+ method: 'GET',
99
+ headers: {
100
+ 'Content-Type': 'application/json',
101
+ 'x-internal-token': token,
102
+ 'x-service-id': serviceId
103
+ },
104
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
105
+ });
106
+
107
+ // A 404 is an answer, not an outage: the group is gone. Distinguished from
108
+ // an unreachable service because the two want opposite handling — one is
109
+ // permanent and cacheable, the other must be retried on the next request.
110
+ if (response.status === 404) {
111
+ logger.warn('Group owner lookup found no such group', { groupId });
112
+ return null;
113
+ }
114
+
115
+ if (!response.ok) {
116
+ logger.error('Group owner lookup failed', { groupId, status: response.status });
117
+ return null;
118
+ }
119
+
120
+ const body = (await response.json()) as GroupOwnerResponse;
121
+ if (!body.ownerEmail) {
122
+ logger.error('Group owner lookup returned no owner', { groupId });
123
+ return null;
124
+ }
125
+
126
+ return {
127
+ groupId: body.groupId ?? groupId,
128
+ ownerEmail: body.ownerEmail.trim().toLowerCase(),
129
+ memberCount: body.memberCount ?? 0,
130
+ deleted: body.deleted === true
131
+ };
132
+ } catch (err) {
133
+ logger.error('Group owner lookup errored', { groupId, error: (err as Error)?.message });
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The group's owner, cached for {@link CACHE_TTL_MS}, or `null` when the group
140
+ * does not exist or group-service could not answer.
141
+ *
142
+ * Nothing negative is cached — neither a missing group nor a failure. A group
143
+ * that 404s is nearly always a caller passing a bad id, so caching it would
144
+ * spend memory on garbage, and caching a failure would keep billing the wrong
145
+ * subject for a minute after the outage ended.
146
+ */
147
+ export async function getGroupOwner(groupId: string): Promise<GroupOwner | null> {
148
+ const key = String(groupId).trim();
149
+ if (!key) return null;
150
+
151
+ const cached = cache.get(key);
152
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
153
+
154
+ const pending = inFlight.get(key);
155
+ if (pending) return pending;
156
+
157
+ const request = fetchGroupOwner(key)
158
+ .then(value => {
159
+ if (value) putInCache(key, value);
160
+ return value;
161
+ })
162
+ .finally(() => {
163
+ inFlight.delete(key);
164
+ });
165
+
166
+ inFlight.set(key, request);
167
+ return request;
168
+ }
169
+
170
+ /**
171
+ * Drop a group's cached owner. Call after anything that moves the `owner` role,
172
+ * to collapse the window in which usage bills to the previous owner.
173
+ */
174
+ export function invalidateGroupOwner(groupId: string): void {
175
+ cache.delete(String(groupId).trim());
176
+ }
177
+
178
+ /** Empties the whole cache. Test seam, and a lever after a bulk ownership edit. */
179
+ export function clearGroupOwnerCache(): void {
180
+ cache.clear();
181
+ inFlight.clear();
182
+ }
@@ -0,0 +1,5 @@
1
+ export { getGroupOwner, invalidateGroupOwner, clearGroupOwnerCache } from './client';
2
+ export type { GroupOwner } from './client';
3
+
4
+ export { groupOwnerSubject } from './subject';
5
+ export type { GroupIdResolver } from './subject';
@@ -0,0 +1,85 @@
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 { Request } from 'express';
7
+ import logger from '../logging/logger';
8
+ import { clearGroupOwnerCache } from './client';
9
+ import { groupOwnerSubject } from './subject';
10
+
11
+ const ORIGINAL_ENV = process.env;
12
+ const ORIGINAL_FETCH = global.fetch;
13
+
14
+ function ownerResponse(ownerEmail = 'owner@example.com'): Response {
15
+ return {
16
+ ok: true,
17
+ status: 200,
18
+ json: async () => ({ success: true, groupId: 'group-1', ownerEmail, memberCount: 2, deleted: false })
19
+ } as Response;
20
+ }
21
+
22
+ const request = (overrides: Partial<Request> = {}) =>
23
+ ({ user: { id: '1', email: 'member@example.com', name: 'Member' }, originalUrl: '/api/photos', ...overrides }) as Request;
24
+
25
+ beforeEach(() => {
26
+ process.env = {
27
+ ...ORIGINAL_ENV,
28
+ GROUP_API_URL: 'http://group-service:5006',
29
+ INTERNAL_SERVICE_TOKEN: 'test-internal-token'
30
+ };
31
+ clearGroupOwnerCache();
32
+ global.fetch = jest.fn();
33
+ (logger.error as jest.Mock).mockClear();
34
+ });
35
+
36
+ afterAll(() => {
37
+ process.env = ORIGINAL_ENV;
38
+ global.fetch = ORIGINAL_FETCH;
39
+ });
40
+
41
+ describe('groupOwnerSubject', () => {
42
+ it('bills a group resource to the group owner, not the uploader', async () => {
43
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
44
+
45
+ const subject = groupOwnerSubject(() => 'group-1');
46
+
47
+ expect(await subject(request())).toBe('owner@example.com');
48
+ });
49
+
50
+ it('bills a personal resource to the caller and never asks group-service', async () => {
51
+ const subject = groupOwnerSubject(() => undefined);
52
+
53
+ expect(await subject(request())).toBe('member@example.com');
54
+ expect(global.fetch).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it('accepts an async group-id resolver', async () => {
58
+ (global.fetch as jest.Mock).mockResolvedValue(ownerResponse());
59
+
60
+ const subject = groupOwnerSubject(async () => 'group-1');
61
+
62
+ expect(await subject(request())).toBe('owner@example.com');
63
+ });
64
+
65
+ it('falls back to the caller and logs when the owner cannot be resolved', async () => {
66
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('ECONNREFUSED'));
67
+
68
+ const subject = groupOwnerSubject(() => 'group-1');
69
+
70
+ // Billing the caller checks the write against their own — typically
71
+ // smaller — allowance rather than refusing it outright, matching how the
72
+ // entitlement client degrades for the same class of outage.
73
+ expect(await subject(request())).toBe('member@example.com');
74
+ expect(logger.error).toHaveBeenCalledWith(
75
+ 'Group owner unresolved; billing usage to the caller instead',
76
+ expect.objectContaining({ groupId: 'group-1', caller: 'member@example.com' })
77
+ );
78
+ });
79
+
80
+ it('returns undefined when there is no caller and no resolvable group, so the middleware 401s', async () => {
81
+ const subject = groupOwnerSubject(() => undefined);
82
+
83
+ expect(await subject(request({ user: undefined }))).toBeUndefined();
84
+ });
85
+ });
@@ -0,0 +1,50 @@
1
+ import { Request } from 'express';
2
+ import logger from '../logging/logger';
3
+ import { SubjectResolver } from '../entitlements/middleware';
4
+ import { getGroupOwner } from './client';
5
+
6
+ export type GroupIdResolver = (req: Request) => string | undefined | Promise<string | undefined>;
7
+
8
+ /**
9
+ * Builds the `subject` resolver that `requireQuota` / `requireEntitlement`
10
+ * already accept, for the meters whose resource can live in a group.
11
+ *
12
+ * ```ts
13
+ * requireQuota('storageBytes', {
14
+ * subject: groupOwnerSubject(req => req.body.groupId),
15
+ * amount: req => req.body.sizeBytes
16
+ * })
17
+ * ```
18
+ *
19
+ * Personal rows keep billing to the caller; group rows bill to the group's
20
+ * owner, because Family sells *pooled* storage and seats and the uploader's own
21
+ * free tier is not the pool.
22
+ *
23
+ * **When the owner cannot be resolved, it bills the caller and says so.** The
24
+ * alternative — refusing the write — is harsher than what the entitlement
25
+ * client itself does for the same class of outage (it degrades to free limits
26
+ * rather than denying), and the error direction here is bounded in the shape
27
+ * that matters: the common case is a member on a cheap personal plan uploading
28
+ * into a group owned by someone on Family, so falling back to the caller checks
29
+ * against the *smaller* allowance. It can over-grant when the caller happens to
30
+ * hold the richer plan, which is why it logs at `error` — a run of these means
31
+ * group-service is down, not that a limit is wrong.
32
+ */
33
+ export function groupOwnerSubject(resolveGroupId: GroupIdResolver): SubjectResolver {
34
+ return async (req: Request) => {
35
+ const caller = req.user?.email;
36
+ const groupId = await resolveGroupId(req);
37
+ if (!groupId) return caller;
38
+
39
+ const owner = await getGroupOwner(String(groupId));
40
+ if (owner) return owner.ownerEmail;
41
+
42
+ logger.error('Group owner unresolved; billing usage to the caller instead', {
43
+ event: 'entitlement.subject_fallback',
44
+ groupId: String(groupId),
45
+ caller,
46
+ route: req.originalUrl
47
+ });
48
+ return caller;
49
+ };
50
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,9 @@ export type { PaymentRequiredDetails } from './errors/HttpError';
27
27
  // Entitlements (plan limits, usage meters, enforcement)
28
28
  export * from './entitlements';
29
29
 
30
+ // Group ownership (the quota subject for anything pooled)
31
+ export * from './groups';
32
+
30
33
  // Middleware
31
34
  export { authenticateToken } from './middleware/authMiddleware';
32
35
  export {
@@ -1,10 +0,0 @@
1
- import { EntitlementsMode } from './types';
2
- /**
3
- * Read per call rather than cached at import time: a service can be flipped to
4
- * `enforce` by restarting it with a new env value, and tests set the variable
5
- * between cases. The cost is one `process.env` lookup per check.
6
- */
7
- export declare function getEntitlementsMode(): EntitlementsMode;
8
- /** Test seam — the "already warned" latch would otherwise leak between cases. */
9
- export declare function resetEntitlementsModeWarning(): void;
10
- //# sourceMappingURL=mode.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"mode.d.ts","sourceRoot":"","sources":["../../src/entitlements/mode.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAM3C;;;;GAIG;AACH,wBAAgB,mBAAmB,IAAI,gBAAgB,CAUtD;AAED,iFAAiF;AACjF,wBAAgB,4BAA4B,IAAI,IAAI,CAEnD"}
@@ -1,32 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getEntitlementsMode = getEntitlementsMode;
7
- exports.resetEntitlementsModeWarning = resetEntitlementsModeWarning;
8
- const logger_1 = __importDefault(require("../logging/logger"));
9
- const VALID_MODES = ['off', 'observe', 'enforce'];
10
- let warnedAboutInvalidMode = false;
11
- /**
12
- * Read per call rather than cached at import time: a service can be flipped to
13
- * `enforce` by restarting it with a new env value, and tests set the variable
14
- * between cases. The cost is one `process.env` lookup per check.
15
- */
16
- function getEntitlementsMode() {
17
- const raw = (process.env.ENTITLEMENTS_MODE || '').trim().toLowerCase();
18
- if (!raw)
19
- return 'observe';
20
- if (VALID_MODES.includes(raw))
21
- return raw;
22
- if (!warnedAboutInvalidMode) {
23
- warnedAboutInvalidMode = true;
24
- logger_1.default.warn('Invalid ENTITLEMENTS_MODE, falling back to observe', { value: raw });
25
- }
26
- return 'observe';
27
- }
28
- /** Test seam — the "already warned" latch would otherwise leak between cases. */
29
- function resetEntitlementsModeWarning() {
30
- warnedAboutInvalidMode = false;
31
- }
32
- //# sourceMappingURL=mode.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"mode.js","sourceRoot":"","sources":["../../src/entitlements/mode.ts"],"names":[],"mappings":";;;;;AAYA,kDAUC;AAGD,oEAEC;AA3BD,+DAAuC;AAGvC,MAAM,WAAW,GAAgC,CAAC,KAAK,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC;AAE/E,IAAI,sBAAsB,GAAG,KAAK,CAAC;AAEnC;;;;GAIG;AACH,SAAgB,mBAAmB;IACjC,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,iBAAiB,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACvE,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,IAAK,WAAiC,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,GAAuB,CAAC;IAErF,IAAI,CAAC,sBAAsB,EAAE,CAAC;QAC5B,sBAAsB,GAAG,IAAI,CAAC;QAC9B,gBAAM,CAAC,IAAI,CAAC,oDAAoD,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC;IACpF,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,iFAAiF;AACjF,SAAgB,4BAA4B;IAC1C,sBAAsB,GAAG,KAAK,CAAC;AACjC,CAAC"}
@@ -1,50 +0,0 @@
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 { getEntitlementsMode, resetEntitlementsModeWarning } from './mode';
8
-
9
- const ORIGINAL_ENV = process.env;
10
-
11
- beforeEach(() => {
12
- process.env = { ...ORIGINAL_ENV };
13
- delete process.env.ENTITLEMENTS_MODE;
14
- resetEntitlementsModeWarning();
15
- (logger.warn as jest.Mock).mockClear();
16
- });
17
-
18
- afterAll(() => {
19
- process.env = ORIGINAL_ENV;
20
- });
21
-
22
- describe('getEntitlementsMode', () => {
23
- it('defaults to observe when unset', () => {
24
- expect(getEntitlementsMode()).toBe('observe');
25
- });
26
-
27
- it('reads each valid mode, case- and whitespace-insensitively', () => {
28
- process.env.ENTITLEMENTS_MODE = 'enforce';
29
- expect(getEntitlementsMode()).toBe('enforce');
30
-
31
- process.env.ENTITLEMENTS_MODE = ' Off ';
32
- expect(getEntitlementsMode()).toBe('off');
33
- });
34
-
35
- it('falls back to observe and warns once on an invalid value', () => {
36
- process.env.ENTITLEMENTS_MODE = 'enforcce';
37
-
38
- expect(getEntitlementsMode()).toBe('observe');
39
- expect(getEntitlementsMode()).toBe('observe');
40
- expect(logger.warn).toHaveBeenCalledTimes(1);
41
- });
42
-
43
- it('picks up a change without a restart of the module', () => {
44
- process.env.ENTITLEMENTS_MODE = 'observe';
45
- expect(getEntitlementsMode()).toBe('observe');
46
-
47
- process.env.ENTITLEMENTS_MODE = 'enforce';
48
- expect(getEntitlementsMode()).toBe('enforce');
49
- });
50
- });
@@ -1,28 +0,0 @@
1
- import logger from '../logging/logger';
2
- import { EntitlementsMode } from './types';
3
-
4
- const VALID_MODES: readonly EntitlementsMode[] = ['off', 'observe', 'enforce'];
5
-
6
- let warnedAboutInvalidMode = false;
7
-
8
- /**
9
- * Read per call rather than cached at import time: a service can be flipped to
10
- * `enforce` by restarting it with a new env value, and tests set the variable
11
- * between cases. The cost is one `process.env` lookup per check.
12
- */
13
- export function getEntitlementsMode(): EntitlementsMode {
14
- const raw = (process.env.ENTITLEMENTS_MODE || '').trim().toLowerCase();
15
- if (!raw) return 'observe';
16
- if ((VALID_MODES as readonly string[]).includes(raw)) return raw as EntitlementsMode;
17
-
18
- if (!warnedAboutInvalidMode) {
19
- warnedAboutInvalidMode = true;
20
- logger.warn('Invalid ENTITLEMENTS_MODE, falling back to observe', { value: raw });
21
- }
22
- return 'observe';
23
- }
24
-
25
- /** Test seam — the "already warned" latch would otherwise leak between cases. */
26
- export function resetEntitlementsModeWarning(): void {
27
- warnedAboutInvalidMode = false;
28
- }