@oxygen-agent/cli 1.750.4 → 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.
- package/README.md +1 -1
- package/dist/command-manifest.js +11 -1
- package/dist/help.js +8 -0
- package/dist/index.js +481 -94
- package/node_modules/@oxygen/shared/dist/billing.d.ts +88 -46
- package/node_modules/@oxygen/shared/dist/billing.js +134 -74
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +12 -4
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/copilot-journeys.js +23 -5
- package/node_modules/@oxygen/shared/dist/future-signup-events.d.ts +13 -2
- package/node_modules/@oxygen/shared/dist/future-signup-events.js +17 -2
- package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +106 -1
- package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +156 -45
- 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/object-storage.d.ts +33 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +69 -4
- package/node_modules/@oxygen/shared/dist/person-name.d.ts +40 -0
- package/node_modules/@oxygen/shared/dist/person-name.js +23 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +82 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.js +130 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/plan-limits.js +18 -2
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +50 -56
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +77 -90
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +62 -0
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +91 -0
- package/node_modules/@oxygen/shared/dist/provider-funding-errors.d.ts +44 -0
- package/node_modules/@oxygen/shared/dist/provider-funding-errors.js +81 -0
- package/node_modules/@oxygen/shared/dist/publishing-limits.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/publishing-limits.js +24 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +27 -6
- package/node_modules/@oxygen/shared/dist/spend-safety.js +34 -6
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +6 -3
- package/node_modules/@oxygen/shared/package.json +10 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +27 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.js +95 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.js +2 -0
- package/package.json +1 -1
|
@@ -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";
|
|
@@ -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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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:
|
|
174
|
-
readonly maxInferenceCredits:
|
|
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:
|
|
53
|
-
maxInferenceCredits:
|
|
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 },
|