@extrovert.dev/mcp 0.1.0-pre.3

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +313 -0
  3. package/dist/bin.d.ts +13 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +89 -0
  6. package/dist/bin.js.map +1 -0
  7. package/dist/client.d.ts +956 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +1354 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +51 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +53 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/contract.d.ts +62 -0
  16. package/dist/contract.d.ts.map +1 -0
  17. package/dist/contract.js +78 -0
  18. package/dist/contract.js.map +1 -0
  19. package/dist/extract.d.ts +25 -0
  20. package/dist/extract.d.ts.map +1 -0
  21. package/dist/extract.js +131 -0
  22. package/dist/extract.js.map +1 -0
  23. package/dist/fixtures.d.ts +676 -0
  24. package/dist/fixtures.d.ts.map +1 -0
  25. package/dist/fixtures.js +2685 -0
  26. package/dist/fixtures.js.map +1 -0
  27. package/dist/http.d.ts +18 -0
  28. package/dist/http.d.ts.map +1 -0
  29. package/dist/http.js +124 -0
  30. package/dist/http.js.map +1 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +18 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/server.d.ts +20 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +36 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/stdio.d.ts +8 -0
  40. package/dist/stdio.d.ts.map +1 -0
  41. package/dist/stdio.js +22 -0
  42. package/dist/stdio.js.map +1 -0
  43. package/dist/tools.d.ts +27 -0
  44. package/dist/tools.d.ts.map +1 -0
  45. package/dist/tools.js +2752 -0
  46. package/dist/tools.js.map +1 -0
  47. package/dist/types.d.ts +1037 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +83 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +72 -0
package/dist/client.js ADDED
@@ -0,0 +1,1354 @@
1
+ /**
2
+ * Thin, typed Extrovert API client.
3
+ *
4
+ * One method per REST endpoint in spec §8 (`/v1`). The base URL and scoped
5
+ * agent key come from config (env `EXTROVERT_API_BASE_URL` / `EXTROVERT_API_KEY`).
6
+ * This module is the single seam between the MCP tools and the network — the
7
+ * tools never touch `fetch` directly.
8
+ *
9
+ * While `config.mock` is true, each method returns deterministic fixture data
10
+ * via `FixtureStore` instead of issuing HTTP.
11
+ */
12
+ import { FixtureStore, NotFoundError } from "./fixtures.js";
13
+ import { keyTierFromRawKey, listEnvelopeToPage } from "./types.js";
14
+ /** Normalized error surfaced from any client call. */
15
+ export class ExtrovertApiError extends Error {
16
+ status;
17
+ code;
18
+ details;
19
+ problemErrors;
20
+ constructor(message, status, code, details,
21
+ /**
22
+ * The problem's machine-readable field hints (`problem.errors[]`), verbatim.
23
+ *
24
+ * These are NOT decoration. A 422 `intent_required` carries the exact JSON to
25
+ * add under `retry_with`, and a 409 carries `state` / `revision` / `version` /
26
+ * one `allowed_action` per legal verb — the facts that let an agent recover in
27
+ * one turn instead of guessing. They reach the model only because
28
+ * `toErrorResult` renders them: anything left in `structuredContent` is
29
+ * invisible to a text-only agent.
30
+ */
31
+ problemErrors) {
32
+ super(message);
33
+ this.status = status;
34
+ this.code = code;
35
+ this.details = details;
36
+ this.problemErrors = problemErrors;
37
+ this.name = "ExtrovertApiError";
38
+ }
39
+ }
40
+ export class ExtrovertClient {
41
+ config;
42
+ store;
43
+ apiKey;
44
+ constructor(config) {
45
+ this.config = config;
46
+ this.apiKey = config.apiKey;
47
+ // The offline store enforces the SAME key-tier ceiling assertions as the live
48
+ // choke-point, derived from the configured key prefix (redesign §3.1).
49
+ if (config.mock) {
50
+ this.store = new FixtureStore({
51
+ keyTier: keyTierFromRawKey(config.apiKey),
52
+ reviewPolicy: config.mockReviewPolicy,
53
+ });
54
+ }
55
+ }
56
+ get isMock() {
57
+ return this.config.mock;
58
+ }
59
+ // ---- enrollment (POST /v1/enroll) -------------------------------------
60
+ async redeemEnrollment(input) {
61
+ if (this.store) {
62
+ const res = this.store.redeemEnrollment(input.enrollment_token, input.agent_handle);
63
+ this.setSessionKey(res.agent_key);
64
+ return res;
65
+ }
66
+ const res = await this.post("/v1/enroll", {
67
+ token: input.enrollment_token,
68
+ agent_handle: input.agent_handle,
69
+ client_id: input.client_id,
70
+ }, undefined, idempotencyHeader(input.client_id));
71
+ this.setSessionKey(res.agent_key);
72
+ return res;
73
+ }
74
+ // ---- self-signup + auth (Slice E) -------------------------------------
75
+ /** Grab a free account: `POST /v1/agent/sign-up` (unauthenticated). */
76
+ async signUp(input) {
77
+ if (this.store) {
78
+ const res = this.store.signUp(input);
79
+ this.setSessionKey(res.agent_key);
80
+ return res;
81
+ }
82
+ const res = await this.post("/v1/agent/sign-up", {
83
+ human_email: input.human_email,
84
+ username: input.username,
85
+ });
86
+ this.setSessionKey(res.agent_key);
87
+ return res;
88
+ }
89
+ /** Confirm the signup OTP and elevate scope: `POST /v1/agent/verify`. */
90
+ async verify(input) {
91
+ if (this.store) {
92
+ const res = this.store.verify(input.otp);
93
+ this.setSessionKey(res.agent_key);
94
+ return res;
95
+ }
96
+ const res = await this.post("/v1/agent/verify", { otp: input.otp });
97
+ this.setSessionKey(res.agent_key);
98
+ return res;
99
+ }
100
+ /** Introspect the principal behind the current key: `GET /v1/auth/me`. */
101
+ async whoami() {
102
+ if (this.store)
103
+ return this.store.whoami();
104
+ return this.get("/v1/auth/me");
105
+ }
106
+ // ---- inboxes ----------------------------------------------------------
107
+ async createInbox(input) {
108
+ if (this.store) {
109
+ return this.store.createInbox({
110
+ username: input.username,
111
+ domain: input.domain,
112
+ displayName: input.display_name,
113
+ inboundWebhookUrl: input.inbound_webhook_url,
114
+ metadata: input.metadata,
115
+ projectId: input.project_id,
116
+ clientId: input.client_id,
117
+ });
118
+ }
119
+ const body = {};
120
+ if (input.username !== undefined)
121
+ body.username = input.username;
122
+ if (input.domain !== undefined)
123
+ body.domain = input.domain;
124
+ if (input.display_name !== undefined)
125
+ body.display_name = input.display_name;
126
+ // The agent-facing `inbound_webhook_url` maps to the API's `webhook_url`.
127
+ if (input.inbound_webhook_url !== undefined)
128
+ body.webhook_url = input.inbound_webhook_url;
129
+ if (input.metadata !== undefined)
130
+ body.metadata = input.metadata;
131
+ if (input.project_id !== undefined)
132
+ body.project_id = input.project_id;
133
+ if (input.client_id !== undefined)
134
+ body.client_id = input.client_id;
135
+ return this.post("/v1/inboxes", body, undefined, idempotencyHeader(input.client_id));
136
+ }
137
+ /**
138
+ * List the agent's inboxes (redesign §4.1 bare-vs-wildcard semantics).
139
+ *
140
+ * Scope is in the KEY. By tier:
141
+ * - project/inbox key → that project's inboxes. Addressed via the canonical
142
+ * project-prefixed envelope `GET /v1/projects/{project_id}/inboxes` (the §5.2
143
+ * `{object:"list", data, has_more, next_cursor}` shape) when a project is
144
+ * resolved; otherwise the bare `/v1/inboxes` curl-sugar form (which resolves
145
+ * to the key's default project and returns the legacy `{inboxes, next_page}`).
146
+ * - org key → MUST pick a breadth: pass `project` (a concrete id) or
147
+ * `wildcard:true` (`/v1/projects/-/inboxes`, the org subtree). A bare org-key
148
+ * list is a 400 `breadth_required` (mirrors the server choke-point) — we fail
149
+ * fast client-side with the same code so the agent sees the next call to make.
150
+ *
151
+ * Either wire shape is normalized into the internal {@link Page}.
152
+ */
153
+ async listInboxes(opts = {}) {
154
+ // Back-compat: a bare number is the page limit.
155
+ const o = typeof opts === "number" ? { limit: opts } : opts;
156
+ const limit = o.limit ?? 20;
157
+ if (this.store)
158
+ return this.store.listInboxes({ limit, project: o.project, wildcard: o.wildcard });
159
+ const tier = this.keyTier();
160
+ // Resolve the project segment for the canonical envelope form.
161
+ const projectSegment = o.wildcard ? "-" : o.project;
162
+ if (projectSegment) {
163
+ // Org key on the wildcard is fine; a non-org key on the wildcard is a 403
164
+ // (server enforces; we let the server be authoritative and just call it).
165
+ const list = await this.get(`/v1/projects/${encodeURIComponent(projectSegment)}/inboxes`, { limit, cursor: o.cursor });
166
+ return listEnvelopeToPage(list);
167
+ }
168
+ if (tier === "org") {
169
+ // Bare list under an org key needs an explicit breadth (RFC D2 / §4.1).
170
+ throw new ExtrovertApiError("An org-tier key must pick a list breadth: pass a project id (e.g. list_inboxes project=<id>) " +
171
+ "or wildcard=true (the org subtree).", 400, "breadth_required");
172
+ }
173
+ // project/inbox key, bare sugar → the legacy {inboxes, next_page} shape.
174
+ const raw = await this.get("/v1/inboxes", { limit });
175
+ const items = raw.inboxes ?? raw.items ?? [];
176
+ const page = { items };
177
+ const cursor = raw.next_cursor ?? raw.next_page;
178
+ if (cursor)
179
+ page.next_cursor = cursor;
180
+ return page;
181
+ }
182
+ /** The ceiling tier encoded in the current session's agent key (redesign §3.1). */
183
+ keyTier() {
184
+ return keyTierFromRawKey(this.apiKey);
185
+ }
186
+ async getInbox(idOrAddress) {
187
+ if (this.store) {
188
+ const inbox = this.store.getInbox(idOrAddress);
189
+ if (!inbox)
190
+ throw new ExtrovertApiError(`Inbox not found: ${idOrAddress}`, 404, "not_found");
191
+ return inbox;
192
+ }
193
+ return this.get(`/v1/inboxes/${encodeURIComponent(idOrAddress)}`);
194
+ }
195
+ /**
196
+ * Update an inbox's settings in place: `PATCH /v1/inboxes/{inbox_id}` with
197
+ * `{display_name?, webhook_url?, daily_send_limit?, metadata?, project_id?}`. The agent-facing
198
+ * `inbound_webhook_url` maps to the API's `webhook_url`. `metadata` is a shallow
199
+ * merge: omit it to leave metadata unchanged; an object merges (a `null` value
200
+ * deletes that key); a top-level `null` clears ALL metadata. Owner-scoped
201
+ * server-side. Changing `daily_send_limit` requires the opt-in `mailbox:quota`
202
+ * scope. Returns the updated inbox with the effective enforced cap.
203
+ */
204
+ async updateInbox(idOrAddress, input) {
205
+ if (this.store) {
206
+ const inbox = this.store.updateInbox(idOrAddress, {
207
+ displayName: input.display_name,
208
+ inboundWebhookUrl: input.inbound_webhook_url,
209
+ dailySendLimit: input.daily_send_limit,
210
+ metadata: input.metadata,
211
+ projectId: input.project_id,
212
+ });
213
+ if (!inbox)
214
+ throw new ExtrovertApiError(`Inbox not found: ${idOrAddress}`, 404, "not_found");
215
+ return inbox;
216
+ }
217
+ const body = {};
218
+ if (input.display_name !== undefined)
219
+ body.display_name = input.display_name;
220
+ if (input.inbound_webhook_url !== undefined)
221
+ body.webhook_url = input.inbound_webhook_url;
222
+ if (input.daily_send_limit !== undefined)
223
+ body.daily_send_limit = input.daily_send_limit;
224
+ // `metadata` is forwarded verbatim — including an explicit top-level `null`
225
+ // (clear-all) — so the merge-null-clear semantics reach the server unchanged.
226
+ if (input.metadata !== undefined)
227
+ body.metadata = input.metadata;
228
+ if (input.project_id !== undefined)
229
+ body.project_id = input.project_id;
230
+ return this.patch(`/v1/inboxes/${encodeURIComponent(idOrAddress)}`, body);
231
+ }
232
+ // ---- credentials (GET /v1/inboxes/{inbox_id}/credentials) -------------------
233
+ async getCredentials(idOrAddress) {
234
+ if (this.store)
235
+ return this.store.getCredentials(idOrAddress);
236
+ return this.get(`/v1/inboxes/${encodeURIComponent(idOrAddress)}/credentials`);
237
+ }
238
+ /**
239
+ * Permanently delete an inbox and its messages/sender identity. Requires
240
+ * `mailbox:delete`; this cannot be undone.
241
+ */
242
+ async deleteInbox(idOrAddress) {
243
+ if (this.store) {
244
+ const result = this.store.deleteInbox(idOrAddress);
245
+ if (!result)
246
+ throw new ExtrovertApiError(`Inbox not found: ${idOrAddress}`, 404, "not_found");
247
+ return result;
248
+ }
249
+ return this.del(`/v1/inboxes/${encodeURIComponent(idOrAddress)}`);
250
+ }
251
+ // ---- send / reply -----------------------------------------------------
252
+ /**
253
+ * Send a new message WITHOUT the review overload.
254
+ *
255
+ * The return type is a union because the endpoint has two outcomes and the
256
+ * account's policy — not the caller — picks between them. This used to be typed
257
+ * `Promise<Message>` and rendered as a message header, which was garbage against
258
+ * every real response: the server has never returned a Message here. It returns
259
+ * `{status:"sent", message_id, review_id}` on the policy's direct path, and the
260
+ * §5.1 `{kind:"queued_for_review", review}` envelope (HTTP 202) when the policy
261
+ * queues it — which, under the `require_review` default, is the normal outcome.
262
+ *
263
+ * A send with no `intent` under `require_review` never reaches either arm: it is
264
+ * refused with 422 `intent_required` and NOTHING is sent or queued.
265
+ */
266
+ async sendEmail(input) {
267
+ if (this.store)
268
+ return this.store.sendEmail(input);
269
+ const { inbox, client_id, ...body } = input;
270
+ // `body` carries to/subject/text/html/cc/bcc/reply_to/headers and (when
271
+ // present) attachments — the canonical send contract, mirroring reply; the
272
+ // server emits multipart/mixed when needed and drops unsafe header names.
273
+ return this.post(`/v1/inboxes/${encodeURIComponent(inbox)}/send`, body, undefined, idempotencyHeader(client_id));
274
+ }
275
+ /**
276
+ * Thread-aware reply. Canonical contract:
277
+ * `POST /v1/inboxes/{inbox_id}/reply` with `{thread_id|message_id, text, html?, cc?,
278
+ * bcc?, reply_to?, reply_all?}`. The server derives To/Subject/In-Reply-To/
279
+ * References and returns `{message_id, thread_id, review_id}`. No `to` is sent.
280
+ *
281
+ * Like {@link sendEmail} the outcome is policy-decided: a bare reply under
282
+ * `require_review` is QUEUED (202 `{kind:"queued_for_review"}`), not sent, and a
283
+ * bare reply with no intent is refused 422 `intent_required`.
284
+ */
285
+ async replyEmail(input) {
286
+ if (this.store) {
287
+ return this.store.replyEmail({
288
+ inbox: input.inbox,
289
+ threadId: input.thread_id,
290
+ messageId: input.message_id,
291
+ text: input.text,
292
+ html: input.html,
293
+ cc: input.cc,
294
+ bcc: input.bcc,
295
+ replyTo: input.reply_to,
296
+ replyAll: input.reply_all,
297
+ attachments: input.attachments,
298
+ });
299
+ }
300
+ const { inbox, client_id, ...body } = input;
301
+ return this.post(`/v1/inboxes/${encodeURIComponent(inbox)}/reply`, body, undefined, idempotencyHeader(client_id));
302
+ }
303
+ /**
304
+ * Forward an existing message to new recipients, preserving the original.
305
+ * `POST /v1/inboxes/{inbox_id}/messages/{id}/forward` `{to[], cc?, bcc?, text?, html?}`.
306
+ * `html` is accepted but ignored: the server materializes one plain-text body
307
+ * containing both the note and quoted parent, so HTML clients cannot hide the quote.
308
+ *
309
+ * Policy-decided like send and reply: a bare forward under `require_review` is
310
+ * QUEUED, and one with no intent is refused 422 `intent_required`.
311
+ */
312
+ async forwardEmail(input) {
313
+ if (this.store) {
314
+ return this.store.forwardEmail({
315
+ inbox: input.inbox,
316
+ messageId: input.message_id,
317
+ to: input.to,
318
+ cc: input.cc,
319
+ bcc: input.bcc,
320
+ text: input.text,
321
+ html: input.html,
322
+ });
323
+ }
324
+ const { inbox, message_id, client_id, ...body } = input;
325
+ return this.post(`/v1/inboxes/${encodeURIComponent(inbox)}/messages/${encodeURIComponent(message_id)}/forward`, body, undefined, idempotencyHeader(client_id));
326
+ }
327
+ /**
328
+ * Submit a forward for human review — the SAME endpoint as {@link forwardEmail}
329
+ * with mode/intent/category_id attached, mirroring how send and reply overload.
330
+ * Returns the discriminated §5.1 envelope (queued OR sent).
331
+ */
332
+ async submitForwardForReview(input) {
333
+ if (this.store)
334
+ return this.store.submitForwardForReview(input);
335
+ const { inbox, message_id, client_id, ...body } = input;
336
+ return this.post(`/v1/inboxes/${encodeURIComponent(inbox)}/messages/${encodeURIComponent(message_id)}/forward`, body, undefined, idempotencyHeader(client_id));
337
+ }
338
+ // ---- Review Loop (HITL) -----------------------------------------------
339
+ /**
340
+ * Submit a new message for human review (`POST /v1/inboxes/{inbox_id}/send` with
341
+ * mode/intent/category_id). The server routes per the account/inbox review
342
+ * policy and returns either `{kind:"queued_for_review"}` (202) or
343
+ * `{kind:"sent"}` (200, policy-permitted direct/graduated path).
344
+ */
345
+ async submitForReview(input) {
346
+ if (this.store)
347
+ return this.store.submitForReview(input);
348
+ const { inbox, client_id, ...body } = input;
349
+ return this.post(`/v1/inboxes/${encodeURIComponent(inbox)}/send`, body, undefined, idempotencyHeader(client_id));
350
+ }
351
+ /**
352
+ * Submit an in-thread reply for human review (`POST /v1/inboxes/{inbox_id}/reply`
353
+ * with mode/intent/category_id). Same routing/return contract as submitForReview.
354
+ */
355
+ async submitReplyForReview(input) {
356
+ if (this.store)
357
+ return this.store.submitReplyForReview(input);
358
+ const { inbox, client_id, ...body } = input;
359
+ return this.post(`/v1/inboxes/${encodeURIComponent(inbox)}/reply`, body, undefined, idempotencyHeader(client_id));
360
+ }
361
+ /** List review requests (`GET /v1/reviews`). Customer-scoped; agent monitors its submissions. */
362
+ async listReviews(input = {}) {
363
+ if (this.store)
364
+ return this.store.listReviews(input);
365
+ const query = {};
366
+ if (input.state !== undefined) {
367
+ query.state = Array.isArray(input.state) ? input.state.join(",") : input.state;
368
+ }
369
+ if (input.category_id !== undefined)
370
+ query.category_id = input.category_id;
371
+ if (input.inbox !== undefined)
372
+ query.inbox = input.inbox;
373
+ if (input.limit !== undefined)
374
+ query.limit = input.limit;
375
+ if (input.page !== undefined)
376
+ query.page = input.page;
377
+ return this.get("/v1/reviews", query);
378
+ }
379
+ /** Get one review request (`GET /v1/reviews/{id}`) — current draft + intent + state. */
380
+ async getReview(id) {
381
+ if (this.store)
382
+ return this.store.getReview(id);
383
+ return this.get(`/v1/reviews/${encodeURIComponent(id)}`);
384
+ }
385
+ /** Get a review's append-only thread turns (`GET /v1/reviews/{id}/turns`). */
386
+ async getReviewTurns(id) {
387
+ if (this.store)
388
+ return this.store.getReviewTurns(id);
389
+ return this.get(`/v1/reviews/${encodeURIComponent(id)}/turns`);
390
+ }
391
+ /**
392
+ * Get the human's assembled feedback for a review (`GET /v1/reviews/{id}/feedback`):
393
+ * the unified + structured diff, the human comments, the decision, and the rules born
394
+ * from this review (rule_ ids). Read-only; $0 LLM (pure assembly).
395
+ */
396
+ async getReviewFeedback(id) {
397
+ if (this.store)
398
+ return this.store.getReviewFeedback(id);
399
+ return this.get(`/v1/reviews/${encodeURIComponent(id)}/feedback`);
400
+ }
401
+ /**
402
+ * Post a chat turn on a review's thread (`POST /v1/reviews/{id}/chat`): append an
403
+ * agent_question turn, flip in_review -> chatting on the first turn, enqueue a
404
+ * feedback_added nudge to the human reviewer + emit review.chat. Idempotent on the
405
+ * client-supplied key. $0 LLM — YOU compose the question.
406
+ */
407
+ async postReviewChat(input) {
408
+ if (this.store)
409
+ return this.store.postReviewChat(input);
410
+ return this.post(`/v1/reviews/${encodeURIComponent(input.id)}/chat`, { text: input.text }, undefined, idempotencyHeader(input.client_id));
411
+ }
412
+ /**
413
+ * Post a new agent draft under a parent_revision CAS (`POST /v1/reviews/{id}/
414
+ * revision`). parent_revision must equal the draft's current revision, else 409
415
+ * STALE with NO mutation (D17). On a clean CAS the draft is re-rendered in place
416
+ * (revision++), returned to needs_review, and the reviewer is nudged. $0 LLM — YOU
417
+ * compose the redraft.
418
+ */
419
+ async submitRevision(input) {
420
+ if (this.store)
421
+ return this.store.submitRevision(input);
422
+ const body = { parent_revision: input.parent_revision };
423
+ if (input.version !== undefined)
424
+ body.version = input.version;
425
+ if (input.subject !== undefined)
426
+ body.subject = input.subject;
427
+ // `text` is canonical; `body` is the permanent deprecated alias. BOTH are
428
+ // forwarded verbatim when supplied so the server's own alias resolution decides
429
+ // — including rejecting a both-but-different pair with 400 conflicting_alias.
430
+ // Picking a winner here would silently relay the wrong bytes.
431
+ if (input.text !== undefined)
432
+ body.text = input.text;
433
+ if (input.body !== undefined)
434
+ body.body = input.body;
435
+ if (input.html !== undefined)
436
+ body.html = input.html;
437
+ if (input.attachments !== undefined)
438
+ body.attachments = input.attachments;
439
+ if (input.built_at !== undefined)
440
+ body.built_at = input.built_at;
441
+ if (input.rules_version_seen !== undefined)
442
+ body.rules_version_seen = input.rules_version_seen;
443
+ return this.post(`/v1/reviews/${encodeURIComponent(input.id)}/revision`, body, undefined, idempotencyHeader(input.client_id));
444
+ }
445
+ /**
446
+ * Withdraw a pending review (`POST /v1/reviews/{id}/cancel`): the composing agent
447
+ * cancels its own review to the terminal cancelled state. A foreign id / another
448
+ * agent's draft 404s; a terminal review 409s.
449
+ */
450
+ async cancelReview(input) {
451
+ if (this.store)
452
+ return this.store.cancelReview(input.id);
453
+ return this.post(`/v1/reviews/${encodeURIComponent(input.id)}/cancel`, {}, undefined, idempotencyHeader(input.client_id));
454
+ }
455
+ /**
456
+ * Re-stamp a draft's rules-version WITHOUT redrafting (`POST /v1/reviews/{id}/
457
+ * restamp`; D19/§8 $0 escape valve). Assert "reviewed against vX, no change needed"
458
+ * and the server advances the draft's composed_* versions with no new draft, no
459
+ * revision bump, no nudge. against_version above the category's current rules-version
460
+ * is 400; a terminal/sent draft 409s.
461
+ */
462
+ async restampReview(input) {
463
+ if (this.store)
464
+ return this.store.restampReview(input);
465
+ const body = { against_version: input.against_version };
466
+ if (input.house_style_version !== undefined)
467
+ body.house_style_version = input.house_style_version;
468
+ return this.post(`/v1/reviews/${encodeURIComponent(input.id)}/restamp`, body, undefined, idempotencyHeader(input.client_id));
469
+ }
470
+ /**
471
+ * Get the REVIEWER's decision context for a review (`GET /v1/reviews/{id}/
472
+ * decision-context`; BYO review-agent plane, D5/§9). The reviewer's read-only view:
473
+ * the intent + current draft + thread + the two-circuit-breaker budget (hop_count vs
474
+ * max_hops, the hard review_deadline). Requires review:act + a matching active link;
475
+ * a cross-tenant id is 404, a non-reviewer is 403. `force_to_human` is true when a
476
+ * reject would be FORCED to the human regardless of intent (D17).
477
+ */
478
+ async getReviewDecisionContext(id) {
479
+ if (this.store)
480
+ return this.store.getReviewDecisionContext(id);
481
+ return this.get(`/v1/reviews/${encodeURIComponent(id)}/decision-context`);
482
+ }
483
+ /**
484
+ * Submit a reviewer decision (`POST /v1/reviews/{id}/decision`; reviewer_decide,
485
+ * D5/§9). approve/edit → the PLATFORM ACS-sends with the COMPOSER's creds (the
486
+ * reviewer NEVER holds mailbox:send — the credential boundary); reject → back to the
487
+ * composer (needs_review, hop_count++); escalate → the human queue. revision/version
488
+ * are the CAS (409 STALE on mismatch, NO mutation — the human always wins, D17). The
489
+ * two circuit breakers (hop_count ≥ max_hops, or the hard review_deadline) FORCE a
490
+ * reject to the human regardless of intent — `forced_by_breaker` names it. $0 LLM —
491
+ * you judged; we route, send, and enforce the breakers.
492
+ */
493
+ async reviewerDecide(input) {
494
+ if (this.store)
495
+ return this.store.reviewerDecide(input);
496
+ const body = { action: input.action, revision: input.revision };
497
+ if (input.version !== undefined)
498
+ body.version = input.version;
499
+ if (input.subject !== undefined)
500
+ body.subject = input.subject;
501
+ if (input.body !== undefined)
502
+ body.body = input.body;
503
+ if (input.feedback !== undefined)
504
+ body.feedback = input.feedback;
505
+ return this.post(`/v1/reviews/${encodeURIComponent(input.id)}/decision`, body);
506
+ }
507
+ /**
508
+ * Drain the next un-acked review events (`GET /v1/reviews/events`). Non-blocking;
509
+ * returns the FIFO-ordered nudges + per-review cursors. Side-effect free.
510
+ */
511
+ async listReviewEvents(input = {}) {
512
+ if (this.store)
513
+ return this.store.listReviewEvents(input);
514
+ const query = {};
515
+ if (input.review_id !== undefined)
516
+ query.review_id = input.review_id;
517
+ if (input.limit !== undefined)
518
+ query.limit = input.limit;
519
+ return this.get("/v1/reviews/events", query);
520
+ }
521
+ /**
522
+ * Long-poll for a review event (`GET /v1/reviews/events/wait`). Blocks ~25–55s
523
+ * until a nudge is available OR the deadline, then returns like
524
+ * {@link listReviewEvents} (empty on timeout).
525
+ */
526
+ async waitForReviewEvent(input = {}) {
527
+ if (this.store)
528
+ return this.store.waitForReviewEvent(input);
529
+ const query = {};
530
+ if (input.review_id !== undefined)
531
+ query.review_id = input.review_id;
532
+ if (input.limit !== undefined)
533
+ query.limit = input.limit;
534
+ if (input.wait_seconds !== undefined)
535
+ query.wait_seconds = input.wait_seconds;
536
+ return this.get("/v1/reviews/events/wait", query);
537
+ }
538
+ /**
539
+ * Ack review events (`POST /v1/reviews/events/ack`): advance per-(agent, review)
540
+ * cursor(s) and/or mark broadcast nudges done. Idempotent + monotonic.
541
+ */
542
+ async ackReviewEvent(input) {
543
+ if (this.store)
544
+ return this.store.ackReviewEvent(input);
545
+ return this.post("/v1/reviews/events/ack", {
546
+ acks: input.acks ?? [],
547
+ broadcast_ids: input.broadcast_ids ?? [],
548
+ });
549
+ }
550
+ // ---- Category registry (Review Loop, D9/D10) --------------------------
551
+ /**
552
+ * Browse the category registry (`GET /v1/categories?match=`). Returns id + name +
553
+ * description + scope + state for fuzzy matching. `match` is a pure lexical filter
554
+ * (NO LLM on our side) — the agent does the semantic match. Customer-scoped.
555
+ */
556
+ async listCategories(match) {
557
+ if (this.store)
558
+ return this.store.listCategories(match);
559
+ const query = {};
560
+ if (match !== undefined && match.trim() !== "")
561
+ query.match = match;
562
+ return this.get("/v1/categories", query);
563
+ }
564
+ /** Get one category (`GET /v1/categories/{id}`). A foreign id is 404. */
565
+ async getCategory(id) {
566
+ if (this.store)
567
+ return this.store.getCategory(id);
568
+ return this.get(`/v1/categories/${encodeURIComponent(id)}`);
569
+ }
570
+ /**
571
+ * Propose a new category (`POST /v1/categories`). It stands immediately
572
+ * (author_kind=agent) and writes a create audit/undo row. Match the registry
573
+ * first so you do not duplicate an existing bucket.
574
+ */
575
+ async proposeCategory(input) {
576
+ if (this.store)
577
+ return this.store.proposeCategory(input);
578
+ return this.post("/v1/categories", {
579
+ name: input.name,
580
+ description: input.description,
581
+ scope: input.scope,
582
+ });
583
+ }
584
+ /**
585
+ * Rename / re-describe a category (`PUT /v1/categories/{id}`) — metadata ONLY
586
+ * (D10). Renaming never breaks a reference; a rename/redescribe undo row is
587
+ * written. Any agent in the customer may edit (the shared-registry exception).
588
+ */
589
+ async updateCategory(input) {
590
+ if (this.store)
591
+ return this.store.updateCategory(input);
592
+ const body = {};
593
+ if (input.name !== undefined)
594
+ body.name = input.name;
595
+ if (input.description !== undefined)
596
+ body.description = input.description;
597
+ return this.put(`/v1/categories/${encodeURIComponent(input.id)}`, body);
598
+ }
599
+ // ---- Graduation + risk dial (Review Loop, D16/D6/D17) — agent READ + PROPOSE --
600
+ /**
601
+ * Read the effective risk dial (`GET /v1/risk-dial`): the account default + every
602
+ * category's overrides (each with its resolved effective value; null override =
603
+ * inherit). Read-only — agents read but NEVER flip the dial (setting it is a human
604
+ * console action; D16).
605
+ */
606
+ async getRiskDial() {
607
+ if (this.store)
608
+ return this.store.getRiskDial();
609
+ return this.get("/v1/risk-dial");
610
+ }
611
+ /**
612
+ * Read a category's graduation gate status (`GET /v1/categories/{id}/graduation-
613
+ * status`): the gates passed / still needed toward the next rung (approvals N/needed,
614
+ * age, maturity gate, drift vs K, can_graduate). Read-only.
615
+ */
616
+ async getGraduationStatus(categoryId) {
617
+ if (this.store)
618
+ return this.store.getGraduationStatus(categoryId);
619
+ return this.get(`/v1/categories/${encodeURIComponent(categoryId)}/graduation-status`);
620
+ }
621
+ /**
622
+ * Propose graduating a category (`POST /v1/categories/{id}/graduation-request`).
623
+ * RECORDS the request (durable evidence) and returns the current gate status; it
624
+ * does NOT change the category state — flipping the bit is a human (console) action
625
+ * (D16/D6). A never_graduate category stays locked.
626
+ */
627
+ async proposeGraduation(categoryId, evidence) {
628
+ if (this.store)
629
+ return this.store.proposeGraduation(categoryId, evidence);
630
+ return this.post(`/v1/categories/${encodeURIComponent(categoryId)}/graduation-request`, {
631
+ evidence: evidence ?? {},
632
+ });
633
+ }
634
+ /**
635
+ * Read the D19/§8 backlog-reconciliation status (`GET /v1/categories/{id}/
636
+ * backlog-status`): how many QUEUED drafts are stale vs current-enough against the
637
+ * current rules-version (a pure $0-LLM integer compare). Read-only — agents READ the
638
+ * picture; the human (console scan-backlog) / hooks TRIGGER the actual sweep.
639
+ */
640
+ async getBacklogStatus(categoryId) {
641
+ if (this.store)
642
+ return this.store.getBacklogStatus(categoryId);
643
+ return this.get(`/v1/categories/${encodeURIComponent(categoryId)}/backlog-status`);
644
+ }
645
+ /**
646
+ * Read the demand-driven pacing state (`GET /v1/categories/{id}/pacing-state`;
647
+ * M7 Slice B/§8): the human review cursor, the effective window/ceiling/interval, and
648
+ * each queued draft's in-window/redrafting/behind-cursor classification. Read-only;
649
+ * the cursor advances from the human's console approve/reject/edit actions.
650
+ */
651
+ async getPacingState(categoryId) {
652
+ if (this.store)
653
+ return this.store.getPacingState(categoryId);
654
+ return this.get(`/v1/categories/${encodeURIComponent(categoryId)}/pacing-state`);
655
+ }
656
+ // ---- Writing rules + house-style + precedence ladder + audit/undo ------
657
+ /**
658
+ * Get the ORDERED active rule set (`GET /v1/rules?category_id=&scope=`). The §7
659
+ * precedence ladder is applied SERVER-SIDE (NO LLM): hard>soft; per-agent>category>
660
+ * general; human>agent; newest rev/created_at; higher priority; plus a soft cap.
661
+ * Includes the general/house-style layer (D2) IN ADDITION to the category's rules.
662
+ */
663
+ async getRules(input = {}) {
664
+ if (this.store)
665
+ return this.store.getRules(input);
666
+ const query = {};
667
+ if (input.category_id)
668
+ query.category_id = input.category_id;
669
+ if (input.scope)
670
+ query.scope = input.scope;
671
+ return this.get("/v1/rules", query);
672
+ }
673
+ /**
674
+ * Save / edit a writing rule (`PUT /v1/rules`) — append-only by supersession (D11).
675
+ * scope='general' iff category_id is empty (house-style, D2). With supersedes_id
676
+ * the write is an EDIT (rev+1, same lineage). Writes a create/supersede audit row.
677
+ */
678
+ async saveRule(input) {
679
+ if (this.store)
680
+ return this.store.saveRule(input);
681
+ const body = { rule_text: input.rule_text };
682
+ if (input.scope)
683
+ body.scope = input.scope;
684
+ if (input.category_id)
685
+ body.category_id = input.category_id;
686
+ if (input.kind)
687
+ body.kind = input.kind;
688
+ if (input.priority !== undefined)
689
+ body.priority = input.priority;
690
+ if (input.source_review_id)
691
+ body.source_review_id = input.source_review_id;
692
+ if (input.source_turn_id)
693
+ body.source_turn_id = input.source_turn_id;
694
+ if (input.supersedes_id)
695
+ body.supersedes_id = input.supersedes_id;
696
+ if (input.scope_agent_id)
697
+ body.scope_agent_id = input.scope_agent_id;
698
+ if (input.propagate_to_pending !== undefined)
699
+ body.propagate_to_pending = input.propagate_to_pending;
700
+ if (input.suggested_batch !== undefined)
701
+ body.suggested_batch = input.suggested_batch;
702
+ return this.put("/v1/rules", body);
703
+ }
704
+ /**
705
+ * Promote a rule between the category and general/house-style layers
706
+ * (`POST /v1/rules/{id}/promote`) via a supersession.
707
+ */
708
+ async promoteRule(id, toScope) {
709
+ if (this.store)
710
+ return this.store.promoteRule(id, toScope);
711
+ return this.post(`/v1/rules/${encodeURIComponent(id)}/promote`, { to_scope: toScope });
712
+ }
713
+ /** Retire a rule (`POST /v1/rules/{id}/retire`) — soft delete; history survives. */
714
+ async retireRule(id) {
715
+ if (this.store)
716
+ return this.store.retireRule(id);
717
+ return this.post(`/v1/rules/${encodeURIComponent(id)}/retire`, {});
718
+ }
719
+ /**
720
+ * Read the rule/category change audit log (`GET /v1/rules/audit`) — read-only,
721
+ * agent-visible (the audit log is the shared safety net, D11).
722
+ */
723
+ async getRuleAudit(input = {}) {
724
+ if (this.store)
725
+ return this.store.getRuleAudit(input);
726
+ const query = {};
727
+ if (input.entity_kind)
728
+ query.entity_kind = input.entity_kind;
729
+ if (input.entity_id)
730
+ query.entity_id = input.entity_id;
731
+ return this.get("/v1/rules/audit", query);
732
+ }
733
+ /**
734
+ * Undo a rule change (`POST /v1/rules/audit/{udo_id}/undo`) — restore the prior
735
+ * version as a forward 'restore' supersession (D11; agents may undo too).
736
+ * Idempotent: a re-undo of an already-undone row is a clean 409.
737
+ */
738
+ async undoRuleChange(udoId) {
739
+ if (this.store)
740
+ return this.store.undoRuleChange(udoId);
741
+ return this.post(`/v1/rules/audit/${encodeURIComponent(udoId)}/undo`, {});
742
+ }
743
+ // ---- read / list / search ---------------------------------------------
744
+ /**
745
+ * List messages in an inbox, newest-first. Canonical contract:
746
+ * `GET /v1/inboxes/{inbox_id}/messages` with optional exact-field filters
747
+ * (from/to/subject), an `unread=true` filter (native IMAP \Seen), and
748
+ * limit/offset paging. Returns the canonical `Page<Message>` ({items,total}).
749
+ */
750
+ async listMessages(input) {
751
+ if (this.store) {
752
+ return this.store.listMessages({
753
+ inbox: input.inbox,
754
+ limit: input.limit,
755
+ offset: input.offset,
756
+ unreadOnly: input.unread_only,
757
+ from: input.from,
758
+ to: input.to,
759
+ subject: input.subject,
760
+ });
761
+ }
762
+ return this.get(`/v1/inboxes/${encodeURIComponent(input.inbox)}/messages`, {
763
+ limit: input.limit,
764
+ offset: input.offset,
765
+ // The server reads the native \Seen flag; `unread=true` keeps only unread.
766
+ unread: input.unread_only ? "true" : undefined,
767
+ from: input.from,
768
+ to: input.to,
769
+ subject: input.subject,
770
+ });
771
+ }
772
+ /** Fetch a single message by its opaque id (`GET /v1/messages/{id}`). */
773
+ async getMessage(id) {
774
+ if (this.store)
775
+ return this.store.getMessage(id);
776
+ return this.get(`/v1/messages/${encodeURIComponent(id)}`);
777
+ }
778
+ /**
779
+ * List a message's attachment metadata
780
+ * (`GET /v1/inboxes/{inbox_id}/messages/{id}/attachments` → `Page<Attachment>`).
781
+ */
782
+ async listAttachments(input) {
783
+ if (this.store)
784
+ return this.store.listAttachments(input.message_id);
785
+ return this.get(`/v1/inboxes/${encodeURIComponent(input.inbox)}/messages/${encodeURIComponent(input.message_id)}/attachments`);
786
+ }
787
+ /**
788
+ * Download one attachment's bytes (base64) + metadata
789
+ * (`GET /v1/inboxes/{inbox_id}/messages/{id}/attachments/{attId}` → raw bytes with
790
+ * Content-Type + Content-Disposition). The "easy attachment fetch."
791
+ */
792
+ async getAttachment(input) {
793
+ if (this.store)
794
+ return this.store.getAttachment(input.message_id, input.attachment_id);
795
+ return this.getBinary(`/v1/inboxes/${encodeURIComponent(input.inbox)}/messages/${encodeURIComponent(input.message_id)}/attachments/${encodeURIComponent(input.attachment_id)}`);
796
+ }
797
+ /**
798
+ * Mark a message read/unread via the native IMAP \Seen flag
799
+ * (`PATCH /v1/inboxes/{inbox_id}/messages/{id}` {read}). The inbox is resolved
800
+ * from the message id. Returns the updated message.
801
+ */
802
+ async markRead(input) {
803
+ if (this.store)
804
+ return this.store.markRead(input.id, input.read);
805
+ return this.request("PATCH", `/v1/inboxes/${encodeURIComponent(input.inbox)}/messages/${encodeURIComponent(input.id)}`, { read: input.read });
806
+ }
807
+ async listThreads(input) {
808
+ if (this.store)
809
+ return this.store.listThreads(input);
810
+ return this.get(`/v1/inboxes/${encodeURIComponent(input.inbox)}/threads`, {
811
+ limit: input.limit,
812
+ });
813
+ }
814
+ /**
815
+ * Fetch one thread (with its messages, oldest-first) by stable id, scoped to
816
+ * the owning inbox: `GET /v1/inboxes/{inbox_id}/threads/{id}`.
817
+ */
818
+ async getThread(input) {
819
+ if (this.store)
820
+ return this.store.getThread(input.inbox, input.thread_id);
821
+ return this.get(`/v1/inboxes/${encodeURIComponent(input.inbox)}/threads/${encodeURIComponent(input.thread_id)}`);
822
+ }
823
+ /**
824
+ * Delete a message: move it to Trash, or permanently expunge it when
825
+ * `expunge` is set (`DELETE /v1/inboxes/{inbox_id}/messages/{id}?expunge=`). A
826
+ * message already in Trash is always expunged.
827
+ */
828
+ async deleteMessage(input) {
829
+ if (this.store)
830
+ return this.store.deleteMessage(input.inbox, input.id, input.expunge ?? false);
831
+ const q = input.expunge ? "?expunge=true" : "";
832
+ return this.del(`/v1/inboxes/${encodeURIComponent(input.inbox)}/messages/${encodeURIComponent(input.id)}${q}`);
833
+ }
834
+ /**
835
+ * Delete an entire thread (every message): move to Trash, or expunge when
836
+ * `expunge` is set (`DELETE /v1/inboxes/{inbox_id}/threads/{id}?expunge=`).
837
+ */
838
+ async deleteThread(input) {
839
+ if (this.store)
840
+ return this.store.deleteThread(input.inbox, input.thread_id, input.expunge ?? false);
841
+ const q = input.expunge ? "?expunge=true" : "";
842
+ return this.del(`/v1/inboxes/${encodeURIComponent(input.inbox)}/threads/${encodeURIComponent(input.thread_id)}${q}`);
843
+ }
844
+ /**
845
+ * Batch mark read/unread and/or move folder for a list of message ids in one
846
+ * inbox (`PATCH /v1/inboxes/{inbox_id}/messages/batch`). At least one of
847
+ * `read` / `folder` must be set; returns the per-id `{updated, failed}` split.
848
+ */
849
+ async batchUpdateMessages(input) {
850
+ if (this.store)
851
+ return this.store.batchUpdateMessages(input.inbox, input.ids, input.read, input.folder);
852
+ const body = { ids: input.ids };
853
+ if (input.read !== undefined)
854
+ body.read = input.read;
855
+ if (input.folder !== undefined)
856
+ body.folder = input.folder;
857
+ return this.request("PATCH", `/v1/inboxes/${encodeURIComponent(input.inbox)}/messages/batch`, body);
858
+ }
859
+ /**
860
+ * Full-text search backed by IMAP SEARCH, scoped to one inbox
861
+ * (`GET /v1/inboxes/{inbox_id}/messages/search?q=...`). When `inbox` is omitted,
862
+ * iterate every inbox the agent owns and merge the results (newest-first).
863
+ */
864
+ async search(input) {
865
+ if (this.store)
866
+ return this.store.search(input);
867
+ const limit = input.limit ?? 20;
868
+ if (input.inbox) {
869
+ return this.get(`/v1/inboxes/${encodeURIComponent(input.inbox)}/messages/search`, { q: input.query, limit });
870
+ }
871
+ // No inbox specified: fan out across the agent's inboxes and merge.
872
+ const inboxes = await this.listInboxes(100);
873
+ const merged = [];
874
+ for (const ibx of inboxes.items) {
875
+ const page = await this.get(`/v1/inboxes/${encodeURIComponent(ibx.address)}/messages/search`, { q: input.query, limit });
876
+ merged.push(...page.items);
877
+ }
878
+ merged.sort((a, b) => b.date.localeCompare(a.date));
879
+ const items = merged.slice(0, limit);
880
+ return { items, total: merged.length };
881
+ }
882
+ // ---- wait_for_email (POST /v1/wait) -----------------------------------
883
+ async waitForEmail(input) {
884
+ if (this.store) {
885
+ return this.store.waitForEmail({
886
+ inbox: input.inbox,
887
+ from: input.from,
888
+ subject: input.subject,
889
+ regex: input.regex,
890
+ linkHint: input.link_hint,
891
+ timeoutMs: input.timeout_ms,
892
+ });
893
+ }
894
+ // The server blocks (IMAP poll) up to timeout_seconds; give the HTTP read a
895
+ // margin over it so the server, not the client, decides the no-match timeout.
896
+ // Field names are the canonical API ones: `match` (regex), `since_now`,
897
+ // `timeout_seconds`.
898
+ const started = Date.now();
899
+ const timeoutSeconds = Math.ceil(input.timeout_ms / 1000);
900
+ const wire = await this.post(`/v1/inboxes/${encodeURIComponent(input.inbox)}/wait`, {
901
+ from: input.from,
902
+ subject: input.subject,
903
+ match: input.regex,
904
+ link_hint: input.link_hint,
905
+ since_now: input.since_now,
906
+ timeout_seconds: timeoutSeconds,
907
+ }, input.timeout_ms + 5_000);
908
+ // Translate the canonical wire shape into the MCP tool's result shape.
909
+ const result = {
910
+ matched: !wire.timed_out,
911
+ waited_ms: Date.now() - started,
912
+ };
913
+ if (wire.message)
914
+ result.message = wire.message;
915
+ if (wire.extracted?.otp)
916
+ result.otp_code = wire.extracted.otp;
917
+ if (wire.extracted?.link)
918
+ result.verification_link = wire.extracted.link;
919
+ return result;
920
+ }
921
+ // ---- webhooks (CRUD; spec §6/§14) -------------------------------------
922
+ /**
923
+ * Register an inbound webhook (`POST /v1/webhooks`). The signing `secret` is
924
+ * returned ONCE here; deliveries are HMAC-signed in the canonical
925
+ * `X-Extrovert-Signature: t=<unix>,v1=<hex>` format.
926
+ */
927
+ async registerWebhook(input) {
928
+ if (this.store) {
929
+ return this.store.registerWebhook({
930
+ url: input.url,
931
+ events: input.events,
932
+ inbox: input.inbox,
933
+ clientId: input.client_id,
934
+ });
935
+ }
936
+ return this.post("/v1/webhooks", {
937
+ url: input.url,
938
+ events: input.events,
939
+ inbox: input.inbox,
940
+ client_id: input.client_id,
941
+ }, undefined, idempotencyHeader(input.client_id));
942
+ }
943
+ /** List registered webhooks (`GET /v1/webhooks`); secrets are redacted. */
944
+ async listWebhooks() {
945
+ if (this.store)
946
+ return this.store.listWebhooks();
947
+ return this.get("/v1/webhooks");
948
+ }
949
+ /** Get one webhook by id (`GET /v1/webhooks/{id}`); secret redacted. */
950
+ async getWebhook(id) {
951
+ if (this.store)
952
+ return this.store.getWebhook(id);
953
+ return this.get(`/v1/webhooks/${encodeURIComponent(id)}`);
954
+ }
955
+ /**
956
+ * Update a webhook in place (`PATCH /v1/webhooks/{id}`). Every field is
957
+ * optional; an omitted field is left unchanged (PATCH semantics). The signing
958
+ * secret is immutable and stays redacted in the response.
959
+ */
960
+ async updateWebhook(id, input) {
961
+ if (this.store)
962
+ return this.store.updateWebhook(id, input);
963
+ const body = {};
964
+ if (input.url !== undefined)
965
+ body.url = input.url;
966
+ if (input.events !== undefined)
967
+ body.events = input.events;
968
+ if (input.inbox !== undefined)
969
+ body.inbox = input.inbox;
970
+ if (input.active !== undefined)
971
+ body.active = input.active;
972
+ return this.patch(`/v1/webhooks/${encodeURIComponent(id)}`, body);
973
+ }
974
+ /** Delete a webhook by id (`DELETE /v1/webhooks/{id}`). */
975
+ async deleteWebhook(id) {
976
+ if (this.store)
977
+ return this.store.deleteWebhook(id);
978
+ await this.del(`/v1/webhooks/${encodeURIComponent(id)}`);
979
+ return { id, deleted: true };
980
+ }
981
+ // ---- contact allow/block lists (Slice 3) ------------------------------
982
+ /**
983
+ * Add an allow/block entry to an inbox's contact lists
984
+ * (`POST /v1/inboxes/{inbox_id}/lists`). A `block` entry rejects a matching
985
+ * recipient on send; when an `allow` entry exists, sends from this inbox are
986
+ * restricted to recipients that match one (allowlist mode).
987
+ */
988
+ async addContactListEntry(input) {
989
+ if (this.store) {
990
+ return this.store.addContactListEntry(input.inbox, {
991
+ kind: input.kind,
992
+ direction: input.direction,
993
+ pattern: input.pattern,
994
+ });
995
+ }
996
+ return this.post(`/v1/inboxes/${encodeURIComponent(input.inbox)}/lists`, {
997
+ kind: input.kind,
998
+ direction: input.direction,
999
+ pattern: input.pattern,
1000
+ });
1001
+ }
1002
+ /** List the contact-list entries governing an inbox (`GET /v1/inboxes/{inbox_id}/lists`). */
1003
+ async listContactListEntries(inbox) {
1004
+ if (this.store)
1005
+ return this.store.listContactListEntries(inbox);
1006
+ return this.get(`/v1/inboxes/${encodeURIComponent(inbox)}/lists`);
1007
+ }
1008
+ /** Delete a contact-list entry by id (`DELETE /v1/inboxes/{inbox_id}/lists/{id}`). */
1009
+ async deleteContactListEntry(inbox, id) {
1010
+ if (this.store)
1011
+ return this.store.deleteContactListEntry(inbox, id);
1012
+ await this.del(`/v1/inboxes/${encodeURIComponent(inbox)}/lists/${encodeURIComponent(id)}`);
1013
+ return { id, deleted: true };
1014
+ }
1015
+ // ---- domains (Slice 5; privileged, domain:manage scope) ---------------
1016
+ /** List the customer's onboarded domains (`GET /v1/domains`). Canonical page envelope. */
1017
+ async listDomains() {
1018
+ if (this.store)
1019
+ return this.store.listDomains();
1020
+ return this.get("/v1/domains");
1021
+ }
1022
+ /** Get one domain's detail + verification status + the DNS records to set (`GET /v1/domains/{domain}`). */
1023
+ async getDomain(domain) {
1024
+ if (this.store)
1025
+ return this.store.getDomain(domain);
1026
+ return this.get(`/v1/domains/${encodeURIComponent(domain)}`);
1027
+ }
1028
+ /** Onboard/add a domain for the customer (`POST /v1/domains`). */
1029
+ async onboardDomain(input) {
1030
+ if (this.store)
1031
+ return this.store.onboardDomain(input);
1032
+ const body = { domain: input.domain };
1033
+ if (input.mode !== undefined)
1034
+ body.mode = input.mode;
1035
+ if (input.mail_host_ip !== undefined)
1036
+ body.mail_host_ip = input.mail_host_ip;
1037
+ if (input.scope !== undefined)
1038
+ body.scope = input.scope;
1039
+ if (input.project_id !== undefined)
1040
+ body.project_id = input.project_id;
1041
+ return this.post("/v1/domains", body);
1042
+ }
1043
+ /** Trigger/refresh verification for a domain (`POST /v1/domains/{domain}/verify`). */
1044
+ async verifyDomain(domain) {
1045
+ if (this.store)
1046
+ return this.store.verifyDomain(domain);
1047
+ return this.post(`/v1/domains/${encodeURIComponent(domain)}/verify`);
1048
+ }
1049
+ /**
1050
+ * Offboard (remove) a domain from the customer (`DELETE /v1/domains/{domain}`).
1051
+ * The API accepts the request (HTTP 202) and runs the teardown — reaping the
1052
+ * outbound provider sender identities + routing rows, then scrubbing the DNS
1053
+ * zone/records and the domain row — as an async job. This returns the job id and
1054
+ * a poll URL (`status_url`, i.e. `GET /v1/jobs/{job_id}`); offboarding is
1055
+ * ACCEPTED, not yet complete. Poll with `getJob(job_id)` (the `get_job` tool)
1056
+ * until the status is terminal.
1057
+ */
1058
+ async offboardDomain(domain) {
1059
+ if (this.store)
1060
+ return this.store.offboardDomain(domain);
1061
+ const res = await this.del(`/v1/domains/${encodeURIComponent(domain)}`);
1062
+ const jobId = res?.job_id ?? "";
1063
+ return {
1064
+ domain,
1065
+ job_id: jobId,
1066
+ status: res?.status ?? "queued",
1067
+ status_url: res?.status_url ?? (jobId ? `/v1/jobs/${jobId}` : ""),
1068
+ };
1069
+ }
1070
+ /**
1071
+ * Poll the status of an async job (`GET /v1/jobs/{job_id}`) — currently only
1072
+ * the domain-offboard teardown enqueues one. `status` is terminal on
1073
+ * succeeded/failed/cancelled; keep polling otherwise.
1074
+ */
1075
+ async getJob(jobId) {
1076
+ if (this.store)
1077
+ return this.store.getJob(jobId);
1078
+ return this.get(`/v1/jobs/${encodeURIComponent(jobId)}`);
1079
+ }
1080
+ // ---- suppressions (recipient opt-outs / list-unsubscribe) -------------
1081
+ /**
1082
+ * Pre-check whether the caller's org suppresses a recipient
1083
+ * (`GET /v1/suppressions?recipient=…`). Returns `{recipient, suppressed, rows}`
1084
+ * over the caller's OWN active org rows — never a global/shared/cross-tenant
1085
+ * opt-out. Use it BEFORE composing to skip a would-be-rejected recipient.
1086
+ */
1087
+ async precheckSuppression(recipient) {
1088
+ if (this.store)
1089
+ return this.store.precheckSuppression(recipient);
1090
+ // The `recipient` query param routes the server to the pre-check shape.
1091
+ return this.get("/v1/suppressions", { recipient });
1092
+ }
1093
+ /**
1094
+ * List the caller's own org suppression rows (`GET /v1/suppressions`). No
1095
+ * `recipient` is sent here — that param switches the server to the pre-check.
1096
+ */
1097
+ async listSuppressions(input = {}) {
1098
+ if (this.store)
1099
+ return this.store.listSuppressions(input);
1100
+ const query = {};
1101
+ if (input.scope)
1102
+ query.scope = input.scope;
1103
+ if (input.include_revoked)
1104
+ query.include_revoked = "true";
1105
+ if (input.limit !== undefined)
1106
+ query.limit = input.limit;
1107
+ if (input.cursor !== undefined)
1108
+ query.cursor = input.cursor;
1109
+ return this.get("/v1/suppressions", query);
1110
+ }
1111
+ /**
1112
+ * Revoke one org-scope suppression row (`POST /v1/suppressions/{id}/revoke`),
1113
+ * re-enabling sending to that recipient. A `reason` is REQUIRED (empty is a 400)
1114
+ * and is audit-logged. A foreign/global/shared id is an indistinguishable 404.
1115
+ */
1116
+ async revokeSuppression(id, reason) {
1117
+ if (this.store)
1118
+ return this.store.revokeSuppression(id, reason);
1119
+ return this.post(`/v1/suppressions/${encodeURIComponent(id)}/revoke`, {
1120
+ reason,
1121
+ });
1122
+ }
1123
+ // ---- reputation / deliverability (diverse-smtp M7) --------------------
1124
+ /**
1125
+ * The caller's org deliverability rollup (`GET /v1/reputation`): derived status
1126
+ * badge, per-provider/tenant sending status, latest Sends/Bounces/Complaints
1127
+ * window, and open-finding count. Read-only; strictly org-scoped. Advisor
1128
+ * findings show `unavailable_vdm_disabled` when VDM is off.
1129
+ */
1130
+ async getReputation() {
1131
+ if (this.store)
1132
+ return this.store.getReputation();
1133
+ return this.get("/v1/reputation");
1134
+ }
1135
+ /**
1136
+ * List the caller's org deliverability findings (`GET /v1/reputation/findings`),
1137
+ * newest-first, with optional status/severity/domain/sender filters. Read-only.
1138
+ */
1139
+ async listDeliverabilityFindings(input = {}) {
1140
+ if (this.store)
1141
+ return this.store.listDeliverabilityFindings(input);
1142
+ const query = {};
1143
+ if (input.status)
1144
+ query.status = input.status;
1145
+ if (input.severity)
1146
+ query.severity = input.severity;
1147
+ if (input.domain)
1148
+ query.domain = input.domain;
1149
+ if (input.sender)
1150
+ query.sender = input.sender;
1151
+ if (input.limit !== undefined)
1152
+ query.limit = input.limit;
1153
+ if (input.cursor !== undefined)
1154
+ query.cursor = input.cursor;
1155
+ return this.get("/v1/reputation/findings", query);
1156
+ }
1157
+ // ---- low-level HTTP ---------------------------------------------------
1158
+ async get(path, query) {
1159
+ return this.request("GET", path, undefined, query);
1160
+ }
1161
+ /**
1162
+ * Fetch a binary endpoint (the attachment download) and return its bytes as
1163
+ * base64 plus the filename + content type pulled from the response headers.
1164
+ * Bypasses the JSON `request` path so arbitrary bytes survive intact.
1165
+ */
1166
+ async getBinary(path) {
1167
+ const url = new URL(this.config.apiBaseUrl + path);
1168
+ const controller = new AbortController();
1169
+ const timer = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
1170
+ const headers = { "User-Agent": "extrovert-mcp/0.1.0" };
1171
+ if (this.apiKey)
1172
+ headers.Authorization = `Bearer ${this.apiKey}`;
1173
+ let res;
1174
+ try {
1175
+ res = await fetch(url, { method: "GET", headers, signal: controller.signal });
1176
+ }
1177
+ catch (err) {
1178
+ const reason = err instanceof Error ? err.message : String(err);
1179
+ throw new ExtrovertApiError(`Request to GET ${path} failed: ${reason}`, 0, "network_error");
1180
+ }
1181
+ finally {
1182
+ clearTimeout(timer);
1183
+ }
1184
+ if (!res.ok) {
1185
+ const raw = await res.text();
1186
+ const parsed = raw ? safeJsonParse(raw) : undefined;
1187
+ throw errorFromBody(res.status, res.statusText, parsed);
1188
+ }
1189
+ const bytes = new Uint8Array(await res.arrayBuffer());
1190
+ return {
1191
+ filename: filenameFromDisposition(res.headers.get("content-disposition") ?? ""),
1192
+ content_type: res.headers.get("content-type") ?? "application/octet-stream",
1193
+ content_base64: bytesToBase64(bytes),
1194
+ };
1195
+ }
1196
+ async post(path, body, timeoutMs, extraHeaders) {
1197
+ return this.request("POST", path, body, undefined, timeoutMs, extraHeaders);
1198
+ }
1199
+ async del(path) {
1200
+ return this.request("DELETE", path);
1201
+ }
1202
+ async patch(path, body) {
1203
+ return this.request("PATCH", path, body);
1204
+ }
1205
+ async put(path, body) {
1206
+ return this.request("PUT", path, body);
1207
+ }
1208
+ async request(method, path, body, query, timeoutMs, extraHeaders) {
1209
+ const url = new URL(this.config.apiBaseUrl + path);
1210
+ if (query) {
1211
+ for (const [k, v] of Object.entries(query)) {
1212
+ if (v !== undefined && v !== null)
1213
+ url.searchParams.set(k, String(v));
1214
+ }
1215
+ }
1216
+ const controller = new AbortController();
1217
+ const timer = setTimeout(() => controller.abort(), timeoutMs ?? this.config.requestTimeoutMs);
1218
+ const headers = {
1219
+ Accept: "application/json",
1220
+ "User-Agent": "extrovert-mcp/0.1.0",
1221
+ };
1222
+ if (this.apiKey)
1223
+ headers.Authorization = `Bearer ${this.apiKey}`;
1224
+ if (body !== undefined)
1225
+ headers["Content-Type"] = "application/json";
1226
+ if (extraHeaders) {
1227
+ for (const [k, v] of Object.entries(extraHeaders)) {
1228
+ if (v)
1229
+ headers[k] = v;
1230
+ }
1231
+ }
1232
+ let res;
1233
+ try {
1234
+ res = await fetch(url, {
1235
+ method,
1236
+ headers,
1237
+ body: body !== undefined ? JSON.stringify(body) : undefined,
1238
+ signal: controller.signal,
1239
+ });
1240
+ }
1241
+ catch (err) {
1242
+ const reason = err instanceof Error ? err.message : String(err);
1243
+ throw new ExtrovertApiError(`Request to ${method} ${path} failed: ${reason}`, 0, "network_error");
1244
+ }
1245
+ finally {
1246
+ clearTimeout(timer);
1247
+ }
1248
+ const raw = await res.text();
1249
+ const parsed = raw ? safeJsonParse(raw) : undefined;
1250
+ if (!res.ok) {
1251
+ throw errorFromBody(res.status, res.statusText, parsed);
1252
+ }
1253
+ return parsed;
1254
+ }
1255
+ setSessionKey(key) {
1256
+ const trimmed = key?.trim();
1257
+ if (trimmed)
1258
+ this.apiKey = trimmed;
1259
+ }
1260
+ }
1261
+ function safeJsonParse(text) {
1262
+ try {
1263
+ return JSON.parse(text);
1264
+ }
1265
+ catch {
1266
+ return text;
1267
+ }
1268
+ }
1269
+ /**
1270
+ * Build an {@link ExtrovertApiError} from a non-2xx response body, supporting BOTH
1271
+ * error shapes the redesigned API can return:
1272
+ * - RFC-9457 problem+json (the END STATE, served as application/problem+json):
1273
+ * `{type, title, status, detail, code, request_id, errors[]}`. `code` is the
1274
+ * closed machine enum clients switch on (e.g. `forbidden_scope`,
1275
+ * `breadth_required`, `idempotency_conflict`); the message prefers `detail`,
1276
+ * then `title`.
1277
+ * - the legacy `{error, message}` envelope (back-compat during migration): `error`
1278
+ * is the machine code, `message` the human detail.
1279
+ * The opaque `request_id` (when present) is appended so an agent can quote it for
1280
+ * support. `code` always reaches {@link ExtrovertApiError.code} so the MCP tool
1281
+ * error text surfaces the machine code.
1282
+ *
1283
+ * `errors[]` is carried through VERBATIM onto the error. The server puts the full
1284
+ * human remediation in `detail` precisely because this surface renders `err.message`
1285
+ * — but the machine duplicate in `errors[]` is what makes a 422 `intent_required`
1286
+ * or a 409 `stale` recoverable in ONE turn (the exact JSON to add; the current
1287
+ * revision to re-CAS against; the verbs that ARE legal). Dropping it on the floor
1288
+ * here is why the remediation never reached the model.
1289
+ */
1290
+ function errorFromBody(status, statusText, parsed) {
1291
+ const body = (parsed && typeof parsed === "object" ? parsed : undefined);
1292
+ // problem+json carries detail/title; the legacy envelope carries message/error.
1293
+ const message = body?.detail ??
1294
+ body?.message ??
1295
+ body?.title ??
1296
+ body?.error ??
1297
+ `${status} ${statusText}`;
1298
+ // The machine code: problem+json `code`, else the legacy `error` code.
1299
+ const code = body?.code ?? body?.error;
1300
+ const withReqId = body?.request_id ? `${message} (request_id: ${body.request_id})` : message;
1301
+ return new ExtrovertApiError(withReqId, status, code, parsed, problemFieldsOf(body?.errors));
1302
+ }
1303
+ /**
1304
+ * Narrow an untrusted `problem.errors` value to the `{field, code, detail}` hints.
1305
+ * Anything that is not an array of objects with string `field` + `code` is dropped
1306
+ * rather than rendered: a malformed hint must never turn a useful error message
1307
+ * into `[object Object]`.
1308
+ */
1309
+ function problemFieldsOf(raw) {
1310
+ if (!Array.isArray(raw))
1311
+ return undefined;
1312
+ const fields = [];
1313
+ for (const entry of raw) {
1314
+ if (!entry || typeof entry !== "object")
1315
+ continue;
1316
+ const e = entry;
1317
+ if (typeof e.field !== "string" || typeof e.code !== "string")
1318
+ continue;
1319
+ fields.push({
1320
+ field: e.field,
1321
+ code: e.code,
1322
+ detail: typeof e.detail === "string" ? e.detail : undefined,
1323
+ });
1324
+ }
1325
+ return fields.length ? fields : undefined;
1326
+ }
1327
+ /**
1328
+ * Build the `Idempotency-Key` header map for a client-supplied key, or undefined
1329
+ * when none was provided. The same key name is used across Go + MCP + SDK; a
1330
+ * mismatch silently breaks server-side dedup.
1331
+ */
1332
+ function idempotencyHeader(clientId) {
1333
+ const key = clientId?.trim();
1334
+ return key ? { "Idempotency-Key": key } : undefined;
1335
+ }
1336
+ /** Runtime-agnostic base64 of a byte array (no Buffer dependency). */
1337
+ function bytesToBase64(bytes) {
1338
+ let binary = "";
1339
+ for (let i = 0; i < bytes.length; i++)
1340
+ binary += String.fromCharCode(bytes[i]);
1341
+ if (typeof btoa === "function")
1342
+ return btoa(binary);
1343
+ const g = globalThis;
1344
+ if (g.Buffer)
1345
+ return g.Buffer.from(binary, "binary").toString("base64");
1346
+ throw new Error("No base64 encoder available in this runtime.");
1347
+ }
1348
+ /** Pull a filename out of a Content-Disposition header value. */
1349
+ function filenameFromDisposition(disposition) {
1350
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition);
1351
+ return m ? decodeURIComponent(m[1].trim()) : "";
1352
+ }
1353
+ export { NotFoundError };
1354
+ //# sourceMappingURL=client.js.map