From a267eedc606b3970bc1fa55ced90b41001d0fb74 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 02:27:24 -0700 Subject: [PATCH 1/7] Cloud plan entitlements honour cmuxVmPlan and company account grants, as classic Cloud does NEXT Cloud read only cmuxPlan, so operator grants (cmuxVmPlan) and verified company-domain accounts (classic #21276) got no machines. CloudDO now reads cmuxVmPlan through TeamDO (cached, last copy kept on failure) and applies classic's precedence; a verified company account's personal team is Team (Max for a Max owner) with no machine cap. Machine limits only: the model router's paid gate and billing keep reading cmuxPlan. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- backend/apps/api/src/cloud-entitlements.ts | 24 ++- backend/apps/api/src/cloud-vm-plan.ts | 50 ++++++ backend/apps/api/src/domains/cloud-plan.ts | 43 ++++- backend/apps/api/src/stack-server.ts | 19 ++- backend/apps/api/src/team-do.ts | 1 + .../api/test/cloud-plan-entitlements.test.ts | 7 +- .../api/test/cloud-plan-overrides.test.ts | 152 ++++++++++++++++++ backend/apps/api/test/team-stack-support.ts | 1 + 8 files changed, 281 insertions(+), 16 deletions(-) create mode 100644 backend/apps/api/src/cloud-vm-plan.ts create mode 100644 backend/apps/api/test/cloud-plan-overrides.test.ts diff --git a/backend/apps/api/src/cloud-entitlements.ts b/backend/apps/api/src/cloud-entitlements.ts index 9deca79191fb..c5366dea7c17 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 { cloudPlanId, companyPlanKey, paidPlanLimits, type CloudEntitlements } from "./domains/cloud-plan.ts" import { personalTeamIdFor } from "./domains/user.ts" import type { Env } from "./env.ts" @@ -54,16 +54,26 @@ 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 domain = personal ? await users.homeVerifiedEmailDomain(user) : null + if (personal && !stackUser) return companyPlanKey(domain, 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 + const own = cloudPlanId(answer.paid ? (answer.plan ?? null) : null, grant.vm_plan) + return companyPlanKey(domain, own) ?? (paidPlanLimits(own) ? own : null) } private record(team: string, plan: string | null, at: number) { 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..3a1366e62253 --- /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) => (v === "user_gone" ? null : v))(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). */ +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`, vmPlan, at) +} diff --git a/backend/apps/api/src/domains/cloud-plan.ts b/backend/apps/api/src/domains/cloud-plan.ts index 0ec49ceaffe8..29eb391f2f90 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,45 @@ 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, as classic Cloud reads it (web + * services/billing/teamResolution.ts billingPlanIdFromMetadata): the operator's `cmuxVmPlan` wins + * over the billing `cmuxPlan`, except that a lower paid grant never hides a paid Max. An explicit + * `cmuxVmPlan` such as "free" is an operator restriction and stands. Machine limits only: the model + * router and billing keep reading `cmuxPlan` alone. + */ +export const cloudPlanId = (plan: string | null, vmPlan: string | null): string | null => { + const norm = (v: string | null) => (v && v.trim() ? v.trim().toLowerCase() : null) + const paid = norm(plan) + const override = norm(vmPlan) + if (paid === "max" && KEEPS_MAX.has(override)) return "max" + return override ?? paid +} + +/** The company grant's entitlement key for a verified email domain, or null; Max stays Max (classic resolveVmEntitlements). */ +export const companyPlanKey = (verifiedDomain: string | null, ownPlan: string | null): string | null => + verifiedDomain && COMPANY_EMAIL_DOMAINS.has(verifiedDomain.toLowerCase()) ? (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 { diff --git a/backend/apps/api/src/stack-server.ts b/backend/apps/api/src/stack-server.ts index f261e95b3f36..e32d8ef6440d 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), null when absent, "user_gone" for USER_NOT_FOUND. Cloud machine limits only. */ + getUserVmPlan(userId: string): Promise /** * 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 @@ -61,12 +64,12 @@ export interface StackMember { const API = "https://api.stack-auth.com/api/v1" /** - * `cmuxPlan` from client_read_only_metadata only: the server and web billing write it, a signed-in + * `cmuxPlan` (or the operator's `cmuxVmPlan`) from client_read_only_metadata only: the server and web billing write it, a signed-in * client cannot. client_metadata (client-writable) and server_metadata are never read for a plan. */ -const readOnlyPlan = (metadata: unknown): string | null => { +const readOnlyPlan = (metadata: unknown, key: "cmuxPlan" | "cmuxVmPlan" = "cmuxPlan"): string | null => { if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null - const plan = (metadata as Record).cmuxPlan + const plan = (metadata as Record)[key] return typeof plan === "string" && plan.length > 0 && plan.length <= 64 ? plan : null } @@ -156,12 +159,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: readOnlyPlan(r.client_read_only_metadata, "cmuxVmPlan"), 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) ? "user_gone" : readOnlyPlan(r.client_read_only_metadata, "cmuxVmPlan") + }, 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/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..b85b6bdb4760 --- /dev/null +++ b/backend/apps/api/test/cloud-plan-overrides.test.ts @@ -0,0 +1,152 @@ +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 { storeVmPlan } from "../src/cloud-vm-plan.ts" +import { cloudPlanId, COMPANY_EMAIL_DOMAINS, COMPANY_MAX_ACTIVE, companyPlanKey, PAID_PLAN_LIMITS, paidPlanLimits } from "../src/domains/cloud-plan.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"] + ] as const)("cmuxPlan %s with cmuxVmPlan %s is %s", (plan, vm, want) => { + expect(cloudPlanId(plan, vm)).toBe(want) + }) + + 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("example.com", "pro")).toBeNull() + expect(companyPlanKey(null, null)).toBeNull() + expect(paidPlanLimits(companyPlanKey("manaflow.ai", null))).toEqual({ ...PAID_PLAN_LIMITS.team, max_active: COMPANY_MAX_ACTIVE }) + expect(paidPlanLimits(companyPlanKey("CMUX.com", "pro"))).toEqual({ ...PAID_PLAN_LIMITS.team, max_active: COMPANY_MAX_ACTIVE }) + expect(paidPlanLimits(companyPlanKey("manaflow.com", "max"))).toEqual({ ...PAID_PLAN_LIMITS.max, max_active: COMPANY_MAX_ACTIVE }) + }) +}) + +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 () => "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 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..5d98b5303fe7 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 () => null, listTeamMembers: async (t: string) => { calls++ if (!teams.has(t)) return "team_gone" as const From 932f1b36a2453897c54b4ccbdc94b90fcd2c1641 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 02:49:51 -0700 Subject: [PATCH 2/7] Cloud plan overrides: exact classic precedence (blank override restricts, Max exception on exact values) and classic's company email check Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- backend/apps/api/src/cloud-entitlements.ts | 6 +-- backend/apps/api/src/domains/cloud-plan.ts | 43 +++++++++++++------ backend/apps/api/src/user-do.ts | 6 +++ .../api/test/cloud-plan-overrides.test.ts | 33 +++++++++++--- 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/backend/apps/api/src/cloud-entitlements.ts b/backend/apps/api/src/cloud-entitlements.ts index c5366dea7c17..023b5fa6cf94 100644 --- a/backend/apps/api/src/cloud-entitlements.ts +++ b/backend/apps/api/src/cloud-entitlements.ts @@ -67,13 +67,13 @@ export class CloudEntitlementStore implements CloudEntitlements { 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 domain = personal ? await users.homeVerifiedEmailDomain(user) : null - if (personal && !stackUser) return companyPlanKey(domain, null) + 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 const own = cloudPlanId(answer.paid ? (answer.plan ?? null) : null, grant.vm_plan) - return companyPlanKey(domain, own) ?? (paidPlanLimits(own) ? own : null) + return companyPlanKey(company, own) ?? (paidPlanLimits(own) ? own : null) } private record(team: string, plan: string | null, at: number) { diff --git a/backend/apps/api/src/domains/cloud-plan.ts b/backend/apps/api/src/domains/cloud-plan.ts index 29eb391f2f90..298ea7bd305a 100644 --- a/backend/apps/api/src/domains/cloud-plan.ts +++ b/backend/apps/api/src/domains/cloud-plan.ts @@ -90,23 +90,40 @@ export const paidPlanLimits = (plan: string | null | undefined): PlanLimits | nu const KEEPS_MAX: ReadonlySet = new Set([null, "pro", "team", "founders", "max"]) /** - * The Cloud plan id of one sign-in service record, as classic Cloud reads it (web - * services/billing/teamResolution.ts billingPlanIdFromMetadata): the operator's `cmuxVmPlan` wins - * over the billing `cmuxPlan`, except that a lower paid grant never hides a paid Max. An explicit - * `cmuxVmPlan` such as "free" is an operator restriction and stands. Machine limits only: the model - * router and billing keep reading `cmuxPlan` alone. + * 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 => { - const norm = (v: string | null) => (v && v.trim() ? v.trim().toLowerCase() : null) - const paid = norm(plan) - const override = norm(vmPlan) - if (paid === "max" && KEEPS_MAX.has(override)) return "max" - return override ?? paid + 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" } -/** The company grant's entitlement key for a verified email domain, or null; Max stays Max (classic resolveVmEntitlements). */ -export const companyPlanKey = (verifiedDomain: string | null, ownPlan: string | null): string | null => - verifiedDomain && COMPANY_EMAIL_DOMAINS.has(verifiedDomain.toLowerCase()) ? (ownPlan === "max" ? "company_max" : "company") : null +/** + * 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 { 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-overrides.test.ts b/backend/apps/api/test/cloud-plan-overrides.test.ts index b85b6bdb4760..cc57ceb129e0 100644 --- a/backend/apps/api/test/cloud-plan-overrides.test.ts +++ b/backend/apps/api/test/cloud-plan-overrides.test.ts @@ -4,7 +4,7 @@ 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 { storeVmPlan } from "../src/cloud-vm-plan.ts" -import { cloudPlanId, COMPANY_EMAIL_DOMAINS, COMPANY_MAX_ACTIVE, companyPlanKey, PAID_PLAN_LIMITS, paidPlanLimits } from "../src/domains/cloud-plan.ts" +import { cloudPlanId, COMPANY_EMAIL_DOMAINS, COMPANY_MAX_ACTIVE, companyPlanKey, isCompanyEmail, PAID_PLAN_LIMITS, paidPlanLimits } from "../src/domains/cloud-plan.ts" import { userIdFor } from "../src/domains/user.ts" import { storePlan } from "../src/inference/plan.ts" import { inDO, post, testEnv } from "./setup/inference-person.ts" @@ -66,7 +66,10 @@ describe("cloudPlanId: classic's billingPlanIdFromMetadata", () => { ["max", null, "max"], ["max", "free", "free"], ["go", "max", "max"], - [" Pro ", null, "pro"] + [" 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) }) @@ -78,11 +81,27 @@ describe("cloudPlanId: classic's billingPlanIdFromMetadata", () => { }) it("the company grant is Team (Max for a Max owner) with no machine cap", () => { - expect(companyPlanKey("example.com", "pro")).toBeNull() - expect(companyPlanKey(null, null)).toBeNull() - expect(paidPlanLimits(companyPlanKey("manaflow.ai", null))).toEqual({ ...PAID_PLAN_LIMITS.team, max_active: COMPANY_MAX_ACTIVE }) - expect(paidPlanLimits(companyPlanKey("CMUX.com", "pro"))).toEqual({ ...PAID_PLAN_LIMITS.team, max_active: COMPANY_MAX_ACTIVE }) - expect(paidPlanLimits(companyPlanKey("manaflow.com", "max"))).toEqual({ ...PAID_PLAN_LIMITS.max, max_active: COMPANY_MAX_ACTIVE }) + 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) }) }) From e4520147e15c9592a9226c203955d854fe3d7acd Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 03:10:08 -0700 Subject: [PATCH 3/7] Cloud plan overrides: a blank or malformed cmuxVmPlan restricts, and Cloud reads the raw cmuxPlan as classic does (router gate unchanged) Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- backend/apps/api/src/cloud-entitlements.ts | 3 +- backend/apps/api/src/stack-server.ts | 24 ++++++++--- .../api/test/cloud-plan-overrides.test.ts | 40 +++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/backend/apps/api/src/cloud-entitlements.ts b/backend/apps/api/src/cloud-entitlements.ts index 023b5fa6cf94..ac07d87dd160 100644 --- a/backend/apps/api/src/cloud-entitlements.ts +++ b/backend/apps/api/src/cloud-entitlements.ts @@ -72,7 +72,8 @@ export class CloudEntitlementStore implements CloudEntitlements { 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 - const own = cloudPlanId(answer.paid ? (answer.plan ?? null) : null, grant.vm_plan) + // 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) return companyPlanKey(company, own) ?? (paidPlanLimits(own) ? own : null) } diff --git a/backend/apps/api/src/stack-server.ts b/backend/apps/api/src/stack-server.ts index e32d8ef6440d..6de98a1db464 100644 --- a/backend/apps/api/src/stack-server.ts +++ b/backend/apps/api/src/stack-server.ts @@ -64,15 +64,29 @@ export interface StackMember { const API = "https://api.stack-auth.com/api/v1" /** - * `cmuxPlan` (or the operator's `cmuxVmPlan`) from client_read_only_metadata only: the server and web billing write it, a signed-in + * `cmuxPlan` from client_read_only_metadata only: the server and web billing write it, a signed-in * client cannot. client_metadata (client-writable) and server_metadata are never read for a plan. */ -const readOnlyPlan = (metadata: unknown, key: "cmuxPlan" | "cmuxVmPlan" = "cmuxPlan"): string | null => { +const readOnlyPlan = (metadata: unknown): string | null => { if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null - const plan = (metadata as Record)[key] + const plan = (metadata as Record).cmuxPlan 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 @@ -159,7 +173,7 @@ 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_plan: readOnlyPlan(r.client_read_only_metadata, "cmuxVmPlan"), 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"]) @@ -167,7 +181,7 @@ export const stackServer = ( }, getUserVmPlan: 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, "cmuxVmPlan") + return isGone(r) ? "user_gone" : readVmPlanOverride(r.client_read_only_metadata) }, listTeamMembers: async (teamId) => { const out: Array<{ user_id: string; display_name: string | null; permissions: ReadonlyArray; anonymous: boolean }> = [] diff --git a/backend/apps/api/test/cloud-plan-overrides.test.ts b/backend/apps/api/test/cloud-plan-overrides.test.ts index cc57ceb129e0..7aab73a7fdd8 100644 --- a/backend/apps/api/test/cloud-plan-overrides.test.ts +++ b/backend/apps/api/test/cloud-plan-overrides.test.ts @@ -4,6 +4,7 @@ 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 { storeVmPlan } from "../src/cloud-vm-plan.ts" +import { readVmPlanOverride } from "../src/stack-server.ts" import { cloudPlanId, COMPANY_EMAIL_DOMAINS, COMPANY_MAX_ACTIVE, companyPlanKey, isCompanyEmail, PAID_PLAN_LIMITS, paidPlanLimits } from "../src/domains/cloud-plan.ts" import { userIdFor } from "../src/domains/user.ts" import { storePlan } from "../src/inference/plan.ts" @@ -74,6 +75,26 @@ describe("cloudPlanId: classic's billingPlanIdFromMetadata", () => { 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("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() @@ -164,6 +185,25 @@ describe("Cloud machine grants", { timeout: 60_000 }, () => { 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 () => 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 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") From c467b070cf7ea413db3af504e7ef918dedc5f41a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 03:32:35 -0700 Subject: [PATCH 4/7] Cloud plan overrides: a restriction also blocks the stub plan; an older grant read never overwrites a newer one Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- backend/apps/api/src/cloud-entitlements.ts | 5 ++-- backend/apps/api/src/cloud-vm-plan.ts | 4 ++-- backend/apps/api/src/domains/cloud-plan.ts | 14 ++++++++--- .../api/test/cloud-plan-overrides.test.ts | 23 +++++++++++++++++-- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/backend/apps/api/src/cloud-entitlements.ts b/backend/apps/api/src/cloud-entitlements.ts index ac07d87dd160..7751c2a58a3f 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 { cloudPlanId, companyPlanKey, paidPlanLimits, type CloudEntitlements } from "./domains/cloud-plan.ts" +import { CLOUD_RESTRICTED, cloudPlanId, companyPlanKey, paidPlanLimits, type CloudEntitlements } from "./domains/cloud-plan.ts" import { personalTeamIdFor } from "./domains/user.ts" import type { Env } from "./env.ts" @@ -74,7 +74,8 @@ export class CloudEntitlementStore implements CloudEntitlements { 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) - return companyPlanKey(company, own) ?? (paidPlanLimits(own) ? own : null) + // A set plan that grants nothing (a restriction, an unknown id) is kept as such: it also blocks the stub plan. + return companyPlanKey(company, own) ?? (own === null ? null : paidPlanLimits(own) ? own : CLOUD_RESTRICTED) } private record(team: string, plan: string | null, at: number) { diff --git a/backend/apps/api/src/cloud-vm-plan.ts b/backend/apps/api/src/cloud-vm-plan.ts index 3a1366e62253..064448e60eab 100644 --- a/backend/apps/api/src/cloud-vm-plan.ts +++ b/backend/apps/api/src/cloud-vm-plan.ts @@ -43,8 +43,8 @@ export const cloudVmPlanOf = async (sql: SqlStorage, state: TeamState | null, en 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). */ +/** 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`, vmPlan, at) + 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 298ea7bd305a..30029d9f5848 100644 --- a/backend/apps/api/src/domains/cloud-plan.ts +++ b/backend/apps/api/src/domains/cloud-plan.ts @@ -155,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/test/cloud-plan-overrides.test.ts b/backend/apps/api/test/cloud-plan-overrides.test.ts index 7aab73a7fdd8..2b1758f24fee 100644 --- a/backend/apps/api/test/cloud-plan-overrides.test.ts +++ b/backend/apps/api/test/cloud-plan-overrides.test.ts @@ -3,9 +3,9 @@ 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 { storeVmPlan } from "../src/cloud-vm-plan.ts" +import { cloudVmPlanOf, storeVmPlan } from "../src/cloud-vm-plan.ts" import { readVmPlanOverride } from "../src/stack-server.ts" -import { cloudPlanId, COMPANY_EMAIL_DOMAINS, COMPANY_MAX_ACTIVE, companyPlanKey, isCompanyEmail, PAID_PLAN_LIMITS, paidPlanLimits } from "../src/domains/cloud-plan.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 { userIdFor } from "../src/domains/user.ts" import { storePlan } from "../src/inference/plan.ts" import { inDO, post, testEnv } from "./setup/inference-person.ts" @@ -95,6 +95,13 @@ describe("cloudPlanId: classic's billingPlanIdFromMetadata", () => { 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() @@ -204,6 +211,18 @@ describe("Cloud machine grants", { timeout: 60_000 }, () => { 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("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") From 561f4a2be07db838142b88581ab88fb3c634feff Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 03:37:54 -0700 Subject: [PATCH 5/7] Cloud plan overrides: internal company keys are never read from metadata; a missing user is a tagged result, not a plan string Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- backend/apps/api/src/cloud-entitlements.ts | 5 +++-- backend/apps/api/src/cloud-vm-plan.ts | 2 +- backend/apps/api/src/stack-server.ts | 6 +++--- backend/apps/api/test/cloud-plan-overrides.test.ts | 14 ++++++++++++-- backend/apps/api/test/team-stack-support.ts | 2 +- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/backend/apps/api/src/cloud-entitlements.ts b/backend/apps/api/src/cloud-entitlements.ts index 7751c2a58a3f..e7be52647988 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 { CLOUD_RESTRICTED, cloudPlanId, companyPlanKey, paidPlanLimits, 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" @@ -75,7 +75,8 @@ export class CloudEntitlementStore implements CloudEntitlements { // 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. - return companyPlanKey(company, own) ?? (own === null ? null : paidPlanLimits(own) ? own : CLOUD_RESTRICTED) + // 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) { diff --git a/backend/apps/api/src/cloud-vm-plan.ts b/backend/apps/api/src/cloud-vm-plan.ts index 064448e60eab..d126c6e7ff9b 100644 --- a/backend/apps/api/src/cloud-vm-plan.ts +++ b/backend/apps/api/src/cloud-vm-plan.ts @@ -20,7 +20,7 @@ export const cloudVmPlanOf = async (sql: SqlStorage, state: TeamState | null, en 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) => (v === "user_gone" ? null : v))(await s.getUserVmPlan(stackUser)) + 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 diff --git a/backend/apps/api/src/stack-server.ts b/backend/apps/api/src/stack-server.ts index 6de98a1db464..ce11e0ab1046 100644 --- a/backend/apps/api/src/stack-server.ts +++ b/backend/apps/api/src/stack-server.ts @@ -31,8 +31,8 @@ export interface StackServer { * 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), null when absent, "user_gone" for USER_NOT_FOUND. Cloud machine limits only. */ - getUserVmPlan(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 @@ -181,7 +181,7 @@ export const stackServer = ( }, getUserVmPlan: async (userId) => { const r = await lookup<{ client_read_only_metadata?: unknown }>(`/users/${encodeURIComponent(userId)}`, "user", ["USER_NOT_FOUND"]) - return isGone(r) ? "user_gone" : readVmPlanOverride(r.client_read_only_metadata) + 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 }> = [] diff --git a/backend/apps/api/test/cloud-plan-overrides.test.ts b/backend/apps/api/test/cloud-plan-overrides.test.ts index 2b1758f24fee..836e0100c19b 100644 --- a/backend/apps/api/test/cloud-plan-overrides.test.ts +++ b/backend/apps/api/test/cloud-plan-overrides.test.ts @@ -139,7 +139,7 @@ describe("Cloud machine grants", { timeout: 60_000 }, () => { // 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 () => "pro", getTeam: async () => null } + 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) @@ -196,7 +196,7 @@ describe("Cloud machine grants", { timeout: 60_000 }, () => { 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 () => readVmPlanOverride({ cmuxVmPlan: "" }), getTeam: async () => null } + 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") @@ -223,6 +223,16 @@ describe("Cloud machine grants", { timeout: 60_000 }, () => { }) }) + 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("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 5d98b5303fe7..fc6cbacc3b8e 100644 --- a/backend/apps/api/test/team-stack-support.ts +++ b/backend/apps/api/test/team-stack-support.ts @@ -37,7 +37,7 @@ export const stackWorld = () => { return teams.has(t) ? { display_name: teams.get(t)!, plan: null } : null }, getUserPlan: async () => null, - getUserVmPlan: async () => null, + getUserVmPlan: async () => ({ vm_plan: null }), listTeamMembers: async (t: string) => { calls++ if (!teams.has(t)) return "team_gone" as const From 22c4cb64a9b675e95d32319a720d0108e08c41aa Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 03:54:12 -0700 Subject: [PATCH 6/7] Cloud plan overrides: test that a company account's tier follows its plan after cmuxVmPlan, as in classic Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- backend/apps/api/test/cloud-plan-overrides.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/apps/api/test/cloud-plan-overrides.test.ts b/backend/apps/api/test/cloud-plan-overrides.test.ts index 836e0100c19b..3fd6691fff83 100644 --- a/backend/apps/api/test/cloud-plan-overrides.test.ts +++ b/backend/apps/api/test/cloud-plan-overrides.test.ts @@ -233,6 +233,13 @@ describe("Cloud machine grants", { timeout: 60_000 }, () => { 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 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") From f67e4bfc2c85de638b44fefa2f4109ecb9a960ab Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 04:11:00 -0700 Subject: [PATCH 7/7] Cloud entitlements: an older read never overwrites a newer one in CloudDO's plan copy Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-4ba8980ae4e8eecb82d6 --- backend/apps/api/src/cloud-entitlements.ts | 9 ++++++--- .../apps/api/test/cloud-plan-overrides.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/backend/apps/api/src/cloud-entitlements.ts b/backend/apps/api/src/cloud-entitlements.ts index e7be52647988..189ed79e9b74 100644 --- a/backend/apps/api/src/cloud-entitlements.ts +++ b/backend/apps/api/src/cloud-entitlements.ts @@ -79,13 +79,16 @@ export class CloudEntitlementStore implements CloudEntitlements { 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/test/cloud-plan-overrides.test.ts b/backend/apps/api/test/cloud-plan-overrides.test.ts index 3fd6691fff83..6ef6585f522c 100644 --- a/backend/apps/api/test/cloud-plan-overrides.test.ts +++ b/backend/apps/api/test/cloud-plan-overrides.test.ts @@ -6,6 +6,7 @@ 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" @@ -240,6 +241,21 @@ describe("Cloud machine grants", { timeout: 60_000 }, () => { 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")