@oxygen-agent/cli 1.226.15 → 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 +887 -12
- package/dist/local-custom-http-column.d.ts +2 -0
- package/dist/local-custom-http-column.js +347 -143
- 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.d.ts +62 -0
- package/node_modules/@oxygen/shared/dist/email-unsubscribe-token.js +99 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/index.js +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-url.js +6 -1
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +11 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +104 -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/node_modules/@oxygen/workflows/dist/index.d.ts +1 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -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 {};
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
/** Constant-time HMAC-SHA256 check; false on any error (bad base64, length mismatch). */
|
|
18
|
+
function verifySignature(body, providedSig, secret) {
|
|
19
|
+
const expected = createHmac("sha256", secret).update(body).digest();
|
|
20
|
+
let provided;
|
|
21
|
+
try {
|
|
22
|
+
provided = Buffer.from(providedSig, "base64url");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
// Equal-length guard before timingSafeEqual (it throws on length mismatch).
|
|
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) {
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (!parsed || typeof parsed !== "object")
|
|
40
|
+
return null;
|
|
41
|
+
const candidate = parsed;
|
|
42
|
+
if (candidate.v !== 1)
|
|
43
|
+
return null;
|
|
44
|
+
const org = typeof candidate.org === "string" ? candidate.org.trim() : "";
|
|
45
|
+
const email = typeof candidate.email === "string" ? candidate.email.trim().toLowerCase() : "";
|
|
46
|
+
if (!org || !email || !email.includes("@"))
|
|
47
|
+
return null;
|
|
48
|
+
return {
|
|
49
|
+
v: 1,
|
|
50
|
+
org,
|
|
51
|
+
email,
|
|
52
|
+
...(typeof candidate.seq === "string" && candidate.seq ? { seq: candidate.seq } : {}),
|
|
53
|
+
...(typeof candidate.enr === "string" && candidate.enr ? { enr: candidate.enr } : {}),
|
|
54
|
+
...(typeof candidate.mbx === "string" && candidate.mbx ? { mbx: candidate.mbx } : {}),
|
|
55
|
+
iat: typeof candidate.iat === "number" && Number.isFinite(candidate.iat) ? candidate.iat : 0,
|
|
56
|
+
};
|
|
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
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The HMAC signing secret for unsubscribe tokens, or null when unset. The send
|
|
78
|
+
* path treats null as "omit the header" (a send with no one-click link still
|
|
79
|
+
* delivers); the webhook route treats null as fail-closed (reject everything).
|
|
80
|
+
*/
|
|
81
|
+
export function unsubscribeSigningSecret(env = process.env) {
|
|
82
|
+
const secret = env.EMAIL_UNSUBSCRIBE_SECRET?.trim();
|
|
83
|
+
return secret ? secret : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The public app base URL the unsubscribe link points at, trailing slash
|
|
87
|
+
* stripped. Prefers NEXT_PUBLIC_APP_URL, then OXYGEN_APP_URL, then the prod host.
|
|
88
|
+
*/
|
|
89
|
+
export function oxygenAppBaseUrl(env = process.env) {
|
|
90
|
+
const raw = (env.NEXT_PUBLIC_APP_URL || env.OXYGEN_APP_URL || "https://oxygen-agent.com").trim();
|
|
91
|
+
return raw.replace(/\/+$/, "");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The canonical one-click unsubscribe URL: org id in the path (the route
|
|
95
|
+
* re-checks it against the token), token in the `token` query param.
|
|
96
|
+
*/
|
|
97
|
+
export function buildUnsubscribeUrl(orgId, token, baseUrl) {
|
|
98
|
+
return `${baseUrl}/api/webhooks/email/unsubscribe/${encodeURIComponent(orgId)}?token=${token}`;
|
|
99
|
+
}
|
|
@@ -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 =
|
|
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,
|
|
@@ -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];
|
|
@@ -48,6 +59,14 @@ export declare function isExternalSequenceSignal(value: string): value is Sequen
|
|
|
48
59
|
* Single source of truth so the send path and the lookup path can't drift.
|
|
49
60
|
*/
|
|
50
61
|
export declare const SEQUENCE_EMAIL_COLUMN_KEYS: readonly ["email", "email_address", "work_email", "primary_email", "Email"];
|
|
62
|
+
/**
|
|
63
|
+
* Resolve an enrollment's recipient email from its row_values, taking the first
|
|
64
|
+
* present SEQUENCE_EMAIL_COLUMN_KEYS value that looks like an address. Single
|
|
65
|
+
* source of truth for both the send path (the dispatcher's `to`) and the
|
|
66
|
+
* complaint loop (the address a bounce/opt-out suppresses), so they can't drift.
|
|
67
|
+
* Returns the trimmed raw value (callers normalize/lowercase as needed) or null.
|
|
68
|
+
*/
|
|
69
|
+
export declare function recipientEmailFromRow(rowValues: Record<string, unknown> | null | undefined): string | null;
|
|
51
70
|
/**
|
|
52
71
|
* row_values keys an enrollment's phone number may live under, in
|
|
53
72
|
* send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
|
|
@@ -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",
|
|
@@ -77,6 +90,21 @@ export function isExternalSequenceSignal(value) {
|
|
|
77
90
|
* Single source of truth so the send path and the lookup path can't drift.
|
|
78
91
|
*/
|
|
79
92
|
export const SEQUENCE_EMAIL_COLUMN_KEYS = ["email", "email_address", "work_email", "primary_email", "Email"];
|
|
93
|
+
/**
|
|
94
|
+
* Resolve an enrollment's recipient email from its row_values, taking the first
|
|
95
|
+
* present SEQUENCE_EMAIL_COLUMN_KEYS value that looks like an address. Single
|
|
96
|
+
* source of truth for both the send path (the dispatcher's `to`) and the
|
|
97
|
+
* complaint loop (the address a bounce/opt-out suppresses), so they can't drift.
|
|
98
|
+
* Returns the trimmed raw value (callers normalize/lowercase as needed) or null.
|
|
99
|
+
*/
|
|
100
|
+
export function recipientEmailFromRow(rowValues) {
|
|
101
|
+
for (const key of SEQUENCE_EMAIL_COLUMN_KEYS) {
|
|
102
|
+
const value = rowValues?.[key];
|
|
103
|
+
if (typeof value === "string" && value.includes("@"))
|
|
104
|
+
return value.trim();
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
80
108
|
/**
|
|
81
109
|
* row_values keys an enrollment's phone number may live under, in
|
|
82
110
|
* send-precedence order, for WhatsApp sends. The enroll path resolves the FIRST
|
|
@@ -213,6 +241,14 @@ export function validateSequenceDefinition(input, options = {}) {
|
|
|
213
241
|
}
|
|
214
242
|
}
|
|
215
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);
|
|
216
252
|
if (issues.length > 0) {
|
|
217
253
|
throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
|
|
218
254
|
}
|
|
@@ -233,6 +269,52 @@ export function lintSequenceDefinition(input, options = {}) {
|
|
|
233
269
|
return [{ path: "steps", message: error instanceof Error ? error.message : "Invalid sequence." }];
|
|
234
270
|
}
|
|
235
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
|
+
}
|
|
236
318
|
function collectSteps(input, issues) {
|
|
237
319
|
const record = isRecord(input) ? input : null;
|
|
238
320
|
const steps = record?.steps;
|
|
@@ -759,18 +841,13 @@ function normalizeVariants(raw, path, fields, issues) {
|
|
|
759
841
|
});
|
|
760
842
|
return out.length > 0 ? out : undefined;
|
|
761
843
|
}
|
|
762
|
-
function
|
|
763
|
-
if (raw === undefined || raw === null)
|
|
764
|
-
return undefined;
|
|
765
|
-
if (!isRecord(raw)) {
|
|
766
|
-
issues.push({ path, message: "send_window must be an object." });
|
|
767
|
-
return undefined;
|
|
768
|
-
}
|
|
844
|
+
function normalizeSendWindowConfiguredTimezone(raw, path, issues) {
|
|
769
845
|
const timezone = typeof raw.timezone === "string" && raw.timezone.trim() ? raw.timezone.trim() : undefined;
|
|
770
846
|
if (!timezone)
|
|
771
847
|
issues.push({ path: `${path}.timezone`, message: "timezone (IANA, e.g. America/New_York) is required." });
|
|
772
|
-
|
|
773
|
-
|
|
848
|
+
return timezone ?? "UTC";
|
|
849
|
+
}
|
|
850
|
+
function validateSendWindowBounds(start, end, path, issues) {
|
|
774
851
|
// A zero-width window (start === end) is never open — isWithinSendWindow
|
|
775
852
|
// returns false for every instant — so a step carrying it defers
|
|
776
853
|
// (outside_send_window) on every dispatch and the enrollment silently stalls
|
|
@@ -779,7 +856,8 @@ function normalizeSendWindow(raw, path, issues) {
|
|
|
779
856
|
if (start !== undefined && end !== undefined && hhmmToMinutes(start) === hhmmToMinutes(end)) {
|
|
780
857
|
issues.push({ path: `${path}.end`, message: "must differ from start — a zero-width send_window never sends." });
|
|
781
858
|
}
|
|
782
|
-
|
|
859
|
+
}
|
|
860
|
+
function normalizeSendWindowTimezoneMode(raw, path, issues) {
|
|
783
861
|
const mode = raw.timezone_mode === "recipient" ? "recipient" : "fixed";
|
|
784
862
|
const column = mode === "recipient"
|
|
785
863
|
? optionalString(raw.recipient_timezone_column, `${path}.recipient_timezone_column`, issues)
|
|
@@ -787,8 +865,23 @@ function normalizeSendWindow(raw, path, issues) {
|
|
|
787
865
|
if (mode === "recipient" && !column) {
|
|
788
866
|
issues.push({ path: `${path}.recipient_timezone_column`, message: 'recipient_timezone_column is required when timezone_mode is "recipient".' });
|
|
789
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);
|
|
790
883
|
return {
|
|
791
|
-
timezone
|
|
884
|
+
timezone,
|
|
792
885
|
...(days ? { days } : {}),
|
|
793
886
|
start: start ?? "09:00",
|
|
794
887
|
end: end ?? "17:00",
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_VERSION = "1.242.6";
|
|
2
2
|
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.242.6";
|
|
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.
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"types": "./dist/file-import.d.ts",
|
|
16
16
|
"import": "./dist/file-import.js",
|
|
17
17
|
"default": "./dist/file-import.js"
|
|
18
|
+
},
|
|
19
|
+
"./custom-http-safety": {
|
|
20
|
+
"types": "./dist/custom-http-safety.d.ts",
|
|
21
|
+
"import": "./dist/custom-http-safety.js",
|
|
22
|
+
"default": "./dist/custom-http-safety.js"
|
|
18
23
|
}
|
|
19
24
|
},
|
|
20
25
|
"dependencies": {}
|