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 | "when": 1776709954401, | 22 | "when": 1776709954401, |
| 23 | "tag": "0002_fat_white_tiger", | 23 | "tag": "0002_fat_white_tiger", |
| 24 | "breakpoints": true | 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 | import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform"; | 1 | import { HttpApiBuilder, HttpMiddleware, HttpRouter, HttpServer } from "@effect/platform"; |
| 2 | import { Effect, Layer } from "effect"; | 2 | import { Effect, Layer } from "effect"; |
| 3 | 3 | ||
| 4 | import { CoreExecutorApi } from "@executor/api"; | 4 | import { CoreExecutorApi, InternalError, observabilityMiddleware } from "@executor/api"; |
| 5 | import { CoreHandlers } from "@executor/api/server"; | 5 | import { CoreHandlers } from "@executor/api/server"; |
| 6 | import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api"; | 6 | import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api"; |
| 7 | import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api"; | 7 | import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api"; |
| @@ -19,6 +19,7 @@ import { DbService } from "../services/db"; · 11 unmodified lines | |||
| 19 | import { TelemetryLive } from "../services/telemetry"; | 19 | import { TelemetryLive } from "../services/telemetry"; |
| 20 | import { OrgHttpApi } from "../org/compose"; | 20 | import { OrgHttpApi } from "../org/compose"; |
| 21 | import { OrgHandlers } from "../org/handlers"; | 21 | import { OrgHandlers } from "../org/handlers"; |
| 22 | import { ErrorCaptureLive } from "../observability"; | ||
| 22 | 23 | ||
| 23 | import { CoreSharedServices } from "./core-shared-services"; | 24 | import { CoreSharedServices } from "./core-shared-services"; |
| 24 | 25 | ||
| @@ -27,8 +28,11 @@ export { CoreSharedServices }; · 2 unmodified lines | |||
| 27 | const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) | 28 | const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) |
| 28 | .add(McpGroup) | 29 | .add(McpGroup) |
| 29 | .add(GraphqlGroup) | 30 | .add(GraphqlGroup) |
| 31 | .addError(InternalError) | ||
| 30 | .middleware(OrgAuth); | 32 | .middleware(OrgAuth); |
| 31 | 33 | ||
| 34 | const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); | ||
| 35 | |||
| 32 | const DbLive = DbService.Live; | 36 | const DbLive = DbService.Live; |
| 33 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); | 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 | McpHandlers, | 54 | McpHandlers, |
| 51 | GraphqlHandlers, | 55 | GraphqlHandlers, |
| 52 | OrgAuthLive, | 56 | OrgAuthLive, |
| 57 | ObservabilityLive, | ||
| 53 | ), | 58 | ), |
| 54 | ), | 59 | ), |
| 60 | Layer.provide(ErrorCaptureLive), | ||
| 55 | ); | 61 | ); |
| 56 | 62 | ||
| 57 | const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe( | 63 | const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe( |
| @@ -15,17 +15,28 @@ import { Cause, Effect, Layer } from "effect"; · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | import { ErrorCapture } from "@executor/api"; | 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 | export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed( | 30 | export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed( |
| 19 | ErrorCapture, | 31 | ErrorCapture, |
| 20 | ErrorCapture.of({ | 32 | ErrorCapture.of({ |
| 21 | captureException: (cause) => | 33 | captureException: (cause) => |
| 22 | Effect.sync(() => { | 34 | Effect.sync(() => { |
| 23 | const squashed = Cause.squash(cause); | 35 | const squashed = Cause.squash(cause); |
| 24 | // Mirror to console so dev terminals see the defect; Sentry gets the | 36 | const pretty = Cause.pretty(cause); |
| 25 | // full structured cause for production correlation. | 37 | console.error("[api] unhandled cause:", truncate(pretty)); |
| 26 | console.error("[api] unhandled cause:", Cause.pretty(cause)); | ||
| 27 | const eventId = Sentry.captureException(squashed, (scope) => { | 38 | const eventId = Sentry.captureException(squashed, (scope) => { |
| 28 | scope.setExtra("cause", Cause.pretty(cause)); | 39 | scope.setExtra("cause", pretty); |
| 29 | return scope; | 40 | return scope; |
| 30 | }); | 41 | }); |
| 31 | return eventId ?? ""; | 42 | return eventId ?? ""; |
| @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines | |||
| 12 | import { Route as ToolsRouteImport } from './routes/tools' | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | import { Route as SecretsRouteImport } from './routes/secrets' | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | import { Route as OrgRouteImport } from './routes/org' | 14 | import { Route as OrgRouteImport } from './routes/org' |
| 15 | import { Route as ConnectionsRouteImport } from './routes/connections' | ||
| 15 | import { Route as BillingRouteImport } from './routes/billing' | 16 | import { Route as BillingRouteImport } from './routes/billing' |
| 16 | import { Route as IndexRouteImport } from './routes/index' | 17 | import { Route as IndexRouteImport } from './routes/index' |
| 17 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' | 18 | import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' |
| @@ -33,6 +34,11 @@ const OrgRoute = OrgRouteImport.update({ · 15 unmodified lines | |||
| 33 | path: '/org', | 34 | path: '/org', |
| 34 | getParentRoute: () => rootRouteImport, | 35 | getParentRoute: () => rootRouteImport, |
| 35 | } as any) | 36 | } as any) |
| 37 | const ConnectionsRoute = ConnectionsRouteImport.update({ | ||
| 38 | id: '/connections', | ||
| 39 | path: '/connections', | ||
| 40 | getParentRoute: () => rootRouteImport, | ||
| 41 | } as any) | ||
| 36 | const BillingRoute = BillingRouteImport.update({ | 42 | const BillingRoute = BillingRouteImport.update({ |
| 37 | id: '/billing', | 43 | id: '/billing', |
| 38 | path: '/billing', | 44 | path: '/billing', |
| @@ -62,6 +68,7 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ · 23 unmodified lines | |||
| 62 | export interface FileRoutesByFullPath { | 68 | export interface FileRoutesByFullPath { |
| 63 | '/': typeof IndexRoute | 69 | '/': typeof IndexRoute |
| 64 | '/billing': typeof BillingRoute | 70 | '/billing': typeof BillingRoute |
| 71 | '/connections': typeof ConnectionsRoute | ||
| 65 | '/org': typeof OrgRoute | 72 | '/org': typeof OrgRoute |
| 66 | '/secrets': typeof SecretsRoute | 73 | '/secrets': typeof SecretsRoute |
| 67 | '/tools': typeof ToolsRoute | 74 | '/tools': typeof ToolsRoute |
| @@ -72,6 +79,7 @@ export interface FileRoutesByFullPath { · 4 unmodified lines | |||
| 72 | export interface FileRoutesByTo { | 79 | export interface FileRoutesByTo { |
| 73 | '/': typeof IndexRoute | 80 | '/': typeof IndexRoute |
| 74 | '/billing': typeof BillingRoute | 81 | '/billing': typeof BillingRoute |
| 82 | '/connections': typeof ConnectionsRoute | ||
| 75 | '/org': typeof OrgRoute | 83 | '/org': typeof OrgRoute |
| 76 | '/secrets': typeof SecretsRoute | 84 | '/secrets': typeof SecretsRoute |
| 77 | '/tools': typeof ToolsRoute | 85 | '/tools': typeof ToolsRoute |
| @@ -83,6 +91,7 @@ export interface FileRoutesById { · 5 unmodified lines | |||
| 83 | __root__: typeof rootRouteImport | 91 | __root__: typeof rootRouteImport |
| 84 | '/': typeof IndexRoute | 92 | '/': typeof IndexRoute |
| 85 | '/billing': typeof BillingRoute | 93 | '/billing': typeof BillingRoute |
| 94 | '/connections': typeof ConnectionsRoute | ||
| 86 | '/org': typeof OrgRoute | 95 | '/org': typeof OrgRoute |
| 87 | '/secrets': typeof SecretsRoute | 96 | '/secrets': typeof SecretsRoute |
| 88 | '/tools': typeof ToolsRoute | 97 | '/tools': typeof ToolsRoute |
| @@ -95,6 +104,7 @@ export interface FileRouteTypes { · 6 unmodified lines | |||
| 95 | fullPaths: | 104 | fullPaths: |
| 96 | | '/' | 105 | | '/' |
| 97 | | '/billing' | 106 | | '/billing' |
| 107 | | '/connections' | ||
| 98 | | '/org' | 108 | | '/org' |
| 99 | | '/secrets' | 109 | | '/secrets' |
| 100 | | '/tools' | 110 | | '/tools' |
| @@ -105,6 +115,7 @@ export interface FileRouteTypes { · 4 unmodified lines | |||
| 105 | to: | 115 | to: |
| 106 | | '/' | 116 | | '/' |
| 107 | | '/billing' | 117 | | '/billing' |
| 118 | | '/connections' | ||
| 108 | | '/org' | 119 | | '/org' |
| 109 | | '/secrets' | 120 | | '/secrets' |
| 110 | | '/tools' | 121 | | '/tools' |
| @@ -115,6 +126,7 @@ export interface FileRouteTypes { · 4 unmodified lines | |||
| 115 | | '__root__' | 126 | | '__root__' |
| 116 | | '/' | 127 | | '/' |
| 117 | | '/billing' | 128 | | '/billing' |
| 129 | | '/connections' | ||
| 118 | | '/org' | 130 | | '/org' |
| 119 | | '/secrets' | 131 | | '/secrets' |
| 120 | | '/tools' | 132 | | '/tools' |
| @@ -126,6 +138,7 @@ export interface FileRouteTypes { · 5 unmodified lines | |||
| 126 | export interface RootRouteChildren { | 138 | export interface RootRouteChildren { |
| 127 | IndexRoute: typeof IndexRoute | 139 | IndexRoute: typeof IndexRoute |
| 128 | BillingRoute: typeof BillingRoute | 140 | BillingRoute: typeof BillingRoute |
| 141 | ConnectionsRoute: typeof ConnectionsRoute | ||
| 129 | OrgRoute: typeof OrgRoute | 142 | OrgRoute: typeof OrgRoute |
| 130 | SecretsRoute: typeof SecretsRoute | 143 | SecretsRoute: typeof SecretsRoute |
| 131 | ToolsRoute: typeof ToolsRoute | 144 | ToolsRoute: typeof ToolsRoute |
| @@ -157,6 +170,13 @@ declare module '@tanstack/react-router' { · 25 unmodified lines | |||
| 157 | preLoaderRoute: typeof OrgRouteImport | 170 | preLoaderRoute: typeof OrgRouteImport |
| 158 | parentRoute: typeof rootRouteImport | 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 | '/billing': { | 180 | '/billing': { |
| 161 | id: '/billing' | 181 | id: '/billing' |
| 162 | path: '/billing' | 182 | path: '/billing' |
| @@ -198,6 +218,7 @@ declare module '@tanstack/react-router' { · 35 unmodified lines | |||
| 198 | const rootRouteChildren: RootRouteChildren = { | 218 | const rootRouteChildren: RootRouteChildren = { |
| 199 | IndexRoute: IndexRoute, | 219 | IndexRoute: IndexRoute, |
| 200 | BillingRoute: BillingRoute, | 220 | BillingRoute: BillingRoute, |
| 221 | ConnectionsRoute: ConnectionsRoute, | ||
| 201 | OrgRoute: OrgRoute, | 222 | OrgRoute: OrgRoute, |
| 202 | SecretsRoute: SecretsRoute, | 223 | SecretsRoute: SecretsRoute, |
| 203 | ToolsRoute: ToolsRoute, | 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 | scope_id: text('scope_id').notNull(), | 56 | scope_id: text('scope_id').notNull(), |
| 57 | name: text('name').notNull(), | 57 | name: text('name').notNull(), |
| 58 | provider: text('provider').notNull(), | 58 | provider: text('provider').notNull(), |
| 59 | owned_by_connection_id: text('owned_by_connection_id'), | ||
| 59 | created_at: timestamp('created_at').notNull() | 60 | created_at: timestamp('created_at').notNull() |
| 60 | }, (table) => [ | 61 | }, (table) => [ |
| 61 | primaryKey({ columns: [table.scope_id, table.id] }), | 62 | primaryKey({ columns: [table.scope_id, table.id] }), |
| 62 | index("secret_scope_id_idx").on(table.scope_id), | 63 | index("secret_scope_id_idx").on(table.scope_id), |
| 63 | index("secret_provider_idx").on(table.provider), | 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 | export const openapi_source = pgTable("openapi_source", { | 87 | export const openapi_source = pgTable("openapi_source", { |
| @@ -15,16 +15,16 @@ · 14 unmodified lines | |||
| 15 | // Two scenarios: | 15 | // Two scenarios: |
| 16 | // | 16 | // |
| 17 | // 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts | 17 | // 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts |
| 18 | // tokens landed in the invoker's secret store under the stable | 18 | // the response carries the Connection id the exchange minted and |
| 19 | // `mcp_<ns>_*` ids and that source-level OAuth state (clientInfo, | 19 | // that source-level OAuth state (clientInfo, AS URL) is echoed back |
| 20 | // AS URL) is echoed back on the response. | 20 | // so subsequent users can skip DCR. |
| 21 | // | 21 | // |
| 22 | // 2. Two users, same source: second user's startOAuth passes the | 22 | // 2. Two users, same source: second user's startOAuth passes the |
| 23 | // clientInformation the first user's completeOAuth returned. The | 23 | // clientInformation the first user's completeOAuth returned. The |
| 24 | // DCR endpoint counter on the fake server must NOT tick — proof | 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 | 25 | // the DCR-hoist code path re-uses the existing client. Both users |
| 26 | // users' tokens must resolve independently at their own scope | 26 | // end up with their own Connection (same id, different scope) via |
| 27 | // despite sharing the same secret id. | 27 | // the SDK's innermost-wins shadowing. |
| 28 | // --------------------------------------------------------------------------- | 28 | // --------------------------------------------------------------------------- |
| 29 | 29 | ||
| 30 | import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; | 30 | import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; |
| @@ -33,7 +33,7 @@ import type { AddressInfo } from "node:net"; · 2 unmodified lines | |||
| 33 | import { createHash, randomBytes } from "node:crypto"; | 33 | import { createHash, randomBytes } from "node:crypto"; |
| 34 | 34 | ||
| 35 | import { Effect } from "effect"; | 35 | import { Effect } from "effect"; |
| 36 | import { ScopeId, SecretId } from "@executor/sdk"; | 36 | import { ScopeId } from "@executor/sdk"; |
| 37 | 37 | ||
| 38 | import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness"; | 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 | const userId = `user_${crypto.randomUUID()}`; | 282 | const userId = `user_${crypto.randomUUID()}`; |
| 283 | const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId)); | 283 | const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId)); |
| 284 | const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; | 284 | const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; |
| 285 | const accessTokenSecretId = `mcp_${namespace}_access_token`; | 285 | const connectionId = `mcp-oauth2-${namespace}`; |
| 286 | const refreshTokenSecretId = `mcp_${namespace}_refresh_token`; | ||
| 287 | const redirectUrl = "http://test.local/api/mcp/oauth/callback"; | 286 | const redirectUrl = "http://test.local/api/mcp/oauth/callback"; |
| 288 | 287 | ||
| 289 | const started = yield* asUser(userId, orgId, (client) => | 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 | payload: { | 291 | payload: { |
| 293 | endpoint: `${fake.url}/mcp`, | 292 | endpoint: `${fake.url}/mcp`, |
| 294 | redirectUrl, | 293 | redirectUrl, |
| 295 | accessTokenSecretId, | 294 | connectionId, |
| 296 | refreshTokenSecretId, | ||
| 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 | payload: { state, code }, | 308 | payload: { state, code }, |
| 311 | }), | 309 | }), |
| 312 | ); | 310 | ); |
| 313 | expect(completed.accessTokenSecretId).toBe(accessTokenSecretId); | 311 | expect(completed.connectionId).toBe(connectionId); |
| 314 | expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId); | ||
| 315 | expect(completed.tokenType).toBe("Bearer"); | 312 | expect(completed.tokenType).toBe("Bearer"); |
| 316 | // DCR hoist: the source-level state the UI will persist on the | 313 | // DCR hoist: the source-level state the UI will persist on the |
| 317 | // source's auth config, so the next user skips DCR entirely. | 314 | // source's auth config, so the next user skips DCR entirely. |
| 318 | expect(completed.clientInformation).not.toBeNull(); | 315 | expect(completed.clientInformation).not.toBeNull(); |
| 319 | expect(completed.authorizationServerUrl).not.toBeNull(); | 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 | 30_000, | 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 | const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId)); | 328 | const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId)); |
| 353 | const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId)); | 329 | const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId)); |
| 354 | const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; | 330 | const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; |
| 355 | const accessTokenSecretId = `mcp_${namespace}_access_token`; | 331 | const connectionId = `mcp-oauth2-${namespace}`; |
| 356 | const refreshTokenSecretId = `mcp_${namespace}_refresh_token`; | ||
| 357 | const endpoint = `${fake.url}/mcp`; | 332 | const endpoint = `${fake.url}/mcp`; |
| 358 | const redirectUrl = "http://test.local/api/mcp/oauth/callback"; | 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 | payload: { | 341 | payload: { |
| 367 | endpoint, | 342 | endpoint, |
| 368 | redirectUrl, | 343 | redirectUrl, |
| 369 | accessTokenSecretId, | 344 | connectionId, |
| 370 | refreshTokenSecretId, | ||
| 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 | payload: { state: redirA.state, code: redirA.code }, | 354 | payload: { state: redirA.state, code: redirA.code }, |
| 381 | }), | 355 | }), |
| 382 | ); | 356 | ); |
| 357 | expect(completedA.connectionId).toBe(connectionId); | ||
| 383 | expect(completedA.clientInformation).not.toBeNull(); | 358 | expect(completedA.clientInformation).not.toBeNull(); |
| 384 | expect(fake.registrations()).toBe(regsBefore + 1); | 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 | payload: { | 365 | payload: { |
| 391 | endpoint, | 366 | endpoint, |
| 392 | redirectUrl, | 367 | redirectUrl, |
| 393 | accessTokenSecretId, | 368 | connectionId, |
| 394 | refreshTokenSecretId, | ||
| 395 | clientInformation: completedA.clientInformation, | 369 | clientInformation: completedA.clientInformation, |
| 396 | authorizationServerUrl: completedA.authorizationServerUrl, | 370 | authorizationServerUrl: completedA.authorizationServerUrl, |
| 397 | resourceMetadataUrl: completedA.resourceMetadataUrl, | 371 | resourceMetadataUrl: completedA.resourceMetadataUrl, |
| @@ -401,28 +375,14 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { · 3 unmodified lines | |||
| 401 | const redirB = yield* Effect.promise(() => | 375 | const redirB = yield* Effect.promise(() => |
| 402 | followAuthorize(startedB.authorizationUrl), | 376 | followAuthorize(startedB.authorizationUrl), |
| 403 | ); | 377 | ); |
| 404 | yield* asUser(userB, orgId, (client) => | 378 | const completedB = yield* asUser(userB, orgId, (client) => |
| 405 | client.mcp.completeOAuth({ | 379 | client.mcp.completeOAuth({ |
| 406 | path: { scopeId: scopeB }, | 380 | path: { scopeId: scopeB }, |
| 407 | payload: { state: redirB.state, code: redirB.code }, | 381 | payload: { state: redirB.state, code: redirB.code }, |
| 408 | }), | 382 | }), |
| 409 | ); | 383 | ); |
| 384 | expect(completedB.connectionId).toBe(connectionId); | ||
| 410 | expect(fake.registrations()).toBe(regsBefore + 1); | 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 | 30_000, | 387 | 30_000, |
| 428 | ); | 388 | ); |
| @@ -360,6 +360,7 @@ function UserFooter() { · 359 unmodified lines | |||
| 360 | function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { | 360 | function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) { |
| 361 | const isHome = props.pathname === "/"; | 361 | const isHome = props.pathname === "/"; |
| 362 | const isSecrets = props.pathname === "/secrets"; | 362 | const isSecrets = props.pathname === "/secrets"; |
| 363 | const isConnections = props.pathname === "/connections"; | ||
| 363 | const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); | 364 | const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); |
| 364 | const isOrg = props.pathname === "/org"; | 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 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> | 377 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> |
| 377 | <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} /> | 378 | <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} /> |
| 379 | <NavItem to="/connections" label="Connections" active={isConnections} onNavigate={props.onNavigate} /> | ||
| 378 | <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} /> | 380 | <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} /> |
| 379 | <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} /> | 381 | <NavItem to="/org" label="Organization" active={isOrg} onNavigate={props.onNavigate} /> |
| 380 | <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} /> | 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 | "when": 1776680432025, | 15 | "when": 1776680432025, |
| 16 | "tag": "0001_sour_catseye", | 16 | "tag": "0001_sour_catseye", |
| 17 | "breakpoints": true | 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 | scope_id: text('scope_id').notNull(), | 56 | scope_id: text('scope_id').notNull(), |
| 57 | name: text('name').notNull(), | 57 | name: text('name').notNull(), |
| 58 | provider: text('provider').notNull(), | 58 | provider: text('provider').notNull(), |
| 59 | owned_by_connection_id: text('owned_by_connection_id'), | ||
| 59 | created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull() | 60 | created_at: integer('created_at', { mode: 'timestamp_ms' }).notNull() |
| 60 | }, (table) => [ | 61 | }, (table) => [ |
| 61 | primaryKey({ columns: [table.scope_id, table.id] }), | 62 | primaryKey({ columns: [table.scope_id, table.id] }), |
| 62 | index("secret_scope_id_idx").on(table.scope_id), | 63 | index("secret_scope_id_idx").on(table.scope_id), |
| 63 | index("secret_provider_idx").on(table.provider), | 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 | export const openapi_source = sqliteTable("openapi_source", { | 87 | export const openapi_source = sqliteTable("openapi_source", { |
| @@ -55,7 +55,6 @@ import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; · 54 unmodified lines | |||
| 55 | import { | 55 | import { |
| 56 | Scope, | 56 | Scope, |
| 57 | ScopeId, | 57 | ScopeId, |
| 58 | SecretId, | ||
| 59 | collectSchemas, | 58 | collectSchemas, |
| 60 | createExecutor, | 59 | createExecutor, |
| 61 | } from "@executor/sdk"; | 60 | } from "@executor/sdk"; |
| @@ -382,14 +381,13 @@ const followAuthorize = async ( · 320 unmodified lines | |||
| 382 | // --------------------------------------------------------------------------- | 381 | // --------------------------------------------------------------------------- |
| 383 | 382 | ||
| 384 | describe("local mcp oauth (real OAuth + MCP server)", () => { | 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 | const clientLayer = FetchHttpClient.layer.pipe( | 385 | const clientLayer = FetchHttpClient.layer.pipe( |
| 387 | Layer.provide(Layer.succeed(FetchHttpClient.Fetch, harness.fetch)), | 386 | Layer.provide(Layer.succeed(FetchHttpClient.Fetch, harness.fetch)), |
| 388 | ); | 387 | ); |
| 389 | 388 | ||
| 390 | const namespace = `ns_${randomBytes(4).toString("hex")}`; | 389 | const namespace = `ns_${randomBytes(4).toString("hex")}`; |
| 391 | const accessTokenSecretId = `mcp_${namespace}_access_token`; | 390 | const connectionId = `mcp-oauth2-${namespace}`; |
| 392 | const refreshTokenSecretId = `mcp_${namespace}_refresh_token`; | ||
| 393 | const redirectUrl = "http://local.test/api/mcp/oauth/callback"; | 391 | const redirectUrl = "http://local.test/api/mcp/oauth/callback"; |
| 394 | const scopeId = ScopeId.make(harness.scopeId); | 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 | payload: { | 408 | payload: { |
| 411 | endpoint: `${fake.url}/mcp`, | 409 | endpoint: `${fake.url}/mcp`, |
| 412 | redirectUrl, | 410 | redirectUrl, |
| 413 | accessTokenSecretId, | 411 | connectionId, |
| 414 | refreshTokenSecretId, | ||
| 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); | 429 | expect(completed.connectionId).toBe(connectionId); |
| 433 | expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId); | ||
| 434 | expect(completed.tokenType).toBe("Bearer"); | 430 | expect(completed.tokenType).toBe("Bearer"); |
| 435 | expect(completed.clientInformation).not.toBeNull(); | 431 | expect(completed.clientInformation).not.toBeNull(); |
| 436 | expect(completed.authorizationServerUrl).not.toBeNull(); | 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 | }, 30_000); | 433 | }, 30_000); |
| 458 | }); | 434 | }); |
| @@ -614,6 +614,7 @@ · 613 unmodified lines | |||
| 614 | "@effect/platform": "catalog:", | 614 | "@effect/platform": "catalog:", |
| 615 | "@effect/platform-node": "catalog:", | 615 | "@effect/platform-node": "catalog:", |
| 616 | "@executor/config": "workspace:*", | 616 | "@executor/config": "workspace:*", |
| 617 | "@executor/plugin-oauth2": "workspace:*", | ||
| 617 | "@executor/sdk": "workspace:*", | 618 | "@executor/sdk": "workspace:*", |
| 618 | "@modelcontextprotocol/sdk": "^1.29.0", | 619 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 619 | "effect": "catalog:", | 620 | "effect": "catalog:", |
| @@ -4,6 +4,7 @@ import type { HttpApiGroup } from "@effect/platform"; · 3 unmodified lines | |||
| 4 | import { ToolsApi } from "./tools/api"; | 4 | import { ToolsApi } from "./tools/api"; |
| 5 | import { SourcesApi } from "./sources/api"; | 5 | import { SourcesApi } from "./sources/api"; |
| 6 | import { SecretsApi } from "./secrets/api"; | 6 | import { SecretsApi } from "./secrets/api"; |
| 7 | import { ConnectionsApi } from "./connections/api"; | ||
| 7 | import { ExecutionsApi } from "./executions/api"; | 8 | import { ExecutionsApi } from "./executions/api"; |
| 8 | import { ScopeApi } from "./scope/api"; | 9 | import { ScopeApi } from "./scope/api"; |
| 9 | 10 | ||
| @@ -11,6 +12,7 @@ export const CoreExecutorApi = HttpApi.make("executor") · 1 unmodified line | |||
| 11 | .add(ToolsApi) | 12 | .add(ToolsApi) |
| 12 | .add(SourcesApi) | 13 | .add(SourcesApi) |
| 13 | .add(SecretsApi) | 14 | .add(SecretsApi) |
| 15 | .add(ConnectionsApi) | ||
| 14 | .add(ExecutionsApi) | 16 | .add(ExecutionsApi) |
| 15 | .add(ScopeApi) | 17 | .add(ScopeApi) |
| 16 | .annotateContext( | 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 | import { ToolsHandlers } from "./tools"; | 3 | import { ToolsHandlers } from "./tools"; |
| 4 | import { SourcesHandlers } from "./sources"; | 4 | import { SourcesHandlers } from "./sources"; |
| 5 | import { SecretsHandlers } from "./secrets"; | 5 | import { SecretsHandlers } from "./secrets"; |
| 6 | import { ConnectionsHandlers } from "./connections"; | ||
| 6 | import { ScopeHandlers } from "./scope"; | 7 | import { ScopeHandlers } from "./scope"; |
| 7 | import { ExecutionsHandlers } from "./executions"; | 8 | import { ExecutionsHandlers } from "./executions"; |
| 8 | 9 | ||
| 9 | export { ToolsHandlers } from "./tools"; | 10 | export { ToolsHandlers } from "./tools"; |
| 10 | export { SourcesHandlers } from "./sources"; | 11 | export { SourcesHandlers } from "./sources"; |
| 11 | export { SecretsHandlers } from "./secrets"; | 12 | export { SecretsHandlers } from "./secrets"; |
| 13 | export { ConnectionsHandlers } from "./connections"; | ||
| 12 | export { ScopeHandlers } from "./scope"; | 14 | export { ScopeHandlers } from "./scope"; |
| 13 | export { ExecutionsHandlers } from "./executions"; | 15 | export { ExecutionsHandlers } from "./executions"; |
| 14 | 16 | ||
| @@ -16,6 +18,7 @@ export const CoreHandlers = Layer.mergeAll( · 1 unmodified line | |||
| 16 | ToolsHandlers, | 18 | ToolsHandlers, |
| 17 | SourcesHandlers, | 19 | SourcesHandlers, |
| 18 | SecretsHandlers, | 20 | SecretsHandlers, |
| 21 | ConnectionsHandlers, | ||
| 19 | ScopeHandlers, | 22 | ScopeHandlers, |
| 20 | ExecutionsHandlers, | 23 | ExecutionsHandlers, |
| 21 | ); | 24 | ); |
| @@ -2,6 +2,7 @@ export { ExecutorApi, CoreExecutorApi, addGroup } from "./api"; · 1 unmodified line | |||
| 2 | export { ToolsApi } from "./tools/api"; | 2 | export { ToolsApi } from "./tools/api"; |
| 3 | export { SourcesApi } from "./sources/api"; | 3 | export { SourcesApi } from "./sources/api"; |
| 4 | export { SecretsApi } from "./secrets/api"; | 4 | export { SecretsApi } from "./secrets/api"; |
| 5 | export { ConnectionsApi } from "./connections/api"; | ||
| 5 | export { ExecutionsApi } from "./executions/api"; | 6 | export { ExecutionsApi } from "./executions/api"; |
| 6 | export { ScopeApi } from "./scope/api"; | 7 | export { ScopeApi } from "./scope/api"; |
| 7 | export { | 8 | export { |
| @@ -1,6 +1,12 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { Schema } from "effect"; | 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 | import { InternalError } from "../observability"; | 11 | import { InternalError } from "../observability"; |
| 6 | 12 | ||
| @@ -48,6 +54,9 @@ const SecretNotFound = SecretNotFoundError.annotations(HttpApiSchema.annotations · 41 unmodified lines | |||
| 48 | const SecretResolution = SecretResolutionError.annotations( | 54 | const SecretResolution = SecretResolutionError.annotations( |
| 49 | HttpApiSchema.annotations({ status: 500 }), | 55 | HttpApiSchema.annotations({ status: 500 }), |
| 50 | ); | 56 | ); |
| 57 | const SecretOwnedByConnection = SecretOwnedByConnectionError.annotations( | ||
| 58 | HttpApiSchema.annotations({ status: 409 }), | ||
| 59 | ); | ||
| 51 | 60 | ||
| 52 | // --------------------------------------------------------------------------- | 61 | // --------------------------------------------------------------------------- |
| 53 | // Group | 62 | // Group |
| @@ -79,6 +88,7 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 25 unmodified lines | |||
| 79 | .add( | 88 | .add( |
| 80 | HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}` | 89 | HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}` |
| 81 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | 90 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) |
| 82 | .addError(SecretNotFound), | 91 | .addError(SecretNotFound) |
| 92 | .addError(SecretOwnedByConnection), | ||
| 83 | ) | 93 | ) |
| 84 | .addError(InternalError) {} | 94 | .addError(InternalError) {} |
| @@ -59,9 +59,10 @@ export const McpAuthConfig = Schema.Union( · 58 unmodified lines | |||
| 59 | }), | 59 | }), |
| 60 | Schema.Struct({ | 60 | Schema.Struct({ |
| 61 | kind: Schema.Literal("oauth2"), | 61 | kind: Schema.Literal("oauth2"), |
| 62 | accessTokenSecret: Schema.String, | 62 | /** Stable id of the SDK Connection holding access + refresh token |
| 63 | refreshTokenSecret: Schema.optional(Schema.NullOr(Schema.String)), | 63 | * material. Scope shadowing means the same id resolves per-user |
| 64 | tokenType: Schema.optional(Schema.String), | 64 | * via the executor's innermost-wins lookup. */ |
| 65 | connectionId: Schema.String, | ||
| 65 | }), | 66 | }), |
| 66 | ); | 67 | ); |
| 67 | export type McpAuthConfig = typeof McpAuthConfig.Type; | 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 | // provider key). Actual values never touch this table — they live in | 84 | // provider key). Actual values never touch this table — they live in |
| 85 | // the secret provider (keychain, 1password, file, etc.) and are | 85 | // the secret provider (keychain, 1password, file, etc.) and are |
| 86 | // resolved on demand via `ctx.secrets.get(id)`. | 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 | secret: { | 95 | secret: { |
| 88 | fields: { | 96 | fields: { |
| 89 | id: { type: "string", required: true }, | 97 | id: { type: "string", required: true }, |
| 90 | scope_id: { type: "string", required: true, index: true }, | 98 | scope_id: { type: "string", required: true, index: true }, |
| 91 | name: { type: "string", required: true }, | 99 | name: { type: "string", required: true }, |
| 92 | provider: { type: "string", required: true, index: true }, | 100 | provider: { type: "string", required: true, index: true }, |
| 101 | owned_by_connection_id: { | ||
| 102 | type: "string", | ||
| 103 | required: false, | ||
| 104 | index: true, | ||
| 105 | }, | ||
| 93 | created_at: { type: "date", required: true }, | 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 | } as const satisfies DBSchema; | 155 | } as const satisfies DBSchema; |
| 97 | 156 | ||
| 98 | export type CoreSchema = typeof coreSchema; | 157 | export type CoreSchema = typeof coreSchema; |
| @@ -116,6 +175,11 @@ export type DefinitionRow = InferDBFieldsOutput< · 17 unmodified lines | |||
| 116 | export type SecretRow = InferDBFieldsOutput<CoreSchema["secret"]["fields"]> & | 175 | export type SecretRow = InferDBFieldsOutput<CoreSchema["secret"]["fields"]> & |
| 117 | Record<string, unknown>; | 176 | Record<string, unknown>; |
| 118 | 177 | ||
| 178 | export type ConnectionRow = InferDBFieldsOutput< | ||
| 179 | CoreSchema["connection"]["fields"] | ||
| 180 | > & | ||
| 181 | Record<string, unknown>; | ||
| 182 | |||
| 119 | // --------------------------------------------------------------------------- | 183 | // --------------------------------------------------------------------------- |
| 120 | // Tool annotations — default-policy metadata the executor consults | 184 | // Tool annotations — default-policy metadata the executor consults |
| 121 | // before invocation. Returned by `plugin.resolveAnnotations` (dynamic | 185 | // before invocation. Returned by `plugin.resolveAnnotations` (dynamic |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { Data, Schema } from "effect"; | 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 | // Tool lifecycle | 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 | // Union type for convenience in signatures. | 115 | // Union type for convenience in signatures. |
| 79 | // --------------------------------------------------------------------------- | 116 | // --------------------------------------------------------------------------- |
| @@ -86,4 +123,8 @@ export type ExecutorError = · 6 unmodified lines | |||
| 86 | | SourceNotFoundError | 123 | | SourceNotFoundError |
| 87 | | SourceRemovalNotAllowedError | 124 | | SourceRemovalNotAllowedError |
| 88 | | SecretNotFoundError | 125 | | SecretNotFoundError |
| 89 | | SecretResolutionError; | 126 | | SecretResolutionError |
| 127 | | SecretOwnedByConnectionError | ||
| 128 | | ConnectionNotFoundError | ||
| 129 | | ConnectionProviderNotRegisteredError | ||
| 130 | | ConnectionRefreshNotSupportedError; | ||
| @@ -13,8 +13,17 @@ import { · 12 unmodified lines | |||
| 13 | pluginBlobStore, | 13 | pluginBlobStore, |
| 14 | type BlobStore, | 14 | type BlobStore, |
| 15 | } from "./blob"; | 15 | } from "./blob"; |
| 16 | import { | ||
| 17 | ConnectionProviderState, | ||
| 18 | ConnectionRef, | ||
| 19 | ConnectionRefreshError, | ||
| 20 | type ConnectionProvider, | ||
| 21 | type CreateConnectionInput, | ||
| 22 | type UpdateConnectionTokensInput, | ||
| 23 | } from "./connections"; | ||
| 16 | import { | 24 | import { |
| 17 | coreSchema, | 25 | coreSchema, |
| 26 | type ConnectionRow, | ||
| 18 | type CoreSchema, | 27 | type CoreSchema, |
| 19 | type DefinitionsInput, | 28 | type DefinitionsInput, |
| 20 | type SourceInput, | 29 | type SourceInput, |
| @@ -30,13 +39,17 @@ import { · 9 unmodified lines | |||
| 30 | type ElicitationRequest, | 39 | type ElicitationRequest, |
| 31 | } from "./elicitation"; | 40 | } from "./elicitation"; |
| 32 | import { | 41 | import { |
| 42 | ConnectionNotFoundError, | ||
| 43 | ConnectionProviderNotRegisteredError, | ||
| 44 | ConnectionRefreshNotSupportedError, | ||
| 33 | NoHandlerError, | 45 | NoHandlerError, |
| 34 | PluginNotLoadedError, | 46 | PluginNotLoadedError, |
| 47 | SecretOwnedByConnectionError, | ||
| 35 | SourceRemovalNotAllowedError, | 48 | SourceRemovalNotAllowedError, |
| 36 | ToolInvocationError, | 49 | ToolInvocationError, |
| 37 | ToolNotFoundError, | 50 | ToolNotFoundError, |
| 38 | } from "./errors"; | 51 | } from "./errors"; |
| 39 | import { ScopeId, SecretId, ToolId } from "./ids"; | 52 | import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids"; |
| 40 | import type { | 53 | import type { |
| 41 | AnyPlugin, | 54 | AnyPlugin, |
| 42 | Elicit, | 55 | Elicit, |
| @@ -168,11 +181,51 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 125 unmodified lines | |||
| 168 | readonly set: ( | 181 | readonly set: ( |
| 169 | input: SetSecretInput, | 182 | input: SetSecretInput, |
| 170 | ) => Effect.Effect<SecretRef, StorageFailure>; | 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 | readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>; | 190 | readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>; |
| 173 | readonly providers: () => Effect.Effect<readonly string[]>; | 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 | readonly close: () => Effect.Effect<void, StorageFailure>; | 229 | readonly close: () => Effect.Effect<void, StorageFailure>; |
| 177 | } & PluginExtensions<TPlugins>; | 230 | } & PluginExtensions<TPlugins>; |
| 178 | 231 | ||
| @@ -547,6 +600,9 @@ export const createExecutor = < · 368 unmodified lines | |||
| 547 | const runtimes = new Map<string, PluginRuntime>(); | 600 | const runtimes = new Map<string, PluginRuntime>(); |
| 548 | // Secret providers keyed by `provider.key`. | 601 | // Secret providers keyed by `provider.key`. |
| 549 | const secretProviders = new Map<string, SecretProvider>(); | 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 | const extensions: Record<string, object> = {}; | 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 | Effect.gen(function* () { | 805 | Effect.gen(function* () { |
| 748 | // Remove is shadowing-aware: drop only the innermost-scope row. | 806 | // Remove is shadowing-aware: drop only the innermost-scope row. |
| 749 | // Removing a user-scope override on a secret that also has an | 807 | // Removing a user-scope override on a secret that also has an |
| @@ -757,6 +815,18 @@ export const createExecutor = < · 7 unmodified lines | |||
| 757 | where: [{ field: "id", value: id }], | 815 | where: [{ field: "id", value: id }], |
| 758 | }); | 816 | }); |
| 759 | const target = findInnermost(rows); | 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 | const targetScope = (target?.scope_id as string | undefined) ?? | 830 | const targetScope = (target?.scope_id as string | undefined) ?? |
| 761 | scopeIds[0]!; | 831 | scopeIds[0]!; |
| 762 | 832 | ||
| @@ -808,8 +878,18 @@ export const createExecutor = < · 45 unmodified lines | |||
| 808 | 878 | ||
| 809 | // Core routing rows first. Adapter returns rows from every | 879 | // Core routing rows first. Adapter returns rows from every |
| 810 | // scope in the stack; resolve collisions using the caller's | 880 | // scope in the stack; resolve collisions using the caller's |
| 811 | // precedence order (innermost first). | 881 | // precedence order (innermost first). Rows owned by a |
| 812 | const rows = yield* core.findMany({ model: "secret" }); | 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 | const precedence = new Map<string, number>(); | 893 | const precedence = new Map<string, number>(); |
| 814 | scopeIds.forEach((id, index) => precedence.set(id, index)); | 894 | scopeIds.forEach((id, index) => precedence.set(id, index)); |
| 815 | const pick = (row: typeof rows[number]) => { | 895 | const pick = (row: typeof rows[number]) => { |
| @@ -859,6 +939,7 @@ export const createExecutor = < · 43 unmodified lines | |||
| 859 | for (const { key, entries } of lists) { | 939 | for (const { key, entries } of lists) { |
| 860 | for (const entry of entries) { | 940 | for (const entry of entries) { |
| 861 | if (byId.has(entry.id)) continue; // core row wins | 941 | if (byId.has(entry.id)) continue; // core row wins |
| 942 | if (connectionOwnedIds.has(entry.id)) continue; // hidden by connection | ||
| 862 | byId.set( | 943 | byId.set( |
| 863 | entry.id, | 944 | entry.id, |
| 864 | new SecretRef({ | 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 | // Plugin wiring — build ctx, run extension, populate static pools, | 1491 | // Plugin wiring — build ctx, run extension, populate static pools, |
| 892 | // register secret providers. No adapter reads here. | 1492 | // register secret providers. No adapter reads here. |
| @@ -996,6 +1596,16 @@ export const createExecutor = < · 103 unmodified lines | |||
| 996 | set: (input) => secretsSet(input), | 1596 | set: (input) => secretsSet(input), |
| 997 | remove: (id) => secretsRemove(id), | 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 | // Open one real tx boundary and route every nested write inside | 1609 | // Open one real tx boundary and route every nested write inside |
| 1000 | // `effect` through that same handle via the activeAdapterRef — | 1610 | // `effect` through that same handle via the activeAdapterRef — |
| 1001 | // see buildAdapterRouter above. Caller-typed errors (`E`) | 1611 | // see buildAdapterRouter above. Caller-typed errors (`E`) |
| @@ -1070,6 +1680,24 @@ export const createExecutor = < · 68 unmodified lines | |||
| 1070 | secretProviders.set(provider.key, provider); | 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 | () => Array.from(secretProviders.keys()) as readonly string[], | 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 | close, | 2292 | close, |
| 1651 | }; | 2293 | }; |
| 1652 | 2294 | ||
| @@ -11,3 +11,6 @@ export type SecretId = typeof SecretId.Type; · 10 unmodified lines | |||
| 11 | 11 | ||
| 12 | export const PolicyId = Schema.String.pipe(Schema.brand("PolicyId")); | 12 | export const PolicyId = Schema.String.pipe(Schema.brand("PolicyId")); |
| 13 | export type PolicyId = typeof PolicyId.Type; | 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 | export { StorageError, UniqueViolationError } from "@executor/storage-core"; | 23 | export { StorageError, UniqueViolationError } from "@executor/storage-core"; |
| 24 | 24 | ||
| 25 | // IDs (branded) | 25 | // IDs (branded) |
| 26 | export { ScopeId, ToolId, SecretId, PolicyId } from "./ids"; | 26 | export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId } from "./ids"; |
| 27 | 27 | ||
| 28 | // Scope | 28 | // Scope |
| 29 | export { Scope } from "./scope"; | 29 | export { Scope } from "./scope"; |
| @@ -38,6 +38,10 @@ export { · 8 unmodified lines | |||
| 38 | PluginNotLoadedError, | 38 | PluginNotLoadedError, |
| 39 | SecretNotFoundError, | 39 | SecretNotFoundError, |
| 40 | SecretResolutionError, | 40 | SecretResolutionError, |
| 41 | SecretOwnedByConnectionError, | ||
| 42 | ConnectionNotFoundError, | ||
| 43 | ConnectionProviderNotRegisteredError, | ||
| 44 | ConnectionRefreshNotSupportedError, | ||
| 41 | type ExecutorError, | 45 | type ExecutorError, |
| 42 | } from "./errors"; | 46 | } from "./errors"; |
| 43 | 47 | ||
| @@ -60,6 +64,7 @@ export { · 16 unmodified lines | |||
| 60 | type ToolRow, | 64 | type ToolRow, |
| 61 | type DefinitionRow, | 65 | type DefinitionRow, |
| 62 | type SecretRow, | 66 | type SecretRow, |
| 67 | type ConnectionRow, | ||
| 63 | type DefinitionsInput, | 68 | type DefinitionsInput, |
| 64 | type ToolAnnotations, | 69 | type ToolAnnotations, |
| 65 | } from "./core-schema"; | 70 | } from "./core-schema"; |
| @@ -71,6 +76,20 @@ export { · 5 unmodified lines | |||
| 71 | type SecretProvider, | 76 | type SecretProvider, |
| 72 | } from "./secrets"; | 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 | // Elicitation | 93 | // Elicitation |
| 75 | export { | 94 | export { |
| 76 | FormElicitation, | 95 | FormElicitation, |
| @@ -7,6 +7,13 @@ import type { · 6 unmodified lines | |||
| 7 | } from "@executor/storage-core"; | 7 | } from "@executor/storage-core"; |
| 8 | 8 | ||
| 9 | import type { PluginBlobStore } from "./blob"; | 9 | import type { PluginBlobStore } from "./blob"; |
| 10 | import type { | ||
| 11 | ConnectionProvider, | ||
| 12 | ConnectionRef, | ||
| 13 | ConnectionRefreshError, | ||
| 14 | CreateConnectionInput, | ||
| 15 | UpdateConnectionTokensInput, | ||
| 16 | } from "./connections"; | ||
| 10 | import type { | 17 | import type { |
| 11 | DefinitionsInput, | 18 | DefinitionsInput, |
| 12 | SourceInput, | 19 | SourceInput, |
| @@ -20,6 +27,12 @@ import type { · 7 unmodified lines | |||
| 20 | ElicitationRequest, | 27 | ElicitationRequest, |
| 21 | ElicitationResponse, | 28 | ElicitationResponse, |
| 22 | } from "./elicitation"; | 29 | } from "./elicitation"; |
| 30 | import type { | ||
| 31 | ConnectionNotFoundError, | ||
| 32 | ConnectionProviderNotRegisteredError, | ||
| 33 | ConnectionRefreshNotSupportedError, | ||
| 34 | SecretOwnedByConnectionError, | ||
| 35 | } from "./errors"; | ||
| 23 | import type { Scope } from "./scope"; | 36 | import type { Scope } from "./scope"; |
| 24 | import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; | 37 | import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; |
| 25 | 38 | ||
| @@ -119,6 +132,9 @@ export interface PluginCtx<TStore = unknown> { · 93 unmodified lines | |||
| 119 | readonly get: ( | 132 | readonly get: ( |
| 120 | id: string, | 133 | id: string, |
| 121 | ) => Effect.Effect<string | null, StorageFailure>; | 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 | readonly list: () => Effect.Effect< | 138 | readonly list: () => Effect.Effect< |
| 123 | readonly { readonly id: string; readonly name: string; readonly provider: string }[], | 139 | readonly { readonly id: string; readonly name: string; readonly provider: string }[], |
| 124 | StorageFailure | 140 | StorageFailure |
| @@ -132,7 +148,54 @@ export interface PluginCtx<TStore = unknown> { · 7 unmodified lines | |||
| 132 | readonly set: ( | 148 | readonly set: ( |
| 133 | input: SetSecretInput, | 149 | input: SetSecretInput, |
| 134 | ) => Effect.Effect<SecretRef, StorageFailure>; | 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 | readonly remove: (id: string) => Effect.Effect<void, StorageFailure>; | 199 | readonly remove: (id: string) => Effect.Effect<void, StorageFailure>; |
| 137 | }; | 200 | }; |
| 138 | 201 | ||
| @@ -331,6 +394,18 @@ export interface PluginSpec< · 192 unmodified lines | |||
| 331 | ctx: PluginCtx<TStore>, | 394 | ctx: PluginCtx<TStore>, |
| 332 | ) => Effect.Effect<readonly SecretProvider[]>); | 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 | readonly close?: () => Effect.Effect<void, unknown>; | 409 | readonly close?: () => Effect.Effect<void, unknown>; |
| 335 | } | 410 | } |
| 336 | 411 | ||
| @@ -260,19 +260,40 @@ const isUniqueViolation = (cause: unknown): boolean => { · 259 unmodified lines | |||
| 260 | return false; | 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 | const classifyError = ( | 283 | const classifyError = ( |
| 264 | op: string, | 284 | op: string, |
| 265 | model: string | undefined, | 285 | model: string | undefined, |
| 266 | cause: unknown, | 286 | cause: unknown, |
| 267 | ): StorageFailure => { | 287 | ): StorageFailure => { |
| 268 | if (isUniqueViolation(cause)) { | 288 | const driverCause = unwrapDriverCause(cause); |
| 289 | if (isUniqueViolation(driverCause)) { | ||
| 269 | return model !== undefined | 290 | return model !== undefined |
| 270 | ? new UniqueViolationError({ model }) | 291 | ? new UniqueViolationError({ model }) |
| 271 | : new UniqueViolationError({}); | 292 | : new UniqueViolationError({}); |
| 272 | } | 293 | } |
| 273 | return new StorageError({ | 294 | return new StorageError({ |
| 274 | message: `[storage-drizzle] ${op} failed: ${cause instanceof Error ? cause.message : String(cause)}`, | 295 | message: `[storage-drizzle] ${op} failed: ${driverCause instanceof Error ? driverCause.message : String(driverCause)}`, |
| 275 | cause, | 296 | cause: driverCause, |
| 276 | }); | 297 | }); |
| 277 | }; | 298 | }; |
| 278 | 299 | ||
| @@ -21,13 +21,9 @@ const AuthPayload = Schema.Union( · 20 unmodified lines | |||
| 21 | }), | 21 | }), |
| 22 | Schema.Struct({ | 22 | Schema.Struct({ |
| 23 | kind: Schema.Literal("oauth2"), | 23 | kind: Schema.Literal("oauth2"), |
| 24 | connectionId: Schema.String, | ||
| 24 | clientIdSecretId: Schema.String, | 25 | clientIdSecretId: Schema.String, |
| 25 | clientSecretSecretId: Schema.NullOr(Schema.String), | 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 | scopes: Schema.Array(Schema.String), | 27 | scopes: Schema.Array(Schema.String), |
| 32 | }), | 28 | }), |
| 33 | ); | 29 | ); |
| @@ -65,6 +61,15 @@ const AddSourceResponse = Schema.Struct({ · 31 unmodified lines | |||
| 65 | namespace: Schema.String, | 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 | const StartOAuthPayload = Schema.Struct({ | 73 | const StartOAuthPayload = Schema.Struct({ |
| 69 | name: Schema.String, | 74 | name: Schema.String, |
| 70 | discoveryUrl: Schema.String, | 75 | discoveryUrl: Schema.String, |
| @@ -72,6 +77,8 @@ const StartOAuthPayload = Schema.Struct({ · 1 unmodified line | |||
| 72 | clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), | 77 | clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), |
| 73 | redirectUrl: Schema.String, | 78 | redirectUrl: Schema.String, |
| 74 | scopes: Schema.optional(Schema.Array(Schema.String)), | 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 | const StartOAuthResponse = Schema.Struct({ | 84 | const StartOAuthResponse = Schema.Struct({ |
| @@ -88,13 +95,11 @@ const CompleteOAuthPayload = Schema.Struct({ · 10 unmodified lines | |||
| 88 | 95 | ||
| 89 | const CompleteOAuthResponse = Schema.Struct({ | 96 | const CompleteOAuthResponse = Schema.Struct({ |
| 90 | kind: Schema.Literal("oauth2"), | 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 | clientIdSecretId: Schema.String, | 101 | clientIdSecretId: Schema.String, |
| 92 | clientSecretSecretId: Schema.NullOr(Schema.String), | 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 | scopes: Schema.Array(Schema.String), | 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 | .setPayload(AddSourcePayload) | 143 | .setPayload(AddSourcePayload) |
| 139 | .addSuccess(AddSourceResponse), | 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 | .add( | 153 | .add( |
| 142 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` | 154 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` |
| 143 | .setPayload(StartOAuthPayload) | 155 | .setPayload(StartOAuthPayload) |
| @@ -26,6 +26,7 @@ const failingExtension: GoogleDiscoveryPluginExtension = { · 25 unmodified lines | |||
| 26 | startOAuth: () => unused, | 26 | startOAuth: () => unused, |
| 27 | completeOAuth: () => Effect.die(new Error("Not implemented")), | 27 | completeOAuth: () => Effect.die(new Error("Not implemented")), |
| 28 | getSource: (_namespace: string, _scope: string) => Effect.succeed(null), | 28 | getSource: (_namespace: string, _scope: string) => Effect.succeed(null), |
| 29 | updateSource: () => unused, | ||
| 29 | }; | 30 | }; |
| 30 | 31 | ||
| 31 | const Api = addGroup(GoogleDiscoveryGroup); | 32 | const Api = addGroup(GoogleDiscoveryGroup); |
| @@ -84,6 +84,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 83 unmodified lines | |||
| 84 | clientSecretSecretId: payload.clientSecretSecretId, | 84 | clientSecretSecretId: payload.clientSecretSecretId, |
| 85 | redirectUrl: payload.redirectUrl, | 85 | redirectUrl: payload.redirectUrl, |
| 86 | scopes: payload.scopes, | 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 | return yield* ext.getSource(path.namespace, path.scopeId); | 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 | .handle("oauthCallback", ({ urlParams }) => | 117 | .handle("oauthCallback", ({ urlParams }) => |
| 107 | capture(Effect.gen(function* () { | 118 | capture(Effect.gen(function* () { |
| 108 | const ext = yield* GoogleDiscoveryExtensionService; | 119 | const ext = yield* GoogleDiscoveryExtensionService; |
| @@ -368,13 +368,9 @@ type ProbeResult = { · 367 unmodified lines | |||
| 368 | 368 | ||
| 369 | type OAuthAuth = { | 369 | type OAuthAuth = { |
| 370 | kind: "oauth2"; | 370 | kind: "oauth2"; |
| 371 | connectionId: string; | ||
| 371 | clientIdSecretId: string; | 372 | clientIdSecretId: string; |
| 372 | clientSecretSecretId: string | null; | 373 | clientSecretSecretId: string | null; |
| 373 | accessTokenSecretId: string; | ||
| 374 | refreshTokenSecretId: string | null; | ||
| 375 | tokenType: string; | ||
| 376 | expiresAt: number | null; | ||
| 377 | scope: string | null; | ||
| 378 | scopes: string[]; | 374 | scopes: string[]; |
| 379 | }; | 375 | }; |
| 380 | 376 | ||
| @@ -522,13 +518,9 @@ export default function AddGoogleDiscoverySource(props: { · 141 unmodified lines | |||
| 522 | if (result.ok) { | 518 | if (result.ok) { |
| 523 | setOauthAuth({ | 519 | setOauthAuth({ |
| 524 | kind: "oauth2", | 520 | kind: "oauth2", |
| 521 | connectionId: result.connectionId, | ||
| 525 | clientIdSecretId: result.clientIdSecretId, | 522 | clientIdSecretId: result.clientIdSecretId, |
| 526 | clientSecretSecretId: result.clientSecretSecretId, | 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 | scopes: [...result.scopes], | 524 | scopes: [...result.scopes], |
| 533 | }); | 525 | }); |
| 534 | setError(null); | 526 | setError(null); |
| @@ -573,8 +565,14 @@ export default function AddGoogleDiscoverySource(props: { · 38 unmodified lines | |||
| 573 | discoveryUrl: discoveryUrl.trim(), | 565 | discoveryUrl: discoveryUrl.trim(), |
| 574 | namespace: slugifyNamespace(identity.namespace) || undefined, | 566 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 575 | auth: | 567 | auth: |
| 576 | authKind === "oauth2" | 568 | authKind === "oauth2" && oauthAuth |
| 577 | ? (oauthAuth ?? { kind: "none" as const }) | 569 | ? { |
| 570 | kind: "oauth2" as const, | ||
| 571 | connectionId: oauthAuth.connectionId, | ||
| 572 | clientIdSecretId: oauthAuth.clientIdSecretId, | ||
| 573 | clientSecretSecretId: oauthAuth.clientSecretSecretId, | ||
| 574 | scopes: oauthAuth.scopes, | ||
| 575 | } | ||
| 578 | : { kind: "none" as const }, | 576 | : { kind: "none" as const }, |
| 579 | }, | 577 | }, |
| 580 | reactivityKeys: [...sourceWriteKeys], | 578 | reactivityKeys: [...sourceWriteKeys], |
| @@ -790,7 +788,7 @@ export default function AddGoogleDiscoverySource(props: { · 209 unmodified lines | |||
| 790 | </Collapsible> | 788 | </Collapsible> |
| 791 | {oauthAuth && ( | 789 | {oauthAuth && ( |
| 792 | <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"> | 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 | </div> | 792 | </div> |
| 795 | )} | 793 | )} |
| 796 | </div> | 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 | "googleDiscovery", | 17 | "googleDiscovery", |
| 18 | "addSource", | 18 | "addSource", |
| 19 | ); | 19 | ); |
| 20 | export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation( | ||
| 21 | "googleDiscovery", | ||
| 22 | "updateSource", | ||
| 23 | ); | ||
| 20 | export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation( | 24 | export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation( |
| 21 | "googleDiscovery", | 25 | "googleDiscovery", |
| 22 | "startOAuth", | 26 | "startOAuth", |
| @@ -8,5 +8,6 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 7 unmodified lines | |||
| 8 | add: lazy(() => import("./AddGoogleDiscoverySource")), | 8 | add: lazy(() => import("./AddGoogleDiscoverySource")), |
| 9 | edit: lazy(() => import("./EditGoogleDiscoverySource")), | 9 | edit: lazy(() => import("./EditGoogleDiscoverySource")), |
| 10 | summary: lazy(() => import("./GoogleDiscoverySourceSummary")), | 10 | summary: lazy(() => import("./GoogleDiscoverySourceSummary")), |
| 11 | signIn: lazy(() => import("./GoogleDiscoverySignInButton")), | ||
| 11 | presets: googleDiscoveryPresets, | 12 | presets: googleDiscoveryPresets, |
| 12 | }; | 13 | }; |
| @@ -154,6 +154,14 @@ export interface GoogleDiscoveryStore { · 153 unmodified lines | |||
| 154 | readonly putSource: ( | 154 | readonly putSource: ( |
| 155 | source: GoogleDiscoveryStoredSource, | 155 | source: GoogleDiscoveryStoredSource, |
| 156 | ) => Effect.Effect<void, StorageFailure>; | 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 | readonly removeSource: ( | 165 | readonly removeSource: ( |
| 158 | sourceId: string, | 166 | sourceId: string, |
| 159 | scope: string, | 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 | removeSource: (sourceId, scope) => | 329 | removeSource: (sourceId, scope) => |
| 292 | db | 330 | db |
| 293 | .delete({ | 331 | .delete({ |
| @@ -1,15 +1,7 @@ | |||
| 1 | import { Effect, Layer, Option } from "effect"; | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; | 2 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; |
| 3 | 3 | ||
| 4 | import { | ||
| 5 | withRefreshedAccessToken, | ||
| 6 | type OAuth2SecretsIO, | ||
| 7 | } from "@executor/plugin-oauth2"; | ||
| 8 | |||
| 9 | import type { PluginCtx, StorageFailure } from "@executor/sdk"; | 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 | import { | 6 | import { |
| 15 | GoogleDiscoveryInvocationError, | 7 | GoogleDiscoveryInvocationError, |
| @@ -18,8 +10,8 @@ import { · 2 unmodified lines | |||
| 18 | import type { GoogleDiscoveryStore } from "./binding-store"; | 10 | import type { GoogleDiscoveryStore } from "./binding-store"; |
| 19 | import { | 11 | import { |
| 20 | GoogleDiscoveryInvocationResult, | 12 | GoogleDiscoveryInvocationResult, |
| 21 | GoogleDiscoveryStoredSourceData, | ||
| 22 | type GoogleDiscoveryParameter, | 13 | type GoogleDiscoveryParameter, |
| 14 | type GoogleDiscoveryStoredSourceData, | ||
| 23 | } from "./types"; | 15 | } from "./types"; |
| 24 | 16 | ||
| 25 | const SAFE_METHODS = new Set(["get", "head", "options"]); | 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 | // Path / query parameter helpers (unchanged from the old invoker) | 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 | // HTTP request builder / executor | 79 | // HTTP request builder / executor |
| 177 | // --------------------------------------------------------------------------- | 80 | // --------------------------------------------------------------------------- |
| @@ -280,10 +183,7 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 102 unmodified lines | |||
| 280 | export const invokeGoogleDiscoveryTool = (input: { | 183 | export const invokeGoogleDiscoveryTool = (input: { |
| 281 | ctx: PluginCtx<GoogleDiscoveryStore>; | 184 | ctx: PluginCtx<GoogleDiscoveryStore>; |
| 282 | toolId: string; | 185 | toolId: string; |
| 283 | /** Resolved owning scope of the tool row (innermost-wins from the | 186 | /** Resolved owning scope of the tool row. */ |
| 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. */ | ||
| 287 | toolScope: string; | 187 | toolScope: string; |
| 288 | args: unknown; | 188 | args: unknown; |
| 289 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | 189 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; |
| @@ -314,18 +214,22 @@ export const invokeGoogleDiscoveryTool = (input: { · 24 unmodified lines | |||
| 314 | } | 214 | } |
| 315 | const source = stored.config; | 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 | const authHeader = | 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 | const layer = input.httpClientLayer ?? FetchHttpClient.layer; | 234 | const layer = input.httpClientLayer ?? FetchHttpClient.layer; |
| 331 | 235 | ||
| @@ -7,12 +7,15 @@ import { Effect } from "effect"; · 6 unmodified lines | |||
| 7 | import { vi } from "vitest"; | 7 | import { vi } from "vitest"; |
| 8 | 8 | ||
| 9 | import { | 9 | import { |
| 10 | ConnectionId, | ||
| 11 | CreateConnectionInput, | ||
| 10 | createExecutor, | 12 | createExecutor, |
| 11 | makeTestConfig, | 13 | makeTestConfig, |
| 12 | Scope, | 14 | Scope, |
| 13 | ScopeId, | 15 | ScopeId, |
| 14 | SecretId, | 16 | SecretId, |
| 15 | SetSecretInput, | 17 | SetSecretInput, |
| 18 | TokenMaterial, | ||
| 16 | type InvokeOptions, | 19 | type InvokeOptions, |
| 17 | } from "@executor/sdk"; | 20 | } from "@executor/sdk"; |
| 18 | 21 | ||
| @@ -300,13 +303,22 @@ describe("Google Discovery plugin", () => { · 281 unmodified lines | |||
| 300 | }); | 303 | }); |
| 301 | 304 | ||
| 302 | expect(auth.kind).toBe("oauth2"); | 305 | expect(auth.kind).toBe("oauth2"); |
| 303 | expect(auth.clientIdSecretId).toBe("google-client-id"); | 306 | expect(auth.connectionId).toMatch(/^google-discovery-oauth2-/); |
| 304 | expect(auth.refreshTokenSecretId).not.toBeNull(); | ||
| 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 | expect(accessToken).toBe("access-token-value"); | 313 | expect(accessToken).toBe("access-token-value"); |
| 308 | const refreshToken = yield* executor.secrets.get(auth.refreshTokenSecretId!); | 314 | |
| 309 | expect(refreshToken).toBe("refresh-token-value"); | 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 | } finally { | 322 | } finally { |
| 311 | fetchMock.mockRestore(); | 323 | fetchMock.mockRestore(); |
| 312 | yield* executor.close(); | 324 | yield* executor.close(); |
| @@ -328,20 +340,31 @@ describe("Google Discovery plugin", () => { · 15 unmodified lines | |||
| 328 | ); | 340 | ); |
| 329 | 341 | ||
| 330 | try { | 342 | try { |
| 331 | yield* executor.secrets.set( | 343 | // A connection wraps the access token (+ optional refresh) and |
| 332 | new SetSecretInput({ | 344 | // the invoke path resolves via ctx.connections.accessToken. |
| 333 | id: SecretId.make("drive-access-token"), | 345 | const connectionId = ConnectionId.make( |
| 334 | scope: "test-scope" as SetSecretInput["scope"], | 346 | "google-discovery-oauth2-test", |
| 335 | name: "Drive Access Token", | ||
| 336 | value: "secret-token", | ||
| 337 | }), | ||
| 338 | ); | 347 | ); |
| 339 | yield* executor.secrets.set( | 348 | yield* executor.connections.create( |
| 340 | new SetSecretInput({ | 349 | new CreateConnectionInput({ |
| 341 | id: SecretId.make("drive-client-id"), | 350 | id: connectionId, |
| 342 | scope: "test-scope" as SetSecretInput["scope"], | 351 | scope: ScopeId.make("test-scope"), |
| 343 | name: "Drive Client ID", | 352 | provider: "google-discovery:oauth2", |
| 344 | value: "client-123", | 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 | namespace: "drive", | 375 | namespace: "drive", |
| 353 | auth: { | 376 | auth: { |
| 354 | kind: "oauth2", | 377 | kind: "oauth2", |
| 378 | connectionId, | ||
| 355 | clientIdSecretId: "drive-client-id", | 379 | clientIdSecretId: "drive-client-id", |
| 356 | clientSecretSecretId: null, | 380 | clientSecretSecretId: null, |
| 357 | accessTokenSecretId: "drive-access-token", | ||
| 358 | refreshTokenSecretId: null, | ||
| 359 | tokenType: "Bearer", | ||
| 360 | expiresAt: null, | ||
| 361 | scope: null, | ||
| 362 | scopes: ["https://www.googleapis.com/auth/drive.readonly"], | 381 | scopes: ["https://www.googleapis.com/auth/drive.readonly"], |
| 363 | }, | 382 | }, |
| 364 | }); | 383 | }); |
| @@ -1,13 +1,25 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | 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 | import { | 11 | import { |
| 8 | definePlugin, | 12 | ConnectionId, |
| 9 | SetSecretInput, | 13 | ConnectionRefreshError, |
| 14 | CreateConnectionInput, | ||
| 15 | ScopeId, | ||
| 16 | SecretId, | ||
| 10 | SourceDetectionResult, | 17 | SourceDetectionResult, |
| 18 | TokenMaterial, | ||
| 19 | definePlugin, | ||
| 20 | type ConnectionProvider, | ||
| 21 | type ConnectionRefreshInput, | ||
| 22 | type ConnectionRefreshResult, | ||
| 11 | type PluginCtx, | 23 | type PluginCtx, |
| 12 | type StorageFailure, | 24 | type StorageFailure, |
| 13 | type ToolAnnotations, | 25 | type ToolAnnotations, |
| @@ -30,6 +42,7 @@ import { · 16 unmodified lines | |||
| 30 | buildGoogleAuthorizationUrl, | 42 | buildGoogleAuthorizationUrl, |
| 31 | createPkceCodeVerifier, | 43 | createPkceCodeVerifier, |
| 32 | exchangeAuthorizationCode, | 44 | exchangeAuthorizationCode, |
| 45 | GOOGLE_TOKEN_URL, | ||
| 33 | } from "./oauth"; | 46 | } from "./oauth"; |
| 34 | import type { | 47 | import type { |
| 35 | GoogleDiscoveryAuth, | 48 | GoogleDiscoveryAuth, |
| @@ -41,7 +54,7 @@ import type { · 5 unmodified lines | |||
| 41 | import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; | 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 | export interface GoogleDiscoveryProbeOperation { | 60 | export interface GoogleDiscoveryProbeOperation { |
| @@ -69,6 +82,13 @@ export interface GoogleDiscoveryAddSourceInput { · 21 unmodified lines | |||
| 69 | readonly auth: GoogleDiscoveryAuth; | 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 | export interface GoogleDiscoveryOAuthStartInput { | 92 | export interface GoogleDiscoveryOAuthStartInput { |
| 73 | readonly name: string; | 93 | readonly name: string; |
| 74 | readonly discoveryUrl: string; | 94 | readonly discoveryUrl: string; |
| @@ -76,6 +96,9 @@ export interface GoogleDiscoveryOAuthStartInput { · 1 unmodified line | |||
| 76 | readonly clientSecretSecretId?: string | null; | 96 | readonly clientSecretSecretId?: string | null; |
| 77 | readonly redirectUrl: string; | 97 | readonly redirectUrl: string; |
| 78 | readonly scopes?: readonly string[]; | 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 | export interface GoogleDiscoveryOAuthStartResponse { | 104 | export interface GoogleDiscoveryOAuthStartResponse { |
| @@ -90,28 +113,23 @@ export interface GoogleDiscoveryOAuthCompleteInput { · 8 unmodified lines | |||
| 90 | readonly error?: string; | 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 | export interface GoogleDiscoveryOAuthAuthResult { | 123 | export interface GoogleDiscoveryOAuthAuthResult { |
| 94 | readonly kind: "oauth2"; | 124 | readonly kind: "oauth2"; |
| 125 | readonly connectionId: string; | ||
| 95 | readonly clientIdSecretId: string; | 126 | readonly clientIdSecretId: string; |
| 96 | readonly clientSecretSecretId: string | null; | 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 | readonly scopes: readonly string[]; | 128 | readonly scopes: readonly string[]; |
| 103 | } | 129 | } |
| 104 | 130 | ||
| 105 | /** | 131 | /** |
| 106 | * Errors any Google Discovery extension method may surface. The first | 132 | * Errors any Google Discovery extension method may surface. |
| 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. | ||
| 115 | */ | 133 | */ |
| 116 | export type GoogleDiscoveryExtensionFailure = | 134 | export type GoogleDiscoveryExtensionFailure = |
| 117 | | GoogleDiscoveryParseError | 135 | | GoogleDiscoveryParseError |
| @@ -155,6 +173,11 @@ export interface GoogleDiscoveryPluginExtension { · 37 unmodified lines | |||
| 155 | namespace: string, | 173 | namespace: string, |
| 156 | scope: string, | 174 | scope: string, |
| 157 | ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>; | 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 | input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`, | 243 | input.name || `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`, |
| 221 | ) || `google_${input.service}`; | 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 | // Register a parsed manifest against the executor core + plugin storage. | 270 | // Register a parsed manifest against the executor core + plugin storage. |
| 225 | // Runs inside a transaction. | 271 | // Runs inside a transaction. |
| @@ -233,13 +279,9 @@ const registerManifest = ( · 7 unmodified lines | |||
| 233 | sourceData: GoogleDiscoveryStoredSourceData, | 279 | sourceData: GoogleDiscoveryStoredSourceData, |
| 234 | ) => | 280 | ) => |
| 235 | Effect.gen(function* () { | 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 | yield* ctx.storage.removeBindingsBySource(namespace, scope); | 282 | yield* ctx.storage.removeBindingsBySource(namespace, scope); |
| 240 | yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore); | 283 | yield* ctx.core.sources.unregister(namespace).pipe(Effect.ignore); |
| 241 | 284 | ||
| 242 | // 2. Register the source + tool rows in core. | ||
| 243 | yield* ctx.core.sources.register({ | 285 | yield* ctx.core.sources.register({ |
| 244 | id: namespace, | 286 | id: namespace, |
| 245 | scope, | 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 | if (Object.keys(manifest.schemaDefinitions).length > 0) { | 302 | if (Object.keys(manifest.schemaDefinitions).length > 0) { |
| 262 | yield* ctx.core.definitions.register({ | 303 | yield* ctx.core.definitions.register({ |
| 263 | sourceId: namespace, | 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 | yield* Effect.forEach( | 310 | yield* Effect.forEach( |
| 272 | manifest.methods, | 311 | manifest.methods, |
| 273 | (method) => | 312 | (method) => |
| @@ -280,7 +319,6 @@ const registerManifest = ( · 6 unmodified lines | |||
| 280 | { discard: true }, | 319 | { discard: true }, |
| 281 | ); | 320 | ); |
| 282 | 321 | ||
| 283 | // 5. Write the source config blob. | ||
| 284 | yield* ctx.storage.putSource({ | 322 | yield* ctx.storage.putSource({ |
| 285 | namespace, | 323 | namespace, |
| 286 | scope, | 324 | scope, |
| @@ -291,32 +329,6 @@ const registerManifest = ( · 4 unmodified lines | |||
| 291 | return manifest.methods.length; | 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 | // Plugin | 333 | // Plugin |
| 322 | // --------------------------------------------------------------------------- | 334 | // --------------------------------------------------------------------------- |
| @@ -418,6 +430,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 95 unmodified lines | |||
| 418 | } | 430 | } |
| 419 | const sessionId = randomUUID(); | 431 | const sessionId = randomUUID(); |
| 420 | const codeVerifier = createPkceCodeVerifier(); | 432 | const codeVerifier = createPkceCodeVerifier(); |
| 433 | const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string); | ||
| 434 | const connectionId = `google-discovery-oauth2-${randomUUID()}`; | ||
| 421 | yield* ctx.storage.putOAuthSession(sessionId, ctx.scopes[0]!.id as string, { | 435 | yield* ctx.storage.putOAuthSession(sessionId, ctx.scopes[0]!.id as string, { |
| 422 | discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), | 436 | discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), |
| 423 | name: input.name, | 437 | name: input.name, |
| @@ -426,6 +440,8 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 2 unmodified lines | |||
| 426 | redirectUrl: input.redirectUrl, | 440 | redirectUrl: input.redirectUrl, |
| 427 | scopes, | 441 | scopes, |
| 428 | codeVerifier, | 442 | codeVerifier, |
| 443 | tokenScope, | ||
| 444 | connectionId, | ||
| 429 | }); | 445 | }); |
| 430 | return { | 446 | return { |
| 431 | sessionId, | 447 | sessionId, |
| @@ -441,102 +457,138 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 9 unmodified lines | |||
| 441 | }), | 457 | }), |
| 442 | 458 | ||
| 443 | completeOAuth: (input) => | 459 | completeOAuth: (input) => |
| 444 | Effect.gen(function* () { | 460 | ctx.transaction( |
| 445 | const session = yield* ctx.storage.getOAuthSession(input.state); | 461 | Effect.gen(function* () { |
| 446 | if (!session) { | 462 | const session = yield* ctx.storage.getOAuthSession(input.state); |
| 447 | return yield* new GoogleDiscoveryOAuthError({ | 463 | if (!session) { |
| 448 | message: "OAuth session not found or has expired", | 464 | return yield* new GoogleDiscoveryOAuthError({ |
| 449 | }); | 465 | message: "OAuth session not found or has expired", |
| 450 | } | 466 | }); |
| 451 | yield* ctx.storage.deleteOAuthSession(input.state); | 467 | } |
| 452 | 468 | yield* ctx.storage.deleteOAuthSession(input.state); | |
| 453 | if (input.error) { | 469 | |
| 454 | return yield* new GoogleDiscoveryOAuthError({ message: input.error }); | 470 | if (input.error) { |
| 455 | } | 471 | return yield* new GoogleDiscoveryOAuthError({ message: input.error }); |
| 456 | if (!input.code) { | 472 | } |
| 457 | return yield* new GoogleDiscoveryOAuthError({ | 473 | if (!input.code) { |
| 458 | message: "OAuth callback did not include an authorization code", | 474 | return yield* new GoogleDiscoveryOAuthError({ |
| 459 | }); | 475 | message: "OAuth callback did not include an authorization code", |
| 460 | } | 476 | }); |
| 477 | } | ||
| 461 | 478 | ||
| 462 | const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId); | 479 | const clientIdValue = yield* ctx.secrets.get(session.clientIdSecretId); |
| 463 | if (clientIdValue === null) { | 480 | if (clientIdValue === null) { |
| 464 | return yield* new GoogleDiscoveryOAuthError({ | 481 | return yield* new GoogleDiscoveryOAuthError({ |
| 465 | message: `OAuth client ID secret not found: ${session.clientIdSecretId}`, | 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 = | 509 | const expiresAt = |
| 470 | session.clientSecretSecretId === null | 510 | typeof tokenResponse.expires_in === "number" |
| 471 | ? null | 511 | ? Date.now() + tokenResponse.expires_in * 1000 |
| 472 | : yield* ctx.secrets.get(session.clientSecretSecretId).pipe( | 512 | : null; |
| 473 | Effect.flatMap((v) => | 513 | |
| 474 | v === null | 514 | const providerState: OAuth2ProviderState = { |
| 475 | ? Effect.fail( | 515 | clientIdSecretId: session.clientIdSecretId, |
| 476 | new GoogleDiscoveryOAuthError({ | 516 | clientSecretSecretId: session.clientSecretSecretId, |
| 477 | message: `OAuth client secret not found: ${session.clientSecretSecretId}`, | 517 | scopes: [...session.scopes], |
| 478 | }), | 518 | }; |
| 479 | ) | 519 | |
| 480 | : Effect.succeed(v), | 520 | yield* ctx.connections |
| 481 | ), | 521 | .create( |
| 482 | ); | 522 | new CreateConnectionInput({ |
| 483 | 523 | id: ConnectionId.make(session.connectionId), | |
| 484 | const tokenResponse = yield* exchangeAuthorizationCode({ | 524 | scope: ScopeId.make(session.tokenScope), |
| 485 | clientId: clientIdValue, | 525 | provider: GOOGLE_DISCOVERY_OAUTH2_PROVIDER_KEY, |
| 486 | clientSecret: clientSecretValue, | 526 | kind: "user", |
| 487 | redirectUrl: session.redirectUrl, | 527 | identityLabel: session.name, |
| 488 | codeVerifier: session.codeVerifier, | 528 | accessToken: new TokenMaterial({ |
| 489 | code: input.code, | 529 | secretId: SecretId.make(`${session.connectionId}.access_token`), |
| 490 | }); | 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({ | 573 | getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope), |
| 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 | ); | ||
| 502 | 574 | ||
| 503 | return { | 575 | updateSource: (namespace, scope, input) => |
| 504 | kind: "oauth2" as const, | 576 | ctx.storage.updateSourceMeta(namespace, scope, { |
| 505 | clientIdSecretId: session.clientIdSecretId, | 577 | name: input.name?.trim() || undefined, |
| 506 | clientSecretSecretId: session.clientSecretSecretId, | 578 | auth: input.auth, |
| 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 | }; | ||
| 514 | }), | 579 | }), |
| 515 | |||
| 516 | getSource: (namespace, scope) => ctx.storage.getSource(namespace, scope), | ||
| 517 | } satisfies GoogleDiscoveryPluginExtension), | 580 | } satisfies GoogleDiscoveryPluginExtension), |
| 518 | 581 | ||
| 519 | invokeTool: ({ ctx, toolRow, args }) => | 582 | invokeTool: ({ ctx, toolRow, args }) => |
| 520 | invokeGoogleDiscoveryTool({ | 583 | invokeGoogleDiscoveryTool({ |
| 521 | ctx: ctx as PluginCtx<GoogleDiscoveryStore>, | 584 | ctx: ctx as PluginCtx<GoogleDiscoveryStore>, |
| 522 | toolId: toolRow.id, | 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 | toolScope: toolRow.scope_id as string, | 586 | toolScope: toolRow.scope_id as string, |
| 529 | args, | 587 | args, |
| 530 | }), | 588 | }), |
| 531 | 589 | ||
| 532 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => | 590 | resolveAnnotations: ({ ctx, sourceId, toolRows }) => |
| 533 | Effect.gen(function* () { | 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 | const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>; | 592 | const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>; |
| 541 | const scopes = new Set<string>(); | 593 | const scopes = new Set<string>(); |
| 542 | for (const row of toolRows) scopes.add(row.scope_id as string); | 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 | refreshSource: ({ ctx, sourceId, scope }) => | 657 | refreshSource: ({ ctx, sourceId, scope }) => |
| 606 | Effect.gen(function* () { | 658 | Effect.gen(function* () { |
| 607 | const typedCtx = ctx as PluginCtx<GoogleDiscoveryStore>; | 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 | const existing = yield* typedCtx.storage.getSource(sourceId, scope); | 660 | const existing = yield* typedCtx.storage.getSource(sourceId, scope); |
| 612 | if (!existing) return; | 661 | if (!existing) return; |
| 613 | const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl); | 662 | const text = yield* fetchDiscoveryDocument(existing.config.discoveryUrl); |
| @@ -621,4 +670,102 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 7 unmodified lines | |||
| 621 | }); | 670 | }); |
| 622 | yield* registerManifest(typedCtx, sourceId, scope, manifest, next); | 671 | yield* registerManifest(typedCtx, sourceId, scope, manifest, next); |
| 623 | }).pipe(Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err))))), | 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 | methods: Schema.Array(GoogleDiscoveryManifestMethod), | 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 | export const GoogleDiscoveryAuth = Schema.Union( | 74 | export const GoogleDiscoveryAuth = Schema.Union( |
| 64 | Schema.Struct({ | 75 | Schema.Struct({ |
| 65 | kind: Schema.Literal("none"), | 76 | kind: Schema.Literal("none"), |
| 66 | }), | 77 | }), |
| 67 | Schema.Struct({ | 78 | Schema.Struct({ |
| 68 | kind: Schema.Literal("oauth2"), | 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 | clientIdSecretId: Schema.String, | 84 | clientIdSecretId: Schema.String, |
| 85 | /** Secret id holding the OAuth client_secret. Null for public clients. */ | ||
| 70 | clientSecretSecretId: Schema.NullOr(Schema.String), | 86 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 71 | accessTokenSecretId: Schema.String, | 87 | /** Scopes requested on sign-in. */ |
| 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), | ||
| 76 | scopes: Schema.Array(Schema.String), | 88 | scopes: Schema.Array(Schema.String), |
| 77 | }), | 89 | }), |
| 78 | ); | 90 | ); |
| @@ -113,5 +125,11 @@ export const GoogleDiscoveryOAuthSession = Schema.Struct({ · 34 unmodified lines | |||
| 113 | redirectUrl: Schema.String, | 125 | redirectUrl: Schema.String, |
| 114 | scopes: Schema.Array(Schema.String), | 126 | scopes: Schema.Array(Schema.String), |
| 115 | codeVerifier: Schema.String, | 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 | export type GoogleDiscoveryOAuthSession = typeof GoogleDiscoveryOAuthSession.Type; | 135 | export type GoogleDiscoveryOAuthSession = typeof GoogleDiscoveryOAuthSession.Type; |
| @@ -50,6 +50,7 @@ · 49 unmodified lines | |||
| 50 | "@effect/platform": "catalog:", | 50 | "@effect/platform": "catalog:", |
| 51 | "@effect/platform-node": "catalog:", | 51 | "@effect/platform-node": "catalog:", |
| 52 | "@executor/config": "workspace:*", | 52 | "@executor/config": "workspace:*", |
| 53 | "@executor/plugin-oauth2": "workspace:*", | ||
| 53 | "@executor/sdk": "workspace:*", | 54 | "@executor/sdk": "workspace:*", |
| 54 | "@modelcontextprotocol/sdk": "^1.29.0", | 55 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 55 | "effect": "catalog:" | 56 | "effect": "catalog:" |
| @@ -36,14 +36,10 @@ const AuthPayload = Schema.Union( · 35 unmodified lines | |||
| 36 | }), | 36 | }), |
| 37 | Schema.Struct({ | 37 | Schema.Struct({ |
| 38 | kind: Schema.Literal("oauth2"), | 38 | kind: Schema.Literal("oauth2"), |
| 39 | accessTokenSecretId: Schema.String, | 39 | /** Stable id of the SDK Connection minted by `completeOAuth`. The |
| 40 | refreshTokenSecretId: Schema.NullOr(Schema.String), | 40 | * backing access/refresh secrets live on the connection row; the |
| 41 | tokenType: Schema.optional(Schema.String), | 41 | * source only needs this pointer. */ |
| 42 | expiresAt: Schema.NullOr(Schema.Number), | 42 | connectionId: Schema.String, |
| 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)), | ||
| 47 | }), | 43 | }), |
| 48 | ); | 44 | ); |
| 49 | 45 | ||
| @@ -113,8 +109,12 @@ const StartOAuthPayload = Schema.Struct({ · 63 unmodified lines | |||
| 113 | endpoint: Schema.String, | 109 | endpoint: Schema.String, |
| 114 | redirectUrl: Schema.String, | 110 | redirectUrl: Schema.String, |
| 115 | queryParams: Schema.optional(Schema.NullOr(StringMap)), | 111 | queryParams: Schema.optional(Schema.NullOr(StringMap)), |
| 116 | accessTokenSecretId: Schema.String, | 112 | /** Pre-decided SDK connection id the exchange will mint. Caller |
| 117 | refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)), | 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 | /** Source-level OAuth state captured by a previous user's flow. When | 118 | /** Source-level OAuth state captured by a previous user's flow. When |
| 119 | * passed, DCR is skipped — the same client_id is re-used so the | 119 | * passed, DCR is skipped — the same client_id is re-used so the |
| 120 | * source's auth config stays stable across users. */ | 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 | const CompleteOAuthResponse = Schema.Struct({ | 163 | const CompleteOAuthResponse = Schema.Struct({ |
| 164 | accessTokenSecretId: Schema.String, | 164 | /** Id of the SDK Connection minted by the exchange. The UI stores it |
| 165 | refreshTokenSecretId: Schema.NullOr(Schema.String), | 165 | * on the source's auth config as `{kind: "oauth2", connectionId}`. */ |
| 166 | connectionId: Schema.String, | ||
| 166 | tokenType: Schema.String, | 167 | tokenType: Schema.String, |
| 167 | expiresAt: Schema.NullOr(Schema.Number), | 168 | expiresAt: Schema.NullOr(Schema.Number), |
| 168 | scope: Schema.NullOr(Schema.String), | 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 | clientInformation: Schema.NullOr(JsonObject), | 173 | clientInformation: Schema.NullOr(JsonObject), |
| 170 | authorizationServerUrl: Schema.NullOr(Schema.String), | 174 | authorizationServerUrl: Schema.NullOr(Schema.String), |
| 171 | resourceMetadataUrl: Schema.NullOr(Schema.String), | 175 | resourceMetadataUrl: Schema.NullOr(Schema.String), |
| @@ -34,11 +34,13 @@ type OAuthPopupResult = · 33 unmodified lines | |||
| 34 | type: "executor:oauth-result"; | 34 | type: "executor:oauth-result"; |
| 35 | ok: true; | 35 | ok: true; |
| 36 | sessionId: string; | 36 | sessionId: string; |
| 37 | accessTokenSecretId: string; | 37 | connectionId: string; |
| 38 | refreshTokenSecretId: string | null; | ||
| 39 | tokenType: string; | 38 | tokenType: string; |
| 40 | expiresAt: number | null; | 39 | expiresAt: number | null; |
| 41 | scope: string | null; | 40 | scope: string | null; |
| 41 | clientInformation: Record<string, unknown> | null; | ||
| 42 | authorizationServerUrl: string | null; | ||
| 43 | resourceMetadataUrl: string | null; | ||
| 42 | } | 44 | } |
| 43 | | { | 45 | | { |
| 44 | type: "executor:oauth-result"; | 46 | type: "executor:oauth-result"; |
| @@ -130,15 +132,6 @@ const toSourceConfig = ( · 85 unmodified lines | |||
| 130 | auth?: { kind: string } & Record<string, unknown>; | 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 | return { | 135 | return { |
| 143 | transport: "remote", | 136 | transport: "remote", |
| 144 | scope, | 137 | scope, |
| @@ -148,7 +141,7 @@ const toSourceConfig = ( · 3 unmodified lines | |||
| 148 | queryParams: p.queryParams, | 141 | queryParams: p.queryParams, |
| 149 | headers: p.headers, | 142 | headers: p.headers, |
| 150 | namespace: p.namespace, | 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 | endpoint: payload.endpoint, | 197 | endpoint: payload.endpoint, |
| 205 | redirectUrl: payload.redirectUrl, | 198 | redirectUrl: payload.redirectUrl, |
| 206 | queryParams: payload.queryParams, | 199 | queryParams: payload.queryParams, |
| 207 | accessTokenSecretId: payload.accessTokenSecretId, | 200 | connectionId: payload.connectionId, |
| 208 | refreshTokenSecretId: payload.refreshTokenSecretId, | ||
| 209 | clientInformation: payload.clientInformation, | 201 | clientInformation: payload.clientInformation, |
| 210 | authorizationServerUrl: payload.authorizationServerUrl, | 202 | authorizationServerUrl: payload.authorizationServerUrl, |
| 211 | resourceMetadataUrl: payload.resourceMetadataUrl, | 203 | resourceMetadataUrl: payload.resourceMetadataUrl, |
| @@ -259,11 +251,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 47 unmodified lines | |||
| 259 | type: "executor:oauth-result", | 251 | type: "executor:oauth-result", |
| 260 | ok: true, | 252 | ok: true, |
| 261 | sessionId: urlParams.state, | 253 | sessionId: urlParams.state, |
| 262 | accessTokenSecretId: c.accessTokenSecretId, | 254 | connectionId: c.connectionId, |
| 263 | refreshTokenSecretId: c.refreshTokenSecretId, | ||
| 264 | tokenType: c.tokenType, | 255 | tokenType: c.tokenType, |
| 265 | expiresAt: c.expiresAt, | 256 | expiresAt: c.expiresAt, |
| 266 | scope: c.scope, | 257 | scope: c.scope, |
| 258 | clientInformation: c.clientInformation, | ||
| 259 | authorizationServerUrl: c.authorizationServerUrl, | ||
| 260 | resourceMetadataUrl: c.resourceMetadataUrl, | ||
| 267 | }), | 261 | }), |
| 268 | onFailure: (cause) => | 262 | onFailure: (cause) => |
| 269 | Effect.succeed<OAuthPopupResult>({ | 263 | Effect.succeed<OAuthPopupResult>({ |
| @@ -49,29 +49,23 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 48 unmodified lines | |||
| 49 | return mcpPresets.find((p) => p.id === id); | 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. | 52 | // Stable SDK connection id for an MCP source's OAuth sign-in. The same |
| 53 | // The same ids are passed into `startOAuth` AND stored on the source's | 53 | // id is passed into `startOAuth` AND stored on the source's auth |
| 54 | // auth config, so at invoke time the per-user scope (which actually | 54 | // config, so at invoke time the per-user scope (which owns the user's |
| 55 | // holds the value) resolves via shadowing over the source-level id. | 55 | // own secrets) resolves via shadowing over the source-level pointer. |
| 56 | // Keyed by namespace so reconnecting overwrites the previous token | 56 | // Keyed by namespace so reconnecting mints a new connection that |
| 57 | // rather than leaking orphaned secret rows. | 57 | // shadows the previous one at the same scope. |
| 58 | const mcpOAuthTokenSecretIds = ( | 58 | const mcpOAuthConnectionId = (namespaceSlug: string): string => |
| 59 | namespaceSlug: string, | 59 | `mcp-oauth2-${namespaceSlug || "default"}`; |
| 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 | }; | ||
| 67 | 60 | ||
| 68 | // --------------------------------------------------------------------------- | 61 | // --------------------------------------------------------------------------- |
| 69 | // State machine (remote flow) | 62 | // State machine (remote flow) |
| 70 | // --------------------------------------------------------------------------- | 63 | // --------------------------------------------------------------------------- |
| 71 | 64 | ||
| 72 | type OAuthTokens = { | 65 | type OAuthTokens = { |
| 73 | accessTokenSecretId: string; | 66 | /** Id of the SDK Connection minted by the exchange. The UI stores it |
| 74 | refreshTokenSecretId: string | null; | 67 | * on the source's auth config as `{kind: "oauth2", connectionId}`. */ |
| 68 | connectionId: string; | ||
| 75 | tokenType: string; | 69 | tokenType: string; |
| 76 | expiresAt: number | null; | 70 | expiresAt: number | null; |
| 77 | scope: string | null; | 71 | scope: string | null; |
| @@ -445,14 +439,13 @@ export default function AddMcpSource(props: { · 367 unmodified lines | |||
| 445 | slugifyNamespace(remoteIdentity.namespace) || | 439 | slugifyNamespace(remoteIdentity.namespace) || |
| 446 | slugifyNamespace(probe?.namespace ?? "") || | 440 | slugifyNamespace(probe?.namespace ?? "") || |
| 447 | "mcp"; | 441 | "mcp"; |
| 448 | const tokenIds = mcpOAuthTokenSecretIds(namespaceSlug); | 442 | const connectionId = mcpOAuthConnectionId(namespaceSlug); |
| 449 | const result = await doStartOAuth({ | 443 | const result = await doStartOAuth({ |
| 450 | path: { scopeId }, | 444 | path: { scopeId }, |
| 451 | payload: { | 445 | payload: { |
| 452 | endpoint: state.url.trim(), | 446 | endpoint: state.url.trim(), |
| 453 | redirectUrl, | 447 | redirectUrl, |
| 454 | accessTokenSecretId: tokenIds.accessTokenSecretId, | 448 | connectionId, |
| 455 | refreshTokenSecretId: tokenIds.refreshTokenSecretId, | ||
| 456 | }, | 449 | }, |
| 457 | }); | 450 | }); |
| 458 | dispatch({ type: "oauth-waiting", sessionId: result.sessionId }); | 451 | dispatch({ type: "oauth-waiting", sessionId: result.sessionId }); |
| @@ -464,8 +457,7 @@ export default function AddMcpSource(props: { · 5 unmodified lines | |||
| 464 | dispatch({ | 457 | dispatch({ |
| 465 | type: "oauth-ok", | 458 | type: "oauth-ok", |
| 466 | tokens: { | 459 | tokens: { |
| 467 | accessTokenSecretId: data.accessTokenSecretId, | 460 | connectionId: data.connectionId, |
| 468 | refreshTokenSecretId: data.refreshTokenSecretId, | ||
| 469 | tokenType: data.tokenType, | 461 | tokenType: data.tokenType, |
| 470 | expiresAt: data.expiresAt, | 462 | expiresAt: data.expiresAt, |
| 471 | scope: data.scope, | 463 | scope: data.scope, |
| @@ -512,14 +504,7 @@ export default function AddMcpSource(props: { · 40 unmodified lines | |||
| 512 | : remoteAuthMode === "oauth2" && tokens | 504 | : remoteAuthMode === "oauth2" && tokens |
| 513 | ? { | 505 | ? { |
| 514 | kind: "oauth2" as const, | 506 | kind: "oauth2" as const, |
| 515 | accessTokenSecretId: tokens.accessTokenSecretId, | 507 | connectionId: tokens.connectionId, |
| 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, | ||
| 523 | } | 508 | } |
| 524 | : { kind: "none" as const }; | 509 | : { kind: "none" as const }; |
| 525 | const headers = Object.fromEntries( | 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 | const LazyAddMcpSource = lazy(() => import("./AddMcpSource")); | 5 | const LazyAddMcpSource = lazy(() => import("./AddMcpSource")); |
| 6 | const LazyEditMcpSource = lazy(() => import("./EditMcpSource")); | 6 | const LazyEditMcpSource = lazy(() => import("./EditMcpSource")); |
| 7 | const LazySummary = lazy(() => import("./McpSourceSummary")); | 7 | const LazySummary = lazy(() => import("./McpSourceSummary")); |
| 8 | const LazyMcpSignInButton = lazy(() => import("./McpSignInButton")); | ||
| 8 | 9 | ||
| 9 | type AddProps = ComponentProps<SourcePlugin["add"]>; | 10 | type AddProps = ComponentProps<SourcePlugin["add"]>; |
| 10 | 11 | ||
| @@ -40,6 +41,7 @@ export const createMcpSourcePlugin = ( · 29 unmodified lines | |||
| 40 | add: AddWithFlag, | 41 | add: AddWithFlag, |
| 41 | edit: LazyEditMcpSource, | 42 | edit: LazyEditMcpSource, |
| 42 | summary: LazySummary, | 43 | summary: LazySummary, |
| 44 | signIn: LazyMcpSignInButton, | ||
| 43 | presets, | 45 | presets, |
| 44 | }; | 46 | }; |
| 45 | }; | 47 | }; |
| @@ -38,18 +38,18 @@ export const McpOAuthSession = Schema.Struct({ · 37 unmodified lines | |||
| 38 | redirectUrl: Schema.String, | 38 | redirectUrl: Schema.String, |
| 39 | codeVerifier: Schema.String, | 39 | codeVerifier: Schema.String, |
| 40 | /** | 40 | /** |
| 41 | * Executor scope id where the minted access/refresh tokens will land. | 41 | * Executor scope id where the minted Connection (and its owned |
| 42 | * Pinned at `startOAuth` time so token writes target the same scope | 42 | * access/refresh secrets) will land. Pinned at `startOAuth` time so |
| 43 | * regardless of who's currently invoking. For per-user OAuth this is | 43 | * token writes target the same scope regardless of who's currently |
| 44 | * the innermost (`ctx.scopes[0]`) scope; for org-shared installs it | 44 | * invoking. For per-user OAuth this is the innermost |
| 45 | * can be the org scope. | 45 | * (`ctx.scopes[0]`) scope; for org-shared installs it can be the org |
| 46 | * scope. | ||
| 46 | */ | 47 | */ |
| 47 | tokenScope: Schema.String, | 48 | tokenScope: Schema.String, |
| 48 | /** Stable secret ids the minted tokens are written to. Stored once on | 49 | /** Stable id of the SDK Connection the exchange will mint. Stored |
| 49 | * the source's auth config so per-user scope shadowing resolves to | 50 | * once on the source's auth config so per-user scope shadowing |
| 50 | * the calling user's tokens at invoke time. */ | 51 | * resolves to the calling user's connection at invoke time. */ |
| 51 | accessTokenSecretId: Schema.String, | 52 | connectionId: Schema.String, |
| 52 | refreshTokenSecretId: Schema.NullOr(Schema.String), | ||
| 53 | }); | 53 | }); |
| 54 | export type McpOAuthSession = typeof McpOAuthSession.Type; | 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 { | 3 | import { |
| 4 | OAuthClientProvider, | 4 | refreshAuthorization, |
| 5 | OAuthDiscoveryState, | 5 | type OAuthClientProvider, |
| 6 | type OAuthDiscoveryState, | ||
| 6 | } from "@modelcontextprotocol/sdk/client/auth.js"; | 7 | } from "@modelcontextprotocol/sdk/client/auth.js"; |
| 7 | import type { | 8 | import type { |
| 8 | OAuthClientInformationMixed, | 9 | OAuthClientInformationMixed, |
| 10 | OAuthMetadata, | ||
| 9 | OAuthTokens, | 11 | OAuthTokens, |
| 10 | } from "@modelcontextprotocol/sdk/shared/auth.js"; | 12 | } from "@modelcontextprotocol/sdk/shared/auth.js"; |
| 11 | 13 | ||
| 12 | import { | 14 | import { |
| 13 | definePlugin, | 15 | ConnectionId, |
| 16 | ConnectionRefreshError, | ||
| 17 | CreateConnectionInput, | ||
| 14 | ScopeId, | 18 | ScopeId, |
| 15 | SecretId, | 19 | SecretId, |
| 16 | SetSecretInput, | ||
| 17 | SourceDetectionResult, | 20 | SourceDetectionResult, |
| 21 | TokenMaterial, | ||
| 22 | definePlugin, | ||
| 23 | type ConnectionProvider, | ||
| 24 | type ConnectionRefreshInput, | ||
| 25 | type ConnectionRefreshResult, | ||
| 18 | type PluginCtx, | 26 | type PluginCtx, |
| 19 | type StorageFailure, | 27 | type StorageFailure, |
| 20 | } from "@executor/sdk"; | 28 | } from "@executor/sdk"; |
| @@ -42,7 +50,12 @@ import { · 21 unmodified lines | |||
| 42 | type McpToolManifestEntry, | 50 | type McpToolManifestEntry, |
| 43 | } from "./manifest"; | 51 | } from "./manifest"; |
| 44 | import { exchangeMcpOAuthCode, startMcpOAuthAuthorization } from "./oauth"; | 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 | import { | 60 | import { |
| 48 | SECRET_REF_PREFIX, | 61 | SECRET_REF_PREFIX, |
| @@ -97,20 +110,20 @@ export interface McpOAuthStartInput { · 48 unmodified lines | |||
| 97 | readonly redirectUrl: string; | 110 | readonly redirectUrl: string; |
| 98 | readonly queryParams?: Record<string, string> | null; | 111 | readonly queryParams?: Record<string, string> | null; |
| 99 | /** | 112 | /** |
| 100 | * Executor scope id where the minted access/refresh tokens will land. | 113 | * Executor scope id where the minted Connection will land. Defaults |
| 101 | * Defaults to `ctx.scopes[0].id` (innermost) — for a per-user stack | 114 | * 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 | 115 | * `[user, org]` that pins the connection to the user scope so the |
| 103 | * stored `accessTokenSecretId` resolves per-user via shadowing. | 116 | * source's stored `connectionId` resolves per-user via shadowing. |
| 104 | */ | 117 | */ |
| 105 | readonly tokenScope?: string; | 118 | readonly tokenScope?: string; |
| 106 | /** | 119 | /** |
| 107 | * Pre-decided secret ids for the minted tokens. Mint deterministically | 120 | * Pre-decided SDK connection id the exchange will mint. Stable per |
| 108 | * (e.g. `mcp_${namespace}_access_token`) so the source's stored | 121 | * source (typically `mcp-oauth2-${namespace}`) so multiple users |
| 109 | * OAuth2 auth carries the same id everyone reads, and `ctx.secrets.get` | 122 | * signing in against the same MCP source all write to the same id at |
| 110 | * resolves per-user via scope fall-through. | 123 | * their own scope — `ctx.connections.accessToken(id)` then resolves |
| 124 | * innermost-first. | ||
| 111 | */ | 125 | */ |
| 112 | readonly accessTokenSecretId: string; | 126 | readonly connectionId: string; |
| 113 | readonly refreshTokenSecretId?: string | null; | ||
| 114 | /** | 127 | /** |
| 115 | * Source-level OAuth state captured by a previous user's flow. Pass | 128 | * Source-level OAuth state captured by a previous user's flow. Pass |
| 116 | * the values stored on the source's auth config to skip Dynamic | 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 | export interface McpOAuthCompleteResponse { | 149 | export interface McpOAuthCompleteResponse { |
| 137 | readonly accessTokenSecretId: string; | 150 | /** Id of the SDK Connection the exchange minted. The caller stores it |
| 138 | readonly refreshTokenSecretId: string | null; | 151 | * on the source's `oauth2` auth field and resolves tokens via |
| 152 | * `ctx.connections`. */ | ||
| 153 | readonly connectionId: string; | ||
| 139 | readonly tokenType: string; | 154 | readonly tokenType: string; |
| 140 | readonly expiresAt: number | null; | 155 | readonly expiresAt: number | null; |
| 141 | readonly scope: string | null; | 156 | readonly scope: string | null; |
| 142 | /** DCR client + discovery URLs captured during the flow. The UI | 157 | /** DCR client + discovery URLs captured during the flow. The UI |
| 143 | * stores them on the source's auth config so refreshes don't | 158 | * stores them on the source's auth config so subsequent users can |
| 144 | * re-register or re-discover. */ | 159 | * skip DCR and re-discovery. */ |
| 145 | readonly clientInformation: Record<string, unknown> | null; | 160 | readonly clientInformation: Record<string, unknown> | null; |
| 146 | readonly authorizationServerUrl: string | null; | 161 | readonly authorizationServerUrl: string | null; |
| 147 | readonly resourceMetadataUrl: string | null; | 162 | readonly resourceMetadataUrl: string | null; |
| @@ -208,31 +223,20 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 60 unmodified lines | |||
| 208 | interface OAuthProviderInputs { | 223 | interface OAuthProviderInputs { |
| 209 | readonly accessToken: string; | 224 | readonly accessToken: string; |
| 210 | readonly tokenType: string; | 225 | readonly tokenType: string; |
| 211 | readonly refreshToken?: string; | 226 | /** Source-level state — same for every user. Surfaced to the MCP |
| 212 | readonly expiresAt?: number | null; | 227 | * transport so internal DCR/discovery lookups find a populated |
| 213 | readonly scope?: string | null; | 228 | * provider, but never used to mint tokens: refresh is owned by the |
| 214 | /** Source-level state — same for every user. */ | 229 | * SDK via `ctx.connections.accessToken`. */ |
| 215 | readonly clientInformation?: OAuthClientInformationMixed | null; | 230 | readonly clientInformation?: OAuthClientInformationMixed | null; |
| 216 | readonly authorizationServerUrl?: string | null; | 231 | readonly authorizationServerUrl?: string | null; |
| 217 | readonly resourceMetadataUrl?: string | null; | 232 | readonly resourceMetadataUrl?: string | null; |
| 218 | readonly endpoint: string; | 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 | const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => { | 236 | const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => { |
| 228 | let currentTokens: OAuthTokens = { | 237 | const currentTokens: OAuthTokens = { |
| 229 | access_token: inputs.accessToken, | 238 | access_token: inputs.accessToken, |
| 230 | token_type: inputs.tokenType, | 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 | let clientInformation: OAuthClientInformationMixed | undefined = | 241 | let clientInformation: OAuthClientInformationMixed | undefined = |
| 238 | inputs.clientInformation ?? undefined; | 242 | inputs.clientInformation ?? undefined; |
| @@ -263,10 +267,13 @@ const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => · 24 unmodified lines | |||
| 263 | clientInformation = ci; | 267 | clientInformation = ci; |
| 264 | }, | 268 | }, |
| 265 | tokens: () => currentTokens, | 269 | tokens: () => currentTokens, |
| 266 | saveTokens: async (t) => { | 270 | // No-op: token refresh is driven by the SDK through |
| 267 | currentTokens = t; | 271 | // `ctx.connections.accessToken`, and the access token resolved at |
| 268 | if (inputs.onRefresh) await inputs.onRefresh(t); | 272 | // `resolveConnectorInput` time is the only one this provider ever |
| 269 | }, | 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 | redirectToAuthorization: async () => { | 277 | redirectToAuthorization: async () => { |
| 271 | throw new Error("MCP OAuth re-authorization required"); | 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 | const remoteConnectionError = (message: string) => | 323 | const remoteConnectionError = (message: string) => |
| 285 | new McpConnectionError({ transport: "remote", message }); | 324 | new McpConnectionError({ transport: "remote", message }); |
| 286 | 325 | ||
| @@ -331,66 +370,47 @@ const resolveConnectorInput = ( · 44 unmodified lines | |||
| 331 | } | 370 | } |
| 332 | headers[auth.headerName] = auth.prefix ? `${auth.prefix}${val}` : val; | 371 | headers[auth.headerName] = auth.prefix ? `${auth.prefix}${val}` : val; |
| 333 | } else if (auth.kind === "oauth2") { | 372 | } else if (auth.kind === "oauth2") { |
| 334 | const accessToken = yield* ctx.secrets.get(auth.accessTokenSecretId); | 373 | // `accessToken(id)` handles refresh internally — by the time we |
| 335 | if (accessToken === null) { | 374 | // hand the value to the MCP transport it's guaranteed fresh. The |
| 336 | return yield* Effect.fail( | 375 | // connection row carries the DCR client + discovery metadata in |
| 337 | remoteConnectionError("Failed to resolve OAuth access token"), | 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 | } | 392 | |
| 340 | const refreshToken = auth.refreshTokenSecretId | 393 | const connection = yield* ctx.connections.get(auth.connectionId); |
| 341 | ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined | 394 | const providerState: OAuth2ProviderState | null = |
| 342 | : undefined; | 395 | connection?.providerState |
| 343 | // Capture the resolved owning scope of these secrets so refreshed | 396 | ? yield* Effect.sync(() => { |
| 344 | // tokens land back at the same per-user scope. `ctx.secrets.get` | 397 | try { |
| 345 | // walks the executor scope stack innermost-first, so the existing | 398 | return decodeProviderState(connection.providerState); |
| 346 | // value lives at whichever scope shadowed the source-level id — | 399 | } catch { |
| 347 | // we mirror that with `ctx.scopes[0]!.id`, matching the scope | 400 | return null; |
| 348 | // chosen at startOAuth time. | 401 | } |
| 349 | const tokenScope = ScopeId.make(ctx.scopes[0]!.id as string); | 402 | }) |
| 350 | const accessSecretId = auth.accessTokenSecretId; | 403 | : null; |
| 351 | const refreshSecretId = auth.refreshTokenSecretId; | 404 | |
| 352 | authProvider = makeOAuthProvider({ | 405 | authProvider = makeOAuthProvider({ |
| 353 | accessToken, | 406 | accessToken, |
| 354 | tokenType: auth.tokenType ?? "Bearer", | 407 | tokenType: providerState?.tokenType ?? "Bearer", |
| 355 | refreshToken, | 408 | clientInformation: |
| 356 | expiresAt: auth.expiresAt, | 409 | (providerState?.clientInformation as OAuthClientInformationMixed | undefined) ?? |
| 357 | scope: auth.scope, | 410 | null, |
| 358 | clientInformation: auth.clientInformation as | 411 | authorizationServerUrl: providerState?.authorizationServerUrl ?? null, |
| 359 | | OAuthClientInformationMixed | 412 | resourceMetadataUrl: providerState?.resourceMetadataUrl ?? null, |
| 360 | | null | ||
| 361 | | undefined, | ||
| 362 | authorizationServerUrl: auth.authorizationServerUrl, | ||
| 363 | resourceMetadataUrl: auth.resourceMetadataUrl, | ||
| 364 | endpoint: sd.endpoint, | 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 | return { | 509 | return { |
| 490 | kind: "oauth2", | 510 | kind: "oauth2", |
| 491 | accessTokenSecret: secretRef(auth.accessTokenSecretId), | 511 | connectionId: auth.connectionId, |
| 492 | refreshTokenSecret: auth.refreshTokenSecretId | ||
| 493 | ? secretRef(auth.refreshTokenSecretId) | ||
| 494 | : null, | ||
| 495 | tokenType: auth.tokenType, | ||
| 496 | }; | 512 | }; |
| 497 | }; | 513 | }; |
| 498 | 514 | ||
| @@ -889,8 +905,7 @@ export const mcpPlugin = definePlugin( · 390 unmodified lines | |||
| 889 | authorizationServerMetadata: started.authorizationServerMetadata, | 905 | authorizationServerMetadata: started.authorizationServerMetadata, |
| 890 | clientInformation: started.clientInformation, | 906 | clientInformation: started.clientInformation, |
| 891 | tokenScope, | 907 | tokenScope, |
| 892 | accessTokenSecretId: input.accessTokenSecretId, | 908 | connectionId: input.connectionId, |
| 893 | refreshTokenSecretId: input.refreshTokenSecretId ?? null, | ||
| 894 | }) | 909 | }) |
| 895 | .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session")); | 910 | .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session")); |
| 896 | 911 | ||
| @@ -901,104 +916,139 @@ export const mcpPlugin = definePlugin( · 4 unmodified lines | |||
| 901 | }).pipe(Effect.withSpan("mcp.plugin.start_oauth")); | 916 | }).pipe(Effect.withSpan("mcp.plugin.start_oauth")); |
| 902 | 917 | ||
| 903 | const completeOAuth = (input: McpOAuthCompleteInput) => | 918 | const completeOAuth = (input: McpOAuthCompleteInput) => |
| 904 | Effect.gen(function* () { | 919 | ctx |
| 905 | if (input.error) { | 920 | .transaction( |
| 906 | return yield* Effect.fail( | 921 | Effect.gen(function* () { |
| 907 | mcpOAuthError(`OAuth error: ${input.error}`), | 922 | if (input.error) { |
| 908 | ); | 923 | return yield* Effect.fail( |
| 909 | } | 924 | mcpOAuthError(`OAuth error: ${input.error}`), |
| 910 | if (!input.code) { | 925 | ); |
| 911 | return yield* Effect.fail( | 926 | } |
| 912 | mcpOAuthError("Missing OAuth authorization code"), | 927 | if (!input.code) { |
| 913 | ); | 928 | return yield* Effect.fail( |
| 914 | } | 929 | mcpOAuthError("Missing OAuth authorization code"), |
| 915 | 930 | ); | |
| 916 | const session = yield* ctx.storage.getOAuthSession(input.state); | 931 | } |
| 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 | ); | ||
| 932 | 932 | ||
| 933 | // Token storage is fully driven by the session: scope and | 933 | const session = yield* ctx.storage.getOAuthSession(input.state); |
| 934 | // secret ids were chosen at startOAuth time and pinned to | 934 | if (!session) { |
| 935 | // the row. That keeps shadowing deterministic — every | 935 | return yield* Effect.fail( |
| 936 | // user's OAuth flow on the same source writes to the same | 936 | mcpOAuthError(`OAuth session not found: ${input.state}`), |
| 937 | // ids, so the source's stored OAuth2 auth resolves | 937 | ); |
| 938 | // per-user via scope fall-through. | 938 | } |
| 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 | ); | ||
| 955 | 939 | ||
| 956 | let refreshTokenSecretId: string | null = null; | 940 | const exchanged = yield* exchangeMcpOAuthCode({ |
| 957 | if (exchanged.tokens.refresh_token && session.refreshTokenSecretId) { | 941 | session, |
| 958 | const refreshId = session.refreshTokenSecretId; | 942 | code: input.code, |
| 959 | yield* ctx.secrets | 943 | }).pipe( |
| 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( | ||
| 969 | Effect.mapError((e) => | 944 | Effect.mapError((e) => |
| 970 | mcpOAuthError( | 945 | mcpOAuthError(`OAuth exchange failed: ${e.message}`), |
| 971 | `Failed to store refresh token: ${String(e)}`, | ||
| 972 | ), | ||
| 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 = | 950 | yield* ctx.storage.deleteOAuthSession(input.state); |
| 981 | typeof exchanged.tokens.expires_in === "number" | 951 | |
| 982 | ? Date.now() + exchanged.tokens.expires_in * 1000 | 952 | const tokenType = exchanged.tokens.token_type ?? "Bearer"; |
| 983 | : null; | 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 { | 1035 | return { |
| 986 | accessTokenSecretId: accessSecretId, | 1036 | connectionId: session.connectionId, |
| 987 | refreshTokenSecretId, | 1037 | tokenType, |
| 988 | tokenType: exchanged.tokens.token_type ?? "Bearer", | 1038 | expiresAt, |
| 989 | expiresAt, | 1039 | scope: exchanged.tokens.scope ?? null, |
| 990 | scope: exchanged.tokens.scope ?? null, | 1040 | // Echo the source-level OAuth state captured during this |
| 991 | // Echo the source-level OAuth state captured during this | 1041 | // flow. The UI persists it on the source's auth config |
| 992 | // flow. The UI persists it on the source's auth config so | 1042 | // so subsequent users can skip DCR + re-discovery. |
| 993 | // refreshes (and any future user's OAuth) re-use the same | 1043 | clientInformation: exchanged.clientInformation as |
| 994 | // DCR client + skip discovery. | 1044 | | Record<string, unknown> |
| 995 | clientInformation: exchanged.clientInformation as | 1045 | | null, |
| 996 | | Record<string, unknown> | 1046 | authorizationServerUrl: exchanged.authorizationServerUrl, |
| 997 | | null, | 1047 | resourceMetadataUrl: exchanged.resourceMetadataUrl, |
| 998 | authorizationServerUrl: exchanged.authorizationServerUrl, | 1048 | }; |
| 999 | resourceMetadataUrl: exchanged.resourceMetadataUrl, | 1049 | }), |
| 1000 | }; | 1050 | ) |
| 1001 | }).pipe(Effect.withSpan("mcp.plugin.complete_oauth")); | 1051 | .pipe(Effect.withSpan("mcp.plugin.complete_oauth")); |
| 1002 | 1052 | ||
| 1003 | const updateSource = ( | 1053 | const updateSource = ( |
| 1004 | namespace: string, | 1054 | namespace: string, |
| @@ -1205,6 +1255,87 @@ export const mcpPlugin = definePlugin( · 200 unmodified lines | |||
| 1205 | 1255 | ||
| 1206 | refreshSource: () => Effect.void, | 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 | close: () => | 1339 | close: () => |
| 1209 | Effect.gen(function* () { | 1340 | Effect.gen(function* () { |
| 1210 | const runtime = runtimeRef.current; | 1341 | const runtime = runtimeRef.current; |
| @@ -13,10 +13,17 @@ export type McpTransport = typeof McpTransport.Type; · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | // --------------------------------------------------------------------------- | 14 | // --------------------------------------------------------------------------- |
| 15 | // Connection auth (only applies to remote sources) | 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 | /** JSON object loosely typed — used for opaque OAuth state we just round-trip. */ | 24 | /** JSON object loosely typed — used for opaque OAuth state we just round-trip. */ |
| 19 | const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown }); | 25 | const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown }); |
| 26 | export { JsonObject as McpJsonObject }; | ||
| 20 | 27 | ||
| 21 | export const McpConnectionAuth = Schema.Union( | 28 | export const McpConnectionAuth = Schema.Union( |
| 22 | Schema.Struct({ kind: Schema.Literal("none") }), | 29 | Schema.Struct({ kind: Schema.Literal("none") }), |
| @@ -28,32 +35,7 @@ export const McpConnectionAuth = Schema.Union( · 5 unmodified lines | |||
| 28 | }), | 35 | }), |
| 29 | Schema.Struct({ | 36 | Schema.Struct({ |
| 30 | kind: Schema.Literal("oauth2"), | 37 | kind: Schema.Literal("oauth2"), |
| 31 | accessTokenSecretId: Schema.String, | 38 | connectionId: 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 | }), | ||
| 57 | }), | 39 | }), |
| 58 | ); | 40 | ); |
| 59 | export type McpConnectionAuth = typeof McpConnectionAuth.Type; | 41 | export type McpConnectionAuth = typeof McpConnectionAuth.Type; |
| @@ -399,17 +399,3 @@ export const shouldRefreshToken = (input: { · 398 unmodified lines | |||
| 399 | return input.expiresAt <= now + skew; | 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 | name: Schema.optional(Schema.String), | 40 | name: Schema.optional(Schema.String), |
| 41 | baseUrl: Schema.optional(Schema.String), | 41 | baseUrl: Schema.optional(Schema.String), |
| 42 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), | 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 | const UpdateSourceResponse = Schema.Struct({ | 48 | const UpdateSourceResponse = Schema.Struct({ |
| @@ -60,10 +63,9 @@ const AddSpecResponse = Schema.Struct({ · 14 unmodified lines | |||
| 60 | // --------------------------------------------------------------------------- | 63 | // --------------------------------------------------------------------------- |
| 61 | 64 | ||
| 62 | // Shared identity fields for both OAuth2 flows. `tokenScope` names which | 65 | // Shared identity fields for both OAuth2 flows. `tokenScope` names which |
| 63 | // executor scope will own the minted tokens (typically the per-user scope). | 66 | // executor scope will own the resulting Connection (typically the per-user |
| 64 | // The token secret ids are pre-decided so the source's stored `OAuth2Auth` | 67 | // scope). Token secret ids are minted by the SDK from the Connection id — |
| 65 | // can reference the same ids across every user — per-user values shadow | 68 | // callers never pick them. |
| 66 | // org-level fallbacks via secret fall-through on read. | ||
| 67 | const StartOAuthIdentityFields = { | 69 | const StartOAuthIdentityFields = { |
| 68 | displayName: Schema.String, | 70 | displayName: Schema.String, |
| 69 | securitySchemeName: Schema.String, | 71 | securitySchemeName: Schema.String, |
| @@ -71,7 +73,6 @@ const StartOAuthIdentityFields = { · 1 unmodified line | |||
| 71 | clientIdSecretId: Schema.String, | 73 | clientIdSecretId: Schema.String, |
| 72 | scopes: Schema.Array(Schema.String), | 74 | scopes: Schema.Array(Schema.String), |
| 73 | tokenScope: Schema.optional(ScopeId), | 75 | tokenScope: Schema.optional(ScopeId), |
| 74 | accessTokenSecretId: Schema.String, | ||
| 75 | } as const; | 76 | } as const; |
| 76 | 77 | ||
| 77 | const StartOAuthPayload = Schema.Union( | 78 | const StartOAuthPayload = Schema.Union( |
| @@ -81,7 +82,6 @@ const StartOAuthPayload = Schema.Union( · 3 unmodified lines | |||
| 81 | authorizationUrl: Schema.String, | 82 | authorizationUrl: Schema.String, |
| 82 | redirectUrl: Schema.String, | 83 | redirectUrl: Schema.String, |
| 83 | clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), | 84 | clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), |
| 84 | refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 85 | }), | 85 | }), |
| 86 | // RFC 6749 §4.4 — no user-interactive step, no session, no popup. The | 86 | // RFC 6749 §4.4 — no user-interactive step, no session, no popup. The |
| 87 | // plugin exchanges tokens inline and returns a completed auth. The | 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 | name: payload.name, | 91 | name: payload.name, |
| 92 | baseUrl: payload.baseUrl, | 92 | baseUrl: payload.baseUrl, |
| 93 | headers: payload.headers as Record<string, HeaderValue> | undefined, | 93 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 94 | oauth2: payload.oauth2, | ||
| 94 | } as OpenApiUpdateSourceInput); | 95 | } as OpenApiUpdateSourceInput); |
| 95 | return { updated: true }; | 96 | return { updated: true }; |
| 96 | })), | 97 | })), |
| @@ -112,7 +113,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 15 unmodified lines | |||
| 112 | clientSecretSecretId: payload.clientSecretSecretId, | 113 | clientSecretSecretId: payload.clientSecretSecretId, |
| 113 | scopes: [...payload.scopes], | 114 | scopes: [...payload.scopes], |
| 114 | tokenScope, | 115 | tokenScope, |
| 115 | accessTokenSecretId: payload.accessTokenSecretId, | ||
| 116 | }); | 116 | }); |
| 117 | } | 117 | } |
| 118 | return yield* ext.startOAuth({ | 118 | return yield* ext.startOAuth({ |
| @@ -126,8 +126,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines | |||
| 126 | clientSecretSecretId: payload.clientSecretSecretId ?? null, | 126 | clientSecretSecretId: payload.clientSecretSecretId ?? null, |
| 127 | scopes: [...payload.scopes], | 127 | scopes: [...payload.scopes], |
| 128 | tokenScope, | 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 | export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; | 61 | export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; |
| 62 | export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/openapi/oauth/callback"; | 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 | const substituteUrlVariables = (url: string, values: Record<string, string>): string => { | 64 | const substituteUrlVariables = (url: string, values: Record<string, string>): string => { |
| 85 | let out = url; | 65 | let out = url; |
| 86 | for (const [name, value] of Object.entries(values)) { | 66 | for (const [name, value] of Object.entries(values)) { |
| @@ -402,15 +382,6 @@ export default function AddOpenApiSource(props: { · 315 unmodified lines | |||
| 402 | const displayName = | 382 | const displayName = |
| 403 | identity.name.trim() || selectedOAuth2Preset.securitySchemeName; | 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 | const tokenUrl = resolveOAuthUrl( | 385 | const tokenUrl = resolveOAuthUrl( |
| 415 | selectedOAuth2Preset.tokenUrl, | 386 | selectedOAuth2Preset.tokenUrl, |
| 416 | resolvedBaseUrl, | 387 | resolvedBaseUrl, |
| @@ -435,7 +406,6 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines | |||
| 435 | clientIdSecretId: oauth2ClientIdSecretId, | 406 | clientIdSecretId: oauth2ClientIdSecretId, |
| 436 | clientSecretSecretId: oauth2ClientSecretSecretId, | 407 | clientSecretSecretId: oauth2ClientSecretSecretId, |
| 437 | scopes: [...oauth2SelectedScopes], | 408 | scopes: [...oauth2SelectedScopes], |
| 438 | accessTokenSecretId: tokenIds.accessTokenSecretId, | ||
| 439 | }, | 409 | }, |
| 440 | }); | 410 | }); |
| 441 | setStartingOAuth(false); | 411 | setStartingOAuth(false); |
| @@ -465,8 +435,6 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines | |||
| 465 | clientIdSecretId: oauth2ClientIdSecretId, | 435 | clientIdSecretId: oauth2ClientIdSecretId, |
| 466 | clientSecretSecretId: oauth2ClientSecretSecretId, | 436 | clientSecretSecretId: oauth2ClientSecretSecretId, |
| 467 | scopes: [...oauth2SelectedScopes], | 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 | oauthCleanup.current = null; | 452 | oauthCleanup.current = null; |
| 485 | setStartingOAuth(false); | 453 | setStartingOAuth(false); |
| 486 | if (result.ok) { | 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 | setOauth2Auth( | 455 | setOauth2Auth( |
| 491 | new OAuth2Auth({ | 456 | new OAuth2Auth({ |
| 492 | kind: "oauth2", | 457 | kind: "oauth2", |
| 458 | connectionId: result.connectionId, | ||
| 493 | securitySchemeName: result.securitySchemeName, | 459 | securitySchemeName: result.securitySchemeName, |
| 494 | flow: result.flow, | 460 | flow: result.flow, |
| 495 | tokenUrl: result.tokenUrl, | 461 | tokenUrl: result.tokenUrl, |
| 462 | authorizationUrl: result.authorizationUrl, | ||
| 496 | clientIdSecretId: result.clientIdSecretId, | 463 | clientIdSecretId: result.clientIdSecretId, |
| 497 | clientSecretSecretId: result.clientSecretSecretId, | 464 | clientSecretSecretId: result.clientSecretSecretId, |
| 498 | accessTokenSecretId: result.accessTokenSecretId, | 465 | scopes: result.scopes, |
| 499 | refreshTokenSecretId: result.refreshTokenSecretId, | ||
| 500 | tokenType: result.tokenType, | ||
| 501 | expiresAt: result.expiresAt, | ||
| 502 | scope: result.scope, | ||
| 503 | scopes: [...result.scopes], | ||
| 504 | }), | 466 | }), |
| 505 | ); | 467 | ); |
| 506 | setOauth2Error(null); | 468 | setOauth2Error(null); |
| @@ -535,7 +497,6 @@ export default function AddOpenApiSource(props: { · 28 unmodified lines | |||
| 535 | doStartOAuth, | 497 | doStartOAuth, |
| 536 | scopeId, | 498 | scopeId, |
| 537 | identity.name, | 499 | identity.name, |
| 538 | identity.namespace, | ||
| 539 | ]); | 500 | ]); |
| 540 | 501 | ||
| 541 | const handleCancelOAuth2 = useCallback(() => { | 502 | const handleCancelOAuth2 = useCallback(() => { |
| @@ -974,8 +935,8 @@ export default function AddOpenApiSource(props: { · 432 unmodified lines | |||
| 974 | {oauth2Auth ? ( | 935 | {oauth2Auth ? ( |
| 975 | <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2"> | 936 | <div className="flex items-center justify-between rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2"> |
| 976 | <div className="text-[11px] text-green-700 dark:text-green-400"> | 937 | <div className="text-[11px] text-green-700 dark:text-green-400"> |
| 977 | Connected · {oauth2Auth.scopes.length} scope | 938 | Connected · {oauth2SelectedScopes.size} scope |
| 978 | {oauth2Auth.scopes.length === 1 ? "" : "s"} granted | 939 | {oauth2SelectedScopes.size === 1 ? "" : "s"} granted |
| 979 | </div> | 940 | </div> |
| 980 | <Button | 941 | <Button |
| 981 | variant="ghost" | 942 | variant="ghost" |
| @@ -1,30 +1,16 @@ | |||
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { | 2 | import { |
| 3 | useAtomValue, | 3 | useAtomValue, |
| 4 | useAtomSet, | 4 | useAtomSet, |
| 5 | useAtomRefresh, | ||
| 6 | Result, | 5 | Result, |
| 7 | } from "@effect-atom/atom-react"; | 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 | import { | 8 | import { |
| 14 | openApiSourceAtom, | 9 | openApiSourceAtom, |
| 15 | previewOpenApiSpec, | ||
| 16 | startOpenApiOAuth, | ||
| 17 | updateOpenApiSource, | 10 | updateOpenApiSource, |
| 18 | } from "./atoms"; | 11 | } from "./atoms"; |
| 19 | import { useScope } from "@executor/react/api/scope-context"; | 12 | import { useScope } from "@executor/react/api/scope-context"; |
| 20 | import { | 13 | import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; |
| 21 | secretStatusAtom, | ||
| 22 | removeSecret, | ||
| 23 | } from "@executor/react/api/atoms"; | ||
| 24 | import { | ||
| 25 | secretWriteKeys, | ||
| 26 | sourceWriteKeys, | ||
| 27 | } from "@executor/react/api/reactivity-keys"; | ||
| 28 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; | 14 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 29 | import { | 15 | import { |
| 30 | headerValueToState, | 16 | headerValueToState, |
| @@ -45,357 +31,12 @@ import { · 14 unmodified lines | |||
| 45 | import { FieldLabel } from "@executor/react/components/field"; | 31 | import { FieldLabel } from "@executor/react/components/field"; |
| 46 | import { Input } from "@executor/react/components/input"; | 32 | import { Input } from "@executor/react/components/input"; |
| 47 | import { Badge } from "@executor/react/components/badge"; | 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 | import type { StoredSourceSchemaType } from "../sdk/store"; | 34 | import type { StoredSourceSchemaType } from "../sdk/store"; |
| 58 | 35 | ||
| 59 | // --------------------------------------------------------------------------- | 36 | // --------------------------------------------------------------------------- |
| 60 | // Connections — one row per OAuth2Auth on the source | 37 | // Edit form — source config only. Sign-in / reconnect lives on the source |
| 61 | // --------------------------------------------------------------------------- | 38 | // detail top bar (see source-plugin.signIn) and connection lifecycle lives |
| 62 | 39 | // on the Connections page. | |
| 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 | ||
| 399 | // --------------------------------------------------------------------------- | 40 | // --------------------------------------------------------------------------- |
| 400 | 41 | ||
| 401 | function EditForm(props: { | 42 | function EditForm(props: { |
| @@ -423,13 +64,6 @@ function EditForm(props: { · 21 unmodified lines | |||
| 423 | 64 | ||
| 424 | const identityDirty = identity.name.trim() !== props.initial.name.trim(); | 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 | const handleHeadersChange = (next: HeaderState[]) => { | 67 | const handleHeadersChange = (next: HeaderState[]) => { |
| 434 | setHeaders(next); | 68 | setHeaders(next); |
| 435 | setDirty(true); | 69 | setDirty(true); |
| @@ -503,15 +137,6 @@ function EditForm(props: { · 67 unmodified lines | |||
| 503 | /> | 137 | /> |
| 504 | </section> | 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 | {error && ( | 140 | {error && ( |
| 516 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | 141 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> |
| 517 | <p className="text-sm text-destructive">{error}</p> | 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 | import { Result, useAtomValue } from "@effect-atom/atom-react"; | 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 | import { Badge } from "@executor/react/components/badge"; | 3 | import { Badge } from "@executor/react/components/badge"; |
| 7 | import { Skeleton } from "@executor/react/components/skeleton"; | ||
| 8 | 4 | ||
| 9 | import { openApiSourceAtom } from "./atoms"; | 5 | import { openApiSourceAtom } from "./atoms"; |
| 6 | import { useScope } from "@executor/react/api/scope-context"; | ||
| 10 | 7 | ||
| 11 | function ConnectedBadge(props: { accessTokenSecretId: string }) { | 8 | function ConnectedBadge() { |
| 12 | const scopeId = useScope(); | 9 | return ( |
| 13 | const status = useAtomValue( | 10 | <Badge |
| 14 | secretStatusAtom(scopeId, SecretId.make(props.accessTokenSecretId)), | 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 | // The entry row already renders name + id + kind, so this summary | 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 | : undefined; | 30 | : undefined; |
| 52 | 31 | ||
| 53 | if (!oauth2) return null; | 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 | add: lazy(() => import("./AddOpenApiSource")), | 8 | add: lazy(() => import("./AddOpenApiSource")), |
| 9 | edit: lazy(() => import("./EditOpenApiSource")), | 9 | edit: lazy(() => import("./EditOpenApiSource")), |
| 10 | summary: lazy(() => import("./OpenApiSourceSummary")), | 10 | summary: lazy(() => import("./OpenApiSourceSummary")), |
| 11 | signIn: lazy(() => import("./OpenApiSignInButton")), | ||
| 11 | presets: openApiPresets, | 12 | presets: openApiPresets, |
| 12 | }; | 13 | }; |
Binary file not shown.
| @@ -3,26 +3,9 @@ · 2 unmodified lines | |||
| 3 | // | 3 | // |
| 4 | // Models the production scenario: an org-level admin uploads the shared | 4 | // Models the production scenario: an org-level admin uploads the shared |
| 5 | // client credentials, each member of the org runs their own OAuth flow, | 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 | 6 | // and each member's access token lives on a per-user Connection. The |
| 7 | // org fallback provides client id/secret and the user scope provides | 7 | // Connections primitive owns every secret — they're filtered out of the |
| 8 | // the access token the invoker actually injects. | 8 | // user-facing `secrets.list()` automatically. |
| 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. | ||
| 26 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| 27 | 10 | ||
| 28 | import { afterEach } from "vitest"; | 11 | import { afterEach } from "vitest"; |
| @@ -53,7 +36,6 @@ import { · 24 unmodified lines | |||
| 53 | } from "@executor/sdk"; | 36 | } from "@executor/sdk"; |
| 54 | import { makeMemoryAdapter } from "@executor/storage-core/testing/memory"; | 37 | import { makeMemoryAdapter } from "@executor/storage-core/testing/memory"; |
| 55 | 38 | ||
| 56 | import { OAuth2Auth } from "./types"; | ||
| 57 | import { openApiPlugin } from "./plugin"; | 39 | import { openApiPlugin } from "./plugin"; |
| 58 | 40 | ||
| 59 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | 41 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| @@ -101,7 +83,7 @@ const TestLayer = HttpApiBuilder.serve().pipe( · 41 unmodified lines | |||
| 101 | const originalFetch = globalThis.fetch; | 83 | const originalFetch = globalThis.fetch; |
| 102 | 84 | ||
| 103 | const mockTokenFetch = (tokenByCode: Record<string, string>) => { | 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 | const bodyText = | 87 | const bodyText = |
| 106 | init?.body instanceof URLSearchParams | 88 | init?.body instanceof URLSearchParams |
| 107 | ? init.body.toString() | 89 | ? init.body.toString() |
| @@ -117,9 +99,6 @@ const mockTokenFetch = (tokenByCode: Record<string, string>) => { · 9 unmodified lines | |||
| 117 | { status: 400, headers: { "content-type": "application/json" } }, | 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 | return new Response( | 102 | return new Response( |
| 124 | JSON.stringify({ | 103 | JSON.stringify({ |
| 125 | access_token: token, | 104 | access_token: token, |
| @@ -141,18 +120,11 @@ afterEach(() => { · 15 unmodified lines | |||
| 141 | 120 | ||
| 142 | layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { | 121 | layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { |
| 143 | it.effect( | 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 | Effect.gen(function* () { | 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 | const secretStore = new Map<string, string>(); | 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 | const memoryProvider: SecretProvider = { | 128 | const memoryProvider: SecretProvider = { |
| 157 | key: "memory", | 129 | key: "memory", |
| 158 | writable: true, | 130 | writable: true, |
| @@ -171,7 +143,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 12 unmodified lines | |||
| 171 | secretProviders: [memoryProvider], | 143 | secretProviders: [memoryProvider], |
| 172 | })); | 144 | })); |
| 173 | 145 | ||
| 174 | // Route OpenAPI tool invocations through the Effect test server. | ||
| 175 | const httpClient = yield* HttpClient.HttpClient; | 146 | const httpClient = yield* HttpClient.HttpClient; |
| 176 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | 147 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); |
| 177 | const plugins = [ | 148 | const plugins = [ |
| @@ -179,8 +150,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 1 unmodified line | |||
| 179 | memorySecretsPlugin(), | 150 | memorySecretsPlugin(), |
| 180 | ] as const; | 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 | const schema = collectSchemas(plugins); | 153 | const schema = collectSchemas(plugins); |
| 185 | const adapter = makeMemoryAdapter({ schema }); | 154 | const adapter = makeMemoryAdapter({ schema }); |
| 186 | const blobs = makeInMemoryBlobStore(); | 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, | 214 | // 2. Each user runs startOAuth + completeOAuth to mint a |
| 246 | // pre-populating OAuth2Auth that points at: | 215 | // per-user Connection. |
| 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. | ||
| 288 | // ------------------------------------------------------------- | 216 | // ------------------------------------------------------------- |
| 289 | mockTokenFetch({ | 217 | mockTokenFetch({ |
| 290 | "code-alice": "alice-token", | 218 | "code-alice": "alice-token", |
| 291 | "code-bob": "bob-token", | 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 | displayName: `Petstore (${user})`, | 223 | displayName: `Petstore (${user})`, |
| 296 | securitySchemeName: "oauth2", | 224 | securitySchemeName: "oauth2", |
| 297 | flow: "authorizationCode" as const, | 225 | flow: "authorizationCode" as const, |
| @@ -302,8 +230,6 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 4 unmodified lines | |||
| 302 | clientSecretSecretId: "petstore_client_secret", | 230 | clientSecretSecretId: "petstore_client_secret", |
| 303 | scopes: ["read"], | 231 | scopes: ["read"], |
| 304 | tokenScope: scope as unknown as string, | 232 | tokenScope: scope as unknown as string, |
| 305 | accessTokenSecretId: `petstore_access_token_${user}`, | ||
| 306 | refreshTokenSecretId: `petstore_refresh_token_${user}`, | ||
| 307 | }); | 233 | }); |
| 308 | 234 | ||
| 309 | const aliceStart = yield* aliceExec.openapi.startOAuth( | 235 | const aliceStart = yield* aliceExec.openapi.startOAuth( |
| @@ -319,18 +245,38 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 9 unmodified lines | |||
| 319 | throw new Error("expected authorizationCode flow for bob"); | 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 | state: aliceStart.sessionId, | 249 | state: aliceStart.sessionId, |
| 324 | code: "code-alice", | 250 | code: "code-alice", |
| 325 | }); | 251 | }); |
| 326 | yield* bobExec.openapi.completeOAuth({ | 252 | const bobAuth = yield* bobExec.openapi.completeOAuth({ |
| 327 | state: bobStart.sessionId, | 253 | state: bobStart.sessionId, |
| 328 | code: "code-bob", | 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 | 278 | // 4. Invoke through each exec — Authorization must carry that |
| 333 | // Authorization header must carry THAT user's token. | 279 | // user's token. |
| 334 | // ------------------------------------------------------------- | 280 | // ------------------------------------------------------------- |
| 335 | const aliceResult = (yield* aliceExec.tools.invoke( | 281 | const aliceResult = (yield* aliceExec.tools.invoke( |
| 336 | "petstore.items.echoHeaders", | 282 | "petstore.items.echoHeaders", |
| @@ -349,51 +295,49 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 12 unmodified lines | |||
| 349 | expect(bobResult.data?.authorization).toBe("Bearer bob-token"); | 295 | expect(bobResult.data?.authorization).toBe("Bearer bob-token"); |
| 350 | 296 | ||
| 351 | // ------------------------------------------------------------- | 297 | // ------------------------------------------------------------- |
| 352 | // 5. Scope isolation on `secrets.list()`: | 298 | // 5. Each user's Connection is scoped to them; admin sees none. |
| 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. | ||
| 357 | // ------------------------------------------------------------- | 299 | // ------------------------------------------------------------- |
| 358 | const aliceIds = new Set( | 300 | const aliceConnections = yield* aliceExec.connections.list(); |
| 359 | (yield* aliceExec.secrets.list()).map((s) => s.id as unknown as string), | 301 | const aliceConn = aliceConnections.find( |
| 302 | (c) => c.id === aliceAuth.connectionId, | ||
| 360 | ); | 303 | ); |
| 361 | expect(aliceIds).toContain("petstore_client_id"); | 304 | expect(aliceConn?.scopeId as unknown as string).toBe("user-alice"); |
| 362 | expect(aliceIds).toContain("petstore_client_secret"); | 305 | expect(aliceConn?.kind).toBe("user"); |
| 363 | expect(aliceIds).toContain("petstore_access_token_alice"); | 306 | |
| 364 | expect(aliceIds).toContain("petstore_refresh_token_alice"); | 307 | const bobConnections = yield* bobExec.connections.list(); |
| 365 | expect(aliceIds).not.toContain("petstore_access_token_bob"); | 308 | const bobConn = bobConnections.find( |
| 366 | expect(aliceIds).not.toContain("petstore_refresh_token_bob"); | 309 | (c) => c.id === bobAuth.connectionId, |
| 367 | |||
| 368 | const bobIds = new Set( | ||
| 369 | (yield* bobExec.secrets.list()).map((s) => s.id as unknown as string), | ||
| 370 | ); | 310 | ); |
| 371 | expect(bobIds).toContain("petstore_client_id"); | 311 | expect(bobConn?.scopeId as unknown as string).toBe("user-bob"); |
| 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"); | ||
| 375 | 312 | ||
| 376 | const adminIds = new Set( | 313 | const adminConnectionIds = new Set( |
| 377 | (yield* adminExec.secrets.list()).map((s) => s.id as unknown as string), | 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 | 324 | // 6. Connection-owned secrets are filtered from secrets.list(). |
| 386 | // her scope, not smuggled in under the org fallback. | 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(); | 328 | const aliceSecretIds = new Set( |
| 389 | const aliceAccess = aliceRows.find( | 329 | (yield* aliceExec.secrets.list()).map( |
| 390 | (r) => (r.id as unknown as string) === "petstore_access_token_alice", | 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"); | 338 | expect(aliceSecretIds).not.toContain( |
| 393 | const aliceClient = aliceRows.find( | 339 | `${aliceAuth.connectionId}.refresh_token`, |
| 394 | (r) => (r.id as unknown as string) === "petstore_client_id", | ||
| 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 | import type { Layer } from "effect"; | 5 | import type { Layer } from "effect"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | OAuth2Error, | ||
| 8 | buildAuthorizationUrl, | 9 | buildAuthorizationUrl, |
| 9 | createPkceCodeVerifier, | 10 | createPkceCodeVerifier, |
| 10 | exchangeAuthorizationCode, | 11 | exchangeAuthorizationCode, |
| 11 | exchangeClientCredentials, | 12 | exchangeClientCredentials, |
| 12 | shouldRefreshToken, | 13 | refreshAccessToken, |
| 13 | withRefreshedAccessToken, | ||
| 14 | type OAuth2TokenResponse, | 14 | type OAuth2TokenResponse, |
| 15 | } from "@executor/plugin-oauth2"; | 15 | } from "@executor/plugin-oauth2"; |
| 16 | 16 | ||
| 17 | import { | 17 | import { |
| 18 | ConnectionId, | ||
| 19 | ConnectionRefreshError, | ||
| 20 | CreateConnectionInput, | ||
| 18 | ScopeId, | 21 | ScopeId, |
| 19 | SecretId, | 22 | SecretId, |
| 20 | SetSecretInput, | ||
| 21 | SourceDetectionResult, | 23 | SourceDetectionResult, |
| 24 | TokenMaterial, | ||
| 22 | definePlugin, | 25 | definePlugin, |
| 26 | type ConnectionProvider, | ||
| 27 | type ConnectionRefreshInput, | ||
| 28 | type ConnectionRefreshResult, | ||
| 23 | type PluginCtx, | 29 | type PluginCtx, |
| 24 | type StorageFailure, | 30 | type StorageFailure, |
| 25 | type ToolAnnotations, | 31 | type ToolAnnotations, |
| @@ -90,27 +96,26 @@ export interface OpenApiUpdateSourceInput { · 64 unmodified lines | |||
| 90 | readonly name?: string; | 96 | readonly name?: string; |
| 91 | readonly baseUrl?: string; | 97 | readonly baseUrl?: string; |
| 92 | readonly headers?: Record<string, HeaderValue>; | 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 | interface StartOAuthIdentity { | 111 | interface StartOAuthIdentity { |
| 108 | readonly displayName: string; | 112 | readonly displayName: string; |
| 109 | readonly securitySchemeName: string; | 113 | readonly securitySchemeName: string; |
| 110 | readonly clientIdSecretId: string; | 114 | readonly clientIdSecretId: string; |
| 111 | readonly scopes: readonly string[]; | 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 | readonly tokenScope?: string; | 118 | readonly tokenScope?: string; |
| 113 | readonly accessTokenSecretId: string; | ||
| 114 | } | 119 | } |
| 115 | 120 | ||
| 116 | export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity { | 121 | export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity { |
| @@ -119,16 +124,13 @@ export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity { · 2 unmodified lines | |||
| 119 | readonly tokenUrl: string; | 124 | readonly tokenUrl: string; |
| 120 | readonly redirectUrl: string; | 125 | readonly redirectUrl: string; |
| 121 | readonly clientSecretSecretId?: string | null; | 126 | readonly clientSecretSecretId?: string | null; |
| 122 | readonly refreshTokenSecretId?: string | null; | ||
| 123 | } | 127 | } |
| 124 | 128 | ||
| 125 | /** | 129 | /** |
| 126 | * RFC 6749 §4.4 has no user-interactive step. `startOAuth` exchanges | 130 | * RFC 6749 §4.4 has no user-interactive step. `startOAuth` exchanges |
| 127 | * tokens inline and writes the access token at | 131 | * tokens inline, creates the Connection, and returns a completed |
| 128 | * `accessTokenSecretId` + `tokenScope`, returning a completed | 132 | * `OAuth2Auth` pointing at it. No `authorizationUrl`, no session row, |
| 129 | * `OAuth2Auth`. No `authorizationUrl`, no session row, no | 133 | * no `completeOAuth`. |
| 130 | * `completeOAuth`. Re-exchange happens at invoke time when the token | ||
| 131 | * is near expiry. | ||
| 132 | */ | 134 | */ |
| 133 | export interface StartClientCredentialsOAuthInput extends StartOAuthIdentity { | 135 | export interface StartClientCredentialsOAuthInput extends StartOAuthIdentity { |
| 134 | readonly flow: "clientCredentials"; | 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 | 287 | // Every field needed to re-hit the token endpoint on refresh lives here, |
| 286 | // recover from an expired access token is a fresh client_credentials | 288 | // so the SDK's `ctx.connections.accessToken(id)` can drive both grant |
| 287 | // exchange. We re-exchange proactively (when `shouldRefreshToken` fires | 289 | // types without the plugin keeping its own refresh state. The flow |
| 288 | // within skew) and persist the new token at the same id+scope the source's | 290 | // literal chooses between `refresh_token` (authorizationCode) and |
| 289 | // OAuth2Auth already references, keeping per-user shadowing intact. | 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 = { | 296 | const OPENAPI_OAUTH2_PROVIDER_KEY = "openapi:oauth2" as const; |
| 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 | }; | ||
| 305 | 297 | ||
| 306 | const resolveClientCredentialsAccessToken = (args: ResolveClientCredentialsArgs) => | 298 | const OAuth2ProviderState = Schema.Struct({ |
| 307 | Effect.gen(function* () { | 299 | flow: Schema.Literal("authorizationCode", "clientCredentials"), |
| 308 | const { auth } = args; | 300 | tokenUrl: Schema.String, |
| 309 | const needsRefresh = shouldRefreshToken({ expiresAt: auth.expiresAt }); | 301 | clientIdSecretId: Schema.String, |
| 310 | 302 | clientSecretSecretId: Schema.NullOr(Schema.String), | |
| 311 | if (!needsRefresh) { | 303 | scopes: Schema.Array(Schema.String), |
| 312 | const current = yield* args.secretsGet(auth.accessTokenSecretId); | 304 | }); |
| 313 | if (current !== null) return current; | 305 | type OAuth2ProviderState = typeof OAuth2ProviderState.Type; |
| 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 | }); | ||
| 349 | 306 | ||
| 350 | const expiresAt = | 307 | const encodeProviderState = Schema.encodeSync(OAuth2ProviderState); |
| 351 | typeof tokenResponse.expires_in === "number" | 308 | const decodeProviderState = Schema.decodeUnknownSync(OAuth2ProviderState); |
| 352 | ? Date.now() + tokenResponse.expires_in * 1000 | 309 | |
| 353 | : null; | 310 | const toProviderStateRecord = ( |
| 354 | 311 | state: OAuth2ProviderState, | |
| 355 | yield* args.updateSourceMeta( | 312 | ): Record<string, unknown> => |
| 356 | new OAuth2Auth({ | 313 | encodeProviderState(state) as unknown as Record<string, unknown>; |
| 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 | }); | ||
| 366 | 314 | ||
| 367 | // --------------------------------------------------------------------------- | 315 | // --------------------------------------------------------------------------- |
| 368 | // Plugin factory | 316 | // Plugin factory |
| @@ -540,28 +488,6 @@ export const openApiPlugin = definePlugin( · 171 unmodified lines | |||
| 540 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), | 488 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), |
| 541 | 489 | ||
| 542 | extension: (ctx) => { | 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 | const addSpecInternal = (config: OpenApiSpecConfig) => | 491 | const addSpecInternal = (config: OpenApiSpecConfig) => |
| 566 | Effect.gen(function* () { | 492 | Effect.gen(function* () { |
| 567 | // Resolve URL → text and parse BEFORE opening a transaction. | 493 | // Resolve URL → text and parse BEFORE opening a transaction. |
| @@ -619,6 +545,7 @@ export const openApiPlugin = definePlugin( · 51 unmodified lines | |||
| 619 | name: input.name?.trim() || undefined, | 545 | name: input.name?.trim() || undefined, |
| 620 | baseUrl: input.baseUrl, | 546 | baseUrl: input.baseUrl, |
| 621 | headers: input.headers, | 547 | headers: input.headers, |
| 548 | oauth2: input.oauth2, | ||
| 622 | }), | 549 | }), |
| 623 | 550 | ||
| 624 | startOAuth: (input) => | 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({ | 593 | const connectionId = `openapi-oauth2-${randomUUID()}`; |
| 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 | |||
| 677 | const expiresAt = | 594 | const expiresAt = |
| 678 | typeof tokenResponse.expires_in === "number" | 595 | typeof tokenResponse.expires_in === "number" |
| 679 | ? Date.now() + tokenResponse.expires_in * 1000 | 596 | ? Date.now() + tokenResponse.expires_in * 1000 |
| 680 | : null; | 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 | const auth = new OAuth2Auth({ | 639 | const auth = new OAuth2Auth({ |
| 683 | kind: "oauth2", | 640 | kind: "oauth2", |
| 641 | connectionId, | ||
| 684 | securitySchemeName: input.securitySchemeName, | 642 | securitySchemeName: input.securitySchemeName, |
| 685 | flow: "clientCredentials", | 643 | flow: "clientCredentials", |
| 686 | tokenUrl: input.tokenUrl, | 644 | tokenUrl: input.tokenUrl, |
| 645 | authorizationUrl: null, | ||
| 687 | clientIdSecretId: input.clientIdSecretId, | 646 | clientIdSecretId: input.clientIdSecretId, |
| 688 | clientSecretSecretId: input.clientSecretSecretId, | 647 | clientSecretSecretId: input.clientSecretSecretId ?? null, |
| 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, | ||
| 695 | scopes: scopesArray, | 648 | scopes: scopesArray, |
| 696 | }); | 649 | }); |
| 697 | 650 | ||
| @@ -705,6 +658,7 @@ export const openApiPlugin = definePlugin( · 7 unmodified lines | |||
| 705 | // authorizationCode path. | 658 | // authorizationCode path. |
| 706 | const sessionId = randomUUID(); | 659 | const sessionId = randomUUID(); |
| 707 | const codeVerifier = createPkceCodeVerifier(); | 660 | const codeVerifier = createPkceCodeVerifier(); |
| 661 | const connectionId = `openapi-oauth2-${randomUUID()}`; | ||
| 708 | 662 | ||
| 709 | yield* ctx.storage | 663 | yield* ctx.storage |
| 710 | .putOAuthSession( | 664 | .putOAuthSession( |
| @@ -714,12 +668,14 @@ export const openApiPlugin = definePlugin( · 3 unmodified lines | |||
| 714 | securitySchemeName: input.securitySchemeName, | 668 | securitySchemeName: input.securitySchemeName, |
| 715 | flow: input.flow, | 669 | flow: input.flow, |
| 716 | tokenUrl: input.tokenUrl, | 670 | tokenUrl: input.tokenUrl, |
| 671 | authorizationUrl: input.authorizationUrl, | ||
| 717 | redirectUrl: input.redirectUrl, | 672 | redirectUrl: input.redirectUrl, |
| 718 | clientIdSecretId: input.clientIdSecretId, | 673 | clientIdSecretId: input.clientIdSecretId, |
| 719 | clientSecretSecretId: input.clientSecretSecretId ?? null, | 674 | clientSecretSecretId: input.clientSecretSecretId ?? null, |
| 720 | tokenScope, | 675 | tokenScope, |
| 721 | accessTokenSecretId: input.accessTokenSecretId, | 676 | connectionId, |
| 722 | refreshTokenSecretId: input.refreshTokenSecretId ?? null, | 677 | accessTokenSecretId: `${connectionId}.access_token`, |
| 678 | refreshTokenSecretId: `${connectionId}.refresh_token`, | ||
| 723 | scopes: scopesArray, | 679 | scopes: scopesArray, |
| 724 | codeVerifier, | 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 | const expiresAt = | 759 | const expiresAt = |
| 838 | typeof tokenResponse.expires_in === "number" | 760 | typeof tokenResponse.expires_in === "number" |
| 839 | ? Date.now() + tokenResponse.expires_in * 1000 | 761 | ? Date.now() + tokenResponse.expires_in * 1000 |
| 840 | : null; | 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 | return new OAuth2Auth({ | 809 | return new OAuth2Auth({ |
| 843 | kind: "oauth2", | 810 | kind: "oauth2", |
| 811 | connectionId: session.connectionId, | ||
| 844 | securitySchemeName: session.securitySchemeName, | 812 | securitySchemeName: session.securitySchemeName, |
| 845 | flow: session.flow, | 813 | flow: "authorizationCode", |
| 846 | tokenUrl: session.tokenUrl, | 814 | tokenUrl: session.tokenUrl, |
| 815 | authorizationUrl: session.authorizationUrl, | ||
| 847 | clientIdSecretId: session.clientIdSecretId, | 816 | clientIdSecretId: session.clientIdSecretId, |
| 848 | clientSecretSecretId: session.clientSecretSecretId, | 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 | scopes: [...session.scopes], | 818 | scopes: [...session.scopes], |
| 855 | }); | 819 | }); |
| 856 | }), | 820 | }), |
| @@ -946,101 +910,27 @@ export const openApiPlugin = definePlugin( · 89 unmodified lines | |||
| 946 | { get: ctx.secrets.get }, | 910 | { get: ctx.secrets.get }, |
| 947 | ); | 911 | ); |
| 948 | 912 | ||
| 949 | // If the source has OAuth2 auth, resolve/refresh access token and | 913 | // If the source has OAuth2 auth, resolve a guaranteed-fresh |
| 950 | // inject Authorization header (wins over a manually-set one). | 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 | if (Option.isSome(config.oauth2)) { | 917 | if (Option.isSome(config.oauth2)) { |
| 952 | const auth = config.oauth2.value; | 918 | const auth = config.oauth2.value; |
| 953 | const accessToken = | 919 | const accessToken = yield* ctx.connections |
| 954 | auth.flow === "clientCredentials" | 920 | .accessToken(auth.connectionId) |
| 955 | ? yield* resolveClientCredentialsAccessToken({ | 921 | .pipe( |
| 956 | auth, | 922 | Effect.mapError( |
| 957 | source, | 923 | (err) => |
| 958 | scopeId: ctx.scopes[0]!.id as string, | 924 | new Error( |
| 959 | secretsGet: (id) => | 925 | `OAuth connection resolution failed: ${ |
| 960 | ctx.secrets | 926 | "message" in err |
| 961 | .get(id) | 927 | ? (err as { message: string }).message |
| 962 | .pipe(Effect.mapError((err) => new Error(err.message))), | 928 | : String(err) |
| 963 | secretsSet: (args) => | 929 | }`, |
| 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), | ||
| 1002 | ), | 930 | ), |
| 1003 | ), | 931 | ), |
| 1004 | setValue: ({ secretId, value, name }) => | 932 | ); |
| 1005 | // Refresh writes land at the innermost scope of the | 933 | resolvedHeaders["Authorization"] = `Bearer ${accessToken}`; |
| 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}`; | ||
| 1044 | } | 934 | } |
| 1045 | 935 | ||
| 1046 | const result = yield* invokeWithLayer( | 936 | const result = yield* invokeWithLayer( |
| @@ -1129,6 +1019,115 @@ export const openApiPlugin = definePlugin( · 82 unmodified lines | |||
| 1129 | namespace, | 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 | // securitySchemeName, with the secret ids that back its tokens. | 111 | // securitySchemeName, with the secret ids that back its tokens. |
| 112 | oauth2: Schema.optional(OAuth2Auth), | 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 | invocationConfig: Schema.optional(InvocationConfig), | 114 | invocationConfig: Schema.optional(InvocationConfig), |
| 118 | }) {} | 115 | }) {} |
| 119 | 116 | ||
| @@ -137,7 +134,16 @@ const encodeInvocationConfig = Schema.encodeSync(InvocationConfig); · 17 unmodified lines | |||
| 137 | const decodeInvocationConfig = Schema.decodeUnknownSync(InvocationConfig); | 134 | const decodeInvocationConfig = Schema.decodeUnknownSync(InvocationConfig); |
| 138 | 135 | ||
| 139 | const encodeOAuth2 = Schema.encodeSync(OAuth2Auth); | 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 | const encodeOAuthSession = Schema.encodeSync(OpenApiOAuthSession); | 148 | const encodeOAuthSession = Schema.encodeSync(OpenApiOAuthSession); |
| 143 | const decodeOAuthSession = Schema.decodeUnknownSync(OpenApiOAuthSession); | 149 | const decodeOAuthSession = Schema.decodeUnknownSync(OpenApiOAuthSession); |
| @@ -239,8 +245,21 @@ export const makeDefaultOpenapiStore = ({ · 95 unmodified lines | |||
| 239 | ? undefined | 245 | ? undefined |
| 240 | : decodeOAuth2(typeof oauth2Raw === "string" ? JSON.parse(oauth2Raw) : oauth2Raw); | 246 | : decodeOAuth2(typeof oauth2Raw === "string" ? JSON.parse(oauth2Raw) : oauth2Raw); |
| 241 | const headers = decodeHeaders(row.headers); | 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 | const invocationConfig = decodeInvocationConfig( | 259 | const invocationConfig = decodeInvocationConfig( |
| 243 | asJsonObject(row.invocation_config), | 260 | nestedOAuth2Valid |
| 261 | ? rawInvocation | ||
| 262 | : { ...rawInvocation, oauth2: undefined }, | ||
| 244 | ); | 263 | ); |
| 245 | return { | 264 | return { |
| 246 | namespace: row.id as string, | 265 | namespace: row.id as string, |
| @@ -113,33 +113,58 @@ export const HeaderValue = Schema.Union( · 112 unmodified lines | |||
| 113 | export type HeaderValue = typeof HeaderValue.Type; | 113 | export type HeaderValue = typeof HeaderValue.Type; |
| 114 | 114 | ||
| 115 | // --------------------------------------------------------------------------- | 115 | // --------------------------------------------------------------------------- |
| 116 | // OAuth2 auth — applied as Authorization: Bearer <token> at invocation time. | 116 | // OAuth2 auth — points at the Connection that owns live tokens, and also |
| 117 | // Tokens are stored as secrets; the bearer value is resolved (and refreshed) | 117 | // carries enough API-level config to kick off a fresh sign-in from the |
| 118 | // on every request via withRefreshedAccessToken. | 118 | // source detail UI without needing the connection to still exist. |
| 119 | // --------------------------------------------------------------------------- | 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 | export class OAuth2Auth extends Schema.Class<OAuth2Auth>("OpenApiOAuth2Auth")({ | 139 | export class OAuth2Auth extends Schema.Class<OAuth2Auth>("OpenApiOAuth2Auth")({ |
| 122 | kind: Schema.Literal("oauth2"), | 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 | securitySchemeName: Schema.String, | 150 | securitySchemeName: Schema.String, |
| 125 | /** | 151 | /** OAuth2 grant type used for this source. Determines which flow the |
| 126 | * Which flow produced this auth. `clientCredentials` sources have no | 152 | * sign-in button runs (authorizationCode opens a browser popup; |
| 127 | * refresh token — the invoker re-exchanges via client_credentials when | 153 | * clientCredentials is server-to-server). */ |
| 128 | * the access token is near expiry. | 154 | flow: OAuth2Flow, |
| 129 | */ | 155 | /** Absolute token endpoint URL. */ |
| 130 | flow: Schema.Literal("authorizationCode", "clientCredentials"), | ||
| 131 | /** Token endpoint (from the flow) — used for refresh / re-exchange. */ | ||
| 132 | tokenUrl: Schema.String, | 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 | clientIdSecretId: Schema.String, | 161 | clientIdSecretId: Schema.String, |
| 162 | /** Secret id holding the OAuth client_secret. Optional for public | ||
| 163 | * clients (PKCE-only authorizationCode). */ | ||
| 134 | clientSecretSecretId: Schema.NullOr(Schema.String), | 164 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 135 | accessTokenSecretId: Schema.String, | 165 | /** OAuth scopes requested on sign-in. Stored as a static list so the |
| 136 | refreshTokenSecretId: Schema.NullOr(Schema.String), | 166 | * sign-in button can re-request the same capabilities without having |
| 137 | tokenType: Schema.String, | 167 | * to re-derive them from the OpenAPI spec. */ |
| 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). */ | ||
| 143 | scopes: Schema.Array(Schema.String), | 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 | export class OpenApiOAuthSession extends Schema.Class<OpenApiOAuthSession>( | 205 | export class OpenApiOAuthSession extends Schema.Class<OpenApiOAuthSession>( |
| 178 | "OpenApiOAuthSession", | 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 | displayName: Schema.String, | 209 | displayName: Schema.String, |
| 182 | securitySchemeName: Schema.String, | 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 | flow: Schema.Literal("authorizationCode"), | 214 | flow: Schema.Literal("authorizationCode"), |
| 185 | tokenUrl: Schema.String, | 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 | redirectUrl: Schema.String, | 219 | redirectUrl: Schema.String, |
| 187 | clientIdSecretId: Schema.String, | 220 | clientIdSecretId: Schema.String, |
| 188 | clientSecretSecretId: Schema.NullOr(Schema.String), | 221 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| 189 | /** | 222 | /** Executor scope that will own the resulting Connection (and its |
| 190 | * Executor scope id where the minted access/refresh token secrets will | 223 | * backing secret rows). Typically the innermost (per-user) scope. */ |
| 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 | */ | ||
| 196 | tokenScope: Schema.String, | 224 | tokenScope: Schema.String, |
| 197 | /** | 225 | /** Pre-decided Connection id stamped at `completeOAuth` time. */ |
| 198 | * Pre-decided secret ids for the minted access + refresh tokens. The | 226 | connectionId: Schema.String, |
| 199 | * caller names these so the source's `OAuth2Auth` can reference the | 227 | /** Pre-decided secret ids for the Connection's access + refresh |
| 200 | * same ids regardless of which scope actually owns the value — | 228 | * tokens. Fixed at session creation so a retried callback lands |
| 201 | * `ctx.secrets.get` resolves them via fallthrough (innermost first), | 229 | * on the same ids. */ |
| 202 | * so per-user tokens shadow org-level fallbacks on the same source. | ||
| 203 | */ | ||
| 204 | accessTokenSecretId: Schema.String, | 230 | accessTokenSecretId: Schema.String, |
| 205 | refreshTokenSecretId: Schema.NullOr(Schema.String), | 231 | refreshTokenSecretId: Schema.String, |
| 206 | scopes: Schema.Array(Schema.String), | 232 | scopes: Schema.Array(Schema.String), |
| 207 | codeVerifier: Schema.String, | 233 | codeVerifier: Schema.String, |
| 208 | }) {} | 234 | }) {} |
| @@ -64,6 +64,13 @@ export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => · 63 unmodified lines | |||
| 64 | reactivityKeys: [ReactivityKey.secrets], | 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 | // Mutation atoms — reactivityKeys must be passed at call site (effect-atom | 75 | // Mutation atoms — reactivityKeys must be passed at call site (effect-atom |
| 69 | // does not accept them at definition time). See `reactivity-keys.tsx` for the | 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 | export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); | 84 | export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); |
| 78 | 85 | ||
| 86 | export const removeConnection = ExecutorApiClient.mutation( | ||
| 87 | "connections", | ||
| 88 | "remove", | ||
| 89 | ); | ||
| 90 | |||
| 79 | export const removeSource = ExecutorApiClient.mutation("sources", "remove"); | 91 | export const removeSource = ExecutorApiClient.mutation("sources", "remove"); |
| 80 | 92 | ||
| 81 | export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); | 93 | export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); |
| @@ -21,6 +21,7 @@ export const ReactivityKey = { · 20 unmodified lines | |||
| 21 | sources: "sources", | 21 | sources: "sources", |
| 22 | tools: "tools", | 22 | tools: "tools", |
| 23 | secrets: "secrets", | 23 | secrets: "secrets", |
| 24 | connections: "connections", | ||
| 24 | scope: "scope", | 25 | scope: "scope", |
| 25 | // cloud-only resources | 26 | // cloud-only resources |
| 26 | orgMembers: "org:members", | 27 | orgMembers: "org:members", |
| @@ -35,6 +36,15 @@ export const sourceWriteKeys = [ReactivityKey.sources, ReactivityKey.tools] as c · 8 unmodified lines | |||
| 35 | /** Mutations that mint or revoke secrets. */ | 36 | /** Mutations that mint or revoke secrets. */ |
| 36 | export const secretWriteKeys = [ReactivityKey.secrets] as const; | 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 | /** Mutations that change scope membership/info. */ | 48 | /** Mutations that change scope membership/info. */ |
| 39 | export const scopeWriteKeys = [ReactivityKey.scope] as const; | 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 | </div> | 127 | </div> |
| 128 | 128 | ||
| 129 | <div className="flex shrink-0 items-center gap-2"> | 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 | {canEdit && editPlugin && !editing && ( | 136 | {canEdit && editPlugin && !editing && ( |
| 131 | <Button variant="outline" size="sm" onClick={() => setEditing(true)}> | 137 | <Button variant="outline" size="sm" onClick={() => setEditing(true)}> |
| 132 | Edit | 138 | Edit |
| @@ -74,6 +74,19 @@ export interface SourcePlugin { · 73 unmodified lines | |||
| 74 | readonly sourceId: string; | 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 | /** Curated presets shown on the sources page for quick-add */ | 90 | /** Curated presets shown on the sources page for quick-add */ |
| 78 | readonly presets?: readonly SourcePreset[]; | 91 | readonly presets?: readonly SourcePreset[]; |
| 79 | } | 92 | } |