@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.
- package/README.md +38 -0
- package/dist/client/assets/index-CVpAmirt.js +26 -0
- package/dist/client/favicon-96x96.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.svg +16 -0
- package/dist/client/index.html +50 -0
- package/dist/client-lib/api.d.ts +3 -0
- package/dist/client-lib/api.d.ts.map +1 -0
- package/dist/client-lib/components/Button.d.ts +10 -0
- package/dist/client-lib/components/Button.d.ts.map +1 -0
- package/dist/client-lib/components/CopyButton.d.ts +6 -0
- package/dist/client-lib/components/CopyButton.d.ts.map +1 -0
- package/dist/client-lib/components/EnvBadge.d.ts +7 -0
- package/dist/client-lib/components/EnvBadge.d.ts.map +1 -0
- package/dist/client-lib/components/MatrixGrid.d.ts +13 -0
- package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -0
- package/dist/client-lib/components/Sidebar.d.ts +16 -0
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -0
- package/dist/client-lib/components/StatusDot.d.ts +6 -0
- package/dist/client-lib/components/StatusDot.d.ts.map +1 -0
- package/dist/client-lib/components/TopBar.d.ts +9 -0
- package/dist/client-lib/components/TopBar.d.ts.map +1 -0
- package/dist/client-lib/index.d.ts +12 -0
- package/dist/client-lib/index.d.ts.map +1 -0
- package/dist/client-lib/theme.d.ts +42 -0
- package/dist/client-lib/theme.d.ts.map +1 -0
- package/dist/server/api.d.ts +11 -0
- package/dist/server/api.d.ts.map +1 -0
- package/dist/server/api.js +1020 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +231 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +74 -0
- package/src/client/App.tsx +205 -0
- package/src/client/api.test.tsx +94 -0
- package/src/client/api.ts +30 -0
- package/src/client/components/Button.tsx +52 -0
- package/src/client/components/CopyButton.test.tsx +43 -0
- package/src/client/components/CopyButton.tsx +36 -0
- package/src/client/components/EnvBadge.tsx +32 -0
- package/src/client/components/MatrixGrid.tsx +265 -0
- package/src/client/components/Sidebar.tsx +337 -0
- package/src/client/components/StatusDot.tsx +30 -0
- package/src/client/components/TopBar.tsx +50 -0
- package/src/client/index.html +50 -0
- package/src/client/index.ts +18 -0
- package/src/client/main.tsx +15 -0
- package/src/client/public/favicon-96x96.png +0 -0
- package/src/client/public/favicon.ico +0 -0
- package/src/client/public/favicon.svg +16 -0
- package/src/client/screens/BackendScreen.test.tsx +611 -0
- package/src/client/screens/BackendScreen.tsx +836 -0
- package/src/client/screens/DiffView.test.tsx +130 -0
- package/src/client/screens/DiffView.tsx +547 -0
- package/src/client/screens/GitLogView.test.tsx +113 -0
- package/src/client/screens/GitLogView.tsx +192 -0
- package/src/client/screens/ImportScreen.tsx +710 -0
- package/src/client/screens/LintView.test.tsx +143 -0
- package/src/client/screens/LintView.tsx +589 -0
- package/src/client/screens/MatrixView.test.tsx +138 -0
- package/src/client/screens/MatrixView.tsx +143 -0
- package/src/client/screens/NamespaceEditor.test.tsx +694 -0
- package/src/client/screens/NamespaceEditor.tsx +1122 -0
- package/src/client/screens/RecipientsScreen.tsx +696 -0
- package/src/client/screens/ScanScreen.test.tsx +323 -0
- package/src/client/screens/ScanScreen.tsx +523 -0
- package/src/client/screens/ServiceIdentitiesScreen.tsx +1398 -0
- 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
|