@shipfox/api-secrets 2.0.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 (288) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +116 -0
  5. package/LICENSE +21 -0
  6. package/README.md +207 -0
  7. package/dist/config.d.ts +8 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +38 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/core/crypto.d.ts +25 -0
  12. package/dist/core/crypto.d.ts.map +1 -0
  13. package/dist/core/crypto.js +85 -0
  14. package/dist/core/crypto.js.map +1 -0
  15. package/dist/core/dek-manager.d.ts +16 -0
  16. package/dist/core/dek-manager.d.ts.map +1 -0
  17. package/dist/core/dek-manager.js +85 -0
  18. package/dist/core/dek-manager.js.map +1 -0
  19. package/dist/core/errors.d.ts +53 -0
  20. package/dist/core/errors.d.ts.map +1 -0
  21. package/dist/core/errors.js +86 -0
  22. package/dist/core/errors.js.map +1 -0
  23. package/dist/core/fingerprint.d.ts +2 -0
  24. package/dist/core/fingerprint.d.ts.map +1 -0
  25. package/dist/core/fingerprint.js +9 -0
  26. package/dist/core/fingerprint.js.map +1 -0
  27. package/dist/core/index.d.ts +30 -0
  28. package/dist/core/index.d.ts.map +1 -0
  29. package/dist/core/index.js +97 -0
  30. package/dist/core/index.js.map +1 -0
  31. package/dist/core/key-provider.d.ts +17 -0
  32. package/dist/core/key-provider.d.ts.map +1 -0
  33. package/dist/core/key-provider.js +45 -0
  34. package/dist/core/key-provider.js.map +1 -0
  35. package/dist/core/local-secret-store.d.ts +19 -0
  36. package/dist/core/local-secret-store.d.ts.map +1 -0
  37. package/dist/core/local-secret-store.js +40 -0
  38. package/dist/core/local-secret-store.js.map +1 -0
  39. package/dist/core/management.d.ts +37 -0
  40. package/dist/core/management.d.ts.map +1 -0
  41. package/dist/core/management.js +403 -0
  42. package/dist/core/management.js.map +1 -0
  43. package/dist/core/rotate-kek.d.ts +10 -0
  44. package/dist/core/rotate-kek.d.ts.map +1 -0
  45. package/dist/core/rotate-kek.js +94 -0
  46. package/dist/core/rotate-kek.js.map +1 -0
  47. package/dist/core/secret-store.d.ts +33 -0
  48. package/dist/core/secret-store.d.ts.map +1 -0
  49. package/dist/core/secret-store.js +209 -0
  50. package/dist/core/secret-store.js.map +1 -0
  51. package/dist/core/store-resolver.d.ts +18 -0
  52. package/dist/core/store-resolver.d.ts.map +1 -0
  53. package/dist/core/store-resolver.js +10 -0
  54. package/dist/core/store-resolver.js.map +1 -0
  55. package/dist/core/store-validation.d.ts +11 -0
  56. package/dist/core/store-validation.d.ts.map +1 -0
  57. package/dist/core/store-validation.js +30 -0
  58. package/dist/core/store-validation.js.map +1 -0
  59. package/dist/core/value-cipher.d.ts +10 -0
  60. package/dist/core/value-cipher.d.ts.map +1 -0
  61. package/dist/core/value-cipher.js +22 -0
  62. package/dist/core/value-cipher.js.map +1 -0
  63. package/dist/core/variable-store.d.ts +24 -0
  64. package/dist/core/variable-store.d.ts.map +1 -0
  65. package/dist/core/variable-store.js +194 -0
  66. package/dist/core/variable-store.js.map +1 -0
  67. package/dist/db/cap.d.ts +8 -0
  68. package/dist/db/cap.d.ts.map +1 -0
  69. package/dist/db/cap.js +39 -0
  70. package/dist/db/cap.js.map +1 -0
  71. package/dist/db/data-keys.d.ts +24 -0
  72. package/dist/db/data-keys.d.ts.map +1 -0
  73. package/dist/db/data-keys.js +61 -0
  74. package/dist/db/data-keys.js.map +1 -0
  75. package/dist/db/db.d.ts +1254 -0
  76. package/dist/db/db.d.ts.map +1 -0
  77. package/dist/db/db.js +24 -0
  78. package/dist/db/db.js.map +1 -0
  79. package/dist/db/index.d.ts +13 -0
  80. package/dist/db/index.d.ts.map +1 -0
  81. package/dist/db/index.js +15 -0
  82. package/dist/db/index.js.map +1 -0
  83. package/dist/db/management.d.ts +50 -0
  84. package/dist/db/management.d.ts.map +1 -0
  85. package/dist/db/management.js +127 -0
  86. package/dist/db/management.js.map +1 -0
  87. package/dist/db/schema/common.d.ts +3 -0
  88. package/dist/db/schema/common.d.ts.map +1 -0
  89. package/dist/db/schema/common.js +8 -0
  90. package/dist/db/schema/common.js.map +1 -0
  91. package/dist/db/schema/data-keys.d.ts +103 -0
  92. package/dist/db/schema/data-keys.d.ts.map +1 -0
  93. package/dist/db/schema/data-keys.js +24 -0
  94. package/dist/db/schema/data-keys.js.map +1 -0
  95. package/dist/db/schema/outbox.d.ts +195 -0
  96. package/dist/db/schema/outbox.d.ts.map +1 -0
  97. package/dist/db/schema/outbox.js +5 -0
  98. package/dist/db/schema/outbox.js.map +1 -0
  99. package/dist/db/schema/values.d.ts +193 -0
  100. package/dist/db/schema/values.d.ts.map +1 -0
  101. package/dist/db/schema/values.js +43 -0
  102. package/dist/db/schema/values.js.map +1 -0
  103. package/dist/db/schema/variables.d.ts +175 -0
  104. package/dist/db/schema/variables.d.ts.map +1 -0
  105. package/dist/db/schema/variables.js +41 -0
  106. package/dist/db/schema/variables.js.map +1 -0
  107. package/dist/db/scope.d.ts +20 -0
  108. package/dist/db/scope.d.ts.map +1 -0
  109. package/dist/db/scope.js +19 -0
  110. package/dist/db/scope.js.map +1 -0
  111. package/dist/db/values.d.ts +33 -0
  112. package/dist/db/values.d.ts.map +1 -0
  113. package/dist/db/values.js +119 -0
  114. package/dist/db/values.js.map +1 -0
  115. package/dist/db/variables.d.ts +32 -0
  116. package/dist/db/variables.d.ts.map +1 -0
  117. package/dist/db/variables.js +117 -0
  118. package/dist/db/variables.js.map +1 -0
  119. package/dist/index.d.ts +4 -0
  120. package/dist/index.d.ts.map +1 -0
  121. package/dist/index.js +28 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/metrics/index.d.ts +3 -0
  124. package/dist/metrics/index.d.ts.map +1 -0
  125. package/dist/metrics/index.js +4 -0
  126. package/dist/metrics/index.js.map +1 -0
  127. package/dist/metrics/instance.d.ts +39 -0
  128. package/dist/metrics/instance.d.ts.map +1 -0
  129. package/dist/metrics/instance.js +151 -0
  130. package/dist/metrics/instance.js.map +1 -0
  131. package/dist/metrics/service.d.ts +2 -0
  132. package/dist/metrics/service.d.ts.map +1 -0
  133. package/dist/metrics/service.js +21 -0
  134. package/dist/metrics/service.js.map +1 -0
  135. package/dist/presentation/dto/index.d.ts +2 -0
  136. package/dist/presentation/dto/index.d.ts.map +1 -0
  137. package/dist/presentation/dto/index.js +3 -0
  138. package/dist/presentation/dto/index.js.map +1 -0
  139. package/dist/presentation/dto/management.d.ts +7 -0
  140. package/dist/presentation/dto/management.d.ts.map +1 -0
  141. package/dist/presentation/dto/management.js +32 -0
  142. package/dist/presentation/dto/management.js.map +1 -0
  143. package/dist/presentation/e2eRoutes/create-secret.d.ts +2 -0
  144. package/dist/presentation/e2eRoutes/create-secret.d.ts.map +1 -0
  145. package/dist/presentation/e2eRoutes/create-secret.js +34 -0
  146. package/dist/presentation/e2eRoutes/create-secret.js.map +1 -0
  147. package/dist/presentation/e2eRoutes/create-variable.d.ts +2 -0
  148. package/dist/presentation/e2eRoutes/create-variable.d.ts.map +1 -0
  149. package/dist/presentation/e2eRoutes/create-variable.js +34 -0
  150. package/dist/presentation/e2eRoutes/create-variable.js.map +1 -0
  151. package/dist/presentation/e2eRoutes/index.d.ts +3 -0
  152. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  153. package/dist/presentation/e2eRoutes/index.js +11 -0
  154. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  155. package/dist/presentation/routes/auth.d.ts +16 -0
  156. package/dist/presentation/routes/auth.d.ts.map +1 -0
  157. package/dist/presentation/routes/auth.js +42 -0
  158. package/dist/presentation/routes/auth.js.map +1 -0
  159. package/dist/presentation/routes/batch-secrets.d.ts +2 -0
  160. package/dist/presentation/routes/batch-secrets.d.ts.map +1 -0
  161. package/dist/presentation/routes/batch-secrets.js +44 -0
  162. package/dist/presentation/routes/batch-secrets.js.map +1 -0
  163. package/dist/presentation/routes/batch-variables.d.ts +2 -0
  164. package/dist/presentation/routes/batch-variables.d.ts.map +1 -0
  165. package/dist/presentation/routes/batch-variables.js +44 -0
  166. package/dist/presentation/routes/batch-variables.js.map +1 -0
  167. package/dist/presentation/routes/cursor.d.ts +3 -0
  168. package/dist/presentation/routes/cursor.d.ts.map +1 -0
  169. package/dist/presentation/routes/cursor.js +19 -0
  170. package/dist/presentation/routes/cursor.js.map +1 -0
  171. package/dist/presentation/routes/delete-secret.d.ts +2 -0
  172. package/dist/presentation/routes/delete-secret.d.ts.map +1 -0
  173. package/dist/presentation/routes/delete-secret.js +40 -0
  174. package/dist/presentation/routes/delete-secret.js.map +1 -0
  175. package/dist/presentation/routes/delete-variable.d.ts +2 -0
  176. package/dist/presentation/routes/delete-variable.d.ts.map +1 -0
  177. package/dist/presentation/routes/delete-variable.js +40 -0
  178. package/dist/presentation/routes/delete-variable.js.map +1 -0
  179. package/dist/presentation/routes/errors.d.ts +2 -0
  180. package/dist/presentation/routes/errors.d.ts.map +1 -0
  181. package/dist/presentation/routes/errors.js +73 -0
  182. package/dist/presentation/routes/errors.js.map +1 -0
  183. package/dist/presentation/routes/get-variable.d.ts +2 -0
  184. package/dist/presentation/routes/get-variable.d.ts.map +1 -0
  185. package/dist/presentation/routes/get-variable.js +42 -0
  186. package/dist/presentation/routes/get-variable.js.map +1 -0
  187. package/dist/presentation/routes/index.d.ts +3 -0
  188. package/dist/presentation/routes/index.d.ts.map +1 -0
  189. package/dist/presentation/routes/index.js +29 -0
  190. package/dist/presentation/routes/index.js.map +1 -0
  191. package/dist/presentation/routes/list-secrets.d.ts +2 -0
  192. package/dist/presentation/routes/list-secrets.d.ts.map +1 -0
  193. package/dist/presentation/routes/list-secrets.js +50 -0
  194. package/dist/presentation/routes/list-secrets.js.map +1 -0
  195. package/dist/presentation/routes/list-variables.d.ts +2 -0
  196. package/dist/presentation/routes/list-variables.d.ts.map +1 -0
  197. package/dist/presentation/routes/list-variables.js +50 -0
  198. package/dist/presentation/routes/list-variables.js.map +1 -0
  199. package/dist/presentation/routes/put-secret.d.ts +2 -0
  200. package/dist/presentation/routes/put-secret.d.ts.map +1 -0
  201. package/dist/presentation/routes/put-secret.js +52 -0
  202. package/dist/presentation/routes/put-secret.js.map +1 -0
  203. package/dist/presentation/routes/put-variable.d.ts +2 -0
  204. package/dist/presentation/routes/put-variable.d.ts.map +1 -0
  205. package/dist/presentation/routes/put-variable.js +52 -0
  206. package/dist/presentation/routes/put-variable.js.map +1 -0
  207. package/dist/presentation/routes/warnings.d.ts +5 -0
  208. package/dist/presentation/routes/warnings.d.ts.map +1 -0
  209. package/dist/presentation/routes/warnings.js +16 -0
  210. package/dist/presentation/routes/warnings.js.map +1 -0
  211. package/dist/tsconfig.test.tsbuildinfo +1 -0
  212. package/drizzle/0000_lowly_ogun.sql +59 -0
  213. package/drizzle/meta/0000_snapshot.json +547 -0
  214. package/drizzle/meta/_journal.json +13 -0
  215. package/drizzle.config.ts +7 -0
  216. package/package.json +77 -0
  217. package/src/config.test.ts +41 -0
  218. package/src/config.ts +41 -0
  219. package/src/core/crypto.test.ts +115 -0
  220. package/src/core/crypto.ts +105 -0
  221. package/src/core/dek-manager.test.ts +120 -0
  222. package/src/core/dek-manager.ts +98 -0
  223. package/src/core/errors.ts +100 -0
  224. package/src/core/fingerprint.test.ts +27 -0
  225. package/src/core/fingerprint.ts +14 -0
  226. package/src/core/index.ts +153 -0
  227. package/src/core/key-provider.test.ts +44 -0
  228. package/src/core/key-provider.ts +70 -0
  229. package/src/core/local-secret-store.ts +49 -0
  230. package/src/core/management.test.ts +37 -0
  231. package/src/core/management.ts +503 -0
  232. package/src/core/rotate-kek.test.ts +183 -0
  233. package/src/core/rotate-kek.ts +100 -0
  234. package/src/core/secret-store.test.ts +301 -0
  235. package/src/core/secret-store.ts +251 -0
  236. package/src/core/store-resolver.test.ts +20 -0
  237. package/src/core/store-resolver.ts +28 -0
  238. package/src/core/store-validation.ts +43 -0
  239. package/src/core/value-cipher.ts +26 -0
  240. package/src/core/variable-store.test.ts +106 -0
  241. package/src/core/variable-store.ts +232 -0
  242. package/src/db/cap.ts +58 -0
  243. package/src/db/data-keys.ts +99 -0
  244. package/src/db/db.ts +26 -0
  245. package/src/db/index.ts +45 -0
  246. package/src/db/management.ts +252 -0
  247. package/src/db/schema/common.ts +8 -0
  248. package/src/db/schema/data-keys.ts +31 -0
  249. package/src/db/schema/outbox.ts +4 -0
  250. package/src/db/schema/values.ts +74 -0
  251. package/src/db/schema/variables.ts +71 -0
  252. package/src/db/scope.ts +42 -0
  253. package/src/db/values.ts +191 -0
  254. package/src/db/variables.ts +187 -0
  255. package/src/index.test.ts +11 -0
  256. package/src/index.ts +50 -0
  257. package/src/metrics/index.ts +19 -0
  258. package/src/metrics/instance.ts +219 -0
  259. package/src/metrics/service.ts +19 -0
  260. package/src/presentation/dto/index.ts +1 -0
  261. package/src/presentation/dto/management.ts +36 -0
  262. package/src/presentation/e2eRoutes/create-secret.ts +31 -0
  263. package/src/presentation/e2eRoutes/create-variable.ts +34 -0
  264. package/src/presentation/e2eRoutes/index.test.ts +94 -0
  265. package/src/presentation/e2eRoutes/index.ts +8 -0
  266. package/src/presentation/routes/auth.ts +50 -0
  267. package/src/presentation/routes/batch-secrets.ts +33 -0
  268. package/src/presentation/routes/batch-variables.ts +33 -0
  269. package/src/presentation/routes/cursor.ts +23 -0
  270. package/src/presentation/routes/delete-secret.ts +26 -0
  271. package/src/presentation/routes/delete-variable.ts +26 -0
  272. package/src/presentation/routes/errors.ts +63 -0
  273. package/src/presentation/routes/get-variable.ts +32 -0
  274. package/src/presentation/routes/index.ts +29 -0
  275. package/src/presentation/routes/list-secrets.ts +41 -0
  276. package/src/presentation/routes/list-variables.ts +41 -0
  277. package/src/presentation/routes/management.test.ts +452 -0
  278. package/src/presentation/routes/put-secret.ts +39 -0
  279. package/src/presentation/routes/put-variable.ts +39 -0
  280. package/src/presentation/routes/warnings.ts +23 -0
  281. package/test/env.ts +8 -0
  282. package/test/globalSetup.ts +18 -0
  283. package/test/setup.ts +17 -0
  284. package/tsconfig.build.json +10 -0
  285. package/tsconfig.build.tsbuildinfo +1 -0
  286. package/tsconfig.json +7 -0
  287. package/tsconfig.test.json +8 -0
  288. package/vitest.config.ts +12 -0
@@ -0,0 +1,43 @@
1
+ import {isSystemNamespace, namespaceSchema, secretKeySchema} from '@shipfox/api-secrets-dto';
2
+ import {config, MAX_VALUE_BYTES} from '#config.js';
3
+ import {countWorkspaceEntries, type Tx} from '#db/index.js';
4
+ import {
5
+ NamespaceValidationError,
6
+ SecretKeyValidationError,
7
+ SecretValueTooLargeError,
8
+ WorkspaceSecretCapExceededError,
9
+ } from './errors.js';
10
+
11
+ export function validateNamespace(namespace: string): void {
12
+ if (namespaceSchema.safeParse(namespace).success) return;
13
+ throw new NamespaceValidationError(namespace);
14
+ }
15
+
16
+ export function validateSecretKeys(keys: Iterable<string>): void {
17
+ for (const key of keys) {
18
+ if (!secretKeySchema.safeParse(key).success) throw new SecretKeyValidationError(key);
19
+ }
20
+ }
21
+
22
+ export function validateValueBytes(values: Iterable<string>): void {
23
+ for (const value of values) {
24
+ if (Buffer.byteLength(value, 'utf8') > MAX_VALUE_BYTES) {
25
+ throw new SecretValueTooLargeError(MAX_VALUE_BYTES);
26
+ }
27
+ }
28
+ }
29
+
30
+ export async function assertWorkspaceCap(params: {
31
+ workspaceId: string;
32
+ namespace: string;
33
+ incomingEntries: number;
34
+ tx: Tx;
35
+ }): Promise<void> {
36
+ // System namespaces are internal-only; public management routes do not accept a namespace.
37
+ if (isSystemNamespace(params.namespace)) return;
38
+
39
+ const count = await countWorkspaceEntries(params.workspaceId, params.tx);
40
+ if (count + params.incomingEntries > config.SECRETS_MAX_PER_WORKSPACE) {
41
+ throw new WorkspaceSecretCapExceededError(params.workspaceId, config.SECRETS_MAX_PER_WORKSPACE);
42
+ }
43
+ }
@@ -0,0 +1,26 @@
1
+ import {aadForValue, aesGcmOpen, aesGcmSeal, type SecretValueAadParams} from './crypto.js';
2
+ import {SecretDecryptionError} from './errors.js';
3
+
4
+ export function encryptSecretValue(
5
+ params: SecretValueAadParams & {dek: Buffer; value: string},
6
+ ): string {
7
+ return aesGcmSeal({
8
+ key: params.dek,
9
+ plaintext: Buffer.from(params.value, 'utf8'),
10
+ aad: aadForValue(params),
11
+ });
12
+ }
13
+
14
+ export function decryptSecretValue(
15
+ params: SecretValueAadParams & {dek: Buffer; ciphertext: string},
16
+ ): string {
17
+ try {
18
+ return aesGcmOpen({
19
+ key: params.dek,
20
+ encoded: params.ciphertext,
21
+ aad: aadForValue(params),
22
+ }).toString('utf8');
23
+ } catch {
24
+ throw new SecretDecryptionError();
25
+ }
26
+ }
@@ -0,0 +1,106 @@
1
+ import crypto from 'node:crypto';
2
+ import {describe, expect, it} from '@shipfox/vitest/vi';
3
+ import {eq} from 'drizzle-orm';
4
+ import {db, secretVariables, upsertSecretVariableRows} from '#db/index.js';
5
+ import {SecretBatchScopeMismatchError, WorkspaceSecretCapExceededError} from './errors.js';
6
+ import {
7
+ deleteVariables,
8
+ getVariable,
9
+ getVariablesByNamespace,
10
+ setVariables,
11
+ } from './variable-store.js';
12
+
13
+ describe('variable store', () => {
14
+ it('stores plaintext variables and applies scope precedence', async () => {
15
+ const workspaceId = crypto.randomUUID();
16
+ const projectId = crypto.randomUUID();
17
+
18
+ await setVariables({workspaceId, values: {REGION: 'us-east-1'}});
19
+ await setVariables({workspaceId, projectId, values: {REGION: 'eu-west-1'}});
20
+ const projectValue = await getVariable({workspaceId, projectId, key: 'REGION'});
21
+ const values = await getVariablesByNamespace({workspaceId, projectId});
22
+ const rows = await db().select().from(secretVariables);
23
+
24
+ expect(projectValue).toBe('eu-west-1');
25
+ expect(values).toEqual({REGION: 'eu-west-1'});
26
+ expect(rows.map((row) => row.value).sort()).toEqual(['eu-west-1', 'us-east-1']);
27
+ });
28
+
29
+ it('normalizes an empty project id to workspace scope', async () => {
30
+ const workspaceId = crypto.randomUUID();
31
+
32
+ await setVariables({workspaceId, projectId: '', values: {REGION: 'us-east-1'}});
33
+ const value = await getVariable({workspaceId, key: 'REGION'});
34
+ const rows = await db()
35
+ .select()
36
+ .from(secretVariables)
37
+ .where(eq(secretVariables.workspaceId, workspaceId));
38
+
39
+ expect(value).toBe('us-east-1');
40
+ expect(rows[0]?.projectId).toBeNull();
41
+ });
42
+
43
+ it('treats an empty delete key list as a no-op', async () => {
44
+ const workspaceId = crypto.randomUUID();
45
+ await setVariables({workspaceId, values: {REGION: 'us-east-1'}});
46
+
47
+ const deleted = await deleteVariables({workspaceId, keys: []});
48
+ const value = await getVariable({workspaceId, key: 'REGION'});
49
+
50
+ expect(deleted).toBe(0);
51
+ expect(value).toBe('us-east-1');
52
+ });
53
+
54
+ it('enforces the workspace cap', async () => {
55
+ const workspaceId = crypto.randomUUID();
56
+ const values = Object.fromEntries(
57
+ Array.from({length: 10_001}, (_, index) => [`KEY_${index}`, 'value']),
58
+ );
59
+
60
+ await expect(setVariables({workspaceId, values})).rejects.toThrow(
61
+ WorkspaceSecretCapExceededError,
62
+ );
63
+ });
64
+
65
+ it('rejects mixed project scopes in one variable upsert batch', async () => {
66
+ const workspaceId = crypto.randomUUID();
67
+
68
+ await expect(
69
+ db().transaction((tx) =>
70
+ upsertSecretVariableRows(
71
+ [
72
+ {
73
+ workspaceId,
74
+ projectId: null,
75
+ namespace: '',
76
+ key: 'REGION',
77
+ value: 'us-east-1',
78
+ },
79
+ {
80
+ workspaceId,
81
+ projectId: crypto.randomUUID(),
82
+ namespace: '',
83
+ key: 'OTHER_REGION',
84
+ value: 'eu-west-1',
85
+ },
86
+ ],
87
+ tx,
88
+ ),
89
+ ),
90
+ ).rejects.toThrow(SecretBatchScopeMismatchError);
91
+ });
92
+
93
+ it('keeps the database namespace check in parity with the DTO pattern', async () => {
94
+ const workspaceId = crypto.randomUUID();
95
+
96
+ await expect(
97
+ db().insert(secretVariables).values({
98
+ workspaceId,
99
+ projectId: null,
100
+ namespace: 'Bad/Namespace',
101
+ key: 'REGION',
102
+ value: 'us-east-1',
103
+ }),
104
+ ).rejects.toThrow();
105
+ });
106
+ });
@@ -0,0 +1,232 @@
1
+ import {
2
+ countSecretVariableRows,
3
+ db,
4
+ deleteSecretVariableRows,
5
+ getSecretVariableRowWithPrecedence,
6
+ listSecretVariableRowsByNamespace,
7
+ lockWorkspaceEntries,
8
+ type StoreScope,
9
+ upsertSecretVariableRows,
10
+ } from '#db/index.js';
11
+ import {normalizedProjectId} from '#db/scope.js';
12
+ import {
13
+ classifySecretsOperationError,
14
+ operationScope,
15
+ recordSecretsEntriesMutated,
16
+ recordSecretsOperation,
17
+ } from '#metrics/instance.js';
18
+ import {
19
+ assertWorkspaceCap,
20
+ validateNamespace,
21
+ validateSecretKeys,
22
+ validateValueBytes,
23
+ } from './store-validation.js';
24
+
25
+ export interface SetVariablesParams extends StoreScope {
26
+ workspaceId: string;
27
+ namespace?: string | undefined;
28
+ values: Record<string, string>;
29
+ editedBy?: string | null | undefined;
30
+ }
31
+
32
+ export interface DeleteVariablesParams extends StoreScope {
33
+ workspaceId: string;
34
+ namespace?: string | undefined;
35
+ keys?: string[] | undefined;
36
+ }
37
+
38
+ export async function getVariable(
39
+ input: StoreScope & {workspaceId: string; namespace?: string | undefined; key: string},
40
+ ): Promise<string | null> {
41
+ const startedAt = Date.now();
42
+ const scope = operationScope(input);
43
+ try {
44
+ const namespace = input.namespace ?? '';
45
+ validateNamespace(namespace);
46
+ validateSecretKeys([input.key]);
47
+
48
+ const row = await getSecretVariableRowWithPrecedence({...input, namespace});
49
+ recordSecretsOperation({
50
+ resource: 'variable',
51
+ operation: 'get',
52
+ surface: 'internal',
53
+ scope,
54
+ outcome: row ? 'success' : 'not_found',
55
+ durationMs: Date.now() - startedAt,
56
+ });
57
+ return row?.value ?? null;
58
+ } catch (error) {
59
+ recordSecretsOperation({
60
+ resource: 'variable',
61
+ operation: 'get',
62
+ surface: 'internal',
63
+ scope,
64
+ outcome: classifySecretsOperationError(error),
65
+ durationMs: Date.now() - startedAt,
66
+ });
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ export async function getVariablesByNamespace(
72
+ input: StoreScope & {workspaceId: string; namespace?: string | undefined},
73
+ ): Promise<Record<string, string>> {
74
+ const startedAt = Date.now();
75
+ const scope = operationScope(input);
76
+ try {
77
+ const namespace = input.namespace ?? '';
78
+ validateNamespace(namespace);
79
+
80
+ const rows = await listSecretVariableRowsByNamespace({...input, namespace});
81
+ recordSecretsOperation({
82
+ resource: 'variable',
83
+ operation: 'get_namespace',
84
+ surface: 'internal',
85
+ scope,
86
+ outcome: 'success',
87
+ durationMs: Date.now() - startedAt,
88
+ });
89
+ return Object.fromEntries(rows.map((row) => [row.key, row.value]));
90
+ } catch (error) {
91
+ recordSecretsOperation({
92
+ resource: 'variable',
93
+ operation: 'get_namespace',
94
+ surface: 'internal',
95
+ scope,
96
+ outcome: classifySecretsOperationError(error),
97
+ durationMs: Date.now() - startedAt,
98
+ });
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ export async function setVariables(input: SetVariablesParams): Promise<void> {
104
+ const startedAt = Date.now();
105
+ const scope = operationScope(input);
106
+ try {
107
+ const namespace = input.namespace ?? '';
108
+ const entries = Object.entries(input.values);
109
+ validateNamespace(namespace);
110
+ validateSecretKeys(entries.map(([key]) => key));
111
+ validateValueBytes(entries.map(([, value]) => value));
112
+ if (entries.length === 0) {
113
+ recordSecretsOperation({
114
+ resource: 'variable',
115
+ operation: 'set',
116
+ surface: 'internal',
117
+ scope,
118
+ outcome: 'success',
119
+ durationMs: Date.now() - startedAt,
120
+ });
121
+ return;
122
+ }
123
+
124
+ const projectId = normalizedProjectId(input);
125
+ let existingEntries = 0;
126
+ await db().transaction(async (tx) => {
127
+ await lockWorkspaceEntries(input.workspaceId, tx);
128
+ existingEntries = await countSecretVariableRows(
129
+ {
130
+ workspaceId: input.workspaceId,
131
+ projectId,
132
+ namespace,
133
+ keys: entries.map(([key]) => key),
134
+ },
135
+ tx,
136
+ );
137
+ await assertWorkspaceCap({
138
+ workspaceId: input.workspaceId,
139
+ namespace,
140
+ incomingEntries: entries.length - existingEntries,
141
+ tx,
142
+ });
143
+ await upsertSecretVariableRows(
144
+ entries.map(([key, value]) => ({
145
+ workspaceId: input.workspaceId,
146
+ projectId,
147
+ namespace,
148
+ key,
149
+ value,
150
+ lastEditedBy: input.editedBy ?? null,
151
+ })),
152
+ tx,
153
+ );
154
+ });
155
+
156
+ recordSecretsEntriesMutated({
157
+ resource: 'variable',
158
+ operation: 'set',
159
+ effect: 'created',
160
+ surface: 'internal',
161
+ count: entries.length - existingEntries,
162
+ });
163
+ recordSecretsEntriesMutated({
164
+ resource: 'variable',
165
+ operation: 'set',
166
+ effect: 'updated',
167
+ surface: 'internal',
168
+ count: existingEntries,
169
+ });
170
+ recordSecretsOperation({
171
+ resource: 'variable',
172
+ operation: 'set',
173
+ surface: 'internal',
174
+ scope,
175
+ outcome: 'success',
176
+ durationMs: Date.now() - startedAt,
177
+ });
178
+ } catch (error) {
179
+ recordSecretsOperation({
180
+ resource: 'variable',
181
+ operation: 'set',
182
+ surface: 'internal',
183
+ scope,
184
+ outcome: classifySecretsOperationError(error),
185
+ durationMs: Date.now() - startedAt,
186
+ });
187
+ throw error;
188
+ }
189
+ }
190
+
191
+ export async function deleteVariables(input: DeleteVariablesParams): Promise<number> {
192
+ const startedAt = Date.now();
193
+ const scope = operationScope(input);
194
+ try {
195
+ const namespace = input.namespace ?? '';
196
+ validateNamespace(namespace);
197
+ if (input.keys) validateSecretKeys(input.keys);
198
+
199
+ const deleted = await deleteSecretVariableRows({
200
+ workspaceId: input.workspaceId,
201
+ projectId: normalizedProjectId(input),
202
+ namespace,
203
+ keys: input.keys,
204
+ });
205
+ recordSecretsEntriesMutated({
206
+ resource: 'variable',
207
+ operation: 'delete',
208
+ effect: 'deleted',
209
+ surface: 'internal',
210
+ count: deleted,
211
+ });
212
+ recordSecretsOperation({
213
+ resource: 'variable',
214
+ operation: 'delete',
215
+ surface: 'internal',
216
+ scope,
217
+ outcome: 'success',
218
+ durationMs: Date.now() - startedAt,
219
+ });
220
+ return deleted;
221
+ } catch (error) {
222
+ recordSecretsOperation({
223
+ resource: 'variable',
224
+ operation: 'delete',
225
+ surface: 'internal',
226
+ scope,
227
+ outcome: classifySecretsOperationError(error),
228
+ durationMs: Date.now() - startedAt,
229
+ });
230
+ throw error;
231
+ }
232
+ }
package/src/db/cap.ts ADDED
@@ -0,0 +1,58 @@
1
+ import {and, count, eq, notLike, sql} from 'drizzle-orm';
2
+ import {db, type Tx} from './db.js';
3
+ import {secretValues} from './schema/values.js';
4
+ import {secretVariables} from './schema/variables.js';
5
+
6
+ export async function countWorkspaceEntries(workspaceId: string, tx?: Tx): Promise<number> {
7
+ const executor = tx ?? db();
8
+ const valueCount = executor
9
+ .select({count: count().as('value_count')})
10
+ .from(secretValues)
11
+ .where(
12
+ and(eq(secretValues.workspaceId, workspaceId), notLike(secretValues.namespace, 'system/%')),
13
+ )
14
+ .as('value_count');
15
+ const variableCount = executor
16
+ .select({count: count().as('variable_count')})
17
+ .from(secretVariables)
18
+ .where(
19
+ and(
20
+ eq(secretVariables.workspaceId, workspaceId),
21
+ notLike(secretVariables.namespace, 'system/%'),
22
+ ),
23
+ )
24
+ .as('variable_count');
25
+ const [row] = await executor
26
+ .select({value: sql<number>`${valueCount.count} + ${variableCount.count}`})
27
+ .from(valueCount)
28
+ .crossJoin(variableCount);
29
+
30
+ return Number(row?.value ?? 0);
31
+ }
32
+
33
+ export async function countPublicEntriesByResource(
34
+ tx?: Tx,
35
+ ): Promise<{secrets: number; variables: number}> {
36
+ const executor = tx ?? db();
37
+ const [secretCount, variableCount] = await Promise.all([
38
+ executor
39
+ .select({count: count().as('secret_count')})
40
+ .from(secretValues)
41
+ .where(notLike(secretValues.namespace, 'system/%')),
42
+ executor
43
+ .select({count: count().as('variable_count')})
44
+ .from(secretVariables)
45
+ .where(notLike(secretVariables.namespace, 'system/%')),
46
+ ]);
47
+
48
+ return {
49
+ secrets: Number(secretCount[0]?.count ?? 0),
50
+ variables: Number(variableCount[0]?.count ?? 0),
51
+ };
52
+ }
53
+
54
+ export async function lockWorkspaceEntries(workspaceId: string, tx: Tx): Promise<void> {
55
+ await tx.execute(sql`
56
+ SELECT pg_advisory_xact_lock(hashtext('shipfox_secrets_workspace_cap'), hashtext(${workspaceId}))
57
+ `);
58
+ }
@@ -0,0 +1,99 @@
1
+ import {and, eq, gt, inArray, notInArray, type SQL, sql} from 'drizzle-orm';
2
+ import {db, type Tx} from './db.js';
3
+ import {type DataKey, secretDataKeys, toDataKey} from './schema/data-keys.js';
4
+
5
+ export async function getDataKey(workspaceId: string, tx?: Tx): Promise<DataKey | undefined> {
6
+ const executor = tx ?? db();
7
+ const rows = await executor
8
+ .select()
9
+ .from(secretDataKeys)
10
+ .where(eq(secretDataKeys.workspaceId, workspaceId))
11
+ .limit(1);
12
+ const row = rows[0];
13
+ return row ? toDataKey(row) : undefined;
14
+ }
15
+
16
+ export async function insertDataKeyIfAbsent(
17
+ dataKey: {workspaceId: string; wrappedDek: string; kekVersion: string},
18
+ tx?: Tx,
19
+ ): Promise<boolean> {
20
+ const executor = tx ?? db();
21
+ const rows = await executor
22
+ .insert(secretDataKeys)
23
+ .values(dataKey)
24
+ .onConflictDoNothing({
25
+ target: secretDataKeys.workspaceId,
26
+ })
27
+ .returning({workspaceId: secretDataKeys.workspaceId});
28
+ return rows.length > 0;
29
+ }
30
+
31
+ export async function updateDataKeyWrapCas(
32
+ params: {workspaceId: string; oldKekVersion: string; wrappedDek: string; kekVersion: string},
33
+ tx?: Tx,
34
+ ): Promise<boolean> {
35
+ const executor = tx ?? db();
36
+ const rows = await executor
37
+ .update(secretDataKeys)
38
+ .set({
39
+ wrappedDek: params.wrappedDek,
40
+ kekVersion: params.kekVersion,
41
+ rotatedAt: sql`NOW()`,
42
+ })
43
+ .where(
44
+ and(
45
+ eq(secretDataKeys.workspaceId, params.workspaceId),
46
+ eq(secretDataKeys.kekVersion, params.oldKekVersion),
47
+ ),
48
+ )
49
+ .returning({workspaceId: secretDataKeys.workspaceId});
50
+ return rows.length > 0;
51
+ }
52
+
53
+ export async function listDataKeyVersions(
54
+ knownVersions: string[],
55
+ params: {workspaceIds?: string[] | undefined} = {},
56
+ ): Promise<string[]> {
57
+ if (params.workspaceIds?.length === 0) return [];
58
+
59
+ const filters: SQL[] = [];
60
+ if (knownVersions.length > 0) {
61
+ filters.push(notInArray(secretDataKeys.kekVersion, knownVersions));
62
+ }
63
+ if (params.workspaceIds) {
64
+ filters.push(inArray(secretDataKeys.workspaceId, params.workspaceIds));
65
+ }
66
+ const rows = await db()
67
+ .selectDistinct({kekVersion: secretDataKeys.kekVersion})
68
+ .from(secretDataKeys)
69
+ .where(filters.length > 0 ? and(...filters) : undefined);
70
+ return rows.map((row) => row.kekVersion);
71
+ }
72
+
73
+ export async function listDataKeysPage(params: {
74
+ afterWorkspaceId?: string | undefined;
75
+ limit: number;
76
+ versions?: string[] | undefined;
77
+ workspaceIds?: string[] | undefined;
78
+ }): Promise<DataKey[]> {
79
+ if (params.versions && params.versions.length === 0) return [];
80
+ if (params.workspaceIds?.length === 0) return [];
81
+
82
+ const filters: SQL[] = [];
83
+ if (params.afterWorkspaceId) {
84
+ filters.push(gt(secretDataKeys.workspaceId, params.afterWorkspaceId));
85
+ }
86
+ if (params.versions) {
87
+ filters.push(inArray(secretDataKeys.kekVersion, params.versions));
88
+ }
89
+ if (params.workspaceIds) {
90
+ filters.push(inArray(secretDataKeys.workspaceId, params.workspaceIds));
91
+ }
92
+ const rows = await db()
93
+ .select()
94
+ .from(secretDataKeys)
95
+ .where(filters.length > 0 ? and(...filters) : undefined)
96
+ .orderBy(secretDataKeys.workspaceId)
97
+ .limit(params.limit);
98
+ return rows.map(toDataKey);
99
+ }
package/src/db/db.ts ADDED
@@ -0,0 +1,26 @@
1
+ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
+ import {pgClient} from '@shipfox/node-postgres';
3
+ import {secretDataKeys} from './schema/data-keys.js';
4
+ import {secretsOutbox} from './schema/outbox.js';
5
+ import {secretValues} from './schema/values.js';
6
+ import {secretVariables} from './schema/variables.js';
7
+
8
+ export const schema = {
9
+ secretDataKeys,
10
+ secretsOutbox,
11
+ secretValues,
12
+ secretVariables,
13
+ };
14
+
15
+ let _db: NodePgDatabase<typeof schema> | undefined;
16
+
17
+ export function db() {
18
+ if (!_db) _db = drizzle(pgClient(), {schema});
19
+ return _db;
20
+ }
21
+
22
+ export function closeDb(): void {
23
+ _db = undefined;
24
+ }
25
+
26
+ export type Tx = Parameters<Parameters<NodePgDatabase<typeof schema>['transaction']>[0]>[0];
@@ -0,0 +1,45 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+
4
+ export {countPublicEntriesByResource, countWorkspaceEntries, lockWorkspaceEntries} from './cap.js';
5
+ export {
6
+ getDataKey,
7
+ insertDataKeyIfAbsent,
8
+ listDataKeysPage,
9
+ listDataKeyVersions,
10
+ updateDataKeyWrapCas,
11
+ } from './data-keys.js';
12
+ export {closeDb, db, schema, type Tx} from './db.js';
13
+ export {
14
+ deleteSecretManagementRows,
15
+ deleteVariableManagementRows,
16
+ getSecretManagementRow,
17
+ getVariableManagementRow,
18
+ listExistingSecretManagementKeys,
19
+ listExistingVariableManagementKeys,
20
+ listSecretManagementRows,
21
+ listVariableManagementRows,
22
+ type SecretManagementRow,
23
+ type VariableManagementListRow,
24
+ } from './management.js';
25
+ export {secretDataKeys} from './schema/data-keys.js';
26
+ export {secretsOutbox} from './schema/outbox.js';
27
+ export {secretValues} from './schema/values.js';
28
+ export {secretVariables} from './schema/variables.js';
29
+ export type {StoreScope} from './scope.js';
30
+ export {
31
+ countSecretValueRows,
32
+ deleteSecretValueRows,
33
+ getSecretValueRowWithPrecedence,
34
+ listSecretValueRowsByNamespace,
35
+ upsertSecretValueRows,
36
+ } from './values.js';
37
+ export {
38
+ countSecretVariableRows,
39
+ deleteSecretVariableRows,
40
+ getSecretVariableRowWithPrecedence,
41
+ listSecretVariableRowsByNamespace,
42
+ upsertSecretVariableRows,
43
+ } from './variables.js';
44
+
45
+ export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');