@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/README.md +25 -2
- package/dist/index.d.ts +23 -8
- package/dist/index.js +363 -48
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +353 -47
- package/dist/index.mjs.map +1 -1
- package/dist-tsc/index.d.ts +68 -0
- package/package.json +37 -28
- package/src/index.ts +390 -84
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
|
-
|
|
24
|
+
|
|
8
25
|
service: string;
|
|
9
|
-
|
|
26
|
+
|
|
10
27
|
action: string;
|
|
11
|
-
|
|
28
|
+
|
|
12
29
|
payload: Record<string, any>;
|
|
13
|
-
|
|
30
|
+
|
|
14
31
|
priority?: Priority;
|
|
15
|
-
|
|
32
|
+
|
|
16
33
|
schema?: Record<string, any>;
|
|
17
|
-
|
|
34
|
+
|
|
35
|
+
state_snapshot?: Record<string, any>;
|
|
36
|
+
|
|
18
37
|
timeoutMs?: number;
|
|
19
|
-
|
|
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
|
-
|
|
43
|
+
status: "APPROVED" | "REJECTED" | "APPROVED_WITH_MODIFICATIONS";
|
|
44
|
+
|
|
29
45
|
payload: any;
|
|
30
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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":
|
|
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 ===
|
|
159
|
-
try {
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
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 };
|