@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,142 +0,0 @@
1
- import { ApiKey, API_KEY_SECRET_FIELDS } 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
- });
119
-
120
- describe('what a key’s audit trail may carry', () => {
121
- /**
122
- * The trail is readable by the account owner and kept for a year. A snapshot
123
- * carrying the hash or the sealed ciphertext would copy credential material
124
- * into a second collection with a different lifetime — which is exactly what
125
- * the encryption exists to prevent.
126
- */
127
- it('redacts every field on the schema that holds a secret', () => {
128
- const secretish = Object.keys(ApiKey.schema.paths).filter((path) =>
129
- /hash|sealed|secret/i.test(path)
130
- );
131
-
132
- // Compared against the schema rather than a hand-written list, so a secret
133
- // field added later fails here instead of quietly reaching the trail.
134
- expect(secretish.sort()).toEqual([...API_KEY_SECRET_FIELDS].sort());
135
- });
136
-
137
- it('keeps the fields that make a row worth reading', () => {
138
- for (const field of ['name', 'scopes', 'tenants', 'revokedAt']) {
139
- expect(API_KEY_SECRET_FIELDS).not.toContain(field);
140
- }
141
- });
142
- });
@@ -1,102 +0,0 @@
1
- import mongoose, { Document, Schema } from 'mongoose';
2
- import { auditPlugin } from '../audit/plugin';
3
- import { PERSONAL_TENANT, type ApiKeyScope, type Tenant } from './types';
4
-
5
- /**
6
- * One API key. Lives in backend-core rather than auth-service because both
7
- * halves need it: auth-service issues and revokes keys, and every other service
8
- * verifies them on the way in. All services share one database, so the model is
9
- * shared rather than the lookup going over HTTP on every request.
10
- */
11
- export interface IApiKey extends Document {
12
- userId: string;
13
- /**
14
- * The owner's identity as it stood when the key was issued.
15
- *
16
- * Snapshotted so verifying a key stays a single indexed read. Every
17
- * authenticated request would otherwise need a second query into the users
18
- * collection to fill in a `UserPayload`, to serve fields almost no handler
19
- * reads. The cost is that a later rename shows the old name on requests made
20
- * with this key, which is a fair price and easy to reason about.
21
- */
22
- userEmail: string;
23
- userName: string;
24
- /** what the user called it — "Claude Code", "home assistant" */
25
- name: string;
26
- /** the public half of the token; unique, and what a presented key is looked up by */
27
- keyId: string;
28
- /** SHA-256 of the secret half — what verification actually compares against */
29
- hash: string;
30
- /** the whole token, encrypted, so the owner can read it back later */
31
- sealedCiphertext: string;
32
- sealedIv: string;
33
- sealedTag: string;
34
- scopes: ApiKeyScope[];
35
- /** every tenant this key may act in; see `Tenant` */
36
- tenants: Tenant[];
37
- /** the one it acts in when a request names none; always a member of `tenants` */
38
- defaultTenant: Tenant;
39
- /**
40
- * The single tenant this key was pinned to, before tenants were a set.
41
- *
42
- * Kept so keys issued under the old model keep working; `readTenants` falls
43
- * back to it. Nothing writes it any more.
44
- */
45
- groupId?: string | null;
46
- lastUsedAt?: Date;
47
- expiresAt?: Date;
48
- revokedAt?: Date;
49
- /** reveals are counted and timestamped — reading back a key is worth an audit trail */
50
- revealCount: number;
51
- lastRevealedAt?: Date;
52
- createdAt: Date;
53
- updatedAt: Date;
54
- }
55
-
56
- const ApiKeySchema = new Schema<IApiKey>(
57
- {
58
- userId: { type: String, required: true, index: true },
59
- userEmail: { type: String, required: true },
60
- userName: { type: String, required: true, default: '' },
61
- name: { type: String, required: true, trim: true, maxlength: 60 },
62
- keyId: { type: String, required: true, unique: true, index: true },
63
- hash: { type: String, required: true },
64
- sealedCiphertext: { type: String, required: true },
65
- sealedIv: { type: String, required: true },
66
- sealedTag: { type: String, required: true },
67
- scopes: { type: [String], default: [] },
68
- // Defaulted rather than optional: which data a key reaches is a decision the
69
- // creator made, and it must not be indistinguishable from a field nobody set.
70
- tenants: { type: [String], default: () => [PERSONAL_TENANT] },
71
- defaultTenant: { type: String, default: PERSONAL_TENANT },
72
- // Written by the single-tenant model this replaced; read-only now.
73
- groupId: { type: String, default: null },
74
- lastUsedAt: { type: Date },
75
- expiresAt: { type: Date },
76
- revokedAt: { type: Date },
77
- revealCount: { type: Number, default: 0 },
78
- lastRevealedAt: { type: Date }
79
- },
80
- { timestamps: true, collection: 'api_keys' }
81
- );
82
-
83
- // Every listing is one user's keys, newest first.
84
- ApiKeySchema.index({ userId: 1, createdAt: -1 });
85
-
86
- /**
87
- * Issuing and revoking a key is worth a record — arguably more than anything a
88
- * key goes on to do, since a credential nobody remembers creating is the one
89
- * that matters.
90
- *
91
- * Every secret-bearing field is redacted. The trail is readable by the account
92
- * owner and kept for a year; a snapshot carrying `hash` or the sealed
93
- * ciphertext would put credential material into a second collection with a
94
- * different lifetime, which is precisely the thing the encryption is for.
95
- */
96
- export const API_KEY_SECRET_FIELDS = ['hash', 'sealedCiphertext', 'sealedIv', 'sealedTag'];
97
-
98
- ApiKeySchema.plugin(auditPlugin, { resource: 'api_key', redact: API_KEY_SECRET_FIELDS });
99
-
100
- export const ApiKey = mongoose.models.ApiKey
101
- ? (mongoose.models.ApiKey as mongoose.Model<IApiKey>)
102
- : mongoose.model<IApiKey>('ApiKey', ApiKeySchema);
@@ -1,161 +0,0 @@
1
- const ORIGINAL_ENV = process.env;
2
-
3
- import {
4
- generateKey,
5
- parseKey,
6
- looksLikeApiKey,
7
- secretMatches,
8
- encryptSecret,
9
- decryptSecret,
10
- displayPrefix,
11
- sha256,
12
- resetEncryptionKeyCache
13
- } from './crypto';
14
-
15
- beforeEach(() => {
16
- process.env = { ...ORIGINAL_ENV, API_KEY_ENCRYPTION_SECRET: 'test-encryption-secret' };
17
- resetEncryptionKeyCache();
18
- });
19
-
20
- afterEach(() => {
21
- process.env = ORIGINAL_ENV;
22
- resetEncryptionKeyCache();
23
- });
24
-
25
- describe('generateKey', () => {
26
- it('produces a prefixed token whose parts round-trip through parseKey', () => {
27
- const { token, id, hash } = generateKey();
28
- const parsed = parseKey(token);
29
-
30
- expect(token.startsWith('tmb_live_')).toBe(true);
31
- expect(parsed).not.toBeNull();
32
- expect(parsed!.id).toBe(id);
33
- expect(sha256(parsed!.secret)).toBe(hash);
34
- });
35
-
36
- it('never repeats a key', () => {
37
- const tokens = new Set(Array.from({ length: 50 }, () => generateKey().token));
38
- expect(tokens.size).toBe(50);
39
- });
40
-
41
- it('does not store the secret in recoverable form in the hash', () => {
42
- const { token, hash } = generateKey();
43
- expect(hash).not.toContain(token);
44
- expect(hash).toMatch(/^[0-9a-f]{64}$/);
45
- });
46
- });
47
-
48
- describe('parseKey', () => {
49
- it.each([
50
- ['a non-string', 12345],
51
- ['an empty string', ''],
52
- ['a foreign prefix', 'sk_live_abcdef123456_secretsecretsecret'],
53
- ['too few parts', 'tmb_live_abcdef123456'],
54
- ['a missing secret', 'tmb_live_abcdef123456_'],
55
- ['a non-hex id', 'tmb_live_ZZZZZZZZZZZZ_secretsecretsecret'],
56
- ['a short id', 'tmb_live_abc_secretsecretsecret'],
57
- ['a truncated secret', 'tmb_live_abcdef123456_short']
58
- ])('rejects %s', (_label, token) => {
59
- expect(parseKey(token)).toBeNull();
60
- });
61
-
62
- it('accepts a secret containing underscores, which base64url produces', () => {
63
- // The alphabet is A-Za-z0-9-_, so roughly a third of real keys carry an
64
- // underscore in the secret. Splitting the token on '_' rejected those.
65
- expect(parseKey('tmb_live_abcdef123456_aa_bb_cc-dd_eeffgghhiijj')).toEqual({
66
- id: 'abcdef123456',
67
- secret: 'aa_bb_cc-dd_eeffgghhiijj'
68
- });
69
- });
70
-
71
- it('round-trips every generated key, underscores and all', () => {
72
- for (let i = 0; i < 200; i++) {
73
- const { token, id } = generateKey();
74
- expect(parseKey(token)?.id).toBe(id);
75
- }
76
- });
77
-
78
- it('accepts a well-formed key', () => {
79
- expect(parseKey('tmb_live_abcdef123456_aaaaaaaaaaaaaaaaaaaa')).toEqual({
80
- id: 'abcdef123456',
81
- secret: 'aaaaaaaaaaaaaaaaaaaa'
82
- });
83
- });
84
- });
85
-
86
- describe('looksLikeApiKey', () => {
87
- it('separates our keys from JWTs so the caller can skip a pointless verify', () => {
88
- expect(looksLikeApiKey(generateKey().token)).toBe(true);
89
- expect(looksLikeApiKey('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc.def')).toBe(false);
90
- expect(looksLikeApiKey(undefined)).toBe(false);
91
- });
92
- });
93
-
94
- describe('secretMatches', () => {
95
- it('accepts the right secret and rejects a wrong one', () => {
96
- const { token, hash } = generateKey();
97
- const { secret } = parseKey(token)!;
98
-
99
- expect(secretMatches(secret, hash)).toBe(true);
100
- expect(secretMatches(`${secret}x`, hash)).toBe(false);
101
- });
102
-
103
- it('returns false rather than throwing on a malformed stored hash', () => {
104
- // timingSafeEqual throws on a length mismatch, which would turn a corrupt
105
- // record into a 500 instead of a failed authentication.
106
- expect(secretMatches('anything', 'not-a-sha256')).toBe(false);
107
- });
108
- });
109
-
110
- describe('encryptSecret / decryptSecret', () => {
111
- it('round-trips a token', () => {
112
- const { token } = generateKey();
113
- expect(decryptSecret(encryptSecret(token))).toBe(token);
114
- });
115
-
116
- it('produces different ciphertext each time, so equal keys are not detectable', () => {
117
- const { token } = generateKey();
118
- const a = encryptSecret(token);
119
- const b = encryptSecret(token);
120
-
121
- expect(a.ciphertext).not.toBe(b.ciphertext);
122
- expect(a.iv).not.toBe(b.iv);
123
- });
124
-
125
- it('refuses to decrypt tampered ciphertext rather than returning rubbish', () => {
126
- const sealed = encryptSecret(generateKey().token);
127
- const flipped = Buffer.from(sealed.ciphertext, 'base64');
128
- flipped[0] ^= 0xff;
129
-
130
- expect(() =>
131
- decryptSecret({ ...sealed, ciphertext: flipped.toString('base64') })
132
- ).toThrow();
133
- });
134
-
135
- it('cannot be decrypted with a different encryption secret', () => {
136
- const sealed = encryptSecret(generateKey().token);
137
-
138
- process.env.API_KEY_ENCRYPTION_SECRET = 'a-completely-different-secret';
139
- resetEncryptionKeyCache();
140
-
141
- expect(() => decryptSecret(sealed)).toThrow();
142
- });
143
-
144
- it('requires the encryption secret to be configured at all', () => {
145
- delete process.env.API_KEY_ENCRYPTION_SECRET;
146
- resetEncryptionKeyCache();
147
-
148
- expect(() => encryptSecret('tmb_live_x')).toThrow(/API_KEY_ENCRYPTION_SECRET/);
149
- });
150
- });
151
-
152
- describe('displayPrefix', () => {
153
- it('identifies a key without exposing enough to use it', () => {
154
- const { token, id } = generateKey();
155
- const prefix = displayPrefix(id);
156
-
157
- expect(prefix).toBe(`tmb_live_${id}`);
158
- expect(token.startsWith(prefix)).toBe(true);
159
- expect(prefix.length).toBeLessThan(token.length);
160
- });
161
- });
@@ -1,163 +0,0 @@
1
- import {
2
- createCipheriv,
3
- createDecipheriv,
4
- createHash,
5
- randomBytes,
6
- scryptSync,
7
- timingSafeEqual
8
- } from 'crypto';
9
- import { requireEnv } from '../config/env';
10
-
11
- /**
12
- * Key material and the two different things we do with it.
13
- *
14
- * A presented key is checked against a SHA-256 digest — fast, because it runs on
15
- * every authenticated request. Plain SHA-256 rather than bcrypt is right here
16
- * and wrong for passwords: the secret is 32 bytes from a CSPRNG, so there is no
17
- * dictionary to attack and nothing for a slow hash to buy.
18
- *
19
- * Separately, the key is stored encrypted so the owner can look it up again
20
- * later. That is a deliberate trade — see `encryptSecret` — and the reason the
21
- * digest is kept as well: verification never touches the reversible copy.
22
- */
23
-
24
- /** `tmb_live_<id>_<secret>` — the prefix makes a leaked key greppable in logs. */
25
- const KEY_PREFIX = 'tmb_live';
26
- const ID_BYTES = 6; // 12 hex chars, the public half
27
- const SECRET_BYTES = 32;
28
-
29
- const ALGORITHM = 'aes-256-gcm';
30
- const IV_BYTES = 12;
31
- /** Fixed: the input is a high-entropy env secret, not a password, so a per-record salt buys nothing. */
32
- const KDF_SALT = 'tumbaland/api-key/v1';
33
-
34
- export interface GeneratedKey {
35
- /** the whole key, shown to the user and never stored in this form */
36
- token: string;
37
- /** the public half, indexed for lookup */
38
- id: string;
39
- /** SHA-256 of the secret half, for verification */
40
- hash: string;
41
- }
42
-
43
- /** Derive the AES key once per process; scrypt is deliberately slow. */
44
- let cachedKey: Buffer | null = null;
45
- function encryptionKey(): Buffer {
46
- if (!cachedKey) {
47
- cachedKey = scryptSync(requireEnv('API_KEY_ENCRYPTION_SECRET'), KDF_SALT, 32);
48
- }
49
- return cachedKey;
50
- }
51
-
52
- /** Test seam: the derived key is cached for the life of the process. */
53
- export const resetEncryptionKeyCache = (): void => {
54
- cachedKey = null;
55
- };
56
-
57
- /**
58
- * Whether keys can be issued and revealed at all.
59
- *
60
- * Only the reversible copy needs the secret — verification runs off the digest —
61
- * so a service that merely accepts keys works without it. That asymmetry is easy
62
- * to get wrong in a deployment, so callers can ask rather than discovering it
63
- * through a 500 on someone's first key.
64
- */
65
- export const isEncryptionConfigured = (): boolean =>
66
- Boolean(process.env.API_KEY_ENCRYPTION_SECRET);
67
-
68
- export const sha256 = (value: string): string =>
69
- createHash('sha256').update(value, 'utf8').digest('hex');
70
-
71
- export const generateKey = (): GeneratedKey => {
72
- const id = randomBytes(ID_BYTES).toString('hex');
73
- const secret = randomBytes(SECRET_BYTES).toString('base64url');
74
-
75
- return {
76
- token: `${KEY_PREFIX}_${id}_${secret}`,
77
- id,
78
- hash: sha256(secret)
79
- };
80
- };
81
-
82
- export interface ParsedKey {
83
- id: string;
84
- secret: string;
85
- }
86
-
87
- /**
88
- * Split a presented key into its public id and its secret half.
89
- *
90
- * Returns null rather than throwing: an unparseable token is an ordinary failed
91
- * authentication, not an exceptional condition, and the caller answers both the
92
- * same way.
93
- */
94
- export const parseKey = (token: unknown): ParsedKey | null => {
95
- if (typeof token !== 'string') return null;
96
-
97
- // Matched positionally rather than split on '_': the base64url alphabet
98
- // includes '_', so a secret can contain any number of them and splitting
99
- // would reject roughly a third of otherwise valid keys. The id is
100
- // fixed-width, so everything after it is the secret.
101
- const match = token.match(/^tmb_live_([0-9a-f]{12})_(.+)$/);
102
- if (!match) return null;
103
- if (match[2].length < 16) return null;
104
-
105
- return { id: match[1], secret: match[2] };
106
- };
107
-
108
- /** True when a token even looks like one of ours — lets the caller skip a JWT parse. */
109
- export const looksLikeApiKey = (token: unknown): boolean =>
110
- typeof token === 'string' && token.startsWith(`${KEY_PREFIX}_`);
111
-
112
- /** Constant-time digest comparison, so a wrong key leaks nothing through timing. */
113
- export const secretMatches = (secret: string, expectedHash: string): boolean => {
114
- const presented = Buffer.from(sha256(secret), 'hex');
115
- const expected = Buffer.from(expectedHash, 'hex');
116
- if (presented.length !== expected.length) return false;
117
- return timingSafeEqual(presented, expected);
118
- };
119
-
120
- export interface SealedSecret {
121
- ciphertext: string;
122
- iv: string;
123
- tag: string;
124
- }
125
-
126
- /**
127
- * Encrypt the key so it can be shown again later.
128
- *
129
- * Storing a recoverable copy is weaker than hashing alone: whoever holds both
130
- * the database and `API_KEY_ENCRYPTION_SECRET` can mint the plaintext. It buys
131
- * the ability to re-read a key you have lost, which the product wants. The
132
- * secret lives outside the database precisely so that a dump, a backup, or a
133
- * read-only replica is not on its own enough.
134
- */
135
- export const encryptSecret = (token: string): SealedSecret => {
136
- const iv = randomBytes(IV_BYTES);
137
- const cipher = createCipheriv(ALGORITHM, encryptionKey(), iv);
138
- const ciphertext = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
139
-
140
- return {
141
- ciphertext: ciphertext.toString('base64'),
142
- iv: iv.toString('base64'),
143
- tag: cipher.getAuthTag().toString('base64')
144
- };
145
- };
146
-
147
- /**
148
- * Recover a stored key. Throws when the ciphertext has been tampered with —
149
- * GCM authenticates, so a modified record fails loudly instead of returning
150
- * plausible rubbish.
151
- */
152
- export const decryptSecret = (sealed: SealedSecret): string => {
153
- const decipher = createDecipheriv(ALGORITHM, encryptionKey(), Buffer.from(sealed.iv, 'base64'));
154
- decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
155
-
156
- return Buffer.concat([
157
- decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
158
- decipher.final()
159
- ]).toString('utf8');
160
- };
161
-
162
- /** What the UI shows in a list: enough to tell two keys apart, not enough to use one. */
163
- export const displayPrefix = (id: string): string => `${KEY_PREFIX}_${id}`;
@@ -1,72 +0,0 @@
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
- });
@@ -1,35 +0,0 @@
1
- export { ApiKey } from './ApiKey';
2
- export type { IApiKey } from './ApiKey';
3
- export {
4
- createApiKey,
5
- listApiKeys,
6
- revealApiKey,
7
- revokeApiKey,
8
- deleteApiKey,
9
- verifyApiKey
10
- } from './service';
11
- export type { CreateApiKeyInput, CreatedApiKey } from './service';
12
- export { authenticateAgent, requireScope, denyApiKeys } from './middleware';
13
- export type { ApiKeyContext } from './middleware';
14
- export {
15
- API_KEY_SCOPES,
16
- isApiKeyScope,
17
- PERSONAL_TENANT,
18
- groupIdOf,
19
- groupIdsOf,
20
- readTenants
21
- } from './types';
22
- export type {
23
- ApiKeyScope,
24
- ApiKeySummary,
25
- ApiKeyVerification,
26
- ApiKeyRejection,
27
- ApiKeyTenant,
28
- Tenant
29
- } from './types';
30
- export {
31
- looksLikeApiKey,
32
- displayPrefix,
33
- isEncryptionConfigured,
34
- resetEncryptionKeyCache
35
- } from './crypto';