@oxygen-agent/cli 1.1011.97 → 1.1011.288
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/admin-primary-providers-render.js +25 -5
- package/dist/column-run-notices.d.ts +16 -0
- package/dist/column-run-notices.js +57 -0
- package/dist/command-manifest.js +10 -1
- package/dist/help.d.ts +27 -1
- package/dist/help.js +112 -29
- package/dist/http-client.js +8 -0
- package/dist/index.js +348 -127
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +20 -69
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +6 -4
- package/node_modules/@oxygen/shared/dist/byok-connect.js +11 -4
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +127 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +286 -24
- package/node_modules/@oxygen/shared/dist/collab.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/collab.js +12 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +7 -3
- package/node_modules/@oxygen/shared/dist/column-output-fields.js +45 -3
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +20 -13
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +29 -13
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +1 -1
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +12 -1
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +37 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +79 -44
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +9 -0
- package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/log.js +15 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +6 -2
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +7 -3
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +22 -1
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +3 -7
- package/node_modules/@oxygen/shared/dist/plan-limits.js +2 -14
- package/node_modules/@oxygen/shared/dist/sending-schedule.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/sending-schedule.js +41 -0
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +20 -0
- package/node_modules/@oxygen/shared/dist/sequence-sender-variables.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/sequence-sender-variables.js +63 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +13 -7
- package/node_modules/@oxygen/shared/dist/sequences.js +26 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +5 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +1 -15
- package/node_modules/@oxygen/shared/dist/standard-webhooks.d.ts +51 -0
- package/node_modules/@oxygen/shared/dist/standard-webhooks.js +124 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/tags.js +13 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +11 -7
- package/node_modules/@oxygen/shared/dist/ugc.js +14 -6
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/zoned-time.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/zoned-time.js +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Every lifecycle a person reads: the stored statuses plus the derived `completed`. */
|
|
2
|
+
export const SEQUENCE_LIFECYCLES = ["draft", "active", "completed", "paused", "archived"];
|
|
3
|
+
/**
|
|
4
|
+
* The lifecycle word a person reads for a Sequence.
|
|
5
|
+
*
|
|
6
|
+
* The stored status records launch intent only (draft, active, paused,
|
|
7
|
+
* archived), and nothing moves it when the last lead finishes, so a campaign
|
|
8
|
+
* that contacted everyone kept reading "Active" forever. Whether work remains
|
|
9
|
+
* is the derived operational state; `drained` means every enrolled lead has
|
|
10
|
+
* reached an end. An active Sequence in that state reads as completed.
|
|
11
|
+
*
|
|
12
|
+
* Derived on read and never stored: operational health is one derived
|
|
13
|
+
* contract, not another lifecycle (docs/primitives/sequences.md, 2026-08-26).
|
|
14
|
+
* Enrolling new leads makes the same Sequence read as active again with no
|
|
15
|
+
* transition to undo. A paused Sequence stays paused, because someone decided
|
|
16
|
+
* that and the decision still holds.
|
|
17
|
+
*/
|
|
18
|
+
export function sequenceDisplayStatus(status, operationalState) {
|
|
19
|
+
return status === "active" && operationalState === "drained" ? "completed" : status;
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reserved `{{sender_*}}` template variables. Every Sequence step can use them
|
|
3
|
+
* without a lead-table column: they resolve per action from whoever actually
|
|
4
|
+
* sends it, the person behind the sending inbox or LinkedIn/WhatsApp account.
|
|
5
|
+
* A lead column with the same key still wins, because row values merge last.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SEQUENCE_SENDER_NAME_VARIABLES: readonly ["sender_first_name", "sender_last_name", "sender_full_name", "sender_name"];
|
|
8
|
+
/** Email steps only: the sending inbox's address. */
|
|
9
|
+
export declare const SEQUENCE_SENDER_EMAIL_VARIABLE = "sender_email";
|
|
10
|
+
export declare const SEQUENCE_SENDER_VARIABLES: readonly string[];
|
|
11
|
+
/** Editor labels, in menu order. `sender_name` stays insertable-by-hand only. */
|
|
12
|
+
export declare const SEQUENCE_SENDER_VARIABLE_LABELS: ReadonlyArray<{
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
15
|
+
emailOnly?: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Who sends an action: the sender profile's own first and last name when it has
|
|
19
|
+
* them, else a display name (a profile's label, an account's or an inbox's).
|
|
20
|
+
*/
|
|
21
|
+
export type SequenceSenderIdentity = {
|
|
22
|
+
name?: string | null;
|
|
23
|
+
firstName?: string | null;
|
|
24
|
+
lastName?: string | null;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The reserved variables for one sender. A profile's stored first and last name
|
|
28
|
+
* win, because its display name may be a label such as "Talia (EU)". Without
|
|
29
|
+
* them the display name is split at its first space, so "Anna von Berg" gives
|
|
30
|
+
* "Anna" and "von Berg". A missing name renders every variable empty, never the
|
|
31
|
+
* literal token. Passing `email` adds `{{sender_email}}`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sequenceSenderVariables(sender: SequenceSenderIdentity | string | null | undefined, email?: string | null): Record<string, string>;
|
|
34
|
+
/** Whether any of these templates refers to a reserved sender variable. */
|
|
35
|
+
export declare function referencesSequenceSenderVariables(templates: ReadonlyArray<unknown>): boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reserved `{{sender_*}}` template variables. Every Sequence step can use them
|
|
3
|
+
* without a lead-table column: they resolve per action from whoever actually
|
|
4
|
+
* sends it, the person behind the sending inbox or LinkedIn/WhatsApp account.
|
|
5
|
+
* A lead column with the same key still wins, because row values merge last.
|
|
6
|
+
*/
|
|
7
|
+
export const SEQUENCE_SENDER_NAME_VARIABLES = [
|
|
8
|
+
"sender_first_name",
|
|
9
|
+
"sender_last_name",
|
|
10
|
+
"sender_full_name",
|
|
11
|
+
"sender_name",
|
|
12
|
+
];
|
|
13
|
+
/** Email steps only: the sending inbox's address. */
|
|
14
|
+
export const SEQUENCE_SENDER_EMAIL_VARIABLE = "sender_email";
|
|
15
|
+
export const SEQUENCE_SENDER_VARIABLES = [
|
|
16
|
+
...SEQUENCE_SENDER_NAME_VARIABLES,
|
|
17
|
+
SEQUENCE_SENDER_EMAIL_VARIABLE,
|
|
18
|
+
];
|
|
19
|
+
/** Editor labels, in menu order. `sender_name` stays insertable-by-hand only. */
|
|
20
|
+
export const SEQUENCE_SENDER_VARIABLE_LABELS = [
|
|
21
|
+
{ key: "sender_first_name", label: "Sender first name" },
|
|
22
|
+
{ key: "sender_last_name", label: "Sender last name" },
|
|
23
|
+
{ key: "sender_full_name", label: "Sender full name" },
|
|
24
|
+
{ key: SEQUENCE_SENDER_EMAIL_VARIABLE, label: "Sender email", emailOnly: true },
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* The reserved variables for one sender. A profile's stored first and last name
|
|
28
|
+
* win, because its display name may be a label such as "Talia (EU)". Without
|
|
29
|
+
* them the display name is split at its first space, so "Anna von Berg" gives
|
|
30
|
+
* "Anna" and "von Berg". A missing name renders every variable empty, never the
|
|
31
|
+
* literal token. Passing `email` adds `{{sender_email}}`.
|
|
32
|
+
*/
|
|
33
|
+
export function sequenceSenderVariables(sender, email) {
|
|
34
|
+
const identity = typeof sender === "string" ? { name: sender } : (sender ?? {});
|
|
35
|
+
const clean = (value) => (value ?? "").trim().replace(/\s+/g, " ");
|
|
36
|
+
const firstName = clean(identity.firstName);
|
|
37
|
+
const lastName = clean(identity.lastName);
|
|
38
|
+
let first;
|
|
39
|
+
let last;
|
|
40
|
+
let full;
|
|
41
|
+
if (firstName || lastName) {
|
|
42
|
+
first = firstName;
|
|
43
|
+
last = lastName;
|
|
44
|
+
full = [firstName, lastName].filter(Boolean).join(" ");
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
full = clean(identity.name);
|
|
48
|
+
const [head = "", ...rest] = full ? full.split(" ") : [];
|
|
49
|
+
first = head;
|
|
50
|
+
last = rest.join(" ");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
sender_first_name: first,
|
|
54
|
+
sender_last_name: last,
|
|
55
|
+
sender_full_name: full,
|
|
56
|
+
sender_name: full,
|
|
57
|
+
...(email !== undefined ? { sender_email: email ?? "" } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Whether any of these templates refers to a reserved sender variable. */
|
|
61
|
+
export function referencesSequenceSenderVariables(templates) {
|
|
62
|
+
return templates.some((text) => typeof text === "string" && /\{\{\s*sender_/.test(text));
|
|
63
|
+
}
|
|
@@ -933,16 +933,22 @@ export type SequenceLintIssue = {
|
|
|
933
933
|
message: string;
|
|
934
934
|
};
|
|
935
935
|
/**
|
|
936
|
-
* A non-blocking fact about
|
|
937
|
-
*
|
|
938
|
-
* `email_reply_rewritten
|
|
939
|
-
*
|
|
940
|
-
* back sees a different `kind` than it wrote with nothing in the
|
|
941
|
-
* explain it — reported as a silent coercion by a customer
|
|
936
|
+
* A non-blocking fact about a saved definition that its author must be told, as
|
|
937
|
+
* opposed to an issue that rejects it:
|
|
938
|
+
* - `email_reply_rewritten`: the retired kind is accepted and rewritten into a
|
|
939
|
+
* subject-less `email_send`, and a caller that only reads the stored
|
|
940
|
+
* definition back sees a different `kind` than it wrote with nothing in the
|
|
941
|
+
* response to explain it — reported as a silent coercion by a customer
|
|
942
|
+
* (Plain T-163).
|
|
943
|
+
* - `sequence_linkedin_owner_conflict`: a draft saves LinkedIn relationship
|
|
944
|
+
* steps that name different people, which only the launch refuses (same
|
|
945
|
+
* code as its launch blocker and the preview's `blocked` marker). A blind MCP run
|
|
946
|
+
* pinned the founder on the connection request alone and learned why the
|
|
947
|
+
* draft could not launch only from the start preview (2026-09-29).
|
|
942
948
|
*/
|
|
943
949
|
export type SequenceDefinitionNotice = {
|
|
944
950
|
path: string;
|
|
945
|
-
code: "email_reply_rewritten";
|
|
951
|
+
code: "email_reply_rewritten" | "sequence_linkedin_owner_conflict";
|
|
946
952
|
message: string;
|
|
947
953
|
};
|
|
948
954
|
export type ValidateSequenceOptions = {
|
|
@@ -1110,6 +1110,25 @@ export function sequenceLinkedInOwnerConflicts(steps) {
|
|
|
1110
1110
|
message: `step '${step.id}': every LinkedIn connection, message and InMail step must come from the same person (or all from the lead's own). LinkedIn lets one of your accounts hold a conversation with a lead.`,
|
|
1111
1111
|
}));
|
|
1112
1112
|
}
|
|
1113
|
+
/**
|
|
1114
|
+
* The draft-save counterpart of the launch's `sequence_linkedin_owner_conflict`:
|
|
1115
|
+
* one notice naming what each relationship step sends from, and both ways out.
|
|
1116
|
+
*/
|
|
1117
|
+
function sequenceLinkedInOwnerNotice(steps) {
|
|
1118
|
+
const conflicts = sequenceLinkedInOwnerConflicts(steps);
|
|
1119
|
+
const first = conflicts[0];
|
|
1120
|
+
if (!first)
|
|
1121
|
+
return null;
|
|
1122
|
+
const named = conflicts.map(({ index, stepId }) => {
|
|
1123
|
+
const person = steps[index] ? stepSenderProfileId(steps[index]) : undefined;
|
|
1124
|
+
return person ? `'${stepId}' names ${person}` : `'${stepId}' names no one`;
|
|
1125
|
+
});
|
|
1126
|
+
return {
|
|
1127
|
+
path: `steps[${first.index}].sender_profile_id`,
|
|
1128
|
+
code: "sequence_linkedin_owner_conflict",
|
|
1129
|
+
message: `Saved as a draft, but it cannot launch yet: LinkedIn connection, message, InMail and withdraw steps must all name the same sender_profile_id, or none (${named.join(", ")}). LinkedIn lets one of your accounts hold a conversation with a lead. To have one person send all of them, name that person on each; to keep every lead's own person, remove it from all.`,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1113
1132
|
export function sequenceStepsMissingCopy(steps) {
|
|
1114
1133
|
const missing = [];
|
|
1115
1134
|
const check = (step, field, value) => {
|
|
@@ -1259,6 +1278,13 @@ export function validateSequenceDefinition(input, options = {}) {
|
|
|
1259
1278
|
issues.push({ path: `steps[${conflict.index}].sender_profile_id`, message: conflict.message });
|
|
1260
1279
|
}
|
|
1261
1280
|
}
|
|
1281
|
+
else {
|
|
1282
|
+
// A draft may be half-built, so a split LinkedIn owner still saves; tell the
|
|
1283
|
+
// author now instead of leaving it for the launch preview to reveal.
|
|
1284
|
+
const ownerNotice = sequenceLinkedInOwnerNotice(normalized);
|
|
1285
|
+
if (ownerNotice)
|
|
1286
|
+
options.notices?.push(ownerNotice);
|
|
1287
|
+
}
|
|
1262
1288
|
if (issues.length > 0) {
|
|
1263
1289
|
throw new OxygenError("invalid_sequence", `Sequence definition is invalid: ${issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`, { details: { issues }, exitCode: 1 });
|
|
1264
1290
|
}
|
|
@@ -16,6 +16,8 @@ export type SignupLeadWebhookInput = {
|
|
|
16
16
|
firstName?: string | null;
|
|
17
17
|
lastName?: string | null;
|
|
18
18
|
phone?: string | null;
|
|
19
|
+
/** Whether the provider verified `phone`; sign-up collects numbers without verifying them. */
|
|
20
|
+
phoneVerified?: boolean | null;
|
|
19
21
|
createdAt?: Date | string | number | null | undefined;
|
|
20
22
|
/** A profile URL the provider already knows for this person; the consent-recorded creator URL still wins at enqueue. */
|
|
21
23
|
linkedinUrl?: string | null;
|
|
@@ -34,6 +34,9 @@ export function buildSignupLeadWebhookPayload(input) {
|
|
|
34
34
|
const firstName = normalizeString(input.user?.firstName);
|
|
35
35
|
const lastName = normalizeString(input.user?.lastName);
|
|
36
36
|
const phone = normalizeString(input.user?.phone);
|
|
37
|
+
const phoneVerified = phone && typeof input.user?.phoneVerified === "boolean"
|
|
38
|
+
? input.user.phoneVerified
|
|
39
|
+
: null;
|
|
37
40
|
const fullName = normalizeString([firstName, lastName].filter(Boolean).join(" "));
|
|
38
41
|
const clerkOrgId = normalizeString(input.organization?.clerkOrgId);
|
|
39
42
|
const companyName = normalizeString(input.organization?.name);
|
|
@@ -53,6 +56,7 @@ export function buildSignupLeadWebhookPayload(input) {
|
|
|
53
56
|
first_name: firstName,
|
|
54
57
|
last_name: lastName,
|
|
55
58
|
phone,
|
|
59
|
+
phone_verified: phoneVerified,
|
|
56
60
|
full_name: fullName,
|
|
57
61
|
created_at: normalizeDate(input.user?.createdAt),
|
|
58
62
|
linkedin_url: normalizeString(input.user?.linkedinUrl) ?? undefined,
|
|
@@ -121,6 +125,7 @@ export function buildSignupLeadWebhookPayload(input) {
|
|
|
121
125
|
first_name: firstName,
|
|
122
126
|
last_name: lastName,
|
|
123
127
|
phone,
|
|
128
|
+
phone_verified: phoneVerified,
|
|
124
129
|
full_name: fullName,
|
|
125
130
|
company_name: companyName,
|
|
126
131
|
company_domain: companyDomain,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { zoneOffsetMs } from "./zoned-time.js";
|
|
1
2
|
export const EARNED_SERIES_MAX_PRORATE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
2
3
|
function pointsFor(timeline, metric) {
|
|
3
4
|
const points = [];
|
|
@@ -179,21 +180,6 @@ export function zonedDayStarts(fromDate, toDate, timeZone) {
|
|
|
179
180
|
}
|
|
180
181
|
return { days, end: startOfZonedDay(cursor.toISOString().slice(0, 10), timeZone) };
|
|
181
182
|
}
|
|
182
|
-
function zoneOffsetMs(instant, timeZone) {
|
|
183
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
184
|
-
timeZone,
|
|
185
|
-
hourCycle: "h23",
|
|
186
|
-
year: "numeric",
|
|
187
|
-
month: "2-digit",
|
|
188
|
-
day: "2-digit",
|
|
189
|
-
hour: "2-digit",
|
|
190
|
-
minute: "2-digit",
|
|
191
|
-
second: "2-digit",
|
|
192
|
-
}).formatToParts(instant);
|
|
193
|
-
const read = (type) => Number(parts.find((part) => part.type === type)?.value ?? 0);
|
|
194
|
-
const asUtc = Date.UTC(read("year"), read("month") - 1, read("day"), read("hour"), read("minute"), read("second"));
|
|
195
|
-
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
|
|
196
|
-
}
|
|
197
183
|
function startOfZonedDay(date, timeZone) {
|
|
198
184
|
const utcMidnight = new Date(`${date}T00:00:00.000Z`).getTime();
|
|
199
185
|
const firstGuess = utcMidnight - zoneOffsetMs(new Date(utcMidnight), timeZone);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export declare const STANDARD_WEBHOOK_SECRET_PREFIX = "whsec_";
|
|
2
|
+
/** MCP Events accepts only `whsec_` secrets whose base64 value decodes to 24–64 bytes. */
|
|
3
|
+
export declare const STANDARD_WEBHOOK_SECRET_MIN_BYTES = 24;
|
|
4
|
+
export declare const STANDARD_WEBHOOK_SECRET_MAX_BYTES = 64;
|
|
5
|
+
/** Default `webhook-timestamp` window, in either direction, as in the reference libraries. */
|
|
6
|
+
export declare const STANDARD_WEBHOOK_TOLERANCE_SECONDS: number;
|
|
7
|
+
export type StandardWebhookErrorReason = "secret_missing" | "secret_prefix" | "secret_encoding" | "secret_length" | "invalid_id" | "invalid_timestamp";
|
|
8
|
+
/** A secret or signing input that cannot produce a valid signature. Never carries the secret. */
|
|
9
|
+
export declare class StandardWebhookError extends Error {
|
|
10
|
+
readonly reason: StandardWebhookErrorReason;
|
|
11
|
+
constructor(reason: StandardWebhookErrorReason, message: string);
|
|
12
|
+
}
|
|
13
|
+
/** One `whsec_` secret, or several during a rotation window (every key signs). */
|
|
14
|
+
export type StandardWebhookSecrets = string | readonly string[];
|
|
15
|
+
/** A `Date`, or whole Unix seconds. */
|
|
16
|
+
export type StandardWebhookTime = Date | number;
|
|
17
|
+
export type StandardWebhookSignInput = {
|
|
18
|
+
secret: StandardWebhookSecrets;
|
|
19
|
+
id: string;
|
|
20
|
+
timestamp: StandardWebhookTime;
|
|
21
|
+
/** The exact request body. Send these same characters; re-serialising breaks the signature. */
|
|
22
|
+
body: string;
|
|
23
|
+
};
|
|
24
|
+
export type StandardWebhookHeaders = {
|
|
25
|
+
"webhook-id": string;
|
|
26
|
+
"webhook-timestamp": string;
|
|
27
|
+
"webhook-signature": string;
|
|
28
|
+
};
|
|
29
|
+
/** Decode a `whsec_` secret to its HMAC key, enforcing the 24–64 byte range. */
|
|
30
|
+
export declare function parseStandardWebhookSecret(secret: string): Buffer;
|
|
31
|
+
/** The `webhook-signature` value: `v1,<base64>`, space-separated when several secrets sign. */
|
|
32
|
+
export declare function signStandardWebhook(input: StandardWebhookSignInput): string;
|
|
33
|
+
/** The three Standard Webhooks headers for one delivery attempt. */
|
|
34
|
+
export declare function standardWebhookHeaders(input: StandardWebhookSignInput): StandardWebhookHeaders;
|
|
35
|
+
/**
|
|
36
|
+
* True when any `v1` entry of `signature` matches any of the secrets and the
|
|
37
|
+
* timestamp is within `toleranceSeconds` of `now`. A malformed id, timestamp or
|
|
38
|
+
* signature is `false`; an invalid secret, `now` or `toleranceSeconds` throws,
|
|
39
|
+
* because that is a misconfiguration rather than a bad request.
|
|
40
|
+
*/
|
|
41
|
+
export declare function verifyStandardWebhook(input: {
|
|
42
|
+
secret: StandardWebhookSecrets;
|
|
43
|
+
id: string;
|
|
44
|
+
/** The `webhook-timestamp` header as received, or a parsed time. */
|
|
45
|
+
timestamp: StandardWebhookTime | string;
|
|
46
|
+
body: string;
|
|
47
|
+
/** The `webhook-signature` header as received. */
|
|
48
|
+
signature: string;
|
|
49
|
+
toleranceSeconds?: number;
|
|
50
|
+
now?: StandardWebhookTime;
|
|
51
|
+
}): boolean;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
// Standard Webhooks symmetric signatures for the webhooks OXYGEN SENDS: MCP
|
|
3
|
+
// Events deliveries and their callback challenges (OXP-11.5.17).
|
|
4
|
+
//
|
|
5
|
+
// The signed content is `${webhook-id}.${webhook-timestamp}.${body}`, and a
|
|
6
|
+
// signature is `v1,<base64 HMAC-SHA256>` keyed by the base64 value after the
|
|
7
|
+
// `whsec_` prefix. `webhook-signature` is a space-delimited list, so a rotation
|
|
8
|
+
// window carries one signature per key and a receiver accepts any of them.
|
|
9
|
+
// Spec: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
|
10
|
+
//
|
|
11
|
+
// Svix uses the same scheme, so OXYGEN's inbound verifiers (the `svix` library
|
|
12
|
+
// on the Clerk and Recall routes, and
|
|
13
|
+
// `apps/web/src/lib/ingress-capture/replay-verify.ts`) accept these signatures.
|
|
14
|
+
export const STANDARD_WEBHOOK_SECRET_PREFIX = "whsec_";
|
|
15
|
+
/** MCP Events accepts only `whsec_` secrets whose base64 value decodes to 24–64 bytes. */
|
|
16
|
+
export const STANDARD_WEBHOOK_SECRET_MIN_BYTES = 24;
|
|
17
|
+
export const STANDARD_WEBHOOK_SECRET_MAX_BYTES = 64;
|
|
18
|
+
/** Default `webhook-timestamp` window, in either direction, as in the reference libraries. */
|
|
19
|
+
export const STANDARD_WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
|
|
20
|
+
/** A secret or signing input that cannot produce a valid signature. Never carries the secret. */
|
|
21
|
+
export class StandardWebhookError extends Error {
|
|
22
|
+
reason;
|
|
23
|
+
constructor(reason, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "StandardWebhookError";
|
|
26
|
+
this.reason = reason;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Standard alphabet, padding optional. The reference decoders reject the
|
|
30
|
+
// URL-safe alphabet, and Buffer.from(value, "base64") silently skips stray
|
|
31
|
+
// characters, which would sign with a key the receiver never derives.
|
|
32
|
+
const STANDARD_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/;
|
|
33
|
+
// Printable ASCII without space or ".": the id is a header value and a
|
|
34
|
+
// "."-delimited part of the signed content, and the spec forbids "." in it.
|
|
35
|
+
const MESSAGE_ID = /^[\x21-\x2d\x2f-\x7e]+$/;
|
|
36
|
+
const UNIX_SECONDS = /^\d{1,15}$/;
|
|
37
|
+
/** Decode a `whsec_` secret to its HMAC key, enforcing the 24–64 byte range. */
|
|
38
|
+
export function parseStandardWebhookSecret(secret) {
|
|
39
|
+
if (!secret.startsWith(STANDARD_WEBHOOK_SECRET_PREFIX)) {
|
|
40
|
+
throw new StandardWebhookError("secret_prefix", "A Standard Webhooks secret must start with whsec_.");
|
|
41
|
+
}
|
|
42
|
+
const encoded = secret.slice(STANDARD_WEBHOOK_SECRET_PREFIX.length);
|
|
43
|
+
if (!STANDARD_BASE64.test(encoded)) {
|
|
44
|
+
throw new StandardWebhookError("secret_encoding", "A Standard Webhooks secret must be standard base64 after whsec_.");
|
|
45
|
+
}
|
|
46
|
+
const key = Buffer.from(encoded, "base64");
|
|
47
|
+
if (key.length < STANDARD_WEBHOOK_SECRET_MIN_BYTES || key.length > STANDARD_WEBHOOK_SECRET_MAX_BYTES) {
|
|
48
|
+
throw new StandardWebhookError("secret_length", `A Standard Webhooks secret must decode to ${STANDARD_WEBHOOK_SECRET_MIN_BYTES}-${STANDARD_WEBHOOK_SECRET_MAX_BYTES} bytes; this one decodes to ${key.length}.`);
|
|
49
|
+
}
|
|
50
|
+
return key;
|
|
51
|
+
}
|
|
52
|
+
/** The `webhook-signature` value: `v1,<base64>`, space-separated when several secrets sign. */
|
|
53
|
+
export function signStandardWebhook(input) {
|
|
54
|
+
const keys = parseSecrets(input.secret);
|
|
55
|
+
if (!MESSAGE_ID.test(input.id)) {
|
|
56
|
+
throw new StandardWebhookError("invalid_id", "A webhook id must be printable ASCII without spaces or full stops.");
|
|
57
|
+
}
|
|
58
|
+
const content = signedContent(input.id, requireUnixSeconds(input.timestamp), input.body);
|
|
59
|
+
return keys.map((key) => `v1,${hmacBase64(key, content)}`).join(" ");
|
|
60
|
+
}
|
|
61
|
+
/** The three Standard Webhooks headers for one delivery attempt. */
|
|
62
|
+
export function standardWebhookHeaders(input) {
|
|
63
|
+
const seconds = requireUnixSeconds(input.timestamp);
|
|
64
|
+
return {
|
|
65
|
+
"webhook-id": input.id,
|
|
66
|
+
"webhook-timestamp": String(seconds),
|
|
67
|
+
"webhook-signature": signStandardWebhook({ ...input, timestamp: seconds }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* True when any `v1` entry of `signature` matches any of the secrets and the
|
|
72
|
+
* timestamp is within `toleranceSeconds` of `now`. A malformed id, timestamp or
|
|
73
|
+
* signature is `false`; an invalid secret, `now` or `toleranceSeconds` throws,
|
|
74
|
+
* because that is a misconfiguration rather than a bad request.
|
|
75
|
+
*/
|
|
76
|
+
export function verifyStandardWebhook(input) {
|
|
77
|
+
const keys = parseSecrets(input.secret);
|
|
78
|
+
const tolerance = input.toleranceSeconds ?? STANDARD_WEBHOOK_TOLERANCE_SECONDS;
|
|
79
|
+
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
80
|
+
throw new RangeError("toleranceSeconds must be a finite, non-negative number of seconds.");
|
|
81
|
+
}
|
|
82
|
+
const now = input.now === undefined ? Math.floor(Date.now() / 1000) : requireUnixSeconds(input.now);
|
|
83
|
+
const seconds = readUnixSeconds(input.timestamp);
|
|
84
|
+
if (seconds === null || !MESSAGE_ID.test(input.id) || Math.abs(now - seconds) > tolerance)
|
|
85
|
+
return false;
|
|
86
|
+
const content = signedContent(input.id, seconds, input.body);
|
|
87
|
+
const expected = keys.map((key) => Buffer.from(hmacBase64(key, content)));
|
|
88
|
+
for (const entry of input.signature.split(" ")) {
|
|
89
|
+
const comma = entry.indexOf(",");
|
|
90
|
+
if (comma < 0 || entry.slice(0, comma) !== "v1")
|
|
91
|
+
continue;
|
|
92
|
+
const presented = Buffer.from(entry.slice(comma + 1));
|
|
93
|
+
if (expected.some((candidate) => candidate.length === presented.length && timingSafeEqual(candidate, presented))) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
function parseSecrets(secret) {
|
|
100
|
+
const secrets = typeof secret === "string" ? [secret] : secret;
|
|
101
|
+
if (secrets.length === 0) {
|
|
102
|
+
throw new StandardWebhookError("secret_missing", "At least one Standard Webhooks secret is required.");
|
|
103
|
+
}
|
|
104
|
+
return secrets.map(parseStandardWebhookSecret);
|
|
105
|
+
}
|
|
106
|
+
function signedContent(id, seconds, body) {
|
|
107
|
+
return `${id}.${seconds}.${body}`;
|
|
108
|
+
}
|
|
109
|
+
function hmacBase64(key, content) {
|
|
110
|
+
return createHmac("sha256", key).update(content, "utf8").digest("base64");
|
|
111
|
+
}
|
|
112
|
+
function requireUnixSeconds(value) {
|
|
113
|
+
const seconds = readUnixSeconds(value);
|
|
114
|
+
if (seconds === null) {
|
|
115
|
+
throw new StandardWebhookError("invalid_timestamp", "A webhook timestamp must be a valid time at or after the Unix epoch.");
|
|
116
|
+
}
|
|
117
|
+
return seconds;
|
|
118
|
+
}
|
|
119
|
+
function readUnixSeconds(value) {
|
|
120
|
+
if (typeof value === "string")
|
|
121
|
+
return UNIX_SECONDS.test(value) ? Number(value) : null;
|
|
122
|
+
const seconds = value instanceof Date ? Math.floor(value.getTime() / 1000) : value;
|
|
123
|
+
return Number.isSafeInteger(seconds) && seconds >= 0 ? seconds : null;
|
|
124
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TagColor } from "./select-options.js";
|
|
2
2
|
/** Primitive kinds that participate in the workspace tag union today. */
|
|
3
|
-
export declare const TAG_KINDS: readonly ["knowledge_page", "publishing_post", "sequence", "table", "workflow", "recipe", "conversation", "mailbox", "sender", "record", "domain", "project", "sender_profile", "voice_number"];
|
|
3
|
+
export declare const TAG_KINDS: readonly ["knowledge_page", "publishing_post", "sequence", "table", "workflow", "recipe", "conversation", "mailbox", "sender", "record", "domain", "project", "sender_profile", "voice_number", "signal_type", "dashboard", "agent", "ad_search_collection"];
|
|
4
4
|
export type TagKind = (typeof TAG_KINDS)[number];
|
|
5
5
|
/**
|
|
6
6
|
* The taggable kinds in prose, for CLI `--help` and MCP tool descriptions.
|
|
@@ -26,6 +26,10 @@ export const TAG_KINDS = [
|
|
|
26
26
|
"project",
|
|
27
27
|
"sender_profile",
|
|
28
28
|
"voice_number",
|
|
29
|
+
"signal_type",
|
|
30
|
+
"dashboard",
|
|
31
|
+
"agent",
|
|
32
|
+
"ad_search_collection",
|
|
29
33
|
];
|
|
30
34
|
/**
|
|
31
35
|
* The taggable kinds in prose, for CLI `--help` and MCP tool descriptions.
|
|
@@ -39,7 +43,7 @@ export const TAG_KINDS = [
|
|
|
39
43
|
*/
|
|
40
44
|
export const TAG_KINDS_PROSE = "publishing posts, knowledge pages, sequences, tables, workflows, recipes, " +
|
|
41
45
|
"inbox conversations, CRM records, mailboxes, sender accounts, sending domains, " +
|
|
42
|
-
"projects, sender profiles, and
|
|
46
|
+
"projects, sender profiles, phone numbers, signal types, dashboards, agents, and saved Ads searches";
|
|
43
47
|
/**
|
|
44
48
|
* The phrase each kind must contribute to {@link TAG_KINDS_PROSE}. Typed as a
|
|
45
49
|
* total map over TagKind, so adding a kind is a compile error until it is named
|
|
@@ -60,6 +64,10 @@ export const TAG_KIND_PROSE_MARKERS = {
|
|
|
60
64
|
project: "project",
|
|
61
65
|
sender_profile: "sender profile",
|
|
62
66
|
voice_number: "phone number",
|
|
67
|
+
signal_type: "signal type",
|
|
68
|
+
dashboard: "dashboard",
|
|
69
|
+
agent: "agent",
|
|
70
|
+
ad_search_collection: "saved Ads search",
|
|
63
71
|
};
|
|
64
72
|
export function isTagKind(value) {
|
|
65
73
|
return typeof value === "string" && TAG_KINDS.includes(value);
|
|
@@ -91,6 +99,10 @@ export const TAG_KIND_LABELS = {
|
|
|
91
99
|
project: { one: "project", many: "projects" },
|
|
92
100
|
sender_profile: { one: "sender profile", many: "sender profiles" },
|
|
93
101
|
voice_number: { one: "phone number", many: "phone numbers" },
|
|
102
|
+
signal_type: { one: "signal type", many: "signal types" },
|
|
103
|
+
dashboard: { one: "dashboard", many: "dashboards" },
|
|
104
|
+
agent: { one: "agent", many: "agents" },
|
|
105
|
+
ad_search_collection: { one: "saved Ads search", many: "saved Ads searches" },
|
|
94
106
|
};
|
|
95
107
|
/** Inline label for a count: `tagKindLabel("sequence", 1)` → "sequence". */
|
|
96
108
|
export function tagKindLabel(kind, count) {
|
|
@@ -8,8 +8,12 @@ export type UgcPostingTarget = {
|
|
|
8
8
|
};
|
|
9
9
|
export declare const UGC_POSTING_TARGET_MAX = 100;
|
|
10
10
|
export declare const UGC_POSTING_TARGET_PERIODS: readonly ["week", "month", "total"];
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The operator-written creator brief: one page, not a document (Philipp, 2026-09-23;
|
|
13
|
+
* raised from 1,200 on 2026-09-29, when a natural founder brief ran 1,298 and blind
|
|
14
|
+
* operators either trimmed it or left the program without one).
|
|
15
|
+
*/
|
|
16
|
+
export declare const UGC_PROGRAM_BRIEF_MAX_CHARS = 4000;
|
|
13
17
|
/** The program's target as its API shape, or null when none is set. */
|
|
14
18
|
export declare function ugcPostingTarget(program: {
|
|
15
19
|
postingTargetCount?: number | null | undefined;
|
|
@@ -21,7 +25,6 @@ export type UgcProgram = {
|
|
|
21
25
|
productUrl: string;
|
|
22
26
|
description: string;
|
|
23
27
|
knowledgePageSlugs: string[];
|
|
24
|
-
brandApprovalRequired: boolean;
|
|
25
28
|
peerEngagementEnabled?: boolean;
|
|
26
29
|
peerEngagementVersion?: number;
|
|
27
30
|
peerEngagementEffectiveAt?: Date | null;
|
|
@@ -94,13 +97,11 @@ export type UgcPostLink = {
|
|
|
94
97
|
participationId: string;
|
|
95
98
|
creatorOrgId: string;
|
|
96
99
|
masterOrgId: string;
|
|
97
|
-
brandApprovalRequired: boolean;
|
|
98
100
|
sourcePostId: string;
|
|
99
101
|
attribution: Record<string, unknown>;
|
|
100
102
|
projection: UgcPostProjection;
|
|
101
103
|
sourceRevision: string;
|
|
102
104
|
creatorApprovedRevision: string | null;
|
|
103
|
-
brandApprovedRevision: string | null;
|
|
104
105
|
grantVersion: number;
|
|
105
106
|
removedAt?: Date | null;
|
|
106
107
|
enrollmentVersion?: number;
|
|
@@ -154,13 +155,16 @@ export declare const UGC_SPONSORED_LINKEDIN_SEATS = 1;
|
|
|
154
155
|
* One label for a sponsor's row (`view=sponsorship`) on every surface, the web
|
|
155
156
|
* Creators tab and the MCP widget alike: a card sponsorship waiting on its
|
|
156
157
|
* checkout reads differently from one waiting on the provider, and a stopped
|
|
157
|
-
* renewal says so while its paid period runs.
|
|
158
|
+
* renewal says so while its paid period runs. `paid_through` tells a stop that
|
|
159
|
+
* keeps a paid period from one that ended it, and from a sponsorship that was
|
|
160
|
+
* cancelled before anything was paid.
|
|
158
161
|
*/
|
|
159
162
|
export declare function ugcSponsorshipStatusLabel(row: {
|
|
160
163
|
status: string;
|
|
161
164
|
checkout_url?: string | null;
|
|
162
165
|
cancellation_pending?: boolean;
|
|
163
|
-
|
|
166
|
+
paid_through?: string | null;
|
|
167
|
+
}, now?: number): string;
|
|
164
168
|
/** What confirming a credit-sponsorship quote does for one creator (`participants[].state`). */
|
|
165
169
|
export declare const UGC_SPONSORSHIP_PLAN_LABELS: Readonly<Record<string, string>>;
|
|
166
170
|
/** When a new credit sponsorship's reservation starts (`participants[].reservation_starts`). */
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export const UGC_POSTING_TARGET_MAX = 100;
|
|
2
2
|
export const UGC_POSTING_TARGET_PERIODS = ["week", "month", "total"];
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The operator-written creator brief: one page, not a document (Philipp, 2026-09-23;
|
|
5
|
+
* raised from 1,200 on 2026-09-29, when a natural founder brief ran 1,298 and blind
|
|
6
|
+
* operators either trimmed it or left the program without one).
|
|
7
|
+
*/
|
|
8
|
+
export const UGC_PROGRAM_BRIEF_MAX_CHARS = 4000;
|
|
5
9
|
/** The program's target as its API shape, or null when none is set. */
|
|
6
10
|
export function ugcPostingTarget(program) {
|
|
7
11
|
return program.postingTargetCount && program.postingTargetPeriod
|
|
@@ -14,17 +18,21 @@ export const UGC_SPONSORED_LINKEDIN_SEATS = 1;
|
|
|
14
18
|
* One label for a sponsor's row (`view=sponsorship`) on every surface, the web
|
|
15
19
|
* Creators tab and the MCP widget alike: a card sponsorship waiting on its
|
|
16
20
|
* checkout reads differently from one waiting on the provider, and a stopped
|
|
17
|
-
* renewal says so while its paid period runs.
|
|
21
|
+
* renewal says so while its paid period runs. `paid_through` tells a stop that
|
|
22
|
+
* keeps a paid period from one that ended it, and from a sponsorship that was
|
|
23
|
+
* cancelled before anything was paid.
|
|
18
24
|
*/
|
|
19
|
-
export function ugcSponsorshipStatusLabel(row) {
|
|
25
|
+
export function ugcSponsorshipStatusLabel(row, now = Date.now()) {
|
|
26
|
+
const paidThrough = row.paid_through ? Date.parse(row.paid_through) : Number.NaN;
|
|
27
|
+
const paid = Number.isFinite(paidThrough);
|
|
20
28
|
if (row.cancellation_pending)
|
|
21
|
-
return "Renewal stopping";
|
|
29
|
+
return paid ? "Renewal stopping" : "Cancelling";
|
|
22
30
|
if (row.status === "active")
|
|
23
31
|
return "Sponsored";
|
|
24
32
|
if (row.status === "pending")
|
|
25
33
|
return row.checkout_url ? "Checkout open" : "Pending";
|
|
26
34
|
if (row.status === "cancelled")
|
|
27
|
-
return "Renewal stopped";
|
|
35
|
+
return !paid ? "Not sponsored" : paidThrough > now ? "Renewal stopped" : "Ended";
|
|
28
36
|
return "Not sponsored";
|
|
29
37
|
}
|
|
30
38
|
/** What confirming a credit-sponsorship quote does for one creator (`participants[].state`). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const OXYGEN_BUILD_VERSION = "1.1011.
|
|
1
|
+
export declare const OXYGEN_BUILD_VERSION = "1.1011.288";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
|
|
2
|
-
export const OXYGEN_BUILD_VERSION = "1.1011.
|
|
2
|
+
export const OXYGEN_BUILD_VERSION = "1.1011.288";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wall-clock time in an IANA time zone, converted to instants DST-correctly.
|
|
3
|
+
*
|
|
4
|
+
* Publishing's publish-time contract rides on this on every surface: an ISO
|
|
5
|
+
* date-time with `Z` or an offset is that exact instant, and one without is
|
|
6
|
+
* wall-clock time in the post's timezone, read with the offset in force at that
|
|
7
|
+
* local time. 09:00 Europe/Berlin is 07:00Z in October and 08:00Z in December.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Is `zone` an IANA time zone this runtime resolves (`Europe/Berlin`, `UTC`)?
|
|
11
|
+
* A bare offset (`+02:00`) is refused although Intl accepts it: it never moves
|
|
12
|
+
* for daylight saving, so a schedule kept in it drifts an hour for half the year.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isValidTimeZone(zone: string): boolean;
|
|
15
|
+
/** `timeZone`'s offset from UTC at `instant`, in milliseconds (Berlin in July: 7,200,000). */
|
|
16
|
+
export declare function zoneOffsetMs(instant: Date, timeZone: string): number;
|
|
17
|
+
export type WallClock = {
|
|
18
|
+
year: number;
|
|
19
|
+
/** 1-12. */
|
|
20
|
+
month: number;
|
|
21
|
+
day: number;
|
|
22
|
+
hour: number;
|
|
23
|
+
minute: number;
|
|
24
|
+
second: number;
|
|
25
|
+
millisecond: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The instant a wall-clock time in `timeZone` names. A time the zone repeats (the
|
|
29
|
+
* autumn fall-back hour) resolves to its first occurrence; a time it skips (the
|
|
30
|
+
* spring-forward gap) resolves past the gap by its length, as a clock would read
|
|
31
|
+
* it: 02:30 Europe/Berlin on 2026-03-29 is 03:30 CEST.
|
|
32
|
+
*/
|
|
33
|
+
export declare function zonedWallClockToInstant(wall: WallClock, timeZone: string): Date;
|
|
34
|
+
/**
|
|
35
|
+
* Parse an ISO date-time: with `Z` or an offset it is that instant; without one
|
|
36
|
+
* it is wall-clock time in `timeZone` (DST-correct). Null when it names no real
|
|
37
|
+
* date-time. Anything that is not ISO-shaped keeps `Date`'s own parsing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseDateTimeInZone(value: string, timeZone: string): Date | null;
|