Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/app/api/feedback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ export async function POST(request: Request) {
}
}

const contentType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
if (contentType !== "multipart/form-data") {
return jsonError("Invalid multipart payload", 415);
}

let formData: FormData;
try {
formData = await request.formData();
Expand Down
2 changes: 2 additions & 0 deletions web/scripts/cloud-vm/projects.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const requiredRuntimeEnvKeys = [
// Freestyle is the production default provider (CMUX_VM_DEFAULT_PROVIDER):
// without credentials and a snapshot selector every create 503s.
"CMUX_VM_FREESTYLE_ENABLED",
"CMUX_VM_PLAN_FREE_CREATE_CREDIT_COST",
"CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Every Vercel cron (VM alerts included) refuses to run without it.
"CRON_SECRET",
// Coderouter: the usage ledger (customer-facing usage, alert source) and
Expand Down
6 changes: 3 additions & 3 deletions web/services/vms/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@ Set these Vercel environment variables per production/staging environment:
- `CMUX_VM_DEFAULT_PLAN`, optional fallback for accounts without plan metadata. It defaults to `free`;
paid values are ignored unless `CMUX_VM_ALLOW_FREE_PROVISIONING=1`, so deployment configuration
cannot silently grant every unclassified account a paid entitlement.
- `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID`, optional Stack Auth team item used as the free-plan create-credit bucket. Leave unset to skip free-plan create-credit accounting; set to `none`, `disabled`, `off`, or `false` to explicitly opt out.
- `CMUX_VM_PLAN_FREE_CREATE_CREDIT_COST`, optional free-plan per-create cost. Defaults to `1`.
- `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID`, Stack Auth team item used as the free-plan create-credit bucket. Required by the production env audit; set to a real item id to enable accounting, or `none`, `disabled`, `off`, or `false` to explicitly opt out.
- `CMUX_VM_PLAN_FREE_CREATE_CREDIT_COST`, free-plan per-create cost. Required by the production env audit; set to a positive integer when accounting is enabled, or pair with the explicit opt-out item value above.
- `CMUX_VM_PLAN_FREE_INITIAL_CREATE_CREDITS`, optional first-use seed for the free-plan Stack Auth create-credit item. Defaults to `20`.
- `CMUX_VM_CREATE_CREDIT_ITEM_ID`, optional global Stack Auth item used as a prepaid create-credit bucket for every plan without a plan-specific item. Set to `none`, `disabled`, `off`, or `false` to opt out of create credits for plans without a plan-specific value.
- `CMUX_VM_CREATE_CREDIT_COST`, default `1`.
Expand Down Expand Up @@ -642,7 +642,7 @@ machine (no env, no rule, still no secret) and must never be set in production.

## Usage, limits, and pricing

The usage ledger is in Postgres. VM create pricing gates can use Stack Auth payment items, but free-plan create credits are opt-in. Configure `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID` only when the free plan should consume a prepaid create-credit bucket. When enabled, the create workflow records a one-time local grant row, seeds the configured Stack Auth item credits once per billing team, reserves one create credit only for a newly inserted row, calls the provider, and refunds the credit if provisioning fails before a usable VM exists.
The usage ledger is in Postgres. VM create pricing gates can use Stack Auth payment items, but free-plan create credits are opt-in at runtime. Production must still configure non-empty `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID` and `CMUX_VM_PLAN_FREE_CREATE_CREDIT_COST` so the audit proves an intentional choice: a real item id enables the prepaid create-credit bucket, while `none`, `disabled`, `off`, or `false` opts out explicitly. When enabled, the create workflow records a one-time local grant row, seeds the configured Stack Auth item credits once per billing team, reserves one create credit only for a newly inserted row, calls the provider, and refunds the credit if provisioning fails before a usable VM exists.

Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows zero active VMs by default and remains at zero regardless of stale free-limit env values while the paid-plan gate is on. A deliberate `CMUX_VM_ALLOW_FREE_PROVISIONING=1` escape hatch re-enables the configured free allowance for local demos or a controlled rollback; Go is a $10/month personal starter plan with one active 2 vCPU / 4 GiB / 16 GB VM and two saved VM records. It includes 40 VM-hours per Stripe billing period. PostgreSQL runtime records commit with VM state changes, and an account-level lock limits Go to two retained VMs in total, with one active. Freestyle lifetime runtime caps enforce the remaining allowance and block restarts until cmux grants a new allowance. A minute job reconciles exhausted machines. Go disk growth beyond 16 GiB is refused; Pro and Max get the allowance sold on /pricing, 50 active machines per billing team, multiplied by the Team subscription's paid seats (`cmuxSeats` in the team's Stack metadata, written from the Stripe quantity) so "50 per paid seat" holds for the whole team (`PAID_MAX_ACTIVE_VMS_DEFAULT`; `maxActiveVms` in entitlements and the list response). New machines use validated Freestyle base snapshots from 4 GiB RAM / 16 GB disk through 64 GiB RAM / 128 GB disk, including the 24 GiB / 96 GB intermediate size. The default is 8 GiB RAM and 32 GB disk. Free, Pro, Team, and Founder's plans may start machines up to 24 GiB; the 32 GiB and 64 GiB sizes require the Max plan (`maxMemoryMbForPlan`, `lockedMemoryOptionsMbForPlan`). The list response names the locked sizes and the upgrade plan, and a create with a locked size returns `402 vm_memory_requires_plan` rather than coercing to a smaller machine. Each machine has its own CPU, memory, and disk. The repository enforces only the machine-count allowance under the billing-team lock; resource metadata supports per-machine fork, snapshot, and resize recovery. Disk growth is independent, grow-only, and capped at 256 GiB in 4 GiB steps. The Freestyle driver applies the default at create (`CMUX_VM_DISK_MB` overrides it), and the resize API reads provider stats before and after the provider confirms the change. Destroyed VMs do not count against a limit; pausing frees the active slot, while Go still counts the retained VM toward its saved limit. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata. Paid-plan `CMUX_VM_PLAN_<PLAN>_MAX_ACTIVE_VMS`, `CMUX_VM_PAID_MAX_ACTIVE_VMS`, and `CMUX_VM_SHARED_CPU_LIMIT_ENABLED` are retired and ignored. The paid allowance lives in code; `CMUX_VM_CREATE_ENABLED=0` remains the provisioning incident control. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN_<PLAN>_CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured.

Expand Down
2 changes: 1 addition & 1 deletion web/services/vms/routeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export function jsonResponse(
): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json", ...headers },
headers: { "content-type": "application/json", "cache-control": "private, no-store", ...headers },
});
}

Expand Down
32 changes: 32 additions & 0 deletions web/tests/feedback-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,38 @@ afterAll(() => {
});

describe("feedback route", () => {
test("rejects urlencoded feedback before parsing or sending email", async () => {
process.env.VERCEL = "1";
const request = new Request("https://cmux.test/api/feedback", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ email: "user@example.test", message: "hello" }),
});
const parse = mock(() => Promise.resolve(new FormData()));
request.formData = parse;

const res = await POST(request);

expect(res.status).toBe(415);
expect(await res.json()).toEqual({ error: "Invalid multipart payload" });
expect(parse).not.toHaveBeenCalled();
expect(sendEmail).not.toHaveBeenCalled();
});

test("checks the configured firewall before parsing the body", async () => {
process.env.VERCEL = "1";
checkRateLimit.mockResolvedValue({ rateLimited: true, error: "blocked" });
const request = feedbackRequest();
const parse = mock(() => Promise.resolve(new FormData()));
request.formData = parse;

const res = await POST(request);

expect(res.status).toBe(429);
expect(parse).not.toHaveBeenCalled();
expect(sendEmail).not.toHaveBeenCalled();
});

test("fails open when the Vercel firewall rule is missing", async () => {
// A deleted rule is an operator action (no limit wanted), not an outage.
process.env.VERCEL = "1";
Expand Down
2 changes: 2 additions & 0 deletions web/tests/vm-route-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ describe("VM REST auth", () => {
);

expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("private, no-store");
expect(await response.json()).toMatchObject({ ok: true, revoked: 2 });
expect(revokeUserVmAccess).toHaveBeenCalledWith({ userId: "user-signout" });
});
Expand Down Expand Up @@ -1958,6 +1959,7 @@ describe("VM REST auth", () => {
context,
);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("private, no-store");
expect(openVmCmuxRemote).toHaveBeenCalledWith({
userId: "user-1",
billingTeamId: "team-1",
Expand Down
Loading