diff --git a/backend/apps/api/src/cloud-entitlements.ts b/backend/apps/api/src/cloud-entitlements.ts index 9deca79191fb..189ed79e9b74 100644 --- a/backend/apps/api/src/cloud-entitlements.ts +++ b/backend/apps/api/src/cloud-entitlements.ts @@ -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" @@ -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 { 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) } } diff --git a/backend/apps/api/src/cloud-vm-plan.ts b/backend/apps/api/src/cloud-vm-plan.ts new file mode 100644 index 000000000000..d126c6e7ff9b --- /dev/null +++ b/backend/apps/api/src/cloud-vm-plan.ts @@ -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 => { + const team = state?.team ?? null + let read: (s: StackServer) => Promise + 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) +} diff --git a/backend/apps/api/src/domains/cloud-plan.ts b/backend/apps/api/src/domains/cloud-plan.ts index 0ec49ceaffe8..30029d9f5848 100644 --- a/backend/apps/api/src/domains/cloud-plan.ts +++ b/backend/apps/api/src/domains/cloud-plan.ts @@ -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 @@ -66,8 +68,62 @@ export const PAID_PLAN_LIMITS: Readonly> = { 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 = 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> = { + 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 = 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 { @@ -99,10 +155,18 @@ export const STUB_PLAN_ENVIRONMENTS: ReadonlySet = new Set(["development export const planFor = (environment: string, team: string | null | undefined, allowed: ReadonlySet): 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. */ diff --git a/backend/apps/api/src/stack-server.ts b/backend/apps/api/src/stack-server.ts index f261e95b3f36..ce11e0ab1046 100644 --- a/backend/apps/api/src/stack-server.ts +++ b/backend/apps/api/src/stack-server.ts @@ -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 + /** 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 @@ -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).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 @@ -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)[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; anonymous: boolean }> = [] let cursor: string | undefined diff --git a/backend/apps/api/src/team-do.ts b/backend/apps/api/src/team-do.ts index fd0760386d2a..f4177b439cfa 100644 --- a/backend/apps/api/src/team-do.ts +++ b/backend/apps/api/src/team-do.ts @@ -642,6 +642,7 @@ export class TeamDO extends OwnerDO { } async memberRole(entity: string, user: string): Promise { return (await this.membership(entity, user))?.role ?? null } // team selection (x-cmux-team) async noOwner(entity: string): Promise { 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 { diff --git a/backend/apps/api/src/user-do.ts b/backend/apps/api/src/user-do.ts index 4a23d1eff419..c3825eed66aa 100644 --- a/backend/apps/api/src/user-do.ts +++ b/backend/apps/api/src/user-do.ts @@ -6,6 +6,7 @@ import * as quota from "./home-attachment-quota.ts" import { deliverKrlNotices, krlDueAt, type KrlRetry } from "./user-krl.ts" import { emailDomainOf, verifyInstallSignature, type InstallClaims } from "./auth.ts" import { verifyAttestation, type AttestedKey } from "./app-attest.ts" +import { isCompanyEmail } from "./domains/cloud-plan.ts" import { admit } from "./domains/common.ts" import { chiefActive, grantFor, inboxRefusalFor, installActive, grantMigrationsDue, userPathAllowed, jwkThumbprint, makeUserDomain, type UserState } from "./domains/user.ts" import { appIdHashFor, confirmRead } from "./domains/user-confirm.ts" @@ -465,6 +466,11 @@ export class UserDO extends OwnerDO { if (!settings.trust_same_domain || !invites.requestTrusted(senderDomain, invites.emailDomain(u.email))) return null return { display_name: u.display_name, allow_requests_from: settings.allow_requests_from } } + /** CloudDO (cloud-entitlements.ts): whether this user's verified email is a company account (cloud-plan.ts isCompanyEmail). Never the address itself. */ + async cloudCompanyAccount(entity: string): Promise { + const u = this.existing()?.currentState.user + return Boolean(u && u.id === entity && u.email_verified === true && isCompanyEmail(u.email)) + } async stackUserOf(entity: string): Promise { const u = this.existing()?.currentState.user; return u && u.id === entity ? u.stack_user_id : null } // CloudDO: the owner's Stack user id (cloud-coderouter-edge.ts) /** TeamDO's identity check only: an active daemon's public key, scoped to its existing team and grant. */ async serverKey(entity: string, install: string, grant: string, team: string): Promise { diff --git a/backend/apps/api/test/cloud-plan-entitlements.test.ts b/backend/apps/api/test/cloud-plan-entitlements.test.ts index e8a327e0d5e3..53098120453b 100644 --- a/backend/apps/api/test/cloud-plan-entitlements.test.ts +++ b/backend/apps/api/test/cloud-plan-entitlements.test.ts @@ -1,5 +1,6 @@ import { env } from "cloudflare:workers" import { describe, expect, it } from "vitest" +import { storeVmPlan } from "../src/cloud-vm-plan.ts" import { storePlan } from "../src/inference/plan.ts" import { inDO, post, testEnv, token } from "./setup/inference-person.ts" import { call, mirroredTeam, teamStub } from "./team-stack-support.ts" @@ -19,7 +20,10 @@ const person = async (subject: string, plan: string | null, entitlements = true) const t = await token(subject) const ensured = await post("/v1/ops", t, { op: "user.ensure", params: {}, idempotency_key: crypto.randomUUID(), origin: "cli" }) const team = ensured.value.personal_team as string - await inDO(testEnv.TEAM_DO.get(testEnv.TEAM_DO.idFromName(team)), async (i) => storePlan(i.ctx.storage.sql, plan, Date.now())) + await inDO(testEnv.TEAM_DO.get(testEnv.TEAM_DO.idFromName(team)), async (i) => { + storePlan(i.ctx.storage.sql, plan, Date.now()) + storeVmPlan(i.ctx.storage.sql, null, Date.now()) + }) await cloudStub(team).fakeControl({ entitlements: entitlements ? "on" : "off" }) return { t, team } } @@ -127,6 +131,7 @@ describe("Cloud plan entitlements", { timeout: 60_000 }, () => { // The member's personal plan does not reach the shared team. const ensured = (await call(t.token, "/v1/ops", { op: "user.ensure", params: {}, idempotency_key: crypto.randomUUID(), origin: "cli" })).body await inDO(testEnv.TEAM_DO.get(testEnv.TEAM_DO.idFromName(ensured.value.personal_team)), async (i) => storePlan(i.ctx.storage.sql, "max", Date.now())) + await inDO(teamStub(t.team), async (i) => storeVmPlan(i.ctx.storage.sql, null, Date.now())) expect((await teamPlanGet()).value).toMatchObject({ plan_id: "none" }) await inDO(teamStub(t.team), async (i) => storePlan(i.ctx.storage.sql, "team", Date.now())) await cloudStub(t.team).fakeControl({ advance_ms: 61_000 }) diff --git a/backend/apps/api/test/cloud-plan-overrides.test.ts b/backend/apps/api/test/cloud-plan-overrides.test.ts new file mode 100644 index 000000000000..6ef6585f522c --- /dev/null +++ b/backend/apps/api/test/cloud-plan-overrides.test.ts @@ -0,0 +1,263 @@ +import { env } from "cloudflare:workers" +import { importJWK, SignJWT, type JWK } from "jose" +import { describe, expect, it } from "vitest" +// @ts-expect-error a Vite raw import of web's company domain list (the test runner bundles it; the Worker build never sees it) +import webAccessText from "../../../../web/services/admin/access.ts?raw" +import { cloudVmPlanOf, storeVmPlan } from "../src/cloud-vm-plan.ts" +import { readVmPlanOverride } from "../src/stack-server.ts" +import { CLOUD_RESTRICTED, cloudPlanId, COMPANY_EMAIL_DOMAINS, COMPANY_MAX_ACTIVE, companyPlanKey, isCompanyEmail, PAID_PLAN_LIMITS, paidPlanLimits, STUB_PLAN, teamPlan, type CloudConfig } from "../src/domains/cloud-plan.ts" +import { CloudEntitlementStore } from "../src/cloud-entitlements.ts" +import { userIdFor } from "../src/domains/user.ts" +import { storePlan } from "../src/inference/plan.ts" +import { inDO, post, testEnv } from "./setup/inference-person.ts" +import { call, mirroredTeam, teamStub } from "./team-stack-support.ts" + +/** + * Cloud machine limits honour the operator and company grants classic Cloud honours (web + * services/billing/teamResolution.ts billingPlanIdFromMetadata, services/vms/entitlements.ts + * resolveVmEntitlements with services/billing/companyPlan.ts hasCompanyPlanGrant). Machine limits + * only: the model router's paid gate (TeamDO.inferencePlan) and billing keep reading cmuxPlan. + */ + +const cloud = env as unknown as { CLOUD_DO: DurableObjectNamespace } +const cloudStub = (team: string) => cloud.CLOUD_DO.get(cloud.CLOUD_DO.idFromName(team)) as unknown as { fakeControl(cmd: Record): Promise } +const teamDO = (team: string) => testEnv.TEAM_DO.get(testEnv.TEAM_DO.idFromName(team)) + +/** A Stack session token with this email; `verified` sets the email_verified claim. */ +const tokenWith = async (subject: string, email: string, verified: boolean) => { + const key = await importJWK(JSON.parse(testEnv.STACK_TEST_PRIVATE_JWK) as JWK, "ES256") + return new SignJWT({ email, name: subject, ...(verified ? { email_verified: true } : {}) }) + .setProtectedHeader({ alg: "ES256", kid: "stack-test" }) + .setIssuer(`https://api.stack-auth.com/api/v1/projects/${testEnv.STACK_PROJECT_ID}`) + .setAudience(testEnv.STACK_PROJECT_ID) + .setSubject(subject) + .setIssuedAt() + .setExpirationTime("10m") + .sign(key) +} + +/** A signed-in person whose personal team has paid plan `plan` and operator grant `vmPlan` (TeamDO's copies); entitlements on. */ +const person = async (subject: string, plan: string | null, vmPlan: string | null, email = `${subject}@example.com`, verified = false) => { + const t = await tokenWith(subject, email, verified) + const ensured = await post("/v1/ops", t, { op: "user.ensure", params: {}, idempotency_key: crypto.randomUUID(), origin: "cli" }) + const team = ensured.value.personal_team as string + await inDO(teamDO(team), async (i) => { + storePlan(i.ctx.storage.sql, plan, Date.now()) + storeVmPlan(i.ctx.storage.sql, vmPlan, Date.now()) + }) + await cloudStub(team).fakeControl({ entitlements: "on" }) + return { t, team } +} +const SMALL = { cpu: 2, memory_mb: 4096, disk_mb: 16384 } +const create = (t: string, size = SMALL) => post("/v1/ops", t, { op: "cloud.machine.create", params: { name: "grant box", size }, idempotency_key: crypto.randomUUID(), origin: "user" }) +const planGet = async (t: string) => (await post("/v1/read", t, { op: "cloud.plan.get", params: {} })).value +const code = (r: any) => r.error?.code ?? r.code + +describe("cloudPlanId: classic's billingPlanIdFromMetadata", () => { + it.each([ + [null, null, null], + ["pro", null, "pro"], + [null, "pro", "pro"], + ["pro", "founders", "founders"], + // An explicit override is an operator restriction, also over a paid plan. + ["pro", "free", "free"], + // A lower paid grant never hides a paid Max; a restriction still does. + ["max", "pro", "max"], + ["max", "team", "max"], + ["max", "founders", "max"], + ["max", null, "max"], + ["max", "free", "free"], + ["go", "max", "max"], + [" Pro ", null, "pro"], + // Classic compares the Max exception exactly, then trims and lowercases the winner; a blank override restricts. + ["max", " pro ", "pro"], + ["pro", " ", "free"] + ] as const)("cmuxPlan %s with cmuxVmPlan %s is %s", (plan, vm, want) => { + expect(cloudPlanId(plan, vm)).toBe(want) + }) + + it.each([ + [{}, null], + [{ cmuxVmPlan: null }, null], + [{ cmuxVmPlan: "pro" }, "pro"], + [{ cmuxVmPlan: "" }, ""], + [{ cmuxVmPlan: " " }, " "], + [{ cmuxVmPlan: 3 }, ""], + [{ cmuxVmPlan: "x".repeat(65) }, ""], + [null, null], + [[], null] + ] as const)("the sign-in record %j reads as cmuxVmPlan %j", (metadata, want) => { + expect(readVmPlanOverride(metadata)).toBe(want) + }) + + it("a blank or malformed cmuxVmPlan read from the record restricts a paid plan, as in classic", () => { + expect(cloudPlanId("pro", readVmPlanOverride({ cmuxVmPlan: "" }))).toBe("free") + expect(cloudPlanId("max", readVmPlanOverride({ cmuxVmPlan: 7 }))).toBe("free") + expect(cloudPlanId("pro", readVmPlanOverride({}))).toBe("pro") + }) + + it("a restriction wins over the stub plan on an allowlisted team; no plan keeps the stub", () => { + const config = (plan: string | null): CloudConfig => ({ environment: "staging", allowedTeams: new Set(["team_a"]), prefix: null, image: null, entitlements: { enabled: true, plan: () => plan } }) + expect(teamPlan(config(CLOUD_RESTRICTED), "team_a")).toBeNull() + expect(teamPlan(config(null), "team_a")).toEqual(STUB_PLAN) + expect(teamPlan(config("pro"), "team_a")).toEqual(PAID_PLAN_LIMITS.pro) + }) + + it("the company domains are web's admin domains", () => { + const list = /export const ADMIN_EMAIL_DOMAINS = \[([^\]]+)\]/.exec(webAccessText as string)?.[1] + expect(list, "web ADMIN_EMAIL_DOMAINS not found").toBeDefined() + expect(new Set(list!.split(",").map((d) => d.trim().replace(/^"|"$/g, "")).filter(Boolean))).toEqual(COMPANY_EMAIL_DOMAINS) + }) + + it("the company grant is Team (Max for a Max owner) with no machine cap", () => { + expect(companyPlanKey(false, "pro")).toBeNull() + expect(paidPlanLimits(companyPlanKey(true, null))).toEqual({ ...PAID_PLAN_LIMITS.team, max_active: COMPANY_MAX_ACTIVE }) + expect(paidPlanLimits(companyPlanKey(true, "pro"))).toEqual({ ...PAID_PLAN_LIMITS.team, max_active: COMPANY_MAX_ACTIVE }) + expect(paidPlanLimits(companyPlanKey(true, "max"))).toEqual({ ...PAID_PLAN_LIMITS.max, max_active: COMPANY_MAX_ACTIVE }) + }) + + it.each([ + ["a@manaflow.ai", true], + ["a.b+c@CMUX.com", true], + [" a@manaflow.com ", true], + ["a@example.com", false], + ['"x"@cmux.com', false], + ["a b@cmux.com", false], + ["a@b@cmux.com", false], + ["a@sub.manaflow.ai", false], + ["a@manaflow.ai.", false], + ["a@manaflow.ai.evil.com", false], + ["@cmux.com", false], + [null, false] + ] as const)("isCompanyEmail(%s) is %s (classic isAdminEmail)", (email, want) => { + expect(isCompanyEmail(email)).toBe(want) + }) +}) + +describe("Cloud machine grants", { timeout: 60_000 }, () => { + it("a cmuxVmPlan grant read from the sign-in service gives its limits; the model router still sees no paid plan", async () => { + const x = await person("grant-vm-pro", null, null) + // The grant copy goes stale; TeamDO reads it from the sign-in service (the owner's user record). + await inDO(teamDO(x.team), async (i) => { + i.ctx.storage.sql.exec(`UPDATE cloud_vm_plan SET checked_at = 0 WHERE id = 1`) + i.stack = { getUserPlan: async () => null, getUserVmPlan: async () => ({ vm_plan: "pro" }), getTeam: async () => null } + }) + expect(await planGet(x.t)).toMatchObject({ plan_id: "pro", limits: { max_active: 5 } }) + const r = await create(x.t) + expect(r.ok, JSON.stringify(r)).toBe(true) + // Billing is untouched: the paid gate the model router and credits read stays unpaid. + const answer = await (teamDO(x.team) as unknown as { inferencePlan(team: string, user: string, stackUser?: string): Promise }).inferencePlan(x.team, userIdFor(testEnv.STACK_PROJECT_ID, "grant-vm-pro"), "grant-vm-pro") + expect(answer).toMatchObject({ ok: true, paid: false }) + }) + + it("cmuxVmPlan free is an operator restriction over a paid plan", async () => { + const x = await person("grant-vm-free", "pro", "free") + expect(code(await create(x.t))).toBe("cloud.plan.required") + expect(await planGet(x.t)).toMatchObject({ plan_id: "none" }) + }) + + it("a paid Max keeps Max over a lower grant", async () => { + const x = await person("grant-vm-max", "max", "pro") + expect(await planGet(x.t)).toMatchObject({ plan_id: "max", limits: { max_active: 20 } }) + }) + + it("a stale grant copy answers while the sign-in service is down", async () => { + const x = await person("grant-vm-outage", null, "pro") + await inDO(teamDO(x.team), async (i) => { + i.ctx.storage.sql.exec(`UPDATE cloud_vm_plan SET checked_at = 0 WHERE id = 1`) + i.stack = { getUserPlan: async () => { throw new Error("stack down") }, getUserVmPlan: async () => { throw new Error("stack down") }, getTeam: async () => { throw new Error("stack down") } } + }) + expect(await planGet(x.t)).toMatchObject({ plan_id: "pro" }) + }) + + it("a shared team's cmuxVmPlan applies to the team", async () => { + const t = await mirroredTeam("Grant Co") + await cloudStub(t.team).fakeControl({ entitlements: "on" }) + await inDO(teamStub(t.team), async (i) => { + storePlan(i.ctx.storage.sql, null, Date.now()) + storeVmPlan(i.ctx.storage.sql, "team", Date.now()) + }) + const got = (await call(t.token, "/v1/read", { op: "cloud.plan.get", params: {} }, t.team)).body + expect(got.value).toMatchObject({ plan_id: "team", limits: { max_active: 5 } }) + }) + + it("a verified company account's personal team is Team with no machine cap", async () => { + const x = await person("grant-company", null, null, "grant-company@manaflow.ai", true) + expect(await planGet(x.t)).toMatchObject({ plan_id: "team", upgrade_plan: null, limits: { max_active: COMPANY_MAX_ACTIVE, memory_options_mb: [4096, 8192, 16384] } }) + for (let i = 0; i < 6; i++) expect((await create(x.t)).ok).toBe(true) + }) + + it("a verified company account that pays for Max gets Max sizes, uncapped", async () => { + const x = await person("grant-company-max", "max", null, "grant-company-max@cmux.com", true) + expect(await planGet(x.t)).toMatchObject({ plan_id: "max", limits: { max_active: COMPANY_MAX_ACTIVE } }) + expect((await create(x.t, { cpu: 16, memory_mb: 32768, disk_mb: 65536 })).ok).toBe(true) + }) + + it("an empty cmuxVmPlan read from the sign-in service restricts a paid Pro", async () => { + const x = await person("grant-vm-empty", "pro", null) + await inDO(teamDO(x.team), async (i) => { + i.ctx.storage.sql.exec(`UPDATE cloud_vm_plan SET checked_at = 0 WHERE id = 1`) + i.stack = { getUserPlan: async () => "pro", getUserVmPlan: async () => ({ vm_plan: readVmPlanOverride({ cmuxVmPlan: "" }) }), getTeam: async () => null } + }) + expect(await planGet(x.t)).toMatchObject({ plan_id: "none" }) + expect(code(await create(x.t))).toBe("cloud.plan.required") + }) + + it("a padded cmuxPlan counts for machines as in classic, while the router's exact paid gate is unchanged", async () => { + const x = await person("grant-padded-max", " Max ", null, "grant-padded-max@manaflow.ai", true) + expect(await planGet(x.t)).toMatchObject({ plan_id: "max", limits: { max_active: COMPANY_MAX_ACTIVE } }) + const y = await person("grant-padded-pro", " Pro ", null) + expect(await planGet(y.t)).toMatchObject({ plan_id: "pro" }) + const answer = await (teamDO(y.team) as unknown as { inferencePlan(team: string, user: string, stackUser?: string): Promise }).inferencePlan(y.team, userIdFor(testEnv.STACK_PROJECT_ID, "grant-padded-pro"), "grant-padded-pro") + expect(answer).toMatchObject({ ok: true, paid: false }) + }) + + it("an older grant answer never overwrites a newer one", async () => { + const x = await person("grant-vm-order", null, null) + await inDO(teamDO(x.team), async (i) => { + const sql = i.ctx.storage.sql as SqlStorage + sql.exec(`DELETE FROM cloud_vm_plan`) + storeVmPlan(sql, "pro", 2_000) + storeVmPlan(sql, "free", 1_000) + expect(sql.exec<{ vm_plan: string; checked_at: number }>(`SELECT vm_plan, checked_at FROM cloud_vm_plan WHERE id = 1`).one()).toEqual({ vm_plan: "pro", checked_at: 2_000 }) + expect(await cloudVmPlanOf(sql, null, x.team, undefined, "grant-vm-order", 2_000 + 1)).toEqual({ ok: true, vm_plan: "pro" }) + }) + }) + + it.each(["company", "company_max", "user_gone", "enterprise"])("a cmuxVmPlan of %s on the record is an unknown plan: it restricts and grants nothing", async (value) => { + const x = await person(`grant-vm-unknown-${value}`, "pro", null) + await inDO(teamDO(x.team), async (i) => { + i.ctx.storage.sql.exec(`UPDATE cloud_vm_plan SET checked_at = 0 WHERE id = 1`) + i.stack = { getUserPlan: async () => "pro", getUserVmPlan: async () => ({ vm_plan: value }), getTeam: async () => null } + }) + expect(await planGet(x.t)).toMatchObject({ plan_id: "none" }) + expect(code(await create(x.t))).toBe("cloud.plan.required") + }) + + it("a company account's tier follows its plan after cmuxVmPlan, as classic's userBillingPlanId does (web services/vms/auth.ts)", async () => { + const x = await person("grant-company-vm-max", "pro", "max", "grant-company-vm-max@manaflow.ai", true) + expect(await planGet(x.t)).toMatchObject({ plan_id: "max", limits: { max_active: COMPANY_MAX_ACTIVE } }) + const y = await person("grant-company-vm-free", "max", "free", "grant-company-vm-free@manaflow.ai", true) + expect(await planGet(y.t)).toMatchObject({ plan_id: "team", limits: { max_active: COMPANY_MAX_ACTIVE } }) + }) + + it("an older entitlement read never overwrites a newer one (concurrent refreshes)", async () => { + const x = await person("grant-ent-order", null, null) + await inDO(cloud.CLOUD_DO.get(cloud.CLOUD_DO.idFromName(x.team)), async (i) => { + const store = new CloudEntitlementStore(i.ctx.storage.sql, env as never, () => 0) + store.record("team_order", "pro", 2_000) + store.record("team_order", "max", 1_000) + expect(store.plan("team_order")).toBe("pro") + store.record("team_order", null, 1_500) + expect(store.plan("team_order")).toBe("pro") + expect(new CloudEntitlementStore(i.ctx.storage.sql, env as never, () => 0).plan("team_order")).toBe("pro") + store.record("team_order", null, 3_000) + expect(new CloudEntitlementStore(i.ctx.storage.sql, env as never, () => 0).plan("team_order")).toBeNull() + }) + }) + + it("an unverified company address gets no grant", async () => { + const x = await person("grant-company-unverified", null, null, "grant-company-unverified@manaflow.ai", false) + expect(code(await create(x.t))).toBe("cloud.plan.required") + }) +}) diff --git a/backend/apps/api/test/team-stack-support.ts b/backend/apps/api/test/team-stack-support.ts index 0d019481e12c..fc6cbacc3b8e 100644 --- a/backend/apps/api/test/team-stack-support.ts +++ b/backend/apps/api/test/team-stack-support.ts @@ -37,6 +37,7 @@ export const stackWorld = () => { return teams.has(t) ? { display_name: teams.get(t)!, plan: null } : null }, getUserPlan: async () => null, + getUserVmPlan: async () => ({ vm_plan: null }), listTeamMembers: async (t: string) => { calls++ if (!teams.has(t)) return "team_gone" as const