@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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +60 -9
- package/package.json +24 -3
- package/src/index.d.ts +110 -9
- package/src/index.ts +525 -27
- package/dist/index.d.mts +0 -37
- package/dist/index.d.ts +0 -59
- package/dist/index.js +0 -528
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -493
- package/dist/index.mjs.map +0 -1
- package/dist/integrations/langgraph.d.mts +0 -20
- package/dist/integrations/langgraph.d.ts +0 -17
- package/dist/integrations/langgraph.js +0 -150
- package/dist/integrations/langgraph.js.map +0 -1
- package/dist/integrations/langgraph.mjs +0 -125
- package/dist/integrations/langgraph.mjs.map +0 -1
- package/dist-tsc/index.d.ts +0 -68
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.
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 };
|