@mnemom/mnemom 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/api.js CHANGED
@@ -26,15 +26,95 @@ function validateUrl(url) {
26
26
  export function newIdempotencyKey() {
27
27
  return randomUUID();
28
28
  }
29
+ /**
30
+ * Extract the human message + code + details from a raw error body,
31
+ * tolerating all three live envelope shapes (prefer nested).
32
+ */
33
+ export function normalizeApiError(raw) {
34
+ if (!raw || typeof raw !== "object")
35
+ return {};
36
+ let message;
37
+ let code;
38
+ let details;
39
+ const e = raw.error;
40
+ if (e && typeof e === "object") {
41
+ // Nested {error:{code,message,details}} (enforce-hook).
42
+ message = typeof e.message === "string" ? e.message : undefined;
43
+ code = typeof e.code === "string" ? e.code : undefined;
44
+ details = e.details;
45
+ }
46
+ else if (typeof e === "string") {
47
+ // Flat {error:"message"} (+ optional top-level code on auth routes).
48
+ message = e;
49
+ }
50
+ // Fallbacks: top-level message/code/details when the above didn't populate.
51
+ if (!message && typeof raw.message === "string")
52
+ message = raw.message;
53
+ if (!code && typeof raw.code === "string")
54
+ code = raw.code;
55
+ if (details === undefined)
56
+ details = raw.details;
57
+ const spec_deviation = raw.spec_deviation && typeof raw.spec_deviation === "object" ? raw.spec_deviation : undefined;
58
+ return { message, code, details, conflict_agent_id: raw.conflict_agent_id, spec_deviation };
59
+ }
60
+ /** Parse a non-OK Response body into normalized error fields (never throws). */
61
+ export async function parseApiErrorBody(response) {
62
+ const raw = (await response.json().catch(() => null));
63
+ return normalizeApiError(raw);
64
+ }
65
+ /**
66
+ * Structured API error. `message` is the human-readable server message (or a
67
+ * fallback); `.status`/`.code`/`.details`/`.conflictAgentId` give callers a
68
+ * structured surface to branch on without parsing the message string.
69
+ * `instanceof MnemomApiError` narrows in command-layer catch blocks.
70
+ */
71
+ export class MnemomApiError extends Error {
72
+ /** The wire HTTP status — may be a synthetic 500 from the enforce hook. */
73
+ status;
74
+ /**
75
+ * The TRUE intended status: `spec_deviation.original_status ?? status`. The
76
+ * enforce hook can rewrite an undocumented status to a synthetic 500; command
77
+ * layers MUST branch on `effectiveStatus` (not `status`) for 404/empty-state
78
+ * detection or they'll be fooled by the rewrite.
79
+ */
80
+ effectiveStatus;
81
+ code;
82
+ details;
83
+ conflictAgentId;
84
+ /** Top-level enforce-hook sibling (present only on a spec-rewritten body). */
85
+ specDeviation;
86
+ constructor(status, message, opts = {}) {
87
+ super(message);
88
+ this.name = "MnemomApiError";
89
+ this.status = status;
90
+ this.code = opts.code;
91
+ this.details = opts.details;
92
+ this.conflictAgentId = opts.conflictAgentId;
93
+ this.specDeviation = opts.specDeviation;
94
+ this.effectiveStatus = opts.specDeviation?.original_status ?? status;
95
+ }
96
+ }
97
+ /**
98
+ * Read a non-OK Response into a MnemomApiError, preferring the server message
99
+ * and falling back to `${fallback}: ${status}` when the body carried none.
100
+ * Captures the enforce-hook `spec_deviation` sibling so `.effectiveStatus`
101
+ * reflects the true intended status behind any synthetic-500 rewrite.
102
+ */
103
+ export async function readApiError(response, fallback) {
104
+ const parsed = await parseApiErrorBody(response);
105
+ const message = parsed.message || `${fallback}: ${response.status}`;
106
+ return new MnemomApiError(response.status, message, {
107
+ code: parsed.code,
108
+ details: parsed.details,
109
+ conflictAgentId: parsed.conflict_agent_id,
110
+ specDeviation: parsed.spec_deviation,
111
+ });
112
+ }
29
113
  async function fetchApi(endpoint) {
30
114
  const url = validateUrl(`${API_BASE}${endpoint}`);
31
115
  const response = await fetch(url);
32
116
  if (!response.ok) {
33
- const error = (await response.json().catch(() => ({
34
- error: "unknown",
35
- message: response.statusText,
36
- })));
37
- throw new Error(error.message || `API request failed: ${response.status}`);
117
+ throw await readApiError(response, "API request failed");
38
118
  }
39
119
  return response.json();
40
120
  }
@@ -58,16 +138,22 @@ export async function postApi(endpoint, body, opts = {}) {
58
138
  body: JSON.stringify(body),
59
139
  });
60
140
  if (!response.ok) {
61
- const error = (await response.json().catch(() => ({
62
- error: "unknown",
63
- message: `HTTP ${response.status}`,
64
- })));
65
- const msg = "message" in error ? error.message : JSON.stringify(error);
66
- // Preserve conflict_agent_id in the error message for 409 handling upstream
67
- if (response.status === 409 && "conflict_agent_id" in error) {
68
- throw new Error(`409: ${msg} (conflict: ${error.conflict_agent_id})`);
141
+ const parsed = await parseApiErrorBody(response);
142
+ const msg = parsed.message || `HTTP ${response.status}`;
143
+ // Preserve the 409 conflict_agent_id surface: carry it structurally on the
144
+ // error (.conflictAgentId) AND keep it in the message for back-compat with
145
+ // any callers still string-matching the old "409: ... (conflict: ...)" form.
146
+ if (response.status === 409 && parsed.conflict_agent_id) {
147
+ throw new MnemomApiError(409, `409: ${msg} (conflict: ${parsed.conflict_agent_id})`, {
148
+ code: parsed.code,
149
+ details: parsed.details,
150
+ conflictAgentId: parsed.conflict_agent_id,
151
+ });
69
152
  }
70
- throw new Error(`${response.status}: ${msg}`);
153
+ throw new MnemomApiError(response.status, `${response.status}: ${msg}`, {
154
+ code: parsed.code,
155
+ details: parsed.details,
156
+ });
71
157
  }
72
158
  return response.json();
73
159
  }
@@ -126,10 +212,9 @@ export async function listAgents() {
126
212
  }));
127
213
  if (!response.ok) {
128
214
  if (response.status === 401) {
129
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
215
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
130
216
  }
131
- const err = (await response.json().catch(() => ({ error: "unknown" })));
132
- throw new Error(err.message || `Failed to list agents: ${response.status}`);
217
+ throw await readApiError(response, "Failed to list agents");
133
218
  }
134
219
  const data = (await response.json());
135
220
  return data.agents ?? [];
@@ -146,10 +231,9 @@ export async function listMyOrgs() {
146
231
  }));
147
232
  if (!response.ok) {
148
233
  if (response.status === 401) {
149
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
234
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
150
235
  }
151
- const err = (await response.json().catch(() => ({ error: "unknown" })));
152
- throw new Error(err.message || `API request failed: ${response.status}`);
236
+ throw await readApiError(response, "API request failed");
153
237
  }
154
238
  const data = (await response.json());
155
239
  return data.orgs ?? [];
@@ -166,10 +250,9 @@ export async function getMyPersonalOrg() {
166
250
  }));
167
251
  if (!response.ok) {
168
252
  if (response.status === 401) {
169
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
253
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
170
254
  }
171
- const err = (await response.json().catch(() => ({ error: "unknown" })));
172
- throw new Error(err.message || `API request failed: ${response.status}`);
255
+ throw await readApiError(response, "API request failed");
173
256
  }
174
257
  return (await response.json());
175
258
  }
@@ -211,13 +294,12 @@ export async function getTeam(teamId) {
211
294
  }));
212
295
  if (!response.ok) {
213
296
  if (response.status === 401) {
214
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
297
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
215
298
  }
216
299
  if (response.status === 404) {
217
- throw new Error(`Team '${teamId}' not found or not accessible.`);
300
+ throw new MnemomApiError(404, `Team '${teamId}' not found or not accessible.`);
218
301
  }
219
- const err = (await response.json().catch(() => ({ error: "unknown" })));
220
- throw new Error(err.message || `API request failed: ${response.status}`);
302
+ throw await readApiError(response, "API request failed");
221
303
  }
222
304
  return (await response.json());
223
305
  }
@@ -234,16 +316,15 @@ export async function getTeamTemplate(teamId, kind) {
234
316
  }));
235
317
  if (!response.ok) {
236
318
  if (response.status === 401) {
237
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
319
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
238
320
  }
239
321
  if (response.status === 403) {
240
- throw new Error(`Forbidden: not a member of team '${teamId}'s org.`);
322
+ throw new MnemomApiError(403, `Forbidden: not a member of team '${teamId}'s org.`);
241
323
  }
242
324
  if (response.status === 404) {
243
- throw new Error(`Team '${teamId}' not found.`);
325
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
244
326
  }
245
- const err = (await response.json().catch(() => ({ error: "unknown" })));
246
- throw new Error(err.message || `API request failed: ${response.status}`);
327
+ throw await readApiError(response, "API request failed");
247
328
  }
248
329
  return (await response.json());
249
330
  }
@@ -271,19 +352,18 @@ export async function putTeamTemplate(teamId, kind, yamlBody) {
271
352
  }));
272
353
  if (!response.ok) {
273
354
  if (response.status === 401) {
274
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
355
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
275
356
  }
276
357
  if (response.status === 403) {
277
- throw new Error(`Forbidden: org admin or owner role required to write a team template.`);
358
+ throw new MnemomApiError(403, `Forbidden: org admin or owner role required to write a team template.`);
278
359
  }
279
360
  if (response.status === 404) {
280
- throw new Error(`Team '${teamId}' not found.`);
361
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
281
362
  }
282
363
  if (response.status === 413) {
283
- throw new Error(`Template too large (server limit: 128 KiB alignment / 64 KiB protection).`);
364
+ throw new MnemomApiError(413, `Template too large (server limit: 128 KiB alignment / 64 KiB protection).`);
284
365
  }
285
- const err = (await response.json().catch(() => ({ error: "unknown" })));
286
- throw new Error(err.message || `API request failed: ${response.status}`);
366
+ throw await readApiError(response, "API request failed");
287
367
  }
288
368
  return (await response.json());
289
369
  }
@@ -305,16 +385,15 @@ export async function deleteTeamTemplate(teamId, kind) {
305
385
  }));
306
386
  if (!response.ok) {
307
387
  if (response.status === 401) {
308
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
388
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
309
389
  }
310
390
  if (response.status === 403) {
311
- throw new Error(`Forbidden: org admin or owner role required to clear a team template.`);
391
+ throw new MnemomApiError(403, `Forbidden: org admin or owner role required to clear a team template.`);
312
392
  }
313
393
  if (response.status === 404) {
314
- throw new Error(`Team '${teamId}' not found.`);
394
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
315
395
  }
316
- const err = (await response.json().catch(() => ({ error: "unknown" })));
317
- throw new Error(err.message || `API request failed: ${response.status}`);
396
+ throw await readApiError(response, "API request failed");
318
397
  }
319
398
  return (await response.json());
320
399
  }
@@ -337,8 +416,7 @@ export async function previewComposeTeamTemplate(teamId, kind, yamlBody) {
337
416
  body: yamlBody,
338
417
  }));
339
418
  if (!response.ok) {
340
- const err = (await response.json().catch(() => ({ error: "unknown" })));
341
- throw new Error(err.message || `Preview failed: ${response.status}`);
419
+ throw await readApiError(response, "Preview failed");
342
420
  }
343
421
  const body = (await response.json());
344
422
  return { composed: body.composed, conflicts: body.conflicts };
@@ -365,16 +443,15 @@ export async function grantTeamAdmin(teamId, userId) {
365
443
  }));
366
444
  if (!response.ok) {
367
445
  if (response.status === 401) {
368
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
446
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
369
447
  }
370
448
  if (response.status === 403) {
371
- throw new Error(`Forbidden: org owner or admin role required, and target user must be in the team's org.`);
449
+ throw new MnemomApiError(403, `Forbidden: org owner or admin role required, and target user must be in the team's org.`);
372
450
  }
373
451
  if (response.status === 404) {
374
- throw new Error(`Team '${teamId}' not found.`);
452
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
375
453
  }
376
- const err = (await response.json().catch(() => ({ error: "unknown" })));
377
- throw new Error(err.message || `API request failed: ${response.status}`);
454
+ throw await readApiError(response, "API request failed");
378
455
  }
379
456
  return (await response.json());
380
457
  }
@@ -398,16 +475,15 @@ export async function revokeTeamAdmin(teamId, userId) {
398
475
  }));
399
476
  if (!response.ok) {
400
477
  if (response.status === 401) {
401
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
478
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
402
479
  }
403
480
  if (response.status === 403) {
404
- throw new Error(`Forbidden: revoke requires org owner/admin, or self-revoke with an active grant.`);
481
+ throw new MnemomApiError(403, `Forbidden: revoke requires org owner/admin, or self-revoke with an active grant.`);
405
482
  }
406
483
  if (response.status === 404) {
407
- throw new Error(`Team '${teamId}' not found.`);
484
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
408
485
  }
409
- const err = (await response.json().catch(() => ({ error: "unknown" })));
410
- throw new Error(err.message || `API request failed: ${response.status}`);
486
+ throw await readApiError(response, "API request failed");
411
487
  }
412
488
  return (await response.json());
413
489
  }
@@ -422,16 +498,15 @@ export async function listTeamAdmins(teamId) {
422
498
  }));
423
499
  if (!response.ok) {
424
500
  if (response.status === 401) {
425
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
501
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
426
502
  }
427
503
  if (response.status === 403) {
428
- throw new Error(`Forbidden: requires membership in the team's org.`);
504
+ throw new MnemomApiError(403, `Forbidden: requires membership in the team's org.`);
429
505
  }
430
506
  if (response.status === 404) {
431
- throw new Error(`Team '${teamId}' not found.`);
507
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
432
508
  }
433
- const err = (await response.json().catch(() => ({ error: "unknown" })));
434
- throw new Error(err.message || `API request failed: ${response.status}`);
509
+ throw await readApiError(response, "API request failed");
435
510
  }
436
511
  return (await response.json());
437
512
  }
@@ -472,11 +547,7 @@ export async function getIntegrity(id) {
472
547
  headers: await authHeaders(),
473
548
  }));
474
549
  if (!response.ok) {
475
- const error = (await response.json().catch(() => ({
476
- error: "unknown",
477
- message: response.statusText,
478
- })));
479
- throw new Error(error.message || `API request failed: ${response.status}`);
550
+ throw await readApiError(response, "API request failed");
480
551
  }
481
552
  // Accept both the canonical docs shape (object) and the RPC-wrapped shape
482
553
  // (array of one row). The latter is what prod actually returns today on
@@ -518,11 +589,7 @@ export async function getTraces(id, limit = 10) {
518
589
  headers: await authHeaders(),
519
590
  }));
520
591
  if (!response.ok) {
521
- const error = (await response.json().catch(() => ({
522
- error: "unknown",
523
- message: response.statusText,
524
- })));
525
- throw new Error(error.message || `API request failed: ${response.status}`);
592
+ throw await readApiError(response, "API request failed");
526
593
  }
527
594
  const data = (await response.json());
528
595
  // Accept both the envelope shape (current API) and a bare array (defensive
@@ -531,105 +598,6 @@ export async function getTraces(id, limit = 10) {
531
598
  return data;
532
599
  return data.traces ?? [];
533
600
  }
534
- export async function getCard(agentId) {
535
- try {
536
- return await fetchApi(`/v1/agents/${agentId}/card`);
537
- }
538
- catch (error) {
539
- const message = error instanceof Error ? error.message : String(error);
540
- if (message.includes("404") || message.includes("not found")) {
541
- return null;
542
- }
543
- throw error;
544
- }
545
- }
546
- export async function updateCard(agentId, cardJson, opts = {}) {
547
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/card`);
548
- const response = await fetch(url, {
549
- method: "PATCH",
550
- headers: {
551
- "Content-Type": "application/json",
552
- "Idempotency-Key": opts.idempotencyKey ?? newIdempotencyKey(),
553
- ...(await authHeaders()),
554
- },
555
- body: sanitizeForHttp(JSON.stringify({ card_json: cardJson })),
556
- });
557
- if (!response.ok) {
558
- const error = (await response.json().catch(() => ({
559
- error: "unknown",
560
- message: response.statusText,
561
- })));
562
- throw new Error(error.message || `Card update failed: ${response.status}`);
563
- }
564
- return response.json();
565
- }
566
- export async function reverifyAgent(agentId, opts = {}) {
567
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/reverify`);
568
- const response = await fetch(url, {
569
- method: "POST",
570
- headers: {
571
- "Content-Type": "application/json",
572
- "Idempotency-Key": opts.idempotencyKey ?? newIdempotencyKey(),
573
- ...(await authHeaders()),
574
- },
575
- });
576
- if (!response.ok) {
577
- const error = (await response.json().catch(() => ({
578
- error: "unknown",
579
- message: response.statusText,
580
- })));
581
- throw new Error(error.message || `Reverify failed: ${response.status}`);
582
- }
583
- return response.json();
584
- }
585
- export async function getPolicy(agentId) {
586
- try {
587
- return await fetchApi(`/v1/agents/${agentId}/policy`);
588
- }
589
- catch (error) {
590
- const message = error instanceof Error ? error.message : String(error);
591
- if (message.includes("404") || message.includes("not found")) {
592
- return null;
593
- }
594
- throw error;
595
- }
596
- }
597
- export async function publishPolicy(agentId, policyJson, opts = {}) {
598
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/policy`);
599
- const response = await fetch(url, {
600
- method: "PUT",
601
- headers: {
602
- "Content-Type": "application/json",
603
- "Idempotency-Key": opts.idempotencyKey ?? newIdempotencyKey(),
604
- ...(await authHeaders()),
605
- },
606
- body: sanitizeForHttp(JSON.stringify({ policy_json: policyJson })),
607
- });
608
- if (!response.ok) {
609
- const error = (await response.json().catch(() => ({
610
- error: "unknown",
611
- message: response.statusText,
612
- })));
613
- throw new Error(error.message || `Policy publish failed: ${response.status}`);
614
- }
615
- return response.json();
616
- }
617
- export async function testPolicyHistorical(agentId, policyJson, limit = 50) {
618
- const url = validateUrl(`${API_BASE}/v1/policies/evaluate/historical`);
619
- const response = await fetch(url, {
620
- method: "POST",
621
- headers: { "Content-Type": "application/json", ...(await authHeaders()) },
622
- body: sanitizeForHttp(JSON.stringify({ agent_id: agentId, policy_json: policyJson, limit })),
623
- });
624
- if (!response.ok) {
625
- const error = (await response.json().catch(() => ({
626
- error: "unknown",
627
- message: response.statusText,
628
- })));
629
- throw new Error(error.message || `Historical evaluation failed: ${response.status}`);
630
- }
631
- return response.json();
632
- }
633
601
  // ============================================================================
634
602
  // Unified Card API (UC-4+)
635
603
  // ============================================================================
@@ -647,11 +615,7 @@ export async function getAlignmentCard(agentId, format = "yaml") {
647
615
  if (response.status === 404) {
648
616
  return { body: "", contentType: "" };
649
617
  }
650
- const error = (await response.json().catch(() => ({
651
- error: "unknown",
652
- message: response.statusText,
653
- })));
654
- throw new Error(error.message || `Failed to fetch alignment card: ${response.status}`);
618
+ throw await readApiError(response, "Failed to fetch alignment card");
655
619
  }
656
620
  const ct = response.headers.get("content-type") ?? "";
657
621
  const body = await response.text();
@@ -689,11 +653,7 @@ export async function putAlignmentCard(agentId, body, contentType = "text/yaml",
689
653
  body: sanitizedBody,
690
654
  }));
691
655
  if (!response.ok) {
692
- const error = (await response.json().catch(() => ({
693
- error: "unknown",
694
- message: response.statusText,
695
- })));
696
- throw new Error(error.message || `Failed to publish alignment card: ${response.status}`);
656
+ throw await readApiError(response, "Failed to publish alignment card");
697
657
  }
698
658
  return response.json();
699
659
  }
@@ -710,11 +670,7 @@ export async function getProtectionCard(agentId, format = "yaml") {
710
670
  if (response.status === 404) {
711
671
  return { body: "", contentType: "" };
712
672
  }
713
- const error = (await response.json().catch(() => ({
714
- error: "unknown",
715
- message: response.statusText,
716
- })));
717
- throw new Error(error.message || `Failed to fetch protection card: ${response.status}`);
673
+ throw await readApiError(response, "Failed to fetch protection card");
718
674
  }
719
675
  const ct = response.headers.get("content-type") ?? "";
720
676
  const body = await response.text();
@@ -744,14 +700,50 @@ export async function putProtectionCard(agentId, body, contentType = "text/yaml"
744
700
  body: sanitizedBody,
745
701
  }));
746
702
  if (!response.ok) {
747
- const error = (await response.json().catch(() => ({
748
- error: "unknown",
749
- message: response.statusText,
750
- })));
751
- throw new Error(error.message || `Failed to publish protection card: ${response.status}`);
703
+ throw await readApiError(response, "Failed to publish protection card");
752
704
  }
753
705
  return response.json();
754
706
  }
707
+ /**
708
+ * Dry-run validate + compose a card body against the server's authoritative
709
+ * pipeline. Returns a structured result — a 422/400 validation failure is an
710
+ * expected `valid:false` outcome (NOT a thrown error). 401 throws (caller
711
+ * should fall back to offline validation); 403/5xx throw a MnemomApiError.
712
+ */
713
+ export async function previewComposeAgentCard(agentId, kind, body, contentType = "text/yaml") {
714
+ const url = validateUrl(`${API_BASE}/v1/${kind}/agent/${encodeURIComponent(agentId)}/preview-compose`);
715
+ const response = await fetchWithAuthRetry(url, async () => ({
716
+ method: "POST",
717
+ headers: { ...(await authHeaders()), "Content-Type": contentType, Accept: "application/json" },
718
+ body: sanitizeForHttp(body),
719
+ }));
720
+ if (response.ok) {
721
+ const data = (await response.json());
722
+ return {
723
+ valid: true,
724
+ status: response.status,
725
+ composed: data.composed,
726
+ conflicts: data.conflicts ?? [],
727
+ coherence_violations: data.coherence_violations ?? [],
728
+ };
729
+ }
730
+ // 401 → unauthenticated: signal the caller to fall back to offline validation.
731
+ if (response.status === 401) {
732
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
733
+ }
734
+ // 400 / 422 → the card is invalid: an expected validation outcome, not an
735
+ // exception. Surface the care-framed findings structurally.
736
+ if (response.status === 400 || response.status === 422) {
737
+ const parsed = await parseApiErrorBody(response);
738
+ return {
739
+ valid: false,
740
+ status: response.status,
741
+ error: { code: parsed.code, message: parsed.message, details: parsed.details },
742
+ };
743
+ }
744
+ // 403 / 5xx → a genuine error (e.g. not the owner, server fault).
745
+ throw await readApiError(response, "Card validation (preview-compose) failed");
746
+ }
755
747
  // ============================================================================
756
748
  // Agent Resolution (server-side, no local config)
757
749
  // ============================================================================
@@ -809,13 +801,12 @@ export async function listPostures(opts) {
809
801
  }));
810
802
  if (!response.ok) {
811
803
  if (response.status === 401) {
812
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
804
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
813
805
  }
814
806
  if (response.status === 403) {
815
- throw new Error(`Forbidden: not a member of org '${opts.orgId}'.`);
807
+ throw new MnemomApiError(403, `Forbidden: not a member of org '${opts.orgId}'.`);
816
808
  }
817
- const err = (await response.json().catch(() => ({ error: "unknown" })));
818
- throw new Error(err.message || `API request failed: ${response.status}`);
809
+ throw await readApiError(response, "API request failed");
819
810
  }
820
811
  const data = (await response.json());
821
812
  return data.postures ?? [];
@@ -828,14 +819,13 @@ export async function getPosture(postureId) {
828
819
  }));
829
820
  if (!response.ok) {
830
821
  if (response.status === 401) {
831
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
822
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
832
823
  }
833
824
  if (response.status === 404)
834
- throw new Error(`Posture '${postureId}' not found.`);
825
+ throw new MnemomApiError(404, `Posture '${postureId}' not found.`);
835
826
  if (response.status === 403)
836
- throw new Error(`Forbidden: not visible to your account.`);
837
- const err = (await response.json().catch(() => ({ error: "unknown" })));
838
- throw new Error(err.message || `API request failed: ${response.status}`);
827
+ throw new MnemomApiError(403, `Forbidden: not visible to your account.`);
828
+ throw await readApiError(response, "API request failed");
839
829
  }
840
830
  return (await response.json());
841
831
  }
@@ -847,11 +837,10 @@ export async function listPostureRevisions(postureId) {
847
837
  }));
848
838
  if (!response.ok) {
849
839
  if (response.status === 401)
850
- throw new Error("Not authenticated.");
840
+ throw new MnemomApiError(401, "Not authenticated.");
851
841
  if (response.status === 404)
852
- throw new Error(`Posture '${postureId}' not found.`);
853
- const err = (await response.json().catch(() => ({ error: "unknown" })));
854
- throw new Error(err.message || `API request failed: ${response.status}`);
842
+ throw new MnemomApiError(404, `Posture '${postureId}' not found.`);
843
+ throw await readApiError(response, "API request failed");
855
844
  }
856
845
  const data = (await response.json());
857
846
  return data.revisions ?? [];
@@ -864,13 +853,16 @@ export async function diffPostureRevisions(postureId, fromNo, toNo) {
864
853
  }));
865
854
  if (!response.ok) {
866
855
  if (response.status === 401)
867
- throw new Error("Not authenticated.");
856
+ throw new MnemomApiError(401, "Not authenticated.");
868
857
  if (response.status === 404) {
869
- const errBody = (await response.json().catch(() => ({})));
870
- throw new Error(errBody.message || `Posture or revision not found.`);
858
+ const errBody = await parseApiErrorBody(response);
859
+ throw new MnemomApiError(404, errBody.message || `Posture or revision not found.`, {
860
+ code: errBody.code,
861
+ details: errBody.details,
862
+ specDeviation: errBody.spec_deviation,
863
+ });
871
864
  }
872
- const err = (await response.json().catch(() => ({ error: "unknown" })));
873
- throw new Error(err.message || `Diff failed: ${response.status}`);
865
+ throw await readApiError(response, "Diff failed");
874
866
  }
875
867
  return (await response.json());
876
868
  }
@@ -889,16 +881,15 @@ export async function createPosture(input) {
889
881
  }));
890
882
  if (!response.ok) {
891
883
  if (response.status === 401)
892
- throw new Error("Not authenticated.");
884
+ throw new MnemomApiError(401, "Not authenticated.");
893
885
  if (response.status === 403) {
894
- throw new Error(`Forbidden: org owner/admin role required (or scope=platform reserved for migrations).`);
886
+ throw new MnemomApiError(403, `Forbidden: org owner/admin role required (or scope=platform reserved for migrations).`);
895
887
  }
896
888
  if (response.status === 409)
897
- throw new Error(`A posture with this slug already exists in the org.`);
889
+ throw new MnemomApiError(409, `A posture with this slug already exists in the org.`);
898
890
  if (response.status === 413)
899
- throw new Error(`Posture body too large (server limit: 256 KiB).`);
900
- const err = (await response.json().catch(() => ({ error: "unknown" })));
901
- throw new Error(err.message || `Create failed: ${response.status}`);
891
+ throw new MnemomApiError(413, `Posture body too large (server limit: 256 KiB).`);
892
+ throw await readApiError(response, "Create failed");
902
893
  }
903
894
  return (await response.json());
904
895
  }
@@ -917,16 +908,15 @@ export async function updatePosture(postureId, input) {
917
908
  }));
918
909
  if (!response.ok) {
919
910
  if (response.status === 401)
920
- throw new Error("Not authenticated.");
911
+ throw new MnemomApiError(401, "Not authenticated.");
921
912
  if (response.status === 403) {
922
- throw new Error(`Forbidden: org owner/admin required, or this is a Mnemom-shipped platform default (immutable — clone first).`);
913
+ throw new MnemomApiError(403, `Forbidden: org owner/admin required, or this is a Mnemom-shipped platform default (immutable — clone first).`);
923
914
  }
924
915
  if (response.status === 404)
925
- throw new Error(`Posture '${postureId}' not found.`);
916
+ throw new MnemomApiError(404, `Posture '${postureId}' not found.`);
926
917
  if (response.status === 413)
927
- throw new Error(`Posture body too large (server limit: 256 KiB).`);
928
- const err = (await response.json().catch(() => ({ error: "unknown" })));
929
- throw new Error(err.message || `Update failed: ${response.status}`);
918
+ throw new MnemomApiError(413, `Posture body too large (server limit: 256 KiB).`);
919
+ throw await readApiError(response, "Update failed");
930
920
  }
931
921
  return (await response.json());
932
922
  }
@@ -945,15 +935,14 @@ export async function clonePosture(postureId, input) {
945
935
  }));
946
936
  if (!response.ok) {
947
937
  if (response.status === 401)
948
- throw new Error("Not authenticated.");
938
+ throw new MnemomApiError(401, "Not authenticated.");
949
939
  if (response.status === 403)
950
- throw new Error(`Forbidden: org owner/admin required on target org.`);
940
+ throw new MnemomApiError(403, `Forbidden: org owner/admin required on target org.`);
951
941
  if (response.status === 404)
952
- throw new Error(`Source posture '${postureId}' not found.`);
942
+ throw new MnemomApiError(404, `Source posture '${postureId}' not found.`);
953
943
  if (response.status === 409)
954
- throw new Error(`A posture with this slug already exists in the target org.`);
955
- const err = (await response.json().catch(() => ({ error: "unknown" })));
956
- throw new Error(err.message || `Clone failed: ${response.status}`);
944
+ throw new MnemomApiError(409, `A posture with this slug already exists in the target org.`);
945
+ throw await readApiError(response, "Clone failed");
957
946
  }
958
947
  return (await response.json());
959
948
  }
@@ -970,16 +959,15 @@ export async function deletePosture(postureId) {
970
959
  }));
971
960
  if (!response.ok) {
972
961
  if (response.status === 401)
973
- throw new Error("Not authenticated.");
962
+ throw new MnemomApiError(401, "Not authenticated.");
974
963
  if (response.status === 403)
975
- throw new Error(`Forbidden: platform defaults are immutable.`);
964
+ throw new MnemomApiError(403, `Forbidden: platform defaults are immutable.`);
976
965
  if (response.status === 404)
977
- throw new Error(`Posture '${postureId}' not found.`);
966
+ throw new MnemomApiError(404, `Posture '${postureId}' not found.`);
978
967
  if (response.status === 409) {
979
- throw new Error(`Cannot delete a posture that is currently assigned to one or more teams. Unassign first.`);
968
+ throw new MnemomApiError(409, `Cannot delete a posture that is currently assigned to one or more teams. Unassign first.`);
980
969
  }
981
- const err = (await response.json().catch(() => ({ error: "unknown" })));
982
- throw new Error(err.message || `Delete failed: ${response.status}`);
970
+ throw await readApiError(response, "Delete failed");
983
971
  }
984
972
  }
985
973
  /** POST /v1/postures/:id/assign — assign to a team. */
@@ -997,15 +985,14 @@ export async function assignPosture(postureId, teamId, pinRevisionNo) {
997
985
  }));
998
986
  if (!response.ok) {
999
987
  if (response.status === 401)
1000
- throw new Error("Not authenticated.");
988
+ throw new MnemomApiError(401, "Not authenticated.");
1001
989
  if (response.status === 403)
1002
- throw new Error(`Forbidden: org owner/admin required on team's org.`);
990
+ throw new MnemomApiError(403, `Forbidden: org owner/admin required on team's org.`);
1003
991
  if (response.status === 404) {
1004
- const errBody = (await response.json().catch(() => ({})));
1005
- throw new Error(errBody.message || `Posture, team, or pinned revision not found.`);
992
+ const errBody = await parseApiErrorBody(response);
993
+ throw new MnemomApiError(404, errBody.message || `Posture, team, or pinned revision not found.`, { code: errBody.code, details: errBody.details, specDeviation: errBody.spec_deviation });
1006
994
  }
1007
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1008
- throw new Error(err.message || `Assign failed: ${response.status}`);
995
+ throw await readApiError(response, "Assign failed");
1009
996
  }
1010
997
  return (await response.json());
1011
998
  }
@@ -1022,14 +1009,13 @@ export async function unassignPosture(postureId, teamId) {
1022
1009
  }));
1023
1010
  if (!response.ok) {
1024
1011
  if (response.status === 401)
1025
- throw new Error("Not authenticated.");
1012
+ throw new MnemomApiError(401, "Not authenticated.");
1026
1013
  if (response.status === 403)
1027
- throw new Error(`Forbidden: org owner/admin required.`);
1014
+ throw new MnemomApiError(403, `Forbidden: org owner/admin required.`);
1028
1015
  if (response.status === 404) {
1029
- throw new Error(`No matching assignment to remove (posture '${postureId}' is not assigned to team '${teamId}').`);
1016
+ throw new MnemomApiError(404, `No matching assignment to remove (posture '${postureId}' is not assigned to team '${teamId}').`);
1030
1017
  }
1031
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1032
- throw new Error(err.message || `Unassign failed: ${response.status}`);
1018
+ throw await readApiError(response, "Unassign failed");
1033
1019
  }
1034
1020
  }
1035
1021
  /** POST /v1/postures/:id/preview-compose — preview effective for a team. */
@@ -1046,13 +1032,16 @@ export async function previewComposePosture(postureId, teamId) {
1046
1032
  }));
1047
1033
  if (!response.ok) {
1048
1034
  if (response.status === 401)
1049
- throw new Error("Not authenticated.");
1035
+ throw new MnemomApiError(401, "Not authenticated.");
1050
1036
  if (response.status === 404) {
1051
- const errBody = (await response.json().catch(() => ({})));
1052
- throw new Error(errBody.message || `Posture or team not found.`);
1037
+ const errBody = await parseApiErrorBody(response);
1038
+ throw new MnemomApiError(404, errBody.message || `Posture or team not found.`, {
1039
+ code: errBody.code,
1040
+ details: errBody.details,
1041
+ specDeviation: errBody.spec_deviation,
1042
+ });
1053
1043
  }
1054
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1055
- throw new Error(err.message || `Preview-compose failed: ${response.status}`);
1044
+ throw await readApiError(response, "Preview-compose failed");
1056
1045
  }
1057
1046
  return (await response.json());
1058
1047
  }
@@ -1076,13 +1065,12 @@ export async function listSidebandAdvisoriesForAgent(agentId, opts = {}) {
1076
1065
  }));
1077
1066
  if (!response.ok) {
1078
1067
  if (response.status === 401)
1079
- throw new Error("Not authenticated.");
1068
+ throw new MnemomApiError(401, "Not authenticated.");
1080
1069
  if (response.status === 403)
1081
- throw new Error("Permission denied (need org membership).");
1070
+ throw new MnemomApiError(403, "Permission denied (need org membership).");
1082
1071
  if (response.status === 404)
1083
- throw new Error(`Agent '${agentId}' not found.`);
1084
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1085
- throw new Error(err.message || `List advisories failed: ${response.status}`);
1072
+ throw new MnemomApiError(404, `Agent '${agentId}' not found.`);
1073
+ throw await readApiError(response, "List advisories failed");
1086
1074
  }
1087
1075
  return (await response.json());
1088
1076
  }
@@ -1106,13 +1094,12 @@ export async function listSidebandAdvisoriesForTeam(teamId, opts = {}) {
1106
1094
  }));
1107
1095
  if (!response.ok) {
1108
1096
  if (response.status === 401)
1109
- throw new Error("Not authenticated.");
1097
+ throw new MnemomApiError(401, "Not authenticated.");
1110
1098
  if (response.status === 403)
1111
- throw new Error("Permission denied (need org membership).");
1099
+ throw new MnemomApiError(403, "Permission denied (need org membership).");
1112
1100
  if (response.status === 404)
1113
- throw new Error(`Team '${teamId}' not found.`);
1114
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1115
- throw new Error(err.message || `List advisories failed: ${response.status}`);
1101
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
1102
+ throw await readApiError(response, "List advisories failed");
1116
1103
  }
1117
1104
  return (await response.json());
1118
1105
  }
@@ -1131,13 +1118,12 @@ export async function getTeamSidebandCoverage(teamId) {
1131
1118
  }));
1132
1119
  if (!response.ok) {
1133
1120
  if (response.status === 401)
1134
- throw new Error("Not authenticated.");
1121
+ throw new MnemomApiError(401, "Not authenticated.");
1135
1122
  if (response.status === 403)
1136
- throw new Error("Permission denied (need org membership).");
1123
+ throw new MnemomApiError(403, "Permission denied (need org membership).");
1137
1124
  if (response.status === 404)
1138
- throw new Error(`Team '${teamId}' not found.`);
1139
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1140
- throw new Error(err.message || `Coverage fetch failed: ${response.status}`);
1125
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
1126
+ throw await readApiError(response, "Coverage fetch failed");
1141
1127
  }
1142
1128
  return (await response.json());
1143
1129
  }
@@ -1154,11 +1140,10 @@ export async function getSafeHouseHarnessState() {
1154
1140
  }));
1155
1141
  if (!response.ok) {
1156
1142
  if (response.status === 401)
1157
- throw new Error("Not authenticated. Run `mnemom login`.");
1143
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login`.");
1158
1144
  if (response.status === 403)
1159
- throw new Error("Permission denied — `mnemom validate safe-house` is mnemom-staff only.");
1160
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1161
- throw new Error(err.message || `Harness state fetch failed: ${response.status}`);
1145
+ throw new MnemomApiError(403, "Permission denied — `mnemom validate safe-house` is mnemom-staff only.");
1146
+ throw await readApiError(response, "Harness state fetch failed");
1162
1147
  }
1163
1148
  return (await response.json());
1164
1149
  }
@@ -1195,13 +1180,12 @@ async function gFetch(path, init = {}, notFoundLabel) {
1195
1180
  }));
1196
1181
  if (!response.ok) {
1197
1182
  if (response.status === 401)
1198
- throw new Error("Not authenticated.");
1183
+ throw new MnemomApiError(401, "Not authenticated.");
1199
1184
  if (response.status === 403)
1200
- throw new Error("Permission denied (need org admin / membership).");
1185
+ throw new MnemomApiError(403, "Permission denied (need org admin / membership).");
1201
1186
  if (response.status === 404 && notFoundLabel)
1202
- throw new Error(`${notFoundLabel} not found.`);
1203
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1204
- throw new Error(err.message || `Request failed: ${response.status}`);
1187
+ throw new MnemomApiError(404, `${notFoundLabel} not found.`);
1188
+ throw await readApiError(response, "Request failed");
1205
1189
  }
1206
1190
  return (await response.json());
1207
1191
  }
@@ -1286,10 +1270,9 @@ export async function listApiKeys() {
1286
1270
  }));
1287
1271
  if (!response.ok) {
1288
1272
  if (response.status === 401) {
1289
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1273
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1290
1274
  }
1291
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1292
- throw new Error(err.message || `Failed to list api keys: ${response.status}`);
1275
+ throw await readApiError(response, "Failed to list api keys");
1293
1276
  }
1294
1277
  const data = (await response.json());
1295
1278
  return data.keys ?? [];
@@ -1310,16 +1293,17 @@ export async function createApiKey(name, scopes) {
1310
1293
  }));
1311
1294
  if (!response.ok) {
1312
1295
  if (response.status === 401) {
1313
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1296
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1314
1297
  }
1315
- const body = await response.text().catch(() => "");
1298
+ const parsed = await parseApiErrorBody(response);
1299
+ const detail = parsed.message || `HTTP ${response.status}`;
1316
1300
  if (response.status === 403) {
1317
- throw new Error(`Mint-time ceiling rejected scope: ${body}`);
1301
+ throw new MnemomApiError(403, `Mint-time ceiling rejected scope: ${detail}`);
1318
1302
  }
1319
1303
  if (response.status === 400) {
1320
- throw new Error(`Invalid scope(s): ${body}`);
1304
+ throw new MnemomApiError(400, `Invalid scope(s): ${detail}`);
1321
1305
  }
1322
- throw new Error(`Failed to create api key: ${response.status} ${body}`);
1306
+ throw new MnemomApiError(response.status, `Failed to create api key: ${response.status} ${detail}`);
1323
1307
  }
1324
1308
  return (await response.json());
1325
1309
  }
@@ -1337,12 +1321,12 @@ export async function rotateApiKey(keyId) {
1337
1321
  }));
1338
1322
  if (!response.ok) {
1339
1323
  if (response.status === 401) {
1340
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1324
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1341
1325
  }
1342
1326
  if (response.status === 404)
1343
- throw new Error(`Key not found: ${keyId}`);
1344
- const body = await response.text().catch(() => "");
1345
- throw new Error(`Failed to rotate api key: ${response.status} ${body}`);
1327
+ throw new MnemomApiError(404, `Key not found: ${keyId}`);
1328
+ const parsed = await parseApiErrorBody(response);
1329
+ throw new MnemomApiError(response.status, `Failed to rotate api key: ${response.status} ${parsed.message ?? ""}`.trim());
1346
1330
  }
1347
1331
  return (await response.json());
1348
1332
  }
@@ -1358,14 +1342,48 @@ export async function revokeApiKey(keyId) {
1358
1342
  }));
1359
1343
  if (!response.ok) {
1360
1344
  if (response.status === 401) {
1361
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1345
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1362
1346
  }
1363
1347
  if (response.status === 404)
1364
- throw new Error(`Key not found: ${keyId}`);
1365
- const body = await response.text().catch(() => "");
1366
- throw new Error(`Failed to revoke api key: ${response.status} ${body}`);
1348
+ throw new MnemomApiError(404, `Key not found: ${keyId}`);
1349
+ const parsed = await parseApiErrorBody(response);
1350
+ throw new MnemomApiError(response.status, `Failed to revoke api key: ${response.status} ${parsed.message ?? ""}`.trim());
1367
1351
  }
1368
1352
  }
1353
+ /**
1354
+ * POST /v1/license/validate — validate a license JWT for an instance.
1355
+ *
1356
+ * UNAUTHENTICATED by design (the license JWT IS the credential; this is a
1357
+ * pre-login path) — sends NO auth header. Source-verified at canonical ref
1358
+ * c18491e6 (handleLicenseValidate): the request body keys are
1359
+ * `{ license, instance_id, instance_metadata }` (NOT `jwt`) — sending `jwt`
1360
+ * would 400 "license is required".
1361
+ *
1362
+ * Returns the parsed validation result on 2xx (including the grace-period
1363
+ * `valid:false` 200 body); throws MnemomApiError on non-2xx so the command
1364
+ * renders `.message`/`.status`. This fixes the live H1 "[object Object]" bug:
1365
+ * the old command-layer `err.error || "unknown"` string-coerced the nested
1366
+ * `{code,message}` object that the enforce hook puts on the wire.
1367
+ *
1368
+ * `license deactivate` may reuse this (best-effort, fire-and-forget) by
1369
+ * catching the throw at the command layer.
1370
+ */
1371
+ export async function validateLicense(jwt, instanceId, instanceMetadata = {}) {
1372
+ const url = validateUrl(`${API_BASE}/v1/license/validate`);
1373
+ const response = await fetch(url, {
1374
+ method: "POST",
1375
+ headers: { "Content-Type": "application/json" },
1376
+ body: JSON.stringify({
1377
+ license: sanitizeForHttp(jwt),
1378
+ instance_id: instanceId,
1379
+ instance_metadata: instanceMetadata,
1380
+ }),
1381
+ });
1382
+ if (!response.ok) {
1383
+ throw await readApiError(response, "License validation failed");
1384
+ }
1385
+ return (await response.json());
1386
+ }
1369
1387
  export async function reportRecipeFnFp(recipeId, input) {
1370
1388
  return postApi(`/v1/recipes/${encodeURIComponent(recipeId)}/report`, input);
1371
1389
  }