@tumbaland/backend-core 1.32.0 → 1.33.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,118 @@
1
+ import { ApiKey } from './ApiKey';
2
+
3
+ /** The fields the schema insists on, so each test only varies what it is about. */
4
+ const complete = {
5
+ userId: 'u1',
6
+ userEmail: 'u1@example.com',
7
+ userName: 'Tester',
8
+ name: 'Claude Code',
9
+ keyId: 'abcdef123456',
10
+ hash: 'a'.repeat(64),
11
+ sealedCiphertext: 'ct',
12
+ sealedIv: 'iv',
13
+ sealedTag: 'tag'
14
+ };
15
+
16
+ describe('required fields', () => {
17
+ it('accepts a complete document', () => {
18
+ expect(new ApiKey(complete).validateSync()).toBeUndefined();
19
+ });
20
+
21
+ it.each([
22
+ 'userId',
23
+ 'userEmail',
24
+ 'name',
25
+ 'keyId',
26
+ 'hash',
27
+ 'sealedCiphertext',
28
+ 'sealedIv',
29
+ 'sealedTag'
30
+ ])('refuses a document with no %s', (field) => {
31
+ const doc = new ApiKey({ ...complete, [field]: undefined });
32
+ expect(doc.validateSync()?.errors[field]).toBeDefined();
33
+ });
34
+
35
+ it('will not store a key without the material needed to verify it', () => {
36
+ // A record missing its digest could never authenticate anything, and one
37
+ // missing the sealed copy could never be revealed — both are corruption,
38
+ // not a valid state.
39
+ const doc = new ApiKey({ ...complete, hash: undefined, sealedCiphertext: undefined });
40
+ const err = doc.validateSync();
41
+
42
+ expect(err?.errors.hash).toBeDefined();
43
+ expect(err?.errors.sealedCiphertext).toBeDefined();
44
+ });
45
+ });
46
+
47
+ describe('defaults', () => {
48
+ it('treats an unpinned key as personal rather than as unset', () => {
49
+ // `null` and "nobody set this" must not be distinguishable downstream: the
50
+ // middleware reads a null groupId as the owner's personal scope.
51
+ expect(new ApiKey(complete).groupId).toBeNull();
52
+ });
53
+
54
+ it('starts with no scopes, so a key grants nothing until asked', () => {
55
+ expect(new ApiKey(complete).scopes).toEqual([]);
56
+ });
57
+
58
+ it('starts the reveal counter at zero', () => {
59
+ expect(new ApiKey(complete).revealCount).toBe(0);
60
+ });
61
+
62
+ it('leaves the lifecycle timestamps unset', () => {
63
+ const doc = new ApiKey(complete);
64
+
65
+ expect(doc.lastUsedAt).toBeUndefined();
66
+ expect(doc.expiresAt).toBeUndefined();
67
+ expect(doc.revokedAt).toBeUndefined();
68
+ expect(doc.lastRevealedAt).toBeUndefined();
69
+ });
70
+ });
71
+
72
+ describe('name', () => {
73
+ it('trims surrounding whitespace', () => {
74
+ expect(new ApiKey({ ...complete, name: ' Claude ' }).name).toBe('Claude');
75
+ });
76
+
77
+ it('caps the length so a listing stays readable', () => {
78
+ const doc = new ApiKey({ ...complete, name: 'x'.repeat(61) });
79
+ expect(doc.validateSync()?.errors.name).toBeDefined();
80
+ });
81
+
82
+ it('accepts a name at the cap', () => {
83
+ const doc = new ApiKey({ ...complete, name: 'x'.repeat(60) });
84
+ expect(doc.validateSync()?.errors.name).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe('indexes', () => {
89
+ const indexes = ApiKey.schema.indexes().map(([fields]) => fields);
90
+
91
+ it('indexes keyId, which every verification looks a key up by', () => {
92
+ const path = ApiKey.schema.path('keyId') as unknown as { options: Record<string, unknown> };
93
+ expect(path.options.index).toBe(true);
94
+ expect(path.options.unique).toBe(true);
95
+ });
96
+
97
+ it('indexes a user’s keys newest first, matching how the listing reads them', () => {
98
+ expect(indexes).toContainEqual({ userId: 1, createdAt: -1 });
99
+ });
100
+ });
101
+
102
+ describe('collection', () => {
103
+ it('lives in api_keys', () => {
104
+ // Named explicitly rather than pluralised by Mongoose, so the collection a
105
+ // migration or a manual query targets is not a guess.
106
+ expect(ApiKey.collection.collectionName).toBe('api_keys');
107
+ });
108
+ });
109
+
110
+ describe('model registration', () => {
111
+ it('reuses the compiled model instead of redefining it', () => {
112
+ // backend-core is imported by every service, and some import it more than
113
+ // once through different paths; recompiling would throw OverwriteModelError.
114
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
115
+ const again = require('./ApiKey').ApiKey;
116
+ expect(again).toBe(ApiKey);
117
+ });
118
+ });
@@ -0,0 +1,72 @@
1
+ import * as core from '../index';
2
+ import * as apiKeys from './index';
3
+
4
+ /**
5
+ * The package's public surface, as the services actually consume it.
6
+ *
7
+ * Services install this lib from npm, so an export dropped in a refactor does
8
+ * not fail here — it fails inside a Docker build as "has no exported member X",
9
+ * against a registry that genuinely has the code. That is a slow and confusing
10
+ * loop, and it is entirely preventable by naming the surface once.
11
+ */
12
+ const PUBLIC_SURFACE = [
13
+ // middleware the services mount
14
+ 'authenticateAgent',
15
+ 'requireScope',
16
+ 'denyApiKeys',
17
+ // key lifecycle, used by auth-service
18
+ 'createApiKey',
19
+ 'listApiKeys',
20
+ 'revealApiKey',
21
+ 'revokeApiKey',
22
+ 'deleteApiKey',
23
+ 'verifyApiKey',
24
+ // configuration and vocabulary
25
+ 'isEncryptionConfigured',
26
+ 'API_KEY_SCOPES',
27
+ 'isApiKeyScope',
28
+ 'ApiKey',
29
+ // helpers
30
+ 'looksLikeApiKey',
31
+ 'displayPrefix',
32
+ 'resetEncryptionKeyCache'
33
+ ] as const;
34
+
35
+ describe('the apiKeys barrel', () => {
36
+ it.each(PUBLIC_SURFACE)('exports %s', (name) => {
37
+ expect(apiKeys).toHaveProperty(name);
38
+ expect((apiKeys as Record<string, unknown>)[name]).toBeDefined();
39
+ });
40
+ });
41
+
42
+ describe('the package root', () => {
43
+ it.each(PUBLIC_SURFACE)('re-exports %s, which is how services import it', (name) => {
44
+ expect((core as Record<string, unknown>)[name]).toBeDefined();
45
+ });
46
+
47
+ it('exposes the same binding through both paths', () => {
48
+ for (const name of PUBLIC_SURFACE) {
49
+ expect((core as Record<string, unknown>)[name]).toBe((apiKeys as Record<string, unknown>)[name]);
50
+ }
51
+ });
52
+
53
+ it('still exports the session middleware the key work sits alongside', () => {
54
+ // `authenticateToken` stays JWT-only and is what auth-service and
55
+ // payment-service rely on to refuse keys outright.
56
+ expect(core.authenticateToken).toBeDefined();
57
+ expect(core.optionalAuth).toBeDefined();
58
+ });
59
+ });
60
+
61
+ describe('the scope vocabulary', () => {
62
+ it('is the same list the services and the UI are written against', () => {
63
+ expect([...core.API_KEY_SCOPES]).toEqual([
64
+ 'relationship:read',
65
+ 'relationship:write',
66
+ 'album:read',
67
+ 'album:write',
68
+ 'finance:read',
69
+ 'finance:write'
70
+ ]);
71
+ });
72
+ });
@@ -8,7 +8,7 @@ jest.mock('../logging/logger', () => ({
8
8
 
9
9
  import jwt from 'jsonwebtoken';
10
10
  import { verifyApiKey } from './service';
11
- import { authenticateAgent, denyApiKeys } from './middleware';
11
+ import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
12
12
 
13
13
  const mockedVerify = verifyApiKey as jest.Mock;
14
14
  const ORIGINAL_ENV = process.env;
@@ -157,6 +157,37 @@ describe('authenticateAgent — API keys', () => {
157
157
  });
158
158
  });
159
159
 
160
+ it('authenticates a key stored before the owner snapshot existed', async () => {
161
+ // Records written by an earlier version carry no userEmail/userName. Such a
162
+ // key must still work — the id is what every handler actually reads — rather
163
+ // than 500 on a missing field.
164
+ mockedVerify.mockResolvedValue({
165
+ ok: true,
166
+ userId: 'u1',
167
+ keyId: 'k1',
168
+ scopes: ['relationship:read'],
169
+ groupId: null
170
+ });
171
+ const request = withKey();
172
+ const next = jest.fn();
173
+
174
+ await authenticateAgent('relationship:read')(request, mockRes(), next);
175
+
176
+ expect(next).toHaveBeenCalled();
177
+ expect(request.user).toEqual({ id: 'u1', email: '', name: '' });
178
+ });
179
+
180
+ it('treats a verification result with no scopes as granting nothing', async () => {
181
+ mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', groupId: null });
182
+ const res = mockRes();
183
+ const next = jest.fn();
184
+
185
+ await authenticateAgent('relationship:read')(withKey(), res, next);
186
+
187
+ expect(res.status).toHaveBeenCalledWith(403);
188
+ expect(next).not.toHaveBeenCalled();
189
+ });
190
+
160
191
  describe('authenticateAgent — tenant pinning', () => {
161
192
  it('grants a personal key no groups at all', async () => {
162
193
  mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
@@ -231,6 +262,74 @@ describe('authenticateAgent — tenant pinning', () => {
231
262
  });
232
263
  });
233
264
 
265
+ describe('requireScope', () => {
266
+ const keyReq = (scopes: string[]) =>
267
+ req({ apiKey: { keyId: 'k1', scopes, groupId: null } } as Partial<Request>);
268
+
269
+ it('lets a session through unconditionally', () => {
270
+ // Scopes narrow what software may do on a person's behalf, not what the
271
+ // person may do themselves — a session carries no apiKey context at all.
272
+ const next = jest.fn();
273
+ requireScope('relationship:write')(req(), mockRes(), next);
274
+ expect(next).toHaveBeenCalled();
275
+ });
276
+
277
+ it('lets a key with the required scope through', () => {
278
+ const next = jest.fn();
279
+ requireScope('relationship:read')(keyReq(['relationship:read']), mockRes(), next);
280
+ expect(next).toHaveBeenCalled();
281
+ });
282
+
283
+ it('refuses a key without it, naming what is missing', () => {
284
+ const res = mockRes();
285
+ const next = jest.fn();
286
+
287
+ requireScope('relationship:write')(keyReq(['relationship:read']), res, next);
288
+
289
+ expect(res.status).toHaveBeenCalledWith(403);
290
+ expect(res.json).toHaveBeenCalledWith(
291
+ expect.objectContaining({ message: expect.stringContaining('relationship:write') })
292
+ );
293
+ expect(next).not.toHaveBeenCalled();
294
+ });
295
+
296
+ it('does not let a read scope stand in for a write', () => {
297
+ const next = jest.fn();
298
+ requireScope('album:write')(keyReq(['album:read']), mockRes(), next);
299
+ expect(next).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it('does not let one area’s scope stand in for another’s', () => {
303
+ const next = jest.fn();
304
+ requireScope('finance:read')(keyReq(['relationship:read', 'album:read']), mockRes(), next);
305
+ expect(next).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it('requires every named scope, not just one of them', () => {
309
+ const res = mockRes();
310
+ const next = jest.fn();
311
+
312
+ requireScope('relationship:read', 'album:write')(keyReq(['relationship:read']), res, next);
313
+
314
+ expect(next).not.toHaveBeenCalled();
315
+ expect(res.json).toHaveBeenCalledWith(
316
+ expect.objectContaining({ message: expect.stringContaining('album:write') })
317
+ );
318
+ });
319
+
320
+ it('refuses a key with no scopes at all', () => {
321
+ const next = jest.fn();
322
+ requireScope('relationship:read')(keyReq([]), mockRes(), next);
323
+ expect(next).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('lets any authenticated caller through when no scope is named', () => {
327
+ const next = jest.fn();
328
+ requireScope()(keyReq([]), mockRes(), next);
329
+ expect(next).toHaveBeenCalled();
330
+ });
331
+ });
332
+
234
333
  describe('denyApiKeys', () => {
235
334
  it('lets a session through', () => {
236
335
  const next = jest.fn();
@@ -0,0 +1,41 @@
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
+ });