// --------------------------------------------------------------------------- // Organization resolution + authorization. // // One module for the cloud org auth-resolution path: // - `resolveOrganization` — local mirror with lazy WorkOS fallback. // - `authorizeOrganization` — live membership check, 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 { Effect } from "effect"; import { UserStoreService } from "./context"; 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((s) => s.getOrganization(organizationId)); if (existing) return existing; const workos = yield* WorkOSClient; const fresh = yield* workos.getOrganization(organizationId); return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); }); // --------------------------------------------------------------------------- // Authorization — live membership check against WorkOS. // --------------------------------------------------------------------------- // // 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 keeps full access // until their access token naturally expires (~10 min). // // To close that gap we verify membership live on every protected request. // `listUserMemberships` is one WorkOS call per request. If this becomes a // hot path we can layer a short per-(user, org) TTL cache underneath, or // swap it for a local memberships table fed by the WorkOS Events API. // // Returns the resolved organization (via resolveOrganization) 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). export const authorizeOrganization = (userId: string, organizationId: string) => Effect.gen(function* () { const workos = yield* WorkOSClient; const memberships = yield* workos.listUserMemberships(userId); const active = memberships.data.find( (m: { readonly organizationId: string; readonly status: string }) => m.organizationId === organizationId && m.status === "active", ); if (!active) return null; return yield* resolveOrganization(organizationId); }); /** * Resolve a tenant 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 live via {@link authorizeOrganization}. */ export const authorizeOrganizationSelector = (userId: string, selector: string) => Effect.gen(function* () { if (selector.startsWith("org_")) { return yield* authorizeOrganization(userId, selector); } const users = yield* UserStoreService; const org = yield* users.use((s) => s.getOrganizationBySlug(selector)); if (!org) return null; return yield* authorizeOrganization(userId, org.id); });