@tumbaland/backend-core 1.44.0 → 1.46.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 (123) hide show
  1. package/dist/fileService/client.d.ts +54 -0
  2. package/dist/fileService/client.d.ts.map +1 -0
  3. package/dist/fileService/client.js +137 -0
  4. package/dist/fileService/client.js.map +1 -0
  5. package/dist/fileService/index.d.ts +4 -0
  6. package/dist/fileService/index.d.ts.map +1 -0
  7. package/dist/fileService/index.js +40 -0
  8. package/dist/fileService/index.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/middleware/corsMiddleware.d.ts.map +1 -1
  14. package/dist/middleware/corsMiddleware.js +4 -1
  15. package/dist/middleware/corsMiddleware.js.map +1 -1
  16. package/dist/middleware/errorHandler.d.ts.map +1 -1
  17. package/dist/middleware/errorHandler.js +5 -8
  18. package/dist/middleware/errorHandler.js.map +1 -1
  19. package/dist/utils/escapeRegex.d.ts +8 -0
  20. package/dist/utils/escapeRegex.d.ts.map +1 -0
  21. package/dist/utils/escapeRegex.js +12 -0
  22. package/dist/utils/escapeRegex.js.map +1 -0
  23. package/package.json +5 -1
  24. package/.versionrc.json +0 -7
  25. package/__mocks__/uuid.js +0 -8
  26. package/jest.config.js +0 -24
  27. package/src/apiKeys/ApiKey.test.ts +0 -142
  28. package/src/apiKeys/ApiKey.ts +0 -102
  29. package/src/apiKeys/crypto.test.ts +0 -161
  30. package/src/apiKeys/crypto.ts +0 -163
  31. package/src/apiKeys/index.test.ts +0 -72
  32. package/src/apiKeys/index.ts +0 -35
  33. package/src/apiKeys/middleware.test.ts +0 -651
  34. package/src/apiKeys/middleware.ts +0 -341
  35. package/src/apiKeys/service.test.ts +0 -401
  36. package/src/apiKeys/service.ts +0 -168
  37. package/src/apiKeys/types.test.ts +0 -41
  38. package/src/apiKeys/types.ts +0 -124
  39. package/src/app/createBaseApp.test.ts +0 -109
  40. package/src/app/createBaseApp.ts +0 -102
  41. package/src/app/shutdown.test.ts +0 -129
  42. package/src/app/shutdown.ts +0 -81
  43. package/src/audit/AuditEvent.ts +0 -123
  44. package/src/audit/actor.test.ts +0 -95
  45. package/src/audit/actor.ts +0 -68
  46. package/src/audit/context.test.ts +0 -91
  47. package/src/audit/context.ts +0 -83
  48. package/src/audit/index.ts +0 -11
  49. package/src/audit/plugin.test.ts +0 -258
  50. package/src/audit/plugin.ts +0 -254
  51. package/src/audit/reads.test.ts +0 -164
  52. package/src/audit/reads.ts +0 -88
  53. package/src/audit/service.test.ts +0 -115
  54. package/src/audit/service.ts +0 -95
  55. package/src/auth/session.test.ts +0 -89
  56. package/src/auth/session.ts +0 -75
  57. package/src/config/env.test.ts +0 -28
  58. package/src/config/env.ts +0 -13
  59. package/src/database/connection.test.ts +0 -188
  60. package/src/database/connection.ts +0 -90
  61. package/src/entitlements/UsageMeter.ts +0 -49
  62. package/src/entitlements/client.test.ts +0 -200
  63. package/src/entitlements/client.ts +0 -179
  64. package/src/entitlements/definitions.test.ts +0 -161
  65. package/src/entitlements/definitions.ts +0 -268
  66. package/src/entitlements/index.ts +0 -42
  67. package/src/entitlements/middleware.test.ts +0 -196
  68. package/src/entitlements/middleware.ts +0 -150
  69. package/src/entitlements/reconcile.test.ts +0 -333
  70. package/src/entitlements/reconcile.ts +0 -384
  71. package/src/entitlements/types.ts +0 -21
  72. package/src/entitlements/usage.test.ts +0 -314
  73. package/src/entitlements/usage.ts +0 -223
  74. package/src/errors/HttpError.test.ts +0 -76
  75. package/src/errors/HttpError.ts +0 -91
  76. package/src/groups/client.test.ts +0 -215
  77. package/src/groups/client.ts +0 -182
  78. package/src/groups/index.ts +0 -7
  79. package/src/groups/membership.test.ts +0 -84
  80. package/src/groups/membership.ts +0 -133
  81. package/src/groups/subject.test.ts +0 -85
  82. package/src/groups/subject.ts +0 -50
  83. package/src/health/createHealthCheck.test.ts +0 -89
  84. package/src/health/createHealthCheck.ts +0 -67
  85. package/src/health/healthController.test.ts +0 -113
  86. package/src/health/healthController.ts +0 -56
  87. package/src/index.ts +0 -88
  88. package/src/logging/logger.test.ts +0 -91
  89. package/src/logging/logger.ts +0 -103
  90. package/src/metrics/index.test.ts +0 -116
  91. package/src/metrics/index.ts +0 -111
  92. package/src/middleware/authMiddleware.test.ts +0 -275
  93. package/src/middleware/authMiddleware.ts +0 -91
  94. package/src/middleware/corsMiddleware.test.ts +0 -135
  95. package/src/middleware/corsMiddleware.ts +0 -65
  96. package/src/middleware/errorHandler.test.ts +0 -188
  97. package/src/middleware/errorHandler.ts +0 -103
  98. package/src/middleware/internalServiceAuth.test.ts +0 -173
  99. package/src/middleware/internalServiceAuth.ts +0 -96
  100. package/src/middleware/requestLogger.test.ts +0 -81
  101. package/src/middleware/requestLogger.ts +0 -48
  102. package/src/middleware/security.test.ts +0 -45
  103. package/src/middleware/security.ts +0 -43
  104. package/src/middleware/validate.test.ts +0 -72
  105. package/src/middleware/validate.ts +0 -23
  106. package/src/oauth/index.ts +0 -29
  107. package/src/oauth/models.ts +0 -164
  108. package/src/oauth/service.test.ts +0 -432
  109. package/src/oauth/service.ts +0 -299
  110. package/src/oauth/tokens.test.ts +0 -146
  111. package/src/oauth/tokens.ts +0 -186
  112. package/src/testing/serviceTestSetup.ts +0 -17
  113. package/src/tracing/index.test.ts +0 -272
  114. package/src/tracing/index.ts +0 -110
  115. package/src/types/auth.ts +0 -45
  116. package/src/utils/correlation.test.ts +0 -47
  117. package/src/utils/correlation.ts +0 -22
  118. package/src/utils/permissionUtils.test.ts +0 -47
  119. package/src/utils/permissionUtils.ts +0 -68
  120. package/src/utils/response.test.ts +0 -64
  121. package/src/utils/response.ts +0 -60
  122. package/tsconfig.build.json +0 -7
  123. package/tsconfig.json +0 -23
@@ -1,651 +0,0 @@
1
- import { Request, Response } from 'express';
2
-
3
- jest.mock('./service', () => ({ verifyApiKey: jest.fn() }));
4
- jest.mock('../logging/logger', () => ({
5
- __esModule: true,
6
- default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }
7
- }));
8
-
9
- import jwt from 'jsonwebtoken';
10
- import { verifyApiKey } from './service';
11
- import { mintAccessToken, type MintAccessTokenInput } from '../oauth/tokens';
12
- import { PERSONAL_TENANT } from './types';
13
- import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
14
- import { overrideSessionCheckForTests } from '../auth/session';
15
- import { overrideMembershipLookupForTests } from '../groups/membership';
16
-
17
- const mockedVerify = verifyApiKey as jest.Mock;
18
- const ORIGINAL_ENV = process.env;
19
-
20
- function mockRes(): Response {
21
- const res: Partial<Response> = {};
22
- res.status = jest.fn().mockReturnValue(res);
23
- res.json = jest.fn().mockReturnValue(res);
24
- return res as Response;
25
- }
26
-
27
- const req = (over: Partial<Request> = {}): Request =>
28
- ({ query: {}, body: {}, headers: {}, path: '/activities', ...over }) as unknown as Request;
29
-
30
- const withKey = (token = 'tmb_live_abcdef123456_secretsecretsecret', over: Partial<Request> = {}) =>
31
- req({ headers: { authorization: `Bearer ${token}` }, ...over });
32
-
33
- /** A grant of one or more tenants; the first is the default unless said otherwise. */
34
- const grant = (...allowed: string[]) => ({ allowed, default: allowed[0] });
35
-
36
- const okVerification = (over: Record<string, unknown> = {}) => ({
37
- ok: true,
38
- userId: 'u1',
39
- userEmail: 'u1@example.com',
40
- userName: 'Tester',
41
- keyId: 'k1',
42
- scopes: ['relationship:read', 'relationship:write'],
43
- tenants: grant(PERSONAL_TENANT),
44
- label: 'Claude Code',
45
- ...over
46
- });
47
-
48
- // Unless a test says otherwise, the owner still belongs to every group used here.
49
- let memberOf: string[];
50
-
51
- beforeEach(() => {
52
- jest.clearAllMocks();
53
- process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-jwt-secret' };
54
- memberOf = ['g1', 'g2'];
55
- overrideMembershipLookupForTests(async () => memberOf);
56
- });
57
-
58
- afterAll(() => overrideMembershipLookupForTests());
59
-
60
- afterEach(() => {
61
- process.env = ORIGINAL_ENV;
62
- });
63
-
64
- describe('authenticateAgent — no credentials', () => {
65
- it('rejects a request with no token', async () => {
66
- const res = mockRes();
67
- const next = jest.fn();
68
-
69
- await authenticateAgent()(req(), res, next);
70
-
71
- expect(res.status).toHaveBeenCalledWith(401);
72
- expect(next).not.toHaveBeenCalled();
73
- });
74
- });
75
-
76
- describe('authenticateAgent — session tokens', () => {
77
- beforeEach(() => overrideSessionCheckForTests(async () => true));
78
- afterAll(() => overrideSessionCheckForTests());
79
-
80
- it('rejects a validly signed JWT whose session has ended', async () => {
81
- overrideSessionCheckForTests(async () => false);
82
- const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', sid: 'a'.repeat(24) }, 'test-jwt-secret');
83
- const res = mockRes();
84
- const next = jest.fn();
85
-
86
- await authenticateAgent()(req({ headers: { authorization: `Bearer ${token}` } }), res, next);
87
-
88
- expect(res.status).toHaveBeenCalledWith(401);
89
- expect(next).not.toHaveBeenCalled();
90
- });
91
-
92
- it("takes the person's groups from group-service, not from the token", async () => {
93
- memberOf = ['g2'];
94
- const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', groups: ['g1'] }, 'test-jwt-secret');
95
- const request = req({ headers: { authorization: `Bearer ${token}` } });
96
-
97
- await authenticateAgent()(request, mockRes(), jest.fn());
98
-
99
- expect(request.userGroups).toEqual(['g2']);
100
- expect(request.user?.groups).toEqual(['g2']);
101
- });
102
-
103
- it('accepts a valid JWT and grants the full session, with no apiKey context', async () => {
104
- const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', groups: ['g1', 'g2'] }, 'test-jwt-secret');
105
- const request = req({ headers: { authorization: `Bearer ${token}` } });
106
- const next = jest.fn();
107
-
108
- await authenticateAgent('relationship:write')(request, mockRes(), next);
109
-
110
- expect(next).toHaveBeenCalled();
111
- expect(request.user?.id).toBe('u1');
112
- // A person keeps every group they belong to — scopes constrain keys, not sessions.
113
- expect(request.userGroups).toEqual(['g1', 'g2']);
114
- expect(request.apiKey).toBeUndefined();
115
- expect(mockedVerify).not.toHaveBeenCalled();
116
- });
117
-
118
- it('reads the token from a cookie as well as the header', async () => {
119
- const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A' }, 'test-jwt-secret');
120
- const request = req({ cookies: { access_token: token } } as Partial<Request>);
121
- const next = jest.fn();
122
-
123
- await authenticateAgent()(request, mockRes(), next);
124
-
125
- expect(next).toHaveBeenCalled();
126
- expect(request.user?.id).toBe('u1');
127
- });
128
-
129
- it('rejects a JWT signed with the wrong secret', async () => {
130
- const token = jwt.sign({ id: 'u1' }, 'not-the-secret');
131
- const res = mockRes();
132
- const next = jest.fn();
133
-
134
- await authenticateAgent()(req({ headers: { authorization: `Bearer ${token}` } }), res, next);
135
-
136
- expect(res.status).toHaveBeenCalledWith(401);
137
- expect(next).not.toHaveBeenCalled();
138
- });
139
- });
140
-
141
- describe('authenticateAgent — API keys', () => {
142
- it('accepts a valid key and populates the request as a session would', async () => {
143
- mockedVerify.mockResolvedValue(okVerification());
144
- const request = withKey();
145
- const next = jest.fn();
146
-
147
- await authenticateAgent('relationship:read')(request, mockRes(), next);
148
-
149
- expect(next).toHaveBeenCalled();
150
- expect(request.user).toEqual({ id: 'u1', email: 'u1@example.com', name: 'Tester' });
151
- expect(request.apiKey).toEqual({
152
- keyId: 'k1',
153
- scopes: ['relationship:read', 'relationship:write'],
154
- tenants: grant(PERSONAL_TENANT),
155
- actingAs: PERSONAL_TENANT,
156
- kind: 'api_key',
157
- // What the owner called it, so a trail reads "Claude Code" not an id.
158
- label: 'Claude Code',
159
- credentialId: 'k1'
160
- });
161
- });
162
-
163
- it.each([
164
- ['unknown'], ['revoked'], ['expired'], ['bad-secret'], ['malformed']
165
- ])('answers a %s key with the same opaque 401', async (rejection) => {
166
- mockedVerify.mockResolvedValue({ ok: false, rejection });
167
- const res = mockRes();
168
- const next = jest.fn();
169
-
170
- await authenticateAgent()(withKey(), res, next);
171
-
172
- expect(res.status).toHaveBeenCalledWith(401);
173
- // Nothing in the body distinguishes the five cases — that difference would
174
- // tell someone guessing which half of the guess was right.
175
- expect(res.json).toHaveBeenCalledWith({
176
- success: false,
177
- message: 'Invalid or expired credentials'
178
- });
179
- expect(next).not.toHaveBeenCalled();
180
- });
181
-
182
- it('refuses a key missing a required scope, and names what is missing', async () => {
183
- mockedVerify.mockResolvedValue(okVerification({ scopes: ['relationship:read'] }));
184
- const res = mockRes();
185
- const next = jest.fn();
186
-
187
- await authenticateAgent('relationship:write')(withKey(), res, next);
188
-
189
- expect(res.status).toHaveBeenCalledWith(403);
190
- expect(res.json).toHaveBeenCalledWith(
191
- expect.objectContaining({ message: expect.stringContaining('relationship:write') })
192
- );
193
- expect(next).not.toHaveBeenCalled();
194
- });
195
-
196
- it('requires every listed scope, not just one of them', async () => {
197
- mockedVerify.mockResolvedValue(okVerification({ scopes: ['relationship:read'] }));
198
- const next = jest.fn();
199
-
200
- await authenticateAgent('relationship:read', 'album:write')(withKey(), mockRes(), next);
201
-
202
- expect(next).not.toHaveBeenCalled();
203
- });
204
- });
205
-
206
- it('authenticates a key stored before the owner snapshot existed', async () => {
207
- // Records written by an earlier version carry no userEmail/userName. Such a
208
- // key must still work — the id is what every handler actually reads — rather
209
- // than 500 on a missing field.
210
- mockedVerify.mockResolvedValue({
211
- ok: true,
212
- userId: 'u1',
213
- keyId: 'k1',
214
- scopes: ['relationship:read'],
215
- tenants: grant(PERSONAL_TENANT)
216
- });
217
- const request = withKey();
218
- const next = jest.fn();
219
-
220
- await authenticateAgent('relationship:read')(request, mockRes(), next);
221
-
222
- expect(next).toHaveBeenCalled();
223
- expect(request.user).toEqual({ id: 'u1', email: '', name: '' });
224
- });
225
-
226
- it('treats a verification result with no scopes as granting nothing', async () => {
227
- mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', tenants: grant(PERSONAL_TENANT) });
228
- const res = mockRes();
229
- const next = jest.fn();
230
-
231
- await authenticateAgent('relationship:read')(withKey(), res, next);
232
-
233
- expect(res.status).toHaveBeenCalledWith(403);
234
- expect(next).not.toHaveBeenCalled();
235
- });
236
-
237
- describe('authenticateAgent — a single-tenant grant', () => {
238
- /**
239
- * The behaviour a pinned credential always had, kept intact now that a grant
240
- * is a set. An agent given one tenant should never learn that a tenant is a
241
- * thing it could name — nothing to choose, nothing to get wrong.
242
- */
243
- it('grants a personal key no groups at all', async () => {
244
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT) }));
245
- const request = withKey();
246
-
247
- await authenticateAgent()(request, mockRes(), jest.fn());
248
-
249
- expect(request.userGroups).toEqual([]);
250
- });
251
-
252
- it('grants a group key exactly its own group, not the owner’s whole membership', async () => {
253
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
254
- const request = withKey();
255
-
256
- await authenticateAgent()(request, mockRes(), jest.fn());
257
-
258
- expect(request.userGroups).toEqual(['g1']);
259
- });
260
-
261
- it('fills in the group so the caller never has to know it', async () => {
262
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
263
- const request = withKey();
264
- const next = jest.fn();
265
-
266
- await authenticateAgent()(request, mockRes(), next);
267
-
268
- expect(next).toHaveBeenCalled();
269
- expect(request.query.groupId).toBe('g1');
270
- expect((request.body as Record<string, unknown>).groupId).toBe('g1');
271
- });
272
-
273
- it('refuses a query that names a different group', async () => {
274
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
275
- const res = mockRes();
276
- const next = jest.fn();
277
-
278
- await authenticateAgent()(withKey(undefined, { query: { groupId: 'g2' } }), res, next);
279
-
280
- expect(res.status).toHaveBeenCalledWith(403);
281
- expect(next).not.toHaveBeenCalled();
282
- });
283
-
284
- it('refuses a body that names a different group', async () => {
285
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
286
- const res = mockRes();
287
- const next = jest.fn();
288
-
289
- await authenticateAgent()(withKey(undefined, { body: { groupId: 'g2' } }), res, next);
290
-
291
- expect(res.status).toHaveBeenCalledWith(403);
292
- expect(next).not.toHaveBeenCalled();
293
- });
294
-
295
- it('refuses a personal key that tries to reach into a group', async () => {
296
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT) }));
297
- const res = mockRes();
298
- const next = jest.fn();
299
-
300
- await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } }), res, next);
301
-
302
- expect(res.status).toHaveBeenCalledWith(403);
303
- expect(next).not.toHaveBeenCalled();
304
- });
305
-
306
- it('allows a request that names the granted group explicitly', async () => {
307
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
308
- const next = jest.fn();
309
-
310
- await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } }), mockRes(), next);
311
-
312
- expect(next).toHaveBeenCalled();
313
- });
314
- });
315
-
316
- describe('authenticateAgent — a multi-tenant grant', () => {
317
- /**
318
- * What the set buys: one connection reaching a shared journal and private data
319
- * without reconnecting. The boundary is unchanged — a tenant outside the grant
320
- * is still refused — so these tests are about the choice *inside* it.
321
- */
322
- const both = { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' };
323
-
324
- it('acts in the default when the request names nothing', async () => {
325
- mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
326
- const request = withKey();
327
-
328
- await authenticateAgent()(request, mockRes(), jest.fn());
329
-
330
- expect(request.query.groupId).toBe('g1');
331
- expect(request.apiKey?.actingAs).toBe('g1');
332
- });
333
-
334
- it('acts in another granted tenant when the request names it', async () => {
335
- mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
336
- const request = withKey(undefined, { query: { groupId: PERSONAL_TENANT } });
337
-
338
- await authenticateAgent()(request, mockRes(), jest.fn());
339
-
340
- // `personal` is erased rather than passed on: handlers have always read the
341
- // personal tenant as an absent groupId, and a literal would reach a query as
342
- // a group that cannot exist.
343
- expect(request.query.groupId).toBeUndefined();
344
- expect(request.apiKey?.actingAs).toBe(PERSONAL_TENANT);
345
- });
346
-
347
- it('erases a stale groupId from the body too, not only the query', async () => {
348
- mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
349
- const request = withKey(undefined, { body: { groupId: PERSONAL_TENANT } });
350
-
351
- await authenticateAgent()(request, mockRes(), jest.fn());
352
-
353
- expect((request.body as Record<string, unknown>).groupId).toBeUndefined();
354
- });
355
-
356
- it('still refuses a tenant outside the grant', async () => {
357
- mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
358
- const res = mockRes();
359
- const next = jest.fn();
360
-
361
- await authenticateAgent()(withKey(undefined, { query: { groupId: 'g2' } }), res, next);
362
-
363
- expect(res.status).toHaveBeenCalledWith(403);
364
- expect(next).not.toHaveBeenCalled();
365
- });
366
-
367
- it('carries every granted group into userGroups, and only those', async () => {
368
- mockedVerify.mockResolvedValue(
369
- okVerification({ tenants: { allowed: [PERSONAL_TENANT, 'g1', 'g2'], default: 'g1' } })
370
- );
371
- const request = withKey();
372
-
373
- await authenticateAgent()(request, mockRes(), jest.fn());
374
-
375
- // The personal tenant is not a group and must not appear here, or it would
376
- // reach a `groupId: { $in: ... }` clause as a group nobody belongs to.
377
- expect(request.userGroups).toEqual(['g1', 'g2']);
378
- });
379
- });
380
-
381
- describe('authenticateAgent — groups the owner has since left', () => {
382
- it('drops a granted group the owner no longer belongs to', async () => {
383
- memberOf = ['g2'];
384
- mockedVerify.mockResolvedValue(
385
- okVerification({ tenants: { allowed: [PERSONAL_TENANT, 'g1', 'g2'], default: PERSONAL_TENANT } })
386
- );
387
- const request = withKey();
388
-
389
- await authenticateAgent()(request, mockRes(), jest.fn());
390
-
391
- expect(request.userGroups).toEqual(['g2']);
392
- expect(request.apiKey?.tenants.allowed).toEqual([PERSONAL_TENANT, 'g2']);
393
- });
394
-
395
- it('refuses a request for a group the owner has left, although the key was granted it', async () => {
396
- memberOf = [];
397
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT, 'g1') }));
398
- const res = mockRes();
399
- const next = jest.fn();
400
-
401
- await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } } as Partial<Request>), res, next);
402
-
403
- expect(res.status).toHaveBeenCalledWith(403);
404
- expect(next).not.toHaveBeenCalled();
405
- });
406
-
407
- it('refuses a key whose only group the owner has left, rather than acting somewhere else', async () => {
408
- memberOf = [];
409
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
410
- const res = mockRes();
411
- const next = jest.fn();
412
-
413
- await authenticateAgent()(withKey(), res, next);
414
-
415
- expect(res.status).toHaveBeenCalledWith(403);
416
- expect(next).not.toHaveBeenCalled();
417
- });
418
-
419
- it('asks about the key owner, by email', async () => {
420
- const lookup = jest.fn(async () => ['g1']);
421
- overrideMembershipLookupForTests(lookup);
422
- mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
423
-
424
- await authenticateAgent()(withKey(), mockRes(), jest.fn());
425
-
426
- expect(lookup).toHaveBeenCalledWith({ email: 'u1@example.com' });
427
- });
428
- });
429
-
430
- describe('requireScope', () => {
431
- const keyReq = (scopes: string[]) =>
432
- req({
433
- apiKey: {
434
- keyId: 'k1',
435
- scopes,
436
- tenants: grant(PERSONAL_TENANT),
437
- actingAs: PERSONAL_TENANT,
438
- kind: 'api_key' as const,
439
- label: 'A key',
440
- credentialId: 'k1'
441
- }
442
- } as Partial<Request>);
443
-
444
- it('lets a session through unconditionally', () => {
445
- // Scopes narrow what software may do on a person's behalf, not what the
446
- // person may do themselves — a session carries no apiKey context at all.
447
- const next = jest.fn();
448
- requireScope('relationship:write')(req(), mockRes(), next);
449
- expect(next).toHaveBeenCalled();
450
- });
451
-
452
- it('lets a key with the required scope through', () => {
453
- const next = jest.fn();
454
- requireScope('relationship:read')(keyReq(['relationship:read']), mockRes(), next);
455
- expect(next).toHaveBeenCalled();
456
- });
457
-
458
- it('refuses a key without it, naming what is missing', () => {
459
- const res = mockRes();
460
- const next = jest.fn();
461
-
462
- requireScope('relationship:write')(keyReq(['relationship:read']), res, next);
463
-
464
- expect(res.status).toHaveBeenCalledWith(403);
465
- expect(res.json).toHaveBeenCalledWith(
466
- expect.objectContaining({ message: expect.stringContaining('relationship:write') })
467
- );
468
- expect(next).not.toHaveBeenCalled();
469
- });
470
-
471
- it('does not let a read scope stand in for a write', () => {
472
- const next = jest.fn();
473
- requireScope('album:write')(keyReq(['album:read']), mockRes(), next);
474
- expect(next).not.toHaveBeenCalled();
475
- });
476
-
477
- it('does not let one area’s scope stand in for another’s', () => {
478
- const next = jest.fn();
479
- requireScope('finance:read')(keyReq(['relationship:read', 'album:read']), mockRes(), next);
480
- expect(next).not.toHaveBeenCalled();
481
- });
482
-
483
- it('requires every named scope, not just one of them', () => {
484
- const res = mockRes();
485
- const next = jest.fn();
486
-
487
- requireScope('relationship:read', 'album:write')(keyReq(['relationship:read']), res, next);
488
-
489
- expect(next).not.toHaveBeenCalled();
490
- expect(res.json).toHaveBeenCalledWith(
491
- expect.objectContaining({ message: expect.stringContaining('album:write') })
492
- );
493
- });
494
-
495
- it('refuses a key with no scopes at all', () => {
496
- const next = jest.fn();
497
- requireScope('relationship:read')(keyReq([]), mockRes(), next);
498
- expect(next).not.toHaveBeenCalled();
499
- });
500
-
501
- it('lets any authenticated caller through when no scope is named', () => {
502
- const next = jest.fn();
503
- requireScope()(keyReq([]), mockRes(), next);
504
- expect(next).toHaveBeenCalled();
505
- });
506
- });
507
-
508
- describe('denyApiKeys', () => {
509
- it('lets a session through', () => {
510
- const next = jest.fn();
511
- denyApiKeys(req(), mockRes(), next);
512
- expect(next).toHaveBeenCalled();
513
- });
514
-
515
- it('blocks a request that arrived on a key', () => {
516
- const res = mockRes();
517
- const next = jest.fn();
518
-
519
- denyApiKeys(
520
- req({
521
- apiKey: {
522
- keyId: 'k1',
523
- scopes: [],
524
- tenants: grant(PERSONAL_TENANT),
525
- actingAs: PERSONAL_TENANT,
526
- kind: 'api_key' as const,
527
- label: 'A key',
528
- credentialId: 'k1'
529
- }
530
- }) as Request,
531
- res,
532
- next
533
- );
534
-
535
- expect(res.status).toHaveBeenCalledWith(403);
536
- expect(next).not.toHaveBeenCalled();
537
- });
538
- });
539
-
540
- describe('authenticateAgent — OAuth access tokens', () => {
541
- /**
542
- * Minted by the real thing rather than hand-signed. The bug this covers was
543
- * that an access token verified as a session — same secret, same header — and
544
- * its claims were then read as a session's: `sub` instead of `id`, no
545
- * `groups`. Every one of those details has to come from the code that issues
546
- * them, or the test simply re-encodes whatever the middleware happens to do.
547
- */
548
- const mint = (over: Partial<MintAccessTokenInput> = {}) =>
549
- mintAccessToken({
550
- userId: 'u1',
551
- email: 'u1@example.com',
552
- name: 'Tester',
553
- resource: 'https://mcp.example.com',
554
- issuer: 'https://auth.example.com',
555
- scopes: ['relationship:read', 'relationship:write'],
556
- tenants: [PERSONAL_TENANT],
557
- defaultTenant: PERSONAL_TENANT,
558
- tenantNames: { [PERSONAL_TENANT]: 'My own data' },
559
- clientId: 'client-1',
560
- clientName: 'Claude',
561
- ...over
562
- }).accessToken;
563
-
564
- const withToken = (token: string, over: Partial<Request> = {}) =>
565
- req({ headers: { authorization: `Bearer ${token}` }, ...over });
566
-
567
- it('names the user, so scoped queries are bounded', async () => {
568
- const request = withToken(mint());
569
- const next = jest.fn();
570
-
571
- await authenticateAgent('relationship:write')(request, mockRes(), next);
572
-
573
- expect(next).toHaveBeenCalled();
574
- // Read as a session this was `undefined`, which reached handlers as "no
575
- // user" rather than as a refusal: writes were rejected as inaccessible and
576
- // reads fell through to an unscoped query.
577
- expect(request.user?.id).toBe('u1');
578
- expect(request.user?.email).toBe('u1@example.com');
579
- });
580
-
581
- it('is held to its scopes, exactly as a key is', async () => {
582
- const res = mockRes();
583
- const next = jest.fn();
584
- const request = withToken(mint({ scopes: ['relationship:read'] }));
585
-
586
- await authenticateAgent('relationship:write')(request, res, next);
587
-
588
- expect(res.status).toHaveBeenCalledWith(403);
589
- expect(next).not.toHaveBeenCalled();
590
- });
591
-
592
- it('carries an apiKey context, so requireScope applies downstream', async () => {
593
- const request = withToken(mint({ scopes: ['relationship:read'] }));
594
-
595
- await authenticateAgent()(request, mockRes(), jest.fn());
596
-
597
- // Without this the token looked like a session and every requireScope on
598
- // every route waved it through.
599
- expect(request.apiKey?.scopes).toEqual(['relationship:read']);
600
-
601
- const res = mockRes();
602
- const next = jest.fn();
603
- requireScope('relationship:write')(request, res, next);
604
-
605
- expect(res.status).toHaveBeenCalledWith(403);
606
- expect(next).not.toHaveBeenCalled();
607
- });
608
-
609
- it('acts in the tenant chosen at consent', async () => {
610
- const request = withToken(mint({ tenants: ['g1'], defaultTenant: 'g1' }));
611
- const next = jest.fn();
612
-
613
- await authenticateAgent()(request, mockRes(), next);
614
-
615
- expect(next).toHaveBeenCalled();
616
- expect(request.userGroups).toEqual(['g1']);
617
- // Written in for a caller that named none, so a handler's group branch
618
- // lands where the person who approved the connection intended.
619
- expect(request.query.groupId).toBe('g1');
620
- });
621
-
622
- it('refuses a request naming a different tenant', async () => {
623
- const res = mockRes();
624
- const next = jest.fn();
625
- const request = withToken(mint({ tenants: ['g1'], defaultTenant: 'g1' }), {
626
- query: { groupId: 'g2' }
627
- });
628
-
629
- await authenticateAgent()(request, res, next);
630
-
631
- expect(res.status).toHaveBeenCalledWith(403);
632
- expect(next).not.toHaveBeenCalled();
633
- });
634
-
635
- it('is refused by denyApiKeys, like any other credential software holds', async () => {
636
- const request = withToken(mint());
637
- await authenticateAgent()(request, mockRes(), jest.fn());
638
-
639
- const res = mockRes();
640
- const next = jest.fn();
641
- denyApiKeys(request, res, next);
642
-
643
- expect(res.status).toHaveBeenCalledWith(403);
644
- expect(next).not.toHaveBeenCalled();
645
- });
646
-
647
- it('never consults the API key store', async () => {
648
- await authenticateAgent()(withToken(mint()), mockRes(), jest.fn());
649
- expect(mockedVerify).not.toHaveBeenCalled();
650
- });
651
- });