@oxygen-agent/cli 1.233.8 → 1.242.6
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 +633 -49
- package/dist/local-custom-http-column.d.ts +2 -0
- package/dist/local-custom-http-column.js +77 -110
- package/node_modules/@oxygen/shared/dist/custom-http-safety.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/custom-http-safety.js +162 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +25 -17
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +89 -11
- 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/shared/package.json +5 -0
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { type CustomHttpResolveHostname } from "@oxygen/shared/custom-http-safety";
|
|
1
2
|
type LocalColumnRunOptions = {
|
|
2
3
|
rowId: string | null;
|
|
3
4
|
limit?: number;
|
|
4
5
|
concurrency?: number;
|
|
5
6
|
force: boolean;
|
|
7
|
+
resolveHostname?: CustomHttpResolveHostname;
|
|
6
8
|
};
|
|
7
9
|
export declare function runLocalCustomHttpColumn(table: string, columnKeyOrId: string, options: LocalColumnRunOptions): Promise<Record<string, unknown>>;
|
|
8
10
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
-
import { isIP } from "node:net";
|
|
3
2
|
import { OxygenError } from "@oxygen/shared";
|
|
3
|
+
import { CustomHttpUrlSafetyError, assertCustomHttpPublicUrlSyntax, assertCustomHttpResolvedHostAllowed, } from "@oxygen/shared/custom-http-safety";
|
|
4
4
|
import { requestOxygen } from "./http-client.js";
|
|
5
5
|
const LOCAL_CUSTOM_HTTP_TIMEOUT_MS = 10_000;
|
|
6
6
|
const LOCAL_CUSTOM_HTTP_MAX_RESPONSE_BYTES = 1_000_000;
|
|
@@ -28,11 +28,13 @@ export async function runLocalCustomHttpColumn(table, columnKeyOrId, options) {
|
|
|
28
28
|
row,
|
|
29
29
|
secrets,
|
|
30
30
|
force: options.force,
|
|
31
|
+
...(options.resolveHostname ? { resolveHostname: options.resolveHostname } : {}),
|
|
31
32
|
}));
|
|
32
33
|
const { completedCount, failedCount, skippedCount } = countLocalCustomHttpResults(results);
|
|
33
34
|
return {
|
|
34
35
|
status: localCustomHttpRunStatus({ completedCount, failedCount, skippedCount }),
|
|
35
36
|
table: described.table ?? queried.table ?? null,
|
|
37
|
+
web_url: localCustomHttpTableWebUrl(table, described.table, queried.table),
|
|
36
38
|
column,
|
|
37
39
|
toolId: "custom_http",
|
|
38
40
|
requestedRowCount: rows.length,
|
|
@@ -56,7 +58,7 @@ async function runLocalCustomHttpRow(input) {
|
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
try {
|
|
59
|
-
const execution = await executeLocalCustomHttpColumn(input.definition, input.row, input.secrets);
|
|
61
|
+
const execution = await executeLocalCustomHttpColumn(input.definition, input.row, input.secrets, input.resolveHostname);
|
|
60
62
|
return await persistLocalCustomHttpSuccess({ ...input, rowId, execution });
|
|
61
63
|
}
|
|
62
64
|
catch (error) {
|
|
@@ -145,6 +147,20 @@ function localFirstRowFields(first) {
|
|
|
145
147
|
fields.run = first.run;
|
|
146
148
|
return fields;
|
|
147
149
|
}
|
|
150
|
+
function localCustomHttpTableWebUrl(table, described, queried) {
|
|
151
|
+
const identifier = readLocalTableIdentifier(described) ?? readLocalTableIdentifier(queried) ?? table;
|
|
152
|
+
return `https://oxygen-agent.com/tables/${encodeURIComponent(identifier)}`;
|
|
153
|
+
}
|
|
154
|
+
function readLocalTableIdentifier(table) {
|
|
155
|
+
if (!isRecord(table))
|
|
156
|
+
return null;
|
|
157
|
+
for (const key of ["id", "slug", "key"]) {
|
|
158
|
+
const value = table[key];
|
|
159
|
+
if (typeof value === "string" && value.trim())
|
|
160
|
+
return value.trim();
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
148
164
|
function findLocalColumn(columns, columnKeyOrId) {
|
|
149
165
|
const column = columns.find((entry) => entry.id === columnKeyOrId || entry.key === columnKeyOrId);
|
|
150
166
|
if (!column) {
|
|
@@ -318,13 +334,13 @@ async function mapLocalConcurrent(values, concurrency, action) {
|
|
|
318
334
|
}));
|
|
319
335
|
return results;
|
|
320
336
|
}
|
|
321
|
-
async function executeLocalCustomHttpColumn(definition, row, secrets) {
|
|
337
|
+
async function executeLocalCustomHttpColumn(definition, row, secrets, resolveHostname) {
|
|
322
338
|
const prepared = prepareLocalCustomHttpRequest(definition, row, secrets);
|
|
323
|
-
const response = await fetchLocalCustomHttpResponse(prepared);
|
|
339
|
+
const response = await fetchLocalCustomHttpResponse(prepared, resolveHostname);
|
|
324
340
|
return await readLocalCustomHttpExecution(response, prepared, definition.outputPath ?? null);
|
|
325
341
|
}
|
|
326
342
|
function prepareLocalCustomHttpRequest(definition, row, secrets) {
|
|
327
|
-
const secretValues =
|
|
343
|
+
const secretValues = expandLocalSecretValues(secrets);
|
|
328
344
|
const method = definition.request.method;
|
|
329
345
|
const url = resolveLocalHttpUrl(definition.request.url, row, secrets, secretValues);
|
|
330
346
|
const headers = resolveLocalHeaders(definition.request.headers ?? {}, row, secrets);
|
|
@@ -333,6 +349,27 @@ function prepareLocalCustomHttpRequest(definition, row, secrets) {
|
|
|
333
349
|
const body = bodyValue === undefined ? undefined : serializeLocalHttpBody(bodyValue, headers);
|
|
334
350
|
return { method, url, headers, bodyValue, body, secretValues };
|
|
335
351
|
}
|
|
352
|
+
function expandLocalSecretValues(secrets) {
|
|
353
|
+
const values = new Set();
|
|
354
|
+
for (const secret of Object.values(secrets)) {
|
|
355
|
+
if (!secret)
|
|
356
|
+
continue;
|
|
357
|
+
values.add(secret);
|
|
358
|
+
try {
|
|
359
|
+
values.add(encodeURIComponent(secret));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// Ignore values that cannot be URL-encoded.
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
values.add(encodeURI(secret));
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// Ignore values that cannot be URL-encoded.
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return [...values];
|
|
372
|
+
}
|
|
336
373
|
function resolveLocalHttpUrl(template, row, secrets, secretValues) {
|
|
337
374
|
const resolvedUrl = resolveLocalTemplateString(template, row, secrets, "request.url");
|
|
338
375
|
if (typeof resolvedUrl === "string" && resolvedUrl.trim())
|
|
@@ -355,7 +392,8 @@ function assertLocalHttpBodyAllowed(method, bodyValue) {
|
|
|
355
392
|
});
|
|
356
393
|
}
|
|
357
394
|
}
|
|
358
|
-
async function fetchLocalCustomHttpResponse(prepared) {
|
|
395
|
+
async function fetchLocalCustomHttpResponse(prepared, resolveHostname) {
|
|
396
|
+
await assertLocalResolvedHttpUrlAllowed(prepared, resolveHostname);
|
|
359
397
|
return await fetch(prepared.url, {
|
|
360
398
|
method: prepared.method,
|
|
361
399
|
headers: prepared.headers,
|
|
@@ -371,6 +409,32 @@ async function fetchLocalCustomHttpResponse(prepared) {
|
|
|
371
409
|
});
|
|
372
410
|
});
|
|
373
411
|
}
|
|
412
|
+
async function assertLocalResolvedHttpUrlAllowed(prepared, resolveHostname) {
|
|
413
|
+
try {
|
|
414
|
+
await assertCustomHttpResolvedHostAllowed({
|
|
415
|
+
url: prepared.url,
|
|
416
|
+
...(resolveHostname ? { resolveHostname } : {}),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
if (!(error instanceof CustomHttpUrlSafetyError))
|
|
421
|
+
throw error;
|
|
422
|
+
if (error.reason === "dns_lookup_failed") {
|
|
423
|
+
throw new OxygenError("custom_http_network_error", "Custom HTTP request failed before receiving a response.", {
|
|
424
|
+
details: {
|
|
425
|
+
reason: redactLocalSecrets(error.message, prepared.secretValues),
|
|
426
|
+
},
|
|
427
|
+
exitCode: 1,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
throw new OxygenError("invalid_column_run", "Custom HTTP request url is invalid.", {
|
|
431
|
+
details: {
|
|
432
|
+
reason: redactLocalSecrets(error.message, prepared.secretValues),
|
|
433
|
+
},
|
|
434
|
+
exitCode: 1,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
374
438
|
async function readLocalCustomHttpExecution(response, prepared, outputPath) {
|
|
375
439
|
assertLocalNoRedirect(response, prepared.secretValues);
|
|
376
440
|
const responseBody = await readLocalHttpResponseBody(response, LOCAL_CUSTOM_HTTP_MAX_RESPONSE_BYTES);
|
|
@@ -435,111 +499,14 @@ function validateLocalHttpUrl(rawUrl, secretValues) {
|
|
|
435
499
|
}
|
|
436
500
|
}
|
|
437
501
|
function validateLocalPublicHttpsUrl(parsed) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
throw
|
|
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));
|
|
502
|
+
try {
|
|
503
|
+
assertCustomHttpPublicUrlSyntax(parsed);
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
if (error instanceof CustomHttpUrlSafetyError)
|
|
507
|
+
throw new Error(error.message);
|
|
508
|
+
throw error;
|
|
525
509
|
}
|
|
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
510
|
}
|
|
544
511
|
function resolveLocalHeaders(headers, row, secrets) {
|
|
545
512
|
const resolved = {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type CustomHttpResolvedAddress = {
|
|
2
|
+
address: string;
|
|
3
|
+
family?: number;
|
|
4
|
+
};
|
|
5
|
+
export type CustomHttpResolveHostname = (hostname: string) => Promise<CustomHttpResolvedAddress[]>;
|
|
6
|
+
export type CustomHttpUrlSafetyErrorReason = "blocked_host" | "blocked_resolved_address" | "credentials" | "dns_lookup_failed" | "protocol";
|
|
7
|
+
export declare class CustomHttpUrlSafetyError extends Error {
|
|
8
|
+
readonly reason: CustomHttpUrlSafetyErrorReason;
|
|
9
|
+
readonly details: Record<string, unknown>;
|
|
10
|
+
constructor(reason: CustomHttpUrlSafetyErrorReason, message: string, details?: Record<string, unknown>);
|
|
11
|
+
}
|
|
12
|
+
export declare function assertCustomHttpPublicUrlSyntax(url: string | URL): URL;
|
|
13
|
+
export declare function assertCustomHttpResolvedHostAllowed(input: {
|
|
14
|
+
url: string | URL;
|
|
15
|
+
resolveHostname?: CustomHttpResolveHostname;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
export declare function normalizeCustomHttpUrlHost(hostname: string): string;
|
|
18
|
+
export declare function isBlockedCustomHttpHost(host: string): boolean;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
export class CustomHttpUrlSafetyError extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
details;
|
|
6
|
+
constructor(reason, message, details = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "CustomHttpUrlSafetyError";
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function assertCustomHttpPublicUrlSyntax(url) {
|
|
14
|
+
const parsed = typeof url === "string" ? new URL(url) : url;
|
|
15
|
+
if (parsed.protocol !== "https:") {
|
|
16
|
+
throw new CustomHttpUrlSafetyError("protocol", "Custom HTTP URLs must use https.", {
|
|
17
|
+
url: safeUrlForDetails(parsed),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (parsed.username || parsed.password) {
|
|
21
|
+
throw new CustomHttpUrlSafetyError("credentials", "Custom HTTP URLs cannot contain username or password credentials.", { url: safeUrlForDetails(parsed) });
|
|
22
|
+
}
|
|
23
|
+
const host = normalizeCustomHttpUrlHost(parsed.hostname);
|
|
24
|
+
if (isBlockedCustomHttpHost(host)) {
|
|
25
|
+
throw new CustomHttpUrlSafetyError("blocked_host", "Custom HTTP URL host is not allowed.", { host });
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
export async function assertCustomHttpResolvedHostAllowed(input) {
|
|
30
|
+
const parsed = assertCustomHttpPublicUrlSyntax(input.url);
|
|
31
|
+
const host = normalizeCustomHttpUrlHost(parsed.hostname);
|
|
32
|
+
if (isIP(host))
|
|
33
|
+
return;
|
|
34
|
+
const resolveHostname = input.resolveHostname ?? defaultResolveHostname;
|
|
35
|
+
let addresses;
|
|
36
|
+
try {
|
|
37
|
+
addresses = await resolveHostname(host);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new CustomHttpUrlSafetyError("dns_lookup_failed", "Custom HTTP URL host could not be resolved.", {
|
|
41
|
+
host,
|
|
42
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (addresses.length === 0) {
|
|
46
|
+
throw new CustomHttpUrlSafetyError("dns_lookup_failed", "Custom HTTP URL host did not resolve to any address.", {
|
|
47
|
+
host,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
for (const address of addresses) {
|
|
51
|
+
const normalizedAddress = normalizeCustomHttpUrlHost(address.address);
|
|
52
|
+
if (isBlockedCustomHttpHost(normalizedAddress)) {
|
|
53
|
+
throw new CustomHttpUrlSafetyError("blocked_resolved_address", "Custom HTTP URL host resolved to a non-public address.", { host, address: normalizedAddress, family: address.family ?? null });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function normalizeCustomHttpUrlHost(hostname) {
|
|
58
|
+
const host = hostname.toLowerCase().replace(/\.+$/, "");
|
|
59
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
60
|
+
}
|
|
61
|
+
export function isBlockedCustomHttpHost(host) {
|
|
62
|
+
return host === "localhost"
|
|
63
|
+
|| host.endsWith(".localhost")
|
|
64
|
+
|| host === "metadata.google.internal"
|
|
65
|
+
|| isBlockedCustomHttpIpv4Address(host)
|
|
66
|
+
|| isBlockedCustomHttpIpv6Address(host);
|
|
67
|
+
}
|
|
68
|
+
function safeUrlForDetails(parsed) {
|
|
69
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
70
|
+
}
|
|
71
|
+
async function defaultResolveHostname(hostname) {
|
|
72
|
+
return await lookup(hostname, { all: true, verbatim: true });
|
|
73
|
+
}
|
|
74
|
+
function isBlockedCustomHttpIpv4Address(host) {
|
|
75
|
+
const address = isIP(host) === 4 ? host : readLeadingCustomHttpIpv4Label(host);
|
|
76
|
+
if (!address)
|
|
77
|
+
return false;
|
|
78
|
+
const parts = address.split(".").map((part) => Number(part));
|
|
79
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
|
|
80
|
+
return true;
|
|
81
|
+
const [a = 0, b = 0, c = 0] = parts;
|
|
82
|
+
return a === 0
|
|
83
|
+
|| a === 10
|
|
84
|
+
|| a === 127
|
|
85
|
+
|| (a === 169 && b === 254)
|
|
86
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
87
|
+
|| (a === 192 && b === 168)
|
|
88
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
89
|
+
|| (a === 192 && b === 0)
|
|
90
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
91
|
+
|| (a === 198 && b === 51 && c === 100)
|
|
92
|
+
|| (a === 203 && b === 0 && c === 113)
|
|
93
|
+
|| a >= 224;
|
|
94
|
+
}
|
|
95
|
+
function readLeadingCustomHttpIpv4Label(host) {
|
|
96
|
+
const match = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\.|$)/.exec(host);
|
|
97
|
+
return match?.[1] ?? null;
|
|
98
|
+
}
|
|
99
|
+
function isBlockedCustomHttpIpv6Address(host) {
|
|
100
|
+
if (isIP(host) !== 6)
|
|
101
|
+
return false;
|
|
102
|
+
const groups = expandCustomHttpIpv6Groups(host);
|
|
103
|
+
if (!groups)
|
|
104
|
+
return true;
|
|
105
|
+
const mappedIpv4 = customHttpIpv4FromIpv6(groups);
|
|
106
|
+
if (mappedIpv4 && isBlockedCustomHttpIpv4Address(mappedIpv4))
|
|
107
|
+
return true;
|
|
108
|
+
const [first = 0, second = 0] = groups;
|
|
109
|
+
const isUnspecified = groups.every((group) => group === 0);
|
|
110
|
+
const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
|
|
111
|
+
return isUnspecified
|
|
112
|
+
|| isLoopback
|
|
113
|
+
|| (first & 0xfe00) === 0xfc00
|
|
114
|
+
|| (first & 0xffc0) === 0xfe80
|
|
115
|
+
|| (first & 0xff00) === 0xff00
|
|
116
|
+
|| (first === 0x2001 && second === 0x0db8);
|
|
117
|
+
}
|
|
118
|
+
function expandCustomHttpIpv6Groups(host) {
|
|
119
|
+
const address = host.toLowerCase();
|
|
120
|
+
const doubleColonParts = address.split("::");
|
|
121
|
+
if (doubleColonParts.length > 2)
|
|
122
|
+
return null;
|
|
123
|
+
const head = splitCustomHttpIpv6Part(doubleColonParts[0] ?? "");
|
|
124
|
+
const tail = splitCustomHttpIpv6Part(doubleColonParts[1] ?? "");
|
|
125
|
+
if (!head || !tail)
|
|
126
|
+
return null;
|
|
127
|
+
const fill = doubleColonParts.length === 2 ? 8 - head.length - tail.length : 0;
|
|
128
|
+
if (fill < 0)
|
|
129
|
+
return null;
|
|
130
|
+
const groups = doubleColonParts.length === 2
|
|
131
|
+
? [...head, ...Array.from({ length: fill }, () => 0), ...tail]
|
|
132
|
+
: head;
|
|
133
|
+
return groups.length === 8 ? groups : null;
|
|
134
|
+
}
|
|
135
|
+
function splitCustomHttpIpv6Part(part) {
|
|
136
|
+
if (!part)
|
|
137
|
+
return [];
|
|
138
|
+
const groups = part.split(":");
|
|
139
|
+
const values = [];
|
|
140
|
+
for (const group of groups) {
|
|
141
|
+
if (!/^[0-9a-f]{1,4}$/.test(group))
|
|
142
|
+
return null;
|
|
143
|
+
values.push(Number.parseInt(group, 16));
|
|
144
|
+
}
|
|
145
|
+
return values;
|
|
146
|
+
}
|
|
147
|
+
function customHttpIpv4FromIpv6(groups) {
|
|
148
|
+
const mappedPrefix = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
|
|
149
|
+
const compatiblePrefix = groups.slice(0, 6).every((group) => group === 0);
|
|
150
|
+
if (!mappedPrefix && !compatiblePrefix)
|
|
151
|
+
return null;
|
|
152
|
+
const high = groups[6] ?? 0;
|
|
153
|
+
const low = groups[7] ?? 0;
|
|
154
|
+
if (high === 0 && low === 0)
|
|
155
|
+
return null;
|
|
156
|
+
return [
|
|
157
|
+
high >> 8,
|
|
158
|
+
high & 0xff,
|
|
159
|
+
low >> 8,
|
|
160
|
+
low & 0xff,
|
|
161
|
+
].join(".");
|
|
162
|
+
}
|
|
@@ -14,31 +14,21 @@ export function signUnsubscribeToken(payload, secret) {
|
|
|
14
14
|
const signature = createHmac("sha256", secret).update(body).digest("base64url");
|
|
15
15
|
return `${body}.${signature}`;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
* when the signature matches AND it is a well-formed v1 payload with a non-empty
|
|
20
|
-
* org + a plausible email; returns null on ANY problem (bad shape, wrong/blank
|
|
21
|
-
* secret, tampered body, replayed garbage). Pure: the route maps null -> 401.
|
|
22
|
-
*/
|
|
23
|
-
export function verifyUnsubscribeToken(token, secret) {
|
|
24
|
-
if (typeof token !== "string" || typeof secret !== "string" || !secret)
|
|
25
|
-
return null;
|
|
26
|
-
const dot = token.indexOf(".");
|
|
27
|
-
if (dot <= 0 || dot === token.length - 1)
|
|
28
|
-
return null;
|
|
29
|
-
const body = token.slice(0, dot);
|
|
30
|
-
const providedSig = token.slice(dot + 1);
|
|
17
|
+
/** Constant-time HMAC-SHA256 check; false on any error (bad base64, length mismatch). */
|
|
18
|
+
function verifySignature(body, providedSig, secret) {
|
|
31
19
|
const expected = createHmac("sha256", secret).update(body).digest();
|
|
32
20
|
let provided;
|
|
33
21
|
try {
|
|
34
22
|
provided = Buffer.from(providedSig, "base64url");
|
|
35
23
|
}
|
|
36
24
|
catch {
|
|
37
|
-
return
|
|
25
|
+
return false;
|
|
38
26
|
}
|
|
39
27
|
// Equal-length guard before timingSafeEqual (it throws on length mismatch).
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
|
29
|
+
}
|
|
30
|
+
/** Decode and validate the base64url body; returns typed payload or null on any problem. */
|
|
31
|
+
function parseTokenPayload(body) {
|
|
42
32
|
let parsed;
|
|
43
33
|
try {
|
|
44
34
|
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
@@ -65,6 +55,24 @@ export function verifyUnsubscribeToken(token, secret) {
|
|
|
65
55
|
iat: typeof candidate.iat === "number" && Number.isFinite(candidate.iat) ? candidate.iat : 0,
|
|
66
56
|
};
|
|
67
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Verify a token's signature (constant-time) and shape. Returns the payload only
|
|
60
|
+
* when the signature matches AND it is a well-formed v1 payload with a non-empty
|
|
61
|
+
* org + a plausible email; returns null on ANY problem (bad shape, wrong/blank
|
|
62
|
+
* secret, tampered body, replayed garbage). Pure: the route maps null -> 401.
|
|
63
|
+
*/
|
|
64
|
+
export function verifyUnsubscribeToken(token, secret) {
|
|
65
|
+
if (typeof token !== "string" || typeof secret !== "string" || !secret)
|
|
66
|
+
return null;
|
|
67
|
+
const dot = token.indexOf(".");
|
|
68
|
+
if (dot <= 0 || dot === token.length - 1)
|
|
69
|
+
return null;
|
|
70
|
+
const body = token.slice(0, dot);
|
|
71
|
+
const providedSig = token.slice(dot + 1);
|
|
72
|
+
if (!verifySignature(body, providedSig, secret))
|
|
73
|
+
return null;
|
|
74
|
+
return parseTokenPayload(body);
|
|
75
|
+
}
|
|
68
76
|
/**
|
|
69
77
|
* The HMAC signing secret for unsubscribe tokens, or null when unset. The send
|
|
70
78
|
* path treats null as "omit the header" (a send with no one-click link still
|
|
@@ -37,6 +37,17 @@ export type SequenceChannel = (typeof SEQUENCE_CHANNELS)[number];
|
|
|
37
37
|
*/
|
|
38
38
|
export declare const SEQUENCE_SIGNALS: readonly ["linkedin_connected", "linkedin_replied", "email_sent", "email_opened", "email_clicked", "email_replied", "email_bounced", "company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
|
|
39
39
|
export type SequenceSignal = (typeof SEQUENCE_SIGNALS)[number];
|
|
40
|
+
/**
|
|
41
|
+
* Email engagement signals that ONLY arrive via the Instantly webhook
|
|
42
|
+
* (email_opened/email_clicked). Native email sends (Gmail API / Microsoft Graph)
|
|
43
|
+
* produce no open/click tracking, so a sequence that sends email natively can
|
|
44
|
+
* never accumulate these — gating a wait_for_signal or signal-branch on them
|
|
45
|
+
* silently always times out / always takes the else arm. validateSequenceDefinition
|
|
46
|
+
* rejects that misconfiguration. (email_replied/email_bounced DO arrive natively
|
|
47
|
+
* via the inbound-message + bounce paths, so they are not listed here.)
|
|
48
|
+
*/
|
|
49
|
+
export declare const SEQUENCE_NATIVE_UNTRACKED_SIGNALS: readonly ["email_opened", "email_clicked"];
|
|
50
|
+
export type SequenceNativeUntrackedSignal = (typeof SEQUENCE_NATIVE_UNTRACKED_SIGNALS)[number];
|
|
40
51
|
/** External GTM signals (the non-engagement subset of SEQUENCE_SIGNALS). */
|
|
41
52
|
export declare const SEQUENCE_EXTERNAL_SIGNALS: readonly ["company_hiring", "company_raised_funds", "job_change", "new_hire", "web_visit", "intent"];
|
|
42
53
|
export type SequenceExternalSignal = (typeof SEQUENCE_EXTERNAL_SIGNALS)[number];
|
|
@@ -58,6 +58,19 @@ export const SEQUENCE_SIGNALS = [
|
|
|
58
58
|
"web_visit",
|
|
59
59
|
"intent",
|
|
60
60
|
];
|
|
61
|
+
/**
|
|
62
|
+
* Email engagement signals that ONLY arrive via the Instantly webhook
|
|
63
|
+
* (email_opened/email_clicked). Native email sends (Gmail API / Microsoft Graph)
|
|
64
|
+
* produce no open/click tracking, so a sequence that sends email natively can
|
|
65
|
+
* never accumulate these — gating a wait_for_signal or signal-branch on them
|
|
66
|
+
* silently always times out / always takes the else arm. validateSequenceDefinition
|
|
67
|
+
* rejects that misconfiguration. (email_replied/email_bounced DO arrive natively
|
|
68
|
+
* via the inbound-message + bounce paths, so they are not listed here.)
|
|
69
|
+
*/
|
|
70
|
+
export const SEQUENCE_NATIVE_UNTRACKED_SIGNALS = ["email_opened", "email_clicked"];
|
|
71
|
+
function isNativeUntrackedSignal(value) {
|
|
72
|
+
return SEQUENCE_NATIVE_UNTRACKED_SIGNALS.includes(value);
|
|
73
|
+
}
|
|
61
74
|
/** External GTM signals (the non-engagement subset of SEQUENCE_SIGNALS). */
|
|
62
75
|
export const SEQUENCE_EXTERNAL_SIGNALS = [
|
|
63
76
|
"company_hiring",
|
|
@@ -228,6 +241,14 @@ export function validateSequenceDefinition(input, options = {}) {
|
|
|
228
241
|
}
|
|
229
242
|
}
|
|
230
243
|
});
|
|
244
|
+
// Pass 3: native-email engagement-gate guard. Native email sends produce no
|
|
245
|
+
// open/click signals (email_opened/email_clicked arrive only from the Instantly
|
|
246
|
+
// webhook). A sequence that sends email natively — has native email steps
|
|
247
|
+
// (email_send/email_reply) and is NOT delegated to an Instantly campaign (no
|
|
248
|
+
// email_enroll/email_move/email_stop steps) — that gates a wait_for_signal or
|
|
249
|
+
// signal-branch on those signals would silently always time out / always take
|
|
250
|
+
// the else arm. Reject it at write time so create/update report the dead gate.
|
|
251
|
+
reportNativeEngagementGates(normalized, issues);
|
|
231
252
|
if (issues.length > 0) {
|
|
232
253
|
throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
|
|
233
254
|
}
|
|
@@ -248,6 +269,52 @@ export function lintSequenceDefinition(input, options = {}) {
|
|
|
248
269
|
return [{ path: "steps", message: error instanceof Error ? error.message : "Invalid sequence." }];
|
|
249
270
|
}
|
|
250
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* Push an issue for every wait_for_signal / signal-branch that gates on a
|
|
274
|
+
* natively-untracked engagement signal (email_opened/email_clicked) inside a
|
|
275
|
+
* sequence that sends email natively. "Native email" = at least one
|
|
276
|
+
* email_send/email_reply step and NO Instantly-delegated email step
|
|
277
|
+
* (email_enroll/email_move/email_stop); an Instantly-bound sequence DOES receive
|
|
278
|
+
* those webhook signals, so it is left alone. Mutates `issues` in place.
|
|
279
|
+
*/
|
|
280
|
+
function reportNativeEngagementGates(steps, issues) {
|
|
281
|
+
const hasNativeEmail = steps.some((s) => s.kind === "email_send" || s.kind === "email_reply");
|
|
282
|
+
const hasInstantlyEmail = steps.some((s) => s.kind === "email_enroll" || s.kind === "email_move" || s.kind === "email_stop");
|
|
283
|
+
if (!hasNativeEmail || hasInstantlyEmail)
|
|
284
|
+
return;
|
|
285
|
+
steps.forEach((step, index) => {
|
|
286
|
+
if (step.kind === "wait_for_signal" && isNativeUntrackedSignal(step.signal)) {
|
|
287
|
+
issues.push({
|
|
288
|
+
path: `steps[${index}].signal`,
|
|
289
|
+
message: `step '${step.id}' waits for '${step.signal}', but this sequence sends email natively (no Instantly binding), which produces no open/click signals — the gate would always time out. Use email_replied/email_bounced, or bind an Instantly campaign to track opens/clicks.`,
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (step.kind === "branch" && typeof step.condition !== "string") {
|
|
294
|
+
const referenced = new Set();
|
|
295
|
+
collectConditionSignals(step.condition, referenced);
|
|
296
|
+
for (const signal of SEQUENCE_NATIVE_UNTRACKED_SIGNALS) {
|
|
297
|
+
if (referenced.has(signal)) {
|
|
298
|
+
issues.push({
|
|
299
|
+
path: `steps[${index}].condition`,
|
|
300
|
+
message: `step '${step.id}' branches on '${signal}', but this sequence sends email natively (no Instantly binding), which produces no open/click signals — the branch would always take the else arm. Use email_replied/email_bounced, or bind an Instantly campaign to track opens/clicks.`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/** Collect every signal name referenced anywhere in a signal-branch condition. */
|
|
308
|
+
function collectConditionSignals(condition, out) {
|
|
309
|
+
if ("signal" in condition)
|
|
310
|
+
out.add(condition.signal);
|
|
311
|
+
else if ("all" in condition)
|
|
312
|
+
condition.all.forEach((c) => collectConditionSignals(c, out));
|
|
313
|
+
else if ("any" in condition)
|
|
314
|
+
condition.any.forEach((c) => collectConditionSignals(c, out));
|
|
315
|
+
else
|
|
316
|
+
collectConditionSignals(condition.not, out);
|
|
317
|
+
}
|
|
251
318
|
function collectSteps(input, issues) {
|
|
252
319
|
const record = isRecord(input) ? input : null;
|
|
253
320
|
const steps = record?.steps;
|
|
@@ -774,18 +841,13 @@ function normalizeVariants(raw, path, fields, issues) {
|
|
|
774
841
|
});
|
|
775
842
|
return out.length > 0 ? out : undefined;
|
|
776
843
|
}
|
|
777
|
-
function
|
|
778
|
-
if (raw === undefined || raw === null)
|
|
779
|
-
return undefined;
|
|
780
|
-
if (!isRecord(raw)) {
|
|
781
|
-
issues.push({ path, message: "send_window must be an object." });
|
|
782
|
-
return undefined;
|
|
783
|
-
}
|
|
844
|
+
function normalizeSendWindowConfiguredTimezone(raw, path, issues) {
|
|
784
845
|
const timezone = typeof raw.timezone === "string" && raw.timezone.trim() ? raw.timezone.trim() : undefined;
|
|
785
846
|
if (!timezone)
|
|
786
847
|
issues.push({ path: `${path}.timezone`, message: "timezone (IANA, e.g. America/New_York) is required." });
|
|
787
|
-
|
|
788
|
-
|
|
848
|
+
return timezone ?? "UTC";
|
|
849
|
+
}
|
|
850
|
+
function validateSendWindowBounds(start, end, path, issues) {
|
|
789
851
|
// A zero-width window (start === end) is never open — isWithinSendWindow
|
|
790
852
|
// returns false for every instant — so a step carrying it defers
|
|
791
853
|
// (outside_send_window) on every dispatch and the enrollment silently stalls
|
|
@@ -794,7 +856,8 @@ function normalizeSendWindow(raw, path, issues) {
|
|
|
794
856
|
if (start !== undefined && end !== undefined && hhmmToMinutes(start) === hhmmToMinutes(end)) {
|
|
795
857
|
issues.push({ path: `${path}.end`, message: "must differ from start — a zero-width send_window never sends." });
|
|
796
858
|
}
|
|
797
|
-
|
|
859
|
+
}
|
|
860
|
+
function normalizeSendWindowTimezoneMode(raw, path, issues) {
|
|
798
861
|
const mode = raw.timezone_mode === "recipient" ? "recipient" : "fixed";
|
|
799
862
|
const column = mode === "recipient"
|
|
800
863
|
? optionalString(raw.recipient_timezone_column, `${path}.recipient_timezone_column`, issues)
|
|
@@ -802,8 +865,23 @@ function normalizeSendWindow(raw, path, issues) {
|
|
|
802
865
|
if (mode === "recipient" && !column) {
|
|
803
866
|
issues.push({ path: `${path}.recipient_timezone_column`, message: 'recipient_timezone_column is required when timezone_mode is "recipient".' });
|
|
804
867
|
}
|
|
868
|
+
return { mode, column };
|
|
869
|
+
}
|
|
870
|
+
function normalizeSendWindow(raw, path, issues) {
|
|
871
|
+
if (raw === undefined || raw === null)
|
|
872
|
+
return undefined;
|
|
873
|
+
if (!isRecord(raw)) {
|
|
874
|
+
issues.push({ path, message: "send_window must be an object." });
|
|
875
|
+
return undefined;
|
|
876
|
+
}
|
|
877
|
+
const timezone = normalizeSendWindowConfiguredTimezone(raw, path, issues);
|
|
878
|
+
const start = normalizeHhmm(raw.start, `${path}.start`, issues);
|
|
879
|
+
const end = normalizeHhmm(raw.end, `${path}.end`, issues);
|
|
880
|
+
validateSendWindowBounds(start, end, path, issues);
|
|
881
|
+
const days = normalizeWeekdays(raw.days, `${path}.days`, issues);
|
|
882
|
+
const { mode, column } = normalizeSendWindowTimezoneMode(raw, path, issues);
|
|
805
883
|
return {
|
|
806
|
-
timezone
|
|
884
|
+
timezone,
|
|
807
885
|
...(days ? { days } : {}),
|
|
808
886
|
start: start ?? "09:00",
|
|
809
887
|
end: end ?? "17:00",
|