Make hosted coderouter free for up to 3 accounts

Pricing change: a team may connect and route up to 3 provider
accounts (subscriptions) for free. More than 3 requires an active
cmux Pro or Team subscription.

- New services/coderouter/entitlement.ts: free tier checked first
  (one indexed count read); the Stripe read runs only for teams over
  the limit. Both checks fail closed.
- Session issuance: replaces the flat Pro gate. Over-limit teams
  without a subscription get 402 with the count in the message.
- Account add: connecting an account beyond the limit without a
  subscription returns 402 before anything is stored. Re-importing
  an account the team already has is always allowed, so a broken
  account stays repairable on the free tier. The accounts POST
  handler is now factory-built for dependency-injected tests.
- Billing-lapse token revocation stays unchanged: after a lapse the
  CLI renews and the free tier re-qualifies teams with <= 3 accounts.
- New analytics: entitlement_basis on route_session_issued and a
  coderouter_account_limit_reached event, both schema-whitelisted.
- CODEROUTER_HOSTED_PRO_REQUIRED=0 still disables all gating.

Boundary: exactly 3 accounts is free; the 4th needs Pro/Team.
This commit is contained in:
Lawrence Chen
2026-08-18 23:21:33 -07:00
parent ae7d761e03
commit 9ad361ed37
8 changed files with 499 additions and 28 deletions
+81 -3
View File
@@ -1,7 +1,12 @@
import { env } from "../../../env";
import {
addAccount,
parseCredential,
} from "../../../../services/coderouter/accounts";
import {
CODEROUTER_FREE_ACCOUNT_LIMIT,
accountAdditionAllowed,
} from "../../../../services/coderouter/entitlement";
import {
resolveCoderouterUsageTeam,
resolveCodeRouterRequestContext,
@@ -70,8 +75,27 @@ export async function GET(request: Request): Promise<Response> {
});
}
export async function POST(request: Request): Promise<Response> {
const resolved = await resolveCodeRouterRequestContext(request, "manage");
type AccountsPostDependencies = {
readonly resolveContext: typeof resolveCodeRouterRequestContext;
readonly additionAllowed: typeof accountAdditionAllowed;
readonly add: typeof addAccount;
readonly hostedProRequired: () => boolean;
};
const defaultAccountsPostDependencies: AccountsPostDependencies = {
resolveContext: resolveCodeRouterRequestContext,
additionAllowed: accountAdditionAllowed,
add: addAccount,
hostedProRequired: () => env.CODEROUTER_HOSTED_PRO_REQUIRED === "1",
};
export const POST = makeCoderouterAccountsPostHandler();
export function makeCoderouterAccountsPostHandler(
dependencies: AccountsPostDependencies = defaultAccountsPostDependencies,
) {
return async function POST(request: Request): Promise<Response> {
const resolved = await dependencies.resolveContext(request, "manage");
if (!resolved.ok) return resolved.response;
const length = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(length) && length > MAX_BODY_BYTES) {
@@ -91,8 +115,61 @@ export async function POST(request: Request): Promise<Response> {
if (!credential) {
return Response.json({ error: "invalid_request" }, { status: 400 });
}
if (dependencies.hostedProRequired()) {
let decision;
try {
decision = await dependencies.additionAllowed({
stackUserId: resolved.value.user.id,
teamId: resolved.value.team.teamId,
provider: credential.provider,
providerAccountId: credential.accountId,
});
} catch (error) {
reportCoderouterFailure("rds", error, {
operation: "account_addition_gate",
});
return Response.json(
{
error: "entitlement_unavailable",
message:
"coderouter could not verify your plan. Nothing was changed; retry shortly.",
retryable: true,
},
{
status: 503,
headers: { "cache-control": "no-store", "retry-after": "5" },
},
);
}
if (!decision.allowed) {
captureCoderouterEvent({
event: "coderouter_account_limit_reached",
userId: resolved.value.user.id,
teamId: resolved.value.team.teamId,
properties: {
provider: credential.provider,
account_count: decision.accountCount,
free_limit: CODEROUTER_FREE_ACCOUNT_LIMIT,
},
});
return Response.json(
{
error: "pro_required",
message:
`Free hosted coderouter covers up to ${CODEROUTER_FREE_ACCOUNT_LIMIT} connected accounts; ` +
`this team already has ${decision.accountCount}. ` +
"Upgrade to cmux Pro or Team to connect more, or remove an account first.",
retryable: false,
},
{
status: 402,
headers: { "cache-control": "no-store" },
},
);
}
}
try {
const result = await addAccount(resolved.value.team.teamId, credential);
const result = await dependencies.add(resolved.value.team.teamId, credential);
captureCoderouterEvent({
event: "coderouter_account_added",
userId: resolved.value.user.id,
@@ -129,6 +206,7 @@ export async function POST(request: Request): Promise<Response> {
},
);
}
};
}
function timing(name: string, duration: number): string {
+20 -11
View File
@@ -1,5 +1,8 @@
import { env } from "../../../env";
import { hasActiveCoderouterSubscription } from "../../../../services/billing/pro";
import {
CODEROUTER_FREE_ACCOUNT_LIMIT,
coderouterEntitlement,
} from "../../../../services/coderouter/entitlement";
import {
authenticateRouteToken,
issueRouteToken,
@@ -16,14 +19,14 @@ import {
type SessionDependencies = {
readonly resolveContext: typeof resolveCodeRouterRequestContext;
readonly hasActiveEntitlement: typeof hasActiveCoderouterSubscription;
readonly entitlement: typeof coderouterEntitlement;
readonly issueToken: typeof issueRouteToken;
readonly hostedProRequired: () => boolean;
};
const defaultDependencies: SessionDependencies = {
resolveContext: resolveCodeRouterRequestContext,
hasActiveEntitlement: hasActiveCoderouterSubscription,
entitlement: coderouterEntitlement,
issueToken: issueRouteToken,
hostedProRequired: () => env.CODEROUTER_HOSTED_PRO_REQUIRED === "1",
};
@@ -77,19 +80,22 @@ export function makeCoderouterSessionPostHandler(
const resolved = await dependencies.resolveContext(request, "use");
if (!resolved.ok) return resolved.response;
const userId = resolved.value.user.id;
let entitlementBasis = "ungated";
if (dependencies.hostedProRequired()) {
try {
if (
!(await dependencies.hasActiveEntitlement(
userId,
resolved.value.team.teamId,
))
) {
const entitlement = await dependencies.entitlement(
userId,
resolved.value.team.teamId,
);
entitlementBasis = entitlement.basis;
if (!entitlement.allowed) {
return Response.json(
{
error: "pro_required",
message:
"Hosted coderouter requires cmux Pro or Team. Upgrade or connect a self-hosted server.",
`Free hosted coderouter covers up to ${CODEROUTER_FREE_ACCOUNT_LIMIT} connected accounts; ` +
`this team has ${entitlement.accountCount}. ` +
"Upgrade to cmux Pro or Team, remove accounts, or connect a self-hosted server.",
retryable: false,
},
{
@@ -144,7 +150,10 @@ export function makeCoderouterSessionPostHandler(
event: "coderouter_route_session_issued",
userId,
teamId: resolved.value.team.teamId,
properties: { hosted_pro_required: dependencies.hostedProRequired() },
properties: {
hosted_pro_required: dependencies.hostedProRequired(),
entitlement_basis: entitlementBasis,
},
});
addCoderouterBreadcrumb("session", "Route session issued");
return Response.json(
+25 -4
View File
@@ -16,6 +16,7 @@ import {
export type CoderouterAnalyticsEvent =
| "coderouter_account_added"
| "coderouter_account_limit_reached"
| "coderouter_account_removed"
| "coderouter_account_status_viewed"
| "coderouter_auth_rejected"
@@ -170,6 +171,7 @@ async function deliver(
function eventNeedsUserScope(event: CoderouterAnalyticsEvent): boolean {
return event === "coderouter_account_added" ||
event === "coderouter_account_limit_reached" ||
event === "coderouter_account_removed" ||
event === "coderouter_route_session_issued" ||
event === "coderouter_route_session_revoked";
@@ -188,6 +190,15 @@ function eventProperties(
}
return { provider, source, already_exists: input.already_exists };
}
case "coderouter_account_limit_reached": {
const provider = accountProvider(input.provider);
if (!provider) return null;
return {
provider,
account_count_bucket: countBucket(input.account_count),
free_limit: typeof input.free_limit === "number" ? input.free_limit : 0,
};
}
case "coderouter_account_removed": {
const source = lifecycleSource(input.source);
if (!source) return null;
@@ -212,10 +223,20 @@ function eventProperties(
const reason = authReason(input.reason);
return surface && reason ? { surface, reason } : null;
}
case "coderouter_route_session_issued":
return typeof input.hosted_pro_required === "boolean"
? { hosted_pro_required: input.hosted_pro_required }
: null;
case "coderouter_route_session_issued": {
if (typeof input.hosted_pro_required !== "boolean") return null;
const output: Record<string, AnalyticsScalar> = {
hosted_pro_required: input.hosted_pro_required,
};
const basis = enumValue(input.entitlement_basis, [
"free_tier",
"subscription",
"pro_required",
"ungated",
]);
if (basis) output.entitlement_basis = basis;
return output;
}
case "coderouter_route_session_revoked":
return {};
case "coderouter_organization_catalog_viewed":
+101
View File
@@ -0,0 +1,101 @@
import { hasActiveCoderouterSubscription } from "../billing/pro";
import { countAccountsForTeam, findAccountByProviderIdentity } from "./repository";
import type { CodeRouterProvider } from "./types";
/**
* Hosted coderouter pricing: a team may connect and route up to this many
* provider accounts (subscriptions) for free. More than this requires an
* active cmux Pro (user) or Team (team) subscription.
*/
export const CODEROUTER_FREE_ACCOUNT_LIMIT = 3;
export type CoderouterEntitlement = {
readonly allowed: boolean;
/** What granted (or would grant) access. */
readonly basis: "free_tier" | "subscription" | "pro_required";
readonly accountCount: number;
};
export type CoderouterEntitlementDependencies = {
readonly countAccounts: typeof countAccountsForTeam;
readonly hasActiveSubscription: typeof hasActiveCoderouterSubscription;
};
/**
* Free tier first: the account count is one cheap indexed RDS read and covers
* most teams, so the Stripe-subscription read only runs for teams over the
* limit. Failures propagate to the caller, which must fail closed.
*/
export function createCoderouterEntitlementCheck(
dependencies: CoderouterEntitlementDependencies,
): (stackUserId: string, teamId: string) => Promise<CoderouterEntitlement> {
return async (stackUserId, teamId) => {
const accountCount = await dependencies.countAccounts(teamId);
if (accountCount <= CODEROUTER_FREE_ACCOUNT_LIMIT) {
return { allowed: true, basis: "free_tier", accountCount };
}
const subscribed = await dependencies.hasActiveSubscription(
stackUserId,
teamId,
);
return subscribed
? { allowed: true, basis: "subscription", accountCount }
: { allowed: false, basis: "pro_required", accountCount };
};
}
export const coderouterEntitlement = createCoderouterEntitlementCheck({
countAccounts: countAccountsForTeam,
hasActiveSubscription: hasActiveCoderouterSubscription,
});
export type AccountAdditionGateDependencies =
& CoderouterEntitlementDependencies
& {
readonly findExisting: typeof findAccountByProviderIdentity;
};
export type AccountAdditionDecision = {
readonly allowed: boolean;
readonly accountCount: number;
};
/**
* Gate for connecting one more provider account. Re-importing a credential
* for an account the team already has never increases the count, so it is
* always allowed — a broken account must stay repairable on the free tier.
* A genuinely new account is allowed while the team stays at or under the
* free limit after adding it, or when a subscription covers the team.
*/
export function createAccountAdditionGate(
dependencies: AccountAdditionGateDependencies,
): (input: {
stackUserId: string;
teamId: string;
provider: CodeRouterProvider;
providerAccountId: string;
}) => Promise<AccountAdditionDecision> {
return async (input) => {
const accountCount = await dependencies.countAccounts(input.teamId);
const existing = await dependencies.findExisting(
input.teamId,
input.provider,
input.providerAccountId,
);
if (existing) return { allowed: true, accountCount };
if (accountCount < CODEROUTER_FREE_ACCOUNT_LIMIT) {
return { allowed: true, accountCount };
}
const subscribed = await dependencies.hasActiveSubscription(
input.stackUserId,
input.teamId,
);
return { allowed: subscribed, accountCount };
};
}
export const accountAdditionAllowed = createAccountAdditionGate({
countAccounts: countAccountsForTeam,
hasActiveSubscription: hasActiveCoderouterSubscription,
findExisting: findAccountByProviderIdentity,
});
+13
View File
@@ -387,6 +387,19 @@ export async function upsertAccountMetadata(input: {
});
}
/**
* Counts every provider account the team has connected, in any state.
* Broken and cooling-down accounts still occupy a slot: the team controls
* them and can remove them; only removal frees the slot.
*/
export async function countAccountsForTeam(teamId: string): Promise<number> {
const [row] = await cloudDb()
.select({ count: sql<number>`count(*)::int` })
.from(coderouterAccounts)
.where(eq(coderouterAccounts.teamId, teamId));
return Number(row?.count ?? 0);
}
export async function findAccountByProviderIdentity(
teamId: string,
provider: CodeRouterProvider,
@@ -0,0 +1,98 @@
import { describe, expect, mock, test } from "bun:test";
import { makeCoderouterAccountsPostHandler } from "../app/api/coderouter/accounts/route";
const context = {
ok: true as const,
value: {
user: { id: "user_1" },
team: {
teamId: "team_1",
teamName: "Team",
use: true,
manageAccounts: true,
},
},
};
const credentialBody = JSON.stringify({
provider: "codex",
accessToken: "access",
refreshToken: "refresh",
idToken: "id",
accountId: "acct-openai-1",
email: "[email protected]",
expiresAt: Date.now() + 60_000,
});
function addRequest(): Request {
return new Request("https://coderouter.dev/api/coderouter/accounts", {
method: "POST",
headers: { "content-type": "application/json" },
body: credentialBody,
});
}
describe("coderouter account addition limit", () => {
test("blocks connecting a fourth account without a subscription", async () => {
const add = mock(async () => ({ accountId: "new", alreadyExists: false }));
const POST = makeCoderouterAccountsPostHandler({
resolveContext: mock(async () => context) as never,
additionAllowed: async () => ({ allowed: false, accountCount: 3 }),
add,
hostedProRequired: () => true,
});
const response = await POST(addRequest());
expect(response.status).toBe(402);
await expect(response.json()).resolves.toMatchObject({
error: "pro_required",
});
expect(add).not.toHaveBeenCalled();
});
test("stores an account the gate allows", async () => {
const POST = makeCoderouterAccountsPostHandler({
resolveContext: mock(async () => context) as never,
additionAllowed: async () => ({ allowed: true, accountCount: 1 }),
add: async () => ({ accountId: "new", alreadyExists: false }),
hostedProRequired: () => true,
});
const response = await POST(addRequest());
expect(response.status).toBe(201);
});
test("fails closed with a retryable error when the gate is unavailable", async () => {
const add = mock(async () => ({ accountId: "new", alreadyExists: false }));
const POST = makeCoderouterAccountsPostHandler({
resolveContext: mock(async () => context) as never,
additionAllowed: async () => {
throw new Error("database unavailable");
},
add,
hostedProRequired: () => true,
});
const response = await POST(addRequest());
expect(response.status).toBe(503);
await expect(response.json()).resolves.toMatchObject({
error: "entitlement_unavailable",
retryable: true,
});
expect(add).not.toHaveBeenCalled();
});
test("skips the gate entirely when hosted billing is off", async () => {
const additionAllowed = mock(async () => ({
allowed: false,
accountCount: 9,
}));
const POST = makeCoderouterAccountsPostHandler({
resolveContext: mock(async () => context) as never,
additionAllowed,
add: async () => ({ accountId: "new", alreadyExists: false }),
hostedProRequired: () => false,
});
const response = await POST(addRequest());
expect(response.status).toBe(201);
expect(additionAllowed).not.toHaveBeenCalled();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, mock, test } from "bun:test";
import {
CODEROUTER_FREE_ACCOUNT_LIMIT,
createAccountAdditionGate,
createCoderouterEntitlementCheck,
} from "../services/coderouter/entitlement";
describe("coderouter entitlement", () => {
test("free limit is 3 connected accounts", () => {
expect(CODEROUTER_FREE_ACCOUNT_LIMIT).toBe(3);
});
test("a team at or under the free limit routes without a subscription", async () => {
for (const count of [0, 1, 2, 3]) {
const hasActiveSubscription = mock(async () => false);
const check = createCoderouterEntitlementCheck({
countAccounts: async () => count,
hasActiveSubscription,
});
const result = await check("user_1", "team_1");
expect(result).toEqual({
allowed: true,
basis: "free_tier",
accountCount: count,
});
// The Stripe read must not run for free-tier teams.
expect(hasActiveSubscription).not.toHaveBeenCalled();
}
});
test("a team over the free limit requires a subscription", async () => {
const check = createCoderouterEntitlementCheck({
countAccounts: async () => 4,
hasActiveSubscription: async () => false,
});
await expect(check("user_1", "team_1")).resolves.toEqual({
allowed: false,
basis: "pro_required",
accountCount: 4,
});
});
test("a subscription covers a team over the free limit", async () => {
const check = createCoderouterEntitlementCheck({
countAccounts: async () => 7,
hasActiveSubscription: async (userId, teamId) =>
userId === "user_1" && teamId === "team_1",
});
await expect(check("user_1", "team_1")).resolves.toEqual({
allowed: true,
basis: "subscription",
accountCount: 7,
});
});
});
describe("coderouter account addition gate", () => {
const input = {
stackUserId: "user_1",
teamId: "team_1",
provider: "codex" as const,
providerAccountId: "acct-openai-1",
};
test("allows a new account while the team stays within the free limit", async () => {
const gate = createAccountAdditionGate({
countAccounts: async () => 2,
hasActiveSubscription: async () => false,
findExisting: async () => null,
});
await expect(gate(input)).resolves.toEqual({
allowed: true,
accountCount: 2,
});
});
test("blocks the fourth account without a subscription", async () => {
const gate = createAccountAdditionGate({
countAccounts: async () => 3,
hasActiveSubscription: async () => false,
findExisting: async () => null,
});
await expect(gate(input)).resolves.toEqual({
allowed: false,
accountCount: 3,
});
});
test("allows the fourth account with a subscription", async () => {
const gate = createAccountAdditionGate({
countAccounts: async () => 3,
hasActiveSubscription: async () => true,
findExisting: async () => null,
});
await expect(gate(input)).resolves.toEqual({
allowed: true,
accountCount: 3,
});
});
test("always allows re-importing an existing account", async () => {
const hasActiveSubscription = mock(async () => false);
const gate = createAccountAdditionGate({
countAccounts: async () => 5,
hasActiveSubscription,
findExisting: async () => ({ id: "acct-1", state: "broken", vaultRevision: 2 }),
});
await expect(gate(input)).resolves.toEqual({
allowed: true,
accountCount: 5,
});
expect(hasActiveSubscription).not.toHaveBeenCalled();
});
});
+46 -10
View File
@@ -46,7 +46,11 @@ describe("coderouter hosted entitlement", () => {
}));
const POST = makeCoderouterSessionPostHandler({
resolveContext: mock(async () => context) as never,
hasActiveEntitlement: mock(async () => false),
entitlement: mock(async () => ({
allowed: false as const,
basis: "pro_required" as const,
accountCount: 5,
})),
issueToken,
hostedProRequired: () => true,
});
@@ -64,15 +68,45 @@ describe("coderouter hosted entitlement", () => {
expect(issueToken).not.toHaveBeenCalled();
});
test("issues a hosted route token on the free tier without a subscription", async () => {
const issueToken = mock(async () => ({
token: "crt_free",
expiresAt: new Date("2026-09-01T00:00:00Z"),
}));
const POST = makeCoderouterSessionPostHandler({
resolveContext: mock(async () => context) as never,
entitlement: mock(async () => ({
allowed: true as const,
basis: "free_tier" as const,
accountCount: 3,
})),
issueToken,
hostedProRequired: () => true,
});
const response = await POST(
new Request("https://coderouter.dev/api/coderouter/session", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(issueToken).toHaveBeenCalledWith("team_1", "user_1");
});
test("keeps self-hosted servers independent from hosted billing", async () => {
const issueToken = mock(async () => ({
token: "crt_test",
expiresAt: new Date("2026-09-01T00:00:00Z"),
}));
const hasActiveEntitlement = mock(async () => false);
const entitlement = mock(async () => ({
allowed: false as const,
basis: "pro_required" as const,
accountCount: 5,
}));
const POST = makeCoderouterSessionPostHandler({
resolveContext: mock(async () => context) as never,
hasActiveEntitlement,
entitlement,
issueToken,
hostedProRequired: () => false,
});
@@ -88,14 +122,14 @@ describe("coderouter hosted entitlement", () => {
token: "crt_test",
openaiBaseUrl: "https://router.example.com/v1",
});
expect(hasActiveEntitlement).not.toHaveBeenCalled();
expect(entitlement).not.toHaveBeenCalled();
expect(issueToken).toHaveBeenCalledWith("team_1", "user_1");
});
test("fails closed when hosted entitlement storage is unavailable", async () => {
const POST = makeCoderouterSessionPostHandler({
resolveContext: mock(async () => context) as never,
hasActiveEntitlement: mock(async () => {
entitlement: mock(async () => {
throw new Error("database unavailable");
}),
issueToken: mock(async () => {
@@ -119,12 +153,14 @@ describe("coderouter hosted entitlement", () => {
token: "crt_team",
expiresAt: new Date("2026-09-01T00:00:00Z"),
}));
const hasActiveEntitlement = mock(async (...args: unknown[]) =>
args[0] === "user_1" && args[1] === "team_1"
);
const entitlement = mock(async (...args: unknown[]) => ({
allowed: args[0] === "user_1" && args[1] === "team_1",
basis: "subscription" as const,
accountCount: 5,
}));
const POST = makeCoderouterSessionPostHandler({
resolveContext: mock(async () => context) as never,
hasActiveEntitlement: hasActiveEntitlement as never,
entitlement: entitlement as never,
issueToken,
hostedProRequired: () => true,
});
@@ -136,7 +172,7 @@ describe("coderouter hosted entitlement", () => {
);
expect(response.status).toBe(200);
expect(hasActiveEntitlement).toHaveBeenCalledWith("user_1", "team_1");
expect(entitlement).toHaveBeenCalledWith("user_1", "team_1");
expect(issueToken).toHaveBeenCalledWith("team_1", "user_1");
});
});