397 lines · 18.1 KB · TypeScript
RawDownload
import { Context, Effect, Layer } from "effect"; import { AccountProvider, type AccountHeaders } from "@executor-js/api/server";import {  AccountError,  AccountForbidden,  AccountNoOrganization,  AccountUnauthorized,} from "@executor-js/api"; import { ApiKeyService } from "../auth/api-keys";import { UserStoreService } from "../auth/context";import type { Session } from "../auth/middleware";import { WorkOSClient } from "../auth/workos";import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization";import { AutumnService } from "../extensions/billing/service";import {  countSeatsUsed,  getMemberLimitForPlan,  selectActiveMemberLimitPlan,} from "../extensions/billing/plans"; // The per-request resolved caller, injected by the cookie-only session// middleware in `account-api.ts`. Carries the authenticated WorkOS session, or// `null` when the `wos-session` cookie is missing/invalid — the service maps// `null` to AccountUnauthorized (401) at the method boundary, exactly where the// inline `requireSession` used to. This is the SINGLE cookie-resolution path:// the same `WorkOSClient.authenticateSealedSession` the rest of cloud uses.export class AccountCaller extends Context.Service<  AccountCaller,  { readonly session: Session | null }>()("@executor-js/cloud/AccountCaller") {} // ---------------------------------------------------------------------------// Cloud AccountProvider — implements the provider-neutral account surface over// WorkOS. The shared `AccountHandlers` call this; self-host provides its own// Better Auth implementation of the same shape.//// The caller is resolved ONCE per request by the cookie-only session// middleware in `account-api.ts` (the SAME `WorkOSClient.authenticateSealedSession`// off the `wos-session` cookie that `SessionAuthLive` uses) and injected here as// the `SessionContext`. This service no longer parses the cookie itself: there// is exactly one cookie-resolution path. It still accepts ONLY the wos-session// sealed-session cookie — it is NOT routed through the api-key-accepting// executor identity provider — so the credentials `/account/*` accepts are// byte-identical to before.//// It then runs the EXACT logic that used to live in `auth/handlers.ts`// (me / API keys) and `org/handlers.ts` (members / roles / invite / role /// name). Native WorkOS / store failures are mapped at this boundary onto the// neutral account errors so the shared UI sees one shape://   WorkOSError | UserStoreError | ApiKeyManagementError → AccountError//   no organization in session                           → AccountNoOrganization//   not-an-admin / over-seat-limit / not-allowed         → AccountForbidden// --------------------------------------------------------------------------- const MAX_API_KEY_NAME_LENGTH = 80; // Lift any cloud-side tagged failure (WorkOSError / UserStoreError /// ApiKeyManagementError — none of which carry a safe user-facing message) onto// the neutral AccountError (500), matching the cloud handlers' httpApiStatus.const toAccountError = () => Effect.fail(new AccountError({ message: "Account request failed" })); export const workosAccountProvider: Layer.Layer<  AccountProvider,  never,  WorkOSClient | UserStoreService | ApiKeyService | AutumnService | AccountCaller> = Layer.effect(AccountProvider)(  Effect.gen(function* () {    const workos = yield* WorkOSClient;    const apiKeys = yield* ApiKeyService;    const autumn = yield* AutumnService;    const users = yield* UserStoreService;     // The caller, resolved once per request by the cookie-only session    // middleware (account-api.ts) — the same credential `SessionAuthLive`    // accepts. The method bodies read the already-authenticated session rather    // than re-parsing the cookie. `null` => no/invalid session.    const caller = yield* AccountCaller;     // Capture the resolved service context once so the method bodies — which    // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) —    // can be erased to `R = never`, as the neutral AccountProvider shape    // requires. Provided per method below.    const ctx = yield* Effect.context<WorkOSClient | UserStoreService>();     // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly    // as the old inline `requireSession` did.    const requireSession = () =>      caller.session        ? Effect.succeed(caller.session)        : Effect.fail<AccountUnauthorized>(new AccountUnauthorized());     // The org scope for an org-scoped request: the console URL's org, sent in    // the selector header. FAIL CLOSED when it's missing — the session's own    // org is a browser-global pinned to whichever org WorkOS last touched, so    // falling back to it scopes a multi-org user's request to the WRONG org    // (see workos-auth-provider.resolveSessionPrincipal). Membership is    // re-checked live, so the header is a selector, not a trust boundary —    // and two browser tabs on different orgs each send their own header, so    // they stay independent (see organization.ts). Yields the session +    // resolved org, or AccountNoOrganization.    const requireOrganization = (headers: AccountHeaders) =>      Effect.gen(function* () {        const session = yield* requireSession();        const selector = headers[ORG_SELECTOR_HEADER];        if (!selector) {          return yield* new AccountNoOrganization();        }        const org = yield* authorizeOrganizationSelector(session.accountId, selector).pipe(          Effect.provideContext(ctx),          Effect.mapError(() => new AccountNoOrganization()),        );        if (!org) return yield* new AccountNoOrganization();        return { session, org };      });     // Mirror of org/handlers `requireAdmin`, but scoped to the resolved org.    const requireAdmin = (accountId: string, organizationId: string) =>      Effect.gen(function* () {        const membership = yield* workos          .getUserOrgMembership(organizationId, accountId)          .pipe(Effect.catchTag("WorkOSError", toAccountError));        if (!membership || membership.role?.slug !== "admin") {          return yield* new AccountForbidden();        }      });     // Mirror of org/handlers `assertMembershipInSessionOrg` — ownership check so    // an admin can't mutate a membership id from another org.    const assertMembershipInOrg = (organizationId: string, membershipId: string) =>      Effect.gen(function* () {        const membership = yield* workos          .getOrgMembership(membershipId)          .pipe(Effect.catchCause(() => Effect.succeed(null)));        if (!membership || membership.organizationId !== organizationId) {          return yield* new AccountForbidden();        }      });     // Mirror of org/handlers `getMemberSeats` — live seat usage from WorkOS.    const getMemberSeats = (organizationId: string) =>      Effect.gen(function* () {        const customer = yield* autumn.use((client) =>          client.customers.getOrCreate({ customerId: organizationId }),        );        const planId = selectActiveMemberLimitPlan(customer.subscriptions);        const limit = getMemberLimitForPlan(planId);         // `listOrgMembers` returns active members AND pending memberships (an        // invited user shows up as status "pending"); `listPendingInvitations`        // returns the same invited users again. `countSeatsUsed` dedupes them        // so an outstanding invite is not counted twice.        const memberships = yield* workos.listOrgMembers(organizationId);        const invitations = yield* workos.listPendingInvitations(organizationId);         return {          used: countSeatsUsed(memberships.data, invitations.data.length),          granted: limit ?? 0,          unlimited: limit === null,        };      });     // Mirror of org/handlers `reserveMemberSlot` — fail closed on lookup error.    const reserveMemberSlot = (organizationId: string) =>      Effect.gen(function* () {        const seats = yield* getMemberSeats(organizationId).pipe(          Effect.catchCause(() => Effect.fail(new AccountForbidden())),        );        if (!seats.unlimited && seats.used >= seats.granted) {          // Name the real reason so the UI can tell the admin this is a plan          // limit (retrying will not help), not a transient failure. The          // fail-closed lookup error above stays message-less (genuinely          // retryable), so only the cap hit carries this copy.          const plural = seats.granted === 1 ? "member" : "members";          return yield* new AccountForbidden({            message: `Your plan includes ${seats.granted} ${plural}. Upgrade your plan to invite more.`,          });        }      });     return AccountProvider.of({      me: (headers) =>        Effect.gen(function* () {          const session = yield* requireSession();          // The ONE sanctioned fallback to the session org. /account/me is          // identity, not tenant data: on a bare URL (no org slug yet, so no          // header) it answers "which org should this browser land in", and          // the session's pinned org is the only candidate. Every org-scoped          // DATA read fails closed instead (requireOrganization above,          // resolveSessionPrincipal) — serving tenant data from this fallback          // is exactly the wrong-org connection-list bug (2026-07). With a          // header (any slugged URL), the URL's org drives the answer so the          // shell reflects the org the tab is viewing.          const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId;          const org = selector            ? yield* authorizeOrganizationSelector(session.accountId, selector).pipe(                Effect.provideContext(ctx),                Effect.orElseSucceed(() => null),              )            : null;          return {            user: {              id: session.accountId,              email: session.email,              name: session.name,              avatarUrl: session.avatarUrl,            },            organization: org ? { id: org.id, name: org.name, slug: org.slug } : null,          };        }),       listApiKeys: (headers) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          const keys = yield* apiKeys            .listUserKeys({ accountId: session.accountId, organizationId: org.id })            .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));          return { apiKeys: keys };        }),       createApiKey: (headers, name) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);          if (!trimmed) {            return yield* new AccountError({ message: "API key name is required" });          }          return yield* apiKeys            .createUserKey({ accountId: session.accountId, organizationId: org.id, name: trimmed })            .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));        }),       revokeApiKey: (headers, apiKeyId) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          const ownedKeys = yield* apiKeys            .listUserKeys({ accountId: session.accountId, organizationId: org.id })            .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));          if (!ownedKeys.some((key) => key.id === apiKeyId)) {            return yield* new AccountError({ message: "API key not found" });          }          yield* apiKeys            .revokeUserKey({ keyId: apiKeyId })            .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));          return { success: true };        }),       // Org keys read every user in the tenant through the `/admin/*` plane, so      // both paths are admin-gated — unlike personal keys, which any member may      // mint for themselves.      listOrgApiKeys: (headers) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          yield* requireAdmin(session.accountId, org.id);          const keys = yield* apiKeys            .listOrgKeys({ organizationId: org.id })            .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));          return { apiKeys: keys };        }),       createOrgApiKey: (headers, name) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          yield* requireAdmin(session.accountId, org.id);          const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH);          if (!trimmed) {            return yield* new AccountError({ message: "API key name is required" });          }          return yield* apiKeys            .createOrgKey({ organizationId: org.id, name: trimmed })            .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError));        }),       // Same admin gate as the mint. Note this deliberately does NOT reuse      // `revokeApiKey`: that path resolves ownership against the caller's      // personal keys, so an org key id is never in its owned set. Ownership      // here is resolved against the ORG's keys inside `revokeOrgKey`, which is      // what keeps a user-scoped (or foreign-org) key id from being destroyed      // through the org route — it surfaces as a 404-shaped AccountError, not a      // silent success and not a 500.      revokeOrgApiKey: (headers, apiKeyId) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          yield* requireAdmin(session.accountId, org.id);          yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe(            Effect.catchTag("ApiKeyManagementError", toAccountError),            Effect.catchTag("OrgApiKeyNotFound", () =>              Effect.fail(new AccountError({ message: "Organization API key not found" })),            ),          );          return { success: true };        }),       listMembers: (headers) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);           // Seats fall back to safe display defaults on lookup error — never          // blank the page over a transient Autumn/WorkOS hiccup. The real cap          // gate lives in `reserveMemberSlot`, which fails closed.          const seats = yield* getMemberSeats(org.id).pipe(            Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })),          );           const memberships = yield* workos            .listOrgMembers(org.id)            .pipe(Effect.catchTag("WorkOSError", toAccountError));           const members = yield* Effect.all(            memberships.data.map((m) =>              Effect.gen(function* () {                const user = yield* workos.getUser(m.userId);                return {                  id: m.id,                  userId: m.userId,                  email: user.email,                  name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,                  avatarUrl: user.profilePictureUrl ?? null,                  role: m.role?.slug ?? "member",                  status: m.status,                  lastActiveAt: user.lastSignInAt ?? null,                  isCurrentUser: m.userId === session.accountId,                };              }),            ),            { concurrency: 5 },          ).pipe(Effect.catchTag("WorkOSError", toAccountError));           return { members, seats };        }),       listRoles: (headers) =>        Effect.gen(function* () {          const { org } = yield* requireOrganization(headers);          const result = yield* workos            .listOrgRoles(org.id)            .pipe(Effect.catchTag("WorkOSError", toAccountError));          return {            roles: result.data.map((r) => ({ slug: r.slug, name: r.name })),          };        }),       inviteMember: (headers, body) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          yield* requireAdmin(session.accountId, org.id);          yield* reserveMemberSlot(org.id);          const invitation = yield* workos            .sendInvitation({              email: body.email,              organizationId: org.id,              ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}),            })            .pipe(Effect.catchTag("WorkOSError", toAccountError));          return { id: invitation.id, email: invitation.email };        }),       removeMember: (headers, membershipId) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          yield* requireAdmin(session.accountId, org.id);          yield* assertMembershipInOrg(org.id, membershipId);          yield* workos            .deleteOrgMembership(membershipId)            .pipe(Effect.catchTag("WorkOSError", toAccountError));          return { success: true };        }),       updateMemberRole: (headers, membershipId, roleSlug) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          yield* requireAdmin(session.accountId, org.id);          yield* assertMembershipInOrg(org.id, membershipId);          yield* workos            .updateOrgMembershipRole(membershipId, roleSlug)            .pipe(Effect.catchTag("WorkOSError", toAccountError));          return { success: true };        }),       updateOrgName: (headers, name) =>        Effect.gen(function* () {          const { session, org } = yield* requireOrganization(headers);          yield* requireAdmin(session.accountId, org.id);          const updated = yield* workos            .updateOrganization(org.id, name)            .pipe(Effect.catchTag("WorkOSError", toAccountError));          yield* users            .use("upsertOrganization", (s) =>              s.upsertOrganization({ id: updated.id, name: updated.name }),            )            .pipe(Effect.catchTag("UserStoreError", toAccountError));          return { name: updated.name };        }),    } satisfies AccountProvider["Service"]);  }),);