Skip to content

Commit 472f5e7

Browse files
committed
Fix paid organization creation limit
1 parent ee0228f commit 472f5e7

13 files changed

Lines changed: 376 additions & 14 deletions

File tree

.oxlintrc.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"executor/no-try-catch-or-throw": "error",
3030
"executor/no-unknown-error-message": "error",
3131
"executor/no-unsupported-effect-api": "error",
32+
"executor/prefer-effect-predicate": "error",
3233
"executor/prefer-schema-inferred-types": "error",
3334
"executor/prefer-value-inferred-extension-types": "error",
3435
"executor/prefer-yield-tagged-error": "error",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { assert, describe, it } from "@effect/vitest";
2+
import { Effect, Exit } from "effect";
3+
4+
import {
5+
CloudAuthApiTestContext,
6+
CloudAuthApiTestContextLayer,
7+
makeCloudAuthApiTestState,
8+
} from "./cloud-auth-api.test-context";
9+
import { makeWorkOSTestMembership, makeWorkOSTestState } from "./workos.test-layer";
10+
import { makeAutumnTestState } from "../services/autumn.test-layer";
11+
12+
describe("create organization API", () => {
13+
it.effect("lets a paid user create another organization through the HTTP API client", () => {
14+
const state = makeCloudAuthApiTestState({
15+
workos: makeWorkOSTestState({
16+
memberships: [
17+
makeWorkOSTestMembership("org_free_1", "active"),
18+
makeWorkOSTestMembership("org_free_2", "active"),
19+
makeWorkOSTestMembership("org_paid", "active"),
20+
],
21+
}),
22+
autumn: makeAutumnTestState({
23+
subscriptionsByOrgId: {
24+
org_paid: [{ planId: "team", status: "active" }],
25+
},
26+
}),
27+
});
28+
29+
return Effect.gen(function* () {
30+
const { client } = yield* CloudAuthApiTestContext;
31+
32+
const result = yield* client.cloudAuth.createOrganization({
33+
payload: { name: "Paid Extra Org" },
34+
});
35+
36+
assert.deepEqual(result, { id: "org_created", name: "Paid Extra Org" });
37+
assert.deepEqual(state.workos.createdOrganizations, [
38+
{ id: "org_created", name: "Paid Extra Org" },
39+
]);
40+
assert.deepEqual(state.workos.createdMemberships, [
41+
{ organizationId: "org_created", userId: "user_1", roleSlug: "admin" },
42+
]);
43+
assert.deepEqual(state.userStore.upsertedOrganizations, [
44+
{ id: "org_created", name: "Paid Extra Org" },
45+
]);
46+
}).pipe(Effect.provide(CloudAuthApiTestContextLayer(state)));
47+
});
48+
49+
it.effect(
50+
"rejects a free-only user at the free organization limit through the HTTP API client",
51+
() => {
52+
const state = makeCloudAuthApiTestState({
53+
workos: makeWorkOSTestState({
54+
memberships: [
55+
makeWorkOSTestMembership("org_free_1", "active"),
56+
makeWorkOSTestMembership("org_free_2", "active"),
57+
makeWorkOSTestMembership("org_free_3", "active"),
58+
],
59+
}),
60+
});
61+
62+
return Effect.gen(function* () {
63+
const { client } = yield* CloudAuthApiTestContext;
64+
65+
const exit = yield* Effect.exit(
66+
client.cloudAuth.createOrganization({
67+
payload: { name: "Blocked Org" },
68+
}),
69+
);
70+
71+
assert.isTrue(Exit.isFailure(exit));
72+
assert.deepEqual(state.workos.createdOrganizations, []);
73+
assert.deepEqual(state.workos.createdMemberships, []);
74+
assert.deepEqual(state.userStore.upsertedOrganizations, []);
75+
}).pipe(Effect.provide(CloudAuthApiTestContextLayer(state)));
76+
},
77+
);
78+
79+
it.effect("does not count pending memberships toward the free organization limit", () => {
80+
const state = makeCloudAuthApiTestState({
81+
workos: makeWorkOSTestState({
82+
memberships: [
83+
makeWorkOSTestMembership("org_free_1", "active"),
84+
makeWorkOSTestMembership("org_free_2", "active"),
85+
makeWorkOSTestMembership("org_invited", "pending"),
86+
],
87+
}),
88+
});
89+
90+
return Effect.gen(function* () {
91+
const { client } = yield* CloudAuthApiTestContext;
92+
93+
const result = yield* client.cloudAuth.createOrganization({
94+
payload: { name: "Below Active Limit" },
95+
});
96+
97+
assert.deepEqual(result, { id: "org_created", name: "Below Active Limit" });
98+
assert.deepEqual(state.workos.createdOrganizations, [
99+
{ id: "org_created", name: "Below Active Limit" },
100+
]);
101+
}).pipe(Effect.provide(CloudAuthApiTestContextLayer(state)));
102+
});
103+
});

apps/cloud/src/auth/handlers.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
22
import { HttpServerResponse } from "effect/unstable/http";
3-
import { Duration, Effect } from "effect";
3+
import { Duration, Effect, Predicate } from "effect";
44
import { setCookie, deleteCookie } from "@tanstack/react-start/server";
55

66
import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
@@ -12,6 +12,12 @@ import { ApiKeyManagementError } from "./api-key-errors";
1212
import { WorkOSError } from "./errors";
1313
import { WorkOSAuth } from "./workos";
1414
import { ApiKeyService } from "./api-keys";
15+
import { AutumnService } from "../services/autumn";
16+
import {
17+
hasPaidOrganizationSubscription,
18+
isOverFreeOrganizationLimit,
19+
shouldApplyFreeOrganizationLimit,
20+
} from "./organization-limits";
1521

1622
const COOKIE_OPTIONS = {
1723
path: "/",
@@ -49,7 +55,6 @@ const DELETE_COOKIE_OPTIONS = {
4955
secure: true,
5056
};
5157

52-
const MAX_ORGANIZATIONS_PER_USER = 3;
5358
const MAX_API_KEY_NAME_LENGTH = 80;
5459

5560
const randomState = (): string => {
@@ -232,9 +237,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
232237
);
233238

234239
return {
235-
organizations: organizations.filter(
236-
(org): org is NonNullable<typeof org> => org !== null,
237-
),
240+
organizations: organizations.filter(Predicate.isNotNull),
238241
activeOrganizationId: session.organizationId,
239242
};
240243
}),
@@ -258,11 +261,38 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
258261
const workos = yield* WorkOSAuth;
259262
const users = yield* UserStoreService;
260263
const session = yield* SessionContext;
264+
const autumn = yield* AutumnService;
261265

262266
const name = payload.name.trim();
263267
const memberships = yield* workos.listUserMemberships(session.accountId);
264-
if (memberships.data.length >= MAX_ORGANIZATIONS_PER_USER) {
265-
return yield* new WorkOSError();
268+
const activeMemberships = memberships.data.filter(
269+
(membership) => membership.status === "active",
270+
);
271+
272+
if (isOverFreeOrganizationLimit(activeMemberships)) {
273+
const paidOrganizationIds = yield* Effect.all(
274+
activeMemberships.map((membership) =>
275+
autumn
276+
.use((client) =>
277+
client.customers.getOrCreate({ customerId: membership.organizationId }),
278+
)
279+
.pipe(
280+
Effect.map((customer) =>
281+
hasPaidOrganizationSubscription(customer.subscriptions)
282+
? membership.organizationId
283+
: null,
284+
),
285+
),
286+
),
287+
{ concurrency: 3 },
288+
).pipe(
289+
Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())),
290+
Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),
291+
);
292+
293+
if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) {
294+
return yield* new WorkOSError();
295+
}
266296
}
267297

268298
const org = yield* workos.createOrganization(name);
@@ -342,7 +372,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
342372
);
343373

344374
return {
345-
invitations: enriched.filter((i): i is NonNullable<typeof i> => i !== null),
375+
invitations: enriched.filter(Predicate.isNotNull),
346376
};
347377
}),
348378
)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import {
4+
FREE_ORGANIZATIONS_PER_USER_LIMIT,
5+
hasPaidOrganizationSubscription,
6+
isOverFreeOrganizationLimit,
7+
shouldApplyFreeOrganizationLimit,
8+
} from "./organization-limits";
9+
10+
describe("organization limits", () => {
11+
it("treats active and trialing paid org subscriptions as paid", () => {
12+
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true);
13+
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true);
14+
});
15+
16+
it("does not treat inactive paid plans or free plans as paid", () => {
17+
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe(false);
18+
expect(hasPaidOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false);
19+
expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false);
20+
});
21+
22+
it("applies the free org limit only when none of the user's active orgs are paid", () => {
23+
const activeMemberships = [
24+
{ organizationId: "org_free_1", status: "active" },
25+
{ organizationId: "org_paid", status: "active" },
26+
];
27+
28+
expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set())).toBe(true);
29+
expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set(["org_paid"]))).toBe(false);
30+
});
31+
32+
it("caps free-only users at active org memberships, not pending invitations", () => {
33+
expect(
34+
isOverFreeOrganizationLimit(
35+
Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT - 1 }, (_, index) => ({
36+
organizationId: `org_${index}`,
37+
status: "active",
38+
})),
39+
),
40+
).toBe(false);
41+
42+
expect(
43+
isOverFreeOrganizationLimit(
44+
Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT }, (_, index) => ({
45+
organizationId: `org_${index}`,
46+
status: "active",
47+
})),
48+
),
49+
).toBe(true);
50+
});
51+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES,
3+
PAID_AUTUMN_PLAN_IDS,
4+
} from "../services/autumn-plans";
5+
6+
export const FREE_ORGANIZATIONS_PER_USER_LIMIT = 3;
7+
8+
export type OrganizationLimitSubscriptionSummary = {
9+
readonly planId?: string | null;
10+
readonly status?: string | null;
11+
};
12+
13+
export type OrganizationLimitMembershipSummary = {
14+
readonly organizationId: string;
15+
readonly status?: string | null;
16+
};
17+
18+
export const isPaidOrganizationSubscription = (
19+
subscription: OrganizationLimitSubscriptionSummary,
20+
): boolean =>
21+
subscription.planId != null &&
22+
PAID_AUTUMN_PLAN_IDS.has(subscription.planId) &&
23+
ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? "");
24+
25+
export const hasPaidOrganizationSubscription = (
26+
subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>,
27+
): boolean => subscriptions.some(isPaidOrganizationSubscription);
28+
29+
export const shouldApplyFreeOrganizationLimit = (
30+
activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,
31+
paidOrganizationIds: ReadonlySet<string>,
32+
): boolean =>
33+
!activeMemberships.some((membership) => paidOrganizationIds.has(membership.organizationId));
34+
35+
export const isOverFreeOrganizationLimit = (
36+
activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,
37+
): boolean => activeMemberships.length >= FREE_ORGANIZATIONS_PER_USER_LIMIT;

apps/cloud/src/org/member-limits.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES } from "../services/autumn-plans";
2+
13
const MEMBER_LIMITS: Record<string, number | null> = {
24
free: 3,
35
"free-pay-as-you-go": 3,
@@ -16,7 +18,7 @@ export const selectActiveMemberLimitPlan = (
1618
): string => {
1719
const active =
1820
subscriptions.find((subscription) =>
19-
["active", "trialing"].includes(subscription.status ?? ""),
21+
ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""),
2022
) ?? subscriptions[0];
2123
return active?.planId ?? "free";
2224
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { team } from "../../autumn.config";
2+
3+
export const PAID_AUTUMN_PLAN_IDS = new Set([team.id]);
4+
5+
export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);

apps/cloud/src/services/mcp-worker-transport.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp";
22
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3-
import { Data, Effect, Exit, Match, Option } from "effect";
3+
import { Data, Effect, Exit, Match, Option, Predicate } from "effect";
44

55
export class McpWorkerTransportError extends Data.TaggedError("McpWorkerTransportError")<{
66
readonly cause: unknown;
@@ -100,7 +100,7 @@ export class JsonRpcRequestIdQueue {
100100
const ids = [...new Set(await extractJsonRpcRequestIdKeys(request))];
101101
if (ids.length === 0) return await run();
102102

103-
const previous = ids.map((id) => this.inFlight.get(id)).filter((p) => p !== undefined);
103+
const previous = ids.map((id) => this.inFlight.get(id)).filter(Predicate.isNotUndefined);
104104
let release!: () => void;
105105
const current = new Promise<void>((resolve) => {
106106
release = resolve;

packages/core/execution/src/tool-invoker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ export const searchTools = Effect.fn("executor.tools.search")(function* (
404404
const ranked = all
405405
.filter((tool: Tool) => matchesNamespace(tool, options?.namespace))
406406
.map((tool: Tool) => scoreToolMatch(tool, query))
407-
.filter((tool): tool is ToolDiscoveryResult => tool !== null)
407+
.filter(Predicate.isNotNull)
408408
.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
409409

410410
const page = paginate(ranked, offset, limit);

packages/core/sdk/src/oxlint-plugin-executor.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,46 @@ describe("executor oxlint plugin", () => {
139139
expect(result.stdout).toContain("Found 0 warnings and 0 errors.");
140140
expect(result.stderr).toBe("");
141141
});
142+
143+
it("rejects hand-rolled null predicates in Effect files", async () => {
144+
const result = await runOxlintOn(
145+
"manual-null-predicate.ts",
146+
`
147+
import { Effect } from "effect";
148+
149+
const isNonNull = <A>(value: A | null): value is A => value !== null;
150+
151+
export const values = [Effect.void, null].filter(isNonNull);
152+
`,
153+
);
154+
155+
expect(result.status).toBe(1);
156+
expect(result.stdout).toContain("executor(prefer-effect-predicate)");
157+
});
158+
159+
it("rejects inline nullish filter predicates in Effect files", async () => {
160+
const result = await runOxlintOn(
161+
"inline-nullish-filter.ts",
162+
`
163+
import { Predicate } from "effect";
164+
165+
export const values = ["ok", null].filter((value): value is string => value !== null);
166+
`,
167+
);
168+
169+
expect(result.status).toBe(1);
170+
expect(result.stdout).toContain("executor(prefer-effect-predicate)");
171+
});
172+
173+
it("allows null filters in files that do not import Effect", async () => {
174+
const result = await runOxlintOn(
175+
"plain-null-filter.ts",
176+
`
177+
export const values = ["ok", null].filter((value): value is string => value !== null);
178+
`,
179+
);
180+
181+
expect(result.status).toBe(0);
182+
expect(result.stdout).toContain("Found 0 warnings and 0 errors.");
183+
});
142184
});

0 commit comments

Comments
 (0)