History

refactor(plugins): migrate openapi/graphql/google-discovery/onepassword to Captured

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.

Rhys Sullivan committed 7 months ago

Commit 3d3d39f1 parent d9aee0e24 files · +621 −510Browse files

Unified · Split

24 files
  1. apps/cloud/src/api/protected.ts +6 −7
  2. apps/cloud/src/services/__test-harness__/api-harness.ts +2 −2
  3. apps/local/src/server/main.ts +8 −9
  4. notes/error-handling.md +20 −20
  5. packages/plugins/google-discovery/src/api/group.ts +38 −26
  6. packages/plugins/google-discovery/src/api/handlers.test.ts +68 −42
  7. packages/plugins/google-discovery/src/api/handlers.ts +43 −50
  8. packages/plugins/google-discovery/src/sdk/binding-store.ts +22 −12
  9. packages/plugins/google-discovery/src/sdk/document.ts +6 −2
  10. packages/plugins/google-discovery/src/sdk/errors.ts +21 −4
  11. packages/plugins/google-discovery/src/sdk/plugin.ts +34 −16
  12. packages/plugins/graphql/src/api/group.ts +23 −17
  13. packages/plugins/graphql/src/api/handlers.ts +26 −51
  14. packages/plugins/graphql/src/sdk/invoke.ts +2 −2
  15. packages/plugins/graphql/src/sdk/plugin.ts +24 −14
  16. packages/plugins/graphql/src/sdk/store.ts +8 −8
  17. packages/plugins/onepassword/src/api/group.ts +19 −14
  18. packages/plugins/onepassword/src/api/handlers.ts +21 −8
  19. packages/plugins/onepassword/src/sdk/errors.ts +9 −4
  20. packages/plugins/onepassword/src/sdk/plugin.ts +108 −103
  21. packages/plugins/openapi/src/api/group.ts +30 −28
  22. packages/plugins/openapi/src/api/handlers.ts +28 −52
  23. packages/plugins/openapi/src/sdk/plugin.ts +36 −8
  24. packages/plugins/openapi/src/sdk/store.ts +19 −11
apps/cloud/src/api/protected.ts+6−7
@@ -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-returning51 // `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-capture54 // `UniqueViolationError` becomes a defect. Handlers see the
55 // shape (openapi / graphql) pull from the raw executor until they55 // 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(
apps/cloud/src/services/__test-harness__/api-harness.ts+2−2
@@ -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(
apps/local/src/server/main.ts+8−9
@@ -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-returning97 // `withCapture` wraps the executor once — every Effect-returning
98 // method translates `StorageError` to `InternalError({ traceId })` via98 // method on core + every plugin extension translates `StorageError`
99 // `ErrorCapture`. Service tags that declare `Captured<...>` (MCP99 // to `InternalError({ traceId })` via `ErrorCapture`;
100 // today) pull from `wrapped.*`; tags still on the pre-capture shape100 // `UniqueViolationError` becomes a defect. Handlers see the
101 // (openapi / graphql / google-discovery / onepassword) pull from the101 // 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(
notes/error-handling.md+20−20
@@ -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`, fails98 on the typed channel, captures the cause via `ErrorCapture`, fails
99 with `InternalError({ traceId })`. Every other typed failure99 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 extension102- `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. Lets104 and applies `capture` to each Effect-returning method. Lets
105 the cloud app wire translation once at Layer composition instead of105 the cloud app wire translation once at Layer composition instead of
106 per handler.106 per handler.
107 107
108 ```ts108 ```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 of115- `Captured<T>` — the type-level mirror of
116 `withStorageCapture`. Service tags declare this post-wrap shape so116 `withCapture`. Service tags declare this post-wrap shape so
117 handlers see `InternalError` in the method's error union (matching117 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 ```ts120 ```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### Hosts170### Hosts
171 171
172- **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides172- **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides
173 `ErrorCaptureLive`, a Sentry-backed implementation. Wired into the173 `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API
174 protected API layer composition (`protected-layers.ts`) where it's174 layer in `protected-layers.ts` so it's available to both
175 available both to `observabilityMiddleware` and to the method175 `observabilityMiddleware` (defect catchall) AND to `withCapture`'s
176 wrappers produced by `withStorageCapture`. `protected.ts` wraps176 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 the178 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 via180 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 still182 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 raw185- **Tests / Promise SDK / examples** — non-HTTP consumers see raw
186 `StorageError` / `StorageFailure` in the SDK's typed channel and186 `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 a196- `sanitize*` helpers in handler files that `catchAllCause` + map to a
197 generic 500 — same swallowing problem in disguise. Prefer197 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 referencing199- SDK code importing `Sentry.captureException` or referencing
200 `InternalError` / `ErrorCapture` — translation lives strictly in200 `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.
packages/plugins/google-discovery/src/api/group.ts+38−26
@@ -1,7 +1,13 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
6import {
7 GoogleDiscoveryOAuthError,
8 GoogleDiscoveryParseError,
9 GoogleDiscoverySourceError,
10} from "../sdk/errors";
5import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";11import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";
6 12
7export { HttpApiSchema };13export { 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
112export 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
120export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")130export 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) {}
packages/plugins/google-discovery/src/api/handlers.test.ts+68−42
@@ -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
1import { HttpApiBuilder, HttpServer } from "@effect/platform";10import { HttpApiBuilder, HttpServer } from "@effect/platform";
2import { describe, expect, it } from "vitest";11import { describe, expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";12import { Effect, Layer } from "effect";
4 13
5import { addGroup } from "@executor/api";14import {
15 addGroup,
16 observabilityMiddleware,
17 withCapture,
18} from "@executor/api";
6import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";19import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
7import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";20import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";
8import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";21import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";
@@ -10,57 +23,70 @@ import { GoogleDiscoveryGroup } from "./group"; · 1 unmodified line
10 23
11const unused = Effect.dieMessage("unused");24const unused = Effect.dieMessage("unused");
12 25
13const createFailingExtension = (): GoogleDiscoveryPluginExtension => ({26const 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
22const Api = addGroup(GoogleDiscoveryGroup);35const Api = addGroup(GoogleDiscoveryGroup);
23 36
24const fakeExecutor = {} as any;37// `acquireRelease` keeps disposal inside the Effect scope — no
25const fakeExecutionEngine = {} as any;38// try/finally, no per-test cleanup plumbing. `it.scoped` closes the
26 39// scope for us.
27const createHandler = () =>40const 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
41describe("GoogleDiscoveryHandlers", () => {64describe("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});
packages/plugins/google-discovery/src/api/handlers.ts+43−50
@@ -1,65 +1,58 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { runOAuthCallback } from "@executor/plugin-oauth2/http";4import { runOAuthCallback } from "@executor/plugin-oauth2/http";
5 5
6import { addGroup } from "@executor/api";6import { addGroup, type Captured } from "@executor/api";
7import type {7import type {
8 GoogleDiscoveryAddSourceInput,8 GoogleDiscoveryAddSourceInput,
9 GoogleDiscoveryOAuthAuthResult,9 GoogleDiscoveryOAuthAuthResult,
10 GoogleDiscoveryPluginExtension,10 GoogleDiscoveryPluginExtension,
11} from "../sdk/plugin";11} from "../sdk/plugin";
12import {12import { GoogleDiscoveryOAuthError } from "../sdk/errors";
13 GoogleDiscoveryInvocationError,13import { GoogleDiscoveryGroup } from "./group";
14 GoogleDiscoveryOAuthError,14
15 GoogleDiscoveryParseError,15// ---------------------------------------------------------------------------
16 GoogleDiscoverySourceError,16// Service tag
17} from "../sdk/errors";17//
18import {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
24export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<26export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<
25 GoogleDiscoveryExtensionService,27 GoogleDiscoveryExtensionService,
26 GoogleDiscoveryPluginExtension28 Captured<GoogleDiscoveryPluginExtension>
27>() {}29>() {}
28 30
31// ---------------------------------------------------------------------------
32// Composed API
33// ---------------------------------------------------------------------------
34
29const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);35const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);
30 36
31const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result";37const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result";
32 38
33const toPopupErrorMessage = (error: unknown): string => {39const 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
40const 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
57const 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
64export const GoogleDiscoveryHandlers = HttpApiBuilder.group(57export 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 never109 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);
packages/plugins/google-discovery/src/sdk/binding-store.ts+22−12
@@ -14,7 +14,11 @@ · 13 unmodified lines
14 14
15import { Effect, Schema } from "effect";15import { Effect, Schema } from "effect";
16 16
17import { defineSchema, type StorageDeps } from "@executor/sdk";17import {
18 defineSchema,
19 type StorageDeps,
20 type StorageFailure,
21} from "@executor/sdk";
18 22
19import {23import {
20 GoogleDiscoveryMethodBinding,24 GoogleDiscoveryMethodBinding,
@@ -101,43 +105,49 @@ const decodeJson = (value: unknown): unknown => { · 80 unmodified lines
101// Store interface105// 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.
104export interface GoogleDiscoveryStore {112export 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 Error115 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 Error129 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// ---------------------------------------------------------------------------
packages/plugins/google-discovery/src/sdk/document.ts+6−2
@@ -130,8 +130,12 @@ const DiscoveryDocumentModel = Schema.Struct({ · 129 unmodified lines
130});130});
131type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;131type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;
132 132
133const 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`.
137const toParseError = (message: string, _cause: unknown) =>
138 new GoogleDiscoveryParseError({ message });
135 139
136const decodeUnknownWith =140const decodeUnknownWith =
137 <A>(141 <A>(
packages/plugins/google-discovery/src/sdk/errors.ts+21−4
@@ -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
1import { Data, Schema } from "effect";12import { Data, Schema } from "effect";
13import { HttpApiSchema } from "@effect/platform";
2import type { Option } from "effect";14import type { Option } from "effect";
3 15
4export class GoogleDiscoveryParseError extends Data.TaggedError("GoogleDiscoveryParseError")<{16export 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
9export class GoogleDiscoveryInvocationError extends Data.TaggedError(24export 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
24export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()(40export 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) {}
packages/plugins/google-discovery/src/sdk/plugin.ts+34−16
@@ -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 */
114export type GoogleDiscoveryExtensionFailure =
115 | GoogleDiscoveryParseError
116 | GoogleDiscoverySourceError
117 | GoogleDiscoveryOAuthError
118 | StorageFailure;
119
102export interface GoogleDiscoveryPluginExtension {120export 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 | Error131 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 | Error140 | 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({
packages/plugins/graphql/src/api/group.ts+23−17
@@ -1,6 +1,7 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
5import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";6import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
6import { HeaderValue } from "../sdk/types";7import { 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
66export class GraphqlInternalError extends Schema.TaggedError<GraphqlInternalError>()(
67 "GraphqlInternalError",
68 {
69 message: Schema.String,
70 },
71 HttpApiSchema.annotations({ status: 500 }),
72) {}
73
74// ---------------------------------------------------------------------------67// ---------------------------------------------------------------------------
75// Group68// 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
78export class GraphqlGroup extends HttpApiGroup.make("graphql")82export 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) {}
packages/plugins/graphql/src/api/handlers.ts+26−51
@@ -1,61 +1,30 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup } from "@executor/api";4import { addGroup, type Captured } from "@executor/api";
5import { GraphqlExtractionError, GraphqlIntrospectionError } from "../sdk/errors";5import type {
6import type { GraphqlPluginExtension, HeaderValue, GraphqlUpdateSourceInput } from "../sdk/plugin";6 GraphqlPluginExtension,
7import { GraphqlGroup, GraphqlInternalError } from "./group";7 HeaderValue,
8 GraphqlUpdateSourceInput,
9} from "../sdk/plugin";
10import { GraphqlGroup } from "./group";
8 11
9// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
10// Service tag — the server provides the GraphQL extension13// 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
13export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")<23export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")<
14 GraphqlExtensionService,24 GraphqlExtensionService,
15 GraphqlPluginExtension25 Captured<GraphqlPluginExtension>
16>() {}26>() {}
17 27
18// ---------------------------------------------------------------------------
19// Failure mapping
20// ---------------------------------------------------------------------------
21
22type GraphqlAddSourceFailure =
23 | GraphqlIntrospectionError
24 | GraphqlExtractionError
25 | GraphqlInternalError;
26
27const 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
39const 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
46const 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
52const 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 group29// Composed API — core + graphql group
61// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
@@ -64,6 +33,12 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); · 2 unmodified lines
64 33
65// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
66// Handlers35// 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
69export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) =>44export 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);
packages/plugins/graphql/src/sdk/invoke.ts+2−2
@@ -14,8 +14,8 @@ import { · 13 unmodified lines
14 14
15export const resolveHeaders = (15export 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 / failing21 // Resolve secret-backed headers in parallel. Missing / failing
packages/plugins/graphql/src/sdk/plugin.ts+24−14
@@ -5,6 +5,7 @@ import type { Layer } from "effect"; · 4 unmodified lines
5import {5import {
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";
26import { extract } from "./extract";27import { extract } from "./extract";
27import { GraphqlExtractionError } from "./errors";28import { GraphqlExtractionError, GraphqlIntrospectionError } from "./errors";
28import { invokeWithLayer, resolveHeaders } from "./invoke";29import { invokeWithLayer, resolveHeaders } from "./invoke";
29import {30import {
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 */
82export type GraphqlExtensionFailure =
83 | GraphqlIntrospectionError
84 | GraphqlExtractionError
85 | StorageFailure;
86
72export interface GraphqlPluginExtension {87export 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 the104 * 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 configFile389 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) => [
packages/plugins/graphql/src/sdk/store.ts+8−8
@@ -1,6 +1,6 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2 2
3import { defineSchema, type StorageDeps } from "@executor/sdk";3import { defineSchema, type StorageDeps, type StorageFailure } from "@executor/sdk";
4 4
5import { OperationBinding, type HeaderValue } from "./types";5import { 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// ---------------------------------------------------------------------------
packages/plugins/onepassword/src/api/group.ts+19−14
@@ -1,6 +1,7 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
5import { OnePasswordError } from "../sdk/errors";6import { OnePasswordError } from "../sdk/errors";
6import { OnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";7import { OnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";
@@ -32,14 +33,18 @@ const ListVaultsResponse = Schema.Struct({ · 25 unmodified lines
32 33
33const GetConfigResponse = Schema.NullOr(OnePasswordConfig);34const GetConfigResponse = Schema.NullOr(OnePasswordConfig);
34 35
35// ---------------------------------------------------------------------------
36// Errors with HTTP status
37// ---------------------------------------------------------------------------
38
39const OpError = OnePasswordError.annotations(HttpApiSchema.annotations({ status: 502 }));
40
41// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
42// Group37// 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
45export class OnePasswordGroup extends HttpApiGroup.make("onepassword")50export 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) {}
packages/plugins/onepassword/src/api/handlers.ts+21−8
@@ -1,17 +1,24 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import { Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup } from "@executor/api";4import { addGroup, type Captured } from "@executor/api";
5import type { OnePasswordExtension } from "../sdk/plugin";5import type { OnePasswordExtension } from "../sdk/plugin";
6import { OnePasswordGroup } from "./group";6import { OnePasswordGroup } from "./group";
7 7
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
9// Service tag — the server provides the 1Password extension9// 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
12export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")<19export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")<
13 OnePasswordExtensionService,20 OnePasswordExtensionService,
14 OnePasswordExtension21 Captured<OnePasswordExtension>
15>() {}22>() {}
16 23
17// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
@@ -22,6 +29,12 @@ const ExecutorApiWithOnePassword = addGroup(OnePasswordGroup); · 4 unmodified lines
22 29
23// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
24// Handlers31// 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
27export const OnePasswordHandlers = HttpApiBuilder.group(40export 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);
packages/plugins/onepassword/src/sdk/errors.ts+9−4
@@ -1,6 +1,11 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { HttpApiSchema } from "@effect/platform";
2 3
3export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()("OnePasswordError", {4export 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) {}
packages/plugins/onepassword/src/sdk/plugin.ts+108−103
@@ -2,9 +2,11 @@ import { Effect, Schema } from "effect"; · 1 unmodified line
2 2
3import {3import {
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
10import { OnePasswordConfig, Vault, ConnectionStatus } from "./types";12import { OnePasswordConfig, Vault, ConnectionStatus } from "./types";
@@ -24,6 +26,20 @@ const CREDENTIAL_FIELD = "credential"; · 13 unmodified lines
24const DEFAULT_TIMEOUT_MS = 15_000;26const DEFAULT_TIMEOUT_MS = 15_000;
25const CONFIG_KEY = "config";27const 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
41export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure;
42
27// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
28// Plugin extension — public API on executor.onepassword44// 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 OnePasswordError56 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 because80// 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
63export interface OnePasswordStore {86export interface OnePasswordStore {
64 readonly getConfig: () => Effect.Effect<87 readonly getConfig: () => Effect.Effect<
65 OnePasswordConfig | null,88 OnePasswordConfig | null,
66 OnePasswordError89 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
74const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig);97const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig);
75 98
99const 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
76export const makeOnePasswordStore = (108export 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
137const resolveAuth = (147const 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 vaults297 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 };
packages/plugins/openapi/src/api/group.ts+30−28
@@ -1,6 +1,7 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
5import {6import {
6 OpenApiParseError,7 OpenApiParseError,
@@ -99,57 +100,52 @@ const ExtractionError = OpenApiExtractionError.annotations( · 92 unmodified lines
99);100);
100const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 }));101const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 }));
101 102
102export class OpenApiInternalError extends Schema.TaggedError<OpenApiInternalError>()(
103 "OpenApiInternalError",
104 {
105 message: Schema.String,
106 },
107 HttpApiSchema.annotations({ status: 500 }),
108) {}
109
110// ---------------------------------------------------------------------------103// ---------------------------------------------------------------------------
111// Group104// 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
114export class OpenApiGroup extends HttpApiGroup.make("openapi")120export 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) {}
packages/plugins/openapi/src/api/handlers.ts+28−52
@@ -1,21 +1,17 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { runOAuthCallback } from "@executor/plugin-oauth2/http";4import { runOAuthCallback } from "@executor/plugin-oauth2/http";
5 5
6import { addGroup } from "@executor/api";6import { addGroup, InternalError, type Captured } from "@executor/api";
7import {7import { OpenApiOAuthError } from "../sdk/errors";
8 OpenApiExtractionError,
9 OpenApiOAuthError,
10 OpenApiParseError,
11} from "../sdk/errors";
12import type {8import type {
13 OpenApiPluginExtension,9 OpenApiPluginExtension,
14 HeaderValue,10 HeaderValue,
15 OpenApiUpdateSourceInput,11 OpenApiUpdateSourceInput,
16} from "../sdk/plugin";12} from "../sdk/plugin";
17import { OAuth2Auth } from "../sdk/types";13import { OAuth2Auth } from "../sdk/types";
18import { OpenApiGroup, OpenApiInternalError } from "./group";14import { OpenApiGroup } from "./group";
19 15
20const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result";16const 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 extension24// 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
31export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")<34export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")<
32 OpenApiExtensionService,35 OpenApiExtensionService,
33 OpenApiPluginExtension36 Captured<OpenApiPluginExtension>
34>() {}37>() {}
35 38
36// ---------------------------------------------------------------------------
37// Failure mapping
38// ---------------------------------------------------------------------------
39
40type OpenApiSpecFailure = OpenApiParseError | OpenApiExtractionError | OpenApiInternalError;
41
42const 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
54const 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
59const 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
65const 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 group40// Composed API — core + openapi group
74// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
@@ -77,6 +44,12 @@ const ExecutorApiWithOpenApi = addGroup(OpenApiGroup); · 2 unmodified lines
77 44
78// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
79// Handlers46// 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
82export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) =>55export 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);
packages/plugins/openapi/src/sdk/plugin.ts+36−8
@@ -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
31import { OpenApiOAuthError } from "./errors";32import {
33 OpenApiExtractionError,
34 OpenApiOAuthError,
35 OpenApiParseError,
36} from "./errors";
32import { parse, resolveSpecText } from "./parse";37import { parse, resolveSpecText } from "./parse";
33import { extract } from "./extract";38import { extract } from "./extract";
34import { compileToolDefinitions, type ToolDefinition } from "./definitions";39import { 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 */
123export type OpenApiExtensionFailure =
124 | OpenApiParseError
125 | OpenApiExtractionError
126 | OpenApiOAuthError
127 | StorageFailure;
128
108export interface OpenApiPluginExtension {129export 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 createSecret254 // 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) => [
packages/plugins/openapi/src/sdk/store.ts+19−11
@@ -1,6 +1,10 @@
1import { Effect, Option, Schema } from "effect";1import { Effect, Option, Schema } from "effect";
2 2
3import { defineSchema, type StorageDeps } from "@executor/sdk";3import {
4 defineSchema,
5 type StorageDeps,
6 type StorageFailure,
7} from "@executor/sdk";
4 8
5import {9import {
6 HeaderValue,10 HeaderValue,
@@ -134,11 +138,15 @@ const decodeHeaders = (value: unknown): Record<string, HeaderValue> => { · 127 unmodified lines
134// Store interface138// 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.
137export interface OpenapiStore {145export 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// ---------------------------------------------------------------------------

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t