@clef-sh/ui 0.1.13-beta.88

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 (70) hide show
  1. package/README.md +38 -0
  2. package/dist/client/assets/index-CVpAmirt.js +26 -0
  3. package/dist/client/favicon-96x96.png +0 -0
  4. package/dist/client/favicon.ico +0 -0
  5. package/dist/client/favicon.svg +16 -0
  6. package/dist/client/index.html +50 -0
  7. package/dist/client-lib/api.d.ts +3 -0
  8. package/dist/client-lib/api.d.ts.map +1 -0
  9. package/dist/client-lib/components/Button.d.ts +10 -0
  10. package/dist/client-lib/components/Button.d.ts.map +1 -0
  11. package/dist/client-lib/components/CopyButton.d.ts +6 -0
  12. package/dist/client-lib/components/CopyButton.d.ts.map +1 -0
  13. package/dist/client-lib/components/EnvBadge.d.ts +7 -0
  14. package/dist/client-lib/components/EnvBadge.d.ts.map +1 -0
  15. package/dist/client-lib/components/MatrixGrid.d.ts +13 -0
  16. package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -0
  17. package/dist/client-lib/components/Sidebar.d.ts +16 -0
  18. package/dist/client-lib/components/Sidebar.d.ts.map +1 -0
  19. package/dist/client-lib/components/StatusDot.d.ts +6 -0
  20. package/dist/client-lib/components/StatusDot.d.ts.map +1 -0
  21. package/dist/client-lib/components/TopBar.d.ts +9 -0
  22. package/dist/client-lib/components/TopBar.d.ts.map +1 -0
  23. package/dist/client-lib/index.d.ts +12 -0
  24. package/dist/client-lib/index.d.ts.map +1 -0
  25. package/dist/client-lib/theme.d.ts +42 -0
  26. package/dist/client-lib/theme.d.ts.map +1 -0
  27. package/dist/server/api.d.ts +11 -0
  28. package/dist/server/api.d.ts.map +1 -0
  29. package/dist/server/api.js +1020 -0
  30. package/dist/server/api.js.map +1 -0
  31. package/dist/server/index.d.ts +12 -0
  32. package/dist/server/index.d.ts.map +1 -0
  33. package/dist/server/index.js +231 -0
  34. package/dist/server/index.js.map +1 -0
  35. package/package.json +74 -0
  36. package/src/client/App.tsx +205 -0
  37. package/src/client/api.test.tsx +94 -0
  38. package/src/client/api.ts +30 -0
  39. package/src/client/components/Button.tsx +52 -0
  40. package/src/client/components/CopyButton.test.tsx +43 -0
  41. package/src/client/components/CopyButton.tsx +36 -0
  42. package/src/client/components/EnvBadge.tsx +32 -0
  43. package/src/client/components/MatrixGrid.tsx +265 -0
  44. package/src/client/components/Sidebar.tsx +337 -0
  45. package/src/client/components/StatusDot.tsx +30 -0
  46. package/src/client/components/TopBar.tsx +50 -0
  47. package/src/client/index.html +50 -0
  48. package/src/client/index.ts +18 -0
  49. package/src/client/main.tsx +15 -0
  50. package/src/client/public/favicon-96x96.png +0 -0
  51. package/src/client/public/favicon.ico +0 -0
  52. package/src/client/public/favicon.svg +16 -0
  53. package/src/client/screens/BackendScreen.test.tsx +611 -0
  54. package/src/client/screens/BackendScreen.tsx +836 -0
  55. package/src/client/screens/DiffView.test.tsx +130 -0
  56. package/src/client/screens/DiffView.tsx +547 -0
  57. package/src/client/screens/GitLogView.test.tsx +113 -0
  58. package/src/client/screens/GitLogView.tsx +192 -0
  59. package/src/client/screens/ImportScreen.tsx +710 -0
  60. package/src/client/screens/LintView.test.tsx +143 -0
  61. package/src/client/screens/LintView.tsx +589 -0
  62. package/src/client/screens/MatrixView.test.tsx +138 -0
  63. package/src/client/screens/MatrixView.tsx +143 -0
  64. package/src/client/screens/NamespaceEditor.test.tsx +694 -0
  65. package/src/client/screens/NamespaceEditor.tsx +1122 -0
  66. package/src/client/screens/RecipientsScreen.tsx +696 -0
  67. package/src/client/screens/ScanScreen.test.tsx +323 -0
  68. package/src/client/screens/ScanScreen.tsx +523 -0
  69. package/src/client/screens/ServiceIdentitiesScreen.tsx +1398 -0
  70. package/src/client/theme.ts +48 -0
@@ -0,0 +1,1020 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createApiRouter = createApiRouter;
37
+ const path = __importStar(require("path"));
38
+ const os = __importStar(require("os"));
39
+ const child_process_1 = require("child_process");
40
+ const express_1 = require("express");
41
+ // On Linux, libuv creates socketpairs for child stdio. Go's os.Open on
42
+ // /dev/stdin re-opens /proc/self/fd/0 which fails with ENXIO on socketpairs.
43
+ // Use a FIFO workaround on Linux, but not inside Jest (where the runner is
44
+ // mocked and real subprocesses are never spawned).
45
+ const _useStdinFifo = process.platform === "linux" && !process.env.JEST_WORKER_ID;
46
+ const core_1 = require("@clef-sh/core");
47
+ function createApiRouter(deps) {
48
+ const router = (0, express_1.Router)();
49
+ const parser = new core_1.ManifestParser();
50
+ const matrix = new core_1.MatrixManager();
51
+ // Wrap the runner so sops subprocesses always run from the repo root
52
+ // and work around /dev/stdin failures on Linux.
53
+ //
54
+ // Problem: SopsClient.encrypt passes /dev/stdin as the input file.
55
+ // On Linux /dev/stdin → /proc/self/fd/0 which fails with ENXIO when
56
+ // the Node SEA binary was spawned with stdin detached.
57
+ //
58
+ // Fix: when we see /dev/stdin in the args AND stdin content in opts,
59
+ // replace it with a FIFO (named pipe). A FIFO is an in-memory kernel
60
+ // buffer — plaintext never touches disk. The FIFO is cleaned up after
61
+ // the subprocess exits.
62
+ const sopsRunner = {
63
+ run: (cmd, args, opts) => {
64
+ const stdinIdx = args.indexOf("/dev/stdin");
65
+ // Only use the FIFO workaround in Linux SEA binaries where
66
+ // /dev/stdin → /proc/self/fd/0 fails with ENXIO on socketpairs.
67
+ // Normal Node.js processes (including Jest on Linux CI) work fine.
68
+ const needsFifo = stdinIdx >= 0 && opts?.stdin !== undefined && _useStdinFifo;
69
+ if (!needsFifo) {
70
+ return deps.runner.run(cmd, args, {
71
+ ...opts,
72
+ cwd: opts?.cwd ?? deps.repoRoot,
73
+ env: opts?.env,
74
+ });
75
+ }
76
+ // Create a FIFO and feed stdin content through a background process
77
+ const fifoDir = (0, child_process_1.execFileSync)("mktemp", ["-d", path.join(os.tmpdir(), "clef-fifo-XXXXXX")])
78
+ .toString()
79
+ .trim();
80
+ const fifoPath = path.join(fifoDir, "input");
81
+ (0, child_process_1.execFileSync)("mkfifo", [fifoPath]);
82
+ // Background writer — blocks at OS level until sops opens the read end
83
+ const writer = (0, child_process_1.spawn)("dd", [`of=${fifoPath}`, "status=none"], {
84
+ stdio: ["pipe", "ignore", "ignore"],
85
+ });
86
+ writer.stdin.write(opts.stdin);
87
+ writer.stdin.end();
88
+ const patchedArgs = [...args];
89
+ patchedArgs[stdinIdx] = fifoPath;
90
+ const { stdin: _stdin, ...restOpts } = opts;
91
+ return deps.runner
92
+ .run(cmd, patchedArgs, {
93
+ ...restOpts,
94
+ cwd: restOpts?.cwd ?? deps.repoRoot,
95
+ env: { SOPS_CONFIG: path.join(deps.repoRoot, ".sops.yaml"), ...restOpts?.env },
96
+ })
97
+ .finally(() => {
98
+ try {
99
+ writer.kill();
100
+ }
101
+ catch {
102
+ /* already exited */
103
+ }
104
+ try {
105
+ (0, child_process_1.execFileSync)("rm", ["-rf", fifoDir]);
106
+ }
107
+ catch {
108
+ /* best effort */
109
+ }
110
+ });
111
+ },
112
+ };
113
+ const sops = new core_1.SopsClient(sopsRunner, deps.ageKeyFile, deps.ageKey, deps.sopsPath);
114
+ const diffEngine = new core_1.DiffEngine();
115
+ const schemaValidator = new core_1.SchemaValidator();
116
+ const lintRunner = new core_1.LintRunner(matrix, schemaValidator, sops);
117
+ const git = new core_1.GitIntegration(deps.runner);
118
+ const scanRunner = new core_1.ScanRunner(deps.runner);
119
+ const recipientManager = new core_1.RecipientManager(sops, matrix);
120
+ const serviceIdManager = new core_1.ServiceIdentityManager(sops, matrix);
121
+ const backendMigrator = new core_1.BackendMigrator(sops, matrix);
122
+ const bulkOps = new core_1.BulkOps();
123
+ // In-session scan cache
124
+ let lastScanResult = null;
125
+ let lastScanAt = null;
126
+ function loadManifest() {
127
+ const manifestPath = `${deps.repoRoot}/clef.yaml`;
128
+ return parser.parse(manifestPath);
129
+ }
130
+ function zeroStringRecord(record) {
131
+ for (const k of Object.keys(record))
132
+ record[k] = "";
133
+ }
134
+ function setNoCacheHeaders(res) {
135
+ res.set({
136
+ "Cache-Control": "no-store, no-cache, must-revalidate",
137
+ Pragma: "no-cache",
138
+ Expires: "0",
139
+ });
140
+ }
141
+ // GET /api/manifest
142
+ router.get("/manifest", (_req, res) => {
143
+ try {
144
+ const manifest = loadManifest();
145
+ res.json(manifest);
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : "Failed to load manifest";
149
+ res.status(500).json({ error: message, code: "MANIFEST_ERROR" });
150
+ }
151
+ });
152
+ // GET /api/matrix
153
+ router.get("/matrix", async (_req, res) => {
154
+ try {
155
+ const manifest = loadManifest();
156
+ const statuses = await matrix.getMatrixStatus(manifest, deps.repoRoot, sops);
157
+ res.json(statuses);
158
+ }
159
+ catch (err) {
160
+ const message = err instanceof Error ? err.message : "Failed to get matrix status";
161
+ res.status(500).json({ error: message, code: "MATRIX_ERROR" });
162
+ }
163
+ });
164
+ // GET /api/namespace/:ns/:env
165
+ // FR-31 note: Decrypted values are held in V8 heap memory during the request lifecycle.
166
+ // JavaScript/V8 uses immutable strings — we cannot reliably zero them after use.
167
+ // This is a known limitation of garbage-collected runtimes.
168
+ router.get("/namespace/:ns/:env", async (req, res) => {
169
+ setNoCacheHeaders(res);
170
+ try {
171
+ const manifest = loadManifest();
172
+ const { ns, env } = req.params;
173
+ const nsExists = manifest.namespaces.some((n) => n.name === ns);
174
+ const envExists = manifest.environments.some((e) => e.name === env);
175
+ if (!nsExists || !envExists) {
176
+ res.status(404).json({
177
+ error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
178
+ code: "NOT_FOUND",
179
+ });
180
+ return;
181
+ }
182
+ const filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
183
+ const decrypted = await sops.decrypt(filePath);
184
+ // Read pending keys from metadata (plaintext sidecar)
185
+ let pending = [];
186
+ try {
187
+ pending = await (0, core_1.getPendingKeys)(filePath);
188
+ }
189
+ catch {
190
+ // Metadata unreadable — no pending info
191
+ }
192
+ res.json({ ...decrypted, pending });
193
+ }
194
+ catch {
195
+ res.status(500).json({ error: "Failed to decrypt namespace", code: "DECRYPT_ERROR" });
196
+ }
197
+ });
198
+ // PUT /api/namespace/:ns/:env/:key
199
+ // body: { value: string } — set a specific value
200
+ // body: { random: true } — generate random value server-side and mark pending
201
+ // Note: Unlike the CLI set command, the API rolls back on metadata failure
202
+ // to ensure callers always get a consistent state. See set.ts for the CLI
203
+ // approach which warns and continues. This asymmetry is intentional.
204
+ router.put("/namespace/:ns/:env/:key", async (req, res) => {
205
+ setNoCacheHeaders(res);
206
+ try {
207
+ const manifest = loadManifest();
208
+ const { ns, env, key } = req.params;
209
+ const { value, random, confirmed } = req.body;
210
+ if (!random && (value === undefined || value === null)) {
211
+ res.status(400).json({
212
+ error: "Request body must include 'value' or 'random: true'.",
213
+ code: "BAD_REQUEST",
214
+ });
215
+ return;
216
+ }
217
+ const nsExists = manifest.namespaces.some((n) => n.name === ns);
218
+ const envExists = manifest.environments.some((e) => e.name === env);
219
+ if (!nsExists || !envExists) {
220
+ res.status(404).json({
221
+ error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
222
+ code: "NOT_FOUND",
223
+ });
224
+ return;
225
+ }
226
+ if (matrix.isProtectedEnvironment(manifest, env) && !confirmed) {
227
+ res.status(409).json({
228
+ error: "Protected environment requires confirmation",
229
+ code: "PROTECTED_ENV",
230
+ protected: true,
231
+ });
232
+ return;
233
+ }
234
+ const filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
235
+ const decrypted = await sops.decrypt(filePath);
236
+ if (random) {
237
+ // Generate random value server-side and mark as pending
238
+ const randomValue = (0, core_1.generateRandomValue)();
239
+ const previousValue = decrypted.values[key];
240
+ decrypted.values[key] = randomValue;
241
+ await sops.encrypt(filePath, decrypted.values, manifest, env);
242
+ try {
243
+ await (0, core_1.markPendingWithRetry)(filePath, [key], "clef ui");
244
+ }
245
+ catch {
246
+ // Both retry attempts failed — roll back the encrypt
247
+ try {
248
+ if (previousValue !== undefined) {
249
+ decrypted.values[key] = previousValue;
250
+ }
251
+ else {
252
+ delete decrypted.values[key];
253
+ }
254
+ await sops.encrypt(filePath, decrypted.values, manifest, env);
255
+ }
256
+ catch {
257
+ // Rollback also failed — return 500 with context
258
+ return res.status(500).json({
259
+ error: "Partial failure",
260
+ message: "Value was encrypted but pending state could not be recorded. " +
261
+ "Rollback also failed. The key may have a random placeholder value. " +
262
+ "Check the file manually.",
263
+ code: "PARTIAL_FAILURE",
264
+ });
265
+ }
266
+ return res.status(500).json({
267
+ error: "Pending state could not be recorded",
268
+ message: "The operation was rolled back. No changes were made.",
269
+ code: "PENDING_FAILURE",
270
+ });
271
+ }
272
+ res.json({ success: true, key, pending: true });
273
+ }
274
+ else {
275
+ decrypted.values[key] = String(value);
276
+ await sops.encrypt(filePath, decrypted.values, manifest, env);
277
+ // Validate against schema if defined (B1)
278
+ const nsDef = manifest.namespaces.find((n) => n.name === ns);
279
+ if (nsDef?.schema) {
280
+ try {
281
+ const schema = schemaValidator.loadSchema(path.join(deps.repoRoot, nsDef.schema));
282
+ const result = schemaValidator.validate({ [key]: String(value) }, schema);
283
+ const violations = [...result.errors, ...result.warnings];
284
+ if (violations.length > 0) {
285
+ // Resolve pending state if the key was pending
286
+ try {
287
+ await (0, core_1.markResolved)(filePath, [key]);
288
+ }
289
+ catch {
290
+ // Metadata update failed — non-fatal
291
+ }
292
+ return res.json({
293
+ success: true,
294
+ key,
295
+ warnings: violations.map((v) => v.message),
296
+ });
297
+ }
298
+ }
299
+ catch {
300
+ // Schema load failed — skip validation, not fatal
301
+ }
302
+ }
303
+ // Resolve pending state if the key was pending
304
+ try {
305
+ await (0, core_1.markResolved)(filePath, [key]);
306
+ }
307
+ catch {
308
+ // Metadata update failed — non-fatal
309
+ }
310
+ res.json({ success: true, key });
311
+ }
312
+ }
313
+ catch {
314
+ res.status(500).json({ error: "Failed to set value", code: "SET_ERROR" });
315
+ }
316
+ });
317
+ // DELETE /api/namespace/:ns/:env/:key
318
+ router.delete("/namespace/:ns/:env/:key", async (req, res) => {
319
+ setNoCacheHeaders(res);
320
+ try {
321
+ const manifest = loadManifest();
322
+ const { ns, env, key } = req.params;
323
+ const { confirmed } = (req.body ?? {});
324
+ const nsExists = manifest.namespaces.some((n) => n.name === ns);
325
+ const envExists = manifest.environments.some((e) => e.name === env);
326
+ if (!nsExists || !envExists) {
327
+ res.status(404).json({
328
+ error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
329
+ code: "NOT_FOUND",
330
+ });
331
+ return;
332
+ }
333
+ if (matrix.isProtectedEnvironment(manifest, env) && !confirmed) {
334
+ res.status(409).json({
335
+ error: "Protected environment requires confirmation",
336
+ code: "PROTECTED_ENV",
337
+ protected: true,
338
+ });
339
+ return;
340
+ }
341
+ const filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
342
+ const decrypted = await sops.decrypt(filePath);
343
+ if (!(key in decrypted.values)) {
344
+ res.status(404).json({
345
+ error: `Key '${key}' not found in ${ns}/${env}.`,
346
+ code: "KEY_NOT_FOUND",
347
+ });
348
+ return;
349
+ }
350
+ delete decrypted.values[key];
351
+ await sops.encrypt(filePath, decrypted.values, manifest, env);
352
+ // Clean up pending metadata if it exists
353
+ try {
354
+ await (0, core_1.markResolved)(filePath, [key]);
355
+ }
356
+ catch {
357
+ // Best effort — orphaned metadata is annoying but not dangerous
358
+ }
359
+ res.json({ success: true, key });
360
+ }
361
+ catch {
362
+ res.status(500).json({ error: "Failed to delete key", code: "DELETE_ERROR" });
363
+ }
364
+ });
365
+ // POST /api/namespace/:ns/:env/:key/accept — resolve pending state without changing the value
366
+ router.post("/namespace/:ns/:env/:key/accept", async (req, res) => {
367
+ setNoCacheHeaders(res);
368
+ try {
369
+ const manifest = loadManifest();
370
+ const { ns, env, key } = req.params;
371
+ const nsExists = manifest.namespaces.some((n) => n.name === ns);
372
+ const envExists = manifest.environments.some((e) => e.name === env);
373
+ if (!nsExists || !envExists) {
374
+ res.status(404).json({
375
+ error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
376
+ code: "NOT_FOUND",
377
+ });
378
+ return;
379
+ }
380
+ const filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
381
+ const decrypted = await sops.decrypt(filePath);
382
+ const value = key in decrypted.values ? String(decrypted.values[key]) : undefined;
383
+ await (0, core_1.markResolved)(filePath, [key]);
384
+ res.json({ success: true, key, value });
385
+ }
386
+ catch {
387
+ res.status(500).json({ error: "Failed to accept pending value", code: "ACCEPT_ERROR" });
388
+ }
389
+ });
390
+ // POST /api/copy
391
+ // body: { key, fromNs, fromEnv, toNs, toEnv, confirmed? }
392
+ router.post("/copy", async (req, res) => {
393
+ try {
394
+ const manifest = loadManifest();
395
+ const { key, fromNs, fromEnv, toNs, toEnv, confirmed } = req.body;
396
+ if (!key || !fromNs || !fromEnv || !toNs || !toEnv) {
397
+ res.status(400).json({
398
+ error: "Request body must include 'key', 'fromNs', 'fromEnv', 'toNs', 'toEnv'.",
399
+ code: "BAD_REQUEST",
400
+ });
401
+ return;
402
+ }
403
+ if (matrix.isProtectedEnvironment(manifest, toEnv) && !confirmed) {
404
+ res.status(409).json({
405
+ error: "Protected environment requires confirmation",
406
+ code: "PROTECTED_ENV",
407
+ protected: true,
408
+ });
409
+ return;
410
+ }
411
+ const cells = matrix.resolveMatrix(manifest, deps.repoRoot);
412
+ const fromCell = cells.find((c) => c.namespace === fromNs && c.environment === fromEnv);
413
+ const toCell = cells.find((c) => c.namespace === toNs && c.environment === toEnv);
414
+ if (!fromCell || !toCell) {
415
+ res.status(404).json({
416
+ error: "Source or destination cell not found in matrix.",
417
+ code: "NOT_FOUND",
418
+ });
419
+ return;
420
+ }
421
+ await bulkOps.copyValue(key, fromCell, toCell, sops, manifest);
422
+ res.json({ success: true, key, from: `${fromNs}/${fromEnv}`, to: `${toNs}/${toEnv}` });
423
+ }
424
+ catch (err) {
425
+ const message = err instanceof Error ? err.message : "Failed to copy value";
426
+ res.status(500).json({ error: message, code: "COPY_ERROR" });
427
+ }
428
+ });
429
+ // GET /api/diff/:ns/:envA/:envB
430
+ router.get("/diff/:ns/:envA/:envB", async (req, res) => {
431
+ setNoCacheHeaders(res);
432
+ try {
433
+ const manifest = loadManifest();
434
+ const { ns, envA, envB } = req.params;
435
+ const nsExists = manifest.namespaces.some((n) => n.name === ns);
436
+ const envAExists = manifest.environments.some((e) => e.name === envA);
437
+ const envBExists = manifest.environments.some((e) => e.name === envB);
438
+ if (!nsExists || !envAExists || !envBExists) {
439
+ res.status(404).json({
440
+ error: `Namespace '${ns}', environment '${envA}', or environment '${envB}' not found.`,
441
+ code: "NOT_FOUND",
442
+ });
443
+ return;
444
+ }
445
+ const result = await diffEngine.diffFiles(ns, envA, envB, manifest, sops, deps.repoRoot);
446
+ // Mask values by default — only reveal when client explicitly requests it
447
+ if (req.query.showValues !== "true") {
448
+ for (const row of result.rows) {
449
+ if (row.valueA !== null)
450
+ row.valueA = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
451
+ if (row.valueB !== null)
452
+ row.valueB = "\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF\u25CF";
453
+ }
454
+ }
455
+ res.json(result);
456
+ }
457
+ catch {
458
+ res.status(500).json({ error: "Failed to compute diff", code: "DIFF_ERROR" });
459
+ }
460
+ });
461
+ // GET /api/lint/:namespace
462
+ router.get("/lint/:namespace", async (req, res) => {
463
+ try {
464
+ const manifest = loadManifest();
465
+ const { namespace } = req.params;
466
+ const nsExists = manifest.namespaces.some((n) => n.name === namespace);
467
+ if (!nsExists) {
468
+ res.status(404).json({
469
+ error: `Namespace '${namespace}' not found in manifest.`,
470
+ code: "NOT_FOUND",
471
+ });
472
+ return;
473
+ }
474
+ const result = await lintRunner.run(manifest, deps.repoRoot);
475
+ const filtered = result.issues.filter((issue) => {
476
+ const issueNs = issue.file.split("/")[0];
477
+ return issueNs === namespace;
478
+ });
479
+ res.json({ issues: filtered, fileCount: result.fileCount });
480
+ }
481
+ catch (err) {
482
+ const message = err instanceof Error ? err.message : "Failed to run lint";
483
+ res.status(500).json({ error: message, code: "LINT_ERROR" });
484
+ }
485
+ });
486
+ // GET /api/lint
487
+ router.get("/lint", async (_req, res) => {
488
+ try {
489
+ const manifest = loadManifest();
490
+ const result = await lintRunner.run(manifest, deps.repoRoot);
491
+ res.json(result);
492
+ }
493
+ catch (err) {
494
+ const message = err instanceof Error ? err.message : "Failed to run lint";
495
+ res.status(500).json({ error: message, code: "LINT_ERROR" });
496
+ }
497
+ });
498
+ // POST /api/lint/fix
499
+ router.post("/lint/fix", async (_req, res) => {
500
+ try {
501
+ const manifest = loadManifest();
502
+ const result = await lintRunner.fix(manifest, deps.repoRoot);
503
+ res.json(result);
504
+ }
505
+ catch (err) {
506
+ const message = err instanceof Error ? err.message : "Failed to run lint fix";
507
+ res.status(500).json({ error: message, code: "LINT_FIX_ERROR" });
508
+ }
509
+ });
510
+ // POST /api/git/commit
511
+ router.post("/git/commit", async (req, res) => {
512
+ try {
513
+ const { message } = req.body;
514
+ if (!message || typeof message !== "string") {
515
+ res.status(400).json({
516
+ error: "Request body must include a 'message' string.",
517
+ code: "BAD_REQUEST",
518
+ });
519
+ return;
520
+ }
521
+ // Stage all modified encrypted files and metadata files
522
+ const status = await git.getStatus(deps.repoRoot);
523
+ const clefFiles = [...status.staged, ...status.unstaged, ...status.untracked].filter((f) => f.endsWith(".enc.yaml") || f.endsWith(".enc.json") || f.endsWith(".clef-meta.yaml"));
524
+ if (clefFiles.length === 0) {
525
+ res.status(400).json({
526
+ error: "No changes to commit",
527
+ code: "NOTHING_TO_COMMIT",
528
+ });
529
+ return;
530
+ }
531
+ await git.stageFiles(clefFiles, deps.repoRoot);
532
+ const hash = await git.commit(message, deps.repoRoot);
533
+ res.json({ hash });
534
+ }
535
+ catch (err) {
536
+ const message = err instanceof Error ? err.message : "Failed to commit";
537
+ res.status(500).json({ error: message, code: "GIT_ERROR" });
538
+ }
539
+ });
540
+ // GET /api/git/status
541
+ router.get("/git/status", async (_req, res) => {
542
+ try {
543
+ const status = await git.getStatus(deps.repoRoot);
544
+ res.json(status);
545
+ }
546
+ catch (err) {
547
+ const message = err instanceof Error ? err.message : "Failed to get git status";
548
+ res.status(500).json({ error: message, code: "GIT_ERROR" });
549
+ }
550
+ });
551
+ // GET /api/git/diff
552
+ router.get("/git/diff", async (_req, res) => {
553
+ setNoCacheHeaders(res);
554
+ try {
555
+ const diff = await git.getDiff(deps.repoRoot);
556
+ res.json({ diff });
557
+ }
558
+ catch (err) {
559
+ const message = err instanceof Error ? err.message : "Could not get diff";
560
+ res.status(500).json({ error: message, code: "GIT_DIFF_ERROR" });
561
+ }
562
+ });
563
+ // GET /api/git/log/:ns/:env
564
+ router.get("/git/log/:ns/:env", async (req, res) => {
565
+ try {
566
+ const manifest = loadManifest();
567
+ const { ns, env } = req.params;
568
+ const nsExists = manifest.namespaces.some((n) => n.name === ns);
569
+ const envExists = manifest.environments.some((e) => e.name === env);
570
+ if (!nsExists || !envExists) {
571
+ res.status(404).json({
572
+ error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
573
+ code: "NOT_FOUND",
574
+ });
575
+ return;
576
+ }
577
+ const filePath = manifest.file_pattern
578
+ .replace("{namespace}", ns)
579
+ .replace("{environment}", env);
580
+ const log = await git.getLog(filePath, deps.repoRoot);
581
+ res.json({ log });
582
+ }
583
+ catch (err) {
584
+ const message = err instanceof Error ? err.message : "Could not get log";
585
+ res.status(500).json({ error: message, code: "GIT_LOG_ERROR" });
586
+ }
587
+ });
588
+ // POST /api/scan
589
+ router.post("/scan", async (req, res) => {
590
+ try {
591
+ const manifest = loadManifest();
592
+ const { severity, paths } = req.body;
593
+ const result = await scanRunner.scan(deps.repoRoot, manifest, {
594
+ severity: severity === "high" ? "high" : "all",
595
+ paths: paths && paths.length > 0 ? paths : undefined,
596
+ });
597
+ lastScanResult = result;
598
+ lastScanAt = new Date().toISOString();
599
+ res.json(result);
600
+ }
601
+ catch (err) {
602
+ const message = err instanceof Error ? err.message : "Scan failed";
603
+ res.status(500).json({ error: message, code: "SCAN_ERROR" });
604
+ }
605
+ });
606
+ // GET /api/scan/status — last scan result for this session
607
+ router.get("/scan/status", (_req, res) => {
608
+ res.json({ lastRun: lastScanResult, lastRunAt: lastScanAt });
609
+ });
610
+ // POST /api/editor/open — open a file in the OS default editor
611
+ router.post("/editor/open", async (req, res) => {
612
+ try {
613
+ const { file } = req.body;
614
+ if (!file || typeof file !== "string") {
615
+ res
616
+ .status(400)
617
+ .json({ error: "Request body must include a 'file' string.", code: "BAD_REQUEST" });
618
+ return;
619
+ }
620
+ const resolved = path.resolve(deps.repoRoot, file);
621
+ if (!resolved.startsWith(deps.repoRoot + path.sep) && resolved !== deps.repoRoot) {
622
+ res.status(400).json({
623
+ error: "File path must be within the repository.",
624
+ code: "BAD_REQUEST",
625
+ });
626
+ return;
627
+ }
628
+ const editor = process.env.EDITOR || (process.env.TERM_PROGRAM === "vscode" ? "code" : "");
629
+ if (!editor) {
630
+ res.status(500).json({
631
+ error: "No editor configured. Set the EDITOR environment variable.",
632
+ code: "NO_EDITOR",
633
+ });
634
+ return;
635
+ }
636
+ await deps.runner.run(editor, [file], { cwd: deps.repoRoot });
637
+ res.json({ success: true });
638
+ }
639
+ catch (err) {
640
+ const message = err instanceof Error ? err.message : "Failed to open editor";
641
+ res.status(500).json({ error: message, code: "EDITOR_ERROR" });
642
+ }
643
+ });
644
+ // POST /api/import/preview — dry run import
645
+ router.post("/import/preview", async (req, res) => {
646
+ try {
647
+ const manifest = loadManifest();
648
+ const { target, content, format, overwriteKeys } = req.body;
649
+ if (!target || typeof content !== "string") {
650
+ res.status(400).json({
651
+ error: "Request body must include 'target' and 'content'.",
652
+ code: "BAD_REQUEST",
653
+ });
654
+ return;
655
+ }
656
+ const parts = target.split("/");
657
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
658
+ res.status(400).json({
659
+ error: "Invalid target format. Use 'namespace/environment'.",
660
+ code: "BAD_REQUEST",
661
+ });
662
+ return;
663
+ }
664
+ const importRunner = new core_1.ImportRunner(sops);
665
+ const result = await importRunner.import(target, null, content, manifest, deps.repoRoot, {
666
+ format,
667
+ dryRun: true,
668
+ });
669
+ // Classify keys using overwriteKeys from the request
670
+ const overwriteSet = new Set(overwriteKeys ?? []);
671
+ const wouldImport = result.imported.filter((k) => !overwriteSet.has(k));
672
+ const wouldOverwrite = result.imported.filter((k) => overwriteSet.has(k));
673
+ const wouldSkip = result.skipped.map((k) => ({ key: k, reason: "already exists" }));
674
+ res.json({
675
+ wouldImport,
676
+ wouldSkip,
677
+ wouldOverwrite,
678
+ warnings: result.warnings,
679
+ totalKeys: result.imported.length + result.skipped.length,
680
+ });
681
+ }
682
+ catch (err) {
683
+ const message = err instanceof Error ? err.message : "Preview failed";
684
+ res.status(500).json({ error: message, code: "IMPORT_PREVIEW_ERROR" });
685
+ }
686
+ });
687
+ // POST /api/import/apply — run actual import
688
+ router.post("/import/apply", async (req, res) => {
689
+ try {
690
+ const manifest = loadManifest();
691
+ const { target, content, format, keys, overwriteKeys } = req.body;
692
+ if (!target || typeof content !== "string") {
693
+ res.status(400).json({
694
+ error: "Request body must include 'target' and 'content'.",
695
+ code: "BAD_REQUEST",
696
+ });
697
+ return;
698
+ }
699
+ if (!Array.isArray(keys)) {
700
+ res
701
+ .status(400)
702
+ .json({ error: "Request body must include 'keys' array.", code: "BAD_REQUEST" });
703
+ return;
704
+ }
705
+ const parts = target.split("/");
706
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
707
+ res.status(400).json({
708
+ error: "Invalid target format. Use 'namespace/environment'.",
709
+ code: "BAD_REQUEST",
710
+ });
711
+ return;
712
+ }
713
+ if (keys.length === 0) {
714
+ res.json({ imported: [], skipped: [], failed: [] });
715
+ return;
716
+ }
717
+ const importRunner = new core_1.ImportRunner(sops);
718
+ const result = await importRunner.import(target, null, content, manifest, deps.repoRoot, {
719
+ format,
720
+ keys,
721
+ overwrite: (overwriteKeys ?? []).length > 0,
722
+ });
723
+ res.json({
724
+ imported: result.imported,
725
+ skipped: result.skipped,
726
+ failed: result.failed,
727
+ });
728
+ }
729
+ catch (err) {
730
+ const message = err instanceof Error ? err.message : "Import failed";
731
+ res.status(500).json({ error: message, code: "IMPORT_APPLY_ERROR" });
732
+ }
733
+ });
734
+ // GET /api/recipients
735
+ router.get("/recipients", async (_req, res) => {
736
+ try {
737
+ const manifest = loadManifest();
738
+ const recipients = await recipientManager.list(manifest, deps.repoRoot);
739
+ const cells = matrix.resolveMatrix(manifest, deps.repoRoot);
740
+ const totalFiles = cells.filter((c) => c.exists).length;
741
+ res.json({ recipients, totalFiles });
742
+ }
743
+ catch (err) {
744
+ const message = err instanceof Error ? err.message : "Failed to list recipients";
745
+ res.status(500).json({ error: message, code: "RECIPIENTS_ERROR" });
746
+ }
747
+ });
748
+ // GET /api/recipients/validate?key=age1...
749
+ router.get("/recipients/validate", (req, res) => {
750
+ const key = req.query.key;
751
+ if (!key) {
752
+ res.status(400).json({ valid: false, error: "Missing 'key' query parameter." });
753
+ return;
754
+ }
755
+ const result = (0, core_1.validateAgePublicKey)(key);
756
+ res.json(result);
757
+ });
758
+ // POST /api/recipients/add
759
+ router.post("/recipients/add", async (req, res) => {
760
+ try {
761
+ const manifest = loadManifest();
762
+ const { key, label } = req.body;
763
+ const result = await recipientManager.add(key, label, manifest, deps.repoRoot);
764
+ res.json(result);
765
+ }
766
+ catch (err) {
767
+ const message = err instanceof Error ? err.message : "Failed to add recipient";
768
+ res.status(500).json({ error: message, code: "RECIPIENTS_ADD_ERROR" });
769
+ }
770
+ });
771
+ // POST /api/recipients/remove
772
+ router.post("/recipients/remove", async (req, res) => {
773
+ try {
774
+ const manifest = loadManifest();
775
+ const { key } = req.body;
776
+ const result = await recipientManager.remove(key, manifest, deps.repoRoot);
777
+ const cells = matrix.resolveMatrix(manifest, deps.repoRoot);
778
+ const targets = cells.filter((c) => c.exists).map((c) => `${c.namespace}/${c.environment}`);
779
+ res.json({ ...result, rotationReminder: targets });
780
+ }
781
+ catch (err) {
782
+ const message = err instanceof Error ? err.message : "Failed to remove recipient";
783
+ res.status(500).json({ error: message, code: "RECIPIENTS_REMOVE_ERROR" });
784
+ }
785
+ });
786
+ // GET /api/service-identities
787
+ router.get("/service-identities", (_req, res) => {
788
+ try {
789
+ setNoCacheHeaders(res);
790
+ const manifest = loadManifest();
791
+ const identities = manifest.service_identities ?? [];
792
+ const result = identities.map((si) => {
793
+ const environments = {};
794
+ for (const [envName, envConfig] of Object.entries(si.environments)) {
795
+ const env = manifest.environments.find((e) => e.name === envName);
796
+ if (envConfig.kms) {
797
+ environments[envName] = {
798
+ type: "kms",
799
+ kms: envConfig.kms,
800
+ protected: env?.protected ?? false,
801
+ };
802
+ }
803
+ else {
804
+ environments[envName] = {
805
+ type: "age",
806
+ publicKey: envConfig.recipient,
807
+ protected: env?.protected ?? false,
808
+ };
809
+ }
810
+ }
811
+ return {
812
+ name: si.name,
813
+ description: si.description,
814
+ namespaces: si.namespaces,
815
+ environments,
816
+ };
817
+ });
818
+ res.json({ identities: result });
819
+ }
820
+ catch (err) {
821
+ const message = err instanceof Error ? err.message : "Failed to load service identities";
822
+ res.status(500).json({ error: message, code: "SERVICE_IDENTITY_ERROR" });
823
+ }
824
+ });
825
+ // POST /api/service-identities — create a new service identity
826
+ router.post("/service-identities", async (req, res) => {
827
+ try {
828
+ const manifest = loadManifest();
829
+ const { name, description, namespaces, kmsEnvConfigs } = req.body;
830
+ if (!name || typeof name !== "string") {
831
+ res.status(400).json({ error: "name is required.", code: "BAD_REQUEST" });
832
+ return;
833
+ }
834
+ if (!Array.isArray(namespaces) || namespaces.length === 0) {
835
+ res
836
+ .status(400)
837
+ .json({ error: "namespaces must be a non-empty array.", code: "BAD_REQUEST" });
838
+ return;
839
+ }
840
+ // Validate and cast KMS configs — provider must be one of the allowed values
841
+ let typedKmsConfigs;
842
+ if (kmsEnvConfigs && Object.keys(kmsEnvConfigs).length > 0) {
843
+ typedKmsConfigs = {};
844
+ for (const [envName, cfg] of Object.entries(kmsEnvConfigs)) {
845
+ if (!core_1.VALID_KMS_PROVIDERS.includes(cfg.provider)) {
846
+ res.status(400).json({
847
+ error: `Invalid KMS provider '${cfg.provider}' for environment '${envName}'. Must be aws, gcp, or azure.`,
848
+ code: "BAD_REQUEST",
849
+ });
850
+ return;
851
+ }
852
+ typedKmsConfigs[envName] = {
853
+ provider: cfg.provider,
854
+ keyId: cfg.keyId,
855
+ };
856
+ }
857
+ }
858
+ const result = await serviceIdManager.create(name, namespaces, description ?? "", manifest, deps.repoRoot, typedKmsConfigs);
859
+ setNoCacheHeaders(res);
860
+ res.json({ identity: result.identity, privateKeys: result.privateKeys });
861
+ // Best-effort: clear references to private key strings (V8 may retain copies)
862
+ zeroStringRecord(result.privateKeys);
863
+ }
864
+ catch (err) {
865
+ const message = err instanceof Error ? err.message : "Failed to create service identity";
866
+ res.status(500).json({ error: message, code: "SERVICE_IDENTITY_ERROR" });
867
+ }
868
+ });
869
+ // DELETE /api/service-identities/:name
870
+ router.delete("/service-identities/:name", async (req, res) => {
871
+ try {
872
+ const name = req.params.name;
873
+ const manifest = loadManifest();
874
+ if (!manifest.service_identities?.find((si) => si.name === name)) {
875
+ res.status(404).json({ error: `Service identity '${name}' not found.`, code: "NOT_FOUND" });
876
+ return;
877
+ }
878
+ await serviceIdManager.delete(name, manifest, deps.repoRoot);
879
+ res.json({ ok: true });
880
+ }
881
+ catch (err) {
882
+ const message = err instanceof Error ? err.message : "Failed to delete service identity";
883
+ res.status(500).json({ error: message, code: "SERVICE_IDENTITY_ERROR" });
884
+ }
885
+ });
886
+ // PATCH /api/service-identities/:name — update environment backends to KMS
887
+ router.patch("/service-identities/:name", async (req, res) => {
888
+ try {
889
+ const name = req.params.name;
890
+ const { kmsEnvConfigs } = req.body;
891
+ if (!kmsEnvConfigs || Object.keys(kmsEnvConfigs).length === 0) {
892
+ res
893
+ .status(400)
894
+ .json({ error: "kmsEnvConfigs must be a non-empty object.", code: "BAD_REQUEST" });
895
+ return;
896
+ }
897
+ const manifest = loadManifest();
898
+ const typedKmsConfigs = {};
899
+ for (const [envName, cfg] of Object.entries(kmsEnvConfigs)) {
900
+ if (cfg.provider !== "aws" && cfg.provider !== "gcp" && cfg.provider !== "azure") {
901
+ res.status(400).json({
902
+ error: `Invalid KMS provider '${cfg.provider}' for environment '${envName}'. Must be aws, gcp, or azure.`,
903
+ code: "BAD_REQUEST",
904
+ });
905
+ return;
906
+ }
907
+ typedKmsConfigs[envName] = { provider: cfg.provider, keyId: cfg.keyId };
908
+ }
909
+ await serviceIdManager.updateEnvironments(name, typedKmsConfigs, manifest, deps.repoRoot);
910
+ res.json({ ok: true });
911
+ }
912
+ catch (err) {
913
+ const message = err instanceof Error ? err.message : "Failed to update service identity";
914
+ res.status(500).json({ error: message, code: "SERVICE_IDENTITY_ERROR" });
915
+ }
916
+ });
917
+ // POST /api/service-identities/:name/rotate — rotate age key(s)
918
+ router.post("/service-identities/:name/rotate", async (req, res) => {
919
+ try {
920
+ const name = req.params.name;
921
+ const { environment } = req.body;
922
+ const manifest = loadManifest();
923
+ const privateKeys = await serviceIdManager.rotateKey(name, manifest, deps.repoRoot, environment);
924
+ setNoCacheHeaders(res);
925
+ res.json({ privateKeys });
926
+ // Best-effort: clear references to private key strings (V8 may retain copies)
927
+ zeroStringRecord(privateKeys);
928
+ }
929
+ catch (err) {
930
+ const message = err instanceof Error ? err.message : "Failed to rotate service identity key";
931
+ res.status(500).json({ error: message, code: "SERVICE_IDENTITY_ERROR" });
932
+ }
933
+ });
934
+ // ── Backend Migration ──────────────────────────────────────────────
935
+ router.get("/backend-config", (_req, res) => {
936
+ try {
937
+ const manifest = loadManifest();
938
+ const global = manifest.sops;
939
+ const environments = manifest.environments.map((env) => ({
940
+ name: env.name,
941
+ protected: env.protected === true,
942
+ effective: (0, core_1.resolveBackendConfig)(manifest, env.name),
943
+ hasOverride: env.sops !== undefined,
944
+ }));
945
+ res.json({ global, environments });
946
+ }
947
+ catch (err) {
948
+ const message = err instanceof Error ? err.message : "Failed to load backend config";
949
+ res.status(500).json({ error: message, code: "BACKEND_CONFIG_ERROR" });
950
+ }
951
+ });
952
+ router.post("/migrate-backend/preview", async (req, res) => {
953
+ try {
954
+ const manifest = loadManifest();
955
+ const { target, environment, confirmed } = req.body;
956
+ if (!target || !target.backend) {
957
+ res.status(400).json({ error: "Missing target backend", code: "BAD_REQUEST" });
958
+ return;
959
+ }
960
+ // Protected environment check
961
+ const impactedEnvs = environment
962
+ ? manifest.environments.filter((e) => e.name === environment)
963
+ : manifest.environments;
964
+ const protectedEnvs = impactedEnvs.filter((e) => e.protected);
965
+ if (protectedEnvs.length > 0 && !confirmed) {
966
+ res.status(409).json({
967
+ error: "Protected environment requires confirmation",
968
+ code: "PROTECTED_ENV",
969
+ protected: true,
970
+ });
971
+ return;
972
+ }
973
+ const events = [];
974
+ const result = await backendMigrator.migrate(manifest, deps.repoRoot, { target, environment, dryRun: true }, (event) => events.push(event));
975
+ res.json({ success: !result.rolledBack, result, events });
976
+ }
977
+ catch (err) {
978
+ const message = err instanceof Error ? err.message : "Migration preview failed";
979
+ res.status(500).json({ error: message, code: "MIGRATION_ERROR" });
980
+ }
981
+ });
982
+ router.post("/migrate-backend/apply", async (req, res) => {
983
+ try {
984
+ const manifest = loadManifest();
985
+ const { target, environment, confirmed } = req.body;
986
+ if (!target || !target.backend) {
987
+ res.status(400).json({ error: "Missing target backend", code: "BAD_REQUEST" });
988
+ return;
989
+ }
990
+ // Protected environment check
991
+ const impactedEnvs = environment
992
+ ? manifest.environments.filter((e) => e.name === environment)
993
+ : manifest.environments;
994
+ const protectedEnvs = impactedEnvs.filter((e) => e.protected);
995
+ if (protectedEnvs.length > 0 && !confirmed) {
996
+ res.status(409).json({
997
+ error: "Protected environment requires confirmation",
998
+ code: "PROTECTED_ENV",
999
+ protected: true,
1000
+ });
1001
+ return;
1002
+ }
1003
+ const events = [];
1004
+ const result = await backendMigrator.migrate(manifest, deps.repoRoot, { target, environment, dryRun: false }, (event) => events.push(event));
1005
+ res.json({ success: !result.rolledBack, result, events });
1006
+ }
1007
+ catch (err) {
1008
+ const message = err instanceof Error ? err.message : "Migration failed";
1009
+ res.status(500).json({ error: message, code: "MIGRATION_ERROR" });
1010
+ }
1011
+ });
1012
+ function dispose() {
1013
+ lastScanResult = null;
1014
+ lastScanAt = null;
1015
+ }
1016
+ // Attach dispose to the router for cleanup
1017
+ router.dispose = dispose;
1018
+ return router;
1019
+ }
1020
+ //# sourceMappingURL=api.js.map