137 lines · 6.2 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// HTTP API middleware tags — pure tag definitions, no server dependencies.// Live implementations are in ./middleware-live.ts to keep the WorkOS SDK// out of the client bundle (this file is imported by `auth/api.ts` which// the SPA pulls in for typed schemas).// --------------------------------------------------------------------------- import { Context } from "effect";import type { HttpServerResponse } from "effect/unstable/http";import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"; // The executor-API identity seam lives in `@executor-js/api/server`: the one// `AuthContext` handlers read (carries roles) and the one `Unauthorized` /// `NoOrganization` error pair (httpApiStatus 401 / 403), shared with self-host.// These are the canonical tags; consumers import them from `@executor-js/api/server`// directly. This module reads them to declare `SessionAuth` / `OrgAuth`.import { AuthContext, NoOrganization, Unauthorized } from "@executor-js/api/server";import type { UserStoreService } from "./context";import type { WorkOSClient } from "./workos"; // ---------------------------------------------------------------------------// Session — what every authenticated request gets// --------------------------------------------------------------------------- // Cookie-write options — exactly the options `HttpServerResponse.setCookieUnsafe`// accepts (derived so they can't drift; `import type` keeps this SPA-imported// module free of any server runtime). The auth handlers hand over the same// `RESPONSE_COOKIE_OPTIONS` / `DELETE_COOKIE_OPTIONS` constants the existing// `setResponseCookie` path uses, so the emitted `Set-Cookie` bytes are identical.export type SessionCookieOptions = NonNullable<  Parameters<typeof HttpServerResponse.setCookieUnsafe>[3]>; export type Session = {  readonly accountId: string;  readonly email: string;  readonly name: string | null;  readonly avatarUrl: string | null;  /** May be null if the user hasn't joined an organization yet. */  readonly organizationId: string | null;  readonly sealedSession: string;  readonly refreshedSession: string | null;}; export class SessionContext extends Context.Service<SessionContext, Session>()(  "@executor-js/cloud/Session",) {} // A request-scoped cookie setter, provided ALONGSIDE `SessionContext` by// `SessionAuth` (see its `provides` below). Typed `.handle()` handlers — the// WorkOS session-refresh on switchOrganization / createOrganization /// acceptInvitation — return DATA, not an `HttpServerResponse`, so they can't// attach a `Set-Cookie` directly. They `yield* SessionCookies` and queue writes;// `SessionAuthLive` drains the queue onto the outgoing response. It's a SEPARATE// service, not a field on `Session`, so the session DATA stays pure — `OrgAuth`// and the account API build a `Session` but never write cookies, so they carry// no writer. This replaces the old `@tanstack/react-start/server` `setCookie`// import (the one thing that pulled TanStack Start into the backend graph).export type SessionCookieSetter = {  /** Queue a `Set-Cookie` to apply to the response. */  readonly set: (name: string, value: string, options: SessionCookieOptions) => void;}; export class SessionCookies extends Context.Service<SessionCookies, SessionCookieSetter>()(  "@executor-js/cloud/SessionCookies",) {} /** * The authenticated result shape `WorkOSClient.authenticateSealedSession` / * `authenticateRequest` yield. Structural so the mapper below stays a pure * function with no WorkOS-SDK import (this module is in the SPA bundle). */export type SealedSessionResult = {  readonly userId: string;  readonly email: string;  readonly firstName?: string | null;  readonly lastName?: string | null;  readonly avatarUrl?: string | null;  readonly organizationId?: string | null;  readonly refreshedSession?: string | undefined;}; /** The display name WorkOS first/last fields collapse to, or `null`. */export const sealedSessionDisplayName = (result: SealedSessionResult): string | null =>  `${result.firstName ?? ""} ${result.lastName ?? ""}`.trim() || null; /** * The ONE sealed-session → {@link Session} mapper. `SessionAuthLive` and the * account-API session middleware both build a `Session` from a verified * sealed-session result; this folds their (previously inline, byte-identical) * copies into one. `sealedSessionFallback` is the cookie value to keep as the * `sealedSession` when WorkOS didn't hand back a refreshed one (the cookie for * `SessionAuthLive`, `""` for the account API which never re-sets the cookie). */export const sessionFromSealed = (  result: SealedSessionResult,  sealedSessionFallback: string,): Session => ({  accountId: result.userId,  email: result.email,  name: sealedSessionDisplayName(result),  avatarUrl: result.avatarUrl ?? null,  organizationId: result.organizationId ?? null,  sealedSession: result.refreshedSession ?? sealedSessionFallback,  refreshedSession: result.refreshedSession ?? null,}); // ---------------------------------------------------------------------------// SessionAuth — resolves the WorkOS session cookie; provides SessionContext AND// the SessionCookies setter (so a typed handler can queue a session-cookie// refresh that SessionAuthLive applies to the response).// --------------------------------------------------------------------------- export class SessionAuth extends HttpApiMiddleware.Service<  SessionAuth,  { provides: SessionContext | SessionCookies }>()("SessionAuth", {  error: Unauthorized,  security: {    cookie: HttpApiSecurity.apiKey({ in: "cookie", key: "wos-session" }),  },}) {} // ---------------------------------------------------------------------------// OrgAuth — like SessionAuth but rejects sessions with no organization.// Provides the shared `AuthContext` (re-exported above).// --------------------------------------------------------------------------- export class OrgAuth extends HttpApiMiddleware.Service<  OrgAuth,  { requires: UserStoreService | WorkOSClient; provides: AuthContext }>()("OrgAuth", {  error: [Unauthorized, NoOrganization],  security: {    cookie: HttpApiSecurity.apiKey({ in: "cookie", key: "wos-session" }),  },}) {}