@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.
@@ -0,0 +1,91 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ function base64urlEncode(value) {
3
+ return Buffer.from(value, "utf8").toString("base64url");
4
+ }
5
+ /**
6
+ * Sign a payload into `<base64url(json)>.<base64url(hmac-sha256)>`. The HMAC is
7
+ * computed over the encoded body, so any tamper to the payload invalidates the
8
+ * signature. Normalizes the email to the stored suppression form so the webhook
9
+ * write and the pre-send gate key on the identical string.
10
+ */
11
+ export function signUnsubscribeToken(payload, secret) {
12
+ const normalized = { ...payload, email: payload.email.trim().toLowerCase() };
13
+ const body = base64urlEncode(JSON.stringify(normalized));
14
+ const signature = createHmac("sha256", secret).update(body).digest("base64url");
15
+ return `${body}.${signature}`;
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);
31
+ const expected = createHmac("sha256", secret).update(body).digest();
32
+ let provided;
33
+ try {
34
+ provided = Buffer.from(providedSig, "base64url");
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ // Equal-length guard before timingSafeEqual (it throws on length mismatch).
40
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected))
41
+ return null;
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ if (!parsed || typeof parsed !== "object")
50
+ return null;
51
+ const candidate = parsed;
52
+ if (candidate.v !== 1)
53
+ return null;
54
+ const org = typeof candidate.org === "string" ? candidate.org.trim() : "";
55
+ const email = typeof candidate.email === "string" ? candidate.email.trim().toLowerCase() : "";
56
+ if (!org || !email || !email.includes("@"))
57
+ return null;
58
+ return {
59
+ v: 1,
60
+ org,
61
+ email,
62
+ ...(typeof candidate.seq === "string" && candidate.seq ? { seq: candidate.seq } : {}),
63
+ ...(typeof candidate.enr === "string" && candidate.enr ? { enr: candidate.enr } : {}),
64
+ ...(typeof candidate.mbx === "string" && candidate.mbx ? { mbx: candidate.mbx } : {}),
65
+ iat: typeof candidate.iat === "number" && Number.isFinite(candidate.iat) ? candidate.iat : 0,
66
+ };
67
+ }
68
+ /**
69
+ * The HMAC signing secret for unsubscribe tokens, or null when unset. The send
70
+ * path treats null as "omit the header" (a send with no one-click link still
71
+ * delivers); the webhook route treats null as fail-closed (reject everything).
72
+ */
73
+ export function unsubscribeSigningSecret(env = process.env) {
74
+ const secret = env.EMAIL_UNSUBSCRIBE_SECRET?.trim();
75
+ return secret ? secret : null;
76
+ }
77
+ /**
78
+ * The public app base URL the unsubscribe link points at, trailing slash
79
+ * stripped. Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host.
80
+ */
81
+ export function oxygenAppBaseUrl(env = process.env) {
82
+ const raw = (env.NEXT_PUBLIC_APP_URL || env.OXYGEN_APP_URL || "https://oxygen-agent.com").trim();
83
+ return raw.replace(/\/+$/, "");
84
+ }
85
+ /**
86
+ * The canonical one-click unsubscribe URL: org id in the path (the route
87
+ * re-checks it against the token), token in the `token` query param.
88
+ */
89
+ export function buildUnsubscribeUrl(orgId, token, baseUrl) {
90
+ return `${baseUrl}/api/webhooks/email/unsubscribe/${encodeURIComponent(orgId)}?token=${token}`;
91
+ }
@@ -7,6 +7,7 @@ export * from "./cli-envelope.js";
7
7
  export * from "./cli-result.js";
8
8
  export * from "./column-types.js";
9
9
  export * from "./credit-guidance.js";
10
+ export * from "./email-unsubscribe-token.js";
10
11
  export * from "./linkedin-post-url.js";
11
12
  export * from "./linkedin-sequences.js";
12
13
  export * from "./networks.js";
@@ -7,6 +7,7 @@ export * from "./cli-envelope.js";
7
7
  export * from "./cli-result.js";
8
8
  export * from "./column-types.js";
9
9
  export * from "./credit-guidance.js";
10
+ export * from "./email-unsubscribe-token.js";
10
11
  export * from "./linkedin-post-url.js";
11
12
  export * from "./linkedin-sequences.js";
12
13
  export * from "./networks.js";
@@ -1,4 +1,5 @@
1
1
  const LINKEDIN_HOST = "linkedin.com";
2
+ const CANONICAL_LINKEDIN_HOST = `www.${LINKEDIN_HOST}`;
2
3
  const URL_LIKE_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
3
4
  function normalizeHostname(hostname) {
4
5
  return hostname.toLowerCase().replace(/\.+$/, "");
@@ -19,6 +20,9 @@ function isLinkedInPostPath(pathname) {
19
20
  const path = safelyDecodePath(pathname).toLowerCase();
20
21
  return /^\/posts\/[^/]+\/?$/.test(path) || /^\/feed\/update\/urn:li:activity:[^/]+\/?$/.test(path);
21
22
  }
23
+ function canonicalizePostPath(pathname) {
24
+ return pathname.replace(/\/+$/, "") || pathname;
25
+ }
22
26
  export function isLinkedInPostUrlLike(value) {
23
27
  const trimmed = value.trim();
24
28
  return URL_LIKE_PATTERN.test(trimmed) || trimmed.toLowerCase().includes("linkedin.com");
@@ -42,7 +46,8 @@ export function normalizeLinkedInPostUrl(value) {
42
46
  if (!isLinkedInPostPath(url.pathname))
43
47
  return null;
44
48
  url.protocol = "https:";
45
- url.hostname = hostname;
49
+ url.hostname = CANONICAL_LINKEDIN_HOST;
50
+ url.pathname = canonicalizePostPath(url.pathname);
46
51
  url.username = "";
47
52
  url.password = "";
48
53
  url.search = "";
@@ -15,6 +15,15 @@ export declare function isStatusSemantic(semanticType: string | null | undefined
15
15
  * transcripts all use this.
16
16
  */
17
17
  export declare function isMarkdownColumnSemantic(semanticType: string | null | undefined): boolean;
18
+ /**
19
+ * `image` is a semantic over a `text` dataType holding an image URL (e.g. a
20
+ * LinkedIn profile picture): the grid renders the cell as a round avatar instead
21
+ * of a link, with initials/empty fallback. Storage stays text — no new dataType.
22
+ * `avatar`/`photo` are accepted as aliases so URLs landed by enrichment/CRM
23
+ * still render; the editor writes the canonical `IMAGE_COLUMN_SEMANTIC`.
24
+ */
25
+ export declare const IMAGE_COLUMN_SEMANTIC = "image";
26
+ export declare function isImageColumnSemantic(semanticType: string | null | undefined): boolean;
18
27
  export type SelectOption = {
19
28
  id: string;
20
29
  value: string;
@@ -55,6 +55,17 @@ export function isStatusSemantic(semanticType) {
55
55
  export function isMarkdownColumnSemantic(semanticType) {
56
56
  return semanticType === "markdown";
57
57
  }
58
+ /**
59
+ * `image` is a semantic over a `text` dataType holding an image URL (e.g. a
60
+ * LinkedIn profile picture): the grid renders the cell as a round avatar instead
61
+ * of a link, with initials/empty fallback. Storage stays text — no new dataType.
62
+ * `avatar`/`photo` are accepted as aliases so URLs landed by enrichment/CRM
63
+ * still render; the editor writes the canonical `IMAGE_COLUMN_SEMANTIC`.
64
+ */
65
+ export const IMAGE_COLUMN_SEMANTIC = "image";
66
+ export function isImageColumnSemantic(semanticType) {
67
+ return semanticType === "image" || semanticType === "avatar" || semanticType === "photo";
68
+ }
58
69
  const MAX_OPTIONS = 200;
59
70
  // Normalize a raw options array (from a column `definition.options`, CLI/MCP, or
60
71
  // the editor) into canonical SelectOptions: dedupe by value, default colors,
@@ -48,6 +48,14 @@ export declare function isExternalSequenceSignal(value: string): value is Sequen
48
48
  * Single source of truth so the send path and the lookup path can't drift.
49
49
  */
50
50
  export declare const SEQUENCE_EMAIL_COLUMN_KEYS: readonly ["email", "email_address", "work_email", "primary_email", "Email"];
51
+ /**
52
+ * Resolve an enrollment's recipient email from its row_values, taking the first
53
+ * present SEQUENCE_EMAIL_COLUMN_KEYS value that looks like an address. Single
54
+ * source of truth for both the send path (the dispatcher's `to`) and the
55
+ * complaint loop (the address a bounce/opt-out suppresses), so they can't drift.
56
+ * Returns the trimmed raw value (callers normalize/lowercase as needed) or null.
57
+ */
58
+ export declare function recipientEmailFromRow(rowValues: Record<string, unknown> | null | undefined): string | null;
51
59
  /**
52
60
  * row_values keys an enrollment's phone number may live under, in
53
61
  * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
@@ -77,6 +77,21 @@ export function isExternalSequenceSignal(value) {
77
77
  * Single source of truth so the send path and the lookup path can't drift.
78
78
  */
79
79
  export const SEQUENCE_EMAIL_COLUMN_KEYS = ["email", "email_address", "work_email", "primary_email", "Email"];
80
+ /**
81
+ * Resolve an enrollment's recipient email from its row_values, taking the first
82
+ * present SEQUENCE_EMAIL_COLUMN_KEYS value that looks like an address. Single
83
+ * source of truth for both the send path (the dispatcher's `to`) and the
84
+ * complaint loop (the address a bounce/opt-out suppresses), so they can't drift.
85
+ * Returns the trimmed raw value (callers normalize/lowercase as needed) or null.
86
+ */
87
+ export function recipientEmailFromRow(rowValues) {
88
+ for (const key of SEQUENCE_EMAIL_COLUMN_KEYS) {
89
+ const value = rowValues?.[key];
90
+ if (typeof value === "string" && value.includes("@"))
91
+ return value.trim();
92
+ }
93
+ return null;
94
+ }
80
95
  /**
81
96
  * row_values keys an enrollment's phone number may live under, in
82
97
  * send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
@@ -1,2 +1,2 @@
1
- export declare const OXYGEN_VERSION = "1.226.15";
1
+ export declare const OXYGEN_VERSION = "1.233.8";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.226.15";
1
+ export const OXYGEN_VERSION = "1.233.8";
2
2
  // Bump this only when deployed CLI/API contracts require a newer CLI.
3
3
  // 1.181.0: paid table action runs and background columns run require
4
4
  // approved=true in addition to max_credits; older CLIs cannot send the flag.
@@ -44,6 +44,7 @@ export type WorkflowTriggerManifest = {
44
44
  type: "cron";
45
45
  cron: string;
46
46
  timezone?: string | null;
47
+ metadata?: Record<string, unknown>;
47
48
  status?: WorkflowStatus;
48
49
  } | {
49
50
  type: "event";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.226.15",
3
+ "version": "1.233.8",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",