@oxygen-agent/cli 1.948.1 → 1.982.3
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 +9 -1
- package/dist/cli-values.d.ts +14 -0
- package/dist/cli-values.js +26 -0
- package/dist/command-manifest.js +6 -0
- package/dist/functions-commands.js +13 -5
- package/dist/help.js +1 -0
- package/dist/index.js +1171 -240
- package/dist/knowledge-repository-commands.d.ts +6 -0
- package/dist/knowledge-repository-commands.js +198 -0
- package/dist/skills.js +20 -0
- package/dist/ugc-commands.js +122 -8
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +99 -15
- package/node_modules/@oxygen/shared/dist/copilot-errors.js +3 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +19 -1
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.generated.js +26 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.js +8 -41
- package/node_modules/@oxygen/shared/dist/inbox-avatar-url.d.ts +28 -0
- package/node_modules/@oxygen/shared/dist/inbox-avatar-url.js +57 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/index.js +4 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bases.d.ts +74 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bases.js +456 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +17 -38
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +14 -39
- package/node_modules/@oxygen/shared/dist/knowledge-repository.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/knowledge-repository.js +121 -0
- package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.d.ts +20 -0
- package/node_modules/@oxygen/shared/dist/knowledge-vault-markdown.js +155 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/mailbox-import.js +53 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +8 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +1 -1
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +24 -0
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/recipes.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +126 -2
- package/node_modules/@oxygen/shared/dist/sequences.js +280 -4
- package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/ugc-amplification-identity.js +24 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +8 -1
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +3 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +15 -4
- package/node_modules/@oxygen/shared/package.json +15 -0
- package/package.json +2 -1
|
@@ -108,8 +108,11 @@ export type EspMatchingMode = (typeof ESP_MATCHING_MODES)[number];
|
|
|
108
108
|
export declare const DEFAULT_ESP_MATCHING_MODE: EspMatchingMode;
|
|
109
109
|
export declare function isEspMatchingMode(value: unknown): value is EspMatchingMode;
|
|
110
110
|
/**
|
|
111
|
-
* Validate settings.esp_matching before persistence. Absent/null is valid
|
|
112
|
-
* DEFAULT_ESP_MATCHING_MODE "
|
|
111
|
+
* Validate settings.esp_matching before persistence. Absent/null is valid and
|
|
112
|
+
* means DEFAULT_ESP_MATCHING_MODE, which is "prefer" — an unset sequence still
|
|
113
|
+
* biases rotation toward a same-provider sender, so a workspace whose senders on
|
|
114
|
+
* one provider are in bad standing concentrates that provider's recipients on
|
|
115
|
+
* them until an operator sets "off". Any other value must be one of the three
|
|
113
116
|
* modes. Pure; throws OxygenError("invalid_sequence_settings") on a bad value so
|
|
114
117
|
* the CLI / MCP / API report an identical error. Called from the tenant-db
|
|
115
118
|
* settings validator alongside the budget/prioritization checks.
|
|
@@ -128,6 +131,127 @@ export declare function validateEspMatchingSetting(value: unknown): void;
|
|
|
128
131
|
*/
|
|
129
132
|
export declare const RECIPIENT_ESPS: readonly ["google", "microsoft"];
|
|
130
133
|
export type RecipientEsp = (typeof RECIPIENT_ESPS)[number];
|
|
134
|
+
/**
|
|
135
|
+
* WEIGHTED SENDER ROUTING — the object form of settings.esp_matching.
|
|
136
|
+
*
|
|
137
|
+
* The three scalar modes above answer one question ("bias toward the recipient's
|
|
138
|
+
* own provider, yes or no"), and they bake in the assumption that same-provider
|
|
139
|
+
* is always better. That assumption fails whenever one provider's senders are in
|
|
140
|
+
* bad standing: a workspace whose Google-hosted domains are refused by Gmail
|
|
141
|
+
* wants its Google-hosted RECIPIENTS served from Microsoft senders, which no
|
|
142
|
+
* scalar mode can express. "off" does not express it either, because free
|
|
143
|
+
* rotation still hands Google recipients a Google sender in proportion to the
|
|
144
|
+
* pool.
|
|
145
|
+
*
|
|
146
|
+
* So the object form states the routing directly, as SEND SHARES per recipient
|
|
147
|
+
* provider:
|
|
148
|
+
*
|
|
149
|
+
* { mode: "prefer",
|
|
150
|
+
* routes: { google: { microsoft: 100 }, microsoft: { microsoft: 100 } },
|
|
151
|
+
* exclude: [{ domain: "burned.example", from_recipients: ["google"] }] }
|
|
152
|
+
*
|
|
153
|
+
* `routes` is keyed by the RECIPIENT's resolved provider; the inner map is the
|
|
154
|
+
* SENDER provider. Weights are relative shares, so { google: 3, microsoft: 1 }
|
|
155
|
+
* and { google: 75, microsoft: 25 } are the same policy and nothing has to sum
|
|
156
|
+
* to 100. An omitted weight is zero. A bucket whose entry is explicitly present
|
|
157
|
+
* but carries no positive weight means "no opinion, rotate freely for this
|
|
158
|
+
* recipient class"; a bucket the operator never mentioned keeps the historical
|
|
159
|
+
* same-provider default, so editing Google routing cannot silently change how
|
|
160
|
+
* Microsoft recipients are served.
|
|
161
|
+
*
|
|
162
|
+
* `exclude` is a hard never, not a preference: a sender domain listed here is
|
|
163
|
+
* removed from the candidate pool for the named recipient buckets (all buckets
|
|
164
|
+
* when `from_recipients` is absent) in EVERY mode, including "off", and it
|
|
165
|
+
* survives the prefer-fallback. A deliverability guard that a matching toggle
|
|
166
|
+
* could disarm would not be a guard.
|
|
167
|
+
*
|
|
168
|
+
* The scalar modes remain valid values and are read as policies, so there is one
|
|
169
|
+
* routing code path rather than two: "prefer" becomes { mode: "prefer" } with no
|
|
170
|
+
* routes, which resolves to the same-provider default and behaves exactly as it
|
|
171
|
+
* did before.
|
|
172
|
+
*/
|
|
173
|
+
export declare const ESP_ROUTE_BUCKETS: readonly ["google", "microsoft", "unknown"];
|
|
174
|
+
/** The RECIPIENT side of a route: a matchable provider, or "unknown" when resolution failed. */
|
|
175
|
+
export type EspRouteBucket = (typeof ESP_ROUTE_BUCKETS)[number];
|
|
176
|
+
/** The SENDER side of a route: relative send shares per mailbox provider. */
|
|
177
|
+
export type EspRouteWeights = Partial<Record<RecipientEsp, number>>;
|
|
178
|
+
export type EspSenderExclusion = {
|
|
179
|
+
domain: string;
|
|
180
|
+
from_recipients?: EspRouteBucket[];
|
|
181
|
+
};
|
|
182
|
+
export type EspRoutingPolicy = {
|
|
183
|
+
mode?: EspMatchingMode;
|
|
184
|
+
routes?: Partial<Record<EspRouteBucket, EspRouteWeights>>;
|
|
185
|
+
exclude?: EspSenderExclusion[];
|
|
186
|
+
};
|
|
187
|
+
/** settings.esp_matching accepts either the legacy scalar or the routing policy. */
|
|
188
|
+
export type EspMatchingSetting = EspMatchingMode | EspRoutingPolicy;
|
|
189
|
+
/**
|
|
190
|
+
* A policy with every field present, which is what the dispatcher and the launch
|
|
191
|
+
* preview consume. Produced only by espPolicyFromSetting so both surfaces answer
|
|
192
|
+
* identically from the same stored value.
|
|
193
|
+
*/
|
|
194
|
+
export type ResolvedEspPolicy = {
|
|
195
|
+
mode: EspMatchingMode;
|
|
196
|
+
routes: Partial<Record<EspRouteBucket, EspRouteWeights>>;
|
|
197
|
+
exclude: Array<{
|
|
198
|
+
domain: string;
|
|
199
|
+
buckets: readonly EspRouteBucket[];
|
|
200
|
+
}>;
|
|
201
|
+
};
|
|
202
|
+
/** Cap on settings.esp_matching.exclude — a routing policy, not a suppression list. */
|
|
203
|
+
export declare const MAX_ESP_SENDER_EXCLUSIONS = 50;
|
|
204
|
+
/** Upper bound on a single route weight; relative shares never need more. */
|
|
205
|
+
export declare const MAX_ESP_ROUTE_WEIGHT = 1000000;
|
|
206
|
+
export declare function isEspRouteBucket(value: unknown): value is EspRouteBucket;
|
|
207
|
+
/**
|
|
208
|
+
* A sending domain as the mailbox table stores it: lowercase, no leading "@".
|
|
209
|
+
* Mirrors lower(split_part(email_address, '@', 2)) in pickEmailMailbox so an
|
|
210
|
+
* exclusion the operator typed as "@Burned.Example " still matches.
|
|
211
|
+
*/
|
|
212
|
+
export declare function normalizeEspSenderDomain(value: string): string;
|
|
213
|
+
/**
|
|
214
|
+
* LENIENT read of a persisted settings.esp_matching (never throws — a read must
|
|
215
|
+
* not break a send). Absent, null, or malformed reads as the default policy, and
|
|
216
|
+
* a malformed FIELD bails the WHOLE object rather than half-applying a routing
|
|
217
|
+
* policy, which is the readMailboxRampConfig convention in tenant-db. The strict
|
|
218
|
+
* counterpart that guards the write path is validateEspMatchingSetting.
|
|
219
|
+
*/
|
|
220
|
+
export declare function espPolicyFromSetting(value: unknown): ResolvedEspPolicy;
|
|
221
|
+
/**
|
|
222
|
+
* The effective send shares for one recipient bucket. An explicitly-present
|
|
223
|
+
* entry wins even when it is empty or all-zero (that is the operator saying
|
|
224
|
+
* "rotate freely here"); a bucket that was never mentioned keeps the historical
|
|
225
|
+
* same-provider default, and "unknown" has no same-provider to default to.
|
|
226
|
+
*/
|
|
227
|
+
export declare function espRouteWeightsFor(policy: ResolvedEspPolicy, bucket: EspRouteBucket): EspRouteWeights;
|
|
228
|
+
/**
|
|
229
|
+
* The sender providers to try, best first, for one recipient bucket — a
|
|
230
|
+
* deterministic weighted shuffle WITHOUT replacement.
|
|
231
|
+
*
|
|
232
|
+
* Returning an ORDER rather than a single draw is what makes "prefer" correct:
|
|
233
|
+
* a 90/10 Google policy over a pool holding no Google mailbox must send 100%
|
|
234
|
+
* from Microsoft, not defer and not fall through to provider-blind rotation.
|
|
235
|
+
* An empty result means "no opinion" and the caller rotates freely.
|
|
236
|
+
*
|
|
237
|
+
* Deterministic by construction: the seed carries the enrollment and step, never
|
|
238
|
+
* a clock or Math.random, so a dry-run preview and the later live send choose
|
|
239
|
+
* the same provider, and a replan after a defer re-derives the same route.
|
|
240
|
+
* Candidates are enumerated in RECIPIENT_ESPS order rather than object-key
|
|
241
|
+
* order, because jsonb does not preserve key order and a policy that round-trips
|
|
242
|
+
* through the database must not reroute the fleet.
|
|
243
|
+
*/
|
|
244
|
+
export declare function espRouteCandidates(input: {
|
|
245
|
+
policy: ResolvedEspPolicy;
|
|
246
|
+
recipient: EspRouteBucket;
|
|
247
|
+
seed: string;
|
|
248
|
+
}): RecipientEsp[];
|
|
249
|
+
/**
|
|
250
|
+
* Sending domains that must not serve this recipient bucket, normalized and
|
|
251
|
+
* de-duplicated for pickEmailMailbox. Applies in every mode and survives the
|
|
252
|
+
* prefer-fallback — see the exclude contract above.
|
|
253
|
+
*/
|
|
254
|
+
export declare function espExcludedSenderDomains(policy: ResolvedEspPolicy, bucket: EspRouteBucket): string[];
|
|
131
255
|
/**
|
|
132
256
|
* The DISPLAY taxonomy: the mail-infrastructure family a recipient domain sits
|
|
133
257
|
* on. Wider than RecipientEsp because "everything that isn't Google or Microsoft"
|
|
@@ -176,8 +176,11 @@ export function isEspMatchingMode(value) {
|
|
|
176
176
|
return typeof value === "string" && ESP_MATCHING_MODES.includes(value);
|
|
177
177
|
}
|
|
178
178
|
/**
|
|
179
|
-
* Validate settings.esp_matching before persistence. Absent/null is valid
|
|
180
|
-
* DEFAULT_ESP_MATCHING_MODE "
|
|
179
|
+
* Validate settings.esp_matching before persistence. Absent/null is valid and
|
|
180
|
+
* means DEFAULT_ESP_MATCHING_MODE, which is "prefer" — an unset sequence still
|
|
181
|
+
* biases rotation toward a same-provider sender, so a workspace whose senders on
|
|
182
|
+
* one provider are in bad standing concentrates that provider's recipients on
|
|
183
|
+
* them until an operator sets "off". Any other value must be one of the three
|
|
181
184
|
* modes. Pure; throws OxygenError("invalid_sequence_settings") on a bad value so
|
|
182
185
|
* the CLI / MCP / API report an identical error. Called from the tenant-db
|
|
183
186
|
* settings validator alongside the budget/prioritization checks.
|
|
@@ -185,8 +188,88 @@ export function isEspMatchingMode(value) {
|
|
|
185
188
|
export function validateEspMatchingSetting(value) {
|
|
186
189
|
if (value === undefined || value === null)
|
|
187
190
|
return;
|
|
188
|
-
if (
|
|
189
|
-
|
|
191
|
+
if (typeof value === "string" || typeof value !== "object" || Array.isArray(value)) {
|
|
192
|
+
if (!isEspMatchingMode(value)) {
|
|
193
|
+
throw new OxygenError("invalid_sequence_settings", `settings.esp_matching must be one of: ${ESP_MATCHING_MODES.join(", ")}.`, { details: { field: "esp_matching", value }, exitCode: 1 });
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
validateEspRoutingPolicy(value);
|
|
198
|
+
}
|
|
199
|
+
/** Throw with the exact jsonb path that failed, so a typo is nameable rather than mysterious. */
|
|
200
|
+
function espSettingError(path, message, value) {
|
|
201
|
+
return new OxygenError("invalid_sequence_settings", `settings.${path} ${message}`, {
|
|
202
|
+
details: { field: "esp_matching", path: `esp_matching.${path.replace(/^esp_matching\.?/, "")}`, ...(value === undefined ? {} : { value }) },
|
|
203
|
+
exitCode: 1,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Strict validation of the object form, the write-path counterpart to the
|
|
208
|
+
* lenient espPolicyFromSetting. Deliberately rejects UNKNOWN TOP-LEVEL KEYS by
|
|
209
|
+
* name: this setting's failure mode is invisible — a policy that silently did
|
|
210
|
+
* nothing because someone wrote "routs" would be discovered only as a month of
|
|
211
|
+
* mail sent from the wrong fleet.
|
|
212
|
+
*/
|
|
213
|
+
function validateEspRoutingPolicy(record) {
|
|
214
|
+
const allowed = new Set(["mode", "routes", "exclude"]);
|
|
215
|
+
for (const key of Object.keys(record)) {
|
|
216
|
+
if (!allowed.has(key)) {
|
|
217
|
+
throw espSettingError("esp_matching", `has an unknown key "${key}"; expected mode, routes or exclude.`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (record.mode !== undefined && record.mode !== null && !isEspMatchingMode(record.mode)) {
|
|
221
|
+
throw espSettingError("esp_matching.mode", `must be one of: ${ESP_MATCHING_MODES.join(", ")}.`, record.mode);
|
|
222
|
+
}
|
|
223
|
+
if (record.routes !== undefined && record.routes !== null) {
|
|
224
|
+
if (typeof record.routes !== "object" || Array.isArray(record.routes)) {
|
|
225
|
+
throw espSettingError("esp_matching.routes", "must be an object keyed by recipient provider.");
|
|
226
|
+
}
|
|
227
|
+
for (const [bucket, weights] of Object.entries(record.routes)) {
|
|
228
|
+
if (!isEspRouteBucket(bucket)) {
|
|
229
|
+
throw espSettingError("esp_matching.routes", `has an unknown recipient "${bucket}"; expected one of: ${ESP_ROUTE_BUCKETS.join(", ")}.`);
|
|
230
|
+
}
|
|
231
|
+
if (typeof weights !== "object" || weights === null || Array.isArray(weights)) {
|
|
232
|
+
throw espSettingError(`esp_matching.routes.${bucket}`, "must be an object of sender weights.");
|
|
233
|
+
}
|
|
234
|
+
for (const [provider, weight] of Object.entries(weights)) {
|
|
235
|
+
if (!RECIPIENT_ESPS.includes(provider)) {
|
|
236
|
+
throw espSettingError(`esp_matching.routes.${bucket}`, `has an unknown sender "${provider}"; expected one of: ${RECIPIENT_ESPS.join(", ")}.`);
|
|
237
|
+
}
|
|
238
|
+
if (typeof weight !== "number" || !Number.isFinite(weight) || weight < 0 || weight > MAX_ESP_ROUTE_WEIGHT) {
|
|
239
|
+
throw espSettingError(`esp_matching.routes.${bucket}.${provider}`, `must be a number between 0 and ${MAX_ESP_ROUTE_WEIGHT}.`, weight);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (record.exclude !== undefined && record.exclude !== null) {
|
|
245
|
+
if (!Array.isArray(record.exclude)) {
|
|
246
|
+
throw espSettingError("esp_matching.exclude", "must be an array of sender-domain exclusions.");
|
|
247
|
+
}
|
|
248
|
+
if (record.exclude.length > MAX_ESP_SENDER_EXCLUSIONS) {
|
|
249
|
+
throw espSettingError("esp_matching.exclude", `must hold at most ${MAX_ESP_SENDER_EXCLUSIONS} entries; it is a routing policy, not a suppression list.`);
|
|
250
|
+
}
|
|
251
|
+
record.exclude.forEach((entry, index) => {
|
|
252
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
253
|
+
throw espSettingError(`esp_matching.exclude[${index}]`, "must be an object with a domain.");
|
|
254
|
+
}
|
|
255
|
+
const item = entry;
|
|
256
|
+
for (const key of Object.keys(item)) {
|
|
257
|
+
if (key !== "domain" && key !== "from_recipients") {
|
|
258
|
+
throw espSettingError(`esp_matching.exclude[${index}]`, `has an unknown key "${key}".`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (typeof item.domain !== "string" || !normalizeEspSenderDomain(item.domain)) {
|
|
262
|
+
throw espSettingError(`esp_matching.exclude[${index}].domain`, "must be a non-empty sending domain.", item.domain);
|
|
263
|
+
}
|
|
264
|
+
if (normalizeEspSenderDomain(item.domain).length > 253) {
|
|
265
|
+
throw espSettingError(`esp_matching.exclude[${index}].domain`, "must be at most 253 characters.");
|
|
266
|
+
}
|
|
267
|
+
if (item.from_recipients !== undefined && item.from_recipients !== null) {
|
|
268
|
+
if (!Array.isArray(item.from_recipients) || !item.from_recipients.every(isEspRouteBucket)) {
|
|
269
|
+
throw espSettingError(`esp_matching.exclude[${index}].from_recipients`, `must be an array of: ${ESP_ROUTE_BUCKETS.join(", ")}.`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
190
273
|
}
|
|
191
274
|
}
|
|
192
275
|
/**
|
|
@@ -201,6 +284,199 @@ export function validateEspMatchingSetting(value) {
|
|
|
201
284
|
* as "microsoft" — see resolveEspFamily.
|
|
202
285
|
*/
|
|
203
286
|
export const RECIPIENT_ESPS = ["google", "microsoft"];
|
|
287
|
+
/**
|
|
288
|
+
* WEIGHTED SENDER ROUTING — the object form of settings.esp_matching.
|
|
289
|
+
*
|
|
290
|
+
* The three scalar modes above answer one question ("bias toward the recipient's
|
|
291
|
+
* own provider, yes or no"), and they bake in the assumption that same-provider
|
|
292
|
+
* is always better. That assumption fails whenever one provider's senders are in
|
|
293
|
+
* bad standing: a workspace whose Google-hosted domains are refused by Gmail
|
|
294
|
+
* wants its Google-hosted RECIPIENTS served from Microsoft senders, which no
|
|
295
|
+
* scalar mode can express. "off" does not express it either, because free
|
|
296
|
+
* rotation still hands Google recipients a Google sender in proportion to the
|
|
297
|
+
* pool.
|
|
298
|
+
*
|
|
299
|
+
* So the object form states the routing directly, as SEND SHARES per recipient
|
|
300
|
+
* provider:
|
|
301
|
+
*
|
|
302
|
+
* { mode: "prefer",
|
|
303
|
+
* routes: { google: { microsoft: 100 }, microsoft: { microsoft: 100 } },
|
|
304
|
+
* exclude: [{ domain: "burned.example", from_recipients: ["google"] }] }
|
|
305
|
+
*
|
|
306
|
+
* `routes` is keyed by the RECIPIENT's resolved provider; the inner map is the
|
|
307
|
+
* SENDER provider. Weights are relative shares, so { google: 3, microsoft: 1 }
|
|
308
|
+
* and { google: 75, microsoft: 25 } are the same policy and nothing has to sum
|
|
309
|
+
* to 100. An omitted weight is zero. A bucket whose entry is explicitly present
|
|
310
|
+
* but carries no positive weight means "no opinion, rotate freely for this
|
|
311
|
+
* recipient class"; a bucket the operator never mentioned keeps the historical
|
|
312
|
+
* same-provider default, so editing Google routing cannot silently change how
|
|
313
|
+
* Microsoft recipients are served.
|
|
314
|
+
*
|
|
315
|
+
* `exclude` is a hard never, not a preference: a sender domain listed here is
|
|
316
|
+
* removed from the candidate pool for the named recipient buckets (all buckets
|
|
317
|
+
* when `from_recipients` is absent) in EVERY mode, including "off", and it
|
|
318
|
+
* survives the prefer-fallback. A deliverability guard that a matching toggle
|
|
319
|
+
* could disarm would not be a guard.
|
|
320
|
+
*
|
|
321
|
+
* The scalar modes remain valid values and are read as policies, so there is one
|
|
322
|
+
* routing code path rather than two: "prefer" becomes { mode: "prefer" } with no
|
|
323
|
+
* routes, which resolves to the same-provider default and behaves exactly as it
|
|
324
|
+
* did before.
|
|
325
|
+
*/
|
|
326
|
+
export const ESP_ROUTE_BUCKETS = ["google", "microsoft", "unknown"];
|
|
327
|
+
/** Cap on settings.esp_matching.exclude — a routing policy, not a suppression list. */
|
|
328
|
+
export const MAX_ESP_SENDER_EXCLUSIONS = 50;
|
|
329
|
+
/** Upper bound on a single route weight; relative shares never need more. */
|
|
330
|
+
export const MAX_ESP_ROUTE_WEIGHT = 1_000_000;
|
|
331
|
+
export function isEspRouteBucket(value) {
|
|
332
|
+
return typeof value === "string" && ESP_ROUTE_BUCKETS.includes(value);
|
|
333
|
+
}
|
|
334
|
+
function isRecipientEspValue(value) {
|
|
335
|
+
return typeof value === "string" && RECIPIENT_ESPS.includes(value);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* A sending domain as the mailbox table stores it: lowercase, no leading "@".
|
|
339
|
+
* Mirrors lower(split_part(email_address, '@', 2)) in pickEmailMailbox so an
|
|
340
|
+
* exclusion the operator typed as "@Burned.Example " still matches.
|
|
341
|
+
*/
|
|
342
|
+
export function normalizeEspSenderDomain(value) {
|
|
343
|
+
return value.trim().toLowerCase().replace(/^@+/, "");
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* LENIENT read of a persisted settings.esp_matching (never throws — a read must
|
|
347
|
+
* not break a send). Absent, null, or malformed reads as the default policy, and
|
|
348
|
+
* a malformed FIELD bails the WHOLE object rather than half-applying a routing
|
|
349
|
+
* policy, which is the readMailboxRampConfig convention in tenant-db. The strict
|
|
350
|
+
* counterpart that guards the write path is validateEspMatchingSetting.
|
|
351
|
+
*/
|
|
352
|
+
export function espPolicyFromSetting(value) {
|
|
353
|
+
const fallback = { mode: DEFAULT_ESP_MATCHING_MODE, routes: {}, exclude: [] };
|
|
354
|
+
if (value === undefined || value === null)
|
|
355
|
+
return fallback;
|
|
356
|
+
if (typeof value === "string") {
|
|
357
|
+
return isEspMatchingMode(value) ? { mode: value, routes: {}, exclude: [] } : fallback;
|
|
358
|
+
}
|
|
359
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
360
|
+
return fallback;
|
|
361
|
+
const record = value;
|
|
362
|
+
const mode = record.mode === undefined || record.mode === null ? DEFAULT_ESP_MATCHING_MODE : record.mode;
|
|
363
|
+
if (!isEspMatchingMode(mode))
|
|
364
|
+
return fallback;
|
|
365
|
+
const routes = {};
|
|
366
|
+
if (record.routes !== undefined && record.routes !== null) {
|
|
367
|
+
if (typeof record.routes !== "object" || Array.isArray(record.routes))
|
|
368
|
+
return fallback;
|
|
369
|
+
for (const [bucket, raw] of Object.entries(record.routes)) {
|
|
370
|
+
if (!isEspRouteBucket(bucket))
|
|
371
|
+
return fallback;
|
|
372
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
373
|
+
return fallback;
|
|
374
|
+
const weights = {};
|
|
375
|
+
for (const [provider, weight] of Object.entries(raw)) {
|
|
376
|
+
if (!isRecipientEspValue(provider))
|
|
377
|
+
return fallback;
|
|
378
|
+
if (typeof weight !== "number" || !Number.isFinite(weight) || weight < 0)
|
|
379
|
+
return fallback;
|
|
380
|
+
weights[provider] = weight;
|
|
381
|
+
}
|
|
382
|
+
routes[bucket] = weights;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const exclude = [];
|
|
386
|
+
if (record.exclude !== undefined && record.exclude !== null) {
|
|
387
|
+
if (!Array.isArray(record.exclude))
|
|
388
|
+
return fallback;
|
|
389
|
+
for (const entry of record.exclude) {
|
|
390
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
391
|
+
return fallback;
|
|
392
|
+
const item = entry;
|
|
393
|
+
if (typeof item.domain !== "string")
|
|
394
|
+
return fallback;
|
|
395
|
+
const domain = normalizeEspSenderDomain(item.domain);
|
|
396
|
+
if (!domain)
|
|
397
|
+
return fallback;
|
|
398
|
+
let buckets = ESP_ROUTE_BUCKETS;
|
|
399
|
+
if (item.from_recipients !== undefined && item.from_recipients !== null) {
|
|
400
|
+
if (!Array.isArray(item.from_recipients))
|
|
401
|
+
return fallback;
|
|
402
|
+
if (item.from_recipients.length > 0) {
|
|
403
|
+
if (!item.from_recipients.every(isEspRouteBucket))
|
|
404
|
+
return fallback;
|
|
405
|
+
buckets = item.from_recipients;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
exclude.push({ domain, buckets });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { mode, routes, exclude };
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The effective send shares for one recipient bucket. An explicitly-present
|
|
415
|
+
* entry wins even when it is empty or all-zero (that is the operator saying
|
|
416
|
+
* "rotate freely here"); a bucket that was never mentioned keeps the historical
|
|
417
|
+
* same-provider default, and "unknown" has no same-provider to default to.
|
|
418
|
+
*/
|
|
419
|
+
export function espRouteWeightsFor(policy, bucket) {
|
|
420
|
+
const configured = policy.routes[bucket];
|
|
421
|
+
if (configured)
|
|
422
|
+
return configured;
|
|
423
|
+
return bucket === "unknown" ? {} : { [bucket]: 1 };
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The sender providers to try, best first, for one recipient bucket — a
|
|
427
|
+
* deterministic weighted shuffle WITHOUT replacement.
|
|
428
|
+
*
|
|
429
|
+
* Returning an ORDER rather than a single draw is what makes "prefer" correct:
|
|
430
|
+
* a 90/10 Google policy over a pool holding no Google mailbox must send 100%
|
|
431
|
+
* from Microsoft, not defer and not fall through to provider-blind rotation.
|
|
432
|
+
* An empty result means "no opinion" and the caller rotates freely.
|
|
433
|
+
*
|
|
434
|
+
* Deterministic by construction: the seed carries the enrollment and step, never
|
|
435
|
+
* a clock or Math.random, so a dry-run preview and the later live send choose
|
|
436
|
+
* the same provider, and a replan after a defer re-derives the same route.
|
|
437
|
+
* Candidates are enumerated in RECIPIENT_ESPS order rather than object-key
|
|
438
|
+
* order, because jsonb does not preserve key order and a policy that round-trips
|
|
439
|
+
* through the database must not reroute the fleet.
|
|
440
|
+
*/
|
|
441
|
+
export function espRouteCandidates(input) {
|
|
442
|
+
const weights = espRouteWeightsFor(input.policy, input.recipient);
|
|
443
|
+
let pool = RECIPIENT_ESPS.map((provider) => ({ provider, weight: weights[provider] ?? 0 })).filter((entry) => Number.isFinite(entry.weight) && entry.weight > 0);
|
|
444
|
+
const order = [];
|
|
445
|
+
let draw = 0;
|
|
446
|
+
while (pool.length > 0) {
|
|
447
|
+
const total = pool.reduce((sum, entry) => sum + entry.weight, 0);
|
|
448
|
+
// Re-hash per draw rather than reusing the residue: a second draw derived
|
|
449
|
+
// from the first would correlate the tail of the order with its head.
|
|
450
|
+
const seed = draw === 0 ? input.seed : `${input.seed}:${draw + 1}`;
|
|
451
|
+
const target = ((hashVariantKey(seed) % 1_000_000) / 1_000_000) * total;
|
|
452
|
+
let cumulative = 0;
|
|
453
|
+
let chosen = pool[pool.length - 1].provider;
|
|
454
|
+
for (const entry of pool) {
|
|
455
|
+
cumulative += entry.weight;
|
|
456
|
+
if (target < cumulative) {
|
|
457
|
+
chosen = entry.provider;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
order.push(chosen);
|
|
462
|
+
pool = pool.filter((entry) => entry.provider !== chosen);
|
|
463
|
+
draw += 1;
|
|
464
|
+
}
|
|
465
|
+
return order;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Sending domains that must not serve this recipient bucket, normalized and
|
|
469
|
+
* de-duplicated for pickEmailMailbox. Applies in every mode and survives the
|
|
470
|
+
* prefer-fallback — see the exclude contract above.
|
|
471
|
+
*/
|
|
472
|
+
export function espExcludedSenderDomains(policy, bucket) {
|
|
473
|
+
const domains = new Set();
|
|
474
|
+
for (const entry of policy.exclude) {
|
|
475
|
+
if (entry.buckets.includes(bucket))
|
|
476
|
+
domains.add(entry.domain);
|
|
477
|
+
}
|
|
478
|
+
return [...domains];
|
|
479
|
+
}
|
|
204
480
|
/**
|
|
205
481
|
* The DISPLAY taxonomy: the mail-infrastructure family a recipient domain sits
|
|
206
482
|
* on. Wider than RecipientEsp because "everything that isn't Google or Microsoft"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { linkedinPostIdFromUrl, looksLikeUrlIdentifier } from "./linkedin-url.js";
|
|
2
|
+
/** Provider aliases for the same public post must share one deduplication key. */
|
|
3
|
+
export function normalizeLinkedInAmplificationTargetId(value) {
|
|
4
|
+
const raw = value.trim();
|
|
5
|
+
if (!raw || raw.length > 2048)
|
|
6
|
+
return null;
|
|
7
|
+
const activity = /^(?:urn:li:(?:activity|share):|linkedin:activity:)?(\d+)$/.exec(raw)?.[1];
|
|
8
|
+
if (activity)
|
|
9
|
+
return `linkedin:activity:${activity}`;
|
|
10
|
+
if (looksLikeUrlIdentifier(raw)) {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (!/^(?:www\.)?linkedin\.com$/i.test(url.hostname) || url.username || url.password)
|
|
19
|
+
return null;
|
|
20
|
+
const id = linkedinPostIdFromUrl(raw);
|
|
21
|
+
return id ? `linkedin:activity:${id}` : null;
|
|
22
|
+
}
|
|
23
|
+
return raw.startsWith("linkedin:opaque:") ? raw : `linkedin:opaque:${raw}`;
|
|
24
|
+
}
|
|
@@ -7,6 +7,9 @@ export type UgcProgram = {
|
|
|
7
7
|
description: string;
|
|
8
8
|
knowledgePageSlugs: string[];
|
|
9
9
|
brandApprovalRequired: boolean;
|
|
10
|
+
peerEngagementEnabled?: boolean;
|
|
11
|
+
peerEngagementVersion?: number;
|
|
12
|
+
peerEngagementEffectiveAt?: Date | null;
|
|
10
13
|
status: "active" | "archived";
|
|
11
14
|
createdAt: Date;
|
|
12
15
|
updatedAt: Date;
|
|
@@ -22,6 +25,8 @@ export type UgcBrief = {
|
|
|
22
25
|
updatedAt: Date;
|
|
23
26
|
};
|
|
24
27
|
export type UgcPostProjection = {
|
|
28
|
+
/** Public author identity from the exact participation, never a private profile corpus. */
|
|
29
|
+
authorLinkedinUrl?: string;
|
|
25
30
|
title?: string | null;
|
|
26
31
|
contentText: string;
|
|
27
32
|
status: string;
|
|
@@ -86,6 +91,9 @@ export type UgcPostLink = {
|
|
|
86
91
|
creatorApprovedRevision: string | null;
|
|
87
92
|
brandApprovedRevision: string | null;
|
|
88
93
|
grantVersion: number;
|
|
94
|
+
removedAt?: Date | null;
|
|
95
|
+
enrollmentVersion?: number;
|
|
96
|
+
projectionVersion?: number;
|
|
89
97
|
createdAt: Date;
|
|
90
98
|
updatedAt: Date;
|
|
91
99
|
};
|
|
@@ -8,9 +8,16 @@ export function inferUserCapabilityRoute(query) {
|
|
|
8
8
|
const route = inferCapabilityRoute(query);
|
|
9
9
|
const providerConnectionIntent = isSimpleProviderConnectionIntent(query);
|
|
10
10
|
if (!providerConnectionIntent
|
|
11
|
-
|| (route
|
|
11
|
+
|| (route != null && route.card.id !== "tables" && route.card.id !== "workspace-access")) {
|
|
12
12
|
return route;
|
|
13
13
|
}
|
|
14
|
+
// `route == null` is corrected too, and that case used to be unreachable for the
|
|
15
|
+
// wrong reason: "reconnect my Salesforce account" only entered this branch
|
|
16
|
+
// because the scorer matched Tables' `connect` term INSIDE "re-connect". Once
|
|
17
|
+
// the scorer anchors terms to word boundaries the base router correctly has no
|
|
18
|
+
// opinion, and a wrapper that only corrected an existing Tables answer would
|
|
19
|
+
// have handed the user nothing at all. An unrouted simple provider-connection
|
|
20
|
+
// intent is exactly what this wrapper exists to answer.
|
|
14
21
|
return getCapabilityRouteMatch("connected-integrations") ?? route;
|
|
15
22
|
}
|
|
16
23
|
// The words that mean "this is Tables work, not a provider connection". Hand-typing
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION
|
|
1
|
+
export declare const OXYGEN_VERSION: string;
|
|
2
2
|
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
|
|
3
3
|
export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
|
|
4
4
|
export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
// Release metadata is a string API: a new version must not change the exported
|
|
2
|
+
// declaration type and invalidate every dependent package's compiler state.
|
|
3
|
+
export const OXYGEN_VERSION = "1.982.3";
|
|
2
4
|
// The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
|
|
3
5
|
// operational route. Raising it hard-rejects every older CLI from the entire
|
|
4
6
|
// product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export declare const WORKSPACE_VISUAL_SOURCE_MAX_BYTES = 1500000;
|
|
2
2
|
export declare const WORKSPACE_VISUAL_BINARY_MAX_BYTES: number;
|
|
3
|
+
export declare const WORKSPACE_OPAQUE_FILE_MAX_BYTES: number;
|
|
3
4
|
/** Platform abuse guard shared by every plan, not a storage entitlement. */
|
|
4
5
|
export declare const WORKSPACE_RETAINED_FILES_MAX_BYTES: number;
|
|
5
6
|
export type WorkspaceVisualBinaryMimeType = "image/png" | "application/pdf";
|
|
7
|
+
export type WorkspaceStoredFileMimeType = WorkspaceVisualBinaryMimeType | "application/octet-stream";
|
|
6
8
|
export declare function assertWorkspaceFileId(id: string): void;
|
|
7
9
|
export declare function normalizeWorkspaceVisualFileName(name: string): string;
|
|
8
10
|
export declare function buildWorkspaceFileObjectKey(input: {
|
|
@@ -19,13 +21,15 @@ export declare function assertWorkspaceFileObjectKey(input: {
|
|
|
19
21
|
/** Framing check only. Full media validation belongs in the isolated renderer validator. */
|
|
20
22
|
export declare function assertWorkspaceVisualBinary(bytes: Uint8Array, mimeType: WorkspaceVisualBinaryMimeType): void;
|
|
21
23
|
export declare function workspaceFileSha256(bytes: Uint8Array): string;
|
|
24
|
+
/** Opaque vault attachments are downloaded as bytes, never rendered as active content. */
|
|
25
|
+
export declare function assertWorkspaceStoredFile(bytes: Uint8Array, mimeType: WorkspaceStoredFileMimeType): void;
|
|
22
26
|
export declare function readWorkspaceFileObject(input: {
|
|
23
27
|
organizationId: string;
|
|
24
28
|
fileId: string;
|
|
25
29
|
storageKey: string;
|
|
26
30
|
sizeBytes: number;
|
|
27
31
|
sha256: string;
|
|
28
|
-
mimeType:
|
|
32
|
+
mimeType: WorkspaceStoredFileMimeType;
|
|
29
33
|
signal?: AbortSignal;
|
|
30
34
|
}): Promise<Uint8Array>;
|
|
31
35
|
/** Immutable PUT followed by bounded readback. A matching existing object is a safe retry. */
|
|
@@ -34,7 +38,7 @@ export declare function putWorkspaceFileObject(input: {
|
|
|
34
38
|
fileId: string;
|
|
35
39
|
fileName: string;
|
|
36
40
|
bytes: Uint8Array;
|
|
37
|
-
mimeType:
|
|
41
|
+
mimeType: WorkspaceStoredFileMimeType;
|
|
38
42
|
signal?: AbortSignal;
|
|
39
43
|
}): Promise<{
|
|
40
44
|
storageKey: string;
|
|
@@ -5,6 +5,7 @@ import { OxygenError } from "./cli-result.js";
|
|
|
5
5
|
import { resolveObjectStorageClient } from "./object-storage.js";
|
|
6
6
|
export const WORKSPACE_VISUAL_SOURCE_MAX_BYTES = 1_500_000;
|
|
7
7
|
export const WORKSPACE_VISUAL_BINARY_MAX_BYTES = 50 * 1024 * 1024;
|
|
8
|
+
export const WORKSPACE_OPAQUE_FILE_MAX_BYTES = 100 * 1024 * 1024;
|
|
8
9
|
/** Platform abuse guard shared by every plan, not a storage entitlement. */
|
|
9
10
|
export const WORKSPACE_RETAINED_FILES_MAX_BYTES = 1024 * 1024 * 1024;
|
|
10
11
|
const STORAGE_TIMEOUT_MS = 60_000;
|
|
@@ -57,16 +58,26 @@ export function assertWorkspaceVisualBinary(bytes, mimeType) {
|
|
|
57
58
|
export function workspaceFileSha256(bytes) {
|
|
58
59
|
return createHash("sha256").update(bytes).digest("hex");
|
|
59
60
|
}
|
|
61
|
+
/** Opaque vault attachments are downloaded as bytes, never rendered as active content. */
|
|
62
|
+
export function assertWorkspaceStoredFile(bytes, mimeType) {
|
|
63
|
+
if (mimeType === "application/octet-stream") {
|
|
64
|
+
if (bytes.byteLength > WORKSPACE_OPAQUE_FILE_MAX_BYTES)
|
|
65
|
+
throw new OxygenError("workspace_file_too_large", "File exceeds 100 MiB.");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
assertWorkspaceVisualBinary(bytes, mimeType);
|
|
69
|
+
}
|
|
60
70
|
export async function readWorkspaceFileObject(input) {
|
|
61
71
|
input.signal?.throwIfAborted();
|
|
62
72
|
assertWorkspaceFileObjectKey(input);
|
|
63
|
-
|
|
73
|
+
const maximumBytes = input.mimeType === "application/octet-stream" ? WORKSPACE_OPAQUE_FILE_MAX_BYTES : WORKSPACE_VISUAL_BINARY_MAX_BYTES;
|
|
74
|
+
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < (input.mimeType === "application/octet-stream" ? 0 : 1) || input.sizeBytes > maximumBytes || !/^[a-f0-9]{64}$/.test(input.sha256)) {
|
|
64
75
|
throw new OxygenError("workspace_file_integrity_failed", "File metadata failed integrity validation.");
|
|
65
76
|
}
|
|
66
77
|
const { client, bucket } = resolveObjectStorageClient();
|
|
67
78
|
const signal = input.signal ? AbortSignal.any([input.signal, AbortSignal.timeout(STORAGE_TIMEOUT_MS)]) : AbortSignal.timeout(STORAGE_TIMEOUT_MS);
|
|
68
79
|
const result = await client.send(new GetObjectCommand({
|
|
69
|
-
Bucket: bucket, Key: input.storageKey, Range: `bytes=0-${input.sizeBytes}
|
|
80
|
+
Bucket: bucket, Key: input.storageKey, ...(input.sizeBytes > 0 ? { Range: `bytes=0-${input.sizeBytes}` } : {}),
|
|
70
81
|
}), { abortSignal: signal });
|
|
71
82
|
const body = result.Body;
|
|
72
83
|
if (!body)
|
|
@@ -93,7 +104,7 @@ export async function readWorkspaceFileObject(input) {
|
|
|
93
104
|
if (size !== input.sizeBytes || workspaceFileSha256(bytes) !== input.sha256 || result.ContentType?.split(";", 1)[0]?.trim().toLowerCase() !== input.mimeType) {
|
|
94
105
|
throw new OxygenError("workspace_file_integrity_failed", "Stored file failed size, format or digest verification.");
|
|
95
106
|
}
|
|
96
|
-
|
|
107
|
+
assertWorkspaceStoredFile(bytes, input.mimeType);
|
|
97
108
|
return bytes;
|
|
98
109
|
}
|
|
99
110
|
finally {
|
|
@@ -104,7 +115,7 @@ export async function readWorkspaceFileObject(input) {
|
|
|
104
115
|
/** Immutable PUT followed by bounded readback. A matching existing object is a safe retry. */
|
|
105
116
|
export async function putWorkspaceFileObject(input) {
|
|
106
117
|
input.signal?.throwIfAborted();
|
|
107
|
-
|
|
118
|
+
assertWorkspaceStoredFile(input.bytes, input.mimeType);
|
|
108
119
|
const storageKey = buildWorkspaceFileObjectKey(input);
|
|
109
120
|
const bytes = Buffer.from(input.bytes);
|
|
110
121
|
const sha256 = workspaceFileSha256(bytes);
|