// ---------------------------------------------------------------------------// Organization resolution + authorization.//// One module for the cloud org auth-resolution path:// - `resolveOrganization` — local mirror with lazy WorkOS fallback.// - `authorizeOrganization` — membership check against the local membership// mirror, returns the resolved org.//// Deliberately billing-FREE: this module is reached by the MCP session DO bundle// (via `mcp/auth.ts`), which must not transitively import any billing config// (`autumn.config` / `atmn`). The free-organizations-per-user limit predicates —// which DO depend on the Autumn plan config — live in `extensions/billing/plans.ts`.// --------------------------------------------------------------------------- import { Clock, Effect } from "effect";import { MemberDirectory } from "@executor-js/api/server";import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; import { UserStoreService } from "./context";import { ensureOrganizationBackfilled } from "./mirror-feeders";import type { Organization } from "./user-store";import { WorkOSClient } from "./workos"; // ---------------------------------------------------------------------------// Resolution — local mirror with lazy WorkOS fallback.// ---------------------------------------------------------------------------//// We keep a minimal local mirror of organizations so domain tables can// foreign-key against them and so we don't hit WorkOS on every request.// But the mirror can drift: a user's session can reference an org that was// created outside this app (or before the mirror existed). Rather than// proactively mirroring on every login — which was the source of the messy// callback flow we just untangled — we mirror lazily the first time an// unknown org is read. All other callers just do `getOrganization` and get// a self-healing lookup for free.//// URL slugs are OURS (WorkOS orgs have none) and are minted at the moment a// row is inserted — `upsertOrganization` is the single mint point, so the// mirror-on-first-read below produces a slugged, routable org without any// read-path healing. export const resolveOrganization = (organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); if (existing) return existing; const workos = yield* WorkOSClient; const fresh = yield* workos.getOrganization(organizationId); return yield* users.use("upsertOrganization", (s) => s.upsertOrganization({ id: fresh.id, name: fresh.name, updatedAt: new Date(fresh.updatedAt), }), ); }); // ---------------------------------------------------------------------------// Deletion mark — the local step that revokes an organization.// ---------------------------------------------------------------------------//// Membership is authorized from the local mirror (below), so deleting the// WorkOS organization revokes nothing here by itself: the local membership// rows keep authorizing sessions until the local purge removes them, and a// purge that fails leaves them live. This mark is what revokes access, and it// is the FIRST step of cloud's deletion flow (`auth/handlers.ts`// deleteOrganization) — before the WorkOS delete and the purge, both of which// can fail — and what the `organization.deleted` event applies// (`workos-events-sync.ts`) when the org was deleted in the WorkOS dashboard// instead. Membership rows are left as they are; the mark alone refuses them.// Idempotent: a retry keeps the first mark. An org the mirror does not hold// is not marked (nothing to revoke), and `false` says so. export const markOrganizationDeleted = (organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; const at = new Date(yield* Clock.currentTimeMillis); const marked = yield* users.use("markOrganizationDeleted", (s) => s.markOrganizationDeleted(organizationId, at), ); return marked !== null; }); // ---------------------------------------------------------------------------// Authorization — membership check against the local membership mirror.// ---------------------------------------------------------------------------//// The sealed session cookie carries an organizationId that WorkOS signed at// login / refresh time. WorkOS does NOT invalidate existing sessions when a// membership is revoked, and `session.authenticate()` validates the JWT// locally without hitting the API — so a removed user would keep full access// until their access token naturally expired (~10 min) if the session were// trusted on its own.//// To close that gap, membership is verified on every protected request// against the LOCAL mirror of WorkOS memberships (`memberships` join// `accounts`, read through the shared `MemberDirectory`) — never against// WorkOS itself, and unconditionally: there is no readiness gate and no// per-request WorkOS fallback. The mirror is not a cache with a TTL; it is a// replica whose freshness is defined by its feeders:// - login (`auth/handlers.ts` callback): the user and every membership WorkOS// lists for them, from the list the callback already fetches;// - write-through: every membership change Executor makes (create org,// invite, accept, remove, change role) lands in the mirror in the same// request, so a revocation through Executor is denied on the NEXT request;// - the WorkOS Events API reconciler (`workos-events-sync.ts`, every minute// by cron plus a signed webhook poke): changes made in the WorkOS// dashboard land within seconds.// The membership row must be `active`: a pending invitee is not a member, and a// deactivated member keeps their row but not their access. And the// organization must not be marked deleted (`organizations.deleted_at`): cloud's// deletion flow (`auth/handlers.ts` deleteOrganization) sets that mark FIRST,// before the WorkOS delete and the local purge, so an org whose deletion did// not finish refuses every session at once — its membership rows are still// there, live, until the purge removes them, and must not authorize anyone.//// The one-off backfill is complete and permanent, and an organization that// predates it is covered on demand (below), so there is nothing left for a// per-request readiness check to gate. What can still go wrong is the events// reconciler falling behind — a member revoked in the WorkOS dashboard would// keep a stale active row until it catches up. That is now an OPERATIONAL// concern, not a request-path fallback: the reconciler itself// (`workos-events-runner.ts`) checks its own drain lag after every run and// raises a Sentry error when it has stalled, so it is fixed by paging someone,// not by asking WorkOS on every request. The deploy gate// (`scripts/ensure-workos-mirror-ready.ts`) separately refuses to ship a build// that trusts the mirror while it is unready, using the same rule// (`mirror-readiness-store.ts`).//// Completeness is PER ORGANIZATION. An organization whose row was minted// after the backfill ran — lazily by a request (`resolveOrganization`), or by// a first login — carries no `backfilled_at`, and the mirror holds only the// memberships login and write-through happened to record for it: a member// who has not signed in since would be refused on a row that was never// written. So the org row is read FIRST, and an unmarked live organization is// scanned from WorkOS (`ensureOrganizationBackfilled`: one membership listing// plus one `getUser` per member, then the mark) BEFORE its mirror is read —// the same on-demand scan the seat gates run. One-time per organization: the// scan marks the row, and this branch is never taken for it again. An// organization the mirror does not hold at all — one that predates the// mirror and that nobody has signed in to since (a CLI or MCP token names it,// and the JWT path has no login feeder), or one created in the WorkOS// dashboard — is reachable by neither the backfill (which lists the mirror's// organizations) nor the reconciler (which starts at the replay boundary), so// it is resolved on demand HERE: WorkOS is asked for the caller's own// membership in it first (`getUserOrgMembership`, a read scoped to this// caller — never a listing of the org), and only a member's answer mints the// row (`resolveOrganization`) and scans it as above. A non-member mints// nothing: a signed-in caller cannot create the row of an arbitrary WorkOS// organization by naming its id. An organization marked deleted is never// scanned: WorkOS no longer has it, and its rows are the purge's to remove,// not a listing's to refresh.//// Returns the resolved organization if the user currently holds an *active*// membership in it, otherwise null. Callers should treat null as "no access"// and route accordingly (onboarding page / 403).//// The ONE caller that may see a marked org is the deletion flow itself// (`deleted: "allow"`): an admin whose deletion failed after the mark must be// able to send it again to finish the purge, and their membership row is// still there to authorize exactly that. export interface AuthorizeOrganizationOptions { /** Whether an organization marked deleted resolves (`"allow"`) or is refused (default). */ readonly deleted?: "refuse" | "allow";} /** The caller's active membership in the org, however it was read: only the role matters past this point. */interface ActiveMembership { readonly role: string;} // The mirror read: the caller's row, active or nothing.const activeMembershipFromMirror = (userId: string, organizationId: string) => Effect.gen(function* () { const directory = yield* MemberDirectory; const membership = yield* directory.membership(userId, organizationId); if (!membership || membership.status !== "active") return null; const active: ActiveMembership = { role: membership.role }; return active; }); // The authorized organization, or null for one marked deleted (unless the// caller is the deletion flow). The membership already names the caller's// role — surfaced normalized so identity resolution can bind the executor's// workspace write permission without a second read. WorkOS issues `admin` /// `member`; anything unrecognized stays a plain member.const authorized = ( org: Organization, membership: ActiveMembership, options: AuthorizeOrganizationOptions,) => { if (org.deletedAt !== null && options.deleted !== "allow") return null; const memberRole: "admin" | "member" = membership.role === "admin" ? "admin" : "member"; return { ...org, memberRole };}; // The organization row for a caller, minted from WorkOS when the mirror// does not hold it — only for a caller WorkOS confirms as its member (see// above). `null` when the mirror has no row and WorkOS lists no membership.const heldOrResolvedForMember = (userId: string, organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; const held = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); if (held) return held; const workos = yield* WorkOSClient; const membership = yield* workos.getUserOrgMembership(organizationId, userId); if (!membership) return null; yield* Effect.logInfo( "authorizeOrganization: organization not mirrored; resolving it from WorkOS for its member", { organizationId }, ); return yield* resolveOrganization(organizationId); }); /** The span every membership authorization runs under. */export const AUTHORIZE_ORGANIZATION_SPAN = "auth.authorize_organization"; export const authorizeOrganization = ( userId: string, organizationId: string, options: AuthorizeOrganizationOptions = {},) => Effect.gen(function* () { const org = yield* heldOrResolvedForMember(userId, organizationId); if (!org) return null; // An unmarked live organization is scanned before its mirror is read // (see above). The row returned below still shows the mark as it was // read; nothing past this point reads it. A marked-deleted organization // is never scanned, so the deletion retry (`deleted: "allow"`) reaches // `authorized()` below with the membership row the purge has not removed // yet. if (org.deletedAt === null && org.backfilledAt === null) { yield* ensureOrganizationBackfilled(organizationId); } const membership = yield* activeMembershipFromMirror(userId, organizationId); if (!membership) return null; return authorized(org, membership, options); }).pipe(Effect.withSpan(AUTHORIZE_ORGANIZATION_SPAN)); // ---------------------------------------------------------------------------// Org SELECTOR — the URL is the scope authority, not the session.// ---------------------------------------------------------------------------//// Org-scoped requests carry the active org in this header, set by the web// client from the console URL's slug (the MCP plane carries the same idea in// its own `x-executor-mcp-organization`). The selector is a slug (`acme`, the// readable URL form) or a WorkOS id (`org_…`, the legacy/token form). It is a// SELECTOR, not a trust boundary: `authorizeOrganizationSelector` re-checks// membership against the mirror, so the worst a forged header does is name an// org the caller already belongs to.//// Why a header and not the session's `org_id`: a browser shares ONE cookie jar// across tabs, so a single session-pinned org makes "active org" a// browser-global — two tabs can't be in two orgs at once, and switching in one// silently re-scopes the other. Scoping per-request from the URL makes each// tab independent. export const ORG_SELECTOR_HEADER = EXECUTOR_ORG_SELECTOR_HEADER; /** The URL-pinned org selector for a request, or `null` to fall back to the session. */export const orgSelectorFromRequest = (request: Request): string | null => request.headers.get(ORG_SELECTOR_HEADER); /** * Resolve an org SELECTOR (URL slug or `org_…` id) to the organization the * caller actively belongs to, or `null`. A slug resolves through the local * mirror to its id first; ids pass straight through. Either way membership is * verified against the mirror via {@link authorizeOrganization}. */export const authorizeOrganizationSelector = ( userId: string, selector: string, options: AuthorizeOrganizationOptions = {},) => Effect.gen(function* () { if (selector.startsWith("org_")) { return yield* authorizeOrganization(userId, selector, options); } const users = yield* UserStoreService; const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector)); if (!org) return null; return yield* authorizeOrganization(userId, org.id, options); });
// ---------------------------------------------------------------------------
// Organization resolution + authorization.
//
// One module for the cloud org auth-resolution path:
// - `resolveOrganization` — local mirror with lazy WorkOS fallback.
// - `authorizeOrganization` — membership check against the local membership
// mirror, returns the resolved org.
//
// Deliberately billing-FREE: this module is reached by the MCP session DO bundle
// (via `mcp/auth.ts`), which must not transitively import any billing config
// (`autumn.config` / `atmn`). The free-organizations-per-user limit predicates —
// which DO depend on the Autumn plan config — live in `extensions/billing/plans.ts`.
// ---------------------------------------------------------------------------
import { Clock, Effect } from "effect";
import { MemberDirectory } from "@executor-js/api/server";
import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared";
import { UserStoreService } from "./context";
import { ensureOrganizationBackfilled } from "./mirror-feeders";
import type { Organization } from "./user-store";
import { WorkOSClient } from "./workos";
// ---------------------------------------------------------------------------
// Resolution — local mirror with lazy WorkOS fallback.
// ---------------------------------------------------------------------------
//
// We keep a minimal local mirror of organizations so domain tables can
// foreign-key against them and so we don't hit WorkOS on every request.
// But the mirror can drift: a user's session can reference an org that was
// created outside this app (or before the mirror existed). Rather than
// proactively mirroring on every login — which was the source of the messy
// callback flow we just untangled — we mirror lazily the first time an
// unknown org is read. All other callers just do `getOrganization` and get
// a self-healing lookup for free.
//
// URL slugs are OURS (WorkOS orgs have none) and are minted at the moment a
// row is inserted — `upsertOrganization` is the single mint point, so the
// mirror-on-first-read below produces a slugged, routable org without any
// read-path healing.
export const resolveOrganization = (organizationId: string) =>
Effect.gen(function* () {
const users = yield* UserStoreService;
const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
if (existing) return existing;
const workos = yield* WorkOSClient;
const fresh = yield* workos.getOrganization(organizationId);
return yield* users.use("upsertOrganization", (s) =>
s.upsertOrganization({
id: fresh.id,
name: fresh.name,
updatedAt: new Date(fresh.updatedAt),
}),
);
});
// ---------------------------------------------------------------------------
// Deletion mark — the local step that revokes an organization.
// ---------------------------------------------------------------------------
//
// Membership is authorized from the local mirror (below), so deleting the
// WorkOS organization revokes nothing here by itself: the local membership
// rows keep authorizing sessions until the local purge removes them, and a
// purge that fails leaves them live. This mark is what revokes access, and it
// is the FIRST step of cloud's deletion flow (`auth/handlers.ts`
// deleteOrganization) — before the WorkOS delete and the purge, both of which
// can fail — and what the `organization.deleted` event applies
// (`workos-events-sync.ts`) when the org was deleted in the WorkOS dashboard
// instead. Membership rows are left as they are; the mark alone refuses them.
// Idempotent: a retry keeps the first mark. An org the mirror does not hold
// is not marked (nothing to revoke), and `false` says so.
export const markOrganizationDeleted = (organizationId: string) =>
Effect.gen(function* () {
const users = yield* UserStoreService;
const at = new Date(yield* Clock.currentTimeMillis);
const marked = yield* users.use("markOrganizationDeleted", (s) =>
s.markOrganizationDeleted(organizationId, at),
);
return marked !== null;
});
// ---------------------------------------------------------------------------
// Authorization — membership check against the local membership mirror.
// ---------------------------------------------------------------------------
//
// The sealed session cookie carries an organizationId that WorkOS signed at
// login / refresh time. WorkOS does NOT invalidate existing sessions when a
// membership is revoked, and `session.authenticate()` validates the JWT
// locally without hitting the API — so a removed user would keep full access
// until their access token naturally expired (~10 min) if the session were
// trusted on its own.
//
// To close that gap, membership is verified on every protected request
// against the LOCAL mirror of WorkOS memberships (`memberships` join
// `accounts`, read through the shared `MemberDirectory`) — never against
// WorkOS itself, and unconditionally: there is no readiness gate and no
// per-request WorkOS fallback. The mirror is not a cache with a TTL; it is a
// replica whose freshness is defined by its feeders:
// - login (`auth/handlers.ts` callback): the user and every membership WorkOS
// lists for them, from the list the callback already fetches;
// - write-through: every membership change Executor makes (create org,
// invite, accept, remove, change role) lands in the mirror in the same
// request, so a revocation through Executor is denied on the NEXT request;
// - the WorkOS Events API reconciler (`workos-events-sync.ts`, every minute
// by cron plus a signed webhook poke): changes made in the WorkOS
// dashboard land within seconds.
// The membership row must be `active`: a pending invitee is not a member, and a
// deactivated member keeps their row but not their access. And the
// organization must not be marked deleted (`organizations.deleted_at`): cloud's
// deletion flow (`auth/handlers.ts` deleteOrganization) sets that mark FIRST,
// before the WorkOS delete and the local purge, so an org whose deletion did
// not finish refuses every session at once — its membership rows are still
// there, live, until the purge removes them, and must not authorize anyone.
//
// The one-off backfill is complete and permanent, and an organization that
// predates it is covered on demand (below), so there is nothing left for a
// per-request readiness check to gate. What can still go wrong is the events
// reconciler falling behind — a member revoked in the WorkOS dashboard would
// keep a stale active row until it catches up. That is now an OPERATIONAL
// concern, not a request-path fallback: the reconciler itself
// (`workos-events-runner.ts`) checks its own drain lag after every run and
// raises a Sentry error when it has stalled, so it is fixed by paging someone,
// not by asking WorkOS on every request. The deploy gate
// (`scripts/ensure-workos-mirror-ready.ts`) separately refuses to ship a build
// that trusts the mirror while it is unready, using the same rule
// (`mirror-readiness-store.ts`).
//
// Completeness is PER ORGANIZATION. An organization whose row was minted
// after the backfill ran — lazily by a request (`resolveOrganization`), or by
// a first login — carries no `backfilled_at`, and the mirror holds only the
// memberships login and write-through happened to record for it: a member
// who has not signed in since would be refused on a row that was never
// written. So the org row is read FIRST, and an unmarked live organization is
// scanned from WorkOS (`ensureOrganizationBackfilled`: one membership listing
// plus one `getUser` per member, then the mark) BEFORE its mirror is read —
// the same on-demand scan the seat gates run. One-time per organization: the
// scan marks the row, and this branch is never taken for it again. An
// organization the mirror does not hold at all — one that predates the
// mirror and that nobody has signed in to since (a CLI or MCP token names it,
// and the JWT path has no login feeder), or one created in the WorkOS
// dashboard — is reachable by neither the backfill (which lists the mirror's
// organizations) nor the reconciler (which starts at the replay boundary), so
// it is resolved on demand HERE: WorkOS is asked for the caller's own
// membership in it first (`getUserOrgMembership`, a read scoped to this
// caller — never a listing of the org), and only a member's answer mints the
// row (`resolveOrganization`) and scans it as above. A non-member mints
// nothing: a signed-in caller cannot create the row of an arbitrary WorkOS
// organization by naming its id. An organization marked deleted is never
// scanned: WorkOS no longer has it, and its rows are the purge's to remove,
// not a listing's to refresh.
//
// Returns the resolved organization if the user currently holds an *active*
// membership in it, otherwise null. Callers should treat null as "no access"
// and route accordingly (onboarding page / 403).
//
// The ONE caller that may see a marked org is the deletion flow itself
// (`deleted: "allow"`): an admin whose deletion failed after the mark must be
// able to send it again to finish the purge, and their membership row is
// still there to authorize exactly that.
export interface AuthorizeOrganizationOptions {
/** Whether an organization marked deleted resolves (`"allow"`) or is refused (default). */
readonly deleted?: "refuse" | "allow";
}
/** The caller's active membership in the org, however it was read: only the role matters past this point. */
interface ActiveMembership {
readonly role: string;
}
// The mirror read: the caller's row, active or nothing.
const activeMembershipFromMirror = (userId: string, organizationId: string) =>
Effect.gen(function* () {
const directory = yield* MemberDirectory;
const membership = yield* directory.membership(userId, organizationId);
if (!membership || membership.status !== "active") return null;
const active: ActiveMembership = { role: membership.role };
return active;
});
// The authorized organization, or null for one marked deleted (unless the
// caller is the deletion flow). The membership already names the caller's
// role — surfaced normalized so identity resolution can bind the executor's
// workspace write permission without a second read. WorkOS issues `admin` /
// `member`; anything unrecognized stays a plain member.
const authorized = (
org: Organization,
membership: ActiveMembership,
options: AuthorizeOrganizationOptions,
) => {
if (org.deletedAt !== null && options.deleted !== "allow") return null;
const memberRole: "admin" | "member" = membership.role === "admin" ? "admin" : "member";
return { ...org, memberRole };
};
// The organization row for a caller, minted from WorkOS when the mirror
// does not hold it — only for a caller WorkOS confirms as its member (see
// above). `null` when the mirror has no row and WorkOS lists no membership.
const heldOrResolvedForMember = (userId: string, organizationId: string) =>
Effect.gen(function* () {
const users = yield* UserStoreService;
const held = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId));
if (held) return held;
const workos = yield* WorkOSClient;
const membership = yield* workos.getUserOrgMembership(organizationId, userId);
if (!membership) return null;
yield* Effect.logInfo(
"authorizeOrganization: organization not mirrored; resolving it from WorkOS for its member",
{ organizationId },
);
return yield* resolveOrganization(organizationId);
});
/** The span every membership authorization runs under. */
export const AUTHORIZE_ORGANIZATION_SPAN = "auth.authorize_organization";
export const authorizeOrganization = (
userId: string,
organizationId: string,
options: AuthorizeOrganizationOptions = {},
) =>
Effect.gen(function* () {
const org = yield* heldOrResolvedForMember(userId, organizationId);
if (!org) return null;
// An unmarked live organization is scanned before its mirror is read
// (see above). The row returned below still shows the mark as it was
// read; nothing past this point reads it. A marked-deleted organization
// is never scanned, so the deletion retry (`deleted: "allow"`) reaches
// `authorized()` below with the membership row the purge has not removed
// yet.
if (org.deletedAt === null && org.backfilledAt === null) {
yield* ensureOrganizationBackfilled(organizationId);
}
const membership = yield* activeMembershipFromMirror(userId, organizationId);
if (!membership) return null;
return authorized(org, membership, options);
}).pipe(Effect.withSpan(AUTHORIZE_ORGANIZATION_SPAN));
// ---------------------------------------------------------------------------
// Org SELECTOR — the URL is the scope authority, not the session.
// ---------------------------------------------------------------------------
//
// Org-scoped requests carry the active org in this header, set by the web
// client from the console URL's slug (the MCP plane carries the same idea in
// its own `x-executor-mcp-organization`). The selector is a slug (`acme`, the
// readable URL form) or a WorkOS id (`org_…`, the legacy/token form). It is a
// SELECTOR, not a trust boundary: `authorizeOrganizationSelector` re-checks
// membership against the mirror, so the worst a forged header does is name an
// org the caller already belongs to.
//
// Why a header and not the session's `org_id`: a browser shares ONE cookie jar
// across tabs, so a single session-pinned org makes "active org" a
// browser-global — two tabs can't be in two orgs at once, and switching in one
// silently re-scopes the other. Scoping per-request from the URL makes each
// tab independent.
export const ORG_SELECTOR_HEADER = EXECUTOR_ORG_SELECTOR_HEADER;
/** The URL-pinned org selector for a request, or `null` to fall back to the session. */
export const orgSelectorFromRequest = (request: Request): string | null =>
request.headers.get(ORG_SELECTOR_HEADER);
/**
* Resolve an org SELECTOR (URL slug or `org_…` id) to the organization the
* caller actively belongs to, or `null`. A slug resolves through the local
* mirror to its id first; ids pass straight through. Either way membership is
* verified against the mirror via {@link authorizeOrganization}.
*/
export const authorizeOrganizationSelector = (
userId: string,
selector: string,
options: AuthorizeOrganizationOptions = {},
) =>
Effect.gen(function* () {
if (selector.startsWith("org_")) {
return yield* authorizeOrganization(userId, selector, options);
}
const users = yield* UserStoreService;
const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector));
if (!org) return null;
return yield* authorizeOrganization(userId, org.id, options);
});