* 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.
Showing the changes to apps/cloud/scripts (1 of 122 files). Show all
Unified · Split
| @@ -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 | |||
| 15 | import postgres from "postgres"; | ||
| 16 | import { Option } from "effect"; | ||
| 17 | import { planAuthConfigMigration, type AuthConfigMigrationRow } from "@executor-js/sdk/http-auth"; | ||
| 18 | import { | ||
| 19 | decodeGraphqlIntegrationConfigOption, | ||
| 20 | migrateGraphqlAuthConfig, | ||
| 21 | } from "@executor-js/plugin-graphql"; | ||
| 22 | import { migrateMcpAuthConfig, parseMcpIntegrationConfig } from "@executor-js/plugin-mcp"; | ||
| 23 | import { | ||
| 24 | decodeOpenApiIntegrationConfig, | ||
| 25 | migrateOpenApiAuthConfig, | ||
| 26 | } from "@executor-js/plugin-openapi"; | ||
| 27 | |||
| 28 | const 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. | ||
| 37 | const 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 | |||
| 43 | const connectionString = process.env.DATABASE_URL; | ||
| 44 | if (!connectionString) { | ||
| 45 | console.error("DATABASE_URL is not set"); | ||
| 46 | process.exit(1); | ||
| 47 | } | ||
| 48 | const dryRun = process.argv.includes("--dry-run"); | ||
| 49 | |||
| 50 | // Direct (non-Hyperdrive) connection — PlanetScale requires TLS. | ||
| 51 | const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" }); | ||
| 52 | |||
| 53 | try { | ||
| 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 | } | ||