@oxygen-agent/cli 1.226.15 → 1.233.8
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 +1 -1
- package/dist/index.js +299 -8
- package/dist/local-custom-http-column.js +378 -141
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.d.ts +62 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +91 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-url.js +6 -1
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +11 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +15 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -0
- package/package.json +1 -1
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { isIP } from "node:net";
|
|
1
3
|
import { OxygenError } from "@oxygen/shared";
|
|
2
4
|
import { requestOxygen } from "./http-client.js";
|
|
5
|
+
const LOCAL_CUSTOM_HTTP_TIMEOUT_MS = 10_000;
|
|
6
|
+
const LOCAL_CUSTOM_HTTP_MAX_RESPONSE_BYTES = 1_000_000;
|
|
3
7
|
export async function runLocalCustomHttpColumn(table, columnKeyOrId, options) {
|
|
4
8
|
const described = await requestOxygen("/api/cli/tables/describe", {
|
|
5
9
|
method: "POST",
|
|
@@ -17,82 +21,17 @@ export async function runLocalCustomHttpColumn(table, columnKeyOrId, options) {
|
|
|
17
21
|
});
|
|
18
22
|
const rows = Array.isArray(queried.rows) ? queried.rows.filter(isRecord) : [];
|
|
19
23
|
const concurrency = normalizeLocalConcurrency(options.concurrency);
|
|
20
|
-
const results = await mapLocalConcurrent(rows, concurrency,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
const execution = await executeLocalCustomHttpColumn(definition, row, secrets);
|
|
32
|
-
const update = await requestOxygen("/api/cli/tables/rows/update", {
|
|
33
|
-
method: "POST",
|
|
34
|
-
body: {
|
|
35
|
-
table,
|
|
36
|
-
row_id: rowId,
|
|
37
|
-
values: { [column.key]: execution.output },
|
|
38
|
-
metadata: {
|
|
39
|
-
command: "columns run --local",
|
|
40
|
-
column_key: column.key,
|
|
41
|
-
column_kind: "tool",
|
|
42
|
-
tool_mode: "custom_http",
|
|
43
|
-
request: execution.request,
|
|
44
|
-
response: execution.response,
|
|
45
|
-
http_status: execution.response.status,
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
return {
|
|
50
|
-
status: "completed",
|
|
51
|
-
rowId,
|
|
52
|
-
toolId: "custom_http",
|
|
53
|
-
request: execution.request,
|
|
54
|
-
response: execution.response,
|
|
55
|
-
output: execution.output,
|
|
56
|
-
run: isRecord(update.run) ? update.run : null,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
const serialized = serializeLocalColumnError(error, "custom_http_run_failed");
|
|
61
|
-
const update = await requestOxygen("/api/cli/tables/rows/update", {
|
|
62
|
-
method: "POST",
|
|
63
|
-
body: {
|
|
64
|
-
table,
|
|
65
|
-
row_id: rowId,
|
|
66
|
-
values: { [column.key]: serialized },
|
|
67
|
-
metadata: {
|
|
68
|
-
command: "columns run --local",
|
|
69
|
-
column_key: column.key,
|
|
70
|
-
column_kind: "tool",
|
|
71
|
-
tool_mode: "custom_http",
|
|
72
|
-
error: serialized.error,
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
return {
|
|
77
|
-
status: "failed",
|
|
78
|
-
rowId,
|
|
79
|
-
toolId: "custom_http",
|
|
80
|
-
output: serialized,
|
|
81
|
-
error: serialized.error,
|
|
82
|
-
run: isRecord(update.run) ? update.run : null,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
const completedCount = results.filter((result) => result.status === "completed").length;
|
|
87
|
-
const failedCount = results.filter((result) => result.status === "failed").length;
|
|
88
|
-
const skippedCount = results.filter((result) => result.status === "skipped").length;
|
|
89
|
-
const first = results[0];
|
|
24
|
+
const results = await mapLocalConcurrent(rows, concurrency, (row) => runLocalCustomHttpRow({
|
|
25
|
+
table,
|
|
26
|
+
columnKey: column.key,
|
|
27
|
+
definition,
|
|
28
|
+
row,
|
|
29
|
+
secrets,
|
|
30
|
+
force: options.force,
|
|
31
|
+
}));
|
|
32
|
+
const { completedCount, failedCount, skippedCount } = countLocalCustomHttpResults(results);
|
|
90
33
|
return {
|
|
91
|
-
status: failedCount
|
|
92
|
-
? "completed_with_errors"
|
|
93
|
-
: completedCount > 0
|
|
94
|
-
? "completed"
|
|
95
|
-
: "skipped",
|
|
34
|
+
status: localCustomHttpRunStatus({ completedCount, failedCount, skippedCount }),
|
|
96
35
|
table: described.table ?? queried.table ?? null,
|
|
97
36
|
column,
|
|
98
37
|
toolId: "custom_http",
|
|
@@ -103,13 +42,109 @@ export async function runLocalCustomHttpColumn(table, columnKeyOrId, options) {
|
|
|
103
42
|
failedCount,
|
|
104
43
|
skippedCount,
|
|
105
44
|
results,
|
|
106
|
-
...(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
45
|
+
...localFirstRowFields(results[0]),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function runLocalCustomHttpRow(input) {
|
|
49
|
+
const rowId = readLocalRowId(input.row);
|
|
50
|
+
if (!input.force && isNonEmptyLocalValue(input.row[input.columnKey])) {
|
|
51
|
+
return {
|
|
52
|
+
status: "skipped",
|
|
53
|
+
rowId,
|
|
54
|
+
reason: "existing_value",
|
|
55
|
+
toolId: "custom_http",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const execution = await executeLocalCustomHttpColumn(input.definition, input.row, input.secrets);
|
|
60
|
+
return await persistLocalCustomHttpSuccess({ ...input, rowId, execution });
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return await persistLocalCustomHttpFailure({ ...input, rowId, error });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function persistLocalCustomHttpSuccess(input) {
|
|
67
|
+
const update = await requestOxygen("/api/cli/tables/rows/update", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
body: {
|
|
70
|
+
table: input.table,
|
|
71
|
+
row_id: input.rowId,
|
|
72
|
+
values: { [input.columnKey]: input.execution.output },
|
|
73
|
+
metadata: {
|
|
74
|
+
command: "columns run --local",
|
|
75
|
+
column_key: input.columnKey,
|
|
76
|
+
column_kind: "tool",
|
|
77
|
+
tool_mode: "custom_http",
|
|
78
|
+
request: input.execution.request,
|
|
79
|
+
response: input.execution.response,
|
|
80
|
+
http_status: input.execution.response.status,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
status: "completed",
|
|
86
|
+
rowId: input.rowId,
|
|
87
|
+
toolId: "custom_http",
|
|
88
|
+
request: input.execution.request,
|
|
89
|
+
response: input.execution.response,
|
|
90
|
+
output: input.execution.output,
|
|
91
|
+
run: isRecord(update.run) ? update.run : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function persistLocalCustomHttpFailure(input) {
|
|
95
|
+
const serialized = serializeLocalColumnError(input.error, "custom_http_run_failed");
|
|
96
|
+
const update = await requestOxygen("/api/cli/tables/rows/update", {
|
|
97
|
+
method: "POST",
|
|
98
|
+
body: {
|
|
99
|
+
table: input.table,
|
|
100
|
+
row_id: input.rowId,
|
|
101
|
+
values: { [input.columnKey]: serialized },
|
|
102
|
+
metadata: {
|
|
103
|
+
command: "columns run --local",
|
|
104
|
+
column_key: input.columnKey,
|
|
105
|
+
column_kind: "tool",
|
|
106
|
+
tool_mode: "custom_http",
|
|
107
|
+
error: serialized.error,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
status: "failed",
|
|
113
|
+
rowId: input.rowId,
|
|
114
|
+
toolId: "custom_http",
|
|
115
|
+
output: serialized,
|
|
116
|
+
error: serialized.error,
|
|
117
|
+
run: isRecord(update.run) ? update.run : null,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function countLocalCustomHttpResults(results) {
|
|
121
|
+
return {
|
|
122
|
+
completedCount: results.filter((result) => result.status === "completed").length,
|
|
123
|
+
failedCount: results.filter((result) => result.status === "failed").length,
|
|
124
|
+
skippedCount: results.filter((result) => result.status === "skipped").length,
|
|
111
125
|
};
|
|
112
126
|
}
|
|
127
|
+
function localCustomHttpRunStatus(counts) {
|
|
128
|
+
if (counts.failedCount > 0)
|
|
129
|
+
return "completed_with_errors";
|
|
130
|
+
if (counts.completedCount > 0)
|
|
131
|
+
return "completed";
|
|
132
|
+
return "skipped";
|
|
133
|
+
}
|
|
134
|
+
function localFirstRowFields(first) {
|
|
135
|
+
if (!first)
|
|
136
|
+
return {};
|
|
137
|
+
const fields = { rowId: first.rowId };
|
|
138
|
+
if ("request" in first)
|
|
139
|
+
fields.request = first.request;
|
|
140
|
+
if ("output" in first)
|
|
141
|
+
fields.output = first.output;
|
|
142
|
+
if ("error" in first)
|
|
143
|
+
fields.error = first.error;
|
|
144
|
+
if ("run" in first)
|
|
145
|
+
fields.run = first.run;
|
|
146
|
+
return fields;
|
|
147
|
+
}
|
|
113
148
|
function findLocalColumn(columns, columnKeyOrId) {
|
|
114
149
|
const column = columns.find((entry) => entry.id === columnKeyOrId || entry.key === columnKeyOrId);
|
|
115
150
|
if (!column) {
|
|
@@ -284,46 +319,94 @@ async function mapLocalConcurrent(values, concurrency, action) {
|
|
|
284
319
|
return results;
|
|
285
320
|
}
|
|
286
321
|
async function executeLocalCustomHttpColumn(definition, row, secrets) {
|
|
322
|
+
const prepared = prepareLocalCustomHttpRequest(definition, row, secrets);
|
|
323
|
+
const response = await fetchLocalCustomHttpResponse(prepared);
|
|
324
|
+
return await readLocalCustomHttpExecution(response, prepared, definition.outputPath ?? null);
|
|
325
|
+
}
|
|
326
|
+
function prepareLocalCustomHttpRequest(definition, row, secrets) {
|
|
287
327
|
const secretValues = Object.values(secrets);
|
|
288
328
|
const method = definition.request.method;
|
|
289
|
-
const
|
|
290
|
-
if (typeof resolvedUrl !== "string" || !resolvedUrl.trim()) {
|
|
291
|
-
throw new OxygenError("invalid_column_run", "Custom HTTP request url must resolve to a string.", {
|
|
292
|
-
details: { field: "request.url" },
|
|
293
|
-
exitCode: 1,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
const url = validateLocalHttpUrl(resolvedUrl, secretValues);
|
|
329
|
+
const url = resolveLocalHttpUrl(definition.request.url, row, secrets, secretValues);
|
|
297
330
|
const headers = resolveLocalHeaders(definition.request.headers ?? {}, row, secrets);
|
|
298
|
-
const bodyValue =
|
|
331
|
+
const bodyValue = resolveLocalHttpBodyValue(definition, row, secrets);
|
|
332
|
+
assertLocalHttpBodyAllowed(method, bodyValue);
|
|
333
|
+
const body = bodyValue === undefined ? undefined : serializeLocalHttpBody(bodyValue, headers);
|
|
334
|
+
return { method, url, headers, bodyValue, body, secretValues };
|
|
335
|
+
}
|
|
336
|
+
function resolveLocalHttpUrl(template, row, secrets, secretValues) {
|
|
337
|
+
const resolvedUrl = resolveLocalTemplateString(template, row, secrets, "request.url");
|
|
338
|
+
if (typeof resolvedUrl === "string" && resolvedUrl.trim())
|
|
339
|
+
return validateLocalHttpUrl(resolvedUrl, secretValues);
|
|
340
|
+
throw new OxygenError("invalid_column_run", "Custom HTTP request url must resolve to a string.", {
|
|
341
|
+
details: { field: "request.url" },
|
|
342
|
+
exitCode: 1,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
function resolveLocalHttpBodyValue(definition, row, secrets) {
|
|
346
|
+
return Object.hasOwn(definition.request, "body")
|
|
299
347
|
? resolveLocalTemplateValue(definition.request.body, row, secrets, "request.body")
|
|
300
348
|
: undefined;
|
|
349
|
+
}
|
|
350
|
+
function assertLocalHttpBodyAllowed(method, bodyValue) {
|
|
301
351
|
if (method === "GET" && bodyValue !== undefined) {
|
|
302
352
|
throw new OxygenError("invalid_column_run", "GET custom HTTP requests cannot include a body.", {
|
|
303
353
|
details: { method },
|
|
304
354
|
exitCode: 1,
|
|
305
355
|
});
|
|
306
356
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
357
|
+
}
|
|
358
|
+
async function fetchLocalCustomHttpResponse(prepared) {
|
|
359
|
+
return await fetch(prepared.url, {
|
|
360
|
+
method: prepared.method,
|
|
361
|
+
headers: prepared.headers,
|
|
362
|
+
...(prepared.body !== undefined ? { body: prepared.body } : {}),
|
|
363
|
+
redirect: "manual",
|
|
364
|
+
signal: AbortSignal.timeout(LOCAL_CUSTOM_HTTP_TIMEOUT_MS),
|
|
312
365
|
}).catch((error) => {
|
|
313
366
|
throw new OxygenError("custom_http_network_error", "Custom HTTP request failed before receiving a response.", {
|
|
314
367
|
details: {
|
|
315
|
-
reason: redactLocalSecrets(error instanceof Error ? error.message : String(error), secretValues),
|
|
368
|
+
reason: redactLocalSecrets(error instanceof Error ? error.message : String(error), prepared.secretValues),
|
|
316
369
|
},
|
|
317
370
|
exitCode: 1,
|
|
318
371
|
});
|
|
319
372
|
});
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
373
|
+
}
|
|
374
|
+
async function readLocalCustomHttpExecution(response, prepared, outputPath) {
|
|
375
|
+
assertLocalNoRedirect(response, prepared.secretValues);
|
|
376
|
+
const responseBody = await readLocalHttpResponseBody(response, LOCAL_CUSTOM_HTTP_MAX_RESPONSE_BYTES);
|
|
377
|
+
const redactedResponseBody = redactLocalSecrets(responseBody, prepared.secretValues);
|
|
378
|
+
const responseSummary = summarizeLocalHttpResponse(response, prepared.secretValues);
|
|
379
|
+
assertLocalOkResponse(response, redactedResponseBody);
|
|
380
|
+
return {
|
|
381
|
+
output: applyLocalOutputPath(redactedResponseBody, outputPath),
|
|
382
|
+
request: redactLocalSecrets({
|
|
383
|
+
method: prepared.method,
|
|
384
|
+
url: prepared.url,
|
|
385
|
+
...(Object.keys(prepared.headers).length > 0 ? { headers: prepared.headers } : {}),
|
|
386
|
+
...(prepared.bodyValue !== undefined ? { body: prepared.bodyValue } : {}),
|
|
387
|
+
}, prepared.secretValues),
|
|
388
|
+
response: responseSummary,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function assertLocalNoRedirect(response, secretValues) {
|
|
392
|
+
if (response.status >= 300 && response.status < 400) {
|
|
393
|
+
throw new OxygenError("custom_http_redirect_blocked", "Custom HTTP redirects are blocked for safety.", {
|
|
394
|
+
details: {
|
|
395
|
+
status: response.status,
|
|
396
|
+
location: redactLocalSecrets(response.headers.get("location") ?? null, secretValues),
|
|
397
|
+
},
|
|
398
|
+
exitCode: 1,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function summarizeLocalHttpResponse(response, secretValues) {
|
|
403
|
+
return {
|
|
323
404
|
status: response.status,
|
|
324
405
|
statusText: response.statusText,
|
|
325
406
|
headers: redactLocalSecrets(localHeadersToObject(response.headers), secretValues),
|
|
326
407
|
};
|
|
408
|
+
}
|
|
409
|
+
function assertLocalOkResponse(response, redactedResponseBody) {
|
|
327
410
|
if (!response.ok) {
|
|
328
411
|
throw new OxygenError("custom_http_request_failed", "Custom HTTP request returned a non-2xx response.", {
|
|
329
412
|
details: {
|
|
@@ -334,22 +417,11 @@ async function executeLocalCustomHttpColumn(definition, row, secrets) {
|
|
|
334
417
|
exitCode: 1,
|
|
335
418
|
});
|
|
336
419
|
}
|
|
337
|
-
return {
|
|
338
|
-
output: applyLocalOutputPath(redactedResponseBody, definition.outputPath ?? null),
|
|
339
|
-
request: redactLocalSecrets({
|
|
340
|
-
method,
|
|
341
|
-
url,
|
|
342
|
-
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
343
|
-
...(bodyValue !== undefined ? { body: bodyValue } : {}),
|
|
344
|
-
}, secretValues),
|
|
345
|
-
response: responseSummary,
|
|
346
|
-
};
|
|
347
420
|
}
|
|
348
421
|
function validateLocalHttpUrl(rawUrl, secretValues) {
|
|
349
422
|
try {
|
|
350
423
|
const parsed = new URL(rawUrl);
|
|
351
|
-
|
|
352
|
-
throw new Error("Unsupported protocol.");
|
|
424
|
+
validateLocalPublicHttpsUrl(parsed);
|
|
353
425
|
return parsed.toString();
|
|
354
426
|
}
|
|
355
427
|
catch (error) {
|
|
@@ -362,6 +434,113 @@ function validateLocalHttpUrl(rawUrl, secretValues) {
|
|
|
362
434
|
});
|
|
363
435
|
}
|
|
364
436
|
}
|
|
437
|
+
function validateLocalPublicHttpsUrl(parsed) {
|
|
438
|
+
if (parsed.protocol !== "https:")
|
|
439
|
+
throw new Error("Custom HTTP URLs must use https.");
|
|
440
|
+
if (parsed.username || parsed.password)
|
|
441
|
+
throw new Error("Custom HTTP URLs cannot contain username or password credentials.");
|
|
442
|
+
const host = normalizeLocalUrlHost(parsed.hostname);
|
|
443
|
+
if (isBlockedLocalCustomHttpHost(host))
|
|
444
|
+
throw new Error("Custom HTTP URL host is not allowed.");
|
|
445
|
+
}
|
|
446
|
+
function normalizeLocalUrlHost(hostname) {
|
|
447
|
+
const host = hostname.toLowerCase().replace(/\.+$/, "");
|
|
448
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
449
|
+
}
|
|
450
|
+
function isBlockedLocalCustomHttpHost(host) {
|
|
451
|
+
return host === "localhost"
|
|
452
|
+
|| host.endsWith(".localhost")
|
|
453
|
+
|| host === "metadata.google.internal"
|
|
454
|
+
|| isBlockedLocalIpv4Address(host)
|
|
455
|
+
|| isBlockedLocalIpv6Address(host);
|
|
456
|
+
}
|
|
457
|
+
function isBlockedLocalIpv4Address(host) {
|
|
458
|
+
const address = isIP(host) === 4 ? host : readLeadingLocalIpv4Label(host);
|
|
459
|
+
if (!address)
|
|
460
|
+
return false;
|
|
461
|
+
const parts = address.split(".").map((part) => Number(part));
|
|
462
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
|
|
463
|
+
return true;
|
|
464
|
+
const [a = 0, b = 0] = parts;
|
|
465
|
+
return a === 0
|
|
466
|
+
|| a === 10
|
|
467
|
+
|| a === 127
|
|
468
|
+
|| (a === 169 && b === 254)
|
|
469
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
470
|
+
|| (a === 192 && b === 168)
|
|
471
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
472
|
+
|| (a === 192 && b === 0)
|
|
473
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
474
|
+
|| a >= 224;
|
|
475
|
+
}
|
|
476
|
+
function readLeadingLocalIpv4Label(host) {
|
|
477
|
+
const match = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\.|$)/.exec(host);
|
|
478
|
+
return match?.[1] ?? null;
|
|
479
|
+
}
|
|
480
|
+
function isBlockedLocalIpv6Address(host) {
|
|
481
|
+
if (isIP(host) !== 6)
|
|
482
|
+
return false;
|
|
483
|
+
const groups = expandLocalIpv6Groups(host);
|
|
484
|
+
if (!groups)
|
|
485
|
+
return true;
|
|
486
|
+
const mappedIpv4 = localIpv4FromIpv6(groups);
|
|
487
|
+
if (mappedIpv4 && isBlockedLocalIpv4Address(mappedIpv4))
|
|
488
|
+
return true;
|
|
489
|
+
const [first = 0, second = 0] = groups;
|
|
490
|
+
const isUnspecified = groups.every((group) => group === 0);
|
|
491
|
+
const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
|
|
492
|
+
return isUnspecified
|
|
493
|
+
|| isLoopback
|
|
494
|
+
|| (first & 0xfe00) === 0xfc00
|
|
495
|
+
|| (first & 0xffc0) === 0xfe80
|
|
496
|
+
|| (first & 0xff00) === 0xff00
|
|
497
|
+
|| (first === 0x2001 && second === 0x0db8);
|
|
498
|
+
}
|
|
499
|
+
function expandLocalIpv6Groups(host) {
|
|
500
|
+
const address = host.toLowerCase();
|
|
501
|
+
const doubleColonParts = address.split("::");
|
|
502
|
+
if (doubleColonParts.length > 2)
|
|
503
|
+
return null;
|
|
504
|
+
const head = splitLocalIpv6Part(doubleColonParts[0] ?? "");
|
|
505
|
+
const tail = splitLocalIpv6Part(doubleColonParts[1] ?? "");
|
|
506
|
+
if (!head || !tail)
|
|
507
|
+
return null;
|
|
508
|
+
const fill = doubleColonParts.length === 2 ? 8 - head.length - tail.length : 0;
|
|
509
|
+
if (fill < 0)
|
|
510
|
+
return null;
|
|
511
|
+
const groups = doubleColonParts.length === 2
|
|
512
|
+
? [...head, ...Array.from({ length: fill }, () => 0), ...tail]
|
|
513
|
+
: head;
|
|
514
|
+
return groups.length === 8 ? groups : null;
|
|
515
|
+
}
|
|
516
|
+
function splitLocalIpv6Part(part) {
|
|
517
|
+
if (!part)
|
|
518
|
+
return [];
|
|
519
|
+
const groups = part.split(":");
|
|
520
|
+
const values = [];
|
|
521
|
+
for (const group of groups) {
|
|
522
|
+
if (!/^[0-9a-f]{1,4}$/.test(group))
|
|
523
|
+
return null;
|
|
524
|
+
values.push(Number.parseInt(group, 16));
|
|
525
|
+
}
|
|
526
|
+
return values;
|
|
527
|
+
}
|
|
528
|
+
function localIpv4FromIpv6(groups) {
|
|
529
|
+
const mappedPrefix = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
|
|
530
|
+
const compatiblePrefix = groups.slice(0, 6).every((group) => group === 0);
|
|
531
|
+
if (!mappedPrefix && !compatiblePrefix)
|
|
532
|
+
return null;
|
|
533
|
+
const high = groups[6] ?? 0;
|
|
534
|
+
const low = groups[7] ?? 0;
|
|
535
|
+
if (high === 0 && low === 0)
|
|
536
|
+
return null;
|
|
537
|
+
return [
|
|
538
|
+
high >> 8,
|
|
539
|
+
high & 0xff,
|
|
540
|
+
low >> 8,
|
|
541
|
+
low & 0xff,
|
|
542
|
+
].join(".");
|
|
543
|
+
}
|
|
365
544
|
function resolveLocalHeaders(headers, row, secrets) {
|
|
366
545
|
const resolved = {};
|
|
367
546
|
for (const [key, value] of Object.entries(headers)) {
|
|
@@ -382,8 +561,17 @@ function hasLocalHeader(headers, name) {
|
|
|
382
561
|
const normalized = name.toLowerCase();
|
|
383
562
|
return Object.keys(headers).some((key) => key.toLowerCase() === normalized);
|
|
384
563
|
}
|
|
385
|
-
async function readLocalHttpResponseBody(response) {
|
|
386
|
-
const
|
|
564
|
+
async function readLocalHttpResponseBody(response, maxBytes) {
|
|
565
|
+
const contentLength = response.headers.get("content-length");
|
|
566
|
+
const declaredLength = contentLength === null ? null : Number(contentLength);
|
|
567
|
+
if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
568
|
+
throw new OxygenError("custom_http_response_too_large", "Custom HTTP response exceeds the configured size cap.", {
|
|
569
|
+
details: { max_response_bytes: maxBytes, content_length: declaredLength },
|
|
570
|
+
exitCode: 1,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
const buffer = await readLocalHttpResponseBuffer(response, maxBytes);
|
|
574
|
+
const text = buffer.toString("utf8");
|
|
387
575
|
if (!text)
|
|
388
576
|
return null;
|
|
389
577
|
const contentType = response.headers.get("content-type") ?? "";
|
|
@@ -403,6 +591,41 @@ async function readLocalHttpResponseBody(response) {
|
|
|
403
591
|
return text;
|
|
404
592
|
}
|
|
405
593
|
}
|
|
594
|
+
async function readLocalHttpResponseBuffer(response, maxBytes) {
|
|
595
|
+
if (!response.body) {
|
|
596
|
+
const buffer = await response.arrayBuffer();
|
|
597
|
+
if (buffer.byteLength > maxBytes) {
|
|
598
|
+
throw new OxygenError("custom_http_response_too_large", "Custom HTTP response exceeds the configured size cap.", {
|
|
599
|
+
details: { max_response_bytes: maxBytes, actual_bytes: buffer.byteLength },
|
|
600
|
+
exitCode: 1,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return Buffer.from(buffer);
|
|
604
|
+
}
|
|
605
|
+
const reader = response.body.getReader();
|
|
606
|
+
const chunks = [];
|
|
607
|
+
let actualBytes = 0;
|
|
608
|
+
try {
|
|
609
|
+
while (true) {
|
|
610
|
+
const { done, value } = await reader.read();
|
|
611
|
+
if (done)
|
|
612
|
+
break;
|
|
613
|
+
actualBytes += value.byteLength;
|
|
614
|
+
if (actualBytes > maxBytes) {
|
|
615
|
+
await reader.cancel().catch(() => undefined);
|
|
616
|
+
throw new OxygenError("custom_http_response_too_large", "Custom HTTP response exceeds the configured size cap.", {
|
|
617
|
+
details: { max_response_bytes: maxBytes, actual_bytes: actualBytes },
|
|
618
|
+
exitCode: 1,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
chunks.push(Buffer.from(value));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
reader.releaseLock();
|
|
626
|
+
}
|
|
627
|
+
return Buffer.concat(chunks, actualBytes);
|
|
628
|
+
}
|
|
406
629
|
function localHeadersToObject(headers) {
|
|
407
630
|
const result = {};
|
|
408
631
|
headers.forEach((value, key) => {
|
|
@@ -434,10 +657,17 @@ function resolveLocalTemplateString(value, row, secrets, inputName) {
|
|
|
434
657
|
});
|
|
435
658
|
}
|
|
436
659
|
function resolveLocalTemplatePath(rawPath, row, secrets, inputName) {
|
|
437
|
-
const segments = rawPath
|
|
660
|
+
const segments = splitLocalTemplatePath(rawPath);
|
|
661
|
+
const root = resolveLocalTemplateRoot(segments, row, secrets, inputName, rawPath);
|
|
662
|
+
return resolveLocalTemplateSegments(root.current, root.segments);
|
|
663
|
+
}
|
|
664
|
+
function splitLocalTemplatePath(rawPath) {
|
|
665
|
+
return rawPath
|
|
438
666
|
.split(".")
|
|
439
667
|
.map((segment) => segment.trim())
|
|
440
668
|
.filter(Boolean);
|
|
669
|
+
}
|
|
670
|
+
function resolveLocalTemplateRoot(segments, row, secrets, inputName, rawPath) {
|
|
441
671
|
const root = segments.shift();
|
|
442
672
|
if (!root) {
|
|
443
673
|
throw new OxygenError("invalid_column_run", "Template reference is empty.", {
|
|
@@ -445,40 +675,47 @@ function resolveLocalTemplatePath(rawPath, row, secrets, inputName) {
|
|
|
445
675
|
exitCode: 1,
|
|
446
676
|
});
|
|
447
677
|
}
|
|
448
|
-
let current;
|
|
449
678
|
if (root === "secrets") {
|
|
450
|
-
|
|
451
|
-
if (!secretName || !Object.hasOwn(secrets, secretName)) {
|
|
452
|
-
throw new OxygenError("invalid_column_run", "Template references an unknown custom HTTP secret.", {
|
|
453
|
-
details: { input: inputName, secret: secretName ?? null, template: rawPath },
|
|
454
|
-
exitCode: 1,
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
current = secrets[secretName];
|
|
679
|
+
return resolveLocalTemplateSecretRoot(segments, secrets, inputName, rawPath);
|
|
458
680
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
681
|
+
return resolveLocalTemplateRowRoot(root, segments, row, inputName, rawPath);
|
|
682
|
+
}
|
|
683
|
+
function resolveLocalTemplateSecretRoot(segments, secrets, inputName, rawPath) {
|
|
684
|
+
const secretName = segments.shift();
|
|
685
|
+
if (!secretName || !Object.hasOwn(secrets, secretName)) {
|
|
686
|
+
throw new OxygenError("invalid_column_run", "Template references an unknown custom HTTP secret.", {
|
|
687
|
+
details: { input: inputName, secret: secretName ?? null, template: rawPath },
|
|
688
|
+
exitCode: 1,
|
|
689
|
+
});
|
|
467
690
|
}
|
|
691
|
+
return { current: secrets[secretName], segments };
|
|
692
|
+
}
|
|
693
|
+
function resolveLocalTemplateRowRoot(root, segments, row, inputName, rawPath) {
|
|
694
|
+
if (!Object.hasOwn(row, root)) {
|
|
695
|
+
throw new OxygenError("invalid_column_run", "Template references an unknown row column.", {
|
|
696
|
+
details: { input: inputName, column_key: root, template: rawPath },
|
|
697
|
+
exitCode: 1,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
return { current: row[root], segments };
|
|
701
|
+
}
|
|
702
|
+
function resolveLocalTemplateSegments(current, segments) {
|
|
703
|
+
let value = current;
|
|
468
704
|
for (const segment of segments) {
|
|
469
|
-
|
|
705
|
+
value = resolveLocalTemplateSegment(value, segment);
|
|
706
|
+
if (value === null)
|
|
470
707
|
return null;
|
|
471
|
-
if (Array.isArray(current) && /^\d+$/.test(segment)) {
|
|
472
|
-
current = current[Number(segment)] ?? null;
|
|
473
|
-
continue;
|
|
474
|
-
}
|
|
475
|
-
if (isRecord(current) && Object.hasOwn(current, segment)) {
|
|
476
|
-
current = current[segment];
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
return null;
|
|
480
708
|
}
|
|
481
|
-
return
|
|
709
|
+
return value ?? null;
|
|
710
|
+
}
|
|
711
|
+
function resolveLocalTemplateSegment(current, segment) {
|
|
712
|
+
if (current === null || current === undefined)
|
|
713
|
+
return null;
|
|
714
|
+
if (Array.isArray(current) && /^\d+$/.test(segment))
|
|
715
|
+
return current[Number(segment)] ?? null;
|
|
716
|
+
if (isRecord(current) && Object.hasOwn(current, segment))
|
|
717
|
+
return current[segment];
|
|
718
|
+
return null;
|
|
482
719
|
}
|
|
483
720
|
function applyLocalOutputPath(value, outputPath) {
|
|
484
721
|
const path = typeof outputPath === "string" ? outputPath.trim() : "";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed token for the RFC 8058 one-click List-Unsubscribe link on native cold
|
|
3
|
+
* email. The worker MINTS the token at send time and bakes the URL into the
|
|
4
|
+
* List-Unsubscribe header; the public web webhook VERIFIES it. The token is the
|
|
5
|
+
* ONLY credential the anonymous unsubscribe endpoint trusts, so it must bind the
|
|
6
|
+
* recipient address — never let the caller pass an arbitrary email. The signing
|
|
7
|
+
* key (EMAIL_UNSUBSCRIBE_SECRET) lives in Doppler oxygen-shared so both the
|
|
8
|
+
* worker (mint) and web (verify) read the same value.
|
|
9
|
+
*
|
|
10
|
+
* Layering: this lives in @oxygen/shared because neither the worker nor the
|
|
11
|
+
* integrations send path can import apps/web, and the web route cannot import the
|
|
12
|
+
* worker. Pure + stateless: crypto only, no env policy (the route decides what a
|
|
13
|
+
* null verify means — a 401).
|
|
14
|
+
*/
|
|
15
|
+
export type UnsubscribeTokenPayload = {
|
|
16
|
+
/** Schema version; only v1 is accepted. */
|
|
17
|
+
v: 1;
|
|
18
|
+
/** Organization id (also the webhook path segment; the route re-checks it). */
|
|
19
|
+
org: string;
|
|
20
|
+
/** The recipient address to suppress (normalized trim+lowercase, = the suppression key). */
|
|
21
|
+
email: string;
|
|
22
|
+
/** Sequence id, for provenance on the suppression row. */
|
|
23
|
+
seq?: string;
|
|
24
|
+
/** Enrollment id, so a one-click stop can halt the exact journey. */
|
|
25
|
+
enr?: string;
|
|
26
|
+
/** Sending mailbox id, for provenance. */
|
|
27
|
+
mbx?: string;
|
|
28
|
+
/** Issued-at (epoch ms), recorded as provenance. */
|
|
29
|
+
iat: number;
|
|
30
|
+
};
|
|
31
|
+
type EnvLike = Record<string, string | undefined>;
|
|
32
|
+
/**
|
|
33
|
+
* Sign a payload into `<base64url(json)>.<base64url(hmac-sha256)>`. The HMAC is
|
|
34
|
+
* computed over the encoded body, so any tamper to the payload invalidates the
|
|
35
|
+
* signature. Normalizes the email to the stored suppression form so the webhook
|
|
36
|
+
* write and the pre-send gate key on the identical string.
|
|
37
|
+
*/
|
|
38
|
+
export declare function signUnsubscribeToken(payload: UnsubscribeTokenPayload, secret: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Verify a token's signature (constant-time) and shape. Returns the payload only
|
|
41
|
+
* when the signature matches AND it is a well-formed v1 payload with a non-empty
|
|
42
|
+
* org + a plausible email; returns null on ANY problem (bad shape, wrong/blank
|
|
43
|
+
* secret, tampered body, replayed garbage). Pure: the route maps null -> 401.
|
|
44
|
+
*/
|
|
45
|
+
export declare function verifyUnsubscribeToken(token: string, secret: string): UnsubscribeTokenPayload | null;
|
|
46
|
+
/**
|
|
47
|
+
* The HMAC signing secret for unsubscribe tokens, or null when unset. The send
|
|
48
|
+
* path treats null as "omit the header" (a send with no one-click link still
|
|
49
|
+
* delivers); the webhook route treats null as fail-closed (reject everything).
|
|
50
|
+
*/
|
|
51
|
+
export declare function unsubscribeSigningSecret(env?: EnvLike): string | null;
|
|
52
|
+
/**
|
|
53
|
+
* The public app base URL the unsubscribe link points at, trailing slash
|
|
54
|
+
* stripped. Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host.
|
|
55
|
+
*/
|
|
56
|
+
export declare function oxygenAppBaseUrl(env?: EnvLike): string;
|
|
57
|
+
/**
|
|
58
|
+
* The canonical one-click unsubscribe URL: org id in the path (the route
|
|
59
|
+
* re-checks it against the token), token in the `token` query param.
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildUnsubscribeUrl(orgId: string, token: string, baseUrl: string): string;
|
|
62
|
+
export {};
|