@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,401 +0,0 @@
1
- const ORIGINAL_ENV = process.env;
2
-
3
- jest.mock('./ApiKey', () => ({
4
- ApiKey: {
5
- create: jest.fn(),
6
- find: jest.fn(),
7
- findOne: jest.fn(),
8
- updateOne: jest.fn(),
9
- deleteOne: jest.fn()
10
- }
11
- }));
12
-
13
- import { ApiKey } from './ApiKey';
14
- import { PERSONAL_TENANT } from './types';
15
- import {
16
- createApiKey,
17
- listApiKeys,
18
- revealApiKey,
19
- revokeApiKey,
20
- deleteApiKey,
21
- verifyApiKey
22
- } from './service';
23
- import { encryptSecret, generateKey, parseKey, resetEncryptionKeyCache, sha256 } from './crypto';
24
-
25
- const mockedCreate = ApiKey.create as unknown as jest.Mock;
26
- const mockedFind = ApiKey.find as unknown as jest.Mock;
27
- const mockedFindOne = ApiKey.findOne as unknown as jest.Mock;
28
- const mockedUpdateOne = ApiKey.updateOne as unknown as jest.Mock;
29
- const mockedDeleteOne = ApiKey.deleteOne as unknown as jest.Mock;
30
-
31
- const CREATED_AT = new Date('2026-09-01T00:00:00.000Z');
32
-
33
- /** A stored key document, with the save()/field mutation the service relies on. */
34
- function storedKey(over: Record<string, unknown> = {}) {
35
- return {
36
- _id: 'k1',
37
- userId: 'u1',
38
- userEmail: 'u1@example.com',
39
- userName: 'Tester',
40
- name: 'Claude',
41
- keyId: 'abcdef123456',
42
- hash: sha256('a-secret-value-long-enough'),
43
- sealedCiphertext: 'ct',
44
- sealedIv: 'iv',
45
- sealedTag: 'tag',
46
- scopes: ['relationship:read'],
47
- tenants: [PERSONAL_TENANT],
48
- defaultTenant: PERSONAL_TENANT,
49
- revealCount: 0,
50
- lastRevealedAt: undefined as Date | undefined,
51
- createdAt: CREATED_AT,
52
- lastUsedAt: undefined,
53
- expiresAt: undefined,
54
- revokedAt: undefined,
55
- save: jest.fn().mockResolvedValue(undefined),
56
- ...over
57
- };
58
- }
59
-
60
- beforeEach(() => {
61
- jest.clearAllMocks();
62
- process.env = { ...ORIGINAL_ENV, API_KEY_ENCRYPTION_SECRET: 'test-encryption-secret' };
63
- resetEncryptionKeyCache();
64
- mockedUpdateOne.mockResolvedValue({});
65
- });
66
-
67
- afterEach(() => {
68
- process.env = ORIGINAL_ENV;
69
- resetEncryptionKeyCache();
70
- });
71
-
72
- describe('createApiKey', () => {
73
- beforeEach(() => mockedCreate.mockImplementation(async (doc) => ({ ...doc, _id: 'k1', createdAt: CREATED_AT })));
74
-
75
- const input = {
76
- userId: 'u1',
77
- userEmail: 'u1@example.com',
78
- userName: 'Tester',
79
- name: 'Claude',
80
- scopes: ['relationship:read' as const]
81
- };
82
-
83
- it('returns a usable token and stores only its digest', async () => {
84
- const { token } = await createApiKey(input);
85
- const [stored] = mockedCreate.mock.calls[0];
86
-
87
- // The plaintext must never reach the hash column — that is the whole point
88
- // of keeping a digest alongside the reversible copy.
89
- expect(stored.hash).not.toContain(token);
90
- expect(stored.hash).toBe(sha256(parseKey(token)!.secret));
91
- expect(stored.keyId).toBe(parseKey(token)!.id);
92
- });
93
-
94
- it('stores the token encrypted, recoverable only with the secret', async () => {
95
- const { token } = await createApiKey(input);
96
- const [stored] = mockedCreate.mock.calls[0];
97
-
98
- expect(stored.sealedCiphertext).not.toContain(token);
99
- expect(stored.sealedIv).toBeTruthy();
100
- expect(stored.sealedTag).toBeTruthy();
101
- });
102
-
103
- it('defaults a key with no stated tenants to the owner’s personal data', async () => {
104
- await createApiKey(input);
105
- expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
106
- expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe(PERSONAL_TENANT);
107
- });
108
-
109
- it('stores every tenant a key was granted', async () => {
110
- await createApiKey({ ...input, tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' });
111
-
112
- expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT, 'g1']);
113
- expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
114
- });
115
-
116
- it('refuses to store a default outside the grant', async () => {
117
- // A key acting by default in something it cannot reach would authenticate
118
- // and then fail every call that did not name a tenant explicitly.
119
- await createApiKey({ ...input, tenants: ['g1'], defaultTenant: 'g2' });
120
-
121
- expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
122
- });
123
-
124
- it('reads an empty grant as the safest non-empty one', async () => {
125
- await createApiKey({ ...input, tenants: [] });
126
- expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
127
- });
128
-
129
- it('snapshots the owner so verification needs no second query', async () => {
130
- await createApiKey(input);
131
- expect(mockedCreate.mock.calls[0][0]).toMatchObject({
132
- userEmail: 'u1@example.com',
133
- userName: 'Tester'
134
- });
135
- });
136
-
137
- it('never returns the secret material in the summary', async () => {
138
- const { summary } = await createApiKey(input);
139
- expect(summary).not.toHaveProperty('hash');
140
- expect(summary).not.toHaveProperty('sealedCiphertext');
141
- expect(summary.prefix).toBe(`tmb_live_${mockedCreate.mock.calls[0][0].keyId}`);
142
- });
143
- });
144
-
145
- describe('listApiKeys', () => {
146
- it('lists the owner’s keys newest first, carrying no secrets', async () => {
147
- const sort = jest.fn().mockResolvedValue([storedKey(), storedKey({ _id: 'k2', name: 'Other' })]);
148
- mockedFind.mockReturnValue({ sort });
149
-
150
- const keys = await listApiKeys('u1');
151
-
152
- expect(mockedFind).toHaveBeenCalledWith({ userId: 'u1' });
153
- expect(sort).toHaveBeenCalledWith({ createdAt: -1 });
154
- for (const key of keys) {
155
- expect(key).not.toHaveProperty('hash');
156
- expect(key).not.toHaveProperty('sealedCiphertext');
157
- expect(JSON.stringify(key)).not.toContain('ct');
158
- }
159
- });
160
-
161
- it('renders absent timestamps as null rather than undefined', async () => {
162
- mockedFind.mockReturnValue({ sort: jest.fn().mockResolvedValue([storedKey()]) });
163
- const [key] = await listApiKeys('u1');
164
-
165
- expect(key.lastUsedAt).toBeNull();
166
- expect(key.expiresAt).toBeNull();
167
- expect(key.revokedAt).toBeNull();
168
- });
169
- });
170
-
171
- describe('revealApiKey', () => {
172
- it('decrypts the stored token for its owner', async () => {
173
- const { token } = generateKey();
174
- const sealed = encryptSecret(token);
175
- const key = storedKey({
176
- sealedCiphertext: sealed.ciphertext,
177
- sealedIv: sealed.iv,
178
- sealedTag: sealed.tag
179
- });
180
- mockedFindOne.mockResolvedValue(key);
181
-
182
- await expect(revealApiKey('u1', 'k1')).resolves.toBe(token);
183
- });
184
-
185
- it('scopes the lookup to the owner, so another user’s key is simply not found', async () => {
186
- mockedFindOne.mockResolvedValue(null);
187
-
188
- await expect(revealApiKey('someone-else', 'k1')).resolves.toBeNull();
189
- // Ownership is part of the query rather than a check afterwards — there is
190
- // no path where a mismatched user still reaches the decrypt.
191
- expect(mockedFindOne).toHaveBeenCalledWith({ _id: 'k1', userId: 'someone-else' });
192
- });
193
-
194
- it('counts and timestamps every reveal', async () => {
195
- const sealed = encryptSecret(generateKey().token);
196
- const key = storedKey({
197
- sealedCiphertext: sealed.ciphertext,
198
- sealedIv: sealed.iv,
199
- sealedTag: sealed.tag,
200
- revealCount: 2
201
- });
202
- mockedFindOne.mockResolvedValue(key);
203
-
204
- await revealApiKey('u1', 'k1');
205
-
206
- expect(key.revealCount).toBe(3);
207
- expect(key.lastRevealedAt).toBeInstanceOf(Date);
208
- expect(key.save).toHaveBeenCalled();
209
- });
210
-
211
- it('still reveals a revoked key, so the owner can see what they turned off', async () => {
212
- const { token } = generateKey();
213
- const sealed = encryptSecret(token);
214
- mockedFindOne.mockResolvedValue(
215
- storedKey({
216
- sealedCiphertext: sealed.ciphertext,
217
- sealedIv: sealed.iv,
218
- sealedTag: sealed.tag,
219
- revokedAt: new Date()
220
- })
221
- );
222
-
223
- await expect(revealApiKey('u1', 'k1')).resolves.toBe(token);
224
- });
225
- });
226
-
227
- describe('revokeApiKey', () => {
228
- it('stamps the key revoked', async () => {
229
- const key = storedKey();
230
- mockedFindOne.mockResolvedValue(key);
231
-
232
- const summary = await revokeApiKey('u1', 'k1');
233
-
234
- expect(key.revokedAt).toBeInstanceOf(Date);
235
- expect(key.save).toHaveBeenCalled();
236
- expect(summary?.revokedAt).not.toBeNull();
237
- });
238
-
239
- it('leaves an already-revoked key’s original timestamp alone', async () => {
240
- const revokedAt = new Date('2026-01-01T00:00:00.000Z');
241
- const key = storedKey({ revokedAt });
242
- mockedFindOne.mockResolvedValue(key);
243
-
244
- await revokeApiKey('u1', 'k1');
245
-
246
- expect(key.revokedAt).toBe(revokedAt);
247
- expect(key.save).not.toHaveBeenCalled();
248
- });
249
-
250
- it('returns null for a key that is not the caller’s', async () => {
251
- mockedFindOne.mockResolvedValue(null);
252
- await expect(revokeApiKey('u2', 'k1')).resolves.toBeNull();
253
- });
254
- });
255
-
256
- describe('deleteApiKey', () => {
257
- it('deletes only within the owner’s keys', async () => {
258
- mockedDeleteOne.mockResolvedValue({ deletedCount: 1 });
259
-
260
- await expect(deleteApiKey('u1', 'k1')).resolves.toBe(true);
261
- expect(mockedDeleteOne).toHaveBeenCalledWith({ _id: 'k1', userId: 'u1' });
262
- });
263
-
264
- it('reports false when nothing matched', async () => {
265
- mockedDeleteOne.mockResolvedValue({ deletedCount: 0 });
266
- await expect(deleteApiKey('u2', 'k1')).resolves.toBe(false);
267
- });
268
- });
269
-
270
- describe('verifyApiKey', () => {
271
- /** A stored record that will actually accept `token`. */
272
- const acceptingKey = (token: string, over: Record<string, unknown> = {}) =>
273
- storedKey({
274
- keyId: parseKey(token)!.id,
275
- hash: sha256(parseKey(token)!.secret),
276
- ...over
277
- });
278
-
279
- it('rejects a malformed token without touching the database', async () => {
280
- await expect(verifyApiKey('not-a-key')).resolves.toEqual({ ok: false, rejection: 'malformed' });
281
- expect(mockedFindOne).not.toHaveBeenCalled();
282
- });
283
-
284
- it('rejects a token for a key that does not exist', async () => {
285
- mockedFindOne.mockResolvedValue(null);
286
- const { token } = generateKey();
287
-
288
- await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'unknown' });
289
- });
290
-
291
- it('rejects a revoked key', async () => {
292
- const { token } = generateKey();
293
- mockedFindOne.mockResolvedValue(acceptingKey(token, { revokedAt: new Date() }));
294
-
295
- await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'revoked' });
296
- });
297
-
298
- it('rejects a key whose expiry has passed', async () => {
299
- const { token } = generateKey();
300
- mockedFindOne.mockResolvedValue(acceptingKey(token, { expiresAt: new Date(Date.now() - 1000) }));
301
-
302
- await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'expired' });
303
- });
304
-
305
- it('accepts a key whose expiry is still ahead', async () => {
306
- const { token } = generateKey();
307
- mockedFindOne.mockResolvedValue(acceptingKey(token, { expiresAt: new Date(Date.now() + 60_000) }));
308
-
309
- await expect(verifyApiKey(token)).resolves.toMatchObject({ ok: true });
310
- });
311
-
312
- it('rejects a right-shaped token whose secret is wrong', async () => {
313
- const { token } = generateKey();
314
- const other = generateKey();
315
- // Same public id, a different secret's digest — the id half is not a credential.
316
- mockedFindOne.mockResolvedValue(
317
- storedKey({ keyId: parseKey(token)!.id, hash: sha256(parseKey(other.token)!.secret) })
318
- );
319
-
320
- await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'bad-secret' });
321
- });
322
-
323
- it('looks a key up by its public id alone', async () => {
324
- const { token } = generateKey();
325
- mockedFindOne.mockResolvedValue(acceptingKey(token));
326
-
327
- await verifyApiKey(token);
328
-
329
- expect(mockedFindOne).toHaveBeenCalledWith({ keyId: parseKey(token)!.id });
330
- });
331
-
332
- it('returns the identity, scopes and tenants a valid key carries', async () => {
333
- const { token } = generateKey();
334
- mockedFindOne.mockResolvedValue(
335
- acceptingKey(token, {
336
- scopes: ['relationship:read', 'album:write'],
337
- tenants: [PERSONAL_TENANT, 'g1'],
338
- defaultTenant: 'g1'
339
- })
340
- );
341
-
342
- await expect(verifyApiKey(token)).resolves.toEqual({
343
- ok: true,
344
- userId: 'u1',
345
- userEmail: 'u1@example.com',
346
- userName: 'Tester',
347
- keyId: 'k1',
348
- scopes: ['relationship:read', 'album:write'],
349
- // The key's own name, so an audit trail reads "Claude" and not "k1".
350
- label: 'Claude',
351
- tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
352
- });
353
- });
354
-
355
- it('reads a key issued before tenants were a set', async () => {
356
- // Keys stored under the single-tenant model carry `groupId` and no array.
357
- // Without this fallback every connected assistant would break at once on
358
- // the deploy that introduced the set.
359
- const { token } = generateKey();
360
- mockedFindOne.mockResolvedValue(
361
- acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: 'g1' })
362
- );
363
-
364
- await expect(verifyApiKey(token)).resolves.toMatchObject({
365
- tenants: { allowed: ['g1'], default: 'g1' }
366
- });
367
- });
368
-
369
- it('reads an old personal key as the personal tenant, not as no tenant', async () => {
370
- const { token } = generateKey();
371
- mockedFindOne.mockResolvedValue(
372
- acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: null })
373
- );
374
-
375
- await expect(verifyApiKey(token)).resolves.toMatchObject({
376
- tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
377
- });
378
- });
379
-
380
- it('records that the key was used', async () => {
381
- const { token } = generateKey();
382
- mockedFindOne.mockResolvedValue(acceptingKey(token));
383
-
384
- await verifyApiKey(token);
385
-
386
- expect(mockedUpdateOne).toHaveBeenCalledWith(
387
- { _id: 'k1' },
388
- { $set: { lastUsedAt: expect.any(Date) } }
389
- );
390
- });
391
-
392
- it('still authenticates when recording the usage timestamp fails', async () => {
393
- const { token } = generateKey();
394
- mockedFindOne.mockResolvedValue(acceptingKey(token));
395
- mockedUpdateOne.mockRejectedValue(new Error('write concern failed'));
396
-
397
- // The timestamp is a convenience for the listing UI; losing it must never
398
- // cost an otherwise valid request.
399
- await expect(verifyApiKey(token)).resolves.toMatchObject({ ok: true });
400
- });
401
- });
@@ -1,168 +0,0 @@
1
- import { ApiKey, IApiKey } from './ApiKey';
2
- import {
3
- decryptSecret,
4
- displayPrefix,
5
- encryptSecret,
6
- generateKey,
7
- parseKey,
8
- secretMatches
9
- } from './crypto';
10
- import {
11
- PERSONAL_TENANT,
12
- readTenants,
13
- type ApiKeyScope,
14
- type ApiKeySummary,
15
- type ApiKeyVerification,
16
- type Tenant
17
- } from './types';
18
-
19
- export interface CreateApiKeyInput {
20
- userId: string;
21
- userEmail: string;
22
- userName: string;
23
- name: string;
24
- scopes: ApiKeyScope[];
25
- /** every tenant the key may act in; defaults to the user's own data alone */
26
- tenants?: Tenant[];
27
- /** which of them it acts in when a request names none */
28
- defaultTenant?: Tenant;
29
- expiresAt?: Date | null;
30
- }
31
-
32
- export interface CreatedApiKey {
33
- summary: ApiKeySummary;
34
- /** the full token — the only time it is returned without an explicit reveal */
35
- token: string;
36
- }
37
-
38
- const toSummary = (key: IApiKey): ApiKeySummary => ({
39
- id: String(key._id),
40
- name: key.name,
41
- prefix: displayPrefix(key.keyId),
42
- scopes: key.scopes,
43
- ...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
44
- readTenants(key)
45
- ),
46
- createdAt: key.createdAt.toISOString(),
47
- lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
48
- expiresAt: key.expiresAt?.toISOString() ?? null,
49
- revokedAt: key.revokedAt?.toISOString() ?? null
50
- });
51
-
52
- export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
53
- // A key that reaches nowhere would authenticate and then fail every call, so
54
- // an empty grant is read as the safest non-empty one rather than stored.
55
- const tenants = input.tenants?.length ? input.tenants : [PERSONAL_TENANT];
56
-
57
- const { token, id, hash } = generateKey();
58
- const sealed = encryptSecret(token);
59
-
60
- const key = await ApiKey.create({
61
- userId: input.userId,
62
- userEmail: input.userEmail,
63
- userName: input.userName,
64
- name: input.name,
65
- keyId: id,
66
- hash,
67
- sealedCiphertext: sealed.ciphertext,
68
- sealedIv: sealed.iv,
69
- sealedTag: sealed.tag,
70
- scopes: input.scopes,
71
- tenants,
72
- defaultTenant: tenants.includes(input.defaultTenant ?? '')
73
- ? input.defaultTenant
74
- : (tenants[0] as Tenant),
75
- expiresAt: input.expiresAt ?? undefined
76
- });
77
-
78
- return { summary: toSummary(key), token };
79
- };
80
-
81
- export const listApiKeys = async (userId: string): Promise<ApiKeySummary[]> => {
82
- const keys = await ApiKey.find({ userId }).sort({ createdAt: -1 });
83
- return keys.map(toSummary);
84
- };
85
-
86
- /**
87
- * Read a key back in the clear.
88
- *
89
- * Scoped to the owner by query rather than by a check afterwards, so there is no
90
- * path where a mismatched userId still reaches the decrypt. Revoked keys are
91
- * still revealable — the user may need to see which key they just turned off.
92
- */
93
- export const revealApiKey = async (userId: string, id: string): Promise<string | null> => {
94
- const key = await ApiKey.findOne({ _id: id, userId });
95
- if (!key) return null;
96
-
97
- const token = decryptSecret({
98
- ciphertext: key.sealedCiphertext,
99
- iv: key.sealedIv,
100
- tag: key.sealedTag
101
- });
102
-
103
- key.revealCount += 1;
104
- key.lastRevealedAt = new Date();
105
- await key.save();
106
-
107
- return token;
108
- };
109
-
110
- /** Revoking is a tombstone, not a delete: the audit trail outlives the key. */
111
- export const revokeApiKey = async (userId: string, id: string): Promise<ApiKeySummary | null> => {
112
- const key = await ApiKey.findOne({ _id: id, userId });
113
- if (!key) return null;
114
-
115
- if (!key.revokedAt) {
116
- key.revokedAt = new Date();
117
- await key.save();
118
- }
119
-
120
- return toSummary(key);
121
- };
122
-
123
- export const deleteApiKey = async (userId: string, id: string): Promise<boolean> => {
124
- const { deletedCount } = await ApiKey.deleteOne({ _id: id, userId });
125
- return deletedCount > 0;
126
- };
127
-
128
- /**
129
- * Check a presented token.
130
- *
131
- * Every failure returns the same shape and the caller answers all of them with
132
- * one message: distinguishing "no such key" from "wrong secret" to the client
133
- * would confirm which half of a guess was right. The `rejection` field exists
134
- * for the server's own logs.
135
- *
136
- * `lastUsedAt` is written on the side and never awaited — it is a convenience
137
- * for the listing UI, and making every authenticated request wait on a write to
138
- * maintain it would be a poor trade.
139
- */
140
- export const verifyApiKey = async (token: unknown): Promise<ApiKeyVerification> => {
141
- const parsed = parseKey(token);
142
- if (!parsed) return { ok: false, rejection: 'malformed' };
143
-
144
- const key = await ApiKey.findOne({ keyId: parsed.id });
145
- if (!key) return { ok: false, rejection: 'unknown' };
146
- if (key.revokedAt) return { ok: false, rejection: 'revoked' };
147
- if (key.expiresAt && key.expiresAt.getTime() <= Date.now()) {
148
- return { ok: false, rejection: 'expired' };
149
- }
150
- if (!secretMatches(parsed.secret, key.hash)) {
151
- return { ok: false, rejection: 'bad-secret' };
152
- }
153
-
154
- void ApiKey.updateOne({ _id: key._id }, { $set: { lastUsedAt: new Date() } }).catch(() => {
155
- // A missed usage timestamp must never fail an otherwise valid request.
156
- });
157
-
158
- return {
159
- ok: true,
160
- userId: key.userId,
161
- userEmail: key.userEmail,
162
- userName: key.userName,
163
- keyId: String(key._id),
164
- label: key.name,
165
- scopes: key.scopes,
166
- tenants: readTenants(key)
167
- };
168
- };
@@ -1,41 +0,0 @@
1
- import { API_KEY_SCOPES, isApiKeyScope } from './types';
2
-
3
- describe('API_KEY_SCOPES', () => {
4
- it('has no duplicates', () => {
5
- expect(new Set(API_KEY_SCOPES).size).toBe(API_KEY_SCOPES.length);
6
- });
7
-
8
- it('offers a read and a write for every area it covers', () => {
9
- for (const area of ['relationship', 'album', 'finance']) {
10
- expect(API_KEY_SCOPES).toContain(`${area}:read`);
11
- expect(API_KEY_SCOPES).toContain(`${area}:write`);
12
- }
13
- });
14
-
15
- it('grants nothing over auth or payment, which stay a person’s to do', () => {
16
- // A key that could mint keys could not be revoked, and one that could change
17
- // a subscription has no ceiling on what it can cost.
18
- for (const scope of API_KEY_SCOPES) {
19
- expect(scope.startsWith('auth:')).toBe(false);
20
- expect(scope.startsWith('payment:')).toBe(false);
21
- }
22
- });
23
- });
24
-
25
- describe('isApiKeyScope', () => {
26
- it.each([...API_KEY_SCOPES])('accepts %s', (scope) => {
27
- expect(isApiKeyScope(scope)).toBe(true);
28
- });
29
-
30
- it.each([
31
- ['an invented area', 'admin:everything'],
32
- ['an invented verb', 'relationship:delete'],
33
- ['a bare area', 'relationship'],
34
- ['an empty string', ''],
35
- ['a non-string', 42],
36
- ['null', null],
37
- ['undefined', undefined]
38
- ])('rejects %s', (_label, value) => {
39
- expect(isApiKeyScope(value)).toBe(false);
40
- });
41
- });