@letsping/sdk 0.1.2 → 0.1.5

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,34 +1,52 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, createHmac } from "node:crypto";
2
+
3
+ let SDK_VERSION = "0.1.5";
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
+
1
21
  export type Priority = "low" | "medium" | "high" | "critical";
2
22
 
3
- /**
4
- * Options for configuring a LetsPing approval request.
5
- */
6
23
  export interface RequestOptions {
7
- /** Name of the agent or service (e.g., "billing-agent") */
24
+
8
25
  service: string;
9
- /** Specific action being requested (e.g., "refund_user") */
26
+
10
27
  action: string;
11
- /** The data payload to be reviewed by the human */
28
+
12
29
  payload: Record<string, any>;
13
- /** Urgency level affecting notification routing (default: "medium") */
30
+
14
31
  priority?: Priority;
15
- /** JSON Schema (Draft 7) for rendering a structured form in the dashboard */
32
+
16
33
  schema?: Record<string, any>;
17
- /** Maximum time to wait for approval in milliseconds (default: 24h) */
34
+
35
+ state_snapshot?: Record<string, any>;
36
+
18
37
  timeoutMs?: number;
19
- /** (Enterprise) Specific role required for approval (e.g., "finance") */
38
+
20
39
  role?: string;
21
40
  }
22
41
 
23
- /**
24
- * The result of a human approval decision.
25
- */
26
42
  export interface Decision {
27
- status: "APPROVED" | "REJECTED";
28
- /** The original payload submitted */
43
+ status: "APPROVED" | "REJECTED" | "APPROVED_WITH_MODIFICATIONS";
44
+
29
45
  payload: any;
30
- /** The modified payload if the human edited values during approval */
46
+
31
47
  patched_payload?: any;
48
+
49
+ diff_summary?: any;
32
50
  metadata?: {
33
51
  resolved_at: string;
34
52
  actor_id: string;
@@ -43,16 +61,147 @@ export class LetsPingError extends Error {
43
61
  }
44
62
  }
45
63
 
64
+ interface EncEnvelope {
65
+ _lp_enc: true;
66
+ iv: string;
67
+ ct: string;
68
+ }
69
+
70
+ function isEncEnvelope(v: unknown): v is EncEnvelope {
71
+ return (
72
+ typeof v === "object" && v !== null &&
73
+ (v as any)._lp_enc === true &&
74
+ typeof (v as any).iv === "string" &&
75
+ typeof (v as any).ct === "string"
76
+ );
77
+ }
78
+
79
+ function encryptPayload(keyBase64: string, payload: Record<string, any>): EncEnvelope {
80
+ const keyBuf = Buffer.from(keyBase64, "base64");
81
+ const iv = randomBytes(12);
82
+ const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
83
+ const plain = Buffer.from(JSON.stringify(payload), "utf8");
84
+ const ct = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
85
+ return {
86
+ _lp_enc: true,
87
+ iv: iv.toString("base64"),
88
+ ct: ct.toString("base64"),
89
+ };
90
+ }
91
+
92
+ function decryptPayload(keyBase64: string, envelope: EncEnvelope): Record<string, any> {
93
+ const keyBuf = Buffer.from(keyBase64, "base64");
94
+ const iv = Buffer.from(envelope.iv, "base64");
95
+ const ctFull = Buffer.from(envelope.ct, "base64");
96
+
97
+ const authTag = ctFull.subarray(ctFull.length - 16);
98
+ const ct = ctFull.subarray(0, ctFull.length - 16);
99
+ const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
100
+ decipher.setAuthTag(authTag);
101
+ const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
102
+ return JSON.parse(plain.toString("utf8"));
103
+ }
104
+
105
+ function computeDiff(original: any, patched: any): any {
106
+ if (original === patched) return null;
107
+
108
+ if (
109
+ typeof original !== "object" ||
110
+ typeof patched !== "object" ||
111
+ original === null ||
112
+ patched === null ||
113
+ Array.isArray(original) ||
114
+ Array.isArray(patched)
115
+ ) {
116
+ if (JSON.stringify(original) !== JSON.stringify(patched)) {
117
+ return { from: original, to: patched };
118
+ }
119
+ return null;
120
+ }
121
+
122
+ const changes: Record<string, any> = {};
123
+ let hasChanges = false;
124
+ const allKeys = new Set([...Object.keys(original), ...Object.keys(patched)]);
125
+
126
+ for (const key of allKeys) {
127
+ const oV = original[key];
128
+ const pV = patched[key];
129
+
130
+ if (!(key in original)) {
131
+ changes[key] = { from: undefined, to: pV };
132
+ hasChanges = true;
133
+ } else if (!(key in patched)) {
134
+ changes[key] = { from: oV, to: undefined };
135
+ hasChanges = true;
136
+ } else {
137
+ const nestedDiff = computeDiff(oV, pV);
138
+ if (nestedDiff) {
139
+ changes[key] = nestedDiff;
140
+ hasChanges = true;
141
+ }
142
+ }
143
+ }
144
+
145
+ return hasChanges ? changes : null;
146
+ }
147
+
46
148
  export class LetsPing {
47
149
  private readonly apiKey: string;
48
150
  private readonly baseUrl: string;
151
+ private readonly encryptionKey: string | null;
49
152
 
50
- constructor(apiKey?: string, options?: { baseUrl?: string }) {
153
+ constructor(apiKey?: string, options?: { baseUrl?: string; encryptionKey?: string }) {
51
154
  const key = apiKey || process.env.LETSPING_API_KEY;
52
155
  if (!key) throw new Error("LetsPing: API Key is required. Pass it to the constructor or set LETSPING_API_KEY env var.");
53
156
 
54
157
  this.apiKey = key;
55
158
  this.baseUrl = options?.baseUrl || "https://letsping.co/api";
159
+ this.encryptionKey = options?.encryptionKey
160
+ ?? process.env.LETSPING_ENCRYPTION_KEY
161
+ ?? null;
162
+ }
163
+
164
+ private _encrypt(payload: Record<string, any>): Record<string, any> {
165
+ if (!this.encryptionKey) return payload;
166
+ return encryptPayload(this.encryptionKey, payload) as any;
167
+ }
168
+
169
+ private _decrypt(val: any): any {
170
+ if (!this.encryptionKey || !isEncEnvelope(val)) return val;
171
+ try {
172
+ return decryptPayload(this.encryptionKey, val);
173
+ } catch {
174
+
175
+ return val;
176
+ }
177
+ }
178
+
179
+ private _prepareStateUpload(
180
+ stateSnapshot: Record<string, any>,
181
+ fallbackDek?: string
182
+ ): { data: any; contentType: string } {
183
+ if (this.encryptionKey) {
184
+ return {
185
+ data: this._encrypt(stateSnapshot),
186
+ contentType: "application/json"
187
+ };
188
+ } else if (fallbackDek) {
189
+ const keyBuf = Buffer.from(fallbackDek, "base64");
190
+ const iv = randomBytes(12);
191
+ const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
192
+ const plain = Buffer.from(JSON.stringify(stateSnapshot), "utf8");
193
+ const ct = Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()]);
194
+ const finalPayload = Buffer.concat([iv, ct]);
195
+
196
+ return {
197
+ data: finalPayload,
198
+ contentType: "application/octet-stream"
199
+ };
200
+ }
201
+ return {
202
+ data: stateSnapshot,
203
+ contentType: "application/json"
204
+ };
56
205
  }
57
206
 
58
207
  async ask(options: RequestOptions): Promise<Decision> {
@@ -60,69 +209,163 @@ export class LetsPing {
60
209
  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.");
61
210
  }
62
211
 
63
- const { id } = await this.request<{ id: string }>("POST", "/ingest", {
64
- service: options.service,
65
- action: options.action,
66
- payload: options.payload,
67
- priority: options.priority || "medium",
68
- schema: options.schema,
69
- metadata: {
70
- role: options.role,
71
- sdk: "node"
212
+ const otel = await getOtel();
213
+ let span: any = null;
214
+ if (otel && otel.trace) {
215
+ const tracer = otel.trace.getTracer("letsping-sdk");
216
+ span = tracer.startSpan(`letsping.ask`, {
217
+ attributes: {
218
+ "letsping.service": options.service,
219
+ "letsping.action": options.action,
220
+ "letsping.priority": options.priority || "medium",
221
+ }
222
+ });
223
+ }
224
+
225
+ try {
226
+ const res = await this.request<{ id: string, uploadUrl?: string, dek?: string }>("POST", "/ingest", {
227
+ service: options.service,
228
+ action: options.action,
229
+ payload: this._encrypt(options.payload),
230
+ priority: options.priority || "medium",
231
+ schema: options.schema,
232
+ metadata: { role: options.role, sdk: "node" }
233
+ });
234
+
235
+ const { id, uploadUrl, dek } = res;
236
+
237
+ if (uploadUrl && options.state_snapshot) {
238
+ try {
239
+ const { data, contentType } = this._prepareStateUpload(options.state_snapshot, dek);
240
+ const putRes = await fetch(uploadUrl, {
241
+ method: "PUT",
242
+ headers: { "Content-Type": contentType },
243
+ body: Buffer.isBuffer(data) ? (data as any) : JSON.stringify(data)
244
+ });
245
+ if (!putRes.ok) {
246
+ console.warn("LetsPing: Failed to upload state_snapshot to storage", await putRes.text());
247
+ }
248
+ } catch (e: any) {
249
+ console.warn("LetsPing: Exception uploading state_snapshot", e.message);
250
+ }
72
251
  }
73
- });
74
252
 
75
- const timeout = options.timeoutMs || 24 * 60 * 60 * 1000;
76
- const start = Date.now();
77
- let delay = 1000;
78
- const maxDelay = 10000;
253
+ if (span) span.setAttribute("letsping.request_id", id);
79
254
 
80
- while (Date.now() - start < timeout) {
81
- try {
82
- const check = await this.request<any>("GET", `/status/${id}`);
83
-
84
- if (check.status === "APPROVED" || check.status === "REJECTED") {
85
- return {
86
- status: check.status,
87
- payload: options.payload,
88
- patched_payload: check.patched_payload || options.payload,
89
- metadata: {
90
- resolved_at: check.resolved_at,
91
- actor_id: check.actor_id
255
+ const timeout = options.timeoutMs || 24 * 60 * 60 * 1000;
256
+ const start = Date.now();
257
+ let delay = 1000;
258
+ const maxDelay = 10000;
259
+
260
+ while (Date.now() - start < timeout) {
261
+ try {
262
+ const check = await this.request<any>("GET", `/status/${id}`);
263
+
264
+ if (check.status === "APPROVED" || check.status === "REJECTED") {
265
+ const decryptedPayload = this._decrypt(check.payload) ?? options.payload;
266
+ const decryptedPatched = check.patched_payload ? this._decrypt(check.patched_payload) : undefined;
267
+
268
+ let diff_summary;
269
+ let finalStatus = check.status;
270
+ if (check.status === "APPROVED" && decryptedPatched !== undefined) {
271
+ finalStatus = "APPROVED_WITH_MODIFICATIONS";
272
+ const diff = computeDiff(decryptedPayload, decryptedPatched);
273
+ diff_summary = diff ? { changes: diff } : { changes: "Unknown structure changes" };
92
274
  }
93
- };
94
- }
95
- } catch (e: any) {
96
- // Retry on:
97
- // 1. Network errors (status is undefined)
98
- // 2. 404 (not found yet)
99
- // 3. 429 (rate limit)
100
- // 4. 5xx (server error)
101
- // Fail on: 400, 401, 403 (client errors)
102
- const status = e.status;
103
- if (status && status >= 400 && status < 500 && status !== 404 && status !== 429) {
104
- throw e;
275
+
276
+ if (span) {
277
+ span.setAttribute("letsping.status", finalStatus);
278
+ if (check.actor_id) span.setAttribute("letsping.actor_id", check.actor_id);
279
+ span.end();
280
+ }
281
+
282
+ return {
283
+ status: finalStatus,
284
+ payload: decryptedPayload,
285
+ patched_payload: decryptedPatched,
286
+ diff_summary,
287
+ metadata: {
288
+ resolved_at: check.resolved_at,
289
+ actor_id: check.actor_id,
290
+ }
291
+ };
292
+ }
293
+ } catch (e: any) {
294
+ const s = e.status;
295
+ if (s && s >= 400 && s < 500 && s !== 404 && s !== 429) throw e;
105
296
  }
297
+
298
+ const jitter = Math.random() * 200;
299
+ await new Promise(r => setTimeout(r, delay + jitter));
300
+ delay = Math.min(delay * 1.5, maxDelay);
106
301
  }
107
302
 
108
- const jitter = Math.random() * 200;
109
- await new Promise(r => setTimeout(r, delay + jitter));
110
- delay = Math.min(delay * 1.5, maxDelay);
303
+ throw new LetsPingError(`Request ${id} timed out waiting for approval.`);
304
+ } catch (error: any) {
305
+ if (span) {
306
+ span.recordException(error);
307
+ span.setStatus({ code: otel.SpanStatusCode.ERROR });
308
+ span.end();
309
+ }
310
+ throw error;
111
311
  }
112
-
113
- throw new LetsPingError(`Request ${id} timed out waiting for approval.`);
114
312
  }
115
313
 
116
314
  async defer(options: RequestOptions): Promise<{ id: string }> {
117
- return this.request<{ id: string }>("POST", "/ingest", options);
315
+ const otel = await getOtel();
316
+ let span: any = null;
317
+ if (otel && otel.trace) {
318
+ const tracer = otel.trace.getTracer("letsping-sdk");
319
+ span = tracer.startSpan(`letsping.defer`, {
320
+ attributes: {
321
+ "letsping.service": options.service,
322
+ "letsping.action": options.action,
323
+ "letsping.priority": options.priority || "medium",
324
+ }
325
+ });
326
+ }
327
+
328
+ try {
329
+ const res = await this.request<{ id: string, uploadUrl?: string, dek?: string }>("POST", "/ingest", {
330
+ ...options,
331
+ payload: this._encrypt(options.payload),
332
+ });
333
+ if (res.uploadUrl && options.state_snapshot) {
334
+ try {
335
+ const { data, contentType } = this._prepareStateUpload(options.state_snapshot, res.dek);
336
+ const putRes = await fetch(res.uploadUrl, {
337
+ method: "PUT",
338
+ headers: { "Content-Type": contentType },
339
+ body: Buffer.isBuffer(data) ? (data as any) : JSON.stringify(data)
340
+ });
341
+ if (!putRes.ok) {
342
+ console.warn("LetsPing: Failed to upload state_snapshot to storage", await putRes.text());
343
+ }
344
+ } catch (e: any) {
345
+ console.warn("LetsPing: Exception uploading state_snapshot", e.message);
346
+ }
347
+ }
348
+
349
+ if (span) {
350
+ span.setAttribute("letsping.request_id", res.id);
351
+ span.end();
352
+ }
353
+ return { id: res.id };
354
+ } catch (error: any) {
355
+ if (span) {
356
+ span.recordException(error);
357
+ span.setStatus({ code: otel.SpanStatusCode.ERROR });
358
+ span.end();
359
+ }
360
+ throw error;
361
+ }
118
362
  }
119
363
 
120
364
  private async request<T>(method: string, path: string, body?: any): Promise<T> {
121
- // Shared headers
122
365
  const headers: Record<string, string> = {
123
366
  "Authorization": `Bearer ${this.apiKey}`,
124
367
  "Content-Type": "application/json",
125
- "User-Agent": "letsping-node/0.1.2"
368
+ "User-Agent": `letsping-node/${SDK_VERSION}`,
126
369
  };
127
370
 
128
371
  try {
@@ -134,56 +377,119 @@ export class LetsPing {
134
377
 
135
378
  if (!response.ok) {
136
379
  const errorText = await response.text();
137
- // Try parsing JSON error message
138
380
  let message = errorText;
139
381
  try {
140
382
  const json = JSON.parse(errorText);
141
383
  if (json.message) message = json.message;
142
384
  } catch { }
143
-
144
385
  throw new LetsPingError(`API Error [${response.status}]: ${message}`, response.status);
145
386
  }
146
387
 
147
388
  return response.json() as Promise<T>;
148
389
  } catch (e: any) {
149
390
  if (e instanceof LetsPingError) throw e;
150
- // Fetch/Network errors
151
391
  throw new LetsPingError(`Network Error: ${e.message}`);
152
392
  }
153
393
  }
394
+
154
395
  tool(service: string, action: string, priority: Priority = "medium"): (context: string | Record<string, any>) => Promise<string> {
155
396
  return async (context: string | Record<string, any>): Promise<string> => {
156
397
  let payload: Record<string, any>;
157
398
  try {
158
- if (typeof context === 'string') {
159
- try {
160
- payload = JSON.parse(context);
161
- } catch {
162
- payload = { raw_context: context };
163
- }
164
- } else if (typeof context === 'object' && context !== null) {
399
+ if (typeof context === "string") {
400
+ try { payload = JSON.parse(context); }
401
+ catch { payload = { raw_context: context }; }
402
+ } else if (typeof context === "object" && context !== null) {
165
403
  payload = context;
166
404
  } else {
167
- // Handle numbers, booleans, undefined, etc.
168
405
  payload = { raw_context: String(context) };
169
406
  }
170
407
 
171
- const result = await this.ask({
172
- service,
173
- action,
174
- payload,
175
- priority
176
- });
408
+ const result = await this.ask({ service, action, payload, priority });
177
409
 
178
410
  if (result.status === "REJECTED") {
179
- return `STOP: Action Rejected by Human.`;
411
+ return "STOP: Action Rejected by Human.";
412
+ }
413
+
414
+ if (result.status === "APPROVED_WITH_MODIFICATIONS") {
415
+ return JSON.stringify({
416
+ status: "APPROVED_WITH_MODIFICATIONS",
417
+ message: "The human reviewer authorized this action but modified your original payload. Please review the diff_summary to learn from this correction.",
418
+ diff_summary: result.diff_summary,
419
+ original_payload: result.payload,
420
+ executed_payload: result.patched_payload
421
+ });
180
422
  }
181
423
 
182
- const finalPayload = result.patched_payload || result.payload;
183
- return JSON.stringify(finalPayload);
424
+ return JSON.stringify({
425
+ status: "APPROVED",
426
+ executed_payload: result.payload
427
+ });
184
428
  } catch (e: any) {
185
429
  return `ERROR: System Failure: ${e.message}`;
186
430
  }
187
431
  };
188
432
  }
189
- }
433
+
434
+ async webhookHandler(
435
+ payloadStr: string,
436
+ signatureHeader: string,
437
+ webhookSecret: string
438
+ ): Promise<{ id: string; event: string; data: Decision; state_snapshot?: Record<string, any> }> {
439
+ const hmac = createHmac("sha256", webhookSecret).update(payloadStr).digest("hex");
440
+ const sigParts = signatureHeader.split(",").map(p => p.split("="));
441
+ const sigMap = Object.fromEntries(sigParts);
442
+
443
+ if (sigMap["v1"] !== hmac) {
444
+ throw new LetsPingError("LetsPing Error: Invalid webhook signature", 401);
445
+ }
446
+
447
+ const payload = JSON.parse(payloadStr);
448
+ const data = payload.data;
449
+ let state_snapshot = undefined;
450
+
451
+ if (data && data.state_download_url) {
452
+ try {
453
+ const res = await fetch(data.state_download_url);
454
+ if (res.ok) {
455
+ const contentType = res.headers.get("content-type") || "";
456
+ if (contentType.includes("application/octet-stream")) {
457
+ const fallbackDek = data.dek;
458
+ if (fallbackDek) {
459
+ const buffer = Buffer.from(await res.arrayBuffer());
460
+ const keyBuf = Buffer.from(fallbackDek, "base64");
461
+ const iv = buffer.subarray(0, 12);
462
+ const ctFull = buffer.subarray(12);
463
+
464
+ const authTag = ctFull.subarray(ctFull.length - 16);
465
+ const ct = ctFull.subarray(0, ctFull.length - 16);
466
+
467
+ const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
468
+ decipher.setAuthTag(authTag);
469
+ const plain = Buffer.concat([decipher.update(ct), decipher.final()]);
470
+ state_snapshot = JSON.parse(plain.toString("utf8"));
471
+ } else {
472
+ console.warn("LetsPing: Missing fallback DEK to decrypt octet-stream storage file");
473
+ }
474
+ } else {
475
+ const encState = await res.json();
476
+ state_snapshot = this._decrypt(encState);
477
+ }
478
+ } else {
479
+ console.warn("LetsPing: Could not fetch state_snapshot from storage", await res.text());
480
+ }
481
+ } catch (e: any) {
482
+ console.warn("LetsPing: Exception downloading state_snapshot from webhook url", e.message);
483
+ }
484
+ }
485
+
486
+ return {
487
+ id: payload.id,
488
+ event: payload.event,
489
+ data,
490
+ state_snapshot
491
+ };
492
+ }
493
+ }
494
+
495
+ export { computeDiff };