History

Fix no-auth connection handling and v1.5 migration planner gaps (#924)

* Allow no-auth connections and surface create rejections as typed 400s A no-auth (template "none") connection legitimately binds zero credential inputs; an empty item_ids map is its canonical shape. Exempt it from the empty-binding create guard and from the produce-time tool wipe. Connection-create validation failures (empty binding on a credentialed template, mixed origins, no user subject) now fail with a typed InvalidConnectionInputError mapped to HTTP 400 carrying the reason, instead of collapsing into an opaque 500. * Derive migrated OAuth authorize URLs instead of bare issuer origins The v1 provider state for some providers (Microsoft) stored only a bare issuer origin plus the token endpoint; v1 discovered the authorize endpoint at runtime. The migration's fallback wrote the bare origin as authorization_url, minting clients whose sign-in completes but never redirects back. When the grant has a browser leg and the token endpoint is a same-origin .../token URL, derive the .../authorize sibling. Client-credentials clients keep their (correct) empty URL. * Fix v1->v2 migration planner gaps - Plan the canonical workspace none-template connection for no-auth sources with no credential bindings; without it the migrated integration produces no tools (v2 tools are per-connection). - Skip secret-binding groups whose bindings reference no secrets instead of planning a credentialed connection with empty item_ids. - Warn on binding groups with nothing migratable instead of dropping them silently, and on orphan secrets with unparseable scopes. - Key text-binding item ids by source id so two sources binding the same slot in one scope cannot collide on one vault item. - Make authorization-server metadata resolution best-effort: a dead or unreachable metadata URL no longer aborts the whole local migration.

Rhys Sullivan committed 5 months ago

Commit 121a84c1 parent fc4416a10 files · +404 −50Browse files

Unified · Split

10 files
  1. packages/core/api/src/connections/api.ts +10 −1
  2. packages/core/sdk/src/connections.test.ts +46 −10
  3. packages/core/sdk/src/errors.ts +10 −0
  4. packages/core/sdk/src/executor.ts +41 −24
  5. packages/core/sdk/src/ids.ts +5 −0
  6. packages/core/sdk/src/migration-oauth-metadata.ts +13 −5
  7. packages/core/sdk/src/migration-spec.test.ts +173 −0
  8. packages/core/sdk/src/migration-spec.ts +100 −9
  9. packages/core/sdk/src/plugin.ts +5 −1
  10. packages/core/sdk/src/shared.ts +1 −0
packages/core/api/src/connections/api.ts+10−1
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
19 IntegrationNotFoundError,19 IntegrationNotFoundError,
20 IntegrationSlug,20 IntegrationSlug,
21 InternalError,21 InternalError,
22 InvalidConnectionInputError,
22 OAuthClientSlug,23 OAuthClientSlug,
23 Owner,24 Owner,
24 ProviderItemId,25 ProviderItemId,
@@ -121,6 +122,9 @@ const IntegrationNotFound = IntegrationNotFoundError.annotate({ · 96 unmodified lines
121const CredentialProviderNotRegistered = CredentialProviderNotRegisteredError.annotate({122const CredentialProviderNotRegistered = CredentialProviderNotRegisteredError.annotate({
122 httpApiStatus: 409,123 httpApiStatus: 409,
123});124});
125const InvalidConnectionInput = InvalidConnectionInputError.annotate({
126 httpApiStatus: 400,
127});
124 128
125// ---------------------------------------------------------------------------129// ---------------------------------------------------------------------------
126// Group130// Group
@@ -138,7 +142,12 @@ export const ConnectionsApi = HttpApiGroup.make("connections") · 11 unmodified lines
138 HttpApiEndpoint.post("create", "/connections", {142 HttpApiEndpoint.post("create", "/connections", {
139 payload: CreateConnectionPayload,143 payload: CreateConnectionPayload,
140 success: ConnectionResponse,144 success: ConnectionResponse,
141 error: [InternalError, IntegrationNotFound, CredentialProviderNotRegistered],145 error: [
146 InternalError,
147 IntegrationNotFound,
148 CredentialProviderNotRegistered,
149 InvalidConnectionInput,
150 ],
142 }),151 }),
143 )152 )
144 .add(153 .add(
packages/core/sdk/src/connections.test.ts+46−10
@@ -162,15 +162,17 @@ describe("connections.create", () => { · 161 unmodified lines
162 }),162 }),
163 );163 );
164 164
165 // A connection is "born wired": it must reference at least one credential165 // A credentialed connection is "born wired": it must reference at least one
166 // input. An empty binding (an empty `values`/`inputs` map) produces a166 // credential input. An empty binding (an empty `values`/`inputs` map) produces
167 // credential with no credential — it persists, produces a full tool catalog,167 // a credential with no credential — it persists, produces a full tool catalog,
168 // and then fails every invocation with `connection_value_missing`. These168 // and then fails every invocation with `connection_value_missing`. These cases
169 // cases must be rejected at create. (An empty-STRING value is allowed — no-auth169 // must be rejected at create with a typed `InvalidConnectionInputError` (the
170 // integrations bind one deliberately; and an external `from` that resolves to170 // HTTP edge answers 400 with the reason, not an opaque 500). The exception is
171 // null is a supported case — both covered by their own tests — so neither is171 // the no-auth template ("none"), where zero inputs and an empty `item_ids`
172 // rejected here.)172 // map are the canonical shape — covered below. (An empty-STRING value is also
173 it.effect("rejects an empty `values` map and persists nothing", () =>173 // allowed, and an external `from` that resolves to null is a supported case —
174 // both covered by their own tests.)
175 it.effect("rejects an empty `values` map on a credentialed template and persists nothing", () =>
174 Effect.gen(function* () {176 Effect.gen(function* () {
175 const executor = yield* setup();177 const executor = yield* setup();
176 const result = yield* Effect.result(178 const result = yield* Effect.result(
@@ -183,13 +185,15 @@ describe("connections.create", () => { · 6 unmodified lines
183 }),185 }),
184 );186 );
185 expect(Result.isFailure(result)).toBe(true);187 expect(Result.isFailure(result)).toBe(true);
188 if (!Result.isFailure(result)) return;
189 expect(Predicate.isTagged("InvalidConnectionInputError")(result.failure)).toBe(true);
186 // No connection row and — critically — no tools were produced.190 // No connection row and — critically — no tools were produced.
187 expect(yield* executor.connections.list()).toEqual([]);191 expect(yield* executor.connections.list()).toEqual([]);
188 expect(yield* executor.tools.list()).toEqual([]);192 expect(yield* executor.tools.list()).toEqual([]);
189 }),193 }),
190 );194 );
191 195
192 it.effect("rejects an empty `inputs` map", () =>196 it.effect("rejects an empty `inputs` map on a credentialed template", () =>
193 Effect.gen(function* () {197 Effect.gen(function* () {
194 const executor = yield* setup();198 const executor = yield* setup();
195 const result = yield* Effect.result(199 const result = yield* Effect.result(
@@ -202,10 +206,42 @@ describe("connections.create", () => { · 6 unmodified lines
202 }),206 }),
203 );207 );
204 expect(Result.isFailure(result)).toBe(true);208 expect(Result.isFailure(result)).toBe(true);
209 if (!Result.isFailure(result)) return;
210 expect(Predicate.isTagged("InvalidConnectionInputError")(result.failure)).toBe(true);
205 expect(yield* executor.connections.list()).toEqual([]);211 expect(yield* executor.connections.list()).toEqual([]);
206 }),212 }),
207 );213 );
208 214
215 // The no-auth template: public servers need no credential. The UI submits
216 // `values: {}` for them and the persisted row carries an empty `item_ids`
217 // map — that is the canonical shape (every migrated no-auth connection in
218 // prod has it), so it must create cleanly and keep its tools on refresh.
219 it.effect('creates a no-auth (`template: "none"`) connection from an empty `values` map', () =>
220 Effect.gen(function* () {
221 const executor = yield* setup();
222 const connection = yield* executor.connections.create({
223 owner: "org",
224 name: ConnectionName.make("public"),
225 integration: INTEG,
226 template: AuthTemplateSlug.make("none"),
227 values: {},
228 });
229 expect(String(connection.address)).toBe("tools.vercel.org.public");
230
231 const tools = yield* executor.tools.list();
232 expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]);
233
234 // Refresh must NOT treat the empty binding as invalid and wipe the tools.
235 const refreshed = yield* executor.connections.refresh({
236 owner: "org",
237 integration: INTEG,
238 name: ConnectionName.make("public"),
239 });
240 expect(refreshed.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]);
241 expect((yield* executor.tools.list()).length).toBe(2);
242 }),
243 );
244
209 it.effect("allows an empty-string value (no-auth integrations bind one)", () =>245 it.effect("allows an empty-string value (no-auth integrations bind one)", () =>
210 Effect.gen(function* () {246 Effect.gen(function* () {
211 const executor = yield* setup();247 const executor = yield* setup();
packages/core/sdk/src/errors.ts+10−0
@@ -91,6 +91,16 @@ export class ConnectionNotFoundError extends Schema.TaggedErrorClass<ConnectionN · 90 unmodified lines
91 },91 },
92) {}92) {}
93 93
94/** A connection create request was rejected before anything was written: the
95 * input is structurally invalid (no credential inputs for a credentialed
96 * template, mixed pasted/external origins, …) or targets owner `user` in a
97 * context that has no user subject. The message says which — it is safe to
98 * show to the caller. */
99export class InvalidConnectionInputError extends Schema.TaggedErrorClass<InvalidConnectionInputError>()(
100 "InvalidConnectionInputError",
101 { message: Schema.String },
102) {}
103
94/** A connection references a credential provider key that isn't registered on104/** A connection references a credential provider key that isn't registered on
95 * the executor. */105 * the executor. */
96export class CredentialProviderNotRegisteredError extends Schema.TaggedErrorClass<CredentialProviderNotRegisteredError>()(106export class CredentialProviderNotRegisteredError extends Schema.TaggedErrorClass<CredentialProviderNotRegisteredError>()(
packages/core/sdk/src/executor.ts+41−24
@@ -51,6 +51,7 @@ import { · 50 unmodified lines
51 CredentialProviderNotRegisteredError,51 CredentialProviderNotRegisteredError,
52 CredentialResolutionError,52 CredentialResolutionError,
53 IntegrationNotFoundError,53 IntegrationNotFoundError,
54 InvalidConnectionInputError,
54 IntegrationRemovalNotAllowedError,55 IntegrationRemovalNotAllowedError,
55 NoHandlerError,56 NoHandlerError,
56 PluginNotLoadedError,57 PluginNotLoadedError,
@@ -64,6 +65,7 @@ import { · 7 unmodified lines
64 ConnectionAddress,65 ConnectionAddress,
65 ConnectionName,66 ConnectionName,
66 IntegrationSlug,67 IntegrationSlug,
68 NO_AUTH_TEMPLATE,
67 OAuthClientSlug,69 OAuthClientSlug,
68 Owner,70 Owner,
69 PolicyId,71 PolicyId,
@@ -256,7 +258,10 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 186 unmodified lines
256 input: CreateConnectionInput,258 input: CreateConnectionInput,
257 ) => Effect.Effect<259 ) => Effect.Effect<
258 Connection,260 Connection,
259 IntegrationNotFoundError | CredentialProviderNotRegisteredError | StorageFailure261 | IntegrationNotFoundError
262 | CredentialProviderNotRegisteredError
263 | InvalidConnectionInputError
264 | StorageFailure
260 >;265 >;
261 readonly list: (filter?: {266 readonly list: (filter?: {
262 readonly integration?: IntegrationSlug;267 readonly integration?: IntegrationSlug;
@@ -1764,15 +1769,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1501 unmodified lines
1764 );1769 );
1765 1770
1766 // Defense in depth (and cleanup for rows created before the create-time1771 // Defense in depth (and cleanup for rows created before the create-time
1767 // guard, or emptied by an external edit): a non-OAuth connection with no1772 // guard, or emptied by an external edit): a credentialed non-OAuth
1768 // bound credential inputs can never resolve a value, so never advertise1773 // connection with no bound credential inputs can never resolve a value,
1769 // tools for it — every call would fail with `connection_value_missing`.1774 // so never advertise tools for it — every call would fail with
1770 // OAuth connections resolve via refresh and carry their token outside1775 // `connection_value_missing`. OAuth connections resolve via refresh and
1771 // `item_ids`, so they are exempt.1776 // carry their token outside `item_ids`; no-auth (`"none"` template)
1777 // connections legitimately bind nothing (an empty `item_ids` is their
1778 // canonical shape) — both are exempt.
1772 const existingRow = yield* findConnectionRow(ref);1779 const existingRow = yield* findConnectionRow(ref);
1773 if (1780 if (
1774 existingRow &&1781 existingRow &&
1775 existingRow.oauth_client == null &&1782 existingRow.oauth_client == null &&
1783 existingRow.template !== String(NO_AUTH_TEMPLATE) &&
1776 Object.keys(connectionItemIds(existingRow)).length === 01784 Object.keys(connectionItemIds(existingRow)).length === 0
1777 ) {1785 ) {
1778 yield* transaction(1786 yield* transaction(
@@ -1876,11 +1884,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 97 unmodified lines
1876 input: CreateConnectionInput,1884 input: CreateConnectionInput,
1877 ): Effect.Effect<1885 ): Effect.Effect<
1878 Connection,1886 Connection,
1879 IntegrationNotFoundError | CredentialProviderNotRegisteredError | StorageFailure1887 | IntegrationNotFoundError
1888 | CredentialProviderNotRegisteredError
1889 | InvalidConnectionInputError
1890 | StorageFailure
1880 > =>1891 > =>
1881 Effect.gen(function* () {1892 Effect.gen(function* () {
1882 const name = connectionIdentifier(String(input.name));1893 const name = connectionIdentifier(String(input.name));
1883 yield* requireUserSubject(input.owner);1894 // Typed (not StorageError) so the HTTP edge can answer 400 with the
1895 // reason instead of an opaque 500 — callers can act on it.
1896 if (input.owner === "user" && subject == null) {
1897 return yield* new InvalidConnectionInputError({
1898 message:
1899 'Cannot create a personal connection: this context has no user subject. Create it with owner "org", or connect as a signed-in user.',
1900 });
1901 }
1884 const integrationRow = yield* findIntegrationRow(input.integration);1902 const integrationRow = yield* findIntegrationRow(input.integration);
1885 if (!integrationRow) {1903 if (!integrationRow) {
1886 return yield* new IntegrationNotFoundError({1904 return yield* new IntegrationNotFoundError({
@@ -1896,27 +1914,27 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 9 unmodified lines
1896 const inputs = normalizeConnectionInputs(input);1914 const inputs = normalizeConnectionInputs(input);
1897 const pasted = inputs.filter((i) => "value" in i.origin);1915 const pasted = inputs.filter((i) => "value" in i.origin);
1898 const external = inputs.filter((i) => "from" in i.origin);1916 const external = inputs.filter((i) => "from" in i.origin);
1899 // A connection is born wired: it must reference at least one credential1917 // A credentialed connection is born wired: it must reference at least
1900 // input. An empty binding (no inputs at all — e.g. an empty `values`/1918 // one credential input. An empty binding (no inputs at all — e.g. an
1901 // `inputs` map) is a credential with no credential: it would persist,1919 // empty `values`/`inputs` map) is a credential with no credential: it
1902 // produce a full tool catalog, and then fail every invocation with1920 // would persist, produce a full tool catalog, and then fail every
1903 // `connection_value_missing`. Reject it here. (An empty-string value is1921 // invocation with `connection_value_missing`. Reject it here — EXCEPT
1904 // NOT rejected — no-auth integrations like MCP deliberately bind one, and1922 // for the no-auth template ("none"), where zero inputs and an empty
1905 // it yields a non-empty `item_ids`. OAuth connections are minted via1923 // `item_ids` map are the canonical shape (public MCP servers; the UI
1906 // `mintOAuthConnection`, not this path; an external `from` reference may1924 // submits `values: {}` for them). OAuth connections are minted via
1907 // resolve to null and is surfaced at invoke time, not here.)1925 // `mintOAuthConnection`, not this path; an external `from` reference
1908 if (inputs.length === 0) {1926 // may resolve to null and is surfaced at invoke time, not here.
1909 return yield* new StorageError({1927 const isNoAuth = String(input.template) === String(NO_AUTH_TEMPLATE);
1928 if (inputs.length === 0 && !isNoAuth) {
1929 return yield* new InvalidConnectionInputError({
1910 message: "A connection must supply at least one credential input.",1930 message: "A connection must supply at least one credential input.",
1911 cause: undefined,
1912 });1931 });
1913 }1932 }
1914 let providerKey: string;1933 let providerKey: string;
1915 const itemIds: Record<string, string> = {};1934 const itemIds: Record<string, string> = {};
1916 if (external.length > 0 && pasted.length > 0) {1935 if (external.length > 0 && pasted.length > 0) {
1917 return yield* new StorageError({1936 return yield* new InvalidConnectionInputError({
1918 message: "A connection cannot mix pasted and external-provider inputs.",1937 message: "A connection cannot mix pasted and external-provider inputs.",
1919 cause: undefined,
1920 });1938 });
1921 }1939 }
1922 if (external.length > 0) {1940 if (external.length > 0) {
@@ -1924,9 +1942,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
1924 external.map((i) => ("from" in i.origin ? String(i.origin.from.provider) : "")),1942 external.map((i) => ("from" in i.origin ? String(i.origin.from.provider) : "")),
1925 );1943 );
1926 if (providers.size > 1) {1944 if (providers.size > 1) {
1927 return yield* new StorageError({1945 return yield* new InvalidConnectionInputError({
1928 message: "A connection's inputs must all use the same external provider.",1946 message: "A connection's inputs must all use the same external provider.",
1929 cause: undefined,
1930 });1947 });
1931 }1948 }
1932 const [only] = [...providers];1949 const [only] = [...providers];
packages/core/sdk/src/ids.ts+5−0
@@ -12,6 +12,11 @@ export type IntegrationSlug = typeof IntegrationSlug.Type; · 11 unmodified lines
12export const AuthTemplateSlug = Schema.String.pipe(Schema.brand("AuthTemplateSlug"));12export const AuthTemplateSlug = Schema.String.pipe(Schema.brand("AuthTemplateSlug"));
13export type AuthTemplateSlug = typeof AuthTemplateSlug.Type;13export type AuthTemplateSlug = typeof AuthTemplateSlug.Type;
14 14
15/** The sentinel template for integrations that require no credential (e.g. a
16 * public MCP server). Connections on it legitimately bind zero inputs — an
17 * empty `item_ids` map is their canonical persisted shape. */
18export const NO_AUTH_TEMPLATE = AuthTemplateSlug.make("none");
19
15/** A connection's name — the `<connection>` segment of an address, scoped under20/** A connection's name — the `<connection>` segment of an address, scoped under
16 * its integration + owner (so the same name can exist on two integrations). */21 * its integration + owner (so the same name can exist on two integrations). */
17export const ConnectionName = Schema.String.pipe(Schema.brand("ConnectionName"));22export const ConnectionName = Schema.String.pipe(Schema.brand("ConnectionName"));
packages/core/sdk/src/migration-oauth-metadata.ts+13−5
@@ -162,11 +162,19 @@ const resolveMigrationOAuthAuthorizationUrl = async ( · 161 unmodified lines
162): Promise<string | null> => {162): Promise<string | null> => {
163 const metadataUrl = client.authorizationServerMetadataUrl?.trim();163 const metadataUrl = client.authorizationServerMetadataUrl?.trim();
164 if (metadataUrl) {164 if (metadataUrl) {
165 return await fetchOAuthAuthorizationEndpoint(165 // Best-effort like the resource/issuer discovery below: a dead, invalid,
166 validateMigrationOAuthUrl(metadataUrl, "authorizationServerMetadataUrl"),166 // or unreachable metadata endpoint (offline machine, archived server)
167 fetchImpl,167 // must not abort the whole migration — fall through to discovery.
168 timeoutMs,168 try {
169 );169 const endpoint = await fetchOAuthAuthorizationEndpoint(
170 validateMigrationOAuthUrl(metadataUrl, "authorizationServerMetadataUrl"),
171 fetchImpl,
172 timeoutMs,
173 );
174 if (endpoint) return endpoint;
175 } catch {
176 // fall through to issuer/resource discovery
177 }
170 }178 }
171 179
172 if (client.grant !== "authorization_code") return null;180 if (client.grant !== "authorization_code") return null;
packages/core/sdk/src/migration-spec.test.ts+173−0
@@ -1117,6 +1117,179 @@ describe("planMigration (the weave)", () => { · 1116 unmodified lines
1117 expect(plan.report.policies).toEqual({ ok: 2, static: 0, deadInert: 0 });1117 expect(plan.report.policies).toEqual({ ok: 2, static: 0, deadInert: 0 });
1118 });1118 });
1119 1119
1120 // The prod Microsoft shape: v1 stored only a bare issuer origin plus the
1121 // full token endpoint (no discrete authorize endpoint — v1 discovered it at
1122 // runtime). A bare origin is a dead authorize URL (sign-in completes but
1123 // never redirects back), so the planner must derive the same-origin
1124 // `…/authorize` sibling of the token endpoint instead.
1125 it("derives the authorize endpoint from the token endpoint when v1 only stored a bare issuer", () => {
1126 const input: MigrationInput = {
1127 nowMs: now,
1128 sources: [
1129 { scopeId: "org_X", id: "microsoft_graph", pluginId: "openapi", name: "Microsoft Graph" },
1130 ],
1131 migratedConfigs: new Map([
1132 [
1133 "org_X microsoft_graph",
1134 cfg({
1135 slotToTemplateSlug: { "oauth2:azureaddelegated:connection": "azureAdDelegated" },
1136 slotToVariable: { "oauth2:azureaddelegated:connection": "token" },
1137 }),
1138 ],
1139 ]),
1140 connections: [
1141 {
1142 id: "openapi-oauth-microsoft-graph",
1143 scopeId: "org_X",
1144 provider: "oauth2",
1145 identityLabel: null,
1146 accessTokenSecretId: "ms-access",
1147 refreshTokenSecretId: null,
1148 expiresAt: null,
1149 providerState: {
1150 kind: "authorization-code",
1151 clientId: "cid-ms",
1152 issuerUrl: "https://login.microsoftonline.com",
1153 tokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
1154 scopes: ["User.Read"],
1155 },
1156 },
1157 ],
1158 bindings: [
1159 {
1160 scopeId: "org_X",
1161 sourceId: "microsoft_graph",
1162 slotKey: "oauth2:azureaddelegated:connection",
1163 kind: "connection",
1164 secretId: null,
1165 connectionId: "openapi-oauth-microsoft-graph",
1166 textValue: null,
1167 },
1168 ],
1169 secrets: [
1170 {
1171 id: "ms-access",
1172 scopeId: "org_X",
1173 name: "",
1174 provider: "workos-vault",
1175 ownedByConnectionId: "openapi-oauth-microsoft-graph",
1176 },
1177 ],
1178 policies: [],
1179 toolSourceIds: [],
1180 };
1181
1182 const plan = planMigration(input);
1183
1184 expect(plan.oauthClients).toHaveLength(1);
1185 expect(plan.oauthClients[0]?.authorizationUrl).toBe(
1186 "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
1187 );
1188 expect(plan.oauthClients[0]?.tokenUrl).toBe(
1189 "https://login.microsoftonline.com/common/oauth2/v2.0/token",
1190 );
1191 });
1192
1193 // v2 produces tools per connection, so a no-auth source (no credential
1194 // bindings at all) must still get its canonical connection — template
1195 // "none", empty item_ids — or the migrated integration is dead.
1196 it("plans a workspace none-template connection for binding-less no-auth sources", () => {
1197 const input: MigrationInput = {
1198 nowMs: now,
1199 sources: [{ scopeId: "org_X", id: "context7", pluginId: "mcp", name: "Context7" }],
1200 migratedConfigs: new Map([
1201 ["org_X context7", cfg({ config: { transport: "remote", auth: { kind: "none" } } })],
1202 ]),
1203 connections: [],
1204 bindings: [],
1205 secrets: [],
1206 policies: [],
1207 toolSourceIds: [],
1208 };
1209
1210 const plan = planMigration(input);
1211
1212 expect(plan.connections).toHaveLength(1);
1213 const conn = plan.connections[0];
1214 expect(conn?.row.integration).toBe("context7");
1215 expect(conn?.row.name).toBe("workspace");
1216 expect(conn?.row.template).toBe("none");
1217 expect(conn?.row.owner).toBe("org");
1218 expect(conn?.itemIds).toEqual({});
1219 // An OAuth-protected source must NOT get one.
1220 const oauthInput: MigrationInput = {
1221 ...input,
1222 migratedConfigs: new Map([
1223 ["org_X context7", cfg({ config: { transport: "remote", auth: { kind: "oauth2" } } })],
1224 ]),
1225 };
1226 expect(planMigration(oauthInput).connections).toHaveLength(0);
1227 });
1228
1229 it("skips secret-binding groups whose bindings reference no secrets, with a warning", () => {
1230 const input: MigrationInput = {
1231 nowMs: now,
1232 sources: [{ scopeId: "org_X", id: "broken_api", pluginId: "openapi", name: "Broken" }],
1233 migratedConfigs: new Map(),
1234 connections: [],
1235 bindings: [
1236 {
1237 scopeId: "org_X",
1238 sourceId: "broken_api",
1239 slotKey: "header:authorization",
1240 kind: "secret",
1241 secretId: null,
1242 connectionId: null,
1243 textValue: null,
1244 },
1245 ],
1246 secrets: [],
1247 policies: [],
1248 toolSourceIds: [],
1249 };
1250
1251 const plan = planMigration(input);
1252
1253 // No empty-item_ids ghost connection (the runtime would refuse it tools).
1254 expect(plan.connections).toHaveLength(0);
1255 expect(plan.report.warnings.some((w) => w.includes("reference no secrets"))).toBe(true);
1256 });
1257
1258 it("keys text-binding item ids by source so same-slot bindings do not collide", () => {
1259 const binding = (sourceId: string, textValue: string) => ({
1260 scopeId: "org_X",
1261 sourceId,
1262 slotKey: "header:authorization",
1263 kind: "text" as const,
1264 secretId: null,
1265 connectionId: null,
1266 textValue,
1267 });
1268 const input: MigrationInput = {
1269 nowMs: now,
1270 sources: [
1271 { scopeId: "org_X", id: "api_one", pluginId: "mcp", name: "One" },
1272 { scopeId: "org_X", id: "api_two", pluginId: "mcp", name: "Two" },
1273 ],
1274 migratedConfigs: new Map(),
1275 connections: [],
1276 bindings: [binding("api_one", "value-one"), binding("api_two", "value-two")],
1277 secrets: [],
1278 policies: [],
1279 toolSourceIds: [],
1280 };
1281
1282 const plan = planMigration(input);
1283
1284 const ids = plan.connections.map((c) => Object.values(c.itemIds)).flat();
1285 expect(new Set(ids).size).toBe(2);
1286 const ops = plan.secretOps.filter((o) => o.role === "apikey");
1287 expect(ops.map((o) => "fromText" in o && o.fromText).sort()).toEqual([
1288 "value-one",
1289 "value-two",
1290 ]);
1291 });
1292
1120 it("plans a v1 client-credentials OAuth connection with secret-backed client credentials", () => {1293 it("plans a v1 client-credentials OAuth connection with secret-backed client credentials", () => {
1121 const input: MigrationInput = {1294 const input: MigrationInput = {
1122 nowMs: now,1295 nowMs: now,
packages/core/sdk/src/migration-spec.ts+100−9
@@ -1256,12 +1256,41 @@ const nonEmptyString = (value: string | null | undefined): string | null => { · 1255 unmodified lines
1256 return trimmed ? trimmed : null;1256 return trimmed ? trimmed : null;
1257};1257};
1258 1258
1259const authorizationUrlFromProviderState = (ps: V1ProviderState | null): string =>1259/** True when the URL has no meaningful path — a bare origin like
1260 nonEmptyString(ps?.authorizationEndpoint) ??1260 * `https://login.microsoftonline.com`. A bare origin is never a usable
1261 nonEmptyString(ps?.authorizationServerMetadata?.authorization_endpoint) ??1261 * authorize endpoint: redirecting there signs the user in and strands them
1262 nonEmptyString(ps?.authorizationServerUrl) ??1262 * (observed in prod with migrated Microsoft clients). */
1263 nonEmptyString(ps?.issuerUrl) ??1263const isBareOrigin = (url: string): boolean => /^https?:\/\/[^/]+\/?$/.test(url);
1264 "";1264
1265const authorizationUrlFromProviderState = (
1266 ps: V1ProviderState | null,
1267 grant: MigrationGrant,
1268): string => {
1269 const explicit =
1270 nonEmptyString(ps?.authorizationEndpoint) ??
1271 nonEmptyString(ps?.authorizationServerMetadata?.authorization_endpoint);
1272 if (explicit) return explicit;
1273 const fallback =
1274 nonEmptyString(ps?.authorizationServerUrl) ?? nonEmptyString(ps?.issuerUrl) ?? "";
1275 // Client-credentials clients have no browser leg — an empty authorization
1276 // URL is their correct shape; never derive one.
1277 if (grant === "client_credentials") return fallback;
1278 // v1 stored only an issuer/server origin for some providers and discovered
1279 // the authorize endpoint at runtime. v2 stores the endpoint itself, so a
1280 // bare origin would mint a broken client. When the token endpoint is a
1281 // same-origin `…/token` URL, its `…/authorize` sibling is the convention
1282 // (and exactly right for the Microsoft v2.0 endpoints that hit this path);
1283 // prefer that over a guaranteed-dead bare origin.
1284 const token = nonEmptyString(ps?.tokenEndpoint);
1285 if (
1286 (fallback === "" || isBareOrigin(fallback)) &&
1287 token?.endsWith("/token") &&
1288 (fallback === "" || token.startsWith(fallback.replace(/\/$/, "") + "/"))
1289 ) {
1290 return token.replace(/\/token$/, "/authorize");
1291 }
1292 return fallback;
1293};
1265 1294
1266const secretOpDedupeKey = (op: SecretOp): string =>1295const secretOpDedupeKey = (op: SecretOp): string =>
1267 `${op.targetProvider}\0${op.owner.tenant}\0${op.owner.owner}\0${op.owner.subject}\0${op.itemId}`;1296 `${op.targetProvider}\0${op.owner.tenant}\0${op.owner.owner}\0${op.owner.subject}\0${op.itemId}`;
@@ -1458,7 +1487,7 @@ export const planMigration = (input: MigrationInput): MigrationPlan => { · 190 unmodified lines
1458 clientId: ps?.clientId ?? "",1487 clientId: ps?.clientId ?? "",
1459 clientIdSecretRef,1488 clientIdSecretRef,
1460 tokenUrl: ps?.tokenEndpoint ?? "",1489 tokenUrl: ps?.tokenEndpoint ?? "",
1461 authorizationUrl: authorizationUrlFromProviderState(ps),1490 authorizationUrl: authorizationUrlFromProviderState(ps, grant),
1462 authorizationServerMetadataUrl: nonEmptyString(ps?.authorizationServerMetadataUrl),1491 authorizationServerMetadataUrl: nonEmptyString(ps?.authorizationServerMetadataUrl),
1463 grant,1492 grant,
1464 resource:1493 resource:
@@ -1530,6 +1559,15 @@ export const planMigration = (input: MigrationInput): MigrationPlan => { · 65 unmodified lines
1530 template = slotTemplate(b.slotKey);1559 template = slotTemplate(b.slotKey);
1531 consume(secretScopeId, b.secretId);1560 consume(secretScopeId, b.secretId);
1532 }1561 }
1562 // All-null secret ids (malformed v1 rows) would plan a credentialed
1563 // connection with an empty `item_ids` map — a credential with no
1564 // credential that the runtime refuses to produce tools for. Skip loudly.
1565 if (Object.keys(itemIds).length === 0) {
1566 warnings.push(
1567 `Skipped binding group "${sourceId}" in scope "${scopeId}": its secret bindings reference no secrets.`,
1568 );
1569 continue;
1570 }
1533 const provider = targetProvider ?? defaultWritableProvider;1571 const provider = targetProvider ?? defaultWritableProvider;
1534 if (providers.size > 1) {1572 if (providers.size > 1) {
1535 warnings.push(1573 warnings.push(
@@ -1567,7 +1605,11 @@ export const planMigration = (input: MigrationInput): MigrationPlan => { · 31 unmodified lines
1567 let template = API_KEY_TEMPLATE_SLUG;1605 let template = API_KEY_TEMPLATE_SLUG;
1568 for (const b of textBindings) {1606 for (const b of textBindings) {
1569 const variable = slotVar(b.slotKey);1607 const variable = slotVar(b.slotKey);
1570 const itemId = migratedItemId(scopeId, `text:${b.slotKey}`);1608 // The source id is part of the key: two sources in one scope can bind
1609 // the same slot (e.g. `header:authorization`) with different values —
1610 // without it they'd collide on one item id and one would silently
1611 // read the other's secret.
1612 const itemId = migratedItemId(scopeId, `text:${sourceId}:${b.slotKey}`);
1571 secretOps.push({1613 secretOps.push({
1572 itemId,1614 itemId,
1573 role: "apikey",1615 role: "apikey",
@@ -1603,6 +1645,52 @@ export const planMigration = (input: MigrationInput): MigrationPlan => { · 29 unmodified lines
1603 refreshItemId: null,1645 refreshItemId: null,
1604 });1646 });
1605 }1647 }
1648 } else {
1649 // Nothing migratable in the group: a connection binding without a
1650 // connection id, or only OAuth client-credential slots (the app config
1651 // migrates via the bound connection's provider state — an app that was
1652 // configured but never connected has nothing to attach to). Say so
1653 // instead of dropping the group silently.
1654 warnings.push(
1655 `Skipped binding group "${sourceId}" in scope "${scopeId}": no migratable credential binding (its secrets fall to the orphan re-key).`,
1656 );
1657 }
1658 }
1659
1660 // --- No-auth sources (mcp/graphql `auth.kind === "none"`) have no credential
1661 // bindings, but v2 produces tools per CONNECTION — without one the migrated
1662 // integration is dead (no tools, nothing to invoke). Plan the canonical
1663 // no-auth connection: template "none", empty item_ids. (This is the planner
1664 // fix for the gap that required the prod `{}` backfill.)
1665 for (const source of input.sources) {
1666 if (groups.has(sourceKey(source.scopeId, source.id))) continue;
1667 const migrated = input.migratedConfigs.get(sourceKey(source.scopeId, source.id));
1668 const auth = (migrated?.config as { auth?: { kind?: string } } | undefined)?.auth;
1669 if (auth?.kind !== "none") continue;
1670 const row = planConnectionRow({
1671 scopeId: source.scopeId,
1672 integration: source.id,
1673 name: "workspace",
1674 template: "none",
1675 provider: defaultWritableProvider,
1676 identityLabel: null,
1677 grant: "authorization_code",
1678 v1ExpiresAt: null,
1679 oauthScopes: [],
1680 oauthClientSlug: null,
1681 oauthClientOwner: null,
1682 nowMs: input.nowMs,
1683 ownerForScope,
1684 });
1685 if (row) {
1686 connections.push({
1687 credentialScopeId: source.scopeId,
1688 sourceScopeId: source.scopeId,
1689 sourceId: source.id,
1690 row,
1691 itemIds: {},
1692 refreshItemId: null,
1693 });
1606 }1694 }
1607 }1695 }
1608 1696
@@ -1630,7 +1718,10 @@ export const planMigration = (input: MigrationInput): MigrationPlan => { · 21 unmodified lines
1630 for (const s of input.secrets) {1718 for (const s of input.secrets) {
1631 if (consumed.has(`${s.scopeId} ${s.id}`)) continue;1719 if (consumed.has(`${s.scopeId} ${s.id}`)) continue;
1632 const owner = ownerForScope(s.scopeId);1720 const owner = ownerForScope(s.scopeId);
1633 if (!owner) continue; // unparseable scope orphan — skip + (counted via warnings below)1721 if (!owner) {
1722 warnings.push(`Skipped orphan secret "${s.id}": unparseable scope "${s.scopeId}".`);
1723 continue;
1724 }
1634 secretOps.push({1725 secretOps.push({
1635 itemId: migratedItemId(s.scopeId, s.id),1726 itemId: migratedItemId(s.scopeId, s.id),
1636 role: "orphan",1727 role: "orphan",
packages/core/sdk/src/plugin.ts+5−1
@@ -36,6 +36,7 @@ import type { · 35 unmodified lines
36 CredentialProviderNotRegisteredError,36 CredentialProviderNotRegisteredError,
37 IntegrationNotFoundError,37 IntegrationNotFoundError,
38 IntegrationRemovalNotAllowedError,38 IntegrationRemovalNotAllowedError,
39 InvalidConnectionInputError,
39} from "./errors";40} from "./errors";
40import type { OAuthService } from "./oauth-client";41import type { OAuthService } from "./oauth-client";
41import type { CredentialProvider, ProviderEntry } from "./provider";42import type { CredentialProvider, ProviderEntry } from "./provider";
@@ -140,7 +141,10 @@ export interface PluginCtx<TStore = unknown> { · 98 unmodified lines
140 input: CreateConnectionInput,141 input: CreateConnectionInput,
141 ) => Effect.Effect<142 ) => Effect.Effect<
142 Connection,143 Connection,
143 IntegrationNotFoundError | CredentialProviderNotRegisteredError | StorageFailure144 | IntegrationNotFoundError
145 | CredentialProviderNotRegisteredError
146 | InvalidConnectionInputError
147 | StorageFailure
144 >;148 >;
145 readonly list: (filter?: {149 readonly list: (filter?: {
146 readonly integration?: IntegrationSlug;150 readonly integration?: IntegrationSlug;
packages/core/sdk/src/shared.ts+1−0
@@ -57,6 +57,7 @@ export { · 56 unmodified lines
57 IntegrationAlreadyExistsError,57 IntegrationAlreadyExistsError,
58 IntegrationRemovalNotAllowedError,58 IntegrationRemovalNotAllowedError,
59 ConnectionNotFoundError,59 ConnectionNotFoundError,
60 InvalidConnectionInputError,
60 CredentialProviderNotRegisteredError,61 CredentialProviderNotRegisteredError,
61 CredentialResolutionError,62 CredentialResolutionError,
62 type ExecuteError,63 type ExecuteError,

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