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