@oxygen-agent/cli 1.233.8 → 1.244.2

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.
@@ -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 = Object.values(secrets);
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
- 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));
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<CustomHttpResolvedAddress[]>;
17
+ export declare function normalizeCustomHttpUrlHost(hostname: string): string;
18
+ export declare function isBlockedCustomHttpHost(host: string): boolean;
@@ -0,0 +1,194 @@
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
+ // Literal IP hosts were already validated by assertCustomHttpPublicUrlSyntax and
33
+ // need no DNS resolution (nor connection pinning) — return an empty answer set.
34
+ if (isIP(host))
35
+ return [];
36
+ const resolveHostname = input.resolveHostname ?? defaultResolveHostname;
37
+ let addresses;
38
+ try {
39
+ addresses = await resolveHostname(host);
40
+ }
41
+ catch (error) {
42
+ throw new CustomHttpUrlSafetyError("dns_lookup_failed", "Custom HTTP URL host could not be resolved.", {
43
+ host,
44
+ reason: error instanceof Error ? error.message : String(error),
45
+ });
46
+ }
47
+ if (addresses.length === 0) {
48
+ throw new CustomHttpUrlSafetyError("dns_lookup_failed", "Custom HTTP URL host did not resolve to any address.", {
49
+ host,
50
+ });
51
+ }
52
+ for (const address of addresses) {
53
+ const normalizedAddress = normalizeCustomHttpUrlHost(address.address);
54
+ if (isBlockedCustomHttpHost(normalizedAddress)) {
55
+ throw new CustomHttpUrlSafetyError("blocked_resolved_address", "Custom HTTP URL host resolved to a non-public address.", { host, address: normalizedAddress, family: address.family ?? null });
56
+ }
57
+ }
58
+ // Return the validated addresses so callers can pin the connection to a checked
59
+ // IP and close the DNS-rebinding TOCTOU (fetch would otherwise re-resolve).
60
+ return addresses;
61
+ }
62
+ export function normalizeCustomHttpUrlHost(hostname) {
63
+ const host = hostname.toLowerCase().replace(/\.+$/, "");
64
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
65
+ }
66
+ export function isBlockedCustomHttpHost(host) {
67
+ return host === "localhost"
68
+ || host.endsWith(".localhost")
69
+ || host === "metadata.google.internal"
70
+ || isBlockedCustomHttpIpv4Address(host)
71
+ || isBlockedCustomHttpIpv6Address(host);
72
+ }
73
+ function safeUrlForDetails(parsed) {
74
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
75
+ }
76
+ async function defaultResolveHostname(hostname) {
77
+ return await lookup(hostname, { all: true, verbatim: true });
78
+ }
79
+ function isBlockedCustomHttpIpv4Address(host) {
80
+ const address = isIP(host) === 4 ? host : readLeadingCustomHttpIpv4Label(host);
81
+ if (!address)
82
+ return false;
83
+ const parts = address.split(".").map((part) => Number(part));
84
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
85
+ return true;
86
+ const [a = 0, b = 0, c = 0] = parts;
87
+ return a === 0
88
+ || a === 10
89
+ || a === 127
90
+ || (a === 169 && b === 254)
91
+ || (a === 172 && b >= 16 && b <= 31)
92
+ || (a === 192 && b === 168)
93
+ || (a === 100 && b >= 64 && b <= 127)
94
+ || (a === 192 && b === 0)
95
+ || (a === 198 && (b === 18 || b === 19))
96
+ || (a === 198 && b === 51 && c === 100)
97
+ || (a === 203 && b === 0 && c === 113)
98
+ || a >= 224;
99
+ }
100
+ function readLeadingCustomHttpIpv4Label(host) {
101
+ const match = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?:\.|$)/.exec(host);
102
+ return match?.[1] ?? null;
103
+ }
104
+ function isBlockedCustomHttpIpv6Address(host) {
105
+ if (isIP(host) !== 6)
106
+ return false;
107
+ const groups = expandCustomHttpIpv6Groups(host);
108
+ if (!groups)
109
+ return true;
110
+ const mappedIpv4 = customHttpIpv4FromIpv6(groups);
111
+ if (mappedIpv4 && isBlockedCustomHttpIpv4Address(mappedIpv4))
112
+ return true;
113
+ const [first = 0, second = 0] = groups;
114
+ const isUnspecified = groups.every((group) => group === 0);
115
+ const isLoopback = groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1;
116
+ return isUnspecified
117
+ || isLoopback
118
+ || (first & 0xfe00) === 0xfc00
119
+ || (first & 0xffc0) === 0xfe80
120
+ || (first & 0xff00) === 0xff00
121
+ || (first === 0x2001 && second === 0x0db8);
122
+ }
123
+ function expandCustomHttpIpv6Groups(host) {
124
+ const address = host.toLowerCase();
125
+ const doubleColonParts = address.split("::");
126
+ if (doubleColonParts.length > 2)
127
+ return null;
128
+ const head = splitCustomHttpIpv6Part(doubleColonParts[0] ?? "");
129
+ const tail = splitCustomHttpIpv6Part(doubleColonParts[1] ?? "");
130
+ if (!head || !tail)
131
+ return null;
132
+ const fill = doubleColonParts.length === 2 ? 8 - head.length - tail.length : 0;
133
+ if (fill < 0)
134
+ return null;
135
+ const groups = doubleColonParts.length === 2
136
+ ? [...head, ...Array.from({ length: fill }, () => 0), ...tail]
137
+ : head;
138
+ return groups.length === 8 ? groups : null;
139
+ }
140
+ function splitCustomHttpIpv6Part(part) {
141
+ if (!part)
142
+ return [];
143
+ const groups = part.split(":");
144
+ const values = [];
145
+ for (let index = 0; index < groups.length; index += 1) {
146
+ const group = groups[index] ?? "";
147
+ // A trailing dotted-decimal group (e.g. `8.8.8.8` in `::ffff:8.8.8.8`) is a
148
+ // valid IPv4-in-IPv6 literal; fold it into its two 16-bit words so mapped/
149
+ // compatible addresses parse instead of fail-closing as "blocked".
150
+ if (index === groups.length - 1 && group.includes(".")) {
151
+ const octets = customHttpDottedIpv4Octets(group);
152
+ if (!octets)
153
+ return null;
154
+ values.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
155
+ continue;
156
+ }
157
+ if (!/^[0-9a-f]{1,4}$/.test(group))
158
+ return null;
159
+ values.push(Number.parseInt(group, 16));
160
+ }
161
+ return values;
162
+ }
163
+ function customHttpDottedIpv4Octets(value) {
164
+ if (isIP(value) !== 4)
165
+ return null;
166
+ const octets = value.split(".").map((octet) => Number(octet));
167
+ return [octets[0] ?? 0, octets[1] ?? 0, octets[2] ?? 0, octets[3] ?? 0];
168
+ }
169
+ function customHttpIpv4FromIpv6(groups) {
170
+ const mappedPrefix = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
171
+ const compatiblePrefix = groups.slice(0, 6).every((group) => group === 0);
172
+ // NAT64/DNS64 well-known prefix 64:ff9b::/96 embeds the IPv4 in the last 32 bits.
173
+ const nat64WellKnownPrefix = groups[0] === 0x0064
174
+ && groups[1] === 0xff9b
175
+ && groups.slice(2, 6).every((group) => group === 0);
176
+ if (mappedPrefix || compatiblePrefix || nat64WellKnownPrefix) {
177
+ return customHttpIpv4FromIpv6Pair(groups[6], groups[7]);
178
+ }
179
+ // 6to4 prefix 2002::/16 embeds the gateway IPv4 in the two groups after the prefix.
180
+ if (groups[0] === 0x2002) {
181
+ return customHttpIpv4FromIpv6Pair(groups[1], groups[2]);
182
+ }
183
+ return null;
184
+ }
185
+ function customHttpIpv4FromIpv6Pair(high = 0, low = 0) {
186
+ if (high === 0 && low === 0)
187
+ return null;
188
+ return [
189
+ high >> 8,
190
+ high & 0xff,
191
+ low >> 8,
192
+ low & 0xff,
193
+ ].join(".");
194
+ }
@@ -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
- * Verify a token's signature (constant-time) and shape. Returns the payload only
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 null;
25
+ return false;
38
26
  }
39
27
  // Equal-length guard before timingSafeEqual (it throws on length mismatch).
40
- if (provided.length !== expected.length || !timingSafeEqual(provided, expected))
41
- return null;
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];