@letsping/sdk 0.2.0 → 0.3.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/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes, createHmac } from "node:crypto";
2
2
 
3
- let SDK_VERSION = "0.2.0";
3
+ let SDK_VERSION = "0.2.1";
4
4
  try {
5
5
 
6
6
  SDK_VERSION = require("../package.json").version;
@@ -62,13 +62,85 @@ export interface Decision {
62
62
  };
63
63
  }
64
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
+
65
90
  export class LetsPingError extends Error {
66
- constructor(message: string, public status?: number) {
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
+ ) {
67
104
  super(message);
68
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);
69
121
  }
70
122
  }
71
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
+
72
144
  interface EncEnvelope {
73
145
  _lp_enc: true;
74
146
  iv: string;
@@ -153,12 +225,283 @@ function computeDiff(original: any, patched: any): any {
153
225
  return hasChanges ? changes : null;
154
226
  }
155
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
+
156
498
  export class LetsPing {
157
499
  private readonly apiKey: string;
158
500
  private readonly baseUrl: string;
159
501
  private readonly encryptionKey: string | null;
502
+ private readonly retry: Required<RetryOptions>;
160
503
 
161
- constructor(apiKey?: string, options?: { baseUrl?: string; encryptionKey?: string }) {
504
+ constructor(apiKey?: string, options?: { baseUrl?: string; encryptionKey?: string; retry?: RetryOptions }) {
162
505
  const key = apiKey || process.env.LETSPING_API_KEY;
163
506
  if (!key) throw new Error("LetsPing: API Key is required. Pass it to the constructor or set LETSPING_API_KEY env var.");
164
507
 
@@ -167,6 +510,12 @@ export class LetsPing {
167
510
  this.encryptionKey = options?.encryptionKey
168
511
  ?? process.env.LETSPING_ENCRYPTION_KEY
169
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
+ };
170
519
  }
171
520
 
172
521
  private _encrypt(payload: Record<string, any>): Record<string, any> {
@@ -212,6 +561,13 @@ export class LetsPing {
212
561
  };
213
562
  }
214
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
+ */
215
571
  async ask(options: RequestOptions): Promise<Decision> {
216
572
  if (options.schema && (options.schema as any)._def) {
217
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.");
@@ -324,8 +680,13 @@ export class LetsPing {
324
680
  delay = Math.min(delay * 1.5, maxDelay);
325
681
  }
326
682
 
327
- throw new LetsPingError(`Request ${id} timed out waiting for approval.`);
328
- } catch (error: any) {
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) {
329
690
  if (span) {
330
691
  span.recordException(error);
331
692
  span.setStatus({ code: otel.SpanStatusCode.ERROR });
@@ -335,6 +696,24 @@ export class LetsPing {
335
696
  }
336
697
  }
337
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
+ */
338
717
  async defer(options: RequestOptions): Promise<{ id: string }> {
339
718
  const otel = await getOtel();
340
719
  let span: any = null;
@@ -410,30 +789,139 @@ export class LetsPing {
410
789
  "User-Agent": `letsping-node/${SDK_VERSION}`,
411
790
  };
412
791
 
413
- try {
414
- const response = await fetch(`${this.baseUrl}${path}`, {
415
- method,
416
- headers,
417
- body: body ? JSON.stringify(body) : undefined,
418
- });
792
+ const maxAttempts = Math.max(1, this.retry.maxAttempts);
793
+ let lastError: LetsPingError | null = null;
419
794
 
420
- if (!response.ok) {
421
- const errorText = await response.text();
422
- let message = errorText;
423
- try {
424
- const json = JSON.parse(errorText);
425
- if (json.message) message = json.message;
426
- } catch { }
427
- throw new LetsPingError(`API Error [${response.status}]: ${message}`, response.status);
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;
428
902
  }
429
903
 
430
- return response.json() as Promise<T>;
431
- } catch (e: any) {
432
- if (e instanceof LetsPingError) throw e;
433
- throw new LetsPingError(`Network Error: ${e.message}`);
904
+ const jitter = Math.random() * 200;
905
+ await new Promise(r => setTimeout(r, delay + jitter));
906
+ delay = Math.min(delay * 1.5, maxDelay);
434
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
+ );
435
915
  }
436
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
+ */
437
925
  tool(service: string, action: string, priority: Priority = "medium"): (context: string | Record<string, any>) => Promise<string> {
438
926
  return async (context: string | Record<string, any>): Promise<string> => {
439
927
  let payload: Record<string, any>;
@@ -473,6 +961,15 @@ export class LetsPing {
473
961
  };
474
962
  }
475
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
+ */
476
973
  async webhookHandler(
477
974
  payloadStr: string,
478
975
  signatureHeader: string,
@@ -483,25 +980,26 @@ export class LetsPing {
483
980
 
484
981
  const rawTs = sigMap["t"];
485
982
  const rawSig = sigMap["v1"];
983
+ const docUrl = `${LETSPING_DOCS_BASE}#webhooks`;
486
984
  if (!rawTs || !rawSig) {
487
- throw new LetsPingError("LetsPing Error: Missing webhook signature fields", 401);
985
+ throw new LetsPingError("LetsPing Error: Missing webhook signature fields", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
488
986
  }
489
987
 
490
988
  const ts = Number(rawTs);
491
989
  if (!Number.isFinite(ts)) {
492
- throw new LetsPingError("LetsPing Error: Invalid webhook timestamp", 401);
990
+ throw new LetsPingError("LetsPing Error: Invalid webhook timestamp", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
493
991
  }
494
992
 
495
993
  const now = Date.now();
496
994
  const skewMs = Math.abs(now - ts);
497
995
  const maxSkewMs = 5 * 60 * 1000; // 5 minutes
498
996
  if (skewMs > maxSkewMs) {
499
- throw new LetsPingError("LetsPing Error: Webhook replay window exceeded", 401);
997
+ throw new LetsPingError("LetsPing Error: Webhook replay window exceeded", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
500
998
  }
501
999
 
502
1000
  const expected = createHmac("sha256", webhookSecret).update(payloadStr).digest("hex");
503
1001
  if (rawSig !== expected) {
504
- throw new LetsPingError("LetsPing Error: Invalid webhook signature", 401);
1002
+ throw new LetsPingError("LetsPing Error: Invalid webhook signature", 401, "LETSPING_WEBHOOK_INVALID", docUrl);
505
1003
  }
506
1004
 
507
1005
  const payload = JSON.parse(payloadStr);
package/dist/index.d.mts DELETED
@@ -1,37 +0,0 @@
1
- type Priority = "low" | "medium" | "high" | "critical";
2
- interface RequestOptions {
3
- service: string;
4
- action: string;
5
- payload: Record<string, any>;
6
- priority?: Priority;
7
- schema?: Record<string, any>;
8
- timeoutMs?: number;
9
- }
10
- interface Decision {
11
- status: "APPROVED" | "REJECTED";
12
- payload: any;
13
- patched_payload?: any;
14
- metadata?: {
15
- resolved_at: string;
16
- actor_id: string;
17
- method?: string;
18
- };
19
- }
20
- declare class LetsPingError extends Error {
21
- status?: number | undefined;
22
- constructor(message: string, status?: number | undefined);
23
- }
24
- declare class LetsPing {
25
- private readonly apiKey;
26
- private readonly baseUrl;
27
- constructor(apiKey?: string, options?: {
28
- baseUrl?: string;
29
- });
30
- ask(options: RequestOptions): Promise<Decision>;
31
- defer(options: RequestOptions): Promise<{
32
- id: string;
33
- }>;
34
- private request;
35
- }
36
-
37
- export { type Decision, LetsPing, LetsPingError, type Priority, type RequestOptions };