// ---------------------------------------------------------------------------// 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.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); });
// ---------------------------------------------------------------------------
// 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.
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);
});