import { Effect } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { BindingError, type ClientResolver } from "@executor-js/plugin-apps/api"; // --------------------------------------------------------------------------- // The REAL per-request ClientResolver for the running self-host (Fix 2). // // A published apps tool declares connection("github") and its sandboxed handler // makes method calls like github.repos.listForAuthenticatedUser(args). This // resolver routes each such call through the executor's per-request context: // 1. resolve the user's connection for the integration by name (the invoking // owner's connection — policy/ownership applies at this lookup); // 2. resolve its credential value through the provider (OAuth refresh happens // here) — credentials are injected at the boundary, never seen by the // sandboxed code; // 3. dispatch the upstream call over the request's HttpClient, with the // credential rendered as a Bearer token, against the integration's base URL // using a REST-path convention (see `methodPathToUrl`). // // This is built PER REQUEST from `ctx` (the plugin's PluginCtx), so it has the // real invoking context the boot-time singleton lacked. It is passed to the apps // plugin's `invokeTool` via `makeResolver`, which threads it into the runtime's // invoke path as a per-request override. // // HONEST GAP: mapping an arbitrary dotted method path to a concrete REST // endpoint is integration-specific. This resolver implements the GitHub-style // REST convention (dotted path -> `/segment/segment`, args -> query/body), which // is enough for the daily-brief fixture and any REST-shaped integration whose // method paths mirror their URL paths. A fully general mapping (per-integration // operation tables, GraphQL, non-REST) is the remaining work; such a call fails // with a typed BindingError naming the unmapped method rather than guessing. // --------------------------------------------------------------------------- /** The subset of PluginCtx this resolver needs. Kept structural so the host does * not import the plugin package's ctx type. */ export interface AppsResolverCtx { readonly httpClientLayer: import("effect").Layer.Layer; readonly connections: { readonly list: (filter?: { readonly integration?: string; }) => Effect.Effect; readonly resolveValue: (ref: { readonly owner: unknown; readonly name: string; readonly integration: string; }) => Effect.Effect; }; /** Catalog integration lookup, used to resolve an integration's base URL from * its registered record. An operator can register a `github` integration * (OpenAPI-shaped) pointed at a loopback emulator; the base URL it stores in * the integration's opaque config is what the resolver dispatches against. * Kept structural so the host does not import the SDK's ctx type. */ readonly core?: { readonly integrations: { readonly get: (slug: string) => Effect.Effect; }; }; } interface AppsResolverIntegration { /** The owning plugin's opaque config. OpenAPI-shaped integrations carry the * spec server URL here as `baseUrl`. */ readonly config?: { readonly baseUrl?: string } | null; } interface AppsResolverConnection { readonly owner: unknown; readonly name: string; readonly integration: string; /** The integration's opaque config; a `baseUrl` here overrides the default. */ readonly config?: { readonly baseUrl?: string } | null; } /** Default base URLs for the integrations the self-host resolver knows how to * reach over REST. An integration whose connection config carries a `baseUrl` * (e.g. an emulator) overrides this. */ const DEFAULT_BASE_URLS: Record = { github: "https://api.github.com", }; const unknownMessage = (cause: unknown): string => { // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: preserve upstream Effect failure text in BindingError return String(cause); }; /** Map a dotted method path to a REST URL path. GitHub-style convention: the * LAST segment is the operation verb (listForAuthenticatedUser, listForRepo) * and the leading segments are the resource. For the daily-brief tool the two * calls are `repos.listForAuthenticatedUser` -> GET /user/repos and * `issues.listForRepo` -> GET /repos/{owner}/{repo}/issues. We special-case the * handful the fixture uses and otherwise fall back to `/segment/segment`. */ const methodPathToRequest = ( path: readonly string[], args: Record, ): { method: "GET" | "POST"; url: string; query: Record; body?: unknown; } | null => { const key = path.join("."); const query: Record = {}; for (const [k, v] of Object.entries(args)) { if (v == null) continue; if (typeof v === "object") continue; query[k] = String(v); } if (key === "repos.listForAuthenticatedUser") { return { method: "GET", url: "/user/repos", query }; } if (key === "issues.listForRepo") { const owner = String(args.owner ?? ""); const repo = String(args.repo ?? ""); const { owner: _o, repo: _r, ...rest } = query; void _o; void _r; return { method: "GET", url: `/repos/${owner}/${repo}/issues`, query: rest, }; } return null; }; export const makeCtxResolver = ( ctxUnknown: unknown, integrationForRole: (role: string) => string = (role) => role, ): ClientResolver => { const ctx = ctxUnknown as AppsResolverCtx; return { call: ({ integration, connection, path, args }) => Effect.gen(function* () { const integrationSlug = integrationForRole(integration); const conns = yield* ctx.connections .list({ integration: integrationSlug }) .pipe(Effect.orElseSucceed(() => [] as readonly AppsResolverConnection[])); // The binding MUST name a real connection: match by name exactly, never // fall back to `conns[0]`. A silent fallback would dispatch the call with // some OTHER connection's credential (whichever happens to sort first), // leaking the wrong owner's token to the upstream. A missing/misnamed // binding is a typed error naming the role + surface, and no upstream // call is made. const conn = conns.find((c) => c.name === connection); if (!conn) { return yield* new BindingError({ message: `no "${integrationSlug}" connection named "${connection}" is bound for role ` + `"${integration}"; bind a connection for this role before invoking (refusing to ` + `fall back to another connection's credential)`, role: integration, surface: integrationSlug, }); } const token = yield* ctx.connections .resolveValue({ owner: conn.owner, name: conn.name, integration: conn.integration, }) .pipe(Effect.orElseSucceed(() => null)); const req = methodPathToRequest(path, (args[0] ?? {}) as Record); if (!req) { return yield* new BindingError({ message: `apps resolver has no REST mapping for "${integrationSlug}.${path.join(".")}" (the general per-integration operation mapping is the remaining gap)`, role: integration, surface: integrationSlug, }); } // Base URL precedence: the connection's own config override (rare), then // the registered integration record's non-secret displayUrl (an operator // registering `github` against a loopback emulator sets it there), then // the built-in default. Reading it from the integration record is the // right seam: the base URL is an integration property, not a credential. const integrationBaseUrl = ctx.core ? yield* ctx.core.integrations.get(integrationSlug).pipe( Effect.map((rec) => { const base = rec?.config?.baseUrl; return typeof base === "string" && base.length > 0 ? base : undefined; }), Effect.orElseSucceed(() => undefined), ) : undefined; const baseUrl = conn.config?.baseUrl ?? integrationBaseUrl ?? DEFAULT_BASE_URLS[integrationSlug]; if (!baseUrl) { return yield* new BindingError({ message: `apps resolver has no base URL for integration "${integrationSlug}"`, role: integration, surface: integrationSlug, }); } const result = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const qs = new URLSearchParams(req.query).toString(); const url = `${baseUrl.replace(/\/$/, "")}${req.url}${qs ? `?${qs}` : ""}`; let request = HttpClientRequest.make(req.method)(url).pipe( HttpClientRequest.setHeader("user-agent", "executor-apps"), HttpClientRequest.setHeader("accept", "application/vnd.github+json"), ); if (token) { request = request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); } const response = yield* client.execute(request); return yield* response.json; }).pipe( Effect.provide(ctx.httpClientLayer), Effect.mapError( (cause) => new BindingError({ message: `upstream call ${integrationSlug}.${path.join(".")} failed: ${unknownMessage(cause)}`, role: integration, surface: integrationSlug, }), ), ); return result; }), }; };