583 lines · 19.6 KB · TypeScript
RawDownload
import { randomUUID } from "node:crypto"; import { Effect, Option } from "effect"; import { storeOAuthTokens } from "@executor/plugin-oauth2"; import {  definePlugin,  SetSecretInput,  SourceDetectionResult,  type PluginCtx,  type StorageFailure,  type ToolAnnotations,} from "@executor/sdk"; import {  googleDiscoverySchema,  makeGoogleDiscoveryStore,  type GoogleDiscoveryStore,  type GoogleDiscoveryStoredSource,} from "./binding-store";import { extractGoogleDiscoveryManifest } from "./document";import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke";import {  GoogleDiscoveryOAuthError,  GoogleDiscoveryParseError,  GoogleDiscoverySourceError,} from "./errors";import {  buildGoogleAuthorizationUrl,  createPkceCodeVerifier,  exchangeAuthorizationCode,} from "./oauth";import type {  GoogleDiscoveryAuth,  GoogleDiscoveryManifest,  GoogleDiscoveryManifestMethod,  GoogleDiscoveryStoredSourceData,} from "./types";import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; // ---------------------------------------------------------------------------// Public input / output shapes (unchanged from the old plugin)// --------------------------------------------------------------------------- export interface GoogleDiscoveryProbeOperation {  readonly toolPath: string;  readonly method: string;  readonly pathTemplate: string;  readonly description: string | null;} export interface GoogleDiscoveryProbeResult {  readonly name: string;  readonly title: string | null;  readonly service: string;  readonly version: string;  readonly toolCount: number;  readonly scopes: readonly string[];  readonly operations: readonly GoogleDiscoveryProbeOperation[];} export interface GoogleDiscoveryAddSourceInput {  readonly name: string;  readonly discoveryUrl: string;  readonly namespace?: string;  readonly auth: GoogleDiscoveryAuth;} export interface GoogleDiscoveryOAuthStartInput {  readonly name: string;  readonly discoveryUrl: string;  readonly clientIdSecretId: string;  readonly clientSecretSecretId?: string | null;  readonly redirectUrl: string;  readonly scopes?: readonly string[];} export interface GoogleDiscoveryOAuthStartResponse {  readonly sessionId: string;  readonly authorizationUrl: string;  readonly scopes: readonly string[];} export interface GoogleDiscoveryOAuthCompleteInput {  readonly state: string;  readonly code?: string;  readonly error?: string;} export interface GoogleDiscoveryOAuthAuthResult {  readonly kind: "oauth2";  readonly clientIdSecretId: string;  readonly clientSecretSecretId: string | null;  readonly accessTokenSecretId: string;  readonly refreshTokenSecretId: string | null;  readonly tokenType: string;  readonly expiresAt: number | null;  readonly scope: string | null;  readonly scopes: readonly string[];} /** * Errors any Google Discovery extension method may surface. The first * three are plugin-domain tagged errors that flow directly to clients * (4xx, each carrying its own `HttpApiSchema` status once wrapped at the * API edge). `StorageFailure` covers raw backend failures (`StorageError`) * plus `UniqueViolationError`; the HTTP edge (`@executor/api`'s * `withCapture`) translates `StorageError` to the opaque * `InternalError({ traceId })` at Layer composition. `UniqueViolationError` * passes through — plugins can `Effect.catchTag` it if they want a * friendlier user-facing error. */export type GoogleDiscoveryExtensionFailure =  | GoogleDiscoveryParseError  | GoogleDiscoverySourceError  | GoogleDiscoveryOAuthError  | StorageFailure; export interface GoogleDiscoveryPluginExtension {  readonly probeDiscovery: (    discoveryUrl: string,  ) => Effect.Effect<    GoogleDiscoveryProbeResult,    GoogleDiscoveryParseError | GoogleDiscoverySourceError  >;  readonly addSource: (    input: GoogleDiscoveryAddSourceInput,  ) => Effect.Effect<    { readonly toolCount: number; readonly namespace: string },    GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure  >;  readonly removeSource: (    namespace: string,  ) => Effect.Effect<void, StorageFailure>;  readonly startOAuth: (    input: GoogleDiscoveryOAuthStartInput,  ) => Effect.Effect<    GoogleDiscoveryOAuthStartResponse,    | GoogleDiscoveryParseError    | GoogleDiscoverySourceError    | GoogleDiscoveryOAuthError    | StorageFailure  >;  readonly completeOAuth: (    input: GoogleDiscoveryOAuthCompleteInput,  ) => Effect.Effect<    GoogleDiscoveryOAuthAuthResult,    GoogleDiscoveryOAuthError | StorageFailure  >;  readonly getSource: (    namespace: string,  ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;} // ---------------------------------------------------------------------------// URL normalization + slug helpers (unchanged)// --------------------------------------------------------------------------- const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; const normalizeDiscoveryUrl = (discoveryUrl: string): string => {  const trimmed = discoveryUrl.trim();  if (trimmed.length === 0) return trimmed;  let parsed: URL;  try {    parsed = new URL(trimmed);  } catch {    return trimmed;  }  if (parsed.pathname !== "/$discovery/rest") return trimmed;  const version = parsed.searchParams.get("version")?.trim();  if (!version) return trimmed;  const host = parsed.hostname.toLowerCase();  if (!host.endsWith(".googleapis.com")) return trimmed;  const rawService = host.slice(0, -".googleapis.com".length);  const service =    rawService === "calendar-json"      ? "calendar"      : rawService.endsWith("-json")        ? rawService.slice(0, -5)        : rawService;  if (!service) return trimmed;  return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;}; const fetchDiscoveryDocument = (discoveryUrl: string) =>  Effect.tryPromise({    try: async () => {      const response = await fetch(normalizeDiscoveryUrl(discoveryUrl), {        signal: AbortSignal.timeout(20_000),      });      if (!response.ok) {        throw new GoogleDiscoverySourceError({          message: `Google Discovery fetch failed with status ${response.status}`,        });      }      return response.text();    },    catch: (cause) =>      cause instanceof GoogleDiscoverySourceError        ? cause        : new GoogleDiscoverySourceError({            message: cause instanceof Error ? cause.message : String(cause),          }),  }); const normalizeSlug = (value: string): string =>  value    .toLowerCase()    .replace(/[^a-z0-9]+/g, "_")    .replace(/^_+|_+$/g, ""); const deriveNamespace = (input: { name: string; service: string; version: string }): string =>  normalizeSlug(    input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,  ) || `google_${input.service}`; // ---------------------------------------------------------------------------// Register a parsed manifest against the executor core + plugin storage.// Runs inside a transaction.// --------------------------------------------------------------------------- const registerManifest = (  ctx: PluginCtx<GoogleDiscoveryStore>,  namespace: string,  manifest: GoogleDiscoveryManifest,  sourceData: GoogleDiscoveryStoredSourceData,) =>  Effect.gen(function* () {    // 1. Clear any previous manifest for this namespace.    yield* ctx.storage.removeBindingsBySource(namespace);    yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore);     // 2. Register the source + tool rows in core.    yield* ctx.core.sources.register({      id: namespace,      kind: "googleDiscovery",      name: sourceData.name,      url: sourceData.rootUrl,      canRemove: true,      canRefresh: true,      canEdit: true,      tools: manifest.methods.map((method: GoogleDiscoveryManifestMethod) => ({        name: method.toolPath,        description: Option.getOrElse(method.description, () => `${method.binding.method.toUpperCase()} ${method.binding.pathTemplate}`),        inputSchema: Option.getOrUndefined(method.inputSchema),        outputSchema: Option.getOrUndefined(method.outputSchema),      })),    });     // 3. Register shared $defs, if any.    if (Object.keys(manifest.schemaDefinitions).length > 0) {      yield* ctx.core.definitions.register({        sourceId: namespace,        definitions: manifest.schemaDefinitions,      });    }     // 4. Write per-tool bindings to plugin storage (keyed by the same    //    ${source_id}.${name} tool id the executor synthesizes).    yield* Effect.forEach(      manifest.methods,      (method) =>        ctx.storage.putBinding(          `${namespace}.${method.toolPath}`,          namespace,          method.binding,        ),      { discard: true },    );     // 5. Write the source config blob.    yield* ctx.storage.putSource({      namespace,      name: sourceData.name,      config: sourceData,    });     return manifest.methods.length;  }); // ---------------------------------------------------------------------------// Mint a fresh secret via ctx.secrets.set and return {id} for storeOAuthTokens.// --------------------------------------------------------------------------- const createSecretForOAuth = (  ctx: PluginCtx<GoogleDiscoveryStore>,  input: {    readonly idPrefix: string;    readonly name: string;    readonly value: string;    readonly purpose: string;  },) =>  ctx.secrets    .set(      new SetSecretInput({        id: `${input.idPrefix}_${randomUUID().slice(0, 8)}` as SetSecretInput["id"],        name: input.name,        value: input.value,      }),    )    .pipe(Effect.map((ref) => ({ id: ref.id as string }))); // ---------------------------------------------------------------------------// Plugin// --------------------------------------------------------------------------- export const googleDiscoveryPlugin = definePlugin(() => ({  id: "googleDiscovery" as const,  schema: googleDiscoverySchema,  storage: (deps) => makeGoogleDiscoveryStore(deps),   extension: (ctx) => ({    probeDiscovery: (discoveryUrl) =>      Effect.gen(function* () {        const text = yield* fetchDiscoveryDocument(discoveryUrl);        const manifest = yield* extractGoogleDiscoveryManifest(text);        const scopes = Object.keys(          manifest.oauthScopes._tag === "Some" ? manifest.oauthScopes.value : {},        ).sort();        const operations = manifest.methods.map((method) => ({          toolPath: method.toolPath,          method: method.binding.method,          pathTemplate: method.binding.pathTemplate,          description: method.description._tag === "Some" ? method.description.value : null,        }));        return {          name:            manifest.title._tag === "Some"              ? manifest.title.value              : `${manifest.service} ${manifest.version}`,          title: manifest.title._tag === "Some" ? manifest.title.value : null,          service: manifest.service,          version: manifest.version,          toolCount: manifest.methods.length,          scopes,          operations,        };      }),     addSource: (input) =>      ctx.transaction(        Effect.gen(function* () {          const text = yield* fetchDiscoveryDocument(input.discoveryUrl);          const manifest = yield* extractGoogleDiscoveryManifest(text);          const namespace =            input.namespace ??            deriveNamespace({              name: input.name,              service: manifest.service,              version: manifest.version,            });          const sourceData = new GoogleDiscoveryStoredSourceDataSchema({            name: input.name,            discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),            service: manifest.service,            version: manifest.version,            rootUrl: manifest.rootUrl,            servicePath: manifest.servicePath,            auth: input.auth,          });          const toolCount = yield* registerManifest(ctx, namespace, manifest, sourceData);          return { toolCount, namespace };        }),      ),     removeSource: (namespace) =>      ctx.transaction(        Effect.gen(function* () {          yield* ctx.storage.removeBindingsBySource(namespace);          yield* ctx.storage.removeSource(namespace);          yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore);        }),      ),     startOAuth: (input) =>      Effect.gen(function* () {        const text = yield* fetchDiscoveryDocument(input.discoveryUrl);        const manifest = yield* extractGoogleDiscoveryManifest(text);        const scopes =          input.scopes && input.scopes.length > 0            ? [...input.scopes]            : Object.keys(                manifest.oauthScopes._tag === "Some" ? manifest.oauthScopes.value : {},              ).sort();        if (scopes.length === 0) {          return yield* new GoogleDiscoveryOAuthError({            message: "This Google Discovery document does not declare any OAuth scopes",          });        }        const clientIdValue = yield* ctx.secrets.get(input.clientIdSecretId);        if (clientIdValue === null) {          return yield* new GoogleDiscoveryOAuthError({            message: `OAuth client ID secret not found: ${input.clientIdSecretId}`,          });        }        const sessionId = randomUUID();        const codeVerifier = createPkceCodeVerifier();        yield* ctx.storage.putOAuthSession(sessionId, {          discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),          name: input.name,          clientIdSecretId: input.clientIdSecretId,          clientSecretSecretId: input.clientSecretSecretId ?? null,          redirectUrl: input.redirectUrl,          scopes,          codeVerifier,        });        return {          sessionId,          authorizationUrl: buildGoogleAuthorizationUrl({            clientId: clientIdValue,            redirectUrl: input.redirectUrl,            scopes,            state: sessionId,            codeVerifier,          }),          scopes,        };      }),     completeOAuth: (input) =>      Effect.gen(function* () {        const session = yield* ctx.storage.getOAuthSession(input.state);        if (!session) {          return yield* new GoogleDiscoveryOAuthError({            message: "OAuth session not found or has expired",          });        }        yield* ctx.storage.deleteOAuthSession(input.state);         if (input.error) {          return yield* new GoogleDiscoveryOAuthError({ message: input.error });        }        if (!input.code) {          return yield* new GoogleDiscoveryOAuthError({            message: "OAuth callback did not include an authorization code",          });        }         const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId);        if (clientIdValue === null) {          return yield* new GoogleDiscoveryOAuthError({            message: `OAuth client ID secret not found: ${session.clientIdSecretId}`,          });        }         const clientSecretValue =          session.clientSecretSecretId === null            ? null            : yield* ctx.secrets.get(session.clientSecretSecretId).pipe(                Effect.flatMap((v) =>                  v === null                    ? Effect.fail(                        new GoogleDiscoveryOAuthError({                          message: `OAuth client secret not found: ${session.clientSecretSecretId}`,                        }),                      )                    : Effect.succeed(v),                ),              );         const tokenResponse = yield* exchangeAuthorizationCode({          clientId: clientIdValue,          clientSecret: clientSecretValue,          redirectUrl: session.redirectUrl,          codeVerifier: session.codeVerifier,          code: input.code,        });         const stored = yield* storeOAuthTokens({          tokens: tokenResponse,          slug: `${normalizeSlug(session.name)}_google`,          displayName: session.name,          accessTokenPurpose: "google_oauth_access_token",          refreshTokenPurpose: "google_oauth_refresh_token",          createSecret: (args) => createSecretForOAuth(ctx, args),        }).pipe(          Effect.mapError((error) => new GoogleDiscoveryOAuthError({ message: error.message })),        );         return {          kind: "oauth2" as const,          clientIdSecretId: session.clientIdSecretId,          clientSecretSecretId: session.clientSecretSecretId,          accessTokenSecretId: stored.accessTokenSecretId,          refreshTokenSecretId: stored.refreshTokenSecretId,          tokenType: stored.tokenType,          expiresAt: stored.expiresAt,          scope: stored.scope,          scopes: [...session.scopes],        };      }),     getSource: (namespace) => ctx.storage.getSource(namespace),  } satisfies GoogleDiscoveryPluginExtension),   invokeTool: ({ ctx, toolRow, args }) =>    invokeGoogleDiscoveryTool({

Lines 1–500 of 583.