@letsping/sdk 0.3.1 → 0.3.2

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/index.mjs ADDED
@@ -0,0 +1,848 @@
1
+ var __getOwnPropNames = Object.getOwnPropertyNames;
2
+ var __commonJS = (cb, mod) => function __require() {
3
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
4
+ };
5
+
6
+ // package.json
7
+ var require_package = __commonJS({
8
+ "package.json"(exports, module) {
9
+ module.exports = {
10
+ name: "@letsping/sdk",
11
+ version: "0.3.2",
12
+ description: "Agent trust layer: behavioral firewall, HITL, and Cryo-Sleep state for AI agents. Works with LangGraph, Vercel AI SDK, and custom runners.",
13
+ main: "./dist/index.js",
14
+ module: "./dist/index.mjs",
15
+ types: "./dist/index.d.ts",
16
+ exports: {
17
+ ".": {
18
+ types: "./dist/index.d.ts",
19
+ require: "./dist/index.js",
20
+ import: "./dist/index.mjs"
21
+ },
22
+ "./integrations/langgraph": {
23
+ types: "./dist/integrations/langgraph.d.ts",
24
+ require: "./dist/integrations/langgraph.js",
25
+ import: "./dist/integrations/langgraph.mjs"
26
+ }
27
+ },
28
+ files: [
29
+ "dist"
30
+ ],
31
+ scripts: {
32
+ build: "tsup && tsc --emitDeclarationOnly --outDir dist",
33
+ dev: "tsup --watch",
34
+ clean: "rm -rf dist .turbo"
35
+ },
36
+ engines: {
37
+ node: ">=18.0.0"
38
+ },
39
+ homepage: "https://letsping.co",
40
+ license: "MIT",
41
+ keywords: [
42
+ "letsping",
43
+ "agent",
44
+ "hitl",
45
+ "human-in-the-loop",
46
+ "behavioral-firewall",
47
+ "langgraph",
48
+ "vercel-ai",
49
+ "cryo-sleep",
50
+ "state-parking"
51
+ ],
52
+ peerDependencies: {
53
+ "@langchain/core": ">=0.1.52",
54
+ "@langchain/langgraph": ">=0.0.1",
55
+ "@opentelemetry/api": "^1.0.0"
56
+ },
57
+ peerDependenciesMeta: {
58
+ "@opentelemetry/api": {
59
+ optional: true
60
+ },
61
+ "@langchain/langgraph": {
62
+ optional: true
63
+ },
64
+ "@langchain/core": {
65
+ optional: true
66
+ }
67
+ },
68
+ devDependencies: {
69
+ "@langchain/core": "^1.1.28",
70
+ "@langchain/langgraph": "^1.1.5",
71
+ "@opentelemetry/api": "^1.9.0",
72
+ "@types/node": "^22.0.0",
73
+ tsup: "^8.0.0",
74
+ typescript: "^5.7.2"
75
+ },
76
+ publishConfig: {
77
+ access: "public"
78
+ },
79
+ repository: {
80
+ type: "git",
81
+ url: "https://github.com/CordiaLabs/LetsPing.git",
82
+ directory: "packages/sdk"
83
+ }
84
+ };
85
+ }
86
+ });
87
+
88
+ // src/index.ts
89
+ import { createCipheriv, createDecipheriv, randomBytes, createHmac } from "crypto";
90
+ var SDK_VERSION = "0.2.1";
91
+ try {
92
+ SDK_VERSION = require_package().version;
93
+ } catch {
94
+ }
95
+ var otelApi = null;
96
+ var otelTried = false;
97
+ async function getOtel() {
98
+ if (otelTried) return otelApi;
99
+ otelTried = true;
100
+ try {
101
+ otelApi = await import("@opentelemetry/api");
102
+ } catch {
103
+ }
104
+ return otelApi;
105
+ }
106
+ var LETSPING_DOCS_BASE = "https://letsping.co/docs";
107
+ var LetsPingError = class extends Error {
108
+ constructor(message, status, code, documentationUrl) {
109
+ super(message);
110
+ this.name = "LetsPingError";
111
+ this.status = status;
112
+ this.code = code ?? (status ? statusToCode(status) : void 0);
113
+ this.documentationUrl = documentationUrl ?? (this.code ? codeToDocUrl(this.code) : void 0);
114
+ }
115
+ };
116
+ function statusToCode(status) {
117
+ switch (status) {
118
+ case 401:
119
+ return "LETSPING_401_AUTH";
120
+ case 402:
121
+ return "LETSPING_402_QUOTA";
122
+ case 403:
123
+ return "LETSPING_403_FORBIDDEN";
124
+ case 404:
125
+ return "LETSPING_404_NOT_FOUND";
126
+ case 429:
127
+ return "LETSPING_429_RATE_LIMIT";
128
+ case 408:
129
+ return "LETSPING_TIMEOUT";
130
+ default:
131
+ return status >= 500 ? "LETSPING_NETWORK" : `LETSPING_${status}`;
132
+ }
133
+ }
134
+ function codeToDocUrl(code) {
135
+ const anchor = {
136
+ LETSPING_401_AUTH: "#auth",
137
+ LETSPING_402_QUOTA: "#billing",
138
+ LETSPING_403_FORBIDDEN: "#auth",
139
+ LETSPING_404_NOT_FOUND: "#requests",
140
+ LETSPING_429_RATE_LIMIT: "#rate-limits",
141
+ LETSPING_TIMEOUT: "#timeouts",
142
+ LETSPING_NETWORK: "#errors",
143
+ LETSPING_WEBHOOK_INVALID: "#webhooks"
144
+ };
145
+ return `${LETSPING_DOCS_BASE}${anchor[code] ?? ""}`;
146
+ }
147
+ function parseApiError(responseStatus, body) {
148
+ const message = body?.message ?? body?.error ?? `API Error [${responseStatus}]`;
149
+ const code = body?.code ?? statusToCode(responseStatus);
150
+ return { message, code, documentationUrl: codeToDocUrl(code) };
151
+ }
152
+ function isEncEnvelope(v) {
153
+ return typeof v === "object" && v !== null && v._lp_enc === true && typeof v.iv === "string" && typeof v.ct === "string";
154
+ }
155
+ function encryptPayload(keyBase64, payload) {
156
+ const keyBuf = Buffer.from(keyBase64, "base64");
157
+ const iv = randomBytes(12);
158
+ const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
159
+ const plain = Buffer.from(JSON.stringify(payload), "utf8");
160
+ const ct = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
161
+ return {
162
+ _lp_enc: true,
163
+ iv: iv.toString("base64"),
164
+ ct: ct.toString("base64")
165
+ };
166
+ }
167
+ function decryptPayload(keyBase64, envelope) {
168
+ const keyBuf = Buffer.from(keyBase64, "base64");
169
+ const iv = Buffer.from(envelope.iv, "base64");
170
+ const ctFull = Buffer.from(envelope.ct, "base64");
171
+ const authTag = ctFull.subarray(ctFull.length - 16);
172
+ const ct = ctFull.subarray(0, ctFull.length - 16);
173
+ const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
174
+ decipher.setAuthTag(authTag);
175
+ const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
176
+ return JSON.parse(plain.toString("utf8"));
177
+ }
178
+ function computeDiff(original, patched) {
179
+ if (original === patched) return null;
180
+ if (typeof original !== "object" || typeof patched !== "object" || original === null || patched === null || Array.isArray(original) || Array.isArray(patched)) {
181
+ if (JSON.stringify(original) !== JSON.stringify(patched)) {
182
+ return { from: original, to: patched };
183
+ }
184
+ return null;
185
+ }
186
+ const changes = {};
187
+ let hasChanges = false;
188
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(original), ...Object.keys(patched)]);
189
+ for (const key of allKeys) {
190
+ const oV = original[key];
191
+ const pV = patched[key];
192
+ if (!(key in original)) {
193
+ changes[key] = { from: void 0, to: pV };
194
+ hasChanges = true;
195
+ } else if (!(key in patched)) {
196
+ changes[key] = { from: oV, to: void 0 };
197
+ hasChanges = true;
198
+ } else {
199
+ const nestedDiff = computeDiff(oV, pV);
200
+ if (nestedDiff) {
201
+ changes[key] = nestedDiff;
202
+ hasChanges = true;
203
+ }
204
+ }
205
+ }
206
+ return hasChanges ? changes : null;
207
+ }
208
+ function verifyEscrow(event, secret) {
209
+ if (!event.escrow || !event.escrow.handoff_signature) return false;
210
+ const base = {
211
+ id: event.id,
212
+ event: event.event,
213
+ data: event.data,
214
+ upstream_agent_id: event.escrow.upstream_agent_id,
215
+ downstream_agent_id: event.escrow.downstream_agent_id,
216
+ x402_mandate: event.escrow.x402_mandate ?? null,
217
+ ap2_mandate: event.escrow.ap2_mandate ?? null
218
+ };
219
+ const expected = createHmac("sha256", secret).update(JSON.stringify(base)).digest("hex");
220
+ return expected === event.escrow.handoff_signature;
221
+ }
222
+ function signAgentCall(agentId, secret, call) {
223
+ const canonical = JSON.stringify({
224
+ project_id: call.project_id,
225
+ service: call.service,
226
+ action: call.action,
227
+ payload: call.payload
228
+ });
229
+ const signature = createHmac("sha256", secret).update(canonical).digest("hex");
230
+ return {
231
+ agent_id: agentId,
232
+ agent_signature: signature
233
+ };
234
+ }
235
+ function signIngestBody(agentId, secret, body) {
236
+ const { agent_id, agent_signature } = signAgentCall(agentId, secret, {
237
+ project_id: body.project_id,
238
+ service: body.service,
239
+ action: body.action,
240
+ payload: body.payload
241
+ });
242
+ return {
243
+ ...body,
244
+ agent_id,
245
+ agent_signature
246
+ };
247
+ }
248
+ async function createAgentWorkspace(options) {
249
+ const baseUrl = (options?.baseUrl ?? process.env.LETSPING_BASE_URL ?? "https://letsping.co").replace(/\/+$/, "");
250
+ const tokenRes = await fetch(`${baseUrl}/api/agent-signup/request-token`, {
251
+ method: "POST",
252
+ headers: { "Content-Type": "application/json" },
253
+ body: "{}"
254
+ });
255
+ if (!tokenRes.ok) {
256
+ const err = await tokenRes.json().catch(() => ({}));
257
+ const { message, code, documentationUrl } = parseApiError(tokenRes.status, err);
258
+ throw new LetsPingError(message, tokenRes.status, code, documentationUrl);
259
+ }
260
+ const { token } = await tokenRes.json();
261
+ if (!token) {
262
+ throw new LetsPingError("LetsPing Error: No token in request-token response");
263
+ }
264
+ const redeemRes = await fetch(`${baseUrl}/api/agent-signup`, {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify({ token })
268
+ });
269
+ if (!redeemRes.ok) {
270
+ const err = await redeemRes.json().catch(() => ({}));
271
+ const { message, code, documentationUrl } = parseApiError(redeemRes.status, err);
272
+ throw new LetsPingError(message, redeemRes.status, code, documentationUrl);
273
+ }
274
+ const redeem = await redeemRes.json();
275
+ if (!redeem.api_key || !redeem.agents_register_url) {
276
+ throw new LetsPingError("LetsPing Error: Invalid redeem response (missing api_key or agents_register_url)");
277
+ }
278
+ const registerRes = await fetch(redeem.agents_register_url, {
279
+ method: "POST",
280
+ headers: {
281
+ Authorization: `Bearer ${redeem.api_key}`,
282
+ "Content-Type": "application/json"
283
+ },
284
+ body: "{}"
285
+ });
286
+ if (!registerRes.ok) {
287
+ const err = await registerRes.json().catch(() => ({}));
288
+ const { message, code, documentationUrl } = parseApiError(registerRes.status, err);
289
+ throw new LetsPingError(message, registerRes.status, code, documentationUrl);
290
+ }
291
+ const reg = await registerRes.json();
292
+ if (!reg.agent_id || !reg.agent_secret) {
293
+ throw new LetsPingError("LetsPing Error: Invalid register response (missing agent_id or agent_secret)");
294
+ }
295
+ return {
296
+ project_id: redeem.project_id,
297
+ api_key: redeem.api_key,
298
+ ingest_url: redeem.ingest_url,
299
+ agents_register_url: redeem.agents_register_url,
300
+ agent_id: reg.agent_id,
301
+ agent_secret: reg.agent_secret,
302
+ org_id: redeem.org_id,
303
+ docs_url: redeem.docs_url
304
+ };
305
+ }
306
+ async function ingestWithAgentSignature(agentId, agentSecret, payload, options) {
307
+ const body = signIngestBody(agentId, agentSecret, {
308
+ project_id: options.projectId,
309
+ service: payload.service,
310
+ action: payload.action,
311
+ payload: payload.payload ?? {}
312
+ });
313
+ const res = await fetch(options.ingestUrl, {
314
+ method: "POST",
315
+ headers: {
316
+ Authorization: `Bearer ${options.apiKey}`,
317
+ "Content-Type": "application/json"
318
+ },
319
+ body: JSON.stringify(body)
320
+ });
321
+ const data = await res.json().catch(() => ({}));
322
+ if (!res.ok) {
323
+ const { message, code, documentationUrl } = parseApiError(res.status, data);
324
+ throw new LetsPingError(message, res.status, code, documentationUrl);
325
+ }
326
+ return data;
327
+ }
328
+ function verifyAgentSignature(agentId, secret, call, signature) {
329
+ const { agent_signature } = signAgentCall(agentId, secret, call);
330
+ return agent_signature === signature;
331
+ }
332
+ function chainHandoff(previous, nextData, secret) {
333
+ const base = {
334
+ id: previous.id,
335
+ event: previous.event,
336
+ data: nextData.payload,
337
+ upstream_agent_id: nextData.upstream_agent_id,
338
+ downstream_agent_id: nextData.downstream_agent_id
339
+ };
340
+ const handoff_signature = createHmac("sha256", secret).update(JSON.stringify(base)).digest("hex");
341
+ return {
342
+ payload: nextData.payload,
343
+ escrow: {
344
+ mode: "handoff",
345
+ upstream_agent_id: nextData.upstream_agent_id,
346
+ downstream_agent_id: nextData.downstream_agent_id,
347
+ handoff_signature
348
+ }
349
+ };
350
+ }
351
+ var LetsPing = class {
352
+ constructor(apiKey, options) {
353
+ const key = apiKey || process.env.LETSPING_API_KEY;
354
+ if (!key) throw new Error("LetsPing: API Key is required. Pass it to the constructor or set LETSPING_API_KEY env var.");
355
+ this.apiKey = key;
356
+ this.baseUrl = options?.baseUrl || "https://letsping.co/api";
357
+ this.encryptionKey = options?.encryptionKey ?? process.env.LETSPING_ENCRYPTION_KEY ?? null;
358
+ const r = options?.retry ?? {};
359
+ this.retry = {
360
+ maxAttempts: r.maxAttempts ?? 1,
361
+ initialDelayMs: r.initialDelayMs ?? 1e3,
362
+ maxDelayMs: r.maxDelayMs ?? 1e4
363
+ };
364
+ }
365
+ _encrypt(payload) {
366
+ if (!this.encryptionKey) return payload;
367
+ return encryptPayload(this.encryptionKey, payload);
368
+ }
369
+ _decrypt(val) {
370
+ if (!this.encryptionKey || !isEncEnvelope(val)) return val;
371
+ try {
372
+ return decryptPayload(this.encryptionKey, val);
373
+ } catch {
374
+ return val;
375
+ }
376
+ }
377
+ _prepareStateUpload(stateSnapshot, fallbackDek) {
378
+ if (this.encryptionKey) {
379
+ return {
380
+ data: this._encrypt(stateSnapshot),
381
+ contentType: "application/json"
382
+ };
383
+ } else if (fallbackDek) {
384
+ const keyBuf = Buffer.from(fallbackDek, "base64");
385
+ const iv = randomBytes(12);
386
+ const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
387
+ const plain = Buffer.from(JSON.stringify(stateSnapshot), "utf8");
388
+ const ct = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
389
+ const finalPayload = Buffer.concat([iv, ct]);
390
+ return {
391
+ data: finalPayload,
392
+ contentType: "application/octet-stream"
393
+ };
394
+ }
395
+ return {
396
+ data: stateSnapshot,
397
+ contentType: "application/json"
398
+ };
399
+ }
400
+ /**
401
+ * Send a request and block until a human approves or rejects it (or timeout). Use for HITL steps in your agent.
402
+ * @param options - service, action, payload; optional priority, schema, state_snapshot, timeoutMs, role
403
+ * @returns Decision with status APPROVED | REJECTED | APPROVED_WITH_MODIFICATIONS and payload (or patched_payload)
404
+ * @throws LetsPingError with code/documentationUrl on API or network errors, or LETSPING_TIMEOUT if no decision in time
405
+ * @see https://letsping.co/docs#ask
406
+ */
407
+ async ask(options) {
408
+ if (options.schema && options.schema._def) {
409
+ throw new LetsPingError("LetsPing Error: Raw Zod schema detected. You must convert it to JSON Schema (e.g. using 'zod-to-json-schema') before passing it to the SDK.");
410
+ }
411
+ const otel = await getOtel();
412
+ let span = null;
413
+ if (otel && otel.trace) {
414
+ const tracer = otel.trace.getTracer("letsping-sdk");
415
+ span = tracer.startSpan(`letsping.ask`, {
416
+ attributes: {
417
+ "letsping.service": options.service,
418
+ "letsping.action": options.action,
419
+ "letsping.priority": options.priority || "medium"
420
+ }
421
+ });
422
+ }
423
+ const traceId = options.trace_id;
424
+ const parentId = options.parent_request_id;
425
+ const basePayload = options.payload || {};
426
+ const metaKey = "_lp_meta";
427
+ const existingMeta = basePayload[metaKey] || {};
428
+ const enrichedPayload = {
429
+ ...basePayload,
430
+ [metaKey]: {
431
+ ...existingMeta,
432
+ ...traceId ? { trace_id: traceId } : {},
433
+ ...parentId ? { parent_request_id: parentId } : {}
434
+ }
435
+ };
436
+ try {
437
+ const res = await this.request("POST", "/ingest", {
438
+ service: options.service,
439
+ action: options.action,
440
+ payload: this._encrypt(enrichedPayload),
441
+ priority: options.priority || "medium",
442
+ schema: options.schema,
443
+ metadata: { role: options.role, sdk: "node", trace_id: traceId, parent_request_id: parentId }
444
+ });
445
+ const { id, uploadUrl, dek } = res;
446
+ if (uploadUrl && options.state_snapshot) {
447
+ try {
448
+ const { data, contentType } = this._prepareStateUpload(options.state_snapshot, dek);
449
+ const putRes = await fetch(uploadUrl, {
450
+ method: "PUT",
451
+ headers: { "Content-Type": contentType },
452
+ body: Buffer.isBuffer(data) ? data : JSON.stringify(data)
453
+ });
454
+ if (!putRes.ok) {
455
+ console.warn("LetsPing: Failed to upload state_snapshot to storage", await putRes.text());
456
+ }
457
+ } catch (e) {
458
+ console.warn("LetsPing: Exception uploading state_snapshot", e.message);
459
+ }
460
+ }
461
+ if (span) span.setAttribute("letsping.request_id", id);
462
+ const timeout = options.timeoutMs || 24 * 60 * 60 * 1e3;
463
+ const start = Date.now();
464
+ let delay = 1e3;
465
+ const maxDelay = 1e4;
466
+ while (Date.now() - start < timeout) {
467
+ try {
468
+ const check = await this.request("GET", `/status/${id}`);
469
+ if (check.status === "APPROVED" || check.status === "REJECTED") {
470
+ const decryptedPayload = this._decrypt(check.payload) ?? options.payload;
471
+ const decryptedPatched = check.patched_payload ? this._decrypt(check.patched_payload) : void 0;
472
+ let diff_summary;
473
+ let finalStatus = check.status;
474
+ if (check.status === "APPROVED" && decryptedPatched !== void 0) {
475
+ finalStatus = "APPROVED_WITH_MODIFICATIONS";
476
+ const diff = computeDiff(decryptedPayload, decryptedPatched);
477
+ diff_summary = diff ? { changes: diff } : { changes: "Unknown structure changes" };
478
+ }
479
+ if (span) {
480
+ span.setAttribute("letsping.status", finalStatus);
481
+ if (check.actor_id) span.setAttribute("letsping.actor_id", check.actor_id);
482
+ span.end();
483
+ }
484
+ return {
485
+ status: finalStatus,
486
+ payload: decryptedPayload,
487
+ patched_payload: decryptedPatched,
488
+ diff_summary,
489
+ metadata: {
490
+ resolved_at: check.resolved_at,
491
+ actor_id: check.actor_id
492
+ }
493
+ };
494
+ }
495
+ } catch (e) {
496
+ const s = e.status;
497
+ if (s && s >= 400 && s < 500 && s !== 404 && s !== 429) throw e;
498
+ }
499
+ const jitter = Math.random() * 200;
500
+ await new Promise((r) => setTimeout(r, delay + jitter));
501
+ delay = Math.min(delay * 1.5, maxDelay);
502
+ }
503
+ throw new LetsPingError(
504
+ `Request ${id} timed out waiting for approval.`,
505
+ void 0,
506
+ "LETSPING_TIMEOUT",
507
+ `${LETSPING_DOCS_BASE}#timeouts`
508
+ );
509
+ } catch (error) {
510
+ if (span) {
511
+ span.recordException(error);
512
+ span.setStatus({ code: otel.SpanStatusCode.ERROR });
513
+ span.end();
514
+ }
515
+ throw error;
516
+ }
517
+ }
518
+ /**
519
+ * Fetch the current status of a request by id. Use after defer() to poll until status is APPROVED or REJECTED without calling the raw HTTP API.
520
+ * @param id - Request id returned from defer()
521
+ * @returns RequestStatus with status PENDING | APPROVED | REJECTED, payload, resolved_at, actor_id
522
+ * @see https://letsping.co/docs#requests
523
+ */
524
+ async getRequestStatus(id) {
525
+ const raw = await this.request("GET", `/status/${id}`);
526
+ return raw;
527
+ }
528
+ /**
529
+ * Send a request and return immediately with the request id. Poll with getRequestStatus(id) or waitForDecision(id) until resolved.
530
+ * Use for async flows (e.g. webhook rehydration) where you do not want to block in-process.
531
+ * @param options - service, action, payload; optional priority, schema, state_snapshot, role
532
+ * @returns { id } - use id with getRequestStatus(id) or waitForDecision(id)
533
+ * @see https://letsping.co/docs#defer
534
+ */
535
+ async defer(options) {
536
+ const otel = await getOtel();
537
+ let span = null;
538
+ if (otel && otel.trace) {
539
+ const tracer = otel.trace.getTracer("letsping-sdk");
540
+ span = tracer.startSpan(`letsping.defer`, {
541
+ attributes: {
542
+ "letsping.service": options.service,
543
+ "letsping.action": options.action,
544
+ "letsping.priority": options.priority || "medium"
545
+ }
546
+ });
547
+ }
548
+ const traceId = options.trace_id;
549
+ const parentId = options.parent_request_id;
550
+ const basePayload = options.payload || {};
551
+ const metaKey = "_lp_meta";
552
+ const existingMeta = basePayload[metaKey] || {};
553
+ const enrichedPayload = {
554
+ ...basePayload,
555
+ [metaKey]: {
556
+ ...existingMeta,
557
+ ...traceId ? { trace_id: traceId } : {},
558
+ ...parentId ? { parent_request_id: parentId } : {}
559
+ }
560
+ };
561
+ try {
562
+ const res = await this.request("POST", "/ingest", {
563
+ service: options.service,
564
+ action: options.action,
565
+ payload: this._encrypt(enrichedPayload),
566
+ priority: options.priority || "medium",
567
+ schema: options.schema,
568
+ metadata: { role: options.role, sdk: "node", trace_id: traceId, parent_request_id: parentId }
569
+ });
570
+ if (res.uploadUrl && options.state_snapshot) {
571
+ try {
572
+ const { data, contentType } = this._prepareStateUpload(options.state_snapshot, res.dek);
573
+ const putRes = await fetch(res.uploadUrl, {
574
+ method: "PUT",
575
+ headers: { "Content-Type": contentType },
576
+ body: Buffer.isBuffer(data) ? data : JSON.stringify(data)
577
+ });
578
+ if (!putRes.ok) {
579
+ console.warn("LetsPing: Failed to upload state_snapshot to storage", await putRes.text());
580
+ }
581
+ } catch (e) {
582
+ console.warn("LetsPing: Exception uploading state_snapshot", e.message);
583
+ }
584
+ }
585
+ if (span) {
586
+ span.setAttribute("letsping.request_id", res.id);
587
+ span.end();
588
+ }
589
+ return { id: res.id };
590
+ } catch (error) {
591
+ if (span) {
592
+ span.recordException(error);
593
+ span.setStatus({ code: otel.SpanStatusCode.ERROR });
594
+ span.end();
595
+ }
596
+ throw error;
597
+ }
598
+ }
599
+ async request(method, path, body) {
600
+ const headers = {
601
+ "Authorization": `Bearer ${this.apiKey}`,
602
+ "Content-Type": "application/json",
603
+ "User-Agent": `letsping-node/${SDK_VERSION}`
604
+ };
605
+ const maxAttempts = Math.max(1, this.retry.maxAttempts);
606
+ let lastError = null;
607
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
608
+ try {
609
+ const response = await fetch(`${this.baseUrl}${path}`, {
610
+ method,
611
+ headers,
612
+ body: body ? JSON.stringify(body) : void 0
613
+ });
614
+ if (!response.ok) {
615
+ const errorText = await response.text();
616
+ let errorBody = {};
617
+ try {
618
+ errorBody = JSON.parse(errorText);
619
+ } catch {
620
+ }
621
+ const { message, code, documentationUrl } = parseApiError(response.status, errorBody);
622
+ lastError = new LetsPingError(message, response.status, code, documentationUrl);
623
+ const retryable = response.status === 429 || response.status >= 500;
624
+ if (retryable && attempt < maxAttempts) {
625
+ await this._delay(attempt);
626
+ continue;
627
+ }
628
+ throw lastError;
629
+ }
630
+ return await response.json();
631
+ } catch (e) {
632
+ if (e instanceof LetsPingError) {
633
+ lastError = e;
634
+ const retryable = e.status === 429 || e.status != null && e.status >= 500;
635
+ if (retryable && attempt < maxAttempts) {
636
+ await this._delay(attempt);
637
+ continue;
638
+ }
639
+ throw e;
640
+ }
641
+ lastError = new LetsPingError(
642
+ `Network Error: ${e?.message ?? "Unknown"}`,
643
+ void 0,
644
+ "LETSPING_NETWORK",
645
+ `${LETSPING_DOCS_BASE}#errors`
646
+ );
647
+ if (attempt < maxAttempts) {
648
+ await this._delay(attempt);
649
+ continue;
650
+ }
651
+ throw lastError;
652
+ }
653
+ }
654
+ throw lastError ?? new LetsPingError("Request failed", void 0, "LETSPING_NETWORK", `${LETSPING_DOCS_BASE}#errors`);
655
+ }
656
+ _delay(attempt) {
657
+ const delay = Math.min(
658
+ this.retry.initialDelayMs * Math.pow(1.5, attempt - 1) + Math.random() * 200,
659
+ this.retry.maxDelayMs
660
+ );
661
+ return new Promise((r) => setTimeout(r, delay));
662
+ }
663
+ /**
664
+ * Poll for a decision on a request created with defer(). Blocks until status is APPROVED/REJECTED or timeout.
665
+ * @param id - request id from defer()
666
+ * @param options - originalPayload (fallback if payload not in response), timeoutMs (default 24h)
667
+ * @returns Decision same shape as ask()
668
+ * @see https://letsping.co/docs#requests
669
+ */
670
+ async waitForDecision(id, options) {
671
+ const basePayload = options?.originalPayload || {};
672
+ const timeout = options?.timeoutMs || 24 * 60 * 60 * 1e3;
673
+ const start = Date.now();
674
+ let delay = 1e3;
675
+ const maxDelay = 1e4;
676
+ while (Date.now() - start < timeout) {
677
+ try {
678
+ const check = await this.request("GET", `/status/${id}`);
679
+ if (check.status === "APPROVED" || check.status === "REJECTED") {
680
+ const decryptedPayload = this._decrypt(check.payload) ?? basePayload;
681
+ const decryptedPatched = check.patched_payload ? this._decrypt(check.patched_payload) : void 0;
682
+ let diff_summary;
683
+ let finalStatus = check.status;
684
+ if (check.status === "APPROVED" && decryptedPatched !== void 0) {
685
+ finalStatus = "APPROVED_WITH_MODIFICATIONS";
686
+ const diff = computeDiff(decryptedPayload, decryptedPatched);
687
+ diff_summary = diff ? { changes: diff } : { changes: "Unknown structure changes" };
688
+ }
689
+ return {
690
+ status: finalStatus,
691
+ payload: decryptedPayload,
692
+ patched_payload: decryptedPatched,
693
+ diff_summary,
694
+ metadata: {
695
+ resolved_at: check.resolved_at,
696
+ actor_id: check.actor_id
697
+ }
698
+ };
699
+ }
700
+ } catch (e) {
701
+ const s = e.status;
702
+ if (s && s >= 400 && s < 500 && s !== 404 && s !== 429) throw e;
703
+ }
704
+ const jitter = Math.random() * 200;
705
+ await new Promise((r) => setTimeout(r, delay + jitter));
706
+ delay = Math.min(delay * 1.5, maxDelay);
707
+ }
708
+ throw new LetsPingError(
709
+ `Request ${id} timed out waiting for approval.`,
710
+ void 0,
711
+ "LETSPING_TIMEOUT",
712
+ `${LETSPING_DOCS_BASE}#timeouts`
713
+ );
714
+ }
715
+ /**
716
+ * Build a callable tool (e.g. for LangChain) that runs ask(service, action, payload) and returns a result string.
717
+ * @param service - LetsPing service name
718
+ * @param action - action name
719
+ * @param priority - optional priority (default medium)
720
+ * @returns Async function(context) => string; context can be JSON string or object
721
+ * @see https://letsping.co/docs#tool
722
+ */
723
+ tool(service, action, priority = "medium") {
724
+ return async (context) => {
725
+ let payload;
726
+ try {
727
+ if (typeof context === "string") {
728
+ try {
729
+ payload = JSON.parse(context);
730
+ } catch {
731
+ payload = { raw_context: context };
732
+ }
733
+ } else if (typeof context === "object" && context !== null) {
734
+ payload = context;
735
+ } else {
736
+ payload = { raw_context: String(context) };
737
+ }
738
+ const result = await this.ask({ service, action, payload, priority });
739
+ if (result.status === "REJECTED") {
740
+ return "STOP: Action Rejected by Human.";
741
+ }
742
+ if (result.status === "APPROVED_WITH_MODIFICATIONS") {
743
+ return JSON.stringify({
744
+ status: "APPROVED_WITH_MODIFICATIONS",
745
+ message: "The human reviewer authorized this action but modified your original payload. Please review the diff_summary to learn from this correction.",
746
+ diff_summary: result.diff_summary,
747
+ original_payload: result.payload,
748
+ executed_payload: result.patched_payload
749
+ });
750
+ }
751
+ return JSON.stringify({
752
+ status: "APPROVED",
753
+ executed_payload: result.payload
754
+ });
755
+ } catch (e) {
756
+ return `ERROR: System Failure: ${e.message}`;
757
+ }
758
+ };
759
+ }
760
+ /**
761
+ * Validate and parse an incoming LetsPing webhook body. Verifies signature and optionally fetches/decrypts state_snapshot.
762
+ * @param payloadStr - raw request body (e.g. await req.text())
763
+ * @param signatureHeader - x-letsping-signature header
764
+ * @param webhookSecret - secret from dashboard → Settings → Webhooks
765
+ * @returns { id, event, data, state_snapshot } for resuming your workflow
766
+ * @throws LetsPingError with code LETSPING_WEBHOOK_INVALID and documentationUrl on invalid signature or replay
767
+ * @see https://letsping.co/docs#webhooks
768
+ */
769
+ async webhookHandler(payloadStr, signatureHeader, webhookSecret) {
770
+ const sigParts = signatureHeader.split(",").map((p) => p.split("="));
771
+ const sigMap = Object.fromEntries(sigParts);
772
+ const rawTs = sigMap["t"];
773
+ const rawSig = sigMap["v1"];
774
+ const docUrl = `${LETSPING_DOCS_BASE}#webhooks`;
775
+ if (!rawTs || !rawSig) {
776
+ throw new LetsPingError("LetsPing Error: Missing webhook signature fields", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
777
+ }
778
+ const ts = Number(rawTs);
779
+ if (!Number.isFinite(ts)) {
780
+ throw new LetsPingError("LetsPing Error: Invalid webhook timestamp", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
781
+ }
782
+ const now = Date.now();
783
+ const skewMs = Math.abs(now - ts);
784
+ const maxSkewMs = 5 * 60 * 1e3;
785
+ if (skewMs > maxSkewMs) {
786
+ throw new LetsPingError("LetsPing Error: Webhook replay window exceeded", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
787
+ }
788
+ const expected = createHmac("sha256", webhookSecret).update(payloadStr).digest("hex");
789
+ if (rawSig !== expected) {
790
+ throw new LetsPingError("LetsPing Error: Invalid webhook signature", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
791
+ }
792
+ const payload = JSON.parse(payloadStr);
793
+ const data = payload.data;
794
+ let state_snapshot = void 0;
795
+ if (data && data.state_download_url) {
796
+ try {
797
+ const res = await fetch(data.state_download_url);
798
+ if (res.ok) {
799
+ const contentType = res.headers.get("content-type") || "";
800
+ if (contentType.includes("application/octet-stream")) {
801
+ const fallbackDek = data.dek;
802
+ if (fallbackDek) {
803
+ const buffer = Buffer.from(await res.arrayBuffer());
804
+ const keyBuf = Buffer.from(fallbackDek, "base64");
805
+ const iv = buffer.subarray(0, 12);
806
+ const ctFull = buffer.subarray(12);
807
+ const authTag = ctFull.subarray(ctFull.length - 16);
808
+ const ct = ctFull.subarray(0, ctFull.length - 16);
809
+ const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
810
+ decipher.setAuthTag(authTag);
811
+ const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
812
+ state_snapshot = JSON.parse(plain.toString("utf8"));
813
+ } else {
814
+ console.warn("LetsPing: Missing fallback DEK to decrypt octet-stream storage file");
815
+ }
816
+ } else {
817
+ const encState = await res.json();
818
+ state_snapshot = this._decrypt(encState);
819
+ }
820
+ } else {
821
+ console.warn("LetsPing: Could not fetch state_snapshot from storage", await res.text());
822
+ }
823
+ } catch (e) {
824
+ console.warn("LetsPing: Exception downloading state_snapshot from webhook url", e.message);
825
+ }
826
+ }
827
+ return {
828
+ id: payload.id,
829
+ event: payload.event,
830
+ data,
831
+ state_snapshot
832
+ };
833
+ }
834
+ };
835
+ export {
836
+ LETSPING_DOCS_BASE,
837
+ LetsPing,
838
+ LetsPingError,
839
+ chainHandoff,
840
+ computeDiff,
841
+ createAgentWorkspace,
842
+ ingestWithAgentSignature,
843
+ signAgentCall,
844
+ signIngestBody,
845
+ verifyAgentSignature,
846
+ verifyEscrow
847
+ };
848
+ //# sourceMappingURL=index.mjs.map