286 lines · 10.1 KB · TypeScript
RawDownload
import * as Cloudflare from "alchemy/Cloudflare/Workers/Runtime";import { HttpApiBuilder } from "effect/unstable/httpapi";import { Cause, Effect } from "effect"; import { UserStoreService } from "../auth/context";import { AuthContext } from "../auth/middleware";import { WorkOSAuth } from "../auth/workos";import { AutumnService } from "../services/autumn";import { OrgHttpApi } from "./compose";import { Forbidden } from "./api";import { getMemberLimitForPlan, selectActiveMemberLimitPlan } from "./member-limits"; const requireAdmin = Effect.gen(function* () {  const auth = yield* AuthContext;  const workos = yield* WorkOSAuth;  const memberships = yield* workos.listOrgMembers(auth.organizationId);  const currentMembership = memberships.data.find((m) => m.userId === auth.accountId);  if (!currentMembership || currentMembership.role?.slug !== "admin") {    return yield* new Forbidden();  }}); // Target-ownership checks — independent of caller privilege. `requireAdmin`// confirms the caller is an admin of their session's org; these confirm the// resource they're about to mutate actually lives in that same org. Without// this, an admin of org A who obtained a membership/domain id from org B// (leak, screenshot, support context) could trigger the WorkOS SDK against// org B's resource — the workspace API key is workspace-wide and WorkOS// does not enforce per-org ownership on delete/update by id. Failures// (not found OR org mismatch) both surface as Forbidden so we don't leak// existence of ids outside the caller's org.const assertMembershipInSessionOrg = (membershipId: string) =>  Effect.gen(function* () {    const auth = yield* AuthContext;    const workos = yield* WorkOSAuth;    const membership = yield* workos      .getOrgMembership(membershipId)      .pipe(Effect.catchCause(() => Effect.succeed(null)));    if (!membership || membership.organizationId !== auth.organizationId) {      return yield* new Forbidden();    }  }); const assertDomainInSessionOrg = (domainId: string) =>  Effect.gen(function* () {    const auth = yield* AuthContext;    const workos = yield* WorkOSAuth;    const domain = yield* workos      .getOrganizationDomain(domainId)      .pipe(Effect.catchCause(() => Effect.succeed(null)));    if (!domain || domain.organizationId !== auth.organizationId) {      return yield* new Forbidden();    }  }); // Compute live seat usage from WorkOS truth (active+pending memberships +// pending invitations) and look up the per-plan cap from MEMBER_LIMITS.// Recomputed on every call — no event-counting drift.const getMemberSeats = (organizationId: string) =>  Effect.gen(function* () {    const autumn = yield* AutumnService;    const workos = yield* WorkOSAuth;     const customer = yield* autumn.use((client) =>      client.customers.getOrCreate({ customerId: organizationId }),    );    const planId = selectActiveMemberLimitPlan(customer.subscriptions);    const limit = getMemberLimitForPlan(planId);     const memberships = yield* workos.listOrgMembers(organizationId);    const invitations = yield* workos.listPendingInvitations(organizationId);     return {      used: memberships.data.length + invitations.data.length,      granted: limit ?? 0,      unlimited: limit === null,    };  }); const reserveMemberSlot = Effect.gen(function* () {  const auth = yield* AuthContext;  const seats = yield* getMemberSeats(auth.organizationId).pipe(    Effect.tap((s) =>      Effect.logInfo("members.check").pipe(        Effect.annotateLogs({          "org.id": auth.organizationId,          "members.used": s.used,          "members.granted": s.granted,          "members.unlimited": s.unlimited,        }),      ),    ),    Effect.catchCause((cause) =>      Effect.gen(function* () {        yield* Effect.logError("members.seats lookup failed; failing closed").pipe(          Effect.annotateLogs({ "org.id": auth.organizationId, cause: Cause.pretty(cause) }),        );        return yield* new Forbidden();      }),    ),  );   if (!seats.unlimited && seats.used >= seats.granted) {    return yield* new Forbidden();  }}); export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) =>  handlers    .handle("listMembers", () =>      Effect.gen(function* () {        const auth = yield* AuthContext;        const workos = yield* WorkOSAuth;         // The list endpoint falls back to safe display defaults if the seats        // lookup errors — we never want a transient Autumn or WorkOS hiccup        // to blank the members page. The actual cap gate lives in        // `reserveMemberSlot`, which fails closed.        const seats = yield* getMemberSeats(auth.organizationId).pipe(          Effect.catchTag("AutumnError", (error) =>            Effect.logError("listMembers.seats: autumn lookup failed").pipe(              Effect.annotateLogs({ "org.id": auth.organizationId, error: String(error.cause) }),              Effect.as({ used: 0, granted: 0, unlimited: false }),            ),          ),        );         const memberships = yield* workos.listOrgMembers(auth.organizationId);         yield* Effect.logInfo("listMembers.seats").pipe(          Effect.annotateLogs({            "org.id": auth.organizationId,            "members.count": memberships.data.length,            "seats.used": seats.used,            "seats.granted": seats.granted,            "seats.unlimited": seats.unlimited,          }),        );         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 === auth.accountId,              };            }),          ),          { concurrency: 5 },        );         return { members, seats };      }),    )    .handle("listRoles", () =>      Effect.gen(function* () {        const auth = yield* AuthContext;        const workos = yield* WorkOSAuth;         const result = yield* workos.listOrgRoles(auth.organizationId);         return {          roles: result.data.map((r) => ({            slug: r.slug,            name: r.name,          })),        };      }),    )    .handle("invite", ({ payload }) =>      Effect.gen(function* () {        yield* requireAdmin;        const auth = yield* AuthContext;        const workos = yield* WorkOSAuth;         yield* reserveMemberSlot;         const invitation = yield* workos.sendInvitation({          email: payload.email,          organizationId: auth.organizationId,          roleSlug: payload.roleSlug,        });         return { id: invitation.id, email: invitation.email };      }),    )    .handle("removeMember", ({ params }) =>      Effect.gen(function* () {        yield* requireAdmin;        yield* assertMembershipInSessionOrg(params.membershipId);        const workos = yield* WorkOSAuth;        yield* workos.deleteOrgMembership(params.membershipId);        return { success: true };      }),    )    .handle("updateMemberRole", ({ params, payload }) =>      Effect.gen(function* () {        yield* requireAdmin;        yield* assertMembershipInSessionOrg(params.membershipId);        const workos = yield* WorkOSAuth;        yield* workos.updateOrgMembershipRole(params.membershipId, payload.roleSlug);        return { success: true };      }),    )    .handle("listDomains", () =>      Effect.gen(function* () {        const auth = yield* AuthContext;        const workos = yield* WorkOSAuth;        const org = yield* workos.getOrganization(auth.organizationId);         const domains = yield* Effect.all(          org.domains.map((d) =>            Effect.gen(function* () {              const full = yield* workos.getOrganizationDomain(d.id);              return {                id: full.id,                domain: full.domain,                state: full.state,                verificationToken: full.verificationToken,                verificationPrefix: full.verificationPrefix,              };            }),          ),          { concurrency: 5 },        );         return { domains };      }),    )    .handle("getDomainVerificationLink", () =>      Effect.gen(function* () {        yield* requireAdmin;        const auth = yield* AuthContext;         const autumn = yield* AutumnService;        const check = yield* autumn          .use((client) =>            client.check({              customerId: auth.organizationId,              featureId: "domain-verification",            }),          )          .pipe(Effect.orElseSucceed(() => ({ allowed: false })));         if (!check.allowed) {          return yield* new Forbidden();        }         const workos = yield* WorkOSAuth;        const env = yield* Cloudflare.WorkerEnvironment.typed<Env>();        const { link } = yield* workos.generateDomainVerificationPortalLink(          auth.organizationId,          env.VITE_PUBLIC_SITE_URL ? `${env.VITE_PUBLIC_SITE_URL}/org` : "/org",        );        return { link };      }),    )    .handle("deleteDomain", ({ params }) =>      Effect.gen(function* () {        yield* requireAdmin;        yield* assertDomainInSessionOrg(params.domainId);        const workos = yield* WorkOSAuth;        yield* workos.deleteOrganizationDomain(params.domainId);        return { success: true };      }),    )    .handle("updateOrgName", ({ payload }) =>      Effect.gen(function* () {        yield* requireAdmin;        const auth = yield* AuthContext;        const workos = yield* WorkOSAuth;        const users = yield* UserStoreService;        const org = yield* workos.updateOrganization(auth.organizationId, payload.name);        yield* users.use((s) => s.upsertOrganization({ id: org.id, name: org.name }));        return { name: org.name };      }),    ),);