@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/src/index.ts DELETED
@@ -1,1053 +0,0 @@
1
- import { createCipheriv, createDecipheriv, randomBytes, createHmac } from "node:crypto";
2
-
3
- let SDK_VERSION = "0.2.1";
4
- try {
5
-
6
- SDK_VERSION = require("../package.json").version;
7
- } catch { }
8
-
9
- let otelApi: any = null;
10
- let otelTried = false;
11
-
12
- async function getOtel() {
13
- if (otelTried) return otelApi;
14
- otelTried = true;
15
- try {
16
- otelApi = await import("@opentelemetry/api");
17
- } catch { }
18
- return otelApi;
19
- }
20
-
21
- export type Priority = "low" | "medium" | "high" | "critical";
22
-
23
- export interface RequestOptions {
24
-
25
- service: string;
26
-
27
- action: string;
28
-
29
- payload: Record<string, any>;
30
-
31
- priority?: Priority;
32
-
33
- schema?: Record<string, any>;
34
-
35
- state_snapshot?: Record<string, any>;
36
-
37
- timeoutMs?: number;
38
-
39
- role?: string;
40
-
41
- /**
42
- * Optional distributed tracing identifiers. If provided, these will be
43
- * attached to the request envelope so downstream frameworks can stitch
44
- * together multi-agent flows.
45
- */
46
- trace_id?: string;
47
- parent_request_id?: string;
48
- }
49
-
50
- export interface Decision {
51
- status: "APPROVED" | "REJECTED" | "APPROVED_WITH_MODIFICATIONS";
52
-
53
- payload: any;
54
-
55
- patched_payload?: any;
56
-
57
- diff_summary?: any;
58
- metadata?: {
59
- resolved_at: string;
60
- actor_id: string;
61
- method?: string;
62
- };
63
- }
64
-
65
- /** Status of a request returned by GET /status/:id. Use with defer() + getRequestStatus() for polling without reading the raw HTTP API. */
66
- export interface RequestStatus {
67
- id: string;
68
- status: "PENDING" | "APPROVED" | "REJECTED";
69
- payload?: any;
70
- patched_payload?: any;
71
- resolved_at?: string | null;
72
- actor_id?: string | null;
73
- }
74
-
75
- /** Base URL for error documentation. Errors may include a link to a specific anchor. */
76
- export const LETSPING_DOCS_BASE = "https://letsping.co/docs";
77
-
78
- /** Known error codes for programmatic handling and doc links. */
79
- export type LetsPingErrorCode =
80
- | "LETSPING_401_AUTH"
81
- | "LETSPING_402_QUOTA"
82
- | "LETSPING_403_FORBIDDEN"
83
- | "LETSPING_404_NOT_FOUND"
84
- | "LETSPING_429_RATE_LIMIT"
85
- | "LETSPING_TIMEOUT"
86
- | "LETSPING_NETWORK"
87
- | "LETSPING_WEBHOOK_INVALID"
88
- | string;
89
-
90
- export class LetsPingError extends Error {
91
- /** HTTP status when the error came from the API (e.g. 402, 429). */
92
- public readonly status?: number;
93
- /** Stable code for handling (e.g. LETSPING_402_QUOTA). Use for branching or logging. */
94
- public readonly code?: LetsPingErrorCode;
95
- /** Link to the relevant doc section. Present when code is set. */
96
- public readonly documentationUrl?: string;
97
-
98
- constructor(
99
- message: string,
100
- status?: number,
101
- code?: LetsPingErrorCode,
102
- documentationUrl?: string
103
- ) {
104
- super(message);
105
- this.name = "LetsPingError";
106
- this.status = status;
107
- this.code = code ?? (status ? statusToCode(status) : undefined);
108
- this.documentationUrl = documentationUrl ?? (this.code ? codeToDocUrl(this.code) : undefined);
109
- }
110
- }
111
-
112
- function statusToCode(status: number): LetsPingErrorCode {
113
- switch (status) {
114
- case 401: return "LETSPING_401_AUTH";
115
- case 402: return "LETSPING_402_QUOTA";
116
- case 403: return "LETSPING_403_FORBIDDEN";
117
- case 404: return "LETSPING_404_NOT_FOUND";
118
- case 429: return "LETSPING_429_RATE_LIMIT";
119
- case 408: return "LETSPING_TIMEOUT";
120
- default: return status >= 500 ? "LETSPING_NETWORK" : (`LETSPING_${status}` as LetsPingErrorCode);
121
- }
122
- }
123
-
124
- function codeToDocUrl(code: LetsPingErrorCode): string {
125
- const anchor: Record<string, string> = {
126
- LETSPING_401_AUTH: "#auth",
127
- LETSPING_402_QUOTA: "#billing",
128
- LETSPING_403_FORBIDDEN: "#auth",
129
- LETSPING_404_NOT_FOUND: "#requests",
130
- LETSPING_429_RATE_LIMIT: "#rate-limits",
131
- LETSPING_TIMEOUT: "#timeouts",
132
- LETSPING_NETWORK: "#errors",
133
- LETSPING_WEBHOOK_INVALID: "#webhooks",
134
- };
135
- return `${LETSPING_DOCS_BASE}${anchor[code] ?? ""}`;
136
- }
137
-
138
- function parseApiError(responseStatus: number, body: { message?: string; error?: string; code?: string }): { message: string; code: LetsPingErrorCode; documentationUrl: string } {
139
- const message = body?.message ?? body?.error ?? `API Error [${responseStatus}]`;
140
- const code = (body?.code as LetsPingErrorCode) ?? statusToCode(responseStatus);
141
- return { message, code, documentationUrl: codeToDocUrl(code) };
142
- }
143
-
144
- interface EncEnvelope {
145
- _lp_enc: true;
146
- iv: string;
147
- ct: string;
148
- }
149
-
150
- function isEncEnvelope(v: unknown): v is EncEnvelope {
151
- return (
152
- typeof v === "object" && v !== null &&
153
- (v as any)._lp_enc === true &&
154
- typeof (v as any).iv === "string" &&
155
- typeof (v as any).ct === "string"
156
- );
157
- }
158
-
159
- function encryptPayload(keyBase64: string, payload: Record<string, any>): EncEnvelope {
160
- const keyBuf = Buffer.from(keyBase64, "base64");
161
- const iv = randomBytes(12);
162
- const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
163
- const plain = Buffer.from(JSON.stringify(payload), "utf8");
164
- const ct = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
165
- return {
166
- _lp_enc: true,
167
- iv: iv.toString("base64"),
168
- ct: ct.toString("base64"),
169
- };
170
- }
171
-
172
- function decryptPayload(keyBase64: string, envelope: EncEnvelope): Record<string, any> {
173
- const keyBuf = Buffer.from(keyBase64, "base64");
174
- const iv = Buffer.from(envelope.iv, "base64");
175
- const ctFull = Buffer.from(envelope.ct, "base64");
176
-
177
- const authTag = ctFull.subarray(ctFull.length - 16);
178
- const ct = ctFull.subarray(0, ctFull.length - 16);
179
- const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
180
- decipher.setAuthTag(authTag);
181
- const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
182
- return JSON.parse(plain.toString("utf8"));
183
- }
184
-
185
- function computeDiff(original: any, patched: any): any {
186
- if (original === patched) return null;
187
-
188
- if (
189
- typeof original !== "object" ||
190
- typeof patched !== "object" ||
191
- original === null ||
192
- patched === null ||
193
- Array.isArray(original) ||
194
- Array.isArray(patched)
195
- ) {
196
- if (JSON.stringify(original) !== JSON.stringify(patched)) {
197
- return { from: original, to: patched };
198
- }
199
- return null;
200
- }
201
-
202
- const changes: Record<string, any> = {};
203
- let hasChanges = false;
204
- const allKeys = new Set([...Object.keys(original), ...Object.keys(patched)]);
205
-
206
- for (const key of allKeys) {
207
- const oV = original[key];
208
- const pV = patched[key];
209
-
210
- if (!(key in original)) {
211
- changes[key] = { from: undefined, to: pV };
212
- hasChanges = true;
213
- } else if (!(key in patched)) {
214
- changes[key] = { from: oV, to: undefined };
215
- hasChanges = true;
216
- } else {
217
- const nestedDiff = computeDiff(oV, pV);
218
- if (nestedDiff) {
219
- changes[key] = nestedDiff;
220
- hasChanges = true;
221
- }
222
- }
223
- }
224
-
225
- return hasChanges ? changes : null;
226
- }
227
-
228
- export interface EscrowEnvelope {
229
- id: string;
230
- event: string;
231
- data: any;
232
- escrow?: {
233
- mode: "none" | "handoff" | "finalized";
234
- handoff_signature: string | null;
235
- upstream_agent_id: string | null;
236
- downstream_agent_id: string | null;
237
- x402_mandate?: any;
238
- ap2_mandate?: any;
239
- };
240
- }
241
-
242
- export function verifyEscrow(event: EscrowEnvelope, secret: string): boolean {
243
- if (!event.escrow || !event.escrow.handoff_signature) return false;
244
- const base = {
245
- id: event.id,
246
- event: event.event,
247
- data: event.data,
248
- upstream_agent_id: event.escrow.upstream_agent_id,
249
- downstream_agent_id: event.escrow.downstream_agent_id,
250
- x402_mandate: event.escrow.x402_mandate ?? null,
251
- ap2_mandate: event.escrow.ap2_mandate ?? null,
252
- };
253
- const expected = createHmac("sha256", secret).update(JSON.stringify(base)).digest("hex");
254
- return expected === event.escrow.handoff_signature;
255
- }
256
-
257
- export interface AgentCallPayload {
258
- project_id: string;
259
- service: string;
260
- action: string;
261
- payload: any;
262
- }
263
-
264
- export function signAgentCall(agentId: string, secret: string, call: AgentCallPayload): {
265
- agent_id: string;
266
- agent_signature: string;
267
- } {
268
- const canonical = JSON.stringify({
269
- project_id: call.project_id,
270
- service: call.service,
271
- action: call.action,
272
- payload: call.payload,
273
- });
274
- const signature = createHmac("sha256", secret).update(canonical).digest("hex");
275
- return {
276
- agent_id: agentId,
277
- agent_signature: signature,
278
- };
279
- }
280
-
281
- export function signIngestBody(
282
- agentId: string,
283
- secret: string,
284
- body: {
285
- project_id: string;
286
- service: string;
287
- action: string;
288
- payload: any;
289
- }
290
- ): {
291
- project_id: string;
292
- service: string;
293
- action: string;
294
- payload: any;
295
- agent_id: string;
296
- agent_signature: string;
297
- } {
298
- const { agent_id, agent_signature } = signAgentCall(agentId, secret, {
299
- project_id: body.project_id,
300
- service: body.service,
301
- action: body.action,
302
- payload: body.payload,
303
- });
304
- return {
305
- ...body,
306
- agent_id,
307
- agent_signature,
308
- };
309
- }
310
-
311
- /** Credentials returned by createAgentWorkspace. Use api_key for Bearer auth and ingestWithAgentSignature for signed ingest. */
312
- export interface AgentWorkspaceCredentials {
313
- project_id: string;
314
- api_key: string;
315
- ingest_url: string;
316
- agents_register_url: string;
317
- agent_id: string;
318
- agent_secret: string;
319
- org_id?: string;
320
- docs_url?: string;
321
- }
322
-
323
- /**
324
- * Request a signup token, redeem it to create a workspace, and register one agent. Returns credentials so the agent can call ingestWithAgentSignature.
325
- * Rate limits apply (see letsping.co/docs). Throws on 4xx/5xx or if self-serve signup is disabled.
326
- * @param options.baseUrl - App root URL (e.g. https://letsping.co). Defaults to LETSPING_BASE_URL or https://letsping.co.
327
- */
328
- export async function createAgentWorkspace(options?: { baseUrl?: string }): Promise<AgentWorkspaceCredentials> {
329
- const baseUrl = (options?.baseUrl ?? process.env.LETSPING_BASE_URL ?? "https://letsping.co").replace(/\/+$/, "");
330
-
331
- const tokenRes = await fetch(`${baseUrl}/api/agent-signup/request-token`, {
332
- method: "POST",
333
- headers: { "Content-Type": "application/json" },
334
- body: "{}",
335
- });
336
- if (!tokenRes.ok) {
337
- const err = await tokenRes.json().catch(() => ({})) as { error?: string; code?: string };
338
- const { message, code, documentationUrl } = parseApiError(tokenRes.status, err);
339
- throw new LetsPingError(message, tokenRes.status, code, documentationUrl);
340
- }
341
- const { token } = (await tokenRes.json()) as { token: string };
342
- if (!token) {
343
- throw new LetsPingError("LetsPing Error: No token in request-token response");
344
- }
345
-
346
- const redeemRes = await fetch(`${baseUrl}/api/agent-signup`, {
347
- method: "POST",
348
- headers: { "Content-Type": "application/json" },
349
- body: JSON.stringify({ token }),
350
- });
351
- if (!redeemRes.ok) {
352
- const err = await redeemRes.json().catch(() => ({})) as { error?: string; message?: string };
353
- const { message, code, documentationUrl } = parseApiError(redeemRes.status, err);
354
- throw new LetsPingError(message, redeemRes.status, code, documentationUrl);
355
- }
356
- const redeem = (await redeemRes.json()) as {
357
- project_id: string;
358
- api_key: string;
359
- ingest_url: string;
360
- agents_register_url: string;
361
- org_id?: string;
362
- docs_url?: string;
363
- };
364
- if (!redeem.api_key || !redeem.agents_register_url) {
365
- throw new LetsPingError("LetsPing Error: Invalid redeem response (missing api_key or agents_register_url)");
366
- }
367
-
368
- const registerRes = await fetch(redeem.agents_register_url, {
369
- method: "POST",
370
- headers: {
371
- Authorization: `Bearer ${redeem.api_key}`,
372
- "Content-Type": "application/json",
373
- },
374
- body: "{}",
375
- });
376
- if (!registerRes.ok) {
377
- const err = await registerRes.json().catch(() => ({})) as { error?: string };
378
- const { message, code, documentationUrl } = parseApiError(registerRes.status, err);
379
- throw new LetsPingError(message, registerRes.status, code, documentationUrl);
380
- }
381
- const reg = (await registerRes.json()) as { agent_id: string; agent_secret: string };
382
- if (!reg.agent_id || !reg.agent_secret) {
383
- throw new LetsPingError("LetsPing Error: Invalid register response (missing agent_id or agent_secret)");
384
- }
385
-
386
- return {
387
- project_id: redeem.project_id,
388
- api_key: redeem.api_key,
389
- ingest_url: redeem.ingest_url,
390
- agents_register_url: redeem.agents_register_url,
391
- agent_id: reg.agent_id,
392
- agent_secret: reg.agent_secret,
393
- org_id: redeem.org_id,
394
- docs_url: redeem.docs_url,
395
- };
396
- }
397
-
398
- /** Options for ingestWithAgentSignature. */
399
- export interface IngestWithAgentSignatureOptions {
400
- projectId: string;
401
- ingestUrl: string;
402
- apiKey: string;
403
- }
404
-
405
- /** Ingest payload: service, action, and payload. */
406
- export interface IngestPayload {
407
- service: string;
408
- action: string;
409
- payload: Record<string, any>;
410
- }
411
-
412
- /**
413
- * Build a signed ingest body and POST it to the ingest URL with Bearer apiKey. Returns the JSON response; throws on non-2xx.
414
- * Use this so the agent quickstart does not require hand-rolled HMAC or curl. See also: signIngestBody.
415
- */
416
- export async function ingestWithAgentSignature(
417
- agentId: string,
418
- agentSecret: string,
419
- payload: IngestPayload,
420
- options: IngestWithAgentSignatureOptions
421
- ): Promise<Record<string, any>> {
422
- const body = signIngestBody(agentId, agentSecret, {
423
- project_id: options.projectId,
424
- service: payload.service,
425
- action: payload.action,
426
- payload: payload.payload ?? {},
427
- });
428
- const res = await fetch(options.ingestUrl, {
429
- method: "POST",
430
- headers: {
431
- Authorization: `Bearer ${options.apiKey}`,
432
- "Content-Type": "application/json",
433
- },
434
- body: JSON.stringify(body),
435
- });
436
- const data = (await res.json().catch(() => ({}))) as Record<string, any>;
437
- if (!res.ok) {
438
- const { message, code, documentationUrl } = parseApiError(res.status, data as { error?: string });
439
- throw new LetsPingError(message, res.status, code, documentationUrl);
440
- }
441
- return data;
442
- }
443
-
444
- export function verifyAgentSignature(
445
- agentId: string,
446
- secret: string,
447
- call: AgentCallPayload,
448
- signature: string
449
- ): boolean {
450
- const { agent_signature } = signAgentCall(agentId, secret, call);
451
- return agent_signature === signature;
452
- }
453
-
454
- export function chainHandoff(previous: EscrowEnvelope, nextData: {
455
- service: string;
456
- action: string;
457
- payload: any;
458
- upstream_agent_id: string;
459
- downstream_agent_id: string;
460
- }, secret: string): {
461
- payload: any;
462
- escrow: {
463
- mode: "handoff";
464
- upstream_agent_id: string;
465
- downstream_agent_id: string;
466
- handoff_signature: string;
467
- };
468
- } {
469
- const base = {
470
- id: previous.id,
471
- event: previous.event,
472
- data: nextData.payload,
473
- upstream_agent_id: nextData.upstream_agent_id,
474
- downstream_agent_id: nextData.downstream_agent_id,
475
- };
476
- const handoff_signature = createHmac("sha256", secret).update(JSON.stringify(base)).digest("hex");
477
- return {
478
- payload: nextData.payload,
479
- escrow: {
480
- mode: "handoff",
481
- upstream_agent_id: nextData.upstream_agent_id,
482
- downstream_agent_id: nextData.downstream_agent_id,
483
- handoff_signature,
484
- },
485
- };
486
- }
487
-
488
- /** Optional retry config for ingest and status calls. Disabled when maxAttempts is 1 or omitted. */
489
- export interface RetryOptions {
490
- /** Max attempts per request (default 1 = no retry). Try 3 for transient resilience. */
491
- maxAttempts?: number;
492
- /** Initial delay in ms before first retry (default 1000). */
493
- initialDelayMs?: number;
494
- /** Cap on delay between retries in ms (default 10000). */
495
- maxDelayMs?: number;
496
- }
497
-
498
- export class LetsPing {
499
- private readonly apiKey: string;
500
- private readonly baseUrl: string;
501
- private readonly encryptionKey: string | null;
502
- private readonly retry: Required<RetryOptions>;
503
-
504
- constructor(apiKey?: string, options?: { baseUrl?: string; encryptionKey?: string; retry?: RetryOptions }) {
505
- const key = apiKey || process.env.LETSPING_API_KEY;
506
- if (!key) throw new Error("LetsPing: API Key is required. Pass it to the constructor or set LETSPING_API_KEY env var.");
507
-
508
- this.apiKey = key;
509
- this.baseUrl = options?.baseUrl || "https://letsping.co/api";
510
- this.encryptionKey = options?.encryptionKey
511
- ?? process.env.LETSPING_ENCRYPTION_KEY
512
- ?? null;
513
- const r = options?.retry ?? {};
514
- this.retry = {
515
- maxAttempts: r.maxAttempts ?? 1,
516
- initialDelayMs: r.initialDelayMs ?? 1000,
517
- maxDelayMs: r.maxDelayMs ?? 10000,
518
- };
519
- }
520
-
521
- private _encrypt(payload: Record<string, any>): Record<string, any> {
522
- if (!this.encryptionKey) return payload;
523
- return encryptPayload(this.encryptionKey, payload) as any;
524
- }
525
-
526
- private _decrypt(val: any): any {
527
- if (!this.encryptionKey || !isEncEnvelope(val)) return val;
528
- try {
529
- return decryptPayload(this.encryptionKey, val);
530
- } catch {
531
-
532
- return val;
533
- }
534
- }
535
-
536
- private _prepareStateUpload(
537
- stateSnapshot: Record<string, any>,
538
- fallbackDek?: string
539
- ): { data: any; contentType: string } {
540
- if (this.encryptionKey) {
541
- return {
542
- data: this._encrypt(stateSnapshot),
543
- contentType: "application/json"
544
- };
545
- } else if (fallbackDek) {
546
- const keyBuf = Buffer.from(fallbackDek, "base64");
547
- const iv = randomBytes(12);
548
- const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
549
- const plain = Buffer.from(JSON.stringify(stateSnapshot), "utf8");
550
- const ct = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
551
- const finalPayload = Buffer.concat([iv, ct]);
552
-
553
- return {
554
- data: finalPayload,
555
- contentType: "application/octet-stream"
556
- };
557
- }
558
- return {
559
- data: stateSnapshot,
560
- contentType: "application/json"
561
- };
562
- }
563
-
564
- /**
565
- * Send a request and block until a human approves or rejects it (or timeout). Use for HITL steps in your agent.
566
- * @param options - service, action, payload; optional priority, schema, state_snapshot, timeoutMs, role
567
- * @returns Decision with status APPROVED | REJECTED | APPROVED_WITH_MODIFICATIONS and payload (or patched_payload)
568
- * @throws LetsPingError with code/documentationUrl on API or network errors, or LETSPING_TIMEOUT if no decision in time
569
- * @see https://letsping.co/docs#ask
570
- */
571
- async ask(options: RequestOptions): Promise<Decision> {
572
- if (options.schema && (options.schema as any)._def) {
573
- 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.");
574
- }
575
-
576
- const otel = await getOtel();
577
- let span: any = null;
578
- if (otel && otel.trace) {
579
- const tracer = otel.trace.getTracer("letsping-sdk");
580
- span = tracer.startSpan(`letsping.ask`, {
581
- attributes: {
582
- "letsping.service": options.service,
583
- "letsping.action": options.action,
584
- "letsping.priority": options.priority || "medium",
585
- }
586
- });
587
- }
588
-
589
- const traceId = options.trace_id;
590
- const parentId = options.parent_request_id;
591
-
592
- // Do not mutate caller payload; attach tracing metadata under a reserved key.
593
- const basePayload = options.payload || {};
594
- const metaKey = "_lp_meta";
595
- const existingMeta = (basePayload as any)[metaKey] || {};
596
- const enrichedPayload = {
597
- ...basePayload,
598
- [metaKey]: {
599
- ...existingMeta,
600
- ...(traceId ? { trace_id: traceId } : {}),
601
- ...(parentId ? { parent_request_id: parentId } : {}),
602
- },
603
- };
604
-
605
- try {
606
- const res = await this.request<{ id: string, uploadUrl?: string, dek?: string }>("POST", "/ingest", {
607
- service: options.service,
608
- action: options.action,
609
- payload: this._encrypt(enrichedPayload),
610
- priority: options.priority || "medium",
611
- schema: options.schema,
612
- metadata: { role: options.role, sdk: "node", trace_id: traceId, parent_request_id: parentId }
613
- });
614
-
615
- const { id, uploadUrl, dek } = res;
616
-
617
- if (uploadUrl && options.state_snapshot) {
618
- try {
619
- const { data, contentType } = this._prepareStateUpload(options.state_snapshot, dek);
620
- const putRes = await fetch(uploadUrl, {
621
- method: "PUT",
622
- headers: { "Content-Type": contentType },
623
- body: Buffer.isBuffer(data) ? (data as any) : JSON.stringify(data)
624
- });
625
- if (!putRes.ok) {
626
- console.warn("LetsPing: Failed to upload state_snapshot to storage", await putRes.text());
627
- }
628
- } catch (e: any) {
629
- console.warn("LetsPing: Exception uploading state_snapshot", e.message);
630
- }
631
- }
632
-
633
- if (span) span.setAttribute("letsping.request_id", id);
634
-
635
- const timeout = options.timeoutMs || 24 * 60 * 60 * 1000;
636
- const start = Date.now();
637
- let delay = 1000;
638
- const maxDelay = 10000;
639
-
640
- while (Date.now() - start < timeout) {
641
- try {
642
- const check = await this.request<any>("GET", `/status/${id}`);
643
-
644
- if (check.status === "APPROVED" || check.status === "REJECTED") {
645
- const decryptedPayload = this._decrypt(check.payload) ?? options.payload;
646
- const decryptedPatched = check.patched_payload ? this._decrypt(check.patched_payload) : undefined;
647
-
648
- let diff_summary;
649
- let finalStatus = check.status;
650
- if (check.status === "APPROVED" && decryptedPatched !== undefined) {
651
- finalStatus = "APPROVED_WITH_MODIFICATIONS";
652
- const diff = computeDiff(decryptedPayload, decryptedPatched);
653
- diff_summary = diff ? { changes: diff } : { changes: "Unknown structure changes" };
654
- }
655
-
656
- if (span) {
657
- span.setAttribute("letsping.status", finalStatus);
658
- if (check.actor_id) span.setAttribute("letsping.actor_id", check.actor_id);
659
- span.end();
660
- }
661
-
662
- return {
663
- status: finalStatus,
664
- payload: decryptedPayload,
665
- patched_payload: decryptedPatched,
666
- diff_summary,
667
- metadata: {
668
- resolved_at: check.resolved_at,
669
- actor_id: check.actor_id,
670
- }
671
- };
672
- }
673
- } catch (e: any) {
674
- const s = e.status;
675
- if (s && s >= 400 && s < 500 && s !== 404 && s !== 429) throw e;
676
- }
677
-
678
- const jitter = Math.random() * 200;
679
- await new Promise(r => setTimeout(r, delay + jitter));
680
- delay = Math.min(delay * 1.5, maxDelay);
681
- }
682
-
683
- throw new LetsPingError(
684
- `Request ${id} timed out waiting for approval.`,
685
- undefined,
686
- "LETSPING_TIMEOUT",
687
- `${LETSPING_DOCS_BASE}#timeouts`
688
- );
689
- } catch (error: any) {
690
- if (span) {
691
- span.recordException(error);
692
- span.setStatus({ code: otel.SpanStatusCode.ERROR });
693
- span.end();
694
- }
695
- throw error;
696
- }
697
- }
698
-
699
- /**
700
- * 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.
701
- * @param id - Request id returned from defer()
702
- * @returns RequestStatus with status PENDING | APPROVED | REJECTED, payload, resolved_at, actor_id
703
- * @see https://letsping.co/docs#requests
704
- */
705
- async getRequestStatus(id: string): Promise<RequestStatus> {
706
- const raw = await this.request<RequestStatus>("GET", `/status/${id}`);
707
- return raw;
708
- }
709
-
710
- /**
711
- * Send a request and return immediately with the request id. Poll with getRequestStatus(id) or waitForDecision(id) until resolved.
712
- * Use for async flows (e.g. webhook rehydration) where you do not want to block in-process.
713
- * @param options - service, action, payload; optional priority, schema, state_snapshot, role
714
- * @returns { id } - use id with getRequestStatus(id) or waitForDecision(id)
715
- * @see https://letsping.co/docs#defer
716
- */
717
- async defer(options: RequestOptions): Promise<{ id: string }> {
718
- const otel = await getOtel();
719
- let span: any = null;
720
- if (otel && otel.trace) {
721
- const tracer = otel.trace.getTracer("letsping-sdk");
722
- span = tracer.startSpan(`letsping.defer`, {
723
- attributes: {
724
- "letsping.service": options.service,
725
- "letsping.action": options.action,
726
- "letsping.priority": options.priority || "medium",
727
- }
728
- });
729
- }
730
-
731
- const traceId = options.trace_id;
732
- const parentId = options.parent_request_id;
733
- const basePayload = options.payload || {};
734
- const metaKey = "_lp_meta";
735
- const existingMeta = (basePayload as any)[metaKey] || {};
736
- const enrichedPayload = {
737
- ...basePayload,
738
- [metaKey]: {
739
- ...existingMeta,
740
- ...(traceId ? { trace_id: traceId } : {}),
741
- ...(parentId ? { parent_request_id: parentId } : {}),
742
- },
743
- };
744
-
745
- try {
746
- const res = await this.request<{ id: string, uploadUrl?: string, dek?: string }>("POST", "/ingest", {
747
- service: options.service,
748
- action: options.action,
749
- payload: this._encrypt(enrichedPayload),
750
- priority: options.priority || "medium",
751
- schema: options.schema,
752
- metadata: { role: options.role, sdk: "node", trace_id: traceId, parent_request_id: parentId },
753
- });
754
- if (res.uploadUrl && options.state_snapshot) {
755
- try {
756
- const { data, contentType } = this._prepareStateUpload(options.state_snapshot, res.dek);
757
- const putRes = await fetch(res.uploadUrl, {
758
- method: "PUT",
759
- headers: { "Content-Type": contentType },
760
- body: Buffer.isBuffer(data) ? (data as any) : JSON.stringify(data)
761
- });
762
- if (!putRes.ok) {
763
- console.warn("LetsPing: Failed to upload state_snapshot to storage", await putRes.text());
764
- }
765
- } catch (e: any) {
766
- console.warn("LetsPing: Exception uploading state_snapshot", e.message);
767
- }
768
- }
769
-
770
- if (span) {
771
- span.setAttribute("letsping.request_id", res.id);
772
- span.end();
773
- }
774
- return { id: res.id };
775
- } catch (error: any) {
776
- if (span) {
777
- span.recordException(error);
778
- span.setStatus({ code: otel.SpanStatusCode.ERROR });
779
- span.end();
780
- }
781
- throw error;
782
- }
783
- }
784
-
785
- private async request<T>(method: string, path: string, body?: any): Promise<T> {
786
- const headers: Record<string, string> = {
787
- "Authorization": `Bearer ${this.apiKey}`,
788
- "Content-Type": "application/json",
789
- "User-Agent": `letsping-node/${SDK_VERSION}`,
790
- };
791
-
792
- const maxAttempts = Math.max(1, this.retry.maxAttempts);
793
- let lastError: LetsPingError | null = null;
794
-
795
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
796
- try {
797
- const response = await fetch(`${this.baseUrl}${path}`, {
798
- method,
799
- headers,
800
- body: body ? JSON.stringify(body) : undefined,
801
- });
802
-
803
- if (!response.ok) {
804
- const errorText = await response.text();
805
- let errorBody: { message?: string; error?: string; code?: string } = {};
806
- try {
807
- errorBody = JSON.parse(errorText);
808
- } catch { }
809
- const { message, code, documentationUrl } = parseApiError(response.status, errorBody);
810
- lastError = new LetsPingError(message, response.status, code, documentationUrl);
811
- const retryable = response.status === 429 || response.status >= 500;
812
- if (retryable && attempt < maxAttempts) {
813
- await this._delay(attempt);
814
- continue;
815
- }
816
- throw lastError;
817
- }
818
-
819
- return await response.json() as T;
820
- } catch (e: any) {
821
- if (e instanceof LetsPingError) {
822
- lastError = e;
823
- const retryable = e.status === 429 || (e.status != null && e.status >= 500);
824
- if (retryable && attempt < maxAttempts) {
825
- await this._delay(attempt);
826
- continue;
827
- }
828
- throw e;
829
- }
830
- lastError = new LetsPingError(
831
- `Network Error: ${e?.message ?? "Unknown"}`,
832
- undefined,
833
- "LETSPING_NETWORK",
834
- `${LETSPING_DOCS_BASE}#errors`
835
- );
836
- if (attempt < maxAttempts) {
837
- await this._delay(attempt);
838
- continue;
839
- }
840
- throw lastError;
841
- }
842
- }
843
-
844
- throw lastError ?? new LetsPingError("Request failed", undefined, "LETSPING_NETWORK", `${LETSPING_DOCS_BASE}#errors`);
845
- }
846
-
847
- private _delay(attempt: number): Promise<void> {
848
- const delay = Math.min(
849
- this.retry.initialDelayMs * Math.pow(1.5, attempt - 1) + Math.random() * 200,
850
- this.retry.maxDelayMs
851
- );
852
- return new Promise(r => setTimeout(r, delay));
853
- }
854
-
855
- /**
856
- * Poll for a decision on a request created with defer(). Blocks until status is APPROVED/REJECTED or timeout.
857
- * @param id - request id from defer()
858
- * @param options - originalPayload (fallback if payload not in response), timeoutMs (default 24h)
859
- * @returns Decision same shape as ask()
860
- * @see https://letsping.co/docs#requests
861
- */
862
- async waitForDecision(
863
- id: string,
864
- options?: { originalPayload?: Record<string, any>; timeoutMs?: number }
865
- ): Promise<Decision> {
866
- const basePayload = options?.originalPayload || {};
867
- const timeout = options?.timeoutMs || 24 * 60 * 60 * 1000;
868
- const start = Date.now();
869
- let delay = 1000;
870
- const maxDelay = 10000;
871
-
872
- while (Date.now() - start < timeout) {
873
- try {
874
- const check = await this.request<any>("GET", `/status/${id}`);
875
-
876
- if (check.status === "APPROVED" || check.status === "REJECTED") {
877
- const decryptedPayload = this._decrypt(check.payload) ?? basePayload;
878
- const decryptedPatched = check.patched_payload ? this._decrypt(check.patched_payload) : undefined;
879
-
880
- let diff_summary;
881
- let finalStatus: Decision["status"] = check.status;
882
- if (check.status === "APPROVED" && decryptedPatched !== undefined) {
883
- finalStatus = "APPROVED_WITH_MODIFICATIONS";
884
- const diff = computeDiff(decryptedPayload, decryptedPatched);
885
- diff_summary = diff ? { changes: diff } : { changes: "Unknown structure changes" };
886
- }
887
-
888
- return {
889
- status: finalStatus,
890
- payload: decryptedPayload,
891
- patched_payload: decryptedPatched,
892
- diff_summary,
893
- metadata: {
894
- resolved_at: check.resolved_at,
895
- actor_id: check.actor_id,
896
- }
897
- };
898
- }
899
- } catch (e: any) {
900
- const s = e.status;
901
- if (s && s >= 400 && s < 500 && s !== 404 && s !== 429) throw e;
902
- }
903
-
904
- const jitter = Math.random() * 200;
905
- await new Promise(r => setTimeout(r, delay + jitter));
906
- delay = Math.min(delay * 1.5, maxDelay);
907
- }
908
-
909
- throw new LetsPingError(
910
- `Request ${id} timed out waiting for approval.`,
911
- undefined,
912
- "LETSPING_TIMEOUT",
913
- `${LETSPING_DOCS_BASE}#timeouts`
914
- );
915
- }
916
-
917
- /**
918
- * Build a callable tool (e.g. for LangChain) that runs ask(service, action, payload) and returns a result string.
919
- * @param service - LetsPing service name
920
- * @param action - action name
921
- * @param priority - optional priority (default medium)
922
- * @returns Async function(context) => string; context can be JSON string or object
923
- * @see https://letsping.co/docs#tool
924
- */
925
- tool(service: string, action: string, priority: Priority = "medium"): (context: string | Record<string, any>) => Promise<string> {
926
- return async (context: string | Record<string, any>): Promise<string> => {
927
- let payload: Record<string, any>;
928
- try {
929
- if (typeof context === "string") {
930
- try { payload = JSON.parse(context); }
931
- catch { payload = { raw_context: context }; }
932
- } else if (typeof context === "object" && context !== null) {
933
- payload = context;
934
- } else {
935
- payload = { raw_context: String(context) };
936
- }
937
-
938
- const result = await this.ask({ service, action, payload, priority });
939
-
940
- if (result.status === "REJECTED") {
941
- return "STOP: Action Rejected by Human.";
942
- }
943
-
944
- if (result.status === "APPROVED_WITH_MODIFICATIONS") {
945
- return JSON.stringify({
946
- status: "APPROVED_WITH_MODIFICATIONS",
947
- message: "The human reviewer authorized this action but modified your original payload. Please review the diff_summary to learn from this correction.",
948
- diff_summary: result.diff_summary,
949
- original_payload: result.payload,
950
- executed_payload: result.patched_payload
951
- });
952
- }
953
-
954
- return JSON.stringify({
955
- status: "APPROVED",
956
- executed_payload: result.payload
957
- });
958
- } catch (e: any) {
959
- return `ERROR: System Failure: ${e.message}`;
960
- }
961
- };
962
- }
963
-
964
- /**
965
- * Validate and parse an incoming LetsPing webhook body. Verifies signature and optionally fetches/decrypts state_snapshot.
966
- * @param payloadStr - raw request body (e.g. await req.text())
967
- * @param signatureHeader - x-letsping-signature header
968
- * @param webhookSecret - secret from dashboard → Settings → Webhooks
969
- * @returns { id, event, data, state_snapshot } for resuming your workflow
970
- * @throws LetsPingError with code LETSPING_WEBHOOK_INVALID and documentationUrl on invalid signature or replay
971
- * @see https://letsping.co/docs#webhooks
972
- */
973
- async webhookHandler(
974
- payloadStr: string,
975
- signatureHeader: string,
976
- webhookSecret: string
977
- ): Promise<{ id: string; event: string; data: Decision; state_snapshot?: Record<string, any> }> {
978
- const sigParts = signatureHeader.split(",").map(p => p.split("="));
979
- const sigMap = Object.fromEntries(sigParts);
980
-
981
- const rawTs = sigMap["t"];
982
- const rawSig = sigMap["v1"];
983
- const docUrl = `${LETSPING_DOCS_BASE}#webhooks`;
984
- if (!rawTs || !rawSig) {
985
- throw new LetsPingError("LetsPing Error: Missing webhook signature fields", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
986
- }
987
-
988
- const ts = Number(rawTs);
989
- if (!Number.isFinite(ts)) {
990
- throw new LetsPingError("LetsPing Error: Invalid webhook timestamp", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
991
- }
992
-
993
- const now = Date.now();
994
- const skewMs = Math.abs(now - ts);
995
- const maxSkewMs = 5 * 60 * 1000; // 5 minutes
996
- if (skewMs > maxSkewMs) {
997
- throw new LetsPingError("LetsPing Error: Webhook replay window exceeded", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
998
- }
999
-
1000
- const expected = createHmac("sha256", webhookSecret).update(payloadStr).digest("hex");
1001
- if (rawSig !== expected) {
1002
- throw new LetsPingError("LetsPing Error: Invalid webhook signature", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
1003
- }
1004
-
1005
- const payload = JSON.parse(payloadStr);
1006
- const data = payload.data;
1007
- let state_snapshot = undefined;
1008
-
1009
- if (data && data.state_download_url) {
1010
- try {
1011
- const res = await fetch(data.state_download_url);
1012
- if (res.ok) {
1013
- const contentType = res.headers.get("content-type") || "";
1014
- if (contentType.includes("application/octet-stream")) {
1015
- const fallbackDek = data.dek;
1016
- if (fallbackDek) {
1017
- const buffer = Buffer.from(await res.arrayBuffer());
1018
- const keyBuf = Buffer.from(fallbackDek, "base64");
1019
- const iv = buffer.subarray(0, 12);
1020
- const ctFull = buffer.subarray(12);
1021
-
1022
- const authTag = ctFull.subarray(ctFull.length - 16);
1023
- const ct = ctFull.subarray(0, ctFull.length - 16);
1024
-
1025
- const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
1026
- decipher.setAuthTag(authTag);
1027
- const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
1028
- state_snapshot = JSON.parse(plain.toString("utf8"));
1029
- } else {
1030
- console.warn("LetsPing: Missing fallback DEK to decrypt octet-stream storage file");
1031
- }
1032
- } else {
1033
- const encState = await res.json();
1034
- state_snapshot = this._decrypt(encState);
1035
- }
1036
- } else {
1037
- console.warn("LetsPing: Could not fetch state_snapshot from storage", await res.text());
1038
- }
1039
- } catch (e: any) {
1040
- console.warn("LetsPing: Exception downloading state_snapshot from webhook url", e.message);
1041
- }
1042
- }
1043
-
1044
- return {
1045
- id: payload.id,
1046
- event: payload.event,
1047
- data,
1048
- state_snapshot
1049
- };
1050
- }
1051
- }
1052
-
1053
- export { computeDiff };