@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.
@@ -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, async (row) => {
21
- const rowId = readLocalRowId(row);
22
- if (!options.force && isNonEmptyLocalValue(row[column.key])) {
23
- return {
24
- status: "skipped",
25
- rowId,
26
- reason: "existing_value",
27
- toolId: "custom_http",
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 > 0
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
- ...(first ? { rowId: first.rowId } : {}),
107
- ...(first && "request" in first ? { request: first.request } : {}),
108
- ...(first && "output" in first ? { output: first.output } : {}),
109
- ...(first && "error" in first ? { error: first.error } : {}),
110
- ...(first && "run" in first ? { run: first.run } : {}),
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 resolvedUrl = resolveLocalTemplateString(definition.request.url, row, secrets, "request.url");
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 = Object.hasOwn(definition.request, "body")
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
- const body = bodyValue === undefined ? undefined : serializeLocalHttpBody(bodyValue, headers);
308
- const response = await fetch(url, {
309
- method,
310
- headers,
311
- ...(body !== undefined ? { body } : {}),
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
- const responseBody = await readLocalHttpResponseBody(response);
321
- const redactedResponseBody = redactLocalSecrets(responseBody, secretValues);
322
- const responseSummary = {
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
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
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 text = await response.text();
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
- const secretName = segments.shift();
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
- else {
460
- if (!Object.hasOwn(row, root)) {
461
- throw new OxygenError("invalid_column_run", "Template references an unknown row column.", {
462
- details: { input: inputName, column_key: root, template: rawPath },
463
- exitCode: 1,
464
- });
465
- }
466
- current = row[root];
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
- if (current === null || current === undefined)
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 current ?? null;
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 {};