Repository navigation
Expand file tree
/
Copy pathlicense.ts
More file actions
505 lines (453 loc) · 18.6 KB
/
Copy pathlicense.ts
File metadata and controls
505 lines (453 loc) · 18.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
/**
* Plan & feature flag system — single source of truth.
*
* All per-plan values (pricing, feature flags, numeric limits, overage
* prices) are derived from the `.contentrain/` content layer at build
* time. Concretely:
*
* `.contentrain/content/system/plans/en.json` → PLAN_PRICING
* `.contentrain/content/system/plan-features/data.json` → FEATURE_MATRIX
* + PLAN_LIMITS
* + OVERAGE_PRICING
*
* This module re-exports the same public API as before; call sites
* (`hasFeature`, `getPlanLimit`, `getPlanParams`, …) do not change.
* Editing a plan price or a feature row in Contentrain (via MCP) is
* enough — no code edit needed. The Polar sync script reads the same
* content to keep Polar products / prices aligned.
*
* Plans:
* - free — structural signup shell; deliberately ABSENT from
* every feature row and zero across every limit
* except `team.members` (one owner seat keeps the
* workspace row valid). Developer signups convert
* via the 14-day trial on a paid plan.
* - starter — $9/mo, 3 seats, solo developer sizing
* - pro — $49/mo, 25 seats, team sizing (3× of the old $29 Pro)
* - enterprise — custom pricing, unlimited where it makes sense,
* plus SSO / white-label / custom CDN domain
*
* Legacy plan names (`business`, `team`) map to `pro` via `normalizePlan`.
*/
import plansData from '../../.contentrain/content/system/plans/en.json'
import planFeaturesData from '../../.contentrain/content/system/plan-features/data.json'
/**
* All runtime plan tiers. `community` is the AGPL-only self-host tier
* and is assigned automatically when the enterprise bridge is absent;
* it is not purchasable on the managed service.
*/
export type StudioPlan = 'community' | 'free' | 'starter' | 'pro' | 'enterprise'
/**
* Edition gates whether ee/-dependent features are usable at runtime.
* 'ee' — enterprise bridge loaded; respect plan-tier gating.
* 'agpl' — bridge absent; force-disable every `requires_ee` feature.
*
* Pass `{ edition }` to `hasFeature` / `getPlanLimit` so matrix rows
* that claim `requires_ee: true` are filtered out in Community Edition.
* The helpers default to `'ee'` to preserve existing call sites in the
* managed service; server code that has access to `resolveDeployment()`
* should pass the real edition explicitly.
*/
export type Edition = 'ee' | 'agpl'
const PLAN_SLUGS: readonly StudioPlan[] = ['community', 'free', 'starter', 'pro', 'enterprise']
// ─── Content shape (narrow types — JSON literal widening) ───
interface PlanContent {
name: string
price_monthly: number
seats_included: number
ai_model_tier: string
badge_text: string
cta_text: string
description: string
has_trial?: boolean
is_highlighted?: boolean
slug: string
sort_order: number
}
interface PlanFeatureContent {
key: string
name: string
type: 'feature' | 'limit'
category: string
community_value: string
free_value: string
starter_value: string
pro_value: string
enterprise_value: string
requires_ee: string
roadmap?: string
overage_price?: number
overage_unit?: string
overage_settings_key?: string
trial_cap_plan?: string
trial_cap_origins?: string
sort_order: number
}
const plans = plansData as unknown as Record<StudioPlan, PlanContent>
const planFeatures = planFeaturesData as unknown as Record<string, PlanFeatureContent>
// ─── Derivation helpers ───
function parseBoolValue(v: string): boolean {
return v === 'true'
}
function parseLimitValue(v: string): number {
if (v === 'unlimited') return Infinity
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
function valueForPlan(row: PlanFeatureContent, plan: StudioPlan): string {
switch (plan) {
case 'community': return row.community_value
case 'free': return row.free_value
case 'starter': return row.starter_value
case 'pro': return row.pro_value
case 'enterprise': return row.enterprise_value
}
}
// ─── Derived matrices ───
/**
* Plan pricing: name + monthly price + included seats per plan.
* Derived from `plans/en.json`.
*
* Enterprise carries `priceMonthly: 0` / `seatsIncluded: 0` as sentinels
* for "contact sales" — callers must not display these as real numbers.
* Community carries the same zeroed pricing; it is not purchasable and
* is only ever assigned automatically in Community Edition.
*/
export const PLAN_PRICING: Record<StudioPlan, { priceMonthly: number, seatsIncluded: number, name: string }>
= Object.fromEntries(
PLAN_SLUGS.map(plan => [plan, {
priceMonthly: plans[plan].price_monthly,
seatsIncluded: plans[plan].seats_included,
name: plans[plan].name,
}]),
) as Record<StudioPlan, { priceMonthly: number, seatsIncluded: number, name: string }>
/**
* Feature matrix: which plans grant each feature flag, plus the
* `requires_ee` and `roadmap` flags per row. Derived from
* plan-features rows with `type: 'feature'`.
*
* The matrix layer does NOT filter `requires_ee` — that decision is
* made by `hasFeature` when the caller supplies an edition.
*/
export interface FeatureMatrixEntry {
plans: StudioPlan[]
requires_ee: boolean
roadmap: boolean
/** Display name from the catalog (`plan-features` `name`). */
name: string
}
export const FEATURE_MATRIX: Record<string, FeatureMatrixEntry> = (() => {
const matrix: Record<string, FeatureMatrixEntry> = {}
for (const row of Object.values(planFeatures)) {
if (row.type !== 'feature') continue
matrix[row.key] = {
plans: PLAN_SLUGS.filter(plan => parseBoolValue(valueForPlan(row, plan))),
requires_ee: parseBoolValue(row.requires_ee),
roadmap: parseBoolValue(row.roadmap ?? 'false'),
name: row.name,
}
}
return matrix
})()
/**
* Numeric plan limits per plan. Derived from plan-features rows with
* `type: 'limit'`. `"unlimited"` in content becomes `Infinity`.
*
* `requires_ee` for a limit means: in Community Edition, callers
* should treat the limit as 0 regardless of the community_value
* (because the underlying feature cannot function without the bridge).
* `getPlanLimit` applies this gate when an edition is supplied.
*/
export interface LimitMatrixEntry {
values: Record<StudioPlan, number>
requires_ee: boolean
/** See `applyTrialCap`. */
trialCap?: { plan: StudioPlan, origins: 'migrate' | 'all' }
}
function parseTrialCap(row: PlanFeatureContent): LimitMatrixEntry['trialCap'] {
const plan = row.trial_cap_plan as StudioPlan | undefined
if (!plan || !PLAN_SLUGS.includes(plan)) return undefined
return { plan, origins: row.trial_cap_origins === 'all' ? 'all' : 'migrate' }
}
export const PLAN_LIMITS: Record<string, LimitMatrixEntry> = (() => {
const limits: Record<string, LimitMatrixEntry> = {}
for (const row of Object.values(planFeatures)) {
if (row.type !== 'limit') continue
limits[row.key] = {
values: {
community: parseLimitValue(row.community_value),
free: parseLimitValue(row.free_value),
starter: parseLimitValue(row.starter_value),
pro: parseLimitValue(row.pro_value),
enterprise: parseLimitValue(row.enterprise_value),
},
requires_ee: parseBoolValue(row.requires_ee),
trialCap: parseTrialCap(row),
}
}
return limits
})()
/**
* Where a trial came from. `migrate` = started from a Migrate order
* (the webhook records it as `plugin_metadata.trial_origin`).
*/
export type TrialOrigin = 'migrate' | 'standard'
export interface TrialContext {
/** The workspace is in an active trial (`trial_active`). */
trialing: boolean
origin: TrialOrigin
}
/**
* The allowance a trialing workspace actually gets for a limit.
*
* Metered usage during a trial is never billed, so a limit whose catalog
* row sets `trial_cap_plan` is held at that plan's value for the length
* of the trial — every Pro feature stays on, only the included credits
* wait for the first payment. `trial_cap_origins` picks which trials:
* `migrate` (today) or `all` (one content change). Outside a trial, or
* for an origin the row does not cover, the limit is returned as is.
* The cap never raises a limit.
*/
export function applyTrialCap(limit: number, limitKey: string, trial: TrialContext | null | undefined): number {
const plan = trialCapPlan(limitKey, trial)
return plan ? Math.min(limit, PLAN_LIMITS[limitKey]!.values[plan]) : limit
}
/**
* The plan a trial is capped to for this limit, or null when no cap
* applies. Per-message ceilings follow it too (a capped Pro trial settles
* at Starter's per-message ceiling, not Pro's).
*/
export function trialCapPlan(limitKey: string, trial: TrialContext | null | undefined): StudioPlan | null {
const cap = PLAN_LIMITS[limitKey]?.trialCap
if (!cap || !trial?.trialing) return null
if (cap.origins === 'migrate' && trial.origin !== 'migrate') return null
return cap.plan
}
/**
* Overage pricing for limits that allow paid overflow. Derived from
* plan-features limit rows that carry `overage_price` +
* `overage_settings_key`.
*
* `settingsKey` matches the JSONB key inside `workspaces.overage_settings`
* — changing it in content has to be coordinated with an app migration.
*/
export const OVERAGE_PRICING: Record<string, { price: number, unit: string, settingsKey: string }> = (() => {
const pricing: Record<string, { price: number, unit: string, settingsKey: string }> = {}
for (const row of Object.values(planFeatures)) {
if (row.type !== 'limit') continue
if (typeof row.overage_price !== 'number') continue
if (!row.overage_settings_key) continue
pricing[row.key] = {
price: row.overage_price,
unit: row.overage_unit ?? 'unit',
settingsKey: row.overage_settings_key,
}
}
return pricing
})()
/** Valid overage settings keys — matches OVERAGE_PRICING values. */
export const OVERAGE_SETTINGS_KEYS = Object.values(OVERAGE_PRICING).map(p => p.settingsKey)
/** `mailto:` address the enterprise CTA button links to. */
export const ENTERPRISE_CONTACT_EMAIL = 'info@contentrain.io'
// ─── Normalisation + lookups (unchanged public API) ───
/**
* Normalize legacy plan names to current plan types.
* Backward compatibility: business→pro, team→pro.
* Default (no plan) → 'free'.
*/
export function normalizePlan(plan: StudioPlan | string | null | undefined): StudioPlan {
if (!plan) return 'free'
const legacy: Record<string, StudioPlan> = {
business: 'pro',
team: 'pro',
}
const normalized = legacy[plan] ?? plan
if ((PLAN_SLUGS as readonly string[]).includes(normalized)) {
return normalized as StudioPlan
}
return 'free'
}
export interface HasFeatureOptions {
/**
* Runtime edition. Defaults to `'ee'` for backward compatibility with
* existing call sites in the managed service. Community Edition code
* paths must pass `'agpl'` so that `requires_ee` features are
* force-disabled.
*/
edition?: Edition
}
/**
* Returns true when the given plan tier grants the given feature AND
* the edition supports it.
*
* Gating rule: `plans.includes(plan) AND (!requires_ee OR edition === 'ee')`.
* Roadmap features are still reported as "granted" so UI can render
* their "Coming Soon" state; enforcement call sites should cross-check
* the `roadmap` flag via `FEATURE_MATRIX[key].roadmap`.
*/
export function hasFeatureForPlan(
plan: StudioPlan | string | null | undefined,
feature: string,
options: HasFeatureOptions = {},
): boolean {
const entry = FEATURE_MATRIX[feature]
if (!entry) return false
if (entry.requires_ee && options.edition === 'agpl') return false
return entry.plans.includes(normalizePlan(plan))
}
export function getPlanLimitForPlan(
plan: StudioPlan | string | null | undefined,
limit: string,
options: HasFeatureOptions = {},
): number {
const entry = PLAN_LIMITS[limit]
if (!entry) return 0
if (entry.requires_ee && options.edition === 'agpl') return 0
return entry.values[normalizePlan(plan)] ?? 0
}
// ─── UI helpers (unchanged) ───
function formatLimit(value: number): string {
if (value === Infinity) return 'unlimited'
// Whole thousands read as "3K"; anything else is spelled out — 1 600 as
// "2K" would tell a v2 Pro workspace (and the agent) 400 credits too many.
if (value >= 1000 && value % 1000 === 0) return `${value / 1000}K`
if (value >= 1000) return value.toLocaleString('en-US')
return String(value)
}
function formatStorage(gb: number): string {
if (gb === Infinity) return 'unlimited'
return `${gb}GB`
}
function formatFileSize(mb: number): string {
return `${mb}MB`
}
/**
* Build interpolation params for a given plan.
* Use with t() / agentMessage() / agentPrompt() / errorMessage().
*/
export function getPlanParams(
plan: StudioPlan | string | null | undefined,
/**
* Credit limits in the account's own unit (`creditLimitsFor` in
* credit-unit.ts). Absent = the catalog's — right for a new subscription,
* wrong for a pre-v2 one, which keeps the quotas it was sold with.
*/
creditLimits?: Partial<Record<'ai.messages_per_month' | 'api.messages_per_month', number>>,
): Record<string, string | number> {
const p = normalizePlan(plan)
const pricing = PLAN_PRICING[p]
function limit(key: string): number {
const own = creditLimits?.[key as 'ai.messages_per_month' | 'api.messages_per_month']
if (own !== undefined) return own
return PLAN_LIMITS[key]?.values[p] ?? 0
}
function limitOrUnlimited(key: string): string | number {
const v = limit(key)
return v === Infinity ? 'unlimited' : v
}
return {
plan: pricing.name,
price: `$${pricing.priceMonthly}`,
seatsIncluded: pricing.seatsIncluded,
aiMessages: formatLimit(limit('ai.messages_per_month')),
seats: limitOrUnlimited('team.members'),
cdnBandwidth: formatStorage(limit('cdn.bandwidth_gb')),
cdnKeys: limitOrUnlimited('cdn.api_keys'),
mediaStorage: formatStorage(limit('media.storage_gb')),
maxFileSize: formatFileSize(limit('media.max_file_size_mb')),
mediaVariants: limitOrUnlimited('media.variants_per_field'),
formModels: limitOrUnlimited('forms.models'),
formSubmissions: formatLimit(limit('forms.submissions_per_month')),
commentModels: limitOrUnlimited('comments.models'),
commentsPerMonth: formatLimit(limit('comments.per_month')),
conversationKeys: limitOrUnlimited('api.conversation_keys'),
apiMessages: formatLimit(limit('api.messages_per_month')),
webhookEndpoints: limitOrUnlimited('api.webhooks'),
mcpKeys: limitOrUnlimited('api.mcp_keys'),
mcpCalls: formatLimit(limit('api.mcp_calls_per_month')),
}
}
/**
* Get the next plan tier for upgrade suggestions.
* community → starter, free → starter, starter → pro, pro → enterprise,
* enterprise → enterprise. Community is treated as pre-free for upgrade
* suggestions even though it is edition-specific; the managed upgrade
* path is a separate conversation from edition switching.
*/
export function getNextPlan(plan: StudioPlan | string | null | undefined): StudioPlan {
const p = normalizePlan(plan)
if (p === 'community') return 'starter'
if (p === 'free') return 'starter'
if (p === 'starter') return 'pro'
if (p === 'pro') return 'enterprise'
return 'enterprise'
}
/**
* Build upgrade comparison params: current plan values + target plan values (prefixed with "to").
* Useful for upgrade strings that compare two plans side by side.
* If toPlan is omitted, uses the next tier automatically.
*/
export function getUpgradeParams(
fromPlan: StudioPlan | string | null | undefined,
toPlan?: StudioPlan | string | null | undefined,
): Record<string, string | number> {
const from = getPlanParams(fromPlan)
const to = getPlanParams(toPlan ?? getNextPlan(fromPlan))
const prefixed: Record<string, string | number> = {}
for (const [k, v] of Object.entries(to)) {
prefixed[`to${k.charAt(0).toUpperCase()}${k.slice(1)}`] = v
}
return { ...from, ...prefixed }
}
// ─── Plan claims in user- and agent-facing text ───
//
// Text that says which plans carry a feature ("available on all plans",
// "BYOA on Pro and Enterprise") goes stale whenever the catalog moves a
// feature. These build that wording from the catalog instead, so the text
// can never promise what the plan does not grant (BG-1 P1-14).
/** The plans a workspace can buy, lowest first. */
const SOLD_PLANS: StudioPlan[] = ['starter', 'pro', 'enterprise']
function joinNames(names: string[]): string {
if (names.length <= 1) return names[0] ?? ''
return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`
}
/** "Pro and Enterprise" / "every paid plan" — the sold plans that grant `feature`. */
export function plansWithFeatureLabel(feature: string): string {
const entry = FEATURE_MATRIX[feature]
const plans = entry ? SOLD_PLANS.filter(plan => entry.plans.includes(plan)) : []
if (plans.length === SOLD_PLANS.length) return 'every paid plan'
if (plans.length === 0) return 'no current plan'
return joinNames(plans.map(plan => PLAN_PRICING[plan].name))
}
/**
* `{plans:<feature key>}` for every catalog feature, e.g.
* `{plans:ai.byoa}` → "Pro and Enterprise". Merged into every error and
* agent string, so any text can state availability without hard-coding it.
*/
export function featurePlanParams(): Record<string, string> {
const params: Record<string, string> = {}
for (const key of Object.keys(FEATURE_MATRIX)) params[`plans:${key}`] = plansWithFeatureLabel(key)
return params
}
/** Shipped features some paid plans lack — the only real difference beyond limits. */
function gatedFeatures(): Array<[string, FeatureMatrixEntry]> {
return Object.entries(FEATURE_MATRIX).filter(([, e]) => {
if (e.roadmap) return false
const onSold = SOLD_PLANS.filter(plan => e.plans.includes(plan)).length
return onSold > 0 && onSold < SOLD_PLANS.length
})
}
/** "Bring Your Own API Key (Pro and Enterprise), Conversation API (Pro and Enterprise), …" */
export function planGatedFeaturesLabel(): string {
return gatedFeatures().map(([key, e]) => `${e.name} (${plansWithFeatureLabel(key)})`).join(', ')
}
/** Shipped features `plan` does not include, by catalog name; empty when it has them all. */
export function featuresMissingOnPlan(plan: StudioPlan | string | null | undefined): string[] {
const p = normalizePlan(plan)
return gatedFeatures().filter(([, e]) => !e.plans.includes(p)).map(([, e]) => e.name)
}
/** Shipped plan-gated features `plan` does include, by catalog name. */
export function gatedFeaturesOnPlan(plan: StudioPlan | string | null | undefined): string[] {
const p = normalizePlan(plan)
return gatedFeatures().filter(([, e]) => e.plans.includes(p)).map(([, e]) => e.name)
}