Skip to content
Merged
36 changes: 26 additions & 10 deletions backend/apps/api/src/cloud-entitlements.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Principal } from "@cmux/ownership"
import type { CloudEntitlements } from "./domains/cloud-plan.ts"
import { CLOUD_RESTRICTED, cloudPlanId, companyPlanKey, PAID_PLAN_LIMITS, type CloudEntitlements } from "./domains/cloud-plan.ts"
import { personalTeamIdFor } from "./domains/user.ts"
import type { Env } from "./env.ts"

Expand Down Expand Up @@ -54,25 +54,41 @@ export class CloudEntitlementStore implements CloudEntitlements {
this.record(team, plan, now)
}

/** The paid plan id, null for none, undefined when the plan service is unavailable. */
/**
* The team's entitlement key (cloud-plan.ts paidPlanLimits), null for none, undefined when the
* plan service is unavailable. As classic Cloud reads it: the paid plan, then the operator's
* `cmuxVmPlan` over it (cloudPlanId), and on a personal team of a verified company account the
* company grant (companyPlanKey). Billing and the model router keep reading the paid plan alone.
*/
private async read(team: string, principal: Principal): Promise<string | null | undefined> {
const user = principal.user!
// A personal team pays through its owner's personal plan (inference/route.ts does the same).
const personal = team === personalTeamIdFor(user)
const stackUser = personal ? (principal.stack_user_id ?? (await this.env.USER_DO.get(this.env.USER_DO.idFromName(user)).stackUserOf(user)) ?? undefined) : undefined
if (personal && !stackUser) return null
const answer = await this.env.TEAM_DO.get(this.env.TEAM_DO.idFromName(team)).inferencePlan(team, user, stackUser)
if (!answer.ok) return undefined
return answer.paid ? (answer.plan ?? null) : null
const users = this.env.USER_DO.get(this.env.USER_DO.idFromName(user))
const stackUser = personal ? (principal.stack_user_id ?? (await users.stackUserOf(user)) ?? undefined) : undefined
// The company grant follows the owner's verified email (UserDO's record), never a client claim.
const company = personal && (await users.cloudCompanyAccount(user))
if (personal && !stackUser) return companyPlanKey(company, null)
const teams = this.env.TEAM_DO.get(this.env.TEAM_DO.idFromName(team))
const [answer, grant] = await Promise.all([teams.inferencePlan(team, user, stackUser), teams.cloudVmPlan(team, stackUser)])
if (!answer.ok || !grant.ok) return undefined
// The raw cmuxPlan, not the router's exact-match paid flag: classic normalizes it (" Max " is max) for machines.
const own = cloudPlanId(answer.plan ?? null, grant.vm_plan)
// A set plan that grants nothing (a restriction, an unknown id) is kept as such: it also blocks the stub plan.
// Only a web plan id counts from the record: the company keys are granted here alone, never read from metadata.
return companyPlanKey(company, own) ?? (own === null ? null : Object.hasOwn(PAID_PLAN_LIMITS, own) ? own : CLOUD_RESTRICTED)
}

private record(team: string, plan: string | null, at: number) {
/** Records a read that started at `at`; a read that started before a newer recorded one changes nothing (concurrent refreshes). */
record(team: string, plan: string | null, at: number) {
const hit = this.memo.get(team)
if (hit && hit.at > at) return
this.memo.set(team, { plan, at })
if (plan !== null) {
this.sql.exec(`CREATE TABLE IF NOT EXISTS cloud_entitlement (team TEXT PRIMARY KEY, plan TEXT NOT NULL, checked_at INTEGER NOT NULL)`)
this.sql.exec(`INSERT INTO cloud_entitlement (team, plan, checked_at) VALUES (?, ?, ?) ON CONFLICT (team) DO UPDATE SET plan = excluded.plan, checked_at = excluded.checked_at`, team, plan, at)
this.sql.exec(`INSERT INTO cloud_entitlement (team, plan, checked_at) VALUES (?, ?, ?) ON CONFLICT (team) DO UPDATE SET plan = excluded.plan, checked_at = excluded.checked_at WHERE excluded.checked_at >= cloud_entitlement.checked_at`, team, plan, at)
} else if (this.hasTable()) {
this.sql.exec(`DELETE FROM cloud_entitlement WHERE team = ?`, team)
this.sql.exec(`DELETE FROM cloud_entitlement WHERE team = ? AND checked_at <= ?`, team, at)
}
}

Expand Down
50 changes: 50 additions & 0 deletions backend/apps/api/src/cloud-vm-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { TeamState } from "./domains/team.ts"
import type { StackServer } from "./stack-server.ts"

/** TeamDO answers a team's operator Cloud grant from its copy for this long, then asks the sign-in service again. */
export const VM_PLAN_FRESH_MS = 10 * 60_000

/** The grant (null = none), or unavailable: the sign-in service failed and there is no fresh copy. */
export type VmPlanAnswer = { readonly ok: true; readonly vm_plan: string | null } | { readonly ok: false }

/**
* TeamDO.cloudVmPlan: the team's operator Cloud grant, `cmuxVmPlan` on the same sign-in service
* record whose `cmuxPlan` inference/plan.ts reads (the owner's user record for a personal team,
* the Stack team's record for a shared one). CloudDO maps it to machine limits (cloud-plan.ts
* cloudPlanId); nothing here reaches the model router or billing. `stackUser` is set only for the
* caller's own personal team, as for inferencePlan.
*/
export const cloudVmPlanOf = async (sql: SqlStorage, state: TeamState | null, entity: string, stack: StackServer | undefined, stackUser: string | undefined, now: number): Promise<VmPlanAnswer> => {
const team = state?.team ?? null
let read: (s: StackServer) => Promise<string | null>
if (stackUser !== undefined) {
// A personal team may not be mirrored here yet; the Worker checked the id is the caller's own.
if (team && (team.id !== entity || team.kind !== "personal")) return { ok: true, vm_plan: null }
read = async (s) => ((v) => ("gone" in v ? null : v.vm_plan))(await s.getUserVmPlan(stackUser))
} else {
if (!team || team.id !== entity || team.deleted_at !== undefined || team.kind !== "stack" || !team.stack_team) return { ok: true, vm_plan: null }
const stackTeam = team.stack_team
read = async (s) => (await s.getTeam(stackTeam))?.vm_plan ?? null
}
ensure(sql)
const row = sql.exec<{ vm_plan: string | null; checked_at: number }>(`SELECT vm_plan, checked_at FROM cloud_vm_plan WHERE id = 1`).toArray()[0]
if (row && now - Number(row.checked_at) < VM_PLAN_FRESH_MS) return { ok: true, vm_plan: row.vm_plan }
try {
if (!stack) throw new Error("stack server not configured")
const vm_plan = await read(stack)
storeVmPlan(sql, vm_plan, now)
return { ok: true, vm_plan }
} catch (e) {
// A failed read keeps the last copy (as CloudDO keeps the last known plan); with none it is unavailable.
console.warn(JSON.stringify({ msg: "cloud.vm_plan.read_failed", error: String(e).slice(0, 160), stale: Boolean(row) }))
return row ? { ok: true, vm_plan: row.vm_plan } : { ok: false }
}
}

const ensure = (sql: SqlStorage) => sql.exec(`CREATE TABLE IF NOT EXISTS cloud_vm_plan (id INTEGER PRIMARY KEY CHECK (id = 1), vm_plan TEXT, checked_at INTEGER NOT NULL)`)

/** Stores what the sign-in service answered at `at` (null = no grant); a read that started before a newer one never overwrites it. */
export const storeVmPlan = (sql: SqlStorage, vmPlan: string | null, at: number) => {
ensure(sql)
sql.exec(`INSERT INTO cloud_vm_plan (id, vm_plan, checked_at) VALUES (1, ?, ?) ON CONFLICT (id) DO UPDATE SET vm_plan = excluded.vm_plan, checked_at = excluded.checked_at WHERE excluded.checked_at >= cloud_vm_plan.checked_at`, vmPlan, at)
}
74 changes: 69 additions & 5 deletions backend/apps/api/src/domains/cloud-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { CloudTransport } from "../cloud-vm-api-config.ts"
* service's cmuxPlan that web billing writes from Stripe), mapped to PAID_PLAN_LIMITS. CloudDO
* keeps the last known plan (cloud-entitlements.ts) and hands it to the pure reducers through
* CloudConfig.entitlements.
* - Operator and company grants, as classic Cloud honours them (cloudPlanId, companyPlanKey): a
* `cmuxVmPlan` on the sign-in record, and verified company-domain accounts. Machine limits only.
* - STUB_PLAN: only in the stub environments (development, staging, test) and only for teams on
* CLOUD_ALLOWED_TEAMS.
* Every other team gets no plan, so create answers cloud.plan.required and no provider VM is made
Expand Down Expand Up @@ -66,8 +68,62 @@ export const PAID_PLAN_LIMITS: Readonly<Record<string, PlanLimits>> = {
max: { plan_id: "max", max_active: 20, max_saved: 50, memory_options_mb: [4 * GiB, 8 * GiB, 16 * GiB, 32 * GiB, 64 * GiB], locked_memory_options_mb: [], vm_hours_included: null, max_cpu: 32, max_disk_mb: 512 * GiB, upgrade_plan: null }
}

/** The paid limits of a plan id, or null (free, unknown, or no plan). */
export const paidPlanLimits = (plan: string | null | undefined): PlanLimits | null => (plan && Object.hasOwn(PAID_PLAN_LIMITS, plan) ? PAID_PLAN_LIMITS[plan]! : null)
/**
* Company accounts (classic web services/billing/companyPlan.ts hasCompanyPlanGrant): a verified email on
* one of these domains is Team by default, Max when the person's own plan is Max, with no machine cap.
* The same list as web services/admin/access.ts ADMIN_EMAIL_DOMAINS (test/cloud-plan-overrides.test.ts).
*/
export const COMPANY_EMAIL_DOMAINS: ReadonlySet<string> = new Set(["cmux.com", "manaflow.ai", "manaflow.com"])
/** Classic gives company accounts no active-machine cap (maxActiveVms null); the reducers compare numbers, so the cap is out of reach. */
export const COMPANY_MAX_ACTIVE = Number.MAX_SAFE_INTEGER
/** Entitlement keys of the company grant (never a web plan id); their limits name the plan they are (team or max). */
export const COMPANY_PLAN_LIMITS: Readonly<Record<string, PlanLimits>> = {
company: { ...PAID_PLAN_LIMITS.team!, max_active: COMPANY_MAX_ACTIVE },
company_max: { ...PAID_PLAN_LIMITS.max!, max_active: COMPANY_MAX_ACTIVE }
}

/** The limits of an entitlement key (a paid plan id or a company key), or null (free, unknown, or no plan). */
export const paidPlanLimits = (plan: string | null | undefined): PlanLimits | null =>
plan && Object.hasOwn(PAID_PLAN_LIMITS, plan) ? PAID_PLAN_LIMITS[plan]! : plan && Object.hasOwn(COMPANY_PLAN_LIMITS, plan) ? COMPANY_PLAN_LIMITS[plan]! : null

/** Plans a `cmuxVmPlan` grant does not lower below a paid Max (classic billingPlanIdFromMetadata). */
const KEEPS_MAX: ReadonlySet<string | null> = new Set([null, "pro", "team", "founders", "max"])

/**
* The Cloud plan id of one sign-in service record, exactly as classic Cloud reads it (web
* services/billing/teamResolution.ts billingPlanIdFromMetadata, then entitlements.ts
* normalizedPlanId): the operator's `cmuxVmPlan` wins over the billing `cmuxPlan`, except that a
* lower paid grant never hides a paid Max (exact values, as classic compares them). An explicit
* `cmuxVmPlan` such as "free", or a blank one, is an operator restriction and stands ("free").
* Machine limits only: the model router and billing keep reading `cmuxPlan` alone.
*/
export const cloudPlanId = (plan: string | null, vmPlan: string | null): string | null => {
if (plan === "max" && KEEPS_MAX.has(vmPlan)) return "max"
const value = vmPlan ?? plan
if (value === null) return null
const id = value.trim().toLowerCase()
return id || "free"
}

/**
* A company account's email (classic web services/admin/access.ts isAdminEmail): printable ASCII,
* a plain local part, and exactly one of COMPANY_EMAIL_DOMAINS after the last "@" (no subdomain,
* lookalike or trailing dot). The caller also requires the address to be verified.
*/
export const isCompanyEmail = (email: string | null | undefined): boolean => {
if (typeof email !== "string") return false
const normalized = email.trim()
if (normalized.length === 0 || normalized.length > 254 || !/^[\x21-\x7e]+$/.test(normalized)) return false
const at = normalized.lastIndexOf("@")
if (at <= 0 || at === normalized.length - 1) return false
const local = normalized.slice(0, at)
const domain = normalized.slice(at + 1).toLowerCase()
if (local.length > 64 || !/^[A-Za-z0-9!#$%&'*+/=?^_`{|}~.-]+$/.test(local)) return false
return /^[a-z0-9.-]+$/.test(domain) && COMPANY_EMAIL_DOMAINS.has(domain)
}

/** The company grant's entitlement key, or null; Max stays Max (classic resolveVmEntitlements). */
export const companyPlanKey = (company: boolean, ownPlan: string | null): string | null => (company ? (ownPlan === "max" ? "company_max" : "company") : null)

/** The team's paid plan as CloudDO last knew it (cloud-entitlements.ts), read synchronously by the reducers. */
export interface CloudEntitlements {
Expand Down Expand Up @@ -99,10 +155,18 @@ export const STUB_PLAN_ENVIRONMENTS: ReadonlySet<string> = new Set(["development
export const planFor = (environment: string, team: string | null | undefined, allowed: ReadonlySet<string>): PlanLimits | null =>
STUB_PLAN_ENVIRONMENTS.has(environment) && team !== null && team !== undefined && allowed.has(team) ? STUB_PLAN : null

/** The team's limits: its paid plan when entitlements are on and it has one, else the stub rule. */
/**
* The entitlement key of a record whose plan is set but grants no machines: an operator restriction
* (`cmuxVmPlan` "free", blank or malformed) or an unknown plan id. Classic gives it zero machines,
* so it also wins over the stub plan.
*/
export const CLOUD_RESTRICTED = "restricted"

/** The team's limits: its paid plan when entitlements are on and it has one, none when it is restricted, else the stub rule. */
export const teamPlan = (config: CloudConfig, team: string | null | undefined): PlanLimits | null => {
const paid = config.entitlements?.enabled && team ? paidPlanLimits(config.entitlements.plan(team)) : null
return paid ?? planFor(config.environment, team, config.allowedTeams)
const key = config.entitlements?.enabled && team ? config.entitlements.plan(team) : null
if (key === CLOUD_RESTRICTED) return null
return paidPlanLimits(key) ?? planFor(config.environment, team, config.allowedTeams)
}

/** CLOUD_ALLOWED_TEAMS: comma-separated team ids; blanks dropped; unset or empty = nobody. */
Expand Down
27 changes: 24 additions & 3 deletions backend/apps/api/src/stack-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@ export interface StackServer {
/**
* The Stack team now, or null when Stack answers TEAM_NOT_FOUND (team webhooks, cx-3bi.43).
* `plan` is the team's client_read_only_metadata.cmuxPlan (web billing's team plan mirror,
* inference-plans.ts), null when absent or not a string.
* inference-plans.ts), null when absent or not a string. `vm_plan` is its operator Cloud grant
* (client_read_only_metadata.cmuxVmPlan, cloud-plan.ts cloudPlanId), read for Cloud machine limits only.
*/
getTeam(teamId: string): Promise<{ display_name: string; plan: string | null; vm_personal_tenant?: boolean } | null>
getTeam(teamId: string): Promise<{ display_name: string; plan: string | null; vm_plan?: string | null; vm_personal_tenant?: boolean } | null>
/**
* A user's client_read_only_metadata.cmuxPlan (web billing's personal plan mirror,
* inference-plans.ts): the plan id, null when absent, "user_gone" for USER_NOT_FOUND.
*/
getUserPlan(userId: string): Promise<string | null | "user_gone">
/** A user's operator Cloud grant (client_read_only_metadata.cmuxVmPlan, as metadata text), null when absent; `gone` for USER_NOT_FOUND. Cloud machine limits only. */
getUserVmPlan(userId: string): Promise<{ readonly vm_plan: string | null } | { readonly gone: true }>
/**
* Every member Stack lists for the team now ("team_gone" for TEAM_NOT_FOUND), with each member's
* team permission ids (GET /team-permissions?team_id&recursive=true, so contained permissions
Expand Down Expand Up @@ -70,6 +73,20 @@ const readOnlyPlan = (metadata: unknown): string | null => {
return typeof plan === "string" && plan.length > 0 && plan.length <= 64 ? plan : null
}

/**
* The operator's `cmuxVmPlan` from client_read_only_metadata only (cloud-plan.ts cloudPlanId), for
* Cloud machine limits. Classic Cloud lets any present value win over `cmuxPlan` (`cmuxVmPlan ??
* cmuxPlan`), and one that is not a usable plan id (blank, not a string) then restricts the record to
* free. So absent or null is null, a string up to 64 characters is kept as written (blank included),
* and any other present value is "" (a restriction).
*/
export const readVmPlanOverride = (metadata: unknown): string | null => {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null
const value = (metadata as Record<string, unknown>).cmuxVmPlan
if (value === undefined || value === null) return null
return typeof value === "string" && value.length <= 64 ? value : ""
}

/**
* One retry after a 429 (cx-f5tk4): the sign-in service rate-limits our server reads. The wait is
* its Retry-After (seconds), 1 s when absent, and never more than RETRY_429_MAX_MS, so a retry stays
Expand Down Expand Up @@ -156,12 +173,16 @@ export const stackServer = (
// Server-only metadata: a cmux VM personal tenant (team-vm-api-personal-stack.ts) is never mirrored as a cmux team.
const meta = r.server_metadata
const vm_personal_tenant = !!meta && typeof meta === "object" && !Array.isArray(meta) && (meta as Record<string, unknown>)[PERSONAL_TENANT_METADATA_KEY] != null
return { display_name: typeof r.display_name === "string" ? r.display_name : "", plan: readOnlyPlan(r.client_read_only_metadata), vm_personal_tenant }
return { display_name: typeof r.display_name === "string" ? r.display_name : "", plan: readOnlyPlan(r.client_read_only_metadata), vm_plan: readVmPlanOverride(r.client_read_only_metadata), vm_personal_tenant }
},
getUserPlan: async (userId) => {
const r = await lookup<{ client_read_only_metadata?: unknown }>(`/users/${encodeURIComponent(userId)}`, "user", ["USER_NOT_FOUND"])
return isGone(r) ? "user_gone" : readOnlyPlan(r.client_read_only_metadata)
},
getUserVmPlan: async (userId) => {
const r = await lookup<{ client_read_only_metadata?: unknown }>(`/users/${encodeURIComponent(userId)}`, "user", ["USER_NOT_FOUND"])
return isGone(r) ? { gone: true as const } : { vm_plan: readVmPlanOverride(r.client_read_only_metadata) }
},
listTeamMembers: async (teamId) => {
const out: Array<{ user_id: string; display_name: string | null; permissions: ReadonlyArray<string>; anonymous: boolean }> = []
let cursor: string | undefined
Expand Down
1 change: 1 addition & 0 deletions backend/apps/api/src/team-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ export class TeamDO extends OwnerDO<TeamState> {
}
async memberRole(entity: string, user: string): Promise<string | null> { return (await this.membership(entity, user))?.role ?? null } // team selection (x-cmux-team)
async noOwner(entity: string): Promise<boolean> { return this.isBound(entity) && noOwnerOf(this.bind(entity).currentState) } // RPC from TeamVmDO's team_vm.status (review P2-3)
async cloudVmPlan(entity: string, stackUser?: string) { return (await import("./cloud-vm-plan.ts")).cloudVmPlanOf(this.ctx.storage.sql, this.isBound(entity) ? this.bind(entity).currentState : null, entity, this.stack ?? stackServer(this.env), stackUser, Date.now()) } // RPC from CloudDO: the operator Cloud grant (cloud-entitlements.ts)
async inferencePlan(entity: string, user: string, stackUser?: string) { return (await import("./inference/plan.ts")).inferencePlanOf(this.ctx.storage.sql, this.isBound(entity) ? this.bind(entity).currentState : null, entity, this.stack ?? stackServer(this.env), stackUser, user) } // RPC from the model router: the paid-plan gate (inference/plan.ts)
/** RPC from the Stack webhook route (stack-webhook.ts): one delivery reconciled with Stack, one at a time per team. */
async stackWebhook(entity: string, event: StackEvent): Promise<StackSyncReply> {
Expand Down
Loading
Loading