@clef-sh/ui 0.1.14 → 0.1.15-beta.98

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.
@@ -42,7 +42,7 @@
42
42
  color: #3d4455;
43
43
  }
44
44
  </style>
45
- <script type="module" crossorigin src="/assets/index-CVpAmirt.js"></script>
45
+ <script type="module" crossorigin src="/assets/index-rBYybJbt.js"></script>
46
46
  </head>
47
47
  <body>
48
48
  <div id="root"></div>
@@ -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;AAEd,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,2CAgOd"}
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"}
@@ -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,EAejB,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,CAomCrD"}
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"}
@@ -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 bulkOps = new core_1.BulkOps();
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 filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
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
- 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 {
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
- catch {
246
- // Both retry attempts failed — roll back the encrypt
247
- try {
248
- if (previousValue !== undefined) {
249
- decrypted.values[key] = previousValue;
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
- else {
252
- delete decrypted.values[key];
277
+ catch {
278
+ // Schema load failed — skip validation, not fatal
253
279
  }
254
- await sops.encrypt(filePath, decrypted.values, manifest, env);
255
280
  }
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) {
281
+ // Resolve pending state if the key was pending
280
282
  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
- }
283
+ await (0, core_1.markResolved)(filePath, [key]);
298
284
  }
299
285
  catch {
300
- // Schema load failed — skip validation, not fatal
286
+ // Metadata update failed — non-fatal
301
287
  }
302
288
  }
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 });
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
- res.status(500).json({ error: "Failed to set value", code: "SET_ERROR" });
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 filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
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
- 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]);
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
- catch {
357
- // Best effort — orphaned metadata is annoying but not dangerous
361
+ else {
362
+ await doWork();
358
363
  }
359
364
  res.json({ success: true, key });
360
365
  }
361
- catch {
362
- res.status(500).json({ error: "Failed to delete key", code: "DELETE_ERROR" });
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;