conduyt-mcp 4.11.0 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -1,15 +1,58 @@
1
1
  import type { ConduytConfig } from "./types.js";
2
+ /**
3
+ * Structured API error (SOL-017). `code` distinguishes transport-level
4
+ * failures (timeout / response_too_large / network) from HTTP failures
5
+ * (code "http", status set). `retryable` reflects whether the FAILURE class
6
+ * is transient — the client itself only ever retries idempotent GETs.
7
+ * The message keeps the legacy "Conduyt API <status>: <msg>" shape so
8
+ * existing tool-level catch blocks keep working.
9
+ */
10
+ export declare class ConduytApiError extends Error {
11
+ readonly status: number | null;
12
+ readonly code: "http" | "timeout" | "response_too_large" | "network";
13
+ readonly retryable: boolean;
14
+ constructor(opts: {
15
+ message: string;
16
+ status?: number | null;
17
+ code: ConduytApiError["code"];
18
+ retryable: boolean;
19
+ cause?: unknown;
20
+ });
21
+ }
22
+ /** Per-call overrides for legitimately long/large operations (GDPR export,
23
+ * AI chat) — everything else rides the bounded defaults. */
24
+ export interface RequestOptions {
25
+ timeoutMs?: number;
26
+ maxResponseBytes?: number;
27
+ }
2
28
  export declare class ConduytClient {
3
29
  private baseUrl;
4
30
  private apiKey;
31
+ private timeoutMs;
32
+ private maxResponseBytes;
33
+ private retryBaseMs;
5
34
  constructor(config: ConduytConfig);
6
- request(method: string, path: string, body?: unknown): Promise<unknown>;
7
- requestText(method: string, path: string, body?: unknown): Promise<string>;
8
- get(path: string): Promise<unknown>;
9
- post(path: string, body: unknown): Promise<unknown>;
10
- postText(path: string, body: unknown): Promise<string>;
11
- patch(path: string, body: unknown): Promise<unknown>;
12
- del(path: string): Promise<unknown>;
35
+ /**
36
+ * One bounded HTTP attempt: deadline via AbortSignal, size cap enforced
37
+ * from Content-Length before the body is read AND from the actual bytes
38
+ * after (chunked responses carry no length header).
39
+ */
40
+ private attempt;
41
+ private httpError;
42
+ private retryDelayMs;
43
+ /**
44
+ * Retries are for idempotent GETs ONLY: transient statuses (429/502/503/
45
+ * 504), timeouts, and network errors. Mutations are never replayed — the
46
+ * server may have applied the write before the failure surfaced.
47
+ */
48
+ private requestRaw;
49
+ request(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<unknown>;
50
+ requestText(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<string>;
51
+ get(path: string, opts?: RequestOptions): Promise<unknown>;
52
+ post(path: string, body: unknown, opts?: RequestOptions): Promise<unknown>;
53
+ postText(path: string, body: unknown, opts?: RequestOptions): Promise<string>;
54
+ patch(path: string, body: unknown, opts?: RequestOptions): Promise<unknown>;
55
+ del(path: string, opts?: RequestOptions): Promise<unknown>;
13
56
  }
14
57
  export declare function formatResult(data: unknown): {
15
58
  content: Array<{
package/dist/client.js CHANGED
@@ -1,6 +1,36 @@
1
+ /**
2
+ * Structured API error (SOL-017). `code` distinguishes transport-level
3
+ * failures (timeout / response_too_large / network) from HTTP failures
4
+ * (code "http", status set). `retryable` reflects whether the FAILURE class
5
+ * is transient — the client itself only ever retries idempotent GETs.
6
+ * The message keeps the legacy "Conduyt API <status>: <msg>" shape so
7
+ * existing tool-level catch blocks keep working.
8
+ */
9
+ export class ConduytApiError extends Error {
10
+ status;
11
+ code;
12
+ retryable;
13
+ constructor(opts) {
14
+ super(opts.message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
15
+ this.name = "ConduytApiError";
16
+ this.status = opts.status ?? null;
17
+ this.code = opts.code;
18
+ this.retryable = opts.retryable;
19
+ }
20
+ }
21
+ /** Bounded defaults; each overridable via config / env (see src/index.ts). */
22
+ const DEFAULT_TIMEOUT_MS = 30_000;
23
+ const DEFAULT_MAX_RESPONSE_BYTES = 5_000_000;
24
+ const DEFAULT_RETRY_BASE_MS = 500;
25
+ const MAX_GET_RETRIES = 2;
26
+ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
27
+ const MAX_RETRY_AFTER_MS = 10_000;
1
28
  export class ConduytClient {
2
29
  baseUrl;
3
30
  apiKey;
31
+ timeoutMs;
32
+ maxResponseBytes;
33
+ retryBaseMs;
4
34
  constructor(config) {
5
35
  // Strip trailing slashes, then guard against a misconfigured base URL that
6
36
  // already includes the API prefix. Every tool builds paths as `/api/v1/...`,
@@ -17,65 +47,197 @@ export class ConduytClient {
17
47
  }
18
48
  this.baseUrl = base;
19
49
  this.apiKey = config.apiKey;
50
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
51
+ this.maxResponseBytes = config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
52
+ this.retryBaseMs = config.retryBaseMs ?? DEFAULT_RETRY_BASE_MS;
20
53
  }
21
- async request(method, path, body) {
54
+ /**
55
+ * One bounded HTTP attempt: deadline via AbortSignal, size cap enforced
56
+ * from Content-Length before the body is read AND from the actual bytes
57
+ * after (chunked responses carry no length header).
58
+ */
59
+ async attempt(method, path, body, timeoutMs, maxResponseBytes) {
22
60
  const url = `${this.baseUrl}${path}`;
23
61
  const headers = {
24
62
  Authorization: `Bearer ${this.apiKey}`,
25
63
  "Content-Type": "application/json",
26
64
  };
27
- const res = await fetch(url, {
28
- method,
29
- headers,
30
- body: body ? JSON.stringify(body) : undefined,
65
+ let res;
66
+ try {
67
+ res = await fetch(url, {
68
+ method,
69
+ headers,
70
+ body: body != null ? JSON.stringify(body) : undefined,
71
+ signal: AbortSignal.timeout(timeoutMs),
72
+ });
73
+ }
74
+ catch (err) {
75
+ if (err?.name === "TimeoutError" || err?.name === "AbortError") {
76
+ throw new ConduytApiError({
77
+ message: `Conduyt API timeout: no response within ${timeoutMs}ms for ${method} ${path}`,
78
+ code: "timeout",
79
+ retryable: true,
80
+ cause: err,
81
+ });
82
+ }
83
+ throw new ConduytApiError({
84
+ message: `Conduyt API network error for ${method} ${path}: ${err?.message ?? String(err)}`,
85
+ code: "network",
86
+ retryable: true,
87
+ cause: err,
88
+ });
89
+ }
90
+ const tooLarge = (bytes) => new ConduytApiError({
91
+ message: `Conduyt API response too large (${bytes} bytes > ${maxResponseBytes} cap) for ${method} ${path}. Narrow the query or paginate.`,
92
+ status: res.status,
93
+ code: "response_too_large",
94
+ retryable: false,
31
95
  });
32
- const json = await res.json().catch(() => null);
33
- if (!res.ok) {
34
- const msg = json?.error || res.statusText;
35
- throw new Error(`Conduyt API ${res.status}: ${msg}`);
96
+ // Declared-size fast path (uncompressed responses)...
97
+ const declared = Number(res.headers.get("content-length") || 0);
98
+ if (declared > maxResponseBytes)
99
+ throw tooLarge(declared);
100
+ // ...then a STREAMED byte-accurate read: chunked/compressed responses
101
+ // carry no usable length header, and reading the whole body before
102
+ // checking would defeat the memory bound. The read stops (and the
103
+ // stream is cancelled) the moment the cap is crossed.
104
+ let text = "";
105
+ if (res.body) {
106
+ const reader = res.body.getReader();
107
+ const chunks = [];
108
+ let bytes = 0;
109
+ try {
110
+ for (;;) {
111
+ const { done, value } = await reader.read();
112
+ if (done)
113
+ break;
114
+ bytes += value.byteLength;
115
+ if (bytes > maxResponseBytes) {
116
+ await reader.cancel().catch(() => { });
117
+ throw tooLarge(bytes);
118
+ }
119
+ chunks.push(value);
120
+ }
121
+ }
122
+ catch (err) {
123
+ if (err instanceof ConduytApiError)
124
+ throw err;
125
+ throw new ConduytApiError({
126
+ message: `Conduyt API network error reading response for ${method} ${path}: ${err?.message ?? String(err)}`,
127
+ code: "network",
128
+ retryable: true,
129
+ cause: err,
130
+ });
131
+ }
132
+ const buf = new Uint8Array(bytes);
133
+ let off = 0;
134
+ for (const c of chunks) {
135
+ buf.set(c, off);
136
+ off += c.byteLength;
137
+ }
138
+ text = new TextDecoder().decode(buf);
36
139
  }
37
- return json;
140
+ else {
141
+ text = await res.text().catch(() => "");
142
+ if (text.length > maxResponseBytes)
143
+ throw tooLarge(text.length);
144
+ }
145
+ return { res, text };
38
146
  }
39
- async requestText(method, path, body) {
40
- const url = `${this.baseUrl}${path}`;
41
- const headers = {
42
- Authorization: `Bearer ${this.apiKey}`,
43
- "Content-Type": "application/json",
44
- };
45
- const res = await fetch(url, {
46
- method,
47
- headers,
48
- body: body !== undefined ? JSON.stringify(body) : undefined,
147
+ httpError(res, text) {
148
+ let msg = res.statusText;
149
+ try {
150
+ const json = JSON.parse(text);
151
+ if (json?.error)
152
+ msg = json.error;
153
+ }
154
+ catch {
155
+ if (text)
156
+ msg = text.slice(0, 500);
157
+ }
158
+ return new ConduytApiError({
159
+ message: `Conduyt API ${res.status}: ${msg}`,
160
+ status: res.status,
161
+ code: "http",
162
+ retryable: RETRYABLE_STATUSES.has(res.status),
49
163
  });
50
- const text = await res.text().catch(() => "");
51
- if (!res.ok) {
52
- let msg = res.statusText;
164
+ }
165
+ retryDelayMs(res, attemptIndex) {
166
+ const retryAfter = Number(res?.headers.get("retry-after"));
167
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) {
168
+ return Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS);
169
+ }
170
+ return Math.min(this.retryBaseMs * 2 ** attemptIndex, MAX_RETRY_AFTER_MS);
171
+ }
172
+ /**
173
+ * Retries are for idempotent GETs ONLY: transient statuses (429/502/503/
174
+ * 504), timeouts, and network errors. Mutations are never replayed — the
175
+ * server may have applied the write before the failure surfaced.
176
+ */
177
+ async requestRaw(method, path, body, opts) {
178
+ const timeoutMs = opts?.timeoutMs ?? this.timeoutMs;
179
+ const maxResponseBytes = opts?.maxResponseBytes ?? this.maxResponseBytes;
180
+ const canRetry = method === "GET";
181
+ const attempts = canRetry ? MAX_GET_RETRIES + 1 : 1;
182
+ // TOTAL time budget across every attempt + backoff: 1.5x the per-call
183
+ // deadline. Without it, 3 stacked attempt timeouts + backoffs could hold
184
+ // an agent turn ~110s while the caller believed the bound was 30s.
185
+ const totalBudgetMs = Math.ceil(timeoutMs * 1.5);
186
+ const startedAt = Date.now();
187
+ let lastError = null;
188
+ for (let i = 0; i < attempts; i++) {
189
+ const remaining = totalBudgetMs - (Date.now() - startedAt);
190
+ let res = null;
53
191
  try {
54
- const json = JSON.parse(text);
55
- msg = json.error || msg;
192
+ const out = await this.attempt(method, path, body, Math.max(1, Math.min(timeoutMs, remaining)), maxResponseBytes);
193
+ res = out.res;
194
+ if (out.res.ok)
195
+ return out.text;
196
+ lastError = this.httpError(out.res, out.text);
56
197
  }
57
- catch {
58
- if (text)
59
- msg = text;
198
+ catch (err) {
199
+ if (!(err instanceof ConduytApiError))
200
+ throw err;
201
+ if (err.code === "response_too_large")
202
+ throw err;
203
+ lastError = err;
60
204
  }
61
- throw new Error(`Conduyt API ${res.status}: ${msg}`);
205
+ const isLast = i === attempts - 1;
206
+ if (!canRetry || isLast || !lastError.retryable)
207
+ throw lastError;
208
+ const delay = this.retryDelayMs(res, i);
209
+ if (Date.now() - startedAt + delay >= totalBudgetMs)
210
+ throw lastError;
211
+ await new Promise((r) => setTimeout(r, delay));
212
+ }
213
+ throw lastError;
214
+ }
215
+ async request(method, path, body, opts) {
216
+ const text = await this.requestRaw(method, path, body, opts);
217
+ try {
218
+ return JSON.parse(text);
219
+ }
220
+ catch {
221
+ return null;
62
222
  }
63
- return text;
64
223
  }
65
- get(path) {
66
- return this.request("GET", path);
224
+ async requestText(method, path, body, opts) {
225
+ return this.requestRaw(method, path, body, opts);
226
+ }
227
+ get(path, opts) {
228
+ return this.request("GET", path, undefined, opts);
67
229
  }
68
- post(path, body) {
69
- return this.request("POST", path, body);
230
+ post(path, body, opts) {
231
+ return this.request("POST", path, body, opts);
70
232
  }
71
- postText(path, body) {
72
- return this.requestText("POST", path, body);
233
+ postText(path, body, opts) {
234
+ return this.requestText("POST", path, body, opts);
73
235
  }
74
- patch(path, body) {
75
- return this.request("PATCH", path, body);
236
+ patch(path, body, opts) {
237
+ return this.request("PATCH", path, body, opts);
76
238
  }
77
- del(path) {
78
- return this.request("DELETE", path);
239
+ del(path, opts) {
240
+ return this.request("DELETE", path, undefined, opts);
79
241
  }
80
242
  }
81
243
  export function formatResult(data) {
package/dist/index.js CHANGED
@@ -47,7 +47,12 @@ if (!apiKey.startsWith("cdy_")) {
47
47
  console.error("CONDUYT_API_KEY must start with 'cdy_' — generate one at Settings > API Keys in Conduyt.");
48
48
  process.exit(1);
49
49
  }
50
- const client = new ConduytClient({ apiUrl, apiKey });
50
+ // Operational bounds (SOL-017): every HTTP call gets a deadline, a
51
+ // response-size cap, and conservative GET-only retries. Overridable via env
52
+ // for constrained runtimes.
53
+ const timeoutMs = Number(process.env.CONDUYT_TIMEOUT_MS) || undefined;
54
+ const maxResponseBytes = Number(process.env.CONDUYT_MAX_RESPONSE_BYTES) || undefined;
55
+ const client = new ConduytClient({ apiUrl, apiKey, timeoutMs, maxResponseBytes });
51
56
  // Single source of truth for the version — read from package.json so the
52
57
  // runtime initialize metadata can never drift from the published package.
53
58
  const pkg = createRequire(import.meta.url)("../package.json");
@@ -93,7 +93,8 @@ export function registerAiExtendedTools(server, client) {
93
93
  .optional()
94
94
  .describe("Pin the chat to a specific record for grounded answers"),
95
95
  }, async (params) => {
96
- const streamText = await client.postText("/api/v1/ai/chat", params);
96
+ // A grounded multi-turn reply can stream well past the 30s default.
97
+ const streamText = await client.postText("/api/v1/ai/chat", params, { timeoutMs: 120_000 });
97
98
  const reply = parseSseText(streamText);
98
99
  return formatResult({ reply });
99
100
  });
@@ -50,7 +50,15 @@ export function registerBulkTools(server, client) {
50
50
  qp.set("created_to", params.created_to);
51
51
  if (params.min_score)
52
52
  qp.set("min_score", String(params.min_score));
53
- const result = await client.get(`/api/v1/contacts/export?${qp}`);
53
+ // Full-book exports routinely exceed the 5MB default cap and the 30s
54
+ // deadline — widened, but bounded at 25MB: the payload is re-serialized
55
+ // into the tool result, so an unbounded budget would just move the
56
+ // failure into memory/model-context. Past 25MB the structured
57
+ // response_too_large error tells the agent to narrow the filters.
58
+ const result = await client.get(`/api/v1/contacts/export?${qp}`, {
59
+ timeoutMs: 120_000,
60
+ maxResponseBytes: 25_000_000,
61
+ });
54
62
  return formatResult(result);
55
63
  });
56
64
  server.tool("conduyt_export_deals", "Export deals as JSON with filtering by status, pipeline, or search term.", {
@@ -65,7 +73,11 @@ export function registerBulkTools(server, client) {
65
73
  qp.set("status", params.status);
66
74
  if (params.pipeline)
67
75
  qp.set("pipeline", params.pipeline);
68
- const result = await client.get(`/api/v1/deals/export?${qp}`);
76
+ // Same widened-but-bounded budget as the contacts export.
77
+ const result = await client.get(`/api/v1/deals/export?${qp}`, {
78
+ timeoutMs: 120_000,
79
+ maxResponseBytes: 25_000_000,
80
+ });
69
81
  return formatResult(result);
70
82
  });
71
83
  server.tool("conduyt_bulk_tag_contacts", "Add one tag to many contacts in a single call. Requires contacts:edit.", {
@@ -1,7 +1,9 @@
1
+ import { createRequire } from "node:module";
1
2
  import { z } from "zod";
2
3
  import { formatResult } from "../client.js";
4
+ const requireJson = createRequire(import.meta.url);
3
5
  export function registerDiscoveryTools(server, client) {
4
- server.tool("conduyt_api_catalog", "Discover all Conduyt CRM API endpoints across 90+ domains, with full request and response schemas. Call this first to learn what the CRM can do — the response carries the exact, current endpoint counts.", {}, async () => {
6
+ server.tool("conduyt_api_catalog", "Discover all Conduyt CRM API endpoints across 90+ domains, with full request and response schemas. Call this first to learn what the CRM can do — the response carries the exact, current endpoint counts. NOTE: the catalog is the full REST surface; not every endpoint has a dedicated MCP tool. Call conduyt_mcp_coverage to see exactly which endpoints are callable through MCP tools versus API-only.", {}, async () => {
5
7
  const result = await client.get("/api/v1/schema/api-catalog");
6
8
  return formatResult(result);
7
9
  });
@@ -22,4 +24,27 @@ export function registerDiscoveryTools(server, client) {
22
24
  const result = await client.get(`/api/v1/search?${params}`);
23
25
  return formatResult(result);
24
26
  });
27
+ server.tool("conduyt_mcp_coverage", "The REST-to-MCP coverage matrix: which of the catalog's API endpoints are callable through MCP tools (and by which tool), which are deliberately excluded (with reasons — e.g. interactive-only auth/billing, inbound webhook receivers, realtime browser surfaces), and which are planned but not yet wrapped. Use this to know up front whether a capability you discovered in conduyt_api_catalog is executable here, instead of failing mid-task. Ships with the package and matches this release's tools exactly.", {
28
+ status: z
29
+ .enum(["covered", "excluded", "planned"])
30
+ .optional()
31
+ .describe("Only return endpoints with this coverage status"),
32
+ pathPrefix: z.string().optional().describe("Only return endpoints whose path starts with this prefix (e.g. /api/v1/contacts)"),
33
+ }, async ({ status, pathPrefix }) => {
34
+ // Pinned at build/publish time by scripts/verify-coverage.mjs (the
35
+ // prepublish gate fails if any endpoint lacks a designation).
36
+ const matrix = requireJson("../../scripts/coverage-matrix.json");
37
+ let entries = Object.entries(matrix.matrix);
38
+ if (status)
39
+ entries = entries.filter(([, v]) => v.status === status);
40
+ if (pathPrefix)
41
+ entries = entries.filter(([k]) => k.split(" ")[1]?.startsWith(pathPrefix));
42
+ return formatResult({
43
+ generatedAt: matrix.generatedAt,
44
+ toolCount: matrix.toolCount,
45
+ totalEndpoints: matrix.totalEndpoints,
46
+ counts: matrix.counts,
47
+ endpoints: Object.fromEntries(entries),
48
+ });
49
+ });
25
50
  }
@@ -14,7 +14,9 @@ export function registerPrivacyTools(server, client) {
14
14
  // into a different subroute. Keep this export-only regardless of key role.
15
15
  id: z.string().uuid().describe("Contact UUID to export"),
16
16
  }, async ({ id }) => {
17
- const result = await client.get(`/api/v1/contacts/${encodeURIComponent(id)}/gdpr-export`);
17
+ // A full portable export is legitimately big and slow on large
18
+ // tenants — wider per-call budget than the 30s/5MB defaults.
19
+ const result = await client.get(`/api/v1/contacts/${encodeURIComponent(id)}/gdpr-export`, { timeoutMs: 120_000, maxResponseBytes: 25_000_000 });
18
20
  return formatResult(result);
19
21
  });
20
22
  server.tool("conduyt_forget_contact", "IRREVERSIBLE GDPR right-to-be-forgotten erasure: permanently anonymizes the contact and erases their personal data EVERYWHERE it appears — messages, notes, deals, files, automations, and exports — across the whole account. Suppression/consent tombstones are kept so they are not re-contacted. THERE IS NO UNDO. Requires an owner API key (the API enforces this) and an explicit confirm='FORGET'. On a large tenant this can legitimately take tens of seconds. A 409 is retryable (a concurrent merge or in-flight automation) — retry once.", {
@@ -25,7 +27,9 @@ export function registerPrivacyTools(server, client) {
25
27
  .literal("FORGET")
26
28
  .describe("Must be exactly the string 'FORGET'. This is a hard safety gate for an irreversible erasure — do not supply it unless the erasure is intended."),
27
29
  }, async ({ id, confirm }) => {
28
- const result = await client.post(`/api/v1/contacts/${encodeURIComponent(id)}/gdpr-forget`, { confirm });
30
+ // The tool doc promises "can take tens of seconds" on large tenants
31
+ // — the erasure must not be aborted by the 30s default deadline.
32
+ const result = await client.post(`/api/v1/contacts/${encodeURIComponent(id)}/gdpr-forget`, { confirm }, { timeoutMs: 300_000 });
29
33
  return formatResult(result);
30
34
  });
31
35
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  export interface ConduytConfig {
2
2
  apiUrl: string;
3
3
  apiKey: string;
4
+ /** Per-call HTTP deadline in ms (default 30_000). Env: CONDUYT_TIMEOUT_MS. */
5
+ timeoutMs?: number;
6
+ /** Response body cap in bytes (default 5_000_000). Env: CONDUYT_MAX_RESPONSE_BYTES. */
7
+ maxResponseBytes?: number;
8
+ /** Base backoff in ms for GET retries (default 500; tests use 1). */
9
+ retryBaseMs?: number;
4
10
  }
5
11
  export interface ApiResponse<T = unknown> {
6
12
  data?: T;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.11.0",
3
+ "version": "4.13.0",
4
4
  "description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,8 @@
10
10
  },
11
11
  "files": [
12
12
  "dist",
13
+ "scripts/coverage-matrix.json",
14
+ "scripts/coverage-manifest.json",
13
15
  "README.md",
14
16
  ".env.example"
15
17
  ],
@@ -20,8 +22,10 @@
20
22
  "start": "tsx src/index.ts",
21
23
  "build": "tsc",
22
24
  "typecheck": "tsc --noEmit",
23
- "prepublishOnly": "npm run check:version && npm run build && ENFORCE=1 node scripts/verify-contracts.mjs",
25
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
26
+ "prepublishOnly": "npm run check:version && npm run test && npm run build && ENFORCE=1 node scripts/verify-contracts.mjs && ENFORCE=1 node scripts/verify-coverage.mjs",
24
27
  "audit:contracts": "node scripts/verify-contracts.mjs",
28
+ "audit:coverage": "node scripts/verify-coverage.mjs",
25
29
  "contracts:refresh": "node scripts/refresh-contracts.mjs",
26
30
  "check:version": "node scripts/check-version-sync.mjs"
27
31
  },