@mnemom/mnemom 0.9.1 → 0.11.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/commands/advisories.d.ts +32 -0
- package/dist/commands/advisories.js +158 -0
- package/dist/commands/api-key.d.ts +37 -0
- package/dist/commands/api-key.js +181 -0
- package/dist/commands/governance.d.ts +85 -0
- package/dist/commands/governance.js +331 -0
- package/dist/commands/org.d.ts +23 -0
- package/dist/commands/org.js +122 -0
- package/dist/commands/posture.d.ts +71 -0
- package/dist/commands/posture.js +440 -0
- package/dist/commands/team.d.ts +56 -0
- package/dist/commands/team.js +507 -0
- package/dist/commands/validate.d.ts +23 -0
- package/dist/commands/validate.js +150 -0
- package/dist/index.js +713 -14
- package/dist/lib/api.d.ts +576 -0
- package/dist/lib/api.js +884 -5
- package/dist/lib/format.d.ts +4 -0
- package/dist/lib/format.js +6 -0
- package/package.json +1 -1
package/dist/lib/api.js
CHANGED
|
@@ -52,6 +52,7 @@ export async function postApi(endpoint, body, opts = {}) {
|
|
|
52
52
|
headers["X-Mnemom-Api-Key"] = cred.key;
|
|
53
53
|
}
|
|
54
54
|
const response = await fetch(url, {
|
|
55
|
+
// lgtm[js/file-data-url]
|
|
55
56
|
method: "POST",
|
|
56
57
|
headers,
|
|
57
58
|
body: JSON.stringify(body),
|
|
@@ -127,12 +128,313 @@ export async function listAgents() {
|
|
|
127
128
|
if (response.status === 401) {
|
|
128
129
|
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
129
130
|
}
|
|
130
|
-
const err = await response.json().catch(() => ({ error: "unknown" }));
|
|
131
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
131
132
|
throw new Error(err.message || `Failed to list agents: ${response.status}`);
|
|
132
133
|
}
|
|
133
|
-
const data = await response.json();
|
|
134
|
+
const data = (await response.json());
|
|
134
135
|
return data.agents ?? [];
|
|
135
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* GET /v1/orgs — list every org the user is a member of, including the
|
|
139
|
+
* personal-org-of-one (per ADR-044 Option A, GitHub model). Personal sorts
|
|
140
|
+
* first; multi-user orgs follow.
|
|
141
|
+
*/
|
|
142
|
+
export async function listMyOrgs() {
|
|
143
|
+
const url = validateUrl(`${API_BASE}/v1/orgs`);
|
|
144
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
145
|
+
headers: await authHeaders(),
|
|
146
|
+
}));
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
if (response.status === 401) {
|
|
149
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
150
|
+
}
|
|
151
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
152
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
153
|
+
}
|
|
154
|
+
const data = (await response.json());
|
|
155
|
+
return data.orgs ?? [];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* GET /v1/auth/me/personal-org — accessor for the user's personal org.
|
|
159
|
+
* Idempotent: lazily provisions for legacy accounts that pre-date the
|
|
160
|
+
* mnemom-api migration 161 backfill.
|
|
161
|
+
*/
|
|
162
|
+
export async function getMyPersonalOrg() {
|
|
163
|
+
const url = validateUrl(`${API_BASE}/v1/auth/me/personal-org`);
|
|
164
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
165
|
+
headers: await authHeaders(),
|
|
166
|
+
}));
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
if (response.status === 401) {
|
|
169
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
170
|
+
}
|
|
171
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
172
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
173
|
+
}
|
|
174
|
+
return (await response.json());
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* GET /v1/orgs/:org_id/teams for every org the user is a member of,
|
|
178
|
+
* concatenated. The CLI consumer that just wants "every team I can see"
|
|
179
|
+
* doesn't need to know about org boundaries; team_id is unique anyway.
|
|
180
|
+
* Returns the flat list with org_name attached for display.
|
|
181
|
+
*/
|
|
182
|
+
export async function listMyTeams() {
|
|
183
|
+
const orgs = await listMyOrgs();
|
|
184
|
+
const teams = [];
|
|
185
|
+
for (const org of orgs) {
|
|
186
|
+
const url = validateUrl(`${API_BASE}/v1/orgs/${org.org_id}/teams`);
|
|
187
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
188
|
+
headers: await authHeaders(),
|
|
189
|
+
}));
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
// Skip orgs the user can't read teams from (e.g., role doesn't permit).
|
|
192
|
+
// The list call should succeed at the member level, but tolerate edge cases.
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const data = (await response.json());
|
|
196
|
+
for (const t of data.teams ?? []) {
|
|
197
|
+
teams.push({ ...t, org_id: t.org_id ?? org.org_id, org_name: org.name });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return teams;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* GET /v1/teams/:team_id — fetch a single team's row. Returns the team
|
|
204
|
+
* detail including org_id (which the CLI uses to disambiguate team_id
|
|
205
|
+
* collisions across orgs in error messages).
|
|
206
|
+
*/
|
|
207
|
+
export async function getTeam(teamId) {
|
|
208
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${teamId}`);
|
|
209
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
210
|
+
headers: await authHeaders(),
|
|
211
|
+
}));
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
if (response.status === 401) {
|
|
214
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
215
|
+
}
|
|
216
|
+
if (response.status === 404) {
|
|
217
|
+
throw new Error(`Team '${teamId}' not found or not accessible.`);
|
|
218
|
+
}
|
|
219
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
220
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
221
|
+
}
|
|
222
|
+
return (await response.json());
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* GET /v1/teams/:team_id/(alignment|protection)-template — read the
|
|
226
|
+
* current team-scope template + its enabled flag. Returns the wire body
|
|
227
|
+
* verbatim. Empty for teams that have no template set yet (template:
|
|
228
|
+
* null, enabled: false).
|
|
229
|
+
*/
|
|
230
|
+
export async function getTeamTemplate(teamId, kind) {
|
|
231
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${teamId}/${kind}-template`);
|
|
232
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
233
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
234
|
+
}));
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
if (response.status === 401) {
|
|
237
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
238
|
+
}
|
|
239
|
+
if (response.status === 403) {
|
|
240
|
+
throw new Error(`Forbidden: not a member of team '${teamId}'s org.`);
|
|
241
|
+
}
|
|
242
|
+
if (response.status === 404) {
|
|
243
|
+
throw new Error(`Team '${teamId}' not found.`);
|
|
244
|
+
}
|
|
245
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
246
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
247
|
+
}
|
|
248
|
+
return (await response.json());
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* PUT /v1/teams/:team_id/(alignment|protection)-template — write the
|
|
252
|
+
* team-scope template. Body is YAML or JSON template content; the API
|
|
253
|
+
* accepts either via Content-Type. Idempotency-Key is required (per the
|
|
254
|
+
* idempotency-with-body-hash discipline in the API).
|
|
255
|
+
*
|
|
256
|
+
* On success returns the post-write team template body, including
|
|
257
|
+
* agents_flagged_for_recompose so the caller can surface the fan-out
|
|
258
|
+
* count to the user.
|
|
259
|
+
*/
|
|
260
|
+
export async function putTeamTemplate(teamId, kind, yamlBody) {
|
|
261
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${teamId}/${kind}-template`);
|
|
262
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
263
|
+
method: "PUT",
|
|
264
|
+
headers: {
|
|
265
|
+
...(await authHeaders()),
|
|
266
|
+
"Content-Type": "text/yaml",
|
|
267
|
+
Accept: "application/json",
|
|
268
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
269
|
+
},
|
|
270
|
+
body: yamlBody,
|
|
271
|
+
}));
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
if (response.status === 401) {
|
|
274
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
275
|
+
}
|
|
276
|
+
if (response.status === 403) {
|
|
277
|
+
throw new Error(`Forbidden: org admin or owner role required to write a team template.`);
|
|
278
|
+
}
|
|
279
|
+
if (response.status === 404) {
|
|
280
|
+
throw new Error(`Team '${teamId}' not found.`);
|
|
281
|
+
}
|
|
282
|
+
if (response.status === 413) {
|
|
283
|
+
throw new Error(`Template too large (server limit: 128 KiB alignment / 64 KiB protection).`);
|
|
284
|
+
}
|
|
285
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
286
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
287
|
+
}
|
|
288
|
+
return (await response.json());
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* DELETE /v1/teams/:team_id/(alignment|protection)-template — clear the
|
|
292
|
+
* template. Idempotent (deleting an already-cleared template is a 200).
|
|
293
|
+
* Returns the post-delete team template body with deleted=true and the
|
|
294
|
+
* recompose fan-out count.
|
|
295
|
+
*/
|
|
296
|
+
export async function deleteTeamTemplate(teamId, kind) {
|
|
297
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${teamId}/${kind}-template`);
|
|
298
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
299
|
+
method: "DELETE",
|
|
300
|
+
headers: {
|
|
301
|
+
...(await authHeaders()),
|
|
302
|
+
Accept: "application/json",
|
|
303
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
304
|
+
},
|
|
305
|
+
}));
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
if (response.status === 401) {
|
|
308
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
309
|
+
}
|
|
310
|
+
if (response.status === 403) {
|
|
311
|
+
throw new Error(`Forbidden: org admin or owner role required to clear a team template.`);
|
|
312
|
+
}
|
|
313
|
+
if (response.status === 404) {
|
|
314
|
+
throw new Error(`Team '${teamId}' not found.`);
|
|
315
|
+
}
|
|
316
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
317
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
318
|
+
}
|
|
319
|
+
return (await response.json());
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* POST /v1/teams/:team_id/(alignment|protection)-template/preview-compose
|
|
323
|
+
* — dry-run the composer with the supplied draft against the team's
|
|
324
|
+
* current org+platform context. Returns the composed canonical output
|
|
325
|
+
* plus per-field conflicts where the draft was tightened by the
|
|
326
|
+
* org/platform floor. No DB writes.
|
|
327
|
+
*/
|
|
328
|
+
export async function previewComposeTeamTemplate(teamId, kind, yamlBody) {
|
|
329
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${teamId}/${kind}-template/preview-compose`);
|
|
330
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: {
|
|
333
|
+
...(await authHeaders()),
|
|
334
|
+
"Content-Type": "text/yaml",
|
|
335
|
+
Accept: "application/json",
|
|
336
|
+
},
|
|
337
|
+
body: yamlBody,
|
|
338
|
+
}));
|
|
339
|
+
if (!response.ok) {
|
|
340
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
341
|
+
throw new Error(err.message || `Preview failed: ${response.status}`);
|
|
342
|
+
}
|
|
343
|
+
const body = (await response.json());
|
|
344
|
+
return { composed: body.composed, conflicts: body.conflicts };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* POST /v1/teams/:team_id/admins — grant team_admin role on a team.
|
|
348
|
+
*
|
|
349
|
+
* Per ADR-046 §3: callable by org_owner / org_admin only. The target
|
|
350
|
+
* user must already be a member of the team's org; cross-org grants 403.
|
|
351
|
+
* Idempotent — re-granting an already-active grant returns 200 with
|
|
352
|
+
* `idempotent_noop: true`.
|
|
353
|
+
*/
|
|
354
|
+
export async function grantTeamAdmin(teamId, userId) {
|
|
355
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${teamId}/admins`);
|
|
356
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: {
|
|
359
|
+
...(await authHeaders()),
|
|
360
|
+
"Content-Type": "application/json",
|
|
361
|
+
Accept: "application/json",
|
|
362
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
363
|
+
},
|
|
364
|
+
body: JSON.stringify({ user_id: sanitizeForHttp(userId) }),
|
|
365
|
+
}));
|
|
366
|
+
if (!response.ok) {
|
|
367
|
+
if (response.status === 401) {
|
|
368
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
369
|
+
}
|
|
370
|
+
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.`);
|
|
372
|
+
}
|
|
373
|
+
if (response.status === 404) {
|
|
374
|
+
throw new Error(`Team '${teamId}' not found.`);
|
|
375
|
+
}
|
|
376
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
377
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
378
|
+
}
|
|
379
|
+
return (await response.json());
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* DELETE /v1/teams/:team_id/admins/:user_id — revoke a team_admin grant.
|
|
383
|
+
*
|
|
384
|
+
* Per ADR-046 §3: callable by org_owner / org_admin OR self-revoke (the
|
|
385
|
+
* team_admin revokes their own grant — the only self-mutation). Idempotent
|
|
386
|
+
* — revoking an absent or already-revoked grant returns 200 with
|
|
387
|
+
* `idempotent_noop: true` and no audit row.
|
|
388
|
+
*/
|
|
389
|
+
export async function revokeTeamAdmin(teamId, userId) {
|
|
390
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${teamId}/admins/${userId}`);
|
|
391
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
392
|
+
method: "DELETE",
|
|
393
|
+
headers: {
|
|
394
|
+
...(await authHeaders()),
|
|
395
|
+
Accept: "application/json",
|
|
396
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
397
|
+
},
|
|
398
|
+
}));
|
|
399
|
+
if (!response.ok) {
|
|
400
|
+
if (response.status === 401) {
|
|
401
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
402
|
+
}
|
|
403
|
+
if (response.status === 403) {
|
|
404
|
+
throw new Error(`Forbidden: revoke requires org owner/admin, or self-revoke with an active grant.`);
|
|
405
|
+
}
|
|
406
|
+
if (response.status === 404) {
|
|
407
|
+
throw new Error(`Team '${teamId}' not found.`);
|
|
408
|
+
}
|
|
409
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
410
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
411
|
+
}
|
|
412
|
+
return (await response.json());
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* GET /v1/teams/:team_id/admins — list active team_admin grants on a team.
|
|
416
|
+
* Any member of the team's org can read.
|
|
417
|
+
*/
|
|
418
|
+
export async function listTeamAdmins(teamId) {
|
|
419
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${teamId}/admins`);
|
|
420
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
421
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
422
|
+
}));
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
if (response.status === 401) {
|
|
425
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
426
|
+
}
|
|
427
|
+
if (response.status === 403) {
|
|
428
|
+
throw new Error(`Forbidden: requires membership in the team's org.`);
|
|
429
|
+
}
|
|
430
|
+
if (response.status === 404) {
|
|
431
|
+
throw new Error(`Team '${teamId}' not found.`);
|
|
432
|
+
}
|
|
433
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
434
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
435
|
+
}
|
|
436
|
+
return (await response.json());
|
|
437
|
+
}
|
|
136
438
|
/**
|
|
137
439
|
* Look up an agent in the authenticated user's account by name.
|
|
138
440
|
* Tries exact match first, then single partial match.
|
|
@@ -143,14 +445,14 @@ export async function listAgents() {
|
|
|
143
445
|
export async function getAgentByName(name) {
|
|
144
446
|
const agents = await listAgents();
|
|
145
447
|
const lower = name.toLowerCase();
|
|
146
|
-
const exact = agents.find(a => a.name?.toLowerCase() === lower);
|
|
448
|
+
const exact = agents.find((a) => a.name?.toLowerCase() === lower);
|
|
147
449
|
if (exact)
|
|
148
450
|
return exact;
|
|
149
|
-
const partials = agents.filter(a => a.name?.toLowerCase().includes(lower));
|
|
451
|
+
const partials = agents.filter((a) => a.name?.toLowerCase().includes(lower));
|
|
150
452
|
if (partials.length === 1)
|
|
151
453
|
return partials[0];
|
|
152
454
|
if (partials.length > 1) {
|
|
153
|
-
const names = partials.map(a => a.name ?? a.id).join(", ");
|
|
455
|
+
const names = partials.map((a) => a.name ?? a.id).join(", ");
|
|
154
456
|
throw new Error(`Multiple agents match '${name}': ${names}. Use a more specific name.`);
|
|
155
457
|
}
|
|
156
458
|
return null;
|
|
@@ -491,3 +793,580 @@ export async function resolveAgentId(agentName) {
|
|
|
491
793
|
console.error("List your agents with: mnemom agents\n");
|
|
492
794
|
process.exit(1);
|
|
493
795
|
}
|
|
796
|
+
/** GET /v1/postures — list visible postures. */
|
|
797
|
+
export async function listPostures(opts) {
|
|
798
|
+
const params = new URLSearchParams();
|
|
799
|
+
if (opts.orgId)
|
|
800
|
+
params.set("org_id", opts.orgId);
|
|
801
|
+
if (opts.includePlatform === false)
|
|
802
|
+
params.set("include_platform", "false");
|
|
803
|
+
if (opts.includeDeleted)
|
|
804
|
+
params.set("include_deleted", "true");
|
|
805
|
+
const qs = params.toString();
|
|
806
|
+
const url = validateUrl(`${API_BASE}/v1/postures${qs ? `?${qs}` : ""}`);
|
|
807
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
808
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
809
|
+
}));
|
|
810
|
+
if (!response.ok) {
|
|
811
|
+
if (response.status === 401) {
|
|
812
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
813
|
+
}
|
|
814
|
+
if (response.status === 403) {
|
|
815
|
+
throw new Error(`Forbidden: not a member of org '${opts.orgId}'.`);
|
|
816
|
+
}
|
|
817
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
818
|
+
throw new Error(err.message || `API request failed: ${response.status}`);
|
|
819
|
+
}
|
|
820
|
+
const data = (await response.json());
|
|
821
|
+
return data.postures ?? [];
|
|
822
|
+
}
|
|
823
|
+
/** GET /v1/postures/:id — read posture with current revision body. */
|
|
824
|
+
export async function getPosture(postureId) {
|
|
825
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}`);
|
|
826
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
827
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
828
|
+
}));
|
|
829
|
+
if (!response.ok) {
|
|
830
|
+
if (response.status === 401) {
|
|
831
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
832
|
+
}
|
|
833
|
+
if (response.status === 404)
|
|
834
|
+
throw new Error(`Posture '${postureId}' not found.`);
|
|
835
|
+
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}`);
|
|
839
|
+
}
|
|
840
|
+
return (await response.json());
|
|
841
|
+
}
|
|
842
|
+
/** GET /v1/postures/:id/revisions — list revisions, newest first. */
|
|
843
|
+
export async function listPostureRevisions(postureId) {
|
|
844
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}/revisions`);
|
|
845
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
846
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
847
|
+
}));
|
|
848
|
+
if (!response.ok) {
|
|
849
|
+
if (response.status === 401)
|
|
850
|
+
throw new Error("Not authenticated.");
|
|
851
|
+
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}`);
|
|
855
|
+
}
|
|
856
|
+
const data = (await response.json());
|
|
857
|
+
return data.revisions ?? [];
|
|
858
|
+
}
|
|
859
|
+
/** GET /v1/postures/:id/diff?from=N&to=M — structural diff. */
|
|
860
|
+
export async function diffPostureRevisions(postureId, fromNo, toNo) {
|
|
861
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}/diff?from=${fromNo}&to=${toNo}`);
|
|
862
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
863
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
864
|
+
}));
|
|
865
|
+
if (!response.ok) {
|
|
866
|
+
if (response.status === 401)
|
|
867
|
+
throw new Error("Not authenticated.");
|
|
868
|
+
if (response.status === 404) {
|
|
869
|
+
const errBody = (await response.json().catch(() => ({})));
|
|
870
|
+
throw new Error(errBody.message || `Posture or revision not found.`);
|
|
871
|
+
}
|
|
872
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
873
|
+
throw new Error(err.message || `Diff failed: ${response.status}`);
|
|
874
|
+
}
|
|
875
|
+
return (await response.json());
|
|
876
|
+
}
|
|
877
|
+
/** POST /v1/postures — create new posture (org-scope only via REST). */
|
|
878
|
+
export async function createPosture(input) {
|
|
879
|
+
const url = validateUrl(`${API_BASE}/v1/postures`);
|
|
880
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
881
|
+
method: "POST",
|
|
882
|
+
headers: {
|
|
883
|
+
...(await authHeaders()),
|
|
884
|
+
"Content-Type": "application/json",
|
|
885
|
+
Accept: "application/json",
|
|
886
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
887
|
+
},
|
|
888
|
+
body: JSON.stringify(input),
|
|
889
|
+
}));
|
|
890
|
+
if (!response.ok) {
|
|
891
|
+
if (response.status === 401)
|
|
892
|
+
throw new Error("Not authenticated.");
|
|
893
|
+
if (response.status === 403) {
|
|
894
|
+
throw new Error(`Forbidden: org owner/admin role required (or scope=platform reserved for migrations).`);
|
|
895
|
+
}
|
|
896
|
+
if (response.status === 409)
|
|
897
|
+
throw new Error(`A posture with this slug already exists in the org.`);
|
|
898
|
+
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}`);
|
|
902
|
+
}
|
|
903
|
+
return (await response.json());
|
|
904
|
+
}
|
|
905
|
+
/** PUT /v1/postures/:id — write a new revision (forward-only). */
|
|
906
|
+
export async function updatePosture(postureId, input) {
|
|
907
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}`);
|
|
908
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
909
|
+
method: "PUT",
|
|
910
|
+
headers: {
|
|
911
|
+
...(await authHeaders()),
|
|
912
|
+
"Content-Type": "application/json",
|
|
913
|
+
Accept: "application/json",
|
|
914
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
915
|
+
},
|
|
916
|
+
body: JSON.stringify(input),
|
|
917
|
+
}));
|
|
918
|
+
if (!response.ok) {
|
|
919
|
+
if (response.status === 401)
|
|
920
|
+
throw new Error("Not authenticated.");
|
|
921
|
+
if (response.status === 403) {
|
|
922
|
+
throw new Error(`Forbidden: org owner/admin required, or this is a Mnemom-shipped platform default (immutable — clone first).`);
|
|
923
|
+
}
|
|
924
|
+
if (response.status === 404)
|
|
925
|
+
throw new Error(`Posture '${postureId}' not found.`);
|
|
926
|
+
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}`);
|
|
930
|
+
}
|
|
931
|
+
return (await response.json());
|
|
932
|
+
}
|
|
933
|
+
/** POST /v1/postures/:id/clone — clone source to org-scope. */
|
|
934
|
+
export async function clonePosture(postureId, input) {
|
|
935
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}/clone`);
|
|
936
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
937
|
+
method: "POST",
|
|
938
|
+
headers: {
|
|
939
|
+
...(await authHeaders()),
|
|
940
|
+
"Content-Type": "application/json",
|
|
941
|
+
Accept: "application/json",
|
|
942
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
943
|
+
},
|
|
944
|
+
body: JSON.stringify(input),
|
|
945
|
+
}));
|
|
946
|
+
if (!response.ok) {
|
|
947
|
+
if (response.status === 401)
|
|
948
|
+
throw new Error("Not authenticated.");
|
|
949
|
+
if (response.status === 403)
|
|
950
|
+
throw new Error(`Forbidden: org owner/admin required on target org.`);
|
|
951
|
+
if (response.status === 404)
|
|
952
|
+
throw new Error(`Source posture '${postureId}' not found.`);
|
|
953
|
+
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}`);
|
|
957
|
+
}
|
|
958
|
+
return (await response.json());
|
|
959
|
+
}
|
|
960
|
+
/** DELETE /v1/postures/:id — soft-delete (refuses if assigned). */
|
|
961
|
+
export async function deletePosture(postureId) {
|
|
962
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}`);
|
|
963
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
964
|
+
method: "DELETE",
|
|
965
|
+
headers: {
|
|
966
|
+
...(await authHeaders()),
|
|
967
|
+
Accept: "application/json",
|
|
968
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
969
|
+
},
|
|
970
|
+
}));
|
|
971
|
+
if (!response.ok) {
|
|
972
|
+
if (response.status === 401)
|
|
973
|
+
throw new Error("Not authenticated.");
|
|
974
|
+
if (response.status === 403)
|
|
975
|
+
throw new Error(`Forbidden: platform defaults are immutable.`);
|
|
976
|
+
if (response.status === 404)
|
|
977
|
+
throw new Error(`Posture '${postureId}' not found.`);
|
|
978
|
+
if (response.status === 409) {
|
|
979
|
+
throw new Error(`Cannot delete a posture that is currently assigned to one or more teams. Unassign first.`);
|
|
980
|
+
}
|
|
981
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
982
|
+
throw new Error(err.message || `Delete failed: ${response.status}`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/** POST /v1/postures/:id/assign — assign to a team. */
|
|
986
|
+
export async function assignPosture(postureId, teamId, pinRevisionNo) {
|
|
987
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}/assign`);
|
|
988
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
989
|
+
method: "POST",
|
|
990
|
+
headers: {
|
|
991
|
+
...(await authHeaders()),
|
|
992
|
+
"Content-Type": "application/json",
|
|
993
|
+
Accept: "application/json",
|
|
994
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
995
|
+
},
|
|
996
|
+
body: JSON.stringify({ team_id: teamId, pin_revision_no: pinRevisionNo ?? null }),
|
|
997
|
+
}));
|
|
998
|
+
if (!response.ok) {
|
|
999
|
+
if (response.status === 401)
|
|
1000
|
+
throw new Error("Not authenticated.");
|
|
1001
|
+
if (response.status === 403)
|
|
1002
|
+
throw new Error(`Forbidden: org owner/admin required on team's org.`);
|
|
1003
|
+
if (response.status === 404) {
|
|
1004
|
+
const errBody = (await response.json().catch(() => ({})));
|
|
1005
|
+
throw new Error(errBody.message || `Posture, team, or pinned revision not found.`);
|
|
1006
|
+
}
|
|
1007
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
1008
|
+
throw new Error(err.message || `Assign failed: ${response.status}`);
|
|
1009
|
+
}
|
|
1010
|
+
return (await response.json());
|
|
1011
|
+
}
|
|
1012
|
+
/** DELETE /v1/postures/:id/assignments/:team_id — remove an assignment. */
|
|
1013
|
+
export async function unassignPosture(postureId, teamId) {
|
|
1014
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}/assignments/${teamId}`);
|
|
1015
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1016
|
+
method: "DELETE",
|
|
1017
|
+
headers: {
|
|
1018
|
+
...(await authHeaders()),
|
|
1019
|
+
Accept: "application/json",
|
|
1020
|
+
"Idempotency-Key": newIdempotencyKey(),
|
|
1021
|
+
},
|
|
1022
|
+
}));
|
|
1023
|
+
if (!response.ok) {
|
|
1024
|
+
if (response.status === 401)
|
|
1025
|
+
throw new Error("Not authenticated.");
|
|
1026
|
+
if (response.status === 403)
|
|
1027
|
+
throw new Error(`Forbidden: org owner/admin required.`);
|
|
1028
|
+
if (response.status === 404) {
|
|
1029
|
+
throw new Error(`No matching assignment to remove (posture '${postureId}' is not assigned to team '${teamId}').`);
|
|
1030
|
+
}
|
|
1031
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
1032
|
+
throw new Error(err.message || `Unassign failed: ${response.status}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
/** POST /v1/postures/:id/preview-compose — preview effective for a team. */
|
|
1036
|
+
export async function previewComposePosture(postureId, teamId) {
|
|
1037
|
+
const url = validateUrl(`${API_BASE}/v1/postures/${postureId}/preview-compose`);
|
|
1038
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1039
|
+
method: "POST",
|
|
1040
|
+
headers: {
|
|
1041
|
+
...(await authHeaders()),
|
|
1042
|
+
"Content-Type": "application/json",
|
|
1043
|
+
Accept: "application/json",
|
|
1044
|
+
},
|
|
1045
|
+
body: JSON.stringify({ team_id: teamId }),
|
|
1046
|
+
}));
|
|
1047
|
+
if (!response.ok) {
|
|
1048
|
+
if (response.status === 401)
|
|
1049
|
+
throw new Error("Not authenticated.");
|
|
1050
|
+
if (response.status === 404) {
|
|
1051
|
+
const errBody = (await response.json().catch(() => ({})));
|
|
1052
|
+
throw new Error(errBody.message || `Posture or team not found.`);
|
|
1053
|
+
}
|
|
1054
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
1055
|
+
throw new Error(err.message || `Preview-compose failed: ${response.status}`);
|
|
1056
|
+
}
|
|
1057
|
+
return (await response.json());
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* GET /v1/agents/:id/sideband-advisories
|
|
1061
|
+
*
|
|
1062
|
+
* Lists pending_advisories rows where source LIKE 'sideband.%' for the
|
|
1063
|
+
* given agent. Default page 50, max 200. Optional ?since=<ISO> filter.
|
|
1064
|
+
*/
|
|
1065
|
+
export async function listSidebandAdvisoriesForAgent(agentId, opts = {}) {
|
|
1066
|
+
const params = new URLSearchParams();
|
|
1067
|
+
if (opts.limit)
|
|
1068
|
+
params.set("limit", String(opts.limit));
|
|
1069
|
+
if (opts.since)
|
|
1070
|
+
params.set("since", opts.since);
|
|
1071
|
+
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
1072
|
+
const url = validateUrl(`${API_BASE}/v1/agents/${encodeURIComponent(agentId)}/sideband-advisories${qs}`);
|
|
1073
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1074
|
+
method: "GET",
|
|
1075
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
1076
|
+
}));
|
|
1077
|
+
if (!response.ok) {
|
|
1078
|
+
if (response.status === 401)
|
|
1079
|
+
throw new Error("Not authenticated.");
|
|
1080
|
+
if (response.status === 403)
|
|
1081
|
+
throw new Error("Permission denied (need org membership).");
|
|
1082
|
+
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}`);
|
|
1086
|
+
}
|
|
1087
|
+
return (await response.json());
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* GET /v1/teams/:id/sideband-advisories
|
|
1091
|
+
*
|
|
1092
|
+
* Lists pending_advisories rows for any agent that is a current member of
|
|
1093
|
+
* the team. Default page 100, max 500. Optional ?since=<ISO> filter.
|
|
1094
|
+
*/
|
|
1095
|
+
export async function listSidebandAdvisoriesForTeam(teamId, opts = {}) {
|
|
1096
|
+
const params = new URLSearchParams();
|
|
1097
|
+
if (opts.limit)
|
|
1098
|
+
params.set("limit", String(opts.limit));
|
|
1099
|
+
if (opts.since)
|
|
1100
|
+
params.set("since", opts.since);
|
|
1101
|
+
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
1102
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${encodeURIComponent(teamId)}/sideband-advisories${qs}`);
|
|
1103
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1104
|
+
method: "GET",
|
|
1105
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
1106
|
+
}));
|
|
1107
|
+
if (!response.ok) {
|
|
1108
|
+
if (response.status === 401)
|
|
1109
|
+
throw new Error("Not authenticated.");
|
|
1110
|
+
if (response.status === 403)
|
|
1111
|
+
throw new Error("Permission denied (need org membership).");
|
|
1112
|
+
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}`);
|
|
1116
|
+
}
|
|
1117
|
+
return (await response.json());
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* GET /v1/teams/:id/sideband-coverage
|
|
1121
|
+
*
|
|
1122
|
+
* Compliance-grade proof-of-coverage for a team — last 30 days of
|
|
1123
|
+
* per-axis sweep aggregates + heartbeat. Returns per-axis summary +
|
|
1124
|
+
* raw rows.
|
|
1125
|
+
*/
|
|
1126
|
+
export async function getTeamSidebandCoverage(teamId) {
|
|
1127
|
+
const url = validateUrl(`${API_BASE}/v1/teams/${encodeURIComponent(teamId)}/sideband-coverage`);
|
|
1128
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1129
|
+
method: "GET",
|
|
1130
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
1131
|
+
}));
|
|
1132
|
+
if (!response.ok) {
|
|
1133
|
+
if (response.status === 401)
|
|
1134
|
+
throw new Error("Not authenticated.");
|
|
1135
|
+
if (response.status === 403)
|
|
1136
|
+
throw new Error("Permission denied (need org membership).");
|
|
1137
|
+
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}`);
|
|
1141
|
+
}
|
|
1142
|
+
return (await response.json());
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* GET /v1/admin/safe-house/harness-state — fetch the most recent
|
|
1146
|
+
* harness run per lane (full + fast). Powers `mnemom validate
|
|
1147
|
+
* safe-house`. Admin-gated; returns 403 for non-mnemom-staff.
|
|
1148
|
+
*/
|
|
1149
|
+
export async function getSafeHouseHarnessState() {
|
|
1150
|
+
const url = validateUrl(`${API_BASE}/v1/admin/safe-house/harness-state`);
|
|
1151
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1152
|
+
method: "GET",
|
|
1153
|
+
headers: { ...(await authHeaders()), Accept: "application/json" },
|
|
1154
|
+
}));
|
|
1155
|
+
if (!response.ok) {
|
|
1156
|
+
if (response.status === 401)
|
|
1157
|
+
throw new Error("Not authenticated. Run `mnemom login`.");
|
|
1158
|
+
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}`);
|
|
1162
|
+
}
|
|
1163
|
+
return (await response.json());
|
|
1164
|
+
}
|
|
1165
|
+
function buildSignalListQuery(opts = {}) {
|
|
1166
|
+
const params = new URLSearchParams();
|
|
1167
|
+
if (opts.source)
|
|
1168
|
+
params.set("source", opts.source);
|
|
1169
|
+
if (opts.severity)
|
|
1170
|
+
params.set("severity", opts.severity);
|
|
1171
|
+
if (opts.status)
|
|
1172
|
+
params.set("status", opts.status);
|
|
1173
|
+
if (opts.scope)
|
|
1174
|
+
params.set("scope", opts.scope);
|
|
1175
|
+
if (opts.pattern_type)
|
|
1176
|
+
params.set("pattern_type", opts.pattern_type);
|
|
1177
|
+
if (opts.since)
|
|
1178
|
+
params.set("since", opts.since);
|
|
1179
|
+
if (opts.limit)
|
|
1180
|
+
params.set("limit", String(opts.limit));
|
|
1181
|
+
const qs = params.toString();
|
|
1182
|
+
return qs ? `?${qs}` : "";
|
|
1183
|
+
}
|
|
1184
|
+
async function gFetch(path, init = {}, notFoundLabel) {
|
|
1185
|
+
const url = validateUrl(`${API_BASE}${path}`);
|
|
1186
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1187
|
+
method: init.method ?? "GET",
|
|
1188
|
+
headers: {
|
|
1189
|
+
...(await authHeaders()),
|
|
1190
|
+
Accept: "application/json",
|
|
1191
|
+
...(init.body ? { "Content-Type": "application/json" } : {}),
|
|
1192
|
+
...(init.headers ?? {}),
|
|
1193
|
+
},
|
|
1194
|
+
body: init.body,
|
|
1195
|
+
}));
|
|
1196
|
+
if (!response.ok) {
|
|
1197
|
+
if (response.status === 401)
|
|
1198
|
+
throw new Error("Not authenticated.");
|
|
1199
|
+
if (response.status === 403)
|
|
1200
|
+
throw new Error("Permission denied (need org admin / membership).");
|
|
1201
|
+
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}`);
|
|
1205
|
+
}
|
|
1206
|
+
return (await response.json());
|
|
1207
|
+
}
|
|
1208
|
+
export async function listGovernanceSignalsForOrg(orgId, opts = {}) {
|
|
1209
|
+
return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/governance/signals${buildSignalListQuery(opts)}`, {}, "Org");
|
|
1210
|
+
}
|
|
1211
|
+
export async function listGovernanceSignalsForTeam(teamId, opts = {}) {
|
|
1212
|
+
return gFetch(`/v1/teams/${encodeURIComponent(teamId)}/governance/signals${buildSignalListQuery(opts)}`, {}, "Team");
|
|
1213
|
+
}
|
|
1214
|
+
export async function listGovernanceSignalsForAgent(agentId, opts = {}) {
|
|
1215
|
+
return gFetch(`/v1/agents/${encodeURIComponent(agentId)}/governance/signals${buildSignalListQuery(opts)}`, {}, "Agent");
|
|
1216
|
+
}
|
|
1217
|
+
export async function getGovernanceSignal(id) {
|
|
1218
|
+
return gFetch(`/v1/governance/signals/${encodeURIComponent(id)}`, {}, "Signal");
|
|
1219
|
+
}
|
|
1220
|
+
export async function acknowledgeGovernanceSignal(id, body = {}) {
|
|
1221
|
+
return gFetch(`/v1/governance/signals/${encodeURIComponent(id)}/acknowledge`, { method: "POST", body: JSON.stringify(body) }, "Signal");
|
|
1222
|
+
}
|
|
1223
|
+
export async function resolveGovernanceSignal(id, body) {
|
|
1224
|
+
return gFetch(`/v1/governance/signals/${encodeURIComponent(id)}/resolve`, { method: "POST", body: JSON.stringify(body) }, "Signal");
|
|
1225
|
+
}
|
|
1226
|
+
export async function dismissGovernanceSignal(id, body = {}) {
|
|
1227
|
+
return gFetch(`/v1/governance/signals/${encodeURIComponent(id)}/dismiss`, { method: "POST", body: JSON.stringify(body) }, "Signal");
|
|
1228
|
+
}
|
|
1229
|
+
export async function listGovernanceDestinations(orgId) {
|
|
1230
|
+
return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/governance/notification-destinations`, {}, "Org");
|
|
1231
|
+
}
|
|
1232
|
+
export async function createGovernanceDestination(orgId, body) {
|
|
1233
|
+
return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/governance/notification-destinations`, { method: "POST", body: JSON.stringify(body) }, "Org");
|
|
1234
|
+
}
|
|
1235
|
+
export async function deleteGovernanceDestination(orgId, destinationId) {
|
|
1236
|
+
return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/governance/notification-destinations/${encodeURIComponent(destinationId)}`, { method: "DELETE" }, "Destination");
|
|
1237
|
+
}
|
|
1238
|
+
export async function testGovernanceDestination(orgId, destinationId) {
|
|
1239
|
+
return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/governance/notification-destinations/${encodeURIComponent(destinationId)}/test`, { method: "POST", body: JSON.stringify({}) }, "Destination");
|
|
1240
|
+
}
|
|
1241
|
+
export async function listGovernanceRules(orgId) {
|
|
1242
|
+
return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/governance/escalation-rules`, {}, "Org");
|
|
1243
|
+
}
|
|
1244
|
+
export async function createGovernanceRule(orgId, body) {
|
|
1245
|
+
return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/governance/escalation-rules`, { method: "POST", body: JSON.stringify(body) }, "Org");
|
|
1246
|
+
}
|
|
1247
|
+
export async function deleteGovernanceRule(orgId, ruleId) {
|
|
1248
|
+
return gFetch(`/v1/orgs/${encodeURIComponent(orgId)}/governance/escalation-rules/${encodeURIComponent(ruleId)}`, { method: "DELETE" }, "Rule");
|
|
1249
|
+
}
|
|
1250
|
+
// ─── api-keys (ADR-049) ─────────────────────────────────────────────────
|
|
1251
|
+
/**
|
|
1252
|
+
* Capability-based scope vocabulary (ADR-049). Mirrors the API's
|
|
1253
|
+
* VALID_SCOPES exactly. Update both surfaces together when the
|
|
1254
|
+
* vocabulary changes.
|
|
1255
|
+
*/
|
|
1256
|
+
export const API_KEY_SCOPES = [
|
|
1257
|
+
"gateway",
|
|
1258
|
+
"api:read",
|
|
1259
|
+
"api:write",
|
|
1260
|
+
"admin:org",
|
|
1261
|
+
"admin:platform",
|
|
1262
|
+
];
|
|
1263
|
+
export const DEFAULT_API_KEY_SCOPES = [
|
|
1264
|
+
"gateway",
|
|
1265
|
+
"api:read",
|
|
1266
|
+
"api:write",
|
|
1267
|
+
];
|
|
1268
|
+
/**
|
|
1269
|
+
* Recognize legacy two-scope sets so the CLI can annotate pre-ADR-049
|
|
1270
|
+
* keys appropriately. Mirror of mnemom-api `expandLegacyScopes` logic
|
|
1271
|
+
* for display purposes; the auth gate handles the actual aliasing.
|
|
1272
|
+
*/
|
|
1273
|
+
export function isLegacyScopeSet(scopes) {
|
|
1274
|
+
if (!scopes || scopes.length === 0)
|
|
1275
|
+
return false;
|
|
1276
|
+
if (scopes.length === 2 && scopes.includes("gateway") && scopes.includes("api")) {
|
|
1277
|
+
return true;
|
|
1278
|
+
}
|
|
1279
|
+
if (scopes.length === 1 && scopes[0] === "api")
|
|
1280
|
+
return true;
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* GET /v1/api-keys — list the caller's active personal API keys.
|
|
1285
|
+
*/
|
|
1286
|
+
export async function listApiKeys() {
|
|
1287
|
+
const url = validateUrl(`${API_BASE}/v1/api-keys`);
|
|
1288
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1289
|
+
headers: await authHeaders(),
|
|
1290
|
+
}));
|
|
1291
|
+
if (!response.ok) {
|
|
1292
|
+
if (response.status === 401) {
|
|
1293
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
1294
|
+
}
|
|
1295
|
+
const err = (await response.json().catch(() => ({ error: "unknown" })));
|
|
1296
|
+
throw new Error(err.message || `Failed to list api keys: ${response.status}`);
|
|
1297
|
+
}
|
|
1298
|
+
const data = (await response.json());
|
|
1299
|
+
return data.keys ?? [];
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* POST /v1/api-keys — mint a new personal API key with explicit scopes.
|
|
1303
|
+
*
|
|
1304
|
+
* Returns the full secret only on this call. The mint-time ceiling
|
|
1305
|
+
* rejects admin scopes the caller is not eligible for (admin:platform
|
|
1306
|
+
* for non-staff, admin:org for users not in any org-admin role).
|
|
1307
|
+
*/
|
|
1308
|
+
export async function createApiKey(name, scopes) {
|
|
1309
|
+
const url = validateUrl(`${API_BASE}/v1/api-keys`);
|
|
1310
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1311
|
+
method: "POST",
|
|
1312
|
+
headers: { ...(await authHeaders()), "Content-Type": "application/json" },
|
|
1313
|
+
body: JSON.stringify({ name, scopes }),
|
|
1314
|
+
}));
|
|
1315
|
+
if (!response.ok) {
|
|
1316
|
+
if (response.status === 401) {
|
|
1317
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
1318
|
+
}
|
|
1319
|
+
const body = await response.text().catch(() => "");
|
|
1320
|
+
if (response.status === 403) {
|
|
1321
|
+
throw new Error(`Mint-time ceiling rejected scope: ${body}`);
|
|
1322
|
+
}
|
|
1323
|
+
if (response.status === 400) {
|
|
1324
|
+
throw new Error(`Invalid scope(s): ${body}`);
|
|
1325
|
+
}
|
|
1326
|
+
throw new Error(`Failed to create api key: ${response.status} ${body}`);
|
|
1327
|
+
}
|
|
1328
|
+
return (await response.json());
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* POST /v1/api-keys/{key_id}/rotate — atomic mint-new + revoke-old.
|
|
1332
|
+
* Returns the full new secret only on this call. The new key inherits
|
|
1333
|
+
* the old key's name and scopes verbatim; the old key is revoked the
|
|
1334
|
+
* moment this returns.
|
|
1335
|
+
*/
|
|
1336
|
+
export async function rotateApiKey(keyId) {
|
|
1337
|
+
const url = validateUrl(`${API_BASE}/v1/api-keys/${encodeURIComponent(keyId)}/rotate`);
|
|
1338
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1339
|
+
method: "POST",
|
|
1340
|
+
headers: { ...(await authHeaders()), "Content-Type": "application/json" },
|
|
1341
|
+
}));
|
|
1342
|
+
if (!response.ok) {
|
|
1343
|
+
if (response.status === 401) {
|
|
1344
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
1345
|
+
}
|
|
1346
|
+
if (response.status === 404)
|
|
1347
|
+
throw new Error(`Key not found: ${keyId}`);
|
|
1348
|
+
const body = await response.text().catch(() => "");
|
|
1349
|
+
throw new Error(`Failed to rotate api key: ${response.status} ${body}`);
|
|
1350
|
+
}
|
|
1351
|
+
return (await response.json());
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* DELETE /v1/api-keys/{key_id} — soft-revoke. The key row stays for
|
|
1355
|
+
* audit; `is_active` flips to false and `revoked_at` is timestamped.
|
|
1356
|
+
*/
|
|
1357
|
+
export async function revokeApiKey(keyId) {
|
|
1358
|
+
const url = validateUrl(`${API_BASE}/v1/api-keys/${encodeURIComponent(keyId)}`);
|
|
1359
|
+
const response = await fetchWithAuthRetry(url, async () => ({
|
|
1360
|
+
method: "DELETE",
|
|
1361
|
+
headers: await authHeaders(),
|
|
1362
|
+
}));
|
|
1363
|
+
if (!response.ok) {
|
|
1364
|
+
if (response.status === 401) {
|
|
1365
|
+
throw new Error("Not authenticated. Run `mnemom login` or set MNEMOM_API_KEY.");
|
|
1366
|
+
}
|
|
1367
|
+
if (response.status === 404)
|
|
1368
|
+
throw new Error(`Key not found: ${keyId}`);
|
|
1369
|
+
const body = await response.text().catch(() => "");
|
|
1370
|
+
throw new Error(`Failed to revoke api key: ${response.status} ${body}`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|