import { Data, Effect, Option, Predicate, Schema } from "effect"; export class UserStoreError extends Schema.TaggedErrorClass()( "UserStoreError", {}, { httpApiStatus: 500 }, ) {} export class WorkOSError extends Schema.TaggedErrorClass()( "WorkOSError", { /** * The upstream HTTP status WorkOS answered with, when the failure WAS an * HTTP answer (the SDK sets `.status` on all its typed exceptions). * Absent for network errors / timeouts. Consumers that must distinguish * "WorkOS said no" (definitive 4xx) from "WorkOS was unreachable" * (transient) branch on this — see `isDefinitiveWorkOSDenialStatus`. */ status: Schema.optional(Schema.Number), }, { httpApiStatus: 500 }, ) {} // Statuses that are a DEFINITIVE denial from WorkOS — a deterministic "this // request cannot be authorized" answer (invalid/revoked API key, forbidden, // resource gone), not a blip. Retrying does not help, and auth decisions built // on them must fail CLOSED. Everything else (429, 5xx, or no status at all — // network error/timeout) is transient and must stay retryable. const DEFINITIVE_DENIAL_STATUSES: ReadonlySet = new Set([401, 403, 404]); export const isDefinitiveWorkOSDenialStatus = (status: number | undefined): boolean => status !== undefined && DEFINITIVE_DENIAL_STATUSES.has(status); // Tag-based guards (same pattern as `isOAuth2Error` in core/sdk/oauth-helpers): // the untyped failure channels these run against carry values that crossed // layer boundaries, so the discriminant is the `_tag`, not the prototype chain. const isWorkOSError = Predicate.isTagged("WorkOSError") as (error: unknown) => error is WorkOSError; /** A `WorkOSError` that is a definitive denial (see above), typed on `unknown` * so auth code holding an untyped failure channel can branch safely. */ export const isDefinitiveWorkOSDenial = (error: unknown): boolean => isWorkOSError(error) && isDefinitiveWorkOSDenialStatus(error.status); // Every typed exception the WorkOS node SDK throws for an HTTP answer carries a // numeric `.status` (UnauthorizedException 401, NotFoundException 404, // RateLimitExceededException 429, GenericServerException/OauthException with the // live status, ...). Network errors and timeouts throw non-SDK errors with no // status. Same extraction pattern as the workos-vault plugin's // `statusFromWorkOSCause` (packages/plugins/workos-vault/src/sdk/client.ts). const CauseWithStatusSchema = Schema.Struct({ status: Schema.Number }); const decodeCauseWithStatusOption = Schema.decodeUnknownOption(CauseWithStatusSchema); export const statusFromWorkOSCause = (cause: unknown): number | undefined => Option.match(decodeCauseWithStatusOption(cause), { onNone: () => undefined, onSome: (decoded) => decoded.status, }); /** * Build the public `WorkOSError` for a WorkOS service-adapter failure, * threading the upstream HTTP status through when the underlying SDK exception * carried one. The failure is normally the `ServiceAdapterError` wrapper from * `tryPromiseService` (SDK exception in `.cause`); a bare cause also works. */ const isServiceAdapterError = Predicate.isTagged("ServiceAdapterError") as ( failure: unknown, ) => failure is ServiceAdapterError; export const workosErrorFromFailure = (failure: unknown): WorkOSError => new WorkOSError({ status: statusFromWorkOSCause(isServiceAdapterError(failure) ? failure.cause : failure), }); export class ApiKeyManagementError extends Schema.TaggedErrorClass()( "ApiKeyManagementError", { cause: Schema.Unknown }, { httpApiStatus: 500 }, ) {} /** * Private wrapper used by service adapters that lift Promise APIs into * Effect. `withServiceLogging` immediately remaps these into a public-facing * tagged error, so callers never observe this tag directly — its only job is * to keep the internal failure channel typed instead of `unknown` / `Error`. */ export class ServiceAdapterError extends Data.TaggedError("ServiceAdapterError")<{ readonly cause: unknown; }> {} /** Lift a Promise-returning function into Effect with a typed failure channel. */ export const tryPromiseService = (fn: () => Promise): Effect.Effect => Effect.tryPromise({ try: fn, catch: (cause) => new ServiceAdapterError({ cause }), }); /** * Service-boundary error wrapper. Logs the full Cause chain (drizzle * query/params, pg error codes, nested Error.cause, etc.) via Effect's * structured logger, then maps to a tagged error so the HTTP wire * response contains only safe fields. * * Use this whenever a Promise-based API gets lifted into an Effect and * its failure needs both debuggable server-side logging and a safe * public shape. */ export const withServiceLogging = ( name: string, // Receives the raw failure (for service adapters: the `ServiceAdapterError` // whose `.cause` is the SDK exception) so the public error can carry safe // classification fields (e.g. `WorkOSError.status`). Zero-arg callers that // ignore the failure keep working unchanged. publicError: (failure: unknown) => E, effect: Effect.Effect, ): Effect.Effect => effect.pipe( Effect.tapCause((cause) => Effect.logError(`${name} failed`, cause)), Effect.mapError(publicError), Effect.withSpan(name), ) as Effect.Effect;