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:
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user