@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,452 @@
1
+ import {
2
+ AUTH_USER,
3
+ buildUserContext,
4
+ setUserContext,
5
+ type UserContextMembership,
6
+ } from '@shipfox/api-auth-context';
7
+ import {ProjectNotFoundError, requireProjectForWorkspace} from '@shipfox/api-projects';
8
+ import {
9
+ SECRET_CREATED,
10
+ SECRET_DELETED,
11
+ SECRET_UPDATED,
12
+ VARIABLE_CREATED,
13
+ VARIABLE_DELETED,
14
+ } from '@shipfox/api-secrets-dto';
15
+ import type {AuthMethod, FastifyRequest} from '@shipfox/node-fastify';
16
+ import {ClientError, closeApp, createApp} from '@shipfox/node-fastify';
17
+ import {and, eq, isNull, sql} from 'drizzle-orm';
18
+ import {setSecrets} from '#core/index.js';
19
+ import {db, secretsOutbox, secretValues, secretVariables} from '#db/index.js';
20
+ import {secretsRoutes} from './index.js';
21
+
22
+ vi.mock('@shipfox/api-projects', async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import('@shipfox/api-projects')>();
24
+ return {...actual, requireProjectForWorkspace: vi.fn()};
25
+ });
26
+
27
+ const USER_ID = '11111111-1111-4111-8111-111111111111';
28
+ let authenticatedMemberships: UserContextMembership[] = [];
29
+
30
+ const fakeUserAuth: AuthMethod = {
31
+ name: AUTH_USER,
32
+ authenticate: (request: FastifyRequest) => {
33
+ if (request.headers.authorization !== 'Bearer user') {
34
+ throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
35
+ }
36
+
37
+ setUserContext(
38
+ request,
39
+ buildUserContext({
40
+ userId: USER_ID,
41
+ email: 'user@example.com',
42
+ memberships: authenticatedMemberships,
43
+ }),
44
+ );
45
+ return Promise.resolve();
46
+ },
47
+ };
48
+
49
+ describe('secrets management routes', () => {
50
+ let app: Awaited<ReturnType<typeof createApp>>;
51
+ let workspaceId: string;
52
+ let projectId: string;
53
+
54
+ beforeEach(async () => {
55
+ await closeApp();
56
+ workspaceId = crypto.randomUUID();
57
+ projectId = crypto.randomUUID();
58
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
59
+ vi.mocked(requireProjectForWorkspace).mockResolvedValue({
60
+ id: projectId,
61
+ workspaceId,
62
+ name: 'Project',
63
+ sourceConnectionId: crypto.randomUUID(),
64
+ sourceExternalRepositoryId: 'repo-1',
65
+ createdAt: new Date(),
66
+ updatedAt: new Date(),
67
+ });
68
+ app = await createApp({auth: [fakeUserAuth], routes: secretsRoutes, swagger: false});
69
+ await app.ready();
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await db().delete(secretValues).where(eq(secretValues.workspaceId, workspaceId));
74
+ await db().delete(secretVariables).where(eq(secretVariables.workspaceId, workspaceId));
75
+ await closeApp();
76
+ });
77
+
78
+ it('registers management routes under user auth', () => {
79
+ expect(secretsRoutes[0]?.auth).toBe(AUTH_USER);
80
+ });
81
+
82
+ it('returns 401 unauthenticated and 403 for non-members', async () => {
83
+ const unauthenticated = await app.inject({
84
+ method: 'GET',
85
+ url: `/workspaces/${workspaceId}/secrets`,
86
+ });
87
+ authenticatedMemberships = [];
88
+
89
+ const forbidden = await app.inject({
90
+ method: 'GET',
91
+ url: `/workspaces/${workspaceId}/secrets`,
92
+ headers: {authorization: 'Bearer user'},
93
+ });
94
+
95
+ expect(unauthenticated.statusCode).toBe(401);
96
+ expect(forbidden.statusCode).toBe(403);
97
+ });
98
+
99
+ it('requires admin membership for writes', async () => {
100
+ authenticatedMemberships = [{workspaceId, role: 'viewer' as UserContextMembership['role']}];
101
+
102
+ const res = await app.inject({
103
+ method: 'PUT',
104
+ url: `/workspaces/${workspaceId}/secrets/API_TOKEN`,
105
+ headers: {authorization: 'Bearer user'},
106
+ payload: {value: 'sk-live-secret'},
107
+ });
108
+
109
+ expect(res.statusCode).toBe(403);
110
+ expect(res.json().code).toBe('forbidden');
111
+ });
112
+
113
+ it('creates secrets without exposing plaintext or fingerprints', async () => {
114
+ const secretValue = 'sk-live-secret-value';
115
+
116
+ const res = await app.inject({
117
+ method: 'PUT',
118
+ url: `/workspaces/${workspaceId}/secrets/API_TOKEN`,
119
+ headers: {authorization: 'Bearer user'},
120
+ payload: {value: secretValue},
121
+ });
122
+ const rows = await db()
123
+ .select()
124
+ .from(secretValues)
125
+ .where(eq(secretValues.workspaceId, workspaceId));
126
+
127
+ expect(res.statusCode).toBe(200);
128
+ expect(res.json().secret).toMatchObject({
129
+ key: 'API_TOKEN',
130
+ project_id: null,
131
+ last_edited_by: USER_ID,
132
+ });
133
+ expect(res.body).not.toContain(secretValue);
134
+ expect(res.body).not.toContain('fingerprint');
135
+ expect(res.body).not.toContain(rows[0]?.fingerprint ?? 'missing-fingerprint');
136
+ });
137
+
138
+ it('keeps system-namespaced records invisible to management lists', async () => {
139
+ await setSecrets({
140
+ workspaceId,
141
+ namespace: 'system/agent/model-provider/openai',
142
+ values: {API_TOKEN: 'system-secret'},
143
+ });
144
+ await app.inject({
145
+ method: 'PUT',
146
+ url: `/workspaces/${workspaceId}/secrets/API_TOKEN`,
147
+ headers: {authorization: 'Bearer user'},
148
+ payload: {value: 'user-secret'},
149
+ });
150
+
151
+ const res = await app.inject({
152
+ method: 'GET',
153
+ url: `/workspaces/${workspaceId}/secrets`,
154
+ headers: {authorization: 'Bearer user'},
155
+ });
156
+
157
+ expect(res.statusCode).toBe(200);
158
+ expect(res.json().secrets).toHaveLength(1);
159
+ expect(res.json().secrets[0].key).toBe('API_TOKEN');
160
+ });
161
+
162
+ it('uses exact project scope for management lists', async () => {
163
+ await app.inject({
164
+ method: 'PUT',
165
+ url: `/workspaces/${workspaceId}/variables/REGION`,
166
+ headers: {authorization: 'Bearer user'},
167
+ payload: {value: 'workspace'},
168
+ });
169
+ await app.inject({
170
+ method: 'PUT',
171
+ url: `/workspaces/${workspaceId}/variables/REGION`,
172
+ headers: {authorization: 'Bearer user'},
173
+ payload: {project_id: projectId, value: 'project'},
174
+ });
175
+
176
+ const res = await app.inject({
177
+ method: 'GET',
178
+ url: `/workspaces/${workspaceId}/variables?project_id=${projectId}`,
179
+ headers: {authorization: 'Bearer user'},
180
+ });
181
+
182
+ expect(res.statusCode).toBe(200);
183
+ expect(res.json().variables).toEqual([
184
+ expect.objectContaining({key: 'REGION', project_id: projectId, value: 'project'}),
185
+ ]);
186
+ });
187
+
188
+ it('returns readable variables and advisory warnings for sensitive names', async () => {
189
+ const put = await app.inject({
190
+ method: 'PUT',
191
+ url: `/workspaces/${workspaceId}/variables/API_TOKEN`,
192
+ headers: {authorization: 'Bearer user'},
193
+ payload: {value: 'not-secret-but-sensitive-name'},
194
+ });
195
+
196
+ const get = await app.inject({
197
+ method: 'GET',
198
+ url: `/workspaces/${workspaceId}/variables/API_TOKEN`,
199
+ headers: {authorization: 'Bearer user'},
200
+ });
201
+
202
+ expect(put.statusCode).toBe(200);
203
+ expect(put.json().warnings).toEqual([{code: 'sensitive-variable-name', key: 'API_TOKEN'}]);
204
+ expect(get.statusCode).toBe(200);
205
+ expect(get.json().variable.value).toBe('not-secret-but-sensitive-name');
206
+ });
207
+
208
+ it('returns a bounded single-line preview for variable list values', async () => {
209
+ await app.inject({
210
+ method: 'PUT',
211
+ url: `/workspaces/${workspaceId}/variables/MULTILINE`,
212
+ headers: {authorization: 'Bearer user'},
213
+ payload: {value: 'first line\nsecond line'},
214
+ });
215
+ await app.inject({
216
+ method: 'PUT',
217
+ url: `/workspaces/${workspaceId}/variables/SHORT`,
218
+ headers: {authorization: 'Bearer user'},
219
+ payload: {value: 'debug'},
220
+ });
221
+
222
+ const list = await app.inject({
223
+ method: 'GET',
224
+ url: `/workspaces/${workspaceId}/variables?limit=10000`,
225
+ headers: {authorization: 'Bearer user'},
226
+ });
227
+ const full = await app.inject({
228
+ method: 'GET',
229
+ url: `/workspaces/${workspaceId}/variables/MULTILINE`,
230
+ headers: {authorization: 'Bearer user'},
231
+ });
232
+
233
+ const byKey = Object.fromEntries(
234
+ (list.json().variables as Array<{key: string; value: string; value_truncated: boolean}>).map(
235
+ (variable) => [variable.key, variable],
236
+ ),
237
+ );
238
+ expect(byKey.MULTILINE).toMatchObject({value: 'first line', value_truncated: true});
239
+ expect(byKey.SHORT).toMatchObject({value: 'debug', value_truncated: false});
240
+ // The full value is still available through the single-item read used for editing.
241
+ expect(full.json().variable.value).toBe('first line\nsecond line');
242
+ });
243
+
244
+ it('supports paginated secret lists', async () => {
245
+ await app.inject({
246
+ method: 'POST',
247
+ url: `/workspaces/${workspaceId}/secrets:batch`,
248
+ headers: {authorization: 'Bearer user'},
249
+ payload: {
250
+ entries: [
251
+ {key: 'ALPHA', value: 'alpha-value'},
252
+ {key: 'BRAVO', value: 'bravo-value'},
253
+ ],
254
+ },
255
+ });
256
+
257
+ const first = await app.inject({
258
+ method: 'GET',
259
+ url: `/workspaces/${workspaceId}/secrets?limit=1`,
260
+ headers: {authorization: 'Bearer user'},
261
+ });
262
+ const second = await app.inject({
263
+ method: 'GET',
264
+ url: `/workspaces/${workspaceId}/secrets?limit=1&cursor=${first.json().next_cursor}`,
265
+ headers: {authorization: 'Bearer user'},
266
+ });
267
+
268
+ expect(first.statusCode).toBe(200);
269
+ expect(first.json().secrets.map((secret: {key: string}) => secret.key)).toEqual(['ALPHA']);
270
+ expect(second.json().secrets.map((secret: {key: string}) => secret.key)).toEqual(['BRAVO']);
271
+ });
272
+
273
+ it('accepts a limit above 100 and returns the whole set in one call', async () => {
274
+ await app.inject({
275
+ method: 'POST',
276
+ url: `/workspaces/${workspaceId}/secrets:batch`,
277
+ headers: {authorization: 'Bearer user'},
278
+ payload: {
279
+ entries: [
280
+ {key: 'ALPHA', value: 'alpha-value'},
281
+ {key: 'BRAVO', value: 'bravo-value'},
282
+ ],
283
+ },
284
+ });
285
+
286
+ const res = await app.inject({
287
+ method: 'GET',
288
+ url: `/workspaces/${workspaceId}/secrets?limit=10000`,
289
+ headers: {authorization: 'Bearer user'},
290
+ });
291
+
292
+ expect(res.statusCode).toBe(200);
293
+ expect(res.json().secrets.map((secret: {key: string}) => secret.key)).toEqual([
294
+ 'ALPHA',
295
+ 'BRAVO',
296
+ ]);
297
+ expect(res.json().next_cursor).toBeNull();
298
+ });
299
+
300
+ it('rejects malformed management cursors', async () => {
301
+ const res = await app.inject({
302
+ method: 'GET',
303
+ url: `/workspaces/${workspaceId}/secrets?cursor=not-a-cursor`,
304
+ headers: {authorization: 'Bearer user'},
305
+ });
306
+
307
+ expect(res.statusCode).toBe(400);
308
+ expect(res.json().code).toBe('invalid-cursor');
309
+ });
310
+
311
+ it('rejects duplicate batch keys without returning stale warnings', async () => {
312
+ const res = await app.inject({
313
+ method: 'POST',
314
+ url: `/workspaces/${workspaceId}/secrets:batch`,
315
+ headers: {authorization: 'Bearer user'},
316
+ payload: {
317
+ entries: [
318
+ {key: 'API_TOKEN', value: 'short'},
319
+ {key: 'API_TOKEN', value: 'long-enough-secret'},
320
+ ],
321
+ },
322
+ });
323
+ const rows = await db()
324
+ .select()
325
+ .from(secretValues)
326
+ .where(eq(secretValues.workspaceId, workspaceId));
327
+
328
+ expect(res.statusCode).toBe(400);
329
+ expect(res.body).not.toContain('short-secret-value');
330
+ expect(rows).toHaveLength(0);
331
+ });
332
+
333
+ it('emits per-key create and update events for batch writes', async () => {
334
+ await app.inject({
335
+ method: 'PUT',
336
+ url: `/workspaces/${workspaceId}/secrets/API_TOKEN`,
337
+ headers: {authorization: 'Bearer user'},
338
+ payload: {value: 'first-secret'},
339
+ });
340
+
341
+ const res = await app.inject({
342
+ method: 'POST',
343
+ url: `/workspaces/${workspaceId}/secrets:batch`,
344
+ headers: {authorization: 'Bearer user'},
345
+ payload: {
346
+ entries: [
347
+ {key: 'API_TOKEN', value: 'updated-secret'},
348
+ {key: 'NEW_TOKEN', value: 'new-secret'},
349
+ ],
350
+ },
351
+ });
352
+ const events = await outboxRowsForWorkspace(workspaceId);
353
+
354
+ expect(res.statusCode).toBe(200);
355
+ expect(events.map((event) => event.eventType)).toEqual([
356
+ SECRET_CREATED,
357
+ SECRET_UPDATED,
358
+ SECRET_CREATED,
359
+ ]);
360
+ expect(events.at(-1)?.payload).toMatchObject({
361
+ actorId: USER_ID,
362
+ workspaceId,
363
+ projectId: null,
364
+ key: 'NEW_TOKEN',
365
+ });
366
+ });
367
+
368
+ it('deletes variables with a 204 and emits no event for missing deletes', async () => {
369
+ await app.inject({
370
+ method: 'PUT',
371
+ url: `/workspaces/${workspaceId}/variables/REGION`,
372
+ headers: {authorization: 'Bearer user'},
373
+ payload: {value: 'eu-west-1'},
374
+ });
375
+
376
+ const deleted = await app.inject({
377
+ method: 'DELETE',
378
+ url: `/workspaces/${workspaceId}/variables/REGION`,
379
+ headers: {authorization: 'Bearer user'},
380
+ });
381
+ const missing = await app.inject({
382
+ method: 'DELETE',
383
+ url: `/workspaces/${workspaceId}/variables/REGION`,
384
+ headers: {authorization: 'Bearer user'},
385
+ });
386
+ const events = await outboxRowsForWorkspace(workspaceId);
387
+
388
+ expect(deleted.statusCode).toBe(204);
389
+ expect(missing.statusCode).toBe(404);
390
+ expect(missing.json().code).toBe('variable-not-found');
391
+ expect(events.map((event) => event.eventType)).toEqual([VARIABLE_CREATED, VARIABLE_DELETED]);
392
+ });
393
+
394
+ it('deletes secrets with a 204 and emits no event for missing deletes', async () => {
395
+ await app.inject({
396
+ method: 'PUT',
397
+ url: `/workspaces/${workspaceId}/secrets/API_TOKEN`,
398
+ headers: {authorization: 'Bearer user'},
399
+ payload: {value: 'secret-value'},
400
+ });
401
+
402
+ const deleted = await app.inject({
403
+ method: 'DELETE',
404
+ url: `/workspaces/${workspaceId}/secrets/API_TOKEN`,
405
+ headers: {authorization: 'Bearer user'},
406
+ });
407
+ const missing = await app.inject({
408
+ method: 'DELETE',
409
+ url: `/workspaces/${workspaceId}/secrets/API_TOKEN`,
410
+ headers: {authorization: 'Bearer user'},
411
+ });
412
+ const rows = await db()
413
+ .select()
414
+ .from(secretValues)
415
+ .where(eq(secretValues.workspaceId, workspaceId));
416
+ const events = await outboxRowsForWorkspace(workspaceId);
417
+
418
+ expect(deleted.statusCode).toBe(204);
419
+ expect(missing.statusCode).toBe(404);
420
+ expect(missing.json().code).toBe('secret-not-found');
421
+ expect(rows).toHaveLength(0);
422
+ expect(events.map((event) => event.eventType)).toEqual([SECRET_CREATED, SECRET_DELETED]);
423
+ });
424
+
425
+ it('returns 404 when project_id does not belong to the workspace', async () => {
426
+ vi.mocked(requireProjectForWorkspace).mockRejectedValueOnce(
427
+ new ProjectNotFoundError(projectId),
428
+ );
429
+
430
+ const res = await app.inject({
431
+ method: 'GET',
432
+ url: `/workspaces/${workspaceId}/variables?project_id=${projectId}`,
433
+ headers: {authorization: 'Bearer user'},
434
+ });
435
+
436
+ expect(res.statusCode).toBe(404);
437
+ expect(res.json().code).toBe('project-not-found');
438
+ });
439
+ });
440
+
441
+ async function outboxRowsForWorkspace(workspaceId: string) {
442
+ return await db()
443
+ .select()
444
+ .from(secretsOutbox)
445
+ .where(
446
+ and(
447
+ sql`${secretsOutbox.payload}->>'workspaceId' = ${workspaceId}`,
448
+ isNull(secretsOutbox.deadLetteredAt),
449
+ ),
450
+ )
451
+ .orderBy(secretsOutbox.createdAt, secretsOutbox.id);
452
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ putSecretBodySchema,
3
+ putSecretResponseSchema,
4
+ secretKeySchema,
5
+ } from '@shipfox/api-secrets-dto';
6
+ import {defineRoute} from '@shipfox/node-fastify';
7
+ import {z} from 'zod';
8
+ import {setManagedSecrets} from '#core/index.js';
9
+ import {toSecretDto} from '#presentation/dto/index.js';
10
+ import {requireManagementWrite} from './auth.js';
11
+ import {translateManagementError} from './errors.js';
12
+ import {secretWarnings} from './warnings.js';
13
+
14
+ export const putSecretRoute = defineRoute({
15
+ method: 'PUT',
16
+ path: '/secrets/:key',
17
+ description: 'Create or update a user-managed secret.',
18
+ schema: {
19
+ params: z.object({workspaceId: z.string().uuid(), key: secretKeySchema}),
20
+ body: putSecretBodySchema,
21
+ response: {200: putSecretResponseSchema},
22
+ },
23
+ errorHandler: translateManagementError,
24
+ handler: async (request) => {
25
+ const {workspaceId, key} = request.params;
26
+ const {project_id: projectId, value} = request.body;
27
+ const access = await requireManagementWrite({request, workspaceId, projectId});
28
+ const entries = [{key, value}];
29
+ const [secret] = await setManagedSecrets({
30
+ workspaceId,
31
+ projectId,
32
+ actorId: access.userId,
33
+ entries,
34
+ });
35
+ if (!secret) throw new Error('Secret write returned no row');
36
+
37
+ return {secret: toSecretDto(secret), warnings: secretWarnings(entries)};
38
+ },
39
+ });
@@ -0,0 +1,39 @@
1
+ import {
2
+ putVariableBodySchema,
3
+ putVariableResponseSchema,
4
+ secretKeySchema,
5
+ } from '@shipfox/api-secrets-dto';
6
+ import {defineRoute} from '@shipfox/node-fastify';
7
+ import {z} from 'zod';
8
+ import {setManagedVariables} from '#core/index.js';
9
+ import {toVariableDto} from '#presentation/dto/index.js';
10
+ import {requireManagementWrite} from './auth.js';
11
+ import {translateManagementError} from './errors.js';
12
+ import {variableWarnings} from './warnings.js';
13
+
14
+ export const putVariableRoute = defineRoute({
15
+ method: 'PUT',
16
+ path: '/variables/:key',
17
+ description: 'Create or update a user-managed variable.',
18
+ schema: {
19
+ params: z.object({workspaceId: z.string().uuid(), key: secretKeySchema}),
20
+ body: putVariableBodySchema,
21
+ response: {200: putVariableResponseSchema},
22
+ },
23
+ errorHandler: translateManagementError,
24
+ handler: async (request) => {
25
+ const {workspaceId, key} = request.params;
26
+ const {project_id: projectId, value} = request.body;
27
+ const access = await requireManagementWrite({request, workspaceId, projectId});
28
+ const entries = [{key, value}];
29
+ const [variable] = await setManagedVariables({
30
+ workspaceId,
31
+ projectId,
32
+ actorId: access.userId,
33
+ entries,
34
+ });
35
+ if (!variable) throw new Error('Variable write returned no row');
36
+
37
+ return {variable: toVariableDto(variable), warnings: variableWarnings(entries)};
38
+ },
39
+ });
@@ -0,0 +1,23 @@
1
+ import {
2
+ SENSITIVE_VARIABLE_NAME_WARNING,
3
+ type SecretWriteWarningDto,
4
+ SHORT_SECRET_VALUE_WARNING,
5
+ shouldWarnSensitiveVariableName,
6
+ shouldWarnShortSecretValue,
7
+ } from '@shipfox/api-secrets-dto';
8
+ import {config} from '#config.js';
9
+ import type {ManagementEntry} from '#core/index.js';
10
+
11
+ export function secretWarnings(entries: ManagementEntry[]): SecretWriteWarningDto[] {
12
+ return entries
13
+ .filter((entry) =>
14
+ shouldWarnShortSecretValue(entry.value, config.SECRETS_SHORT_VALUE_WARN_LENGTH),
15
+ )
16
+ .map((entry) => ({code: SHORT_SECRET_VALUE_WARNING, key: entry.key}));
17
+ }
18
+
19
+ export function variableWarnings(entries: ManagementEntry[]): SecretWriteWarningDto[] {
20
+ return entries
21
+ .filter((entry) => shouldWarnSensitiveVariableName(entry.key))
22
+ .map((entry) => ({code: SENSITIVE_VARIABLE_NAME_WARNING, key: entry.key}));
23
+ }
package/test/env.ts ADDED
@@ -0,0 +1,8 @@
1
+ process.env.POSTGRES_HOST ??= 'localhost';
2
+ process.env.POSTGRES_PORT ??= '5432';
3
+ process.env.POSTGRES_USERNAME ??= 'shipfox';
4
+ process.env.POSTGRES_PASSWORD ??= 'password';
5
+ process.env.POSTGRES_DATABASE = 'api_test';
6
+ process.env.POSTGRES_MAX_CONNECTIONS ??= '5';
7
+ process.env.TZ = 'UTC';
8
+ process.env.SECRETS_ENCRYPTION_KEK = 'ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=';
@@ -0,0 +1,18 @@
1
+ import './env.js';
2
+ import {runMigrations} from '@shipfox/node-drizzle';
3
+ import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
4
+ import {sql} from 'drizzle-orm';
5
+ import {closeDb, db, migrationsPath} from '#db/index.js';
6
+
7
+ export async function setup() {
8
+ createPostgresClient();
9
+
10
+ await runMigrations(db(), migrationsPath, '__drizzle_migrations_secrets');
11
+ await db().execute(sql`TRUNCATE secrets_outbox CASCADE`);
12
+ await db().execute(sql`TRUNCATE secrets_values CASCADE`);
13
+ await db().execute(sql`TRUNCATE secrets_variables CASCADE`);
14
+ await db().execute(sql`TRUNCATE secrets_data_keys CASCADE`);
15
+
16
+ closeDb();
17
+ await closePostgresClient();
18
+ }
package/test/setup.ts ADDED
@@ -0,0 +1,17 @@
1
+ import './env.js';
2
+ import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
3
+ import {afterAll, afterEach, beforeAll, vi} from '@shipfox/vitest/vi';
4
+ import {closeDb} from '#db/index.js';
5
+
6
+ beforeAll(() => {
7
+ createPostgresClient();
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ afterAll(async () => {
15
+ closeDb();
16
+ await closePostgresClient();
17
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "@shipfox/ts-config/node",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "references": [{ "path": "../secrets-dto/tsconfig.build.json" }],
8
+ "include": ["src"],
9
+ "exclude": ["**/*.test.tsx", "**/*.test.ts"]
10
+ }