cascrow-sdk 0.1.0 → 0.4.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.js CHANGED
@@ -1,17 +1,216 @@
1
- function makeApiError(status, body) {
2
- const message = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string"
3
- ? body.error
4
- : `HTTP ${status}`;
1
+ /**
2
+ * Client deadlines, mirrored from `src/lib/latency-budget.ts` (SEC-073).
3
+ *
4
+ * A published package cannot import from the app, so these are copies by
5
+ * necessity. `standalone-client-deadline.test.ts` is what keeps every copy in the
6
+ * tree equal — a number typed in FOUR places (this file, `cascrow-mcp`,
7
+ * `cascrow-cli`, and the owner in `src/lib/latency-budget.ts`) is four numbers
8
+ * that agree today.
9
+ */
10
+ /** `/api/verify` streams under a 240 s function cap; one step below its kill. */
11
+ const CLIENT_ABORT_MS = 235_000;
12
+ /** `/api/verify/standalone` is capped at 120 s. */
13
+ const STANDALONE_CLIENT_ABORT_MS = 115_000;
14
+ /**
15
+ * The default for every other call. The longest `maxDuration` any route in this
16
+ * API declares is 240 s, so this cannot cut a legitimately slow on-chain call
17
+ * short — it only bounds a connection nobody is on the other end of.
18
+ */
19
+ const REQUEST_TIMEOUT_MS = 240_000;
20
+ /**
21
+ * `/api/proof/upload`: its 30 s function cap plus a transfer allowance for a
22
+ * 20 MB body. Mirrors `UPLOAD_CLIENT_ABORT_MS` in src/lib/latency-budget.ts;
23
+ * `client-deadline-mirrors.test.ts` keeps the two equal. `submitProof` had NO
24
+ * deadline before T11.
25
+ */
26
+ const UPLOAD_CLIENT_ABORT_MS = 120_000;
27
+ /**
28
+ * An API error a caller can branch on (T11-A2): the HTTP status, the stable `code`
29
+ * (see the API's error codes — plus `UPSTREAM_NON_JSON`, which this client sets when
30
+ * the server answered with something that is not JSON), and how long the server
31
+ * asked us to wait (`Retry-After`, else `retry_after_seconds`). Additive: `status`,
32
+ * `body` and the message are what they always were.
33
+ */
34
+ function makeApiError(status, body, headers) {
35
+ const b = (typeof body === "object" && body !== null ? body : {});
36
+ const message = typeof b.error === "string" ? b.error : `HTTP ${status}`;
5
37
  const err = new Error(message);
6
38
  err.status = status;
7
39
  err.body = body;
40
+ err.code = typeof b.code === "string" ? b.code : null;
41
+ const header = headers?.get("retry-after") ?? null;
42
+ err.retryAfterSeconds =
43
+ header !== null && /^\d+$/.test(header.trim())
44
+ ? Number(header.trim())
45
+ : typeof b.retry_after_seconds === "number"
46
+ ? b.retry_after_seconds
47
+ : null;
48
+ // The lost-claim 409 (VERIFICATION_IN_PROGRESS) says whether a run is going and
49
+ // where to watch it — `waitForProof(proofId)` is the call to make then.
50
+ if (typeof b.phase === "string")
51
+ err.phase = b.phase;
52
+ if (typeof b.watch_url === "string")
53
+ err.watchUrl = b.watch_url;
54
+ if (typeof b.expected_done_in_seconds === "number")
55
+ err.expectedDoneInSeconds = b.expected_done_in_seconds;
8
56
  return err;
9
57
  }
58
+ /** A body parsed without trusting it to be JSON: an HTML 502 is "HTTP 502 (non-JSON response from server)". */
59
+ async function readJsonSafe(res) {
60
+ const text = await res.text();
61
+ if (!text)
62
+ return {};
63
+ try {
64
+ return JSON.parse(text);
65
+ }
66
+ catch {
67
+ return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
68
+ }
69
+ }
70
+ /** True when `value` is the non-JSON marker `readJsonSafe` produced. */
71
+ function isNonJson(value) {
72
+ return typeof value === "object" && value !== null && value.code === "UPSTREAM_NON_JSON";
73
+ }
74
+ // ─── The bar a milestone is judged against ────────────────────────────────────
75
+ // COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
76
+ // (a published package cannot import the app). milestone-bar-input-parity.test.ts runs
77
+ // this copy against the owner over the same case matrix.
78
+ const CHECKLIST_MAX_ITEMS = 20;
79
+ const CHECKLIST_TITLE_MAX = 200;
80
+ const CHECKLIST_ID_MAX = 100;
81
+ const CRITERIA_MAX = 4000;
82
+ const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
83
+ function normalizeMilestoneChecklist(items) {
84
+ if (items === undefined || items === null)
85
+ return undefined;
86
+ if (!Array.isArray(items))
87
+ throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
88
+ if (items.length === 0)
89
+ return undefined;
90
+ if (items.length > CHECKLIST_MAX_ITEMS)
91
+ throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
92
+ items.forEach((raw, i) => {
93
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
94
+ throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
95
+ }
96
+ });
97
+ const withId = items.filter((it) => it.id !== undefined).length;
98
+ if (withId !== 0 && withId !== items.length) {
99
+ throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
100
+ }
101
+ const seen = new Set();
102
+ return items.map((raw, i) => {
103
+ const it = raw;
104
+ const title = typeof it.title === "string" ? it.title.trim() : "";
105
+ if (title.length === 0)
106
+ throw new Error(`verificationChecklist[${i}]: title is required`);
107
+ if (title.length > CHECKLIST_TITLE_MAX)
108
+ throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
109
+ let id = i + 1;
110
+ if (withId) {
111
+ if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
112
+ throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
113
+ }
114
+ if (seen.has(it.id))
115
+ throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
116
+ seen.add(it.id);
117
+ id = it.id;
118
+ }
119
+ const out = { id, title };
120
+ if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
121
+ const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
122
+ if (!CHECKLIST_SEVERITIES.includes(sev)) {
123
+ throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
124
+ }
125
+ out.severity = sev;
126
+ }
127
+ return out;
128
+ });
129
+ }
130
+ function normalizeCriteria(value) {
131
+ if (value === undefined || value === null)
132
+ return undefined;
133
+ if (typeof value !== "string")
134
+ throw new Error("verificationCriteria: must be a string");
135
+ const v = value.trim();
136
+ if (v.length === 0)
137
+ return undefined;
138
+ if (v.length > CRITERIA_MAX)
139
+ throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
140
+ return v;
141
+ }
10
142
  /**
11
143
  * Typed client for the Cascrow API. Wraps the same REST endpoints used by
12
144
  * the cascrow-mcp server and the Cascrow web app — this is the plain-code
13
145
  * equivalent for applications that don't go through an MCP/LLM tool call.
14
146
  */
147
+ /** Every `action` the verification stream can send. Kept next to the check so the
148
+ * two cannot drift: adding a value to `VerificationResult["action"]` without adding
149
+ * it here makes this file fail to compile. */
150
+ const STREAM_ACTIONS = ["VERIFIED", "PENDING_REVIEW", "REJECTED", "COMPLETED", "SUPERSEDED"];
151
+ /**
152
+ * The stream's `action`, or a thrown error — never an invented verdict.
153
+ *
154
+ * SEC-059. This used to be `(msg.action as VerificationResult["action"]) ?? "REJECTED"`,
155
+ * twice. Two things were wrong with it. The cast accepted `COMPLETED` and `SUPERSEDED`
156
+ * — real values the stream sends — while the declared union listed neither, so the
157
+ * returned object was outside its own type and nothing said so. And the fallback
158
+ * invented `REJECTED` for an absent field: a missing `action` is a protocol violation,
159
+ * not a rejection, and defaulting it in the one direction that costs the builder their
160
+ * reputation is the worst available choice. Every `complete` frame the route sends
161
+ * carries an action, so this cannot fire against a healthy server; when it does fire,
162
+ * it throws the way a malformed frame already does rather than guessing.
163
+ */
164
+ function requireAction(value) {
165
+ if (typeof value === "string" && STREAM_ACTIONS.includes(value)) {
166
+ return value;
167
+ }
168
+ throw new Error(`Verification stream sent an action this client does not know: ${JSON.stringify(value)}. ` +
169
+ `Expected one of ${STREAM_ACTIONS.join(", ")} — upgrade cascrow-sdk.`);
170
+ }
171
+ /**
172
+ * The terminal status of the row a proof settles against, or null while it is open.
173
+ * `milestoneClosed` is derived from the row by `/api/agent/proof-status`;
174
+ * `milestoneStatus` is the fallback for a server that predates the field.
175
+ */
176
+ function closedMilestoneStatus(s) {
177
+ if (s.milestoneClosed === true)
178
+ return s.terminalStatus || s.milestoneStatus || s.contractStatus || "EXPIRED";
179
+ // A legacy contract-level proof has no milestone: an older server reports its row only
180
+ // as `contractStatus`, and reading `milestoneStatus` alone reported a proof whose
181
+ // contract had EXPIRED as open.
182
+ const row = s.milestoneStatus ?? s.contractStatus;
183
+ return row === "EXPIRED" || row === "COMPLETED" ? row : null;
184
+ }
185
+ /**
186
+ * True when the milestone is approved (VERIFIED) or paid (COMPLETED) and this proof has
187
+ * no YES of its own: another proof won it, or the funder approved it in review — which
188
+ * leaves this proof's `aiDecision` untouched (NO, or null after a panel outage). Nothing
189
+ * is left for this proof to decide: `/api/verify`'s claim excludes VERIFIED and
190
+ * COMPLETED for every verdict, so a pending proof there never gets one, and the loop
191
+ * below used to burn its whole timeout on it. Same predicate as both MCP surfaces.
192
+ */
193
+ function approvedWithoutThisVerdict(s) {
194
+ // Except while this proof's panel is RUNNING on a VERIFIED row. VERIFIED is not sticky:
195
+ // a definite release failure rolls it back, `contracts/review` hands it back to
196
+ // PENDING_REVIEW and the cron's hand-back blocks do the same, and a running claim then
197
+ // decides the row after all. COMPLETED is terminal and stays final either way.
198
+ // `running` is absent on a server that predates it, which keeps the old reading.
199
+ const row = closedMilestoneStatus(s) === "COMPLETED" ? "COMPLETED" : (s.milestoneStatus ?? s.contractStatus);
200
+ if (s.decision === "YES")
201
+ return false;
202
+ return row === "COMPLETED" || (row === "VERIFIED" && s.running !== true);
203
+ }
204
+ /**
205
+ * Parked for a human: no verdict, the milestone at `PENDING_REVIEW`, and no panel running
206
+ * on this proof. A RUNNING proof on a PENDING_REVIEW row is not parked — a sibling proof
207
+ * in the 60-85 band put the milestone there while this one's panel still runs, and this
208
+ * one can still take it to COMPLETED. Same test as `parkedForHuman` at both MCP surfaces;
209
+ * without `running` (an older server) the old reading stays.
210
+ */
211
+ function parkedForHuman(s) {
212
+ return Boolean(s.pending) && s.running !== true && s.milestoneStatus === "PENDING_REVIEW";
213
+ }
15
214
  export class CascrowClient {
16
215
  apiKey;
17
216
  baseUrl;
@@ -22,19 +221,84 @@ export class CascrowClient {
22
221
  authHeaders() {
23
222
  return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
24
223
  }
25
- async request(method, path, body) {
26
- const res = await fetch(`${this.baseUrl}${path}`, {
27
- method,
28
- headers: {
29
- ...this.authHeaders(),
30
- ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
31
- },
32
- body: body !== undefined ? JSON.stringify(body) : undefined,
33
- });
34
- const data = await res.json().catch(() => ({}));
35
- if (!res.ok)
36
- throw makeApiError(res.status, data);
37
- return data;
224
+ /**
225
+ * Every non-streaming call carries a deadline (SEC-073).
226
+ *
227
+ * Before this, the only `AbortController` in the SDK was inside `verify()`, the
228
+ * streaming method — so `verifyWork()`, which waits on a route the platform
229
+ * kills at 120 s, waited forever and then surfaced a torn connection. The
230
+ * default is the longest cap any route in this API declares (240 s), so it
231
+ * cannot cut a legitimately slow on-chain call short; `verifyWork` passes the
232
+ * tighter one because its own route is capped lower.
233
+ *
234
+ * An `AbortError` is re-thrown as a sentence, not as the DOM error: a caller
235
+ * cannot tell "we stopped waiting" from "the panel said no" out of an
236
+ * exception whose message is "This operation was aborted" (F-72).
237
+ */
238
+ async request(method, path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
239
+ const controller = new AbortController();
240
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
241
+ const isForm = typeof FormData !== "undefined" && body instanceof FormData;
242
+ let res;
243
+ try {
244
+ res = await fetch(`${this.baseUrl}${path}`, {
245
+ method,
246
+ headers: {
247
+ ...this.authHeaders(),
248
+ ...(body !== undefined && !isForm ? { "Content-Type": "application/json" } : {}),
249
+ },
250
+ body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
251
+ signal: controller.signal,
252
+ });
253
+ }
254
+ catch (err) {
255
+ if (err instanceof Error && err.name === "AbortError") {
256
+ throw new Error(`cascrow did not answer ${method} ${path} within ${Math.round(timeoutMs / 1000)}s. ` +
257
+ "This client stopped waiting; the server may still be working on it.");
258
+ }
259
+ clearTimeout(timer);
260
+ throw err;
261
+ }
262
+ // Cleared AFTER the body, not in a `finally` around the header fetch: a
263
+ // server that sends headers and then stalls the body was otherwise
264
+ // unbounded, and the promise below is what the caller actually waits on.
265
+ // The streaming path in this same file learned this already — its abort
266
+ // "only became load-bearing when the clearTimeout moved below the read loop".
267
+ try {
268
+ let data;
269
+ try {
270
+ data = await readJsonSafe(res);
271
+ }
272
+ catch (err) {
273
+ // The deadline can fire during the body read too; say so the same way as
274
+ // at the header fetch, with the code a caller branches on.
275
+ if (err instanceof Error && err.name === "AbortError") {
276
+ const e = new Error(`cascrow did not finish answering ${method} ${path} within ${Math.round(timeoutMs / 1000)}s. ` +
277
+ "This client stopped waiting; the server may still be working on it.");
278
+ e.status = res.status;
279
+ e.body = null;
280
+ e.code = "CLIENT_TIMEOUT";
281
+ e.retryAfterSeconds = null;
282
+ throw e;
283
+ }
284
+ throw err;
285
+ }
286
+ // A 200 that is not JSON is not a success anyone can read.
287
+ if (!res.ok || isNonJson(data)) {
288
+ const err = makeApiError(res.status, data, res.headers);
289
+ // A platform error page on a POST (the 504 when a route hits its own
290
+ // maxDuration — escrow-fund's is 120 s) does not mean the call failed: the
291
+ // function may have moved money before it was killed.
292
+ if (method !== "GET" && res.status >= 500 && isNonJson(data)) {
293
+ err.message += ". The request may still have completed on the server — check the contract (getContract) before repeating it.";
294
+ }
295
+ throw err;
296
+ }
297
+ return data;
298
+ }
299
+ finally {
300
+ clearTimeout(timer);
301
+ }
38
302
  }
39
303
  // ─── Auth ───────────────────────────────────────────────────────────────────
40
304
  /** Self-register an agent account. No CAPTCHA, no email verification. Returns an API key — save it, it is shown only once. */
@@ -57,11 +321,27 @@ export class CascrowClient {
57
321
  return d.toISOString();
58
322
  };
59
323
  const data = await this.request("POST", "/api/contracts", {
60
- milestones: input.milestones.map((m) => ({
61
- title: m.title,
62
- amountUSD: m.amountUSD ?? 0,
63
- cancelAfter: cancelAfter(m.daysUntilDeadline),
64
- })),
324
+ milestones: input.milestones.map((m, i) => {
325
+ // T07. Normalised here so a bad item is refused with its milestone and
326
+ // item index, not as a 400 on the whole contract. Nothing given → the
327
+ // body is what it always was.
328
+ let criteria;
329
+ let checklist;
330
+ try {
331
+ criteria = normalizeCriteria(m.verificationCriteria);
332
+ checklist = normalizeMilestoneChecklist(m.verificationChecklist);
333
+ }
334
+ catch (e) {
335
+ throw new Error(`milestones[${i}]: ${e instanceof Error ? e.message : String(e)}`);
336
+ }
337
+ return {
338
+ title: m.title,
339
+ amountUSD: m.amountUSD ?? 0,
340
+ cancelAfter: cancelAfter(m.daysUntilDeadline),
341
+ ...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
342
+ ...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
343
+ };
344
+ }),
65
345
  agentReviewMode: input.agentReviewMode,
66
346
  });
67
347
  return {
@@ -83,9 +363,52 @@ export class CascrowClient {
83
363
  async joinContract(inviteCode) {
84
364
  return this.request("POST", "/api/contracts/join", { inviteCode });
85
365
  }
86
- /** Approve or reject a borderline (PENDING_REVIEW) contract. Only valid when agentReviewMode is MANUAL or MANUAL_AUTO. */
87
- async reviewContract(contractId, decision) {
88
- return this.request("POST", "/api/contracts/review", { contractId, decision });
366
+ /**
367
+ * Approve or reject a borderline (PENDING_REVIEW) MILESTONE. Only valid when
368
+ * agentReviewMode is MANUAL or MANUAL_AUTO.
369
+ *
370
+ * The CONTRACT row need not be PENDING_REVIEW: funding a later milestone
371
+ * overwrites that column, and such a contract is still reviewable. Only a
372
+ * terminal contract (COMPLETED, EXPIRED, DECLINED) is refused.
373
+ *
374
+ * `milestoneId` is optional while exactly one milestone of the contract is
375
+ * pending review and REQUIRED once more than one is — the API answers 400
376
+ * "Multiple milestones are pending review — specify milestoneId to
377
+ * disambiguate". Until this parameter existed an SDK caller had no way to
378
+ * comply with that refusal, which is exactly the case SEC-076 is about: a
379
+ * multi-milestone contract where a second proof lands while the first is
380
+ * still under review (SEC-120, F-72).
381
+ *
382
+ * Additive optional third argument — every existing call site still compiles.
383
+ */
384
+ async reviewContract(contractId, decision, milestoneId) {
385
+ return this.request("POST", "/api/contracts/review", milestoneId ? { contractId, decision, milestoneId } : { contractId, decision });
386
+ }
387
+ /**
388
+ * The contracts on which THIS agent, as funder, still owes a manual verdict.
389
+ *
390
+ * The pull behind the `manual_review.required` webhook. A webhook that does
391
+ * not arrive — the agent was down, the endpoint 500'd, the subscription was
392
+ * never made — is a decision that never gets made, and `cron/cancel-expired`
393
+ * then auto-approves the milestone after 48 h (agent contracts) or 14 days
394
+ * (human) and releases the escrow. The whole point of the 60–85 confidence
395
+ * band is that somebody looks.
396
+ *
397
+ * Every browser surface the funder has for this asks
398
+ * `funderReviewQueueWhere()`; so does this route, so the queue an agent polls
399
+ * and the queue `reviewContract` accepts a verdict on cannot come apart.
400
+ *
401
+ * `reviewMilestoneId` is what to pass to {@link reviewContract}: non-null when
402
+ * exactly one milestone of that contract is pending, `null` for the legacy
403
+ * contract-level population that is reviewed by `contractId` alone. When
404
+ * `pendingMilestones` holds more than one entry the milestone has to be chosen
405
+ * — `POST /api/contracts/review` answers 400 otherwise.
406
+ *
407
+ * `truncated` says the list is capped while `total` is not, so a poller does
408
+ * not read a short list as "that was all".
409
+ */
410
+ async reviewQueue() {
411
+ return this.request("GET", "/api/agent/review-queue");
89
412
  }
90
413
  /** Check for pending contract invites sent to this agent. */
91
414
  async checkInvites() {
@@ -100,25 +423,42 @@ export class CascrowClient {
100
423
  async fundMilestone(contractId, milestoneId) {
101
424
  return this.request("POST", "/api/agent/fund-milestone", milestoneId ? { contractId, milestoneId } : { contractId });
102
425
  }
103
- /** Fund a milestone with real on-chain RLUSD escrow using the agent's own EVM private key. */
426
+ /**
427
+ * Fund a milestone with real on-chain RLUSD escrow using the agent's own EVM
428
+ * private key.
429
+ *
430
+ * **The escrowed amount is the milestone's own `amountUSD`.** The route
431
+ * destructures `{ contractId, milestoneId, agentPrivateKey }` and funds
432
+ * `milestone.amountUSD`; a body `amountUSD` has never been read.
433
+ *
434
+ * SEC-120 removed the parameter from both MCP tool schemas, the OpenAPI spec
435
+ * and the CLI — and stopped here, at the one surface where the demand was
436
+ * enforced by the COMPILER rather than merely written down: `amountUSD:
437
+ * number`, no `?`, and `input` forwarded whole. That is the same defect the
438
+ * removal was about, at the door that states it most strongly, and it
439
+ * survived because the correction was checked against the places that
440
+ * document the parameter rather than against the places that require it.
441
+ *
442
+ * It is deprecated rather than deleted: deleting it would break compilation
443
+ * for every existing caller, which is a worse answer than telling them the
444
+ * truth. It is no longer forwarded, so the request body now says what the
445
+ * server actually reads.
446
+ */
104
447
  async escrowFund(input) {
105
- return this.request("POST", "/api/agent/escrow-fund", input);
448
+ const { contractId, agentPrivateKey, milestoneId } = input;
449
+ return this.request("POST", "/api/agent/escrow-fund", { contractId, agentPrivateKey, milestoneId });
106
450
  }
107
451
  // ─── Proof & verification ───────────────────────────────────────────────────
108
- /** Submit a text proof report for a milestone. Automatically triggers AI verification. */
452
+ /**
453
+ * Submit a text proof report for a milestone. Verification starts by itself —
454
+ * wait for the verdict with `waitForProof(proofId)`; do not call `verify()` for it,
455
+ * that run is already going.
456
+ */
109
457
  async submitProof(input) {
110
458
  const form = new FormData();
111
459
  form.append("milestoneId", input.milestoneId);
112
460
  form.append("file", new Blob([input.content], { type: "text/plain" }), input.filename ?? "proof-report.txt");
113
- const res = await fetch(`${this.baseUrl}/api/proof/upload`, {
114
- method: "POST",
115
- headers: this.authHeaders(),
116
- body: form,
117
- });
118
- const data = await res.json().catch(() => ({}));
119
- if (!res.ok)
120
- throw makeApiError(res.status, data);
121
- return data;
461
+ return this.request("POST", "/api/proof/upload", form, UPLOAD_CLIENT_ABORT_MS);
122
462
  }
123
463
  /** Check the AI verification result for a submitted proof (poll after submitProof). */
124
464
  async getProofStatus(proofId) {
@@ -126,11 +466,21 @@ export class CascrowClient {
126
466
  }
127
467
  /**
128
468
  * Stream live 5-model AI verification results for a submitted proof.
129
- * Resolves once the final result event arrives (up to ~180s).
469
+ *
470
+ * By default resolves once the final result arrives, i.e. after the on-chain
471
+ * release, so `txHash` is populated. Pass `{ resolveOn: "decision" }` to
472
+ * resolve as soon as the AI verdict is known instead — typically several
473
+ * seconds earlier, since the release waits for a sidechain block. `txHash` is
474
+ * then null and the release is still in flight; use a webhook or
475
+ * `getProofStatus` if you need to observe it landing.
130
476
  */
131
- async verify(proofId) {
477
+ async verify(proofId, options = {}) {
478
+ const resolveOn = options.resolveOn ?? "complete";
132
479
  const controller = new AbortController();
133
- const timeout = setTimeout(() => controller.abort(), 180_000);
480
+ // SEC-073: 235_000, and it only became load-bearing when the clearTimeout
481
+ // moved below the read loop — see src/lib/latency-budget.ts:CLIENT_ABORT_MS.
482
+ // Kept as a numeric literal because this package cannot import from src/.
483
+ const timeout = setTimeout(() => controller.abort(), CLIENT_ABORT_MS);
134
484
  let res;
135
485
  try {
136
486
  res = await fetch(`${this.baseUrl}/api/verify`, {
@@ -140,12 +490,28 @@ export class CascrowClient {
140
490
  signal: controller.signal,
141
491
  });
142
492
  }
143
- finally {
493
+ catch (e) {
494
+ clearTimeout(timeout);
495
+ if (e instanceof Error && e.name === "AbortError") {
496
+ throw new Error(`Verification is still running on the server — this client stopped waiting, it did not fail. Poll GET /api/agent/proof-status/${proofId} for the verdict.`);
497
+ }
498
+ throw e;
499
+ }
500
+ if (!res.ok) {
144
501
  clearTimeout(timeout);
502
+ // A 409 with code VERIFICATION_IN_PROGRESS and phase "running" means a run is
503
+ // already going (usually the one upload started): the error carries `phase`,
504
+ // `watchUrl` and `expectedDoneInSeconds` — call `waitForProof(proofId)`. Its
505
+ // `retryAfterSeconds` is the ~1 h takeover cutoff, NOT a backoff.
506
+ throw makeApiError(res.status, await readJsonSafe(res), res.headers);
145
507
  }
146
- if (!res.ok || !res.body) {
147
- const d = await res.json().catch(() => ({}));
148
- throw makeApiError(res.status, d);
508
+ // Split from the line above, which was `!res.ok || !res.body`. A 200 with no
509
+ // body then produced `makeApiError(200, {})` — an "API error" carrying a
510
+ // success status, and the one door of six without the separate branch the
511
+ // other five have.
512
+ if (!res.body) {
513
+ clearTimeout(timeout);
514
+ throw new Error("Verification stream had no body");
149
515
  }
150
516
  const votes = [];
151
517
  const reader = res.body.getReader();
@@ -175,14 +541,48 @@ export class CascrowClient {
175
541
  if (msg.type === "model_vote") {
176
542
  votes.push(msg);
177
543
  }
544
+ else if (msg.type === "decision" && resolveOn === "decision") {
545
+ return {
546
+ decision: msg.decision ?? "NO",
547
+ confidence: msg.confidence ?? 0,
548
+ action: requireAction(msg.action),
549
+ reasoning: msg.reasoning ?? "",
550
+ modelVotes: votes,
551
+ txHash: null,
552
+ };
553
+ // `reasoning` here is MODEL OUTPUT over evidence the counterparty
554
+ // submitted, and it reaches you RAW. The MCP servers prefix it with an
555
+ // explicit untrusted-content marker; this SDK does not, deliberately —
556
+ // its return value may equally end up in a UI, where a marker is noise
557
+ // on a page, and a library cannot know which.
558
+ //
559
+ // That asymmetry was justified in the app for a while with a sentence
560
+ // that is FALSE of this file: "an agent reaching the SSE frames does so
561
+ // through an MCP server that marks at its own end". A review pass found
562
+ // this door — `verify()`/`verifyAndWait` hand the field to a
563
+ // TypeScript agent with no MCP server in the path. The decision is
564
+ // unchanged; the reason is now stated where it is true. If you feed this
565
+ // into a model, treat it as data and not as instructions.
566
+ }
178
567
  else if (msg.type === "complete") {
179
568
  return {
180
569
  decision: msg.decision ?? "NO",
181
570
  confidence: msg.confidence ?? 0,
182
- action: msg.action ?? "REJECTED",
571
+ action: requireAction(msg.action),
183
572
  reasoning: msg.reasoning ?? "",
184
573
  modelVotes: votes,
185
574
  txHash: msg.txHash ?? null,
575
+ // Forwarded because `action` alone does not answer "did the money move":
576
+ // a VERIFIED with a hold or a compliance block did not, and a COMPLETED
577
+ // that someone else released did. SEC-058 / SEC-059.
578
+ ...(msg.alreadyReleased === true ? { alreadyReleased: true } : {}),
579
+ ...(typeof msg.releaseHoldUntil === "string" ? { releaseHoldUntil: msg.releaseHoldUntil } : {}),
580
+ ...(msg.complianceBlocked === true ? { complianceBlocked: true } : {}),
581
+ ...(msg.timedOut === true ? { timedOut: true } : {}),
582
+ // With `action: "SUPERSEDED"`: the milestone was closed (e.g. EXPIRED)
583
+ // before this verification could settle it, rather than decided by
584
+ // another proof.
585
+ ...(typeof msg.settledAs === "string" ? { settledAs: msg.settledAs } : {}),
186
586
  };
187
587
  }
188
588
  else if (msg.type === "error") {
@@ -190,8 +590,26 @@ export class CascrowClient {
190
590
  }
191
591
  }
192
592
  }
593
+ // SEC-073. The abort timer is cleared HERE, after the read loop — not in a
594
+ // `finally` around the `fetch`. `fetch` resolves on response HEADERS, about
595
+ // 12 s into `/api/verify`, and the entire 205 s retry ladder arrives
596
+ // afterwards out of `res.body`. Clearing it at the fetch made this a
597
+ // time-to-first-byte deadline over a twelve-second phase, so the number
598
+ // bounded nothing that mattered and could sit below the server's worst
599
+ // case unnoticed.
600
+ }
601
+ catch (e) {
602
+ // SEC-073. Live deadline, so this can fire mid-read; and per
603
+ // latency-budget.ts the abort can land after releaseMilestone(), which
604
+ // makes a bare "aborted" not merely unhelpful but possibly false about
605
+ // whether the money moved.
606
+ if (e instanceof Error && e.name === "AbortError") {
607
+ throw new Error(`Verification is still running on the server — this client stopped waiting, it did not fail. Poll GET /api/agent/proof-status/${proofId} for the verdict.`);
608
+ }
609
+ throw e;
193
610
  }
194
611
  finally {
612
+ clearTimeout(timeout);
195
613
  reader.cancel().catch(() => { });
196
614
  }
197
615
  throw new Error("Verification stream ended without a complete event");
@@ -212,48 +630,179 @@ export class CascrowClient {
212
630
  custom_fields: input.evidence.customFields,
213
631
  },
214
632
  });
633
+ // Ordered so the two verdict-less responses are classified before the field that
634
+ // is missing on both is read. A `?? "rejected"` here would be the same
635
+ // wrong-direction default `requireAction` above was written to remove.
636
+ const verdict = data.decided === "elsewhere"
637
+ ? "decided_elsewhere"
638
+ : data.panelUnavailable
639
+ ? "panel_unavailable"
640
+ : data.verdict;
641
+ if (!verdict) {
642
+ throw new Error("mcp/submit answered 200 with no verdict and no known refusal state — upgrade cascrow-sdk.");
643
+ }
215
644
  return {
216
- verdict: data.verdict,
217
- confidence: data.confidence,
218
- reasoning: data.reasoning,
219
- onChainUrl: data.on_chain_url,
220
- proofId: data.proof_id,
645
+ verdict,
646
+ confidence: data.confidence ?? null,
647
+ reasoning: data.reasoning ?? "",
648
+ onChainUrl: data.on_chain_url ?? null,
649
+ proofId: data.proof_id ?? null,
650
+ releaseHoldUntil: data.release_hold_until ?? null,
651
+ settledAs: verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null,
221
652
  };
222
653
  }
223
654
  /**
224
655
  * Verify AI-generated work with no escrow contract — a task description
225
- * plus a GitHub PR URL or pasted code. Works without an API key (3 free
226
- * anonymous verifications per IP per 24h); pass an apiKey for more credits.
656
+ * plus a GitHub PR URL or pasted code. Works without an API key: 3 free
657
+ * verifications per IP per 24h. An API key attributes the result to your
658
+ * account but does not raise that allowance today — there is no paid tier
659
+ * yet, so do not treat a key as a way to get more runs.
227
660
  */
228
661
  async verifyWork(input) {
229
- return this.request("POST", "/api/verify/standalone", input);
662
+ return this.request("POST", "/api/verify/standalone", input, STANDALONE_CLIENT_ABORT_MS);
663
+ }
664
+ /**
665
+ * True when a thrown `CascrowApiError` means "cascrow could not run the panel",
666
+ * not "the work was not verified" (SEC-128).
667
+ *
668
+ * `verifyWork` resolves ONLY when a verdict exists. When fewer than three of the
669
+ * five models answer, the endpoint answers 503 with `panelUnavailable: true` and
670
+ * carries NO decision and NO confidence, so `request` throws — deliberately, and
671
+ * that is the whole repair: the previous behaviour was a 200 carrying
672
+ * `decision: "NO", confidence: 65`, which every consumer rendered as a rejection
673
+ * of the submitted work. An outage is not a verdict, and a retry is the right
674
+ * next move rather than a resubmission with "stronger" evidence.
675
+ *
676
+ * A thrown 500 from the same endpoint is NOT this: it reports `false` here,
677
+ * because "the models did not answer" is a claim about the panel and a crash is
678
+ * not evidence for it.
679
+ */
680
+ static isPanelUnavailable(err) {
681
+ const body = err?.body;
682
+ return typeof body === "object" && body !== null && body.panelUnavailable === true;
230
683
  }
231
684
  /**
232
685
  * Submit a proof and wait for AI verification to complete — combines
233
686
  * submitProof + polling into a single call. Times out after ~100s and
234
687
  * returns { pending: true, proofId } so you can poll manually.
688
+ *
689
+ * Returns early, with `milestoneClosed: true`, when the milestone is already
690
+ * terminal — including when no verdict was recorded, which on a closed milestone
691
+ * will never change. Check `milestoneClosed` before `decision`: a `"YES"` on a
692
+ * closed, `EXPIRED` milestone is the panel's opinion of the proof and moved no funds.
693
+ *
694
+ * Also returns early, with `approvedWithoutThisVerdict: true`, when the milestone is
695
+ * VERIFIED or COMPLETED without a YES from this proof (another proof won it, or the
696
+ * funder approved it in review): `decision` is then `"NO"` or `null`, and the
697
+ * milestone's outcome is NOT this proof's.
698
+ *
699
+ * A proof parked for a human (no verdict, milestone at `PENDING_REVIEW`) returns
700
+ * `{ pending: true, proofId, awaitingHumanReview: true }` on the first poll instead of
701
+ * after the full timeout: no poll will change it, the funder decides.
702
+ *
703
+ * Neither early return is taken while the server reports `running: true` on a
704
+ * VERIFIED or PENDING_REVIEW milestone: this proof's panel is still running and can
705
+ * still decide the row, so the loop keeps polling. A server that predates `running`
706
+ * cannot say so, and there the early returns stay as described.
235
707
  */
236
708
  async watchVerification(input) {
237
709
  const { proofId } = await this.submitProof(input);
238
- const MAX_WAIT_MS = 100_000;
239
- const POLL_MS = 5_000;
710
+ return this.waitForProof(proofId);
711
+ }
712
+ /**
713
+ * Wait for the verification of a proof that is already submitted — the call to
714
+ * make after `submitProof`, and after `verify()` threw a 409 VERIFICATION_IN_PROGRESS
715
+ * with `phase: "running"` (T01-7). Same results as `watchVerification`, which is
716
+ * this plus the upload.
717
+ *
718
+ * Stops early, by throwing, when waiting cannot help: 401 / 403 (the key or the
719
+ * account does not change by waiting) and 404 (permanent — upload answers only
720
+ * after the proof exists and proof-status reads the primary database, so a 404
721
+ * means deleted or erased). Returns `{ pending: true, interrupted: true }` when the
722
+ * run that held the proof died (`claimPhase: "orphaned"`). On 429 / 503 it waits at
723
+ * least the server's Retry-After, never past `timeoutMs`; network errors and other
724
+ * 5xx are transient and it polls again.
725
+ */
726
+ async waitForProof(proofId, options = {}) {
727
+ const MAX_WAIT_MS = options.timeoutMs ?? 100_000;
728
+ const POLL_MS = options.intervalMs ?? 5_000;
240
729
  const started = Date.now();
730
+ let waitMs = POLL_MS;
241
731
  while (Date.now() - started < MAX_WAIT_MS) {
242
- await new Promise((r) => setTimeout(r, POLL_MS));
243
- const status = await this.getProofStatus(proofId).catch(() => null);
244
- if (status && !status.pending) {
732
+ await new Promise((r) => setTimeout(r, waitMs));
733
+ waitMs = POLL_MS;
734
+ let status = null;
735
+ try {
736
+ status = await this.getProofStatus(proofId);
737
+ }
738
+ catch (e) {
739
+ const err = e;
740
+ if (err.status === 401 || err.status === 403 || err.status === 404)
741
+ throw e;
742
+ if ((err.status === 429 || err.status === 503) && typeof err.retryAfterSeconds === "number") {
743
+ waitMs = Math.min(Math.max(POLL_MS, err.retryAfterSeconds * 1000), Math.max(0, MAX_WAIT_MS - (Date.now() - started)));
744
+ }
745
+ continue;
746
+ }
747
+ // A closed milestone ends the wait whether or not a verdict landed: an outage
748
+ // that lost to EXPIRED leaves `pending: true` for good, and the loop used to burn
749
+ // its full timeout on it. `milestoneStatus` is the fallback for a server that
750
+ // predates `milestoneClosed`.
751
+ const closed = status ? closedMilestoneStatus(status) : null;
752
+ // Approved or paid without a YES from this proof: nothing is left for it to
753
+ // decide, whether or not it has a verdict of its own.
754
+ const elsewhere = status ? approvedWithoutThisVerdict(status) : false;
755
+ if (status && (!status.pending || closed || elsewhere)) {
245
756
  return {
246
757
  pending: false,
247
- decision: (status.decision ?? "NO"),
758
+ // `?? "NO"` before: an ABSENT decision was reported as a rejection, the one
759
+ // direction that costs the builder their reputation. `null` says "the route
760
+ // reported none", which is the truth and forces the caller to look at
761
+ // `milestoneStatus`.
762
+ decision: status.decision ?? null,
248
763
  confidence: status.confidence ?? null,
764
+ // Both forwarded, because `decision` answers a question about the PROOF and
765
+ // these answer the one about the MILESTONE. Dropping them is how a scheduled,
766
+ // contestable, unpaid approval reached a caller as a completed payout
767
+ // (SEC-058, at the fourth door of three that were fixed).
768
+ milestoneStatus: status.milestoneStatus,
769
+ releaseHoldUntil: status.releaseHoldUntil ?? null,
770
+ milestoneClosed: closed !== null,
771
+ terminalStatus: closed,
772
+ terminalReason: closed ? (status.terminalReason ?? null) : null,
773
+ approvedWithoutThisVerdict: elsewhere,
249
774
  reasoning: status.reasoning,
250
775
  };
251
776
  }
777
+ // Parked for a human: `pending` stays true on this path until the funder decides,
778
+ // so waiting out the loop only delayed the same answer by 100 s. Same test as
779
+ // `parkedForHuman` at both MCP surfaces.
780
+ if (status && parkedForHuman(status)) {
781
+ return { pending: true, proofId, awaitingHumanReview: true };
782
+ }
783
+ // The run that held this proof died: nothing is running to wait for. verify()
784
+ // can restart it once its claim is an hour old; submitting again always works.
785
+ if (status && status.claimPhase === "orphaned") {
786
+ return { pending: true, proofId, interrupted: true };
787
+ }
252
788
  }
253
789
  return { pending: true, proofId };
254
790
  }
255
791
  // ─── Agent marketplace ──────────────────────────────────────────────────────
256
- /** Look up any agent's on-chain reputation by XRPL wallet address. No auth required. */
792
+ /**
793
+ * Look up any agent's on-chain reputation by EVM wallet address. No auth required.
794
+ *
795
+ * `summary` reaches you RAW, and so do `name` and `skills` from `discoverAgents`
796
+ * below. The route deliberately does not mark them (the browser profile page renders
797
+ * the same fields) and both MCP servers do mark them — so this SDK is a machine
798
+ * boundary that does not, exactly like `verify()`.
799
+ *
800
+ * The DECISION is the same and so is its reason: a library cannot know whether its
801
+ * return value ends in a model's context or on a page, where a marker is noise.
802
+ * What was missing here was the REASON, stated at `verify()` and nowhere
803
+ * else, which made this door look like an oversight rather than a choice (SEC-120).
804
+ * If you feed these fields to a model, treat them as data and not as instructions.
805
+ */
257
806
  async getReputation(walletAddress) {
258
807
  return this.request("GET", `/api/agent/reputation/${encodeURIComponent(walletAddress)}`);
259
808
  }