* Tighten HTTP error schemas, keep rich causes internally Establish a clear rule: Schema.TaggedError is HTTP wire-facing — every field must be a safe public field. Data.TaggedError is internal-only; it can carry rich cause: unknown for logging/debugging and can't be accidentally wired to an HttpApi endpoint because it isn't a Schema. - OpenApiParseError (HTTP-exposed): drop error: Schema.Defect field - GraphqlIntrospectionError (HTTP-exposed): drop error: Schema.Defect field; log underlying causes via Effect.logError at construction sites in introspect.ts - OpenApiInvocationError, GraphqlInvocationError, GoogleDiscoveryParseError, GoogleDiscoveryInvocationError, KeychainError, ToolInvocationError: convert Schema.TaggedError -> Data.TaggedError, rename error -> cause - Cloud auth (UserStoreError, WorkOSError): keep empty schema + 500 annotation, log full cause via Effect.tapErrorCause at service wrappers * Extract withServiceLogging helper + errors.test.ts contract Collapse the repetitive tapErrorCause + mapError + withSpan ceremony at each service wrapper into one helper in auth/errors.ts. Add a real HttpApi integration test that pins down the contract: - Wire response contains only declared tagged-error fields - Server-side logs capture the full Cause chain (drizzle query, params, nested pg Error.cause) The test uses a real HttpApi + HttpApiBuilder.toWebHandler + fetch round-trip with a capturing Logger to verify both sides without mocking. Validates Option C from the earlier discussion: service-level logging is the only pattern that preserves the full cause because mapError discards it before any edge middleware could see it. * Remove errors.test.ts * Simplify graphql introspection error handling Drop ad-hoc Effect.logError sprinkles on branch-level failures (bad status, returned errors, missing __schema) — the tagged error's message already carries the useful info and there's no Cause chain to tap. Keep tapErrorCause + mapError inline for the two cases where an upstream error exists (HTTP request failure, JSON parse failure).
Showing the changes to apps/cloud/src (3 of 14 files). Show all
Unified · Split
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Context, Effect, Layer } from "effect"; | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | import { makeUserStore } from "../services/user-store"; | 2 | import { makeUserStore } from "../services/user-store"; |
| 3 | import { DbService } from "../services/db"; | 3 | import { DbService } from "../services/db"; |
| 4 | import { UserStoreError } from "./errors"; | 4 | import { UserStoreError, withServiceLogging } from "./errors"; |
| 5 | 5 | ||
| 6 | // AuthContext is defined in ./middleware.ts to keep middleware-related types together. | 6 | // AuthContext is defined in ./middleware.ts to keep middleware-related types together. |
| 7 | export { AuthContext } from "./middleware"; | 7 | export { AuthContext } from "./middleware"; |
| @@ -12,24 +12,14 @@ export { AuthContext } from "./middleware"; · 4 unmodified lines | |||
| 12 | 12 | ||
| 13 | type RawStore = ReturnType<typeof makeUserStore>; | 13 | type RawStore = ReturnType<typeof makeUserStore>; |
| 14 | 14 | ||
| 15 | const makeService = (store: RawStore) => { | 15 | const makeService = (store: RawStore) => ({ |
| 16 | const use = <A>(fn: (s: RawStore) => Promise<A>) => | 16 | use: <A>(fn: (s: RawStore) => Promise<A>) => |
| 17 | Effect.tryPromise({ | 17 | withServiceLogging( |
| 18 | try: () => fn(store), | 18 | "user_store", |
| 19 | catch: (cause) => cause, | 19 | () => new UserStoreError(), |
| 20 | }).pipe( | 20 | Effect.tryPromise({ try: () => fn(store), catch: (e) => e }), |
| 21 | Effect.tapError((cause) => | 21 | ), |
| 22 | Effect.sync(() => { | 22 | }); |
| 23 | // eslint-disable-next-line no-console | ||
| 24 | console.error("[user_store] query failed:", cause); | ||
| 25 | }), | ||
| 26 | ), | ||
| 27 | Effect.mapError(() => new UserStoreError()), | ||
| 28 | Effect.withSpan("user_store"), | ||
| 29 | ); | ||
| 30 | |||
| 31 | return { use }; | ||
| 32 | }; | ||
| 33 | 23 | ||
| 34 | type UserStoreServiceType = ReturnType<typeof makeService>; | 24 | type UserStoreServiceType = ReturnType<typeof makeService>; |
| 35 | 25 | ||
| @@ -1,10 +1,6 @@ | |||
| 1 | import { HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiSchema } from "@effect/platform"; |
| 2 | import { Schema } from "effect"; | 2 | import { Effect, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | // Tagged errors returned to HTTP clients. The response payload must NEVER | ||
| 5 | // include internal details (SQL, stack traces, WorkOS error bodies) — those | ||
| 6 | // can leak schema + secrets. Keep the schema empty and log the `cause` | ||
| 7 | // server-side via Effect's logger when the error is constructed. | ||
| 8 | export class UserStoreError extends Schema.TaggedError<UserStoreError>()( | 4 | export class UserStoreError extends Schema.TaggedError<UserStoreError>()( |
| 9 | "UserStoreError", | 5 | "UserStoreError", |
| 10 | {}, | 6 | {}, |
| @@ -16,3 +12,26 @@ export class WorkOSError extends Schema.TaggedError<WorkOSError>()( · 5 unmodified lines | |||
| 16 | {}, | 12 | {}, |
| 17 | HttpApiSchema.annotations({ status: 500 }), | 13 | HttpApiSchema.annotations({ status: 500 }), |
| 18 | ) {} | 14 | ) {} |
| 15 | |||
| 16 | /** | ||
| 17 | * Service-boundary error wrapper. Logs the full Cause chain (drizzle | ||
| 18 | * query/params, pg error codes, nested Error.cause, etc.) via Effect's | ||
| 19 | * structured logger, then maps to a tagged error so the HTTP wire | ||
| 20 | * response contains only safe fields. | ||
| 21 | * | ||
| 22 | * Use this whenever a Promise-based API gets lifted into an Effect and | ||
| 23 | * its failure needs both debuggable server-side logging and a safe | ||
| 24 | * public shape. | ||
| 25 | */ | ||
| 26 | export const withServiceLogging = <A, E, R>( | ||
| 27 | name: string, | ||
| 28 | publicError: () => E, | ||
| 29 | effect: Effect.Effect<A, unknown, R>, | ||
| 30 | ): Effect.Effect<A, E, R> => | ||
| 31 | effect.pipe( | ||
| 32 | Effect.tapErrorCause((cause) => | ||
| 33 | Effect.logError(`${name} failed`, cause), | ||
| 34 | ), | ||
| 35 | Effect.mapError(publicError), | ||
| 36 | Effect.withSpan(name), | ||
| 37 | ); | ||
| @@ -4,7 +4,7 @@ · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { Context, Effect, Layer } from "effect"; | 5 | import { Context, Effect, Layer } from "effect"; |
| 6 | import { WorkOS } from "@workos-inc/node/worker"; | 6 | import { WorkOS } from "@workos-inc/node/worker"; |
| 7 | import { WorkOSError } from "./errors"; | 7 | import { WorkOSError, withServiceLogging } from "./errors"; |
| 8 | import { server } from "../env"; | 8 | import { server } from "../env"; |
| 9 | 9 | ||
| 10 | const COOKIE_NAME = "wos-session"; | 10 | const COOKIE_NAME = "wos-session"; |
| @@ -26,18 +26,10 @@ const make = Effect.gen(function* () { · 15 unmodified lines | |||
| 26 | const workos = new WorkOS({ apiKey, clientId }); | 26 | const workos = new WorkOS({ apiKey, clientId }); |
| 27 | 27 | ||
| 28 | const use = <A>(fn: (wos: WorkOS) => Promise<A>) => | 28 | const use = <A>(fn: (wos: WorkOS) => Promise<A>) => |
| 29 | Effect.tryPromise({ | 29 | withServiceLogging( |
| 30 | try: () => fn(workos), | 30 | "workos", |
| 31 | catch: (cause) => cause, | 31 | () => new WorkOSError(), |
| 32 | }).pipe( | 32 | Effect.tryPromise({ try: () => fn(workos), catch: (e) => e }), |
| 33 | Effect.tapError((cause) => | ||
| 34 | Effect.sync(() => { | ||
| 35 | // eslint-disable-next-line no-console | ||
| 36 | console.error("[workos] call failed:", cause); | ||
| 37 | }), | ||
| 38 | ), | ||
| 39 | Effect.mapError(() => new WorkOSError()), | ||
| 40 | Effect.withSpan("workos"), | ||
| 41 | ); | 33 | ); |
| 42 | 34 | ||
| 43 | const authenticateSealedSession = (sessionData: string) => | 35 | const authenticateSealedSession = (sessionData: string) => |