@oxygen-agent/cli 1.739.1 → 1.766.0

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.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +17 -2
  3. package/dist/help.js +8 -0
  4. package/dist/index.js +562 -125
  5. package/node_modules/@oxygen/shared/dist/billing.d.ts +88 -46
  6. package/node_modules/@oxygen/shared/dist/billing.js +134 -74
  7. package/node_modules/@oxygen/shared/dist/capability-discovery.js +12 -4
  8. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  9. package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +8 -0
  10. package/node_modules/@oxygen/shared/dist/copilot-journeys.js +23 -5
  11. package/node_modules/@oxygen/shared/dist/future-signup-events.d.ts +13 -2
  12. package/node_modules/@oxygen/shared/dist/future-signup-events.js +17 -2
  13. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +106 -1
  14. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +156 -45
  15. package/node_modules/@oxygen/shared/dist/index.d.ts +5 -1
  16. package/node_modules/@oxygen/shared/dist/index.js +5 -1
  17. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +33 -0
  18. package/node_modules/@oxygen/shared/dist/object-storage.js +69 -4
  19. package/node_modules/@oxygen/shared/dist/person-name.d.ts +40 -0
  20. package/node_modules/@oxygen/shared/dist/person-name.js +23 -0
  21. package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +82 -0
  22. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +130 -0
  23. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +2 -2
  24. package/node_modules/@oxygen/shared/dist/plan-limits.js +18 -2
  25. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +50 -56
  26. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +77 -90
  27. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +62 -0
  28. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +91 -0
  29. package/node_modules/@oxygen/shared/dist/provider-funding-errors.d.ts +44 -0
  30. package/node_modules/@oxygen/shared/dist/provider-funding-errors.js +81 -0
  31. package/node_modules/@oxygen/shared/dist/publishing-limits.d.ts +24 -0
  32. package/node_modules/@oxygen/shared/dist/publishing-limits.js +24 -0
  33. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +27 -6
  34. package/node_modules/@oxygen/shared/dist/spend-safety.js +34 -6
  35. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
  36. package/node_modules/@oxygen/shared/dist/version.js +14 -3
  37. package/node_modules/@oxygen/shared/package.json +10 -0
  38. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +29 -1
  39. package/node_modules/@oxygen/workflows/dist/graph/expression.js +307 -42
  40. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +1 -0
  41. package/node_modules/@oxygen/workflows/dist/graph/index.js +1 -0
  42. package/node_modules/@oxygen/workflows/dist/graph/lint.js +153 -19
  43. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +92 -0
  44. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +4 -0
  45. package/node_modules/@oxygen/workflows/dist/graph/mapping.d.ts +79 -0
  46. package/node_modules/@oxygen/workflows/dist/graph/mapping.js +436 -0
  47. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +36 -0
  48. package/node_modules/@oxygen/workflows/dist/graph/topology.js +147 -0
  49. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +31 -0
  50. package/node_modules/@oxygen/workflows/dist/graph/types.js +16 -0
  51. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- export { MANAGED_INBOX_MINIMUM_CLI_VERSION, OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION, } from "./version.js";
1
+ export { MANAGED_INBOX_MINIMUM_CLI_VERSION, OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION, SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION, } from "./version.js";
2
2
  export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
3
3
  export { WORKFLOW_STATUS_CHANGE_METADATA_KEY, type WorkflowStatusChange, type WorkflowStatusChangeActor, type WorkflowStatusChangeSource, describeWorkflowStatusChange, formatWorkflowStatusChangeTimestamp, parseWorkflowStatusChange, readWorkflowStatusChange, } from "./workflow-status-change.js";
4
4
  export * from "./billing.js";
@@ -6,8 +6,11 @@ export * from "./billing-anchors.js";
6
6
  export * from "./budget-scopes.js";
7
7
  export * from "./capability-discovery.js";
8
8
  export * from "./user-capability-routing.js";
9
+ export * from "./plan-capabilities.js";
9
10
  export * from "./plan-limits.js";
10
11
  export * from "./plain-support-events.js";
12
+ export * from "./provider-funding-errors.js";
13
+ export * from "./publishing-limits.js";
11
14
  export * from "./spend-safety.js";
12
15
  export * from "./cell-format.js";
13
16
  export * from "./cli-envelope.js";
@@ -42,6 +45,7 @@ export * from "./linkedin-sequences.js";
42
45
  export * from "./member-columns.js";
43
46
  export * from "./microsoft-consent-url.js";
44
47
  export * from "./networks.js";
48
+ export * from "./person-name.js";
45
49
  export * from "./recipes.js";
46
50
  export * from "./sequence-template.js";
47
51
  export * from "./sequence-crm-events.js";
@@ -1,4 +1,4 @@
1
- export { MANAGED_INBOX_MINIMUM_CLI_VERSION, OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION, } from "./version.js";
1
+ export { MANAGED_INBOX_MINIMUM_CLI_VERSION, OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_VERSION, SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION, } from "./version.js";
2
2
  export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
3
3
  export { WORKFLOW_STATUS_CHANGE_METADATA_KEY, describeWorkflowStatusChange, formatWorkflowStatusChangeTimestamp, parseWorkflowStatusChange, readWorkflowStatusChange, } from "./workflow-status-change.js";
4
4
  export * from "./billing.js";
@@ -6,8 +6,11 @@ export * from "./billing-anchors.js";
6
6
  export * from "./budget-scopes.js";
7
7
  export * from "./capability-discovery.js";
8
8
  export * from "./user-capability-routing.js";
9
+ export * from "./plan-capabilities.js";
9
10
  export * from "./plan-limits.js";
10
11
  export * from "./plain-support-events.js";
12
+ export * from "./provider-funding-errors.js";
13
+ export * from "./publishing-limits.js";
11
14
  export * from "./spend-safety.js";
12
15
  export * from "./cell-format.js";
13
16
  export * from "./cli-envelope.js";
@@ -42,6 +45,7 @@ export * from "./linkedin-sequences.js";
42
45
  export * from "./member-columns.js";
43
46
  export * from "./microsoft-consent-url.js";
44
47
  export * from "./networks.js";
48
+ export * from "./person-name.js";
45
49
  export * from "./recipes.js";
46
50
  export * from "./sequence-template.js";
47
51
  export * from "./sequence-crm-events.js";
@@ -125,6 +125,25 @@ export declare function presignInboxAvatarUpload(input: {
125
125
  contentType: string;
126
126
  contentLength: number;
127
127
  }): Promise<PresignedImportUpload>;
128
+ /**
129
+ * Store avatar bytes we already hold server-side.
130
+ *
131
+ * The presign/PUT/confirm dance exists so a BROWSER can upload without routing
132
+ * megabytes through a serverless function. When the server is the one holding the
133
+ * bytes — mirroring a LinkedIn photo so it survives past that URL's expiry — a
134
+ * presigned round trip back to ourselves buys nothing. `contentType` must come from
135
+ * a byte sniff, never from the remote's header, for the same reason the confirm hop
136
+ * re-sniffs: the public route serves this back from our own origin.
137
+ */
138
+ export declare function putInboxAvatarObject(input: {
139
+ organizationId: string;
140
+ body: Uint8Array;
141
+ contentType: string;
142
+ fileName: string;
143
+ }): Promise<{
144
+ storageKey: string;
145
+ contentLength: number;
146
+ }>;
128
147
  /**
129
148
  * Read an avatar back, bounded. `maxBytes` is a hard stop rather than a hint:
130
149
  * this is called from an unauthenticated route, so an object that somehow grew
@@ -144,8 +163,22 @@ export declare function getInboxAvatarObjectMetadata(input: {
144
163
  }): Promise<{
145
164
  contentLength: number | null;
146
165
  }>;
166
+ /**
167
+ * Delete a stored avatar.
168
+ *
169
+ * The prefix assertion is load-bearing, not defensive dressing. One bucket holds
170
+ * `imports/<org>/` (customer lead lists), `copilot/<org>/`, `publishing-media/<org>/`
171
+ * and `inbox-avatars/<org>/`, and this issues a bare DeleteObjectCommand — so any
172
+ * caller that ever passed a key it did not build itself would be one string away
173
+ * from deleting another tenant's uploaded CSV. Avatar keys reach durable state
174
+ * (`sender_profiles.avatar_storage_key`) and come back out again on replace and on
175
+ * profile delete, which is exactly the round trip where a key stops being obviously
176
+ * trustworthy. Pass `organizationId` wherever it is known: the prefix check alone
177
+ * only proves "some org's avatar".
178
+ */
147
179
  export declare function deleteInboxAvatarObject(input: {
148
180
  storageKey: string;
181
+ organizationId?: string;
149
182
  }): Promise<void>;
150
183
  export declare function deleteImportObject(input: {
151
184
  storageKey: string;
@@ -8,6 +8,26 @@ import { OxygenError } from "./cli-result.js";
8
8
  // it. Configured against Hetzner Object Storage (S3-compatible) via the
9
9
  // OXYGEN_IMPORT_S3_* env vars; works with any S3-compatible endpoint.
10
10
  const PRESIGN_EXPIRY_SECONDS = 900;
11
+ /**
12
+ * Sign `Content-Type` on every upload presign.
13
+ *
14
+ * `@smithy/signature-v4`'s `prepareRequest` does an unconditional
15
+ * `unsignableHeaders.add("content-type")`, so by default the header we declare
16
+ * on the PutObjectCommand is NOT part of the signature and the client may send
17
+ * anything. A customer hit exactly that: `curl --upload-file` defaults to
18
+ * `application/x-www-form-urlencoded`, S3 accepted the PUT, and the stored
19
+ * object's content type silently disagreed with its own bytes — surfacing much
20
+ * later as a scheduled post whose image would not render.
21
+ *
22
+ * `signableHeaders` takes precedence over the unsignable set (see
23
+ * `getCanonicalHeaders`), so this makes S3 reject the mismatched PUT at upload
24
+ * time instead. It only binds when we actually declared a ContentType; with no
25
+ * declared type there is no such header on the request and nothing is enforced.
26
+ */
27
+ const PRESIGN_UPLOAD_OPTIONS = {
28
+ expiresIn: PRESIGN_EXPIRY_SECONDS,
29
+ signableHeaders: new Set(["content-type"]),
30
+ };
11
31
  // The Fly worker publishes tenants serially, so a hung S3 GET on one tenant's
12
32
  // publishing media would stall every other tenant's publishing tick. Bound the
13
33
  // media download the same way the native-provider fetches are bounded
@@ -107,7 +127,7 @@ export async function presignImportUpload(input) {
107
127
  ContentLength: input.contentLength,
108
128
  ...(input.contentType ? { ContentType: input.contentType } : {}),
109
129
  });
110
- const uploadUrl = await getSignedUrl(client, command, { expiresIn: PRESIGN_EXPIRY_SECONDS });
130
+ const uploadUrl = await getSignedUrl(client, command, { ...PRESIGN_UPLOAD_OPTIONS });
111
131
  return {
112
132
  uploadUrl,
113
133
  bucket: config.bucket,
@@ -129,7 +149,7 @@ export async function presignPublishingMediaUpload(input) {
129
149
  ContentLength: input.contentLength,
130
150
  ...(input.contentType ? { ContentType: input.contentType } : {}),
131
151
  });
132
- const uploadUrl = await getSignedUrl(client, command, { expiresIn: PRESIGN_EXPIRY_SECONDS });
152
+ const uploadUrl = await getSignedUrl(client, command, { ...PRESIGN_UPLOAD_OPTIONS });
133
153
  return {
134
154
  uploadUrl,
135
155
  bucket: config.bucket,
@@ -151,7 +171,7 @@ export async function presignCopilotAttachmentUpload(input) {
151
171
  ContentLength: input.contentLength,
152
172
  ...(input.contentType ? { ContentType: input.contentType } : {}),
153
173
  });
154
- const uploadUrl = await getSignedUrl(client, command, { expiresIn: PRESIGN_EXPIRY_SECONDS });
174
+ const uploadUrl = await getSignedUrl(client, command, { ...PRESIGN_UPLOAD_OPTIONS });
155
175
  return {
156
176
  uploadUrl,
157
177
  bucket: config.bucket,
@@ -279,7 +299,7 @@ export async function presignInboxAvatarUpload(input) {
279
299
  ContentLength: input.contentLength,
280
300
  ContentType: input.contentType,
281
301
  });
282
- const uploadUrl = await getSignedUrl(client, command, { expiresIn: PRESIGN_EXPIRY_SECONDS });
302
+ const uploadUrl = await getSignedUrl(client, command, { ...PRESIGN_UPLOAD_OPTIONS });
283
303
  return {
284
304
  uploadUrl,
285
305
  bucket: config.bucket,
@@ -289,6 +309,31 @@ export async function presignInboxAvatarUpload(input) {
289
309
  expiresInSeconds: PRESIGN_EXPIRY_SECONDS,
290
310
  };
291
311
  }
312
+ /**
313
+ * Store avatar bytes we already hold server-side.
314
+ *
315
+ * The presign/PUT/confirm dance exists so a BROWSER can upload without routing
316
+ * megabytes through a serverless function. When the server is the one holding the
317
+ * bytes — mirroring a LinkedIn photo so it survives past that URL's expiry — a
318
+ * presigned round trip back to ourselves buys nothing. `contentType` must come from
319
+ * a byte sniff, never from the remote's header, for the same reason the confirm hop
320
+ * re-sniffs: the public route serves this back from our own origin.
321
+ */
322
+ export async function putInboxAvatarObject(input) {
323
+ const { client, config } = resolveClient();
324
+ const storageKey = buildInboxAvatarObjectKey({
325
+ organizationId: input.organizationId,
326
+ fileName: input.fileName,
327
+ });
328
+ await client.send(new PutObjectCommand({
329
+ Bucket: config.bucket,
330
+ Key: storageKey,
331
+ Body: input.body,
332
+ ContentLength: input.body.byteLength,
333
+ ContentType: input.contentType,
334
+ }));
335
+ return { storageKey, contentLength: input.body.byteLength };
336
+ }
292
337
  /**
293
338
  * Read an avatar back, bounded. `maxBytes` is a hard stop rather than a hint:
294
339
  * this is called from an unauthenticated route, so an object that somehow grew
@@ -318,7 +363,27 @@ export async function getInboxAvatarObjectMetadata(input) {
318
363
  contentLength: typeof result.ContentLength === "number" ? result.ContentLength : null,
319
364
  };
320
365
  }
366
+ /**
367
+ * Delete a stored avatar.
368
+ *
369
+ * The prefix assertion is load-bearing, not defensive dressing. One bucket holds
370
+ * `imports/<org>/` (customer lead lists), `copilot/<org>/`, `publishing-media/<org>/`
371
+ * and `inbox-avatars/<org>/`, and this issues a bare DeleteObjectCommand — so any
372
+ * caller that ever passed a key it did not build itself would be one string away
373
+ * from deleting another tenant's uploaded CSV. Avatar keys reach durable state
374
+ * (`sender_profiles.avatar_storage_key`) and come back out again on replace and on
375
+ * profile delete, which is exactly the round trip where a key stops being obviously
376
+ * trustworthy. Pass `organizationId` wherever it is known: the prefix check alone
377
+ * only proves "some org's avatar".
378
+ */
321
379
  export async function deleteInboxAvatarObject(input) {
380
+ if (!input.storageKey.startsWith("inbox-avatars/")) {
381
+ throw new OxygenError("invalid_object_key", "Refusing to delete an object outside the inbox-avatars prefix.", { details: { prefix: input.storageKey.split("/")[0] ?? "" }, exitCode: 1 });
382
+ }
383
+ if (input.organizationId
384
+ && !isInboxAvatarObjectKeyForOrganization(input.storageKey, input.organizationId)) {
385
+ throw new OxygenError("invalid_object_key", "Refusing to delete an avatar that belongs to another organization.", { exitCode: 1 });
386
+ }
322
387
  const { client, config } = resolveClient();
323
388
  await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: input.storageKey }));
324
389
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * One split rule for "a person's display name" -> given name + family name.
3
+ *
4
+ * This exists because the answer is written to a place that can never be
5
+ * corrected. The managed-inbox vendor takes `first_name` + `last_name` at ORDER
6
+ * time and exposes no post-provisioning update, so whatever split runs is stamped
7
+ * on a paid mailbox forever. Two surfaces derive it — the sender-profile editor
8
+ * (prefilling the two inputs from an older single-`name` profile) and the order
9
+ * resolver (falling back when a profile predates those columns) — and if they
10
+ * disagree, opening and saving an untouched sender silently rewrites the identity
11
+ * the vendor will be given.
12
+ *
13
+ * The rule: split on the FIRST whitespace run. The first token is the given name;
14
+ * the entire remainder is the family name.
15
+ *
16
+ * "Talia Rosen" -> { first: "Talia", last: "Rosen" }
17
+ * "Anna van der Berg" -> { first: "Anna", last: "van der Berg" }
18
+ * "Support" -> { first: "Support", last: "" }
19
+ * "" -> { first: "", last: "" }
20
+ *
21
+ * A last-token split ("Anna van der" / "Berg") is wrong for every particle-carrying
22
+ * European surname, which is a large slice of the ICP. A single-token name yields
23
+ * an EMPTY last name on purpose — callers must refuse rather than invent one. The
24
+ * literal "Team" that the expansion dialog used to synthesize is exactly the
25
+ * failure mode this returns "" to prevent.
26
+ *
27
+ * Mirrored by BACKFILL 1 in tenant migration
28
+ * 0202_sender_profile_order_identity.sql; keep the two in step.
29
+ */
30
+ export type PersonNameParts = {
31
+ first: string;
32
+ last: string;
33
+ };
34
+ export declare function splitPersonName(name: string | null | undefined): PersonNameParts;
35
+ /**
36
+ * The inverse: the display name a first/last pair composes to. Used when the
37
+ * editor writes both halves so `name` stays consistent with them, and when a
38
+ * caller supplies only the halves.
39
+ */
40
+ export declare function joinPersonName(first: string | null | undefined, last: string | null | undefined): string;
@@ -0,0 +1,23 @@
1
+ export function splitPersonName(name) {
2
+ const normalized = (name ?? "").replace(/\s+/g, " ").trim();
3
+ if (!normalized)
4
+ return { first: "", last: "" };
5
+ const separator = normalized.indexOf(" ");
6
+ if (separator < 0)
7
+ return { first: normalized, last: "" };
8
+ return {
9
+ first: normalized.slice(0, separator),
10
+ last: normalized.slice(separator + 1).trim(),
11
+ };
12
+ }
13
+ /**
14
+ * The inverse: the display name a first/last pair composes to. Used when the
15
+ * editor writes both halves so `name` stays consistent with them, and when a
16
+ * caller supplies only the halves.
17
+ */
18
+ export function joinPersonName(first, last) {
19
+ return [first, last]
20
+ .map((part) => (part ?? "").replace(/\s+/g, " ").trim())
21
+ .filter(Boolean)
22
+ .join(" ");
23
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The free-versus-plan boundary, expressed exactly once (OXP-10.3.1).
3
+ *
4
+ * Every wall in the product that means "this needs a paid plan" resolves
5
+ * through this table. Nothing else may encode the boundary: a second list would
6
+ * drift, and this one is the customer-visible product surface of the whole
7
+ * free-tier goal — it is the moment a free user decides whether to pay.
8
+ *
9
+ * WHAT BELONGS HERE. Only capabilities the founder walled off on 2026-08-16:
10
+ * acting on the outside world through OXYGEN's own identity (connecting a
11
+ * sender, dispatching a sequence, connecting and delivering Publishing, buying
12
+ * managed email infrastructure) plus BYOK. Nothing else. In particular:
13
+ *
14
+ * - Per-call COST never belongs here. Enrichment, AI columns, waterfalls,
15
+ * provider signals, Ads intel, Knowledge synthesis, inference, and managed
16
+ * phone enrichment at 100 credits on hit are ALL available on free and simply
17
+ * draw credits (founder, 2026-08-17). Credits are the meter and top-ups are
18
+ * always purchasable; "expensive" is not a reason to add a row here.
19
+ * - Zero-marginal-cost capabilities never belong here. CRM, Tables, Workflows,
20
+ * Knowledge, Recipes, Sequence authoring/preview/enrollment, Agents, Copilot,
21
+ * Collaboration, Dashboards, Tags, Observability, seats, and API keys are
22
+ * unmetered and permanent on free.
23
+ *
24
+ * The exhaustive per-capability verdicts, with the enforcing module for each,
25
+ * live in docs/free-tier-capability-matrix.md.
26
+ *
27
+ * WHY GATES ARE CHECKED BEFORE BALANCE. A connected sender is also a FIXED
28
+ * monthly credit commitment (1,000 per mailbox, 10,000 per LinkedIn account,
29
+ * 10,000 per WhatsApp number), and top-ups stay purchasable on free — so a free
30
+ * workspace could buy 10,000 credits for $12.50 and fund a LinkedIn seat that
31
+ * Starter prices at $99. Enforcing by balance would therefore price sending at
32
+ * an eighth of Starter. The capability gate must run BEFORE any balance or
33
+ * commitment check so the customer is told to upgrade, not to top up.
34
+ */
35
+ import type { PlanTier } from "./billing.js";
36
+ export declare const PLAN_GATED_CAPABILITIES: readonly ["sender_connect", "sequence_dispatch", "publishing_connect", "publishing_deliver", "managed_email_infrastructure", "byok_provider_keys"];
37
+ export type PlanGatedCapability = (typeof PLAN_GATED_CAPABILITIES)[number];
38
+ /**
39
+ * Every tier except `free` unblocks every gated capability.
40
+ *
41
+ * Deliberately not a per-capability tier list. The founder drew ONE line —
42
+ * free versus paid — and inventing per-capability tiers here would quietly
43
+ * create a second pricing axis nobody ratified. Provider tools that genuinely
44
+ * need a higher tier keep expressing that through their own
45
+ * `required_plan_tiers` in tool-access, which the resolver consults separately.
46
+ */
47
+ export declare const PLAN_GATE_QUALIFYING_TIERS: readonly PlanTier[];
48
+ export type PlanGatedCapabilityCopy = {
49
+ /** Customer language, not the identifier. Reads inside "… needs a paid plan". */
50
+ label: string;
51
+ /** What the free tier CAN still do here, so the refusal is not a dead end. */
52
+ freeAlternative: string;
53
+ /** Where the customer goes to inspect or change the affected resources. */
54
+ deepLinkPath: string;
55
+ };
56
+ export declare const PLAN_GATED_CAPABILITY_COPY: Record<PlanGatedCapability, PlanGatedCapabilityCopy>;
57
+ export declare function isPlanGatedCapability(value: unknown): value is PlanGatedCapability;
58
+ /**
59
+ * The whole boundary, in one expression.
60
+ *
61
+ * Note this asks about the TIER, not about entitlement. A churned workspace and
62
+ * a brand-new one both resolve to `free` and both get the same answer here —
63
+ * one entitlement, one capability set, differing only in grants and upgrade
64
+ * copy (founder, 2026-08-17).
65
+ */
66
+ export declare function planTierAllowsCapability(tier: PlanTier, _capability: PlanGatedCapability): boolean;
67
+ export declare const FREE_TIER_ENTITLEMENT_ENABLED_ENV_VAR = "OXYGEN_FREE_TIER_ENTITLEMENT_ENABLED";
68
+ /**
69
+ * Fail-closed rollout switch for capability-scoped entitlement.
70
+ *
71
+ * UNSET MEANS OFF, and off means today's behaviour exactly: `free` is
72
+ * unentitled, the binary subscription gate refuses everything except read,
73
+ * export, and recovery, and these capability gates are moot because nothing
74
+ * reaches them.
75
+ *
76
+ * ONE flag governs both halves on purpose. Arming free-tier entitlement without
77
+ * the capability gates would hand every churned and never-subscribed workspace
78
+ * unrestricted sending, publishing, managed-infrastructure purchase, and BYOK.
79
+ * They must flip together or not at all, so they share a switch rather than
80
+ * being independently armable.
81
+ */
82
+ export declare function freeTierEntitlementEnabled(env?: Record<string, string | undefined>): boolean;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The free-versus-plan boundary, expressed exactly once (OXP-10.3.1).
3
+ *
4
+ * Every wall in the product that means "this needs a paid plan" resolves
5
+ * through this table. Nothing else may encode the boundary: a second list would
6
+ * drift, and this one is the customer-visible product surface of the whole
7
+ * free-tier goal — it is the moment a free user decides whether to pay.
8
+ *
9
+ * WHAT BELONGS HERE. Only capabilities the founder walled off on 2026-08-16:
10
+ * acting on the outside world through OXYGEN's own identity (connecting a
11
+ * sender, dispatching a sequence, connecting and delivering Publishing, buying
12
+ * managed email infrastructure) plus BYOK. Nothing else. In particular:
13
+ *
14
+ * - Per-call COST never belongs here. Enrichment, AI columns, waterfalls,
15
+ * provider signals, Ads intel, Knowledge synthesis, inference, and managed
16
+ * phone enrichment at 100 credits on hit are ALL available on free and simply
17
+ * draw credits (founder, 2026-08-17). Credits are the meter and top-ups are
18
+ * always purchasable; "expensive" is not a reason to add a row here.
19
+ * - Zero-marginal-cost capabilities never belong here. CRM, Tables, Workflows,
20
+ * Knowledge, Recipes, Sequence authoring/preview/enrollment, Agents, Copilot,
21
+ * Collaboration, Dashboards, Tags, Observability, seats, and API keys are
22
+ * unmetered and permanent on free.
23
+ *
24
+ * The exhaustive per-capability verdicts, with the enforcing module for each,
25
+ * live in docs/free-tier-capability-matrix.md.
26
+ *
27
+ * WHY GATES ARE CHECKED BEFORE BALANCE. A connected sender is also a FIXED
28
+ * monthly credit commitment (1,000 per mailbox, 10,000 per LinkedIn account,
29
+ * 10,000 per WhatsApp number), and top-ups stay purchasable on free — so a free
30
+ * workspace could buy 10,000 credits for $12.50 and fund a LinkedIn seat that
31
+ * Starter prices at $99. Enforcing by balance would therefore price sending at
32
+ * an eighth of Starter. The capability gate must run BEFORE any balance or
33
+ * commitment check so the customer is told to upgrade, not to top up.
34
+ */
35
+ export const PLAN_GATED_CAPABILITIES = [
36
+ "sender_connect",
37
+ "sequence_dispatch",
38
+ "publishing_connect",
39
+ "publishing_deliver",
40
+ "managed_email_infrastructure",
41
+ "byok_provider_keys",
42
+ ];
43
+ /**
44
+ * Every tier except `free` unblocks every gated capability.
45
+ *
46
+ * Deliberately not a per-capability tier list. The founder drew ONE line —
47
+ * free versus paid — and inventing per-capability tiers here would quietly
48
+ * create a second pricing axis nobody ratified. Provider tools that genuinely
49
+ * need a higher tier keep expressing that through their own
50
+ * `required_plan_tiers` in tool-access, which the resolver consults separately.
51
+ */
52
+ export const PLAN_GATE_QUALIFYING_TIERS = [
53
+ "starter",
54
+ "pro",
55
+ "team",
56
+ "scale",
57
+ "enterprise",
58
+ ];
59
+ export const PLAN_GATED_CAPABILITY_COPY = {
60
+ sender_connect: {
61
+ label: "Connecting a sending account",
62
+ freeAlternative: "You can build and preview the whole motion on Free — sequences, steps, and enrollment all work. Connecting a mailbox, LinkedIn account, or WhatsApp number is what needs a plan.",
63
+ deepLinkPath: "/settings/connections",
64
+ },
65
+ sequence_dispatch: {
66
+ label: "Sending a sequence",
67
+ freeAlternative: "Authoring, versioning, enrolling, and previewing a sequence are all free. Dispatching to real recipients is what needs a plan.",
68
+ deepLinkPath: "/sequencer/sequences",
69
+ },
70
+ publishing_connect: {
71
+ label: "Connecting a publishing account",
72
+ freeAlternative: "Drafting posts and reviewing analytics stay free. Connecting the account you publish from is what needs a plan.",
73
+ deepLinkPath: "/settings/connections",
74
+ },
75
+ publishing_deliver: {
76
+ label: "Publishing a post",
77
+ freeAlternative: "You can draft, review, and approve posts on Free. Delivering them to a live account is what needs a plan.",
78
+ deepLinkPath: "/publishing",
79
+ },
80
+ managed_email_infrastructure: {
81
+ label: "Buying managed email infrastructure",
82
+ freeAlternative: "Managed domains, inboxes, warmup, deliverability monitoring, and dedicated egress are purchases OXYGEN makes on your behalf, so they need a plan.",
83
+ deepLinkPath: "/settings/email-infrastructure",
84
+ },
85
+ byok_provider_keys: {
86
+ label: "Using your own provider keys",
87
+ freeAlternative: "Every provider in the catalog already works on Free using OXYGEN's managed keys and your credits — including enrichment, AI columns, and phone lookups. Bringing your own key is what needs a plan.",
88
+ deepLinkPath: "/settings/integrations",
89
+ },
90
+ };
91
+ export function isPlanGatedCapability(value) {
92
+ return (typeof value === "string"
93
+ && PLAN_GATED_CAPABILITIES.includes(value));
94
+ }
95
+ /**
96
+ * The whole boundary, in one expression.
97
+ *
98
+ * Note this asks about the TIER, not about entitlement. A churned workspace and
99
+ * a brand-new one both resolve to `free` and both get the same answer here —
100
+ * one entitlement, one capability set, differing only in grants and upgrade
101
+ * copy (founder, 2026-08-17).
102
+ */
103
+ export function planTierAllowsCapability(tier, _capability) {
104
+ return tier !== "free";
105
+ }
106
+ export const FREE_TIER_ENTITLEMENT_ENABLED_ENV_VAR = "OXYGEN_FREE_TIER_ENTITLEMENT_ENABLED";
107
+ /**
108
+ * Fail-closed rollout switch for capability-scoped entitlement.
109
+ *
110
+ * UNSET MEANS OFF, and off means today's behaviour exactly: `free` is
111
+ * unentitled, the binary subscription gate refuses everything except read,
112
+ * export, and recovery, and these capability gates are moot because nothing
113
+ * reaches them.
114
+ *
115
+ * ONE flag governs both halves on purpose. Arming free-tier entitlement without
116
+ * the capability gates would hand every churned and never-subscribed workspace
117
+ * unrestricted sending, publishing, managed-infrastructure purchase, and BYOK.
118
+ * They must flip together or not at all, so they share a switch rather than
119
+ * being independently armable.
120
+ */
121
+ export function freeTierEntitlementEnabled(env = process.env) {
122
+ const raw = env[FREE_TIER_ENTITLEMENT_ENABLED_ENV_VAR];
123
+ if (!raw)
124
+ return false;
125
+ const normalized = raw.trim().toLowerCase();
126
+ return (normalized === "1"
127
+ || normalized === "true"
128
+ || normalized === "yes"
129
+ || normalized === "on");
130
+ }
@@ -170,8 +170,8 @@ export declare const PLAN_LIMITS: {
170
170
  readonly maxDirectCsvBodyBytes: 10000000;
171
171
  };
172
172
  readonly agents: {
173
- readonly maxTotalCredits: 10000;
174
- readonly maxInferenceCredits: 5000;
173
+ readonly maxTotalCredits: 2500;
174
+ readonly maxInferenceCredits: 1500;
175
175
  readonly maxUpstreamCostUsd: 10;
176
176
  readonly maxUpstreamCostUsdByok: 10;
177
177
  };
@@ -48,12 +48,28 @@ export const PLAN_LIMITS = {
48
48
  maxFileBytes: 100 * MIB,
49
49
  maxDirectCsvBodyBytes: 10_000_000,
50
50
  },
51
+ // Sized against the free tier's actual funding (OXP-10.2.4, 2026-08-17):
52
+ // 5,000 credits at signup plus 5,000 on CLI/MCP activation, one time each,
53
+ // with no monthly allowance to refill them. The previous ceilings were
54
+ // written when `free` meant a churned org that could spend nothing, so they
55
+ // were harmless at any value; now a single Agent run capped at 10,000 could
56
+ // consume the ENTIRE lifetime grant, and one capped at 5,000 could consume
57
+ // the whole signup grant before the customer saw anything work.
58
+ //
59
+ // These are runaway guards, not the billing meter — a workspace that tops up
60
+ // is bounded by its balance, not by these. The intent is only that no single
61
+ // run can silently eat a first impression.
51
62
  agents: {
52
- maxTotalCredits: 10_000,
53
- maxInferenceCredits: 5_000,
63
+ maxTotalCredits: 2_500,
64
+ maxInferenceCredits: 1_500,
54
65
  maxUpstreamCostUsd: 10,
55
66
  maxUpstreamCostUsdByok: 10,
56
67
  },
68
+ // Left at 5,000/1,500 deliberately. The signup grant is exactly one default
69
+ // Copilot session, and that is the point: a founder who picks the in-product
70
+ // path over the CLI must be able to complete one real session on the base
71
+ // grant alone. Lowering this would re-create the dead-on-arrival Copilot the
72
+ // split grant exists to prevent.
57
73
  copilot: { maxSessionBudgetCredits: 5_000, defaultPerTurnCreditCeiling: 1_500 },
58
74
  signals: { maxEvents: 100, maxWindowDays: 30 },
59
75
  tableActions: { maxActionsPerRun: 10 },