Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .agents/skills/stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ Use this to understand local stack metadata, current branch position, missing
parents, tracked PR numbers, and PR titles when GitHub is available. It is
opinionated: backup branches are hidden, and when the current branch is
stack-relevant it focuses on that stack instead of listing every local branch.
When relaying a stack view to the user, always include the full PR URLs for
every branch that has a PR, not just PR numbers or titles. If `stack status`
does not print a URL for a branch, query GitHub with `gh pr view`/`gh pr list`
and include the URL in the shown stack view. Use Markdown links or plain URL
lines for user-facing stack views so the PR links are clickable; do not hide the
only copy of the PR URLs inside a fenced code block.

Use `stack sync --dry-run`, not `stack status`, when you need GitHub PR-base
inference before mutation.
Expand Down Expand Up @@ -129,6 +135,9 @@ nonzero if any stack failed.
Sync output is intentionally outcome-oriented. It should show the stack tree with
icons like `●`, `✓`, `◌`, and `✕`, plus changed PRs/backups/undo instructions. It
should not default to internal phase logs like fetch, inspect, or reconcile.
When you show a stack tree to the user, include full PR URLs alongside each PR
entry so the stack view is directly actionable. Prefer Markdown list/tree output
with clickable links over fenced code blocks when relaying PR URLs to a user.

If a replay fails, `stack sync` aborts the failed cherry-pick, restores the
original branch, deletes the temporary replay branch, keeps backups and the undo
Expand Down
1 change: 1 addition & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"executor/no-try-catch-or-throw": "error",
"executor/no-unknown-error-message": "error",
"executor/no-unsupported-effect-api": "error",
"executor/prefer-effect-predicate": "error",
"executor/prefer-schema-inferred-types": "error",
"executor/prefer-value-inferred-extension-types": "error",
"executor/prefer-yield-tagged-error": "error",
Expand Down
103 changes: 103 additions & 0 deletions apps/cloud/src/auth/create-organization.e2e.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { assert, describe, it } from "@effect/vitest";
import { Effect, Exit } from "effect";

import {
CloudAuthApiTestContext,
CloudAuthApiTestContextLayer,
makeCloudAuthApiTestState,
} from "./cloud-auth-api.test-context";
import { makeWorkOSTestMembership, makeWorkOSTestState } from "./workos.test-layer";
import { makeAutumnTestState } from "../services/autumn.test-layer";

describe("create organization API", () => {
it.effect("lets a paid user create another organization through the HTTP API client", () => {
const state = makeCloudAuthApiTestState({
workos: makeWorkOSTestState({
memberships: [
makeWorkOSTestMembership("org_free_1", "active"),
makeWorkOSTestMembership("org_free_2", "active"),
makeWorkOSTestMembership("org_paid", "active"),
],
}),
autumn: makeAutumnTestState({
subscriptionsByOrgId: {
org_paid: [{ planId: "team", status: "active" }],
},
}),
});

return Effect.gen(function* () {
const { client } = yield* CloudAuthApiTestContext;

const result = yield* client.cloudAuth.createOrganization({
payload: { name: "Paid Extra Org" },
});

assert.deepEqual(result, { id: "org_created", name: "Paid Extra Org" });
assert.deepEqual(state.workos.createdOrganizations, [
{ id: "org_created", name: "Paid Extra Org" },
]);
assert.deepEqual(state.workos.createdMemberships, [
{ organizationId: "org_created", userId: "user_1", roleSlug: "admin" },
]);
assert.deepEqual(state.userStore.upsertedOrganizations, [
{ id: "org_created", name: "Paid Extra Org" },
]);
}).pipe(Effect.provide(CloudAuthApiTestContextLayer(state)));
});

it.effect(
"rejects a free-only user at the free organization limit through the HTTP API client",
() => {
const state = makeCloudAuthApiTestState({
workos: makeWorkOSTestState({
memberships: [
makeWorkOSTestMembership("org_free_1", "active"),
makeWorkOSTestMembership("org_free_2", "active"),
makeWorkOSTestMembership("org_free_3", "active"),
],
}),
});

return Effect.gen(function* () {
const { client } = yield* CloudAuthApiTestContext;

const exit = yield* Effect.exit(
client.cloudAuth.createOrganization({
payload: { name: "Blocked Org" },
}),
);

assert.isTrue(Exit.isFailure(exit));
assert.deepEqual(state.workos.createdOrganizations, []);
assert.deepEqual(state.workos.createdMemberships, []);
assert.deepEqual(state.userStore.upsertedOrganizations, []);
}).pipe(Effect.provide(CloudAuthApiTestContextLayer(state)));
},
);

it.effect("does not count pending memberships toward the free organization limit", () => {
const state = makeCloudAuthApiTestState({
workos: makeWorkOSTestState({
memberships: [
makeWorkOSTestMembership("org_free_1", "active"),
makeWorkOSTestMembership("org_free_2", "active"),
makeWorkOSTestMembership("org_invited", "pending"),
],
}),
});

return Effect.gen(function* () {
const { client } = yield* CloudAuthApiTestContext;

const result = yield* client.cloudAuth.createOrganization({
payload: { name: "Below Active Limit" },
});

assert.deepEqual(result, { id: "org_created", name: "Below Active Limit" });
assert.deepEqual(state.workos.createdOrganizations, [
{ id: "org_created", name: "Below Active Limit" },
]);
}).pipe(Effect.provide(CloudAuthApiTestContextLayer(state)));
});
});
46 changes: 38 additions & 8 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
import { HttpServerResponse } from "effect/unstable/http";
import { Duration, Effect } from "effect";
import { Duration, Effect, Predicate } from "effect";
import { setCookie, deleteCookie } from "@tanstack/react-start/server";

import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api";
Expand All @@ -12,6 +12,12 @@ import { ApiKeyManagementError } from "./api-key-errors";
import { WorkOSError } from "./errors";
import { WorkOSAuth } from "./workos";
import { ApiKeyService } from "./api-keys";
import { AutumnService } from "../services/autumn";
import {
hasPaidOrganizationSubscription,
isOverFreeOrganizationLimit,
shouldApplyFreeOrganizationLimit,
} from "./organization-limits";

const COOKIE_OPTIONS = {
path: "/",
Expand Down Expand Up @@ -49,7 +55,6 @@ const DELETE_COOKIE_OPTIONS = {
secure: true,
};

const MAX_ORGANIZATIONS_PER_USER = 3;
const MAX_API_KEY_NAME_LENGTH = 80;

const randomState = (): string => {
Expand Down Expand Up @@ -232,9 +237,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
);

return {
organizations: organizations.filter(
(org): org is NonNullable<typeof org> => org !== null,
),
organizations: organizations.filter(Predicate.isNotNull),
activeOrganizationId: session.organizationId,
};
}),
Expand All @@ -258,11 +261,38 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
const workos = yield* WorkOSAuth;
const users = yield* UserStoreService;
const session = yield* SessionContext;
const autumn = yield* AutumnService;

const name = payload.name.trim();
const memberships = yield* workos.listUserMemberships(session.accountId);
if (memberships.data.length >= MAX_ORGANIZATIONS_PER_USER) {
return yield* new WorkOSError();
const activeMemberships = memberships.data.filter(
(membership) => membership.status === "active",
);

if (isOverFreeOrganizationLimit(activeMemberships)) {
const paidOrganizationIds = yield* Effect.all(
activeMemberships.map((membership) =>
autumn
.use((client) =>
client.customers.getOrCreate({ customerId: membership.organizationId }),
)
.pipe(
Effect.map((customer) =>
hasPaidOrganizationSubscription(customer.subscriptions)
? membership.organizationId
: null,
),
),
),
{ concurrency: 3 },
).pipe(
Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())),
Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))),
);

if (shouldApplyFreeOrganizationLimit(activeMemberships, paidOrganizationIds)) {
return yield* new WorkOSError();
}
}

const org = yield* workos.createOrganization(name);
Expand Down Expand Up @@ -342,7 +372,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
);

return {
invitations: enriched.filter((i): i is NonNullable<typeof i> => i !== null),
invitations: enriched.filter(Predicate.isNotNull),
};
}),
)
Expand Down
51 changes: 51 additions & 0 deletions apps/cloud/src/auth/organization-limits.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "@effect/vitest";

import {
FREE_ORGANIZATIONS_PER_USER_LIMIT,
hasPaidOrganizationSubscription,
isOverFreeOrganizationLimit,
shouldApplyFreeOrganizationLimit,
} from "./organization-limits";

describe("organization limits", () => {
it("treats active and trialing paid org subscriptions as paid", () => {
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true);
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "trialing" }])).toBe(true);
});

it("does not treat inactive paid plans or free plans as paid", () => {
expect(hasPaidOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe(false);
expect(hasPaidOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false);
expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false);
});

it("applies the free org limit only when none of the user's active orgs are paid", () => {
const activeMemberships = [
{ organizationId: "org_free_1", status: "active" },
{ organizationId: "org_paid", status: "active" },
];

expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set())).toBe(true);
expect(shouldApplyFreeOrganizationLimit(activeMemberships, new Set(["org_paid"]))).toBe(false);
});

it("caps free-only users at active org memberships, not pending invitations", () => {
expect(
isOverFreeOrganizationLimit(
Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT - 1 }, (_, index) => ({
organizationId: `org_${index}`,
status: "active",
})),
),
).toBe(false);

expect(
isOverFreeOrganizationLimit(
Array.from({ length: FREE_ORGANIZATIONS_PER_USER_LIMIT }, (_, index) => ({
organizationId: `org_${index}`,
status: "active",
})),
),
).toBe(true);
});
});
37 changes: 37 additions & 0 deletions apps/cloud/src/auth/organization-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES,
PAID_AUTUMN_PLAN_IDS,
} from "../services/autumn-plans";

export const FREE_ORGANIZATIONS_PER_USER_LIMIT = 3;

export type OrganizationLimitSubscriptionSummary = {
readonly planId?: string | null;
readonly status?: string | null;
};

export type OrganizationLimitMembershipSummary = {
readonly organizationId: string;
readonly status?: string | null;
};

export const isPaidOrganizationSubscription = (
subscription: OrganizationLimitSubscriptionSummary,
): boolean =>
subscription.planId != null &&
PAID_AUTUMN_PLAN_IDS.has(subscription.planId) &&
ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? "");

export const hasPaidOrganizationSubscription = (
subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>,
): boolean => subscriptions.some(isPaidOrganizationSubscription);

export const shouldApplyFreeOrganizationLimit = (
activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,
paidOrganizationIds: ReadonlySet<string>,
): boolean =>
!activeMemberships.some((membership) => paidOrganizationIds.has(membership.organizationId));

export const isOverFreeOrganizationLimit = (
activeMemberships: ReadonlyArray<OrganizationLimitMembershipSummary>,
): boolean => activeMemberships.length >= FREE_ORGANIZATIONS_PER_USER_LIMIT;
4 changes: 3 additions & 1 deletion apps/cloud/src/org/member-limits.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES } from "../services/autumn-plans";

const MEMBER_LIMITS: Record<string, number | null> = {
free: 3,
"free-pay-as-you-go": 3,
Expand All @@ -16,7 +18,7 @@ export const selectActiveMemberLimitPlan = (
): string => {
const active =
subscriptions.find((subscription) =>
["active", "trialing"].includes(subscription.status ?? ""),
ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""),
) ?? subscriptions[0];
return active?.planId ?? "free";
};
Expand Down
5 changes: 5 additions & 0 deletions apps/cloud/src/services/autumn-plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { team } from "../../autumn.config";

export const PAID_AUTUMN_PLAN_IDS = new Set([team.id]);

export const ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
4 changes: 2 additions & 2 deletions apps/cloud/src/services/mcp-worker-transport.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { WorkerTransport, type WorkerTransportOptions } from "agents/mcp";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Data, Effect, Exit, Match, Option } from "effect";
import { Data, Effect, Exit, Match, Option, Predicate } from "effect";

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

const previous = ids.map((id) => this.inFlight.get(id)).filter((p) => p !== undefined);
const previous = ids.map((id) => this.inFlight.get(id)).filter(Predicate.isNotUndefined);
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/execution/src/tool-invoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ export const searchTools = Effect.fn("executor.tools.search")(function* (
const ranked = all
.filter((tool: Tool) => matchesNamespace(tool, options?.namespace))
.map((tool: Tool) => scoreToolMatch(tool, query))
.filter((tool): tool is ToolDiscoveryResult => tool !== null)
.filter(Predicate.isNotNull)
.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));

const page = paginate(ranked, offset, limit);
Expand Down
Loading
Loading