History

Unify auth methods across OpenAPI, GraphQL, and MCP on a shared placements model (#943)

* Add @executor-js/http-auth: shared placements-based auth-method model One vocabulary for the HTTP protocol plugins (openapi/graphql/mcp): an apikey method carries N header/query placements, each bound to its own credential variable (or sharing one), plus literal placements for static values. Includes the renderer, required-variable extraction, slug normalization, and catalog projection helpers. Core gains the carrier-agnostic getValues seam on ResolveToolsInput (multi-input analog of getValue) and the catalog wire schema now carries placement variable/literal so multi-input methods survive the HTTP boundary. Removes the dead http-source and graphql-greenfield packages (zero consumers; they forked this exact logic). * MCP: adopt the shared placements auth model McpAuthMethod becomes none | apikey(placements) | oauth2 — one method can mix header and query placements, each rendered from its own credential input (or sharing one). buildConnectorInput renders via the shared renderer over credential.values; invokeTool fails explicitly with connection_value_missing instead of dialing unauthenticated; discovery keeps its tolerant skip. The addServer 'auth' shorthand stays as input sugar. Pre-canonical stored shapes (singular auth, retired header/query kinds, openapi-shaped v1-migration templates) are rewritten by the new one-off migrate-config module — runtime decodes only the canonical shape. Slugs and variable names are preserved verbatim (connection.template / item_ids contracts). The react codec collapses onto the new shared-auth-method-codec in packages/react, deleting the first-named-placement truncation; the edit screen diffs placements deeply. Wire tests pin the extreme case: one method = bearer header + team-id query param, asserted on what the server received. * OpenAPI: adopt the shared placements auth model The apiKey arm of Authentication becomes the shared ApiKeyAuthMethod (placements bound to per-input variables) — the variable()-templated header/query records are retired. The oauth arm keeps its core shape (type: "oauth", stored endpoints + scopes). renderAuthTemplate and requiredTemplateVariables now sit on the shared renderer; the catalog projection and react codec collapse onto the shared helpers. Stored legacy templates are rewritten by the one-off migrate-config module; equivalence tests pin that migrated templates render byte-identically to the legacy renderer (multi-variable Datadog-style, literal-only values, prefix extraction). Slugs and variable names are preserved verbatim. * Wire the one-off auth-config migration + unified-auth e2e scenarios The migration planner (and a shared SQLite runner) live in http-auth; each app routes by plugin_id to the plugins' migrate functions: local runs it at boot after the v1->v2 migration (desktop inherits), selfhost at boot beside its defensive column adds, cloud as an out-of-band script (db:migrate-auth:prod) run before deploying canonical-only code. e2e: scenarios move to canonical wire shapes and gain the extreme case — one MCP source declaring [oauth][bearer header + team-id query (two inputs)][bearer header][query token], one connection per method, every invocation made through the real MCP execute surface and asserted against what a recording MCP server received. The selfhost browser suite gains the 2-input connect modal scenario (one field per input, both rendered together on the wire). * GraphQL: adopt the shared placements auth model GraphqlAuthMethod becomes none | apikey(placements) | oauth2(header?, prefix?) — one method can mix header and query placements, each rendered from its own credential input. The local renderer is deleted: apikey renders through the shared renderer, oauth2 through oauthBearerPlacement (custom header/prefix overrides preserved). Introspection, discovery, and invoke run on credential.values; a missing input fails explicitly, naming the variable. Pre-canonical stored shapes (retired {kind:'apiKey', in, name} entries, openapi-shaped v1-migration templates, the singular auth field — incl. the v1 output that wrote BOTH auth:{oauth2} and an apiKey array) are rewritten by the one-off migrate-config module. The react codec collapses onto shared-auth-method-codec, deleting the one-template-per-placement workaround. Wire tests pin the extreme case (bearer header + team-id query on one method) against a recording GraphQL server, on both introspection and invocation. * Sweep remaining consumers onto the canonical auth shapes Examples (docs quickstart + regenerated snippet, promise-sdk, all-plugins), the kernel dynamic-worker integration test, and the local/cloud auth-tool-failure suites move off the retired variable()-templated apiKey shape. Boundary lint suppressions for the migration runner/script; changelog stub for the new http-auth package. * e2e: make the sandbox tool-result assertion shape-agnostic * Changeset + repo notes for the unified auth model * Fold http-auth into @executor-js/sdk as the /http-auth subpath Carrier-agnosticism is a composition property, not a location one: core never imports the module, so it doesn't need its own package. The vocabulary ships as @executor-js/sdk/http-auth (browser-safe subpath, own tsup entry); the standalone package, its workspace entry, deps, and fixed-group/changeset references are gone. * Accept request-shaped auth authoring on every plugin Auth inputs take two dialects, normalized to the one stored model: the canonical placements shape, and the request-shaped template that reads like the request it produces — { type: "apiKey", headers: { Authorization: ["Bearer ", variable("token")] }, queryParams: { team_id: [variable("team_id")] } } variable() is exported from each plugin. Authoring is strict where the migration is tolerant: a template value renders at most ONE variable, as the FINAL part — exotic interleavings are rejected at decode with a pointed message instead of silently flattened (only the one-off migration flattens, for data that already exists). The dialect is accepted by addServer/addIntegration/addSpec/configure on all three plugins (typed clients included — the docs quickstart and the auth-tool-failure suites author this way again), expanded before merge so stored configs stay canonical. Wire tests pin that an authored template renders identically to its canonical twin. * One apikey authoring dialect: request-shaped in, placements out Auth INPUTS accept exactly one apikey shape — the request-shaped template (headers/queryParams records with variable() slots) — expanded to canonical placements at the boundary. Stored configs and the catalog keep reading as placements; apiKeyAuthTemplateFromMethod serializes them back, so every read-modify-write surface (edit screen replace, custom- method remove, add flows) round-trips through one serializer instead of the wire accepting two dialects. Published schemas now teach a single authoring shape; canonical placement inputs are rejected with a schema error pointing at the dialect. * One discriminator: OpenAPI oauth templates re-key to kind:"oauth2" Every auth method is now kind-keyed — the core OAuthAuthentication shape drops its legacy type:"oauth" spelling, so the Authentication union narrows on .kind alone and the '"kind" in template' guards are gone. The one-off config migration rewrites stored type:"oauth" entries (slug and endpoints verbatim); oauth INPUTS are wire-typed (plain slug) and branded in the normalize pass, letting the HTTP handlers pass payloads through untouched. * Migration script: TLS for direct connections + post-migration decode audit The script connects to PlanetScale directly (no Hyperdrive), which requires TLS. The plan now also audits the SIMULATED final state of every protocol-plugin row — rewritten or untouched — against the new runtime's canonical decoders and refuses to write if any row would read as 'no usable config' after deploy. Verified against prod: 343 rows, 302 rewrite, audit clean.

Rhys Sullivan committed 5 months ago

Commit c92bbda1 parent 7212406122 files · +4629 −5854Browse files

Showing the changes to apps/cloud/scripts (1 of 122 files). Show all

Unified · Split

apps/cloud/scripts/migrate-auth-configs.ts+101−0
@@ -0,0 +1,101 @@
1/* oxlint-disable executor/no-try-catch-or-throw, executor/no-json-parse -- boundary: out-of-band migration script over a raw postgres connection */
2// ---------------------------------------------------------------------------
3// One-off data migration: rewrite pre-canonical integration auth configs
4// into the shared placements model. Run OUT-OF-BAND against the database
5// BEFORE deploying code that only decodes the canonical shape (the same
6// rule as schema migrations — Workers never migrate at request time):
7//
8// bun run db:migrate-auth:prod # op run --env-file=.env.production
9// bun run db:migrate-auth:dev # against the local PGlite dev db
10//
11// Idempotent — canonical rows plan zero updates, so re-running is safe.
12// Pass --dry-run to print the plan without writing.
13// ---------------------------------------------------------------------------
14
15import postgres from "postgres";
16import { Option } from "effect";
17import { planAuthConfigMigration, type AuthConfigMigrationRow } from "@executor-js/sdk/http-auth";
18import {
19 decodeGraphqlIntegrationConfigOption,
20 migrateGraphqlAuthConfig,
21} from "@executor-js/plugin-graphql";
22import { migrateMcpAuthConfig, parseMcpIntegrationConfig } from "@executor-js/plugin-mcp";
23import {
24 decodeOpenApiIntegrationConfig,
25 migrateOpenApiAuthConfig,
26} from "@executor-js/plugin-openapi";
27
28const transforms = {
29 mcp: migrateMcpAuthConfig,
30 graphql: migrateGraphqlAuthConfig,
31 openapi: migrateOpenApiAuthConfig,
32};
33
34// Whether a config decodes under the NEW runtime (the canonical shapes).
35// Used by the post-migration audit: every row the new code will read must
36// decode, whether the migration rewrote it or left it untouched.
37const decodesCanonically: Record<string, (config: unknown) => boolean> = {
38 mcp: (config) => parseMcpIntegrationConfig(config) !== null,
39 graphql: (config) => Option.isSome(decodeGraphqlIntegrationConfigOption(config)),
40 openapi: (config) => decodeOpenApiIntegrationConfig(config) !== null,
41};
42
43const connectionString = process.env.DATABASE_URL;
44if (!connectionString) {
45 console.error("DATABASE_URL is not set");
46 process.exit(1);
47}
48const dryRun = process.argv.includes("--dry-run");
49
50// Direct (non-Hyperdrive) connection — PlanetScale requires TLS.
51const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" });
52
53try {
54 const rows = await sql<{ row_id: string; plugin_id: string; config: unknown }[]>`
55 SELECT row_id, plugin_id, config FROM integration
56 `;
57
58 const inputs: AuthConfigMigrationRow[] = rows.map((row) => ({
59 rowId: row.row_id,
60 pluginId: row.plugin_id,
61 // postgres-js parses json columns; tolerate text just in case.
62 config: typeof row.config === "string" ? JSON.parse(row.config) : row.config,
63 }));
64
65 const updates = planAuthConfigMigration(inputs, transforms);
66 console.log(`${rows.length} integration row(s), ${updates.length} to rewrite`);
67
68 // Post-migration audit: simulate the final state of every row this plugin
69 // set owns and check it decodes under the new runtime. A row that neither
70 // rewrites nor decodes would read as "no usable config" after deploy —
71 // surface those BEFORE writing anything.
72 const planned = new Map(updates.map((update) => [update.rowId, update.config]));
73 const undecodable = inputs.filter((row) => {
74 const decodes = decodesCanonically[row.pluginId];
75 if (!decodes) return false; // not a protocol-plugin row (no auth config)
76 return !decodes(planned.get(row.rowId) ?? row.config);
77 });
78 if (undecodable.length > 0) {
79 console.error(`${undecodable.length} row(s) would NOT decode after migration:`);
80 for (const row of undecodable) console.error(` ${row.pluginId} ${row.rowId}`);
81 process.exit(1);
82 }
83 console.log("audit: every row decodes canonically after migration");
84
85 if (dryRun) {
86 for (const update of updates) console.log(` would rewrite ${update.rowId}`);
87 } else if (updates.length > 0) {
88 await sql.begin(async (tx) => {
89 for (const update of updates) {
90 await tx`
91 UPDATE integration
92 SET config = ${tx.json(update.config as never)}
93 WHERE row_id = ${update.rowId}
94 `;
95 }
96 });
97 console.log(`rewrote ${updates.length} row(s)`);
98 }
99} finally {
100 await sql.end();
101}