Introduces a `Connection` resource on the core SDK (`ctx.connections`) that owns live OAuth access/refresh tokens and the plugin-specific providerState needed to refresh them. Plugins register a `ConnectionProvider` alongside their secret providers; source auth configs become thin pointers (`{kind: "oauth2", connectionId}`) instead of inlining secret/token state. - core/sdk: ConnectionId brand, ConnectionRef, CreateConnectionInput, ConnectionRefreshError, ConnectionRefreshInput, executor.connections (get/list/remove/accessToken), plugin.connectionProviders hook. - core/api: /scopes/:id/connections endpoints + handler group, secrets list filter that hides connection-owned rows. - storage: new `connection` table (drizzle migrations for cloud + local). - plugins (openapi, google-discovery, mcp): migrated onto ctx.connections, widened stored-auth schemas to carry the full OAuth config so re-sign-in is reproducible without the prior Connection row existing. - react: `/connections` page with removal; signIn slot on SourcePlugin; per-plugin SignInButton mounted on the source detail top bar with dynamic "Sign in" / "Reconnect" copy driven by `connectionsAtom`. - openapi store: tolerant oauth2 decode at the row boundary so legacy rows written before the schema widening surface without OAuth attached instead of crashing the source list. - oauth2 plugin: removes the dead standalone refresh module now that refresh lives behind ctx.connections.
Unified · Split
| @@ -0,0 +1,20 @@ | ||
| 1 | +CREATE TABLE "connection" ( | |
| 2 | + "id" text NOT NULL, | |
| 3 | + "scope_id" text NOT NULL, | |
| 4 | + "provider" text NOT NULL, | |
| 5 | + "kind" text NOT NULL, | |
| 6 | + "identity_label" text, | |
| 7 | + "access_token_secret_id" text NOT NULL, | |
| 8 | + "refresh_token_secret_id" text, | |
| 9 | + "expires_at" bigint, | |
| 10 | + "scope" text, | |
| 11 | + "provider_state" jsonb, | |
| 12 | + "created_at" timestamp NOT NULL, | |
| 13 | + "updated_at" timestamp NOT NULL, | |
| 14 | + CONSTRAINT "connection_scope_id_id_pk" PRIMARY KEY("scope_id","id") | |
| 15 | +); | |
| 16 | +--> statement-breakpoint | |
| 17 | +ALTER TABLE "secret" ADD COLUMN "owned_by_connection_id" text;--> statement-breakpoint | |
| 18 | +CREATE INDEX "connection_scope_id_idx" ON "connection" USING btree ("scope_id");--> statement-breakpoint | |
| 19 | +CREATE INDEX "connection_provider_idx" ON "connection" USING btree ("provider");--> statement-breakpoint | |
| 20 | +CREATE INDEX "secret_owned_by_connection_id_idx" ON "secret" USING btree ("owned_by_connection_id"); | |
| @@ -0,0 +1,1421 @@ | ||
| 1 | +{ | |
| 2 | + "id": "09d08343-8162-4e6b-91ab-ce0a9d6bad10", | |
| 3 | + "prevId": "f029f809-1bf3-46e0-a31b-82df7c9c7171", | |
| 4 | + "version": "7", | |
| 5 | + "dialect": "postgresql", | |
| 6 | + "tables": { | |
| 7 | + "public.accounts": { | |
| 8 | + "name": "accounts", | |
| 9 | + "schema": "", | |
| 10 | + "columns": { | |
| 11 | + "id": { | |
| 12 | + "name": "id", | |
| 13 | + "type": "text", | |
| 14 | + "primaryKey": true, | |
| 15 | + "notNull": true | |
| 16 | + }, | |
| 17 | + "created_at": { | |
| 18 | + "name": "created_at", | |
| 19 | + "type": "timestamp with time zone", | |
| 20 | + "primaryKey": false, | |
| 21 | + "notNull": true, | |
| 22 | + "default": "now()" | |
| 23 | + } | |
| 24 | + }, | |
| 25 | + "indexes": {}, | |
| 26 | + "foreignKeys": {}, | |
| 27 | + "compositePrimaryKeys": {}, | |
| 28 | + "uniqueConstraints": {}, | |
| 29 | + "policies": {}, | |
| 30 | + "checkConstraints": {}, | |
| 31 | + "isRLSEnabled": false | |
| 32 | + }, | |
| 33 | + "public.memberships": { | |
| 34 | + "name": "memberships", | |
| 35 | + "schema": "", | |
| 36 | + "columns": { | |
| 37 | + "account_id": { | |
| 38 | + "name": "account_id", | |
| 39 | + "type": "text", | |
| 40 | + "primaryKey": false, | |
| 41 | + "notNull": true | |
| 42 | + }, | |
| 43 | + "organization_id": { | |
| 44 | + "name": "organization_id", | |
| 45 | + "type": "text", | |
| 46 | + "primaryKey": false, | |
| 47 | + "notNull": true | |
| 48 | + }, | |
| 49 | + "created_at": { | |
| 50 | + "name": "created_at", | |
| 51 | + "type": "timestamp with time zone", | |
| 52 | + "primaryKey": false, | |
| 53 | + "notNull": true, | |
| 54 | + "default": "now()" | |
| 55 | + } | |
| 56 | + }, | |
| 57 | + "indexes": {}, | |
| 58 | + "foreignKeys": { | |
| 59 | + "memberships_account_id_accounts_id_fk": { | |
| 60 | + "name": "memberships_account_id_accounts_id_fk", | |
| 61 | + "tableFrom": "memberships", | |
| 62 | + "tableTo": "accounts", | |
| 63 | + "columnsFrom": [ | |
| 64 | + "account_id" | |
| 65 | + ], | |
| 66 | + "columnsTo": [ | |
| 67 | + "id" | |
| 68 | + ], | |
| 69 | + "onDelete": "cascade", | |
| 70 | + "onUpdate": "no action" | |
| 71 | + }, | |
| 72 | + "memberships_organization_id_organizations_id_fk": { | |
| 73 | + "name": "memberships_organization_id_organizations_id_fk", | |
| 74 | + "tableFrom": "memberships", | |
| 75 | + "tableTo": "organizations", | |
| 76 | + "columnsFrom": [ | |
| 77 | + "organization_id" | |
| 78 | + ], | |
| 79 | + "columnsTo": [ | |
| 80 | + "id" | |
| 81 | + ], | |
| 82 | + "onDelete": "cascade", | |
| 83 | + "onUpdate": "no action" | |
| 84 | + } | |
| 85 | + }, | |
| 86 | + "compositePrimaryKeys": { | |
| 87 | + "memberships_account_id_organization_id_pk": { | |
| 88 | + "name": "memberships_account_id_organization_id_pk", | |
| 89 | + "columns": [ | |
| 90 | + "account_id", | |
| 91 | + "organization_id" | |
| 92 | + ] | |
| 93 | + } | |
| 94 | + }, | |
| 95 | + "uniqueConstraints": {}, | |
| 96 | + "policies": {}, | |
| 97 | + "checkConstraints": {}, | |
| 98 | + "isRLSEnabled": false | |
| 99 | + }, | |
| 100 | + "public.organizations": { | |
| 101 | + "name": "organizations", | |
| 102 | + "schema": "", | |
| 103 | + "columns": { | |
| 104 | + "id": { | |
| 105 | + "name": "id", | |
| 106 | + "type": "text", | |
| 107 | + "primaryKey": true, | |
| 108 | + "notNull": true | |
| 109 | + }, | |
| 110 | + "name": { | |
| 111 | + "name": "name", | |
| 112 | + "type": "text", | |
| 113 | + "primaryKey": false, | |
| 114 | + "notNull": true | |
| 115 | + }, | |
| 116 | + "created_at": { | |
| 117 | + "name": "created_at", | |
| 118 | + "type": "timestamp with time zone", | |
| 119 | + "primaryKey": false, | |
| 120 | + "notNull": true, | |
| 121 | + "default": "now()" | |
| 122 | + } | |
| 123 | + }, | |
| 124 | + "indexes": {}, | |
| 125 | + "foreignKeys": {}, | |
| 126 | + "compositePrimaryKeys": {}, | |
| 127 | + "uniqueConstraints": {}, | |
| 128 | + "policies": {}, | |
| 129 | + "checkConstraints": {}, | |
| 130 | + "isRLSEnabled": false | |
| 131 | + }, | |
| 132 | + "public.blob": { | |
| 133 | + "name": "blob", | |
| 134 | + "schema": "", | |
| 135 | + "columns": { | |
| 136 | + "namespace": { | |
| 137 | + "name": "namespace", | |
| 138 | + "type": "text", | |
| 139 | + "primaryKey": false, | |
| 140 | + "notNull": true | |
| 141 | + }, | |
| 142 | + "key": { | |
| 143 | + "name": "key", | |
| 144 | + "type": "text", | |
| 145 | + "primaryKey": false, | |
| 146 | + "notNull": true | |
| 147 | + }, | |
| 148 | + "value": { | |
| 149 | + "name": "value", | |
| 150 | + "type": "text", | |
| 151 | + "primaryKey": false, | |
| 152 | + "notNull": true | |
| 153 | + } | |
| 154 | + }, | |
| 155 | + "indexes": {}, | |
| 156 | + "foreignKeys": {}, | |
| 157 | + "compositePrimaryKeys": { | |
| 158 | + "blob_namespace_key_pk": { | |
| 159 | + "name": "blob_namespace_key_pk", | |
| 160 | + "columns": [ | |
| 161 | + "namespace", | |
| 162 | + "key" | |
| 163 | + ] | |
| 164 | + } | |
| 165 | + }, | |
| 166 | + "uniqueConstraints": {}, | |
| 167 | + "policies": {}, | |
| 168 | + "checkConstraints": {}, | |
| 169 | + "isRLSEnabled": false | |
| 170 | + }, | |
| 171 | + "public.connection": { | |
| 172 | + "name": "connection", | |
| 173 | + "schema": "", | |
| 174 | + "columns": { | |
| 175 | + "id": { | |
| 176 | + "name": "id", | |
| 177 | + "type": "text", | |
| 178 | + "primaryKey": false, | |
| 179 | + "notNull": true | |
| 180 | + }, | |
| 181 | + "scope_id": { | |
| 182 | + "name": "scope_id", | |
| 183 | + "type": "text", | |
| 184 | + "primaryKey": false, | |
| 185 | + "notNull": true | |
| 186 | + }, | |
| 187 | + "provider": { | |
| 188 | + "name": "provider", | |
| 189 | + "type": "text", | |
| 190 | + "primaryKey": false, | |
| 191 | + "notNull": true | |
| 192 | + }, | |
| 193 | + "kind": { | |
| 194 | + "name": "kind", | |
| 195 | + "type": "text", | |
| 196 | + "primaryKey": false, | |
| 197 | + "notNull": true | |
| 198 | + }, | |
| 199 | + "identity_label": { | |
| 200 | + "name": "identity_label", | |
| 201 | + "type": "text", | |
| 202 | + "primaryKey": false, | |
| 203 | + "notNull": false | |
| 204 | + }, | |
| 205 | + "access_token_secret_id": { | |
| 206 | + "name": "access_token_secret_id", | |
| 207 | + "type": "text", | |
| 208 | + "primaryKey": false, | |
| 209 | + "notNull": true | |
| 210 | + }, | |
| 211 | + "refresh_token_secret_id": { | |
| 212 | + "name": "refresh_token_secret_id", | |
| 213 | + "type": "text", | |
| 214 | + "primaryKey": false, | |
| 215 | + "notNull": false | |
| 216 | + }, | |
| 217 | + "expires_at": { | |
| 218 | + "name": "expires_at", | |
| 219 | + "type": "bigint", | |
| 220 | + "primaryKey": false, | |
| 221 | + "notNull": false | |
| 222 | + }, | |
| 223 | + "scope": { | |
| 224 | + "name": "scope", | |
| 225 | + "type": "text", | |
| 226 | + "primaryKey": false, | |
| 227 | + "notNull": false | |
| 228 | + }, | |
| 229 | + "provider_state": { | |
| 230 | + "name": "provider_state", | |
| 231 | + "type": "jsonb", | |
| 232 | + "primaryKey": false, | |
| 233 | + "notNull": false | |
| 234 | + }, | |
| 235 | + "created_at": { | |
| 236 | + "name": "created_at", | |
| 237 | + "type": "timestamp", | |
| 238 | + "primaryKey": false, | |
| 239 | + "notNull": true | |
| 240 | + }, | |
| 241 | + "updated_at": { | |
| 242 | + "name": "updated_at", | |
| 243 | + "type": "timestamp", | |
| 244 | + "primaryKey": false, | |
| 245 | + "notNull": true | |
| 246 | + } | |
| 247 | + }, | |
| 248 | + "indexes": { | |
| 249 | + "connection_scope_id_idx": { | |
| 250 | + "name": "connection_scope_id_idx", | |
| 251 | + "columns": [ | |
| 252 | + { | |
| 253 | + "expression": "scope_id", | |
| 254 | + "isExpression": false, | |
| 255 | + "asc": true, | |
| 256 | + "nulls": "last" | |
| 257 | + } | |
| 258 | + ], | |
| 259 | + "isUnique": false, | |
| 260 | + "concurrently": false, | |
| 261 | + "method": "btree", | |
| 262 | + "with": {} | |
| 263 | + }, | |
| 264 | + "connection_provider_idx": { | |
| 265 | + "name": "connection_provider_idx", | |
| 266 | + "columns": [ | |
| 267 | + { | |
| 268 | + "expression": "provider", | |
| 269 | + "isExpression": false, | |
| 270 | + "asc": true, | |
| 271 | + "nulls": "last" | |
| 272 | + } | |
| 273 | + ], | |
| 274 | + "isUnique": false, | |
| 275 | + "concurrently": false, | |
| 276 | + "method": "btree", | |
| 277 | + "with": {} | |
| 278 | + } | |
| 279 | + }, | |
| 280 | + "foreignKeys": {}, | |
| 281 | + "compositePrimaryKeys": { | |
| 282 | + "connection_scope_id_id_pk": { | |
| 283 | + "name": "connection_scope_id_id_pk", | |
| 284 | + "columns": [ | |
| 285 | + "scope_id", | |
| 286 | + "id" | |
| 287 | + ] | |
| 288 | + } | |
| 289 | + }, | |
| 290 | + "uniqueConstraints": {}, | |
| 291 | + "policies": {}, | |
| 292 | + "checkConstraints": {}, | |
| 293 | + "isRLSEnabled": false | |
| 294 | + }, | |
| 295 | + "public.definition": { | |
| 296 | + "name": "definition", | |
| 297 | + "schema": "", | |
| 298 | + "columns": { | |
| 299 | + "id": { | |
| 300 | + "name": "id", | |
| 301 | + "type": "text", | |
| 302 | + "primaryKey": false, | |
| 303 | + "notNull": true | |
| 304 | + }, | |
| 305 | + "scope_id": { | |
| 306 | + "name": "scope_id", | |
| 307 | + "type": "text", | |
| 308 | + "primaryKey": false, | |
| 309 | + "notNull": true | |
| 310 | + }, | |
| 311 | + "source_id": { | |
| 312 | + "name": "source_id", | |
| 313 | + "type": "text", | |
| 314 | + "primaryKey": false, | |
| 315 | + "notNull": true | |
| 316 | + }, | |
| 317 | + "plugin_id": { | |
| 318 | + "name": "plugin_id", | |
| 319 | + "type": "text", | |
| 320 | + "primaryKey": false, | |
| 321 | + "notNull": true | |
| 322 | + }, | |
| 323 | + "name": { | |
| 324 | + "name": "name", | |
| 325 | + "type": "text", | |
| 326 | + "primaryKey": false, | |
| 327 | + "notNull": true | |
| 328 | + }, | |
| 329 | + "schema": { | |
| 330 | + "name": "schema", | |
| 331 | + "type": "jsonb", | |
| 332 | + "primaryKey": false, | |
| 333 | + "notNull": true | |
| 334 | + }, | |
| 335 | + "created_at": { | |
| 336 | + "name": "created_at", | |
| 337 | + "type": "timestamp", | |
| 338 | + "primaryKey": false, | |
| 339 | + "notNull": true | |
| 340 | + } | |
| 341 | + }, | |
| 342 | + "indexes": { | |
| 343 | + "definition_scope_id_idx": { | |
| 344 | + "name": "definition_scope_id_idx", | |
| 345 | + "columns": [ | |
| 346 | + { | |
| 347 | + "expression": "scope_id", | |
| 348 | + "isExpression": false, | |
| 349 | + "asc": true, | |
| 350 | + "nulls": "last" | |
| 351 | + } | |
| 352 | + ], | |
| 353 | + "isUnique": false, | |
| 354 | + "concurrently": false, | |
| 355 | + "method": "btree", | |
| 356 | + "with": {} | |
| 357 | + }, | |
| 358 | + "definition_source_id_idx": { | |
| 359 | + "name": "definition_source_id_idx", | |
| 360 | + "columns": [ | |
| 361 | + { | |
| 362 | + "expression": "source_id", | |
| 363 | + "isExpression": false, | |
| 364 | + "asc": true, | |
| 365 | + "nulls": "last" | |
| 366 | + } | |
| 367 | + ], | |
| 368 | + "isUnique": false, | |
| 369 | + "concurrently": false, | |
| 370 | + "method": "btree", | |
| 371 | + "with": {} | |
| 372 | + }, | |
| 373 | + "definition_plugin_id_idx": { | |
| 374 | + "name": "definition_plugin_id_idx", | |
| 375 | + "columns": [ | |
| 376 | + { | |
| 377 | + "expression": "plugin_id", | |
| 378 | + "isExpression": false, | |
| 379 | + "asc": true, | |
| 380 | + "nulls": "last" | |
| 381 | + } | |
| 382 | + ], | |
| 383 | + "isUnique": false, | |
| 384 | + "concurrently": false, | |
| 385 | + "method": "btree", | |
| 386 | + "with": {} | |
| 387 | + } | |
| 388 | + }, | |
| 389 | + "foreignKeys": {}, | |
| 390 | + "compositePrimaryKeys": { | |
| 391 | + "definition_scope_id_id_pk": { | |
| 392 | + "name": "definition_scope_id_id_pk", | |
| 393 | + "columns": [ | |
| 394 | + "scope_id", | |
| 395 | + "id" | |
| 396 | + ] | |
| 397 | + } | |
| 398 | + }, | |
| 399 | + "uniqueConstraints": {}, | |
| 400 | + "policies": {}, | |
| 401 | + "checkConstraints": {}, | |
| 402 | + "isRLSEnabled": false | |
| 403 | + }, | |
| 404 | + "public.graphql_operation": { | |
| 405 | + "name": "graphql_operation", | |
| 406 | + "schema": "", | |
| 407 | + "columns": { | |
| 408 | + "id": { | |
| 409 | + "name": "id", | |
| 410 | + "type": "text", | |
| 411 | + "primaryKey": false, | |
| 412 | + "notNull": true | |
| 413 | + }, | |
| 414 | + "scope_id": { | |
| 415 | + "name": "scope_id", | |
| 416 | + "type": "text", | |
| 417 | + "primaryKey": false, | |
| 418 | + "notNull": true | |
| 419 | + }, | |
| 420 | + "source_id": { | |
| 421 | + "name": "source_id", | |
| 422 | + "type": "text", | |
| 423 | + "primaryKey": false, | |
| 424 | + "notNull": true | |
| 425 | + }, | |
| 426 | + "binding": { | |
| 427 | + "name": "binding", | |
| 428 | + "type": "jsonb", | |
| 429 | + "primaryKey": false, | |
| 430 | + "notNull": true | |
| 431 | + } | |
| 432 | + }, | |
| 433 | + "indexes": { | |
| 434 | + "graphql_operation_scope_id_idx": { | |
| 435 | + "name": "graphql_operation_scope_id_idx", | |
| 436 | + "columns": [ | |
| 437 | + { | |
| 438 | + "expression": "scope_id", | |
| 439 | + "isExpression": false, | |
| 440 | + "asc": true, | |
| 441 | + "nulls": "last" | |
| 442 | + } | |
| 443 | + ], | |
| 444 | + "isUnique": false, | |
| 445 | + "concurrently": false, | |
| 446 | + "method": "btree", | |
| 447 | + "with": {} | |
| 448 | + }, | |
| 449 | + "graphql_operation_source_id_idx": { | |
| 450 | + "name": "graphql_operation_source_id_idx", | |
| 451 | + "columns": [ | |
| 452 | + { | |
| 453 | + "expression": "source_id", | |
| 454 | + "isExpression": false, | |
| 455 | + "asc": true, | |
| 456 | + "nulls": "last" | |
| 457 | + } | |
| 458 | + ], | |
| 459 | + "isUnique": false, | |
| 460 | + "concurrently": false, | |
| 461 | + "method": "btree", | |
| 462 | + "with": {} | |
| 463 | + } | |
| 464 | + }, | |
| 465 | + "foreignKeys": {}, | |
| 466 | + "compositePrimaryKeys": { | |
| 467 | + "graphql_operation_scope_id_id_pk": { | |
| 468 | + "name": "graphql_operation_scope_id_id_pk", | |
| 469 | + "columns": [ | |
| 470 | + "scope_id", | |
| 471 | + "id" | |
| 472 | + ] | |
| 473 | + } | |
| 474 | + }, | |
| 475 | + "uniqueConstraints": {}, | |
| 476 | + "policies": {}, | |
| 477 | + "checkConstraints": {}, | |
| 478 | + "isRLSEnabled": false | |
| 479 | + }, | |
| 480 | + "public.graphql_source": { | |
| 481 | + "name": "graphql_source", | |
| 482 | + "schema": "", | |
| 483 | + "columns": { | |
| 484 | + "id": { | |
| 485 | + "name": "id", | |
| 486 | + "type": "text", | |
| 487 | + "primaryKey": false, | |
| 488 | + "notNull": true | |
| 489 | + }, | |
| 490 | + "scope_id": { | |
| 491 | + "name": "scope_id", | |
| 492 | + "type": "text", | |
| 493 | + "primaryKey": false, | |
| 494 | + "notNull": true | |
| 495 | + }, | |
| 496 | + "name": { | |
| 497 | + "name": "name", | |
| 498 | + "type": "text", | |
| 499 | + "primaryKey": false, | |
| 500 | + "notNull": true | |
| 501 | + }, | |
| 502 | + "endpoint": { | |
| 503 | + "name": "endpoint", | |
| 504 | + "type": "text", | |
| 505 | + "primaryKey": false, | |
| 506 | + "notNull": true | |
| 507 | + }, | |
| 508 | + "headers": { | |
| 509 | + "name": "headers", | |
| 510 | + "type": "jsonb", | |
| 511 | + "primaryKey": false, | |
| 512 | + "notNull": false | |
| 513 | + } | |
| 514 | + }, | |
| 515 | + "indexes": { | |
| 516 | + "graphql_source_scope_id_idx": { | |
| 517 | + "name": "graphql_source_scope_id_idx", | |
| 518 | + "columns": [ | |
| 519 | + { | |
| 520 | + "expression": "scope_id", | |
| 521 | + "isExpression": false, | |
| 522 | + "asc": true, | |
| 523 | + "nulls": "last" | |
| 524 | + } | |
| 525 | + ], | |
| 526 | + "isUnique": false, | |
| 527 | + "concurrently": false, | |
| 528 | + "method": "btree", | |
| 529 | + "with": {} | |
| 530 | + } | |
| 531 | + }, | |
| 532 | + "foreignKeys": {}, | |
| 533 | + "compositePrimaryKeys": { | |
| 534 | + "graphql_source_scope_id_id_pk": { | |
| 535 | + "name": "graphql_source_scope_id_id_pk", | |
| 536 | + "columns": [ | |
| 537 | + "scope_id", | |
| 538 | + "id" | |
| 539 | + ] | |
| 540 | + } | |
| 541 | + }, | |
| 542 | + "uniqueConstraints": {}, | |
| 543 | + "policies": {}, | |
| 544 | + "checkConstraints": {}, | |
| 545 | + "isRLSEnabled": false | |
| 546 | + }, | |
| 547 | + "public.mcp_binding": { | |
| 548 | + "name": "mcp_binding", | |
| 549 | + "schema": "", | |
| 550 | + "columns": { | |
| 551 | + "id": { | |
| 552 | + "name": "id", | |
| 553 | + "type": "text", | |
| 554 | + "primaryKey": false, | |
| 555 | + "notNull": true | |
| 556 | + }, | |
| 557 | + "scope_id": { | |
| 558 | + "name": "scope_id", | |
| 559 | + "type": "text", | |
| 560 | + "primaryKey": false, | |
| 561 | + "notNull": true | |
| 562 | + }, | |
| 563 | + "source_id": { | |
| 564 | + "name": "source_id", | |
| 565 | + "type": "text", | |
| 566 | + "primaryKey": false, | |
| 567 | + "notNull": true | |
| 568 | + }, | |
| 569 | + "binding": { | |
| 570 | + "name": "binding", | |
| 571 | + "type": "jsonb", | |
| 572 | + "primaryKey": false, | |
| 573 | + "notNull": true | |
| 574 | + }, | |
| 575 | + "created_at": { | |
| 576 | + "name": "created_at", | |
| 577 | + "type": "timestamp", | |
| 578 | + "primaryKey": false, | |
| 579 | + "notNull": true | |
| 580 | + } | |
| 581 | + }, | |
| 582 | + "indexes": { | |
| 583 | + "mcp_binding_scope_id_idx": { | |
| 584 | + "name": "mcp_binding_scope_id_idx", | |
| 585 | + "columns": [ | |
| 586 | + { | |
| 587 | + "expression": "scope_id", | |
| 588 | + "isExpression": false, | |
| 589 | + "asc": true, | |
| 590 | + "nulls": "last" | |
| 591 | + } | |
| 592 | + ], | |
| 593 | + "isUnique": false, | |
| 594 | + "concurrently": false, | |
| 595 | + "method": "btree", | |
| 596 | + "with": {} | |
| 597 | + }, | |
| 598 | + "mcp_binding_source_id_idx": { | |
| 599 | + "name": "mcp_binding_source_id_idx", | |
| 600 | + "columns": [ | |
| 601 | + { | |
| 602 | + "expression": "source_id", | |
| 603 | + "isExpression": false, | |
| 604 | + "asc": true, | |
| 605 | + "nulls": "last" | |
| 606 | + } | |
| 607 | + ], | |
| 608 | + "isUnique": false, | |
| 609 | + "concurrently": false, | |
| 610 | + "method": "btree", | |
| 611 | + "with": {} | |
| 612 | + } | |
| 613 | + }, | |
| 614 | + "foreignKeys": {}, | |
| 615 | + "compositePrimaryKeys": { | |
| 616 | + "mcp_binding_scope_id_id_pk": { | |
| 617 | + "name": "mcp_binding_scope_id_id_pk", | |
| 618 | + "columns": [ | |
| 619 | + "scope_id", | |
| 620 | + "id" | |
| 621 | + ] | |
| 622 | + } | |
| 623 | + }, | |
| 624 | + "uniqueConstraints": {}, | |
| 625 | + "policies": {}, | |
| 626 | + "checkConstraints": {}, | |
| 627 | + "isRLSEnabled": false | |
| 628 | + }, | |
| 629 | + "public.mcp_oauth_session": { | |
| 630 | + "name": "mcp_oauth_session", | |
| 631 | + "schema": "", | |
| 632 | + "columns": { | |
| 633 | + "id": { | |
| 634 | + "name": "id", | |
| 635 | + "type": "text", | |
| 636 | + "primaryKey": false, | |
| 637 | + "notNull": true | |
| 638 | + }, | |
| 639 | + "scope_id": { | |
| 640 | + "name": "scope_id", | |
| 641 | + "type": "text", | |
| 642 | + "primaryKey": false, | |
| 643 | + "notNull": true | |
| 644 | + }, | |
| 645 | + "session": { | |
| 646 | + "name": "session", | |
| 647 | + "type": "jsonb", | |
| 648 | + "primaryKey": false, | |
| 649 | + "notNull": true | |
| 650 | + }, | |
| 651 | + "expires_at": { | |
| 652 | + "name": "expires_at", | |
| 653 | + "type": "bigint", | |
| 654 | + "primaryKey": false, | |
| 655 | + "notNull": true | |
| 656 | + }, | |
| 657 | + "created_at": { | |
| 658 | + "name": "created_at", | |
| 659 | + "type": "timestamp", | |
| 660 | + "primaryKey": false, | |
| 661 | + "notNull": true | |
| 662 | + } | |
| 663 | + }, | |
| 664 | + "indexes": { | |
| 665 | + "mcp_oauth_session_scope_id_idx": { | |
| 666 | + "name": "mcp_oauth_session_scope_id_idx", | |
| 667 | + "columns": [ | |
| 668 | + { | |
| 669 | + "expression": "scope_id", | |
| 670 | + "isExpression": false, | |
| 671 | + "asc": true, | |
| 672 | + "nulls": "last" | |
| 673 | + } | |
| 674 | + ], | |
| 675 | + "isUnique": false, | |
| 676 | + "concurrently": false, | |
| 677 | + "method": "btree", | |
| 678 | + "with": {} | |
| 679 | + } | |
| 680 | + }, | |
| 681 | + "foreignKeys": {}, | |
| 682 | + "compositePrimaryKeys": { | |
| 683 | + "mcp_oauth_session_scope_id_id_pk": { | |
| 684 | + "name": "mcp_oauth_session_scope_id_id_pk", | |
| 685 | + "columns": [ | |
| 686 | + "scope_id", | |
| 687 | + "id" | |
| 688 | + ] | |
| 689 | + } | |
| 690 | + }, | |
| 691 | + "uniqueConstraints": {}, | |
| 692 | + "policies": {}, | |
| 693 | + "checkConstraints": {}, | |
| 694 | + "isRLSEnabled": false | |
| 695 | + }, | |
| 696 | + "public.mcp_source": { | |
| 697 | + "name": "mcp_source", | |
| 698 | + "schema": "", | |
| 699 | + "columns": { | |
| 700 | + "id": { | |
| 701 | + "name": "id", | |
| 702 | + "type": "text", | |
| 703 | + "primaryKey": false, | |
| 704 | + "notNull": true | |
| 705 | + }, | |
| 706 | + "scope_id": { | |
| 707 | + "name": "scope_id", | |
| 708 | + "type": "text", | |
| 709 | + "primaryKey": false, | |
| 710 | + "notNull": true | |
| 711 | + }, | |
| 712 | + "name": { | |
| 713 | + "name": "name", | |
| 714 | + "type": "text", | |
| 715 | + "primaryKey": false, | |
| 716 | + "notNull": true | |
| 717 | + }, | |
| 718 | + "config": { | |
| 719 | + "name": "config", | |
| 720 | + "type": "jsonb", | |
| 721 | + "primaryKey": false, | |
| 722 | + "notNull": true | |
| 723 | + }, | |
| 724 | + "created_at": { | |
| 725 | + "name": "created_at", | |
| 726 | + "type": "timestamp", | |
| 727 | + "primaryKey": false, | |
| 728 | + "notNull": true | |
| 729 | + } | |
| 730 | + }, | |
| 731 | + "indexes": { | |
| 732 | + "mcp_source_scope_id_idx": { | |
| 733 | + "name": "mcp_source_scope_id_idx", | |
| 734 | + "columns": [ | |
| 735 | + { | |
| 736 | + "expression": "scope_id", | |
| 737 | + "isExpression": false, | |
| 738 | + "asc": true, | |
| 739 | + "nulls": "last" | |
| 740 | + } | |
| 741 | + ], | |
| 742 | + "isUnique": false, | |
| 743 | + "concurrently": false, | |
| 744 | + "method": "btree", | |
| 745 | + "with": {} | |
| 746 | + } | |
| 747 | + }, | |
| 748 | + "foreignKeys": {}, | |
| 749 | + "compositePrimaryKeys": { | |
| 750 | + "mcp_source_scope_id_id_pk": { | |
| 751 | + "name": "mcp_source_scope_id_id_pk", | |
| 752 | + "columns": [ | |
| 753 | + "scope_id", | |
| 754 | + "id" | |
| 755 | + ] | |
| 756 | + } | |
| 757 | + }, | |
| 758 | + "uniqueConstraints": {}, | |
| 759 | + "policies": {}, | |
| 760 | + "checkConstraints": {}, | |
| 761 | + "isRLSEnabled": false | |
| 762 | + }, | |
| 763 | + "public.openapi_oauth_session": { | |
| 764 | + "name": "openapi_oauth_session", | |
| 765 | + "schema": "", | |
| 766 | + "columns": { | |
| 767 | + "id": { | |
| 768 | + "name": "id", | |
| 769 | + "type": "text", | |
| 770 | + "primaryKey": false, | |
| 771 | + "notNull": true | |
| 772 | + }, | |
| 773 | + "scope_id": { | |
| 774 | + "name": "scope_id", | |
| 775 | + "type": "text", | |
| 776 | + "primaryKey": false, | |
| 777 | + "notNull": true | |
| 778 | + }, | |
| 779 | + "session": { | |
| 780 | + "name": "session", | |
| 781 | + "type": "jsonb", | |
| 782 | + "primaryKey": false, | |
| 783 | + "notNull": true | |
| 784 | + }, | |
| 785 | + "created_at": { | |
| 786 | + "name": "created_at", | |
| 787 | + "type": "timestamp", | |
| 788 | + "primaryKey": false, | |
| 789 | + "notNull": true | |
| 790 | + } | |
| 791 | + }, | |
| 792 | + "indexes": { | |
| 793 | + "openapi_oauth_session_scope_id_idx": { | |
| 794 | + "name": "openapi_oauth_session_scope_id_idx", | |
| 795 | + "columns": [ | |
| 796 | + { | |
| 797 | + "expression": "scope_id", | |
| 798 | + "isExpression": false, | |
| 799 | + "asc": true, | |
| 800 | + "nulls": "last" | |
| 801 | + } | |
| 802 | + ], | |
| 803 | + "isUnique": false, | |
| 804 | + "concurrently": false, | |
| 805 | + "method": "btree", | |
| 806 | + "with": {} | |
| 807 | + } | |
| 808 | + }, | |
| 809 | + "foreignKeys": {}, | |
| 810 | + "compositePrimaryKeys": { | |
| 811 | + "openapi_oauth_session_scope_id_id_pk": { | |
| 812 | + "name": "openapi_oauth_session_scope_id_id_pk", | |
| 813 | + "columns": [ | |
| 814 | + "scope_id", | |
| 815 | + "id" | |
| 816 | + ] | |
| 817 | + } | |
| 818 | + }, | |
| 819 | + "uniqueConstraints": {}, | |
| 820 | + "policies": {}, | |
| 821 | + "checkConstraints": {}, | |
| 822 | + "isRLSEnabled": false | |
| 823 | + }, | |
| 824 | + "public.openapi_operation": { | |
| 825 | + "name": "openapi_operation", | |
| 826 | + "schema": "", | |
| 827 | + "columns": { | |
| 828 | + "id": { | |
| 829 | + "name": "id", | |
| 830 | + "type": "text", | |
| 831 | + "primaryKey": false, | |
| 832 | + "notNull": true | |
| 833 | + }, | |
| 834 | + "scope_id": { | |
| 835 | + "name": "scope_id", | |
| 836 | + "type": "text", | |
| 837 | + "primaryKey": false, | |
| 838 | + "notNull": true | |
| 839 | + }, | |
| 840 | + "source_id": { | |
| 841 | + "name": "source_id", | |
| 842 | + "type": "text", | |
| 843 | + "primaryKey": false, | |
| 844 | + "notNull": true | |
| 845 | + }, | |
| 846 | + "binding": { | |
| 847 | + "name": "binding", | |
| 848 | + "type": "jsonb", | |
| 849 | + "primaryKey": false, | |
| 850 | + "notNull": true | |
| 851 | + } | |
| 852 | + }, | |
| 853 | + "indexes": { | |
| 854 | + "openapi_operation_scope_id_idx": { | |
| 855 | + "name": "openapi_operation_scope_id_idx", | |
| 856 | + "columns": [ | |
| 857 | + { | |
| 858 | + "expression": "scope_id", | |
| 859 | + "isExpression": false, | |
| 860 | + "asc": true, | |
| 861 | + "nulls": "last" | |
| 862 | + } | |
| 863 | + ], | |
| 864 | + "isUnique": false, | |
| 865 | + "concurrently": false, | |
| 866 | + "method": "btree", | |
| 867 | + "with": {} | |
| 868 | + }, | |
| 869 | + "openapi_operation_source_id_idx": { | |
| 870 | + "name": "openapi_operation_source_id_idx", | |
| 871 | + "columns": [ | |
| 872 | + { | |
| 873 | + "expression": "source_id", | |
| 874 | + "isExpression": false, | |
| 875 | + "asc": true, | |
| 876 | + "nulls": "last" | |
| 877 | + } | |
| 878 | + ], | |
| 879 | + "isUnique": false, | |
| 880 | + "concurrently": false, | |
| 881 | + "method": "btree", | |
| 882 | + "with": {} | |
| 883 | + } | |
| 884 | + }, | |
| 885 | + "foreignKeys": {}, | |
| 886 | + "compositePrimaryKeys": { | |
| 887 | + "openapi_operation_scope_id_id_pk": { | |
| 888 | + "name": "openapi_operation_scope_id_id_pk", | |
| 889 | + "columns": [ | |
| 890 | + "scope_id", | |
| 891 | + "id" | |
| 892 | + ] | |
| 893 | + } | |
| 894 | + }, | |
| 895 | + "uniqueConstraints": {}, | |
| 896 | + "policies": {}, | |
| 897 | + "checkConstraints": {}, | |
| 898 | + "isRLSEnabled": false | |
| 899 | + }, | |
| 900 | + "public.openapi_source": { | |
| 901 | + "name": "openapi_source", | |
| 902 | + "schema": "", | |
| 903 | + "columns": { | |
| 904 | + "id": { | |
| 905 | + "name": "id", | |
| 906 | + "type": "text", | |
| 907 | + "primaryKey": false, | |
| 908 | + "notNull": true | |
| 909 | + }, | |
| 910 | + "scope_id": { | |
| 911 | + "name": "scope_id", | |
| 912 | + "type": "text", | |
| 913 | + "primaryKey": false, | |
| 914 | + "notNull": true | |
| 915 | + }, | |
| 916 | + "name": { | |
| 917 | + "name": "name", | |
| 918 | + "type": "text", | |
| 919 | + "primaryKey": false, | |
| 920 | + "notNull": true | |
| 921 | + }, | |
| 922 | + "spec": { | |
| 923 | + "name": "spec", | |
| 924 | + "type": "text", | |
| 925 | + "primaryKey": false, | |
| 926 | + "notNull": true | |
| 927 | + }, | |
| 928 | + "source_url": { | |
| 929 | + "name": "source_url", | |
| 930 | + "type": "text", | |
| 931 | + "primaryKey": false, | |
| 932 | + "notNull": false | |
| 933 | + }, | |
| 934 | + "base_url": { | |
| 935 | + "name": "base_url", | |
| 936 | + "type": "text", | |
| 937 | + "primaryKey": false, | |
| 938 | + "notNull": false | |
| 939 | + }, | |
| 940 | + "headers": { | |
| 941 | + "name": "headers", | |
| 942 | + "type": "jsonb", | |
| 943 | + "primaryKey": false, | |
| 944 | + "notNull": false | |
| 945 | + }, | |
| 946 | + "oauth2": { | |
| 947 | + "name": "oauth2", | |
| 948 | + "type": "jsonb", | |
| 949 | + "primaryKey": false, | |
| 950 | + "notNull": false | |
| 951 | + }, | |
| 952 | + "invocation_config": { | |
| 953 | + "name": "invocation_config", | |
| 954 | + "type": "jsonb", | |
| 955 | + "primaryKey": false, | |
| 956 | + "notNull": true | |
| 957 | + } | |
| 958 | + }, | |
| 959 | + "indexes": { | |
| 960 | + "openapi_source_scope_id_idx": { | |
| 961 | + "name": "openapi_source_scope_id_idx", | |
| 962 | + "columns": [ | |
| 963 | + { | |
| 964 | + "expression": "scope_id", | |
| 965 | + "isExpression": false, | |
| 966 | + "asc": true, | |
| 967 | + "nulls": "last" | |
| 968 | + } | |
| 969 | + ], | |
| 970 | + "isUnique": false, | |
| 971 | + "concurrently": false, | |
| 972 | + "method": "btree", | |
| 973 | + "with": {} | |
| 974 | + } | |
| 975 | + }, | |
| 976 | + "foreignKeys": {}, | |
| 977 | + "compositePrimaryKeys": { | |
| 978 | + "openapi_source_scope_id_id_pk": { | |
| 979 | + "name": "openapi_source_scope_id_id_pk", | |
| 980 | + "columns": [ | |
| 981 | + "scope_id", | |
| 982 | + "id" | |
| 983 | + ] | |
| 984 | + } | |
| 985 | + }, | |
| 986 | + "uniqueConstraints": {}, | |
| 987 | + "policies": {}, | |
| 988 | + "checkConstraints": {}, | |
| 989 | + "isRLSEnabled": false | |
| 990 | + }, | |
| 991 | + "public.secret": { | |
| 992 | + "name": "secret", | |
| 993 | + "schema": "", | |
| 994 | + "columns": { | |
| 995 | + "id": { | |
| 996 | + "name": "id", | |
| 997 | + "type": "text", | |
| 998 | + "primaryKey": false, | |
| 999 | + "notNull": true | |
| 1000 | + }, | |
| 1001 | + "scope_id": { | |
| 1002 | + "name": "scope_id", | |
| 1003 | + "type": "text", | |
| 1004 | + "primaryKey": false, | |
| 1005 | + "notNull": true | |
| 1006 | + }, | |
| 1007 | + "name": { | |
| 1008 | + "name": "name", | |
| 1009 | + "type": "text", | |
| 1010 | + "primaryKey": false, | |
| 1011 | + "notNull": true | |
| 1012 | + }, | |
| 1013 | + "provider": { | |
| 1014 | + "name": "provider", | |
| 1015 | + "type": "text", | |
| 1016 | + "primaryKey": false, | |
| 1017 | + "notNull": true | |
| 1018 | + }, | |
| 1019 | + "owned_by_connection_id": { | |
| 1020 | + "name": "owned_by_connection_id", | |
| 1021 | + "type": "text", | |
| 1022 | + "primaryKey": false, | |
| 1023 | + "notNull": false | |
| 1024 | + }, | |
| 1025 | + "created_at": { | |
| 1026 | + "name": "created_at", | |
| 1027 | + "type": "timestamp", | |
| 1028 | + "primaryKey": false, | |
| 1029 | + "notNull": true | |
| 1030 | + } | |
| 1031 | + }, | |
| 1032 | + "indexes": { | |
| 1033 | + "secret_scope_id_idx": { | |
| 1034 | + "name": "secret_scope_id_idx", | |
| 1035 | + "columns": [ | |
| 1036 | + { | |
| 1037 | + "expression": "scope_id", | |
| 1038 | + "isExpression": false, | |
| 1039 | + "asc": true, | |
| 1040 | + "nulls": "last" | |
| 1041 | + } | |
| 1042 | + ], | |
| 1043 | + "isUnique": false, | |
| 1044 | + "concurrently": false, | |
| 1045 | + "method": "btree", | |
| 1046 | + "with": {} | |
| 1047 | + }, | |
| 1048 | + "secret_provider_idx": { | |
| 1049 | + "name": "secret_provider_idx", | |
| 1050 | + "columns": [ | |
| 1051 | + { | |
| 1052 | + "expression": "provider", | |
| 1053 | + "isExpression": false, | |
| 1054 | + "asc": true, | |
| 1055 | + "nulls": "last" | |
| 1056 | + } | |
| 1057 | + ], | |
| 1058 | + "isUnique": false, | |
| 1059 | + "concurrently": false, | |
| 1060 | + "method": "btree", | |
| 1061 | + "with": {} | |
| 1062 | + }, | |
| 1063 | + "secret_owned_by_connection_id_idx": { | |
| 1064 | + "name": "secret_owned_by_connection_id_idx", | |
| 1065 | + "columns": [ | |
| 1066 | + { | |
| 1067 | + "expression": "owned_by_connection_id", | |
| 1068 | + "isExpression": false, | |
| 1069 | + "asc": true, | |
| 1070 | + "nulls": "last" | |
| 1071 | + } | |
| 1072 | + ], | |
| 1073 | + "isUnique": false, | |
| 1074 | + "concurrently": false, | |
| 1075 | + "method": "btree", | |
| 1076 | + "with": {} | |
| 1077 | + } | |
| 1078 | + }, | |
| 1079 | + "foreignKeys": {}, | |
| 1080 | + "compositePrimaryKeys": { | |
| 1081 | + "secret_scope_id_id_pk": { | |
| 1082 | + "name": "secret_scope_id_id_pk", | |
| 1083 | + "columns": [ | |
| 1084 | + "scope_id", | |
| 1085 | + "id" | |
| 1086 | + ] | |
| 1087 | + } | |
| 1088 | + }, | |
| 1089 | + "uniqueConstraints": {}, | |
| 1090 | + "policies": {}, | |
| 1091 | + "checkConstraints": {}, | |
| 1092 | + "isRLSEnabled": false | |
| 1093 | + }, | |
| 1094 | + "public.source": { | |
| 1095 | + "name": "source", | |
| 1096 | + "schema": "", | |
| 1097 | + "columns": { | |
| 1098 | + "id": { | |
| 1099 | + "name": "id", | |
| 1100 | + "type": "text", | |
| 1101 | + "primaryKey": false, | |
| 1102 | + "notNull": true | |
| 1103 | + }, | |
| 1104 | + "scope_id": { | |
| 1105 | + "name": "scope_id", | |
| 1106 | + "type": "text", | |
| 1107 | + "primaryKey": false, | |
| 1108 | + "notNull": true | |
| 1109 | + }, | |
| 1110 | + "plugin_id": { | |
| 1111 | + "name": "plugin_id", | |
| 1112 | + "type": "text", | |
| 1113 | + "primaryKey": false, | |
| 1114 | + "notNull": true | |
| 1115 | + }, | |
| 1116 | + "kind": { | |
| 1117 | + "name": "kind", | |
| 1118 | + "type": "text", | |
| 1119 | + "primaryKey": false, | |
| 1120 | + "notNull": true | |
| 1121 | + }, | |
| 1122 | + "name": { | |
| 1123 | + "name": "name", | |
| 1124 | + "type": "text", | |
| 1125 | + "primaryKey": false, | |
| 1126 | + "notNull": true | |
| 1127 | + }, | |
| 1128 | + "url": { | |
| 1129 | + "name": "url", | |
| 1130 | + "type": "text", | |
| 1131 | + "primaryKey": false, | |
| 1132 | + "notNull": false | |
| 1133 | + }, | |
| 1134 | + "can_remove": { | |
| 1135 | + "name": "can_remove", | |
| 1136 | + "type": "boolean", | |
| 1137 | + "primaryKey": false, | |
| 1138 | + "notNull": true, | |
| 1139 | + "default": true | |
| 1140 | + }, | |
| 1141 | + "can_refresh": { | |
| 1142 | + "name": "can_refresh", | |
| 1143 | + "type": "boolean", | |
| 1144 | + "primaryKey": false, | |
| 1145 | + "notNull": true, | |
| 1146 | + "default": false | |
| 1147 | + }, | |
| 1148 | + "can_edit": { | |
| 1149 | + "name": "can_edit", | |
| 1150 | + "type": "boolean", | |
| 1151 | + "primaryKey": false, | |
| 1152 | + "notNull": true, | |
| 1153 | + "default": false | |
| 1154 | + }, | |
| 1155 | + "created_at": { | |
| 1156 | + "name": "created_at", | |
| 1157 | + "type": "timestamp", | |
| 1158 | + "primaryKey": false, | |
| 1159 | + "notNull": true | |
| 1160 | + }, | |
| 1161 | + "updated_at": { | |
| 1162 | + "name": "updated_at", | |
| 1163 | + "type": "timestamp", | |
| 1164 | + "primaryKey": false, | |
| 1165 | + "notNull": true | |
| 1166 | + } | |
| 1167 | + }, | |
| 1168 | + "indexes": { | |
| 1169 | + "source_scope_id_idx": { | |
| 1170 | + "name": "source_scope_id_idx", | |
| 1171 | + "columns": [ | |
| 1172 | + { | |
| 1173 | + "expression": "scope_id", | |
| 1174 | + "isExpression": false, | |
| 1175 | + "asc": true, | |
| 1176 | + "nulls": "last" | |
| 1177 | + } | |
| 1178 | + ], | |
| 1179 | + "isUnique": false, | |
| 1180 | + "concurrently": false, | |
| 1181 | + "method": "btree", | |
| 1182 | + "with": {} | |
| 1183 | + }, | |
| 1184 | + "source_plugin_id_idx": { | |
| 1185 | + "name": "source_plugin_id_idx", | |
| 1186 | + "columns": [ | |
| 1187 | + { | |
| 1188 | + "expression": "plugin_id", | |
| 1189 | + "isExpression": false, | |
| 1190 | + "asc": true, | |
| 1191 | + "nulls": "last" | |
| 1192 | + } | |
| 1193 | + ], | |
| 1194 | + "isUnique": false, | |
| 1195 | + "concurrently": false, | |
| 1196 | + "method": "btree", | |
| 1197 | + "with": {} | |
| 1198 | + } | |
| 1199 | + }, | |
| 1200 | + "foreignKeys": {}, | |
| 1201 | + "compositePrimaryKeys": { | |
| 1202 | + "source_scope_id_id_pk": { | |
| 1203 | + "name": "source_scope_id_id_pk", | |
| 1204 | + "columns": [ | |
| 1205 | + "scope_id", | |
| 1206 | + "id" | |
| 1207 | + ] | |
| 1208 | + } | |
| 1209 | + }, | |
| 1210 | + "uniqueConstraints": {}, | |
| 1211 | + "policies": {}, | |
| 1212 | + "checkConstraints": {}, | |
| 1213 | + "isRLSEnabled": false | |
| 1214 | + }, | |
| 1215 | + "public.tool": { | |
| 1216 | + "name": "tool", | |
| 1217 | + "schema": "", | |
| 1218 | + "columns": { | |
| 1219 | + "id": { | |
| 1220 | + "name": "id", | |
| 1221 | + "type": "text", | |
| 1222 | + "primaryKey": false, | |
| 1223 | + "notNull": true | |
| 1224 | + }, | |
| 1225 | + "scope_id": { | |
| 1226 | + "name": "scope_id", | |
| 1227 | + "type": "text", | |
| 1228 | + "primaryKey": false, | |
| 1229 | + "notNull": true | |
| 1230 | + }, | |
| 1231 | + "source_id": { | |
| 1232 | + "name": "source_id", | |
| 1233 | + "type": "text", | |
| 1234 | + "primaryKey": false, | |
| 1235 | + "notNull": true | |
| 1236 | + }, | |
| 1237 | + "plugin_id": { | |
| 1238 | + "name": "plugin_id", | |
| 1239 | + "type": "text", | |
| 1240 | + "primaryKey": false, | |
| 1241 | + "notNull": true | |
| 1242 | + }, | |
| 1243 | + "name": { | |
| 1244 | + "name": "name", | |
| 1245 | + "type": "text", | |
| 1246 | + "primaryKey": false, | |
| 1247 | + "notNull": true | |
| 1248 | + }, | |
| 1249 | + "description": { | |
| 1250 | + "name": "description", | |
| 1251 | + "type": "text", | |
| 1252 | + "primaryKey": false, | |
| 1253 | + "notNull": true | |
| 1254 | + }, | |
| 1255 | + "input_schema": { | |
| 1256 | + "name": "input_schema", | |
| 1257 | + "type": "jsonb", | |
| 1258 | + "primaryKey": false, | |
| 1259 | + "notNull": false | |
| 1260 | + }, | |
| 1261 | + "output_schema": { | |
| 1262 | + "name": "output_schema", | |
| 1263 | + "type": "jsonb", | |
| 1264 | + "primaryKey": false, | |
| 1265 | + "notNull": false | |
| 1266 | + }, | |
| 1267 | + "created_at": { | |
| 1268 | + "name": "created_at", | |
| 1269 | + "type": "timestamp", | |
| 1270 | + "primaryKey": false, | |
| 1271 | + "notNull": true | |
| 1272 | + }, | |
| 1273 | + "updated_at": { | |
| 1274 | + "name": "updated_at", | |
| 1275 | + "type": "timestamp", | |
| 1276 | + "primaryKey": false, | |
| 1277 | + "notNull": true | |
| 1278 | + } | |
| 1279 | + }, | |
| 1280 | + "indexes": { | |
| 1281 | + "tool_scope_id_idx": { | |
| 1282 | + "name": "tool_scope_id_idx", | |
| 1283 | + "columns": [ | |
| 1284 | + { | |
| 1285 | + "expression": "scope_id", | |
| 1286 | + "isExpression": false, | |
| 1287 | + "asc": true, | |
| 1288 | + "nulls": "last" | |
| 1289 | + } | |
| 1290 | + ], | |
| 1291 | + "isUnique": false, | |
| 1292 | + "concurrently": false, | |
| 1293 | + "method": "btree", | |
| 1294 | + "with": {} | |
| 1295 | + }, | |
| 1296 | + "tool_source_id_idx": { | |
| 1297 | + "name": "tool_source_id_idx", | |
| 1298 | + "columns": [ | |
| 1299 | + { | |
| 1300 | + "expression": "source_id", | |
| 1301 | + "isExpression": false, | |
| 1302 | + "asc": true, | |
| 1303 | + "nulls": "last" | |
| 1304 | + } | |
| 1305 | + ], | |
| 1306 | + "isUnique": false, | |
| 1307 | + "concurrently": false, | |
| 1308 | + "method": "btree", | |
| 1309 | + "with": {} | |
| 1310 | + }, | |
| 1311 | + "tool_plugin_id_idx": { | |
| 1312 | + "name": "tool_plugin_id_idx", | |
| 1313 | + "columns": [ | |
| 1314 | + { | |
| 1315 | + "expression": "plugin_id", | |
| 1316 | + "isExpression": false, | |
| 1317 | + "asc": true, | |
| 1318 | + "nulls": "last" | |
| 1319 | + } | |
| 1320 | + ], | |
| 1321 | + "isUnique": false, | |
| 1322 | + "concurrently": false, | |
| 1323 | + "method": "btree", | |
| 1324 | + "with": {} | |
| 1325 | + } | |
| 1326 | + }, | |
| 1327 | + "foreignKeys": {}, | |
| 1328 | + "compositePrimaryKeys": { | |
| 1329 | + "tool_scope_id_id_pk": { | |
| 1330 | + "name": "tool_scope_id_id_pk", | |
| 1331 | + "columns": [ | |
| 1332 | + "scope_id", | |
| 1333 | + "id" | |
| 1334 | + ] | |
| 1335 | + } | |
| 1336 | + }, | |
| 1337 | + "uniqueConstraints": {}, | |
| 1338 | + "policies": {}, | |
| 1339 | + "checkConstraints": {}, | |
| 1340 | + "isRLSEnabled": false | |
| 1341 | + }, | |
| 1342 | + "public.workos_vault_metadata": { | |
| 1343 | + "name": "workos_vault_metadata", | |
| 1344 | + "schema": "", | |
| 1345 | + "columns": { | |
| 1346 | + "id": { | |
| 1347 | + "name": "id", | |
| 1348 | + "type": "text", | |
| 1349 | + "primaryKey": false, | |
| 1350 | + "notNull": true | |
| 1351 | + }, | |
| 1352 | + "scope_id": { | |
| 1353 | + "name": "scope_id", | |
| 1354 | + "type": "text", | |
| 1355 | + "primaryKey": false, | |
| 1356 | + "notNull": true | |
| 1357 | + }, | |
| 1358 | + "name": { | |
| 1359 | + "name": "name", | |
| 1360 | + "type": "text", | |
| 1361 | + "primaryKey": false, | |
| 1362 | + "notNull": true | |
| 1363 | + }, | |
| 1364 | + "purpose": { | |
| 1365 | + "name": "purpose", | |
| 1366 | + "type": "text", | |
| 1367 | + "primaryKey": false, | |
| 1368 | + "notNull": false | |
| 1369 | + }, | |
| 1370 | + "created_at": { | |
| 1371 | + "name": "created_at", | |
| 1372 | + "type": "timestamp", | |
| 1373 | + "primaryKey": false, | |
| 1374 | + "notNull": true | |
| 1375 | + } | |
| 1376 | + }, | |
| 1377 | + "indexes": { | |
| 1378 | + "workos_vault_metadata_scope_id_idx": { | |
| 1379 | + "name": "workos_vault_metadata_scope_id_idx", | |
| 1380 | + "columns": [ | |
| 1381 | + { | |
| 1382 | + "expression": "scope_id", | |
| 1383 | + "isExpression": false, | |
| 1384 | + "asc": true, | |
| 1385 | + "nulls": "last" | |
| 1386 | + } | |
| 1387 | + ], | |
| 1388 | + "isUnique": false, | |
| 1389 | + "concurrently": false, | |
| 1390 | + "method": "btree", | |
| 1391 | + "with": {} | |
| 1392 | + } | |
| 1393 | + }, | |
| 1394 | + "foreignKeys": {}, | |
| 1395 | + "compositePrimaryKeys": { | |
| 1396 | + "workos_vault_metadata_scope_id_id_pk": { | |
| 1397 | + "name": "workos_vault_metadata_scope_id_id_pk", | |
| 1398 | + "columns": [ | |
| 1399 | + "scope_id", | |
| 1400 | + "id" | |
| 1401 | + ] | |
| 1402 | + } | |
| 1403 | + }, | |
| 1404 | + "uniqueConstraints": {}, | |
| 1405 | + "policies": {}, | |
| 1406 | + "checkConstraints": {}, | |
| 1407 | + "isRLSEnabled": false | |
| 1408 | + } | |
| 1409 | + }, | |
| 1410 | + "enums": {}, | |
| 1411 | + "schemas": {}, | |
| 1412 | + "sequences": {}, | |
| 1413 | + "roles": {}, | |
| 1414 | + "policies": {}, | |
| 1415 | + "views": {}, | |
| 1416 | + "_meta": { | |
| 1417 | + "columns": {}, | |
| 1418 | + "schemas": {}, | |
| 1419 | + "tables": {} | |
| 1420 | + } | |
| 1421 | +} | |
| @@ -22,6 +22,13 @@ · 21 unmodified lines | ||
| 22 | 22 | "when": 1776709954401, |
| 23 | 23 | "tag": "0002_fat_white_tiger", |
| 24 | 24 | "breakpoints": true |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "idx": 3, | |
| 28 | + "version": "7", | |
| 29 | + "when": 1776728656793, | |
| 30 | + "tag": "0003_add_connections", | |
| 31 | + "breakpoints": true | |
| 25 | 32 | } |
| 26 | 33 | ] |
| 27 | 34 | } |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform"; |
| 2 | 2 | import { Effect, Layer } from "effect"; |
| 3 | 3 | |
| 4 | −import { CoreExecutorApi } from "@executor/api"; | |
| 4 | +import { CoreExecutorApi, InternalError, observabilityMiddleware } from "@executor/api"; | |
| 5 | 5 | import { CoreHandlers } from "@executor/api/server"; |
| 6 | 6 | import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api"; |
| 7 | 7 | import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api"; |
| @@ -19,6 +19,7 @@ import { DbService } from "../services/db"; · 11 unmodified lines | ||
| 19 | 19 | import { TelemetryLive } from "../services/telemetry"; |
| 20 | 20 | import { OrgHttpApi } from "../org/compose"; |
| 21 | 21 | import { OrgHandlers } from "../org/handlers"; |
| 22 | +import { ErrorCaptureLive } from "../observability"; | |
| 22 | 23 | |
| 23 | 24 | import { CoreSharedServices } from "./core-shared-services"; |
| 24 | 25 | |
| @@ -27,8 +28,11 @@ export { CoreSharedServices }; · 2 unmodified lines | ||
| 27 | 28 | const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) |
| 28 | 29 | .add(McpGroup) |
| 29 | 30 | .add(GraphqlGroup) |
| 31 | + .addError(InternalError) | |
| 30 | 32 | .middleware(OrgAuth); |
| 31 | 33 | |
| 34 | +const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); | |
| 35 | + | |
| 32 | 36 | const DbLive = DbService.Live; |
| 33 | 37 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); |
| 34 | 38 | |
| @@ -50,8 +54,10 @@ export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe( · 15 unmodified lines | ||
| 50 | 54 | McpHandlers, |
| 51 | 55 | GraphqlHandlers, |
| 52 | 56 | OrgAuthLive, |
| 57 | + ObservabilityLive, | |
| 53 | 58 | ), |
| 54 | 59 | ), |
| 60 | + Layer.provide(ErrorCaptureLive), | |
| 55 | 61 | ); |
| 56 | 62 | |
| 57 | 63 | const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe( |
| @@ -15,17 +15,28 @@ import { Cause, Effect, Layer } from "effect"; · 14 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | import { ErrorCapture } from "@executor/api"; |
| 17 | 17 | |
| 18 | +// Drizzle/postgres-js include the failing SQL (params + bound values) in | |
| 19 | +// their error message. For OpenAPI source inserts that's 1MB+ of spec | |
| 20 | +// text which blows past terminal scrollback and hides the actual pg | |
| 21 | +// error. Sentry still receives the full, untruncated cause via | |
| 22 | +// `setExtra`; only the dev-console mirror is capped. | |
| 23 | +const MAX_CONSOLE_CAUSE_CHARS = 4_000; | |
| 24 | + | |
| 25 | +const truncate = (s: string): string => | |
| 26 | + s.length <= MAX_CONSOLE_CAUSE_CHARS | |
| 27 | + ? s | |
| 28 | + : `${s.slice(0, MAX_CONSOLE_CAUSE_CHARS)}\n…[truncated ${s.length - MAX_CONSOLE_CAUSE_CHARS} chars]`; | |
| 29 | + | |
| 18 | 30 | export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed( |
| 19 | 31 | ErrorCapture, |
| 20 | 32 | ErrorCapture.of({ |
| 21 | 33 | captureException: (cause) => |
| 22 | 34 | Effect.sync(() => { |
| 23 | 35 | const squashed = Cause.squash(cause); |
| 24 | − // Mirror to console so dev terminals see the defect; Sentry gets the | |
| 25 | − // full structured cause for production correlation. | |
| 26 | − console.error("[api] unhandled cause:", Cause.pretty(cause)); | |
| 36 | + const pretty = Cause.pretty(cause); | |
| 37 | + console.error("[api] unhandled cause:", truncate(pretty)); | |
| 27 | 38 | const eventId = Sentry.captureException(squashed, (scope) => { |
| 28 | − scope.setExtra("cause", Cause.pretty(cause)); | |
| 39 | + scope.setExtra("cause", pretty); | |
| 29 | 40 | return scope; |
| 30 | 41 | }); |
| 31 | 42 | return eventId ?? ""; |
| @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines | ||
| 12 | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | 14 | import { Route as OrgRouteImport } from './routes/org' |
| 15 | +import { Route as ConnectionsRouteImport } from './routes/connections' | |
| 15 | 16 | import { Route as BillingRouteImport } from './routes/billing' |
| 16 | 17 | import { Route as IndexRouteImport } from './routes/index' |
| 17 | 18 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' |
| @@ -33,6 +34,11 @@ const OrgRoute = OrgRouteImport.update({ · 15 unmodified lines | ||
| 33 | 34 | path: '/org', |
| 34 | 35 | getParentRoute: () => rootRouteImport, |
| 35 | 36 | } as any) |
| 37 | +const ConnectionsRoute = ConnectionsRouteImport.update({ | |
| 38 | + id: '/connections', | |
| 39 | + path: '/connections', | |
| 40 | + getParentRoute: () => rootRouteImport, | |
| 41 | +} as any) | |
| 36 | 42 | const BillingRoute = BillingRouteImport.update({ |
| 37 | 43 | id: '/billing', |
| 38 | 44 | path: '/billing', |
| @@ -62,6 +68,7 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ · 23 unmodified lines | ||
| 62 | 68 | export interface FileRoutesByFullPath { |
| 63 | 69 | '/': typeof IndexRoute |
| 64 | 70 | '/billing': typeof BillingRoute |
| 71 | + '/connections': typeof ConnectionsRoute | |
| 65 | 72 | '/org': typeof OrgRoute |
| 66 | 73 | '/secrets': typeof SecretsRoute |
| 67 | 74 | '/tools': typeof ToolsRoute |
| @@ -72,6 +79,7 @@ export interface FileRoutesByFullPath { · 4 unmodified lines | ||
| 72 | 79 | export interface FileRoutesByTo { |
| 73 | 80 | '/': typeof IndexRoute |
| 74 | 81 | '/billing': typeof BillingRoute |
| 82 | + '/connections': typeof ConnectionsRoute | |
| 75 | 83 | '/org': typeof OrgRoute |
| 76 | 84 | '/secrets': typeof SecretsRoute |
| 77 | 85 | '/tools': typeof ToolsRoute |
| @@ -83,6 +91,7 @@ export interface FileRoutesById { · 5 unmodified lines | ||
| 83 | 91 | __root__: typeof rootRouteImport |
| 84 | 92 | '/': typeof IndexRoute |
| 85 | 93 | '/billing': typeof BillingRoute |
| 94 | + '/connections': typeof ConnectionsRoute | |
| 86 | 95 | '/org': typeof OrgRoute |
| 87 | 96 | '/secrets': typeof SecretsRoute |
| 88 | 97 | '/tools': typeof ToolsRoute |
| @@ -95,6 +104,7 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 95 | 104 | fullPaths: |
| 96 | 105 | | '/' |
| 97 | 106 | | '/billing' |
| 107 | + | '/connections' | |
| 98 | 108 | | '/org' |
| 99 | 109 | | '/secrets' |
| 100 | 110 | | '/tools' |
| @@ -105,6 +115,7 @@ export interface FileRouteTypes { · 4 unmodified lines | ||
| 105 | 115 | to: |
| 106 | 116 | | '/' |
| 107 | 117 | | '/billing' |
| 118 | + | '/connections' | |
| 108 | 119 | | '/org' |
| 109 | 120 | | '/secrets' |
| 110 | 121 | | '/tools' |
| @@ -115,6 +126,7 @@ export interface FileRouteTypes { · 4 unmodified lines | ||
| 115 | 126 | | '__root__' |
| 116 | 127 | | '/' |
| 117 | 128 | | '/billing' |
| 129 | + | '/connections' | |
| 118 | 130 | | '/org' |
| 119 | 131 | | '/secrets' |
| 120 | 132 | | '/tools' |
| @@ -126,6 +138,7 @@ export interface FileRouteTypes { · 5 unmodified lines | ||
| 126 | 138 | export interface RootRouteChildren { |
| 127 | 139 | IndexRoute: typeof IndexRoute |
| 128 | 140 | BillingRoute: typeof BillingRoute |
| 141 | + ConnectionsRoute: typeof ConnectionsRoute | |
| 129 | 142 | OrgRoute: typeof OrgRoute |
| 130 | 143 | SecretsRoute: typeof SecretsRoute |
| 131 | 144 | ToolsRoute: typeof ToolsRoute |
| @@ -157,6 +170,13 @@ declare module '@tanstack/react-router' { · 25 unmodified lines | ||
| 157 | 170 | preLoaderRoute: typeof OrgRouteImport |
| 158 | 171 | parentRoute: typeof rootRouteImport |
| 159 | 172 | } |
| 173 | + '/connections': { | |
| 174 | + id: '/connections' | |
| 175 | + path: '/connections' | |
| 176 | + fullPath: '/connections' | |
| 177 | + preLoaderRoute: typeof ConnectionsRouteImport | |
| 178 | + parentRoute: typeof rootRouteImport | |
| 179 | + } | |
| 160 | 180 | '/billing': { |
| 161 | 181 | id: '/billing' |
| 162 | 182 | path: '/billing' |
| @@ -198,6 +218,7 @@ declare module '@tanstack/react-router' { · 35 unmodified lines | ||
| 198 | 218 | const rootRouteChildren: RootRouteChildren = { |
| 199 | 219 | IndexRoute: IndexRoute, |
| 200 | 220 | BillingRoute: BillingRoute, |
| 221 | + ConnectionsRoute: ConnectionsRoute, | |
| 201 | 222 | OrgRoute: OrgRoute, |
| 202 | 223 | SecretsRoute: SecretsRoute, |
| 203 | 224 | ToolsRoute: ToolsRoute, |
| @@ -0,0 +1,6 @@ | ||
| 1 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | +import { ConnectionsPage } from "@executor/react/pages/connections"; | |
| 3 | + | |
| 4 | +export const Route = createFileRoute("/connections")({ | |
| 5 | + component: () => <ConnectionsPage />, | |
| 6 | +}); | |
| @@ -56,11 +56,32 @@ export const secret = pgTable("secret", { · 55 unmodified lines | ||
| 56 | 56 | scope_id: text('scope_id').notNull(), |
| 57 | 57 | name: text('name').notNull(), |
| 58 | 58 | provider: text('provider').notNull(), |
| 59 | + owned_by_connection_id: text('owned_by_connection_id'), | |
| 59 | 60 | created_at: timestamp('created_at').notNull() |
| 60 | 61 | }, (table) => [ |
| 61 | 62 | primaryKey({ columns: [table.scope_id, table.id] }), |
| 62 | 63 | index("secret_scope_id_idx").on(table.scope_id), |
| 63 | 64 | index("secret_provider_idx").on(table.provider), |
| 65 | + index("secret_owned_by_connection_id_idx").on(table.owned_by_connection_id), | |
| 66 | +]); | |
| 67 | + | |
| 68 | +export const connection = pgTable("connection", { | |
| 69 | + id: text('id').notNull(), | |
| 70 | + scope_id: text('scope_id').notNull(), | |
| 71 | + provider: text('provider').notNull(), | |
| 72 | + kind: text('kind').notNull(), | |
| 73 | + identity_label: text('identity_label'), | |
| 74 | + access_token_secret_id: text('access_token_secret_id').notNull(), | |
| 75 | + refresh_token_secret_id: text('refresh_token_secret_id'), | |
| 76 | + expires_at: bigint('expires_at', { mode: 'number' }), | |
| 77 | + scope: text('scope'), | |
| 78 | + provider_state: jsonb('provider_state'), | |
| 79 | + created_at: timestamp('created_at').notNull(), | |
| 80 | + updated_at: timestamp('updated_at').notNull() | |
| 81 | +}, (table) => [ | |
| 82 | + primaryKey({ columns: [table.scope_id, table.id] }), | |
| 83 | + index("connection_scope_id_idx").on(table.scope_id), | |
| 84 | + index("connection_provider_idx").on(table.provider), | |
| 64 | 85 | ]); |
| 65 | 86 | |
| 66 | 87 | export const openapi_source = pgTable("openapi_source", { |
| @@ -15,16 +15,16 @@ · 14 unmodified lines | ||
| 15 | 15 | // Two scenarios: |
| 16 | 16 | // |
| 17 | 17 | // 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts |
| 18 | −// tokens landed in the invoker's secret store under the stable | |
| 19 | −// `mcp_<ns>_*` ids and that source-level OAuth state (clientInfo, | |
| 20 | −// AS URL) is echoed back on the response. | |
| 18 | +// the response carries the Connection id the exchange minted and | |
| 19 | +// that source-level OAuth state (clientInfo, AS URL) is echoed back | |
| 20 | +// so subsequent users can skip DCR. | |
| 21 | 21 | // |
| 22 | 22 | // 2. Two users, same source: second user's startOAuth passes the |
| 23 | 23 | // clientInformation the first user's completeOAuth returned. The |
| 24 | 24 | // DCR endpoint counter on the fake server must NOT tick — proof |
| 25 | −// the DCR-hoist code path re-uses the existing client. Then both | |
| 26 | −// users' tokens must resolve independently at their own scope | |
| 27 | −// despite sharing the same secret id. | |
| 25 | +// the DCR-hoist code path re-uses the existing client. Both users | |
| 26 | +// end up with their own Connection (same id, different scope) via | |
| 27 | +// the SDK's innermost-wins shadowing. | |
| 28 | 28 | // --------------------------------------------------------------------------- |
| 29 | 29 | |
| 30 | 30 | import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; |
| @@ -33,7 +33,7 @@ import type { AddressInfo } from "node:net"; · 2 unmodified lines | ||
| 33 | 33 | import { createHash, randomBytes } from "node:crypto"; |
| 34 | 34 | |
| 35 | 35 | import { Effect } from "effect"; |
| 36 | −import { ScopeId, SecretId } from "@executor/sdk"; | |
| 36 | +import { ScopeId } from "@executor/sdk"; | |
| 37 | 37 | |
| 38 | 38 | import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; |
| 39 | 39 | |
| @@ -282,8 +282,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 242 unmodified lines | ||
| 282 | 282 | const userId = `user_${crypto.randomUUID()}`; |
| 283 | 283 | const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId)); |
| 284 | 284 | const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; |
| 285 | − const accessTokenSecretId = `mcp_${namespace}_access_token`; | |
| 286 | − const refreshTokenSecretId = `mcp_${namespace}_refresh_token`; | |
| 285 | + const connectionId = `mcp-oauth2-${namespace}`; | |
| 287 | 286 | const redirectUrl = "http://test.local/api/mcp/oauth/callback"; |
| 288 | 287 | |
| 289 | 288 | const started = yield* asUser(userId, orgId, (client) => |
| @@ -292,8 +291,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 2 unmodified lines | ||
| 292 | 291 | payload: { |
| 293 | 292 | endpoint: `${fake.url}/mcp`, |
| 294 | 293 | redirectUrl, |
| 295 | − accessTokenSecretId, | |
| 296 | − refreshTokenSecretId, | |
| 294 | + connectionId, | |
| 297 | 295 | }, |
| 298 | 296 | }), |
| 299 | 297 | ); |
| @@ -310,34 +308,12 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 10 unmodified lines | ||
| 310 | 308 | payload: { state, code }, |
| 311 | 309 | }), |
| 312 | 310 | ); |
| 313 | − expect(completed.accessTokenSecretId).toBe(accessTokenSecretId); | |
| 314 | − expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId); | |
| 311 | + expect(completed.connectionId).toBe(connectionId); | |
| 315 | 312 | expect(completed.tokenType).toBe("Bearer"); |
| 316 | 313 | // DCR hoist: the source-level state the UI will persist on the |
| 317 | 314 | // source's auth config, so the next user skips DCR entirely. |
| 318 | 315 | expect(completed.clientInformation).not.toBeNull(); |
| 319 | 316 | expect(completed.authorizationServerUrl).not.toBeNull(); |
| 320 | − | |
| 321 | − // Tokens landed at the user scope via the stable ids. | |
| 322 | − const access = yield* asUser(userId, orgId, (client) => | |
| 323 | − client.secrets.resolve({ | |
| 324 | − path: { | |
| 325 | − scopeId: userScope, | |
| 326 | − secretId: SecretId.make(accessTokenSecretId), | |
| 327 | − }, | |
| 328 | − }), | |
| 329 | − ); | |
| 330 | − expect(access.value).toMatch(/^at_/); | |
| 331 | − | |
| 332 | − const refresh = yield* asUser(userId, orgId, (client) => | |
| 333 | − client.secrets.resolve({ | |
| 334 | − path: { | |
| 335 | − scopeId: userScope, | |
| 336 | − secretId: SecretId.make(refreshTokenSecretId), | |
| 337 | − }, | |
| 338 | − }), | |
| 339 | − ); | |
| 340 | − expect(refresh.value).toMatch(/^rt_/); | |
| 341 | 317 | }), |
| 342 | 318 | 30_000, |
| 343 | 319 | ); |
| @@ -352,8 +328,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 8 unmodified lines | ||
| 352 | 328 | const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId)); |
| 353 | 329 | const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId)); |
| 354 | 330 | const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; |
| 355 | − const accessTokenSecretId = `mcp_${namespace}_access_token`; | |
| 356 | − const refreshTokenSecretId = `mcp_${namespace}_refresh_token`; | |
| 331 | + const connectionId = `mcp-oauth2-${namespace}`; | |
| 357 | 332 | const endpoint = `${fake.url}/mcp`; |
| 358 | 333 | const redirectUrl = "http://test.local/api/mcp/oauth/callback"; |
| 359 | 334 | |
| @@ -366,8 +341,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 6 unmodified lines | ||
| 366 | 341 | payload: { |
| 367 | 342 | endpoint, |
| 368 | 343 | redirectUrl, |
| 369 | − accessTokenSecretId, | |
| 370 | − refreshTokenSecretId, | |
| 344 | + connectionId, | |
| 371 | 345 | }, |
| 372 | 346 | }), |
| 373 | 347 | ); |
| @@ -380,6 +354,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 6 unmodified lines | ||
| 380 | 354 | payload: { state: redirA.state, code: redirA.code }, |
| 381 | 355 | }), |
| 382 | 356 | ); |
| 357 | + expect(completedA.connectionId).toBe(connectionId); | |
| 383 | 358 | expect(completedA.clientInformation).not.toBeNull(); |
| 384 | 359 | expect(fake.registrations()).toBe(regsBefore + 1); |
| 385 | 360 | |
| @@ -390,8 +365,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 4 unmodified lines | ||
| 390 | 365 | payload: { |
| 391 | 366 | endpoint, |
| 392 | 367 | redirectUrl, |
| 393 | − accessTokenSecretId, | |
| 394 | − refreshTokenSecretId, | |
| 368 | + connectionId, | |
| 395 | 369 | clientInformation: completedA.clientInformation, |
| 396 | 370 | authorizationServerUrl: completedA.authorizationServerUrl, |
| 397 | 371 | resourceMetadataUrl: completedA.resourceMetadataUrl, |
| @@ -401,28 +375,14 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 3 unmodified lines | ||
| 401 | 375 | const redirB = yield* Effect.promise(() => |
| 402 | 376 | followAuthorize(startedB.authorizationUrl), |
| 403 | 377 | ); |
| 404 | − yield* asUser(userB, orgId, (client) => | |
| 378 | + const completedB = yield* asUser(userB, orgId, (client) => | |
| 405 | 379 | client.mcp.completeOAuth({ |
| 406 | 380 | path: { scopeId: scopeB }, |
| 407 | 381 | payload: { state: redirB.state, code: redirB.code }, |
| 408 | 382 | }), |
| 409 | 383 | ); |
| 384 | + expect(completedB.connectionId).toBe(connectionId); | |
| 410 | 385 | expect(fake.registrations()).toBe(regsBefore + 1); |
| 411 | − | |
| 412 | − // --- Per-user tokens despite the shared secret id. --- | |
| 413 | − const tokenA = yield* asUser(userA, orgId, (client) => | |
| 414 | − client.secrets.resolve({ | |
| 415 | − path: { scopeId: scopeA, secretId: SecretId.make(accessTokenSecretId) }, | |
| 416 | − }), | |
| 417 | − ); | |
| 418 | − const tokenB = yield* asUser(userB, orgId, (client) => | |
| 419 | − client.secrets.resolve({ | |
| 420 | − path: { scopeId: scopeB, secretId: SecretId.make(accessTokenSecretId) }, | |
| 421 | − }), | |
| 422 | − ); | |
| 423 | − expect(tokenA.value).not.toBe(tokenB.value); | |
| 424 | − expect(tokenA.value).toMatch(/^at_/); | |
| 425 | − expect(tokenB.value).toMatch(/^at_/); | |
| 426 | 386 | }), |
| 427 | 387 | 30_000, |
| 428 | 388 | ); |
| @@ -360,6 +360,7 @@ function UserFooter() { · 359 unmodified lines | ||
| 360 | 360 | function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { |
| 361 | 361 | const isHome = props.pathname === "/"; |
| 362 | 362 | const isSecrets = props.pathname === "/secrets"; |
| 363 | + const isConnections = props.pathname === "/connections"; | |
| 363 | 364 | const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); |
| 364 | 365 | const isOrg = props.pathname === "/org"; |
| 365 | 366 | |
| @@ -375,6 +376,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 9 unmodified lines | ||
| 375 | 376 | |
| 376 | 377 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> |
| 377 | 378 | <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} /> |
| 379 | + <NavItem to="/connections" label="Connections" active={isConnections} onNavigate={props.onNavigate} /> | |
| 378 | 380 | <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} /> |
| 379 | 381 | <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} /> |
| 380 | 382 | <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} /> |
| @@ -0,0 +1,20 @@ | ||
| 1 | +CREATE TABLE `connection` ( | |
| 2 | + `id` text NOT NULL, | |
| 3 | + `scope_id` text NOT NULL, | |
| 4 | + `provider` text NOT NULL, | |
| 5 | + `kind` text NOT NULL, | |
| 6 | + `identity_label` text, | |
| 7 | + `access_token_secret_id` text NOT NULL, | |
| 8 | + `refresh_token_secret_id` text, | |
| 9 | + `expires_at` integer, | |
| 10 | + `scope` text, | |
| 11 | + `provider_state` text, | |
| 12 | + `created_at` integer NOT NULL, | |
| 13 | + `updated_at` integer NOT NULL, | |
| 14 | + PRIMARY KEY(`scope_id`, `id`) | |
| 15 | +); | |
| 16 | +--> statement-breakpoint | |
| 17 | +CREATE INDEX `connection_scope_id_idx` ON `connection` (`scope_id`);--> statement-breakpoint | |
| 18 | +CREATE INDEX `connection_provider_idx` ON `connection` (`provider`);--> statement-breakpoint | |
| 19 | +ALTER TABLE `secret` ADD `owned_by_connection_id` text;--> statement-breakpoint | |
| 20 | +CREATE INDEX `secret_owned_by_connection_id_idx` ON `secret` (`owned_by_connection_id`); | |
| @@ -0,0 +1,1274 @@ | ||
| 1 | +{ | |
| 2 | + "version": "6", | |
| 3 | + "dialect": "sqlite", | |
| 4 | + "id": "8778ceaf-ae8f-4fae-8484-4b0b2e3abe66", | |
| 5 | + "prevId": "acff0d94-170c-40bb-9271-dc3a429ba0ce", | |
| 6 | + "tables": { | |
| 7 | + "blob": { | |
| 8 | + "name": "blob", | |
| 9 | + "columns": { | |
| 10 | + "namespace": { | |
| 11 | + "name": "namespace", | |
| 12 | + "type": "text", | |
| 13 | + "primaryKey": false, | |
| 14 | + "notNull": true, | |
| 15 | + "autoincrement": false | |
| 16 | + }, | |
| 17 | + "key": { | |
| 18 | + "name": "key", | |
| 19 | + "type": "text", | |
| 20 | + "primaryKey": false, | |
| 21 | + "notNull": true, | |
| 22 | + "autoincrement": false | |
| 23 | + }, | |
| 24 | + "value": { | |
| 25 | + "name": "value", | |
| 26 | + "type": "text", | |
| 27 | + "primaryKey": false, | |
| 28 | + "notNull": true, | |
| 29 | + "autoincrement": false | |
| 30 | + } | |
| 31 | + }, | |
| 32 | + "indexes": {}, | |
| 33 | + "foreignKeys": {}, | |
| 34 | + "compositePrimaryKeys": { | |
| 35 | + "blob_namespace_key_pk": { | |
| 36 | + "columns": [ | |
| 37 | + "namespace", | |
| 38 | + "key" | |
| 39 | + ], | |
| 40 | + "name": "blob_namespace_key_pk" | |
| 41 | + } | |
| 42 | + }, | |
| 43 | + "uniqueConstraints": {}, | |
| 44 | + "checkConstraints": {} | |
| 45 | + }, | |
| 46 | + "connection": { | |
| 47 | + "name": "connection", | |
| 48 | + "columns": { | |
| 49 | + "id": { | |
| 50 | + "name": "id", | |
| 51 | + "type": "text", | |
| 52 | + "primaryKey": false, | |
| 53 | + "notNull": true, | |
| 54 | + "autoincrement": false | |
| 55 | + }, | |
| 56 | + "scope_id": { | |
| 57 | + "name": "scope_id", | |
| 58 | + "type": "text", | |
| 59 | + "primaryKey": false, | |
| 60 | + "notNull": true, | |
| 61 | + "autoincrement": false | |
| 62 | + }, | |
| 63 | + "provider": { | |
| 64 | + "name": "provider", | |
| 65 | + "type": "text", | |
| 66 | + "primaryKey": false, | |
| 67 | + "notNull": true, | |
| 68 | + "autoincrement": false | |
| 69 | + }, | |
| 70 | + "kind": { | |
| 71 | + "name": "kind", | |
| 72 | + "type": "text", | |
| 73 | + "primaryKey": false, | |
| 74 | + "notNull": true, | |
| 75 | + "autoincrement": false | |
| 76 | + }, | |
| 77 | + "identity_label": { | |
| 78 | + "name": "identity_label", | |
| 79 | + "type": "text", | |
| 80 | + "primaryKey": false, | |
| 81 | + "notNull": false, | |
| 82 | + "autoincrement": false | |
| 83 | + }, | |
| 84 | + "access_token_secret_id": { | |
| 85 | + "name": "access_token_secret_id", | |
| 86 | + "type": "text", | |
| 87 | + "primaryKey": false, | |
| 88 | + "notNull": true, | |
| 89 | + "autoincrement": false | |
| 90 | + }, | |
| 91 | + "refresh_token_secret_id": { | |
| 92 | + "name": "refresh_token_secret_id", | |
| 93 | + "type": "text", | |
| 94 | + "primaryKey": false, | |
| 95 | + "notNull": false, | |
| 96 | + "autoincrement": false | |
| 97 | + }, | |
| 98 | + "expires_at": { | |
| 99 | + "name": "expires_at", | |
| 100 | + "type": "integer", | |
| 101 | + "primaryKey": false, | |
| 102 | + "notNull": false, | |
| 103 | + "autoincrement": false | |
| 104 | + }, | |
| 105 | + "scope": { | |
| 106 | + "name": "scope", | |
| 107 | + "type": "text", | |
| 108 | + "primaryKey": false, | |
| 109 | + "notNull": false, | |
| 110 | + "autoincrement": false | |
| 111 | + }, | |
| 112 | + "provider_state": { | |
| 113 | + "name": "provider_state", | |
| 114 | + "type": "text", | |
| 115 | + "primaryKey": false, | |
| 116 | + "notNull": false, | |
| 117 | + "autoincrement": false | |
| 118 | + }, | |
| 119 | + "created_at": { | |
| 120 | + "name": "created_at", | |
| 121 | + "type": "integer", | |
| 122 | + "primaryKey": false, | |
| 123 | + "notNull": true, | |
| 124 | + "autoincrement": false | |
| 125 | + }, | |
| 126 | + "updated_at": { | |
| 127 | + "name": "updated_at", | |
| 128 | + "type": "integer", | |
| 129 | + "primaryKey": false, | |
| 130 | + "notNull": true, | |
| 131 | + "autoincrement": false | |
| 132 | + } | |
| 133 | + }, | |
| 134 | + "indexes": { | |
| 135 | + "connection_scope_id_idx": { | |
| 136 | + "name": "connection_scope_id_idx", | |
| 137 | + "columns": [ | |
| 138 | + "scope_id" | |
| 139 | + ], | |
| 140 | + "isUnique": false | |
| 141 | + }, | |
| 142 | + "connection_provider_idx": { | |
| 143 | + "name": "connection_provider_idx", | |
| 144 | + "columns": [ | |
| 145 | + "provider" | |
| 146 | + ], | |
| 147 | + "isUnique": false | |
| 148 | + } | |
| 149 | + }, | |
| 150 | + "foreignKeys": {}, | |
| 151 | + "compositePrimaryKeys": { | |
| 152 | + "connection_scope_id_id_pk": { | |
| 153 | + "columns": [ | |
| 154 | + "scope_id", | |
| 155 | + "id" | |
| 156 | + ], | |
| 157 | + "name": "connection_scope_id_id_pk" | |
| 158 | + } | |
| 159 | + }, | |
| 160 | + "uniqueConstraints": {}, | |
| 161 | + "checkConstraints": {} | |
| 162 | + }, | |
| 163 | + "definition": { | |
| 164 | + "name": "definition", | |
| 165 | + "columns": { | |
| 166 | + "id": { | |
| 167 | + "name": "id", | |
| 168 | + "type": "text", | |
| 169 | + "primaryKey": false, | |
| 170 | + "notNull": true, | |
| 171 | + "autoincrement": false | |
| 172 | + }, | |
| 173 | + "scope_id": { | |
| 174 | + "name": "scope_id", | |
| 175 | + "type": "text", | |
| 176 | + "primaryKey": false, | |
| 177 | + "notNull": true, | |
| 178 | + "autoincrement": false | |
| 179 | + }, | |
| 180 | + "source_id": { | |
| 181 | + "name": "source_id", | |
| 182 | + "type": "text", | |
| 183 | + "primaryKey": false, | |
| 184 | + "notNull": true, | |
| 185 | + "autoincrement": false | |
| 186 | + }, | |
| 187 | + "plugin_id": { | |
| 188 | + "name": "plugin_id", | |
| 189 | + "type": "text", | |
| 190 | + "primaryKey": false, | |
| 191 | + "notNull": true, | |
| 192 | + "autoincrement": false | |
| 193 | + }, | |
| 194 | + "name": { | |
| 195 | + "name": "name", | |
| 196 | + "type": "text", | |
| 197 | + "primaryKey": false, | |
| 198 | + "notNull": true, | |
| 199 | + "autoincrement": false | |
| 200 | + }, | |
| 201 | + "schema": { | |
| 202 | + "name": "schema", | |
| 203 | + "type": "text", | |
| 204 | + "primaryKey": false, | |
| 205 | + "notNull": true, | |
| 206 | + "autoincrement": false | |
| 207 | + }, | |
| 208 | + "created_at": { | |
| 209 | + "name": "created_at", | |
| 210 | + "type": "integer", | |
| 211 | + "primaryKey": false, | |
| 212 | + "notNull": true, | |
| 213 | + "autoincrement": false | |
| 214 | + } | |
| 215 | + }, | |
| 216 | + "indexes": { | |
| 217 | + "definition_scope_id_idx": { | |
| 218 | + "name": "definition_scope_id_idx", | |
| 219 | + "columns": [ | |
| 220 | + "scope_id" | |
| 221 | + ], | |
| 222 | + "isUnique": false | |
| 223 | + }, | |
| 224 | + "definition_source_id_idx": { | |
| 225 | + "name": "definition_source_id_idx", | |
| 226 | + "columns": [ | |
| 227 | + "source_id" | |
| 228 | + ], | |
| 229 | + "isUnique": false | |
| 230 | + }, | |
| 231 | + "definition_plugin_id_idx": { | |
| 232 | + "name": "definition_plugin_id_idx", | |
| 233 | + "columns": [ | |
| 234 | + "plugin_id" | |
| 235 | + ], | |
| 236 | + "isUnique": false | |
| 237 | + } | |
| 238 | + }, | |
| 239 | + "foreignKeys": {}, | |
| 240 | + "compositePrimaryKeys": { | |
| 241 | + "definition_scope_id_id_pk": { | |
| 242 | + "columns": [ | |
| 243 | + "scope_id", | |
| 244 | + "id" | |
| 245 | + ], | |
| 246 | + "name": "definition_scope_id_id_pk" | |
| 247 | + } | |
| 248 | + }, | |
| 249 | + "uniqueConstraints": {}, | |
| 250 | + "checkConstraints": {} | |
| 251 | + }, | |
| 252 | + "google_discovery_binding": { | |
| 253 | + "name": "google_discovery_binding", | |
| 254 | + "columns": { | |
| 255 | + "id": { | |
| 256 | + "name": "id", | |
| 257 | + "type": "text", | |
| 258 | + "primaryKey": false, | |
| 259 | + "notNull": true, | |
| 260 | + "autoincrement": false | |
| 261 | + }, | |
| 262 | + "scope_id": { | |
| 263 | + "name": "scope_id", | |
| 264 | + "type": "text", | |
| 265 | + "primaryKey": false, | |
| 266 | + "notNull": true, | |
| 267 | + "autoincrement": false | |
| 268 | + }, | |
| 269 | + "source_id": { | |
| 270 | + "name": "source_id", | |
| 271 | + "type": "text", | |
| 272 | + "primaryKey": false, | |
| 273 | + "notNull": true, | |
| 274 | + "autoincrement": false | |
| 275 | + }, | |
| 276 | + "binding": { | |
| 277 | + "name": "binding", | |
| 278 | + "type": "text", | |
| 279 | + "primaryKey": false, | |
| 280 | + "notNull": true, | |
| 281 | + "autoincrement": false | |
| 282 | + }, | |
| 283 | + "created_at": { | |
| 284 | + "name": "created_at", | |
| 285 | + "type": "integer", | |
| 286 | + "primaryKey": false, | |
| 287 | + "notNull": true, | |
| 288 | + "autoincrement": false | |
| 289 | + } | |
| 290 | + }, | |
| 291 | + "indexes": { | |
| 292 | + "google_discovery_binding_scope_id_idx": { | |
| 293 | + "name": "google_discovery_binding_scope_id_idx", | |
| 294 | + "columns": [ | |
| 295 | + "scope_id" | |
| 296 | + ], | |
| 297 | + "isUnique": false | |
| 298 | + }, | |
| 299 | + "google_discovery_binding_source_id_idx": { | |
| 300 | + "name": "google_discovery_binding_source_id_idx", | |
| 301 | + "columns": [ | |
| 302 | + "source_id" | |
| 303 | + ], | |
| 304 | + "isUnique": false | |
| 305 | + } | |
| 306 | + }, | |
| 307 | + "foreignKeys": {}, | |
| 308 | + "compositePrimaryKeys": { | |
| 309 | + "google_discovery_binding_scope_id_id_pk": { | |
| 310 | + "columns": [ | |
| 311 | + "scope_id", | |
| 312 | + "id" | |
| 313 | + ], | |
| 314 | + "name": "google_discovery_binding_scope_id_id_pk" | |
| 315 | + } | |
| 316 | + }, | |
| 317 | + "uniqueConstraints": {}, | |
| 318 | + "checkConstraints": {} | |
| 319 | + }, | |
| 320 | + "google_discovery_oauth_session": { | |
| 321 | + "name": "google_discovery_oauth_session", | |
| 322 | + "columns": { | |
| 323 | + "id": { | |
| 324 | + "name": "id", | |
| 325 | + "type": "text", | |
| 326 | + "primaryKey": false, | |
| 327 | + "notNull": true, | |
| 328 | + "autoincrement": false | |
| 329 | + }, | |
| 330 | + "scope_id": { | |
| 331 | + "name": "scope_id", | |
| 332 | + "type": "text", | |
| 333 | + "primaryKey": false, | |
| 334 | + "notNull": true, | |
| 335 | + "autoincrement": false | |
| 336 | + }, | |
| 337 | + "session": { | |
| 338 | + "name": "session", | |
| 339 | + "type": "text", | |
| 340 | + "primaryKey": false, | |
| 341 | + "notNull": true, | |
| 342 | + "autoincrement": false | |
| 343 | + }, | |
| 344 | + "expires_at": { | |
| 345 | + "name": "expires_at", | |
| 346 | + "type": "integer", | |
| 347 | + "primaryKey": false, | |
| 348 | + "notNull": true, | |
| 349 | + "autoincrement": false | |
| 350 | + } | |
| 351 | + }, | |
| 352 | + "indexes": { | |
| 353 | + "google_discovery_oauth_session_scope_id_idx": { | |
| 354 | + "name": "google_discovery_oauth_session_scope_id_idx", | |
| 355 | + "columns": [ | |
| 356 | + "scope_id" | |
| 357 | + ], | |
| 358 | + "isUnique": false | |
| 359 | + } | |
| 360 | + }, | |
| 361 | + "foreignKeys": {}, | |
| 362 | + "compositePrimaryKeys": { | |
| 363 | + "google_discovery_oauth_session_scope_id_id_pk": { | |
| 364 | + "columns": [ | |
| 365 | + "scope_id", | |
| 366 | + "id" | |
| 367 | + ], | |
| 368 | + "name": "google_discovery_oauth_session_scope_id_id_pk" | |
| 369 | + } | |
| 370 | + }, | |
| 371 | + "uniqueConstraints": {}, | |
| 372 | + "checkConstraints": {} | |
| 373 | + }, | |
| 374 | + "google_discovery_source": { | |
| 375 | + "name": "google_discovery_source", | |
| 376 | + "columns": { | |
| 377 | + "id": { | |
| 378 | + "name": "id", | |
| 379 | + "type": "text", | |
| 380 | + "primaryKey": false, | |
| 381 | + "notNull": true, | |
| 382 | + "autoincrement": false | |
| 383 | + }, | |
| 384 | + "scope_id": { | |
| 385 | + "name": "scope_id", | |
| 386 | + "type": "text", | |
| 387 | + "primaryKey": false, | |
| 388 | + "notNull": true, | |
| 389 | + "autoincrement": false | |
| 390 | + }, | |
| 391 | + "name": { | |
| 392 | + "name": "name", | |
| 393 | + "type": "text", | |
| 394 | + "primaryKey": false, | |
| 395 | + "notNull": true, | |
| 396 | + "autoincrement": false | |
| 397 | + }, | |
| 398 | + "config": { | |
| 399 | + "name": "config", | |
| 400 | + "type": "text", | |
| 401 | + "primaryKey": false, | |
| 402 | + "notNull": true, | |
| 403 | + "autoincrement": false | |
| 404 | + }, | |
| 405 | + "created_at": { | |
| 406 | + "name": "created_at", | |
| 407 | + "type": "integer", | |
| 408 | + "primaryKey": false, | |
| 409 | + "notNull": true, | |
| 410 | + "autoincrement": false | |
| 411 | + }, | |
| 412 | + "updated_at": { | |
| 413 | + "name": "updated_at", | |
| 414 | + "type": "integer", | |
| 415 | + "primaryKey": false, | |
| 416 | + "notNull": true, | |
| 417 | + "autoincrement": false | |
| 418 | + } | |
| 419 | + }, | |
| 420 | + "indexes": { | |
| 421 | + "google_discovery_source_scope_id_idx": { | |
| 422 | + "name": "google_discovery_source_scope_id_idx", | |
| 423 | + "columns": [ | |
| 424 | + "scope_id" | |
| 425 | + ], | |
| 426 | + "isUnique": false | |
| 427 | + } | |
| 428 | + }, | |
| 429 | + "foreignKeys": {}, | |
| 430 | + "compositePrimaryKeys": { | |
| 431 | + "google_discovery_source_scope_id_id_pk": { | |
| 432 | + "columns": [ | |
| 433 | + "scope_id", | |
| 434 | + "id" | |
| 435 | + ], | |
| 436 | + "name": "google_discovery_source_scope_id_id_pk" | |
| 437 | + } | |
| 438 | + }, | |
| 439 | + "uniqueConstraints": {}, | |
| 440 | + "checkConstraints": {} | |
| 441 | + }, | |
| 442 | + "graphql_operation": { | |
| 443 | + "name": "graphql_operation", | |
| 444 | + "columns": { | |
| 445 | + "id": { | |
| 446 | + "name": "id", | |
| 447 | + "type": "text", | |
| 448 | + "primaryKey": false, | |
| 449 | + "notNull": true, | |
| 450 | + "autoincrement": false | |
| 451 | + }, | |
| 452 | + "scope_id": { | |
| 453 | + "name": "scope_id", | |
| 454 | + "type": "text", | |
| 455 | + "primaryKey": false, | |
| 456 | + "notNull": true, | |
| 457 | + "autoincrement": false | |
| 458 | + }, | |
| 459 | + "source_id": { | |
| 460 | + "name": "source_id", | |
| 461 | + "type": "text", | |
| 462 | + "primaryKey": false, | |
| 463 | + "notNull": true, | |
| 464 | + "autoincrement": false | |
| 465 | + }, | |
| 466 | + "binding": { | |
| 467 | + "name": "binding", | |
| 468 | + "type": "text", | |
| 469 | + "primaryKey": false, | |
| 470 | + "notNull": true, | |
| 471 | + "autoincrement": false | |
| 472 | + } | |
| 473 | + }, | |
| 474 | + "indexes": { | |
| 475 | + "graphql_operation_scope_id_idx": { | |
| 476 | + "name": "graphql_operation_scope_id_idx", | |
| 477 | + "columns": [ | |
| 478 | + "scope_id" | |
| 479 | + ], | |
| 480 | + "isUnique": false | |
| 481 | + }, | |
| 482 | + "graphql_operation_source_id_idx": { | |
| 483 | + "name": "graphql_operation_source_id_idx", | |
| 484 | + "columns": [ | |
| 485 | + "source_id" | |
| 486 | + ], | |
| 487 | + "isUnique": false | |
| 488 | + } | |
| 489 | + }, | |
| 490 | + "foreignKeys": {}, | |
| 491 | + "compositePrimaryKeys": { | |
| 492 | + "graphql_operation_scope_id_id_pk": { | |
| 493 | + "columns": [ | |
| 494 | + "scope_id", | |
| 495 | + "id" | |
| 496 | + ], | |
| 497 | + "name": "graphql_operation_scope_id_id_pk" | |
| 498 | + } | |
| 499 | + }, | |
| 500 | + "uniqueConstraints": {}, | |
| 501 | + "checkConstraints": {} | |
| 502 | + }, | |
| 503 | + "graphql_source": { | |
| 504 | + "name": "graphql_source", | |
| 505 | + "columns": { | |
| 506 | + "id": { | |
| 507 | + "name": "id", | |
| 508 | + "type": "text", | |
| 509 | + "primaryKey": false, | |
| 510 | + "notNull": true, | |
| 511 | + "autoincrement": false | |
| 512 | + }, | |
| 513 | + "scope_id": { | |
| 514 | + "name": "scope_id", | |
| 515 | + "type": "text", | |
| 516 | + "primaryKey": false, | |
| 517 | + "notNull": true, | |
| 518 | + "autoincrement": false | |
| 519 | + }, | |
| 520 | + "name": { | |
| 521 | + "name": "name", | |
| 522 | + "type": "text", | |
| 523 | + "primaryKey": false, | |
| 524 | + "notNull": true, | |
| 525 | + "autoincrement": false | |
| 526 | + }, | |
| 527 | + "endpoint": { | |
| 528 | + "name": "endpoint", | |
| 529 | + "type": "text", | |
| 530 | + "primaryKey": false, | |
| 531 | + "notNull": true, | |
| 532 | + "autoincrement": false | |
| 533 | + }, | |
| 534 | + "headers": { | |
| 535 | + "name": "headers", | |
| 536 | + "type": "text", | |
| 537 | + "primaryKey": false, | |
| 538 | + "notNull": false, | |
| 539 | + "autoincrement": false | |
| 540 | + } | |
| 541 | + }, | |
| 542 | + "indexes": { | |
| 543 | + "graphql_source_scope_id_idx": { | |
| 544 | + "name": "graphql_source_scope_id_idx", | |
| 545 | + "columns": [ | |
| 546 | + "scope_id" | |
| 547 | + ], | |
| 548 | + "isUnique": false | |
| 549 | + } | |
| 550 | + }, | |
| 551 | + "foreignKeys": {}, | |
| 552 | + "compositePrimaryKeys": { | |
| 553 | + "graphql_source_scope_id_id_pk": { | |
| 554 | + "columns": [ | |
| 555 | + "scope_id", | |
| 556 | + "id" | |
| 557 | + ], | |
| 558 | + "name": "graphql_source_scope_id_id_pk" | |
| 559 | + } | |
| 560 | + }, | |
| 561 | + "uniqueConstraints": {}, | |
| 562 | + "checkConstraints": {} | |
| 563 | + }, | |
| 564 | + "mcp_binding": { | |
| 565 | + "name": "mcp_binding", | |
| 566 | + "columns": { | |
| 567 | + "id": { | |
| 568 | + "name": "id", | |
| 569 | + "type": "text", | |
| 570 | + "primaryKey": false, | |
| 571 | + "notNull": true, | |
| 572 | + "autoincrement": false | |
| 573 | + }, | |
| 574 | + "scope_id": { | |
| 575 | + "name": "scope_id", | |
| 576 | + "type": "text", | |
| 577 | + "primaryKey": false, | |
| 578 | + "notNull": true, | |
| 579 | + "autoincrement": false | |
| 580 | + }, | |
| 581 | + "source_id": { | |
| 582 | + "name": "source_id", | |
| 583 | + "type": "text", | |
| 584 | + "primaryKey": false, | |
| 585 | + "notNull": true, | |
| 586 | + "autoincrement": false | |
| 587 | + }, | |
| 588 | + "binding": { | |
| 589 | + "name": "binding", | |
| 590 | + "type": "text", | |
| 591 | + "primaryKey": false, | |
| 592 | + "notNull": true, | |
| 593 | + "autoincrement": false | |
| 594 | + }, | |
| 595 | + "created_at": { | |
| 596 | + "name": "created_at", | |
| 597 | + "type": "integer", | |
| 598 | + "primaryKey": false, | |
| 599 | + "notNull": true, | |
| 600 | + "autoincrement": false | |
| 601 | + } | |
| 602 | + }, | |
| 603 | + "indexes": { | |
| 604 | + "mcp_binding_scope_id_idx": { | |
| 605 | + "name": "mcp_binding_scope_id_idx", | |
| 606 | + "columns": [ | |
| 607 | + "scope_id" | |
| 608 | + ], | |
| 609 | + "isUnique": false | |
| 610 | + }, | |
| 611 | + "mcp_binding_source_id_idx": { | |
| 612 | + "name": "mcp_binding_source_id_idx", | |
| 613 | + "columns": [ | |
| 614 | + "source_id" | |
| 615 | + ], | |
| 616 | + "isUnique": false | |
| 617 | + } | |
| 618 | + }, | |
| 619 | + "foreignKeys": {}, | |
| 620 | + "compositePrimaryKeys": { | |
| 621 | + "mcp_binding_scope_id_id_pk": { | |
| 622 | + "columns": [ | |
| 623 | + "scope_id", | |
| 624 | + "id" | |
| 625 | + ], | |
| 626 | + "name": "mcp_binding_scope_id_id_pk" | |
| 627 | + } | |
| 628 | + }, | |
| 629 | + "uniqueConstraints": {}, | |
| 630 | + "checkConstraints": {} | |
| 631 | + }, | |
| 632 | + "mcp_oauth_session": { | |
| 633 | + "name": "mcp_oauth_session", | |
| 634 | + "columns": { | |
| 635 | + "id": { | |
| 636 | + "name": "id", | |
| 637 | + "type": "text", | |
| 638 | + "primaryKey": false, | |
| 639 | + "notNull": true, | |
| 640 | + "autoincrement": false | |
| 641 | + }, | |
| 642 | + "scope_id": { | |
| 643 | + "name": "scope_id", | |
| 644 | + "type": "text", | |
| 645 | + "primaryKey": false, | |
| 646 | + "notNull": true, | |
| 647 | + "autoincrement": false | |
| 648 | + }, | |
| 649 | + "session": { | |
| 650 | + "name": "session", | |
| 651 | + "type": "text", | |
| 652 | + "primaryKey": false, | |
| 653 | + "notNull": true, | |
| 654 | + "autoincrement": false | |
| 655 | + }, | |
| 656 | + "expires_at": { | |
| 657 | + "name": "expires_at", | |
| 658 | + "type": "integer", | |
| 659 | + "primaryKey": false, | |
| 660 | + "notNull": true, | |
| 661 | + "autoincrement": false | |
| 662 | + }, | |
| 663 | + "created_at": { | |
| 664 | + "name": "created_at", | |
| 665 | + "type": "integer", | |
| 666 | + "primaryKey": false, | |
| 667 | + "notNull": true, | |
| 668 | + "autoincrement": false | |
| 669 | + } | |
| 670 | + }, | |
| 671 | + "indexes": { | |
| 672 | + "mcp_oauth_session_scope_id_idx": { | |
| 673 | + "name": "mcp_oauth_session_scope_id_idx", | |
| 674 | + "columns": [ | |
| 675 | + "scope_id" | |
| 676 | + ], | |
| 677 | + "isUnique": false | |
| 678 | + } | |
| 679 | + }, | |
| 680 | + "foreignKeys": {}, | |
| 681 | + "compositePrimaryKeys": { | |
| 682 | + "mcp_oauth_session_scope_id_id_pk": { | |
| 683 | + "columns": [ | |
| 684 | + "scope_id", | |
| 685 | + "id" | |
| 686 | + ], | |
| 687 | + "name": "mcp_oauth_session_scope_id_id_pk" | |
| 688 | + } | |
| 689 | + }, | |
| 690 | + "uniqueConstraints": {}, | |
| 691 | + "checkConstraints": {} | |
| 692 | + }, | |
| 693 | + "mcp_source": { | |
| 694 | + "name": "mcp_source", | |
| 695 | + "columns": { | |
| 696 | + "id": { | |
| 697 | + "name": "id", | |
| 698 | + "type": "text", | |
| 699 | + "primaryKey": false, | |
| 700 | + "notNull": true, | |
| 701 | + "autoincrement": false | |
| 702 | + }, | |
| 703 | + "scope_id": { | |
| 704 | + "name": "scope_id", | |
| 705 | + "type": "text", | |
| 706 | + "primaryKey": false, | |
| 707 | + "notNull": true, | |
| 708 | + "autoincrement": false | |
| 709 | + }, | |
| 710 | + "name": { | |
| 711 | + "name": "name", | |
| 712 | + "type": "text", | |
| 713 | + "primaryKey": false, | |
| 714 | + "notNull": true, | |
| 715 | + "autoincrement": false | |
| 716 | + }, | |
| 717 | + "config": { | |
| 718 | + "name": "config", | |
| 719 | + "type": "text", | |
| 720 | + "primaryKey": false, | |
| 721 | + "notNull": true, | |
| 722 | + "autoincrement": false | |
| 723 | + }, | |
| 724 | + "created_at": { | |
| 725 | + "name": "created_at", | |
| 726 | + "type": "integer", | |
| 727 | + "primaryKey": false, | |
| 728 | + "notNull": true, | |
| 729 | + "autoincrement": false | |
| 730 | + } | |
| 731 | + }, | |
| 732 | + "indexes": { | |
| 733 | + "mcp_source_scope_id_idx": { | |
| 734 | + "name": "mcp_source_scope_id_idx", | |
| 735 | + "columns": [ | |
| 736 | + "scope_id" | |
| 737 | + ], | |
| 738 | + "isUnique": false | |
| 739 | + } | |
| 740 | + }, | |
| 741 | + "foreignKeys": {}, | |
| 742 | + "compositePrimaryKeys": { | |
| 743 | + "mcp_source_scope_id_id_pk": { | |
| 744 | + "columns": [ | |
| 745 | + "scope_id", | |
| 746 | + "id" | |
| 747 | + ], | |
| 748 | + "name": "mcp_source_scope_id_id_pk" | |
| 749 | + } | |
| 750 | + }, | |
| 751 | + "uniqueConstraints": {}, | |
| 752 | + "checkConstraints": {} | |
| 753 | + }, | |
| 754 | + "openapi_oauth_session": { | |
| 755 | + "name": "openapi_oauth_session", | |
| 756 | + "columns": { | |
| 757 | + "id": { | |
| 758 | + "name": "id", | |
| 759 | + "type": "text", | |
| 760 | + "primaryKey": false, | |
| 761 | + "notNull": true, | |
| 762 | + "autoincrement": false | |
| 763 | + }, | |
| 764 | + "scope_id": { | |
| 765 | + "name": "scope_id", | |
| 766 | + "type": "text", | |
| 767 | + "primaryKey": false, | |
| 768 | + "notNull": true, | |
| 769 | + "autoincrement": false | |
| 770 | + }, | |
| 771 | + "session": { | |
| 772 | + "name": "session", | |
| 773 | + "type": "text", | |
| 774 | + "primaryKey": false, | |
| 775 | + "notNull": true, | |
| 776 | + "autoincrement": false | |
| 777 | + }, | |
| 778 | + "created_at": { | |
| 779 | + "name": "created_at", | |
| 780 | + "type": "integer", | |
| 781 | + "primaryKey": false, | |
| 782 | + "notNull": true, | |
| 783 | + "autoincrement": false | |
| 784 | + } | |
| 785 | + }, | |
| 786 | + "indexes": { | |
| 787 | + "openapi_oauth_session_scope_id_idx": { | |
| 788 | + "name": "openapi_oauth_session_scope_id_idx", | |
| 789 | + "columns": [ | |
| 790 | + "scope_id" | |
| 791 | + ], | |
| 792 | + "isUnique": false | |
| 793 | + } | |
| 794 | + }, | |
| 795 | + "foreignKeys": {}, | |
| 796 | + "compositePrimaryKeys": { | |
| 797 | + "openapi_oauth_session_scope_id_id_pk": { | |
| 798 | + "columns": [ | |
| 799 | + "scope_id", | |
| 800 | + "id" | |
| 801 | + ], | |
| 802 | + "name": "openapi_oauth_session_scope_id_id_pk" | |
| 803 | + } | |
| 804 | + }, | |
| 805 | + "uniqueConstraints": {}, | |
| 806 | + "checkConstraints": {} | |
| 807 | + }, | |
| 808 | + "openapi_operation": { | |
| 809 | + "name": "openapi_operation", | |
| 810 | + "columns": { | |
| 811 | + "id": { | |
| 812 | + "name": "id", | |
| 813 | + "type": "text", | |
| 814 | + "primaryKey": false, | |
| 815 | + "notNull": true, | |
| 816 | + "autoincrement": false | |
| 817 | + }, | |
| 818 | + "scope_id": { | |
| 819 | + "name": "scope_id", | |
| 820 | + "type": "text", | |
| 821 | + "primaryKey": false, | |
| 822 | + "notNull": true, | |
| 823 | + "autoincrement": false | |
| 824 | + }, | |
| 825 | + "source_id": { | |
| 826 | + "name": "source_id", | |
| 827 | + "type": "text", | |
| 828 | + "primaryKey": false, | |
| 829 | + "notNull": true, | |
| 830 | + "autoincrement": false | |
| 831 | + }, | |
| 832 | + "binding": { | |
| 833 | + "name": "binding", | |
| 834 | + "type": "text", | |
| 835 | + "primaryKey": false, | |
| 836 | + "notNull": true, | |
| 837 | + "autoincrement": false | |
| 838 | + } | |
| 839 | + }, | |
| 840 | + "indexes": { | |
| 841 | + "openapi_operation_scope_id_idx": { | |
| 842 | + "name": "openapi_operation_scope_id_idx", | |
| 843 | + "columns": [ | |
| 844 | + "scope_id" | |
| 845 | + ], | |
| 846 | + "isUnique": false | |
| 847 | + }, | |
| 848 | + "openapi_operation_source_id_idx": { | |
| 849 | + "name": "openapi_operation_source_id_idx", | |
| 850 | + "columns": [ | |
| 851 | + "source_id" | |
| 852 | + ], | |
| 853 | + "isUnique": false | |
| 854 | + } | |
| 855 | + }, | |
| 856 | + "foreignKeys": {}, | |
| 857 | + "compositePrimaryKeys": { | |
| 858 | + "openapi_operation_scope_id_id_pk": { | |
| 859 | + "columns": [ | |
| 860 | + "scope_id", | |
| 861 | + "id" | |
| 862 | + ], | |
| 863 | + "name": "openapi_operation_scope_id_id_pk" | |
| 864 | + } | |
| 865 | + }, | |
| 866 | + "uniqueConstraints": {}, | |
| 867 | + "checkConstraints": {} | |
| 868 | + }, | |
| 869 | + "openapi_source": { | |
| 870 | + "name": "openapi_source", | |
| 871 | + "columns": { | |
| 872 | + "id": { | |
| 873 | + "name": "id", | |
| 874 | + "type": "text", | |
| 875 | + "primaryKey": false, | |
| 876 | + "notNull": true, | |
| 877 | + "autoincrement": false | |
| 878 | + }, | |
| 879 | + "scope_id": { | |
| 880 | + "name": "scope_id", | |
| 881 | + "type": "text", | |
| 882 | + "primaryKey": false, | |
| 883 | + "notNull": true, | |
| 884 | + "autoincrement": false | |
| 885 | + }, | |
| 886 | + "name": { | |
| 887 | + "name": "name", | |
| 888 | + "type": "text", | |
| 889 | + "primaryKey": false, | |
| 890 | + "notNull": true, | |
| 891 | + "autoincrement": false | |
| 892 | + }, | |
| 893 | + "spec": { | |
| 894 | + "name": "spec", | |
| 895 | + "type": "text", | |
| 896 | + "primaryKey": false, | |
| 897 | + "notNull": true, | |
| 898 | + "autoincrement": false | |
| 899 | + }, | |
| 900 | + "source_url": { | |
| 901 | + "name": "source_url", | |
| 902 | + "type": "text", | |
| 903 | + "primaryKey": false, | |
| 904 | + "notNull": false, | |
| 905 | + "autoincrement": false | |
| 906 | + }, | |
| 907 | + "base_url": { | |
| 908 | + "name": "base_url", | |
| 909 | + "type": "text", | |
| 910 | + "primaryKey": false, | |
| 911 | + "notNull": false, | |
| 912 | + "autoincrement": false | |
| 913 | + }, | |
| 914 | + "headers": { | |
| 915 | + "name": "headers", | |
| 916 | + "type": "text", | |
| 917 | + "primaryKey": false, | |
| 918 | + "notNull": false, | |
| 919 | + "autoincrement": false | |
| 920 | + }, | |
| 921 | + "oauth2": { | |
| 922 | + "name": "oauth2", | |
| 923 | + "type": "text", | |
| 924 | + "primaryKey": false, | |
| 925 | + "notNull": false, | |
| 926 | + "autoincrement": false | |
| 927 | + }, | |
| 928 | + "invocation_config": { | |
| 929 | + "name": "invocation_config", | |
| 930 | + "type": "text", | |
| 931 | + "primaryKey": false, | |
| 932 | + "notNull": true, | |
| 933 | + "autoincrement": false | |
| 934 | + } | |
| 935 | + }, | |
| 936 | + "indexes": { | |
| 937 | + "openapi_source_scope_id_idx": { | |
| 938 | + "name": "openapi_source_scope_id_idx", | |
| 939 | + "columns": [ | |
| 940 | + "scope_id" | |
| 941 | + ], | |
| 942 | + "isUnique": false | |
| 943 | + } | |
| 944 | + }, | |
| 945 | + "foreignKeys": {}, | |
| 946 | + "compositePrimaryKeys": { | |
| 947 | + "openapi_source_scope_id_id_pk": { | |
| 948 | + "columns": [ | |
| 949 | + "scope_id", | |
| 950 | + "id" | |
| 951 | + ], | |
| 952 | + "name": "openapi_source_scope_id_id_pk" | |
| 953 | + } | |
| 954 | + }, | |
| 955 | + "uniqueConstraints": {}, | |
| 956 | + "checkConstraints": {} | |
| 957 | + }, | |
| 958 | + "secret": { | |
| 959 | + "name": "secret", | |
| 960 | + "columns": { | |
| 961 | + "id": { | |
| 962 | + "name": "id", | |
| 963 | + "type": "text", | |
| 964 | + "primaryKey": false, | |
| 965 | + "notNull": true, | |
| 966 | + "autoincrement": false | |
| 967 | + }, | |
| 968 | + "scope_id": { | |
| 969 | + "name": "scope_id", | |
| 970 | + "type": "text", | |
| 971 | + "primaryKey": false, | |
| 972 | + "notNull": true, | |
| 973 | + "autoincrement": false | |
| 974 | + }, | |
| 975 | + "name": { | |
| 976 | + "name": "name", | |
| 977 | + "type": "text", | |
| 978 | + "primaryKey": false, | |
| 979 | + "notNull": true, | |
| 980 | + "autoincrement": false | |
| 981 | + }, | |
| 982 | + "provider": { | |
| 983 | + "name": "provider", | |
| 984 | + "type": "text", | |
| 985 | + "primaryKey": false, | |
| 986 | + "notNull": true, | |
| 987 | + "autoincrement": false | |
| 988 | + }, | |
| 989 | + "owned_by_connection_id": { | |
| 990 | + "name": "owned_by_connection_id", | |
| 991 | + "type": "text", | |
| 992 | + "primaryKey": false, | |
| 993 | + "notNull": false, | |
| 994 | + "autoincrement": false | |
| 995 | + }, | |
| 996 | + "created_at": { | |
| 997 | + "name": "created_at", | |
| 998 | + "type": "integer", | |
| 999 | + "primaryKey": false, | |
| 1000 | + "notNull": true, | |
| 1001 | + "autoincrement": false | |
| 1002 | + } | |
| 1003 | + }, | |
| 1004 | + "indexes": { | |
| 1005 | + "secret_scope_id_idx": { | |
| 1006 | + "name": "secret_scope_id_idx", | |
| 1007 | + "columns": [ | |
| 1008 | + "scope_id" | |
| 1009 | + ], | |
| 1010 | + "isUnique": false | |
| 1011 | + }, | |
| 1012 | + "secret_provider_idx": { | |
| 1013 | + "name": "secret_provider_idx", | |
| 1014 | + "columns": [ | |
| 1015 | + "provider" | |
| 1016 | + ], | |
| 1017 | + "isUnique": false | |
| 1018 | + }, | |
| 1019 | + "secret_owned_by_connection_id_idx": { | |
| 1020 | + "name": "secret_owned_by_connection_id_idx", | |
| 1021 | + "columns": [ | |
| 1022 | + "owned_by_connection_id" | |
| 1023 | + ], | |
| 1024 | + "isUnique": false | |
| 1025 | + } | |
| 1026 | + }, | |
| 1027 | + "foreignKeys": {}, | |
| 1028 | + "compositePrimaryKeys": { | |
| 1029 | + "secret_scope_id_id_pk": { | |
| 1030 | + "columns": [ | |
| 1031 | + "scope_id", | |
| 1032 | + "id" | |
| 1033 | + ], | |
| 1034 | + "name": "secret_scope_id_id_pk" | |
| 1035 | + } | |
| 1036 | + }, | |
| 1037 | + "uniqueConstraints": {}, | |
| 1038 | + "checkConstraints": {} | |
| 1039 | + }, | |
| 1040 | + "source": { | |
| 1041 | + "name": "source", | |
| 1042 | + "columns": { | |
| 1043 | + "id": { | |
| 1044 | + "name": "id", | |
| 1045 | + "type": "text", | |
| 1046 | + "primaryKey": false, | |
| 1047 | + "notNull": true, | |
| 1048 | + "autoincrement": false | |
| 1049 | + }, | |
| 1050 | + "scope_id": { | |
| 1051 | + "name": "scope_id", | |
| 1052 | + "type": "text", | |
| 1053 | + "primaryKey": false, | |
| 1054 | + "notNull": true, | |
| 1055 | + "autoincrement": false | |
| 1056 | + }, | |
| 1057 | + "plugin_id": { | |
| 1058 | + "name": "plugin_id", | |
| 1059 | + "type": "text", | |
| 1060 | + "primaryKey": false, | |
| 1061 | + "notNull": true, | |
| 1062 | + "autoincrement": false | |
| 1063 | + }, | |
| 1064 | + "kind": { | |
| 1065 | + "name": "kind", | |
| 1066 | + "type": "text", | |
| 1067 | + "primaryKey": false, | |
| 1068 | + "notNull": true, | |
| 1069 | + "autoincrement": false | |
| 1070 | + }, | |
| 1071 | + "name": { | |
| 1072 | + "name": "name", | |
| 1073 | + "type": "text", | |
| 1074 | + "primaryKey": false, | |
| 1075 | + "notNull": true, | |
| 1076 | + "autoincrement": false | |
| 1077 | + }, | |
| 1078 | + "url": { | |
| 1079 | + "name": "url", | |
| 1080 | + "type": "text", | |
| 1081 | + "primaryKey": false, | |
| 1082 | + "notNull": false, | |
| 1083 | + "autoincrement": false | |
| 1084 | + }, | |
| 1085 | + "can_remove": { | |
| 1086 | + "name": "can_remove", | |
| 1087 | + "type": "integer", | |
| 1088 | + "primaryKey": false, | |
| 1089 | + "notNull": true, | |
| 1090 | + "autoincrement": false, | |
| 1091 | + "default": true | |
| 1092 | + }, | |
| 1093 | + "can_refresh": { | |
| 1094 | + "name": "can_refresh", | |
| 1095 | + "type": "integer", | |
| 1096 | + "primaryKey": false, | |
| 1097 | + "notNull": true, | |
| 1098 | + "autoincrement": false, | |
| 1099 | + "default": false | |
| 1100 | + }, | |
| 1101 | + "can_edit": { | |
| 1102 | + "name": "can_edit", | |
| 1103 | + "type": "integer", | |
| 1104 | + "primaryKey": false, | |
| 1105 | + "notNull": true, | |
| 1106 | + "autoincrement": false, | |
| 1107 | + "default": false | |
| 1108 | + }, | |
| 1109 | + "created_at": { | |
| 1110 | + "name": "created_at", | |
| 1111 | + "type": "integer", | |
| 1112 | + "primaryKey": false, | |
| 1113 | + "notNull": true, | |
| 1114 | + "autoincrement": false | |
| 1115 | + }, | |
| 1116 | + "updated_at": { | |
| 1117 | + "name": "updated_at", | |
| 1118 | + "type": "integer", | |
| 1119 | + "primaryKey": false, | |
| 1120 | + "notNull": true, | |
| 1121 | + "autoincrement": false | |
| 1122 | + } | |
| 1123 | + }, | |
| 1124 | + "indexes": { | |
| 1125 | + "source_scope_id_idx": { | |
| 1126 | + "name": "source_scope_id_idx", | |
| 1127 | + "columns": [ | |
| 1128 | + "scope_id" | |
| 1129 | + ], | |
| 1130 | + "isUnique": false | |
| 1131 | + }, | |
| 1132 | + "source_plugin_id_idx": { | |
| 1133 | + "name": "source_plugin_id_idx", | |
| 1134 | + "columns": [ | |
| 1135 | + "plugin_id" | |
| 1136 | + ], | |
| 1137 | + "isUnique": false | |
| 1138 | + } | |
| 1139 | + }, | |
| 1140 | + "foreignKeys": {}, | |
| 1141 | + "compositePrimaryKeys": { | |
| 1142 | + "source_scope_id_id_pk": { | |
| 1143 | + "columns": [ | |
| 1144 | + "scope_id", | |
| 1145 | + "id" | |
| 1146 | + ], | |
| 1147 | + "name": "source_scope_id_id_pk" | |
| 1148 | + } | |
| 1149 | + }, | |
| 1150 | + "uniqueConstraints": {}, | |
| 1151 | + "checkConstraints": {} | |
| 1152 | + }, | |
| 1153 | + "tool": { | |
| 1154 | + "name": "tool", | |
| 1155 | + "columns": { | |
| 1156 | + "id": { | |
| 1157 | + "name": "id", | |
| 1158 | + "type": "text", | |
| 1159 | + "primaryKey": false, | |
| 1160 | + "notNull": true, | |
| 1161 | + "autoincrement": false | |
| 1162 | + }, | |
| 1163 | + "scope_id": { | |
| 1164 | + "name": "scope_id", | |
| 1165 | + "type": "text", | |
| 1166 | + "primaryKey": false, | |
| 1167 | + "notNull": true, | |
| 1168 | + "autoincrement": false | |
| 1169 | + }, | |
| 1170 | + "source_id": { | |
| 1171 | + "name": "source_id", | |
| 1172 | + "type": "text", | |
| 1173 | + "primaryKey": false, | |
| 1174 | + "notNull": true, | |
| 1175 | + "autoincrement": false | |
| 1176 | + }, | |
| 1177 | + "plugin_id": { | |
| 1178 | + "name": "plugin_id", | |
| 1179 | + "type": "text", | |
| 1180 | + "primaryKey": false, | |
| 1181 | + "notNull": true, | |
| 1182 | + "autoincrement": false | |
| 1183 | + }, | |
| 1184 | + "name": { | |
| 1185 | + "name": "name", | |
| 1186 | + "type": "text", | |
| 1187 | + "primaryKey": false, | |
| 1188 | + "notNull": true, | |
| 1189 | + "autoincrement": false | |
| 1190 | + }, | |
| 1191 | + "description": { | |
| 1192 | + "name": "description", | |
| 1193 | + "type": "text", | |
| 1194 | + "primaryKey": false, | |
| 1195 | + "notNull": true, | |
| 1196 | + "autoincrement": false | |
| 1197 | + }, | |
| 1198 | + "input_schema": { | |
| 1199 | + "name": "input_schema", | |
| 1200 | + "type": "text", | |
| 1201 | + "primaryKey": false, | |
| 1202 | + "notNull": false, | |
| 1203 | + "autoincrement": false | |
| 1204 | + }, | |
| 1205 | + "output_schema": { | |
| 1206 | + "name": "output_schema", | |
| 1207 | + "type": "text", | |
| 1208 | + "primaryKey": false, | |
| 1209 | + "notNull": false, | |
| 1210 | + "autoincrement": false | |
| 1211 | + }, | |
| 1212 | + "created_at": { | |
| 1213 | + "name": "created_at", | |
| 1214 | + "type": "integer", | |
| 1215 | + "primaryKey": false, | |
| 1216 | + "notNull": true, | |
| 1217 | + "autoincrement": false | |
| 1218 | + }, | |
| 1219 | + "updated_at": { | |
| 1220 | + "name": "updated_at", | |
| 1221 | + "type": "integer", | |
| 1222 | + "primaryKey": false, | |
| 1223 | + "notNull": true, | |
| 1224 | + "autoincrement": false | |
| 1225 | + } | |
| 1226 | + }, | |
| 1227 | + "indexes": { | |
| 1228 | + "tool_scope_id_idx": { | |
| 1229 | + "name": "tool_scope_id_idx", | |
| 1230 | + "columns": [ | |
| 1231 | + "scope_id" | |
| 1232 | + ], | |
| 1233 | + "isUnique": false | |
| 1234 | + }, | |
| 1235 | + "tool_source_id_idx": { | |
| 1236 | + "name": "tool_source_id_idx", | |
| 1237 | + "columns": [ | |
| 1238 | + "source_id" | |
| 1239 | + ], | |
| 1240 | + "isUnique": false | |
| 1241 | + }, | |
| 1242 | + "tool_plugin_id_idx": { | |
| 1243 | + "name": "tool_plugin_id_idx", | |
| 1244 | + "columns": [ | |
| 1245 | + "plugin_id" | |
| 1246 | + ], | |
| 1247 | + "isUnique": false | |
| 1248 | + } | |
| 1249 | + }, | |
| 1250 | + "foreignKeys": {}, | |
| 1251 | + "compositePrimaryKeys": { | |
| 1252 | + "tool_scope_id_id_pk": { | |
| 1253 | + "columns": [ | |
| 1254 | + "scope_id", | |
| 1255 | + "id" | |
| 1256 | + ], | |
| 1257 | + "name": "tool_scope_id_id_pk" | |
| 1258 | + } | |
| 1259 | + }, | |
| 1260 | + "uniqueConstraints": {}, | |
| 1261 | + "checkConstraints": {} | |
| 1262 | + } | |
| 1263 | + }, | |
| 1264 | + "views": {}, | |
| 1265 | + "enums": {}, | |
| 1266 | + "_meta": { | |
| 1267 | + "schemas": {}, | |
| 1268 | + "tables": {}, | |
| 1269 | + "columns": {} | |
| 1270 | + }, | |
| 1271 | + "internal": { | |
| 1272 | + "indexes": {} | |
| 1273 | + } | |
| 1274 | +} | |
| @@ -15,6 +15,13 @@ · 14 unmodified lines | ||
| 15 | 15 | "when": 1776680432025, |
| 16 | 16 | "tag": "0001_sour_catseye", |
| 17 | 17 | "breakpoints": true |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "idx": 2, | |
| 21 | + "version": "6", | |
| 22 | + "when": 1776732062873, | |
| 23 | + "tag": "0002_lively_sue_storm", | |
| 24 | + "breakpoints": true | |
| 18 | 25 | } |
| 19 | 26 | ] |
| 20 | 27 | } |
| @@ -56,11 +56,32 @@ export const secret = sqliteTable("secret", { · 55 unmodified lines | ||
| 56 | 56 | scope_id: text('scope_id').notNull(), |
| 57 | 57 | name: text('name').notNull(), |
| 58 | 58 | provider: text('provider').notNull(), |
| 59 | + owned_by_connection_id: text('owned_by_connection_id'), | |
| 59 | 60 | created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull() |
| 60 | 61 | }, (table) => [ |
| 61 | 62 | primaryKey({ columns: [table.scope_id, table.id] }), |
| 62 | 63 | index("secret_scope_id_idx").on(table.scope_id), |
| 63 | 64 | index("secret_provider_idx").on(table.provider), |
| 65 | + index("secret_owned_by_connection_id_idx").on(table.owned_by_connection_id), | |
| 66 | +]); | |
| 67 | + | |
| 68 | +export const connection = sqliteTable("connection", { | |
| 69 | + id: text('id').notNull(), | |
| 70 | + scope_id: text('scope_id').notNull(), | |
| 71 | + provider: text('provider').notNull(), | |
| 72 | + kind: text('kind').notNull(), | |
| 73 | + identity_label: text('identity_label'), | |
| 74 | + access_token_secret_id: text('access_token_secret_id').notNull(), | |
| 75 | + refresh_token_secret_id: text('refresh_token_secret_id'), | |
| 76 | + expires_at: integer('expires_at'), | |
| 77 | + scope: text('scope'), | |
| 78 | + provider_state: text('provider_state', { mode: "json" }), | |
| 79 | + created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull(), | |
| 80 | + updated_at: integer('updated_at', { mode: 'timestamp_ms' }).notNull() | |
| 81 | +}, (table) => [ | |
| 82 | + primaryKey({ columns: [table.scope_id, table.id] }), | |
| 83 | + index("connection_scope_id_idx").on(table.scope_id), | |
| 84 | + index("connection_provider_idx").on(table.provider), | |
| 64 | 85 | ]); |
| 65 | 86 | |
| 66 | 87 | export const openapi_source = sqliteTable("openapi_source", { |
| @@ -55,7 +55,6 @@ import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; · 54 unmodified lines | ||
| 55 | 55 | import { |
| 56 | 56 | Scope, |
| 57 | 57 | ScopeId, |
| 58 | − SecretId, | |
| 59 | 58 | collectSchemas, |
| 60 | 59 | createExecutor, |
| 61 | 60 | } from "@executor/sdk"; |
| @@ -382,14 +381,13 @@ const followAuthorize = async ( · 320 unmodified lines | ||
| 382 | 381 | // --------------------------------------------------------------------------- |
| 383 | 382 | |
| 384 | 383 | describe("local mcp oauth (real OAuth + MCP server)", () => { |
| 385 | − it("startOAuth → authorize → completeOAuth writes tokens at the scope", async () => { | |
| 384 | + it("startOAuth → authorize → completeOAuth mints a Connection at the scope", async () => { | |
| 386 | 385 | const clientLayer = FetchHttpClient.layer.pipe( |
| 387 | 386 | Layer.provide(Layer.succeed(FetchHttpClient.Fetch, harness.fetch)), |
| 388 | 387 | ); |
| 389 | 388 | |
| 390 | 389 | const namespace = `ns_${randomBytes(4).toString("hex")}`; |
| 391 | − const accessTokenSecretId = `mcp_${namespace}_access_token`; | |
| 392 | − const refreshTokenSecretId = `mcp_${namespace}_refresh_token`; | |
| 390 | + const connectionId = `mcp-oauth2-${namespace}`; | |
| 393 | 391 | const redirectUrl = "http://local.test/api/mcp/oauth/callback"; |
| 394 | 392 | const scopeId = ScopeId.make(harness.scopeId); |
| 395 | 393 | |
| @@ -410,8 +408,7 @@ describe("local mcp oauth (real OAuth + MCP server)", () => { · 14 unmodified lines | ||
| 410 | 408 | payload: { |
| 411 | 409 | endpoint: `${fake.url}/mcp`, |
| 412 | 410 | redirectUrl, |
| 413 | − accessTokenSecretId, | |
| 414 | − refreshTokenSecretId, | |
| 411 | + connectionId, | |
| 415 | 412 | }, |
| 416 | 413 | }), |
| 417 | 414 | ), |
| @@ -429,30 +426,9 @@ describe("local mcp oauth (real OAuth + MCP server)", () => { · 11 unmodified lines | ||
| 429 | 426 | }), |
| 430 | 427 | ), |
| 431 | 428 | ); |
| 432 | − expect(completed.accessTokenSecretId).toBe(accessTokenSecretId); | |
| 433 | − expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId); | |
| 429 | + expect(completed.connectionId).toBe(connectionId); | |
| 434 | 430 | expect(completed.tokenType).toBe("Bearer"); |
| 435 | 431 | expect(completed.clientInformation).not.toBeNull(); |
| 436 | 432 | expect(completed.authorizationServerUrl).not.toBeNull(); |
| 437 | − | |
| 438 | − // Tokens landed at the (single) local scope and resolve back through | |
| 439 | − // the same provider that the plugin wrote them to. | |
| 440 | − const access = await Effect.runPromise( | |
| 441 | − run((client) => | |
| 442 | − client.secrets.resolve({ | |
| 443 | − path: { scopeId, secretId: SecretId.make(accessTokenSecretId) }, | |
| 444 | − }), | |
| 445 | − ), | |
| 446 | − ); | |
| 447 | − expect(access.value).toMatch(/^at_/); | |
| 448 | − | |
| 449 | − const refresh = await Effect.runPromise( | |
| 450 | − run((client) => | |
| 451 | − client.secrets.resolve({ | |
| 452 | − path: { scopeId, secretId: SecretId.make(refreshTokenSecretId) }, | |
| 453 | − }), | |
| 454 | − ), | |
| 455 | − ); | |
| 456 | − expect(refresh.value).toMatch(/^rt_/); | |
| 457 | 433 | }, 30_000); |
| 458 | 434 | }); |
| @@ -614,6 +614,7 @@ · 613 unmodified lines | ||
| 614 | 614 | "@effect/platform": "catalog:", |
| 615 | 615 | "@effect/platform-node": "catalog:", |
| 616 | 616 | "@executor/config": "workspace:*", |
| 617 | + "@executor/plugin-oauth2": "workspace:*", | |
| 617 | 618 | "@executor/sdk": "workspace:*", |
| 618 | 619 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 619 | 620 | "effect": "catalog:", |
| @@ -4,6 +4,7 @@ import type { HttpApiGroup } from "@effect/platform"; · 3 unmodified lines | ||
| 4 | 4 | import { ToolsApi } from "./tools/api"; |
| 5 | 5 | import { SourcesApi } from "./sources/api"; |
| 6 | 6 | import { SecretsApi } from "./secrets/api"; |
| 7 | +import { ConnectionsApi } from "./connections/api"; | |
| 7 | 8 | import { ExecutionsApi } from "./executions/api"; |
| 8 | 9 | import { ScopeApi } from "./scope/api"; |
| 9 | 10 | |
| @@ -11,6 +12,7 @@ export const CoreExecutorApi = HttpApi.make("executor") · 1 unmodified line | ||
| 11 | 12 | .add(ToolsApi) |
| 12 | 13 | .add(SourcesApi) |
| 13 | 14 | .add(SecretsApi) |
| 15 | + .add(ConnectionsApi) | |
| 14 | 16 | .add(ExecutionsApi) |
| 15 | 17 | .add(ScopeApi) |
| 16 | 18 | .annotateContext( |
| @@ -0,0 +1,57 @@ | ||
| 1 | +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + ConnectionId, | |
| 6 | + ConnectionKind, | |
| 7 | + ScopeId, | |
| 8 | + SecretId, | |
| 9 | +} from "@executor/sdk"; | |
| 10 | + | |
| 11 | +import { InternalError } from "../observability"; | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | +// Params | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | + | |
| 17 | +const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); | |
| 18 | +const connectionIdParam = HttpApiSchema.param("connectionId", ConnectionId); | |
| 19 | + | |
| 20 | +// --------------------------------------------------------------------------- | |
| 21 | +// Response schemas | |
| 22 | +// --------------------------------------------------------------------------- | |
| 23 | + | |
| 24 | +const ConnectionRefResponse = Schema.Struct({ | |
| 25 | + id: ConnectionId, | |
| 26 | + scopeId: ScopeId, | |
| 27 | + provider: Schema.String, | |
| 28 | + kind: ConnectionKind, | |
| 29 | + identityLabel: Schema.NullOr(Schema.String), | |
| 30 | + accessTokenSecretId: SecretId, | |
| 31 | + refreshTokenSecretId: Schema.NullOr(SecretId), | |
| 32 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 33 | + oauthScope: Schema.NullOr(Schema.String), | |
| 34 | + createdAt: Schema.Number, | |
| 35 | + updatedAt: Schema.Number, | |
| 36 | +}); | |
| 37 | + | |
| 38 | +// --------------------------------------------------------------------------- | |
| 39 | +// Group | |
| 40 | +// --------------------------------------------------------------------------- | |
| 41 | + | |
| 42 | +export class ConnectionsApi extends HttpApiGroup.make("connections") | |
| 43 | + .add( | |
| 44 | + HttpApiEndpoint.get( | |
| 45 | + "list", | |
| 46 | + )`/scopes/${scopeIdParam}/connections`.addSuccess( | |
| 47 | + Schema.Array(ConnectionRefResponse), | |
| 48 | + ), | |
| 49 | + ) | |
| 50 | + .add( | |
| 51 | + HttpApiEndpoint.del( | |
| 52 | + "remove", | |
| 53 | + )`/scopes/${scopeIdParam}/connections/${connectionIdParam}`.addSuccess( | |
| 54 | + Schema.Struct({ removed: Schema.Boolean }), | |
| 55 | + ), | |
| 56 | + ) | |
| 57 | + .addError(InternalError) {} | |
| @@ -0,0 +1,47 @@ | ||
| 1 | +import { HttpApiBuilder } from "@effect/platform"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | + | |
| 4 | +import { capture } from "@executor/api"; | |
| 5 | +import type { ConnectionRef } from "@executor/sdk"; | |
| 6 | + | |
| 7 | +import { ExecutorApi } from "../api"; | |
| 8 | +import { ExecutorService } from "../services"; | |
| 9 | + | |
| 10 | +const refToResponse = (ref: ConnectionRef) => ({ | |
| 11 | + id: ref.id, | |
| 12 | + scopeId: ref.scopeId, | |
| 13 | + provider: ref.provider, | |
| 14 | + kind: ref.kind, | |
| 15 | + identityLabel: ref.identityLabel, | |
| 16 | + accessTokenSecretId: ref.accessTokenSecretId, | |
| 17 | + refreshTokenSecretId: ref.refreshTokenSecretId, | |
| 18 | + expiresAt: ref.expiresAt, | |
| 19 | + oauthScope: ref.oauthScope, | |
| 20 | + createdAt: ref.createdAt.getTime(), | |
| 21 | + updatedAt: ref.updatedAt.getTime(), | |
| 22 | +}); | |
| 23 | + | |
| 24 | +export const ConnectionsHandlers = HttpApiBuilder.group( | |
| 25 | + ExecutorApi, | |
| 26 | + "connections", | |
| 27 | + (handlers) => | |
| 28 | + handlers | |
| 29 | + .handle("list", () => | |
| 30 | + capture( | |
| 31 | + Effect.gen(function* () { | |
| 32 | + const executor = yield* ExecutorService; | |
| 33 | + const refs = yield* executor.connections.list(); | |
| 34 | + return refs.map(refToResponse); | |
| 35 | + }), | |
| 36 | + ), | |
| 37 | + ) | |
| 38 | + .handle("remove", ({ path }) => | |
| 39 | + capture( | |
| 40 | + Effect.gen(function* () { | |
| 41 | + const executor = yield* ExecutorService; | |
| 42 | + yield* executor.connections.remove(path.connectionId); | |
| 43 | + return { removed: true }; | |
| 44 | + }), | |
| 45 | + ), | |
| 46 | + ), | |
| 47 | +); | |
| @@ -3,12 +3,14 @@ import { Layer } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import { ToolsHandlers } from "./tools"; |
| 4 | 4 | import { SourcesHandlers } from "./sources"; |
| 5 | 5 | import { SecretsHandlers } from "./secrets"; |
| 6 | +import { ConnectionsHandlers } from "./connections"; | |
| 6 | 7 | import { ScopeHandlers } from "./scope"; |
| 7 | 8 | import { ExecutionsHandlers } from "./executions"; |
| 8 | 9 | |
| 9 | 10 | export { ToolsHandlers } from "./tools"; |
| 10 | 11 | export { SourcesHandlers } from "./sources"; |
| 11 | 12 | export { SecretsHandlers } from "./secrets"; |
| 13 | +export { ConnectionsHandlers } from "./connections"; | |
| 12 | 14 | export { ScopeHandlers } from "./scope"; |
| 13 | 15 | export { ExecutionsHandlers } from "./executions"; |
| 14 | 16 | |
| @@ -16,6 +18,7 @@ export const CoreHandlers = Layer.mergeAll( · 1 unmodified line | ||
| 16 | 18 | ToolsHandlers, |
| 17 | 19 | SourcesHandlers, |
| 18 | 20 | SecretsHandlers, |
| 21 | + ConnectionsHandlers, | |
| 19 | 22 | ScopeHandlers, |
| 20 | 23 | ExecutionsHandlers, |
| 21 | 24 | ); |
| @@ -2,6 +2,7 @@ export { ExecutorApi, CoreExecutorApi, addGroup } from "./api"; · 1 unmodified line | ||
| 2 | 2 | export { ToolsApi } from "./tools/api"; |
| 3 | 3 | export { SourcesApi } from "./sources/api"; |
| 4 | 4 | export { SecretsApi } from "./secrets/api"; |
| 5 | +export { ConnectionsApi } from "./connections/api"; | |
| 5 | 6 | export { ExecutionsApi } from "./executions/api"; |
| 6 | 7 | export { ScopeApi } from "./scope/api"; |
| 7 | 8 | export { |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk"; | |
| 3 | +import { | |
| 4 | + ScopeId, | |
| 5 | + SecretId, | |
| 6 | + SecretNotFoundError, | |
| 7 | + SecretOwnedByConnectionError, | |
| 8 | + SecretResolutionError, | |
| 9 | +} from "@executor/sdk"; | |
| 4 | 10 | |
| 5 | 11 | import { InternalError } from "../observability"; |
| 6 | 12 | |
| @@ -48,6 +54,9 @@ const SecretNotFound = SecretNotFoundError.annotations(HttpApiSchema.annotations · 41 unmodified lines | ||
| 48 | 54 | const SecretResolution = SecretResolutionError.annotations( |
| 49 | 55 | HttpApiSchema.annotations({ status: 500 }), |
| 50 | 56 | ); |
| 57 | +const SecretOwnedByConnection = SecretOwnedByConnectionError.annotations( | |
| 58 | + HttpApiSchema.annotations({ status: 409 }), | |
| 59 | +); | |
| 51 | 60 | |
| 52 | 61 | // --------------------------------------------------------------------------- |
| 53 | 62 | // Group |
| @@ -79,6 +88,7 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 25 unmodified lines | ||
| 79 | 88 | .add( |
| 80 | 89 | HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}` |
| 81 | 90 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) |
| 82 | − .addError(SecretNotFound), | |
| 91 | + .addError(SecretNotFound) | |
| 92 | + .addError(SecretOwnedByConnection), | |
| 83 | 93 | ) |
| 84 | 94 | .addError(InternalError) {} |
| @@ -59,9 +59,10 @@ export const McpAuthConfig = Schema.Union( · 58 unmodified lines | ||
| 59 | 59 | }), |
| 60 | 60 | Schema.Struct({ |
| 61 | 61 | kind: Schema.Literal("oauth2"), |
| 62 | − accessTokenSecret: Schema.String, | |
| 63 | − refreshTokenSecret: Schema.optional(Schema.NullOr(Schema.String)), | |
| 64 | − tokenType: Schema.optional(Schema.String), | |
| 62 | + /** Stable id of the SDK Connection holding access + refresh token | |
| 63 | + * material. Scope shadowing means the same id resolves per-user | |
| 64 | + * via the executor's innermost-wins lookup. */ | |
| 65 | + connectionId: Schema.String, | |
| 65 | 66 | }), |
| 66 | 67 | ); |
| 67 | 68 | export type McpAuthConfig = typeof McpAuthConfig.Type; |
Binary file not shown.
| @@ -0,0 +1,181 @@ | ||
| 1 | +import { Data, Effect, Schema } from "effect"; | |
| 2 | + | |
| 3 | +import { ConnectionId, ScopeId, SecretId } from "./ids"; | |
| 4 | + | |
| 5 | +// --------------------------------------------------------------------------- | |
| 6 | +// Connections — the product-level "sign-in state" primitive. A Connection | |
| 7 | +// owns one or more backing `secret` rows (access + refresh tokens) via | |
| 8 | +// `secret.owned_by_connection_id`; the user sees the Connection, the SDK | |
| 9 | +// handles every refresh internally. Plugins register a refresh handler | |
| 10 | +// per provider via `plugin.connectionProviders`, mirroring the shape of | |
| 11 | +// `plugin.secretProviders` for ordinary secret backends. | |
| 12 | +// --------------------------------------------------------------------------- | |
| 13 | + | |
| 14 | +/** Minimal JSON-object carrier for the plugin-owned `providerState` | |
| 15 | + * blob. The SDK never inspects its shape; plugins encode/decode their | |
| 16 | + * own structure. Never sensitive — that's what the secret rows are | |
| 17 | + * for. */ | |
| 18 | +export const ConnectionProviderState = Schema.Record({ | |
| 19 | + key: Schema.String, | |
| 20 | + value: Schema.Unknown, | |
| 21 | +}); | |
| 22 | +export type ConnectionProviderState = typeof ConnectionProviderState.Type; | |
| 23 | + | |
| 24 | +export const ConnectionKind = Schema.Literal("user", "app"); | |
| 25 | +export type ConnectionKind = typeof ConnectionKind.Type; | |
| 26 | + | |
| 27 | +// --------------------------------------------------------------------------- | |
| 28 | +// ConnectionRef — metadata projection returned from `ctx.connections.list` | |
| 29 | +// / `executor.connections.list`. Holds token secret ids (so a plugin can | |
| 30 | +// reference them from its source config) but not token values. | |
| 31 | +// --------------------------------------------------------------------------- | |
| 32 | + | |
| 33 | +export class ConnectionRef extends Schema.Class<ConnectionRef>("ConnectionRef")({ | |
| 34 | + id: ConnectionId, | |
| 35 | + scopeId: ScopeId, | |
| 36 | + provider: Schema.String, | |
| 37 | + kind: ConnectionKind, | |
| 38 | + identityLabel: Schema.NullOr(Schema.String), | |
| 39 | + accessTokenSecretId: SecretId, | |
| 40 | + refreshTokenSecretId: Schema.NullOr(SecretId), | |
| 41 | + /** Epoch ms when the access token expires; null if not declared. */ | |
| 42 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 43 | + /** OAuth-style scope string as returned by the token endpoint. Named | |
| 44 | + * `oauthScope` to avoid collision with the executor scope id. */ | |
| 45 | + oauthScope: Schema.NullOr(Schema.String), | |
| 46 | + providerState: Schema.NullOr(ConnectionProviderState), | |
| 47 | + createdAt: Schema.DateFromNumber, | |
| 48 | + updatedAt: Schema.DateFromNumber, | |
| 49 | +}) {} | |
| 50 | + | |
| 51 | +// --------------------------------------------------------------------------- | |
| 52 | +// CreateConnectionInput — what a plugin passes to create a fresh | |
| 53 | +// Connection after a successful OAuth exchange. The SDK writes the | |
| 54 | +// backing secret rows and the connection row in one transaction, and | |
| 55 | +// stamps `owned_by_connection_id` so `ctx.secrets.list` automatically | |
| 56 | +// hides them from the bare-secrets UI. | |
| 57 | +// | |
| 58 | +// `provider` must match a registered `ConnectionProvider.key`. The SDK | |
| 59 | +// validates this at create time so a typo surfaces immediately instead | |
| 60 | +// of the first time a refresh is attempted. | |
| 61 | +// --------------------------------------------------------------------------- | |
| 62 | + | |
| 63 | +export class TokenMaterial extends Schema.Class<TokenMaterial>("TokenMaterial")({ | |
| 64 | + /** Target secret id. Plugins typically derive this from the source id | |
| 65 | + * + a stable suffix (e.g. `${sourceId}.access_token`). */ | |
| 66 | + secretId: SecretId, | |
| 67 | + /** Display name stamped on the secret row. Only visible to code — the | |
| 68 | + * Connections UI hides connection-owned secrets. */ | |
| 69 | + name: Schema.String, | |
| 70 | + value: Schema.String, | |
| 71 | +}) {} | |
| 72 | + | |
| 73 | +export class CreateConnectionInput extends Schema.Class<CreateConnectionInput>( | |
| 74 | + "CreateConnectionInput", | |
| 75 | +)({ | |
| 76 | + id: ConnectionId, | |
| 77 | + /** Executor scope id that will own this connection + its backing | |
| 78 | + * secrets. Usually the innermost (per-user) scope for user | |
| 79 | + * sign-ins, or an org scope for shared installs. */ | |
| 80 | + scope: ScopeId, | |
| 81 | + provider: Schema.String, | |
| 82 | + kind: ConnectionKind, | |
| 83 | + identityLabel: Schema.NullOr(Schema.String), | |
| 84 | + accessToken: TokenMaterial, | |
| 85 | + refreshToken: Schema.NullOr(TokenMaterial), | |
| 86 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 87 | + /** OAuth-style scope string. Distinct from the executor scope above. */ | |
| 88 | + oauthScope: Schema.NullOr(Schema.String), | |
| 89 | + providerState: Schema.NullOr(ConnectionProviderState), | |
| 90 | +}) {} | |
| 91 | + | |
| 92 | +// --------------------------------------------------------------------------- | |
| 93 | +// ConnectionRefreshError — typed error surface for refresh handlers. | |
| 94 | +// Plugins either return a fresh token envelope or fail with this error; | |
| 95 | +// the SDK rethrows it from `ctx.connections.accessToken` callers. | |
| 96 | +// --------------------------------------------------------------------------- | |
| 97 | + | |
| 98 | +export class ConnectionRefreshError extends Data.TaggedError( | |
| 99 | + "ConnectionRefreshError", | |
| 100 | +)<{ | |
| 101 | + readonly connectionId: ConnectionId; | |
| 102 | + readonly message: string; | |
| 103 | + readonly cause?: unknown; | |
| 104 | +}> {} | |
| 105 | + | |
| 106 | +// --------------------------------------------------------------------------- | |
| 107 | +// ConnectionRefreshInput — what the SDK hands to a provider's `refresh` | |
| 108 | +// callback. Includes the current refresh-token value (already resolved | |
| 109 | +// from the secret row) and the opaque providerState blob so handlers | |
| 110 | +// don't need to hit secrets themselves. | |
| 111 | +// --------------------------------------------------------------------------- | |
| 112 | + | |
| 113 | +export interface ConnectionRefreshInput { | |
| 114 | + readonly connectionId: ConnectionId; | |
| 115 | + readonly scopeId: ScopeId; | |
| 116 | + readonly kind: ConnectionKind; | |
| 117 | + readonly identityLabel: string | null; | |
| 118 | + /** Resolved refresh token value, or null if the connection has none. */ | |
| 119 | + readonly refreshToken: string | null; | |
| 120 | + /** Plugin-owned blob persisted at create / previous refresh. */ | |
| 121 | + readonly providerState: ConnectionProviderState | null; | |
| 122 | + /** OAuth scope string from the last token issuance. */ | |
| 123 | + readonly oauthScope: string | null; | |
| 124 | +} | |
| 125 | + | |
| 126 | +// --------------------------------------------------------------------------- | |
| 127 | +// ConnectionRefreshResult — what a provider's `refresh` callback returns | |
| 128 | +// on success. The SDK writes the new token values through the secret | |
| 129 | +// providers, updates `expires_at` / `scope` / `provider_state` on the | |
| 130 | +// connection row, and returns the fresh access token to the caller. | |
| 131 | +// | |
| 132 | +// `refreshToken` is optional: if the AS rotates refresh tokens it's | |
| 133 | +// present, if the AS issues long-lived refresh tokens it's absent. The | |
| 134 | +// SDK updates the refresh secret only when a new value is supplied. | |
| 135 | +// --------------------------------------------------------------------------- | |
| 136 | + | |
| 137 | +export interface ConnectionRefreshResult { | |
| 138 | + readonly accessToken: string; | |
| 139 | + readonly refreshToken?: string | null; | |
| 140 | + readonly expiresAt?: number | null; | |
| 141 | + readonly oauthScope?: string | null; | |
| 142 | + readonly providerState?: ConnectionProviderState | null; | |
| 143 | +} | |
| 144 | + | |
| 145 | +// --------------------------------------------------------------------------- | |
| 146 | +// ConnectionProvider — plugin contribution. Registered via | |
| 147 | +// `plugin.connectionProviders`. One per refresh strategy (oauth2 | |
| 148 | +// authorization-code, oauth2 client-credentials, per-provider custom, | |
| 149 | +// etc). Keyed by `key`; the connection row's `provider` column | |
| 150 | +// references this key. | |
| 151 | +// | |
| 152 | +// Omitting `refresh` means "tokens minted by this provider never | |
| 153 | +// refresh" — accessToken(id) just returns the stored value. Useful for | |
| 154 | +// long-lived API tokens wrapped as connections for UX consistency. | |
| 155 | +// --------------------------------------------------------------------------- | |
| 156 | + | |
| 157 | +export interface ConnectionProvider { | |
| 158 | + readonly key: string; | |
| 159 | + readonly refresh?: ( | |
| 160 | + input: ConnectionRefreshInput, | |
| 161 | + ) => Effect.Effect<ConnectionRefreshResult, ConnectionRefreshError>; | |
| 162 | +} | |
| 163 | + | |
| 164 | +// --------------------------------------------------------------------------- | |
| 165 | +// UpdateConnectionTokensInput — for flows that re-exchange tokens out | |
| 166 | +// of band (e.g. an OAuth re-auth where the user signs in again). The | |
| 167 | +// SDK overwrites the backing secrets and updates the connection row in | |
| 168 | +// one transaction. | |
| 169 | +// --------------------------------------------------------------------------- | |
| 170 | + | |
| 171 | +export class UpdateConnectionTokensInput extends Schema.Class<UpdateConnectionTokensInput>( | |
| 172 | + "UpdateConnectionTokensInput", | |
| 173 | +)({ | |
| 174 | + id: ConnectionId, | |
| 175 | + accessToken: Schema.String, | |
| 176 | + refreshToken: Schema.optional(Schema.NullOr(Schema.String)), | |
| 177 | + expiresAt: Schema.optional(Schema.NullOr(Schema.Number)), | |
| 178 | + oauthScope: Schema.optional(Schema.NullOr(Schema.String)), | |
| 179 | + providerState: Schema.optional(Schema.NullOr(ConnectionProviderState)), | |
| 180 | + identityLabel: Schema.optional(Schema.NullOr(Schema.String)), | |
| 181 | +}) {} | |
| @@ -84,15 +84,74 @@ export const coreSchema = { · 83 unmodified lines | ||
| 84 | 84 | // provider key). Actual values never touch this table — they live in |
| 85 | 85 | // the secret provider (keychain, 1password, file, etc.) and are |
| 86 | 86 | // resolved on demand via `ctx.secrets.get(id)`. |
| 87 | + // | |
| 88 | + // `owned_by_connection_id` ties the row to a connection. Connection- | |
| 89 | + // owned secrets are plumbing, not user-facing values: `ctx.secrets.list` | |
| 90 | + // filters them out (the user sees the Connection instead), and | |
| 91 | + // `ctx.secrets.remove` refuses to delete them (Connection.remove is | |
| 92 | + // the single owner of the lifecycle). The FK is nullable so existing | |
| 93 | + // "bare" secrets (API keys entered by the user, pre-connection OAuth | |
| 94 | + // rows during migration) remain visible and removable unchanged. | |
| 87 | 95 | secret: { |
| 88 | 96 | fields: { |
| 89 | 97 | id: { type: "string", required: true }, |
| 90 | 98 | scope_id: { type: "string", required: true, index: true }, |
| 91 | 99 | name: { type: "string", required: true }, |
| 92 | 100 | provider: { type: "string", required: true, index: true }, |
| 101 | + owned_by_connection_id: { | |
| 102 | + type: "string", | |
| 103 | + required: false, | |
| 104 | + index: true, | |
| 105 | + }, | |
| 93 | 106 | created_at: { type: "date", required: true }, |
| 94 | 107 | }, |
| 95 | 108 | }, |
| 109 | + // Connections — sign-in state for one identity against one remote | |
| 110 | + // provider. A Connection owns one or more `secret` rows (access + | |
| 111 | + // refresh tokens, etc.) via `secret.owned_by_connection_id`, and the | |
| 112 | + // SDK exposes `ctx.connections.accessToken(id)` which transparently | |
| 113 | + // refreshes the backing secrets when they're near expiry. Plugins | |
| 114 | + // contribute refresh behavior via `plugin.connectionProviders[].refresh` | |
| 115 | + // keyed by `provider`, same pattern as `secretProviders`. | |
| 116 | + // | |
| 117 | + // `provider_state` is plugin-owned opaque JSON — token endpoint URL, | |
| 118 | + // scopes, issuer, auth-server metadata — whatever the provider's | |
| 119 | + // refresh handler needs to re-hit the token endpoint. It's NOT | |
| 120 | + // sensitive (all secrets go through the provider-backed secret rows); | |
| 121 | + // it's just enough metadata to drive a refresh without re-running | |
| 122 | + // discovery. | |
| 123 | + connection: { | |
| 124 | + fields: { | |
| 125 | + id: { type: "string", required: true }, | |
| 126 | + scope_id: { type: "string", required: true, index: true }, | |
| 127 | + /** Routing key into `plugin.connectionProviders`. Typical shape | |
| 128 | + * is `${pluginId}:${kind}` (e.g. `openapi:oauth2`, `mcp:oauth2`, | |
| 129 | + * `google-discovery:google`). Mirrors `secret.provider`. */ | |
| 130 | + provider: { type: "string", required: true, index: true }, | |
| 131 | + /** "user" = per-person sign-in; "app" = app-level client-credentials | |
| 132 | + * style install. Affects UI copy only; the SDK treats both the | |
| 133 | + * same for refresh. */ | |
| 134 | + kind: { type: "string", required: true }, | |
| 135 | + /** Display label shown in the Connections UI. Usually the account | |
| 136 | + * email / handle / org name the user signed in as. */ | |
| 137 | + identity_label: { type: "string", required: false }, | |
| 138 | + /** Stable id of the access-token secret. Always present. */ | |
| 139 | + access_token_secret_id: { type: "string", required: true }, | |
| 140 | + /** Stable id of the refresh-token secret. Null for flows that | |
| 141 | + * don't mint a refresh token (client_credentials, etc.). */ | |
| 142 | + refresh_token_secret_id: { type: "string", required: false }, | |
| 143 | + /** Epoch ms when the access token expires. Null if the provider | |
| 144 | + * didn't declare an expiry. Used as the refresh trigger. */ | |
| 145 | + expires_at: { type: "number", required: false }, | |
| 146 | + /** Scope string as returned by the token endpoint. */ | |
| 147 | + scope: { type: "string", required: false }, | |
| 148 | + /** Opaque plugin-owned JSON — token endpoint URL, scopes list, | |
| 149 | + * discovery hints, etc. Never sensitive. */ | |
| 150 | + provider_state: { type: "json", required: false }, | |
| 151 | + created_at: { type: "date", required: true }, | |
| 152 | + updated_at: { type: "date", required: true }, | |
| 153 | + }, | |
| 154 | + }, | |
| 96 | 155 | } as const satisfies DBSchema; |
| 97 | 156 | |
| 98 | 157 | export type CoreSchema = typeof coreSchema; |
| @@ -116,6 +175,11 @@ export type DefinitionRow = InferDBFieldsOutput< · 17 unmodified lines | ||
| 116 | 175 | export type SecretRow = InferDBFieldsOutput<CoreSchema["secret"]["fields"]> & |
| 117 | 176 | Record<string, unknown>; |
| 118 | 177 | |
| 178 | +export type ConnectionRow = InferDBFieldsOutput< | |
| 179 | + CoreSchema["connection"]["fields"] | |
| 180 | +> & | |
| 181 | + Record<string, unknown>; | |
| 182 | + | |
| 119 | 183 | // --------------------------------------------------------------------------- |
| 120 | 184 | // Tool annotations — default-policy metadata the executor consults |
| 121 | 185 | // before invocation. Returned by `plugin.resolveAnnotations` (dynamic |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { Data, Schema } from "effect"; |
| 2 | 2 | |
| 3 | −import { ToolId, SecretId } from "./ids"; | |
| 3 | +import { ConnectionId, ToolId, SecretId } from "./ids"; | |
| 4 | 4 | |
| 5 | 5 | // --------------------------------------------------------------------------- |
| 6 | 6 | // Tool lifecycle |
| @@ -74,6 +74,43 @@ export class SecretResolutionError extends Schema.TaggedError<SecretResolutionEr · 67 unmodified lines | ||
| 74 | 74 | }, |
| 75 | 75 | ) {} |
| 76 | 76 | |
| 77 | +/** Raised when `secrets.remove(id)` is called on a secret whose row has | |
| 78 | + * `owned_by_connection_id` set. The connection owns the lifecycle — | |
| 79 | + * callers must go through `connections.remove(connectionId)` to | |
| 80 | + * delete it along with its siblings. */ | |
| 81 | +export class SecretOwnedByConnectionError extends Schema.TaggedError<SecretOwnedByConnectionError>()( | |
| 82 | + "SecretOwnedByConnectionError", | |
| 83 | + { | |
| 84 | + secretId: SecretId, | |
| 85 | + connectionId: ConnectionId, | |
| 86 | + }, | |
| 87 | +) {} | |
| 88 | + | |
| 89 | +// --------------------------------------------------------------------------- | |
| 90 | +// Connections | |
| 91 | +// --------------------------------------------------------------------------- | |
| 92 | + | |
| 93 | +export class ConnectionNotFoundError extends Schema.TaggedError<ConnectionNotFoundError>()( | |
| 94 | + "ConnectionNotFoundError", | |
| 95 | + { connectionId: ConnectionId }, | |
| 96 | +) {} | |
| 97 | + | |
| 98 | +export class ConnectionProviderNotRegisteredError extends Schema.TaggedError<ConnectionProviderNotRegisteredError>()( | |
| 99 | + "ConnectionProviderNotRegisteredError", | |
| 100 | + { | |
| 101 | + provider: Schema.String, | |
| 102 | + connectionId: Schema.optional(ConnectionId), | |
| 103 | + }, | |
| 104 | +) {} | |
| 105 | + | |
| 106 | +export class ConnectionRefreshNotSupportedError extends Schema.TaggedError<ConnectionRefreshNotSupportedError>()( | |
| 107 | + "ConnectionRefreshNotSupportedError", | |
| 108 | + { | |
| 109 | + connectionId: ConnectionId, | |
| 110 | + provider: Schema.String, | |
| 111 | + }, | |
| 112 | +) {} | |
| 113 | + | |
| 77 | 114 | // --------------------------------------------------------------------------- |
| 78 | 115 | // Union type for convenience in signatures. |
| 79 | 116 | // --------------------------------------------------------------------------- |
| @@ -86,4 +123,8 @@ export type ExecutorError = · 6 unmodified lines | ||
| 86 | 123 | | SourceNotFoundError |
| 87 | 124 | | SourceRemovalNotAllowedError |
| 88 | 125 | | SecretNotFoundError |
| 89 | − | SecretResolutionError; | |
| 126 | + | SecretResolutionError | |
| 127 | + | SecretOwnedByConnectionError | |
| 128 | + | ConnectionNotFoundError | |
| 129 | + | ConnectionProviderNotRegisteredError | |
| 130 | + | ConnectionRefreshNotSupportedError; | |
| @@ -13,8 +13,17 @@ import { · 12 unmodified lines | ||
| 13 | 13 | pluginBlobStore, |
| 14 | 14 | type BlobStore, |
| 15 | 15 | } from "./blob"; |
| 16 | +import { | |
| 17 | + ConnectionProviderState, | |
| 18 | + ConnectionRef, | |
| 19 | + ConnectionRefreshError, | |
| 20 | + type ConnectionProvider, | |
| 21 | + type CreateConnectionInput, | |
| 22 | + type UpdateConnectionTokensInput, | |
| 23 | +} from "./connections"; | |
| 16 | 24 | import { |
| 17 | 25 | coreSchema, |
| 26 | + type ConnectionRow, | |
| 18 | 27 | type CoreSchema, |
| 19 | 28 | type DefinitionsInput, |
| 20 | 29 | type SourceInput, |
| @@ -30,13 +39,17 @@ import { · 9 unmodified lines | ||
| 30 | 39 | type ElicitationRequest, |
| 31 | 40 | } from "./elicitation"; |
| 32 | 41 | import { |
| 42 | + ConnectionNotFoundError, | |
| 43 | + ConnectionProviderNotRegisteredError, | |
| 44 | + ConnectionRefreshNotSupportedError, | |
| 33 | 45 | NoHandlerError, |
| 34 | 46 | PluginNotLoadedError, |
| 47 | + SecretOwnedByConnectionError, | |
| 35 | 48 | SourceRemovalNotAllowedError, |
| 36 | 49 | ToolInvocationError, |
| 37 | 50 | ToolNotFoundError, |
| 38 | 51 | } from "./errors"; |
| 39 | −import { ScopeId, SecretId, ToolId } from "./ids"; | |
| 52 | +import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids"; | |
| 40 | 53 | import type { |
| 41 | 54 | AnyPlugin, |
| 42 | 55 | Elicit, |
| @@ -168,11 +181,51 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 125 unmodified lines | ||
| 168 | 181 | readonly set: ( |
| 169 | 182 | input: SetSecretInput, |
| 170 | 183 | ) => Effect.Effect<SecretRef, StorageFailure>; |
| 171 | − readonly remove: (id: string) => Effect.Effect<void, StorageFailure>; | |
| 184 | + /** Delete a bare (non-connection-owned) secret. Connection-owned | |
| 185 | + * secrets are rejected with `SecretOwnedByConnectionError` — use | |
| 186 | + * `connections.remove` instead. */ | |
| 187 | + readonly remove: ( | |
| 188 | + id: string, | |
| 189 | + ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>; | |
| 172 | 190 | readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>; |
| 173 | 191 | readonly providers: () => Effect.Effect<readonly string[]>; |
| 174 | 192 | }; |
| 175 | 193 | |
| 194 | + readonly connections: { | |
| 195 | + readonly get: ( | |
| 196 | + id: string, | |
| 197 | + ) => Effect.Effect<ConnectionRef | null, StorageFailure>; | |
| 198 | + readonly list: () => Effect.Effect<readonly ConnectionRef[], StorageFailure>; | |
| 199 | + readonly create: ( | |
| 200 | + input: CreateConnectionInput, | |
| 201 | + ) => Effect.Effect< | |
| 202 | + ConnectionRef, | |
| 203 | + ConnectionProviderNotRegisteredError | StorageFailure | |
| 204 | + >; | |
| 205 | + readonly updateTokens: ( | |
| 206 | + input: UpdateConnectionTokensInput, | |
| 207 | + ) => Effect.Effect< | |
| 208 | + ConnectionRef, | |
| 209 | + ConnectionNotFoundError | StorageFailure | |
| 210 | + >; | |
| 211 | + readonly setIdentityLabel: ( | |
| 212 | + id: string, | |
| 213 | + label: string | null, | |
| 214 | + ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; | |
| 215 | + readonly accessToken: ( | |
| 216 | + id: string, | |
| 217 | + ) => Effect.Effect< | |
| 218 | + string, | |
| 219 | + | ConnectionNotFoundError | |
| 220 | + | ConnectionProviderNotRegisteredError | |
| 221 | + | ConnectionRefreshNotSupportedError | |
| 222 | + | ConnectionRefreshError | |
| 223 | + | StorageFailure | |
| 224 | + >; | |
| 225 | + readonly remove: (id: string) => Effect.Effect<void, StorageFailure>; | |
| 226 | + readonly providers: () => Effect.Effect<readonly string[]>; | |
| 227 | + }; | |
| 228 | + | |
| 176 | 229 | readonly close: () => Effect.Effect<void, StorageFailure>; |
| 177 | 230 | } & PluginExtensions<TPlugins>; |
| 178 | 231 | |
| @@ -547,6 +600,9 @@ export const createExecutor = < · 368 unmodified lines | ||
| 547 | 600 | const runtimes = new Map<string, PluginRuntime>(); |
| 548 | 601 | // Secret providers keyed by `provider.key`. |
| 549 | 602 | const secretProviders = new Map<string, SecretProvider>(); |
| 603 | + // Connection providers keyed by `provider.key` — drive the refresh | |
| 604 | + // lifecycle for connection-owned tokens. | |
| 605 | + const connectionProviders = new Map<string, ConnectionProvider>(); | |
| 550 | 606 | const extensions: Record<string, object> = {}; |
| 551 | 607 | |
| 552 | 608 | // ------------------------------------------------------------------ |
| @@ -743,7 +799,9 @@ export const createExecutor = < · 190 unmodified lines | ||
| 743 | 799 | }); |
| 744 | 800 | }); |
| 745 | 801 | |
| 746 | − const secretsRemove = (id: string): Effect.Effect<void, StorageFailure> => | |
| 802 | + const secretsRemove = ( | |
| 803 | + id: string, | |
| 804 | + ): Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure> => | |
| 747 | 805 | Effect.gen(function* () { |
| 748 | 806 | // Remove is shadowing-aware: drop only the innermost-scope row. |
| 749 | 807 | // Removing a user-scope override on a secret that also has an |
| @@ -757,6 +815,18 @@ export const createExecutor = < · 7 unmodified lines | ||
| 757 | 815 | where: [{ field: "id", value: id }], |
| 758 | 816 | }); |
| 759 | 817 | const target = findInnermost(rows); |
| 818 | + // Refuse to delete connection-owned secrets. The connection owns | |
| 819 | + // the lifecycle — callers must go through connections.remove. | |
| 820 | + if (target && target.owned_by_connection_id) { | |
| 821 | + return yield* Effect.fail( | |
| 822 | + new SecretOwnedByConnectionError({ | |
| 823 | + secretId: SecretId.make(id), | |
| 824 | + connectionId: ConnectionId.make( | |
| 825 | + target.owned_by_connection_id as string, | |
| 826 | + ), | |
| 827 | + }), | |
| 828 | + ); | |
| 829 | + } | |
| 760 | 830 | const targetScope = (target?.scope_id as string | undefined) ?? |
| 761 | 831 | scopeIds[0]!; |
| 762 | 832 | |
| @@ -808,8 +878,18 @@ export const createExecutor = < · 45 unmodified lines | ||
| 808 | 878 | |
| 809 | 879 | // Core routing rows first. Adapter returns rows from every |
| 810 | 880 | // scope in the stack; resolve collisions using the caller's |
| 811 | − // precedence order (innermost first). | |
| 812 | − const rows = yield* core.findMany({ model: "secret" }); | |
| 881 | + // precedence order (innermost first). Rows owned by a | |
| 882 | + // connection are filtered out — the user sees the Connection | |
| 883 | + // entry, not its backing token secrets. Their ids go in a | |
| 884 | + // deny-set so provider `list()` results for the same id can't | |
| 885 | + // leak them back in below. | |
| 886 | + const allRows = yield* core.findMany({ model: "secret" }); | |
| 887 | + const connectionOwnedIds = new Set( | |
| 888 | + allRows | |
| 889 | + .filter((r) => r.owned_by_connection_id) | |
| 890 | + .map((r) => r.id as string), | |
| 891 | + ); | |
| 892 | + const rows = allRows.filter((r) => !r.owned_by_connection_id); | |
| 813 | 893 | const precedence = new Map<string, number>(); |
| 814 | 894 | scopeIds.forEach((id, index) => precedence.set(id, index)); |
| 815 | 895 | const pick = (row: typeof rows[number]) => { |
| @@ -859,6 +939,7 @@ export const createExecutor = < · 43 unmodified lines | ||
| 859 | 939 | for (const { key, entries } of lists) { |
| 860 | 940 | for (const entry of entries) { |
| 861 | 941 | if (byId.has(entry.id)) continue; // core row wins |
| 942 | + if (connectionOwnedIds.has(entry.id)) continue; // hidden by connection | |
| 862 | 943 | byId.set( |
| 863 | 944 | entry.id, |
| 864 | 945 | new SecretRef({ |
| @@ -887,6 +968,525 @@ export const createExecutor = < · 22 unmodified lines | ||
| 887 | 968 | })); |
| 888 | 969 | }); |
| 889 | 970 | |
| 971 | + // ------------------------------------------------------------------ | |
| 972 | + // Connections facade — sign-in state as a first-class primitive. | |
| 973 | + // Connection rows own one or more backing `secret` rows via | |
| 974 | + // `secret.owned_by_connection_id`; the SDK orchestrates refresh via | |
| 975 | + // the registered provider keyed by `connection.provider`. | |
| 976 | + // ------------------------------------------------------------------ | |
| 977 | + | |
| 978 | + // Refresh skew: treat the access token as "about to expire" when | |
| 979 | + // we're within this many ms of the expiry the AS declared. | |
| 980 | + // Matches the value the old per-plugin refresh code used, so | |
| 981 | + // behavior under the new SDK orchestration stays identical. | |
| 982 | + const CONNECTION_REFRESH_SKEW_MS = 60_000; | |
| 983 | + | |
| 984 | + const toJsonObject = ( | |
| 985 | + value: unknown, | |
| 986 | + ): ConnectionProviderState | null => { | |
| 987 | + if (value === null || value === undefined) return null; | |
| 988 | + if (typeof value === "string") { | |
| 989 | + try { | |
| 990 | + const parsed = JSON.parse(value); | |
| 991 | + return parsed && typeof parsed === "object" && !Array.isArray(parsed) | |
| 992 | + ? (parsed as ConnectionProviderState) | |
| 993 | + : null; | |
| 994 | + } catch { | |
| 995 | + return null; | |
| 996 | + } | |
| 997 | + } | |
| 998 | + if (typeof value !== "object" || Array.isArray(value)) return null; | |
| 999 | + return value as ConnectionProviderState; | |
| 1000 | + }; | |
| 1001 | + | |
| 1002 | + const rowToConnection = (row: ConnectionRow): ConnectionRef => | |
| 1003 | + new ConnectionRef({ | |
| 1004 | + id: ConnectionId.make(row.id as string), | |
| 1005 | + scopeId: ScopeId.make(row.scope_id as string), | |
| 1006 | + provider: row.provider as string, | |
| 1007 | + kind: (row.kind as "user" | "app") ?? "user", | |
| 1008 | + identityLabel: (row.identity_label as string | null | undefined) ?? null, | |
| 1009 | + accessTokenSecretId: SecretId.make(row.access_token_secret_id as string), | |
| 1010 | + refreshTokenSecretId: | |
| 1011 | + row.refresh_token_secret_id != null | |
| 1012 | + ? SecretId.make(row.refresh_token_secret_id as string) | |
| 1013 | + : null, | |
| 1014 | + expiresAt: | |
| 1015 | + row.expires_at != null ? Number(row.expires_at as number) : null, | |
| 1016 | + oauthScope: (row.scope as string | null | undefined) ?? null, | |
| 1017 | + providerState: toJsonObject(row.provider_state), | |
| 1018 | + createdAt: | |
| 1019 | + row.created_at instanceof Date | |
| 1020 | + ? row.created_at | |
| 1021 | + : new Date(row.created_at as string), | |
| 1022 | + updatedAt: | |
| 1023 | + row.updated_at instanceof Date | |
| 1024 | + ? row.updated_at | |
| 1025 | + : new Date(row.updated_at as string), | |
| 1026 | + }); | |
| 1027 | + | |
| 1028 | + const findInnermostConnectionRow = ( | |
| 1029 | + id: string, | |
| 1030 | + ): Effect.Effect<ConnectionRow | null, StorageFailure> => | |
| 1031 | + Effect.gen(function* () { | |
| 1032 | + const rows = yield* core.findMany({ | |
| 1033 | + model: "connection", | |
| 1034 | + where: [{ field: "id", value: id }], | |
| 1035 | + }); | |
| 1036 | + return findInnermost(rows as readonly ConnectionRow[]); | |
| 1037 | + }); | |
| 1038 | + | |
| 1039 | + const connectionsGet = ( | |
| 1040 | + id: string, | |
| 1041 | + ): Effect.Effect<ConnectionRef | null, StorageFailure> => | |
| 1042 | + Effect.gen(function* () { | |
| 1043 | + const row = yield* findInnermostConnectionRow(id); | |
| 1044 | + return row ? rowToConnection(row) : null; | |
| 1045 | + }); | |
| 1046 | + | |
| 1047 | + const connectionsList = (): Effect.Effect< | |
| 1048 | + readonly ConnectionRef[], | |
| 1049 | + StorageFailure | |
| 1050 | + > => | |
| 1051 | + Effect.gen(function* () { | |
| 1052 | + const rows = yield* core.findMany({ model: "connection" }); | |
| 1053 | + // Dedup by id, innermost scope wins — same rule as sources/tools. | |
| 1054 | + const byId = new Map<string, ConnectionRow>(); | |
| 1055 | + const byIdRank = new Map<string, number>(); | |
| 1056 | + for (const row of rows as readonly ConnectionRow[]) { | |
| 1057 | + const rank = scopeRank(row as { scope_id: unknown }); | |
| 1058 | + const existing = byIdRank.get(row.id as string); | |
| 1059 | + if (existing === undefined || rank < existing) { | |
| 1060 | + byId.set(row.id as string, row); | |
| 1061 | + byIdRank.set(row.id as string, rank); | |
| 1062 | + } | |
| 1063 | + } | |
| 1064 | + return [...byId.values()].map(rowToConnection); | |
| 1065 | + }); | |
| 1066 | + | |
| 1067 | + // Write a secret value through a specific provider, bypassing the | |
| 1068 | + // bare-secrets ownership check so the SDK can stamp | |
| 1069 | + // `owned_by_connection_id` atomically alongside a connection row. | |
| 1070 | + const writeOwnedSecret = ( | |
| 1071 | + params: { | |
| 1072 | + id: string; | |
| 1073 | + scope: string; | |
| 1074 | + name: string; | |
| 1075 | + value: string; | |
| 1076 | + provider: string; | |
| 1077 | + ownedByConnectionId: string; | |
| 1078 | + }, | |
| 1079 | + ): Effect.Effect<void, StorageFailure> => | |
| 1080 | + Effect.gen(function* () { | |
| 1081 | + const target = secretProviders.get(params.provider); | |
| 1082 | + if (!target) { | |
| 1083 | + return yield* Effect.fail( | |
| 1084 | + new StorageError({ | |
| 1085 | + message: `Unknown secret provider: ${params.provider}`, | |
| 1086 | + cause: undefined, | |
| 1087 | + }), | |
| 1088 | + ); | |
| 1089 | + } | |
| 1090 | + if (!target.writable || !target.set) { | |
| 1091 | + return yield* Effect.fail( | |
| 1092 | + new StorageError({ | |
| 1093 | + message: `Secret provider "${target.key}" is read-only`, | |
| 1094 | + cause: undefined, | |
| 1095 | + }), | |
| 1096 | + ); | |
| 1097 | + } | |
| 1098 | + yield* target.set(params.id, params.value, params.scope); | |
| 1099 | + | |
| 1100 | + const now = new Date(); | |
| 1101 | + yield* core.delete({ | |
| 1102 | + model: "secret", | |
| 1103 | + where: [ | |
| 1104 | + { field: "id", value: params.id }, | |
| 1105 | + { field: "scope_id", value: params.scope }, | |
| 1106 | + ], | |
| 1107 | + }); | |
| 1108 | + yield* core.create({ | |
| 1109 | + model: "secret", | |
| 1110 | + data: { | |
| 1111 | + id: params.id, | |
| 1112 | + scope_id: params.scope, | |
| 1113 | + name: params.name, | |
| 1114 | + provider: target.key, | |
| 1115 | + owned_by_connection_id: params.ownedByConnectionId, | |
| 1116 | + created_at: now, | |
| 1117 | + }, | |
| 1118 | + forceAllowId: true, | |
| 1119 | + }); | |
| 1120 | + }); | |
| 1121 | + | |
| 1122 | + const pickWritableProvider = ( | |
| 1123 | + requested?: string, | |
| 1124 | + ): Effect.Effect<SecretProvider, StorageFailure> => | |
| 1125 | + Effect.gen(function* () { | |
| 1126 | + if (requested) { | |
| 1127 | + const p = secretProviders.get(requested); | |
| 1128 | + if (!p) { | |
| 1129 | + return yield* Effect.fail( | |
| 1130 | + new StorageError({ | |
| 1131 | + message: `Unknown secret provider: ${requested}`, | |
| 1132 | + cause: undefined, | |
| 1133 | + }), | |
| 1134 | + ); | |
| 1135 | + } | |
| 1136 | + return p; | |
| 1137 | + } | |
| 1138 | + for (const p of secretProviders.values()) { | |
| 1139 | + if (p.writable && p.set) return p; | |
| 1140 | + } | |
| 1141 | + return yield* Effect.fail( | |
| 1142 | + new StorageError({ | |
| 1143 | + message: "No writable secret providers registered", | |
| 1144 | + cause: undefined, | |
| 1145 | + }), | |
| 1146 | + ); | |
| 1147 | + }); | |
| 1148 | + | |
| 1149 | + const connectionsCreate = ( | |
| 1150 | + input: CreateConnectionInput, | |
| 1151 | + ): Effect.Effect< | |
| 1152 | + ConnectionRef, | |
| 1153 | + ConnectionProviderNotRegisteredError | StorageFailure | |
| 1154 | + > => | |
| 1155 | + Effect.gen(function* () { | |
| 1156 | + if (!scopeIds.includes(input.scope as string)) { | |
| 1157 | + return yield* Effect.fail( | |
| 1158 | + new StorageError({ | |
| 1159 | + message: | |
| 1160 | + `connections.create targets scope "${input.scope}" which is not ` + | |
| 1161 | + `in the executor's scope stack [${scopeIds.join(", ")}].`, | |
| 1162 | + cause: undefined, | |
| 1163 | + }), | |
| 1164 | + ); | |
| 1165 | + } | |
| 1166 | + if (!connectionProviders.has(input.provider)) { | |
| 1167 | + return yield* Effect.fail( | |
| 1168 | + new ConnectionProviderNotRegisteredError({ | |
| 1169 | + provider: input.provider, | |
| 1170 | + connectionId: input.id, | |
| 1171 | + }), | |
| 1172 | + ); | |
| 1173 | + } | |
| 1174 | + | |
| 1175 | + const writable = yield* pickWritableProvider(); | |
| 1176 | + const now = new Date(); | |
| 1177 | + | |
| 1178 | + return yield* adapter.transaction(() => | |
| 1179 | + Effect.gen(function* () { | |
| 1180 | + // Drop any existing connection row at this scope first so a | |
| 1181 | + // re-auth replaces cleanly. Owned-secret rows for the old | |
| 1182 | + // connection are removed by the cascade below (we delete | |
| 1183 | + // both old + new token secret ids explicitly). | |
| 1184 | + yield* core.delete({ | |
| 1185 | + model: "connection", | |
| 1186 | + where: [ | |
| 1187 | + { field: "id", value: input.id as string }, | |
| 1188 | + { field: "scope_id", value: input.scope as string }, | |
| 1189 | + ], | |
| 1190 | + }); | |
| 1191 | + | |
| 1192 | + yield* writeOwnedSecret({ | |
| 1193 | + id: input.accessToken.secretId as string, | |
| 1194 | + scope: input.scope as string, | |
| 1195 | + name: input.accessToken.name, | |
| 1196 | + value: input.accessToken.value, | |
| 1197 | + provider: writable.key, | |
| 1198 | + ownedByConnectionId: input.id as string, | |
| 1199 | + }); | |
| 1200 | + if (input.refreshToken) { | |
| 1201 | + yield* writeOwnedSecret({ | |
| 1202 | + id: input.refreshToken.secretId as string, | |
| 1203 | + scope: input.scope as string, | |
| 1204 | + name: input.refreshToken.name, | |
| 1205 | + value: input.refreshToken.value, | |
| 1206 | + provider: writable.key, | |
| 1207 | + ownedByConnectionId: input.id as string, | |
| 1208 | + }); | |
| 1209 | + } | |
| 1210 | + | |
| 1211 | + yield* core.create({ | |
| 1212 | + model: "connection", | |
| 1213 | + data: { | |
| 1214 | + id: input.id as string, | |
| 1215 | + scope_id: input.scope as string, | |
| 1216 | + provider: input.provider, | |
| 1217 | + kind: input.kind, | |
| 1218 | + identity_label: input.identityLabel ?? undefined, | |
| 1219 | + access_token_secret_id: input.accessToken.secretId as string, | |
| 1220 | + refresh_token_secret_id: | |
| 1221 | + input.refreshToken?.secretId ?? undefined, | |
| 1222 | + expires_at: input.expiresAt ?? undefined, | |
| 1223 | + scope: input.oauthScope ?? undefined, | |
| 1224 | + provider_state: input.providerState ?? undefined, | |
| 1225 | + created_at: now, | |
| 1226 | + updated_at: now, | |
| 1227 | + }, | |
| 1228 | + forceAllowId: true, | |
| 1229 | + }); | |
| 1230 | + | |
| 1231 | + return new ConnectionRef({ | |
| 1232 | + id: input.id, | |
| 1233 | + scopeId: input.scope, | |
| 1234 | + provider: input.provider, | |
| 1235 | + kind: input.kind, | |
| 1236 | + identityLabel: input.identityLabel, | |
| 1237 | + accessTokenSecretId: input.accessToken.secretId, | |
| 1238 | + refreshTokenSecretId: | |
| 1239 | + input.refreshToken?.secretId ?? null, | |
| 1240 | + expiresAt: input.expiresAt, | |
| 1241 | + oauthScope: input.oauthScope, | |
| 1242 | + providerState: input.providerState, | |
| 1243 | + createdAt: now, | |
| 1244 | + updatedAt: now, | |
| 1245 | + }); | |
| 1246 | + }), | |
| 1247 | + ); | |
| 1248 | + }); | |
| 1249 | + | |
| 1250 | + // Write new token material into the existing secret rows and bump | |
| 1251 | + // the connection row's expiry / scope / providerState. Never | |
| 1252 | + // mutates `access_token_secret_id` or `refresh_token_secret_id` — | |
| 1253 | + // those stay pinned so consumers that stashed them in source | |
| 1254 | + // configs still resolve. | |
| 1255 | + const connectionsUpdateTokens = ( | |
| 1256 | + input: UpdateConnectionTokensInput, | |
| 1257 | + ): Effect.Effect< | |
| 1258 | + ConnectionRef, | |
| 1259 | + ConnectionNotFoundError | StorageFailure | |
| 1260 | + > => | |
| 1261 | + Effect.gen(function* () { | |
| 1262 | + const row = yield* findInnermostConnectionRow(input.id as string); | |
| 1263 | + if (!row) { | |
| 1264 | + return yield* Effect.fail( | |
| 1265 | + new ConnectionNotFoundError({ connectionId: input.id }), | |
| 1266 | + ); | |
| 1267 | + } | |
| 1268 | + const writable = yield* pickWritableProvider(); | |
| 1269 | + const accessName = | |
| 1270 | + `Connection ${input.id as string} access token`; | |
| 1271 | + const refreshName = | |
| 1272 | + `Connection ${input.id as string} refresh token`; | |
| 1273 | + | |
| 1274 | + return yield* adapter.transaction(() => | |
| 1275 | + Effect.gen(function* () { | |
| 1276 | + yield* writeOwnedSecret({ | |
| 1277 | + id: row.access_token_secret_id as string, | |
| 1278 | + scope: row.scope_id as string, | |
| 1279 | + name: accessName, | |
| 1280 | + value: input.accessToken, | |
| 1281 | + provider: writable.key, | |
| 1282 | + ownedByConnectionId: row.id as string, | |
| 1283 | + }); | |
| 1284 | + const rotatedRefresh = input.refreshToken ?? undefined; | |
| 1285 | + if ( | |
| 1286 | + rotatedRefresh && | |
| 1287 | + row.refresh_token_secret_id | |
| 1288 | + ) { | |
| 1289 | + yield* writeOwnedSecret({ | |
| 1290 | + id: row.refresh_token_secret_id as string, | |
| 1291 | + scope: row.scope_id as string, | |
| 1292 | + name: refreshName, | |
| 1293 | + value: rotatedRefresh, | |
| 1294 | + provider: writable.key, | |
| 1295 | + ownedByConnectionId: row.id as string, | |
| 1296 | + }); | |
| 1297 | + } | |
| 1298 | + const now = new Date(); | |
| 1299 | + const patch: Record<string, unknown> = { updated_at: now }; | |
| 1300 | + if (input.expiresAt !== undefined) | |
| 1301 | + patch.expires_at = input.expiresAt ?? undefined; | |
| 1302 | + if (input.oauthScope !== undefined) | |
| 1303 | + patch.scope = input.oauthScope ?? undefined; | |
| 1304 | + if (input.providerState !== undefined) | |
| 1305 | + patch.provider_state = input.providerState ?? undefined; | |
| 1306 | + if (input.identityLabel !== undefined) | |
| 1307 | + patch.identity_label = input.identityLabel ?? undefined; | |
| 1308 | + yield* core.update({ | |
| 1309 | + model: "connection", | |
| 1310 | + where: [ | |
| 1311 | + { field: "id", value: row.id as string }, | |
| 1312 | + { field: "scope_id", value: row.scope_id as string }, | |
| 1313 | + ], | |
| 1314 | + update: patch, | |
| 1315 | + }); | |
| 1316 | + const updated = yield* findInnermostConnectionRow( | |
| 1317 | + row.id as string, | |
| 1318 | + ); | |
| 1319 | + if (!updated) { | |
| 1320 | + return yield* Effect.fail( | |
| 1321 | + new ConnectionNotFoundError({ connectionId: input.id }), | |
| 1322 | + ); | |
| 1323 | + } | |
| 1324 | + return rowToConnection(updated); | |
| 1325 | + }), | |
| 1326 | + ); | |
| 1327 | + }); | |
| 1328 | + | |
| 1329 | + const connectionsSetIdentityLabel = ( | |
| 1330 | + id: string, | |
| 1331 | + label: string | null, | |
| 1332 | + ): Effect.Effect<void, ConnectionNotFoundError | StorageFailure> => | |
| 1333 | + Effect.gen(function* () { | |
| 1334 | + const row = yield* findInnermostConnectionRow(id); | |
| 1335 | + if (!row) { | |
| 1336 | + return yield* Effect.fail( | |
| 1337 | + new ConnectionNotFoundError({ | |
| 1338 | + connectionId: ConnectionId.make(id), | |
| 1339 | + }), | |
| 1340 | + ); | |
| 1341 | + } | |
| 1342 | + yield* core.update({ | |
| 1343 | + model: "connection", | |
| 1344 | + where: [ | |
| 1345 | + { field: "id", value: id }, | |
| 1346 | + { field: "scope_id", value: row.scope_id as string }, | |
| 1347 | + ], | |
| 1348 | + update: { | |
| 1349 | + identity_label: label ?? undefined, | |
| 1350 | + updated_at: new Date(), | |
| 1351 | + }, | |
| 1352 | + }); | |
| 1353 | + }); | |
| 1354 | + | |
| 1355 | + const connectionsRemove = ( | |
| 1356 | + id: string, | |
| 1357 | + ): Effect.Effect<void, StorageFailure> => | |
| 1358 | + Effect.gen(function* () { | |
| 1359 | + const row = yield* findInnermostConnectionRow(id); | |
| 1360 | + if (!row) return; | |
| 1361 | + const scope = row.scope_id as string; | |
| 1362 | + yield* adapter.transaction(() => | |
| 1363 | + Effect.gen(function* () { | |
| 1364 | + // Find every owned secret at this scope and drop through | |
| 1365 | + // its provider + the core row. We look up by | |
| 1366 | + // `owned_by_connection_id` rather than just the two ids on | |
| 1367 | + // the connection row so any accidentally-orphaned siblings | |
| 1368 | + // get cleaned up too. | |
| 1369 | + const owned = yield* core.findMany({ | |
| 1370 | + model: "secret", | |
| 1371 | + where: [ | |
| 1372 | + { field: "owned_by_connection_id", value: id }, | |
| 1373 | + { field: "scope_id", value: scope }, | |
| 1374 | + ], | |
| 1375 | + }); | |
| 1376 | + const deleters = [...secretProviders.values()].filter( | |
| 1377 | + (p): p is typeof p & { delete: NonNullable<typeof p.delete> } => | |
| 1378 | + !!(p.writable && p.delete), | |
| 1379 | + ); | |
| 1380 | + for (const secret of owned) { | |
| 1381 | + yield* Effect.all( | |
| 1382 | + deleters.map((p) => p.delete(secret.id as string, scope)), | |
| 1383 | + { concurrency: "unbounded" }, | |
| 1384 | + ); | |
| 1385 | + } | |
| 1386 | + yield* core.deleteMany({ | |
| 1387 | + model: "secret", | |
| 1388 | + where: [ | |
| 1389 | + { field: "owned_by_connection_id", value: id }, | |
| 1390 | + { field: "scope_id", value: scope }, | |
| 1391 | + ], | |
| 1392 | + }); | |
| 1393 | + yield* core.delete({ | |
| 1394 | + model: "connection", | |
| 1395 | + where: [ | |
| 1396 | + { field: "id", value: id }, | |
| 1397 | + { field: "scope_id", value: scope }, | |
| 1398 | + ], | |
| 1399 | + }); | |
| 1400 | + }), | |
| 1401 | + ); | |
| 1402 | + }); | |
| 1403 | + | |
| 1404 | + // accessToken(id) — the single surface plugins use at invoke time. | |
| 1405 | + // Resolves the backing secret, checks expiry, calls the provider's | |
| 1406 | + // refresh handler if we're inside the skew window. New tokens are | |
| 1407 | + // written back through the same provider and the connection row is | |
| 1408 | + // patched with the new expiry. | |
| 1409 | + const connectionsAccessToken = ( | |
| 1410 | + id: string, | |
| 1411 | + ): Effect.Effect< | |
| 1412 | + string, | |
| 1413 | + | ConnectionNotFoundError | |
| 1414 | + | ConnectionProviderNotRegisteredError | |
| 1415 | + | ConnectionRefreshNotSupportedError | |
| 1416 | + | ConnectionRefreshError | |
| 1417 | + | StorageFailure | |
| 1418 | + > => | |
| 1419 | + Effect.gen(function* () { | |
| 1420 | + const row = yield* findInnermostConnectionRow(id); | |
| 1421 | + if (!row) { | |
| 1422 | + return yield* Effect.fail( | |
| 1423 | + new ConnectionNotFoundError({ | |
| 1424 | + connectionId: ConnectionId.make(id), | |
| 1425 | + }), | |
| 1426 | + ); | |
| 1427 | + } | |
| 1428 | + const ref = rowToConnection(row); | |
| 1429 | + const now = Date.now(); | |
| 1430 | + const needsRefresh = | |
| 1431 | + ref.expiresAt !== null && | |
| 1432 | + ref.expiresAt - CONNECTION_REFRESH_SKEW_MS <= now; | |
| 1433 | + | |
| 1434 | + if (!needsRefresh) { | |
| 1435 | + const current = yield* secretsGet( | |
| 1436 | + ref.accessTokenSecretId as unknown as string, | |
| 1437 | + ); | |
| 1438 | + if (current !== null) return current; | |
| 1439 | + // Fall through to refresh if the stored token vanished — a | |
| 1440 | + // genuinely-missing secret with no way to refresh is a | |
| 1441 | + // hard-failure, same behavior as if `expires_at` had passed. | |
| 1442 | + } | |
| 1443 | + | |
| 1444 | + const provider = connectionProviders.get(ref.provider); | |
| 1445 | + if (!provider) { | |
| 1446 | + return yield* Effect.fail( | |
| 1447 | + new ConnectionProviderNotRegisteredError({ | |
| 1448 | + provider: ref.provider, | |
| 1449 | + connectionId: ref.id, | |
| 1450 | + }), | |
| 1451 | + ); | |
| 1452 | + } | |
| 1453 | + if (!provider.refresh) { | |
| 1454 | + return yield* Effect.fail( | |
| 1455 | + new ConnectionRefreshNotSupportedError({ | |
| 1456 | + connectionId: ref.id, | |
| 1457 | + provider: ref.provider, | |
| 1458 | + }), | |
| 1459 | + ); | |
| 1460 | + } | |
| 1461 | + | |
| 1462 | + const refreshTokenValue = ref.refreshTokenSecretId | |
| 1463 | + ? yield* secretsGet(ref.refreshTokenSecretId as unknown as string) | |
| 1464 | + : null; | |
| 1465 | + | |
| 1466 | + const result = yield* provider.refresh({ | |
| 1467 | + connectionId: ref.id, | |
| 1468 | + scopeId: ref.scopeId, | |
| 1469 | + kind: ref.kind, | |
| 1470 | + identityLabel: ref.identityLabel, | |
| 1471 | + refreshToken: refreshTokenValue, | |
| 1472 | + providerState: ref.providerState, | |
| 1473 | + oauthScope: ref.oauthScope, | |
| 1474 | + }); | |
| 1475 | + | |
| 1476 | + yield* connectionsUpdateTokens({ | |
| 1477 | + id: ref.id, | |
| 1478 | + accessToken: result.accessToken, | |
| 1479 | + refreshToken: result.refreshToken, | |
| 1480 | + expiresAt: result.expiresAt, | |
| 1481 | + oauthScope: result.oauthScope, | |
| 1482 | + providerState: result.providerState, | |
| 1483 | + } as UpdateConnectionTokensInput); | |
| 1484 | + | |
| 1485 | + return result.accessToken; | |
| 1486 | + }); | |
| 1487 | + | |
| 1488 | + const connectionsListForCtx = () => connectionsList(); | |
| 1489 | + | |
| 890 | 1490 | // ------------------------------------------------------------------ |
| 891 | 1491 | // Plugin wiring — build ctx, run extension, populate static pools, |
| 892 | 1492 | // register secret providers. No adapter reads here. |
| @@ -996,6 +1596,16 @@ export const createExecutor = < · 103 unmodified lines | ||
| 996 | 1596 | set: (input) => secretsSet(input), |
| 997 | 1597 | remove: (id) => secretsRemove(id), |
| 998 | 1598 | }, |
| 1599 | + connections: { | |
| 1600 | + get: (id) => connectionsGet(id), | |
| 1601 | + list: () => connectionsListForCtx(), | |
| 1602 | + create: (input) => connectionsCreate(input), | |
| 1603 | + updateTokens: (input) => connectionsUpdateTokens(input), | |
| 1604 | + setIdentityLabel: (id, label) => | |
| 1605 | + connectionsSetIdentityLabel(id, label), | |
| 1606 | + accessToken: (id) => connectionsAccessToken(id), | |
| 1607 | + remove: (id) => connectionsRemove(id), | |
| 1608 | + }, | |
| 999 | 1609 | // Open one real tx boundary and route every nested write inside |
| 1000 | 1610 | // `effect` through that same handle via the activeAdapterRef — |
| 1001 | 1611 | // see buildAdapterRouter above. Caller-typed errors (`E`) |
| @@ -1070,6 +1680,24 @@ export const createExecutor = < · 68 unmodified lines | ||
| 1070 | 1680 | secretProviders.set(provider.key, provider); |
| 1071 | 1681 | } |
| 1072 | 1682 | } |
| 1683 | + | |
| 1684 | + if (plugin.connectionProviders) { | |
| 1685 | + const raw = | |
| 1686 | + typeof plugin.connectionProviders === "function" | |
| 1687 | + ? plugin.connectionProviders(ctx) | |
| 1688 | + : plugin.connectionProviders; | |
| 1689 | + const providers = Effect.isEffect(raw) ? yield* raw : raw; | |
| 1690 | + for (const provider of providers) { | |
| 1691 | + if (connectionProviders.has(provider.key)) { | |
| 1692 | + return yield* Effect.fail( | |
| 1693 | + new Error( | |
| 1694 | + `Duplicate connection provider key: ${provider.key} (from plugin ${plugin.id})`, | |
| 1695 | + ), | |
| 1696 | + ); | |
| 1697 | + } | |
| 1698 | + connectionProviders.set(provider.key, provider); | |
| 1699 | + } | |
| 1700 | + } | |
| 1073 | 1701 | } |
| 1074 | 1702 | |
| 1075 | 1703 | // ------------------------------------------------------------------ |
| @@ -1647,6 +2275,20 @@ export const createExecutor = < · 571 unmodified lines | ||
| 1647 | 2275 | () => Array.from(secretProviders.keys()) as readonly string[], |
| 1648 | 2276 | ), |
| 1649 | 2277 | }, |
| 2278 | + connections: { | |
| 2279 | + get: connectionsGet, | |
| 2280 | + list: connectionsList, | |
| 2281 | + create: connectionsCreate, | |
| 2282 | + updateTokens: connectionsUpdateTokens, | |
| 2283 | + setIdentityLabel: connectionsSetIdentityLabel, | |
| 2284 | + accessToken: connectionsAccessToken, | |
| 2285 | + remove: connectionsRemove, | |
| 2286 | + providers: () => | |
| 2287 | + Effect.sync( | |
| 2288 | + () => | |
| 2289 | + Array.from(connectionProviders.keys()) as readonly string[], | |
| 2290 | + ), | |
| 2291 | + }, | |
| 1650 | 2292 | close, |
| 1651 | 2293 | }; |
| 1652 | 2294 | |
| @@ -11,3 +11,6 @@ export type SecretId = typeof SecretId.Type; · 10 unmodified lines | ||
| 11 | 11 | |
| 12 | 12 | export const PolicyId = Schema.String.pipe(Schema.brand("PolicyId")); |
| 13 | 13 | export type PolicyId = typeof PolicyId.Type; |
| 14 | + | |
| 15 | +export const ConnectionId = Schema.String.pipe(Schema.brand("ConnectionId")); | |
| 16 | +export type ConnectionId = typeof ConnectionId.Type; | |
| @@ -23,7 +23,7 @@ export { typedAdapter } from "@executor/storage-core"; · 22 unmodified lines | ||
| 23 | 23 | export { StorageError, UniqueViolationError } from "@executor/storage-core"; |
| 24 | 24 | |
| 25 | 25 | // IDs (branded) |
| 26 | −export { ScopeId, ToolId, SecretId, PolicyId } from "./ids"; | |
| 26 | +export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId } from "./ids"; | |
| 27 | 27 | |
| 28 | 28 | // Scope |
| 29 | 29 | export { Scope } from "./scope"; |
| @@ -38,6 +38,10 @@ export { · 8 unmodified lines | ||
| 38 | 38 | PluginNotLoadedError, |
| 39 | 39 | SecretNotFoundError, |
| 40 | 40 | SecretResolutionError, |
| 41 | + SecretOwnedByConnectionError, | |
| 42 | + ConnectionNotFoundError, | |
| 43 | + ConnectionProviderNotRegisteredError, | |
| 44 | + ConnectionRefreshNotSupportedError, | |
| 41 | 45 | type ExecutorError, |
| 42 | 46 | } from "./errors"; |
| 43 | 47 | |
| @@ -60,6 +64,7 @@ export { · 16 unmodified lines | ||
| 60 | 64 | type ToolRow, |
| 61 | 65 | type DefinitionRow, |
| 62 | 66 | type SecretRow, |
| 67 | + type ConnectionRow, | |
| 63 | 68 | type DefinitionsInput, |
| 64 | 69 | type ToolAnnotations, |
| 65 | 70 | } from "./core-schema"; |
| @@ -71,6 +76,20 @@ export { · 5 unmodified lines | ||
| 71 | 76 | type SecretProvider, |
| 72 | 77 | } from "./secrets"; |
| 73 | 78 | |
| 79 | +// Connections | |
| 80 | +export { | |
| 81 | + ConnectionRef, | |
| 82 | + ConnectionKind, | |
| 83 | + ConnectionProviderState, | |
| 84 | + CreateConnectionInput, | |
| 85 | + UpdateConnectionTokensInput, | |
| 86 | + TokenMaterial, | |
| 87 | + ConnectionRefreshError, | |
| 88 | + type ConnectionProvider, | |
| 89 | + type ConnectionRefreshInput, | |
| 90 | + type ConnectionRefreshResult, | |
| 91 | +} from "./connections"; | |
| 92 | + | |
| 74 | 93 | // Elicitation |
| 75 | 94 | export { |
| 76 | 95 | FormElicitation, |
| @@ -7,6 +7,13 @@ import type { · 6 unmodified lines | ||
| 7 | 7 | } from "@executor/storage-core"; |
| 8 | 8 | |
| 9 | 9 | import type { PluginBlobStore } from "./blob"; |
| 10 | +import type { | |
| 11 | + ConnectionProvider, | |
| 12 | + ConnectionRef, | |
| 13 | + ConnectionRefreshError, | |
| 14 | + CreateConnectionInput, | |
| 15 | + UpdateConnectionTokensInput, | |
| 16 | +} from "./connections"; | |
| 10 | 17 | import type { |
| 11 | 18 | DefinitionsInput, |
| 12 | 19 | SourceInput, |
| @@ -20,6 +27,12 @@ import type { · 7 unmodified lines | ||
| 20 | 27 | ElicitationRequest, |
| 21 | 28 | ElicitationResponse, |
| 22 | 29 | } from "./elicitation"; |
| 30 | +import type { | |
| 31 | + ConnectionNotFoundError, | |
| 32 | + ConnectionProviderNotRegisteredError, | |
| 33 | + ConnectionRefreshNotSupportedError, | |
| 34 | + SecretOwnedByConnectionError, | |
| 35 | +} from "./errors"; | |
| 23 | 36 | import type { Scope } from "./scope"; |
| 24 | 37 | import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; |
| 25 | 38 | |
| @@ -119,6 +132,9 @@ export interface PluginCtx<TStore = unknown> { · 93 unmodified lines | ||
| 119 | 132 | readonly get: ( |
| 120 | 133 | id: string, |
| 121 | 134 | ) => Effect.Effect<string | null, StorageFailure>; |
| 135 | + /** List user-visible secrets. Connection-owned secrets (rows with | |
| 136 | + * `owned_by_connection_id` set) are filtered out so they don't | |
| 137 | + * clutter the UI — users see the Connection instead. */ | |
| 122 | 138 | readonly list: () => Effect.Effect< |
| 123 | 139 | readonly { readonly id: string; readonly name: string; readonly provider: string }[], |
| 124 | 140 | StorageFailure |
| @@ -132,7 +148,54 @@ export interface PluginCtx<TStore = unknown> { · 7 unmodified lines | ||
| 132 | 148 | readonly set: ( |
| 133 | 149 | input: SetSecretInput, |
| 134 | 150 | ) => Effect.Effect<SecretRef, StorageFailure>; |
| 135 | − /** Delete a secret from its pinned provider and the core table. */ | |
| 151 | + /** Delete a secret from its pinned provider and the core table. | |
| 152 | + * Rejects with `SecretOwnedByConnectionError` if the row is owned | |
| 153 | + * by a connection — callers must go through `connections.remove` | |
| 154 | + * to drop the whole sign-in. */ | |
| 155 | + readonly remove: ( | |
| 156 | + id: string, | |
| 157 | + ) => Effect.Effect<void, SecretOwnedByConnectionError | StorageFailure>; | |
| 158 | + }; | |
| 159 | + | |
| 160 | + /** Connections — product-level sign-in state. Owns backing secret | |
| 161 | + * rows via `secret.owned_by_connection_id`. Plugins call | |
| 162 | + * `connections.accessToken(id)` at invoke time to get a guaranteed- | |
| 163 | + * fresh token (the SDK handles refresh via the registered provider | |
| 164 | + * keyed by `connection.provider`). */ | |
| 165 | + readonly connections: { | |
| 166 | + readonly get: ( | |
| 167 | + id: string, | |
| 168 | + ) => Effect.Effect<ConnectionRef | null, StorageFailure>; | |
| 169 | + readonly list: () => Effect.Effect<readonly ConnectionRef[], StorageFailure>; | |
| 170 | + readonly create: ( | |
| 171 | + input: CreateConnectionInput, | |
| 172 | + ) => Effect.Effect< | |
| 173 | + ConnectionRef, | |
| 174 | + ConnectionProviderNotRegisteredError | StorageFailure | |
| 175 | + >; | |
| 176 | + readonly updateTokens: ( | |
| 177 | + input: UpdateConnectionTokensInput, | |
| 178 | + ) => Effect.Effect< | |
| 179 | + ConnectionRef, | |
| 180 | + ConnectionNotFoundError | StorageFailure | |
| 181 | + >; | |
| 182 | + readonly setIdentityLabel: ( | |
| 183 | + id: string, | |
| 184 | + label: string | null, | |
| 185 | + ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>; | |
| 186 | + /** Get a guaranteed-fresh access token. Calls the provider's | |
| 187 | + * `refresh` handler if `expires_at` is in the past / within the | |
| 188 | + * refresh skew window. */ | |
| 189 | + readonly accessToken: ( | |
| 190 | + id: string, | |
| 191 | + ) => Effect.Effect< | |
| 192 | + string, | |
| 193 | + | ConnectionNotFoundError | |
| 194 | + | ConnectionProviderNotRegisteredError | |
| 195 | + | ConnectionRefreshNotSupportedError | |
| 196 | + | ConnectionRefreshError | |
| 197 | + | StorageFailure | |
| 198 | + >; | |
| 136 | 199 | readonly remove: (id: string) => Effect.Effect<void, StorageFailure>; |
| 137 | 200 | }; |
| 138 | 201 | |
| @@ -331,6 +394,18 @@ export interface PluginSpec< · 192 unmodified lines | ||
| 331 | 394 | ctx: PluginCtx<TStore>, |
| 332 | 395 | ) => Effect.Effect<readonly SecretProvider[]>); |
| 333 | 396 | |
| 397 | + /** Connection providers contributed by this plugin. Same registration | |
| 398 | + * shape as `secretProviders`. Each provider's `key` is what | |
| 399 | + * `connection.provider` references in the core table; the `refresh` | |
| 400 | + * handler is the SDK's single entry point for token lifecycle — | |
| 401 | + * plugins don't run their own refresh loops anymore. */ | |
| 402 | + readonly connectionProviders?: | |
| 403 | + | readonly ConnectionProvider[] | |
| 404 | + | ((ctx: PluginCtx<TStore>) => readonly ConnectionProvider[]) | |
| 405 | + | (( | |
| 406 | + ctx: PluginCtx<TStore>, | |
| 407 | + ) => Effect.Effect<readonly ConnectionProvider[]>); | |
| 408 | + | |
| 334 | 409 | readonly close?: () => Effect.Effect<void, unknown>; |
| 335 | 410 | } |
| 336 | 411 | |
| @@ -260,19 +260,40 @@ const isUniqueViolation = (cause: unknown): boolean => { · 259 unmodified lines | ||
| 260 | 260 | return false; |
| 261 | 261 | }; |
| 262 | 262 | |
| 263 | +// drizzle-orm wraps driver errors as `DrizzleQueryError` with a synthetic | |
| 264 | +// `"Failed query: <SQL>\nparams: <values>"` message and the real driver | |
| 265 | +// error on `.cause`. Walk down so classification + logging see the | |
| 266 | +// server-side code/message (`23505`, `value too long`, etc.) instead of | |
| 267 | +// the SQL+bound-values blob, which for OpenAPI specs is 1MB+ of spec text. | |
| 268 | +const unwrapDriverCause = (cause: unknown): unknown => { | |
| 269 | + let cur = cause; | |
| 270 | + for (let i = 0; i < 5; i++) { | |
| 271 | + if (!cur || typeof cur !== "object") return cur; | |
| 272 | + const c = cur as { cause?: unknown; code?: unknown; message?: unknown }; | |
| 273 | + if (typeof c.code === "string" && c.code.length > 0) return cur; | |
| 274 | + if (c.cause && c.cause !== cur) { | |
| 275 | + cur = c.cause; | |
| 276 | + continue; | |
| 277 | + } | |
| 278 | + return cur; | |
| 279 | + } | |
| 280 | + return cur; | |
| 281 | +}; | |
| 282 | + | |
| 263 | 283 | const classifyError = ( |
| 264 | 284 | op: string, |
| 265 | 285 | model: string | undefined, |
| 266 | 286 | cause: unknown, |
| 267 | 287 | ): StorageFailure => { |
| 268 | − if (isUniqueViolation(cause)) { | |
| 288 | + const driverCause = unwrapDriverCause(cause); | |
| 289 | + if (isUniqueViolation(driverCause)) { | |
| 269 | 290 | return model !== undefined |
| 270 | 291 | ? new UniqueViolationError({ model }) |
| 271 | 292 | : new UniqueViolationError({}); |
| 272 | 293 | } |
| 273 | 294 | return new StorageError({ |
| 274 | − message: `[storage-drizzle] ${op} failed: ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 275 | − cause, | |
| 295 | + message: `[storage-drizzle] ${op} failed: ${driverCause instanceof Error ? driverCause.message : String(driverCause)}`, | |
| 296 | + cause: driverCause, | |
| 276 | 297 | }); |
| 277 | 298 | }; |
| 278 | 299 | |
| @@ -21,13 +21,9 @@ const AuthPayload = Schema.Union( · 20 unmodified lines | ||
| 21 | 21 | }), |
| 22 | 22 | Schema.Struct({ |
| 23 | 23 | kind: Schema.Literal("oauth2"), |
| 24 | + connectionId: Schema.String, | |
| 24 | 25 | clientIdSecretId: Schema.String, |
| 25 | 26 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 26 | − accessTokenSecretId: Schema.String, | |
| 27 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 28 | − tokenType: Schema.optional(Schema.String), | |
| 29 | − expiresAt: Schema.NullOr(Schema.Number), | |
| 30 | − scope: Schema.NullOr(Schema.String), | |
| 31 | 27 | scopes: Schema.Array(Schema.String), |
| 32 | 28 | }), |
| 33 | 29 | ); |
| @@ -65,6 +61,15 @@ const AddSourceResponse = Schema.Struct({ · 31 unmodified lines | ||
| 65 | 61 | namespace: Schema.String, |
| 66 | 62 | }); |
| 67 | 63 | |
| 64 | +const UpdateSourcePayload = Schema.Struct({ | |
| 65 | + name: Schema.optional(Schema.String), | |
| 66 | + auth: Schema.optional(AuthPayload), | |
| 67 | +}); | |
| 68 | + | |
| 69 | +const UpdateSourceResponse = Schema.Struct({ | |
| 70 | + updated: Schema.Boolean, | |
| 71 | +}); | |
| 72 | + | |
| 68 | 73 | const StartOAuthPayload = Schema.Struct({ |
| 69 | 74 | name: Schema.String, |
| 70 | 75 | discoveryUrl: Schema.String, |
| @@ -72,6 +77,8 @@ const StartOAuthPayload = Schema.Struct({ · 1 unmodified line | ||
| 72 | 77 | clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), |
| 73 | 78 | redirectUrl: Schema.String, |
| 74 | 79 | scopes: Schema.optional(Schema.Array(Schema.String)), |
| 80 | + /** Optional executor scope that will own the resulting Connection. */ | |
| 81 | + tokenScope: Schema.optional(Schema.String), | |
| 75 | 82 | }); |
| 76 | 83 | |
| 77 | 84 | const StartOAuthResponse = Schema.Struct({ |
| @@ -88,13 +95,11 @@ const CompleteOAuthPayload = Schema.Struct({ · 10 unmodified lines | ||
| 88 | 95 | |
| 89 | 96 | const CompleteOAuthResponse = Schema.Struct({ |
| 90 | 97 | kind: Schema.Literal("oauth2"), |
| 98 | + /** Id of the Connection the SDK minted. The caller stores it on the | |
| 99 | + * source's `oauth2` field and resolves tokens via `ctx.connections`. */ | |
| 100 | + connectionId: Schema.String, | |
| 91 | 101 | clientIdSecretId: Schema.String, |
| 92 | 102 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 93 | − accessTokenSecretId: Schema.String, | |
| 94 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 95 | − tokenType: Schema.String, | |
| 96 | − expiresAt: Schema.NullOr(Schema.Number), | |
| 97 | − scope: Schema.NullOr(Schema.String), | |
| 98 | 103 | scopes: Schema.Array(Schema.String), |
| 99 | 104 | }); |
| 100 | 105 | |
| @@ -138,6 +143,13 @@ export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") · 37 unmodified lines | ||
| 138 | 143 | .setPayload(AddSourcePayload) |
| 139 | 144 | .addSuccess(AddSourceResponse), |
| 140 | 145 | ) |
| 146 | + .add( | |
| 147 | + HttpApiEndpoint.patch( | |
| 148 | + "updateSource", | |
| 149 | + )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}` | |
| 150 | + .setPayload(UpdateSourcePayload) | |
| 151 | + .addSuccess(UpdateSourceResponse), | |
| 152 | + ) | |
| 141 | 153 | .add( |
| 142 | 154 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` |
| 143 | 155 | .setPayload(StartOAuthPayload) |
| @@ -26,6 +26,7 @@ const failingExtension: GoogleDiscoveryPluginExtension = { · 25 unmodified lines | ||
| 26 | 26 | startOAuth: () => unused, |
| 27 | 27 | completeOAuth: () => Effect.die(new Error("Not implemented")), |
| 28 | 28 | getSource: (_namespace: string, _scope: string) => Effect.succeed(null), |
| 29 | + updateSource: () => unused, | |
| 29 | 30 | }; |
| 30 | 31 | |
| 31 | 32 | const Api = addGroup(GoogleDiscoveryGroup); |
| @@ -84,6 +84,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 83 unmodified lines | ||
| 84 | 84 | clientSecretSecretId: payload.clientSecretSecretId, |
| 85 | 85 | redirectUrl: payload.redirectUrl, |
| 86 | 86 | scopes: payload.scopes, |
| 87 | + tokenScope: payload.tokenScope, | |
| 87 | 88 | }); |
| 88 | 89 | })), |
| 89 | 90 | ) |
| @@ -103,6 +104,16 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 13 unmodified lines | ||
| 103 | 104 | return yield* ext.getSource(path.namespace, path.scopeId); |
| 104 | 105 | })), |
| 105 | 106 | ) |
| 107 | + .handle("updateSource", ({ path, payload }) => | |
| 108 | + capture(Effect.gen(function* () { | |
| 109 | + const ext = yield* GoogleDiscoveryExtensionService; | |
| 110 | + yield* ext.updateSource(path.namespace, path.scopeId, { | |
| 111 | + name: payload.name, | |
| 112 | + auth: payload.auth, | |
| 113 | + }); | |
| 114 | + return { updated: true }; | |
| 115 | + })), | |
| 116 | + ) | |
| 106 | 117 | .handle("oauthCallback", ({ urlParams }) => |
| 107 | 118 | capture(Effect.gen(function* () { |
| 108 | 119 | const ext = yield* GoogleDiscoveryExtensionService; |
| @@ -368,13 +368,9 @@ type ProbeResult = { · 367 unmodified lines | ||
| 368 | 368 | |
| 369 | 369 | type OAuthAuth = { |
| 370 | 370 | kind: "oauth2"; |
| 371 | + connectionId: string; | |
| 371 | 372 | clientIdSecretId: string; |
| 372 | 373 | clientSecretSecretId: string | null; |
| 373 | − accessTokenSecretId: string; | |
| 374 | − refreshTokenSecretId: string | null; | |
| 375 | − tokenType: string; | |
| 376 | − expiresAt: number | null; | |
| 377 | − scope: string | null; | |
| 378 | 374 | scopes: string[]; |
| 379 | 375 | }; |
| 380 | 376 | |
| @@ -522,13 +518,9 @@ export default function AddGoogleDiscoverySource(props: { · 141 unmodified lines | ||
| 522 | 518 | if (result.ok) { |
| 523 | 519 | setOauthAuth({ |
| 524 | 520 | kind: "oauth2", |
| 521 | + connectionId: result.connectionId, | |
| 525 | 522 | clientIdSecretId: result.clientIdSecretId, |
| 526 | 523 | clientSecretSecretId: result.clientSecretSecretId, |
| 527 | − accessTokenSecretId: result.accessTokenSecretId, | |
| 528 | − refreshTokenSecretId: result.refreshTokenSecretId, | |
| 529 | − tokenType: result.tokenType, | |
| 530 | − expiresAt: result.expiresAt, | |
| 531 | − scope: result.scope, | |
| 532 | 524 | scopes: [...result.scopes], |
| 533 | 525 | }); |
| 534 | 526 | setError(null); |
| @@ -573,8 +565,14 @@ export default function AddGoogleDiscoverySource(props: { · 38 unmodified lines | ||
| 573 | 565 | discoveryUrl: discoveryUrl.trim(), |
| 574 | 566 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 575 | 567 | auth: |
| 576 | − authKind === "oauth2" | |
| 577 | − ? (oauthAuth ?? { kind: "none" as const }) | |
| 568 | + authKind === "oauth2" && oauthAuth | |
| 569 | + ? { | |
| 570 | + kind: "oauth2" as const, | |
| 571 | + connectionId: oauthAuth.connectionId, | |
| 572 | + clientIdSecretId: oauthAuth.clientIdSecretId, | |
| 573 | + clientSecretSecretId: oauthAuth.clientSecretSecretId, | |
| 574 | + scopes: oauthAuth.scopes, | |
| 575 | + } | |
| 578 | 576 | : { kind: "none" as const }, |
| 579 | 577 | }, |
| 580 | 578 | reactivityKeys: [...sourceWriteKeys], |
| @@ -790,7 +788,7 @@ export default function AddGoogleDiscoverySource(props: { · 209 unmodified lines | ||
| 790 | 788 | </Collapsible> |
| 791 | 789 | {oauthAuth && ( |
| 792 | 790 | <div className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300"> |
| 793 | − Connected. Access token stored as secret `{oauthAuth.accessTokenSecretId}`. | |
| 791 | + Connected. Manage this connection from the Connections page. | |
| 794 | 792 | </div> |
| 795 | 793 | )} |
| 796 | 794 | </div> |
| @@ -0,0 +1,159 @@ | ||
| 1 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 2 | +import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; | |
| 3 | + | |
| 4 | +import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react"; | |
| 5 | +import { useScope } from "@executor/react/api/scope-context"; | |
| 6 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 7 | +import { connectionsAtom } from "@executor/react/api/atoms"; | |
| 8 | +import { Button } from "@executor/react/components/button"; | |
| 9 | + | |
| 10 | +import { | |
| 11 | + googleDiscoverySourceAtom, | |
| 12 | + startGoogleDiscoveryOAuth, | |
| 13 | + updateGoogleDiscoverySource, | |
| 14 | +} from "./atoms"; | |
| 15 | + | |
| 16 | +// --------------------------------------------------------------------------- | |
| 17 | +// GoogleDiscoverySignInButton — top-bar action on the source detail page. | |
| 18 | +// | |
| 19 | +// Reads the source's stored `GoogleDiscoveryAuth`, re-runs the authorization | |
| 20 | +// code flow via popup using the same `clientIdSecretId` / `clientSecretSecretId` | |
| 21 | +// / `scopes` the source was originally configured with, and on success | |
| 22 | +// rewrites the source's auth pointer to the freshly minted connection id. | |
| 23 | +// Works whether or not the previous Connection still exists — source-owned | |
| 24 | +// OAuth config is the source of truth. | |
| 25 | +// --------------------------------------------------------------------------- | |
| 26 | + | |
| 27 | +const CALLBACK_PATH = "/api/google-discovery/oauth/callback"; | |
| 28 | +const POPUP_NAME = "google-discovery-oauth"; | |
| 29 | +const CHANNEL_NAME = "executor:google-discovery-oauth-result"; | |
| 30 | + | |
| 31 | +type GoogleOAuthPopupPayload = { | |
| 32 | + kind: "oauth2"; | |
| 33 | + connectionId: string; | |
| 34 | + clientIdSecretId: string; | |
| 35 | + clientSecretSecretId: string | null; | |
| 36 | + scopes: readonly string[]; | |
| 37 | +}; | |
| 38 | + | |
| 39 | +export default function GoogleDiscoverySignInButton(props: { sourceId: string }) { | |
| 40 | + const scopeId = useScope(); | |
| 41 | + const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, props.sourceId)); | |
| 42 | + const connectionsResult = useAtomValue(connectionsAtom(scopeId)); | |
| 43 | + const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" }); | |
| 44 | + const doUpdate = useAtomSet(updateGoogleDiscoverySource, { mode: "promise" }); | |
| 45 | + | |
| 46 | + const [busy, setBusy] = useState(false); | |
| 47 | + const [error, setError] = useState<string | null>(null); | |
| 48 | + const cleanupRef = useRef<(() => void) | null>(null); | |
| 49 | + | |
| 50 | + useEffect(() => () => cleanupRef.current?.(), []); | |
| 51 | + | |
| 52 | + const source = | |
| 53 | + Result.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; | |
| 54 | + const auth = source?.config.auth; | |
| 55 | + const oauth2 = auth && auth.kind === "oauth2" ? auth : null; | |
| 56 | + const connections = Result.isSuccess(connectionsResult) | |
| 57 | + ? connectionsResult.value | |
| 58 | + : null; | |
| 59 | + const isConnected = | |
| 60 | + oauth2 !== null && | |
| 61 | + connections !== null && | |
| 62 | + connections.some((c) => c.id === oauth2.connectionId); | |
| 63 | + | |
| 64 | + const redirectUrl = | |
| 65 | + typeof window !== "undefined" | |
| 66 | + ? `${window.location.origin}${CALLBACK_PATH}` | |
| 67 | + : CALLBACK_PATH; | |
| 68 | + | |
| 69 | + const handleSignIn = useCallback(async () => { | |
| 70 | + if (!oauth2 || !source) return; | |
| 71 | + cleanupRef.current?.(); | |
| 72 | + cleanupRef.current = null; | |
| 73 | + setBusy(true); | |
| 74 | + setError(null); | |
| 75 | + try { | |
| 76 | + const response = await doStartOAuth({ | |
| 77 | + path: { scopeId }, | |
| 78 | + payload: { | |
| 79 | + name: source.name, | |
| 80 | + discoveryUrl: source.config.discoveryUrl, | |
| 81 | + clientIdSecretId: oauth2.clientIdSecretId, | |
| 82 | + clientSecretSecretId: oauth2.clientSecretSecretId, | |
| 83 | + redirectUrl, | |
| 84 | + scopes: [...oauth2.scopes], | |
| 85 | + }, | |
| 86 | + }); | |
| 87 | + | |
| 88 | + cleanupRef.current = openOAuthPopup<GoogleOAuthPopupPayload>({ | |
| 89 | + url: response.authorizationUrl, | |
| 90 | + popupName: POPUP_NAME, | |
| 91 | + channelName: CHANNEL_NAME, | |
| 92 | + onResult: async (result: OAuthPopupResult<GoogleOAuthPopupPayload>) => { | |
| 93 | + cleanupRef.current = null; | |
| 94 | + if (!result.ok) { | |
| 95 | + setBusy(false); | |
| 96 | + setError(result.error); | |
| 97 | + return; | |
| 98 | + } | |
| 99 | + try { | |
| 100 | + await doUpdate({ | |
| 101 | + path: { scopeId, namespace: props.sourceId }, | |
| 102 | + payload: { | |
| 103 | + auth: { | |
| 104 | + kind: "oauth2", | |
| 105 | + connectionId: result.connectionId, | |
| 106 | + clientIdSecretId: result.clientIdSecretId, | |
| 107 | + clientSecretSecretId: result.clientSecretSecretId, | |
| 108 | + scopes: result.scopes, | |
| 109 | + }, | |
| 110 | + }, | |
| 111 | + reactivityKeys: sourceWriteKeys, | |
| 112 | + }); | |
| 113 | + setBusy(false); | |
| 114 | + } catch (e) { | |
| 115 | + setBusy(false); | |
| 116 | + setError( | |
| 117 | + e instanceof Error ? e.message : "Failed to persist new connection", | |
| 118 | + ); | |
| 119 | + } | |
| 120 | + }, | |
| 121 | + onClosed: () => { | |
| 122 | + cleanupRef.current = null; | |
| 123 | + setBusy(false); | |
| 124 | + setError("Sign-in cancelled — popup was closed before completing the flow."); | |
| 125 | + }, | |
| 126 | + onOpenFailed: () => { | |
| 127 | + cleanupRef.current = null; | |
| 128 | + setBusy(false); | |
| 129 | + setError("Sign-in popup was blocked by the browser"); | |
| 130 | + }, | |
| 131 | + }); | |
| 132 | + } catch (e) { | |
| 133 | + setBusy(false); | |
| 134 | + setError(e instanceof Error ? e.message : "Failed to start sign-in"); | |
| 135 | + } | |
| 136 | + }, [oauth2, source, scopeId, props.sourceId, redirectUrl, doStartOAuth, doUpdate]); | |
| 137 | + | |
| 138 | + if (!oauth2) return null; | |
| 139 | + | |
| 140 | + return ( | |
| 141 | + <div className="flex items-center gap-2"> | |
| 142 | + {error && <span className="text-xs text-destructive">{error}</span>} | |
| 143 | + <Button | |
| 144 | + variant="outline" | |
| 145 | + size="sm" | |
| 146 | + onClick={() => void handleSignIn()} | |
| 147 | + disabled={busy} | |
| 148 | + > | |
| 149 | + {busy | |
| 150 | + ? isConnected | |
| 151 | + ? "Reconnecting…" | |
| 152 | + : "Signing in…" | |
| 153 | + : isConnected | |
| 154 | + ? "Reconnect" | |
| 155 | + : "Sign in"} | |
| 156 | + </Button> | |
| 157 | + </div> | |
| 158 | + ); | |
| 159 | +} | |
| @@ -17,6 +17,10 @@ export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation( · 16 unmodified lines | ||
| 17 | 17 | "googleDiscovery", |
| 18 | 18 | "addSource", |
| 19 | 19 | ); |
| 20 | +export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation( | |
| 21 | + "googleDiscovery", | |
| 22 | + "updateSource", | |
| 23 | +); | |
| 20 | 24 | export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation( |
| 21 | 25 | "googleDiscovery", |
| 22 | 26 | "startOAuth", |
| @@ -8,5 +8,6 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 7 unmodified lines | ||
| 8 | 8 | add: lazy(() => import("./AddGoogleDiscoverySource")), |
| 9 | 9 | edit: lazy(() => import("./EditGoogleDiscoverySource")), |
| 10 | 10 | summary: lazy(() => import("./GoogleDiscoverySourceSummary")), |
| 11 | + signIn: lazy(() => import("./GoogleDiscoverySignInButton")), | |
| 11 | 12 | presets: googleDiscoveryPresets, |
| 12 | 13 | }; |
| @@ -154,6 +154,14 @@ export interface GoogleDiscoveryStore { · 153 unmodified lines | ||
| 154 | 154 | readonly putSource: ( |
| 155 | 155 | source: GoogleDiscoveryStoredSource, |
| 156 | 156 | ) => Effect.Effect<void, StorageFailure>; |
| 157 | + readonly updateSourceMeta: ( | |
| 158 | + sourceId: string, | |
| 159 | + scope: string, | |
| 160 | + update: { | |
| 161 | + readonly name?: string; | |
| 162 | + readonly auth?: import("./types").GoogleDiscoveryAuth; | |
| 163 | + }, | |
| 164 | + ) => Effect.Effect<void, StorageFailure>; | |
| 157 | 165 | readonly removeSource: ( |
| 158 | 166 | sourceId: string, |
| 159 | 167 | scope: string, |
| @@ -288,6 +296,36 @@ export const makeGoogleDiscoveryStore = ( · 128 unmodified lines | ||
| 288 | 296 | }); |
| 289 | 297 | }), |
| 290 | 298 | |
| 299 | + updateSourceMeta: (sourceId, scope, update) => | |
| 300 | + Effect.gen(function* () { | |
| 301 | + const row = yield* db.findOne({ | |
| 302 | + model: "google_discovery_source", | |
| 303 | + where: [ | |
| 304 | + { field: "id", value: sourceId }, | |
| 305 | + { field: "scope_id", value: scope }, | |
| 306 | + ], | |
| 307 | + }); | |
| 308 | + if (!row) return; | |
| 309 | + const config = decodeStoredSourceData(decodeJson(row.config)); | |
| 310 | + const nextConfig = new GoogleDiscoveryStoredSourceData({ | |
| 311 | + ...config, | |
| 312 | + name: update.name ?? config.name, | |
| 313 | + auth: update.auth ?? config.auth, | |
| 314 | + }); | |
| 315 | + yield* db.update({ | |
| 316 | + model: "google_discovery_source", | |
| 317 | + where: [ | |
| 318 | + { field: "id", value: sourceId }, | |
| 319 | + { field: "scope_id", value: scope }, | |
| 320 | + ], | |
| 321 | + update: { | |
| 322 | + name: update.name ?? (row.name as string), | |
| 323 | + config: encodeStoredSourceData(nextConfig) as unknown as Record<string, unknown>, | |
| 324 | + updated_at: new Date(), | |
| 325 | + }, | |
| 326 | + }); | |
| 327 | + }), | |
| 328 | + | |
| 291 | 329 | removeSource: (sourceId, scope) => |
| 292 | 330 | db |
| 293 | 331 | .delete({ |
| @@ -1,15 +1,7 @@ | ||
| 1 | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; |
| 3 | 3 | |
| 4 | −import { | |
| 5 | − withRefreshedAccessToken, | |
| 6 | − type OAuth2SecretsIO, | |
| 7 | −} from "@executor/plugin-oauth2"; | |
| 8 | − | |
| 9 | 4 | import type { PluginCtx, StorageFailure } from "@executor/sdk"; |
| 10 | −import { SetSecretInput } from "@executor/sdk"; | |
| 11 | − | |
| 12 | −import { GOOGLE_TOKEN_URL } from "./oauth"; | |
| 13 | 5 | |
| 14 | 6 | import { |
| 15 | 7 | GoogleDiscoveryInvocationError, |
| @@ -18,8 +10,8 @@ import { · 2 unmodified lines | ||
| 18 | 10 | import type { GoogleDiscoveryStore } from "./binding-store"; |
| 19 | 11 | import { |
| 20 | 12 | GoogleDiscoveryInvocationResult, |
| 21 | − GoogleDiscoveryStoredSourceData, | |
| 22 | 13 | type GoogleDiscoveryParameter, |
| 14 | + type GoogleDiscoveryStoredSourceData, | |
| 23 | 15 | } from "./types"; |
| 24 | 16 | |
| 25 | 17 | const SAFE_METHODS = new Set(["get", "head", "options"]); |
| @@ -35,36 +27,6 @@ export const annotationsForOperation = ( · 9 unmodified lines | ||
| 35 | 27 | }; |
| 36 | 28 | }; |
| 37 | 29 | |
| 38 | −// --------------------------------------------------------------------------- | |
| 39 | −// OAuth2 secrets adapter — wraps ctx.secrets.get / ctx.secrets.set so | |
| 40 | −// the shared `@executor/plugin-oauth2` helpers can read/write token | |
| 41 | −// secrets without knowing about PluginCtx. | |
| 42 | −// --------------------------------------------------------------------------- | |
| 43 | − | |
| 44 | −const makeSecretsIO = (ctx: PluginCtx<GoogleDiscoveryStore>): OAuth2SecretsIO => ({ | |
| 45 | − resolve: (id) => | |
| 46 | − ctx.secrets.get(id).pipe( | |
| 47 | − Effect.flatMap((value) => | |
| 48 | − value === null | |
| 49 | − ? Effect.fail(new Error(`Secret not found: ${id}`)) | |
| 50 | − : Effect.succeed(value), | |
| 51 | − ), | |
| 52 | − ), | |
| 53 | − setValue: ({ secretId, value, name }) => | |
| 54 | − ctx.secrets | |
| 55 | − .set( | |
| 56 | − new SetSecretInput({ | |
| 57 | − id: secretId as SetSecretInput["id"], | |
| 58 | − // Refresh writes land at the innermost (user) scope — same | |
| 59 | − // partition as the original mint. | |
| 60 | − scope: ctx.scopes[0]!.id, | |
| 61 | − name, | |
| 62 | − value, | |
| 63 | − }), | |
| 64 | − ) | |
| 65 | − .pipe(Effect.asVoid), | |
| 66 | −}); | |
| 67 | − | |
| 68 | 30 | // --------------------------------------------------------------------------- |
| 69 | 31 | // Path / query parameter helpers (unchanged from the old invoker) |
| 70 | 32 | // --------------------------------------------------------------------------- |
| @@ -113,65 +75,6 @@ const isJsonContentType = (contentType: string | null | undefined): boolean => { · 42 unmodified lines | ||
| 113 | 75 | ); |
| 114 | 76 | }; |
| 115 | 77 | |
| 116 | −// --------------------------------------------------------------------------- | |
| 117 | −// Resolve (and lazily refresh) an OAuth2 access token for a stored source. | |
| 118 | −// --------------------------------------------------------------------------- | |
| 119 | − | |
| 120 | −const resolveOAuthAccessToken = (input: { | |
| 121 | − ctx: PluginCtx<GoogleDiscoveryStore>; | |
| 122 | − sourceId: string; | |
| 123 | − sourceScope: string; | |
| 124 | − source: GoogleDiscoveryStoredSourceData; | |
| 125 | −}): Effect.Effect<string, GoogleDiscoveryOAuthError> => | |
| 126 | − Effect.gen(function* () { | |
| 127 | − if (input.source.auth.kind !== "oauth2") return ""; | |
| 128 | − const auth = input.source.auth; | |
| 129 | − | |
| 130 | − return yield* withRefreshedAccessToken({ | |
| 131 | − auth: { | |
| 132 | − clientIdSecretId: auth.clientIdSecretId, | |
| 133 | − clientSecretSecretId: auth.clientSecretSecretId, | |
| 134 | − accessTokenSecretId: auth.accessTokenSecretId, | |
| 135 | − refreshTokenSecretId: auth.refreshTokenSecretId, | |
| 136 | − tokenType: auth.tokenType, | |
| 137 | − expiresAt: auth.expiresAt, | |
| 138 | − scopes: auth.scopes, | |
| 139 | − }, | |
| 140 | − tokenUrl: GOOGLE_TOKEN_URL, | |
| 141 | − secrets: makeSecretsIO(input.ctx), | |
| 142 | − displayName: input.source.name, | |
| 143 | − accessTokenPurpose: "google_oauth_access_token", | |
| 144 | − refreshTokenPurpose: "google_oauth_refresh_token", | |
| 145 | − persistAuth: (snapshot) => | |
| 146 | − Effect.gen(function* () { | |
| 147 | − const updated = new GoogleDiscoveryStoredSourceData({ | |
| 148 | − ...input.source, | |
| 149 | − auth: { | |
| 150 | − kind: "oauth2", | |
| 151 | − clientIdSecretId: auth.clientIdSecretId, | |
| 152 | − clientSecretSecretId: auth.clientSecretSecretId, | |
| 153 | − accessTokenSecretId: auth.accessTokenSecretId, | |
| 154 | − refreshTokenSecretId: auth.refreshTokenSecretId, | |
| 155 | − tokenType: snapshot.tokenType, | |
| 156 | − expiresAt: snapshot.expiresAt, | |
| 157 | − scope: snapshot.scope ?? auth.scope, | |
| 158 | − scopes: auth.scopes, | |
| 159 | − }, | |
| 160 | − }); | |
| 161 | − yield* input.ctx.storage.putSource({ | |
| 162 | − namespace: input.sourceId, | |
| 163 | − scope: input.sourceScope, | |
| 164 | − name: input.source.name, | |
| 165 | − config: updated, | |
| 166 | − }); | |
| 167 | − }), | |
| 168 | − }).pipe( | |
| 169 | − Effect.mapError( | |
| 170 | − (error) => new GoogleDiscoveryOAuthError({ message: error.message }), | |
| 171 | − ), | |
| 172 | − ); | |
| 173 | − }); | |
| 174 | − | |
| 175 | 78 | // --------------------------------------------------------------------------- |
| 176 | 79 | // HTTP request builder / executor |
| 177 | 80 | // --------------------------------------------------------------------------- |
| @@ -280,10 +183,7 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 102 unmodified lines | ||
| 280 | 183 | export const invokeGoogleDiscoveryTool = (input: { |
| 281 | 184 | ctx: PluginCtx<GoogleDiscoveryStore>; |
| 282 | 185 | toolId: string; |
| 283 | − /** Resolved owning scope of the tool row (innermost-wins from the | |
| 284 | − * executor's stack). The matching binding + source rows live at the | |
| 285 | − * same scope, so pin every store lookup to this instead of relying | |
| 286 | − * on the scoped adapter's stack-wide fall-through. */ | |
| 186 | + /** Resolved owning scope of the tool row. */ | |
| 287 | 187 | toolScope: string; |
| 288 | 188 | args: unknown; |
| 289 | 189 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; |
| @@ -314,18 +214,22 @@ export const invokeGoogleDiscoveryTool = (input: { · 24 unmodified lines | ||
| 314 | 214 | } |
| 315 | 215 | const source = stored.config; |
| 316 | 216 | |
| 317 | − const accessToken = | |
| 318 | − source.auth.kind === "oauth2" | |
| 319 | − ? yield* resolveOAuthAccessToken({ | |
| 320 | − ctx: input.ctx, | |
| 321 | − sourceId: entry.namespace, | |
| 322 | − sourceScope: stored.scope, | |
| 323 | − source, | |
| 324 | − }) | |
| 325 | − : ""; | |
| 326 | − | |
| 327 | 217 | const authHeader = |
| 328 | − source.auth.kind === "oauth2" ? `${source.auth.tokenType} ${accessToken}` : undefined; | |
| 218 | + source.auth.kind === "oauth2" | |
| 219 | + ? `Bearer ${yield* input.ctx.connections | |
| 220 | + .accessToken(source.auth.connectionId) | |
| 221 | + .pipe( | |
| 222 | + Effect.mapError( | |
| 223 | + (err) => | |
| 224 | + new GoogleDiscoveryOAuthError({ | |
| 225 | + message: | |
| 226 | + "message" in err | |
| 227 | + ? (err as { message: string }).message | |
| 228 | + : String(err), | |
| 229 | + }), | |
| 230 | + ), | |
| 231 | + )}` | |
| 232 | + : undefined; | |
| 329 | 233 | |
| 330 | 234 | const layer = input.httpClientLayer ?? FetchHttpClient.layer; |
| 331 | 235 | |
| @@ -7,12 +7,15 @@ import { Effect } from "effect"; · 6 unmodified lines | ||
| 7 | 7 | import { vi } from "vitest"; |
| 8 | 8 | |
| 9 | 9 | import { |
| 10 | + ConnectionId, | |
| 11 | + CreateConnectionInput, | |
| 10 | 12 | createExecutor, |
| 11 | 13 | makeTestConfig, |
| 12 | 14 | Scope, |
| 13 | 15 | ScopeId, |
| 14 | 16 | SecretId, |
| 15 | 17 | SetSecretInput, |
| 18 | + TokenMaterial, | |
| 16 | 19 | type InvokeOptions, |
| 17 | 20 | } from "@executor/sdk"; |
| 18 | 21 | |
| @@ -300,13 +303,22 @@ describe("Google Discovery plugin", () => { · 281 unmodified lines | ||
| 300 | 303 | }); |
| 301 | 304 | |
| 302 | 305 | expect(auth.kind).toBe("oauth2"); |
| 303 | − expect(auth.clientIdSecretId).toBe("google-client-id"); | |
| 304 | − expect(auth.refreshTokenSecretId).not.toBeNull(); | |
| 306 | + expect(auth.connectionId).toMatch(/^google-discovery-oauth2-/); | |
| 305 | 307 | |
| 306 | − const accessToken = yield* executor.secrets.get(auth.accessTokenSecretId); | |
| 308 | + // Tokens live on the SDK connection — resolving via | |
| 309 | + // ctx.connections.accessToken returns the minted value. | |
| 310 | + const accessToken = yield* executor.connections.accessToken( | |
| 311 | + auth.connectionId as Parameters<typeof executor.connections.accessToken>[0], | |
| 312 | + ); | |
| 307 | 313 | expect(accessToken).toBe("access-token-value"); |
| 308 | − const refreshToken = yield* executor.secrets.get(auth.refreshTokenSecretId!); | |
| 309 | − expect(refreshToken).toBe("refresh-token-value"); | |
| 314 | + | |
| 315 | + // Backing access-token secret is owned by the connection, so | |
| 316 | + // it's filtered out of the user-facing secret list. | |
| 317 | + const secretIds = new Set( | |
| 318 | + (yield* executor.secrets.list()).map((s) => s.id as unknown as string), | |
| 319 | + ); | |
| 320 | + expect(secretIds).not.toContain(`${auth.connectionId}.access_token`); | |
| 321 | + expect(secretIds).not.toContain(`${auth.connectionId}.refresh_token`); | |
| 310 | 322 | } finally { |
| 311 | 323 | fetchMock.mockRestore(); |
| 312 | 324 | yield* executor.close(); |
| @@ -328,20 +340,31 @@ describe("Google Discovery plugin", () => { · 15 unmodified lines | ||
| 328 | 340 | ); |
| 329 | 341 | |
| 330 | 342 | try { |
| 331 | − yield* executor.secrets.set( | |
| 332 | − new SetSecretInput({ | |
| 333 | − id: SecretId.make("drive-access-token"), | |
| 334 | − scope: "test-scope" as SetSecretInput["scope"], | |
| 335 | − name: "Drive Access Token", | |
| 336 | − value: "secret-token", | |
| 337 | − }), | |
| 343 | + // A connection wraps the access token (+ optional refresh) and | |
| 344 | + // the invoke path resolves via ctx.connections.accessToken. | |
| 345 | + const connectionId = ConnectionId.make( | |
| 346 | + "google-discovery-oauth2-test", | |
| 338 | 347 | ); |
| 339 | − yield* executor.secrets.set( | |
| 340 | − new SetSecretInput({ | |
| 341 | − id: SecretId.make("drive-client-id"), | |
| 342 | − scope: "test-scope" as SetSecretInput["scope"], | |
| 343 | − name: "Drive Client ID", | |
| 344 | − value: "client-123", | |
| 348 | + yield* executor.connections.create( | |
| 349 | + new CreateConnectionInput({ | |
| 350 | + id: connectionId, | |
| 351 | + scope: ScopeId.make("test-scope"), | |
| 352 | + provider: "google-discovery:oauth2", | |
| 353 | + kind: "user", | |
| 354 | + identityLabel: "Drive Test", | |
| 355 | + accessToken: new TokenMaterial({ | |
| 356 | + secretId: SecretId.make(`${connectionId}.access_token`), | |
| 357 | + name: "Drive Access Token", | |
| 358 | + value: "secret-token", | |
| 359 | + }), | |
| 360 | + refreshToken: null, | |
| 361 | + expiresAt: null, | |
| 362 | + oauthScope: null, | |
| 363 | + providerState: { | |
| 364 | + clientIdSecretId: "drive-client-id", | |
| 365 | + clientSecretSecretId: null, | |
| 366 | + scopes: ["https://www.googleapis.com/auth/drive.readonly"], | |
| 367 | + }, | |
| 345 | 368 | }), |
| 346 | 369 | ); |
| 347 | 370 | |
| @@ -352,13 +375,9 @@ describe("Google Discovery plugin", () => { · 4 unmodified lines | ||
| 352 | 375 | namespace: "drive", |
| 353 | 376 | auth: { |
| 354 | 377 | kind: "oauth2", |
| 378 | + connectionId, | |
| 355 | 379 | clientIdSecretId: "drive-client-id", |
| 356 | 380 | clientSecretSecretId: null, |
| 357 | − accessTokenSecretId: "drive-access-token", | |
| 358 | − refreshTokenSecretId: null, | |
| 359 | − tokenType: "Bearer", | |
| 360 | − expiresAt: null, | |
| 361 | − scope: null, | |
| 362 | 381 | scopes: ["https://www.googleapis.com/auth/drive.readonly"], |
| 363 | 382 | }, |
| 364 | 383 | }); |
| @@ -1,13 +1,25 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | |
| 3 | −import { Effect, Option } from "effect"; | |
| 3 | +import { Effect, Option, Schema } from "effect"; | |
| 4 | 4 | |
| 5 | −import { storeOAuthTokens } from "@executor/plugin-oauth2"; | |
| 5 | +import { | |
| 6 | + OAuth2Error, | |
| 7 | + refreshAccessToken, | |
| 8 | + type OAuth2TokenResponse, | |
| 9 | +} from "@executor/plugin-oauth2"; | |
| 6 | 10 | |
| 7 | 11 | import { |
| 8 | − definePlugin, | |
| 9 | − SetSecretInput, | |
| 12 | + ConnectionId, | |
| 13 | + ConnectionRefreshError, | |
| 14 | + CreateConnectionInput, | |
| 15 | + ScopeId, | |
| 16 | + SecretId, | |
| 10 | 17 | SourceDetectionResult, |
| 18 | + TokenMaterial, | |
| 19 | + definePlugin, | |
| 20 | + type ConnectionProvider, | |
| 21 | + type ConnectionRefreshInput, | |
| 22 | + type ConnectionRefreshResult, | |
| 11 | 23 | type PluginCtx, |
| 12 | 24 | type StorageFailure, |
| 13 | 25 | type ToolAnnotations, |
| @@ -30,6 +42,7 @@ import { · 16 unmodified lines | ||
| 30 | 42 | buildGoogleAuthorizationUrl, |
| 31 | 43 | createPkceCodeVerifier, |
| 32 | 44 | exchangeAuthorizationCode, |
| 45 | + GOOGLE_TOKEN_URL, | |
| 33 | 46 | } from "./oauth"; |
| 34 | 47 | import type { |
| 35 | 48 | GoogleDiscoveryAuth, |
| @@ -41,7 +54,7 @@ import type { · 5 unmodified lines | ||
| 41 | 54 | import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; |
| 42 | 55 | |
| 43 | 56 | // --------------------------------------------------------------------------- |
| 44 | −// Public input / output shapes (unchanged from the old plugin) | |
| 57 | +// Public input / output shapes | |
| 45 | 58 | // --------------------------------------------------------------------------- |
| 46 | 59 | |
| 47 | 60 | export interface GoogleDiscoveryProbeOperation { |
| @@ -69,6 +82,13 @@ export interface GoogleDiscoveryAddSourceInput { · 21 unmodified lines | ||
| 69 | 82 | readonly auth: GoogleDiscoveryAuth; |
| 70 | 83 | } |
| 71 | 84 | |
| 85 | +export interface GoogleDiscoveryUpdateSourceInput { | |
| 86 | + readonly name?: string; | |
| 87 | + /** Rewrite the source's auth — typically after a successful | |
| 88 | + * re-authenticate, to point at a freshly minted Connection. */ | |
| 89 | + readonly auth?: GoogleDiscoveryAuth; | |
| 90 | +} | |
| 91 | + | |
| 72 | 92 | export interface GoogleDiscoveryOAuthStartInput { |
| 73 | 93 | readonly name: string; |
| 74 | 94 | readonly discoveryUrl: string; |
| @@ -76,6 +96,9 @@ export interface GoogleDiscoveryOAuthStartInput { · 1 unmodified line | ||
| 76 | 96 | readonly clientSecretSecretId?: string | null; |
| 77 | 97 | readonly redirectUrl: string; |
| 78 | 98 | readonly scopes?: readonly string[]; |
| 99 | + /** Executor scope that will own the resulting Connection + its backing | |
| 100 | + * secrets. Defaults to `ctx.scopes[0].id` (innermost / per-user). */ | |
| 101 | + readonly tokenScope?: string; | |
| 79 | 102 | } |
| 80 | 103 | |
| 81 | 104 | export interface GoogleDiscoveryOAuthStartResponse { |
| @@ -90,28 +113,23 @@ export interface GoogleDiscoveryOAuthCompleteInput { · 8 unmodified lines | ||
| 90 | 113 | readonly error?: string; |
| 91 | 114 | } |
| 92 | 115 | |
| 116 | +/** | |
| 117 | + * Completed OAuth auth handed back to the caller (either an HTTP client or | |
| 118 | + * a static-tool handler). Carries the full API-level OAuth config so the | |
| 119 | + * caller can stamp it onto the source's `GoogleDiscoveryAuth` — that makes | |
| 120 | + * sign-in reproducible from the source detail page without depending on | |
| 121 | + * the prior Connection still existing. | |
| 122 | + */ | |
| 93 | 123 | export interface GoogleDiscoveryOAuthAuthResult { |
| 94 | 124 | readonly kind: "oauth2"; |
| 125 | + readonly connectionId: string; | |
| 95 | 126 | readonly clientIdSecretId: string; |
| 96 | 127 | readonly clientSecretSecretId: string | null; |
| 97 | − readonly accessTokenSecretId: string; | |
| 98 | − readonly refreshTokenSecretId: string | null; | |
| 99 | − readonly tokenType: string; | |
| 100 | − readonly expiresAt: number | null; | |
| 101 | − readonly scope: string | null; | |
| 102 | 128 | readonly scopes: readonly string[]; |
| 103 | 129 | } |
| 104 | 130 | |
| 105 | 131 | /** |
| 106 | − * Errors any Google Discovery extension method may surface. The first | |
| 107 | − * three are plugin-domain tagged errors that flow directly to clients | |
| 108 | − * (4xx, each carrying its own `HttpApiSchema` status once wrapped at the | |
| 109 | − * API edge). `StorageFailure` covers raw backend failures (`StorageError`) | |
| 110 | − * plus `UniqueViolationError`; the HTTP edge (`@executor/api`'s | |
| 111 | − * `withCapture`) translates `StorageError` to the opaque | |
| 112 | − * `InternalError({ traceId })` at Layer composition. `UniqueViolationError` | |
| 113 | − * passes through — plugins can `Effect.catchTag` it if they want a | |
| 114 | − * friendlier user-facing error. | |
| 132 | + * Errors any Google Discovery extension method may surface. | |
| 115 | 133 | */ |
| 116 | 134 | export type GoogleDiscoveryExtensionFailure = |
| 117 | 135 | | GoogleDiscoveryParseError |
| @@ -155,6 +173,11 @@ export interface GoogleDiscoveryPluginExtension { · 37 unmodified lines | ||
| 155 | 173 | namespace: string, |
| 156 | 174 | scope: string, |
| 157 | 175 | ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>; |
| 176 | + readonly updateSource: ( | |
| 177 | + namespace: string, | |
| 178 | + scope: string, | |
| 179 | + input: GoogleDiscoveryUpdateSourceInput, | |
| 180 | + ) => Effect.Effect<void, StorageFailure>; | |
| 158 | 181 | } |
| 159 | 182 | |
| 160 | 183 | // --------------------------------------------------------------------------- |
| @@ -220,6 +243,29 @@ const deriveNamespace = (input: { name: string; service: string; version: string · 59 unmodified lines | ||
| 220 | 243 | input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`, |
| 221 | 244 | ) || `google_${input.service}`; |
| 222 | 245 | |
| 246 | +// --------------------------------------------------------------------------- | |
| 247 | +// Connection provider_state — every knob needed to drive a refresh. NOT | |
| 248 | +// sensitive — client credentials live in secrets and are resolved by the | |
| 249 | +// refresh handler at runtime. | |
| 250 | +// --------------------------------------------------------------------------- | |
| 251 | + | |
| 252 | +const GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY = "google-discovery:oauth2" as const; | |
| 253 | + | |
| 254 | +const OAuth2ProviderState = Schema.Struct({ | |
| 255 | + clientIdSecretId: Schema.String, | |
| 256 | + clientSecretSecretId: Schema.NullOr(Schema.String), | |
| 257 | + scopes: Schema.Array(Schema.String), | |
| 258 | +}); | |
| 259 | +type OAuth2ProviderState = typeof OAuth2ProviderState.Type; | |
| 260 | + | |
| 261 | +const encodeProviderState = Schema.encodeSync(OAuth2ProviderState); | |
| 262 | +const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState); | |
| 263 | + | |
| 264 | +const toProviderStateRecord = ( | |
| 265 | + state: OAuth2ProviderState, | |
| 266 | +): Record<string, unknown> => | |
| 267 | + encodeProviderState(state) as unknown as Record<string, unknown>; | |
| 268 | + | |
| 223 | 269 | // --------------------------------------------------------------------------- |
| 224 | 270 | // Register a parsed manifest against the executor core + plugin storage. |
| 225 | 271 | // Runs inside a transaction. |
| @@ -233,13 +279,9 @@ const registerManifest = ( · 7 unmodified lines | ||
| 233 | 279 | sourceData: GoogleDiscoveryStoredSourceData, |
| 234 | 280 | ) => |
| 235 | 281 | Effect.gen(function* () { |
| 236 | − // 1. Clear any previous manifest for this namespace at this scope. | |
| 237 | − // Scope-pinned so a refresh/re-add at a user scope can't wipe | |
| 238 | − // bindings from an org-level shadow of the same namespace. | |
| 239 | 282 | yield* ctx.storage.removeBindingsBySource(namespace, scope); |
| 240 | 283 | yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore); |
| 241 | 284 | |
| 242 | − // 2. Register the source + tool rows in core. | |
| 243 | 285 | yield* ctx.core.sources.register({ |
| 244 | 286 | id: namespace, |
| 245 | 287 | scope, |
| @@ -257,7 +299,6 @@ const registerManifest = ( · 11 unmodified lines | ||
| 257 | 299 | })), |
| 258 | 300 | }); |
| 259 | 301 | |
| 260 | − // 3. Register shared $defs, if any. | |
| 261 | 302 | if (Object.keys(manifest.schemaDefinitions).length > 0) { |
| 262 | 303 | yield* ctx.core.definitions.register({ |
| 263 | 304 | sourceId: namespace, |
| @@ -266,8 +307,6 @@ const registerManifest = ( · 2 unmodified lines | ||
| 266 | 307 | }); |
| 267 | 308 | } |
| 268 | 309 | |
| 269 | − // 4. Write per-tool bindings to plugin storage (keyed by the same | |
| 270 | − // ${source_id}.${name} tool id the executor synthesizes). | |
| 271 | 310 | yield* Effect.forEach( |
| 272 | 311 | manifest.methods, |
| 273 | 312 | (method) => |
| @@ -280,7 +319,6 @@ const registerManifest = ( · 6 unmodified lines | ||
| 280 | 319 | { discard: true }, |
| 281 | 320 | ); |
| 282 | 321 | |
| 283 | − // 5. Write the source config blob. | |
| 284 | 322 | yield* ctx.storage.putSource({ |
| 285 | 323 | namespace, |
| 286 | 324 | scope, |
| @@ -291,32 +329,6 @@ const registerManifest = ( · 4 unmodified lines | ||
| 291 | 329 | return manifest.methods.length; |
| 292 | 330 | }); |
| 293 | 331 | |
| 294 | −// --------------------------------------------------------------------------- | |
| 295 | −// Mint a fresh secret via ctx.secrets.set and return {id} for storeOAuthTokens. | |
| 296 | −// --------------------------------------------------------------------------- | |
| 297 | − | |
| 298 | −const createSecretForOAuth = ( | |
| 299 | − ctx: PluginCtx<GoogleDiscoveryStore>, | |
| 300 | − input: { | |
| 301 | − readonly idPrefix: string; | |
| 302 | − readonly name: string; | |
| 303 | − readonly value: string; | |
| 304 | − readonly purpose: string; | |
| 305 | − }, | |
| 306 | −) => | |
| 307 | − ctx.secrets | |
| 308 | − .set( | |
| 309 | − new SetSecretInput({ | |
| 310 | − id: `${input.idPrefix}_${randomUUID().slice(0, 8)}` as SetSecretInput["id"], | |
| 311 | − // OAuth tokens are per-user: pin to the innermost scope so Alice | |
| 312 | − // and Bob don't overwrite each other's tokens. | |
| 313 | − scope: ctx.scopes[0]!.id, | |
| 314 | − name: input.name, | |
| 315 | − value: input.value, | |
| 316 | − }), | |
| 317 | − ) | |
| 318 | − .pipe(Effect.map((ref) => ({ id: ref.id as string }))); | |
| 319 | − | |
| 320 | 332 | // --------------------------------------------------------------------------- |
| 321 | 333 | // Plugin |
| 322 | 334 | // --------------------------------------------------------------------------- |
| @@ -418,6 +430,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 95 unmodified lines | ||
| 418 | 430 | } |
| 419 | 431 | const sessionId = randomUUID(); |
| 420 | 432 | const codeVerifier = createPkceCodeVerifier(); |
| 433 | + const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string); | |
| 434 | + const connectionId = `google-discovery-oauth2-${randomUUID()}`; | |
| 421 | 435 | yield* ctx.storage.putOAuthSession(sessionId, ctx.scopes[0]!.id as string, { |
| 422 | 436 | discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), |
| 423 | 437 | name: input.name, |
| @@ -426,6 +440,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 2 unmodified lines | ||
| 426 | 440 | redirectUrl: input.redirectUrl, |
| 427 | 441 | scopes, |
| 428 | 442 | codeVerifier, |
| 443 | + tokenScope, | |
| 444 | + connectionId, | |
| 429 | 445 | }); |
| 430 | 446 | return { |
| 431 | 447 | sessionId, |
| @@ -441,102 +457,138 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 9 unmodified lines | ||
| 441 | 457 | }), |
| 442 | 458 | |
| 443 | 459 | completeOAuth: (input) => |
| 444 | − Effect.gen(function* () { | |
| 445 | − const session = yield* ctx.storage.getOAuthSession(input.state); | |
| 446 | − if (!session) { | |
| 447 | − return yield* new GoogleDiscoveryOAuthError({ | |
| 448 | − message: "OAuth session not found or has expired", | |
| 449 | − }); | |
| 450 | − } | |
| 451 | − yield* ctx.storage.deleteOAuthSession(input.state); | |
| 452 | − | |
| 453 | − if (input.error) { | |
| 454 | − return yield* new GoogleDiscoveryOAuthError({ message: input.error }); | |
| 455 | − } | |
| 456 | − if (!input.code) { | |
| 457 | − return yield* new GoogleDiscoveryOAuthError({ | |
| 458 | − message: "OAuth callback did not include an authorization code", | |
| 459 | − }); | |
| 460 | − } | |
| 460 | + ctx.transaction( | |
| 461 | + Effect.gen(function* () { | |
| 462 | + const session = yield* ctx.storage.getOAuthSession(input.state); | |
| 463 | + if (!session) { | |
| 464 | + return yield* new GoogleDiscoveryOAuthError({ | |
| 465 | + message: "OAuth session not found or has expired", | |
| 466 | + }); | |
| 467 | + } | |
| 468 | + yield* ctx.storage.deleteOAuthSession(input.state); | |
| 469 | + | |
| 470 | + if (input.error) { | |
| 471 | + return yield* new GoogleDiscoveryOAuthError({ message: input.error }); | |
| 472 | + } | |
| 473 | + if (!input.code) { | |
| 474 | + return yield* new GoogleDiscoveryOAuthError({ | |
| 475 | + message: "OAuth callback did not include an authorization code", | |
| 476 | + }); | |
| 477 | + } | |
| 461 | 478 | |
| 462 | − const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId); | |
| 463 | − if (clientIdValue === null) { | |
| 464 | − return yield* new GoogleDiscoveryOAuthError({ | |
| 465 | − message: `OAuth client ID secret not found: ${session.clientIdSecretId}`, | |
| 479 | + const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId); | |
| 480 | + if (clientIdValue === null) { | |
| 481 | + return yield* new GoogleDiscoveryOAuthError({ | |
| 482 | + message: `OAuth client ID secret not found: ${session.clientIdSecretId}`, | |
| 483 | + }); | |
| 484 | + } | |
| 485 | + | |
| 486 | + const clientSecretValue = | |
| 487 | + session.clientSecretSecretId === null | |
| 488 | + ? null | |
| 489 | + : yield* ctx.secrets.get(session.clientSecretSecretId).pipe( | |
| 490 | + Effect.flatMap((v) => | |
| 491 | + v === null | |
| 492 | + ? Effect.fail( | |
| 493 | + new GoogleDiscoveryOAuthError({ | |
| 494 | + message: `OAuth client secret not found: ${session.clientSecretSecretId}`, | |
| 495 | + }), | |
| 496 | + ) | |
| 497 | + : Effect.succeed(v), | |
| 498 | + ), | |
| 499 | + ); | |
| 500 | + | |
| 501 | + const tokenResponse = yield* exchangeAuthorizationCode({ | |
| 502 | + clientId: clientIdValue, | |
| 503 | + clientSecret: clientSecretValue, | |
| 504 | + redirectUrl: session.redirectUrl, | |
| 505 | + codeVerifier: session.codeVerifier, | |
| 506 | + code: input.code, | |
| 466 | 507 | }); |
| 467 | − } | |
| 468 | 508 | |
| 469 | − const clientSecretValue = | |
| 470 | − session.clientSecretSecretId === null | |
| 471 | − ? null | |
| 472 | − : yield* ctx.secrets.get(session.clientSecretSecretId).pipe( | |
| 473 | − Effect.flatMap((v) => | |
| 474 | − v === null | |
| 475 | − ? Effect.fail( | |
| 476 | − new GoogleDiscoveryOAuthError({ | |
| 477 | − message: `OAuth client secret not found: ${session.clientSecretSecretId}`, | |
| 478 | − }), | |
| 479 | − ) | |
| 480 | − : Effect.succeed(v), | |
| 481 | − ), | |
| 482 | − ); | |
| 483 | − | |
| 484 | − const tokenResponse = yield* exchangeAuthorizationCode({ | |
| 485 | − clientId: clientIdValue, | |
| 486 | − clientSecret: clientSecretValue, | |
| 487 | − redirectUrl: session.redirectUrl, | |
| 488 | − codeVerifier: session.codeVerifier, | |
| 489 | − code: input.code, | |
| 490 | − }); | |
| 509 | + const expiresAt = | |
| 510 | + typeof tokenResponse.expires_in === "number" | |
| 511 | + ? Date.now() + tokenResponse.expires_in * 1000 | |
| 512 | + : null; | |
| 513 | + | |
| 514 | + const providerState: OAuth2ProviderState = { | |
| 515 | + clientIdSecretId: session.clientIdSecretId, | |
| 516 | + clientSecretSecretId: session.clientSecretSecretId, | |
| 517 | + scopes: [...session.scopes], | |
| 518 | + }; | |
| 519 | + | |
| 520 | + yield* ctx.connections | |
| 521 | + .create( | |
| 522 | + new CreateConnectionInput({ | |
| 523 | + id: ConnectionId.make(session.connectionId), | |
| 524 | + scope: ScopeId.make(session.tokenScope), | |
| 525 | + provider: GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY, | |
| 526 | + kind: "user", | |
| 527 | + identityLabel: session.name, | |
| 528 | + accessToken: new TokenMaterial({ | |
| 529 | + secretId: SecretId.make(`${session.connectionId}.access_token`), | |
| 530 | + name: `${session.name} Access Token`, | |
| 531 | + value: tokenResponse.access_token, | |
| 532 | + }), | |
| 533 | + refreshToken: tokenResponse.refresh_token | |
| 534 | + ? new TokenMaterial({ | |
| 535 | + secretId: SecretId.make(`${session.connectionId}.refresh_token`), | |
| 536 | + name: `${session.name} Refresh Token`, | |
| 537 | + value: tokenResponse.refresh_token, | |
| 538 | + }) | |
| 539 | + : null, | |
| 540 | + expiresAt, | |
| 541 | + oauthScope: tokenResponse.scope ?? null, | |
| 542 | + providerState: toProviderStateRecord(providerState), | |
| 543 | + }), | |
| 544 | + ) | |
| 545 | + .pipe( | |
| 546 | + Effect.mapError( | |
| 547 | + (err) => | |
| 548 | + new GoogleDiscoveryOAuthError({ | |
| 549 | + message: | |
| 550 | + "message" in err | |
| 551 | + ? (err as { message: string }).message | |
| 552 | + : String(err), | |
| 553 | + }), | |
| 554 | + ), | |
| 555 | + ); | |
| 556 | + | |
| 557 | + return { | |
| 558 | + kind: "oauth2" as const, | |
| 559 | + connectionId: session.connectionId, | |
| 560 | + clientIdSecretId: session.clientIdSecretId, | |
| 561 | + clientSecretSecretId: session.clientSecretSecretId, | |
| 562 | + scopes: [...session.scopes], | |
| 563 | + }; | |
| 564 | + }), | |
| 565 | + ).pipe( | |
| 566 | + Effect.mapError((err) => | |
| 567 | + err instanceof GoogleDiscoveryOAuthError | |
| 568 | + ? err | |
| 569 | + : new GoogleDiscoveryOAuthError({ message: err.message }), | |
| 570 | + ), | |
| 571 | + ), | |
| 491 | 572 | |
| 492 | − const stored = yield* storeOAuthTokens({ | |
| 493 | − tokens: tokenResponse, | |
| 494 | − slug: `${normalizeSlug(session.name)}_google`, | |
| 495 | − displayName: session.name, | |
| 496 | − accessTokenPurpose: "google_oauth_access_token", | |
| 497 | − refreshTokenPurpose: "google_oauth_refresh_token", | |
| 498 | − createSecret: (args) => createSecretForOAuth(ctx, args), | |
| 499 | − }).pipe( | |
| 500 | − Effect.mapError((error) => new GoogleDiscoveryOAuthError({ message: error.message })), | |
| 501 | − ); | |
| 573 | + getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope), | |
| 502 | 574 | |
| 503 | − return { | |
| 504 | − kind: "oauth2" as const, | |
| 505 | − clientIdSecretId: session.clientIdSecretId, | |
| 506 | − clientSecretSecretId: session.clientSecretSecretId, | |
| 507 | − accessTokenSecretId: stored.accessTokenSecretId, | |
| 508 | − refreshTokenSecretId: stored.refreshTokenSecretId, | |
| 509 | − tokenType: stored.tokenType, | |
| 510 | − expiresAt: stored.expiresAt, | |
| 511 | − scope: stored.scope, | |
| 512 | − scopes: [...session.scopes], | |
| 513 | − }; | |
| 575 | + updateSource: (namespace, scope, input) => | |
| 576 | + ctx.storage.updateSourceMeta(namespace, scope, { | |
| 577 | + name: input.name?.trim() || undefined, | |
| 578 | + auth: input.auth, | |
| 514 | 579 | }), |
| 515 | − | |
| 516 | − getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope), | |
| 517 | 580 | } satisfies GoogleDiscoveryPluginExtension), |
| 518 | 581 | |
| 519 | 582 | invokeTool: ({ ctx, toolRow, args }) => |
| 520 | 583 | invokeGoogleDiscoveryTool({ |
| 521 | 584 | ctx: ctx as PluginCtx<GoogleDiscoveryStore>, |
| 522 | 585 | toolId: toolRow.id, |
| 523 | − // toolRow.scope_id is the resolved owning scope of the tool | |
| 524 | − // (innermost-wins from the executor's stack). The matching | |
| 525 | − // binding + source rows live at the same scope, so pin every | |
| 526 | − // store lookup to it instead of relying on the scoped adapter's | |
| 527 | − // stack-wide fall-through. | |
| 528 | 586 | toolScope: toolRow.scope_id as string, |
| 529 | 587 | args, |
| 530 | 588 | }), |
| 531 | 589 | |
| 532 | 590 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| 533 | 591 | Effect.gen(function* () { |
| 534 | − // toolRows for a single (plugin_id, source_id) group can still | |
| 535 | − // straddle multiple scopes when the source is shadowed (e.g. an | |
| 536 | − // org-level source plus a per-user override that re-registers | |
| 537 | − // the same tool ids). Run one getBindingsForSource per distinct | |
| 538 | − // scope so each lookup pins {source_id, scope_id} and we don't | |
| 539 | − // fall through to the wrong scope's bindings. | |
| 540 | 592 | const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>; |
| 541 | 593 | const scopes = new Set<string>(); |
| 542 | 594 | for (const row of toolRows) scopes.add(row.scope_id as string); |
| @@ -605,9 +657,6 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 62 unmodified lines | ||
| 605 | 657 | refreshSource: ({ ctx, sourceId, scope }) => |
| 606 | 658 | Effect.gen(function* () { |
| 607 | 659 | const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>; |
| 608 | − // Pin the initial read to the source's owning scope — otherwise | |
| 609 | − // fall-through could surface a shadowed row at another scope and | |
| 610 | − // we'd refresh the wrong one. | |
| 611 | 660 | const existing = yield* typedCtx.storage.getSource(sourceId, scope); |
| 612 | 661 | if (!existing) return; |
| 613 | 662 | const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl); |
| @@ -621,4 +670,102 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 7 unmodified lines | ||
| 621 | 670 | }); |
| 622 | 671 | yield* registerManifest(typedCtx, sourceId, scope, manifest, next); |
| 623 | 672 | }).pipe(Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err))))), |
| 673 | + | |
| 674 | + // SDK's `ctx.connections.accessToken(id)` dispatches here when a token | |
| 675 | + // is near expiry. All knobs (client secret id, scopes) come from | |
| 676 | + // providerState; the refresh_token value is pre-resolved by the SDK. | |
| 677 | + connectionProviders: (ctx): readonly ConnectionProvider[] => [ | |
| 678 | + { | |
| 679 | + key: GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY, | |
| 680 | + refresh: (input: ConnectionRefreshInput) => | |
| 681 | + Effect.gen(function* () { | |
| 682 | + if (!input.providerState) { | |
| 683 | + return yield* new ConnectionRefreshError({ | |
| 684 | + connectionId: input.connectionId, | |
| 685 | + message: | |
| 686 | + "google-discovery:oauth2 connection is missing providerState", | |
| 687 | + }); | |
| 688 | + } | |
| 689 | + const state = yield* Effect.try({ | |
| 690 | + try: () => decodeProviderState(input.providerState), | |
| 691 | + catch: (cause) => | |
| 692 | + new ConnectionRefreshError({ | |
| 693 | + connectionId: input.connectionId, | |
| 694 | + message: `google-discovery:oauth2 providerState is malformed: ${ | |
| 695 | + cause instanceof Error ? cause.message : String(cause) | |
| 696 | + }`, | |
| 697 | + cause, | |
| 698 | + }), | |
| 699 | + }); | |
| 700 | + | |
| 701 | + if (input.refreshToken === null) { | |
| 702 | + return yield* new ConnectionRefreshError({ | |
| 703 | + connectionId: input.connectionId, | |
| 704 | + message: | |
| 705 | + "google-discovery:oauth2 connection has no refresh token", | |
| 706 | + }); | |
| 707 | + } | |
| 708 | + | |
| 709 | + const clientId = yield* ctx.secrets.get(state.clientIdSecretId).pipe( | |
| 710 | + Effect.mapError( | |
| 711 | + (err) => | |
| 712 | + new ConnectionRefreshError({ | |
| 713 | + connectionId: input.connectionId, | |
| 714 | + message: `Failed to resolve client id secret: ${err.message}`, | |
| 715 | + cause: err, | |
| 716 | + }), | |
| 717 | + ), | |
| 718 | + ); | |
| 719 | + if (clientId === null) { | |
| 720 | + return yield* new ConnectionRefreshError({ | |
| 721 | + connectionId: input.connectionId, | |
| 722 | + message: `Missing client id secret: ${state.clientIdSecretId}`, | |
| 723 | + }); | |
| 724 | + } | |
| 725 | + | |
| 726 | + const clientSecret = state.clientSecretSecretId | |
| 727 | + ? yield* ctx.secrets.get(state.clientSecretSecretId).pipe( | |
| 728 | + Effect.mapError( | |
| 729 | + (err) => | |
| 730 | + new ConnectionRefreshError({ | |
| 731 | + connectionId: input.connectionId, | |
| 732 | + message: `Failed to resolve client secret: ${err.message}`, | |
| 733 | + cause: err, | |
| 734 | + }), | |
| 735 | + ), | |
| 736 | + ) | |
| 737 | + : null; | |
| 738 | + | |
| 739 | + const tokenResponse: OAuth2TokenResponse = yield* refreshAccessToken({ | |
| 740 | + tokenUrl: GOOGLE_TOKEN_URL, | |
| 741 | + clientId, | |
| 742 | + clientSecret, | |
| 743 | + refreshToken: input.refreshToken, | |
| 744 | + scopes: state.scopes, | |
| 745 | + }).pipe( | |
| 746 | + Effect.mapError( | |
| 747 | + (err: OAuth2Error) => | |
| 748 | + new ConnectionRefreshError({ | |
| 749 | + connectionId: input.connectionId, | |
| 750 | + message: err.message, | |
| 751 | + cause: err, | |
| 752 | + }), | |
| 753 | + ), | |
| 754 | + ); | |
| 755 | + | |
| 756 | + const expiresAt = | |
| 757 | + typeof tokenResponse.expires_in === "number" | |
| 758 | + ? Date.now() + tokenResponse.expires_in * 1000 | |
| 759 | + : null; | |
| 760 | + | |
| 761 | + const result: ConnectionRefreshResult = { | |
| 762 | + accessToken: tokenResponse.access_token, | |
| 763 | + refreshToken: tokenResponse.refresh_token ?? undefined, | |
| 764 | + expiresAt, | |
| 765 | + oauthScope: tokenResponse.scope ?? input.oauthScope, | |
| 766 | + }; | |
| 767 | + return result; | |
| 768 | + }), | |
| 769 | + }, | |
| 770 | + ], | |
| 624 | 771 | })); |
| @@ -60,19 +60,31 @@ export class GoogleDiscoveryManifest extends Schema.Class<GoogleDiscoveryManifes · 59 unmodified lines | ||
| 60 | 60 | methods: Schema.Array(GoogleDiscoveryManifestMethod), |
| 61 | 61 | }) {} |
| 62 | 62 | |
| 63 | +// --------------------------------------------------------------------------- | |
| 64 | +// Auth — a source either runs unauthenticated or is backed by a Connection. | |
| 65 | +// | |
| 66 | +// The source owns the API-level OAuth config (client credential secret | |
| 67 | +// ids + scopes) so a stale sign-in can always be re-run from the source | |
| 68 | +// detail page without needing the prior Connection to still exist. The | |
| 69 | +// Connection owns live tokens + refresh state (and caches the same | |
| 70 | +// config on `providerState` for the refresh path). This small | |
| 71 | +// duplication keeps reconnect fully source-driven. | |
| 72 | +// --------------------------------------------------------------------------- | |
| 73 | + | |
| 63 | 74 | export const GoogleDiscoveryAuth = Schema.Union( |
| 64 | 75 | Schema.Struct({ |
| 65 | 76 | kind: Schema.Literal("none"), |
| 66 | 77 | }), |
| 67 | 78 | Schema.Struct({ |
| 68 | 79 | kind: Schema.Literal("oauth2"), |
| 80 | + /** Connection id; resolve via `ctx.connections.accessToken(id)`. | |
| 81 | + * Rewritten on sign-in to point at the freshly minted connection. */ | |
| 82 | + connectionId: Schema.String, | |
| 83 | + /** Secret id holding the OAuth client_id. */ | |
| 69 | 84 | clientIdSecretId: Schema.String, |
| 85 | + /** Secret id holding the OAuth client_secret. Null for public clients. */ | |
| 70 | 86 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 71 | − accessTokenSecretId: Schema.String, | |
| 72 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 73 | − tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }), | |
| 74 | − expiresAt: Schema.NullOr(Schema.Number), | |
| 75 | − scope: Schema.NullOr(Schema.String), | |
| 87 | + /** Scopes requested on sign-in. */ | |
| 76 | 88 | scopes: Schema.Array(Schema.String), |
| 77 | 89 | }), |
| 78 | 90 | ); |
| @@ -113,5 +125,11 @@ export const GoogleDiscoveryOAuthSession = Schema.Struct({ · 34 unmodified lines | ||
| 113 | 125 | redirectUrl: Schema.String, |
| 114 | 126 | scopes: Schema.Array(Schema.String), |
| 115 | 127 | codeVerifier: Schema.String, |
| 128 | + /** Executor scope that will own the resulting Connection + its backing | |
| 129 | + * secrets. Typically the innermost (per-user) scope. */ | |
| 130 | + tokenScope: Schema.String, | |
| 131 | + /** Pre-decided Connection id stamped at completeOAuth time so a retried | |
| 132 | + * callback lands on the same id. */ | |
| 133 | + connectionId: Schema.String, | |
| 116 | 134 | }); |
| 117 | 135 | export type GoogleDiscoveryOAuthSession = typeof GoogleDiscoveryOAuthSession.Type; |
| @@ -50,6 +50,7 @@ · 49 unmodified lines | ||
| 50 | 50 | "@effect/platform": "catalog:", |
| 51 | 51 | "@effect/platform-node": "catalog:", |
| 52 | 52 | "@executor/config": "workspace:*", |
| 53 | + "@executor/plugin-oauth2": "workspace:*", | |
| 53 | 54 | "@executor/sdk": "workspace:*", |
| 54 | 55 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 55 | 56 | "effect": "catalog:" |
| @@ -36,14 +36,10 @@ const AuthPayload = Schema.Union( · 35 unmodified lines | ||
| 36 | 36 | }), |
| 37 | 37 | Schema.Struct({ |
| 38 | 38 | kind: Schema.Literal("oauth2"), |
| 39 | − accessTokenSecretId: Schema.String, | |
| 40 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 41 | − tokenType: Schema.optional(Schema.String), | |
| 42 | − expiresAt: Schema.NullOr(Schema.Number), | |
| 43 | − scope: Schema.NullOr(Schema.String), | |
| 44 | − clientInformation: Schema.optional(Schema.NullOr(JsonObject)), | |
| 45 | − authorizationServerUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 46 | − resourceMetadataUrl: Schema.optional(Schema.NullOr(Schema.String)), | |
| 39 | + /** Stable id of the SDK Connection minted by `completeOAuth`. The | |
| 40 | + * backing access/refresh secrets live on the connection row; the | |
| 41 | + * source only needs this pointer. */ | |
| 42 | + connectionId: Schema.String, | |
| 47 | 43 | }), |
| 48 | 44 | ); |
| 49 | 45 | |
| @@ -113,8 +109,12 @@ const StartOAuthPayload = Schema.Struct({ · 63 unmodified lines | ||
| 113 | 109 | endpoint: Schema.String, |
| 114 | 110 | redirectUrl: Schema.String, |
| 115 | 111 | queryParams: Schema.optional(Schema.NullOr(StringMap)), |
| 116 | − accessTokenSecretId: Schema.String, | |
| 117 | − refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 112 | + /** Pre-decided SDK connection id the exchange will mint. Caller | |
| 113 | + * passes a stable value (`mcp-oauth2-${namespace}`) so every user | |
| 114 | + * signing in against the same source writes to the same id at their | |
| 115 | + * own scope — the source's stored `{kind: "oauth2", connectionId}` | |
| 116 | + * then resolves per-user via shadowing. */ | |
| 117 | + connectionId: Schema.String, | |
| 118 | 118 | /** Source-level OAuth state captured by a previous user's flow. When |
| 119 | 119 | * passed, DCR is skipped — the same client_id is re-used so the |
| 120 | 120 | * source's auth config stays stable across users. */ |
| @@ -161,11 +161,15 @@ const StartOAuthResponse = Schema.Struct({ · 40 unmodified lines | ||
| 161 | 161 | }); |
| 162 | 162 | |
| 163 | 163 | const CompleteOAuthResponse = Schema.Struct({ |
| 164 | − accessTokenSecretId: Schema.String, | |
| 165 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 164 | + /** Id of the SDK Connection minted by the exchange. The UI stores it | |
| 165 | + * on the source's auth config as `{kind: "oauth2", connectionId}`. */ | |
| 166 | + connectionId: Schema.String, | |
| 166 | 167 | tokenType: Schema.String, |
| 167 | 168 | expiresAt: Schema.NullOr(Schema.Number), |
| 168 | 169 | scope: Schema.NullOr(Schema.String), |
| 170 | + /** DCR client + discovery URLs captured during the flow. Persisted | |
| 171 | + * on the source's auth config so subsequent users skip DCR + re- | |
| 172 | + * discovery. */ | |
| 169 | 173 | clientInformation: Schema.NullOr(JsonObject), |
| 170 | 174 | authorizationServerUrl: Schema.NullOr(Schema.String), |
| 171 | 175 | resourceMetadataUrl: Schema.NullOr(Schema.String), |
| @@ -34,11 +34,13 @@ type OAuthPopupResult = · 33 unmodified lines | ||
| 34 | 34 | type: "executor:oauth-result"; |
| 35 | 35 | ok: true; |
| 36 | 36 | sessionId: string; |
| 37 | − accessTokenSecretId: string; | |
| 38 | − refreshTokenSecretId: string | null; | |
| 37 | + connectionId: string; | |
| 39 | 38 | tokenType: string; |
| 40 | 39 | expiresAt: number | null; |
| 41 | 40 | scope: string | null; |
| 41 | + clientInformation: Record<string, unknown> | null; | |
| 42 | + authorizationServerUrl: string | null; | |
| 43 | + resourceMetadataUrl: string | null; | |
| 42 | 44 | } |
| 43 | 45 | | { |
| 44 | 46 | type: "executor:oauth-result"; |
| @@ -130,15 +132,6 @@ const toSourceConfig = ( · 85 unmodified lines | ||
| 130 | 132 | auth?: { kind: string } & Record<string, unknown>; |
| 131 | 133 | }; |
| 132 | 134 | |
| 133 | − const auth = p.auth | |
| 134 | − ? p.auth.kind === "oauth2" | |
| 135 | − ? { | |
| 136 | − ...p.auth, | |
| 137 | − tokenType: (p.auth as { tokenType?: string }).tokenType ?? "Bearer", | |
| 138 | − } | |
| 139 | − : p.auth | |
| 140 | − : undefined; | |
| 141 | − | |
| 142 | 135 | return { |
| 143 | 136 | transport: "remote", |
| 144 | 137 | scope, |
| @@ -148,7 +141,7 @@ const toSourceConfig = ( · 3 unmodified lines | ||
| 148 | 141 | queryParams: p.queryParams, |
| 149 | 142 | headers: p.headers, |
| 150 | 143 | namespace: p.namespace, |
| 151 | − auth: auth as McpSourceConfig extends { auth?: infer A } ? A : never, | |
| 144 | + auth: p.auth as McpSourceConfig extends { auth?: infer A } ? A : never, | |
| 152 | 145 | }; |
| 153 | 146 | }; |
| 154 | 147 | |
| @@ -204,8 +197,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 49 unmodified lines | ||
| 204 | 197 | endpoint: payload.endpoint, |
| 205 | 198 | redirectUrl: payload.redirectUrl, |
| 206 | 199 | queryParams: payload.queryParams, |
| 207 | − accessTokenSecretId: payload.accessTokenSecretId, | |
| 208 | − refreshTokenSecretId: payload.refreshTokenSecretId, | |
| 200 | + connectionId: payload.connectionId, | |
| 209 | 201 | clientInformation: payload.clientInformation, |
| 210 | 202 | authorizationServerUrl: payload.authorizationServerUrl, |
| 211 | 203 | resourceMetadataUrl: payload.resourceMetadataUrl, |
| @@ -259,11 +251,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 47 unmodified lines | ||
| 259 | 251 | type: "executor:oauth-result", |
| 260 | 252 | ok: true, |
| 261 | 253 | sessionId: urlParams.state, |
| 262 | − accessTokenSecretId: c.accessTokenSecretId, | |
| 263 | − refreshTokenSecretId: c.refreshTokenSecretId, | |
| 254 | + connectionId: c.connectionId, | |
| 264 | 255 | tokenType: c.tokenType, |
| 265 | 256 | expiresAt: c.expiresAt, |
| 266 | 257 | scope: c.scope, |
| 258 | + clientInformation: c.clientInformation, | |
| 259 | + authorizationServerUrl: c.authorizationServerUrl, | |
| 260 | + resourceMetadataUrl: c.resourceMetadataUrl, | |
| 267 | 261 | }), |
| 268 | 262 | onFailure: (cause) => |
| 269 | 263 | Effect.succeed<OAuthPopupResult>({ |
| @@ -49,29 +49,23 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 48 unmodified lines | ||
| 49 | 49 | return mcpPresets.find((p) => p.id === id); |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | −// Stable secret ids for an MCP source's OAuth access/refresh tokens. | |
| 53 | −// The same ids are passed into `startOAuth` AND stored on the source's | |
| 54 | −// auth config, so at invoke time the per-user scope (which actually | |
| 55 | −// holds the value) resolves via shadowing over the source-level id. | |
| 56 | −// Keyed by namespace so reconnecting overwrites the previous token | |
| 57 | −// rather than leaking orphaned secret rows. | |
| 58 | −const mcpOAuthTokenSecretIds = ( | |
| 59 | − namespaceSlug: string, | |
| 60 | −): { readonly accessTokenSecretId: string; readonly refreshTokenSecretId: string } => { | |
| 61 | − const prefix = `mcp_${namespaceSlug}`; | |
| 62 | − return { | |
| 63 | − accessTokenSecretId: `${prefix}_access_token`, | |
| 64 | − refreshTokenSecretId: `${prefix}_refresh_token`, | |
| 65 | − }; | |
| 66 | −}; | |
| 52 | +// Stable SDK connection id for an MCP source's OAuth sign-in. The same | |
| 53 | +// id is passed into `startOAuth` AND stored on the source's auth | |
| 54 | +// config, so at invoke time the per-user scope (which owns the user's | |
| 55 | +// own secrets) resolves via shadowing over the source-level pointer. | |
| 56 | +// Keyed by namespace so reconnecting mints a new connection that | |
| 57 | +// shadows the previous one at the same scope. | |
| 58 | +const mcpOAuthConnectionId = (namespaceSlug: string): string => | |
| 59 | + `mcp-oauth2-${namespaceSlug || "default"}`; | |
| 67 | 60 | |
| 68 | 61 | // --------------------------------------------------------------------------- |
| 69 | 62 | // State machine (remote flow) |
| 70 | 63 | // --------------------------------------------------------------------------- |
| 71 | 64 | |
| 72 | 65 | type OAuthTokens = { |
| 73 | − accessTokenSecretId: string; | |
| 74 | − refreshTokenSecretId: string | null; | |
| 66 | + /** Id of the SDK Connection minted by the exchange. The UI stores it | |
| 67 | + * on the source's auth config as `{kind: "oauth2", connectionId}`. */ | |
| 68 | + connectionId: string; | |
| 75 | 69 | tokenType: string; |
| 76 | 70 | expiresAt: number | null; |
| 77 | 71 | scope: string | null; |
| @@ -445,14 +439,13 @@ export default function AddMcpSource(props: { · 367 unmodified lines | ||
| 445 | 439 | slugifyNamespace(remoteIdentity.namespace) || |
| 446 | 440 | slugifyNamespace(probe?.namespace ?? "") || |
| 447 | 441 | "mcp"; |
| 448 | − const tokenIds = mcpOAuthTokenSecretIds(namespaceSlug); | |
| 442 | + const connectionId = mcpOAuthConnectionId(namespaceSlug); | |
| 449 | 443 | const result = await doStartOAuth({ |
| 450 | 444 | path: { scopeId }, |
| 451 | 445 | payload: { |
| 452 | 446 | endpoint: state.url.trim(), |
| 453 | 447 | redirectUrl, |
| 454 | − accessTokenSecretId: tokenIds.accessTokenSecretId, | |
| 455 | − refreshTokenSecretId: tokenIds.refreshTokenSecretId, | |
| 448 | + connectionId, | |
| 456 | 449 | }, |
| 457 | 450 | }); |
| 458 | 451 | dispatch({ type: "oauth-waiting", sessionId: result.sessionId }); |
| @@ -464,8 +457,7 @@ export default function AddMcpSource(props: { · 5 unmodified lines | ||
| 464 | 457 | dispatch({ |
| 465 | 458 | type: "oauth-ok", |
| 466 | 459 | tokens: { |
| 467 | − accessTokenSecretId: data.accessTokenSecretId, | |
| 468 | − refreshTokenSecretId: data.refreshTokenSecretId, | |
| 460 | + connectionId: data.connectionId, | |
| 469 | 461 | tokenType: data.tokenType, |
| 470 | 462 | expiresAt: data.expiresAt, |
| 471 | 463 | scope: data.scope, |
| @@ -512,14 +504,7 @@ export default function AddMcpSource(props: { · 40 unmodified lines | ||
| 512 | 504 | : remoteAuthMode === "oauth2" && tokens |
| 513 | 505 | ? { |
| 514 | 506 | kind: "oauth2" as const, |
| 515 | − accessTokenSecretId: tokens.accessTokenSecretId, | |
| 516 | − refreshTokenSecretId: tokens.refreshTokenSecretId, | |
| 517 | − tokenType: tokens.tokenType, | |
| 518 | − expiresAt: tokens.expiresAt, | |
| 519 | − scope: tokens.scope, | |
| 520 | − clientInformation: tokens.clientInformation, | |
| 521 | − authorizationServerUrl: tokens.authorizationServerUrl, | |
| 522 | − resourceMetadataUrl: tokens.resourceMetadataUrl, | |
| 507 | + connectionId: tokens.connectionId, | |
| 523 | 508 | } |
| 524 | 509 | : { kind: "none" as const }; |
| 525 | 510 | const headers = Object.fromEntries( |
| @@ -0,0 +1,153 @@ | ||
| 1 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 2 | +import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; | |
| 3 | + | |
| 4 | +import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react"; | |
| 5 | +import { useScope } from "@executor/react/api/scope-context"; | |
| 6 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 7 | +import { connectionsAtom } from "@executor/react/api/atoms"; | |
| 8 | +import { Button } from "@executor/react/components/button"; | |
| 9 | +import { slugifyNamespace } from "@executor/react/plugins/source-identity"; | |
| 10 | + | |
| 11 | +import { mcpSourceAtom, startMcpOAuth, updateMcpSource } from "./atoms"; | |
| 12 | + | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | +// McpSignInButton — top-bar action on the source detail page. | |
| 15 | +// | |
| 16 | +// Reads the source's stored endpoint + oauth2 pointer, re-runs the DCR / | |
| 17 | +// authorization-code flow against a stable `mcp-oauth2-${namespace}` | |
| 18 | +// connection id, and on success rewrites the source's auth pointer to | |
| 19 | +// the freshly minted connection. Works whether or not the previous | |
| 20 | +// Connection still exists — source-owned config is the source of truth. | |
| 21 | +// --------------------------------------------------------------------------- | |
| 22 | + | |
| 23 | +const CALLBACK_PATH = "/api/mcp/oauth/callback"; | |
| 24 | +const POPUP_NAME = "mcp-oauth"; | |
| 25 | +const CHANNEL_NAME = "executor:mcp-oauth-result"; | |
| 26 | + | |
| 27 | +type McpOAuthPopupPayload = { | |
| 28 | + connectionId: string; | |
| 29 | + tokenType: string; | |
| 30 | + expiresAt: number | null; | |
| 31 | + scope: string | null; | |
| 32 | + clientInformation: Record<string, unknown> | null; | |
| 33 | + authorizationServerUrl: string | null; | |
| 34 | + resourceMetadataUrl: string | null; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +const mcpOAuthConnectionId = (namespaceSlug: string): string => | |
| 38 | + `mcp-oauth2-${namespaceSlug || "default"}`; | |
| 39 | + | |
| 40 | +export default function McpSignInButton(props: { sourceId: string }) { | |
| 41 | + const scopeId = useScope(); | |
| 42 | + const sourceResult = useAtomValue(mcpSourceAtom(scopeId, props.sourceId)); | |
| 43 | + const connectionsResult = useAtomValue(connectionsAtom(scopeId)); | |
| 44 | + const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" }); | |
| 45 | + const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" }); | |
| 46 | + | |
| 47 | + const [busy, setBusy] = useState(false); | |
| 48 | + const [error, setError] = useState<string | null>(null); | |
| 49 | + const cleanupRef = useRef<(() => void) | null>(null); | |
| 50 | + | |
| 51 | + useEffect(() => () => cleanupRef.current?.(), []); | |
| 52 | + | |
| 53 | + const source = | |
| 54 | + Result.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; | |
| 55 | + const remote = source && source.config.transport === "remote" ? source.config : null; | |
| 56 | + const oauth2 = remote && remote.auth.kind === "oauth2" ? remote.auth : null; | |
| 57 | + const connections = Result.isSuccess(connectionsResult) | |
| 58 | + ? connectionsResult.value | |
| 59 | + : null; | |
| 60 | + const isConnected = | |
| 61 | + oauth2 !== null && | |
| 62 | + connections !== null && | |
| 63 | + connections.some((c) => c.id === oauth2.connectionId); | |
| 64 | + | |
| 65 | + const redirectUrl = | |
| 66 | + typeof window !== "undefined" | |
| 67 | + ? `${window.location.origin}${CALLBACK_PATH}` | |
| 68 | + : CALLBACK_PATH; | |
| 69 | + | |
| 70 | + const handleSignIn = useCallback(async () => { | |
| 71 | + if (!remote || !oauth2 || !source) return; | |
| 72 | + cleanupRef.current?.(); | |
| 73 | + cleanupRef.current = null; | |
| 74 | + setBusy(true); | |
| 75 | + setError(null); | |
| 76 | + try { | |
| 77 | + const namespaceSlug = slugifyNamespace(source.namespace) || "mcp"; | |
| 78 | + const connectionId = mcpOAuthConnectionId(namespaceSlug); | |
| 79 | + const response = await doStartOAuth({ | |
| 80 | + path: { scopeId }, | |
| 81 | + payload: { | |
| 82 | + endpoint: remote.endpoint, | |
| 83 | + redirectUrl, | |
| 84 | + connectionId, | |
| 85 | + }, | |
| 86 | + }); | |
| 87 | + | |
| 88 | + cleanupRef.current = openOAuthPopup<McpOAuthPopupPayload>({ | |
| 89 | + url: response.authorizationUrl, | |
| 90 | + popupName: POPUP_NAME, | |
| 91 | + channelName: CHANNEL_NAME, | |
| 92 | + onResult: async (result: OAuthPopupResult<McpOAuthPopupPayload>) => { | |
| 93 | + cleanupRef.current = null; | |
| 94 | + if (!result.ok) { | |
| 95 | + setBusy(false); | |
| 96 | + setError(result.error); | |
| 97 | + return; | |
| 98 | + } | |
| 99 | + try { | |
| 100 | + await doUpdate({ | |
| 101 | + path: { scopeId, namespace: props.sourceId }, | |
| 102 | + payload: { | |
| 103 | + auth: { kind: "oauth2", connectionId: result.connectionId }, | |
| 104 | + }, | |
| 105 | + reactivityKeys: sourceWriteKeys, | |
| 106 | + }); | |
| 107 | + setBusy(false); | |
| 108 | + } catch (e) { | |
| 109 | + setBusy(false); | |
| 110 | + setError( | |
| 111 | + e instanceof Error ? e.message : "Failed to persist new connection", | |
| 112 | + ); | |
| 113 | + } | |
| 114 | + }, | |
| 115 | + onClosed: () => { | |
| 116 | + cleanupRef.current = null; | |
| 117 | + setBusy(false); | |
| 118 | + setError("Sign-in cancelled — popup was closed before completing the flow."); | |
| 119 | + }, | |
| 120 | + onOpenFailed: () => { | |
| 121 | + cleanupRef.current = null; | |
| 122 | + setBusy(false); | |
| 123 | + setError("Sign-in popup was blocked by the browser"); | |
| 124 | + }, | |
| 125 | + }); | |
| 126 | + } catch (e) { | |
| 127 | + setBusy(false); | |
| 128 | + setError(e instanceof Error ? e.message : "Failed to start sign-in"); | |
| 129 | + } | |
| 130 | + }, [remote, oauth2, source, scopeId, props.sourceId, redirectUrl, doStartOAuth, doUpdate]); | |
| 131 | + | |
| 132 | + if (!oauth2) return null; | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + <div className="flex items-center gap-2"> | |
| 136 | + {error && <span className="text-xs text-destructive">{error}</span>} | |
| 137 | + <Button | |
| 138 | + variant="outline" | |
| 139 | + size="sm" | |
| 140 | + onClick={() => void handleSignIn()} | |
| 141 | + disabled={busy} | |
| 142 | + > | |
| 143 | + {busy | |
| 144 | + ? isConnected | |
| 145 | + ? "Reconnecting…" | |
| 146 | + : "Signing in…" | |
| 147 | + : isConnected | |
| 148 | + ? "Reconnect" | |
| 149 | + : "Sign in"} | |
| 150 | + </Button> | |
| 151 | + </div> | |
| 152 | + ); | |
| 153 | +} | |
| @@ -5,6 +5,7 @@ import { mcpPresets } from "../sdk/presets"; · 4 unmodified lines | ||
| 5 | 5 | const LazyAddMcpSource = lazy(() => import("./AddMcpSource")); |
| 6 | 6 | const LazyEditMcpSource = lazy(() => import("./EditMcpSource")); |
| 7 | 7 | const LazySummary = lazy(() => import("./McpSourceSummary")); |
| 8 | +const LazyMcpSignInButton = lazy(() => import("./McpSignInButton")); | |
| 8 | 9 | |
| 9 | 10 | type AddProps = ComponentProps<SourcePlugin["add"]>; |
| 10 | 11 | |
| @@ -40,6 +41,7 @@ export const createMcpSourcePlugin = ( · 29 unmodified lines | ||
| 40 | 41 | add: AddWithFlag, |
| 41 | 42 | edit: LazyEditMcpSource, |
| 42 | 43 | summary: LazySummary, |
| 44 | + signIn: LazyMcpSignInButton, | |
| 43 | 45 | presets, |
| 44 | 46 | }; |
| 45 | 47 | }; |
| @@ -38,18 +38,18 @@ export const McpOAuthSession = Schema.Struct({ · 37 unmodified lines | ||
| 38 | 38 | redirectUrl: Schema.String, |
| 39 | 39 | codeVerifier: Schema.String, |
| 40 | 40 | /** |
| 41 | − * Executor scope id where the minted access/refresh tokens will land. | |
| 42 | − * Pinned at `startOAuth` time so token writes target the same scope | |
| 43 | − * regardless of who's currently invoking. For per-user OAuth this is | |
| 44 | − * the innermost (`ctx.scopes[0]`) scope; for org-shared installs it | |
| 45 | − * can be the org scope. | |
| 41 | + * Executor scope id where the minted Connection (and its owned | |
| 42 | + * access/refresh secrets) will land. Pinned at `startOAuth` time so | |
| 43 | + * token writes target the same scope regardless of who's currently | |
| 44 | + * invoking. For per-user OAuth this is the innermost | |
| 45 | + * (`ctx.scopes[0]`) scope; for org-shared installs it can be the org | |
| 46 | + * scope. | |
| 46 | 47 | */ |
| 47 | 48 | tokenScope: Schema.String, |
| 48 | − /** Stable secret ids the minted tokens are written to. Stored once on | |
| 49 | − * the source's auth config so per-user scope shadowing resolves to | |
| 50 | − * the calling user's tokens at invoke time. */ | |
| 51 | − accessTokenSecretId: Schema.String, | |
| 52 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 49 | + /** Stable id of the SDK Connection the exchange will mint. Stored | |
| 50 | + * once on the source's auth config so per-user scope shadowing | |
| 51 | + * resolves to the calling user's connection at invoke time. */ | |
| 52 | + connectionId: Schema.String, | |
| 53 | 53 | }); |
| 54 | 54 | export type McpOAuthSession = typeof McpOAuthSession.Type; |
| 55 | 55 | |
| @@ -1,20 +1,28 @@ | ||
| 1 | −import { Duration, Effect, Exit, Scope, ScopedCache } from "effect"; | |
| 1 | +import { Duration, Effect, Exit, Schema, Scope, ScopedCache } from "effect"; | |
| 2 | 2 | |
| 3 | −import type { | |
| 4 | − OAuthClientProvider, | |
| 5 | − OAuthDiscoveryState, | |
| 3 | +import { | |
| 4 | + refreshAuthorization, | |
| 5 | + type OAuthClientProvider, | |
| 6 | + type OAuthDiscoveryState, | |
| 6 | 7 | } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 7 | 8 | import type { |
| 8 | 9 | OAuthClientInformationMixed, |
| 10 | + OAuthMetadata, | |
| 9 | 11 | OAuthTokens, |
| 10 | 12 | } from "@modelcontextprotocol/sdk/shared/auth.js"; |
| 11 | 13 | |
| 12 | 14 | import { |
| 13 | − definePlugin, | |
| 15 | + ConnectionId, | |
| 16 | + ConnectionRefreshError, | |
| 17 | + CreateConnectionInput, | |
| 14 | 18 | ScopeId, |
| 15 | 19 | SecretId, |
| 16 | − SetSecretInput, | |
| 17 | 20 | SourceDetectionResult, |
| 21 | + TokenMaterial, | |
| 22 | + definePlugin, | |
| 23 | + type ConnectionProvider, | |
| 24 | + type ConnectionRefreshInput, | |
| 25 | + type ConnectionRefreshResult, | |
| 18 | 26 | type PluginCtx, |
| 19 | 27 | type StorageFailure, |
| 20 | 28 | } from "@executor/sdk"; |
| @@ -42,7 +50,12 @@ import { · 21 unmodified lines | ||
| 42 | 50 | type McpToolManifestEntry, |
| 43 | 51 | } from "./manifest"; |
| 44 | 52 | import { exchangeMcpOAuthCode, startMcpOAuthAuthorization } from "./oauth"; |
| 45 | −import { McpToolBinding, type McpConnectionAuth, type McpStoredSourceData } from "./types"; | |
| 53 | +import { | |
| 54 | + McpToolBinding, | |
| 55 | + McpJsonObject, | |
| 56 | + type McpConnectionAuth, | |
| 57 | + type McpStoredSourceData, | |
| 58 | +} from "./types"; | |
| 46 | 59 | |
| 47 | 60 | import { |
| 48 | 61 | SECRET_REF_PREFIX, |
| @@ -97,20 +110,20 @@ export interface McpOAuthStartInput { · 48 unmodified lines | ||
| 97 | 110 | readonly redirectUrl: string; |
| 98 | 111 | readonly queryParams?: Record<string, string> | null; |
| 99 | 112 | /** |
| 100 | − * Executor scope id where the minted access/refresh tokens will land. | |
| 101 | − * Defaults to `ctx.scopes[0].id` (innermost) — for a per-user stack | |
| 102 | − * `[user, org]` that pins tokens to the user scope so the source's | |
| 103 | − * stored `accessTokenSecretId` resolves per-user via shadowing. | |
| 113 | + * Executor scope id where the minted Connection will land. Defaults | |
| 114 | + * to `ctx.scopes[0].id` (innermost) — for a per-user stack | |
| 115 | + * `[user, org]` that pins the connection to the user scope so the | |
| 116 | + * source's stored `connectionId` resolves per-user via shadowing. | |
| 104 | 117 | */ |
| 105 | 118 | readonly tokenScope?: string; |
| 106 | 119 | /** |
| 107 | − * Pre-decided secret ids for the minted tokens. Mint deterministically | |
| 108 | − * (e.g. `mcp_${namespace}_access_token`) so the source's stored | |
| 109 | − * OAuth2 auth carries the same id everyone reads, and `ctx.secrets.get` | |
| 110 | − * resolves per-user via scope fall-through. | |
| 120 | + * Pre-decided SDK connection id the exchange will mint. Stable per | |
| 121 | + * source (typically `mcp-oauth2-${namespace}`) so multiple users | |
| 122 | + * signing in against the same MCP source all write to the same id at | |
| 123 | + * their own scope — `ctx.connections.accessToken(id)` then resolves | |
| 124 | + * innermost-first. | |
| 111 | 125 | */ |
| 112 | − readonly accessTokenSecretId: string; | |
| 113 | − readonly refreshTokenSecretId?: string | null; | |
| 126 | + readonly connectionId: string; | |
| 114 | 127 | /** |
| 115 | 128 | * Source-level OAuth state captured by a previous user's flow. Pass |
| 116 | 129 | * the values stored on the source's auth config to skip Dynamic |
| @@ -134,14 +147,16 @@ export interface McpOAuthCompleteInput { · 17 unmodified lines | ||
| 134 | 147 | } |
| 135 | 148 | |
| 136 | 149 | export interface McpOAuthCompleteResponse { |
| 137 | − readonly accessTokenSecretId: string; | |
| 138 | − readonly refreshTokenSecretId: string | null; | |
| 150 | + /** Id of the SDK Connection the exchange minted. The caller stores it | |
| 151 | + * on the source's `oauth2` auth field and resolves tokens via | |
| 152 | + * `ctx.connections`. */ | |
| 153 | + readonly connectionId: string; | |
| 139 | 154 | readonly tokenType: string; |
| 140 | 155 | readonly expiresAt: number | null; |
| 141 | 156 | readonly scope: string | null; |
| 142 | 157 | /** DCR client + discovery URLs captured during the flow. The UI |
| 143 | − * stores them on the source's auth config so refreshes don't | |
| 144 | − * re-register or re-discover. */ | |
| 158 | + * stores them on the source's auth config so subsequent users can | |
| 159 | + * skip DCR and re-discovery. */ | |
| 145 | 160 | readonly clientInformation: Record<string, unknown> | null; |
| 146 | 161 | readonly authorizationServerUrl: string | null; |
| 147 | 162 | readonly resourceMetadataUrl: string | null; |
| @@ -208,31 +223,20 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 60 unmodified lines | ||
| 208 | 223 | interface OAuthProviderInputs { |
| 209 | 224 | readonly accessToken: string; |
| 210 | 225 | readonly tokenType: string; |
| 211 | − readonly refreshToken?: string; | |
| 212 | − readonly expiresAt?: number | null; | |
| 213 | − readonly scope?: string | null; | |
| 214 | − /** Source-level state — same for every user. */ | |
| 226 | + /** Source-level state — same for every user. Surfaced to the MCP | |
| 227 | + * transport so internal DCR/discovery lookups find a populated | |
| 228 | + * provider, but never used to mint tokens: refresh is owned by the | |
| 229 | + * SDK via `ctx.connections.accessToken`. */ | |
| 215 | 230 | readonly clientInformation?: OAuthClientInformationMixed | null; |
| 216 | 231 | readonly authorizationServerUrl?: string | null; |
| 217 | 232 | readonly resourceMetadataUrl?: string | null; |
| 218 | 233 | readonly endpoint: string; |
| 219 | − /** | |
| 220 | − * Called when the SDK refreshes tokens (grant_type=refresh_token). | |
| 221 | − * Persisting new tokens back to per-user secrets is what closes the | |
| 222 | − * refresh loop — without it the next invocation reads stale values. | |
| 223 | − */ | |
| 224 | − readonly onRefresh?: (tokens: OAuthTokens) => Promise<void> | void; | |
| 225 | 234 | } |
| 226 | 235 | |
| 227 | 236 | const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => { |
| 228 | − let currentTokens: OAuthTokens = { | |
| 237 | + const currentTokens: OAuthTokens = { | |
| 229 | 238 | access_token: inputs.accessToken, |
| 230 | 239 | token_type: inputs.tokenType, |
| 231 | − ...(inputs.refreshToken ? { refresh_token: inputs.refreshToken } : {}), | |
| 232 | − ...(inputs.expiresAt | |
| 233 | − ? { expires_in: Math.max(0, Math.floor((inputs.expiresAt - Date.now()) / 1000)) } | |
| 234 | − : {}), | |
| 235 | − ...(inputs.scope ? { scope: inputs.scope } : {}), | |
| 236 | 240 | }; |
| 237 | 241 | let clientInformation: OAuthClientInformationMixed | undefined = |
| 238 | 242 | inputs.clientInformation ?? undefined; |
| @@ -263,10 +267,13 @@ const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => · 24 unmodified lines | ||
| 263 | 267 | clientInformation = ci; |
| 264 | 268 | }, |
| 265 | 269 | tokens: () => currentTokens, |
| 266 | − saveTokens: async (t) => { | |
| 267 | − currentTokens = t; | |
| 268 | − if (inputs.onRefresh) await inputs.onRefresh(t); | |
| 269 | − }, | |
| 270 | + // No-op: token refresh is driven by the SDK through | |
| 271 | + // `ctx.connections.accessToken`, and the access token resolved at | |
| 272 | + // `resolveConnectorInput` time is the only one this provider ever | |
| 273 | + // hands to the transport. A 401 from the server flips the | |
| 274 | + // invokeMcpTool retry path, which invalidates the cached connection | |
| 275 | + // and re-resolves — picking up a freshly refreshed token. | |
| 276 | + saveTokens: () => undefined, | |
| 270 | 277 | redirectToAuthorization: async () => { |
| 271 | 278 | throw new Error("MCP OAuth re-authorization required"); |
| 272 | 279 | }, |
| @@ -281,6 +288,38 @@ const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => · 8 unmodified lines | ||
| 281 | 288 | }; |
| 282 | 289 | }; |
| 283 | 290 | |
| 291 | +// --------------------------------------------------------------------------- | |
| 292 | +// Connection `provider_state` shape for mcp-oauth2. | |
| 293 | +// | |
| 294 | +// Carries everything the SDK hands to the refresh handler so it can | |
| 295 | +// re-hit the token endpoint without re-reading plugin storage. The DCR | |
| 296 | +// client info is NOT sensitive on its own — the MCP SDK's DCR flow uses | |
| 297 | +// `token_endpoint_auth_method: "none"` (PKCE), so there's no | |
| 298 | +// client_secret to protect. Having it here lets the refresh handler | |
| 299 | +// skip discovery entirely. | |
| 300 | +// --------------------------------------------------------------------------- | |
| 301 | + | |
| 302 | +const MCP_OAUTH2_PROVIDER_KEY = "mcp:oauth2" as const; | |
| 303 | + | |
| 304 | +const OAuth2ProviderState = Schema.Struct({ | |
| 305 | + endpoint: Schema.String, | |
| 306 | + tokenType: Schema.String, | |
| 307 | + clientInformation: McpJsonObject, | |
| 308 | + authorizationServerUrl: Schema.NullOr(Schema.String), | |
| 309 | + authorizationServerMetadata: Schema.NullOr(McpJsonObject), | |
| 310 | + resourceMetadataUrl: Schema.NullOr(Schema.String), | |
| 311 | + resourceMetadata: Schema.NullOr(McpJsonObject), | |
| 312 | +}); | |
| 313 | +type OAuth2ProviderState = typeof OAuth2ProviderState.Type; | |
| 314 | + | |
| 315 | +const encodeProviderState = Schema.encodeSync(OAuth2ProviderState); | |
| 316 | +const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState); | |
| 317 | + | |
| 318 | +const toProviderStateRecord = ( | |
| 319 | + state: OAuth2ProviderState, | |
| 320 | +): Record<string, unknown> => | |
| 321 | + encodeProviderState(state) as unknown as Record<string, unknown>; | |
| 322 | + | |
| 284 | 323 | const remoteConnectionError = (message: string) => |
| 285 | 324 | new McpConnectionError({ transport: "remote", message }); |
| 286 | 325 | |
| @@ -331,66 +370,47 @@ const resolveConnectorInput = ( · 44 unmodified lines | ||
| 331 | 370 | } |
| 332 | 371 | headers[auth.headerName] = auth.prefix ? `${auth.prefix}${val}` : val; |
| 333 | 372 | } else if (auth.kind === "oauth2") { |
| 334 | − const accessToken = yield* ctx.secrets.get(auth.accessTokenSecretId); | |
| 335 | − if (accessToken === null) { | |
| 336 | − return yield* Effect.fail( | |
| 337 | − remoteConnectionError("Failed to resolve OAuth access token"), | |
| 373 | + // `accessToken(id)` handles refresh internally — by the time we | |
| 374 | + // hand the value to the MCP transport it's guaranteed fresh. The | |
| 375 | + // connection row carries the DCR client + discovery metadata in | |
| 376 | + // `providerState`, but we read it off the connection here only | |
| 377 | + // for the `OAuthClientProvider` surface; the actual token | |
| 378 | + // material is what matters at the wire. | |
| 379 | + const accessToken = yield* ctx.connections | |
| 380 | + .accessToken(auth.connectionId) | |
| 381 | + .pipe( | |
| 382 | + Effect.mapError((err) => | |
| 383 | + remoteConnectionError( | |
| 384 | + `Failed to resolve OAuth connection "${auth.connectionId}": ${ | |
| 385 | + "message" in err | |
| 386 | + ? (err as { message: string }).message | |
| 387 | + : String(err) | |
| 388 | + }`, | |
| 389 | + ), | |
| 390 | + ), | |
| 338 | 391 | ); |
| 339 | − } | |
| 340 | − const refreshToken = auth.refreshTokenSecretId | |
| 341 | − ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined | |
| 342 | − : undefined; | |
| 343 | − // Capture the resolved owning scope of these secrets so refreshed | |
| 344 | − // tokens land back at the same per-user scope. `ctx.secrets.get` | |
| 345 | − // walks the executor scope stack innermost-first, so the existing | |
| 346 | − // value lives at whichever scope shadowed the source-level id — | |
| 347 | − // we mirror that with `ctx.scopes[0]!.id`, matching the scope | |
| 348 | − // chosen at startOAuth time. | |
| 349 | − const tokenScope = ScopeId.make(ctx.scopes[0]!.id as string); | |
| 350 | − const accessSecretId = auth.accessTokenSecretId; | |
| 351 | − const refreshSecretId = auth.refreshTokenSecretId; | |
| 392 | + | |
| 393 | + const connection = yield* ctx.connections.get(auth.connectionId); | |
| 394 | + const providerState: OAuth2ProviderState | null = | |
| 395 | + connection?.providerState | |
| 396 | + ? yield* Effect.sync(() => { | |
| 397 | + try { | |
| 398 | + return decodeProviderState(connection.providerState); | |
| 399 | + } catch { | |
| 400 | + return null; | |
| 401 | + } | |
| 402 | + }) | |
| 403 | + : null; | |
| 404 | + | |
| 352 | 405 | authProvider = makeOAuthProvider({ |
| 353 | 406 | accessToken, |
| 354 | − tokenType: auth.tokenType ?? "Bearer", | |
| 355 | − refreshToken, | |
| 356 | − expiresAt: auth.expiresAt, | |
| 357 | − scope: auth.scope, | |
| 358 | − clientInformation: auth.clientInformation as | |
| 359 | − | OAuthClientInformationMixed | |
| 360 | − | null | |
| 361 | − | undefined, | |
| 362 | − authorizationServerUrl: auth.authorizationServerUrl, | |
| 363 | − resourceMetadataUrl: auth.resourceMetadataUrl, | |
| 407 | + tokenType: providerState?.tokenType ?? "Bearer", | |
| 408 | + clientInformation: | |
| 409 | + (providerState?.clientInformation as OAuthClientInformationMixed | undefined) ?? | |
| 410 | + null, | |
| 411 | + authorizationServerUrl: providerState?.authorizationServerUrl ?? null, | |
| 412 | + resourceMetadataUrl: providerState?.resourceMetadataUrl ?? null, | |
| 364 | 413 | endpoint: sd.endpoint, |
| 365 | − onRefresh: async (tokens) => { | |
| 366 | − // Persist refreshed tokens back to the calling user's scope | |
| 367 | − // so subsequent invocations see the new value rather than | |
| 368 | − // re-refreshing on every request. Uses runPromise because | |
| 369 | − // OAuthClientProvider.saveTokens is an async callback, not | |
| 370 | − // an Effect. | |
| 371 | − await Effect.runPromise( | |
| 372 | − ctx.secrets.set( | |
| 373 | − new SetSecretInput({ | |
| 374 | − id: SecretId.make(accessSecretId), | |
| 375 | − scope: tokenScope, | |
| 376 | − name: "MCP OAuth Access Token", | |
| 377 | − value: tokens.access_token, | |
| 378 | − }), | |
| 379 | − ), | |
| 380 | − ); | |
| 381 | − if (tokens.refresh_token && refreshSecretId) { | |
| 382 | − await Effect.runPromise( | |
| 383 | − ctx.secrets.set( | |
| 384 | − new SetSecretInput({ | |
| 385 | − id: SecretId.make(refreshSecretId), | |
| 386 | − scope: tokenScope, | |
| 387 | − name: "MCP OAuth Refresh Token", | |
| 388 | − value: tokens.refresh_token, | |
| 389 | − }), | |
| 390 | − ), | |
| 391 | − ); | |
| 392 | − } | |
| 393 | − }, | |
| 394 | 414 | }); |
| 395 | 415 | } |
| 396 | 416 | |
| @@ -488,11 +508,7 @@ const authToConfig = (auth: McpConnectionAuth | undefined): McpAuthConfig | unde · 91 unmodified lines | ||
| 488 | 508 | } |
| 489 | 509 | return { |
| 490 | 510 | kind: "oauth2", |
| 491 | − accessTokenSecret: secretRef(auth.accessTokenSecretId), | |
| 492 | − refreshTokenSecret: auth.refreshTokenSecretId | |
| 493 | − ? secretRef(auth.refreshTokenSecretId) | |
| 494 | − : null, | |
| 495 | − tokenType: auth.tokenType, | |
| 511 | + connectionId: auth.connectionId, | |
| 496 | 512 | }; |
| 497 | 513 | }; |
| 498 | 514 | |
| @@ -889,8 +905,7 @@ export const mcpPlugin = definePlugin( · 390 unmodified lines | ||
| 889 | 905 | authorizationServerMetadata: started.authorizationServerMetadata, |
| 890 | 906 | clientInformation: started.clientInformation, |
| 891 | 907 | tokenScope, |
| 892 | − accessTokenSecretId: input.accessTokenSecretId, | |
| 893 | − refreshTokenSecretId: input.refreshTokenSecretId ?? null, | |
| 908 | + connectionId: input.connectionId, | |
| 894 | 909 | }) |
| 895 | 910 | .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session")); |
| 896 | 911 | |
| @@ -901,104 +916,139 @@ export const mcpPlugin = definePlugin( · 4 unmodified lines | ||
| 901 | 916 | }).pipe(Effect.withSpan("mcp.plugin.start_oauth")); |
| 902 | 917 | |
| 903 | 918 | const completeOAuth = (input: McpOAuthCompleteInput) => |
| 904 | − Effect.gen(function* () { | |
| 905 | − if (input.error) { | |
| 906 | − return yield* Effect.fail( | |
| 907 | − mcpOAuthError(`OAuth error: ${input.error}`), | |
| 908 | − ); | |
| 909 | − } | |
| 910 | − if (!input.code) { | |
| 911 | − return yield* Effect.fail( | |
| 912 | − mcpOAuthError("Missing OAuth authorization code"), | |
| 913 | − ); | |
| 914 | − } | |
| 915 | − | |
| 916 | − const session = yield* ctx.storage.getOAuthSession(input.state); | |
| 917 | − if (!session) { | |
| 918 | − return yield* Effect.fail( | |
| 919 | − mcpOAuthError(`OAuth session not found: ${input.state}`), | |
| 920 | − ); | |
| 921 | − } | |
| 922 | − | |
| 923 | − const exchanged = yield* exchangeMcpOAuthCode({ | |
| 924 | − session, | |
| 925 | − code: input.code, | |
| 926 | − }).pipe( | |
| 927 | − Effect.mapError((e) => | |
| 928 | − mcpOAuthError(`OAuth exchange failed: ${e.message}`), | |
| 929 | − ), | |
| 930 | − Effect.withSpan("mcp.plugin.oauth.exchange_code"), | |
| 931 | − ); | |
| 919 | + ctx | |
| 920 | + .transaction( | |
| 921 | + Effect.gen(function* () { | |
| 922 | + if (input.error) { | |
| 923 | + return yield* Effect.fail( | |
| 924 | + mcpOAuthError(`OAuth error: ${input.error}`), | |
| 925 | + ); | |
| 926 | + } | |
| 927 | + if (!input.code) { | |
| 928 | + return yield* Effect.fail( | |
| 929 | + mcpOAuthError("Missing OAuth authorization code"), | |
| 930 | + ); | |
| 931 | + } | |
| 932 | 932 | |
| 933 | − // Token storage is fully driven by the session: scope and | |
| 934 | − // secret ids were chosen at startOAuth time and pinned to | |
| 935 | − // the row. That keeps shadowing deterministic — every | |
| 936 | − // user's OAuth flow on the same source writes to the same | |
| 937 | − // ids, so the source's stored OAuth2 auth resolves | |
| 938 | − // per-user via scope fall-through. | |
| 939 | − const tokenScope = ScopeId.make(session.tokenScope); | |
| 940 | − const accessSecretId = session.accessTokenSecretId; | |
| 941 | − yield* ctx.secrets | |
| 942 | − .set( | |
| 943 | − new SetSecretInput({ | |
| 944 | − id: SecretId.make(accessSecretId), | |
| 945 | − scope: tokenScope, | |
| 946 | − name: "MCP OAuth Access Token", | |
| 947 | − value: exchanged.tokens.access_token, | |
| 948 | − }), | |
| 949 | − ) | |
| 950 | − .pipe( | |
| 951 | − Effect.mapError((e) => | |
| 952 | − mcpOAuthError(`Failed to store access token: ${String(e)}`), | |
| 953 | − ), | |
| 954 | − ); | |
| 933 | + const session = yield* ctx.storage.getOAuthSession(input.state); | |
| 934 | + if (!session) { | |
| 935 | + return yield* Effect.fail( | |
| 936 | + mcpOAuthError(`OAuth session not found: ${input.state}`), | |
| 937 | + ); | |
| 938 | + } | |
| 955 | 939 | |
| 956 | − let refreshTokenSecretId: string | null = null; | |
| 957 | − if (exchanged.tokens.refresh_token && session.refreshTokenSecretId) { | |
| 958 | − const refreshId = session.refreshTokenSecretId; | |
| 959 | − yield* ctx.secrets | |
| 960 | − .set( | |
| 961 | − new SetSecretInput({ | |
| 962 | − id: SecretId.make(refreshId), | |
| 963 | − scope: tokenScope, | |
| 964 | − name: "MCP OAuth Refresh Token", | |
| 965 | − value: exchanged.tokens.refresh_token, | |
| 966 | − }), | |
| 967 | − ) | |
| 968 | − .pipe( | |
| 940 | + const exchanged = yield* exchangeMcpOAuthCode({ | |
| 941 | + session, | |
| 942 | + code: input.code, | |
| 943 | + }).pipe( | |
| 969 | 944 | Effect.mapError((e) => |
| 970 | − mcpOAuthError( | |
| 971 | − `Failed to store refresh token: ${String(e)}`, | |
| 972 | − ), | |
| 945 | + mcpOAuthError(`OAuth exchange failed: ${e.message}`), | |
| 973 | 946 | ), |
| 947 | + Effect.withSpan("mcp.plugin.oauth.exchange_code"), | |
| 974 | 948 | ); |
| 975 | − refreshTokenSecretId = refreshId; | |
| 976 | − } | |
| 977 | − | |
| 978 | − yield* ctx.storage.deleteOAuthSession(input.state); | |
| 979 | 949 | |
| 980 | − const expiresAt = | |
| 981 | − typeof exchanged.tokens.expires_in === "number" | |
| 982 | − ? Date.now() + exchanged.tokens.expires_in * 1000 | |
| 983 | − : null; | |
| 950 | + yield* ctx.storage.deleteOAuthSession(input.state); | |
| 951 | + | |
| 952 | + const tokenType = exchanged.tokens.token_type ?? "Bearer"; | |
| 953 | + const expiresAt = | |
| 954 | + typeof exchanged.tokens.expires_in === "number" | |
| 955 | + ? Date.now() + exchanged.tokens.expires_in * 1000 | |
| 956 | + : null; | |
| 957 | + | |
| 958 | + // Derive a friendly label for the Connections page. MCP | |
| 959 | + // OAuth tokens don't include identity claims, so fall back | |
| 960 | + // to the endpoint host (e.g., "mcp.axiom.co"), which is | |
| 961 | + // what the user recognises from the source they added. | |
| 962 | + let derivedLabel: string | null = null; | |
| 963 | + try { | |
| 964 | + derivedLabel = new URL(session.endpoint).host; | |
| 965 | + } catch { | |
| 966 | + derivedLabel = null; | |
| 967 | + } | |
| 968 | + | |
| 969 | + const providerState: OAuth2ProviderState = { | |
| 970 | + endpoint: session.endpoint, | |
| 971 | + tokenType, | |
| 972 | + clientInformation: | |
| 973 | + (exchanged.clientInformation as Record<string, unknown> | null) ?? | |
| 974 | + (session.clientInformation ?? {}), | |
| 975 | + authorizationServerUrl: | |
| 976 | + exchanged.authorizationServerUrl ?? | |
| 977 | + session.authorizationServerUrl, | |
| 978 | + authorizationServerMetadata: | |
| 979 | + exchanged.authorizationServerMetadata ?? | |
| 980 | + session.authorizationServerMetadata, | |
| 981 | + resourceMetadataUrl: | |
| 982 | + exchanged.resourceMetadataUrl ?? session.resourceMetadataUrl, | |
| 983 | + resourceMetadata: | |
| 984 | + exchanged.resourceMetadata ?? session.resourceMetadata, | |
| 985 | + }; | |
| 986 | + | |
| 987 | + // Mint the connection + its owned access/refresh secret | |
| 988 | + // rows in one transactional write. The session-pinned | |
| 989 | + // `connectionId` stays stable across re-auths by the same | |
| 990 | + // source, which means the UI's stored `{kind: "oauth2", | |
| 991 | + // connectionId}` on the source auth config resolves | |
| 992 | + // per-user via scope shadowing once each user completes | |
| 993 | + // their own OAuth. | |
| 994 | + yield* ctx.connections | |
| 995 | + .create( | |
| 996 | + new CreateConnectionInput({ | |
| 997 | + id: ConnectionId.make(session.connectionId), | |
| 998 | + scope: ScopeId.make(session.tokenScope), | |
| 999 | + provider: MCP_OAUTH2_PROVIDER_KEY, | |
| 1000 | + kind: "user", | |
| 1001 | + identityLabel: derivedLabel, | |
| 1002 | + accessToken: new TokenMaterial({ | |
| 1003 | + secretId: SecretId.make( | |
| 1004 | + `${session.connectionId}.access_token`, | |
| 1005 | + ), | |
| 1006 | + name: "MCP OAuth Access Token", | |
| 1007 | + value: exchanged.tokens.access_token, | |
| 1008 | + }), | |
| 1009 | + refreshToken: exchanged.tokens.refresh_token | |
| 1010 | + ? new TokenMaterial({ | |
| 1011 | + secretId: SecretId.make( | |
| 1012 | + `${session.connectionId}.refresh_token`, | |
| 1013 | + ), | |
| 1014 | + name: "MCP OAuth Refresh Token", | |
| 1015 | + value: exchanged.tokens.refresh_token, | |
| 1016 | + }) | |
| 1017 | + : null, | |
| 1018 | + expiresAt, | |
| 1019 | + oauthScope: exchanged.tokens.scope ?? null, | |
| 1020 | + providerState: toProviderStateRecord(providerState), | |
| 1021 | + }), | |
| 1022 | + ) | |
| 1023 | + .pipe( | |
| 1024 | + Effect.mapError((err) => | |
| 1025 | + mcpOAuthError( | |
| 1026 | + `Failed to create OAuth connection: ${ | |
| 1027 | + "message" in err | |
| 1028 | + ? (err as { message: string }).message | |
| 1029 | + : String(err) | |
| 1030 | + }`, | |
| 1031 | + ), | |
| 1032 | + ), | |
| 1033 | + ); | |
| 984 | 1034 | |
| 985 | − return { | |
| 986 | − accessTokenSecretId: accessSecretId, | |
| 987 | − refreshTokenSecretId, | |
| 988 | − tokenType: exchanged.tokens.token_type ?? "Bearer", | |
| 989 | − expiresAt, | |
| 990 | − scope: exchanged.tokens.scope ?? null, | |
| 991 | − // Echo the source-level OAuth state captured during this | |
| 992 | − // flow. The UI persists it on the source's auth config so | |
| 993 | − // refreshes (and any future user's OAuth) re-use the same | |
| 994 | − // DCR client + skip discovery. | |
| 995 | − clientInformation: exchanged.clientInformation as | |
| 996 | − | Record<string, unknown> | |
| 997 | − | null, | |
| 998 | − authorizationServerUrl: exchanged.authorizationServerUrl, | |
| 999 | − resourceMetadataUrl: exchanged.resourceMetadataUrl, | |
| 1000 | − }; | |
| 1001 | − }).pipe(Effect.withSpan("mcp.plugin.complete_oauth")); | |
| 1035 | + return { | |
| 1036 | + connectionId: session.connectionId, | |
| 1037 | + tokenType, | |
| 1038 | + expiresAt, | |
| 1039 | + scope: exchanged.tokens.scope ?? null, | |
| 1040 | + // Echo the source-level OAuth state captured during this | |
| 1041 | + // flow. The UI persists it on the source's auth config | |
| 1042 | + // so subsequent users can skip DCR + re-discovery. | |
| 1043 | + clientInformation: exchanged.clientInformation as | |
| 1044 | + | Record<string, unknown> | |
| 1045 | + | null, | |
| 1046 | + authorizationServerUrl: exchanged.authorizationServerUrl, | |
| 1047 | + resourceMetadataUrl: exchanged.resourceMetadataUrl, | |
| 1048 | + }; | |
| 1049 | + }), | |
| 1050 | + ) | |
| 1051 | + .pipe(Effect.withSpan("mcp.plugin.complete_oauth")); | |
| 1002 | 1052 | |
| 1003 | 1053 | const updateSource = ( |
| 1004 | 1054 | namespace: string, |
| @@ -1205,6 +1255,87 @@ export const mcpPlugin = definePlugin( · 200 unmodified lines | ||
| 1205 | 1255 | |
| 1206 | 1256 | refreshSource: () => Effect.void, |
| 1207 | 1257 | |
| 1258 | + // The SDK's `ctx.connections.accessToken(id)` dispatches here when | |
| 1259 | + // a connection with `provider: "mcp:oauth2"` is near expiry. The | |
| 1260 | + // providerState blob — captured in completeOAuth — carries | |
| 1261 | + // everything the MCP SDK needs to skip discovery + DCR and go | |
| 1262 | + // straight to the refresh token endpoint. | |
| 1263 | + connectionProviders: (ctx): readonly ConnectionProvider[] => [ | |
| 1264 | + { | |
| 1265 | + key: MCP_OAUTH2_PROVIDER_KEY, | |
| 1266 | + refresh: (input: ConnectionRefreshInput) => | |
| 1267 | + Effect.gen(function* () { | |
| 1268 | + if (!input.providerState) { | |
| 1269 | + return yield* new ConnectionRefreshError({ | |
| 1270 | + connectionId: input.connectionId, | |
| 1271 | + message: "mcp:oauth2 connection is missing providerState", | |
| 1272 | + }); | |
| 1273 | + } | |
| 1274 | + const refreshToken = input.refreshToken; | |
| 1275 | + if (!refreshToken) { | |
| 1276 | + return yield* new ConnectionRefreshError({ | |
| 1277 | + connectionId: input.connectionId, | |
| 1278 | + message: "mcp:oauth2 connection has no refresh token", | |
| 1279 | + }); | |
| 1280 | + } | |
| 1281 | + const state = yield* Effect.try({ | |
| 1282 | + try: () => decodeProviderState(input.providerState), | |
| 1283 | + catch: (cause) => | |
| 1284 | + new ConnectionRefreshError({ | |
| 1285 | + connectionId: input.connectionId, | |
| 1286 | + message: `mcp:oauth2 providerState is malformed: ${ | |
| 1287 | + cause instanceof Error ? cause.message : String(cause) | |
| 1288 | + }`, | |
| 1289 | + cause, | |
| 1290 | + }), | |
| 1291 | + }); | |
| 1292 | + | |
| 1293 | + const authServerUrl = | |
| 1294 | + state.authorizationServerUrl ?? | |
| 1295 | + new URL("/", state.endpoint).toString(); | |
| 1296 | + | |
| 1297 | + const tokens = yield* Effect.tryPromise({ | |
| 1298 | + try: () => | |
| 1299 | + refreshAuthorization(authServerUrl, { | |
| 1300 | + metadata: | |
| 1301 | + (state.authorizationServerMetadata as | |
| 1302 | + | OAuthMetadata | |
| 1303 | + | null) ?? undefined, | |
| 1304 | + clientInformation: | |
| 1305 | + state.clientInformation as OAuthClientInformationMixed, | |
| 1306 | + refreshToken, | |
| 1307 | + }), | |
| 1308 | + catch: (cause) => | |
| 1309 | + new ConnectionRefreshError({ | |
| 1310 | + connectionId: input.connectionId, | |
| 1311 | + message: `MCP OAuth refresh failed: ${ | |
| 1312 | + cause instanceof Error ? cause.message : String(cause) | |
| 1313 | + }`, | |
| 1314 | + cause, | |
| 1315 | + }), | |
| 1316 | + }); | |
| 1317 | + | |
| 1318 | + const expiresAt = | |
| 1319 | + typeof tokens.expires_in === "number" | |
| 1320 | + ? Date.now() + tokens.expires_in * 1000 | |
| 1321 | + : null; | |
| 1322 | + | |
| 1323 | + // The MCP SDK preserves the original refresh_token if the | |
| 1324 | + // AS doesn't rotate (see refreshAuthorization doc-comment), | |
| 1325 | + // so we forward whatever it returned. Undefined here would | |
| 1326 | + // mean "keep the stored one", but we always pass the | |
| 1327 | + // current value through for consistency. | |
| 1328 | + const result: ConnectionRefreshResult = { | |
| 1329 | + accessToken: tokens.access_token, | |
| 1330 | + refreshToken: tokens.refresh_token ?? undefined, | |
| 1331 | + expiresAt, | |
| 1332 | + oauthScope: tokens.scope ?? input.oauthScope, | |
| 1333 | + }; | |
| 1334 | + return result; | |
| 1335 | + }), | |
| 1336 | + }, | |
| 1337 | + ], | |
| 1338 | + | |
| 1208 | 1339 | close: () => |
| 1209 | 1340 | Effect.gen(function* () { |
| 1210 | 1341 | const runtime = runtimeRef.current; |
| @@ -13,10 +13,17 @@ export type McpTransport = typeof McpTransport.Type; · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | // --------------------------------------------------------------------------- |
| 15 | 15 | // Connection auth (only applies to remote sources) |
| 16 | +// | |
| 17 | +// `oauth2` is a thin pointer to an SDK Connection (`ctx.connections`) — | |
| 18 | +// the access/refresh secrets, expiry, DCR client info, and authorization- | |
| 19 | +// server discovery URLs all live on the connection row. Scope shadowing | |
| 20 | +// means the same `connectionId` resolves per-user via the executor's | |
| 21 | +// innermost-wins lookup. | |
| 16 | 22 | // --------------------------------------------------------------------------- |
| 17 | 23 | |
| 18 | 24 | /** JSON object loosely typed — used for opaque OAuth state we just round-trip. */ |
| 19 | 25 | const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown }); |
| 26 | +export { JsonObject as McpJsonObject }; | |
| 20 | 27 | |
| 21 | 28 | export const McpConnectionAuth = Schema.Union( |
| 22 | 29 | Schema.Struct({ kind: Schema.Literal("none") }), |
| @@ -28,32 +35,7 @@ export const McpConnectionAuth = Schema.Union( · 5 unmodified lines | ||
| 28 | 35 | }), |
| 29 | 36 | Schema.Struct({ |
| 30 | 37 | kind: Schema.Literal("oauth2"), |
| 31 | − accessTokenSecretId: Schema.String, | |
| 32 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 33 | − tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }), | |
| 34 | − expiresAt: Schema.NullOr(Schema.Number), | |
| 35 | − scope: Schema.NullOr(Schema.String), | |
| 36 | − /** | |
| 37 | − * Source-level OAuth state shared by every user. Lives on the | |
| 38 | − * source row (org scope) so DCR runs once per source instead of | |
| 39 | − * once per user, and so refresh can use the same client_id the | |
| 40 | − * upstream auth server originally registered. | |
| 41 | − * | |
| 42 | − * - `clientInformation`: DCR-issued client credentials (client_id, | |
| 43 | − * optional client_secret). When present, no DCR happens on | |
| 44 | − * subsequent OAuth flows or refreshes. | |
| 45 | − * - `authorizationServerUrl` / `resourceMetadataUrl`: discovery | |
| 46 | − * URLs captured at first OAuth so refreshes don't re-discover. | |
| 47 | − */ | |
| 48 | − clientInformation: Schema.optionalWith(Schema.NullOr(JsonObject), { | |
| 49 | − default: () => null, | |
| 50 | − }), | |
| 51 | − authorizationServerUrl: Schema.optionalWith(Schema.NullOr(Schema.String), { | |
| 52 | − default: () => null, | |
| 53 | − }), | |
| 54 | − resourceMetadataUrl: Schema.optionalWith(Schema.NullOr(Schema.String), { | |
| 55 | − default: () => null, | |
| 56 | − }), | |
| 38 | + connectionId: Schema.String, | |
| 57 | 39 | }), |
| 58 | 40 | ); |
| 59 | 41 | export type McpConnectionAuth = typeof McpConnectionAuth.Type; |
| @@ -399,17 +399,3 @@ export const shouldRefreshToken = (input: { · 398 unmodified lines | ||
| 399 | 399 | return input.expiresAt <= now + skew; |
| 400 | 400 | }; |
| 401 | 401 | |
| 402 | −// --------------------------------------------------------------------------- | |
| 403 | −// Re-exports from sibling modules | |
| 404 | −// --------------------------------------------------------------------------- | |
| 405 | − | |
| 406 | −export { | |
| 407 | − type OAuth2AuthRefs, | |
| 408 | − type OAuth2SecretsIO, | |
| 409 | − type RefreshedAuthSnapshot, | |
| 410 | − type StoreOAuthTokensInput, | |
| 411 | − type StoredOAuthTokens, | |
| 412 | − type WithRefreshedAccessTokenInput, | |
| 413 | − storeOAuthTokens, | |
| 414 | − withRefreshedAccessToken, | |
| 415 | −} from "./refresh"; | |
| @@ -1,396 +0,0 @@ | ||
| 1 | −// --------------------------------------------------------------------------- | |
| 2 | −// Fidelity tests for storeOAuthTokens + withRefreshedAccessToken. | |
| 3 | −// Uses an in-memory secrets adapter so we can assert exactly which calls | |
| 4 | −// happen in which order across the "no refresh needed", "refresh needed", | |
| 5 | −// "refresh rotates the refresh_token", and "secret resolution fails" paths. | |
| 6 | −// --------------------------------------------------------------------------- | |
| 7 | − | |
| 8 | −import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 9 | −import { Effect, Exit } from "effect"; | |
| 10 | − | |
| 11 | −import { | |
| 12 | − OAUTH2_REFRESH_SKEW_MS, | |
| 13 | − storeOAuthTokens, | |
| 14 | − withRefreshedAccessToken, | |
| 15 | − type OAuth2AuthRefs, | |
| 16 | − type OAuth2SecretsIO, | |
| 17 | − type RefreshedAuthSnapshot, | |
| 18 | −} from "./index"; | |
| 19 | − | |
| 20 | −// --------------------------------------------------------------------------- | |
| 21 | −// In-memory secrets adapter | |
| 22 | −// --------------------------------------------------------------------------- | |
| 23 | − | |
| 24 | −const makeSecrets = (initial: Record<string, string>): { | |
| 25 | − io: OAuth2SecretsIO; | |
| 26 | − state: Map<string, { value: string; name: string; purpose: string }>; | |
| 27 | − missing: Set<string>; | |
| 28 | −} => { | |
| 29 | − const state = new Map<string, { value: string; name: string; purpose: string }>(); | |
| 30 | − for (const [id, v] of Object.entries(initial)) | |
| 31 | − state.set(id, { value: v, name: "", purpose: "" }); | |
| 32 | − const missing = new Set<string>(); | |
| 33 | − const io: OAuth2SecretsIO = { | |
| 34 | − resolve: (secretId) => | |
| 35 | − Effect.gen(function* () { | |
| 36 | − if (missing.has(secretId)) | |
| 37 | − return yield* Effect.fail(new Error(`secret ${secretId} not found`)); | |
| 38 | − const entry = state.get(secretId); | |
| 39 | − if (!entry) | |
| 40 | − return yield* Effect.fail(new Error(`secret ${secretId} not found`)); | |
| 41 | − return entry.value; | |
| 42 | − }), | |
| 43 | − setValue: ({ secretId, value, name, purpose }) => | |
| 44 | − Effect.sync(() => { | |
| 45 | − state.set(secretId, { value, name, purpose }); | |
| 46 | − }), | |
| 47 | − }; | |
| 48 | − return { io, state, missing }; | |
| 49 | −}; | |
| 50 | − | |
| 51 | −// --------------------------------------------------------------------------- | |
| 52 | −// fetch capture for refresh path | |
| 53 | −// --------------------------------------------------------------------------- | |
| 54 | − | |
| 55 | −const originalFetch = globalThis.fetch; | |
| 56 | − | |
| 57 | −const captureFetch = ( | |
| 58 | − response: Response | (() => Response), | |
| 59 | −): { calls: Array<{ url: string; init: RequestInit }> } => { | |
| 60 | − const calls: Array<{ url: string; init: RequestInit }> = []; | |
| 61 | − globalThis.fetch = vi | |
| 62 | − .fn() | |
| 63 | − .mockImplementation(async (url: string, init: RequestInit) => { | |
| 64 | − calls.push({ url, init }); | |
| 65 | − return typeof response === "function" ? response() : response; | |
| 66 | − }) as unknown as typeof fetch; | |
| 67 | − return { calls }; | |
| 68 | −}; | |
| 69 | − | |
| 70 | −const jsonResponse = (status: number, body: unknown): Response => | |
| 71 | − new Response(JSON.stringify(body), { | |
| 72 | − status, | |
| 73 | − headers: { "content-type": "application/json" }, | |
| 74 | − }); | |
| 75 | − | |
| 76 | −afterEach(() => { | |
| 77 | − globalThis.fetch = originalFetch; | |
| 78 | −}); | |
| 79 | − | |
| 80 | −// --------------------------------------------------------------------------- | |
| 81 | −// storeOAuthTokens | |
| 82 | −// --------------------------------------------------------------------------- | |
| 83 | − | |
| 84 | −describe("storeOAuthTokens", () => { | |
| 85 | − const baseInput = { | |
| 86 | − slug: "acme_api", | |
| 87 | − displayName: "Acme API", | |
| 88 | − accessTokenPurpose: "acme_oauth_access_token", | |
| 89 | − refreshTokenPurpose: "acme_oauth_refresh_token", | |
| 90 | − }; | |
| 91 | − | |
| 92 | − it("stores both access and refresh tokens as new secrets", async () => { | |
| 93 | − const calls: Array<{ idPrefix: string; name: string; purpose: string; value: string }> = []; | |
| 94 | − const result = await Effect.runPromise( | |
| 95 | − storeOAuthTokens({ | |
| 96 | − ...baseInput, | |
| 97 | − tokens: { | |
| 98 | − access_token: "tok", | |
| 99 | − refresh_token: "rtok", | |
| 100 | − token_type: "Bearer", | |
| 101 | − expires_in: 3600, | |
| 102 | − scope: "read write", | |
| 103 | − }, | |
| 104 | − createSecret: (input) => { | |
| 105 | − calls.push(input); | |
| 106 | − return Effect.succeed({ id: `id_${calls.length}` }); | |
| 107 | − }, | |
| 108 | − }), | |
| 109 | − ); | |
| 110 | − expect(calls).toHaveLength(2); | |
| 111 | − expect(calls[0]!.idPrefix).toBe("acme_api_access_token"); | |
| 112 | − expect(calls[0]!.name).toBe("Acme API Access Token"); | |
| 113 | − expect(calls[0]!.purpose).toBe("acme_oauth_access_token"); | |
| 114 | − expect(calls[0]!.value).toBe("tok"); | |
| 115 | − expect(calls[1]!.idPrefix).toBe("acme_api_refresh_token"); | |
| 116 | − expect(calls[1]!.value).toBe("rtok"); | |
| 117 | − expect(result.accessTokenSecretId).toBe("id_1"); | |
| 118 | − expect(result.refreshTokenSecretId).toBe("id_2"); | |
| 119 | − expect(result.tokenType).toBe("Bearer"); | |
| 120 | − expect(result.scope).toBe("read write"); | |
| 121 | − expect(result.expiresAt).toBeGreaterThan(Date.now()); | |
| 122 | − }); | |
| 123 | − | |
| 124 | − it("omits the refresh-token secret when the response did not return one", async () => { | |
| 125 | − let calls = 0; | |
| 126 | − const result = await Effect.runPromise( | |
| 127 | − storeOAuthTokens({ | |
| 128 | − ...baseInput, | |
| 129 | − tokens: { access_token: "tok" }, | |
| 130 | − createSecret: () => { | |
| 131 | − calls++; | |
| 132 | − return Effect.succeed({ id: `id_${calls}` }); | |
| 133 | − }, | |
| 134 | − }), | |
| 135 | − ); | |
| 136 | − expect(calls).toBe(1); | |
| 137 | − expect(result.refreshTokenSecretId).toBeNull(); | |
| 138 | − expect(result.tokenType).toBe("Bearer"); | |
| 139 | − expect(result.expiresAt).toBeNull(); | |
| 140 | − expect(result.scope).toBeNull(); | |
| 141 | − }); | |
| 142 | − | |
| 143 | − it("wraps createSecret failure in an OAuth2Error with a descriptive message", async () => { | |
| 144 | − const exit = await Effect.runPromiseExit( | |
| 145 | − storeOAuthTokens({ | |
| 146 | − ...baseInput, | |
| 147 | − tokens: { access_token: "tok", refresh_token: "rtok" }, | |
| 148 | − createSecret: () => Effect.fail(new Error("kv write failed")), | |
| 149 | − }), | |
| 150 | − ); | |
| 151 | − expect(Exit.isFailure(exit)).toBe(true); | |
| 152 | − if (Exit.isFailure(exit)) { | |
| 153 | − const json = JSON.stringify(exit.cause); | |
| 154 | − expect(json).toContain("OAuth2Error"); | |
| 155 | − expect(json).toContain("Failed to store access token"); | |
| 156 | − expect(json).toContain("kv write failed"); | |
| 157 | − } | |
| 158 | − }); | |
| 159 | −}); | |
| 160 | − | |
| 161 | −// --------------------------------------------------------------------------- | |
| 162 | −// withRefreshedAccessToken | |
| 163 | −// --------------------------------------------------------------------------- | |
| 164 | − | |
| 165 | −describe("withRefreshedAccessToken", () => { | |
| 166 | − const makeAuth = (overrides: Partial<OAuth2AuthRefs> = {}): OAuth2AuthRefs => ({ | |
| 167 | − clientIdSecretId: "cid-secret", | |
| 168 | − clientSecretSecretId: "csecret-secret", | |
| 169 | − accessTokenSecretId: "atok-secret", | |
| 170 | − refreshTokenSecretId: "rtok-secret", | |
| 171 | − tokenType: "Bearer", | |
| 172 | − expiresAt: null, | |
| 173 | − scopes: ["read", "write"], | |
| 174 | − ...overrides, | |
| 175 | − }); | |
| 176 | − | |
| 177 | − const baseInput = { | |
| 178 | − tokenUrl: "https://api.example.com/oauth/token", | |
| 179 | − displayName: "Acme", | |
| 180 | − accessTokenPurpose: "acme_access", | |
| 181 | − refreshTokenPurpose: "acme_refresh", | |
| 182 | − }; | |
| 183 | − | |
| 184 | − it("returns the current access token without refreshing when expiresAt is null", async () => { | |
| 185 | − const { io } = makeSecrets({ | |
| 186 | − "atok-secret": "current-access-token", | |
| 187 | − "rtok-secret": "stored-refresh", | |
| 188 | − "cid-secret": "cid", | |
| 189 | − "csecret-secret": "csecret", | |
| 190 | − }); | |
| 191 | − const fetchSpy = vi.fn(); | |
| 192 | − globalThis.fetch = fetchSpy as unknown as typeof fetch; | |
| 193 | − const persistAuth = vi.fn(() => Effect.void); | |
| 194 | − | |
| 195 | − const result = await Effect.runPromise( | |
| 196 | − withRefreshedAccessToken({ | |
| 197 | − ...baseInput, | |
| 198 | − auth: makeAuth({ expiresAt: null }), | |
| 199 | − secrets: io, | |
| 200 | − persistAuth, | |
| 201 | − }), | |
| 202 | − ); | |
| 203 | − expect(result).toBe("current-access-token"); | |
| 204 | − expect(fetchSpy).not.toHaveBeenCalled(); | |
| 205 | − expect(persistAuth).not.toHaveBeenCalled(); | |
| 206 | − }); | |
| 207 | − | |
| 208 | − it("returns the current access token when expiresAt is comfortably in the future", async () => { | |
| 209 | − const { io } = makeSecrets({ | |
| 210 | − "atok-secret": "current", | |
| 211 | − "rtok-secret": "r", | |
| 212 | − "cid-secret": "c", | |
| 213 | − "csecret-secret": "s", | |
| 214 | − }); | |
| 215 | − const fetchSpy = vi.fn(); | |
| 216 | − globalThis.fetch = fetchSpy as unknown as typeof fetch; | |
| 217 | − | |
| 218 | − const result = await Effect.runPromise( | |
| 219 | − withRefreshedAccessToken({ | |
| 220 | − ...baseInput, | |
| 221 | − auth: makeAuth({ expiresAt: Date.now() + 10 * 60_000 }), | |
| 222 | − secrets: io, | |
| 223 | − persistAuth: () => Effect.void, | |
| 224 | − }), | |
| 225 | − ); | |
| 226 | − expect(result).toBe("current"); | |
| 227 | − expect(fetchSpy).not.toHaveBeenCalled(); | |
| 228 | − }); | |
| 229 | − | |
| 230 | − it("refreshes when expiresAt is within the skew window, persists new token, calls persistAuth", async () => { | |
| 231 | − const { io, state } = makeSecrets({ | |
| 232 | − "atok-secret": "old-access", | |
| 233 | − "rtok-secret": "stored-refresh", | |
| 234 | − "cid-secret": "client-1", | |
| 235 | − "csecret-secret": "secret-1", | |
| 236 | − }); | |
| 237 | − const { calls } = captureFetch( | |
| 238 | − jsonResponse(200, { | |
| 239 | − access_token: "new-access", | |
| 240 | − token_type: "Bearer", | |
| 241 | − expires_in: 3600, | |
| 242 | − }), | |
| 243 | − ); | |
| 244 | − const persistedSnapshots: RefreshedAuthSnapshot[] = []; | |
| 245 | − const persistAuth = (snapshot: RefreshedAuthSnapshot) => | |
| 246 | − Effect.sync(() => { | |
| 247 | − persistedSnapshots.push(snapshot); | |
| 248 | − }); | |
| 249 | − | |
| 250 | − const result = await Effect.runPromise( | |
| 251 | − withRefreshedAccessToken({ | |
| 252 | − ...baseInput, | |
| 253 | − auth: makeAuth({ expiresAt: Date.now() + 30_000 }), | |
| 254 | − secrets: io, | |
| 255 | − persistAuth, | |
| 256 | − }), | |
| 257 | − ); | |
| 258 | − | |
| 259 | − expect(result).toBe("new-access"); | |
| 260 | − expect(calls).toHaveLength(1); | |
| 261 | − const body = calls[0]!.init.body as URLSearchParams; | |
| 262 | − expect(body.get("grant_type")).toBe("refresh_token"); | |
| 263 | − expect(body.get("refresh_token")).toBe("stored-refresh"); | |
| 264 | − expect(body.get("client_id")).toBe("client-1"); | |
| 265 | − expect(body.get("client_secret")).toBe("secret-1"); | |
| 266 | − expect(body.get("scope")).toBe("read write"); | |
| 267 | − | |
| 268 | − // Access-token secret was updated in place with the new value. | |
| 269 | − expect(state.get("atok-secret")!.value).toBe("new-access"); | |
| 270 | − // persistAuth called with updated snapshot. | |
| 271 | − expect(persistedSnapshots).toHaveLength(1); | |
| 272 | − expect(persistedSnapshots[0]!.tokenType).toBe("Bearer"); | |
| 273 | − expect(persistedSnapshots[0]!.expiresAt).toBeGreaterThan(Date.now()); | |
| 274 | − }); | |
| 275 | − | |
| 276 | − it("rotates the refresh_token secret when the server returns a new one", async () => { | |
| 277 | − const { io, state } = makeSecrets({ | |
| 278 | − "atok-secret": "old", | |
| 279 | − "rtok-secret": "old-refresh", | |
| 280 | − "cid-secret": "c", | |
| 281 | − "csecret-secret": "s", | |
| 282 | − }); | |
| 283 | − captureFetch( | |
| 284 | − jsonResponse(200, { | |
| 285 | − access_token: "new", | |
| 286 | − refresh_token: "new-refresh", | |
| 287 | − expires_in: 3600, | |
| 288 | − }), | |
| 289 | − ); | |
| 290 | − await Effect.runPromise( | |
| 291 | − withRefreshedAccessToken({ | |
| 292 | − ...baseInput, | |
| 293 | − auth: makeAuth({ expiresAt: Date.now() + 30_000 }), | |
| 294 | − secrets: io, | |
| 295 | − persistAuth: () => Effect.void, | |
| 296 | − }), | |
| 297 | − ); | |
| 298 | − expect(state.get("rtok-secret")!.value).toBe("new-refresh"); | |
| 299 | − }); | |
| 300 | − | |
| 301 | − it("does NOT touch the refresh_token secret when the server omits it", async () => { | |
| 302 | − const { io, state } = makeSecrets({ | |
| 303 | − "atok-secret": "old", | |
| 304 | − "rtok-secret": "old-refresh", | |
| 305 | − "cid-secret": "c", | |
| 306 | − "csecret-secret": "s", | |
| 307 | − }); | |
| 308 | − captureFetch(jsonResponse(200, { access_token: "new", expires_in: 3600 })); | |
| 309 | − await Effect.runPromise( | |
| 310 | − withRefreshedAccessToken({ | |
| 311 | − ...baseInput, | |
| 312 | − auth: makeAuth({ expiresAt: Date.now() + 30_000 }), | |
| 313 | − secrets: io, | |
| 314 | − persistAuth: () => Effect.void, | |
| 315 | − }), | |
| 316 | − ); | |
| 317 | − expect(state.get("rtok-secret")!.value).toBe("old-refresh"); | |
| 318 | − }); | |
| 319 | − | |
| 320 | − it("skips the refresh path entirely when refreshTokenSecretId is null, even if expired", async () => { | |
| 321 | − const { io } = makeSecrets({ | |
| 322 | − "atok-secret": "only-access", | |
| 323 | − "cid-secret": "c", | |
| 324 | − "csecret-secret": "s", | |
| 325 | − }); | |
| 326 | − const fetchSpy = vi.fn(); | |
| 327 | − globalThis.fetch = fetchSpy as unknown as typeof fetch; | |
| 328 | − const result = await Effect.runPromise( | |
| 329 | − withRefreshedAccessToken({ | |
| 330 | − ...baseInput, | |
| 331 | − auth: makeAuth({ | |
| 332 | − refreshTokenSecretId: null, | |
| 333 | − expiresAt: Date.now() - 10_000, | |
| 334 | − }), | |
| 335 | − secrets: io, | |
| 336 | − persistAuth: () => Effect.void, | |
| 337 | − }), | |
| 338 | − ); | |
| 339 | − expect(result).toBe("only-access"); | |
| 340 | − expect(fetchSpy).not.toHaveBeenCalled(); | |
| 341 | − }); | |
| 342 | − | |
| 343 | − it("omits client_secret from the refresh call when clientSecretSecretId is null", async () => { | |
| 344 | − const { io } = makeSecrets({ | |
| 345 | − "atok-secret": "old", | |
| 346 | − "rtok-secret": "rtok", | |
| 347 | − "cid-secret": "public-client", | |
| 348 | − }); | |
| 349 | − const { calls } = captureFetch( | |
| 350 | − jsonResponse(200, { access_token: "new", expires_in: 3600 }), | |
| 351 | − ); | |
| 352 | − await Effect.runPromise( | |
| 353 | − withRefreshedAccessToken({ | |
| 354 | − ...baseInput, | |
| 355 | − auth: makeAuth({ | |
| 356 | − clientSecretSecretId: null, | |
| 357 | − expiresAt: Date.now() + 30_000, | |
| 358 | − }), | |
| 359 | − secrets: io, | |
| 360 | − persistAuth: () => Effect.void, | |
| 361 | − }), | |
| 362 | − ); | |
| 363 | − const body = calls[0]!.init.body as URLSearchParams; | |
| 364 | − expect(body.get("client_id")).toBe("public-client"); | |
| 365 | − expect(body.has("client_secret")).toBe(false); | |
| 366 | − }); | |
| 367 | − | |
| 368 | − it("wraps secret-resolve failures in OAuth2Error with descriptive messages", async () => { | |
| 369 | − const { io, missing } = makeSecrets({ | |
| 370 | − "atok-secret": "old", | |
| 371 | − "rtok-secret": "rtok", | |
| 372 | − "cid-secret": "c", | |
| 373 | − "csecret-secret": "s", | |
| 374 | − }); | |
| 375 | − missing.add("rtok-secret"); | |
| 376 | − captureFetch(jsonResponse(200, { access_token: "new" })); | |
| 377 | − const exit = await Effect.runPromiseExit( | |
| 378 | − withRefreshedAccessToken({ | |
| 379 | − ...baseInput, | |
| 380 | − auth: makeAuth({ expiresAt: Date.now() + 30_000 }), | |
| 381 | − secrets: io, | |
| 382 | − persistAuth: () => Effect.void, | |
| 383 | − }), | |
| 384 | − ); | |
| 385 | − expect(Exit.isFailure(exit)).toBe(true); | |
| 386 | − if (Exit.isFailure(exit)) { | |
| 387 | − const json = JSON.stringify(exit.cause); | |
| 388 | − expect(json).toContain("OAuth2Error"); | |
| 389 | − expect(json).toContain("Failed to resolve OAuth refresh token"); | |
| 390 | − } | |
| 391 | − }); | |
| 392 | − | |
| 393 | − it("uses the default 60s refresh skew window", () => { | |
| 394 | − expect(OAUTH2_REFRESH_SKEW_MS).toBe(60_000); | |
| 395 | − }); | |
| 396 | −}); | |
| @@ -1,238 +0,0 @@ | ||
| 1 | −// --------------------------------------------------------------------------- | |
| 2 | −// Generic helpers for storing OAuth token secrets and transparently | |
| 3 | −// refreshing access tokens before a request. | |
| 4 | −// | |
| 5 | −// Plugins (google-discovery, openapi) supply an adapter for their secrets | |
| 6 | −// API and a `persistAuth` callback so this module stays decoupled from any | |
| 7 | −// specific plugin SDK or store shape. | |
| 8 | −// --------------------------------------------------------------------------- | |
| 9 | − | |
| 10 | −import { Effect } from "effect"; | |
| 11 | − | |
| 12 | −import { OAuth2Error, type OAuth2TokenResponse } from "./index"; | |
| 13 | −import { | |
| 14 | − OAUTH2_DEFAULT_TIMEOUT_MS, | |
| 15 | − refreshAccessToken, | |
| 16 | − shouldRefreshToken, | |
| 17 | − type ClientAuthMethod, | |
| 18 | −} from "./index"; | |
| 19 | − | |
| 20 | −// --------------------------------------------------------------------------- | |
| 21 | −// Secrets I/O adapter — plain string IDs, no plugin types. | |
| 22 | −// --------------------------------------------------------------------------- | |
| 23 | − | |
| 24 | −export type OAuth2SecretsIO = { | |
| 25 | − readonly resolve: (secretId: string) => Effect.Effect<string, unknown>; | |
| 26 | − readonly setValue: (input: { | |
| 27 | − readonly secretId: string; | |
| 28 | − readonly value: string; | |
| 29 | − readonly name: string; | |
| 30 | − readonly purpose: string; | |
| 31 | − }) => Effect.Effect<void, unknown>; | |
| 32 | −}; | |
| 33 | − | |
| 34 | −const wrapSecretError = | |
| 35 | − (operation: string) => | |
| 36 | − (cause: unknown): OAuth2Error => | |
| 37 | − new OAuth2Error({ | |
| 38 | − message: `${operation}: ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 39 | − cause, | |
| 40 | − }); | |
| 41 | − | |
| 42 | −// --------------------------------------------------------------------------- | |
| 43 | −// storeOAuthTokens — freshly store access + refresh tokens as new secrets. | |
| 44 | −// --------------------------------------------------------------------------- | |
| 45 | − | |
| 46 | −/** Shape of a newly persisted OAuth2 auth descriptor. */ | |
| 47 | −export type StoredOAuthTokens = { | |
| 48 | − readonly accessTokenSecretId: string; | |
| 49 | − readonly refreshTokenSecretId: string | null; | |
| 50 | − readonly tokenType: string; | |
| 51 | − readonly expiresAt: number | null; | |
| 52 | − readonly scope: string | null; | |
| 53 | −}; | |
| 54 | − | |
| 55 | −export type StoreOAuthTokensInput = { | |
| 56 | − readonly tokens: OAuth2TokenResponse; | |
| 57 | − /** Slug used as prefix for new secret IDs, e.g. `acme_api`. */ | |
| 58 | − readonly slug: string; | |
| 59 | − /** Human-readable name for the source, used in secret labels. */ | |
| 60 | − readonly displayName: string; | |
| 61 | − /** `purpose` label stored on the access-token secret. */ | |
| 62 | − readonly accessTokenPurpose: string; | |
| 63 | − /** `purpose` label stored on the refresh-token secret. */ | |
| 64 | − readonly refreshTokenPurpose: string; | |
| 65 | − /** Adapter that creates a new secret and returns its freshly-minted ID. */ | |
| 66 | − readonly createSecret: (input: { | |
| 67 | − readonly idPrefix: string; | |
| 68 | − readonly name: string; | |
| 69 | − readonly value: string; | |
| 70 | − readonly purpose: string; | |
| 71 | − }) => Effect.Effect<{ readonly id: string }, unknown>; | |
| 72 | −}; | |
| 73 | − | |
| 74 | −/** | |
| 75 | − * Persist access + refresh tokens from an OAuth2 token response as new | |
| 76 | − * secrets and return a `StoredOAuthTokens` descriptor ready to write to a | |
| 77 | − * source config. | |
| 78 | − */ | |
| 79 | −export const storeOAuthTokens = ( | |
| 80 | − input: StoreOAuthTokensInput, | |
| 81 | −): Effect.Effect<StoredOAuthTokens, OAuth2Error> => | |
| 82 | − Effect.gen(function* () { | |
| 83 | − const accessRef = yield* input | |
| 84 | − .createSecret({ | |
| 85 | − idPrefix: `${input.slug}_access_token`, | |
| 86 | − name: `${input.displayName} Access Token`, | |
| 87 | − value: input.tokens.access_token, | |
| 88 | − purpose: input.accessTokenPurpose, | |
| 89 | − }) | |
| 90 | − .pipe(Effect.mapError(wrapSecretError("Failed to store access token"))); | |
| 91 | − | |
| 92 | − const refreshRef = input.tokens.refresh_token | |
| 93 | − ? yield* input | |
| 94 | − .createSecret({ | |
| 95 | − idPrefix: `${input.slug}_refresh_token`, | |
| 96 | − name: `${input.displayName} Refresh Token`, | |
| 97 | − value: input.tokens.refresh_token, | |
| 98 | − purpose: input.refreshTokenPurpose, | |
| 99 | − }) | |
| 100 | − .pipe(Effect.mapError(wrapSecretError("Failed to store refresh token"))) | |
| 101 | − : null; | |
| 102 | − | |
| 103 | − return { | |
| 104 | − accessTokenSecretId: accessRef.id, | |
| 105 | − refreshTokenSecretId: refreshRef?.id ?? null, | |
| 106 | − tokenType: input.tokens.token_type ?? "Bearer", | |
| 107 | − expiresAt: | |
| 108 | − typeof input.tokens.expires_in === "number" | |
| 109 | − ? Date.now() + input.tokens.expires_in * 1000 | |
| 110 | − : null, | |
| 111 | − scope: input.tokens.scope ?? null, | |
| 112 | − }; | |
| 113 | − }); | |
| 114 | − | |
| 115 | −// --------------------------------------------------------------------------- | |
| 116 | −// withRefreshedAccessToken — return the current access token, refreshing | |
| 117 | −// first if it's within the skew window. | |
| 118 | −// --------------------------------------------------------------------------- | |
| 119 | − | |
| 120 | −/** The subset of a stored OAuth2 auth descriptor this helper needs. */ | |
| 121 | −export type OAuth2AuthRefs = { | |
| 122 | − readonly clientIdSecretId: string; | |
| 123 | − readonly clientSecretSecretId: string | null; | |
| 124 | − readonly accessTokenSecretId: string; | |
| 125 | − readonly refreshTokenSecretId: string | null; | |
| 126 | − readonly tokenType: string; | |
| 127 | − readonly expiresAt: number | null; | |
| 128 | − readonly scopes: readonly string[]; | |
| 129 | −}; | |
| 130 | − | |
| 131 | −/** Snapshot of auth values written back after a successful refresh. */ | |
| 132 | −export type RefreshedAuthSnapshot = { | |
| 133 | − readonly tokenType: string; | |
| 134 | − readonly expiresAt: number | null; | |
| 135 | − readonly scope: string | null; | |
| 136 | −}; | |
| 137 | − | |
| 138 | −export type WithRefreshedAccessTokenInput = { | |
| 139 | − readonly auth: OAuth2AuthRefs; | |
| 140 | − readonly tokenUrl: string; | |
| 141 | − readonly secrets: OAuth2SecretsIO; | |
| 142 | − /** Display name used when re-setting the access-token secret after refresh. */ | |
| 143 | − readonly displayName: string; | |
| 144 | − readonly accessTokenPurpose: string; | |
| 145 | − readonly refreshTokenPurpose: string; | |
| 146 | − readonly clientAuth?: ClientAuthMethod; | |
| 147 | − readonly timeoutMs?: number; | |
| 148 | − /** | |
| 149 | − * Called after a successful refresh with the new expiry / tokenType / | |
| 150 | − * scope so the plugin can write the updated auth back to its source | |
| 151 | − * config. The accessTokenSecretId does NOT change — the value behind | |
| 152 | − * it is overwritten in place. | |
| 153 | − */ | |
| 154 | − readonly persistAuth: ( | |
| 155 | − snapshot: RefreshedAuthSnapshot, | |
| 156 | − ) => Effect.Effect<void, unknown>; | |
| 157 | −}; | |
| 158 | − | |
| 159 | −/** | |
| 160 | − * Resolve the current access token for a source — if it's within the | |
| 161 | − * refresh-skew window, first exchange the refresh token for a new access | |
| 162 | − * token, persist it, and write the updated expiry back via `persistAuth`. | |
| 163 | − * | |
| 164 | − * Returns the access-token string to be used for the upcoming request. | |
| 165 | − */ | |
| 166 | −export const withRefreshedAccessToken = ( | |
| 167 | − input: WithRefreshedAccessTokenInput, | |
| 168 | −): Effect.Effect<string, OAuth2Error> => | |
| 169 | − Effect.gen(function* () { | |
| 170 | − const { auth, secrets } = input; | |
| 171 | − const needsRefresh = | |
| 172 | − auth.refreshTokenSecretId !== null && shouldRefreshToken({ expiresAt: auth.expiresAt }); | |
| 173 | − | |
| 174 | − if (!needsRefresh) { | |
| 175 | − return yield* secrets | |
| 176 | − .resolve(auth.accessTokenSecretId) | |
| 177 | − .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth access token"))); | |
| 178 | − } | |
| 179 | − | |
| 180 | − // Proactive refresh path. | |
| 181 | − const refreshToken = yield* secrets | |
| 182 | − .resolve(auth.refreshTokenSecretId!) | |
| 183 | − .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth refresh token"))); | |
| 184 | − | |
| 185 | − const clientId = yield* secrets | |
| 186 | − .resolve(auth.clientIdSecretId) | |
| 187 | − .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth client ID"))); | |
| 188 | − | |
| 189 | − const clientSecret = | |
| 190 | − auth.clientSecretSecretId === null | |
| 191 | − ? null | |
| 192 | − : yield* secrets | |
| 193 | − .resolve(auth.clientSecretSecretId) | |
| 194 | − .pipe(Effect.mapError(wrapSecretError("Failed to resolve OAuth client secret"))); | |
| 195 | − | |
| 196 | − const refreshed = yield* refreshAccessToken({ | |
| 197 | − tokenUrl: input.tokenUrl, | |
| 198 | − clientId, | |
| 199 | − clientSecret, | |
| 200 | − refreshToken, | |
| 201 | − scopes: auth.scopes, | |
| 202 | − clientAuth: input.clientAuth, | |
| 203 | − timeoutMs: input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS, | |
| 204 | − }); | |
| 205 | − | |
| 206 | − yield* secrets | |
| 207 | − .setValue({ | |
| 208 | − secretId: auth.accessTokenSecretId, | |
| 209 | − value: refreshed.access_token, | |
| 210 | − name: `${input.displayName} Access Token`, | |
| 211 | − purpose: input.accessTokenPurpose, | |
| 212 | − }) | |
| 213 | − .pipe(Effect.mapError(wrapSecretError("Failed to persist refreshed access token"))); | |
| 214 | − | |
| 215 | − if (refreshed.refresh_token && auth.refreshTokenSecretId) { | |
| 216 | − yield* secrets | |
| 217 | − .setValue({ | |
| 218 | − secretId: auth.refreshTokenSecretId, | |
| 219 | − value: refreshed.refresh_token, | |
| 220 | − name: `${input.displayName} Refresh Token`, | |
| 221 | − purpose: input.refreshTokenPurpose, | |
| 222 | − }) | |
| 223 | − .pipe(Effect.mapError(wrapSecretError("Failed to persist rotated refresh token"))); | |
| 224 | − } | |
| 225 | − | |
| 226 | − yield* input | |
| 227 | − .persistAuth({ | |
| 228 | − tokenType: refreshed.token_type ?? auth.tokenType, | |
| 229 | − expiresAt: | |
| 230 | − typeof refreshed.expires_in === "number" | |
| 231 | − ? Date.now() + refreshed.expires_in * 1000 | |
| 232 | − : auth.expiresAt, | |
| 233 | − scope: refreshed.scope ?? null, | |
| 234 | − }) | |
| 235 | − .pipe(Effect.mapError(wrapSecretError("Failed to persist updated OAuth auth"))); | |
| 236 | − | |
| 237 | − return refreshed.access_token; | |
| 238 | − }); | |
| @@ -40,6 +40,9 @@ const UpdateSourcePayload = Schema.Struct({ · 39 unmodified lines | ||
| 40 | 40 | name: Schema.optional(Schema.String), |
| 41 | 41 | baseUrl: Schema.optional(Schema.String), |
| 42 | 42 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), |
| 43 | + // Set after a successful re-authenticate to rewrite the source's | |
| 44 | + // OAuth2Auth pointer to the freshly minted connection. | |
| 45 | + oauth2: Schema.optional(OAuth2Auth), | |
| 43 | 46 | }); |
| 44 | 47 | |
| 45 | 48 | const UpdateSourceResponse = Schema.Struct({ |
| @@ -60,10 +63,9 @@ const AddSpecResponse = Schema.Struct({ · 14 unmodified lines | ||
| 60 | 63 | // --------------------------------------------------------------------------- |
| 61 | 64 | |
| 62 | 65 | // Shared identity fields for both OAuth2 flows. `tokenScope` names which |
| 63 | −// executor scope will own the minted tokens (typically the per-user scope). | |
| 64 | −// The token secret ids are pre-decided so the source's stored `OAuth2Auth` | |
| 65 | −// can reference the same ids across every user — per-user values shadow | |
| 66 | −// org-level fallbacks via secret fall-through on read. | |
| 66 | +// executor scope will own the resulting Connection (typically the per-user | |
| 67 | +// scope). Token secret ids are minted by the SDK from the Connection id — | |
| 68 | +// callers never pick them. | |
| 67 | 69 | const StartOAuthIdentityFields = { |
| 68 | 70 | displayName: Schema.String, |
| 69 | 71 | securitySchemeName: Schema.String, |
| @@ -71,7 +73,6 @@ const StartOAuthIdentityFields = { · 1 unmodified line | ||
| 71 | 73 | clientIdSecretId: Schema.String, |
| 72 | 74 | scopes: Schema.Array(Schema.String), |
| 73 | 75 | tokenScope: Schema.optional(ScopeId), |
| 74 | − accessTokenSecretId: Schema.String, | |
| 75 | 76 | } as const; |
| 76 | 77 | |
| 77 | 78 | const StartOAuthPayload = Schema.Union( |
| @@ -81,7 +82,6 @@ const StartOAuthPayload = Schema.Union( · 3 unmodified lines | ||
| 81 | 82 | authorizationUrl: Schema.String, |
| 82 | 83 | redirectUrl: Schema.String, |
| 83 | 84 | clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), |
| 84 | − refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 85 | 85 | }), |
| 86 | 86 | // RFC 6749 §4.4 — no user-interactive step, no session, no popup. The |
| 87 | 87 | // plugin exchanges tokens inline and returns a completed auth. The |
| @@ -91,6 +91,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 90 unmodified lines | ||
| 91 | 91 | name: payload.name, |
| 92 | 92 | baseUrl: payload.baseUrl, |
| 93 | 93 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 94 | + oauth2: payload.oauth2, | |
| 94 | 95 | } as OpenApiUpdateSourceInput); |
| 95 | 96 | return { updated: true }; |
| 96 | 97 | })), |
| @@ -112,7 +113,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 15 unmodified lines | ||
| 112 | 113 | clientSecretSecretId: payload.clientSecretSecretId, |
| 113 | 114 | scopes: [...payload.scopes], |
| 114 | 115 | tokenScope, |
| 115 | − accessTokenSecretId: payload.accessTokenSecretId, | |
| 116 | 116 | }); |
| 117 | 117 | } |
| 118 | 118 | return yield* ext.startOAuth({ |
| @@ -126,8 +126,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines | ||
| 126 | 126 | clientSecretSecretId: payload.clientSecretSecretId ?? null, |
| 127 | 127 | scopes: [...payload.scopes], |
| 128 | 128 | tokenScope, |
| 129 | − accessTokenSecretId: payload.accessTokenSecretId, | |
| 130 | − refreshTokenSecretId: payload.refreshTokenSecretId ?? null, | |
| 131 | 129 | }); |
| 132 | 130 | })), |
| 133 | 131 | ) |
| @@ -61,26 +61,6 @@ export const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result"; · 60 unmodified lines | ||
| 61 | 61 | export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; |
| 62 | 62 | export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/openapi/oauth/callback"; |
| 63 | 63 | |
| 64 | −// Stable secret ids for the access/refresh tokens a given OAuth2 security | |
| 65 | −// scheme on a given source mints. The same ids must be passed into | |
| 66 | −// `startOAuth` AND stored on the source's `OAuth2Auth`, so at invoke time | |
| 67 | −// the per-user scope (which actually holds the token value) resolves via | |
| 68 | −// shadowing over the source-level reference. Keyed by source namespace + | |
| 69 | −// scheme name so a single spec can host multiple schemes without | |
| 70 | −// colliding, and so reconnecting OAuth overwrites the previous token | |
| 71 | −// rather than leaking orphaned secret rows. | |
| 72 | −const oauthTokenSecretIds = ( | |
| 73 | − namespaceSlug: string, | |
| 74 | − securitySchemeName: string, | |
| 75 | −): { readonly accessTokenSecretId: string; readonly refreshTokenSecretId: string } => { | |
| 76 | − const slugScheme = securitySchemeName.replace(/[^a-zA-Z0-9_-]/g, "_"); | |
| 77 | − const prefix = `openapi_${namespaceSlug}_${slugScheme}`; | |
| 78 | − return { | |
| 79 | − accessTokenSecretId: `${prefix}_access_token`, | |
| 80 | − refreshTokenSecretId: `${prefix}_refresh_token`, | |
| 81 | − }; | |
| 82 | −}; | |
| 83 | − | |
| 84 | 64 | const substituteUrlVariables = (url: string, values: Record<string, string>): string => { |
| 85 | 65 | let out = url; |
| 86 | 66 | for (const [name, value] of Object.entries(values)) { |
| @@ -402,15 +382,6 @@ export default function AddOpenApiSource(props: { · 315 unmodified lines | ||
| 402 | 382 | const displayName = |
| 403 | 383 | identity.name.trim() || selectedOAuth2Preset.securitySchemeName; |
| 404 | 384 | |
| 405 | − const namespaceSlug = | |
| 406 | − slugifyNamespace(identity.namespace) || | |
| 407 | − slugifyNamespace(Option.getOrElse(preview.title, () => "")) || | |
| 408 | − "openapi"; | |
| 409 | − const tokenIds = oauthTokenSecretIds( | |
| 410 | − namespaceSlug, | |
| 411 | − selectedOAuth2Preset.securitySchemeName, | |
| 412 | − ); | |
| 413 | − | |
| 414 | 385 | const tokenUrl = resolveOAuthUrl( |
| 415 | 386 | selectedOAuth2Preset.tokenUrl, |
| 416 | 387 | resolvedBaseUrl, |
| @@ -435,7 +406,6 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines | ||
| 435 | 406 | clientIdSecretId: oauth2ClientIdSecretId, |
| 436 | 407 | clientSecretSecretId: oauth2ClientSecretSecretId, |
| 437 | 408 | scopes: [...oauth2SelectedScopes], |
| 438 | − accessTokenSecretId: tokenIds.accessTokenSecretId, | |
| 439 | 409 | }, |
| 440 | 410 | }); |
| 441 | 411 | setStartingOAuth(false); |
| @@ -465,8 +435,6 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines | ||
| 465 | 435 | clientIdSecretId: oauth2ClientIdSecretId, |
| 466 | 436 | clientSecretSecretId: oauth2ClientSecretSecretId, |
| 467 | 437 | scopes: [...oauth2SelectedScopes], |
| 468 | − accessTokenSecretId: tokenIds.accessTokenSecretId, | |
| 469 | − refreshTokenSecretId: tokenIds.refreshTokenSecretId, | |
| 470 | 438 | }, |
| 471 | 439 | }); |
| 472 | 440 | |
| @@ -484,23 +452,17 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines | ||
| 484 | 452 | oauthCleanup.current = null; |
| 485 | 453 | setStartingOAuth(false); |
| 486 | 454 | if (result.ok) { |
| 487 | − // The popup payload is an OAuthPopupResult wrapping the | |
| 488 | − // backend's OAuth2Auth descriptor; strip the envelope and | |
| 489 | − // reconstruct the OAuth2Auth class instance for type safety. | |
| 490 | 455 | setOauth2Auth( |
| 491 | 456 | new OAuth2Auth({ |
| 492 | 457 | kind: "oauth2", |
| 458 | + connectionId: result.connectionId, | |
| 493 | 459 | securitySchemeName: result.securitySchemeName, |
| 494 | 460 | flow: result.flow, |
| 495 | 461 | tokenUrl: result.tokenUrl, |
| 462 | + authorizationUrl: result.authorizationUrl, | |
| 496 | 463 | clientIdSecretId: result.clientIdSecretId, |
| 497 | 464 | clientSecretSecretId: result.clientSecretSecretId, |
| 498 | − accessTokenSecretId: result.accessTokenSecretId, | |
| 499 | − refreshTokenSecretId: result.refreshTokenSecretId, | |
| 500 | − tokenType: result.tokenType, | |
| 501 | − expiresAt: result.expiresAt, | |
| 502 | − scope: result.scope, | |
| 503 | − scopes: [...result.scopes], | |
| 465 | + scopes: result.scopes, | |
| 504 | 466 | }), |
| 505 | 467 | ); |
| 506 | 468 | setOauth2Error(null); |
| @@ -535,7 +497,6 @@ export default function AddOpenApiSource(props: { · 28 unmodified lines | ||
| 535 | 497 | doStartOAuth, |
| 536 | 498 | scopeId, |
| 537 | 499 | identity.name, |
| 538 | − identity.namespace, | |
| 539 | 500 | ]); |
| 540 | 501 | |
| 541 | 502 | const handleCancelOAuth2 = useCallback(() => { |
| @@ -974,8 +935,8 @@ export default function AddOpenApiSource(props: { · 432 unmodified lines | ||
| 974 | 935 | {oauth2Auth ? ( |
| 975 | 936 | <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2"> |
| 976 | 937 | <div className="text-[11px] text-green-700 dark:text-green-400"> |
| 977 | − Connected · {oauth2Auth.scopes.length} scope | |
| 978 | − {oauth2Auth.scopes.length === 1 ? "" : "s"} granted | |
| 938 | + Connected · {oauth2SelectedScopes.size} scope | |
| 939 | + {oauth2SelectedScopes.size === 1 ? "" : "s"} granted | |
| 979 | 940 | </div> |
| 980 | 941 | <Button |
| 981 | 942 | variant="ghost" |
| @@ -1,30 +1,16 @@ | ||
| 1 | −import { useCallback, useEffect, useRef, useState } from "react"; | |
| 1 | +import { useState } from "react"; | |
| 2 | 2 | import { |
| 3 | 3 | useAtomValue, |
| 4 | 4 | useAtomSet, |
| 5 | − useAtomRefresh, | |
| 6 | 5 | Result, |
| 7 | 6 | } from "@effect-atom/atom-react"; |
| 8 | −import { Option } from "effect"; | |
| 9 | − | |
| 10 | −import { SecretId } from "@executor/sdk"; | |
| 11 | −import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react"; | |
| 12 | 7 | |
| 13 | 8 | import { |
| 14 | 9 | openApiSourceAtom, |
| 15 | − previewOpenApiSpec, | |
| 16 | − startOpenApiOAuth, | |
| 17 | 10 | updateOpenApiSource, |
| 18 | 11 | } from "./atoms"; |
| 19 | 12 | import { useScope } from "@executor/react/api/scope-context"; |
| 20 | −import { | |
| 21 | − secretStatusAtom, | |
| 22 | − removeSecret, | |
| 23 | −} from "@executor/react/api/atoms"; | |
| 24 | −import { | |
| 25 | − secretWriteKeys, | |
| 26 | − sourceWriteKeys, | |
| 27 | −} from "@executor/react/api/reactivity-keys"; | |
| 13 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 28 | 14 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 29 | 15 | import { |
| 30 | 16 | headerValueToState, |
| @@ -45,357 +31,12 @@ import { · 14 unmodified lines | ||
| 45 | 31 | import { FieldLabel } from "@executor/react/components/field"; |
| 46 | 32 | import { Input } from "@executor/react/components/input"; |
| 47 | 33 | import { Badge } from "@executor/react/components/badge"; |
| 48 | −import { Spinner } from "@executor/react/components/spinner"; | |
| 49 | −import { | |
| 50 | − OPENAPI_OAUTH_CALLBACK_PATH, | |
| 51 | − OPENAPI_OAUTH_CHANNEL, | |
| 52 | − OPENAPI_OAUTH_POPUP_NAME, | |
| 53 | − resolveOAuthUrl, | |
| 54 | −} from "./AddOpenApiSource"; | |
| 55 | −import type { SpecPreview, OAuth2Preset } from "../sdk/preview"; | |
| 56 | −import { OAuth2Auth } from "../sdk/types"; | |
| 57 | 34 | import type { StoredSourceSchemaType } from "../sdk/store"; |
| 58 | 35 | |
| 59 | 36 | // --------------------------------------------------------------------------- |
| 60 | −// Connections — one row per OAuth2Auth on the source | |
| 61 | −// --------------------------------------------------------------------------- | |
| 62 | − | |
| 63 | −/** | |
| 64 | − * Single OAuth2Auth connection row. Each org member sees the status of | |
| 65 | − * their own tokens (access/refresh secrets resolve via per-user scope | |
| 66 | − * fall-through) and can trigger the same OAuth flow another member used | |
| 67 | − * when first onboarding the source. | |
| 68 | − */ | |
| 69 | −function ConnectionRow(props: { | |
| 70 | − readonly auth: OAuth2Auth; | |
| 71 | − readonly sourceName: string; | |
| 72 | − readonly sourceBaseUrl: string; | |
| 73 | − readonly preset: OAuth2Preset | null; | |
| 74 | − readonly previewError: string | null; | |
| 75 | −}) { | |
| 76 | − const scopeId = useScope(); | |
| 77 | − const { auth, preset } = props; | |
| 78 | − | |
| 79 | − const statusAtom = secretStatusAtom( | |
| 80 | − scopeId, | |
| 81 | − SecretId.make(auth.accessTokenSecretId), | |
| 82 | − ); | |
| 83 | − const accessStatus = useAtomValue(statusAtom); | |
| 84 | − const refreshStatus = useAtomRefresh(statusAtom); | |
| 85 | − const isConnected = | |
| 86 | − Result.isSuccess(accessStatus) && accessStatus.value.status === "resolved"; | |
| 87 | − | |
| 88 | − const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" }); | |
| 89 | − const doRemoveSecret = useAtomSet(removeSecret, { mode: "promise" }); | |
| 90 | − | |
| 91 | − const [busy, setBusy] = useState(false); | |
| 92 | − const [error, setError] = useState<string | null>(null); | |
| 93 | − const cleanupRef = useRef<(() => void) | null>(null); | |
| 94 | − | |
| 95 | − useEffect(() => () => cleanupRef.current?.(), []); | |
| 96 | − | |
| 97 | − const redirectUrl = | |
| 98 | − typeof window !== "undefined" | |
| 99 | − ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}` | |
| 100 | − : OPENAPI_OAUTH_CALLBACK_PATH; | |
| 101 | − | |
| 102 | − const handleConnect = useCallback(async () => { | |
| 103 | − if (!preset) return; | |
| 104 | − cleanupRef.current?.(); | |
| 105 | − cleanupRef.current = null; | |
| 106 | − setBusy(true); | |
| 107 | − setError(null); | |
| 108 | − try { | |
| 109 | − const tokenUrl = resolveOAuthUrl(auth.tokenUrl, props.sourceBaseUrl); | |
| 110 | − | |
| 111 | − if (auth.flow === "clientCredentials") { | |
| 112 | − // No popup, no session — the backend exchanges tokens inline. | |
| 113 | − if (!auth.clientSecretSecretId) { | |
| 114 | − setBusy(false); | |
| 115 | − setError("client_credentials requires a client secret"); | |
| 116 | − return; | |
| 117 | − } | |
| 118 | − const response = await doStartOAuth({ | |
| 119 | − path: { scopeId }, | |
| 120 | − payload: { | |
| 121 | − displayName: props.sourceName || auth.securitySchemeName, | |
| 122 | − securitySchemeName: auth.securitySchemeName, | |
| 123 | − flow: "clientCredentials", | |
| 124 | − tokenUrl, | |
| 125 | − clientIdSecretId: auth.clientIdSecretId, | |
| 126 | − clientSecretSecretId: auth.clientSecretSecretId, | |
| 127 | − scopes: [...auth.scopes], | |
| 128 | − accessTokenSecretId: auth.accessTokenSecretId, | |
| 129 | − }, | |
| 130 | − }); | |
| 131 | − setBusy(false); | |
| 132 | − if (response.flow !== "clientCredentials") { | |
| 133 | − setError("Unexpected response flow from server"); | |
| 134 | − return; | |
| 135 | − } | |
| 136 | − setError(null); | |
| 137 | − refreshStatus(); | |
| 138 | − return; | |
| 139 | − } | |
| 140 | − | |
| 141 | − const authorizationUrl = resolveOAuthUrl( | |
| 142 | − Option.getOrElse(preset.authorizationUrl, () => ""), | |
| 143 | − props.sourceBaseUrl, | |
| 144 | − ); | |
| 145 | − | |
| 146 | − const response = await doStartOAuth({ | |
| 147 | − path: { scopeId }, | |
| 148 | − payload: { | |
| 149 | − displayName: props.sourceName || auth.securitySchemeName, | |
| 150 | − securitySchemeName: auth.securitySchemeName, | |
| 151 | − flow: "authorizationCode", | |
| 152 | − authorizationUrl, | |
| 153 | − tokenUrl, | |
| 154 | − redirectUrl, | |
| 155 | − clientIdSecretId: auth.clientIdSecretId, | |
| 156 | − clientSecretSecretId: auth.clientSecretSecretId, | |
| 157 | − // Reuse the source-wide scopes granted at onboarding time — | |
| 158 | − // per-member connects should land the same capabilities. | |
| 159 | − scopes: [...auth.scopes], | |
| 160 | − // Reuse the same secret ids the source already references. | |
| 161 | − // On completeOAuth the plugin writes tokens at the innermost | |
| 162 | − // (per-user) scope by default; same id + inner scope shadows | |
| 163 | − // any org-level fallback so each member's bearer is theirs. | |
| 164 | − accessTokenSecretId: auth.accessTokenSecretId, | |
| 165 | − refreshTokenSecretId: auth.refreshTokenSecretId, | |
| 166 | − }, | |
| 167 | − }); | |
| 168 | − | |
| 169 | − if (response.flow !== "authorizationCode") { | |
| 170 | − setBusy(false); | |
| 171 | − setError("Unexpected response flow from server"); | |
| 172 | − return; | |
| 173 | − } | |
| 174 | − | |
| 175 | − cleanupRef.current = openOAuthPopup<OAuth2Auth>({ | |
| 176 | − url: response.authorizationUrl, | |
| 177 | − popupName: OPENAPI_OAUTH_POPUP_NAME, | |
| 178 | − channelName: OPENAPI_OAUTH_CHANNEL, | |
| 179 | − onResult: (result: OAuthPopupResult<OAuth2Auth>) => { | |
| 180 | − cleanupRef.current = null; | |
| 181 | − setBusy(false); | |
| 182 | − if (result.ok) { | |
| 183 | − setError(null); | |
| 184 | − // completeOAuth ran server-side (inside the popup's callback | |
| 185 | − // HTML handler) and wrote the access/refresh tokens via | |
| 186 | − // ctx.secrets.set — that bypasses our atom-layer mutation, | |
| 187 | − // so we refresh the status atom here to flip the badge. | |
| 188 | − refreshStatus(); | |
| 189 | − } else { | |
| 190 | − setError(result.error); | |
| 191 | − } | |
| 192 | − }, | |
| 193 | − onClosed: () => { | |
| 194 | − cleanupRef.current = null; | |
| 195 | − setBusy(false); | |
| 196 | − setError("OAuth cancelled — popup was closed before completing the flow."); | |
| 197 | − }, | |
| 198 | − onOpenFailed: () => { | |
| 199 | − cleanupRef.current = null; | |
| 200 | − setBusy(false); | |
| 201 | − setError("OAuth popup was blocked by the browser"); | |
| 202 | − }, | |
| 203 | − }); | |
| 204 | − } catch (e) { | |
| 205 | − setBusy(false); | |
| 206 | − setError(e instanceof Error ? e.message : "Failed to start OAuth"); | |
| 207 | − } | |
| 208 | − }, [ | |
| 209 | − preset, | |
| 210 | − auth.flow, | |
| 211 | − auth.securitySchemeName, | |
| 212 | − auth.tokenUrl, | |
| 213 | − auth.clientIdSecretId, | |
| 214 | − auth.clientSecretSecretId, | |
| 215 | − auth.accessTokenSecretId, | |
| 216 | − auth.refreshTokenSecretId, | |
| 217 | − auth.scopes, | |
| 218 | − doStartOAuth, | |
| 219 | − redirectUrl, | |
| 220 | − scopeId, | |
| 221 | − props.sourceName, | |
| 222 | − props.sourceBaseUrl, | |
| 223 | − refreshStatus, | |
| 224 | − ]); | |
| 225 | − | |
| 226 | − const handleDisconnect = useCallback(async () => { | |
| 227 | − setBusy(true); | |
| 228 | − setError(null); | |
| 229 | − try { | |
| 230 | − await doRemoveSecret({ | |
| 231 | − path: { | |
| 232 | − scopeId, | |
| 233 | − secretId: SecretId.make(auth.accessTokenSecretId), | |
| 234 | − }, | |
| 235 | − reactivityKeys: secretWriteKeys, | |
| 236 | − }); | |
| 237 | − if (auth.refreshTokenSecretId) { | |
| 238 | − await doRemoveSecret({ | |
| 239 | − path: { | |
| 240 | − scopeId, | |
| 241 | − secretId: SecretId.make(auth.refreshTokenSecretId), | |
| 242 | − }, | |
| 243 | − reactivityKeys: secretWriteKeys, | |
| 244 | − }).catch(() => { | |
| 245 | − // The refresh secret may not exist for this user — ignore. | |
| 246 | − }); | |
| 247 | − } | |
| 248 | − } catch (e) { | |
| 249 | − setError(e instanceof Error ? e.message : "Failed to disconnect"); | |
| 250 | − } finally { | |
| 251 | − setBusy(false); | |
| 252 | − } | |
| 253 | − }, [ | |
| 254 | − doRemoveSecret, | |
| 255 | − scopeId, | |
| 256 | − auth.accessTokenSecretId, | |
| 257 | − auth.refreshTokenSecretId, | |
| 258 | − ]); | |
| 259 | − | |
| 260 | − const canConnect = preset !== null && Option.isSome(preset.authorizationUrl); | |
| 261 | − | |
| 262 | − return ( | |
| 263 | − <div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-background/40 p-3"> | |
| 264 | − <div className="flex items-center justify-between gap-3"> | |
| 265 | − <div className="min-w-0 flex-1"> | |
| 266 | − <div className="text-xs font-medium text-foreground"> | |
| 267 | − {auth.securitySchemeName} | |
| 268 | − </div> | |
| 269 | − <div className="mt-0.5 text-[10px] text-muted-foreground"> | |
| 270 | − {auth.scopes.length} scope{auth.scopes.length === 1 ? "" : "s"} | |
| 271 | − </div> | |
| 272 | − </div> | |
| 273 | − {isConnected ? ( | |
| 274 | − <Badge | |
| 275 | − variant="outline" | |
| 276 | − className="border-green-500/30 bg-green-500/5 text-green-700 dark:text-green-400" | |
| 277 | − > | |
| 278 | − Connected | |
| 279 | − </Badge> | |
| 280 | − ) : ( | |
| 281 | − <Badge variant="outline" className="text-muted-foreground"> | |
| 282 | − Not connected | |
| 283 | − </Badge> | |
| 284 | − )} | |
| 285 | − </div> | |
| 286 | − | |
| 287 | − <div className="flex items-center justify-end gap-2"> | |
| 288 | − {isConnected ? ( | |
| 289 | − <Button | |
| 290 | − variant="ghost" | |
| 291 | − size="sm" | |
| 292 | − onClick={handleDisconnect} | |
| 293 | − disabled={busy} | |
| 294 | − > | |
| 295 | − {busy && <Spinner className="size-3.5" />} | |
| 296 | − Disconnect | |
| 297 | − </Button> | |
| 298 | − ) : ( | |
| 299 | − <Button | |
| 300 | − variant="secondary" | |
| 301 | − size="sm" | |
| 302 | − onClick={handleConnect} | |
| 303 | − disabled={busy || !canConnect} | |
| 304 | − > | |
| 305 | − {busy && <Spinner className="size-3.5" />} | |
| 306 | − Connect | |
| 307 | − </Button> | |
| 308 | − )} | |
| 309 | − </div> | |
| 310 | − | |
| 311 | − {!canConnect && props.previewError && ( | |
| 312 | − <p className="text-[11px] text-amber-600 dark:text-amber-400"> | |
| 313 | − {props.previewError} | |
| 314 | − </p> | |
| 315 | − )} | |
| 316 | − {!canConnect && !props.previewError && preset === null && ( | |
| 317 | − <p className="text-[11px] text-muted-foreground"> | |
| 318 | − Loading OAuth configuration… | |
| 319 | − </p> | |
| 320 | − )} | |
| 321 | − {error && ( | |
| 322 | − <div className="rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-1.5"> | |
| 323 | − <p className="text-[11px] text-destructive">{error}</p> | |
| 324 | − </div> | |
| 325 | − )} | |
| 326 | − </div> | |
| 327 | − ); | |
| 328 | −} | |
| 329 | − | |
| 330 | −function ConnectionsSection(props: { | |
| 331 | − readonly sourceName: string; | |
| 332 | − readonly sourceBaseUrl: string; | |
| 333 | − readonly spec: string; | |
| 334 | − readonly oauth2Entries: readonly OAuth2Auth[]; | |
| 335 | −}) { | |
| 336 | − const scopeId = useScope(); | |
| 337 | − const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); | |
| 338 | − const [preview, setPreview] = useState<SpecPreview | null>(null); | |
| 339 | − const [previewError, setPreviewError] = useState<string | null>(null); | |
| 340 | − | |
| 341 | − useEffect(() => { | |
| 342 | − let cancelled = false; | |
| 343 | − doPreview({ path: { scopeId }, payload: { spec: props.spec } }) | |
| 344 | − .then((result) => { | |
| 345 | − if (!cancelled) setPreview(result); | |
| 346 | − }) | |
| 347 | − .catch((e: unknown) => { | |
| 348 | − if (!cancelled) { | |
| 349 | − setPreviewError( | |
| 350 | − e instanceof Error | |
| 351 | − ? `Couldn't load OAuth config from spec: ${e.message}` | |
| 352 | − : "Couldn't load OAuth config from spec", | |
| 353 | − ); | |
| 354 | − } | |
| 355 | − }); | |
| 356 | − return () => { | |
| 357 | − cancelled = true; | |
| 358 | − }; | |
| 359 | − }, [doPreview, scopeId, props.spec]); | |
| 360 | − | |
| 361 | − const presetFor = (securitySchemeName: string): OAuth2Preset | null => { | |
| 362 | − if (!preview) return null; | |
| 363 | − return ( | |
| 364 | − preview.oauth2Presets.find( | |
| 365 | − (p) => | |
| 366 | − p.securitySchemeName === securitySchemeName && | |
| 367 | − p.flow === "authorizationCode", | |
| 368 | − ) ?? null | |
| 369 | − ); | |
| 370 | − }; | |
| 371 | − | |
| 372 | − return ( | |
| 373 | − <section className="space-y-2.5"> | |
| 374 | − <div> | |
| 375 | − <FieldLabel>Connections</FieldLabel> | |
| 376 | − <p className="mt-1 text-[11px] text-muted-foreground"> | |
| 377 | − Each member of this organization connects with their own OAuth | |
| 378 | − credentials. | |
| 379 | − </p> | |
| 380 | − </div> | |
| 381 | − <div className="space-y-2"> | |
| 382 | − {props.oauth2Entries.map((auth) => ( | |
| 383 | − <ConnectionRow | |
| 384 | − key={auth.securitySchemeName} | |
| 385 | − auth={auth} | |
| 386 | − sourceName={props.sourceName} | |
| 387 | − sourceBaseUrl={props.sourceBaseUrl} | |
| 388 | − preset={presetFor(auth.securitySchemeName)} | |
| 389 | − previewError={previewError} | |
| 390 | − /> | |
| 391 | − ))} | |
| 392 | − </div> | |
| 393 | − </section> | |
| 394 | − ); | |
| 395 | −} | |
| 396 | − | |
| 397 | −// --------------------------------------------------------------------------- | |
| 398 | −// Edit form | |
| 37 | +// Edit form — source config only. Sign-in / reconnect lives on the source | |
| 38 | +// detail top bar (see source-plugin.signIn) and connection lifecycle lives | |
| 39 | +// on the Connections page. | |
| 399 | 40 | // --------------------------------------------------------------------------- |
| 400 | 41 | |
| 401 | 42 | function EditForm(props: { |
| @@ -423,13 +64,6 @@ function EditForm(props: { · 21 unmodified lines | ||
| 423 | 64 | |
| 424 | 65 | const identityDirty = identity.name.trim() !== props.initial.name.trim(); |
| 425 | 66 | |
| 426 | − // A source may have zero or one stored OAuth2Auth today, but the | |
| 427 | − // "Connections" section is written to iterate so future multi-scheme | |
| 428 | − // specs surface without another pass. | |
| 429 | − const oauth2Entries: readonly OAuth2Auth[] = props.initial.config.oauth2 | |
| 430 | − ? [props.initial.config.oauth2] | |
| 431 | − : []; | |
| 432 | − | |
| 433 | 67 | const handleHeadersChange = (next: HeaderState[]) => { |
| 434 | 68 | setHeaders(next); |
| 435 | 69 | setDirty(true); |
| @@ -503,15 +137,6 @@ function EditForm(props: { · 67 unmodified lines | ||
| 503 | 137 | /> |
| 504 | 138 | </section> |
| 505 | 139 | |
| 506 | − {oauth2Entries.length > 0 && ( | |
| 507 | − <ConnectionsSection | |
| 508 | − sourceName={props.initial.name} | |
| 509 | − sourceBaseUrl={baseUrl.trim() || props.initial.config.baseUrl || ""} | |
| 510 | − spec={props.initial.config.spec} | |
| 511 | − oauth2Entries={oauth2Entries} | |
| 512 | − /> | |
| 513 | − )} | |
| 514 | − | |
| 515 | 140 | {error && ( |
| 516 | 141 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> |
| 517 | 142 | <p className="text-sm text-destructive">{error}</p> |
| @@ -0,0 +1,213 @@ | ||
| 1 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 2 | +import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; | |
| 3 | + | |
| 4 | +import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react"; | |
| 5 | +import { useScope } from "@executor/react/api/scope-context"; | |
| 6 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 7 | +import { connectionsAtom } from "@executor/react/api/atoms"; | |
| 8 | +import { Button } from "@executor/react/components/button"; | |
| 9 | + | |
| 10 | +import { | |
| 11 | + openApiSourceAtom, | |
| 12 | + startOpenApiOAuth, | |
| 13 | + updateOpenApiSource, | |
| 14 | +} from "./atoms"; | |
| 15 | +import { | |
| 16 | + OPENAPI_OAUTH_CALLBACK_PATH, | |
| 17 | + OPENAPI_OAUTH_CHANNEL, | |
| 18 | + OPENAPI_OAUTH_POPUP_NAME, | |
| 19 | +} from "./AddOpenApiSource"; | |
| 20 | +import { OAuth2Auth } from "../sdk/types"; | |
| 21 | + | |
| 22 | +// --------------------------------------------------------------------------- | |
| 23 | +// OpenApiSignInButton — top-bar action on the source detail page | |
| 24 | +// | |
| 25 | +// Reads the source's stored OAuth2Auth, runs the same OAuth flow as Add | |
| 26 | +// (authorizationCode via popup, clientCredentials inline), and on success | |
| 27 | +// rewrites the source's OAuth2Auth pointer to the freshly minted | |
| 28 | +// connection id. Works whether or not the previous connection still | |
| 29 | +// exists — source-owned OAuth config is the source of truth. | |
| 30 | +// --------------------------------------------------------------------------- | |
| 31 | + | |
| 32 | +export default function OpenApiSignInButton(props: { sourceId: string }) { | |
| 33 | + const scopeId = useScope(); | |
| 34 | + const sourceResult = useAtomValue(openApiSourceAtom(scopeId, props.sourceId)); | |
| 35 | + const connectionsResult = useAtomValue(connectionsAtom(scopeId)); | |
| 36 | + const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" }); | |
| 37 | + const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" }); | |
| 38 | + | |
| 39 | + const [busy, setBusy] = useState(false); | |
| 40 | + const [error, setError] = useState<string | null>(null); | |
| 41 | + const cleanupRef = useRef<(() => void) | null>(null); | |
| 42 | + | |
| 43 | + useEffect(() => () => cleanupRef.current?.(), []); | |
| 44 | + | |
| 45 | + const source = | |
| 46 | + Result.isSuccess(sourceResult) && sourceResult.value | |
| 47 | + ? sourceResult.value | |
| 48 | + : null; | |
| 49 | + const oauth2 = source?.config.oauth2 ?? null; | |
| 50 | + const connections = Result.isSuccess(connectionsResult) | |
| 51 | + ? connectionsResult.value | |
| 52 | + : null; | |
| 53 | + const isConnected = | |
| 54 | + oauth2 !== null && | |
| 55 | + connections !== null && | |
| 56 | + connections.some((c) => c.id === oauth2.connectionId); | |
| 57 | + | |
| 58 | + const redirectUrl = | |
| 59 | + typeof window !== "undefined" | |
| 60 | + ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}` | |
| 61 | + : OPENAPI_OAUTH_CALLBACK_PATH; | |
| 62 | + | |
| 63 | + const handleSignIn = useCallback(async () => { | |
| 64 | + if (!oauth2) return; | |
| 65 | + cleanupRef.current?.(); | |
| 66 | + cleanupRef.current = null; | |
| 67 | + setBusy(true); | |
| 68 | + setError(null); | |
| 69 | + try { | |
| 70 | + const displayName = source?.name ?? oauth2.securitySchemeName; | |
| 71 | + | |
| 72 | + if (oauth2.flow === "clientCredentials") { | |
| 73 | + if (!oauth2.clientSecretSecretId) { | |
| 74 | + setBusy(false); | |
| 75 | + setError("client_credentials requires a client secret"); | |
| 76 | + return; | |
| 77 | + } | |
| 78 | + const response = await doStartOAuth({ | |
| 79 | + path: { scopeId }, | |
| 80 | + payload: { | |
| 81 | + displayName, | |
| 82 | + securitySchemeName: oauth2.securitySchemeName, | |
| 83 | + flow: "clientCredentials", | |
| 84 | + tokenUrl: oauth2.tokenUrl, | |
| 85 | + clientIdSecretId: oauth2.clientIdSecretId, | |
| 86 | + clientSecretSecretId: oauth2.clientSecretSecretId, | |
| 87 | + scopes: [...oauth2.scopes], | |
| 88 | + }, | |
| 89 | + }); | |
| 90 | + if (response.flow !== "clientCredentials") { | |
| 91 | + setBusy(false); | |
| 92 | + setError("Unexpected response flow from server"); | |
| 93 | + return; | |
| 94 | + } | |
| 95 | + await doUpdate({ | |
| 96 | + path: { scopeId, namespace: props.sourceId }, | |
| 97 | + payload: { oauth2: response.auth }, | |
| 98 | + reactivityKeys: sourceWriteKeys, | |
| 99 | + }); | |
| 100 | + setBusy(false); | |
| 101 | + return; | |
| 102 | + } | |
| 103 | + | |
| 104 | + if (!oauth2.authorizationUrl) { | |
| 105 | + setBusy(false); | |
| 106 | + setError("authorizationCode flow is missing its authorization URL"); | |
| 107 | + return; | |
| 108 | + } | |
| 109 | + | |
| 110 | + const response = await doStartOAuth({ | |
| 111 | + path: { scopeId }, | |
| 112 | + payload: { | |
| 113 | + displayName, | |
| 114 | + securitySchemeName: oauth2.securitySchemeName, | |
| 115 | + flow: "authorizationCode", | |
| 116 | + authorizationUrl: oauth2.authorizationUrl, | |
| 117 | + tokenUrl: oauth2.tokenUrl, | |
| 118 | + redirectUrl, | |
| 119 | + clientIdSecretId: oauth2.clientIdSecretId, | |
| 120 | + clientSecretSecretId: oauth2.clientSecretSecretId ?? undefined, | |
| 121 | + scopes: [...oauth2.scopes], | |
| 122 | + }, | |
| 123 | + }); | |
| 124 | + | |
| 125 | + if (response.flow !== "authorizationCode") { | |
| 126 | + setBusy(false); | |
| 127 | + setError("Unexpected response flow from server"); | |
| 128 | + return; | |
| 129 | + } | |
| 130 | + | |
| 131 | + cleanupRef.current = openOAuthPopup<OAuth2Auth>({ | |
| 132 | + url: response.authorizationUrl, | |
| 133 | + popupName: OPENAPI_OAUTH_POPUP_NAME, | |
| 134 | + channelName: OPENAPI_OAUTH_CHANNEL, | |
| 135 | + onResult: async (result: OAuthPopupResult<OAuth2Auth>) => { | |
| 136 | + cleanupRef.current = null; | |
| 137 | + if (!result.ok) { | |
| 138 | + setBusy(false); | |
| 139 | + setError(result.error); | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + try { | |
| 143 | + const nextAuth = new OAuth2Auth({ | |
| 144 | + kind: "oauth2", | |
| 145 | + connectionId: result.connectionId, | |
| 146 | + securitySchemeName: result.securitySchemeName, | |
| 147 | + flow: result.flow, | |
| 148 | + tokenUrl: result.tokenUrl, | |
| 149 | + authorizationUrl: result.authorizationUrl, | |
| 150 | + clientIdSecretId: result.clientIdSecretId, | |
| 151 | + clientSecretSecretId: result.clientSecretSecretId, | |
| 152 | + scopes: result.scopes, | |
| 153 | + }); | |
| 154 | + await doUpdate({ | |
| 155 | + path: { scopeId, namespace: props.sourceId }, | |
| 156 | + payload: { oauth2: nextAuth }, | |
| 157 | + reactivityKeys: sourceWriteKeys, | |
| 158 | + }); | |
| 159 | + setBusy(false); | |
| 160 | + } catch (e) { | |
| 161 | + setBusy(false); | |
| 162 | + setError( | |
| 163 | + e instanceof Error ? e.message : "Failed to persist new connection", | |
| 164 | + ); | |
| 165 | + } | |
| 166 | + }, | |
| 167 | + onClosed: () => { | |
| 168 | + cleanupRef.current = null; | |
| 169 | + setBusy(false); | |
| 170 | + setError("Sign-in cancelled — popup was closed before completing the flow."); | |
| 171 | + }, | |
| 172 | + onOpenFailed: () => { | |
| 173 | + cleanupRef.current = null; | |
| 174 | + setBusy(false); | |
| 175 | + setError("Sign-in popup was blocked by the browser"); | |
| 176 | + }, | |
| 177 | + }); | |
| 178 | + } catch (e) { | |
| 179 | + setBusy(false); | |
| 180 | + setError(e instanceof Error ? e.message : "Failed to start sign-in"); | |
| 181 | + } | |
| 182 | + }, [ | |
| 183 | + oauth2, | |
| 184 | + source?.name, | |
| 185 | + scopeId, | |
| 186 | + props.sourceId, | |
| 187 | + redirectUrl, | |
| 188 | + doStartOAuth, | |
| 189 | + doUpdate, | |
| 190 | + ]); | |
| 191 | + | |
| 192 | + if (!oauth2) return null; | |
| 193 | + | |
| 194 | + return ( | |
| 195 | + <div className="flex items-center gap-2"> | |
| 196 | + {error && <span className="text-xs text-destructive">{error}</span>} | |
| 197 | + <Button | |
| 198 | + variant="outline" | |
| 199 | + size="sm" | |
| 200 | + onClick={() => void handleSignIn()} | |
| 201 | + disabled={busy} | |
| 202 | + > | |
| 203 | + {busy | |
| 204 | + ? isConnected | |
| 205 | + ? "Reconnecting…" | |
| 206 | + : "Signing in…" | |
| 207 | + : isConnected | |
| 208 | + ? "Reconnect" | |
| 209 | + : "Sign in"} | |
| 210 | + </Button> | |
| 211 | + </div> | |
| 212 | + ); | |
| 213 | +} | |
| @@ -1,40 +1,19 @@ | ||
| 1 | 1 | import { Result, useAtomValue } from "@effect-atom/atom-react"; |
| 2 | 2 | |
| 3 | −import { SecretId } from "@executor/sdk"; | |
| 4 | −import { useScope } from "@executor/react/api/scope-context"; | |
| 5 | −import { secretStatusAtom } from "@executor/react/api/atoms"; | |
| 6 | 3 | import { Badge } from "@executor/react/components/badge"; |
| 7 | −import { Skeleton } from "@executor/react/components/skeleton"; | |
| 8 | 4 | |
| 9 | 5 | import { openApiSourceAtom } from "./atoms"; |
| 6 | +import { useScope } from "@executor/react/api/scope-context"; | |
| 10 | 7 | |
| 11 | −function ConnectedBadge(props: { accessTokenSecretId: string }) { | |
| 12 | − const scopeId = useScope(); | |
| 13 | − const status = useAtomValue( | |
| 14 | − secretStatusAtom(scopeId, SecretId.make(props.accessTokenSecretId)), | |
| 8 | +function ConnectedBadge() { | |
| 9 | + return ( | |
| 10 | + <Badge | |
| 11 | + variant="outline" | |
| 12 | + className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400" | |
| 13 | + > | |
| 14 | + Connected | |
| 15 | + </Badge> | |
| 15 | 16 | ); |
| 16 | − | |
| 17 | − return Result.match(status, { | |
| 18 | − onInitial: () => <Skeleton className="h-5 w-20 rounded-full" />, | |
| 19 | − onFailure: () => ( | |
| 20 | − <Badge variant="outline" className="text-[10px] text-muted-foreground"> | |
| 21 | − Not connected | |
| 22 | − </Badge> | |
| 23 | − ), | |
| 24 | − onSuccess: ({ value }) => | |
| 25 | − value.status === "resolved" ? ( | |
| 26 | − <Badge | |
| 27 | − variant="outline" | |
| 28 | − className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400" | |
| 29 | − > | |
| 30 | − Connected | |
| 31 | − </Badge> | |
| 32 | − ) : ( | |
| 33 | − <Badge variant="outline" className="text-[10px] text-muted-foreground"> | |
| 34 | − Not connected | |
| 35 | − </Badge> | |
| 36 | − ), | |
| 37 | − }); | |
| 38 | 17 | } |
| 39 | 18 | |
| 40 | 19 | // The entry row already renders name + id + kind, so this summary |
| @@ -51,5 +30,5 @@ export default function OpenApiSourceSummary(props: { sourceId: string }) { · 10 unmodified lines | ||
| 51 | 30 | : undefined; |
| 52 | 31 | |
| 53 | 32 | if (!oauth2) return null; |
| 54 | − return <ConnectedBadge accessTokenSecretId={oauth2.accessTokenSecretId} />; | |
| 33 | + return <ConnectedBadge />; | |
| 55 | 34 | } |
| @@ -8,5 +8,6 @@ export const openApiSourcePlugin: SourcePlugin = { · 7 unmodified lines | ||
| 8 | 8 | add: lazy(() => import("./AddOpenApiSource")), |
| 9 | 9 | edit: lazy(() => import("./EditOpenApiSource")), |
| 10 | 10 | summary: lazy(() => import("./OpenApiSourceSummary")), |
| 11 | + signIn: lazy(() => import("./OpenApiSignInButton")), | |
| 11 | 12 | presets: openApiPresets, |
| 12 | 13 | }; |
Binary file not shown.
| @@ -3,26 +3,9 @@ · 2 unmodified lines | ||
| 3 | 3 | // |
| 4 | 4 | // Models the production scenario: an org-level admin uploads the shared |
| 5 | 5 | // client credentials, each member of the org runs their own OAuth flow, |
| 6 | −// and each member's access token is stamped at the per-user scope so the | |
| 7 | −// org fallback provides client id/secret and the user scope provides | |
| 8 | −// the access token the invoker actually injects. | |
| 9 | −// | |
| 10 | −// Three executors share a single backing adapter + blob store + secret | |
| 11 | −// provider: | |
| 12 | −// - admin: scopes = [org] | |
| 13 | −// - alice: scopes = [user-a, org] | |
| 14 | −// - bob: scopes = [user-b, org] | |
| 15 | −// | |
| 16 | −// Expectations: | |
| 17 | −// 1. Admin writes `petstore_client_id`/`petstore_client_secret` at org. | |
| 18 | −// 2. Both users addSpec at their own scope with OAuth2Auth referencing | |
| 19 | −// the shared org client secrets + user-unique access/refresh token ids. | |
| 20 | −// 3. Each user runs startOAuth / completeOAuth; tokens land at their | |
| 21 | −// own scope via the session's `tokenScope`. | |
| 22 | −// 4. Invoking `petstore.items.echoHeaders` through alice returns | |
| 23 | −// alice's token; through bob returns bob's. | |
| 24 | −// 5. `secrets.list()` for alice surfaces the org client creds + her | |
| 25 | −// own tokens but NOT bob's. | |
| 6 | +// and each member's access token lives on a per-user Connection. The | |
| 7 | +// Connections primitive owns every secret — they're filtered out of the | |
| 8 | +// user-facing `secrets.list()` automatically. | |
| 26 | 9 | // --------------------------------------------------------------------------- |
| 27 | 10 | |
| 28 | 11 | import { afterEach } from "vitest"; |
| @@ -53,7 +36,6 @@ import { · 24 unmodified lines | ||
| 53 | 36 | } from "@executor/sdk"; |
| 54 | 37 | import { makeMemoryAdapter } from "@executor/storage-core/testing/memory"; |
| 55 | 38 | |
| 56 | −import { OAuth2Auth } from "./types"; | |
| 57 | 39 | import { openApiPlugin } from "./plugin"; |
| 58 | 40 | |
| 59 | 41 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| @@ -101,7 +83,7 @@ const TestLayer = HttpApiBuilder.serve().pipe( · 41 unmodified lines | ||
| 101 | 83 | const originalFetch = globalThis.fetch; |
| 102 | 84 | |
| 103 | 85 | const mockTokenFetch = (tokenByCode: Record<string, string>) => { |
| 104 | − globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { | |
| 86 | + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { | |
| 105 | 87 | const bodyText = |
| 106 | 88 | init?.body instanceof URLSearchParams |
| 107 | 89 | ? init.body.toString() |
| @@ -117,9 +99,6 @@ const mockTokenFetch = (tokenByCode: Record<string, string>) => { · 9 unmodified lines | ||
| 117 | 99 | { status: 400, headers: { "content-type": "application/json" } }, |
| 118 | 100 | ); |
| 119 | 101 | } |
| 120 | − // Omit `expires_in` so the resulting OAuth2Auth has expiresAt=null. | |
| 121 | − // That sidesteps the refresh path during invocation — the invoker | |
| 122 | − // returns the stored access token directly. | |
| 123 | 102 | return new Response( |
| 124 | 103 | JSON.stringify({ |
| 125 | 104 | access_token: token, |
| @@ -141,18 +120,11 @@ afterEach(() => { · 15 unmodified lines | ||
| 141 | 120 | |
| 142 | 121 | layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { |
| 143 | 122 | it.effect( |
| 144 | − "per-user access tokens coexist with a shared org-level client credential", | |
| 123 | + "per-user Connections coexist with a shared org-level client credential", | |
| 145 | 124 | () => |
| 146 | 125 | Effect.gen(function* () { |
| 147 | − // Shared secret provider. No `list()` implementation — we don't | |
| 148 | − // want the scope-agnostic provider-enumeration fallback to leak | |
| 149 | − // user-scoped ids across executors during this test. In | |
| 150 | − // production, providers that enumerate (1password, workos-vault) | |
| 151 | − // partition by their own tenancy; the in-memory provider here | |
| 152 | − // is flat, so removing `list()` keeps the core-table walk the | |
| 153 | − // only resolver. | |
| 154 | 126 | const secretStore = new Map<string, string>(); |
| 155 | − const key = (scope: string, id: string) => `${scope}\u0000${id}`; | |
| 127 | + const key = (scope: string, id: string) => `${scope} ${id}`; | |
| 156 | 128 | const memoryProvider: SecretProvider = { |
| 157 | 129 | key: "memory", |
| 158 | 130 | writable: true, |
| @@ -171,7 +143,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 12 unmodified lines | ||
| 171 | 143 | secretProviders: [memoryProvider], |
| 172 | 144 | })); |
| 173 | 145 | |
| 174 | − // Route OpenAPI tool invocations through the Effect test server. | |
| 175 | 146 | const httpClient = yield* HttpClient.HttpClient; |
| 176 | 147 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); |
| 177 | 148 | const plugins = [ |
| @@ -179,8 +150,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 1 unmodified line | ||
| 179 | 150 | memorySecretsPlugin(), |
| 180 | 151 | ] as const; |
| 181 | 152 | |
| 182 | − // One adapter + blob store behind all three executors — exactly | |
| 183 | − // what a multi-tenant deployment looks like in production. | |
| 184 | 153 | const schema = collectSchemas(plugins); |
| 185 | 154 | const adapter = makeMemoryAdapter({ schema }); |
| 186 | 155 | const blobs = makeInMemoryBlobStore(); |
| @@ -242,56 +211,15 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 55 unmodified lines | ||
| 242 | 211 | ); |
| 243 | 212 | |
| 244 | 213 | // ------------------------------------------------------------- |
| 245 | − // 2. Each user adds the petstore source at their own scope, | |
| 246 | − // pre-populating OAuth2Auth that points at: | |
| 247 | − // - shared org client creds (read via scope fallthrough) | |
| 248 | − // - user-unique access/refresh token ids (read directly | |
| 249 | − // from the user scope). | |
| 250 | − // `expiresAt: null` keeps the invoker on the no-refresh | |
| 251 | − // path — it returns the stored access token verbatim. | |
| 252 | − // ------------------------------------------------------------- | |
| 253 | − const makeAuth = (user: "alice" | "bob"): OAuth2Auth => | |
| 254 | − new OAuth2Auth({ | |
| 255 | − kind: "oauth2", | |
| 256 | − securitySchemeName: "oauth2", | |
| 257 | − flow: "authorizationCode", | |
| 258 | − tokenUrl: "https://token.example.com/token", | |
| 259 | − clientIdSecretId: "petstore_client_id", | |
| 260 | − clientSecretSecretId: "petstore_client_secret", | |
| 261 | − accessTokenSecretId: `petstore_access_token_${user}`, | |
| 262 | − refreshTokenSecretId: `petstore_refresh_token_${user}`, | |
| 263 | − tokenType: "Bearer", | |
| 264 | − expiresAt: null, | |
| 265 | − scope: null, | |
| 266 | − scopes: ["read"], | |
| 267 | − }); | |
| 268 | − | |
| 269 | − yield* aliceExec.openapi.addSpec({ | |
| 270 | − spec: specJson, | |
| 271 | − scope: aliceScope.id as string, | |
| 272 | − namespace: "petstore", | |
| 273 | − baseUrl: "", | |
| 274 | − oauth2: makeAuth("alice"), | |
| 275 | − }); | |
| 276 | − yield* bobExec.openapi.addSpec({ | |
| 277 | − spec: specJson, | |
| 278 | − scope: bobScope.id as string, | |
| 279 | − namespace: "petstore", | |
| 280 | − baseUrl: "", | |
| 281 | − oauth2: makeAuth("bob"), | |
| 282 | − }); | |
| 283 | − | |
| 284 | − // ------------------------------------------------------------- | |
| 285 | − // 3. Each user runs the OAuth flow. Mock the token endpoint to | |
| 286 | − // return a distinct access_token per `code` so we can tell | |
| 287 | − // them apart downstream. | |
| 214 | + // 2. Each user runs startOAuth + completeOAuth to mint a | |
| 215 | + // per-user Connection. | |
| 288 | 216 | // ------------------------------------------------------------- |
| 289 | 217 | mockTokenFetch({ |
| 290 | 218 | "code-alice": "alice-token", |
| 291 | 219 | "code-bob": "bob-token", |
| 292 | 220 | }); |
| 293 | 221 | |
| 294 | − const startInputFor = (user: "alice" | "bob", scope: ScopeId) => ({ | |
| 222 | + const startInputFor = (user: string, scope: ScopeId) => ({ | |
| 295 | 223 | displayName: `Petstore (${user})`, |
| 296 | 224 | securitySchemeName: "oauth2", |
| 297 | 225 | flow: "authorizationCode" as const, |
| @@ -302,8 +230,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 4 unmodified lines | ||
| 302 | 230 | clientSecretSecretId: "petstore_client_secret", |
| 303 | 231 | scopes: ["read"], |
| 304 | 232 | tokenScope: scope as unknown as string, |
| 305 | − accessTokenSecretId: `petstore_access_token_${user}`, | |
| 306 | − refreshTokenSecretId: `petstore_refresh_token_${user}`, | |
| 307 | 233 | }); |
| 308 | 234 | |
| 309 | 235 | const aliceStart = yield* aliceExec.openapi.startOAuth( |
| @@ -319,18 +245,38 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 9 unmodified lines | ||
| 319 | 245 | throw new Error("expected authorizationCode flow for bob"); |
| 320 | 246 | } |
| 321 | 247 | |
| 322 | − yield* aliceExec.openapi.completeOAuth({ | |
| 248 | + const aliceAuth = yield* aliceExec.openapi.completeOAuth({ | |
| 323 | 249 | state: aliceStart.sessionId, |
| 324 | 250 | code: "code-alice", |
| 325 | 251 | }); |
| 326 | − yield* bobExec.openapi.completeOAuth({ | |
| 252 | + const bobAuth = yield* bobExec.openapi.completeOAuth({ | |
| 327 | 253 | state: bobStart.sessionId, |
| 328 | 254 | code: "code-bob", |
| 329 | 255 | }); |
| 330 | 256 | |
| 257 | + expect(aliceAuth.connectionId).not.toBe(bobAuth.connectionId); | |
| 258 | + | |
| 259 | + // ------------------------------------------------------------- | |
| 260 | + // 3. Each user adds the spec with the auth they just minted. | |
| 261 | + // ------------------------------------------------------------- | |
| 262 | + yield* aliceExec.openapi.addSpec({ | |
| 263 | + spec: specJson, | |
| 264 | + scope: aliceScope.id as string, | |
| 265 | + namespace: "petstore", | |
| 266 | + baseUrl: "", | |
| 267 | + oauth2: aliceAuth, | |
| 268 | + }); | |
| 269 | + yield* bobExec.openapi.addSpec({ | |
| 270 | + spec: specJson, | |
| 271 | + scope: bobScope.id as string, | |
| 272 | + namespace: "petstore", | |
| 273 | + baseUrl: "", | |
| 274 | + oauth2: bobAuth, | |
| 275 | + }); | |
| 276 | + | |
| 331 | 277 | // ------------------------------------------------------------- |
| 332 | − // 4. Invoke the tool through each executor. The injected | |
| 333 | − // Authorization header must carry THAT user's token. | |
| 278 | + // 4. Invoke through each exec — Authorization must carry that | |
| 279 | + // user's token. | |
| 334 | 280 | // ------------------------------------------------------------- |
| 335 | 281 | const aliceResult = (yield* aliceExec.tools.invoke( |
| 336 | 282 | "petstore.items.echoHeaders", |
| @@ -349,51 +295,49 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 12 unmodified lines | ||
| 349 | 295 | expect(bobResult.data?.authorization).toBe("Bearer bob-token"); |
| 350 | 296 | |
| 351 | 297 | // ------------------------------------------------------------- |
| 352 | − // 5. Scope isolation on `secrets.list()`: | |
| 353 | − // - alice sees org client creds + her own tokens. | |
| 354 | − // - alice does NOT see bob's tokens. | |
| 355 | − // - bob symmetrically sees only the org creds + his tokens. | |
| 356 | − // - admin sees only the org creds. | |
| 298 | + // 5. Each user's Connection is scoped to them; admin sees none. | |
| 357 | 299 | // ------------------------------------------------------------- |
| 358 | − const aliceIds = new Set( | |
| 359 | − (yield* aliceExec.secrets.list()).map((s) => s.id as unknown as string), | |
| 300 | + const aliceConnections = yield* aliceExec.connections.list(); | |
| 301 | + const aliceConn = aliceConnections.find( | |
| 302 | + (c) => c.id === aliceAuth.connectionId, | |
| 360 | 303 | ); |
| 361 | − expect(aliceIds).toContain("petstore_client_id"); | |
| 362 | − expect(aliceIds).toContain("petstore_client_secret"); | |
| 363 | − expect(aliceIds).toContain("petstore_access_token_alice"); | |
| 364 | − expect(aliceIds).toContain("petstore_refresh_token_alice"); | |
| 365 | − expect(aliceIds).not.toContain("petstore_access_token_bob"); | |
| 366 | − expect(aliceIds).not.toContain("petstore_refresh_token_bob"); | |
| 367 | − | |
| 368 | − const bobIds = new Set( | |
| 369 | − (yield* bobExec.secrets.list()).map((s) => s.id as unknown as string), | |
| 304 | + expect(aliceConn?.scopeId as unknown as string).toBe("user-alice"); | |
| 305 | + expect(aliceConn?.kind).toBe("user"); | |
| 306 | + | |
| 307 | + const bobConnections = yield* bobExec.connections.list(); | |
| 308 | + const bobConn = bobConnections.find( | |
| 309 | + (c) => c.id === bobAuth.connectionId, | |
| 370 | 310 | ); |
| 371 | − expect(bobIds).toContain("petstore_client_id"); | |
| 372 | − expect(bobIds).toContain("petstore_access_token_bob"); | |
| 373 | − expect(bobIds).toContain("petstore_refresh_token_bob"); | |
| 374 | − expect(bobIds).not.toContain("petstore_access_token_alice"); | |
| 311 | + expect(bobConn?.scopeId as unknown as string).toBe("user-bob"); | |
| 375 | 312 | |
| 376 | − const adminIds = new Set( | |
| 377 | − (yield* adminExec.secrets.list()).map((s) => s.id as unknown as string), | |
| 313 | + const adminConnectionIds = new Set( | |
| 314 | + (yield* adminExec.connections.list()).map((c) => c.id as string), | |
| 315 | + ); | |
| 316 | + expect(adminConnectionIds).not.toContain( | |
| 317 | + aliceAuth.connectionId as unknown as string, | |
| 318 | + ); | |
| 319 | + expect(adminConnectionIds).not.toContain( | |
| 320 | + bobAuth.connectionId as unknown as string, | |
| 378 | 321 | ); |
| 379 | − expect(adminIds).toContain("petstore_client_id"); | |
| 380 | − expect(adminIds).toContain("petstore_client_secret"); | |
| 381 | − expect(adminIds).not.toContain("petstore_access_token_alice"); | |
| 382 | − expect(adminIds).not.toContain("petstore_access_token_bob"); | |
| 383 | 322 | |
| 384 | 323 | // ------------------------------------------------------------- |
| 385 | − // 6. Secret-row scope attribution: alice's tokens are pinned to | |
| 386 | − // her scope, not smuggled in under the org fallback. | |
| 324 | + // 6. Connection-owned secrets are filtered from secrets.list(). | |
| 325 | + // Alice only sees the org client creds; her access / refresh | |
| 326 | + // tokens are hidden behind the Connection primitive. | |
| 387 | 327 | // ------------------------------------------------------------- |
| 388 | − const aliceRows = yield* aliceExec.secrets.list(); | |
| 389 | − const aliceAccess = aliceRows.find( | |
| 390 | − (r) => (r.id as unknown as string) === "petstore_access_token_alice", | |
| 328 | + const aliceSecretIds = new Set( | |
| 329 | + (yield* aliceExec.secrets.list()).map( | |
| 330 | + (s) => s.id as unknown as string, | |
| 331 | + ), | |
| 332 | + ); | |
| 333 | + expect(aliceSecretIds).toContain("petstore_client_id"); | |
| 334 | + expect(aliceSecretIds).toContain("petstore_client_secret"); | |
| 335 | + expect(aliceSecretIds).not.toContain( | |
| 336 | + `${aliceAuth.connectionId}.access_token`, | |
| 391 | 337 | ); |
| 392 | − expect(aliceAccess?.scopeId as unknown as string).toBe("user-alice"); | |
| 393 | − const aliceClient = aliceRows.find( | |
| 394 | − (r) => (r.id as unknown as string) === "petstore_client_id", | |
| 338 | + expect(aliceSecretIds).not.toContain( | |
| 339 | + `${aliceAuth.connectionId}.refresh_token`, | |
| 395 | 340 | ); |
| 396 | − expect(aliceClient?.scopeId as unknown as string).toBe("org"); | |
| 397 | 341 | }), |
| 398 | 342 | ); |
| 399 | 343 | }); |
| @@ -5,21 +5,27 @@ import { FetchHttpClient, HttpClient } from "@effect/platform"; · 4 unmodified lines | ||
| 5 | 5 | import type { Layer } from "effect"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | + OAuth2Error, | |
| 8 | 9 | buildAuthorizationUrl, |
| 9 | 10 | createPkceCodeVerifier, |
| 10 | 11 | exchangeAuthorizationCode, |
| 11 | 12 | exchangeClientCredentials, |
| 12 | − shouldRefreshToken, | |
| 13 | − withRefreshedAccessToken, | |
| 13 | + refreshAccessToken, | |
| 14 | 14 | type OAuth2TokenResponse, |
| 15 | 15 | } from "@executor/plugin-oauth2"; |
| 16 | 16 | |
| 17 | 17 | import { |
| 18 | + ConnectionId, | |
| 19 | + ConnectionRefreshError, | |
| 20 | + CreateConnectionInput, | |
| 18 | 21 | ScopeId, |
| 19 | 22 | SecretId, |
| 20 | − SetSecretInput, | |
| 21 | 23 | SourceDetectionResult, |
| 24 | + TokenMaterial, | |
| 22 | 25 | definePlugin, |
| 26 | + type ConnectionProvider, | |
| 27 | + type ConnectionRefreshInput, | |
| 28 | + type ConnectionRefreshResult, | |
| 23 | 29 | type PluginCtx, |
| 24 | 30 | type StorageFailure, |
| 25 | 31 | type ToolAnnotations, |
| @@ -90,27 +96,26 @@ export interface OpenApiUpdateSourceInput { · 64 unmodified lines | ||
| 90 | 96 | readonly name?: string; |
| 91 | 97 | readonly baseUrl?: string; |
| 92 | 98 | readonly headers?: Record<string, HeaderValue>; |
| 99 | + /** Rewrite the source's OAuth2Auth — typically after a successful | |
| 100 | + * re-authenticate, to point at a freshly minted connection. */ | |
| 101 | + readonly oauth2?: OAuth2Auth; | |
| 93 | 102 | } |
| 94 | 103 | |
| 95 | 104 | // --------------------------------------------------------------------------- |
| 96 | −// OAuth2 onboarding inputs / outputs | |
| 105 | +// OAuth2 onboarding inputs / outputs — callers pre-decide identity knobs | |
| 106 | +// (display name, scheme name, scopes, target scope) and the SDK mints a | |
| 107 | +// Connection when the flow completes. The caller receives an OAuth2Auth | |
| 108 | +// carrying just the resulting connection id. | |
| 97 | 109 | // --------------------------------------------------------------------------- |
| 98 | 110 | |
| 99 | −/** | |
| 100 | − * Shared caller-owned token-identity knobs. `tokenScope` names which | |
| 101 | − * executor scope will own the minted tokens (typically the per-user | |
| 102 | − * scope, defaulting to `ctx.scopes[0].id`). The token secret ids are | |
| 103 | − * pre-decided so the source's stored `OAuth2Auth` can reference the | |
| 104 | − * same ids across every user — per-user values shadow org-level | |
| 105 | − * fallbacks via secret fall-through on read. | |
| 106 | − */ | |
| 107 | 111 | interface StartOAuthIdentity { |
| 108 | 112 | readonly displayName: string; |
| 109 | 113 | readonly securitySchemeName: string; |
| 110 | 114 | readonly clientIdSecretId: string; |
| 111 | 115 | readonly scopes: readonly string[]; |
| 116 | + /** Executor scope that will own the resulting Connection (and its | |
| 117 | + * backing token secrets). Defaults to `ctx.scopes[0].id`. */ | |
| 112 | 118 | readonly tokenScope?: string; |
| 113 | − readonly accessTokenSecretId: string; | |
| 114 | 119 | } |
| 115 | 120 | |
| 116 | 121 | export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity { |
| @@ -119,16 +124,13 @@ export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity { · 2 unmodified lines | ||
| 119 | 124 | readonly tokenUrl: string; |
| 120 | 125 | readonly redirectUrl: string; |
| 121 | 126 | readonly clientSecretSecretId?: string | null; |
| 122 | − readonly refreshTokenSecretId?: string | null; | |
| 123 | 127 | } |
| 124 | 128 | |
| 125 | 129 | /** |
| 126 | 130 | * RFC 6749 §4.4 has no user-interactive step. `startOAuth` exchanges |
| 127 | − * tokens inline and writes the access token at | |
| 128 | − * `accessTokenSecretId` + `tokenScope`, returning a completed | |
| 129 | − * `OAuth2Auth`. No `authorizationUrl`, no session row, no | |
| 130 | − * `completeOAuth`. Re-exchange happens at invoke time when the token | |
| 131 | − * is near expiry. | |
| 131 | + * tokens inline, creates the Connection, and returns a completed | |
| 132 | + * `OAuth2Auth` pointing at it. No `authorizationUrl`, no session row, | |
| 133 | + * no `completeOAuth`. | |
| 132 | 134 | */ |
| 133 | 135 | export interface StartClientCredentialsOAuthInput extends StartOAuthIdentity { |
| 134 | 136 | readonly flow: "clientCredentials"; |
| @@ -280,89 +282,35 @@ const descriptionFor = (def: ToolDefinition): string => { · 145 unmodified lines | ||
| 280 | 282 | }; |
| 281 | 283 | |
| 282 | 284 | // --------------------------------------------------------------------------- |
| 283 | −// clientCredentials access-token resolver. | |
| 285 | +// Connection `provider_state` shape for openapi-oauth2. | |
| 284 | 286 | // |
| 285 | −// RFC 6749 §4.4.3 forbids refresh tokens for this grant, so the only way to | |
| 286 | −// recover from an expired access token is a fresh client_credentials | |
| 287 | −// exchange. We re-exchange proactively (when `shouldRefreshToken` fires | |
| 288 | −// within skew) and persist the new token at the same id+scope the source's | |
| 289 | −// OAuth2Auth already references, keeping per-user shadowing intact. | |
| 287 | +// Every field needed to re-hit the token endpoint on refresh lives here, | |
| 288 | +// so the SDK's `ctx.connections.accessToken(id)` can drive both grant | |
| 289 | +// types without the plugin keeping its own refresh state. The flow | |
| 290 | +// literal chooses between `refresh_token` (authorizationCode) and | |
| 291 | +// re-`exchange client_credentials` at refresh time. NONE of this is | |
| 292 | +// sensitive — client credentials themselves still live in secrets and | |
| 293 | +// are resolved via `ctx.secrets.get` inside the refresh handler. | |
| 290 | 294 | // --------------------------------------------------------------------------- |
| 291 | 295 | |
| 292 | −type ResolveClientCredentialsArgs = { | |
| 293 | − readonly auth: OAuth2Auth; | |
| 294 | − readonly source: StoredSource; | |
| 295 | − readonly scopeId: string; | |
| 296 | − readonly secretsGet: (id: string) => Effect.Effect<string | null, Error>; | |
| 297 | − readonly secretsSet: (args: { | |
| 298 | − readonly id: string; | |
| 299 | − readonly scope: string; | |
| 300 | − readonly name: string; | |
| 301 | − readonly value: string; | |
| 302 | − }) => Effect.Effect<void, Error>; | |
| 303 | − readonly updateSourceMeta: (oauth2: OAuth2Auth) => Effect.Effect<void, Error>; | |
| 304 | −}; | |
| 296 | +const OPENAPI_OAUTH2_PROVIDER_KEY = "openapi:oauth2" as const; | |
| 305 | 297 | |
| 306 | −const resolveClientCredentialsAccessToken = (args: ResolveClientCredentialsArgs) => | |
| 307 | − Effect.gen(function* () { | |
| 308 | − const { auth } = args; | |
| 309 | − const needsRefresh = shouldRefreshToken({ expiresAt: auth.expiresAt }); | |
| 310 | − | |
| 311 | − if (!needsRefresh) { | |
| 312 | − const current = yield* args.secretsGet(auth.accessTokenSecretId); | |
| 313 | − if (current !== null) return current; | |
| 314 | − // Secret was deleted out from under us — fall through to re-exchange. | |
| 315 | − } | |
| 316 | − | |
| 317 | − if (auth.clientSecretSecretId === null) { | |
| 318 | − return yield* Effect.fail( | |
| 319 | − new Error("client_credentials flow requires clientSecretSecretId"), | |
| 320 | − ); | |
| 321 | − } | |
| 322 | − | |
| 323 | − const clientId = yield* args.secretsGet(auth.clientIdSecretId); | |
| 324 | − if (clientId === null) { | |
| 325 | − return yield* Effect.fail( | |
| 326 | − new Error(`Missing client ID secret: ${auth.clientIdSecretId}`), | |
| 327 | − ); | |
| 328 | − } | |
| 329 | − const clientSecret = yield* args.secretsGet(auth.clientSecretSecretId); | |
| 330 | − if (clientSecret === null) { | |
| 331 | − return yield* Effect.fail( | |
| 332 | − new Error(`Missing client secret: ${auth.clientSecretSecretId}`), | |
| 333 | − ); | |
| 334 | − } | |
| 335 | − | |
| 336 | − const tokenResponse = yield* exchangeClientCredentials({ | |
| 337 | − tokenUrl: auth.tokenUrl, | |
| 338 | − clientId, | |
| 339 | − clientSecret, | |
| 340 | − scopes: auth.scopes, | |
| 341 | − }).pipe(Effect.mapError((err) => new Error(err.message))); | |
| 342 | − | |
| 343 | − yield* args.secretsSet({ | |
| 344 | − id: auth.accessTokenSecretId, | |
| 345 | − scope: args.scopeId, | |
| 346 | − name: `${args.source.name} Access Token`, | |
| 347 | − value: tokenResponse.access_token, | |
| 348 | − }); | |
| 298 | +const OAuth2ProviderState = Schema.Struct({ | |
| 299 | + flow: Schema.Literal("authorizationCode", "clientCredentials"), | |
| 300 | + tokenUrl: Schema.String, | |
| 301 | + clientIdSecretId: Schema.String, | |
| 302 | + clientSecretSecretId: Schema.NullOr(Schema.String), | |
| 303 | + scopes: Schema.Array(Schema.String), | |
| 304 | +}); | |
| 305 | +type OAuth2ProviderState = typeof OAuth2ProviderState.Type; | |
| 349 | 306 | |
| 350 | − const expiresAt = | |
| 351 | − typeof tokenResponse.expires_in === "number" | |
| 352 | − ? Date.now() + tokenResponse.expires_in * 1000 | |
| 353 | − : null; | |
| 354 | − | |
| 355 | − yield* args.updateSourceMeta( | |
| 356 | − new OAuth2Auth({ | |
| 357 | − ...auth, | |
| 358 | − tokenType: tokenResponse.token_type ?? auth.tokenType, | |
| 359 | − expiresAt, | |
| 360 | − scope: tokenResponse.scope ?? auth.scope, | |
| 361 | − }), | |
| 362 | − ); | |
| 363 | − | |
| 364 | − return tokenResponse.access_token; | |
| 365 | − }); | |
| 307 | +const encodeProviderState = Schema.encodeSync(OAuth2ProviderState); | |
| 308 | +const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState); | |
| 309 | + | |
| 310 | +const toProviderStateRecord = ( | |
| 311 | + state: OAuth2ProviderState, | |
| 312 | +): Record<string, unknown> => | |
| 313 | + encodeProviderState(state) as unknown as Record<string, unknown>; | |
| 366 | 314 | |
| 367 | 315 | // --------------------------------------------------------------------------- |
| 368 | 316 | // Plugin factory |
| @@ -540,28 +488,6 @@ export const openApiPlugin = definePlugin( · 171 unmodified lines | ||
| 540 | 488 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), |
| 541 | 489 | |
| 542 | 490 | extension: (ctx) => { |
| 543 | − // Write a token secret at a caller-pinned id and scope. The | |
| 544 | − // session persisted by `startOAuth` names both, so the plugin | |
| 545 | − // never picks them itself — that keeps shadowing by id | |
| 546 | − // deterministic (user scope overrides org scope for the same | |
| 547 | − // secret id) instead of minting random suffixes per completion. | |
| 548 | − const writeOAuthSecret = (args: { | |
| 549 | − readonly id: string; | |
| 550 | − readonly scope: string; | |
| 551 | − readonly name: string; | |
| 552 | − readonly value: string; | |
| 553 | − }) => | |
| 554 | − ctx.secrets | |
| 555 | − .set( | |
| 556 | − new SetSecretInput({ | |
| 557 | − id: SecretId.make(args.id), | |
| 558 | − scope: ScopeId.make(args.scope), | |
| 559 | − name: args.name, | |
| 560 | − value: args.value, | |
| 561 | − }), | |
| 562 | − ) | |
| 563 | − .pipe(Effect.map((ref) => ({ id: ref.id as string }))); | |
| 564 | − | |
| 565 | 491 | const addSpecInternal = (config: OpenApiSpecConfig) => |
| 566 | 492 | Effect.gen(function* () { |
| 567 | 493 | // Resolve URL → text and parse BEFORE opening a transaction. |
| @@ -619,6 +545,7 @@ export const openApiPlugin = definePlugin( · 51 unmodified lines | ||
| 619 | 545 | name: input.name?.trim() || undefined, |
| 620 | 546 | baseUrl: input.baseUrl, |
| 621 | 547 | headers: input.headers, |
| 548 | + oauth2: input.oauth2, | |
| 622 | 549 | }), |
| 623 | 550 | |
| 624 | 551 | startOAuth: (input) => |
| @@ -663,35 +590,61 @@ export const openApiPlugin = definePlugin( · 38 unmodified lines | ||
| 663 | 590 | ), |
| 664 | 591 | ); |
| 665 | 592 | |
| 666 | − const accessRef = yield* writeOAuthSecret({ | |
| 667 | − id: input.accessTokenSecretId, | |
| 668 | − scope: tokenScope, | |
| 669 | − name: `${input.displayName} Access Token`, | |
| 670 | − value: tokenResponse.access_token, | |
| 671 | − }).pipe( | |
| 672 | − Effect.mapError( | |
| 673 | − (err) => new OpenApiOAuthError({ message: err.message }), | |
| 674 | − ), | |
| 675 | − ); | |
| 676 | − | |
| 593 | + const connectionId = `openapi-oauth2-${randomUUID()}`; | |
| 677 | 594 | const expiresAt = |
| 678 | 595 | typeof tokenResponse.expires_in === "number" |
| 679 | 596 | ? Date.now() + tokenResponse.expires_in * 1000 |
| 680 | 597 | : null; |
| 681 | 598 | |
| 599 | + const providerState: OAuth2ProviderState = { | |
| 600 | + flow: "clientCredentials", | |
| 601 | + tokenUrl: input.tokenUrl, | |
| 602 | + clientIdSecretId: input.clientIdSecretId, | |
| 603 | + clientSecretSecretId: input.clientSecretSecretId, | |
| 604 | + scopes: scopesArray, | |
| 605 | + }; | |
| 606 | + | |
| 607 | + yield* ctx.connections | |
| 608 | + .create( | |
| 609 | + new CreateConnectionInput({ | |
| 610 | + id: ConnectionId.make(connectionId), | |
| 611 | + scope: ScopeId.make(tokenScope), | |
| 612 | + provider: OPENAPI_OAUTH2_PROVIDER_KEY, | |
| 613 | + kind: "app", | |
| 614 | + identityLabel: input.displayName, | |
| 615 | + accessToken: new TokenMaterial({ | |
| 616 | + secretId: SecretId.make(`${connectionId}.access_token`), | |
| 617 | + name: `${input.displayName} Access Token`, | |
| 618 | + value: tokenResponse.access_token, | |
| 619 | + }), | |
| 620 | + // RFC 6749 §4.4.3: no refresh tokens for this grant. | |
| 621 | + refreshToken: null, | |
| 622 | + expiresAt, | |
| 623 | + oauthScope: tokenResponse.scope ?? null, | |
| 624 | + providerState: toProviderStateRecord(providerState), | |
| 625 | + }), | |
| 626 | + ) | |
| 627 | + .pipe( | |
| 628 | + Effect.mapError( | |
| 629 | + (err) => | |
| 630 | + new OpenApiOAuthError({ | |
| 631 | + message: | |
| 632 | + "message" in err | |
| 633 | + ? (err as { message: string }).message | |
| 634 | + : String(err), | |
| 635 | + }), | |
| 636 | + ), | |
| 637 | + ); | |
| 638 | + | |
| 682 | 639 | const auth = new OAuth2Auth({ |
| 683 | 640 | kind: "oauth2", |
| 641 | + connectionId, | |
| 684 | 642 | securitySchemeName: input.securitySchemeName, |
| 685 | 643 | flow: "clientCredentials", |
| 686 | 644 | tokenUrl: input.tokenUrl, |
| 645 | + authorizationUrl: null, | |
| 687 | 646 | clientIdSecretId: input.clientIdSecretId, |
| 688 | − clientSecretSecretId: input.clientSecretSecretId, | |
| 689 | − accessTokenSecretId: accessRef.id, | |
| 690 | − // RFC 6749 §4.4.3: refresh tokens SHOULD NOT be issued. | |
| 691 | − refreshTokenSecretId: null, | |
| 692 | − tokenType: tokenResponse.token_type ?? "Bearer", | |
| 693 | − expiresAt, | |
| 694 | − scope: tokenResponse.scope ?? null, | |
| 647 | + clientSecretSecretId: input.clientSecretSecretId ?? null, | |
| 695 | 648 | scopes: scopesArray, |
| 696 | 649 | }); |
| 697 | 650 | |
| @@ -705,6 +658,7 @@ export const openApiPlugin = definePlugin( · 7 unmodified lines | ||
| 705 | 658 | // authorizationCode path. |
| 706 | 659 | const sessionId = randomUUID(); |
| 707 | 660 | const codeVerifier = createPkceCodeVerifier(); |
| 661 | + const connectionId = `openapi-oauth2-${randomUUID()}`; | |
| 708 | 662 | |
| 709 | 663 | yield* ctx.storage |
| 710 | 664 | .putOAuthSession( |
| @@ -714,12 +668,14 @@ export const openApiPlugin = definePlugin( · 3 unmodified lines | ||
| 714 | 668 | securitySchemeName: input.securitySchemeName, |
| 715 | 669 | flow: input.flow, |
| 716 | 670 | tokenUrl: input.tokenUrl, |
| 671 | + authorizationUrl: input.authorizationUrl, | |
| 717 | 672 | redirectUrl: input.redirectUrl, |
| 718 | 673 | clientIdSecretId: input.clientIdSecretId, |
| 719 | 674 | clientSecretSecretId: input.clientSecretSecretId ?? null, |
| 720 | 675 | tokenScope, |
| 721 | − accessTokenSecretId: input.accessTokenSecretId, | |
| 722 | − refreshTokenSecretId: input.refreshTokenSecretId ?? null, | |
| 676 | + connectionId, | |
| 677 | + accessTokenSecretId: `${connectionId}.access_token`, | |
| 678 | + refreshTokenSecretId: `${connectionId}.refresh_token`, | |
| 723 | 679 | scopes: scopesArray, |
| 724 | 680 | codeVerifier, |
| 725 | 681 | }), |
| @@ -800,57 +756,65 @@ export const openApiPlugin = definePlugin( · 74 unmodified lines | ||
| 800 | 756 | ), |
| 801 | 757 | ); |
| 802 | 758 | |
| 803 | − // Write tokens at the exact id + scope named by the | |
| 804 | − // session. Shadowing by id (per-user scope wins over | |
| 805 | − // org fall-through) is what makes a single stored | |
| 806 | − // `OAuth2Auth` on the source resolve to per-user | |
| 807 | − // values at invoke time. | |
| 808 | − const accessRef = yield* writeOAuthSecret({ | |
| 809 | − id: session.accessTokenSecretId, | |
| 810 | − scope: session.tokenScope, | |
| 811 | − name: `${session.displayName} Access Token`, | |
| 812 | − value: tokenResponse.access_token, | |
| 813 | − }).pipe( | |
| 814 | − Effect.mapError( | |
| 815 | − (err) => new OpenApiOAuthError({ message: err.message }), | |
| 816 | − ), | |
| 817 | − ); | |
| 818 | − | |
| 819 | − let refreshSecretId: string | null = null; | |
| 820 | − if ( | |
| 821 | − tokenResponse.refresh_token && | |
| 822 | − session.refreshTokenSecretId | |
| 823 | − ) { | |
| 824 | − const refreshRef = yield* writeOAuthSecret({ | |
| 825 | − id: session.refreshTokenSecretId, | |
| 826 | − scope: session.tokenScope, | |
| 827 | − name: `${session.displayName} Refresh Token`, | |
| 828 | − value: tokenResponse.refresh_token, | |
| 829 | − }).pipe( | |
| 830 | − Effect.mapError( | |
| 831 | − (err) => new OpenApiOAuthError({ message: err.message }), | |
| 832 | − ), | |
| 833 | − ); | |
| 834 | − refreshSecretId = refreshRef.id; | |
| 835 | − } | |
| 836 | − | |
| 837 | 759 | const expiresAt = |
| 838 | 760 | typeof tokenResponse.expires_in === "number" |
| 839 | 761 | ? Date.now() + tokenResponse.expires_in * 1000 |
| 840 | 762 | : null; |
| 841 | 763 | |
| 764 | + const providerState: OAuth2ProviderState = { | |
| 765 | + flow: "authorizationCode", | |
| 766 | + tokenUrl: session.tokenUrl, | |
| 767 | + clientIdSecretId: session.clientIdSecretId, | |
| 768 | + clientSecretSecretId: session.clientSecretSecretId, | |
| 769 | + scopes: [...session.scopes], | |
| 770 | + }; | |
| 771 | + | |
| 772 | + yield* ctx.connections | |
| 773 | + .create( | |
| 774 | + new CreateConnectionInput({ | |
| 775 | + id: ConnectionId.make(session.connectionId), | |
| 776 | + scope: ScopeId.make(session.tokenScope), | |
| 777 | + provider: OPENAPI_OAUTH2_PROVIDER_KEY, | |
| 778 | + kind: "user", | |
| 779 | + identityLabel: session.displayName, | |
| 780 | + accessToken: new TokenMaterial({ | |
| 781 | + secretId: SecretId.make(session.accessTokenSecretId), | |
| 782 | + name: `${session.displayName} Access Token`, | |
| 783 | + value: tokenResponse.access_token, | |
| 784 | + }), | |
| 785 | + refreshToken: tokenResponse.refresh_token | |
| 786 | + ? new TokenMaterial({ | |
| 787 | + secretId: SecretId.make(session.refreshTokenSecretId), | |
| 788 | + name: `${session.displayName} Refresh Token`, | |
| 789 | + value: tokenResponse.refresh_token, | |
| 790 | + }) | |
| 791 | + : null, | |
| 792 | + expiresAt, | |
| 793 | + oauthScope: tokenResponse.scope ?? null, | |
| 794 | + providerState: toProviderStateRecord(providerState), | |
| 795 | + }), | |
| 796 | + ) | |
| 797 | + .pipe( | |
| 798 | + Effect.mapError( | |
| 799 | + (err) => | |
| 800 | + new OpenApiOAuthError({ | |
| 801 | + message: | |
| 802 | + "message" in err | |
| 803 | + ? (err as { message: string }).message | |
| 804 | + : String(err), | |
| 805 | + }), | |
| 806 | + ), | |
| 807 | + ); | |
| 808 | + | |
| 842 | 809 | return new OAuth2Auth({ |
| 843 | 810 | kind: "oauth2", |
| 811 | + connectionId: session.connectionId, | |
| 844 | 812 | securitySchemeName: session.securitySchemeName, |
| 845 | − flow: session.flow, | |
| 813 | + flow: "authorizationCode", | |
| 846 | 814 | tokenUrl: session.tokenUrl, |
| 815 | + authorizationUrl: session.authorizationUrl, | |
| 847 | 816 | clientIdSecretId: session.clientIdSecretId, |
| 848 | 817 | clientSecretSecretId: session.clientSecretSecretId, |
| 849 | − accessTokenSecretId: accessRef.id, | |
| 850 | − refreshTokenSecretId: refreshSecretId, | |
| 851 | − tokenType: tokenResponse.token_type ?? "Bearer", | |
| 852 | − expiresAt, | |
| 853 | − scope: tokenResponse.scope ?? null, | |
| 854 | 818 | scopes: [...session.scopes], |
| 855 | 819 | }); |
| 856 | 820 | }), |
| @@ -946,101 +910,27 @@ export const openApiPlugin = definePlugin( · 89 unmodified lines | ||
| 946 | 910 | { get: ctx.secrets.get }, |
| 947 | 911 | ); |
| 948 | 912 | |
| 949 | − // If the source has OAuth2 auth, resolve/refresh access token and | |
| 950 | − // inject Authorization header (wins over a manually-set one). | |
| 913 | + // If the source has OAuth2 auth, resolve a guaranteed-fresh | |
| 914 | + // access token from the backing Connection and inject the | |
| 915 | + // Authorization header (wins over a manually-set one). All the | |
| 916 | + // refresh complexity lives in the SDK — the plugin just asks. | |
| 951 | 917 | if (Option.isSome(config.oauth2)) { |
| 952 | 918 | const auth = config.oauth2.value; |
| 953 | − const accessToken = | |
| 954 | − auth.flow === "clientCredentials" | |
| 955 | − ? yield* resolveClientCredentialsAccessToken({ | |
| 956 | − auth, | |
| 957 | − source, | |
| 958 | − scopeId: ctx.scopes[0]!.id as string, | |
| 959 | − secretsGet: (id) => | |
| 960 | − ctx.secrets | |
| 961 | − .get(id) | |
| 962 | − .pipe(Effect.mapError((err) => new Error(err.message))), | |
| 963 | − secretsSet: (args) => | |
| 964 | − ctx.secrets | |
| 965 | − .set( | |
| 966 | − new SetSecretInput({ | |
| 967 | − id: SecretId.make(args.id), | |
| 968 | − scope: ScopeId.make(args.scope), | |
| 969 | − name: args.name, | |
| 970 | − value: args.value, | |
| 971 | − }), | |
| 972 | − ) | |
| 973 | − .pipe( | |
| 974 | − Effect.asVoid, | |
| 975 | − Effect.mapError((err) => new Error(err.message)), | |
| 976 | − ), | |
| 977 | − updateSourceMeta: (oauth2) => | |
| 978 | − ctx.storage | |
| 979 | − .updateSourceMeta(source.namespace, source.scope, { | |
| 980 | − oauth2, | |
| 981 | − }) | |
| 982 | − .pipe(Effect.mapError((err) => new Error(err.message))), | |
| 983 | − }) | |
| 984 | − : yield* withRefreshedAccessToken({ | |
| 985 | − auth: { | |
| 986 | − clientIdSecretId: auth.clientIdSecretId, | |
| 987 | − clientSecretSecretId: auth.clientSecretSecretId, | |
| 988 | − accessTokenSecretId: auth.accessTokenSecretId, | |
| 989 | − refreshTokenSecretId: auth.refreshTokenSecretId, | |
| 990 | − tokenType: auth.tokenType, | |
| 991 | − expiresAt: auth.expiresAt, | |
| 992 | − scopes: auth.scopes, | |
| 993 | − }, | |
| 994 | − tokenUrl: auth.tokenUrl, | |
| 995 | − secrets: { | |
| 996 | − resolve: (id) => | |
| 997 | − ctx.secrets.get(id).pipe( | |
| 998 | − Effect.flatMap((v) => | |
| 999 | − v === null | |
| 1000 | − ? Effect.fail(new Error(`Missing secret: ${id}`)) | |
| 1001 | − : Effect.succeed(v), | |
| 919 | + const accessToken = yield* ctx.connections | |
| 920 | + .accessToken(auth.connectionId) | |
| 921 | + .pipe( | |
| 922 | + Effect.mapError( | |
| 923 | + (err) => | |
| 924 | + new Error( | |
| 925 | + `OAuth connection resolution failed: ${ | |
| 926 | + "message" in err | |
| 927 | + ? (err as { message: string }).message | |
| 928 | + : String(err) | |
| 929 | + }`, | |
| 1002 | 930 | ), |
| 1003 | − ), | |
| 1004 | − setValue: ({ secretId, value, name }) => | |
| 1005 | − // Refresh writes land at the innermost scope of the | |
| 1006 | − // executor that invoked the tool. When a per-user | |
| 1007 | − // stack calls, that's the user scope — their token | |
| 1008 | − // shadows the org fall-through on the same id. | |
| 1009 | − ctx.secrets | |
| 1010 | − .set( | |
| 1011 | − new SetSecretInput({ | |
| 1012 | − id: SecretId.make(secretId), | |
| 1013 | − scope: ctx.scopes[0]!.id, | |
| 1014 | − name, | |
| 1015 | − value, | |
| 1016 | − }), | |
| 1017 | − ) | |
| 1018 | − .pipe(Effect.asVoid), | |
| 1019 | − }, | |
| 1020 | − displayName: source.name, | |
| 1021 | − accessTokenPurpose: "openapi_oauth_access_token", | |
| 1022 | − refreshTokenPurpose: "openapi_oauth_refresh_token", | |
| 1023 | − persistAuth: (snapshot) => | |
| 1024 | − Effect.gen(function* () { | |
| 1025 | − const updatedOAuth = new OAuth2Auth({ | |
| 1026 | − ...auth, | |
| 1027 | − tokenType: snapshot.tokenType, | |
| 1028 | − expiresAt: snapshot.expiresAt, | |
| 1029 | − scope: snapshot.scope ?? auth.scope, | |
| 1030 | − }); | |
| 1031 | − // Pin the meta update to the source's own scope so a | |
| 1032 | − // refreshed token snapshot can't accidentally land on | |
| 1033 | − // a shadowed source row at another scope in the stack. | |
| 1034 | − yield* ctx.storage.updateSourceMeta(source.namespace, source.scope, { | |
| 1035 | − oauth2: updatedOAuth, | |
| 1036 | − }); | |
| 1037 | − }), | |
| 1038 | − }).pipe( | |
| 1039 | − Effect.mapError( | |
| 1040 | − (err) => new Error(`OAuth token refresh failed: ${err.message}`), | |
| 1041 | − ), | |
| 1042 | − ); | |
| 1043 | − resolvedHeaders["Authorization"] = `${auth.tokenType || "Bearer"} ${accessToken}`; | |
| 931 | + ), | |
| 932 | + ); | |
| 933 | + resolvedHeaders["Authorization"] = `Bearer ${accessToken}`; | |
| 1044 | 934 | } |
| 1045 | 935 | |
| 1046 | 936 | const result = yield* invokeWithLayer( |
| @@ -1129,6 +1019,115 @@ export const openApiPlugin = definePlugin( · 82 unmodified lines | ||
| 1129 | 1019 | namespace, |
| 1130 | 1020 | }); |
| 1131 | 1021 | }), |
| 1022 | + | |
| 1023 | + // The SDK's `ctx.connections.accessToken(id)` dispatches here when a | |
| 1024 | + // token is near expiry. Both flows share one provider key — the | |
| 1025 | + // concrete refresh strategy is selected from `providerState.flow` | |
| 1026 | + // because the caller already persisted all the knobs we need. | |
| 1027 | + connectionProviders: (ctx): readonly ConnectionProvider[] => [ | |
| 1028 | + { | |
| 1029 | + key: OPENAPI_OAUTH2_PROVIDER_KEY, | |
| 1030 | + refresh: (input: ConnectionRefreshInput) => | |
| 1031 | + Effect.gen(function* () { | |
| 1032 | + if (!input.providerState) { | |
| 1033 | + return yield* new ConnectionRefreshError({ | |
| 1034 | + connectionId: input.connectionId, | |
| 1035 | + message: | |
| 1036 | + "openapi:oauth2 connection is missing providerState", | |
| 1037 | + }); | |
| 1038 | + } | |
| 1039 | + const state = yield* Effect.try({ | |
| 1040 | + try: () => decodeProviderState(input.providerState), | |
| 1041 | + catch: (cause) => | |
| 1042 | + new ConnectionRefreshError({ | |
| 1043 | + connectionId: input.connectionId, | |
| 1044 | + message: `openapi:oauth2 providerState is malformed: ${ | |
| 1045 | + cause instanceof Error ? cause.message : String(cause) | |
| 1046 | + }`, | |
| 1047 | + cause, | |
| 1048 | + }), | |
| 1049 | + }); | |
| 1050 | + | |
| 1051 | + const clientId = yield* ctx.secrets.get(state.clientIdSecretId).pipe( | |
| 1052 | + Effect.mapError( | |
| 1053 | + (err) => | |
| 1054 | + new ConnectionRefreshError({ | |
| 1055 | + connectionId: input.connectionId, | |
| 1056 | + message: `Failed to resolve client id secret: ${err.message}`, | |
| 1057 | + cause: err, | |
| 1058 | + }), | |
| 1059 | + ), | |
| 1060 | + ); | |
| 1061 | + if (clientId === null) { | |
| 1062 | + return yield* new ConnectionRefreshError({ | |
| 1063 | + connectionId: input.connectionId, | |
| 1064 | + message: `Missing client id secret: ${state.clientIdSecretId}`, | |
| 1065 | + }); | |
| 1066 | + } | |
| 1067 | + | |
| 1068 | + const clientSecret = state.clientSecretSecretId | |
| 1069 | + ? yield* ctx.secrets.get(state.clientSecretSecretId).pipe( | |
| 1070 | + Effect.mapError( | |
| 1071 | + (err) => | |
| 1072 | + new ConnectionRefreshError({ | |
| 1073 | + connectionId: input.connectionId, | |
| 1074 | + message: `Failed to resolve client secret: ${err.message}`, | |
| 1075 | + cause: err, | |
| 1076 | + }), | |
| 1077 | + ), | |
| 1078 | + ) | |
| 1079 | + : null; | |
| 1080 | + | |
| 1081 | + const toRefreshError = (err: OAuth2Error) => | |
| 1082 | + new ConnectionRefreshError({ | |
| 1083 | + connectionId: input.connectionId, | |
| 1084 | + message: err.message, | |
| 1085 | + cause: err, | |
| 1086 | + }); | |
| 1087 | + | |
| 1088 | + const tokenResponse = yield* (state.flow === "clientCredentials" | |
| 1089 | + ? exchangeClientCredentials({ | |
| 1090 | + tokenUrl: state.tokenUrl, | |
| 1091 | + clientId, | |
| 1092 | + clientSecret: clientSecret ?? "", | |
| 1093 | + scopes: state.scopes, | |
| 1094 | + }) | |
| 1095 | + : (() => { | |
| 1096 | + if (input.refreshToken === null) { | |
| 1097 | + return Effect.fail( | |
| 1098 | + new OAuth2Error({ | |
| 1099 | + message: | |
| 1100 | + "authorizationCode connection has no refresh token", | |
| 1101 | + }), | |
| 1102 | + ); | |
| 1103 | + } | |
| 1104 | + return refreshAccessToken({ | |
| 1105 | + tokenUrl: state.tokenUrl, | |
| 1106 | + clientId, | |
| 1107 | + clientSecret, | |
| 1108 | + refreshToken: input.refreshToken, | |
| 1109 | + scopes: state.scopes, | |
| 1110 | + }); | |
| 1111 | + })() | |
| 1112 | + ).pipe(Effect.mapError(toRefreshError)); | |
| 1113 | + | |
| 1114 | + const expiresAt = | |
| 1115 | + typeof tokenResponse.expires_in === "number" | |
| 1116 | + ? Date.now() + tokenResponse.expires_in * 1000 | |
| 1117 | + : null; | |
| 1118 | + | |
| 1119 | + const result: ConnectionRefreshResult = { | |
| 1120 | + accessToken: tokenResponse.access_token, | |
| 1121 | + // Rotated refresh token (RFC 6749 §6) — undefined means | |
| 1122 | + // "keep the stored one"; null means "AS didn't issue one". | |
| 1123 | + refreshToken: tokenResponse.refresh_token ?? undefined, | |
| 1124 | + expiresAt, | |
| 1125 | + oauthScope: tokenResponse.scope ?? input.oauthScope, | |
| 1126 | + }; | |
| 1127 | + return result; | |
| 1128 | + }), | |
| 1129 | + }, | |
| 1130 | + ], | |
| 1132 | 1131 | }; |
| 1133 | 1132 | }, |
| 1134 | 1133 | ); |
| @@ -111,9 +111,6 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>( · 110 unmodified lines | ||
| 111 | 111 | // securitySchemeName, with the secret ids that back its tokens. |
| 112 | 112 | oauth2: Schema.optional(OAuth2Auth), |
| 113 | 113 | }), |
| 114 | − // TODO(migration): make required once all rows have been migrated to | |
| 115 | − // carry invocationConfig. Left optional for decode compat with rows | |
| 116 | − // written before the source-level invocationConfig refactor. | |
| 117 | 114 | invocationConfig: Schema.optional(InvocationConfig), |
| 118 | 115 | }) {} |
| 119 | 116 | |
| @@ -137,7 +134,16 @@ const encodeInvocationConfig = Schema.encodeSync(InvocationConfig); · 17 unmodified lines | ||
| 137 | 134 | const decodeInvocationConfig = Schema.decodeUnknownSync(InvocationConfig); |
| 138 | 135 | |
| 139 | 136 | const encodeOAuth2 = Schema.encodeSync(OAuth2Auth); |
| 140 | −const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth); | |
| 137 | + | |
| 138 | +// Tolerant decode for the stored `oauth2` column: the schema was widened | |
| 139 | +// (flow, tokenUrl, authorizationUrl, scopes, ...) after rows were first | |
| 140 | +// written as thin `{kind, connectionId}` pointers. Legacy rows must not | |
| 141 | +// crash the source list — they're surfaced with no stored OAuth config, | |
| 142 | +// and the user can re-sign-in from the source detail page to repopulate | |
| 143 | +// the new fields. | |
| 144 | +const decodeOAuth2Option = Schema.decodeUnknownOption(OAuth2Auth); | |
| 145 | +const decodeOAuth2 = (value: unknown): OAuth2Auth | undefined => | |
| 146 | + Option.getOrUndefined(decodeOAuth2Option(value)); | |
| 141 | 147 | |
| 142 | 148 | const encodeOAuthSession = Schema.encodeSync(OpenApiOAuthSession); |
| 143 | 149 | const decodeOAuthSession = Schema.decodeUnknownSync(OpenApiOAuthSession); |
| @@ -239,8 +245,21 @@ export const makeDefaultOpenapiStore = ({ · 95 unmodified lines | ||
| 239 | 245 | ? undefined |
| 240 | 246 | : decodeOAuth2(typeof oauth2Raw === "string" ? JSON.parse(oauth2Raw) : oauth2Raw); |
| 241 | 247 | const headers = decodeHeaders(row.headers); |
| 248 | + // Strip a legacy `oauth2` blob from invocation_config before decoding. | |
| 249 | + // The schema inside InvocationConfig embeds OAuth2Auth, which was | |
| 250 | + // widened after some rows were written; the tolerant per-field decode | |
| 251 | + // we already have for `row.oauth2` doesn't reach the nested copy, so | |
| 252 | + // we drop it here if it fails to decode against the current schema. | |
| 253 | + // The legit copy lives on `config.oauth2`; the user re-signs-in from | |
| 254 | + // the source detail page to repopulate both. | |
| 255 | + const rawInvocation = asJsonObject(row.invocation_config); | |
| 256 | + const rawNestedOAuth2 = rawInvocation.oauth2; | |
| 257 | + const nestedOAuth2Valid = | |
| 258 | + rawNestedOAuth2 != null && decodeOAuth2(rawNestedOAuth2) !== undefined; | |
| 242 | 259 | const invocationConfig = decodeInvocationConfig( |
| 243 | − asJsonObject(row.invocation_config), | |
| 260 | + nestedOAuth2Valid | |
| 261 | + ? rawInvocation | |
| 262 | + : { ...rawInvocation, oauth2: undefined }, | |
| 244 | 263 | ); |
| 245 | 264 | return { |
| 246 | 265 | namespace: row.id as string, |
| @@ -113,33 +113,58 @@ export const HeaderValue = Schema.Union( · 112 unmodified lines | ||
| 113 | 113 | export type HeaderValue = typeof HeaderValue.Type; |
| 114 | 114 | |
| 115 | 115 | // --------------------------------------------------------------------------- |
| 116 | −// OAuth2 auth — applied as Authorization: Bearer <token> at invocation time. | |
| 117 | −// Tokens are stored as secrets; the bearer value is resolved (and refreshed) | |
| 118 | −// on every request via withRefreshedAccessToken. | |
| 119 | −// --------------------------------------------------------------------------- | |
| 116 | +// OAuth2 auth — points at the Connection that owns live tokens, and also | |
| 117 | +// carries enough API-level config to kick off a fresh sign-in from the | |
| 118 | +// source detail UI without needing the connection to still exist. | |
| 119 | +// | |
| 120 | +// Split of responsibilities: | |
| 121 | +// - The Source owns: the OAuth config (tokenUrl, authorizationUrl, | |
| 122 | +// client credential secret ids, scopes, flow, securitySchemeName). | |
| 123 | +// Values are a property of the target API, identical for every user | |
| 124 | +// signing into this source. Source-owned = reconnect works even if | |
| 125 | +// the connection row has been removed. | |
| 126 | +// - The Connection owns: live access/refresh tokens, token expiry, | |
| 127 | +// provider state the refresh path reads from. The connection's | |
| 128 | +// `providerState` caches the refresh-relevant bits of the config | |
| 129 | +// so the refresh loop never reaches back into source storage. | |
| 130 | +// | |
| 131 | +// This is a deliberate small duplication (scopes + tokenUrl + | |
| 132 | +// clientIdSecretId + clientSecretSecretId appear on both). The values | |
| 133 | +// are static per source so the two copies can't drift. | |
| 134 | +// --------------------------------------------------------------------------- | |
| 135 | + | |
| 136 | +export const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials"); | |
| 137 | +export type OAuth2Flow = typeof OAuth2Flow.Type; | |
| 120 | 138 | |
| 121 | 139 | export class OAuth2Auth extends Schema.Class<OAuth2Auth>("OpenApiOAuth2Auth")({ |
| 122 | 140 | kind: Schema.Literal("oauth2"), |
| 123 | − /** Key into `components.securitySchemes` this auth came from. */ | |
| 141 | + /** Id of the Connection that owns this sign-in. Points at the core | |
| 142 | + * `connection` table; resolve via `ctx.connections.get(id)` or | |
| 143 | + * `ctx.connections.accessToken(id)`. Updated when the user signs in | |
| 144 | + * again from the source detail UI (a fresh connection is minted and | |
| 145 | + * this pointer is rewritten). */ | |
| 146 | + connectionId: Schema.String, | |
| 147 | + /** Key into `components.securitySchemes` this auth came from. Kept here | |
| 148 | + * so a spec with multiple OAuth2 schemes can wire each one to its own | |
| 149 | + * connection. */ | |
| 124 | 150 | securitySchemeName: Schema.String, |
| 125 | − /** | |
| 126 | − * Which flow produced this auth. `clientCredentials` sources have no | |
| 127 | − * refresh token — the invoker re-exchanges via client_credentials when | |
| 128 | − * the access token is near expiry. | |
| 129 | − */ | |
| 130 | − flow: Schema.Literal("authorizationCode", "clientCredentials"), | |
| 131 | − /** Token endpoint (from the flow) — used for refresh / re-exchange. */ | |
| 151 | + /** OAuth2 grant type used for this source. Determines which flow the | |
| 152 | + * sign-in button runs (authorizationCode opens a browser popup; | |
| 153 | + * clientCredentials is server-to-server). */ | |
| 154 | + flow: OAuth2Flow, | |
| 155 | + /** Absolute token endpoint URL. */ | |
| 132 | 156 | tokenUrl: Schema.String, |
| 157 | + /** Absolute authorization endpoint URL. Only used for authorizationCode | |
| 158 | + * flows; clientCredentials has no user consent step. */ | |
| 159 | + authorizationUrl: Schema.NullOr(Schema.String), | |
| 160 | + /** Secret id holding the OAuth client_id. */ | |
| 133 | 161 | clientIdSecretId: Schema.String, |
| 162 | + /** Secret id holding the OAuth client_secret. Optional for public | |
| 163 | + * clients (PKCE-only authorizationCode). */ | |
| 134 | 164 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 135 | − accessTokenSecretId: Schema.String, | |
| 136 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 137 | − tokenType: Schema.String, | |
| 138 | − /** Epoch ms when the access token expires; null if the server did not declare an expiry. */ | |
| 139 | − expiresAt: Schema.NullOr(Schema.Number), | |
| 140 | − /** Scope string as returned by the token endpoint. */ | |
| 141 | − scope: Schema.NullOr(Schema.String), | |
| 142 | − /** Scopes this auth was granted (for display + refresh). */ | |
| 165 | + /** OAuth scopes requested on sign-in. Stored as a static list so the | |
| 166 | + * sign-in button can re-request the same capabilities without having | |
| 167 | + * to re-derive them from the OpenAPI spec. */ | |
| 143 | 168 | scopes: Schema.Array(Schema.String), |
| 144 | 169 | }) {} |
| 145 | 170 | |
| @@ -171,38 +196,39 @@ export class InvocationConfig extends Schema.Class<InvocationConfig>("Invocation · 25 unmodified lines | ||
| 171 | 196 | }) {} |
| 172 | 197 | |
| 173 | 198 | // --------------------------------------------------------------------------- |
| 174 | −// Pending OAuth session — persisted between startOAuth and completeOAuth | |
| 199 | +// Pending OAuth session — persisted between startOAuth and completeOAuth. | |
| 200 | +// All the fields the exchange needs (token endpoint, client credential | |
| 201 | +// secret ids, redirect URL, PKCE verifier) plus the pre-decided Connection | |
| 202 | +// / secret ids the SDK stamps when the user returns. | |
| 175 | 203 | // --------------------------------------------------------------------------- |
| 176 | 204 | |
| 177 | 205 | export class OpenApiOAuthSession extends Schema.Class<OpenApiOAuthSession>( |
| 178 | 206 | "OpenApiOAuthSession", |
| 179 | 207 | )({ |
| 180 | − /** Display name used for the stored token secret labels. */ | |
| 208 | + /** Display name used for the resulting Connection's identity label. */ | |
| 181 | 209 | displayName: Schema.String, |
| 182 | 210 | securitySchemeName: Schema.String, |
| 183 | − /** For now only authorizationCode is supported end-to-end; clientCredentials is follow-up work. */ | |
| 211 | + /** Only authorizationCode reaches this session type. client_credentials | |
| 212 | + * has no user-interactive step so it creates the Connection inline in | |
| 213 | + * `startOAuth` without persisting a session. */ | |
| 184 | 214 | flow: Schema.Literal("authorizationCode"), |
| 185 | 215 | tokenUrl: Schema.String, |
| 216 | + /** Absolute authorization endpoint — persisted so completeOAuth can | |
| 217 | + * stamp it onto the resulting `OAuth2Auth` for future sign-ins. */ | |
| 218 | + authorizationUrl: Schema.String, | |
| 186 | 219 | redirectUrl: Schema.String, |
| 187 | 220 | clientIdSecretId: Schema.String, |
| 188 | 221 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 189 | − /** | |
| 190 | − * Executor scope id where the minted access/refresh token secrets will | |
| 191 | − * land when `completeOAuth` runs. Typically the innermost (per-user) | |
| 192 | − * scope. Persisted in the session so the callback that completes the | |
| 193 | − * flow writes tokens to the same tenancy the caller intended at | |
| 194 | − * `startOAuth` time. | |
| 195 | − */ | |
| 222 | + /** Executor scope that will own the resulting Connection (and its | |
| 223 | + * backing secret rows). Typically the innermost (per-user) scope. */ | |
| 196 | 224 | tokenScope: Schema.String, |
| 197 | − /** | |
| 198 | − * Pre-decided secret ids for the minted access + refresh tokens. The | |
| 199 | − * caller names these so the source's `OAuth2Auth` can reference the | |
| 200 | − * same ids regardless of which scope actually owns the value — | |
| 201 | − * `ctx.secrets.get` resolves them via fallthrough (innermost first), | |
| 202 | − * so per-user tokens shadow org-level fallbacks on the same source. | |
| 203 | − */ | |
| 225 | + /** Pre-decided Connection id stamped at `completeOAuth` time. */ | |
| 226 | + connectionId: Schema.String, | |
| 227 | + /** Pre-decided secret ids for the Connection's access + refresh | |
| 228 | + * tokens. Fixed at session creation so a retried callback lands | |
| 229 | + * on the same ids. */ | |
| 204 | 230 | accessTokenSecretId: Schema.String, |
| 205 | − refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 231 | + refreshTokenSecretId: Schema.String, | |
| 206 | 232 | scopes: Schema.Array(Schema.String), |
| 207 | 233 | codeVerifier: Schema.String, |
| 208 | 234 | }) {} |
| @@ -64,6 +64,13 @@ export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => · 63 unmodified lines | ||
| 64 | 64 | reactivityKeys: [ReactivityKey.secrets], |
| 65 | 65 | }); |
| 66 | 66 | |
| 67 | +export const connectionsAtom = (scopeId: ScopeId) => | |
| 68 | + ExecutorApiClient.query("connections", "list", { | |
| 69 | + path: { scopeId }, | |
| 70 | + timeToLive: "30 seconds", | |
| 71 | + reactivityKeys: [ReactivityKey.connections], | |
| 72 | + }); | |
| 73 | + | |
| 67 | 74 | // --------------------------------------------------------------------------- |
| 68 | 75 | // Mutation atoms — reactivityKeys must be passed at call site (effect-atom |
| 69 | 76 | // does not accept them at definition time). See `reactivity-keys.tsx` for the |
| @@ -76,6 +83,11 @@ export const resolveSecret = ExecutorApiClient.mutation("secrets", "resolve"); · 6 unmodified lines | ||
| 76 | 83 | |
| 77 | 84 | export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); |
| 78 | 85 | |
| 86 | +export const removeConnection = ExecutorApiClient.mutation( | |
| 87 | + "connections", | |
| 88 | + "remove", | |
| 89 | +); | |
| 90 | + | |
| 79 | 91 | export const removeSource = ExecutorApiClient.mutation("sources", "remove"); |
| 80 | 92 | |
| 81 | 93 | export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); |
| @@ -21,6 +21,7 @@ export const ReactivityKey = { · 20 unmodified lines | ||
| 21 | 21 | sources: "sources", |
| 22 | 22 | tools: "tools", |
| 23 | 23 | secrets: "secrets", |
| 24 | + connections: "connections", | |
| 24 | 25 | scope: "scope", |
| 25 | 26 | // cloud-only resources |
| 26 | 27 | orgMembers: "org:members", |
| @@ -35,6 +36,15 @@ export const sourceWriteKeys = [ReactivityKey.sources, ReactivityKey.tools] as c · 8 unmodified lines | ||
| 35 | 36 | /** Mutations that mint or revoke secrets. */ |
| 36 | 37 | export const secretWriteKeys = [ReactivityKey.secrets] as const; |
| 37 | 38 | |
| 39 | +/** Mutations that create or remove connections. Touches `secrets` too | |
| 40 | + * because connection-owned secret rows are filtered out of the secrets | |
| 41 | + * list — signing out should unhide them (in the future) or remove | |
| 42 | + * them, and either way the secrets page needs to re-query. */ | |
| 43 | +export const connectionWriteKeys = [ | |
| 44 | + ReactivityKey.connections, | |
| 45 | + ReactivityKey.secrets, | |
| 46 | +] as const; | |
| 47 | + | |
| 38 | 48 | /** Mutations that change scope membership/info. */ |
| 39 | 49 | export const scopeWriteKeys = [ReactivityKey.scope] as const; |
| 40 | 50 | |
| @@ -0,0 +1,185 @@ | ||
| 1 | +import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; | |
| 2 | +import { ConnectionId } from "@executor/sdk"; | |
| 3 | + | |
| 4 | +import { connectionsAtom, removeConnection } from "../api/atoms"; | |
| 5 | +import { connectionWriteKeys } from "../api/reactivity-keys"; | |
| 6 | +import { useScope } from "../hooks/use-scope"; | |
| 7 | +import { Badge } from "../components/badge"; | |
| 8 | +import { Button } from "../components/button"; | |
| 9 | +import { | |
| 10 | + CardStack, | |
| 11 | + CardStackContent, | |
| 12 | + CardStackEntry, | |
| 13 | + CardStackEntryActions, | |
| 14 | + CardStackEntryContent, | |
| 15 | + CardStackEntryDescription, | |
| 16 | + CardStackEntryTitle, | |
| 17 | + CardStackHeader, | |
| 18 | +} from "../components/card-stack"; | |
| 19 | +import { | |
| 20 | + DropdownMenu, | |
| 21 | + DropdownMenuContent, | |
| 22 | + DropdownMenuItem, | |
| 23 | + DropdownMenuTrigger, | |
| 24 | +} from "../components/dropdown-menu"; | |
| 25 | + | |
| 26 | +// --------------------------------------------------------------------------- | |
| 27 | +// Provider display | |
| 28 | +// --------------------------------------------------------------------------- | |
| 29 | + | |
| 30 | +// Friendly labels for the internal provider keys minted by plugins. | |
| 31 | +// Falls through to the raw key so new providers still render something. | |
| 32 | +const providerDisplayNames: Record<string, string> = { | |
| 33 | + "mcp:oauth2": "MCP", | |
| 34 | + "openapi:oauth2": "OpenAPI", | |
| 35 | + "google-discovery:oauth2": "Google", | |
| 36 | +}; | |
| 37 | + | |
| 38 | +const displayProvider = (provider: string): string => | |
| 39 | + providerDisplayNames[provider] ?? provider; | |
| 40 | + | |
| 41 | +// --------------------------------------------------------------------------- | |
| 42 | +// Connection row | |
| 43 | +// --------------------------------------------------------------------------- | |
| 44 | + | |
| 45 | +function ConnectionRow(props: { | |
| 46 | + connection: { | |
| 47 | + id: string; | |
| 48 | + provider: string; | |
| 49 | + identityLabel: string | null; | |
| 50 | + kind: "user" | "app"; | |
| 51 | + }; | |
| 52 | + onRemove: () => void; | |
| 53 | +}) { | |
| 54 | + const { connection } = props; | |
| 55 | + const displayLabel = | |
| 56 | + connection.identityLabel && connection.identityLabel.length > 0 | |
| 57 | + ? connection.identityLabel | |
| 58 | + : connection.id; | |
| 59 | + | |
| 60 | + return ( | |
| 61 | + <CardStackEntry> | |
| 62 | + <CardStackEntryContent> | |
| 63 | + <CardStackEntryTitle className="flex items-center gap-2"> | |
| 64 | + <span className="truncate">{displayLabel}</span> | |
| 65 | + </CardStackEntryTitle> | |
| 66 | + <CardStackEntryDescription className="text-xs text-muted-foreground"> | |
| 67 | + {displayProvider(connection.provider)} | |
| 68 | + </CardStackEntryDescription> | |
| 69 | + </CardStackEntryContent> | |
| 70 | + <CardStackEntryActions> | |
| 71 | + <Badge variant="outline">{connection.kind}</Badge> | |
| 72 | + <DropdownMenu> | |
| 73 | + <DropdownMenuTrigger asChild> | |
| 74 | + <Button | |
| 75 | + variant="ghost" | |
| 76 | + size="icon" | |
| 77 | + className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100" | |
| 78 | + > | |
| 79 | + <svg viewBox="0 0 16 16" className="size-3"> | |
| 80 | + <circle cx="8" cy="3" r="1.2" fill="currentColor" /> | |
| 81 | + <circle cx="8" cy="8" r="1.2" fill="currentColor" /> | |
| 82 | + <circle cx="8" cy="13" r="1.2" fill="currentColor" /> | |
| 83 | + </svg> | |
| 84 | + </Button> | |
| 85 | + </DropdownMenuTrigger> | |
| 86 | + <DropdownMenuContent align="end" className="w-40"> | |
| 87 | + <DropdownMenuItem | |
| 88 | + className="text-destructive focus:text-destructive text-sm" | |
| 89 | + onClick={props.onRemove} | |
| 90 | + > | |
| 91 | + Remove | |
| 92 | + </DropdownMenuItem> | |
| 93 | + </DropdownMenuContent> | |
| 94 | + </DropdownMenu> | |
| 95 | + </CardStackEntryActions> | |
| 96 | + </CardStackEntry> | |
| 97 | + ); | |
| 98 | +} | |
| 99 | + | |
| 100 | +// --------------------------------------------------------------------------- | |
| 101 | +// Page | |
| 102 | +// --------------------------------------------------------------------------- | |
| 103 | + | |
| 104 | +export function ConnectionsPage() { | |
| 105 | + const scopeId = useScope(); | |
| 106 | + const connections = useAtomValue(connectionsAtom(scopeId)); | |
| 107 | + const doRemove = useAtomSet(removeConnection, { mode: "promise" }); | |
| 108 | + | |
| 109 | + const handleRemove = async (connectionId: string) => { | |
| 110 | + try { | |
| 111 | + await doRemove({ | |
| 112 | + path: { scopeId, connectionId: ConnectionId.make(connectionId) }, | |
| 113 | + reactivityKeys: connectionWriteKeys, | |
| 114 | + }); | |
| 115 | + } catch { | |
| 116 | + // TODO: toast | |
| 117 | + } | |
| 118 | + }; | |
| 119 | + | |
| 120 | + return ( | |
| 121 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 122 | + <div className="mx-auto max-w-3xl px-6 py-10 lg:px-8 lg:py-14"> | |
| 123 | + <div className="flex items-end justify-between mb-10"> | |
| 124 | + <div> | |
| 125 | + <h1 className="font-display text-[2rem] tracking-tight text-foreground leading-none"> | |
| 126 | + Connections | |
| 127 | + </h1> | |
| 128 | + <p className="mt-2 text-sm text-muted-foreground leading-relaxed"> | |
| 129 | + Signed-in accounts your sources use to call their APIs. | |
| 130 | + Remove a connection to revoke access and drop its tokens. | |
| 131 | + </p> | |
| 132 | + </div> | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + {Result.match(connections, { | |
| 136 | + onInitial: () => ( | |
| 137 | + <div className="flex items-center gap-2 py-8"> | |
| 138 | + <div className="size-1.5 rounded-full bg-muted-foreground/30 animate-pulse" /> | |
| 139 | + <p className="text-sm text-muted-foreground"> | |
| 140 | + Loading connections… | |
| 141 | + </p> | |
| 142 | + </div> | |
| 143 | + ), | |
| 144 | + onFailure: () => ( | |
| 145 | + <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3"> | |
| 146 | + <p className="text-sm text-destructive"> | |
| 147 | + Failed to load connections | |
| 148 | + </p> | |
| 149 | + </div> | |
| 150 | + ), | |
| 151 | + onSuccess: ({ value }) => ( | |
| 152 | + <CardStack> | |
| 153 | + <CardStackHeader>Connections</CardStackHeader> | |
| 154 | + <CardStackContent> | |
| 155 | + {value.length === 0 ? ( | |
| 156 | + <CardStackEntry> | |
| 157 | + <CardStackEntryContent> | |
| 158 | + <CardStackEntryDescription> | |
| 159 | + No signed-in accounts yet. Add an OAuth source and | |
| 160 | + its sign-in will appear here. | |
| 161 | + </CardStackEntryDescription> | |
| 162 | + </CardStackEntryContent> | |
| 163 | + </CardStackEntry> | |
| 164 | + ) : ( | |
| 165 | + value.map((c) => ( | |
| 166 | + <ConnectionRow | |
| 167 | + key={c.id} | |
| 168 | + connection={{ | |
| 169 | + id: c.id, | |
| 170 | + provider: c.provider, | |
| 171 | + identityLabel: c.identityLabel, | |
| 172 | + kind: c.kind, | |
| 173 | + }} | |
| 174 | + onRemove={() => handleRemove(c.id)} | |
| 175 | + /> | |
| 176 | + )) | |
| 177 | + )} | |
| 178 | + </CardStackContent> | |
| 179 | + </CardStack> | |
| 180 | + ), | |
| 181 | + })} | |
| 182 | + </div> | |
| 183 | + </div> | |
| 184 | + ); | |
| 185 | +} | |
| @@ -127,6 +127,12 @@ export function SourceDetailPage(props: { · 126 unmodified lines | ||
| 127 | 127 | </div> |
| 128 | 128 | |
| 129 | 129 | <div className="flex shrink-0 items-center gap-2"> |
| 130 | + {editPlugin?.signIn && !editing && ( | |
| 131 | + <Suspense fallback={null}> | |
| 132 | + <editPlugin.signIn sourceId={namespace} /> | |
| 133 | + </Suspense> | |
| 134 | + )} | |
| 135 | + | |
| 130 | 136 | {canEdit && editPlugin && !editing && ( |
| 131 | 137 | <Button variant="outline" size="sm" onClick={() => setEditing(true)}> |
| 132 | 138 | Edit |
| @@ -74,6 +74,19 @@ export interface SourcePlugin { · 73 unmodified lines | ||
| 74 | 74 | readonly sourceId: string; |
| 75 | 75 | }>; |
| 76 | 76 | |
| 77 | + /** | |
| 78 | + * Optional top-bar action for signing in / reconnecting to the source. | |
| 79 | + * Rendered by the shell next to Edit when present. Plugins that don't | |
| 80 | + * support interactive sign-in (no OAuth etc.) simply omit this. | |
| 81 | + * | |
| 82 | + * Deliberately separate from `edit` — edit is source config, sign-in is | |
| 83 | + * the user-level connection lifecycle. A source can be valid-to-edit | |
| 84 | + * while its sign-in is expired / missing, and vice versa. | |
| 85 | + */ | |
| 86 | + readonly signIn?: ComponentType<{ | |
| 87 | + readonly sourceId: string; | |
| 88 | + }>; | |
| 89 | + | |
| 77 | 90 | /** Curated presets shown on the sources page for quick-add */ |
| 78 | 91 | readonly presets?: readonly SourcePreset[]; |
| 79 | 92 | } |