@mnemom/mnemom 0.12.1 → 0.14.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
  }
@@ -119,6 +205,13 @@ async function fetchWithAuthRetry(url, buildInit) {
119
205
  export async function getAgent(id) {
120
206
  return fetchApi(`/v1/agents/${id}`);
121
207
  }
208
+ /**
209
+ * @deprecated Legacy per-user listing — `GET /v1/agents` is scoped by the
210
+ * caller's `claimed_by` rows, which ADR-062 retired as an authorization /
211
+ * listing boundary (org_id is now the sole boundary). New code must list via
212
+ * {@link listOrgAgents} (org-scoped). Retained only for back-compat callers;
213
+ * the `mnemom agents` command and name resolution no longer use it.
214
+ */
122
215
  export async function listAgents() {
123
216
  const url = validateUrl(`${API_BASE}/v1/agents?limit=100`);
124
217
  const response = await fetchWithAuthRetry(url, async () => ({
@@ -126,10 +219,9 @@ export async function listAgents() {
126
219
  }));
127
220
  if (!response.ok) {
128
221
  if (response.status === 401) {
129
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
222
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
130
223
  }
131
- const err = (await response.json().catch(() => ({ error: "unknown" })));
132
- throw new Error(err.message || `Failed to list agents: ${response.status}`);
224
+ throw await readApiError(response, "Failed to list agents");
133
225
  }
134
226
  const data = (await response.json());
135
227
  return data.agents ?? [];
@@ -146,14 +238,134 @@ export async function listMyOrgs() {
146
238
  }));
147
239
  if (!response.ok) {
148
240
  if (response.status === 401) {
149
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
241
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
150
242
  }
151
- const err = (await response.json().catch(() => ({ error: "unknown" })));
152
- throw new Error(err.message || `API request failed: ${response.status}`);
243
+ throw await readApiError(response, "API request failed");
153
244
  }
154
245
  const data = (await response.json());
155
246
  return data.orgs ?? [];
156
247
  }
248
+ /**
249
+ * GET /v1/orgs/:org_id/agents — the org-scoped agent fleet (ADR-062). Any
250
+ * member of the org may read it; non-members get 403.
251
+ */
252
+ export async function fetchOrgFleet(orgId) {
253
+ const url = validateUrl(`${API_BASE}/v1/orgs/${encodeURIComponent(orgId)}/agents`);
254
+ const response = await fetchWithAuthRetry(url, async () => ({
255
+ headers: { ...(await authHeaders()), Accept: "application/json" },
256
+ }));
257
+ if (!response.ok) {
258
+ if (response.status === 401) {
259
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
260
+ }
261
+ throw await readApiError(response, "Failed to list org agents");
262
+ }
263
+ const data = (await response.json());
264
+ return data.agents ?? [];
265
+ }
266
+ function fleetToRow(a, org) {
267
+ return {
268
+ id: a.agent_id,
269
+ name: a.agent_name ?? null,
270
+ email: a.owner_email ?? null,
271
+ created_at: a.created_at,
272
+ last_seen: a.last_seen ?? null,
273
+ containment_status: a.containment_status ?? null,
274
+ org_id: org.org_id,
275
+ org_name: org.name,
276
+ };
277
+ }
278
+ /**
279
+ * List agents the ADR-062 way: scoped by org membership, never by `claimed_by`.
280
+ *
281
+ * - With `orgId`: lists that single org's fleet.
282
+ * - Without `orgId`: aggregates the fleet across every org the caller belongs
283
+ * to (the same pattern as {@link listAllTeams}), tagging each row with its
284
+ * org. Orgs the caller can't read (403/404) are skipped silently so one
285
+ * inaccessible org doesn't fail the whole listing.
286
+ */
287
+ export async function listOrgAgents(orgId) {
288
+ let orgs;
289
+ if (orgId) {
290
+ // Resolve a friendly name if the caller is a member; fall back to the id.
291
+ const mine = await listMyOrgs().catch(() => []);
292
+ const match = mine.find((o) => o.org_id === orgId);
293
+ orgs = [{ org_id: orgId, name: match?.name ?? orgId }];
294
+ }
295
+ else {
296
+ orgs = (await listMyOrgs()).map((o) => ({ org_id: o.org_id, name: o.name }));
297
+ }
298
+ const rows = [];
299
+ const seen = new Set();
300
+ for (const org of orgs) {
301
+ let fleet;
302
+ try {
303
+ fleet = await fetchOrgFleet(org.org_id);
304
+ }
305
+ catch (err) {
306
+ // A specific --org that we can't read is a real error worth surfacing;
307
+ // a skipped org during aggregation is not.
308
+ if (orgId)
309
+ throw err;
310
+ continue;
311
+ }
312
+ for (const a of fleet) {
313
+ if (seen.has(a.agent_id))
314
+ continue;
315
+ seen.add(a.agent_id);
316
+ rows.push(fleetToRow(a, org));
317
+ }
318
+ }
319
+ return rows;
320
+ }
321
+ /**
322
+ * POST /v1/agents/:id/claim — claim an agent into an org (ADR-062).
323
+ *
324
+ * Body is `{ hash_proof, org_id? }`. `hash_proof` (the agent's full SHA-256
325
+ * proof) authenticates the caller as the agent's owner; the user's own auth
326
+ * header rides along too so the platform can validate org membership and link
327
+ * the principal. Omit `orgId` to land in the caller's personal org (the
328
+ * platform default); a supplied `orgId` is validated against the caller's
329
+ * memberships server-side (a non-member gets a 403 the command layer turns
330
+ * into a teaching error listing claimable orgs).
331
+ *
332
+ * An Idempotency-Key is minted (and held across the 401-refresh retry) so a
333
+ * retried claim replays the same logical operation server-side.
334
+ */
335
+ export async function claimAgent(agentId, body, opts = {}) {
336
+ const url = validateUrl(`${API_BASE}/v1/agents/${encodeURIComponent(agentId)}/claim`);
337
+ const payload = {
338
+ hash_proof: sanitizeForHttp(body.hashProof),
339
+ };
340
+ if (body.orgId)
341
+ payload.org_id = sanitizeForHttp(body.orgId);
342
+ const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
343
+ const response = await fetchWithAuthRetry(url, async () => ({
344
+ method: "POST",
345
+ headers: {
346
+ ...(await authHeaders()),
347
+ "Content-Type": "application/json",
348
+ Accept: "application/json",
349
+ "Idempotency-Key": idempotencyKey,
350
+ },
351
+ body: JSON.stringify(payload),
352
+ }));
353
+ if (!response.ok) {
354
+ if (response.status === 401) {
355
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
356
+ }
357
+ throw await readApiError(response, "Failed to claim agent");
358
+ }
359
+ const data = (await response.json());
360
+ return {
361
+ claimed: data.claimed ?? true,
362
+ agent_id: data.agent_id ?? agentId,
363
+ // Reflect what the server resolved — null (not "") when absent/unscoped,
364
+ // matching the locked SDK contract so the two surfaces don't drift.
365
+ org_id: data.org_id ?? null,
366
+ claimed_at: data.claimed_at ?? null,
367
+ };
368
+ }
157
369
  /**
158
370
  * GET /v1/auth/me/personal-org — accessor for the user's personal org.
159
371
  * Idempotent: lazily provisions for legacy accounts that pre-date the
@@ -166,10 +378,9 @@ export async function getMyPersonalOrg() {
166
378
  }));
167
379
  if (!response.ok) {
168
380
  if (response.status === 401) {
169
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
381
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
170
382
  }
171
- const err = (await response.json().catch(() => ({ error: "unknown" })));
172
- throw new Error(err.message || `API request failed: ${response.status}`);
383
+ throw await readApiError(response, "API request failed");
173
384
  }
174
385
  return (await response.json());
175
386
  }
@@ -211,13 +422,12 @@ export async function getTeam(teamId) {
211
422
  }));
212
423
  if (!response.ok) {
213
424
  if (response.status === 401) {
214
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
425
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
215
426
  }
216
427
  if (response.status === 404) {
217
- throw new Error(`Team '${teamId}' not found or not accessible.`);
428
+ throw new MnemomApiError(404, `Team '${teamId}' not found or not accessible.`);
218
429
  }
219
- const err = (await response.json().catch(() => ({ error: "unknown" })));
220
- throw new Error(err.message || `API request failed: ${response.status}`);
430
+ throw await readApiError(response, "API request failed");
221
431
  }
222
432
  return (await response.json());
223
433
  }
@@ -234,16 +444,15 @@ export async function getTeamTemplate(teamId, kind) {
234
444
  }));
235
445
  if (!response.ok) {
236
446
  if (response.status === 401) {
237
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
447
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
238
448
  }
239
449
  if (response.status === 403) {
240
- throw new Error(`Forbidden: not a member of team '${teamId}'s org.`);
450
+ throw new MnemomApiError(403, `Forbidden: not a member of team '${teamId}'s org.`);
241
451
  }
242
452
  if (response.status === 404) {
243
- throw new Error(`Team '${teamId}' not found.`);
453
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
244
454
  }
245
- const err = (await response.json().catch(() => ({ error: "unknown" })));
246
- throw new Error(err.message || `API request failed: ${response.status}`);
455
+ throw await readApiError(response, "API request failed");
247
456
  }
248
457
  return (await response.json());
249
458
  }
@@ -271,19 +480,18 @@ export async function putTeamTemplate(teamId, kind, yamlBody) {
271
480
  }));
272
481
  if (!response.ok) {
273
482
  if (response.status === 401) {
274
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
483
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
275
484
  }
276
485
  if (response.status === 403) {
277
- throw new Error(`Forbidden: org admin or owner role required to write a team template.`);
486
+ throw new MnemomApiError(403, `Forbidden: org admin or owner role required to write a team template.`);
278
487
  }
279
488
  if (response.status === 404) {
280
- throw new Error(`Team '${teamId}' not found.`);
489
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
281
490
  }
282
491
  if (response.status === 413) {
283
- throw new Error(`Template too large (server limit: 128 KiB alignment / 64 KiB protection).`);
492
+ throw new MnemomApiError(413, `Template too large (server limit: 128 KiB alignment / 64 KiB protection).`);
284
493
  }
285
- const err = (await response.json().catch(() => ({ error: "unknown" })));
286
- throw new Error(err.message || `API request failed: ${response.status}`);
494
+ throw await readApiError(response, "API request failed");
287
495
  }
288
496
  return (await response.json());
289
497
  }
@@ -305,16 +513,15 @@ export async function deleteTeamTemplate(teamId, kind) {
305
513
  }));
306
514
  if (!response.ok) {
307
515
  if (response.status === 401) {
308
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
516
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
309
517
  }
310
518
  if (response.status === 403) {
311
- throw new Error(`Forbidden: org admin or owner role required to clear a team template.`);
519
+ throw new MnemomApiError(403, `Forbidden: org admin or owner role required to clear a team template.`);
312
520
  }
313
521
  if (response.status === 404) {
314
- throw new Error(`Team '${teamId}' not found.`);
522
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
315
523
  }
316
- const err = (await response.json().catch(() => ({ error: "unknown" })));
317
- throw new Error(err.message || `API request failed: ${response.status}`);
524
+ throw await readApiError(response, "API request failed");
318
525
  }
319
526
  return (await response.json());
320
527
  }
@@ -337,8 +544,7 @@ export async function previewComposeTeamTemplate(teamId, kind, yamlBody) {
337
544
  body: yamlBody,
338
545
  }));
339
546
  if (!response.ok) {
340
- const err = (await response.json().catch(() => ({ error: "unknown" })));
341
- throw new Error(err.message || `Preview failed: ${response.status}`);
547
+ throw await readApiError(response, "Preview failed");
342
548
  }
343
549
  const body = (await response.json());
344
550
  return { composed: body.composed, conflicts: body.conflicts };
@@ -365,16 +571,15 @@ export async function grantTeamAdmin(teamId, userId) {
365
571
  }));
366
572
  if (!response.ok) {
367
573
  if (response.status === 401) {
368
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
574
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
369
575
  }
370
576
  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.`);
577
+ throw new MnemomApiError(403, `Forbidden: org owner or admin role required, and target user must be in the team's org.`);
372
578
  }
373
579
  if (response.status === 404) {
374
- throw new Error(`Team '${teamId}' not found.`);
580
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
375
581
  }
376
- const err = (await response.json().catch(() => ({ error: "unknown" })));
377
- throw new Error(err.message || `API request failed: ${response.status}`);
582
+ throw await readApiError(response, "API request failed");
378
583
  }
379
584
  return (await response.json());
380
585
  }
@@ -398,16 +603,15 @@ export async function revokeTeamAdmin(teamId, userId) {
398
603
  }));
399
604
  if (!response.ok) {
400
605
  if (response.status === 401) {
401
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
606
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
402
607
  }
403
608
  if (response.status === 403) {
404
- throw new Error(`Forbidden: revoke requires org owner/admin, or self-revoke with an active grant.`);
609
+ throw new MnemomApiError(403, `Forbidden: revoke requires org owner/admin, or self-revoke with an active grant.`);
405
610
  }
406
611
  if (response.status === 404) {
407
- throw new Error(`Team '${teamId}' not found.`);
612
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
408
613
  }
409
- const err = (await response.json().catch(() => ({ error: "unknown" })));
410
- throw new Error(err.message || `API request failed: ${response.status}`);
614
+ throw await readApiError(response, "API request failed");
411
615
  }
412
616
  return (await response.json());
413
617
  }
@@ -422,28 +626,27 @@ export async function listTeamAdmins(teamId) {
422
626
  }));
423
627
  if (!response.ok) {
424
628
  if (response.status === 401) {
425
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
629
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
426
630
  }
427
631
  if (response.status === 403) {
428
- throw new Error(`Forbidden: requires membership in the team's org.`);
632
+ throw new MnemomApiError(403, `Forbidden: requires membership in the team's org.`);
429
633
  }
430
634
  if (response.status === 404) {
431
- throw new Error(`Team '${teamId}' not found.`);
635
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
432
636
  }
433
- const err = (await response.json().catch(() => ({ error: "unknown" })));
434
- throw new Error(err.message || `API request failed: ${response.status}`);
637
+ throw await readApiError(response, "API request failed");
435
638
  }
436
639
  return (await response.json());
437
640
  }
438
641
  /**
439
- * Look up an agent in the authenticated user's account by name.
642
+ * Look up an agent by name across the caller's org fleets (ADR-062-scoped via
643
+ * {@link listOrgAgents}, not the legacy `claimed_by` list).
440
644
  * Tries exact match first, then single partial match.
441
645
  * Throws if multiple agents partially match (ambiguous).
442
646
  * Returns null if no match found.
443
- * Note: capped at 100 agents by listAgents().
444
647
  */
445
648
  export async function getAgentByName(name) {
446
- const agents = await listAgents();
649
+ const agents = await listOrgAgents();
447
650
  const lower = name.toLowerCase();
448
651
  const exact = agents.find((a) => a.name?.toLowerCase() === lower);
449
652
  if (exact)
@@ -472,11 +675,7 @@ export async function getIntegrity(id) {
472
675
  headers: await authHeaders(),
473
676
  }));
474
677
  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}`);
678
+ throw await readApiError(response, "API request failed");
480
679
  }
481
680
  // Accept both the canonical docs shape (object) and the RPC-wrapped shape
482
681
  // (array of one row). The latter is what prod actually returns today on
@@ -518,11 +717,7 @@ export async function getTraces(id, limit = 10) {
518
717
  headers: await authHeaders(),
519
718
  }));
520
719
  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}`);
720
+ throw await readApiError(response, "API request failed");
526
721
  }
527
722
  const data = (await response.json());
528
723
  // Accept both the envelope shape (current API) and a bare array (defensive
@@ -531,105 +726,6 @@ export async function getTraces(id, limit = 10) {
531
726
  return data;
532
727
  return data.traces ?? [];
533
728
  }
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
729
  // ============================================================================
634
730
  // Unified Card API (UC-4+)
635
731
  // ============================================================================
@@ -639,7 +735,10 @@ export async function testPolicyHistorical(agentId, policyJson, limit = 50) {
639
735
  */
640
736
  export async function getAlignmentCard(agentId, format = "yaml") {
641
737
  const accept = format === "yaml" ? "text/yaml" : "application/json";
642
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/alignment-card`);
738
+ // Canonical Resources × Scope × Verb surface (ADR-062 / cards-as-primitive
739
+ // W1.2b). The legacy `/v1/agents/:id/alignment-card` only 308-redirects to
740
+ // this — first-party callers target canonical directly (no legacy shapes).
741
+ const url = validateUrl(`${API_BASE}/v1/alignment/agent/${encodeURIComponent(agentId)}`);
643
742
  const response = await fetch(url, {
644
743
  headers: { Accept: accept, ...(await authHeaders()) },
645
744
  });
@@ -647,11 +746,7 @@ export async function getAlignmentCard(agentId, format = "yaml") {
647
746
  if (response.status === 404) {
648
747
  return { body: "", contentType: "" };
649
748
  }
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}`);
749
+ throw await readApiError(response, "Failed to fetch alignment card");
655
750
  }
656
751
  const ct = response.headers.get("content-type") ?? "";
657
752
  const body = await response.text();
@@ -664,7 +759,10 @@ export const ALIGNMENT_CARD_MAX_BYTES = 128 * 1024;
664
759
  * Accepts YAML or JSON body; set contentType accordingly.
665
760
  */
666
761
  export async function putAlignmentCard(agentId, body, contentType = "text/yaml", opts = {}) {
667
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/alignment-card`);
762
+ // Canonical Resources × Scope × Verb surface (ADR-062 / cards-as-primitive
763
+ // W1.2b). The legacy `/v1/agents/:id/alignment-card` only 308-redirects to
764
+ // this — first-party callers target canonical directly (no legacy shapes).
765
+ const url = validateUrl(`${API_BASE}/v1/alignment/agent/${encodeURIComponent(agentId)}`);
668
766
  // Lock in a single Idempotency-Key for this logical mutation. If the auth
669
767
  // token is stale and the first attempt returns 401, fetchWithAuthRetry
670
768
  // refreshes the token and retries — but the Idempotency-Key must be the
@@ -689,11 +787,7 @@ export async function putAlignmentCard(agentId, body, contentType = "text/yaml",
689
787
  body: sanitizedBody,
690
788
  }));
691
789
  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}`);
790
+ throw await readApiError(response, "Failed to publish alignment card");
697
791
  }
698
792
  return response.json();
699
793
  }
@@ -702,7 +796,9 @@ export async function putAlignmentCard(agentId, body, contentType = "text/yaml",
702
796
  */
703
797
  export async function getProtectionCard(agentId, format = "yaml") {
704
798
  const accept = format === "yaml" ? "text/yaml" : "application/json";
705
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/protection-card`);
799
+ // Canonical surface (ADR-062 / W1.2b); legacy `/v1/agents/:id/protection-card`
800
+ // only 308-redirects here. First-party callers use canonical directly.
801
+ const url = validateUrl(`${API_BASE}/v1/protection/agent/${encodeURIComponent(agentId)}`);
706
802
  const response = await fetch(url, {
707
803
  headers: { Accept: accept, ...(await authHeaders()) },
708
804
  });
@@ -710,11 +806,7 @@ export async function getProtectionCard(agentId, format = "yaml") {
710
806
  if (response.status === 404) {
711
807
  return { body: "", contentType: "" };
712
808
  }
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}`);
809
+ throw await readApiError(response, "Failed to fetch protection card");
718
810
  }
719
811
  const ct = response.headers.get("content-type") ?? "";
720
812
  const body = await response.text();
@@ -726,7 +818,9 @@ export const PROTECTION_CARD_MAX_BYTES = 64 * 1024;
726
818
  * Publish (create or update) a protection card.
727
819
  */
728
820
  export async function putProtectionCard(agentId, body, contentType = "text/yaml", opts = {}) {
729
- const url = validateUrl(`${API_BASE}/v1/agents/${agentId}/protection-card`);
821
+ // Canonical surface (ADR-062 / W1.2b); legacy `/v1/agents/:id/protection-card`
822
+ // only 308-redirects here. First-party callers use canonical directly.
823
+ const url = validateUrl(`${API_BASE}/v1/protection/agent/${encodeURIComponent(agentId)}`);
730
824
  // See putAlignmentCard for why the Idempotency-Key is computed once outside
731
825
  // the retry closure.
732
826
  const idempotencyKey = opts.idempotencyKey ?? newIdempotencyKey();
@@ -744,14 +838,50 @@ export async function putProtectionCard(agentId, body, contentType = "text/yaml"
744
838
  body: sanitizedBody,
745
839
  }));
746
840
  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}`);
841
+ throw await readApiError(response, "Failed to publish protection card");
752
842
  }
753
843
  return response.json();
754
844
  }
845
+ /**
846
+ * Dry-run validate + compose a card body against the server's authoritative
847
+ * pipeline. Returns a structured result — a 422/400 validation failure is an
848
+ * expected `valid:false` outcome (NOT a thrown error). 401 throws (caller
849
+ * should fall back to offline validation); 403/5xx throw a MnemomApiError.
850
+ */
851
+ export async function previewComposeAgentCard(agentId, kind, body, contentType = "text/yaml") {
852
+ const url = validateUrl(`${API_BASE}/v1/${kind}/agent/${encodeURIComponent(agentId)}/preview-compose`);
853
+ const response = await fetchWithAuthRetry(url, async () => ({
854
+ method: "POST",
855
+ headers: { ...(await authHeaders()), "Content-Type": contentType, Accept: "application/json" },
856
+ body: sanitizeForHttp(body),
857
+ }));
858
+ if (response.ok) {
859
+ const data = (await response.json());
860
+ return {
861
+ valid: true,
862
+ status: response.status,
863
+ composed: data.composed,
864
+ conflicts: data.conflicts ?? [],
865
+ coherence_violations: data.coherence_violations ?? [],
866
+ };
867
+ }
868
+ // 401 → unauthenticated: signal the caller to fall back to offline validation.
869
+ if (response.status === 401) {
870
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
871
+ }
872
+ // 400 / 422 → the card is invalid: an expected validation outcome, not an
873
+ // exception. Surface the care-framed findings structurally.
874
+ if (response.status === 400 || response.status === 422) {
875
+ const parsed = await parseApiErrorBody(response);
876
+ return {
877
+ valid: false,
878
+ status: response.status,
879
+ error: { code: parsed.code, message: parsed.message, details: parsed.details },
880
+ };
881
+ }
882
+ // 403 / 5xx → a genuine error (e.g. not the owner, server fault).
883
+ throw await readApiError(response, "Card validation (preview-compose) failed");
884
+ }
755
885
  // ============================================================================
756
886
  // Agent Resolution (server-side, no local config)
757
887
  // ============================================================================
@@ -809,13 +939,12 @@ export async function listPostures(opts) {
809
939
  }));
810
940
  if (!response.ok) {
811
941
  if (response.status === 401) {
812
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
942
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
813
943
  }
814
944
  if (response.status === 403) {
815
- throw new Error(`Forbidden: not a member of org '${opts.orgId}'.`);
945
+ throw new MnemomApiError(403, `Forbidden: not a member of org '${opts.orgId}'.`);
816
946
  }
817
- const err = (await response.json().catch(() => ({ error: "unknown" })));
818
- throw new Error(err.message || `API request failed: ${response.status}`);
947
+ throw await readApiError(response, "API request failed");
819
948
  }
820
949
  const data = (await response.json());
821
950
  return data.postures ?? [];
@@ -828,14 +957,13 @@ export async function getPosture(postureId) {
828
957
  }));
829
958
  if (!response.ok) {
830
959
  if (response.status === 401) {
831
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
960
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
832
961
  }
833
962
  if (response.status === 404)
834
- throw new Error(`Posture '${postureId}' not found.`);
963
+ throw new MnemomApiError(404, `Posture '${postureId}' not found.`);
835
964
  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}`);
965
+ throw new MnemomApiError(403, `Forbidden: not visible to your account.`);
966
+ throw await readApiError(response, "API request failed");
839
967
  }
840
968
  return (await response.json());
841
969
  }
@@ -847,11 +975,10 @@ export async function listPostureRevisions(postureId) {
847
975
  }));
848
976
  if (!response.ok) {
849
977
  if (response.status === 401)
850
- throw new Error("Not authenticated.");
978
+ throw new MnemomApiError(401, "Not authenticated.");
851
979
  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}`);
980
+ throw new MnemomApiError(404, `Posture '${postureId}' not found.`);
981
+ throw await readApiError(response, "API request failed");
855
982
  }
856
983
  const data = (await response.json());
857
984
  return data.revisions ?? [];
@@ -864,13 +991,16 @@ export async function diffPostureRevisions(postureId, fromNo, toNo) {
864
991
  }));
865
992
  if (!response.ok) {
866
993
  if (response.status === 401)
867
- throw new Error("Not authenticated.");
994
+ throw new MnemomApiError(401, "Not authenticated.");
868
995
  if (response.status === 404) {
869
- const errBody = (await response.json().catch(() => ({})));
870
- throw new Error(errBody.message || `Posture or revision not found.`);
996
+ const errBody = await parseApiErrorBody(response);
997
+ throw new MnemomApiError(404, errBody.message || `Posture or revision not found.`, {
998
+ code: errBody.code,
999
+ details: errBody.details,
1000
+ specDeviation: errBody.spec_deviation,
1001
+ });
871
1002
  }
872
- const err = (await response.json().catch(() => ({ error: "unknown" })));
873
- throw new Error(err.message || `Diff failed: ${response.status}`);
1003
+ throw await readApiError(response, "Diff failed");
874
1004
  }
875
1005
  return (await response.json());
876
1006
  }
@@ -889,16 +1019,15 @@ export async function createPosture(input) {
889
1019
  }));
890
1020
  if (!response.ok) {
891
1021
  if (response.status === 401)
892
- throw new Error("Not authenticated.");
1022
+ throw new MnemomApiError(401, "Not authenticated.");
893
1023
  if (response.status === 403) {
894
- throw new Error(`Forbidden: org owner/admin role required (or scope=platform reserved for migrations).`);
1024
+ throw new MnemomApiError(403, `Forbidden: org owner/admin role required (or scope=platform reserved for migrations).`);
895
1025
  }
896
1026
  if (response.status === 409)
897
- throw new Error(`A posture with this slug already exists in the org.`);
1027
+ throw new MnemomApiError(409, `A posture with this slug already exists in the org.`);
898
1028
  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}`);
1029
+ throw new MnemomApiError(413, `Posture body too large (server limit: 256 KiB).`);
1030
+ throw await readApiError(response, "Create failed");
902
1031
  }
903
1032
  return (await response.json());
904
1033
  }
@@ -917,16 +1046,15 @@ export async function updatePosture(postureId, input) {
917
1046
  }));
918
1047
  if (!response.ok) {
919
1048
  if (response.status === 401)
920
- throw new Error("Not authenticated.");
1049
+ throw new MnemomApiError(401, "Not authenticated.");
921
1050
  if (response.status === 403) {
922
- throw new Error(`Forbidden: org owner/admin required, or this is a Mnemom-shipped platform default (immutable — clone first).`);
1051
+ throw new MnemomApiError(403, `Forbidden: org owner/admin required, or this is a Mnemom-shipped platform default (immutable — clone first).`);
923
1052
  }
924
1053
  if (response.status === 404)
925
- throw new Error(`Posture '${postureId}' not found.`);
1054
+ throw new MnemomApiError(404, `Posture '${postureId}' not found.`);
926
1055
  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}`);
1056
+ throw new MnemomApiError(413, `Posture body too large (server limit: 256 KiB).`);
1057
+ throw await readApiError(response, "Update failed");
930
1058
  }
931
1059
  return (await response.json());
932
1060
  }
@@ -945,15 +1073,14 @@ export async function clonePosture(postureId, input) {
945
1073
  }));
946
1074
  if (!response.ok) {
947
1075
  if (response.status === 401)
948
- throw new Error("Not authenticated.");
1076
+ throw new MnemomApiError(401, "Not authenticated.");
949
1077
  if (response.status === 403)
950
- throw new Error(`Forbidden: org owner/admin required on target org.`);
1078
+ throw new MnemomApiError(403, `Forbidden: org owner/admin required on target org.`);
951
1079
  if (response.status === 404)
952
- throw new Error(`Source posture '${postureId}' not found.`);
1080
+ throw new MnemomApiError(404, `Source posture '${postureId}' not found.`);
953
1081
  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}`);
1082
+ throw new MnemomApiError(409, `A posture with this slug already exists in the target org.`);
1083
+ throw await readApiError(response, "Clone failed");
957
1084
  }
958
1085
  return (await response.json());
959
1086
  }
@@ -970,16 +1097,15 @@ export async function deletePosture(postureId) {
970
1097
  }));
971
1098
  if (!response.ok) {
972
1099
  if (response.status === 401)
973
- throw new Error("Not authenticated.");
1100
+ throw new MnemomApiError(401, "Not authenticated.");
974
1101
  if (response.status === 403)
975
- throw new Error(`Forbidden: platform defaults are immutable.`);
1102
+ throw new MnemomApiError(403, `Forbidden: platform defaults are immutable.`);
976
1103
  if (response.status === 404)
977
- throw new Error(`Posture '${postureId}' not found.`);
1104
+ throw new MnemomApiError(404, `Posture '${postureId}' not found.`);
978
1105
  if (response.status === 409) {
979
- throw new Error(`Cannot delete a posture that is currently assigned to one or more teams. Unassign first.`);
1106
+ throw new MnemomApiError(409, `Cannot delete a posture that is currently assigned to one or more teams. Unassign first.`);
980
1107
  }
981
- const err = (await response.json().catch(() => ({ error: "unknown" })));
982
- throw new Error(err.message || `Delete failed: ${response.status}`);
1108
+ throw await readApiError(response, "Delete failed");
983
1109
  }
984
1110
  }
985
1111
  /** POST /v1/postures/:id/assign — assign to a team. */
@@ -997,15 +1123,14 @@ export async function assignPosture(postureId, teamId, pinRevisionNo) {
997
1123
  }));
998
1124
  if (!response.ok) {
999
1125
  if (response.status === 401)
1000
- throw new Error("Not authenticated.");
1126
+ throw new MnemomApiError(401, "Not authenticated.");
1001
1127
  if (response.status === 403)
1002
- throw new Error(`Forbidden: org owner/admin required on team's org.`);
1128
+ throw new MnemomApiError(403, `Forbidden: org owner/admin required on team's org.`);
1003
1129
  if (response.status === 404) {
1004
- const errBody = (await response.json().catch(() => ({})));
1005
- throw new Error(errBody.message || `Posture, team, or pinned revision not found.`);
1130
+ const errBody = await parseApiErrorBody(response);
1131
+ throw new MnemomApiError(404, errBody.message || `Posture, team, or pinned revision not found.`, { code: errBody.code, details: errBody.details, specDeviation: errBody.spec_deviation });
1006
1132
  }
1007
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1008
- throw new Error(err.message || `Assign failed: ${response.status}`);
1133
+ throw await readApiError(response, "Assign failed");
1009
1134
  }
1010
1135
  return (await response.json());
1011
1136
  }
@@ -1022,14 +1147,13 @@ export async function unassignPosture(postureId, teamId) {
1022
1147
  }));
1023
1148
  if (!response.ok) {
1024
1149
  if (response.status === 401)
1025
- throw new Error("Not authenticated.");
1150
+ throw new MnemomApiError(401, "Not authenticated.");
1026
1151
  if (response.status === 403)
1027
- throw new Error(`Forbidden: org owner/admin required.`);
1152
+ throw new MnemomApiError(403, `Forbidden: org owner/admin required.`);
1028
1153
  if (response.status === 404) {
1029
- throw new Error(`No matching assignment to remove (posture '${postureId}' is not assigned to team '${teamId}').`);
1154
+ throw new MnemomApiError(404, `No matching assignment to remove (posture '${postureId}' is not assigned to team '${teamId}').`);
1030
1155
  }
1031
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1032
- throw new Error(err.message || `Unassign failed: ${response.status}`);
1156
+ throw await readApiError(response, "Unassign failed");
1033
1157
  }
1034
1158
  }
1035
1159
  /** POST /v1/postures/:id/preview-compose — preview effective for a team. */
@@ -1046,13 +1170,16 @@ export async function previewComposePosture(postureId, teamId) {
1046
1170
  }));
1047
1171
  if (!response.ok) {
1048
1172
  if (response.status === 401)
1049
- throw new Error("Not authenticated.");
1173
+ throw new MnemomApiError(401, "Not authenticated.");
1050
1174
  if (response.status === 404) {
1051
- const errBody = (await response.json().catch(() => ({})));
1052
- throw new Error(errBody.message || `Posture or team not found.`);
1175
+ const errBody = await parseApiErrorBody(response);
1176
+ throw new MnemomApiError(404, errBody.message || `Posture or team not found.`, {
1177
+ code: errBody.code,
1178
+ details: errBody.details,
1179
+ specDeviation: errBody.spec_deviation,
1180
+ });
1053
1181
  }
1054
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1055
- throw new Error(err.message || `Preview-compose failed: ${response.status}`);
1182
+ throw await readApiError(response, "Preview-compose failed");
1056
1183
  }
1057
1184
  return (await response.json());
1058
1185
  }
@@ -1076,13 +1203,12 @@ export async function listSidebandAdvisoriesForAgent(agentId, opts = {}) {
1076
1203
  }));
1077
1204
  if (!response.ok) {
1078
1205
  if (response.status === 401)
1079
- throw new Error("Not authenticated.");
1206
+ throw new MnemomApiError(401, "Not authenticated.");
1080
1207
  if (response.status === 403)
1081
- throw new Error("Permission denied (need org membership).");
1208
+ throw new MnemomApiError(403, "Permission denied (need org membership).");
1082
1209
  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}`);
1210
+ throw new MnemomApiError(404, `Agent '${agentId}' not found.`);
1211
+ throw await readApiError(response, "List advisories failed");
1086
1212
  }
1087
1213
  return (await response.json());
1088
1214
  }
@@ -1106,13 +1232,12 @@ export async function listSidebandAdvisoriesForTeam(teamId, opts = {}) {
1106
1232
  }));
1107
1233
  if (!response.ok) {
1108
1234
  if (response.status === 401)
1109
- throw new Error("Not authenticated.");
1235
+ throw new MnemomApiError(401, "Not authenticated.");
1110
1236
  if (response.status === 403)
1111
- throw new Error("Permission denied (need org membership).");
1237
+ throw new MnemomApiError(403, "Permission denied (need org membership).");
1112
1238
  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}`);
1239
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
1240
+ throw await readApiError(response, "List advisories failed");
1116
1241
  }
1117
1242
  return (await response.json());
1118
1243
  }
@@ -1131,13 +1256,12 @@ export async function getTeamSidebandCoverage(teamId) {
1131
1256
  }));
1132
1257
  if (!response.ok) {
1133
1258
  if (response.status === 401)
1134
- throw new Error("Not authenticated.");
1259
+ throw new MnemomApiError(401, "Not authenticated.");
1135
1260
  if (response.status === 403)
1136
- throw new Error("Permission denied (need org membership).");
1261
+ throw new MnemomApiError(403, "Permission denied (need org membership).");
1137
1262
  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}`);
1263
+ throw new MnemomApiError(404, `Team '${teamId}' not found.`);
1264
+ throw await readApiError(response, "Coverage fetch failed");
1141
1265
  }
1142
1266
  return (await response.json());
1143
1267
  }
@@ -1154,11 +1278,10 @@ export async function getSafeHouseHarnessState() {
1154
1278
  }));
1155
1279
  if (!response.ok) {
1156
1280
  if (response.status === 401)
1157
- throw new Error("Not authenticated. Run `mnemom login`.");
1281
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login`.");
1158
1282
  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}`);
1283
+ throw new MnemomApiError(403, "Permission denied — `mnemom validate safe-house` is mnemom-staff only.");
1284
+ throw await readApiError(response, "Harness state fetch failed");
1162
1285
  }
1163
1286
  return (await response.json());
1164
1287
  }
@@ -1195,13 +1318,12 @@ async function gFetch(path, init = {}, notFoundLabel) {
1195
1318
  }));
1196
1319
  if (!response.ok) {
1197
1320
  if (response.status === 401)
1198
- throw new Error("Not authenticated.");
1321
+ throw new MnemomApiError(401, "Not authenticated.");
1199
1322
  if (response.status === 403)
1200
- throw new Error("Permission denied (need org admin / membership).");
1323
+ throw new MnemomApiError(403, "Permission denied (need org admin / membership).");
1201
1324
  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}`);
1325
+ throw new MnemomApiError(404, `${notFoundLabel} not found.`);
1326
+ throw await readApiError(response, "Request failed");
1205
1327
  }
1206
1328
  return (await response.json());
1207
1329
  }
@@ -1286,10 +1408,9 @@ export async function listApiKeys() {
1286
1408
  }));
1287
1409
  if (!response.ok) {
1288
1410
  if (response.status === 401) {
1289
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1411
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1290
1412
  }
1291
- const err = (await response.json().catch(() => ({ error: "unknown" })));
1292
- throw new Error(err.message || `Failed to list api keys: ${response.status}`);
1413
+ throw await readApiError(response, "Failed to list api keys");
1293
1414
  }
1294
1415
  const data = (await response.json());
1295
1416
  return data.keys ?? [];
@@ -1310,16 +1431,17 @@ export async function createApiKey(name, scopes) {
1310
1431
  }));
1311
1432
  if (!response.ok) {
1312
1433
  if (response.status === 401) {
1313
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1434
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1314
1435
  }
1315
- const body = await response.text().catch(() => "");
1436
+ const parsed = await parseApiErrorBody(response);
1437
+ const detail = parsed.message || `HTTP ${response.status}`;
1316
1438
  if (response.status === 403) {
1317
- throw new Error(`Mint-time ceiling rejected scope: ${body}`);
1439
+ throw new MnemomApiError(403, `Mint-time ceiling rejected scope: ${detail}`);
1318
1440
  }
1319
1441
  if (response.status === 400) {
1320
- throw new Error(`Invalid scope(s): ${body}`);
1442
+ throw new MnemomApiError(400, `Invalid scope(s): ${detail}`);
1321
1443
  }
1322
- throw new Error(`Failed to create api key: ${response.status} ${body}`);
1444
+ throw new MnemomApiError(response.status, `Failed to create api key: ${response.status} ${detail}`);
1323
1445
  }
1324
1446
  return (await response.json());
1325
1447
  }
@@ -1337,12 +1459,12 @@ export async function rotateApiKey(keyId) {
1337
1459
  }));
1338
1460
  if (!response.ok) {
1339
1461
  if (response.status === 401) {
1340
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1462
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1341
1463
  }
1342
1464
  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}`);
1465
+ throw new MnemomApiError(404, `Key not found: ${keyId}`);
1466
+ const parsed = await parseApiErrorBody(response);
1467
+ throw new MnemomApiError(response.status, `Failed to rotate api key: ${response.status} ${parsed.message ?? ""}`.trim());
1346
1468
  }
1347
1469
  return (await response.json());
1348
1470
  }
@@ -1358,14 +1480,48 @@ export async function revokeApiKey(keyId) {
1358
1480
  }));
1359
1481
  if (!response.ok) {
1360
1482
  if (response.status === 401) {
1361
- throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1483
+ throw new MnemomApiError(401, "Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
1362
1484
  }
1363
1485
  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}`);
1486
+ throw new MnemomApiError(404, `Key not found: ${keyId}`);
1487
+ const parsed = await parseApiErrorBody(response);
1488
+ throw new MnemomApiError(response.status, `Failed to revoke api key: ${response.status} ${parsed.message ?? ""}`.trim());
1367
1489
  }
1368
1490
  }
1491
+ /**
1492
+ * POST /v1/license/validate — validate a license JWT for an instance.
1493
+ *
1494
+ * UNAUTHENTICATED by design (the license JWT IS the credential; this is a
1495
+ * pre-login path) — sends NO auth header. Source-verified at canonical ref
1496
+ * c18491e6 (handleLicenseValidate): the request body keys are
1497
+ * `{ license, instance_id, instance_metadata }` (NOT `jwt`) — sending `jwt`
1498
+ * would 400 "license is required".
1499
+ *
1500
+ * Returns the parsed validation result on 2xx (including the grace-period
1501
+ * `valid:false` 200 body); throws MnemomApiError on non-2xx so the command
1502
+ * renders `.message`/`.status`. This fixes the live H1 "[object Object]" bug:
1503
+ * the old command-layer `err.error || "unknown"` string-coerced the nested
1504
+ * `{code,message}` object that the enforce hook puts on the wire.
1505
+ *
1506
+ * `license deactivate` may reuse this (best-effort, fire-and-forget) by
1507
+ * catching the throw at the command layer.
1508
+ */
1509
+ export async function validateLicense(jwt, instanceId, instanceMetadata = {}) {
1510
+ const url = validateUrl(`${API_BASE}/v1/license/validate`);
1511
+ const response = await fetch(url, {
1512
+ method: "POST",
1513
+ headers: { "Content-Type": "application/json" },
1514
+ body: JSON.stringify({
1515
+ license: sanitizeForHttp(jwt),
1516
+ instance_id: instanceId,
1517
+ instance_metadata: instanceMetadata,
1518
+ }),
1519
+ });
1520
+ if (!response.ok) {
1521
+ throw await readApiError(response, "License validation failed");
1522
+ }
1523
+ return (await response.json());
1524
+ }
1369
1525
  export async function reportRecipeFnFp(recipeId, input) {
1370
1526
  return postApi(`/v1/recipes/${encodeURIComponent(recipeId)}/report`, input);
1371
1527
  }