@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,2 @@
1
+ $ shipfox-swc
2
+ Successfully compiled: 51 files with swc (105.96ms)
@@ -0,0 +1 @@
1
+ $ shipfox-tsc-emit
@@ -0,0 +1 @@
1
+ $ shipfox-tsc-check
package/CHANGELOG.md ADDED
@@ -0,0 +1,116 @@
1
+ # @shipfox/api-secrets
2
+
3
+ ## 2.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1b0d344: Publishes the complete API runtime closure with packed-consumer-safe internal imports and records its exact package set in application releases.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [0cd6dd4]
12
+ - Updated dependencies [1b0d344]
13
+ - Updated dependencies [521e006]
14
+ - @shipfox/node-module@0.2.0
15
+ - @shipfox/api-auth-context@2.0.0
16
+ - @shipfox/api-projects@2.0.0
17
+ - @shipfox/api-secrets-dto@2.0.0
18
+ - @shipfox/config@1.2.1
19
+ - @shipfox/node-drizzle@0.2.1
20
+ - @shipfox/node-fastify@0.2.1
21
+ - @shipfox/node-opentelemetry@0.5.0
22
+ - @shipfox/node-outbox@0.2.1
23
+ - @shipfox/node-postgres@0.4.1
24
+
25
+ ## 0.1.2
26
+
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies [705dd43]
30
+ - @shipfox/node-outbox@0.2.0
31
+ - @shipfox/api-projects@0.1.2
32
+ - @shipfox/node-module@0.1.2
33
+
34
+ ## 0.1.1
35
+
36
+ ### Patch Changes
37
+
38
+ - Updated dependencies [ec75cd5]
39
+ - Updated dependencies [6a1fb54]
40
+ - @shipfox/node-drizzle@0.2.0
41
+ - @shipfox/node-postgres@0.4.0
42
+ - @shipfox/api-projects@0.1.1
43
+ - @shipfox/node-module@0.1.1
44
+ - @shipfox/node-outbox@0.1.1
45
+
46
+ ## 0.1.0
47
+
48
+ ### Minor Changes
49
+
50
+ - 3b45d86: Adds the secrets and variables backend foundation with encrypted secret storage, plaintext variables, and shared DTO contracts.
51
+ - 3ddde91: Adds the secrets and variables management API with DTO contracts, exact-scope storage helpers, outbox events, and workspace-admin route guards.
52
+
53
+ ### Patch Changes
54
+
55
+ - f66f606: Test suites can create managed secrets and variables through protected setup APIs.
56
+ - e51d464: Add the Secrets & Variables workspace settings UI (S1b).
57
+ - New `@shipfox/client-secrets` package: transport + React Query hooks (a shared
58
+ `createStoreApi` factory), a write-only secret form and a readable variable form
59
+ (TanStack Form + Zod, multiline `Textarea` values, live short-value / sensitive-name
60
+ advisories), and the workspace secrets/variables sections (single-call list, masked
61
+ secret values, copy-name, delete with blast-radius warning).
62
+ - `@shipfox/client-workspace-settings`: new Secrets and Variables settings pages and nav
63
+ entries.
64
+ - `@shipfox/api-secrets-dto`: export `SECRETS_MAX_LIST_LIMIT` and raise the list `limit`
65
+ cap so the settings UI can fetch the whole bounded set in one request; the variable
66
+ list item now carries `value_truncated`.
67
+ - `@shipfox/api-secrets`: the variable list returns a bounded single-line preview of each
68
+ value (the full value is read via `GET /variables/:key` when editing) so a single-call
69
+ list cannot materialize very large responses; startup fails if `SECRETS_MAX_PER_WORKSPACE`
70
+ exceeds the list limit.
71
+ - `@shipfox/client-router`: register the `/workspaces/$wid/settings/secrets` and
72
+ `/variables` routes.
73
+
74
+ - 82d22e4: Make the workspace-membership gate stateless. `requireWorkspaceAccess` now lives in `@shipfox/api-auth-context` and authorizes a request purely from the verified session-token claims, replacing the `requireMembership` gate in `@shipfox/api-workspaces` that read the workspace row from the database on every workspace-scoped request. Membership and role already travel in the token, so the check needs no database access.
75
+
76
+ This removes the per-request database read and severs the runtime dependency on `@shipfox/api-workspaces` from feature modules that only needed the membership gate (integration providers, secrets, projects, agent, runners). Workspace existence and `active`-status enforcement, which no code path currently exercises, moves off the hot path; enforce it at token issuance when workspace suspension is introduced.
77
+
78
+ - Updated dependencies [34ba284]
79
+ - Updated dependencies [3b45d86]
80
+ - Updated dependencies [5707d6d]
81
+ - Updated dependencies [b9c3f32]
82
+ - Updated dependencies [a81b68c]
83
+ - Updated dependencies [115655e]
84
+ - Updated dependencies [c0a883c]
85
+ - Updated dependencies [72ce351]
86
+ - Updated dependencies [e47f8da]
87
+ - Updated dependencies [7b175f5]
88
+ - Updated dependencies [ae7a63c]
89
+ - Updated dependencies [5729548]
90
+ - Updated dependencies [f92122b]
91
+ - Updated dependencies [857fd73]
92
+ - Updated dependencies [75520ff]
93
+ - Updated dependencies [f66f606]
94
+ - Updated dependencies [e51d464]
95
+ - Updated dependencies [d6d4862]
96
+ - Updated dependencies [c0a883c]
97
+ - Updated dependencies [6077301]
98
+ - Updated dependencies [3bea87f]
99
+ - Updated dependencies [82d22e4]
100
+ - Updated dependencies [8ecba0f]
101
+ - Updated dependencies [27770eb]
102
+ - Updated dependencies [2933c33]
103
+ - Updated dependencies [43fd0c1]
104
+ - Updated dependencies [6181819]
105
+ - Updated dependencies [3ddde91]
106
+ - Updated dependencies [9c149d1]
107
+ - @shipfox/node-fastify@0.2.0
108
+ - @shipfox/api-secrets-dto@0.1.0
109
+ - @shipfox/node-drizzle@0.1.0
110
+ - @shipfox/api-auth-context@0.1.0
111
+ - @shipfox/api-projects@0.1.0
112
+ - @shipfox/node-opentelemetry@0.4.2
113
+ - @shipfox/node-postgres@0.3.2
114
+ - @shipfox/node-module@0.1.0
115
+ - @shipfox/node-outbox@0.1.0
116
+ - @shipfox/config@1.2.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shipfox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # @shipfox/api-secrets
2
+
3
+ `@shipfox/api-secrets` stores workspace secrets and variables for backend modules.
4
+
5
+ ## What it does
6
+
7
+ - **`secretsModule`**: Declares the module name and database migrations for the API module loader.
8
+ - **Secret store API**: `setSecrets`, `getSecret`, `getSecretsByNamespace`, and `deleteSecrets` manage encrypted values by scope and namespace.
9
+ - **Variable store API**: `setVariables`, `getVariable`, `getVariablesByNamespace`, and `deleteVariables` manage non-secret config values with the same scope rules.
10
+ - **`rotateWorkspaceDataKeys()`**: Re-wraps workspace data keys under the current KEK. It does not re-encrypt stored values.
11
+ - **Store resolver**: `resolveSecretStore` supports the built-in `local` store and rejects unknown store names with `UnknownSecretStoreError`.
12
+ - **Domain errors**: Typed errors let route layers map validation, cap, crypto, and config failures to client responses.
13
+
14
+ ## Installation / Setup
15
+
16
+ Install the package from the registry:
17
+
18
+ ```sh
19
+ pnpm add @shipfox/api-secrets
20
+ ```
21
+
22
+ Register `secretsModule` before modules that read or write secrets so its migrations run first.
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ import {getSecret, secretsModule, setSecrets, setVariables} from '@shipfox/api-secrets';
28
+ import {initializeModules} from '@shipfox/node-module';
29
+
30
+ await initializeModules({modules: [secretsModule]});
31
+
32
+ await setSecrets({
33
+ workspaceId: '018f1c9c-7c30-7f41-9c32-7668a7f3cc11',
34
+ projectId: '018f1c9d-1b3c-7d4a-a5a2-8c2fdf4d7a91',
35
+ namespace: 'system/agent/model-provider/openai',
36
+ values: {API_KEY: 'sk-live-value'},
37
+ });
38
+
39
+ await setVariables({
40
+ workspaceId: '018f1c9c-7c30-7f41-9c32-7668a7f3cc11',
41
+ namespace: 'agent/defaults',
42
+ values: {REGION: 'us-east-1'},
43
+ });
44
+
45
+ const apiKey = await getSecret({
46
+ workspaceId: '018f1c9c-7c30-7f41-9c32-7668a7f3cc11',
47
+ projectId: '018f1c9d-1b3c-7d4a-a5a2-8c2fdf4d7a91',
48
+ namespace: 'system/agent/model-provider/openai',
49
+ key: 'API_KEY',
50
+ });
51
+ ```
52
+
53
+ ## Namespaces
54
+
55
+ Namespaces group values that belong to one module-owned purpose. Use lowercase
56
+ slug paths in this shape:
57
+
58
+ ```text
59
+ <module>/<entity>/<id>
60
+ ```
61
+
62
+ Use the module name first, then the entity type, then the entity identifier. For
63
+ module-internal records that should not appear in user-facing management lists,
64
+ prefix the namespace with `system/`:
65
+
66
+ ```text
67
+ system/<module>/<entity>/<id>
68
+ ```
69
+
70
+ For example, agent model-provider credentials for OpenAI live under:
71
+
72
+ ```text
73
+ system/agent/model-provider/openai
74
+ ```
75
+
76
+ Keep the namespace specific to the entity that owns the keys. Do not put several
77
+ unrelated entity types under the same namespace; create separate namespaces such
78
+ as `system/agent/model-provider/openai` and `system/agent/<other-entity>/<id>`.
79
+
80
+ ## Environment
81
+
82
+ | Variable | Default | Purpose |
83
+ | --- | --- | --- |
84
+ | `SECRETS_ENCRYPTION_KEK` | none | Required base64-encoded 32-byte key used to wrap workspace data keys. Generate one per environment. |
85
+ | `SECRETS_ENCRYPTION_KEK_PREVIOUS` | none | Previous KEK needed during rotation and for normal operations until all workspace data keys are re-wrapped. Set it to the old key until data-key rotation completes. |
86
+ | `SECRETS_MAX_PER_WORKSPACE` | `10000` | Maximum total count of secrets and variables in one workspace. The count includes all namespaces and project scopes. |
87
+ | `SECRETS_SHORT_VALUE_WARN_LENGTH` | `12` | Length below which management routes can show a short-secret warning. The core store does not emit the warning itself. |
88
+
89
+ Startup checks each KEK as a canonical base64 value for a 32-byte key. It also rejects non-positive numeric limits.
90
+
91
+ ## API
92
+
93
+ | Function | Result |
94
+ | --- | --- |
95
+ | `setSecrets({workspaceId, projectId?, namespace?, values, editedBy?})` | Encrypts and upserts a batch of secret values. |
96
+ | `getSecret({workspaceId, projectId?, namespace?, key, store?})` | Returns one plaintext secret or `null`. Project scope wins over workspace scope. |
97
+ | `getSecretsByNamespace({workspaceId, projectId?, namespace?, store?})` | Returns plaintext secrets in a namespace. Project scope wins over workspace scope. |
98
+ | `deleteSecrets({workspaceId, projectId?, namespace?, keys?})` | Deletes exact-scope secrets. Omitted `keys` deletes the scope namespace. Empty `keys` deletes nothing. |
99
+ | `setVariables({workspaceId, projectId?, namespace?, values, editedBy?})` | Upserts plaintext variables with the same validation and cap rules as secrets. |
100
+ | `getVariable({workspaceId, projectId?, namespace?, key})` | Returns one variable or `null`. Project scope wins over workspace scope. |
101
+ | `getVariablesByNamespace({workspaceId, projectId?, namespace?})` | Returns variables in a namespace. Project scope wins over workspace scope. |
102
+ | `deleteVariables({workspaceId, projectId?, namespace?, keys?})` | Deletes exact-scope variables. Omitted `keys` deletes the scope namespace. Empty `keys` deletes nothing. |
103
+ | `rotateWorkspaceDataKeys()` | Re-wraps stored data keys from the previous KEK to the current KEK. |
104
+
105
+ Keys must match `^[A-Z_][A-Z0-9_]*$`. Keys and namespaces may be at most 128 characters. Namespaces may be empty or a lowercase slug path.
106
+
107
+ ## Data Model
108
+
109
+ This module owns tables with the `secrets_` prefix.
110
+
111
+ | Table | Purpose |
112
+ | --- | --- |
113
+ | `secrets_data_keys` | Stores one wrapped data-encryption key per workspace. The row includes the KEK version used to wrap it. |
114
+ | `secrets_values` | Stores encrypted secret values, a keyed HMAC fingerprint, scope columns, and edit metadata. |
115
+ | `secrets_variables` | Stores plaintext variable values, scope columns, and edit metadata. |
116
+
117
+ `secrets_values` and `secrets_variables` use partial unique indexes for scope:
118
+
119
+ - Workspace scope is unique by `(workspace_id, namespace, key)` where `project_id IS NULL`.
120
+ - Project scope is unique by `(workspace_id, project_id, namespace, key)` where `project_id IS NOT NULL`.
121
+
122
+ The database checks key and namespace patterns. The core layer also validates them before writing.
123
+
124
+ ## Security Model
125
+
126
+ ### Trust boundary
127
+
128
+ This package is an in-process storage module. It does not perform user authorization.
129
+
130
+ Callers must derive `workspaceId`, `projectId`, and namespace from trusted context. Do not pass attacker-controlled scope values into the store functions.
131
+
132
+ Secret management routes should keep secret values write-only at the HTTP boundary. Internal callers may read plaintext secrets only after their own tenancy checks pass.
133
+
134
+ ### Encryption
135
+
136
+ Secret values use envelope encryption.
137
+
138
+ - Each workspace has one 32-byte data-encryption key (DEK).
139
+ - The DEK is generated with `crypto.randomBytes(32)` on first use.
140
+ - The DEK is wrapped with the configured key-encryption key (KEK) and stored in `secrets_data_keys`.
141
+ - Secret values are encrypted with AES-256-GCM under the plaintext DEK.
142
+ - Ciphertexts are encoded as `v1:<base64(iv || authTag || ciphertext)>`.
143
+ - AES-GCM uses a fresh 12-byte IV for each seal.
144
+
145
+ Additional authenticated data binds encrypted values to:
146
+
147
+ - `workspaceId`
148
+ - scope, either workspace or project ID
149
+ - namespace
150
+ - key
151
+
152
+ Moving ciphertext to another scope makes decryption fail closed with `SecretDecryptionError`.
153
+
154
+ ### Key handling
155
+
156
+ `SECRETS_ENCRYPTION_KEK` is the current KEK. It must be a canonical base64-encoded 32-byte value. Startup rejects malformed, unpadded, over-padded, or whitespace-tainted keys.
157
+
158
+ `SECRETS_ENCRYPTION_KEK_PREVIOUS` supports rotation. During that window, the key provider can unwrap DEKs with the old KEK and wrap them with the new KEK.
159
+
160
+ KEK versions come from a domain-separated SHA-256 hash of the KEK. The version is stored with each wrapped DEK. Rotation refuses unknown KEK versions.
161
+
162
+ Plaintext DEKs are cached in memory for hot reads. The cache has a size limit and TTL. The manager stores cache-owned Buffer copies and returns defensive copies. Node cannot promise full memory wipes, so the design limits how long keys stay in memory.
163
+
164
+ ### Rotation
165
+
166
+ KEK rotation re-wraps DEKs only. It does not re-encrypt `secrets_values`.
167
+
168
+ Rotation pages over `secrets_data_keys`. It unwraps each DEK with the previous KEK and wraps it with the current KEK. The update uses compare-and-swap on the old version. If two rotations race, one wins and the other skips the row.
169
+
170
+ After each unwrap in the rotation path, the temporary plaintext DEK Buffer is filled with zeroes.
171
+
172
+ ### Fingerprints
173
+
174
+ `secrets_values.fingerprint` stores a keyed HMAC-SHA256 digest of the secret value using the workspace DEK. It is not a plaintext suffix.
175
+
176
+ The fingerprint supports equality and display workflows without storing part of the secret in clear text. A database leak does not reveal the end of a secret through this column.
177
+
178
+ ### Failure behavior
179
+
180
+ Secret decryption failures collapse to `SecretDecryptionError`. The error does not include secret values, key material, or ciphertext.
181
+
182
+ Deleting a workspace data key makes existing secrets unreadable. Reads fail closed instead of returning unauthenticated plaintext.
183
+
184
+ ## Behavior Notes
185
+
186
+ - **Scope precedence:** project-scoped rows override workspace-scoped rows on reads and namespace lists.
187
+ - **Exact deletes:** deletes target only the exact scope passed by the caller. Deleting a project secret does not delete the workspace fallback.
188
+ - **Batch writes:** each write batch must target one scope. Mixed workspace and project rows are rejected by the persistence layer.
189
+ - **Workspace cap:** cap checks count secrets and variables together. The check runs inside the write transaction. It uses a workspace advisory lock and counts only net-new keys.
190
+ - **Variables:** variables are plaintext by design. Use them for non-secret config only.
191
+ - **Stores:** `local` is the only built-in store. Unknown stores throw `UnknownSecretStoreError`.
192
+
193
+ ## Development
194
+
195
+ The tests use PostgreSQL. Start local services before running package tests:
196
+
197
+ ```sh
198
+ docker compose up -d
199
+ turbo build --filter=@shipfox/api-secrets
200
+ turbo check --filter=@shipfox/api-secrets
201
+ turbo type --filter=@shipfox/api-secrets
202
+ turbo test --filter=@shipfox/api-secrets
203
+ ```
204
+
205
+ ## License
206
+
207
+ MIT
@@ -0,0 +1,8 @@
1
+ export declare const MAX_VALUE_BYTES: number;
2
+ export declare const config: Readonly<{
3
+ SECRETS_ENCRYPTION_KEK: string;
4
+ SECRETS_ENCRYPTION_KEK_PREVIOUS: string | undefined;
5
+ SECRETS_MAX_PER_WORKSPACE: number;
6
+ SECRETS_SHORT_VALUE_WARN_LENGTH: number;
7
+ } & import("@shipfox/config").CleanedEnvAccessors>;
8
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,eAAe,QAAY,CAAC;AAEzC,eAAO,MAAM,MAAM;;;;;kDAgBjB,CAAC"}
package/dist/config.js ADDED
@@ -0,0 +1,38 @@
1
+ import { SECRETS_MAX_LIST_LIMIT } from '@shipfox/api-secrets-dto';
2
+ import { createConfig, num, str } from '@shipfox/config';
3
+ import { decodeBase64Key } from '#core/crypto.js';
4
+ export const MAX_VALUE_BYTES = 64 * 1024;
5
+ export const config = createConfig({
6
+ SECRETS_ENCRYPTION_KEK: str({
7
+ desc: 'Master key used to protect all stored secrets. Required. Generate a unique value per environment with openssl rand -base64 32 and provide it from a secret manager. The committed .env value is only for local development. Losing this key makes stored secrets unrecoverable. To rotate it, set SECRETS_ENCRYPTION_KEK_PREVIOUS to the old value during the rotation window.'
8
+ }),
9
+ SECRETS_ENCRYPTION_KEK_PREVIOUS: str({
10
+ desc: 'Previous master key used only while rotating stored secret data keys. Optional. Set it to the old SECRETS_ENCRYPTION_KEK value until rotation has completed.',
11
+ default: undefined
12
+ }),
13
+ SECRETS_MAX_PER_WORKSPACE: num({
14
+ desc: 'Maximum number of secrets and variables allowed per workspace across all project scopes and namespaces.',
15
+ default: 10000
16
+ }),
17
+ SECRETS_SHORT_VALUE_WARN_LENGTH: num({
18
+ desc: 'Secret values shorter than this many characters are classified for a write warning by management routes.',
19
+ default: 12
20
+ })
21
+ });
22
+ decodeBase64Key(config.SECRETS_ENCRYPTION_KEK, 'SECRETS_ENCRYPTION_KEK');
23
+ if (config.SECRETS_ENCRYPTION_KEK_PREVIOUS) {
24
+ decodeBase64Key(config.SECRETS_ENCRYPTION_KEK_PREVIOUS, 'SECRETS_ENCRYPTION_KEK_PREVIOUS');
25
+ }
26
+ if (config.SECRETS_MAX_PER_WORKSPACE < 1) {
27
+ throw new Error('SECRETS_MAX_PER_WORKSPACE must be greater than 0.');
28
+ }
29
+ if (config.SECRETS_MAX_PER_WORKSPACE > SECRETS_MAX_LIST_LIMIT) {
30
+ // The settings UI lists the whole bounded set in one call (limit = SECRETS_MAX_LIST_LIMIT).
31
+ // A cap above that would let the list silently truncate, so fail fast instead.
32
+ throw new Error(`SECRETS_MAX_PER_WORKSPACE (${config.SECRETS_MAX_PER_WORKSPACE}) cannot exceed SECRETS_MAX_LIST_LIMIT (${SECRETS_MAX_LIST_LIMIT}).`);
33
+ }
34
+ if (config.SECRETS_SHORT_VALUE_WARN_LENGTH < 1) {
35
+ throw new Error('SECRETS_SHORT_VALUE_WARN_LENGTH must be greater than 0.');
36
+ }
37
+
38
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["import {SECRETS_MAX_LIST_LIMIT} from '@shipfox/api-secrets-dto';\nimport {createConfig, num, str} from '@shipfox/config';\nimport {decodeBase64Key} from '#core/crypto.js';\n\nexport const MAX_VALUE_BYTES = 64 * 1024;\n\nexport const config = createConfig({\n SECRETS_ENCRYPTION_KEK: str({\n desc: 'Master key used to protect all stored secrets. Required. Generate a unique value per environment with openssl rand -base64 32 and provide it from a secret manager. The committed .env value is only for local development. Losing this key makes stored secrets unrecoverable. To rotate it, set SECRETS_ENCRYPTION_KEK_PREVIOUS to the old value during the rotation window.',\n }),\n SECRETS_ENCRYPTION_KEK_PREVIOUS: str({\n desc: 'Previous master key used only while rotating stored secret data keys. Optional. Set it to the old SECRETS_ENCRYPTION_KEK value until rotation has completed.',\n default: undefined,\n }),\n SECRETS_MAX_PER_WORKSPACE: num({\n desc: 'Maximum number of secrets and variables allowed per workspace across all project scopes and namespaces.',\n default: 10000,\n }),\n SECRETS_SHORT_VALUE_WARN_LENGTH: num({\n desc: 'Secret values shorter than this many characters are classified for a write warning by management routes.',\n default: 12,\n }),\n});\n\ndecodeBase64Key(config.SECRETS_ENCRYPTION_KEK, 'SECRETS_ENCRYPTION_KEK');\nif (config.SECRETS_ENCRYPTION_KEK_PREVIOUS) {\n decodeBase64Key(config.SECRETS_ENCRYPTION_KEK_PREVIOUS, 'SECRETS_ENCRYPTION_KEK_PREVIOUS');\n}\nif (config.SECRETS_MAX_PER_WORKSPACE < 1) {\n throw new Error('SECRETS_MAX_PER_WORKSPACE must be greater than 0.');\n}\nif (config.SECRETS_MAX_PER_WORKSPACE > SECRETS_MAX_LIST_LIMIT) {\n // The settings UI lists the whole bounded set in one call (limit = SECRETS_MAX_LIST_LIMIT).\n // A cap above that would let the list silently truncate, so fail fast instead.\n throw new Error(\n `SECRETS_MAX_PER_WORKSPACE (${config.SECRETS_MAX_PER_WORKSPACE}) cannot exceed SECRETS_MAX_LIST_LIMIT (${SECRETS_MAX_LIST_LIMIT}).`,\n );\n}\nif (config.SECRETS_SHORT_VALUE_WARN_LENGTH < 1) {\n throw new Error('SECRETS_SHORT_VALUE_WARN_LENGTH must be greater than 0.');\n}\n"],"names":["SECRETS_MAX_LIST_LIMIT","createConfig","num","str","decodeBase64Key","MAX_VALUE_BYTES","config","SECRETS_ENCRYPTION_KEK","desc","SECRETS_ENCRYPTION_KEK_PREVIOUS","default","undefined","SECRETS_MAX_PER_WORKSPACE","SECRETS_SHORT_VALUE_WARN_LENGTH","Error"],"mappings":"AAAA,SAAQA,sBAAsB,QAAO,2BAA2B;AAChE,SAAQC,YAAY,EAAEC,GAAG,EAAEC,GAAG,QAAO,kBAAkB;AACvD,SAAQC,eAAe,QAAO,kBAAkB;AAEhD,OAAO,MAAMC,kBAAkB,KAAK,KAAK;AAEzC,OAAO,MAAMC,SAASL,aAAa;IACjCM,wBAAwBJ,IAAI;QAC1BK,MAAM;IACR;IACAC,iCAAiCN,IAAI;QACnCK,MAAM;QACNE,SAASC;IACX;IACAC,2BAA2BV,IAAI;QAC7BM,MAAM;QACNE,SAAS;IACX;IACAG,iCAAiCX,IAAI;QACnCM,MAAM;QACNE,SAAS;IACX;AACF,GAAG;AAEHN,gBAAgBE,OAAOC,sBAAsB,EAAE;AAC/C,IAAID,OAAOG,+BAA+B,EAAE;IAC1CL,gBAAgBE,OAAOG,+BAA+B,EAAE;AAC1D;AACA,IAAIH,OAAOM,yBAAyB,GAAG,GAAG;IACxC,MAAM,IAAIE,MAAM;AAClB;AACA,IAAIR,OAAOM,yBAAyB,GAAGZ,wBAAwB;IAC7D,4FAA4F;IAC5F,+EAA+E;IAC/E,MAAM,IAAIc,MACR,CAAC,2BAA2B,EAAER,OAAOM,yBAAyB,CAAC,wCAAwC,EAAEZ,uBAAuB,EAAE,CAAC;AAEvI;AACA,IAAIM,OAAOO,+BAA+B,GAAG,GAAG;IAC9C,MAAM,IAAIC,MAAM;AAClB"}
@@ -0,0 +1,25 @@
1
+ export interface AesGcmSealParams {
2
+ key: Buffer;
3
+ plaintext: Buffer;
4
+ aad: string;
5
+ }
6
+ export interface AesGcmOpenParams {
7
+ key: Buffer;
8
+ encoded: string;
9
+ aad: string;
10
+ }
11
+ export interface SecretScope {
12
+ projectId?: string | null | undefined;
13
+ }
14
+ export interface SecretValueAadParams {
15
+ workspaceId: string;
16
+ scope?: SecretScope | undefined;
17
+ namespace: string;
18
+ key: string;
19
+ }
20
+ export declare function aesGcmSeal(params: AesGcmSealParams): string;
21
+ export declare function aesGcmOpen(params: AesGcmOpenParams): Buffer;
22
+ export declare function decodeBase64Key(encoded: string | undefined, label: string): Buffer;
23
+ export declare function aadForDek(workspaceId: string, kekVersion: string): string;
24
+ export declare function aadForValue(params: SecretValueAadParams): string;
25
+ //# sourceMappingURL=crypto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/core/crypto.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACvC;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACb;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAS3D;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CA4B3D;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAelF;AAED,wBAAgB,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEzE;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,oBAAoB,GAAG,MAAM,CAIhE"}
@@ -0,0 +1,85 @@
1
+ import crypto from 'node:crypto';
2
+ import { KekConfigurationError, SecretDecryptionError } from './errors.js';
3
+ const CIPHER = 'aes-256-gcm';
4
+ const ENCODED_PREFIX = 'v1:';
5
+ const IV_BYTES = 12;
6
+ const AUTH_TAG_BYTES = 16;
7
+ const KEY_BYTES = 32;
8
+ const BASE64_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
9
+ const BASE64_PADDING_SUFFIX = /=+$/;
10
+ export function aesGcmSeal(params) {
11
+ const iv = crypto.randomBytes(IV_BYTES);
12
+ const cipher = crypto.createCipheriv(CIPHER, params.key, iv);
13
+ cipher.setAAD(Buffer.from(params.aad, 'utf8'));
14
+ const ciphertext = Buffer.concat([
15
+ cipher.update(params.plaintext),
16
+ cipher.final()
17
+ ]);
18
+ const authTag = cipher.getAuthTag();
19
+ return `${ENCODED_PREFIX}${Buffer.concat([
20
+ iv,
21
+ authTag,
22
+ ciphertext
23
+ ]).toString('base64')}`;
24
+ }
25
+ export function aesGcmOpen(params) {
26
+ if (!params.encoded.startsWith(ENCODED_PREFIX)) throw new SecretDecryptionError();
27
+ const encodedPayload = params.encoded.slice(ENCODED_PREFIX.length);
28
+ const payload = Buffer.from(encodedPayload, 'base64');
29
+ const canonical = payload.toString('base64');
30
+ if (!BASE64_KEY_PATTERN.test(encodedPayload) || canonical.replace(BASE64_PADDING_SUFFIX, '') !== encodedPayload.replace(BASE64_PADDING_SUFFIX, '')) {
31
+ throw new SecretDecryptionError();
32
+ }
33
+ if (payload.length < IV_BYTES + AUTH_TAG_BYTES) throw new SecretDecryptionError();
34
+ try {
35
+ const iv = payload.subarray(0, IV_BYTES);
36
+ const authTag = payload.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);
37
+ const ciphertext = payload.subarray(IV_BYTES + AUTH_TAG_BYTES);
38
+ const decipher = crypto.createDecipheriv(CIPHER, params.key, iv);
39
+ decipher.setAAD(Buffer.from(params.aad, 'utf8'));
40
+ decipher.setAuthTag(authTag);
41
+ return Buffer.concat([
42
+ decipher.update(ciphertext),
43
+ decipher.final()
44
+ ]);
45
+ } catch (error) {
46
+ if (error instanceof SecretDecryptionError) throw error;
47
+ throw new SecretDecryptionError();
48
+ }
49
+ }
50
+ export function decodeBase64Key(encoded, label) {
51
+ if (!encoded) {
52
+ throw new KekConfigurationError(`${label} is required and must be a base64-encoded 32-byte key. Generate one with openssl rand -base64 32.`);
53
+ }
54
+ const key = Buffer.from(encoded, 'base64');
55
+ if (key.length !== KEY_BYTES || !isCanonicalBase64Key(encoded, key)) {
56
+ throw new KekConfigurationError(`${label} must be a canonical base64-encoded 32-byte key. Strip whitespace and generate a new value with openssl rand -base64 32 if needed.`);
57
+ }
58
+ return key;
59
+ }
60
+ export function aadForDek(workspaceId, kekVersion) {
61
+ return JSON.stringify([
62
+ workspaceId,
63
+ kekVersion
64
+ ]);
65
+ }
66
+ export function aadForValue(params) {
67
+ const projectId = params.scope?.projectId ?? null;
68
+ const scopeTuple = projectId !== null ? [
69
+ 'project',
70
+ projectId
71
+ ] : [
72
+ 'workspace'
73
+ ];
74
+ return JSON.stringify([
75
+ params.workspaceId,
76
+ scopeTuple,
77
+ params.namespace,
78
+ params.key
79
+ ]);
80
+ }
81
+ function isCanonicalBase64Key(encoded, key) {
82
+ return BASE64_KEY_PATTERN.test(encoded) && key.toString('base64') === encoded;
83
+ }
84
+
85
+ //# sourceMappingURL=crypto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/crypto.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport {KekConfigurationError, SecretDecryptionError} from './errors.js';\n\nconst CIPHER = 'aes-256-gcm';\nconst ENCODED_PREFIX = 'v1:';\nconst IV_BYTES = 12;\nconst AUTH_TAG_BYTES = 16;\nconst KEY_BYTES = 32;\nconst BASE64_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;\nconst BASE64_PADDING_SUFFIX = /=+$/;\n\nexport interface AesGcmSealParams {\n key: Buffer;\n plaintext: Buffer;\n aad: string;\n}\n\nexport interface AesGcmOpenParams {\n key: Buffer;\n encoded: string;\n aad: string;\n}\n\nexport interface SecretScope {\n projectId?: string | null | undefined;\n}\n\nexport interface SecretValueAadParams {\n workspaceId: string;\n scope?: SecretScope | undefined;\n namespace: string;\n key: string;\n}\n\nexport function aesGcmSeal(params: AesGcmSealParams): string {\n const iv = crypto.randomBytes(IV_BYTES);\n const cipher = crypto.createCipheriv(CIPHER, params.key, iv);\n cipher.setAAD(Buffer.from(params.aad, 'utf8'));\n\n const ciphertext = Buffer.concat([cipher.update(params.plaintext), cipher.final()]);\n const authTag = cipher.getAuthTag();\n\n return `${ENCODED_PREFIX}${Buffer.concat([iv, authTag, ciphertext]).toString('base64')}`;\n}\n\nexport function aesGcmOpen(params: AesGcmOpenParams): Buffer {\n if (!params.encoded.startsWith(ENCODED_PREFIX)) throw new SecretDecryptionError();\n\n const encodedPayload = params.encoded.slice(ENCODED_PREFIX.length);\n const payload = Buffer.from(encodedPayload, 'base64');\n const canonical = payload.toString('base64');\n if (\n !BASE64_KEY_PATTERN.test(encodedPayload) ||\n canonical.replace(BASE64_PADDING_SUFFIX, '') !==\n encodedPayload.replace(BASE64_PADDING_SUFFIX, '')\n ) {\n throw new SecretDecryptionError();\n }\n if (payload.length < IV_BYTES + AUTH_TAG_BYTES) throw new SecretDecryptionError();\n\n try {\n const iv = payload.subarray(0, IV_BYTES);\n const authTag = payload.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES);\n const ciphertext = payload.subarray(IV_BYTES + AUTH_TAG_BYTES);\n const decipher = crypto.createDecipheriv(CIPHER, params.key, iv);\n decipher.setAAD(Buffer.from(params.aad, 'utf8'));\n decipher.setAuthTag(authTag);\n\n return Buffer.concat([decipher.update(ciphertext), decipher.final()]);\n } catch (error) {\n if (error instanceof SecretDecryptionError) throw error;\n throw new SecretDecryptionError();\n }\n}\n\nexport function decodeBase64Key(encoded: string | undefined, label: string): Buffer {\n if (!encoded) {\n throw new KekConfigurationError(\n `${label} is required and must be a base64-encoded 32-byte key. Generate one with openssl rand -base64 32.`,\n );\n }\n\n const key = Buffer.from(encoded, 'base64');\n if (key.length !== KEY_BYTES || !isCanonicalBase64Key(encoded, key)) {\n throw new KekConfigurationError(\n `${label} must be a canonical base64-encoded 32-byte key. Strip whitespace and generate a new value with openssl rand -base64 32 if needed.`,\n );\n }\n\n return key;\n}\n\nexport function aadForDek(workspaceId: string, kekVersion: string): string {\n return JSON.stringify([workspaceId, kekVersion]);\n}\n\nexport function aadForValue(params: SecretValueAadParams): string {\n const projectId = params.scope?.projectId ?? null;\n const scopeTuple = projectId !== null ? ['project', projectId] : ['workspace'];\n return JSON.stringify([params.workspaceId, scopeTuple, params.namespace, params.key]);\n}\n\nfunction isCanonicalBase64Key(encoded: string, key: Buffer): boolean {\n return BASE64_KEY_PATTERN.test(encoded) && key.toString('base64') === encoded;\n}\n"],"names":["crypto","KekConfigurationError","SecretDecryptionError","CIPHER","ENCODED_PREFIX","IV_BYTES","AUTH_TAG_BYTES","KEY_BYTES","BASE64_KEY_PATTERN","BASE64_PADDING_SUFFIX","aesGcmSeal","params","iv","randomBytes","cipher","createCipheriv","key","setAAD","Buffer","from","aad","ciphertext","concat","update","plaintext","final","authTag","getAuthTag","toString","aesGcmOpen","encoded","startsWith","encodedPayload","slice","length","payload","canonical","test","replace","subarray","decipher","createDecipheriv","setAuthTag","error","decodeBase64Key","label","isCanonicalBase64Key","aadForDek","workspaceId","kekVersion","JSON","stringify","aadForValue","projectId","scope","scopeTuple","namespace"],"mappings":"AAAA,OAAOA,YAAY,cAAc;AACjC,SAAQC,qBAAqB,EAAEC,qBAAqB,QAAO,cAAc;AAEzE,MAAMC,SAAS;AACf,MAAMC,iBAAiB;AACvB,MAAMC,WAAW;AACjB,MAAMC,iBAAiB;AACvB,MAAMC,YAAY;AAClB,MAAMC,qBAAqB;AAC3B,MAAMC,wBAAwB;AAyB9B,OAAO,SAASC,WAAWC,MAAwB;IACjD,MAAMC,KAAKZ,OAAOa,WAAW,CAACR;IAC9B,MAAMS,SAASd,OAAOe,cAAc,CAACZ,QAAQQ,OAAOK,GAAG,EAAEJ;IACzDE,OAAOG,MAAM,CAACC,OAAOC,IAAI,CAACR,OAAOS,GAAG,EAAE;IAEtC,MAAMC,aAAaH,OAAOI,MAAM,CAAC;QAACR,OAAOS,MAAM,CAACZ,OAAOa,SAAS;QAAGV,OAAOW,KAAK;KAAG;IAClF,MAAMC,UAAUZ,OAAOa,UAAU;IAEjC,OAAO,GAAGvB,iBAAiBc,OAAOI,MAAM,CAAC;QAACV;QAAIc;QAASL;KAAW,EAAEO,QAAQ,CAAC,WAAW;AAC1F;AAEA,OAAO,SAASC,WAAWlB,MAAwB;IACjD,IAAI,CAACA,OAAOmB,OAAO,CAACC,UAAU,CAAC3B,iBAAiB,MAAM,IAAIF;IAE1D,MAAM8B,iBAAiBrB,OAAOmB,OAAO,CAACG,KAAK,CAAC7B,eAAe8B,MAAM;IACjE,MAAMC,UAAUjB,OAAOC,IAAI,CAACa,gBAAgB;IAC5C,MAAMI,YAAYD,QAAQP,QAAQ,CAAC;IACnC,IACE,CAACpB,mBAAmB6B,IAAI,CAACL,mBACzBI,UAAUE,OAAO,CAAC7B,uBAAuB,QACvCuB,eAAeM,OAAO,CAAC7B,uBAAuB,KAChD;QACA,MAAM,IAAIP;IACZ;IACA,IAAIiC,QAAQD,MAAM,GAAG7B,WAAWC,gBAAgB,MAAM,IAAIJ;IAE1D,IAAI;QACF,MAAMU,KAAKuB,QAAQI,QAAQ,CAAC,GAAGlC;QAC/B,MAAMqB,UAAUS,QAAQI,QAAQ,CAAClC,UAAUA,WAAWC;QACtD,MAAMe,aAAac,QAAQI,QAAQ,CAAClC,WAAWC;QAC/C,MAAMkC,WAAWxC,OAAOyC,gBAAgB,CAACtC,QAAQQ,OAAOK,GAAG,EAAEJ;QAC7D4B,SAASvB,MAAM,CAACC,OAAOC,IAAI,CAACR,OAAOS,GAAG,EAAE;QACxCoB,SAASE,UAAU,CAAChB;QAEpB,OAAOR,OAAOI,MAAM,CAAC;YAACkB,SAASjB,MAAM,CAACF;YAAamB,SAASf,KAAK;SAAG;IACtE,EAAE,OAAOkB,OAAO;QACd,IAAIA,iBAAiBzC,uBAAuB,MAAMyC;QAClD,MAAM,IAAIzC;IACZ;AACF;AAEA,OAAO,SAAS0C,gBAAgBd,OAA2B,EAAEe,KAAa;IACxE,IAAI,CAACf,SAAS;QACZ,MAAM,IAAI7B,sBACR,GAAG4C,MAAM,iGAAiG,CAAC;IAE/G;IAEA,MAAM7B,MAAME,OAAOC,IAAI,CAACW,SAAS;IACjC,IAAId,IAAIkB,MAAM,KAAK3B,aAAa,CAACuC,qBAAqBhB,SAASd,MAAM;QACnE,MAAM,IAAIf,sBACR,GAAG4C,MAAM,kIAAkI,CAAC;IAEhJ;IAEA,OAAO7B;AACT;AAEA,OAAO,SAAS+B,UAAUC,WAAmB,EAAEC,UAAkB;IAC/D,OAAOC,KAAKC,SAAS,CAAC;QAACH;QAAaC;KAAW;AACjD;AAEA,OAAO,SAASG,YAAYzC,MAA4B;IACtD,MAAM0C,YAAY1C,OAAO2C,KAAK,EAAED,aAAa;IAC7C,MAAME,aAAaF,cAAc,OAAO;QAAC;QAAWA;KAAU,GAAG;QAAC;KAAY;IAC9E,OAAOH,KAAKC,SAAS,CAAC;QAACxC,OAAOqC,WAAW;QAAEO;QAAY5C,OAAO6C,SAAS;QAAE7C,OAAOK,GAAG;KAAC;AACtF;AAEA,SAAS8B,qBAAqBhB,OAAe,EAAEd,GAAW;IACxD,OAAOR,mBAAmB6B,IAAI,CAACP,YAAYd,IAAIY,QAAQ,CAAC,cAAcE;AACxE"}
@@ -0,0 +1,16 @@
1
+ import type { KeyProvider } from './key-provider.js';
2
+ /**
3
+ * Plaintext DEKs live in memory by design so hot secret reads avoid unwrapping on
4
+ * every access. Node cannot reliably zeroize Buffers, so residency is bounded by
5
+ * LRU size and lazy TTL instead of pretending wipes are a complete mitigation.
6
+ */
7
+ export declare class DekManager {
8
+ #private;
9
+ constructor(keyProvider: KeyProvider, options: {
10
+ maxEntries: number;
11
+ ttlMs: number;
12
+ });
13
+ getPlaintextDek(workspaceId: string): Promise<Buffer>;
14
+ invalidate(workspaceId: string): void;
15
+ }
16
+ //# sourceMappingURL=dek-manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dek-manager.d.ts","sourceRoot":"","sources":["../../src/core/dek-manager.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,mBAAmB,CAAC;AAInD;;;;GAIG;AACH,qBAAa,UAAU;;gBAKT,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC;IAK5E,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA4D3D,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;CAetC"}
@@ -0,0 +1,85 @@
1
+ import crypto from 'node:crypto';
2
+ import { getDataKey, insertDataKeyIfAbsent } from '#db/index.js';
3
+ import { classifyDekAccessError, recordSecretsDekAccess } from '#metrics/instance.js';
4
+ const DEK_BYTES = 32;
5
+ /**
6
+ * Plaintext DEKs live in memory by design so hot secret reads avoid unwrapping on
7
+ * every access. Node cannot reliably zeroize Buffers, so residency is bounded by
8
+ * LRU size and lazy TTL instead of pretending wipes are a complete mitigation.
9
+ */ export class DekManager {
10
+ #cache;
11
+ #keyProvider;
12
+ #options;
13
+ constructor(keyProvider, options){
14
+ this.#cache = new Map();
15
+ this.#keyProvider = keyProvider;
16
+ this.#options = options;
17
+ }
18
+ async getPlaintextDek(workspaceId) {
19
+ const startedAt = Date.now();
20
+ try {
21
+ const cached = this.#cache.get(workspaceId);
22
+ if (cached && cached.expiresAt > Date.now()) {
23
+ this.#cache.delete(workspaceId);
24
+ this.#cache.set(workspaceId, cached);
25
+ recordSecretsDekAccess({
26
+ outcome: 'cache_hit',
27
+ durationMs: Date.now() - startedAt
28
+ });
29
+ return Buffer.from(cached.dek);
30
+ }
31
+ const hadExpiredCache = Boolean(cached);
32
+ if (cached) this.#cache.delete(workspaceId);
33
+ const existing = await getDataKey(workspaceId);
34
+ if (existing) {
35
+ const dek = this.#keyProvider.unwrapDek(workspaceId, existing.wrappedDek, existing.kekVersion);
36
+ this.#set(workspaceId, dek);
37
+ recordSecretsDekAccess({
38
+ outcome: hadExpiredCache ? 'cache_expired' : 'db_unwrapped',
39
+ durationMs: Date.now() - startedAt
40
+ });
41
+ return Buffer.from(dek);
42
+ }
43
+ const generatedDek = crypto.randomBytes(DEK_BYTES);
44
+ const wrapped = this.#keyProvider.wrapDek(workspaceId, generatedDek);
45
+ const inserted = await insertDataKeyIfAbsent({
46
+ workspaceId,
47
+ wrappedDek: wrapped.wrappedDek,
48
+ kekVersion: wrapped.kekVersion
49
+ });
50
+ // The DEK row commits before value writes. If concurrent first-use inserts race,
51
+ // the primary key decides the winner and every caller re-reads the persisted row.
52
+ const persisted = await getDataKey(workspaceId);
53
+ if (!persisted) throw new Error(`Data key was not persisted for workspace ${workspaceId}`);
54
+ const dek = this.#keyProvider.unwrapDek(workspaceId, persisted.wrappedDek, persisted.kekVersion);
55
+ this.#set(workspaceId, dek);
56
+ recordSecretsDekAccess({
57
+ outcome: inserted ? 'generated' : 'db_unwrapped',
58
+ durationMs: Date.now() - startedAt
59
+ });
60
+ return Buffer.from(dek);
61
+ } catch (error) {
62
+ recordSecretsDekAccess({
63
+ outcome: classifyDekAccessError(error),
64
+ durationMs: Date.now() - startedAt
65
+ });
66
+ throw error;
67
+ }
68
+ }
69
+ invalidate(workspaceId) {
70
+ this.#cache.delete(workspaceId);
71
+ }
72
+ #set(workspaceId, dek) {
73
+ this.#cache.set(workspaceId, {
74
+ dek: Buffer.from(dek),
75
+ expiresAt: Date.now() + this.#options.ttlMs
76
+ });
77
+ while(this.#cache.size > this.#options.maxEntries){
78
+ const oldest = this.#cache.keys().next().value;
79
+ if (!oldest) break;
80
+ this.#cache.delete(oldest);
81
+ }
82
+ }
83
+ }
84
+
85
+ //# sourceMappingURL=dek-manager.js.map