@clef-sh/ui 0.1.14 → 0.1.15-beta.97
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/dist/client/assets/index-rBYybJbt.js +26 -0
- package/dist/client/index.html +1 -1
- package/dist/client-lib/components/Sidebar.d.ts +1 -1
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +275 -87
- package/dist/server/api.js.map +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +8 -0
- package/src/client/components/Sidebar.tsx +15 -1
- package/src/client/screens/ManifestScreen.test.tsx +394 -0
- package/src/client/screens/ManifestScreen.tsx +977 -0
- package/src/client/screens/MatrixView.tsx +10 -1
- package/src/client/screens/NamespaceEditor.tsx +13 -3
- package/src/client/screens/ResetScreen.test.tsx +397 -0
- package/src/client/screens/ResetScreen.tsx +614 -0
- package/dist/client/assets/index-CVpAmirt.js +0 -26
package/dist/client/index.html
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClefManifest, MatrixStatus, GitStatus as GitStatusType } from "@clef-sh/core";
|
|
2
|
-
export type ViewName = "matrix" | "editor" | "diff" | "lint" | "scan" | "import" | "recipients" | "identities" | "backend" | "history";
|
|
2
|
+
export type ViewName = "matrix" | "editor" | "diff" | "lint" | "scan" | "import" | "recipients" | "identities" | "backend" | "reset" | "history" | "manifest";
|
|
3
3
|
interface SidebarProps {
|
|
4
4
|
activeView: ViewName;
|
|
5
5
|
setView: (view: ViewName) => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Sidebar.d.ts","sourceRoot":"","sources":["../../../src/client/components/Sidebar.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,eAAe,CAAC;AAE5F,MAAM,MAAM,QAAQ,GAChB,QAAQ,GACR,QAAQ,GACR,MAAM,GACN,MAAM,GACN,MAAM,GACN,QAAQ,GACR,YAAY,GACZ,YAAY,GACZ,SAAS,GACT,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"Sidebar.d.ts","sourceRoot":"","sources":["../../../src/client/components/Sidebar.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,eAAe,CAAC;AAE5F,MAAM,MAAM,QAAQ,GAChB,QAAQ,GACR,QAAQ,GACR,MAAM,GACN,MAAM,GACN,MAAM,GACN,QAAQ,GACR,YAAY,GACZ,YAAY,GACZ,SAAS,GACT,OAAO,GACP,SAAS,GACT,UAAU,CAAC;AAEf,UAAU,YAAY;IACpB,UAAU,EAAE,QAAQ,CAAC;IACrB,OAAO,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B,SAAS,EAAE,aAAa,GAAG,IAAI,CAAC;IAChC,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,OAAO,CAAC,EACtB,UAAU,EACV,OAAO,EACP,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,cAAc,EACd,SAAS,EACT,cAAc,EACd,cAAc,GACf,EAAE,YAAY,2CA4Od"}
|
package/dist/server/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/server/api.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AAOpD,OAAO,EAUL,gBAAgB,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/server/api.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AAOpD,OAAO,EAUL,gBAAgB,EAmBjB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,gBAAgB,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CA41CrD"}
|
package/dist/server/api.js
CHANGED
|
@@ -115,11 +115,14 @@ function createApiRouter(deps) {
|
|
|
115
115
|
const schemaValidator = new core_1.SchemaValidator();
|
|
116
116
|
const lintRunner = new core_1.LintRunner(matrix, schemaValidator, sops);
|
|
117
117
|
const git = new core_1.GitIntegration(deps.runner);
|
|
118
|
+
const tx = new core_1.TransactionManager(git);
|
|
118
119
|
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
|
|
120
|
+
const recipientManager = new core_1.RecipientManager(sops, matrix, tx);
|
|
121
|
+
const serviceIdManager = new core_1.ServiceIdentityManager(sops, matrix, tx);
|
|
122
|
+
const backendMigrator = new core_1.BackendMigrator(sops, matrix, tx);
|
|
123
|
+
const resetManager = new core_1.ResetManager(matrix, sops, schemaValidator, tx);
|
|
124
|
+
const bulkOps = new core_1.BulkOps(tx);
|
|
125
|
+
const structureManager = new core_1.StructureManager(matrix, sops, tx);
|
|
123
126
|
// In-session scan cache
|
|
124
127
|
let lastScanResult = null;
|
|
125
128
|
let lastScanAt = null;
|
|
@@ -206,7 +209,10 @@ function createApiRouter(deps) {
|
|
|
206
209
|
try {
|
|
207
210
|
const manifest = loadManifest();
|
|
208
211
|
const { ns, env, key } = req.params;
|
|
209
|
-
const { value, random, confirmed } = req.body;
|
|
212
|
+
const { value, random, confirmed, commit: commitFlag, } = req.body;
|
|
213
|
+
// Auto-commit by default. The edit-multiple-rows-then-batch-commit
|
|
214
|
+
// flow in NamespaceEditor.handleSave passes commit:false to defer.
|
|
215
|
+
const shouldCommit = commitFlag !== false;
|
|
210
216
|
if (!random && (value === undefined || value === null)) {
|
|
211
217
|
res.status(400).json({
|
|
212
218
|
error: "Request body must include 'value' or 'random: true'.",
|
|
@@ -231,87 +237,71 @@ function createApiRouter(deps) {
|
|
|
231
237
|
});
|
|
232
238
|
return;
|
|
233
239
|
}
|
|
234
|
-
const
|
|
240
|
+
const relCellPath = manifest.file_pattern
|
|
241
|
+
.replace("{namespace}", ns)
|
|
242
|
+
.replace("{environment}", env);
|
|
243
|
+
const filePath = `${deps.repoRoot}/${relCellPath}`;
|
|
235
244
|
const decrypted = await sops.decrypt(filePath);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
245
|
+
// Inside the mutate callback we do the actual encrypt + metadata
|
|
246
|
+
// update. When auto-commit is enabled this runs inside tx.run; when
|
|
247
|
+
// disabled (batched edit flow) it runs directly.
|
|
248
|
+
let response = { success: true, key };
|
|
249
|
+
const doWork = async () => {
|
|
250
|
+
if (random) {
|
|
251
|
+
decrypted.values[key] = (0, core_1.generateRandomValue)();
|
|
252
|
+
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
253
|
+
// Metadata update failure used to trigger an in-method rollback
|
|
254
|
+
// here. Inside tx.run, that rollback comes for free via git
|
|
255
|
+
// reset, so we just let the throw propagate.
|
|
243
256
|
await (0, core_1.markPendingWithRetry)(filePath, [key], "clef ui");
|
|
257
|
+
response = { success: true, key, pending: true };
|
|
244
258
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
259
|
+
else {
|
|
260
|
+
decrypted.values[key] = String(value);
|
|
261
|
+
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
262
|
+
// Validate against schema if defined
|
|
263
|
+
const nsDef = manifest.namespaces.find((n) => n.name === ns);
|
|
264
|
+
if (nsDef?.schema) {
|
|
265
|
+
try {
|
|
266
|
+
const schema = schemaValidator.loadSchema(path.join(deps.repoRoot, nsDef.schema));
|
|
267
|
+
const result = schemaValidator.validate({ [key]: String(value) }, schema);
|
|
268
|
+
const violations = [...result.errors, ...result.warnings];
|
|
269
|
+
if (violations.length > 0) {
|
|
270
|
+
response = {
|
|
271
|
+
success: true,
|
|
272
|
+
key,
|
|
273
|
+
warnings: violations.map((v) => v.message),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
250
276
|
}
|
|
251
|
-
|
|
252
|
-
|
|
277
|
+
catch {
|
|
278
|
+
// Schema load failed — skip validation, not fatal
|
|
253
279
|
}
|
|
254
|
-
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
255
280
|
}
|
|
256
|
-
|
|
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) {
|
|
281
|
+
// Resolve pending state if the key was pending
|
|
280
282
|
try {
|
|
281
|
-
|
|
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
|
-
}
|
|
283
|
+
await (0, core_1.markResolved)(filePath, [key]);
|
|
298
284
|
}
|
|
299
285
|
catch {
|
|
300
|
-
//
|
|
286
|
+
// Metadata update failed — non-fatal
|
|
301
287
|
}
|
|
302
288
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
|
|
289
|
+
};
|
|
290
|
+
if (shouldCommit) {
|
|
291
|
+
await tx.run(deps.repoRoot, {
|
|
292
|
+
description: `clef ui: set ${ns}/${env}/${key}`,
|
|
293
|
+
paths: [relCellPath, relCellPath.replace(/\.enc\.(yaml|json)$/, ".clef-meta.yaml")],
|
|
294
|
+
mutate: doWork,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
await doWork();
|
|
311
299
|
}
|
|
300
|
+
res.json(response);
|
|
312
301
|
}
|
|
313
|
-
catch {
|
|
314
|
-
|
|
302
|
+
catch (err) {
|
|
303
|
+
const message = err instanceof Error ? err.message : "Failed to set value";
|
|
304
|
+
res.status(500).json({ error: message, code: "SET_ERROR" });
|
|
315
305
|
}
|
|
316
306
|
});
|
|
317
307
|
// DELETE /api/namespace/:ns/:env/:key
|
|
@@ -320,7 +310,8 @@ function createApiRouter(deps) {
|
|
|
320
310
|
try {
|
|
321
311
|
const manifest = loadManifest();
|
|
322
312
|
const { ns, env, key } = req.params;
|
|
323
|
-
const { confirmed } = (req.body ?? {});
|
|
313
|
+
const { confirmed, commit: commitFlag } = (req.body ?? {});
|
|
314
|
+
const shouldCommit = commitFlag !== false;
|
|
324
315
|
const nsExists = manifest.namespaces.some((n) => n.name === ns);
|
|
325
316
|
const envExists = manifest.environments.some((e) => e.name === env);
|
|
326
317
|
if (!nsExists || !envExists) {
|
|
@@ -338,7 +329,10 @@ function createApiRouter(deps) {
|
|
|
338
329
|
});
|
|
339
330
|
return;
|
|
340
331
|
}
|
|
341
|
-
const
|
|
332
|
+
const relCellPath = manifest.file_pattern
|
|
333
|
+
.replace("{namespace}", ns)
|
|
334
|
+
.replace("{environment}", env);
|
|
335
|
+
const filePath = `${deps.repoRoot}/${relCellPath}`;
|
|
342
336
|
const decrypted = await sops.decrypt(filePath);
|
|
343
337
|
if (!(key in decrypted.values)) {
|
|
344
338
|
res.status(404).json({
|
|
@@ -347,19 +341,31 @@ function createApiRouter(deps) {
|
|
|
347
341
|
});
|
|
348
342
|
return;
|
|
349
343
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
344
|
+
const doWork = async () => {
|
|
345
|
+
delete decrypted.values[key];
|
|
346
|
+
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
347
|
+
try {
|
|
348
|
+
await (0, core_1.markResolved)(filePath, [key]);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Best effort — orphaned metadata is annoying but not dangerous
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
if (shouldCommit) {
|
|
355
|
+
await tx.run(deps.repoRoot, {
|
|
356
|
+
description: `clef ui: delete ${ns}/${env}/${key}`,
|
|
357
|
+
paths: [relCellPath, relCellPath.replace(/\.enc\.(yaml|json)$/, ".clef-meta.yaml")],
|
|
358
|
+
mutate: doWork,
|
|
359
|
+
});
|
|
355
360
|
}
|
|
356
|
-
|
|
357
|
-
|
|
361
|
+
else {
|
|
362
|
+
await doWork();
|
|
358
363
|
}
|
|
359
364
|
res.json({ success: true, key });
|
|
360
365
|
}
|
|
361
|
-
catch {
|
|
362
|
-
|
|
366
|
+
catch (err) {
|
|
367
|
+
const message = err instanceof Error ? err.message : "Failed to delete key";
|
|
368
|
+
res.status(500).json({ error: message, code: "DELETE_ERROR" });
|
|
363
369
|
}
|
|
364
370
|
});
|
|
365
371
|
// POST /api/namespace/:ns/:env/:key/accept — resolve pending state without changing the value
|
|
@@ -418,7 +424,7 @@ function createApiRouter(deps) {
|
|
|
418
424
|
});
|
|
419
425
|
return;
|
|
420
426
|
}
|
|
421
|
-
await bulkOps.copyValue(key, fromCell, toCell, sops, manifest);
|
|
427
|
+
await bulkOps.copyValue(key, fromCell, toCell, sops, manifest, deps.repoRoot);
|
|
422
428
|
res.json({ success: true, key, from: `${fromNs}/${fromEnv}`, to: `${toNs}/${toEnv}` });
|
|
423
429
|
}
|
|
424
430
|
catch (err) {
|
|
@@ -661,7 +667,7 @@ function createApiRouter(deps) {
|
|
|
661
667
|
});
|
|
662
668
|
return;
|
|
663
669
|
}
|
|
664
|
-
const importRunner = new core_1.ImportRunner(sops);
|
|
670
|
+
const importRunner = new core_1.ImportRunner(sops, tx);
|
|
665
671
|
const result = await importRunner.import(target, null, content, manifest, deps.repoRoot, {
|
|
666
672
|
format,
|
|
667
673
|
dryRun: true,
|
|
@@ -714,7 +720,7 @@ function createApiRouter(deps) {
|
|
|
714
720
|
res.json({ imported: [], skipped: [], failed: [] });
|
|
715
721
|
return;
|
|
716
722
|
}
|
|
717
|
-
const importRunner = new core_1.ImportRunner(sops);
|
|
723
|
+
const importRunner = new core_1.ImportRunner(sops, tx);
|
|
718
724
|
const result = await importRunner.import(target, null, content, manifest, deps.repoRoot, {
|
|
719
725
|
format,
|
|
720
726
|
keys,
|
|
@@ -931,6 +937,143 @@ function createApiRouter(deps) {
|
|
|
931
937
|
res.status(500).json({ error: message, code: "SERVICE_IDENTITY_ERROR" });
|
|
932
938
|
}
|
|
933
939
|
});
|
|
940
|
+
// ── Manifest Structure (namespaces + environments) ─────────────────
|
|
941
|
+
//
|
|
942
|
+
// Each endpoint maps to a StructureManager method. Errors from the manager
|
|
943
|
+
// are mapped to HTTP status codes:
|
|
944
|
+
// 400 — invalid input (missing/wrong-type body field, invalid identifier)
|
|
945
|
+
// 404 — name not found in the manifest
|
|
946
|
+
// 409 — name collision (entity already exists, or rename target taken)
|
|
947
|
+
// 412 — refusal precondition (protected env, last namespace/env, orphaned SI)
|
|
948
|
+
// 500 — anything else (filesystem, sops, transaction failure)
|
|
949
|
+
/**
|
|
950
|
+
* Map a thrown error from StructureManager to an HTTP status. The manager
|
|
951
|
+
* throws plain `Error` instances with descriptive messages — we sniff the
|
|
952
|
+
* message text to pick the right status. Brittle but contained: every
|
|
953
|
+
* sniff matches a string the manager itself produces.
|
|
954
|
+
*/
|
|
955
|
+
function structureErrorStatus(err) {
|
|
956
|
+
const message = err instanceof Error ? err.message : "";
|
|
957
|
+
if (/not found/.test(message))
|
|
958
|
+
return { status: 404, code: "NOT_FOUND" };
|
|
959
|
+
if (/already exists/.test(message))
|
|
960
|
+
return { status: 409, code: "CONFLICT" };
|
|
961
|
+
if (/Invalid (namespace|environment) name/.test(message))
|
|
962
|
+
return { status: 400, code: "BAD_REQUEST" };
|
|
963
|
+
if (/is protected|last (namespace|environment)|only scope/.test(message))
|
|
964
|
+
return { status: 412, code: "PRECONDITION_FAILED" };
|
|
965
|
+
return { status: 500, code: "STRUCTURE_ERROR" };
|
|
966
|
+
}
|
|
967
|
+
// POST /api/namespaces — add a new namespace and scaffold cells
|
|
968
|
+
router.post("/namespaces", async (req, res) => {
|
|
969
|
+
try {
|
|
970
|
+
const { name, description, schema } = req.body;
|
|
971
|
+
if (!name || typeof name !== "string") {
|
|
972
|
+
res.status(400).json({ error: "name is required.", code: "BAD_REQUEST" });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const manifest = loadManifest();
|
|
976
|
+
await structureManager.addNamespace(name, { description, schema }, manifest, deps.repoRoot);
|
|
977
|
+
res.status(201).json({ name, description: description ?? "", schema });
|
|
978
|
+
}
|
|
979
|
+
catch (err) {
|
|
980
|
+
const { status, code } = structureErrorStatus(err);
|
|
981
|
+
const message = err instanceof Error ? err.message : "Failed to add namespace";
|
|
982
|
+
res.status(status).json({ error: message, code });
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
// PATCH /api/namespaces/:name — edit description, schema, or rename
|
|
986
|
+
router.patch("/namespaces/:name", async (req, res) => {
|
|
987
|
+
try {
|
|
988
|
+
const name = req.params.name;
|
|
989
|
+
const { rename, description, schema } = req.body;
|
|
990
|
+
if (rename === undefined && description === undefined && schema === undefined) {
|
|
991
|
+
res.status(400).json({
|
|
992
|
+
error: "At least one of rename, description, or schema is required.",
|
|
993
|
+
code: "BAD_REQUEST",
|
|
994
|
+
});
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const manifest = loadManifest();
|
|
998
|
+
await structureManager.editNamespace(name, { rename, description, schema }, manifest, deps.repoRoot);
|
|
999
|
+
res.json({ name: rename ?? name, previousName: rename ? name : undefined });
|
|
1000
|
+
}
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
const { status, code } = structureErrorStatus(err);
|
|
1003
|
+
const message = err instanceof Error ? err.message : "Failed to edit namespace";
|
|
1004
|
+
res.status(status).json({ error: message, code });
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
// DELETE /api/namespaces/:name — remove namespace and cascade through SIs
|
|
1008
|
+
router.delete("/namespaces/:name", async (req, res) => {
|
|
1009
|
+
try {
|
|
1010
|
+
const name = req.params.name;
|
|
1011
|
+
const manifest = loadManifest();
|
|
1012
|
+
await structureManager.removeNamespace(name, manifest, deps.repoRoot);
|
|
1013
|
+
res.json({ ok: true });
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
const { status, code } = structureErrorStatus(err);
|
|
1017
|
+
const message = err instanceof Error ? err.message : "Failed to remove namespace";
|
|
1018
|
+
res.status(status).json({ error: message, code });
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
// POST /api/environments — add a new environment and scaffold cells
|
|
1022
|
+
router.post("/environments", async (req, res) => {
|
|
1023
|
+
try {
|
|
1024
|
+
const { name, description, protected: isProtected, } = req.body;
|
|
1025
|
+
if (!name || typeof name !== "string") {
|
|
1026
|
+
res.status(400).json({ error: "name is required.", code: "BAD_REQUEST" });
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const manifest = loadManifest();
|
|
1030
|
+
await structureManager.addEnvironment(name, { description, protected: isProtected }, manifest, deps.repoRoot);
|
|
1031
|
+
res
|
|
1032
|
+
.status(201)
|
|
1033
|
+
.json({ name, description: description ?? "", protected: isProtected ?? false });
|
|
1034
|
+
}
|
|
1035
|
+
catch (err) {
|
|
1036
|
+
const { status, code } = structureErrorStatus(err);
|
|
1037
|
+
const message = err instanceof Error ? err.message : "Failed to add environment";
|
|
1038
|
+
res.status(status).json({ error: message, code });
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
// PATCH /api/environments/:name — edit description, protected, or rename
|
|
1042
|
+
router.patch("/environments/:name", async (req, res) => {
|
|
1043
|
+
try {
|
|
1044
|
+
const name = req.params.name;
|
|
1045
|
+
const { rename, description, protected: isProtected, } = req.body;
|
|
1046
|
+
if (rename === undefined && description === undefined && isProtected === undefined) {
|
|
1047
|
+
res.status(400).json({
|
|
1048
|
+
error: "At least one of rename, description, or protected is required.",
|
|
1049
|
+
code: "BAD_REQUEST",
|
|
1050
|
+
});
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const manifest = loadManifest();
|
|
1054
|
+
await structureManager.editEnvironment(name, { rename, description, protected: isProtected }, manifest, deps.repoRoot);
|
|
1055
|
+
res.json({ name: rename ?? name, previousName: rename ? name : undefined });
|
|
1056
|
+
}
|
|
1057
|
+
catch (err) {
|
|
1058
|
+
const { status, code } = structureErrorStatus(err);
|
|
1059
|
+
const message = err instanceof Error ? err.message : "Failed to edit environment";
|
|
1060
|
+
res.status(status).json({ error: message, code });
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
// DELETE /api/environments/:name — remove env and cascade through SIs
|
|
1064
|
+
router.delete("/environments/:name", async (req, res) => {
|
|
1065
|
+
try {
|
|
1066
|
+
const name = req.params.name;
|
|
1067
|
+
const manifest = loadManifest();
|
|
1068
|
+
await structureManager.removeEnvironment(name, manifest, deps.repoRoot);
|
|
1069
|
+
res.json({ ok: true });
|
|
1070
|
+
}
|
|
1071
|
+
catch (err) {
|
|
1072
|
+
const { status, code } = structureErrorStatus(err);
|
|
1073
|
+
const message = err instanceof Error ? err.message : "Failed to remove environment";
|
|
1074
|
+
res.status(status).json({ error: message, code });
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
934
1077
|
// ── Backend Migration ──────────────────────────────────────────────
|
|
935
1078
|
router.get("/backend-config", (_req, res) => {
|
|
936
1079
|
try {
|
|
@@ -1009,6 +1152,51 @@ function createApiRouter(deps) {
|
|
|
1009
1152
|
res.status(500).json({ error: message, code: "MIGRATION_ERROR" });
|
|
1010
1153
|
}
|
|
1011
1154
|
});
|
|
1155
|
+
// ── Destructive Reset ───────────────────────────────────────────────
|
|
1156
|
+
//
|
|
1157
|
+
// Disaster-recovery endpoint. Abandons the current encrypted contents of
|
|
1158
|
+
// a scope (env / namespace / cell) and re-scaffolds fresh placeholders,
|
|
1159
|
+
// optionally switching to a new SOPS backend in the same transaction.
|
|
1160
|
+
// The UI gates this with a typed-confirmation modal — there is no
|
|
1161
|
+
// server-side `confirmed` field because every other destructive endpoint
|
|
1162
|
+
// (DELETE namespaces, DELETE environments) follows the same pattern of
|
|
1163
|
+
// letting the UI carry the confirmation responsibility.
|
|
1164
|
+
router.post("/reset", async (req, res) => {
|
|
1165
|
+
try {
|
|
1166
|
+
const { scope, backend, key, keys } = req.body;
|
|
1167
|
+
if (!scope || typeof scope !== "object" || !("kind" in scope)) {
|
|
1168
|
+
res.status(400).json({
|
|
1169
|
+
error: "Reset requires a scope. Provide { kind: 'env'|'namespace'|'cell', ... }.",
|
|
1170
|
+
code: "BAD_REQUEST",
|
|
1171
|
+
});
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
const manifest = loadManifest();
|
|
1175
|
+
// Surface a clean 4xx for unknown scope before any destructive work.
|
|
1176
|
+
// ResetManager re-validates internally as defence in depth.
|
|
1177
|
+
try {
|
|
1178
|
+
(0, core_1.validateResetScope)(scope, manifest);
|
|
1179
|
+
}
|
|
1180
|
+
catch (err) {
|
|
1181
|
+
const message = err instanceof Error ? err.message : "Invalid reset scope";
|
|
1182
|
+
res.status(404).json({ error: message, code: "NOT_FOUND" });
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const result = await resetManager.reset({ scope, backend, key, keys }, manifest, deps.repoRoot);
|
|
1186
|
+
res.json({ success: true, result });
|
|
1187
|
+
}
|
|
1188
|
+
catch (err) {
|
|
1189
|
+
const message = err instanceof Error ? err.message : "Reset failed";
|
|
1190
|
+
// Validation-style errors that ResetManager throws after the scope
|
|
1191
|
+
// check passes — bad backend/key combination, scope matches zero
|
|
1192
|
+
// cells. Map these to 400 so the UI can render them as user errors
|
|
1193
|
+
// rather than server errors.
|
|
1194
|
+
const isUserError = /requires a key|does not take a key|matches zero cells/.test(message);
|
|
1195
|
+
const status = isUserError ? 400 : 500;
|
|
1196
|
+
const code = isUserError ? "BAD_REQUEST" : "RESET_ERROR";
|
|
1197
|
+
res.status(status).json({ error: message, code });
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1012
1200
|
function dispose() {
|
|
1013
1201
|
lastScanResult = null;
|
|
1014
1202
|
lastScanAt = null;
|