Four plugin migrations in parallel. For each: - Deleted the per-plugin `*InternalError` class; use the shared `InternalError` from `@executor/api` at the group level. - Deleted `sanitize*` / `toXxxInternalError` helpers from `api/handlers.ts`; handlers are now bare `yield the service; return yield* ext.foo(...)`. - Changed every extension service tag to `Captured<XxxPluginExtension>`. - Narrowed `Effect<_, Error>` blanket types on extension methods to typed domain errors + `StorageFailure`. Internal helpers (`OpenapiStore`, `GraphqlStore`, `GoogleDiscoveryStore`, `OnePasswordStore`) got matching narrowings. - Each plugin's `extension(ctx) => ({...}) satisfies XxxPluginExtension` now holds without any `as unknown as` cast. - `GoogleDiscoveryParseError`/`OAuthError`/`SourceError` converted from `Data.TaggedError` to `Schema.TaggedError` with `HttpApiSchema` status annotations so they can `.addError(...)` on the group. - OnePassword: dropped `Effect.orDie` from every handler (was masking defects); `OnePasswordStore` surfaces storage-layer failures as `StorageError` with cause preserved. Host wiring (`apps/cloud/src/api/protected.ts`, `apps/local/src/server/main.ts`, cloud test-harness) now pulls every extension from `wrapped.X` — single top-level `const wrapped = withCapture(executor)`, no per-extension wrapping, no raw/wrapped split. 34/34 typecheck, every plugin's tests + cloud's 27+23 tests green.
Unified · Split
| @@ -49,18 +49,17 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 48 unmodified lines | ||
| 49 | 49 | ); |
| 50 | 50 | |
| 51 | 51 | // `withCapture` wraps the executor once — every Effect-returning |
| 52 | − // method translates `StorageError` → `InternalError({ traceId })` | |
| 53 | − // via `ErrorCapture`. Service tags that declare `Captured<...>` | |
| 54 | − // (MCP today) pull from `wrapped.*`; tags still on the pre-capture | |
| 55 | − // shape (openapi / graphql) pull from the raw executor until they | |
| 56 | − // migrate. See notes/error-handling.md. | |
| 52 | + // method on core + every plugin extension translates `StorageError` | |
| 53 | + // → `InternalError({ traceId })` via `ErrorCapture`; | |
| 54 | + // `UniqueViolationError` becomes a defect. Handlers see the | |
| 55 | + // already-captured shape. See notes/error-handling.md. | |
| 57 | 56 | const wrapped = withCapture(executor); |
| 58 | 57 | const requestServices = Layer.mergeAll( |
| 59 | 58 | Layer.succeed(ExecutorService, executor), |
| 60 | 59 | Layer.succeed(ExecutionEngineService, engine), |
| 61 | − Layer.succeed(OpenApiExtensionService, executor.openapi), | |
| 60 | + Layer.succeed(OpenApiExtensionService, wrapped.openapi), | |
| 62 | 61 | Layer.succeed(McpExtensionService, wrapped.mcp), |
| 63 | − Layer.succeed(GraphqlExtensionService, executor.graphql), | |
| 62 | + Layer.succeed(GraphqlExtensionService, wrapped.graphql), | |
| 64 | 63 | ); |
| 65 | 64 | |
| 66 | 65 | return yield* HttpApiBuilder.httpApp.pipe( |
| @@ -221,9 +221,9 @@ const buildAppForScope = (scopeId: string, scopeName: string) => · 220 unmodified lines | ||
| 221 | 221 | const services = Layer.mergeAll( |
| 222 | 222 | Layer.succeed(ExecutorService, executor), |
| 223 | 223 | Layer.succeed(ExecutionEngineService, engine), |
| 224 | − Layer.succeed(OpenApiExtensionService, executor.openapi), | |
| 224 | + Layer.succeed(OpenApiExtensionService, wrapped.openapi), | |
| 225 | 225 | Layer.succeed(McpExtensionService, wrapped.mcp), |
| 226 | − Layer.succeed(GraphqlExtensionService, executor.graphql), | |
| 226 | + Layer.succeed(GraphqlExtensionService, wrapped.graphql), | |
| 227 | 227 | ); |
| 228 | 228 | return yield* HttpApiBuilder.httpApp.pipe( |
| 229 | 229 | Effect.provide( |
| @@ -95,18 +95,17 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 94 unmodified lines | ||
| 95 | 95 | const engine = createExecutionEngine({ executor }); |
| 96 | 96 | |
| 97 | 97 | // `withCapture` wraps the executor once — every Effect-returning |
| 98 | − // method translates `StorageError` to `InternalError({ traceId })` via | |
| 99 | − // `ErrorCapture`. Service tags that declare `Captured<...>` (MCP | |
| 100 | − // today) pull from `wrapped.*`; tags still on the pre-capture shape | |
| 101 | − // (openapi / graphql / google-discovery / onepassword) pull from the | |
| 102 | − // raw executor until they migrate. | |
| 98 | + // method on core + every plugin extension translates `StorageError` | |
| 99 | + // to `InternalError({ traceId })` via `ErrorCapture`; | |
| 100 | + // `UniqueViolationError` becomes a defect. Handlers see the | |
| 101 | + // already-captured shape. | |
| 103 | 102 | const wrapped = withCapture(executor); |
| 104 | 103 | const pluginExtensions = Layer.mergeAll( |
| 105 | − Layer.succeed(OpenApiExtensionService, executor.openapi), | |
| 104 | + Layer.succeed(OpenApiExtensionService, wrapped.openapi), | |
| 106 | 105 | Layer.succeed(McpExtensionService, wrapped.mcp), |
| 107 | − Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery), | |
| 108 | − Layer.succeed(OnePasswordExtensionService, executor.onepassword), | |
| 109 | − Layer.succeed(GraphqlExtensionService, executor.graphql), | |
| 106 | + Layer.succeed(GoogleDiscoveryExtensionService, wrapped.googleDiscovery), | |
| 107 | + Layer.succeed(OnePasswordExtensionService, wrapped.onepassword), | |
| 108 | + Layer.succeed(GraphqlExtensionService, wrapped.graphql), | |
| 110 | 109 | ); |
| 111 | 110 | |
| 112 | 111 | const api = HttpApiBuilder.toWebHandler( |
| @@ -94,33 +94,33 @@ Owns the translation, the opaque wire schema, and the capture service. · 93 unmodified lines | ||
| 94 | 94 | Optional — resolved via `Effect.serviceOption`; missing service = |
| 95 | 95 | empty trace ids. Nothing breaks if it's not wired. |
| 96 | 96 | |
| 97 | −- `captureStorage(eff)` — single-Effect wrapper. Catches `StorageError` | |
| 97 | +- `capture(eff)` — single-Effect wrapper. Catches `StorageError` | |
| 98 | 98 | on the typed channel, captures the cause via `ErrorCapture`, fails |
| 99 | 99 | with `InternalError({ traceId })`. Every other typed failure |
| 100 | 100 | (`UniqueViolationError`, plugin-domain errors) passes through. |
| 101 | 101 | |
| 102 | −- `withStorageCapture(obj)` — proxy wrapper for a whole extension | |
| 102 | +- `withCapture(obj)` — proxy wrapper for a whole extension | |
| 103 | 103 | surface. Walks methods (recursing into nested plain-object shapes) |
| 104 | − and applies `captureStorage` to each Effect-returning method. Lets | |
| 104 | + and applies `capture` to each Effect-returning method. Lets | |
| 105 | 105 | the cloud app wire translation once at Layer composition instead of |
| 106 | 106 | per handler. |
| 107 | 107 | |
| 108 | 108 | ```ts |
| 109 | 109 | Layer.succeed( |
| 110 | 110 | McpExtensionService, |
| 111 | − withStorageCapture(executor.mcp), | |
| 111 | + withCapture(executor.mcp), | |
| 112 | 112 | ) |
| 113 | 113 | ``` |
| 114 | 114 | |
| 115 | −- `StorageCaptured<T>` — the type-level mirror of | |
| 116 | − `withStorageCapture`. Service tags declare this post-wrap shape so | |
| 115 | +- `Captured<T>` — the type-level mirror of | |
| 116 | + `withCapture`. Service tags declare this post-wrap shape so | |
| 117 | 117 | handlers see `InternalError` in the method's error union (matching |
| 118 | 118 | the group's `.addError(InternalError)`). Example: |
| 119 | 119 | |
| 120 | 120 | ```ts |
| 121 | 121 | class McpExtensionService extends Context.Tag("McpExtensionService")< |
| 122 | 122 | McpExtensionService, |
| 123 | − StorageCaptured<McpPluginExtension> | |
| 123 | + Captured<McpPluginExtension> | |
| 124 | 124 | >() {} |
| 125 | 125 | ``` |
| 126 | 126 | |
| @@ -170,18 +170,18 @@ its annotated status. · 43 unmodified lines | ||
| 170 | 170 | ### Hosts |
| 171 | 171 | |
| 172 | 172 | - **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides |
| 173 | − `ErrorCaptureLive`, a Sentry-backed implementation. Wired into the | |
| 174 | − protected API layer composition (`protected-layers.ts`) where it's | |
| 175 | − available both to `observabilityMiddleware` and to the method | |
| 176 | − wrappers produced by `withStorageCapture`. `protected.ts` wraps | |
| 177 | − per-request: `Layer.succeed(McpExtensionService, withStorageCapture(executor.mcp))`. | |
| 178 | − (Distinct from `apps/cloud/src/services/telemetry.ts`, which is the | |
| 179 | − OTEL→Axiom span bridge — "telemetry" in the tracing sense.) | |
| 180 | −- **CLI** (`apps/local/src/server/main.ts`) — also wraps via | |
| 181 | − `withStorageCapture` at Layer composition. No `ErrorCaptureLive` | |
| 182 | − provided, so trace ids come back empty; storage failures still | |
| 183 | − surface as `InternalError(traceId="")`. Drop-in an `ErrorCapture` | |
| 184 | − layer when we want a console or file sink. | |
| 173 | + `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API | |
| 174 | + layer in `protected-layers.ts` so it's available to both | |
| 175 | + `observabilityMiddleware` (defect catchall) AND to `withCapture`'s | |
| 176 | + typed-channel translation. `protected.ts` wraps once per-request: | |
| 177 | + `const wrapped = withCapture(executor)` → every extension service | |
| 178 | + pulls from `wrapped.X`. (Distinct from `apps/cloud/src/services/telemetry.ts`, | |
| 179 | + which is the OTEL→Axiom span bridge — "telemetry" in the tracing | |
| 180 | + sense.) | |
| 181 | +- **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a | |
| 182 | + console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`) | |
| 183 | + that prints the squashed cause + pretty cause to stderr and returns a | |
| 184 | + short correlation id. | |
| 185 | 185 | - **Tests / Promise SDK / examples** — non-HTTP consumers see raw |
| 186 | 186 | `StorageError` / `StorageFailure` in the SDK's typed channel and |
| 187 | 187 | can match on it directly. |
| @@ -195,7 +195,7 @@ its annotated status. · 7 unmodified lines | ||
| 195 | 195 | emitted a 500 anyway. Use the shared `InternalError`. |
| 196 | 196 | - `sanitize*` helpers in handler files that `catchAllCause` + map to a |
| 197 | 197 | generic 500 — same swallowing problem in disguise. Prefer |
| 198 | − `withStorageCapture` at Layer composition. | |
| 198 | + `withCapture` at Layer composition. | |
| 199 | 199 | - SDK code importing `Sentry.captureException` or referencing |
| 200 | 200 | `InternalError` / `ErrorCapture` — translation lives strictly in |
| 201 | 201 | `@executor/api`. If the SDK imports it, the layering is wrong. |
| @@ -1,7 +1,13 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 6 | +import { | |
| 7 | + GoogleDiscoveryOAuthError, | |
| 8 | + GoogleDiscoveryParseError, | |
| 9 | + GoogleDiscoverySourceError, | |
| 10 | +} from "../sdk/errors"; | |
| 5 | 11 | import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; |
| 6 | 12 | |
| 7 | 13 | export { HttpApiSchema }; |
| @@ -109,55 +115,61 @@ export class GoogleDiscoveryApiError extends Schema.TaggedError<GoogleDiscoveryA · 101 unmodified lines | ||
| 109 | 115 | HttpApiSchema.annotations({ status: 400 }), |
| 110 | 116 | ) {} |
| 111 | 117 | |
| 112 | −export class GoogleDiscoveryInternalError extends Schema.TaggedError<GoogleDiscoveryInternalError>()( | |
| 113 | − "GoogleDiscoveryInternalError", | |
| 114 | − { | |
| 115 | − message: Schema.String, | |
| 116 | − }, | |
| 117 | − HttpApiSchema.annotations({ status: 500 }), | |
| 118 | −) {} | |
| 118 | +// --------------------------------------------------------------------------- | |
| 119 | +// Group | |
| 120 | +// | |
| 121 | +// Domain errors + the shared opaque 500 (`InternalError`) are declared | |
| 122 | +// once at the group level via `.addError(...)` — every endpoint | |
| 123 | +// inherits them. The domain error carries its HTTP status via | |
| 124 | +// `HttpApiSchema.annotations`; `InternalError` is the public 5xx | |
| 125 | +// surface, translated from `StorageError` at the HTTP edge by | |
| 126 | +// `withCapture`. No per-endpoint `.addError(...)`, no per-handler | |
| 127 | +// InternalError — handlers just `return yield* ext.foo(...)`. | |
| 128 | +// --------------------------------------------------------------------------- | |
| 119 | 129 | |
| 120 | 130 | export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") |
| 121 | 131 | .add( |
| 122 | 132 | HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe` |
| 123 | 133 | .setPayload(ProbePayload) |
| 124 | − .addSuccess(ProbeResponse) | |
| 125 | − .addError(GoogleDiscoveryApiError) | |
| 126 | − .addError(GoogleDiscoveryInternalError), | |
| 134 | + .addSuccess(ProbeResponse), | |
| 127 | 135 | ) |
| 128 | 136 | .add( |
| 129 | 137 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources` |
| 130 | 138 | .setPayload(AddSourcePayload) |
| 131 | − .addSuccess(AddSourceResponse) | |
| 132 | − .addError(GoogleDiscoveryApiError) | |
| 133 | − .addError(GoogleDiscoveryInternalError), | |
| 139 | + .addSuccess(AddSourceResponse), | |
| 134 | 140 | ) |
| 135 | 141 | .add( |
| 136 | 142 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` |
| 137 | 143 | .setPayload(StartOAuthPayload) |
| 138 | − .addSuccess(StartOAuthResponse) | |
| 139 | − .addError(GoogleDiscoveryApiError) | |
| 140 | − .addError(GoogleDiscoveryInternalError), | |
| 144 | + .addSuccess(StartOAuthResponse), | |
| 141 | 145 | ) |
| 142 | 146 | .add( |
| 143 | 147 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete` |
| 144 | 148 | .setPayload(CompleteOAuthPayload) |
| 145 | − .addSuccess(CompleteOAuthResponse) | |
| 146 | − .addError(GoogleDiscoveryApiError) | |
| 147 | − .addError(GoogleDiscoveryInternalError), | |
| 149 | + .addSuccess(CompleteOAuthResponse), | |
| 148 | 150 | ) |
| 149 | 151 | .add( |
| 150 | 152 | HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback` |
| 151 | 153 | .setUrlParams(OAuthCallbackParams) |
| 152 | − .addSuccess(HtmlResponse) | |
| 153 | − .addError(GoogleDiscoveryApiError) | |
| 154 | − .addError(GoogleDiscoveryInternalError), | |
| 154 | + .addSuccess(HtmlResponse), | |
| 155 | 155 | ) |
| 156 | 156 | .add( |
| 157 | 157 | HttpApiEndpoint.get( |
| 158 | 158 | "getSource", |
| 159 | 159 | )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}` |
| 160 | − .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)) | |
| 161 | − .addError(GoogleDiscoveryApiError) | |
| 162 | − .addError(GoogleDiscoveryInternalError), | |
| 163 | − ) {} | |
| 160 | + .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)), | |
| 161 | + ) | |
| 162 | + // Errors declared once at the group level — every endpoint inherits. | |
| 163 | + // `InternalError` is the shared opaque 500 translated at the HTTP edge | |
| 164 | + // by `withCapture`. The others are 4xx domain errors carrying their | |
| 165 | + // status via `HttpApiSchema.annotations`; handlers return them through | |
| 166 | + // the typed channel and HttpApi encodes them directly. We only list | |
| 167 | + // errors a Google Discovery *group* endpoint can surface — | |
| 168 | + // `GoogleDiscoveryInvocationError` is thrown inside `invokeTool` which | |
| 169 | + // is reached via the core `tools.invoke` endpoint, not any Google | |
| 170 | + // Discovery-group endpoint, so it doesn't belong here. | |
| 171 | + .addError(InternalError) | |
| 172 | + .addError(GoogleDiscoveryApiError) | |
| 173 | + .addError(GoogleDiscoveryOAuthError) | |
| 174 | + .addError(GoogleDiscoveryParseError) | |
| 175 | + .addError(GoogleDiscoverySourceError) {} | |
| @@ -1,8 +1,21 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Handler-level integration test for the Google Discovery group. | |
| 3 | +// | |
| 4 | +// Verifies the layer wiring stays coherent end-to-end: the handlers | |
| 5 | +// pull the wrapped extension from the service, and any un-caught cause | |
| 6 | +// lands in the observability middleware — producing a 500 whose body is | |
| 7 | +// the opaque `InternalError` schema (no internal leakage). | |
| 8 | +// --------------------------------------------------------------------------- | |
| 9 | + | |
| 1 | 10 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 2 | −import { describe, expect, it } from "vitest"; | |
| 11 | +import { describe, expect, it } from "@effect/vitest"; | |
| 3 | 12 | import { Effect, Layer } from "effect"; |
| 4 | 13 | |
| 5 | −import { addGroup } from "@executor/api"; | |
| 14 | +import { | |
| 15 | + addGroup, | |
| 16 | + observabilityMiddleware, | |
| 17 | + withCapture, | |
| 18 | +} from "@executor/api"; | |
| 6 | 19 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; |
| 7 | 20 | import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin"; |
| 8 | 21 | import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; |
| @@ -10,57 +23,70 @@ import { GoogleDiscoveryGroup } from "./group"; · 1 unmodified line | ||
| 10 | 23 | |
| 11 | 24 | const unused = Effect.dieMessage("unused"); |
| 12 | 25 | |
| 13 | −const createFailingExtension = (): GoogleDiscoveryPluginExtension => ({ | |
| 26 | +const failingExtension: GoogleDiscoveryPluginExtension = { | |
| 14 | 27 | probeDiscovery: () => Effect.die(new Error("Not implemented")), |
| 15 | 28 | addSource: () => unused, |
| 16 | 29 | removeSource: () => unused, |
| 17 | 30 | startOAuth: () => unused, |
| 18 | 31 | completeOAuth: () => Effect.die(new Error("Not implemented")), |
| 19 | 32 | getSource: () => Effect.succeed(null), |
| 20 | −}); | |
| 33 | +}; | |
| 21 | 34 | |
| 22 | 35 | const Api = addGroup(GoogleDiscoveryGroup); |
| 23 | 36 | |
| 24 | −const fakeExecutor = {} as any; | |
| 25 | −const fakeExecutionEngine = {} as any; | |
| 26 | − | |
| 27 | −const createHandler = () => | |
| 28 | − HttpApiBuilder.toWebHandler( | |
| 29 | − HttpApiBuilder.api(Api).pipe( | |
| 30 | − Layer.provide(CoreHandlers), | |
| 31 | − Layer.provide(GoogleDiscoveryHandlers), | |
| 32 | − Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)), | |
| 33 | − Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)), | |
| 34 | − Layer.provide(Layer.succeed(GoogleDiscoveryExtensionService, createFailingExtension())), | |
| 35 | − Layer.provideMerge(HttpServer.layerContext), | |
| 36 | − Layer.provideMerge(HttpApiBuilder.Router.Live), | |
| 37 | − Layer.provideMerge(HttpApiBuilder.Middleware.layer), | |
| 37 | +// `acquireRelease` keeps disposal inside the Effect scope — no | |
| 38 | +// try/finally, no per-test cleanup plumbing. `it.scoped` closes the | |
| 39 | +// scope for us. | |
| 40 | +const WebHandler = Effect.acquireRelease( | |
| 41 | + Effect.sync(() => | |
| 42 | + HttpApiBuilder.toWebHandler( | |
| 43 | + HttpApiBuilder.api(Api).pipe( | |
| 44 | + Layer.provide(CoreHandlers), | |
| 45 | + Layer.provide(GoogleDiscoveryHandlers), | |
| 46 | + Layer.provide(observabilityMiddleware(Api)), | |
| 47 | + Layer.provide(Layer.succeed(ExecutorService, {} as never)), | |
| 48 | + Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)), | |
| 49 | + Layer.provide( | |
| 50 | + Layer.succeed( | |
| 51 | + GoogleDiscoveryExtensionService, | |
| 52 | + withCapture(failingExtension), | |
| 53 | + ), | |
| 54 | + ), | |
| 55 | + Layer.provideMerge(HttpServer.layerContext), | |
| 56 | + Layer.provideMerge(HttpApiBuilder.Router.Live), | |
| 57 | + Layer.provideMerge(HttpApiBuilder.Middleware.layer), | |
| 58 | + ), | |
| 38 | 59 | ), |
| 39 | − ); | |
| 60 | + ), | |
| 61 | + (web) => Effect.promise(() => web.dispose()), | |
| 62 | +); | |
| 40 | 63 | |
| 41 | 64 | describe("GoogleDiscoveryHandlers", () => { |
| 42 | − it("sanitizes unknown endpoint failures", async () => { | |
| 43 | − const web = createHandler(); | |
| 44 | − try { | |
| 45 | − const response = await web.handler( | |
| 46 | − new Request("http://localhost/scopes/scope_1/google-discovery/probe", { | |
| 47 | − method: "POST", | |
| 48 | − headers: { "content-type": "application/json" }, | |
| 49 | − body: JSON.stringify({ | |
| 50 | − discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1", | |
| 51 | − }), | |
| 52 | − }), | |
| 53 | − ); | |
| 65 | + it.scoped( | |
| 66 | + "defect-returning methods produce an opaque InternalError, no leakage", | |
| 67 | + () => | |
| 68 | + Effect.gen(function* () { | |
| 69 | + const web = yield* WebHandler; | |
| 70 | + const response = yield* Effect.promise(() => | |
| 71 | + web.handler( | |
| 72 | + new Request("http://localhost/scopes/scope_1/google-discovery/probe", { | |
| 73 | + method: "POST", | |
| 74 | + headers: { "content-type": "application/json" }, | |
| 75 | + body: JSON.stringify({ | |
| 76 | + discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1", | |
| 77 | + }), | |
| 78 | + }), | |
| 79 | + ), | |
| 80 | + ); | |
| 54 | 81 | |
| 55 | − expect(response.status).toBe(500); | |
| 56 | − const body = await response.json(); | |
| 57 | − expect(body).toEqual({ | |
| 58 | − _tag: "GoogleDiscoveryInternalError", | |
| 59 | − message: "Internal server error", | |
| 60 | − }); | |
| 61 | − expect(JSON.stringify(body)).not.toContain("Not implemented"); | |
| 62 | − } finally { | |
| 63 | − await web.dispose(); | |
| 64 | − } | |
| 65 | − }); | |
| 82 | + expect(response.status).toBe(500); | |
| 83 | + const body = (yield* Effect.promise(() => response.json())) as { | |
| 84 | + _tag?: string; | |
| 85 | + traceId?: string; | |
| 86 | + }; | |
| 87 | + expect(body._tag).toBe("InternalError"); | |
| 88 | + expect(typeof body.traceId).toBe("string"); | |
| 89 | + expect(JSON.stringify(body)).not.toContain("Not implemented"); | |
| 90 | + }), | |
| 91 | + ); | |
| 66 | 92 | }); |
| @@ -1,65 +1,58 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | −import { Cause, Context, Effect } from "effect"; | |
| 2 | +import { Context, Effect } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; |
| 5 | 5 | |
| 6 | −import { addGroup } from "@executor/api"; | |
| 6 | +import { addGroup, type Captured } from "@executor/api"; | |
| 7 | 7 | import type { |
| 8 | 8 | GoogleDiscoveryAddSourceInput, |
| 9 | 9 | GoogleDiscoveryOAuthAuthResult, |
| 10 | 10 | GoogleDiscoveryPluginExtension, |
| 11 | 11 | } from "../sdk/plugin"; |
| 12 | −import { | |
| 13 | − GoogleDiscoveryInvocationError, | |
| 14 | − GoogleDiscoveryOAuthError, | |
| 15 | − GoogleDiscoveryParseError, | |
| 16 | − GoogleDiscoverySourceError, | |
| 17 | −} from "../sdk/errors"; | |
| 18 | −import { | |
| 19 | − GoogleDiscoveryApiError, | |
| 20 | − GoogleDiscoveryGroup, | |
| 21 | − GoogleDiscoveryInternalError, | |
| 22 | −} from "./group"; | |
| 12 | +import { GoogleDiscoveryOAuthError } from "../sdk/errors"; | |
| 13 | +import { GoogleDiscoveryGroup } from "./group"; | |
| 14 | + | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | +// Service tag | |
| 17 | +// | |
| 18 | +// Holds the `Captured` shape — every method's `StorageFailure` channel | |
| 19 | +// has been swapped for `InternalError({ traceId })`. The host app | |
| 20 | +// provides an already-wrapped extension via | |
| 21 | +// `Layer.succeed(GoogleDiscoveryExtensionService, withCapture(executor.googleDiscovery))`. | |
| 22 | +// Handlers see `InternalError` in the error union, which matches | |
| 23 | +// `.addError(InternalError)` on the group — no per-handler translation. | |
| 24 | +// --------------------------------------------------------------------------- | |
| 23 | 25 | |
| 24 | 26 | export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")< |
| 25 | 27 | GoogleDiscoveryExtensionService, |
| 26 | − GoogleDiscoveryPluginExtension | |
| 28 | + Captured<GoogleDiscoveryPluginExtension> | |
| 27 | 29 | >() {} |
| 28 | 30 | |
| 31 | +// --------------------------------------------------------------------------- | |
| 32 | +// Composed API | |
| 33 | +// --------------------------------------------------------------------------- | |
| 34 | + | |
| 29 | 35 | const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup); |
| 30 | 36 | |
| 31 | 37 | const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result"; |
| 32 | 38 | |
| 33 | −const toPopupErrorMessage = (error: unknown): string => { | |
| 34 | − if (error instanceof GoogleDiscoveryOAuthError) { | |
| 35 | − return error.message; | |
| 36 | − } | |
| 37 | − return "Authentication failed"; | |
| 38 | −}; | |
| 39 | − | |
| 40 | −const toGoogleDiscoveryApiError = ( | |
| 41 | − error: unknown, | |
| 42 | −): GoogleDiscoveryApiError | GoogleDiscoveryInternalError => { | |
| 43 | − if (error instanceof GoogleDiscoveryApiError || error instanceof GoogleDiscoveryInternalError) { | |
| 44 | − return error; | |
| 45 | − } | |
| 46 | − if ( | |
| 47 | − error instanceof GoogleDiscoveryParseError || | |
| 48 | − error instanceof GoogleDiscoverySourceError || | |
| 49 | − error instanceof GoogleDiscoveryOAuthError || | |
| 50 | − error instanceof GoogleDiscoveryInvocationError | |
| 51 | − ) { | |
| 52 | − return new GoogleDiscoveryApiError({ message: error.message }); | |
| 53 | − } | |
| 54 | − return new GoogleDiscoveryInternalError({ message: "Internal server error" }); | |
| 55 | −}; | |
| 39 | +const toPopupErrorMessage = (error: unknown): string => | |
| 40 | + error instanceof GoogleDiscoveryOAuthError ? error.message : "Authentication failed"; | |
| 56 | 41 | |
| 57 | −const sanitizeGoogleDiscoveryFailure = <A, R>( | |
| 58 | − effect: Effect.Effect<A, unknown, R>, | |
| 59 | −): Effect.Effect<A, GoogleDiscoveryApiError | GoogleDiscoveryInternalError, R> => | |
| 60 | − Effect.catchAllCause(effect, (cause) => | |
| 61 | − Effect.fail(toGoogleDiscoveryApiError(Cause.squash(cause))), | |
| 62 | − ); | |
| 42 | +// --------------------------------------------------------------------------- | |
| 43 | +// Handlers | |
| 44 | +// | |
| 45 | +// Each handler is exactly: yield the extension service, call the method, | |
| 46 | +// return. Plugin SDK errors flow through the typed channel and are | |
| 47 | +// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls). | |
| 48 | +// `StorageFailure` has already been translated to `InternalError` by | |
| 49 | +// `withCapture` on the service instance; defects bubble up and are | |
| 50 | +// captured + downgraded to `InternalError(traceId)` by the API-level | |
| 51 | +// observability middleware. | |
| 52 | +// | |
| 53 | +// No `sanitize*`, no `liftDomainErrors`, no per-handler error mapping. | |
| 54 | +// If you find yourself adding error-handling here you're in the wrong layer. | |
| 55 | +// --------------------------------------------------------------------------- | |
| 63 | 56 | |
| 64 | 57 | export const GoogleDiscoveryHandlers = HttpApiBuilder.group( |
| 65 | 58 | ExecutorApiWithGoogleDiscovery, |
| @@ -70,13 +63,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 4 unmodified lines | ||
| 70 | 63 | Effect.gen(function* () { |
| 71 | 64 | const ext = yield* GoogleDiscoveryExtensionService; |
| 72 | 65 | return yield* ext.probeDiscovery(payload.discoveryUrl); |
| 73 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 66 | + }), | |
| 74 | 67 | ) |
| 75 | 68 | .handle("addSource", ({ payload }) => |
| 76 | 69 | Effect.gen(function* () { |
| 77 | 70 | const ext = yield* GoogleDiscoveryExtensionService; |
| 78 | 71 | return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); |
| 79 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 72 | + }), | |
| 80 | 73 | ) |
| 81 | 74 | .handle("startOAuth", ({ payload }) => |
| 82 | 75 | Effect.gen(function* () { |
| @@ -89,7 +82,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 6 unmodified lines | ||
| 89 | 82 | redirectUrl: payload.redirectUrl, |
| 90 | 83 | scopes: payload.scopes, |
| 91 | 84 | }); |
| 92 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 85 | + }), | |
| 93 | 86 | ) |
| 94 | 87 | .handle("completeOAuth", ({ payload }) => |
| 95 | 88 | Effect.gen(function* () { |
| @@ -99,20 +92,20 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines | ||
| 99 | 92 | code: payload.code, |
| 100 | 93 | error: payload.error, |
| 101 | 94 | }); |
| 102 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 95 | + }), | |
| 103 | 96 | ) |
| 104 | 97 | .handle("getSource", ({ path }) => |
| 105 | 98 | Effect.gen(function* () { |
| 106 | 99 | const ext = yield* GoogleDiscoveryExtensionService; |
| 107 | 100 | return yield* ext.getSource(path.namespace); |
| 108 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 101 | + }), | |
| 109 | 102 | ) |
| 110 | 103 | .handle("oauthCallback", ({ urlParams }) => |
| 111 | 104 | Effect.gen(function* () { |
| 112 | 105 | const ext = yield* GoogleDiscoveryExtensionService; |
| 113 | 106 | const html = yield* runOAuthCallback< |
| 114 | 107 | GoogleDiscoveryOAuthAuthResult, |
| 115 | − GoogleDiscoveryOAuthError, | |
| 108 | + unknown, | |
| 116 | 109 | never |
| 117 | 110 | >({ |
| 118 | 111 | complete: ({ state, code, error }) => |
| @@ -126,6 +119,6 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 7 unmodified lines | ||
| 126 | 119 | channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL, |
| 127 | 120 | }); |
| 128 | 121 | return yield* HttpServerResponse.html(html); |
| 129 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 122 | + }), | |
| 130 | 123 | ), |
| 131 | 124 | ); |
| @@ -14,7 +14,11 @@ · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | import { Effect, Schema } from "effect"; |
| 16 | 16 | |
| 17 | −import { defineSchema, type StorageDeps } from "@executor/sdk"; | |
| 17 | +import { | |
| 18 | + defineSchema, | |
| 19 | + type StorageDeps, | |
| 20 | + type StorageFailure, | |
| 21 | +} from "@executor/sdk"; | |
| 18 | 22 | |
| 19 | 23 | import { |
| 20 | 24 | GoogleDiscoveryMethodBinding, |
| @@ -101,43 +105,49 @@ const decodeJson = (value: unknown): unknown => { · 80 unmodified lines | ||
| 101 | 105 | // Store interface |
| 102 | 106 | // --------------------------------------------------------------------------- |
| 103 | 107 | |
| 108 | +// Every method routes through the typed adapter (`ctx.storage.adapter`) | |
| 109 | +// so the typed error channel is `StorageFailure`. Schema-decode failures | |
| 110 | +// inside `Effect.gen` land as defects, not typed errors, and are caught | |
| 111 | +// by the HTTP edge's observability middleware. | |
| 104 | 112 | export interface GoogleDiscoveryStore { |
| 105 | 113 | readonly getBinding: (toolId: string) => Effect.Effect< |
| 106 | 114 | { readonly namespace: string; readonly binding: GoogleDiscoveryMethodBinding } | null, |
| 107 | − Error | |
| 115 | + StorageFailure | |
| 108 | 116 | >; |
| 109 | 117 | readonly putBinding: ( |
| 110 | 118 | toolId: string, |
| 111 | 119 | sourceId: string, |
| 112 | 120 | binding: GoogleDiscoveryMethodBinding, |
| 113 | − ) => Effect.Effect<void, Error>; | |
| 121 | + ) => Effect.Effect<void, StorageFailure>; | |
| 114 | 122 | readonly removeBindingsBySource: ( |
| 115 | 123 | sourceId: string, |
| 116 | − ) => Effect.Effect<readonly string[], Error>; | |
| 124 | + ) => Effect.Effect<readonly string[], StorageFailure>; | |
| 117 | 125 | readonly getBindingsForSource: ( |
| 118 | 126 | sourceId: string, |
| 119 | 127 | ) => Effect.Effect< |
| 120 | 128 | ReadonlyMap<string, GoogleDiscoveryMethodBinding>, |
| 121 | − Error | |
| 129 | + StorageFailure | |
| 122 | 130 | >; |
| 123 | 131 | |
| 124 | − readonly putSource: (source: GoogleDiscoveryStoredSource) => Effect.Effect<void, Error>; | |
| 125 | − readonly removeSource: (sourceId: string) => Effect.Effect<void, Error>; | |
| 132 | + readonly putSource: ( | |
| 133 | + source: GoogleDiscoveryStoredSource, | |
| 134 | + ) => Effect.Effect<void, StorageFailure>; | |
| 135 | + readonly removeSource: (sourceId: string) => Effect.Effect<void, StorageFailure>; | |
| 126 | 136 | readonly getSource: ( |
| 127 | 137 | sourceId: string, |
| 128 | − ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>; | |
| 138 | + ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>; | |
| 129 | 139 | readonly getSourceConfig: ( |
| 130 | 140 | sourceId: string, |
| 131 | − ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, Error>; | |
| 141 | + ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, StorageFailure>; | |
| 132 | 142 | |
| 133 | 143 | readonly putOAuthSession: ( |
| 134 | 144 | sessionId: string, |
| 135 | 145 | session: GoogleDiscoveryOAuthSession, |
| 136 | − ) => Effect.Effect<void, Error>; | |
| 146 | + ) => Effect.Effect<void, StorageFailure>; | |
| 137 | 147 | readonly getOAuthSession: ( |
| 138 | 148 | sessionId: string, |
| 139 | − ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, Error>; | |
| 140 | − readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>; | |
| 149 | + ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, StorageFailure>; | |
| 150 | + readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>; | |
| 141 | 151 | } |
| 142 | 152 | |
| 143 | 153 | // --------------------------------------------------------------------------- |
| @@ -130,8 +130,12 @@ const DiscoveryDocumentModel = Schema.Struct({ · 129 unmodified lines | ||
| 130 | 130 | }); |
| 131 | 131 | type DiscoveryDocument = typeof DiscoveryDocumentModel.Type; |
| 132 | 132 | |
| 133 | −const toParseError = (message: string, cause: unknown) => | |
| 134 | − new GoogleDiscoveryParseError({ message, cause }); | |
| 133 | +// The Schema.TaggedError version of GoogleDiscoveryParseError no longer | |
| 134 | +// carries a `cause` field — it would leak raw decoder internals over the | |
| 135 | +// wire. The decoder failure still shows up on the Effect cause chain for | |
| 136 | +// server-side logging; the client only sees the user-facing `message`. | |
| 137 | +const toParseError = (message: string, _cause: unknown) => | |
| 138 | + new GoogleDiscoveryParseError({ message }); | |
| 135 | 139 | |
| 136 | 140 | const decodeUnknownWith = |
| 137 | 141 | <A>( |
| @@ -1,10 +1,25 @@ | ||
| 1 | +// Google Discovery plugin tagged errors. The three errors that cross | |
| 2 | +// the HTTP edge carry an `HttpApiSchema` annotation so they can be | |
| 3 | +// `.addError(...)` directly on the API group — handlers return them | |
| 4 | +// and HttpApi encodes each as a 4xx response with a typed body, no | |
| 5 | +// per-handler sanitisation step. | |
| 6 | +// | |
| 7 | +// `GoogleDiscoveryInvocationError` stays a `Data.TaggedError` because | |
| 8 | +// it only surfaces through `invokeTool`, which runs under the core | |
| 9 | +// `tools.invoke` endpoint — not any endpoint on the Google Discovery | |
| 10 | +// group — so it doesn't need an HTTP annotation. | |
| 11 | + | |
| 1 | 12 | import { Data, Schema } from "effect"; |
| 13 | +import { HttpApiSchema } from "@effect/platform"; | |
| 2 | 14 | import type { Option } from "effect"; |
| 3 | 15 | |
| 4 | −export class GoogleDiscoveryParseError extends Data.TaggedError("GoogleDiscoveryParseError")<{ | |
| 5 | − readonly message: string; | |
| 6 | − readonly cause?: unknown; | |
| 7 | −}> {} | |
| 16 | +export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()( | |
| 17 | + "GoogleDiscoveryParseError", | |
| 18 | + { | |
| 19 | + message: Schema.String, | |
| 20 | + }, | |
| 21 | + HttpApiSchema.annotations({ status: 400 }), | |
| 22 | +) {} | |
| 8 | 23 | |
| 9 | 24 | export class GoogleDiscoveryInvocationError extends Data.TaggedError( |
| 10 | 25 | "GoogleDiscoveryInvocationError", |
| @@ -19,6 +34,7 @@ export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscover · 8 unmodified lines | ||
| 19 | 34 | { |
| 20 | 35 | message: Schema.String, |
| 21 | 36 | }, |
| 37 | + HttpApiSchema.annotations({ status: 400 }), | |
| 22 | 38 | ) {} |
| 23 | 39 | |
| 24 | 40 | export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()( |
| @@ -26,4 +42,5 @@ export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscove · 1 unmodified line | ||
| 26 | 42 | { |
| 27 | 43 | message: Schema.String, |
| 28 | 44 | }, |
| 45 | + HttpApiSchema.annotations({ status: 400 }), | |
| 29 | 46 | ) {} |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | SetSecretInput, |
| 10 | 10 | SourceDetectionResult, |
| 11 | 11 | type PluginCtx, |
| 12 | + type StorageFailure, | |
| 12 | 13 | type ToolAnnotations, |
| 13 | 14 | } from "@executor/sdk"; |
| 14 | 15 | |
| @@ -99,6 +100,23 @@ export interface GoogleDiscoveryOAuthAuthResult { · 84 unmodified lines | ||
| 99 | 100 | readonly scopes: readonly string[]; |
| 100 | 101 | } |
| 101 | 102 | |
| 103 | +/** | |
| 104 | + * Errors any Google Discovery extension method may surface. The first | |
| 105 | + * three are plugin-domain tagged errors that flow directly to clients | |
| 106 | + * (4xx, each carrying its own `HttpApiSchema` status once wrapped at the | |
| 107 | + * API edge). `StorageFailure` covers raw backend failures (`StorageError`) | |
| 108 | + * plus `UniqueViolationError`; the HTTP edge (`@executor/api`'s | |
| 109 | + * `withCapture`) translates `StorageError` to the opaque | |
| 110 | + * `InternalError({ traceId })` at Layer composition. `UniqueViolationError` | |
| 111 | + * passes through — plugins can `Effect.catchTag` it if they want a | |
| 112 | + * friendlier user-facing error. | |
| 113 | + */ | |
| 114 | +export type GoogleDiscoveryExtensionFailure = | |
| 115 | + | GoogleDiscoveryParseError | |
| 116 | + | GoogleDiscoverySourceError | |
| 117 | + | GoogleDiscoveryOAuthError | |
| 118 | + | StorageFailure; | |
| 119 | + | |
| 102 | 120 | export interface GoogleDiscoveryPluginExtension { |
| 103 | 121 | readonly probeDiscovery: ( |
| 104 | 122 | discoveryUrl: string, |
| @@ -110,21 +128,29 @@ export interface GoogleDiscoveryPluginExtension { · 5 unmodified lines | ||
| 110 | 128 | input: GoogleDiscoveryAddSourceInput, |
| 111 | 129 | ) => Effect.Effect< |
| 112 | 130 | { readonly toolCount: number; readonly namespace: string }, |
| 113 | − GoogleDiscoveryParseError | GoogleDiscoverySourceError | Error | |
| 131 | + GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure | |
| 114 | 132 | >; |
| 115 | − readonly removeSource: (namespace: string) => Effect.Effect<void, Error>; | |
| 133 | + readonly removeSource: ( | |
| 134 | + namespace: string, | |
| 135 | + ) => Effect.Effect<void, StorageFailure>; | |
| 116 | 136 | readonly startOAuth: ( |
| 117 | 137 | input: GoogleDiscoveryOAuthStartInput, |
| 118 | 138 | ) => Effect.Effect< |
| 119 | 139 | GoogleDiscoveryOAuthStartResponse, |
| 120 | − GoogleDiscoveryParseError | GoogleDiscoverySourceError | GoogleDiscoveryOAuthError | Error | |
| 140 | + | GoogleDiscoveryParseError | |
| 141 | + | GoogleDiscoverySourceError | |
| 142 | + | GoogleDiscoveryOAuthError | |
| 143 | + | StorageFailure | |
| 121 | 144 | >; |
| 122 | 145 | readonly completeOAuth: ( |
| 123 | 146 | input: GoogleDiscoveryOAuthCompleteInput, |
| 124 | − ) => Effect.Effect<GoogleDiscoveryOAuthAuthResult, GoogleDiscoveryOAuthError>; | |
| 147 | + ) => Effect.Effect< | |
| 148 | + GoogleDiscoveryOAuthAuthResult, | |
| 149 | + GoogleDiscoveryOAuthError | StorageFailure | |
| 150 | + >; | |
| 125 | 151 | readonly getSource: ( |
| 126 | 152 | namespace: string, |
| 127 | − ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>; | |
| 153 | + ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>; | |
| 128 | 154 | } |
| 129 | 155 | |
| 130 | 156 | // --------------------------------------------------------------------------- |
| @@ -286,7 +312,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 155 unmodified lines | ||
| 286 | 312 | schema: googleDiscoverySchema, |
| 287 | 313 | storage: (deps) => makeGoogleDiscoveryStore(deps), |
| 288 | 314 | |
| 289 | − extension: (ctx): GoogleDiscoveryPluginExtension => ({ | |
| 315 | + extension: (ctx) => ({ | |
| 290 | 316 | probeDiscovery: (discoveryUrl) => |
| 291 | 317 | Effect.gen(function* () { |
| 292 | 318 | const text = yield* fetchDiscoveryDocument(discoveryUrl); |
| @@ -465,18 +491,10 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 172 unmodified lines | ||
| 465 | 491 | scope: stored.scope, |
| 466 | 492 | scopes: [...session.scopes], |
| 467 | 493 | }; |
| 468 | − }).pipe( | |
| 469 | − Effect.mapError((err) => | |
| 470 | − err instanceof GoogleDiscoveryOAuthError | |
| 471 | − ? err | |
| 472 | − : new GoogleDiscoveryOAuthError({ | |
| 473 | − message: err instanceof Error ? err.message : String(err), | |
| 474 | − }), | |
| 475 | − ), | |
| 476 | − ), | |
| 494 | + }), | |
| 477 | 495 | |
| 478 | 496 | getSource: (namespace) => ctx.storage.getSource(namespace), |
| 479 | − }), | |
| 497 | + } satisfies GoogleDiscoveryPluginExtension), | |
| 480 | 498 | |
| 481 | 499 | invokeTool: ({ ctx, toolRow, args }) => |
| 482 | 500 | invokeGoogleDiscoveryTool({ |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 5 | 6 | import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; |
| 6 | 7 | import { HeaderValue } from "../sdk/types"; |
| @@ -63,35 +64,40 @@ const ExtractionError = GraphqlExtractionError.annotations( · 56 unmodified lines | ||
| 63 | 64 | HttpApiSchema.annotations({ status: 400 }), |
| 64 | 65 | ); |
| 65 | 66 | |
| 66 | −export class GraphqlInternalError extends Schema.TaggedError<GraphqlInternalError>()( | |
| 67 | − "GraphqlInternalError", | |
| 68 | − { | |
| 69 | − message: Schema.String, | |
| 70 | − }, | |
| 71 | − HttpApiSchema.annotations({ status: 500 }), | |
| 72 | −) {} | |
| 73 | − | |
| 74 | 67 | // --------------------------------------------------------------------------- |
| 75 | 68 | // Group |
| 69 | +// | |
| 70 | +// Plugin SDK errors (GraphqlIntrospectionError etc.) are declared once at | |
| 71 | +// the group level via `.addError(...)` — every endpoint inherits them. The | |
| 72 | +// errors themselves carry their HTTP status via `HttpApiSchema.annotations` | |
| 73 | +// above, so handlers just `return yield* ext.foo(...)` and the schema | |
| 74 | +// encodes whatever it gets. | |
| 75 | +// | |
| 76 | +// 5xx is handled at the API level: `.addError(InternalError)` adds a | |
| 77 | +// single shared opaque-by-schema 500 surface translated from `StorageError` | |
| 78 | +// by `withCapture` at the HTTP edge. No per-handler wrapping, no | |
| 79 | +// per-plugin InternalError. | |
| 76 | 80 | // --------------------------------------------------------------------------- |
| 77 | 81 | |
| 78 | 82 | export class GraphqlGroup extends HttpApiGroup.make("graphql") |
| 79 | 83 | .add( |
| 80 | 84 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/graphql/sources` |
| 81 | 85 | .setPayload(AddSourcePayload) |
| 82 | − .addSuccess(AddSourceResponse) | |
| 83 | − .addError(IntrospectionError) | |
| 84 | − .addError(ExtractionError) | |
| 85 | − .addError(GraphqlInternalError), | |
| 86 | + .addSuccess(AddSourceResponse), | |
| 86 | 87 | ) |
| 87 | 88 | .add( |
| 88 | 89 | HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}` |
| 89 | − .addSuccess(Schema.NullOr(StoredSourceSchema)) | |
| 90 | − .addError(GraphqlInternalError), | |
| 90 | + .addSuccess(Schema.NullOr(StoredSourceSchema)), | |
| 91 | 91 | ) |
| 92 | 92 | .add( |
| 93 | 93 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}` |
| 94 | 94 | .setPayload(UpdateSourcePayload) |
| 95 | − .addSuccess(UpdateSourceResponse) | |
| 96 | − .addError(GraphqlInternalError), | |
| 97 | − ) {} | |
| 95 | + .addSuccess(UpdateSourceResponse), | |
| 96 | + ) | |
| 97 | + // Errors declared once at the group level — every endpoint inherits. | |
| 98 | + // Plugin domain errors carry their own HttpApiSchema status (4xx); | |
| 99 | + // `InternalError` is the shared opaque 500 translated at the HTTP | |
| 100 | + // edge by `withCapture`. | |
| 101 | + .addError(InternalError) | |
| 102 | + .addError(IntrospectionError) | |
| 103 | + .addError(ExtractionError) {} | |
| @@ -1,61 +1,30 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | −import { Cause, Context, Effect } from "effect"; | |
| 2 | +import { Context, Effect } from "effect"; | |
| 3 | 3 | |
| 4 | −import { addGroup } from "@executor/api"; | |
| 5 | −import { GraphqlExtractionError, GraphqlIntrospectionError } from "../sdk/errors"; | |
| 6 | −import type { GraphqlPluginExtension, HeaderValue, GraphqlUpdateSourceInput } from "../sdk/plugin"; | |
| 7 | −import { GraphqlGroup, GraphqlInternalError } from "./group"; | |
| 4 | +import { addGroup, type Captured } from "@executor/api"; | |
| 5 | +import type { | |
| 6 | + GraphqlPluginExtension, | |
| 7 | + HeaderValue, | |
| 8 | + GraphqlUpdateSourceInput, | |
| 9 | +} from "../sdk/plugin"; | |
| 10 | +import { GraphqlGroup } from "./group"; | |
| 8 | 11 | |
| 9 | 12 | // --------------------------------------------------------------------------- |
| 10 | −// Service tag — the server provides the GraphQL extension | |
| 13 | +// Service tag | |
| 14 | +// | |
| 15 | +// Holds the `Captured` shape — every method's `StorageError` channel has | |
| 16 | +// been swapped for `InternalError({ traceId })`. The host app provides an | |
| 17 | +// already-wrapped extension via | |
| 18 | +// `Layer.succeed(GraphqlExtensionService, withCapture(executor.graphql))`. | |
| 19 | +// Handlers see `InternalError` in the error union, which matches | |
| 20 | +// `.addError(InternalError)` on the group — no per-handler translation. | |
| 11 | 21 | // --------------------------------------------------------------------------- |
| 12 | 22 | |
| 13 | 23 | export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")< |
| 14 | 24 | GraphqlExtensionService, |
| 15 | − GraphqlPluginExtension | |
| 25 | + Captured<GraphqlPluginExtension> | |
| 16 | 26 | >() {} |
| 17 | 27 | |
| 18 | −// --------------------------------------------------------------------------- | |
| 19 | −// Failure mapping | |
| 20 | −// --------------------------------------------------------------------------- | |
| 21 | − | |
| 22 | −type GraphqlAddSourceFailure = | |
| 23 | − | GraphqlIntrospectionError | |
| 24 | − | GraphqlExtractionError | |
| 25 | − | GraphqlInternalError; | |
| 26 | − | |
| 27 | −const toGraphqlAddSourceFailure = (error: unknown): GraphqlAddSourceFailure => { | |
| 28 | − if ( | |
| 29 | − error instanceof GraphqlIntrospectionError || | |
| 30 | − error instanceof GraphqlExtractionError || | |
| 31 | − error instanceof GraphqlInternalError | |
| 32 | − ) { | |
| 33 | − return error; | |
| 34 | − } | |
| 35 | − const message = error instanceof Error ? error.message : String(error); | |
| 36 | − return new GraphqlInternalError({ message }); | |
| 37 | −}; | |
| 38 | − | |
| 39 | −const sanitizeAddSourceFailure = <A, R>( | |
| 40 | − effect: Effect.Effect<A, unknown, R>, | |
| 41 | −): Effect.Effect<A, GraphqlAddSourceFailure, R> => | |
| 42 | − Effect.catchAllCause(effect, (cause) => | |
| 43 | − Effect.fail(toGraphqlAddSourceFailure(Cause.squash(cause))), | |
| 44 | − ); | |
| 45 | − | |
| 46 | −const toGraphqlInternalError = (error: unknown): GraphqlInternalError => { | |
| 47 | − if (error instanceof GraphqlInternalError) return error; | |
| 48 | − const message = error instanceof Error ? error.message : String(error); | |
| 49 | − return new GraphqlInternalError({ message }); | |
| 50 | −}; | |
| 51 | − | |
| 52 | −const sanitizeInternalFailure = <A, R>( | |
| 53 | − effect: Effect.Effect<A, unknown, R>, | |
| 54 | −): Effect.Effect<A, GraphqlInternalError, R> => | |
| 55 | − Effect.catchAllCause(effect, (cause) => | |
| 56 | − Effect.fail(toGraphqlInternalError(Cause.squash(cause))), | |
| 57 | − ); | |
| 58 | − | |
| 59 | 28 | // --------------------------------------------------------------------------- |
| 60 | 29 | // Composed API — core + graphql group |
| 61 | 30 | // --------------------------------------------------------------------------- |
| @@ -64,6 +33,12 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); · 2 unmodified lines | ||
| 64 | 33 | |
| 65 | 34 | // --------------------------------------------------------------------------- |
| 66 | 35 | // Handlers |
| 36 | +// | |
| 37 | +// Each handler is exactly: yield the extension service, call the method, | |
| 38 | +// return. Plugin SDK errors flow through the typed channel and are | |
| 39 | +// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls). | |
| 40 | +// Defects bubble up and are captured + downgraded to `InternalError(traceId)` | |
| 41 | +// by the API-level observability middleware. | |
| 67 | 42 | // --------------------------------------------------------------------------- |
| 68 | 43 | |
| 69 | 44 | export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) => |
| @@ -82,13 +57,13 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 12 unmodified lines | ||
| 82 | 57 | toolCount: result.toolCount, |
| 83 | 58 | namespace: payload.namespace ?? "graphql", |
| 84 | 59 | }; |
| 85 | − }).pipe(sanitizeAddSourceFailure), | |
| 60 | + }), | |
| 86 | 61 | ) |
| 87 | 62 | .handle("getSource", ({ path }) => |
| 88 | 63 | Effect.gen(function* () { |
| 89 | 64 | const ext = yield* GraphqlExtensionService; |
| 90 | 65 | return yield* ext.getSource(path.namespace); |
| 91 | − }).pipe(sanitizeInternalFailure), | |
| 66 | + }), | |
| 92 | 67 | ) |
| 93 | 68 | .handle("updateSource", ({ path, payload }) => |
| 94 | 69 | Effect.gen(function* () { |
| @@ -99,6 +74,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 4 unmodified lines | ||
| 99 | 74 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 100 | 75 | } as GraphqlUpdateSourceInput); |
| 101 | 76 | return { updated: true }; |
| 102 | − }).pipe(sanitizeInternalFailure), | |
| 77 | + }), | |
| 103 | 78 | ), |
| 104 | 79 | ); |
| @@ -14,8 +14,8 @@ import { · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | export const resolveHeaders = ( |
| 16 | 16 | headers: Record<string, HeaderValue>, |
| 17 | − secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> }, | |
| 18 | −): Effect.Effect<Record<string, string>, Error> => | |
| 17 | + secrets: { readonly get: (id: string) => Effect.Effect<string | null, unknown> }, | |
| 18 | +): Effect.Effect<Record<string, string>> => | |
| 19 | 19 | Effect.gen(function* () { |
| 20 | 20 | const entries = Object.entries(headers); |
| 21 | 21 | // Resolve secret-backed headers in parallel. Missing / failing |
| @@ -5,6 +5,7 @@ import type { Layer } from "effect"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | definePlugin, |
| 7 | 7 | SourceDetectionResult, |
| 8 | + type StorageFailure, | |
| 8 | 9 | type ToolAnnotations, |
| 9 | 10 | type ToolRow, |
| 10 | 11 | } from "@executor/sdk"; |
| @@ -24,7 +25,7 @@ import { · 13 unmodified lines | ||
| 24 | 25 | type IntrospectionTypeRef, |
| 25 | 26 | } from "./introspect"; |
| 26 | 27 | import { extract } from "./extract"; |
| 27 | −import { GraphqlExtractionError } from "./errors"; | |
| 28 | +import { GraphqlExtractionError, GraphqlIntrospectionError } from "./errors"; | |
| 28 | 29 | import { invokeWithLayer, resolveHeaders } from "./invoke"; |
| 29 | 30 | import { |
| 30 | 31 | graphqlSchema, |
| @@ -69,19 +70,35 @@ export interface GraphqlUpdateSourceInput { · 38 unmodified lines | ||
| 69 | 70 | readonly headers?: Record<string, HeaderValue>; |
| 70 | 71 | } |
| 71 | 72 | |
| 73 | +/** | |
| 74 | + * Errors any GraphQL extension method may surface. `GraphqlIntrospectionError` | |
| 75 | + * and `GraphqlExtractionError` are plugin-domain tagged errors that flow | |
| 76 | + * directly to clients (4xx, each carrying its own `HttpApiSchema` status). | |
| 77 | + * `StorageFailure` covers raw backend failures (`StorageError` plus | |
| 78 | + * `UniqueViolationError`); the HTTP edge (`@executor/api`'s `withCapture`) | |
| 79 | + * translates `StorageError` to the opaque `InternalError({ traceId })` at | |
| 80 | + * Layer composition. | |
| 81 | + */ | |
| 82 | +export type GraphqlExtensionFailure = | |
| 83 | + | GraphqlIntrospectionError | |
| 84 | + | GraphqlExtractionError | |
| 85 | + | StorageFailure; | |
| 86 | + | |
| 72 | 87 | export interface GraphqlPluginExtension { |
| 73 | 88 | /** Add a GraphQL endpoint and register its operations as tools */ |
| 74 | 89 | readonly addSource: ( |
| 75 | 90 | config: GraphqlSourceConfig, |
| 76 | − ) => Effect.Effect<{ readonly toolCount: number }, Error>; | |
| 91 | + ) => Effect.Effect<{ readonly toolCount: number }, GraphqlExtensionFailure>; | |
| 77 | 92 | |
| 78 | 93 | /** Remove all tools from a previously added GraphQL source by namespace */ |
| 79 | − readonly removeSource: (namespace: string) => Effect.Effect<void, Error>; | |
| 94 | + readonly removeSource: ( | |
| 95 | + namespace: string, | |
| 96 | + ) => Effect.Effect<void, StorageFailure>; | |
| 80 | 97 | |
| 81 | 98 | /** Fetch the full stored source by namespace (or null if missing) */ |
| 82 | 99 | readonly getSource: ( |
| 83 | 100 | namespace: string, |
| 84 | − ) => Effect.Effect<StoredGraphqlSource | null, Error>; | |
| 101 | + ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>; | |
| 85 | 102 | |
| 86 | 103 | /** Update config (endpoint, headers) for an existing GraphQL source. |
| 87 | 104 | * Does NOT re-introspect or re-register tools — just patches the |
| @@ -89,7 +106,7 @@ export interface GraphqlPluginExtension { · 1 unmodified line | ||
| 89 | 106 | readonly updateSource: ( |
| 90 | 107 | namespace: string, |
| 91 | 108 | input: GraphqlUpdateSourceInput, |
| 92 | − ) => Effect.Effect<void, Error>; | |
| 109 | + ) => Effect.Effect<void, StorageFailure>; | |
| 93 | 110 | } |
| 94 | 111 | |
| 95 | 112 | // --------------------------------------------------------------------------- |
| @@ -282,7 +299,7 @@ export const graphqlPlugin = definePlugin( · 186 unmodified lines | ||
| 282 | 299 | schema: graphqlSchema, |
| 283 | 300 | storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps), |
| 284 | 301 | |
| 285 | − extension: (ctx): GraphqlPluginExtension => { | |
| 302 | + extension: (ctx) => { | |
| 286 | 303 | const resolveConfigHeaders = ( |
| 287 | 304 | headers: Record<string, HeaderValue> | undefined, |
| 288 | 305 | ) => |
| @@ -368,13 +385,6 @@ export const graphqlPlugin = definePlugin( · 79 unmodified lines | ||
| 368 | 385 | return { |
| 369 | 386 | addSource: (config) => |
| 370 | 387 | addSourceInternal(config).pipe( |
| 371 | − Effect.mapError((err) => | |
| 372 | − err instanceof Error | |
| 373 | − ? err | |
| 374 | − : new GraphqlExtractionError({ | |
| 375 | − message: String(err), | |
| 376 | − }), | |
| 377 | − ), | |
| 378 | 388 | Effect.tap((result) => |
| 379 | 389 | configFile |
| 380 | 390 | ? configFile.upsertSource( |
| @@ -406,7 +416,7 @@ export const graphqlPlugin = definePlugin( · 25 unmodified lines | ||
| 406 | 416 | endpoint: input.endpoint, |
| 407 | 417 | headers: input.headers, |
| 408 | 418 | }), |
| 409 | − }; | |
| 419 | + } satisfies GraphqlPluginExtension; | |
| 410 | 420 | }, |
| 411 | 421 | |
| 412 | 422 | staticSources: (self) => [ |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { Effect } from "effect"; |
| 2 | 2 | |
| 3 | −import { defineSchema, type StorageDeps } from "@executor/sdk"; | |
| 3 | +import { defineSchema, type StorageDeps, type StorageFailure } from "@executor/sdk"; | |
| 4 | 4 | |
| 5 | 5 | import { OperationBinding, type HeaderValue } from "./types"; |
| 6 | 6 | |
| @@ -89,28 +89,28 @@ export interface GraphqlStore { · 82 unmodified lines | ||
| 89 | 89 | readonly upsertSource: ( |
| 90 | 90 | input: StoredGraphqlSource, |
| 91 | 91 | operations: readonly StoredOperation[], |
| 92 | − ) => Effect.Effect<void, Error>; | |
| 92 | + ) => Effect.Effect<void, StorageFailure>; | |
| 93 | 93 | |
| 94 | 94 | readonly updateSourceMeta: ( |
| 95 | 95 | namespace: string, |
| 96 | 96 | patch: { readonly name?: string; readonly endpoint?: string; readonly headers?: Record<string, HeaderValue> }, |
| 97 | − ) => Effect.Effect<void, Error>; | |
| 97 | + ) => Effect.Effect<void, StorageFailure>; | |
| 98 | 98 | |
| 99 | 99 | readonly getSource: ( |
| 100 | 100 | namespace: string, |
| 101 | − ) => Effect.Effect<StoredGraphqlSource | null, Error>; | |
| 101 | + ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>; | |
| 102 | 102 | |
| 103 | − readonly listSources: () => Effect.Effect<readonly StoredGraphqlSource[], Error>; | |
| 103 | + readonly listSources: () => Effect.Effect<readonly StoredGraphqlSource[], StorageFailure>; | |
| 104 | 104 | |
| 105 | 105 | readonly getOperationByToolId: ( |
| 106 | 106 | toolId: string, |
| 107 | − ) => Effect.Effect<StoredOperation | null, Error>; | |
| 107 | + ) => Effect.Effect<StoredOperation | null, StorageFailure>; | |
| 108 | 108 | |
| 109 | 109 | readonly listOperationsBySource: ( |
| 110 | 110 | sourceId: string, |
| 111 | − ) => Effect.Effect<readonly StoredOperation[], Error>; | |
| 111 | + ) => Effect.Effect<readonly StoredOperation[], StorageFailure>; | |
| 112 | 112 | |
| 113 | − readonly removeSource: (namespace: string) => Effect.Effect<void, Error>; | |
| 113 | + readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>; | |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | 116 | // --------------------------------------------------------------------------- |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 5 | 6 | import { OnePasswordError } from "../sdk/errors"; |
| 6 | 7 | import { OnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types"; |
| @@ -32,14 +33,18 @@ const ListVaultsResponse = Schema.Struct({ · 25 unmodified lines | ||
| 32 | 33 | |
| 33 | 34 | const GetConfigResponse = Schema.NullOr(OnePasswordConfig); |
| 34 | 35 | |
| 35 | −// --------------------------------------------------------------------------- | |
| 36 | −// Errors with HTTP status | |
| 37 | −// --------------------------------------------------------------------------- | |
| 38 | − | |
| 39 | −const OpError = OnePasswordError.annotations(HttpApiSchema.annotations({ status: 502 })); | |
| 40 | − | |
| 41 | 36 | // --------------------------------------------------------------------------- |
| 42 | 37 | // Group |
| 38 | +// | |
| 39 | +// Plugin SDK errors (OnePasswordError) are declared once at the group level | |
| 40 | +// via `.addError(...)` — every endpoint inherits. The error carries its own | |
| 41 | +// 502 status via `HttpApiSchema.annotations` in errors.ts. | |
| 42 | +// | |
| 43 | +// `InternalError` is the shared opaque 500 schema translated at the HTTP | |
| 44 | +// edge by `withCapture` (see observability.ts). Storage failures on | |
| 45 | +// `ctx.storage`/`ctx.secrets` flow through as `StorageFailure` in the | |
| 46 | +// typed channel and are captured + downgraded to `InternalError({ traceId })` | |
| 47 | +// at Layer composition. No per-handler translation. | |
| 43 | 48 | // --------------------------------------------------------------------------- |
| 44 | 49 | |
| 45 | 50 | export class OnePasswordGroup extends HttpApiGroup.make("onepassword") |
| @@ -51,8 +56,7 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 5 unmodified lines | ||
| 51 | 56 | .add( |
| 52 | 57 | HttpApiEndpoint.put("configure")`/scopes/${scopeIdParam}/onepassword/config` |
| 53 | 58 | .setPayload(ConfigurePayload) |
| 54 | − .addSuccess(Schema.Void) | |
| 55 | − .addError(OpError), | |
| 59 | + .addSuccess(Schema.Void), | |
| 56 | 60 | ) |
| 57 | 61 | .add( |
| 58 | 62 | HttpApiEndpoint.del("removeConfig")`/scopes/${scopeIdParam}/onepassword/config`.addSuccess( |
| @@ -60,13 +64,14 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 1 unmodified line | ||
| 60 | 64 | ), |
| 61 | 65 | ) |
| 62 | 66 | .add( |
| 63 | − HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status` | |
| 64 | − .addSuccess(ConnectionStatus) | |
| 65 | − .addError(OpError), | |
| 67 | + HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status`.addSuccess( | |
| 68 | + ConnectionStatus, | |
| 69 | + ), | |
| 66 | 70 | ) |
| 67 | 71 | .add( |
| 68 | 72 | HttpApiEndpoint.get("listVaults")`/scopes/${scopeIdParam}/onepassword/vaults` |
| 69 | 73 | .setUrlParams(ListVaultsParams) |
| 70 | − .addSuccess(ListVaultsResponse) | |
| 71 | − .addError(OpError), | |
| 72 | − ) {} | |
| 74 | + .addSuccess(ListVaultsResponse), | |
| 75 | + ) | |
| 76 | + .addError(InternalError) | |
| 77 | + .addError(OnePasswordError) {} | |
| @@ -1,17 +1,24 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | 2 | import { Context, Effect } from "effect"; |
| 3 | 3 | |
| 4 | −import { addGroup } from "@executor/api"; | |
| 4 | +import { addGroup, type Captured } from "@executor/api"; | |
| 5 | 5 | import type { OnePasswordExtension } from "../sdk/plugin"; |
| 6 | 6 | import { OnePasswordGroup } from "./group"; |
| 7 | 7 | |
| 8 | 8 | // --------------------------------------------------------------------------- |
| 9 | −// Service tag — the server provides the 1Password extension | |
| 9 | +// Service tag | |
| 10 | +// | |
| 11 | +// Holds the `Captured` shape — every method's `StorageFailure` channel has | |
| 12 | +// been swapped for `InternalError({ traceId })`. The host provides an | |
| 13 | +// already-wrapped extension via | |
| 14 | +// `Layer.succeed(OnePasswordExtensionService, withCapture(executor).onepassword)`. | |
| 15 | +// Handlers see `InternalError` in the error union, which matches | |
| 16 | +// `.addError(InternalError)` on the group — no per-handler translation. | |
| 10 | 17 | // --------------------------------------------------------------------------- |
| 11 | 18 | |
| 12 | 19 | export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")< |
| 13 | 20 | OnePasswordExtensionService, |
| 14 | − OnePasswordExtension | |
| 21 | + Captured<OnePasswordExtension> | |
| 15 | 22 | >() {} |
| 16 | 23 | |
| 17 | 24 | // --------------------------------------------------------------------------- |
| @@ -22,6 +29,12 @@ const ExecutorApiWithOnePassword = addGroup(OnePasswordGroup); · 4 unmodified lines | ||
| 22 | 29 | |
| 23 | 30 | // --------------------------------------------------------------------------- |
| 24 | 31 | // Handlers |
| 32 | +// | |
| 33 | +// Each handler is exactly: yield the extension service, call the method, | |
| 34 | +// return. Plugin SDK errors flow through the typed channel and are | |
| 35 | +// schema-encoded (OnePasswordError -> 502) by HttpApi. Defects bubble up | |
| 36 | +// and are captured + downgraded to `InternalError(traceId)` by the | |
| 37 | +// observability middleware. | |
| 25 | 38 | // --------------------------------------------------------------------------- |
| 26 | 39 | |
| 27 | 40 | export const OnePasswordHandlers = HttpApiBuilder.group( |
| @@ -33,25 +46,25 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 5 unmodified lines | ||
| 33 | 46 | Effect.gen(function* () { |
| 34 | 47 | const ext = yield* OnePasswordExtensionService; |
| 35 | 48 | return yield* ext.getConfig(); |
| 36 | − }).pipe(Effect.orDie), | |
| 49 | + }), | |
| 37 | 50 | ) |
| 38 | 51 | .handle("configure", ({ payload }) => |
| 39 | 52 | Effect.gen(function* () { |
| 40 | 53 | const ext = yield* OnePasswordExtensionService; |
| 41 | 54 | yield* ext.configure(payload); |
| 42 | − }).pipe(Effect.orDie), | |
| 55 | + }), | |
| 43 | 56 | ) |
| 44 | 57 | .handle("removeConfig", () => |
| 45 | 58 | Effect.gen(function* () { |
| 46 | 59 | const ext = yield* OnePasswordExtensionService; |
| 47 | 60 | yield* ext.removeConfig(); |
| 48 | − }).pipe(Effect.orDie), | |
| 61 | + }), | |
| 49 | 62 | ) |
| 50 | 63 | .handle("status", () => |
| 51 | 64 | Effect.gen(function* () { |
| 52 | 65 | const ext = yield* OnePasswordExtensionService; |
| 53 | 66 | return yield* ext.status(); |
| 54 | − }).pipe(Effect.orDie), | |
| 67 | + }), | |
| 55 | 68 | ) |
| 56 | 69 | .handle("listVaults", ({ urlParams }) => |
| 57 | 70 | Effect.gen(function* () { |
| @@ -62,6 +75,6 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 4 unmodified lines | ||
| 62 | 75 | : { kind: "service-account" as const, tokenSecretId: urlParams.account }; |
| 63 | 76 | const vaults = yield* ext.listVaults(auth); |
| 64 | 77 | return { vaults: [...vaults] }; |
| 65 | − }).pipe(Effect.orDie), | |
| 78 | + }), | |
| 66 | 79 | ), |
| 67 | 80 | ); |
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | +import { HttpApiSchema } from "@effect/platform"; | |
| 2 | 3 | |
| 3 | −export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()("OnePasswordError", { | |
| 4 | − operation: Schema.String, | |
| 5 | − message: Schema.String, | |
| 6 | −}) {} | |
| 4 | +export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()( | |
| 5 | + "OnePasswordError", | |
| 6 | + { | |
| 7 | + operation: Schema.String, | |
| 8 | + message: Schema.String, | |
| 9 | + }, | |
| 10 | + HttpApiSchema.annotations({ status: 502 }), | |
| 11 | +) {} | |
| @@ -2,9 +2,11 @@ import { Effect, Schema } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | definePlugin, |
| 5 | + StorageError, | |
| 5 | 6 | type PluginCtx, |
| 6 | 7 | type ScopedBlobStore, |
| 7 | 8 | type SecretProvider, |
| 9 | + type StorageFailure, | |
| 8 | 10 | } from "@executor/sdk"; |
| 9 | 11 | |
| 10 | 12 | import { OnePasswordConfig, Vault, ConnectionStatus } from "./types"; |
| @@ -24,6 +26,20 @@ const CREDENTIAL_FIELD = "credential"; · 13 unmodified lines | ||
| 24 | 26 | const DEFAULT_TIMEOUT_MS = 15_000; |
| 25 | 27 | const CONFIG_KEY = "config"; |
| 26 | 28 | |
| 29 | +// --------------------------------------------------------------------------- | |
| 30 | +// Shared failure alias. | |
| 31 | +// | |
| 32 | +// Every extension method either touches storage (`ctx.storage` blobs or | |
| 33 | +// `ctx.secrets`) or reaches the 1Password backend. Storage I/O surfaces | |
| 34 | +// as `StorageFailure`; the HTTP edge (`withCapture`) translates | |
| 35 | +// `StorageError` to `InternalError({ traceId })`. Domain problems (not | |
| 36 | +// configured, service-account token missing, backend RPC failure) stay | |
| 37 | +// as `OnePasswordError` and encode to 502 via the schema annotation on | |
| 38 | +// the class. | |
| 39 | +// --------------------------------------------------------------------------- | |
| 40 | + | |
| 41 | +export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; | |
| 42 | + | |
| 27 | 43 | // --------------------------------------------------------------------------- |
| 28 | 44 | // Plugin extension — public API on executor.onepassword |
| 29 | 45 | // --------------------------------------------------------------------------- |
| @@ -32,59 +48,69 @@ export interface OnePasswordExtension { · 2 unmodified lines | ||
| 32 | 48 | /** Configure the 1Password connection */ |
| 33 | 49 | readonly configure: ( |
| 34 | 50 | config: OnePasswordConfig, |
| 35 | − ) => Effect.Effect<void, OnePasswordError>; | |
| 51 | + ) => Effect.Effect<void, StorageFailure>; | |
| 36 | 52 | |
| 37 | 53 | /** Get current configuration (if any) */ |
| 38 | 54 | readonly getConfig: () => Effect.Effect< |
| 39 | 55 | OnePasswordConfig | null, |
| 40 | − OnePasswordError | |
| 56 | + OnePasswordExtensionFailure | |
| 41 | 57 | >; |
| 42 | 58 | |
| 43 | 59 | /** Remove the 1Password configuration */ |
| 44 | − readonly removeConfig: () => Effect.Effect<void, OnePasswordError>; | |
| 60 | + readonly removeConfig: () => Effect.Effect<void, StorageFailure>; | |
| 45 | 61 | |
| 46 | 62 | /** Check connection status */ |
| 47 | − readonly status: () => Effect.Effect<ConnectionStatus, OnePasswordError>; | |
| 63 | + readonly status: () => Effect.Effect< | |
| 64 | + ConnectionStatus, | |
| 65 | + OnePasswordExtensionFailure | |
| 66 | + >; | |
| 48 | 67 | |
| 49 | 68 | /** List accessible vaults (requires auth) */ |
| 50 | 69 | readonly listVaults: ( |
| 51 | 70 | auth: OnePasswordAuth, |
| 52 | − ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordError>; | |
| 71 | + ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordExtensionFailure>; | |
| 53 | 72 | |
| 54 | 73 | /** Resolve a secret directly by op:// URI */ |
| 55 | − readonly resolve: (uri: string) => Effect.Effect<string, OnePasswordError>; | |
| 74 | + readonly resolve: ( | |
| 75 | + uri: string, | |
| 76 | + ) => Effect.Effect<string, OnePasswordExtensionFailure>; | |
| 56 | 77 | } |
| 57 | 78 | |
| 58 | 79 | // --------------------------------------------------------------------------- |
| 59 | −// Typed config store — single blob, JSON encoded. Declared inline because | |
| 60 | −// there's only one key and one document; a schema table would be overkill. | |
| 80 | +// Typed config store — single blob, JSON encoded. Blob I/O failures surface | |
| 81 | +// as `StorageError` (HTTP edge translates to `InternalError`); decode | |
| 82 | +// failures stay `OnePasswordError` — the blob's contents are a plugin | |
| 83 | +// concern, not an infrastructure one. | |
| 61 | 84 | // --------------------------------------------------------------------------- |
| 62 | 85 | |
| 63 | 86 | export interface OnePasswordStore { |
| 64 | 87 | readonly getConfig: () => Effect.Effect< |
| 65 | 88 | OnePasswordConfig | null, |
| 66 | − OnePasswordError | |
| 89 | + StorageError | OnePasswordError | |
| 67 | 90 | >; |
| 68 | 91 | readonly saveConfig: ( |
| 69 | 92 | config: OnePasswordConfig, |
| 70 | − ) => Effect.Effect<void, OnePasswordError>; | |
| 71 | − readonly deleteConfig: () => Effect.Effect<void, OnePasswordError>; | |
| 93 | + ) => Effect.Effect<void, StorageError>; | |
| 94 | + readonly deleteConfig: () => Effect.Effect<void, StorageError>; | |
| 72 | 95 | } |
| 73 | 96 | |
| 74 | 97 | const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig); |
| 75 | 98 | |
| 99 | +const blobStorageError = (operation: string) => | |
| 100 | + (cause: unknown): StorageError => | |
| 101 | + new StorageError({ | |
| 102 | + message: `onepassword blob ${operation}: ${ | |
| 103 | + cause instanceof Error ? cause.message : String(cause) | |
| 104 | + }`, | |
| 105 | + cause, | |
| 106 | + }); | |
| 107 | + | |
| 76 | 108 | export const makeOnePasswordStore = ( |
| 77 | 109 | blobs: ScopedBlobStore, |
| 78 | 110 | ): OnePasswordStore => ({ |
| 79 | 111 | getConfig: () => |
| 80 | 112 | blobs.get(CONFIG_KEY).pipe( |
| 81 | − Effect.mapError( | |
| 82 | − (cause) => | |
| 83 | − new OnePasswordError({ | |
| 84 | − operation: "config load", | |
| 85 | − message: cause instanceof Error ? cause.message : String(cause), | |
| 86 | − }), | |
| 87 | − ), | |
| 113 | + Effect.mapError(blobStorageError("read")), | |
| 88 | 114 | Effect.flatMap((raw) => { |
| 89 | 115 | if (raw === null) return Effect.succeed(null); |
| 90 | 116 | return Effect.try({ |
| @@ -108,26 +134,10 @@ export const makeOnePasswordStore = ( · 17 unmodified lines | ||
| 108 | 134 | name: config.name, |
| 109 | 135 | }), |
| 110 | 136 | ) |
| 111 | − .pipe( | |
| 112 | − Effect.mapError( | |
| 113 | − (cause) => | |
| 114 | − new OnePasswordError({ | |
| 115 | − operation: "config save", | |
| 116 | − message: cause instanceof Error ? cause.message : String(cause), | |
| 117 | − }), | |
| 118 | − ), | |
| 119 | − ), | |
| 137 | + .pipe(Effect.mapError(blobStorageError("write"))), | |
| 120 | 138 | |
| 121 | 139 | deleteConfig: () => |
| 122 | − blobs.delete(CONFIG_KEY).pipe( | |
| 123 | − Effect.mapError( | |
| 124 | − (cause) => | |
| 125 | − new OnePasswordError({ | |
| 126 | − operation: "config delete", | |
| 127 | − message: cause instanceof Error ? cause.message : String(cause), | |
| 128 | − }), | |
| 129 | − ), | |
| 130 | − ), | |
| 140 | + blobs.delete(CONFIG_KEY).pipe(Effect.mapError(blobStorageError("delete"))), | |
| 131 | 141 | }); |
| 132 | 142 | |
| 133 | 143 | // --------------------------------------------------------------------------- |
| @@ -137,7 +147,7 @@ export const makeOnePasswordStore = ( · 3 unmodified lines | ||
| 137 | 147 | const resolveAuth = ( |
| 138 | 148 | auth: OnePasswordAuth, |
| 139 | 149 | ctx: PluginCtx<OnePasswordStore>, |
| 140 | −): Effect.Effect<ResolvedAuth, OnePasswordError> => { | |
| 150 | +): Effect.Effect<ResolvedAuth, OnePasswordError | StorageFailure> => { | |
| 141 | 151 | if (auth.kind === "desktop-app") { |
| 142 | 152 | return Effect.succeed({ |
| 143 | 153 | kind: "desktop-app" as const, |
| @@ -145,13 +155,6 @@ const resolveAuth = ( · 1 unmodified line | ||
| 145 | 155 | }); |
| 146 | 156 | } |
| 147 | 157 | return ctx.secrets.get(auth.tokenSecretId).pipe( |
| 148 | − Effect.mapError( | |
| 149 | − (cause) => | |
| 150 | − new OnePasswordError({ | |
| 151 | − operation: "auth resolution", | |
| 152 | − message: `Failed to resolve service account token secret "${auth.tokenSecretId}": ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 153 | − }), | |
| 154 | − ), | |
| 155 | 158 | Effect.flatMap((token) => { |
| 156 | 159 | if (token === null) { |
| 157 | 160 | return Effect.fail( |
| @@ -174,7 +177,7 @@ const getServiceFromConfig = ( · 16 unmodified lines | ||
| 174 | 177 | ctx: PluginCtx<OnePasswordStore>, |
| 175 | 178 | timeoutMs: number, |
| 176 | 179 | preferSdk: boolean | undefined, |
| 177 | −): Effect.Effect<OnePasswordService, OnePasswordError> => | |
| 180 | +): Effect.Effect<OnePasswordService, OnePasswordError | StorageFailure> => | |
| 178 | 181 | resolveAuth(config.auth, ctx).pipe( |
| 179 | 182 | Effect.flatMap((resolved) => |
| 180 | 183 | makeOnePasswordService(resolved, { timeoutMs, preferSdk }), |
| @@ -252,69 +255,71 @@ export const onepasswordPlugin = definePlugin( · 71 unmodified lines | ||
| 252 | 255 | id: "onepassword" as const, |
| 253 | 256 | storage: ({ blobs }) => makeOnePasswordStore(blobs), |
| 254 | 257 | |
| 255 | − extension: (ctx): OnePasswordExtension => ({ | |
| 256 | − configure: (config) => ctx.storage.saveConfig(config), | |
| 257 | − | |
| 258 | − getConfig: () => ctx.storage.getConfig(), | |
| 259 | − | |
| 260 | − removeConfig: () => ctx.storage.deleteConfig(), | |
| 261 | − | |
| 262 | − status: () => | |
| 263 | − Effect.gen(function* () { | |
| 264 | − const config = yield* ctx.storage.getConfig(); | |
| 265 | − if (!config) { | |
| 258 | + extension: (ctx) => { | |
| 259 | + return { | |
| 260 | + configure: (config) => ctx.storage.saveConfig(config), | |
| 261 | + | |
| 262 | + getConfig: () => ctx.storage.getConfig(), | |
| 263 | + | |
| 264 | + removeConfig: () => ctx.storage.deleteConfig(), | |
| 265 | + | |
| 266 | + status: () => | |
| 267 | + Effect.gen(function* () { | |
| 268 | + const config = yield* ctx.storage.getConfig(); | |
| 269 | + if (!config) { | |
| 270 | + return new ConnectionStatus({ | |
| 271 | + connected: false, | |
| 272 | + error: "Not configured", | |
| 273 | + }); | |
| 274 | + } | |
| 275 | + const svc = yield* getServiceFromConfig( | |
| 276 | + config, | |
| 277 | + ctx, | |
| 278 | + timeoutMs, | |
| 279 | + preferSdk, | |
| 280 | + ); | |
| 281 | + const vaults = yield* svc.listVaults(); | |
| 282 | + const vault = vaults.find((v) => v.id === config.vaultId); | |
| 266 | 283 | return new ConnectionStatus({ |
| 267 | − connected: false, | |
| 268 | − error: "Not configured", | |
| 284 | + connected: true, | |
| 285 | + vaultName: vault?.title, | |
| 269 | 286 | }); |
| 270 | − } | |
| 271 | − const svc = yield* getServiceFromConfig( | |
| 272 | − config, | |
| 273 | − ctx, | |
| 274 | − timeoutMs, | |
| 275 | − preferSdk, | |
| 276 | − ); | |
| 277 | − const vaults = yield* svc.listVaults(); | |
| 278 | − const vault = vaults.find((v) => v.id === config.vaultId); | |
| 279 | − return new ConnectionStatus({ | |
| 280 | − connected: true, | |
| 281 | − vaultName: vault?.title, | |
| 282 | − }); | |
| 283 | − }), | |
| 287 | + }), | |
| 284 | 288 | |
| 285 | − listVaults: (auth) => | |
| 286 | − Effect.gen(function* () { | |
| 287 | − const resolved = yield* resolveAuth(auth, ctx); | |
| 288 | − const svc = yield* makeOnePasswordService(resolved, { | |
| 289 | − timeoutMs, | |
| 290 | − preferSdk, | |
| 291 | − }); | |
| 292 | − const vaults = yield* svc.listVaults(); | |
| 293 | − return vaults | |
| 294 | − .map((v) => new Vault({ id: v.id, name: v.title })) | |
| 295 | − .sort((a, b) => a.name.localeCompare(b.name)); | |
| 296 | − }), | |
| 289 | + listVaults: (auth) => | |
| 290 | + Effect.gen(function* () { | |
| 291 | + const resolved = yield* resolveAuth(auth, ctx); | |
| 292 | + const svc = yield* makeOnePasswordService(resolved, { | |
| 293 | + timeoutMs, | |
| 294 | + preferSdk, | |
| 295 | + }); | |
| 296 | + const vaults = yield* svc.listVaults(); | |
| 297 | + return vaults | |
| 298 | + .map((v) => new Vault({ id: v.id, name: v.title })) | |
| 299 | + .sort((a, b) => a.name.localeCompare(b.name)); | |
| 300 | + }), | |
| 297 | 301 | |
| 298 | − resolve: (uri) => | |
| 299 | − Effect.gen(function* () { | |
| 300 | − const config = yield* ctx.storage.getConfig(); | |
| 301 | − if (!config) { | |
| 302 | − return yield* Effect.fail( | |
| 303 | − new OnePasswordError({ | |
| 304 | − operation: "resolve", | |
| 305 | − message: "1Password is not configured", | |
| 306 | − }), | |
| 302 | + resolve: (uri) => | |
| 303 | + Effect.gen(function* () { | |
| 304 | + const config = yield* ctx.storage.getConfig(); | |
| 305 | + if (!config) { | |
| 306 | + return yield* Effect.fail( | |
| 307 | + new OnePasswordError({ | |
| 308 | + operation: "resolve", | |
| 309 | + message: "1Password is not configured", | |
| 310 | + }), | |
| 311 | + ); | |
| 312 | + } | |
| 313 | + const svc = yield* getServiceFromConfig( | |
| 314 | + config, | |
| 315 | + ctx, | |
| 316 | + timeoutMs, | |
| 317 | + preferSdk, | |
| 307 | 318 | ); |
| 308 | − } | |
| 309 | − const svc = yield* getServiceFromConfig( | |
| 310 | − config, | |
| 311 | − ctx, | |
| 312 | − timeoutMs, | |
| 313 | − preferSdk, | |
| 314 | − ); | |
| 315 | − return yield* svc.resolveSecret(uri); | |
| 316 | − }), | |
| 317 | − }), | |
| 319 | + return yield* svc.resolveSecret(uri); | |
| 320 | + }), | |
| 321 | + } satisfies OnePasswordExtension; | |
| 322 | + }, | |
| 318 | 323 | |
| 319 | 324 | secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)], |
| 320 | 325 | }; |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 5 | 6 | import { |
| 6 | 7 | OpenApiParseError, |
| @@ -99,57 +100,52 @@ const ExtractionError = OpenApiExtractionError.annotations( · 92 unmodified lines | ||
| 99 | 100 | ); |
| 100 | 101 | const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 })); |
| 101 | 102 | |
| 102 | −export class OpenApiInternalError extends Schema.TaggedError<OpenApiInternalError>()( | |
| 103 | − "OpenApiInternalError", | |
| 104 | − { | |
| 105 | − message: Schema.String, | |
| 106 | − }, | |
| 107 | − HttpApiSchema.annotations({ status: 500 }), | |
| 108 | −) {} | |
| 109 | − | |
| 110 | 103 | // --------------------------------------------------------------------------- |
| 111 | 104 | // Group |
| 105 | +// | |
| 106 | +// Plugin SDK errors (OpenApiParseError, OpenApiExtractionError, | |
| 107 | +// OpenApiOAuthError) are declared once at the group level via | |
| 108 | +// `.addError(...)` — every endpoint inherits them. The errors themselves | |
| 109 | +// carry their HTTP status via `HttpApiSchema.annotations` above, so | |
| 110 | +// handlers just `return yield* ext.foo(...)` and the schema encodes | |
| 111 | +// whatever comes out. | |
| 112 | +// | |
| 113 | +// 5xx is handled at the API level: `.addError(InternalError)` adds the | |
| 114 | +// shared opaque 500 surface. Defects are captured + downgraded to it by | |
| 115 | +// an HttpApiBuilder middleware (see apps/cloud/src/observability.ts). | |
| 116 | +// StorageError → InternalError translation happens at service wiring | |
| 117 | +// time via `withCapture(executor)`. | |
| 112 | 118 | // --------------------------------------------------------------------------- |
| 113 | 119 | |
| 114 | 120 | export class OpenApiGroup extends HttpApiGroup.make("openapi") |
| 115 | 121 | .add( |
| 116 | 122 | HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview` |
| 117 | 123 | .setPayload(PreviewSpecPayload) |
| 118 | − .addSuccess(SpecPreview) | |
| 119 | − .addError(ParseError) | |
| 120 | − .addError(ExtractionError) | |
| 121 | − .addError(OpenApiInternalError), | |
| 124 | + .addSuccess(SpecPreview), | |
| 122 | 125 | ) |
| 123 | 126 | .add( |
| 124 | 127 | HttpApiEndpoint.post("addSpec")`/scopes/${scopeIdParam}/openapi/specs` |
| 125 | 128 | .setPayload(AddSpecPayload) |
| 126 | − .addSuccess(AddSpecResponse) | |
| 127 | − .addError(ParseError) | |
| 128 | − .addError(ExtractionError) | |
| 129 | − .addError(OpenApiInternalError), | |
| 129 | + .addSuccess(AddSpecResponse), | |
| 130 | 130 | ) |
| 131 | 131 | .add( |
| 132 | 132 | HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}` |
| 133 | − .addSuccess(Schema.NullOr(StoredSourceSchema)) | |
| 134 | − .addError(OpenApiInternalError), | |
| 133 | + .addSuccess(Schema.NullOr(StoredSourceSchema)), | |
| 135 | 134 | ) |
| 136 | 135 | .add( |
| 137 | 136 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}` |
| 138 | 137 | .setPayload(UpdateSourcePayload) |
| 139 | − .addSuccess(UpdateSourceResponse) | |
| 140 | − .addError(OpenApiInternalError), | |
| 138 | + .addSuccess(UpdateSourceResponse), | |
| 141 | 139 | ) |
| 142 | 140 | .add( |
| 143 | 141 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/openapi/oauth/start` |
| 144 | 142 | .setPayload(StartOAuthPayload) |
| 145 | − .addSuccess(StartOAuthResponse) | |
| 146 | − .addError(OAuthError), | |
| 143 | + .addSuccess(StartOAuthResponse), | |
| 147 | 144 | ) |
| 148 | 145 | .add( |
| 149 | 146 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/openapi/oauth/complete` |
| 150 | 147 | .setPayload(CompleteOAuthPayload) |
| 151 | − .addSuccess(OAuth2Auth) | |
| 152 | − .addError(OAuthError), | |
| 148 | + .addSuccess(OAuth2Auth), | |
| 153 | 149 | ) |
| 154 | 150 | .add( |
| 155 | 151 | HttpApiEndpoint.get("oauthCallback", "/openapi/oauth/callback") |
| @@ -158,7 +154,13 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 2 unmodified lines | ||
| 158 | 154 | Schema.Unknown.annotations( |
| 159 | 155 | HttpApiSchema.annotations({ contentType: "text/html" }), |
| 160 | 156 | ), |
| 161 | − ) | |
| 162 | − .addError(OAuthError) | |
| 163 | − .addError(OpenApiInternalError), | |
| 164 | − ) {} | |
| 157 | + ), | |
| 158 | + ) | |
| 159 | + // Errors declared once at the group level — every endpoint inherits. | |
| 160 | + // Plugin domain errors carry their own HttpApiSchema status (4xx); | |
| 161 | + // `InternalError` is the shared opaque 500 translated at the HTTP | |
| 162 | + // edge by `withCapture`. | |
| 163 | + .addError(InternalError) | |
| 164 | + .addError(ParseError) | |
| 165 | + .addError(ExtractionError) | |
| 166 | + .addError(OAuthError) {} | |
| @@ -1,21 +1,17 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | −import { Cause, Context, Effect } from "effect"; | |
| 2 | +import { Context, Effect } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; |
| 5 | 5 | |
| 6 | −import { addGroup } from "@executor/api"; | |
| 7 | −import { | |
| 8 | − OpenApiExtractionError, | |
| 9 | − OpenApiOAuthError, | |
| 10 | − OpenApiParseError, | |
| 11 | −} from "../sdk/errors"; | |
| 6 | +import { addGroup, InternalError, type Captured } from "@executor/api"; | |
| 7 | +import { OpenApiOAuthError } from "../sdk/errors"; | |
| 12 | 8 | import type { |
| 13 | 9 | OpenApiPluginExtension, |
| 14 | 10 | HeaderValue, |
| 15 | 11 | OpenApiUpdateSourceInput, |
| 16 | 12 | } from "../sdk/plugin"; |
| 17 | 13 | import { OAuth2Auth } from "../sdk/types"; |
| 18 | −import { OpenApiGroup, OpenApiInternalError } from "./group"; | |
| 14 | +import { OpenApiGroup } from "./group"; | |
| 19 | 15 | |
| 20 | 16 | const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result"; |
| 21 | 17 | |
| @@ -25,50 +21,21 @@ const toPopupErrorMessage = (error: unknown): string => { · 3 unmodified lines | ||
| 25 | 21 | }; |
| 26 | 22 | |
| 27 | 23 | // --------------------------------------------------------------------------- |
| 28 | −// Service tag — the server provides the OpenAPI extension | |
| 24 | +// Service tag | |
| 25 | +// | |
| 26 | +// Holds the `Captured` shape — every method's `StorageFailure` | |
| 27 | +// channel has been swapped for `InternalError({ traceId })`. The cloud | |
| 28 | +// app provides an already-wrapped extension via | |
| 29 | +// `Layer.succeed(OpenApiExtensionService, withCapture(executor.openapi))`. | |
| 30 | +// Handlers see `InternalError` in the error union, which matches | |
| 31 | +// `.addError(InternalError)` on the group — no per-handler translation. | |
| 29 | 32 | // --------------------------------------------------------------------------- |
| 30 | 33 | |
| 31 | 34 | export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")< |
| 32 | 35 | OpenApiExtensionService, |
| 33 | − OpenApiPluginExtension | |
| 36 | + Captured<OpenApiPluginExtension> | |
| 34 | 37 | >() {} |
| 35 | 38 | |
| 36 | −// --------------------------------------------------------------------------- | |
| 37 | −// Failure mapping | |
| 38 | −// --------------------------------------------------------------------------- | |
| 39 | − | |
| 40 | −type OpenApiSpecFailure = OpenApiParseError | OpenApiExtractionError | OpenApiInternalError; | |
| 41 | − | |
| 42 | −const toOpenApiSpecFailure = (error: unknown): OpenApiSpecFailure => { | |
| 43 | − if ( | |
| 44 | − error instanceof OpenApiParseError || | |
| 45 | − error instanceof OpenApiExtractionError || | |
| 46 | − error instanceof OpenApiInternalError | |
| 47 | − ) { | |
| 48 | − return error; | |
| 49 | − } | |
| 50 | − const message = error instanceof Error ? error.message : String(error); | |
| 51 | − return new OpenApiInternalError({ message }); | |
| 52 | −}; | |
| 53 | − | |
| 54 | −const sanitizeSpecFailure = <A, R>( | |
| 55 | − effect: Effect.Effect<A, unknown, R>, | |
| 56 | −): Effect.Effect<A, OpenApiSpecFailure, R> => | |
| 57 | − Effect.catchAllCause(effect, (cause) => Effect.fail(toOpenApiSpecFailure(Cause.squash(cause)))); | |
| 58 | − | |
| 59 | −const toOpenApiInternalError = (error: unknown): OpenApiInternalError => { | |
| 60 | − if (error instanceof OpenApiInternalError) return error; | |
| 61 | − const message = error instanceof Error ? error.message : String(error); | |
| 62 | − return new OpenApiInternalError({ message }); | |
| 63 | −}; | |
| 64 | − | |
| 65 | −const sanitizeInternalFailure = <A, R>( | |
| 66 | − effect: Effect.Effect<A, unknown, R>, | |
| 67 | −): Effect.Effect<A, OpenApiInternalError, R> => | |
| 68 | − Effect.catchAllCause(effect, (cause) => | |
| 69 | − Effect.fail(toOpenApiInternalError(Cause.squash(cause))), | |
| 70 | − ); | |
| 71 | − | |
| 72 | 39 | // --------------------------------------------------------------------------- |
| 73 | 40 | // Composed API — core + openapi group |
| 74 | 41 | // --------------------------------------------------------------------------- |
| @@ -77,6 +44,12 @@ const ExecutorApiWithOpenApi = addGroup(OpenApiGroup); · 2 unmodified lines | ||
| 77 | 44 | |
| 78 | 45 | // --------------------------------------------------------------------------- |
| 79 | 46 | // Handlers |
| 47 | +// | |
| 48 | +// Each handler is exactly: yield the extension service, call the method, | |
| 49 | +// return. Plugin SDK errors flow through the typed channel and are | |
| 50 | +// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls). | |
| 51 | +// Defects bubble up and are captured + downgraded to `InternalError(traceId)` | |
| 52 | +// by the API-level observability middleware. | |
| 80 | 53 | // --------------------------------------------------------------------------- |
| 81 | 54 | |
| 82 | 55 | export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) => |
| @@ -85,7 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 2 unmodified lines | ||
| 85 | 58 | Effect.gen(function* () { |
| 86 | 59 | const ext = yield* OpenApiExtensionService; |
| 87 | 60 | return yield* ext.previewSpec(payload.spec); |
| 88 | − }).pipe(sanitizeSpecFailure), | |
| 61 | + }), | |
| 89 | 62 | ) |
| 90 | 63 | .handle("addSpec", ({ payload }) => |
| 91 | 64 | Effect.gen(function* () { |
| @@ -102,13 +75,13 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 10 unmodified lines | ||
| 102 | 75 | toolCount: result.toolCount, |
| 103 | 76 | namespace: result.sourceId, |
| 104 | 77 | }; |
| 105 | − }).pipe(sanitizeSpecFailure), | |
| 78 | + }), | |
| 106 | 79 | ) |
| 107 | 80 | .handle("getSource", ({ path }) => |
| 108 | 81 | Effect.gen(function* () { |
| 109 | 82 | const ext = yield* OpenApiExtensionService; |
| 110 | 83 | return yield* ext.getSource(path.namespace); |
| 111 | − }).pipe(sanitizeInternalFailure), | |
| 84 | + }), | |
| 112 | 85 | ) |
| 113 | 86 | .handle("updateSource", ({ path, payload }) => |
| 114 | 87 | Effect.gen(function* () { |
| @@ -119,7 +92,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 4 unmodified lines | ||
| 119 | 92 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 120 | 93 | } as OpenApiUpdateSourceInput); |
| 121 | 94 | return { updated: true }; |
| 122 | − }).pipe(sanitizeInternalFailure), | |
| 95 | + }), | |
| 123 | 96 | ) |
| 124 | 97 | .handle("startOAuth", ({ payload }) => |
| 125 | 98 | Effect.gen(function* () { |
| @@ -148,9 +121,12 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 22 unmodified lines | ||
| 148 | 121 | }), |
| 149 | 122 | ) |
| 150 | 123 | .handle("oauthCallback", ({ urlParams }) => |
| 124 | + // OAuth popup is special: it always returns 200 HTML and renders the | |
| 125 | + // failure into the popup body so the parent window's listener gets a | |
| 126 | + // structured result. | |
| 151 | 127 | Effect.gen(function* () { |
| 152 | 128 | const ext = yield* OpenApiExtensionService; |
| 153 | − const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError, never>({ | |
| 129 | + const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({ | |
| 154 | 130 | complete: ({ state, code, error }) => |
| 155 | 131 | ext.completeOAuth({ |
| 156 | 132 | state, |
| @@ -162,6 +138,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 5 unmodified lines | ||
| 162 | 138 | channelName: OPENAPI_OAUTH_CHANNEL, |
| 163 | 139 | }); |
| 164 | 140 | return yield* HttpServerResponse.html(html); |
| 165 | − }).pipe(sanitizeInternalFailure), | |
| 141 | + }), | |
| 166 | 142 | ), |
| 167 | 143 | ); |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | SetSecretInput, |
| 19 | 19 | SourceDetectionResult, |
| 20 | 20 | definePlugin, |
| 21 | + type StorageFailure, | |
| 21 | 22 | type ToolAnnotations, |
| 22 | 23 | type ToolRow, |
| 23 | 24 | } from "@executor/sdk"; |
| @@ -28,7 +29,11 @@ import { · 4 unmodified lines | ||
| 28 | 29 | type OpenApiSourceConfig, |
| 29 | 30 | } from "@executor/config"; |
| 30 | 31 | |
| 31 | −import { OpenApiOAuthError } from "./errors"; | |
| 32 | +import { | |
| 33 | + OpenApiExtractionError, | |
| 34 | + OpenApiOAuthError, | |
| 35 | + OpenApiParseError, | |
| 36 | +} from "./errors"; | |
| 32 | 37 | import { parse, resolveSpecText } from "./parse"; |
| 33 | 38 | import { extract } from "./extract"; |
| 34 | 39 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; |
| @@ -105,17 +110,40 @@ export interface OpenApiCompleteOAuthInput { · 70 unmodified lines | ||
| 105 | 110 | readonly error?: string; |
| 106 | 111 | } |
| 107 | 112 | |
| 113 | +/** | |
| 114 | + * Errors any OpenAPI extension method may surface. The first three are | |
| 115 | + * plugin-domain tagged errors that flow directly to clients (4xx, each | |
| 116 | + * carrying its own `HttpApiSchema` status). `StorageFailure` covers | |
| 117 | + * raw backend failures (`StorageError`) plus `UniqueViolationError`; | |
| 118 | + * the HTTP edge (`@executor/api`'s `withCapture`) translates | |
| 119 | + * `StorageError` to the opaque `InternalError({ traceId })` at Layer | |
| 120 | + * composition. `UniqueViolationError` passes through — plugins can | |
| 121 | + * `Effect.catchTag` it if they want a friendlier user-facing error. | |
| 122 | + */ | |
| 123 | +export type OpenApiExtensionFailure = | |
| 124 | + | OpenApiParseError | |
| 125 | + | OpenApiExtractionError | |
| 126 | + | OpenApiOAuthError | |
| 127 | + | StorageFailure; | |
| 128 | + | |
| 108 | 129 | export interface OpenApiPluginExtension { |
| 109 | − readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>; | |
| 130 | + readonly previewSpec: ( | |
| 131 | + specText: string, | |
| 132 | + ) => Effect.Effect<SpecPreview, OpenApiParseError | OpenApiExtractionError>; | |
| 110 | 133 | readonly addSpec: ( |
| 111 | 134 | config: OpenApiSpecConfig, |
| 112 | − ) => Effect.Effect<{ readonly sourceId: string; readonly toolCount: number }, Error>; | |
| 113 | − readonly removeSpec: (namespace: string) => Effect.Effect<void, Error>; | |
| 114 | − readonly getSource: (namespace: string) => Effect.Effect<StoredSource | null, Error>; | |
| 135 | + ) => Effect.Effect< | |
| 136 | + { readonly sourceId: string; readonly toolCount: number }, | |
| 137 | + OpenApiParseError | OpenApiExtractionError | StorageFailure | |
| 138 | + >; | |
| 139 | + readonly removeSpec: (namespace: string) => Effect.Effect<void, StorageFailure>; | |
| 140 | + readonly getSource: ( | |
| 141 | + namespace: string, | |
| 142 | + ) => Effect.Effect<StoredSource | null, StorageFailure>; | |
| 115 | 143 | readonly updateSource: ( |
| 116 | 144 | namespace: string, |
| 117 | 145 | input: OpenApiUpdateSourceInput, |
| 118 | − ) => Effect.Effect<void, Error>; | |
| 146 | + ) => Effect.Effect<void, StorageFailure>; | |
| 119 | 147 | readonly startOAuth: ( |
| 120 | 148 | input: OpenApiStartOAuthInput, |
| 121 | 149 | ) => Effect.Effect<OpenApiStartOAuthResponse, OpenApiOAuthError>; |
| @@ -222,7 +250,7 @@ export const openApiPlugin = definePlugin( · 100 unmodified lines | ||
| 222 | 250 | schema: openapiSchema, |
| 223 | 251 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), |
| 224 | 252 | |
| 225 | − extension: (ctx): OpenApiPluginExtension => { | |
| 253 | + extension: (ctx) => { | |
| 226 | 254 | // Wraps ctx.secrets.set for the oauth2 helper so createSecret |
| 227 | 255 | // returns the freshly-minted id. |
| 228 | 256 | const createOAuthSecret = (args: { |
| @@ -523,7 +551,7 @@ export const openApiPlugin = definePlugin( · 294 unmodified lines | ||
| 523 | 551 | : new OpenApiOAuthError({ message: err.message }), |
| 524 | 552 | ), |
| 525 | 553 | ), |
| 526 | − }; | |
| 554 | + } satisfies OpenApiPluginExtension; | |
| 527 | 555 | }, |
| 528 | 556 | |
| 529 | 557 | staticSources: (self) => [ |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { Effect, Option, Schema } from "effect"; |
| 2 | 2 | |
| 3 | −import { defineSchema, type StorageDeps } from "@executor/sdk"; | |
| 3 | +import { | |
| 4 | + defineSchema, | |
| 5 | + type StorageDeps, | |
| 6 | + type StorageFailure, | |
| 7 | +} from "@executor/sdk"; | |
| 4 | 8 | |
| 5 | 9 | import { |
| 6 | 10 | HeaderValue, |
| @@ -134,11 +138,15 @@ const decodeHeaders = (value: unknown): Record<string, HeaderValue> => { · 127 unmodified lines | ||
| 134 | 138 | // Store interface |
| 135 | 139 | // --------------------------------------------------------------------------- |
| 136 | 140 | |
| 141 | +// Every method routes through the typed adapter (`ctx.storage.adapter`) | |
| 142 | +// so the typed error channel is `StorageFailure`. Schema-decode failures | |
| 143 | +// inside `Effect.gen` land as defects, not typed errors, and are caught | |
| 144 | +// by the HTTP edge's observability middleware. | |
| 137 | 145 | export interface OpenapiStore { |
| 138 | 146 | readonly upsertSource: ( |
| 139 | 147 | input: StoredSource, |
| 140 | 148 | operations: readonly StoredOperation[], |
| 141 | − ) => Effect.Effect<void, Error>; | |
| 149 | + ) => Effect.Effect<void, StorageFailure>; | |
| 142 | 150 | |
| 143 | 151 | readonly updateSourceMeta: ( |
| 144 | 152 | namespace: string, |
| @@ -148,34 +156,34 @@ export interface OpenapiStore { · 3 unmodified lines | ||
| 148 | 156 | readonly headers?: Record<string, HeaderValue>; |
| 149 | 157 | readonly oauth2?: OAuth2Auth; |
| 150 | 158 | }, |
| 151 | − ) => Effect.Effect<void, Error>; | |
| 159 | + ) => Effect.Effect<void, StorageFailure>; | |
| 152 | 160 | |
| 153 | 161 | readonly getSource: ( |
| 154 | 162 | namespace: string, |
| 155 | − ) => Effect.Effect<StoredSource | null, Error>; | |
| 163 | + ) => Effect.Effect<StoredSource | null, StorageFailure>; | |
| 156 | 164 | |
| 157 | − readonly listSources: () => Effect.Effect<readonly StoredSource[], Error>; | |
| 165 | + readonly listSources: () => Effect.Effect<readonly StoredSource[], StorageFailure>; | |
| 158 | 166 | |
| 159 | 167 | readonly getOperationByToolId: ( |
| 160 | 168 | toolId: string, |
| 161 | − ) => Effect.Effect<StoredOperation | null, Error>; | |
| 169 | + ) => Effect.Effect<StoredOperation | null, StorageFailure>; | |
| 162 | 170 | |
| 163 | 171 | readonly listOperationsBySource: ( |
| 164 | 172 | sourceId: string, |
| 165 | − ) => Effect.Effect<readonly StoredOperation[], Error>; | |
| 173 | + ) => Effect.Effect<readonly StoredOperation[], StorageFailure>; | |
| 166 | 174 | |
| 167 | − readonly removeSource: (namespace: string) => Effect.Effect<void, Error>; | |
| 175 | + readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>; | |
| 168 | 176 | |
| 169 | 177 | readonly putOAuthSession: ( |
| 170 | 178 | sessionId: string, |
| 171 | 179 | session: OpenApiOAuthSession, |
| 172 | − ) => Effect.Effect<void, Error>; | |
| 180 | + ) => Effect.Effect<void, StorageFailure>; | |
| 173 | 181 | |
| 174 | 182 | readonly getOAuthSession: ( |
| 175 | 183 | sessionId: string, |
| 176 | − ) => Effect.Effect<OpenApiOAuthSession | null, Error>; | |
| 184 | + ) => Effect.Effect<OpenApiOAuthSession | null, StorageFailure>; | |
| 177 | 185 | |
| 178 | − readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>; | |
| 186 | + readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>; | |
| 179 | 187 | } |
| 180 | 188 | |
| 181 | 189 | // --------------------------------------------------------------------------- |