* openapi: stabilize oauth connection id + fix 4xx encoding Multi-user bug: `clientCredentials` sign-ins minted a fresh UUID per click and wrote the row at `ctx.scopes[0].id` (innermost/user scope), even though the grant is app-level (RFC 6749 §4.4) with no user identity. The org source's `oauth2.connectionId` then flipped to the last signer's UUID, breaking invocations for every other user in the org and accumulating orphan rows. Fix: derive a stable id from the source namespace (`openapi-oauth2-app-${sourceId}`) and write at the outermost scope so every user in the org resolves the same row. `connections.create` is delete-then-insert on (id, scope_id), so repeat sign-ins become an in-place token refresh. Also applies the same stable-id treatment to authorizationCode (`openapi-oauth2-user-${sourceId}` at the per-user scope) so repeat sign-ins refresh a single row instead of spawning a new UUID each click. Sentry spam: `OpenApiParseError`/`OpenApiExtractionError`/ `OpenApiOAuthError` had `HttpApiSchema.annotations({ status: 400 })` applied post-hoc via `.annotations(...)` in group.ts. That wrapper AST wasn't picked up by HttpApiBuilder's error encoder, so the errors slipped the typed channel and were captured as 500s by the observability middleware — each user-misconfig fired a Sentry alert (e.g. a bad OAuth token-endpoint reply with `invalid_client`). Fix: move the annotation into the `Schema.TaggedError` class declarations (same pattern as google-discovery). UI label staleness: "Sign in" button stayed on "Sign in" after a successful sign-in until page reload because only `sourceWriteKeys` was emitted. Added `connectionWriteKeys` to the invalidation set so `connectionsAtom` refreshes and `isConnected` recomputes. Regression test in multi-scope-oauth.test.ts asserts: stable id equality across callers, connection at org scope with kind "app", exactly one row per source, and that a second user signing in doesn't break the first user's invocations. * openapi: keep clientCredentials per-user scoped to preserve credential shadowing My earlier fix forced `clientCredentials` connections to the outermost (org) scope on the theory that the grant is app-level per RFC 6749 §4.4 and therefore meant to be shared. That collapsed a real feature: a shared org source whose `clientIdSecretId` resolves per-caller via scope-stacked secret shadowing — i.e. each user providing their own API key that happens to use the client_credentials wire protocol. Under the previous fix, whichever user signed in last overwrote the single org row's token and everyone else got that user's identity. Revert the scope placement to innermost (per-user), same as authorizationCode, but keep the stable source-derived id. The id is a *name* — `openapi-oauth2-app-${sourceId}` — identical across all users; `findInnermostConnectionRow` resolves each caller's stack to their own physical row. A single `source.oauth2.connectionId` on a shared org source now correctly addresses per-user rows with per-user tokens, while repeat sign-ins by the same user still refresh one row (stable id → delete-then-insert on the same PK). Regression test updated to assert: (a) stable id string across admin/alice/bob, (b) physical rows at each user's scope, (c) Alice's user-scope secret shadow produced `alice-client` on the wire, (d) Bob with no shadow fell through to `org-client`, (e) each user's invocation resolves their own row and token, (f) Alice's repeat sign-in is idempotent (row count unchanged). * openapi: preserve oauth credential identity * react: optimistically remove connections
Unified · Split
| @@ -40,8 +40,8 @@ const UpdateSourcePayload = Schema.Struct({ · 39 unmodified lines | ||
| 40 | 40 | name: Schema.optional(Schema.String), |
| 41 | 41 | baseUrl: Schema.optional(Schema.String), |
| 42 | 42 | headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), |
| 43 | − // Set after a successful re-authenticate to rewrite the source's | |
| 44 | − // OAuth2Auth pointer to the freshly minted connection. | |
| 43 | + // Set after a successful re-authenticate to refresh the source's | |
| 44 | + // stored OAuth2 metadata. | |
| 45 | 45 | oauth2: Schema.optional(OAuth2Auth), |
| 46 | 46 | }); |
| 47 | 47 | |
| @@ -62,10 +62,18 @@ const AddSpecResponse = Schema.Struct({ · 14 unmodified lines | ||
| 62 | 62 | // OAuth payloads / responses |
| 63 | 63 | // --------------------------------------------------------------------------- |
| 64 | 64 | |
| 65 | −// Shared identity fields for both OAuth2 flows. `tokenScope` names which | |
| 66 | −// executor scope will own the resulting Connection (typically the per-user | |
| 67 | −// scope). Token secret ids are minted by the SDK from the Connection id — | |
| 68 | −// callers never pick them. | |
| 65 | +// Shared identity fields for both OAuth2 flows. | |
| 66 | +// | |
| 67 | +// `sourceId` (namespace) pins the resulting Connection *name* to a | |
| 68 | +// stable per-source value so repeated sign-ins refresh an existing | |
| 69 | +// row per scope instead of spawning a fresh UUID every click. Both | |
| 70 | +// flows write at the innermost executor scope by default (per-user | |
| 71 | +// row, per-user token), which preserves per-user credentials via | |
| 72 | +// scope-stacked secret shadowing. | |
| 73 | +// | |
| 74 | +// `tokenScope` defaults to `ctx.scopes[0].id` (innermost). Callers | |
| 75 | +// can override — e.g. an admin writing an org-wide shared connection | |
| 76 | +// — and the SDK validates that the target is in the executor's stack. | |
| 69 | 77 | const StartOAuthIdentityFields = { |
| 70 | 78 | displayName: Schema.String, |
| 71 | 79 | securitySchemeName: Schema.String, |
| @@ -73,6 +81,8 @@ const StartOAuthIdentityFields = { · 1 unmodified line | ||
| 73 | 81 | clientIdSecretId: Schema.String, |
| 74 | 82 | scopes: Schema.Array(Schema.String), |
| 75 | 83 | tokenScope: Schema.optional(ScopeId), |
| 84 | + connectionId: Schema.optional(Schema.String), | |
| 85 | + sourceId: Schema.String, | |
| 76 | 86 | } as const; |
| 77 | 87 | |
| 78 | 88 | const StartOAuthPayload = Schema.Union( |
| @@ -121,15 +131,8 @@ const OAuthCallbackUrlParams = Schema.Struct({ · 42 unmodified lines | ||
| 121 | 131 | error_description: Schema.optional(Schema.String), |
| 122 | 132 | }); |
| 123 | 133 | |
| 124 | −// --------------------------------------------------------------------------- | |
| 125 | −// Errors with HTTP status | |
| 126 | −// --------------------------------------------------------------------------- | |
| 127 | − | |
| 128 | −const ParseError = OpenApiParseError.annotations(HttpApiSchema.annotations({ status: 400 })); | |
| 129 | −const ExtractionError = OpenApiExtractionError.annotations( | |
| 130 | − HttpApiSchema.annotations({ status: 400 }), | |
| 131 | −); | |
| 132 | −const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 })); | |
| 134 | +// HTTP status on the three domain errors lives on their class | |
| 135 | +// declarations in `../sdk/errors.ts` — see the comment there. | |
| 133 | 136 | |
| 134 | 137 | // --------------------------------------------------------------------------- |
| 135 | 138 | // Group |
| @@ -192,6 +195,6 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 56 unmodified lines | ||
| 192 | 195 | // `InternalError` is the shared opaque 500 translated at the HTTP |
| 193 | 196 | // edge by `withCapture`. |
| 194 | 197 | .addError(InternalError) |
| 195 | − .addError(ParseError) | |
| 196 | − .addError(ExtractionError) | |
| 197 | − .addError(OAuthError) {} | |
| 198 | + .addError(OpenApiParseError) | |
| 199 | + .addError(OpenApiExtractionError) | |
| 200 | + .addError(OpenApiOAuthError) {} | |
| @@ -99,13 +99,13 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 98 unmodified lines | ||
| 99 | 99 | .handle("startOAuth", ({ payload }) => |
| 100 | 100 | capture(Effect.gen(function* () { |
| 101 | 101 | const ext = yield* OpenApiExtensionService; |
| 102 | − // No tokenScope → plugin defaults to ctx.scopes[0].id (innermost). | |
| 103 | − // Single-scope executors: only scope in stack. | |
| 104 | − // Stacked executors: per-user scope, so tokens shadow by id. | |
| 102 | + // No tokenScope -> plugin defaults to ctx.scopes[0].id | |
| 103 | + // (innermost) for both OAuth flows. | |
| 105 | 104 | const tokenScope = payload.tokenScope as string | undefined; |
| 106 | 105 | if (payload.flow === "clientCredentials") { |
| 107 | 106 | return yield* ext.startOAuth({ |
| 108 | 107 | flow: "clientCredentials", |
| 108 | + sourceId: payload.sourceId, | |
| 109 | 109 | displayName: payload.displayName, |
| 110 | 110 | securitySchemeName: payload.securitySchemeName, |
| 111 | 111 | tokenUrl: payload.tokenUrl, |
| @@ -113,10 +113,12 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line | ||
| 113 | 113 | clientSecretSecretId: payload.clientSecretSecretId, |
| 114 | 114 | scopes: [...payload.scopes], |
| 115 | 115 | tokenScope, |
| 116 | + connectionId: payload.connectionId, | |
| 116 | 117 | }); |
| 117 | 118 | } |
| 118 | 119 | return yield* ext.startOAuth({ |
| 119 | 120 | flow: "authorizationCode", |
| 121 | + sourceId: payload.sourceId, | |
| 120 | 122 | displayName: payload.displayName, |
| 121 | 123 | securitySchemeName: payload.securitySchemeName, |
| 122 | 124 | authorizationUrl: payload.authorizationUrl, |
| @@ -126,6 +128,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 3 unmodified lines | ||
| 126 | 128 | clientSecretSecretId: payload.clientSecretSecretId ?? null, |
| 127 | 129 | scopes: [...payload.scopes], |
| 128 | 130 | tokenScope, |
| 131 | + connectionId: payload.connectionId, | |
| 129 | 132 | }); |
| 130 | 133 | })), |
| 131 | 134 | ) |
| @@ -5,7 +5,20 @@ import { Option } from "effect"; · 4 unmodified lines | ||
| 5 | 5 | import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react"; |
| 6 | 6 | |
| 7 | 7 | import { useScope } from "@executor/react/api/scope-context"; |
| 8 | −import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 8 | +import { | |
| 9 | + connectionWriteKeys, | |
| 10 | + sourceWriteKeys, | |
| 11 | +} from "@executor/react/api/reactivity-keys"; | |
| 12 | + | |
| 13 | +// `addSpec` with an oauth2 payload persists a source row AND (for | |
| 14 | +// clientCredentials) a freshly-minted Connection + owned secrets, | |
| 15 | +// because the inline token exchange happens during `startOAuth`. | |
| 16 | +// Invalidate both so the source-detail page opens into its connected | |
| 17 | +// state without a refresh. | |
| 18 | +const addSpecWriteKeys = [ | |
| 19 | + ...sourceWriteKeys, | |
| 20 | + ...connectionWriteKeys, | |
| 21 | +] as const; | |
| 9 | 22 | import { usePendingSources } from "@executor/react/api/optimistic"; |
| 10 | 23 | import { HeadersList } from "@executor/react/plugins/headers-list"; |
| 11 | 24 | import { |
| @@ -69,15 +82,13 @@ const substituteUrlVariables = (url: string, values: Record<string, string>): st · 57 unmodified lines | ||
| 69 | 82 | return out; |
| 70 | 83 | }; |
| 71 | 84 | |
| 72 | −/** | |
| 73 | − * Stable placeholder connection id used when saving a source without | |
| 74 | − * completing the OAuth flow. OpenApiSignInButton rewrites `oauth2.connectionId` | |
| 75 | − * to a freshly minted id on first sign-in, so this only needs to be | |
| 76 | − * deterministic per-namespace and distinct from any real connection id | |
| 77 | − * (real ids are `openapi-oauth2-${uuid}`). | |
| 78 | − */ | |
| 79 | −const openApiOAuthConnectionId = (namespaceSlug: string): string => | |
| 80 | − `openapi-oauth2-pending-${namespaceSlug || "default"}`; | |
| 85 | +const openApiOAuthConnectionId = ( | |
| 86 | + namespaceSlug: string, | |
| 87 | + flow: OAuth2Preset["flow"], | |
| 88 | +): string => | |
| 89 | + flow === "clientCredentials" | |
| 90 | + ? `openapi-oauth2-app-${namespaceSlug || "default"}` | |
| 91 | + : `openapi-oauth2-user-${namespaceSlug || "default"}`; | |
| 81 | 92 | |
| 82 | 93 | /** |
| 83 | 94 | * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute, |
| @@ -195,7 +206,10 @@ export default function AddOpenApiSource(props: { · 111 unmodified lines | ||
| 195 | 206 | null, |
| 196 | 207 | ); |
| 197 | 208 | const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set()); |
| 198 | − const [oauth2Auth, setOauth2Auth] = useState<OAuth2Auth | null>(null); | |
| 209 | + const [oauth2AuthState, setOauth2AuthState] = useState<{ | |
| 210 | + readonly fingerprint: string; | |
| 211 | + readonly auth: OAuth2Auth; | |
| 212 | + } | null>(null); | |
| 199 | 213 | const [startingOAuth, setStartingOAuth] = useState(false); |
| 200 | 214 | const [oauth2Error, setOauth2Error] = useState<string | null>(null); |
| 201 | 215 | const oauthCleanup = useRef<(() => void) | null>(null); |
| @@ -277,8 +291,28 @@ export default function AddOpenApiSource(props: { · 75 unmodified lines | ||
| 277 | 291 | typeof window !== "undefined" |
| 278 | 292 | ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}` |
| 279 | 293 | : OPENAPI_OAUTH_CALLBACK_PATH; |
| 294 | + // Stable source id derivation. Matches the value `handleAdd` sends as | |
| 295 | + // `namespace`, and is also the default credential key when the user | |
| 296 | + // does not provide a more explicit shared connection id. | |
| 297 | + const resolvedSourceId = | |
| 298 | + slugifyNamespace(identity.namespace) || | |
| 299 | + (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); | |
| 280 | 300 | const selectedOAuth2Preset: OAuth2Preset | null = |
| 281 | 301 | strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null; |
| 302 | + const selectedOAuth2Fingerprint = selectedOAuth2Preset | |
| 303 | + ? [ | |
| 304 | + resolvedSourceId, | |
| 305 | + resolvedBaseUrl, | |
| 306 | + selectedOAuth2Preset.securitySchemeName, | |
| 307 | + selectedOAuth2Preset.flow, | |
| 308 | + selectedOAuth2Preset.tokenUrl, | |
| 309 | + Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""), | |
| 310 | + ].join("\n") | |
| 311 | + : ""; | |
| 312 | + const oauth2Auth = | |
| 313 | + oauth2AuthState?.fingerprint === selectedOAuth2Fingerprint | |
| 314 | + ? oauth2AuthState.auth | |
| 315 | + : null; | |
| 282 | 316 | |
| 283 | 317 | // OAuth is "ready to save" without completing the flow as long as we have |
| 284 | 318 | // enough metadata to run sign-in later: a preset, a client id secret, and — |
| @@ -344,7 +378,7 @@ export default function AddOpenApiSource(props: { · 59 unmodified lines | ||
| 344 | 378 | setStrategy(next); |
| 345 | 379 | // Clear any stale OAuth grant whenever the strategy changes away from oauth2. |
| 346 | 380 | if (next.kind !== "oauth2") { |
| 347 | − setOauth2Auth(null); | |
| 381 | + setOauth2AuthState(null); | |
| 348 | 382 | setOauth2Error(null); |
| 349 | 383 | } |
| 350 | 384 | switch (next.kind) { |
| @@ -389,7 +423,7 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines | ||
| 389 | 423 | return copy; |
| 390 | 424 | }); |
| 391 | 425 | // Changing scopes invalidates any previously-granted token. |
| 392 | − setOauth2Auth(null); | |
| 426 | + setOauth2AuthState(null); | |
| 393 | 427 | }; |
| 394 | 428 | |
| 395 | 429 | const handleConnectOAuth2 = useCallback(async () => { |
| @@ -419,6 +453,11 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines | ||
| 419 | 453 | const response = await doStartOAuth({ |
| 420 | 454 | path: { scopeId }, |
| 421 | 455 | payload: { |
| 456 | + sourceId: resolvedSourceId, | |
| 457 | + connectionId: openApiOAuthConnectionId( | |
| 458 | + resolvedSourceId, | |
| 459 | + selectedOAuth2Preset.flow, | |
| 460 | + ), | |
| 422 | 461 | displayName, |
| 423 | 462 | securitySchemeName: selectedOAuth2Preset.securitySchemeName, |
| 424 | 463 | flow: "clientCredentials", |
| @@ -433,7 +472,10 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines | ||
| 433 | 472 | setOauth2Error("Unexpected response flow from server"); |
| 434 | 473 | return; |
| 435 | 474 | } |
| 436 | − setOauth2Auth(response.auth); | |
| 475 | + setOauth2AuthState({ | |
| 476 | + fingerprint: selectedOAuth2Fingerprint, | |
| 477 | + auth: response.auth, | |
| 478 | + }); | |
| 437 | 479 | setOauth2Error(null); |
| 438 | 480 | return; |
| 439 | 481 | } |
| @@ -446,6 +488,11 @@ export default function AddOpenApiSource(props: { · 6 unmodified lines | ||
| 446 | 488 | const response = await doStartOAuth({ |
| 447 | 489 | path: { scopeId }, |
| 448 | 490 | payload: { |
| 491 | + sourceId: resolvedSourceId, | |
| 492 | + connectionId: openApiOAuthConnectionId( | |
| 493 | + resolvedSourceId, | |
| 494 | + selectedOAuth2Preset.flow, | |
| 495 | + ), | |
| 449 | 496 | displayName, |
| 450 | 497 | securitySchemeName: selectedOAuth2Preset.securitySchemeName, |
| 451 | 498 | flow: "authorizationCode", |
| @@ -472,8 +519,9 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines | ||
| 472 | 519 | oauthCleanup.current = null; |
| 473 | 520 | setStartingOAuth(false); |
| 474 | 521 | if (result.ok) { |
| 475 | − setOauth2Auth( | |
| 476 | − new OAuth2Auth({ | |
| 522 | + setOauth2AuthState({ | |
| 523 | + fingerprint: selectedOAuth2Fingerprint, | |
| 524 | + auth: new OAuth2Auth({ | |
| 477 | 525 | kind: "oauth2", |
| 478 | 526 | connectionId: result.connectionId, |
| 479 | 527 | securitySchemeName: result.securitySchemeName, |
| @@ -484,7 +532,7 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines | ||
| 484 | 532 | clientSecretSecretId: result.clientSecretSecretId, |
| 485 | 533 | scopes: result.scopes, |
| 486 | 534 | }), |
| 487 | − ); | |
| 535 | + }); | |
| 488 | 536 | setOauth2Error(null); |
| 489 | 537 | } else { |
| 490 | 538 | setOauth2Error(result.error); |
| @@ -517,6 +565,8 @@ export default function AddOpenApiSource(props: { · 26 unmodified lines | ||
| 517 | 565 | doStartOAuth, |
| 518 | 566 | scopeId, |
| 519 | 567 | identity.name, |
| 568 | + resolvedSourceId, | |
| 569 | + selectedOAuth2Fingerprint, | |
| 520 | 570 | ]); |
| 521 | 571 | |
| 522 | 572 | const handleCancelOAuth2 = useCallback(() => { |
| @@ -531,9 +581,7 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines | ||
| 531 | 581 | const handleAdd = async () => { |
| 532 | 582 | setAdding(true); |
| 533 | 583 | setAddError(null); |
| 534 | − const namespace = | |
| 535 | − slugifyNamespace(identity.namespace) || | |
| 536 | − (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); | |
| 584 | + const namespace = resolvedSourceId; | |
| 537 | 585 | const displayName = |
| 538 | 586 | identity.name.trim() || |
| 539 | 587 | (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); |
| @@ -544,9 +592,8 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines | ||
| 544 | 592 | url: resolvedBaseUrl || undefined, |
| 545 | 593 | }); |
| 546 | 594 | // If the user picked oauth2 but didn't complete sign-in, persist a |
| 547 | − // deferred OAuth2Auth with a stable placeholder connectionId so the | |
| 548 | − // source lands with its OAuth config intact. OpenApiSignInButton | |
| 549 | − // rewrites the pointer once a user actually signs in. | |
| 595 | + // deferred OAuth2Auth with the same stable connection id sign-in will | |
| 596 | + // later create. The source config stays stable before and after auth. | |
| 550 | 597 | let oauth2ToSave: OAuth2Auth | null = oauth2Auth; |
| 551 | 598 | if ( |
| 552 | 599 | !oauth2ToSave && |
| @@ -567,7 +614,10 @@ export default function AddOpenApiSource(props: { · 14 unmodified lines | ||
| 567 | 614 | : null; |
| 568 | 615 | oauth2ToSave = new OAuth2Auth({ |
| 569 | 616 | kind: "oauth2", |
| 570 | − connectionId: openApiOAuthConnectionId(namespace), | |
| 617 | + connectionId: openApiOAuthConnectionId( | |
| 618 | + namespace, | |
| 619 | + selectedOAuth2Preset.flow, | |
| 620 | + ), | |
| 571 | 621 | securitySchemeName: selectedOAuth2Preset.securitySchemeName, |
| 572 | 622 | flow: selectedOAuth2Preset.flow, |
| 573 | 623 | tokenUrl, |
| @@ -588,7 +638,7 @@ export default function AddOpenApiSource(props: { · 14 unmodified lines | ||
| 588 | 638 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 589 | 639 | ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}), |
| 590 | 640 | }, |
| 591 | − reactivityKeys: sourceWriteKeys, | |
| 641 | + reactivityKeys: addSpecWriteKeys, | |
| 592 | 642 | }); |
| 593 | 643 | props.onComplete(); |
| 594 | 644 | } catch (e) { |
| @@ -624,7 +674,7 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines | ||
| 624 | 674 | setVariableSelections({}); |
| 625 | 675 | setCustomHeaders([]); |
| 626 | 676 | setStrategy({ kind: "none" }); |
| 627 | − setOauth2Auth(null); | |
| 677 | + setOauth2AuthState(null); | |
| 628 | 678 | setOauth2Error(null); |
| 629 | 679 | } |
| 630 | 680 | }} |
| @@ -930,7 +980,7 @@ export default function AddOpenApiSource(props: { · 299 unmodified lines | ||
| 930 | 980 | value={oauth2ClientIdSecretId} |
| 931 | 981 | onSelect={(id: string) => { |
| 932 | 982 | setOauth2ClientIdSecretId(id); |
| 933 | − setOauth2Auth(null); | |
| 983 | + setOauth2AuthState(null); | |
| 934 | 984 | }} |
| 935 | 985 | secrets={secretList} |
| 936 | 986 | sourceName={identity.name} |
| @@ -948,7 +998,7 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines | ||
| 948 | 998 | value={oauth2ClientSecretSecretId} |
| 949 | 999 | onSelect={(id: string) => { |
| 950 | 1000 | setOauth2ClientSecretSecretId(id); |
| 951 | − setOauth2Auth(null); | |
| 1001 | + setOauth2AuthState(null); | |
| 952 | 1002 | }} |
| 953 | 1003 | secrets={secretList} |
| 954 | 1004 | sourceName={identity.name} |
| @@ -995,7 +1045,7 @@ export default function AddOpenApiSource(props: { · 40 unmodified lines | ||
| 995 | 1045 | <Button |
| 996 | 1046 | variant="ghost" |
| 997 | 1047 | size="sm" |
| 998 | − onClick={() => setOauth2Auth(null)} | |
| 1048 | + onClick={() => setOauth2AuthState(null)} | |
| 999 | 1049 | > |
| 1000 | 1050 | Disconnect |
| 1001 | 1051 | </Button> |
| @@ -3,10 +3,23 @@ import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react"; |
| 5 | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | −import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 6 | +import { | |
| 7 | + connectionWriteKeys, | |
| 8 | + sourceWriteKeys, | |
| 9 | +} from "@executor/react/api/reactivity-keys"; | |
| 7 | 10 | import { connectionsAtom } from "@executor/react/api/atoms"; |
| 8 | 11 | import { Button } from "@executor/react/components/button"; |
| 9 | 12 | |
| 13 | +// A successful sign-in mutates BOTH the source row (oauth2 pointer) and | |
| 14 | +// the Connections primitive (new/refreshed row + possibly new owned | |
| 15 | +// secrets). Passing only `sourceWriteKeys` leaves `connectionsAtom` | |
| 16 | +// stale, so `isConnected` keeps returning false and the button sticks | |
| 17 | +// on "Sign in" until a reload. | |
| 18 | +const signInWriteKeys = [ | |
| 19 | + ...sourceWriteKeys, | |
| 20 | + ...connectionWriteKeys, | |
| 21 | +] as const; | |
| 22 | + | |
| 10 | 23 | import { |
| 11 | 24 | openApiSourceAtom, |
| 12 | 25 | startOpenApiOAuth, |
| @@ -24,7 +37,7 @@ import { OAuth2Auth } from "../sdk/types"; · 11 unmodified lines | ||
| 24 | 37 | // |
| 25 | 38 | // Reads the source's stored OAuth2Auth, runs the same OAuth flow as Add |
| 26 | 39 | // (authorizationCode via popup, clientCredentials inline), and on success |
| 27 | −// rewrites the source's OAuth2Auth pointer to the freshly minted | |
| 40 | +// refreshes the source's stored OAuth2Auth while preserving its logical | |
| 28 | 41 | // connection id. Works whether or not the previous connection still |
| 29 | 42 | // exists — source-owned OAuth config is the source of truth. |
| 30 | 43 | // --------------------------------------------------------------------------- |
| @@ -78,6 +91,8 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 47 unmodified lines | ||
| 78 | 91 | const response = await doStartOAuth({ |
| 79 | 92 | path: { scopeId }, |
| 80 | 93 | payload: { |
| 94 | + sourceId: props.sourceId, | |
| 95 | + connectionId: oauth2.connectionId, | |
| 81 | 96 | displayName, |
| 82 | 97 | securitySchemeName: oauth2.securitySchemeName, |
| 83 | 98 | flow: "clientCredentials", |
| @@ -95,7 +110,7 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 11 unmodified lines | ||
| 95 | 110 | await doUpdate({ |
| 96 | 111 | path: { scopeId, namespace: props.sourceId }, |
| 97 | 112 | payload: { oauth2: response.auth }, |
| 98 | − reactivityKeys: sourceWriteKeys, | |
| 113 | + reactivityKeys: signInWriteKeys, | |
| 99 | 114 | }); |
| 100 | 115 | setBusy(false); |
| 101 | 116 | return; |
| @@ -110,6 +125,8 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 8 unmodified lines | ||
| 110 | 125 | const response = await doStartOAuth({ |
| 111 | 126 | path: { scopeId }, |
| 112 | 127 | payload: { |
| 128 | + sourceId: props.sourceId, | |
| 129 | + connectionId: oauth2.connectionId, | |
| 113 | 130 | displayName, |
| 114 | 131 | securitySchemeName: oauth2.securitySchemeName, |
| 115 | 132 | flow: "authorizationCode", |
| @@ -154,7 +171,7 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 38 unmodified lines | ||
| 154 | 171 | await doUpdate({ |
| 155 | 172 | path: { scopeId, namespace: props.sourceId }, |
| 156 | 173 | payload: { oauth2: nextAuth }, |
| 157 | − reactivityKeys: sourceWriteKeys, | |
| 174 | + reactivityKeys: signInWriteKeys, | |
| 158 | 175 | }); |
| 159 | 176 | setBusy(false); |
| 160 | 177 | } catch (e) { |
Binary file not shown.
| @@ -1,11 +1,20 @@ | ||
| 1 | 1 | import { Data, Schema } from "effect"; |
| 2 | +import { HttpApiSchema } from "@effect/platform"; | |
| 2 | 3 | import type { Option } from "effect"; |
| 3 | 4 | |
| 5 | +// HTTP status lives on the class declaration so HttpApiBuilder's error | |
| 6 | +// encoder (which reads `ast.annotations` off the schema it stored on | |
| 7 | +// `group.addError(...)`) finds it. Applying the annotation post-hoc | |
| 8 | +// via `.annotations(...)` in group.ts produced a transform-wrapper AST | |
| 9 | +// whose status was not picked up — the error then slipped the typed | |
| 10 | +// channel and was captured as a 500 by the observability middleware, | |
| 11 | +// spamming Sentry on user misconfig. | |
| 4 | 12 | export class OpenApiParseError extends Schema.TaggedError<OpenApiParseError>()( |
| 5 | 13 | "OpenApiParseError", |
| 6 | 14 | { |
| 7 | 15 | message: Schema.String, |
| 8 | 16 | }, |
| 17 | + HttpApiSchema.annotations({ status: 400 }), | |
| 9 | 18 | ) {} |
| 10 | 19 | |
| 11 | 20 | export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtractionError>()( |
| @@ -13,6 +22,7 @@ export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtraction · 1 unmodified line | ||
| 13 | 22 | { |
| 14 | 23 | message: Schema.String, |
| 15 | 24 | }, |
| 25 | + HttpApiSchema.annotations({ status: 400 }), | |
| 16 | 26 | ) {} |
| 17 | 27 | |
| 18 | 28 | export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{ |
| @@ -26,4 +36,5 @@ export class OpenApiOAuthError extends Schema.TaggedError<OpenApiOAuthError>()( · 7 unmodified lines | ||
| 26 | 36 | { |
| 27 | 37 | message: Schema.String, |
| 28 | 38 | }, |
| 39 | + HttpApiSchema.annotations({ status: 400 }), | |
| 29 | 40 | ) {} |
| @@ -220,6 +220,7 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 219 unmodified lines | ||
| 220 | 220 | }); |
| 221 | 221 | |
| 222 | 222 | const startInputFor = (user: string, scope: ScopeId) => ({ |
| 223 | + sourceId: "petstore", | |
| 223 | 224 | displayName: `Petstore (${user})`, |
| 224 | 225 | securitySchemeName: "oauth2", |
| 225 | 226 | flow: "authorizationCode" as const, |
| @@ -254,7 +255,12 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 28 unmodified lines | ||
| 254 | 255 | code: "code-bob", |
| 255 | 256 | }); |
| 256 | 257 | |
| 257 | − expect(aliceAuth.connectionId).not.toBe(bobAuth.connectionId); | |
| 258 | + // With the stable-id fix both users derive the same row id | |
| 259 | + // string from `sourceId`, but the rows live at different user | |
| 260 | + // scopes (ids are only unique within a scope). The assertion | |
| 261 | + // below that `adminConnectionIds` doesn't include either one | |
| 262 | + // proves admin's stack can't reach either user's row. | |
| 263 | + expect(aliceAuth.connectionId).toBe(bobAuth.connectionId); | |
| 258 | 264 | |
| 259 | 265 | // ------------------------------------------------------------- |
| 260 | 266 | // 3. Each user adds the spec with the auth they just minted. |
| @@ -340,4 +346,265 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 79 unmodified lines | ||
| 340 | 346 | ); |
| 341 | 347 | }), |
| 342 | 348 | ); |
| 349 | + | |
| 350 | + // ------------------------------------------------------------------------- | |
| 351 | + // Regression: repeated `clientCredentials` sign-ins used to mint a fresh | |
| 352 | + // random UUID per call AND rewrite `source.oauth2.connectionId` to that | |
| 353 | + // new id, which meant whichever user signed in last owned the pointer | |
| 354 | + // and everyone else's invocations broke (their scope stack couldn't | |
| 355 | + // find the previous signer's row). Fix: the Connection id is now a | |
| 356 | + // stable `openapi-oauth2-app-${sourceId}` *name* — the same string | |
| 357 | + // across callers — written at the innermost (per-user) scope. Each | |
| 358 | + // user's stack resolves that one name to their own physical row via | |
| 359 | + // `findInnermostConnectionRow`, so shared source + per-user credentials | |
| 360 | + // (secrets shadowed at user scope) keeps producing per-user tokens | |
| 361 | + // without clobbering each other. | |
| 362 | + // ------------------------------------------------------------------------- | |
| 363 | + it.effect( | |
| 364 | + "clientCredentials sign-in is per-user with a stable shared connection name", | |
| 365 | + () => | |
| 366 | + Effect.gen(function* () { | |
| 367 | + const secretStore = new Map<string, string>(); | |
| 368 | + const key = (scope: string, id: string) => `${scope} ${id}`; | |
| 369 | + const memoryProvider: SecretProvider = { | |
| 370 | + key: "memory", | |
| 371 | + writable: true, | |
| 372 | + get: (id, scope) => | |
| 373 | + Effect.sync(() => secretStore.get(key(scope, id)) ?? null), | |
| 374 | + set: (id, value, scope) => | |
| 375 | + Effect.sync(() => { | |
| 376 | + secretStore.set(key(scope, id), value); | |
| 377 | + }), | |
| 378 | + delete: (id, scope) => | |
| 379 | + Effect.sync(() => secretStore.delete(key(scope, id))), | |
| 380 | + }; | |
| 381 | + const memorySecretsPlugin = definePlugin(() => ({ | |
| 382 | + id: "memory-secrets" as const, | |
| 383 | + storage: () => ({}), | |
| 384 | + secretProviders: [memoryProvider], | |
| 385 | + })); | |
| 386 | + | |
| 387 | + const httpClient = yield* HttpClient.HttpClient; | |
| 388 | + const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 389 | + const plugins = [ | |
| 390 | + openApiPlugin({ httpClientLayer: clientLayer }), | |
| 391 | + memorySecretsPlugin(), | |
| 392 | + ] as const; | |
| 393 | + | |
| 394 | + const schema = collectSchemas(plugins); | |
| 395 | + const adapter = makeMemoryAdapter({ schema }); | |
| 396 | + const blobs = makeInMemoryBlobStore(); | |
| 397 | + | |
| 398 | + const now = new Date(); | |
| 399 | + const orgScope = new Scope({ | |
| 400 | + id: ScopeId.make("org"), | |
| 401 | + name: "acme-org", | |
| 402 | + createdAt: now, | |
| 403 | + }); | |
| 404 | + const aliceScope = new Scope({ | |
| 405 | + id: ScopeId.make("user-alice"), | |
| 406 | + name: "alice", | |
| 407 | + createdAt: now, | |
| 408 | + }); | |
| 409 | + const bobScope = new Scope({ | |
| 410 | + id: ScopeId.make("user-bob"), | |
| 411 | + name: "bob", | |
| 412 | + createdAt: now, | |
| 413 | + }); | |
| 414 | + | |
| 415 | + const adminExec = yield* createExecutor({ | |
| 416 | + scopes: [orgScope], | |
| 417 | + adapter, | |
| 418 | + blobs, | |
| 419 | + plugins, | |
| 420 | + }); | |
| 421 | + const aliceExec = yield* createExecutor({ | |
| 422 | + scopes: [aliceScope, orgScope], | |
| 423 | + adapter, | |
| 424 | + blobs, | |
| 425 | + plugins, | |
| 426 | + }); | |
| 427 | + const bobExec = yield* createExecutor({ | |
| 428 | + scopes: [bobScope, orgScope], | |
| 429 | + adapter, | |
| 430 | + blobs, | |
| 431 | + plugins, | |
| 432 | + }); | |
| 433 | + | |
| 434 | + // Org-wide default client_id at org scope. Alice then shadows | |
| 435 | + // with her own value at user-alice — the common "per-user API | |
| 436 | + // key that uses client_credentials as the wire protocol" | |
| 437 | + // pattern. Bob doesn't shadow → he falls through to the org | |
| 438 | + // default. This exercises scope-stacked secret resolution. | |
| 439 | + yield* adminExec.secrets.set( | |
| 440 | + new SetSecretInput({ | |
| 441 | + id: SecretId.make("client_id"), | |
| 442 | + scope: orgScope.id, | |
| 443 | + name: "Client ID", | |
| 444 | + value: "org-client", | |
| 445 | + }), | |
| 446 | + ); | |
| 447 | + yield* adminExec.secrets.set( | |
| 448 | + new SetSecretInput({ | |
| 449 | + id: SecretId.make("client_secret"), | |
| 450 | + scope: orgScope.id, | |
| 451 | + name: "Client Secret", | |
| 452 | + value: "org-secret", | |
| 453 | + }), | |
| 454 | + ); | |
| 455 | + yield* aliceExec.secrets.set( | |
| 456 | + new SetSecretInput({ | |
| 457 | + id: SecretId.make("client_id"), | |
| 458 | + scope: aliceScope.id, | |
| 459 | + name: "Alice Client ID", | |
| 460 | + value: "alice-client", | |
| 461 | + }), | |
| 462 | + ); | |
| 463 | + yield* aliceExec.secrets.set( | |
| 464 | + new SetSecretInput({ | |
| 465 | + id: SecretId.make("client_secret"), | |
| 466 | + scope: aliceScope.id, | |
| 467 | + name: "Alice Client Secret", | |
| 468 | + value: "alice-secret", | |
| 469 | + }), | |
| 470 | + ); | |
| 471 | + | |
| 472 | + // client_credentials token endpoint stub. Issues a token that | |
| 473 | + // encodes which client_id was used, so we can assert each | |
| 474 | + // user's row ends up with a token minted from *their own* | |
| 475 | + // credential resolution. | |
| 476 | + const tokenCalls: string[] = []; | |
| 477 | + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { | |
| 478 | + const bodyText = | |
| 479 | + init?.body instanceof URLSearchParams | |
| 480 | + ? init.body.toString() | |
| 481 | + : typeof init?.body === "string" | |
| 482 | + ? init.body | |
| 483 | + : ""; | |
| 484 | + const params = new URLSearchParams(bodyText); | |
| 485 | + const clientId = params.get("client_id") ?? "unknown"; | |
| 486 | + tokenCalls.push(clientId); | |
| 487 | + return new Response( | |
| 488 | + JSON.stringify({ | |
| 489 | + access_token: `token-for-${clientId}`, | |
| 490 | + token_type: "Bearer", | |
| 491 | + }), | |
| 492 | + { status: 200, headers: { "content-type": "application/json" } }, | |
| 493 | + ); | |
| 494 | + }) as unknown as typeof fetch; | |
| 495 | + | |
| 496 | + const startInput = { | |
| 497 | + sourceId: "petstore", | |
| 498 | + connectionId: "shared-petstore-oauth", | |
| 499 | + displayName: "Petstore", | |
| 500 | + securitySchemeName: "oauth2", | |
| 501 | + flow: "clientCredentials" as const, | |
| 502 | + tokenUrl: "https://token.example.com/token", | |
| 503 | + clientIdSecretId: "client_id", | |
| 504 | + clientSecretSecretId: "client_secret", | |
| 505 | + scopes: ["read"], | |
| 506 | + }; | |
| 507 | + | |
| 508 | + // Admin adds the org-scoped source with an initial oauth2 | |
| 509 | + // pointer — same shape the onboarding UI writes via `addSpec`. | |
| 510 | + // Admin's scope stack is [org] so their sign-in resolves the | |
| 511 | + // org-level creds and writes the connection at org. | |
| 512 | + const adminAuth = yield* adminExec.openapi.startOAuth(startInput); | |
| 513 | + if (adminAuth.flow !== "clientCredentials") { | |
| 514 | + throw new Error("expected clientCredentials flow"); | |
| 515 | + } | |
| 516 | + yield* adminExec.openapi.addSpec({ | |
| 517 | + spec: specJson, | |
| 518 | + scope: orgScope.id as string, | |
| 519 | + namespace: "petstore", | |
| 520 | + baseUrl: "", | |
| 521 | + oauth2: adminAuth.auth, | |
| 522 | + }); | |
| 523 | + | |
| 524 | + // Alice signs in → resolves her shadowed user-scope creds | |
| 525 | + // (`alice-client`), mints her own token, writes at user-alice. | |
| 526 | + const aliceStart = yield* aliceExec.openapi.startOAuth(startInput); | |
| 527 | + if (aliceStart.flow !== "clientCredentials") { | |
| 528 | + throw new Error("expected clientCredentials flow for alice"); | |
| 529 | + } | |
| 530 | + // Bob signs in → no user-scope shadow, falls through to the | |
| 531 | + // org defaults (`org-client`), writes at user-bob. | |
| 532 | + const bobStart = yield* bobExec.openapi.startOAuth(startInput); | |
| 533 | + if (bobStart.flow !== "clientCredentials") { | |
| 534 | + throw new Error("expected clientCredentials flow for bob"); | |
| 535 | + } | |
| 536 | + | |
| 537 | + // ---- Regression assertions ---- | |
| 538 | + | |
| 539 | + // (1) All three startOAuth calls return the SAME connection | |
| 540 | + // id — it's a stable *name* carried by the source config. No | |
| 541 | + // UUID-per-click churn, and the id does not have to be tied to | |
| 542 | + // the source namespace. | |
| 543 | + const stableId = startInput.connectionId; | |
| 544 | + expect(adminAuth.auth.connectionId).toBe(stableId); | |
| 545 | + expect(aliceStart.auth.connectionId).toBe(stableId); | |
| 546 | + expect(bobStart.auth.connectionId).toBe(stableId); | |
| 547 | + | |
| 548 | + // (2) Each user's physical row lives at their own scope. The | |
| 549 | + // id *string* collides across scopes intentionally — that's | |
| 550 | + // what lets a single `source.oauth2.connectionId` resolve | |
| 551 | + // per-caller via `findInnermostConnectionRow`. | |
| 552 | + const aliceConn = (yield* aliceExec.connections.list()).find( | |
| 553 | + (c) => c.id === stableId && (c.scopeId as unknown as string) === "user-alice", | |
| 554 | + ); | |
| 555 | + const bobConn = (yield* bobExec.connections.list()).find( | |
| 556 | + (c) => c.id === stableId && (c.scopeId as unknown as string) === "user-bob", | |
| 557 | + ); | |
| 558 | + const orgConn = (yield* adminExec.connections.list()).find( | |
| 559 | + (c) => c.id === stableId, | |
| 560 | + ); | |
| 561 | + expect(aliceConn).toBeDefined(); | |
| 562 | + expect(bobConn).toBeDefined(); | |
| 563 | + expect(orgConn).toBeDefined(); | |
| 564 | + expect(orgConn?.scopeId as unknown as string).toBe("org"); | |
| 565 | + | |
| 566 | + // (3) Scope-stacked secret resolution produced per-user tokens. | |
| 567 | + // The exchange call Alice made used her shadowed value; Bob's | |
| 568 | + // fell through to the org default. | |
| 569 | + expect(tokenCalls).toContain("alice-client"); | |
| 570 | + expect(tokenCalls.filter((v) => v === "org-client").length).toBeGreaterThan( | |
| 571 | + 0, | |
| 572 | + ); | |
| 573 | + | |
| 574 | + // (4) Each user's invocation resolves their OWN row and gets | |
| 575 | + // their OWN token — not whatever the last signer happened to | |
| 576 | + // mint. This is the core multi-user regression. | |
| 577 | + yield* aliceExec.openapi.updateSource( | |
| 578 | + "petstore", | |
| 579 | + orgScope.id as string, | |
| 580 | + { oauth2: aliceStart.auth }, | |
| 581 | + ); | |
| 582 | + const aliceResult = (yield* aliceExec.tools.invoke( | |
| 583 | + "petstore.items.echoHeaders", | |
| 584 | + {}, | |
| 585 | + autoApprove, | |
| 586 | + )) as { data: { authorization?: string } | null; error: unknown }; | |
| 587 | + expect(aliceResult.error).toBeNull(); | |
| 588 | + expect(aliceResult.data?.authorization).toBe("Bearer token-for-alice-client"); | |
| 589 | + | |
| 590 | + const bobResult = (yield* bobExec.tools.invoke( | |
| 591 | + "petstore.items.echoHeaders", | |
| 592 | + {}, | |
| 593 | + autoApprove, | |
| 594 | + )) as { data: { authorization?: string } | null; error: unknown }; | |
| 595 | + expect(bobResult.error).toBeNull(); | |
| 596 | + expect(bobResult.data?.authorization).toBe("Bearer token-for-org-client"); | |
| 597 | + | |
| 598 | + // (5) Alice's sign-in is idempotent per-user — a repeat click | |
| 599 | + // refreshes her one row instead of piling on orphans. | |
| 600 | + const countBefore = (yield* aliceExec.connections.list()).filter( | |
| 601 | + (c) => c.id === stableId, | |
| 602 | + ).length; | |
| 603 | + yield* aliceExec.openapi.startOAuth(startInput); | |
| 604 | + const countAfter = (yield* aliceExec.connections.list()).filter( | |
| 605 | + (c) => c.id === stableId, | |
| 606 | + ).length; | |
| 607 | + expect(countAfter).toBe(countBefore); | |
| 608 | + }), | |
| 609 | + ); | |
| 343 | 610 | }); |
| @@ -96,8 +96,8 @@ export interface OpenApiUpdateSourceInput { · 95 unmodified lines | ||
| 96 | 96 | readonly name?: string; |
| 97 | 97 | readonly baseUrl?: string; |
| 98 | 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. */ | |
| 99 | + /** Refresh the source's stored OAuth2 metadata after a successful | |
| 100 | + * re-authenticate. */ | |
| 101 | 101 | readonly oauth2?: OAuth2Auth; |
| 102 | 102 | } |
| 103 | 103 | |
| @@ -113,11 +113,44 @@ interface StartOAuthIdentity { · 9 unmodified lines | ||
| 113 | 113 | readonly securitySchemeName: string; |
| 114 | 114 | readonly clientIdSecretId: string; |
| 115 | 115 | readonly scopes: readonly string[]; |
| 116 | + /** Stable logical Connection id this source should resolve at invoke time. | |
| 117 | + * Physical ownership still comes from `tokenScope`; the same id can | |
| 118 | + * have separate rows at user/org scopes. Defaults to the legacy | |
| 119 | + * source-derived id for compatibility. */ | |
| 120 | + readonly connectionId?: string; | |
| 121 | + /** | |
| 122 | + * Source (namespace) the resulting Connection will back. Used as the | |
| 123 | + * compatibility default for the stable Connection *name* so repeat | |
| 124 | + * sign-ins refresh a single row per scope instead of spawning a | |
| 125 | + * fresh UUID every click: | |
| 126 | + * | |
| 127 | + * clientCredentials → `openapi-oauth2-app-${sourceId}` | |
| 128 | + * authorizationCode → `openapi-oauth2-user-${sourceId}` | |
| 129 | + * | |
| 130 | + * The resulting Connection is written at the innermost executor | |
| 131 | + * scope so per-user credentials (secrets shadowed at user scope via | |
| 132 | + * `ctx.secrets.get`'s scope-stacked resolution) and per-user consent | |
| 133 | + * (authorizationCode) both produce per-user rows. Because | |
| 134 | + * `findInnermostConnectionRow` resolves by id across the caller's | |
| 135 | + * stack, the single `source.oauth2.connectionId` string on a shared | |
| 136 | + * org source still lets every user reach their own physical row. | |
| 137 | + */ | |
| 138 | + readonly sourceId: string; | |
| 116 | 139 | /** Executor scope that will own the resulting Connection (and its |
| 117 | − * backing token secrets). Defaults to `ctx.scopes[0].id`. */ | |
| 140 | + * backing token secrets). Defaults to `ctx.scopes[0].id`. Callers | |
| 141 | + * can override to write at a different stack scope (e.g. an admin | |
| 142 | + * writing an org-wide shared connection). */ | |
| 118 | 143 | readonly tokenScope?: string; |
| 119 | 144 | } |
| 120 | 145 | |
| 146 | +const defaultOAuthConnectionId = ( | |
| 147 | + flow: "authorizationCode" | "clientCredentials", | |
| 148 | + sourceId: string, | |
| 149 | +): string => | |
| 150 | + flow === "clientCredentials" | |
| 151 | + ? `openapi-oauth2-app-${sourceId}` | |
| 152 | + : `openapi-oauth2-user-${sourceId}`; | |
| 153 | + | |
| 121 | 154 | export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity { |
| 122 | 155 | readonly flow: "authorizationCode"; |
| 123 | 156 | readonly authorizationUrl: string; |
| @@ -551,7 +584,14 @@ export const openApiPlugin = definePlugin( · 427 unmodified lines | ||
| 551 | 584 | startOAuth: (input) => |
| 552 | 585 | Effect.gen(function* () { |
| 553 | 586 | const scopesArray = [...input.scopes]; |
| 554 | − const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string); | |
| 587 | + // Innermost = user scope in a stacked [user, org] executor. | |
| 588 | + // Both flows write at the innermost scope so per-user | |
| 589 | + // credentials (secrets shadowed at user scope) and per-user | |
| 590 | + // authorization codes each produce a per-user connection | |
| 591 | + // row. `source.oauth2.connectionId` is a single *name* — | |
| 592 | + // `findInnermostConnectionRow` walks each caller's stack | |
| 593 | + // to resolve the right physical row. | |
| 594 | + const innermostScope = ctx.scopes[0]!.id as string; | |
| 555 | 595 | |
| 556 | 596 | const clientId = yield* ctx.secrets.get(input.clientIdSecretId).pipe( |
| 557 | 597 | Effect.mapError((err) => new OpenApiOAuthError({ message: err.message })), |
| @@ -590,7 +630,21 @@ export const openApiPlugin = definePlugin( · 32 unmodified lines | ||
| 590 | 630 | ), |
| 591 | 631 | ); |
| 592 | 632 | |
| 593 | − const connectionId = `openapi-oauth2-${randomUUID()}`; | |
| 633 | + // Stable id, per-user scope. The id is a *name* — the | |
| 634 | + // same string across every user — and each user's stack | |
| 635 | + // resolves it to their own physical row via | |
| 636 | + // `findInnermostConnectionRow`. That's what lets the | |
| 637 | + // shared org-scoped source carry a single | |
| 638 | + // `oauth2.connectionId` string while still supporting | |
| 639 | + // per-user credentials (via scope-stacked secret | |
| 640 | + // shadowing) and per-user tokens. `connections.create` | |
| 641 | + // is delete-then-insert on `(id, scope_id)`, so each | |
| 642 | + // user's repeat sign-ins refresh a single row rather | |
| 643 | + // than accumulate UUIDs. | |
| 644 | + const connectionId = | |
| 645 | + input.connectionId ?? | |
| 646 | + defaultOAuthConnectionId("clientCredentials", input.sourceId); | |
| 647 | + const connectionScope = input.tokenScope ?? innermostScope; | |
| 594 | 648 | const expiresAt = |
| 595 | 649 | typeof tokenResponse.expires_in === "number" |
| 596 | 650 | ? Date.now() + tokenResponse.expires_in * 1000 |
| @@ -608,7 +662,7 @@ export const openApiPlugin = definePlugin( · 11 unmodified lines | ||
| 608 | 662 | .create( |
| 609 | 663 | new CreateConnectionInput({ |
| 610 | 664 | id: ConnectionId.make(connectionId), |
| 611 | − scope: ScopeId.make(tokenScope), | |
| 665 | + scope: ScopeId.make(connectionScope), | |
| 612 | 666 | provider: OPENAPI_OAUTH2_PROVIDER_KEY, |
| 613 | 667 | kind: "app", |
| 614 | 668 | identityLabel: input.displayName, |
| @@ -655,10 +709,17 @@ export const openApiPlugin = definePlugin( · 40 unmodified lines | ||
| 655 | 709 | }; |
| 656 | 710 | } |
| 657 | 711 | |
| 658 | − // authorizationCode path. | |
| 712 | + // authorizationCode path. The source's logical connection | |
| 713 | + // id is stable, so repeated "Sign in" clicks refresh one | |
| 714 | + // row per target scope instead of creating UUID churn. By | |
| 715 | + // default this grant writes per-user because it carries user | |
| 716 | + // identity. | |
| 717 | + const tokenScope = input.tokenScope ?? innermostScope; | |
| 659 | 718 | const sessionId = randomUUID(); |
| 660 | 719 | const codeVerifier = createPkceCodeVerifier(); |
| 661 | − const connectionId = `openapi-oauth2-${randomUUID()}`; | |
| 720 | + const connectionId = | |
| 721 | + input.connectionId ?? | |
| 722 | + defaultOAuthConnectionId("authorizationCode", input.sourceId); | |
| 662 | 723 | |
| 663 | 724 | yield* ctx.storage |
| 664 | 725 | .putOAuthSession( |
| @@ -2,7 +2,7 @@ import * as React from "react"; · 1 unmodified line | ||
| 2 | 2 | import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; |
| 3 | 3 | import type { ScopeId } from "@executor/sdk"; |
| 4 | 4 | |
| 5 | −import { sourcesAtom } from "./atoms"; | |
| 5 | +import { connectionsAtom, sourcesAtom } from "./atoms"; | |
| 6 | 6 | |
| 7 | 7 | /** |
| 8 | 8 | * Client-only optimistic-update layer. |
| @@ -21,6 +21,7 @@ import { sourcesAtom } from "./atoms"; · 12 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | export const PendingResource = { |
| 23 | 23 | sources: "sources", |
| 24 | + connectionRemovals: "connection-removals", | |
| 24 | 25 | } as const; |
| 25 | 26 | |
| 26 | 27 | export interface PendingEntry<T> { |
| @@ -44,7 +45,12 @@ export const usePendingResource = <T,>(resource: string) => { · 17 unmodified lines | ||
| 44 | 45 | |
| 45 | 46 | const add = React.useCallback( |
| 46 | 47 | (entry: PendingEntry<T>) => |
| 47 | − setPending((prev) => [...(prev as ReadonlyArray<PendingEntry<T>>), entry]), | |
| 48 | + setPending((prev) => [ | |
| 49 | + ...(prev as ReadonlyArray<PendingEntry<T>>).filter( | |
| 50 | + (p) => p.id !== entry.id, | |
| 51 | + ), | |
| 52 | + entry, | |
| 53 | + ]), | |
| 48 | 54 | [setPending], |
| 49 | 55 | ); |
| 50 | 56 | |
| @@ -143,3 +149,57 @@ export const usePendingSources = () => { · 92 unmodified lines | ||
| 143 | 149 | [add, remove], |
| 144 | 150 | ); |
| 145 | 151 | }; |
| 152 | + | |
| 153 | +// --------------------------------------------------------------------------- | |
| 154 | +// Connections — optimistic removals. | |
| 155 | +// --------------------------------------------------------------------------- | |
| 156 | + | |
| 157 | +interface PendingConnectionRemoval { | |
| 158 | + readonly id: string; | |
| 159 | +} | |
| 160 | + | |
| 161 | +export const useConnectionsWithPendingRemovals = (scopeId: ScopeId) => { | |
| 162 | + const result = useAtomValue(connectionsAtom(scopeId)); | |
| 163 | + const { pending, remove } = usePendingResource<PendingConnectionRemoval>( | |
| 164 | + PendingResource.connectionRemovals, | |
| 165 | + ); | |
| 166 | + | |
| 167 | + React.useEffect(() => { | |
| 168 | + if (!Result.isSuccess(result) || pending.length === 0) return; | |
| 169 | + | |
| 170 | + const serverIds = new Set( | |
| 171 | + result.value.map((connection) => connection.id as string), | |
| 172 | + ); | |
| 173 | + for (const entry of pending) { | |
| 174 | + if (!serverIds.has(entry.id)) remove(entry.id); | |
| 175 | + } | |
| 176 | + }, [result, pending, remove]); | |
| 177 | + | |
| 178 | + return React.useMemo( | |
| 179 | + () => | |
| 180 | + Result.map(result, (connections) => { | |
| 181 | + if (pending.length === 0) return connections; | |
| 182 | + const hiddenIds = new Set(pending.map((entry) => entry.id)); | |
| 183 | + return connections.filter( | |
| 184 | + (connection) => !hiddenIds.has(connection.id as string), | |
| 185 | + ); | |
| 186 | + }), | |
| 187 | + [result, pending], | |
| 188 | + ); | |
| 189 | +}; | |
| 190 | + | |
| 191 | +export const usePendingConnectionRemovals = () => { | |
| 192 | + const { add, remove } = usePendingResource<PendingConnectionRemoval>( | |
| 193 | + PendingResource.connectionRemovals, | |
| 194 | + ); | |
| 195 | + | |
| 196 | + return React.useMemo( | |
| 197 | + () => ({ | |
| 198 | + beginRemove: (id: string) => { | |
| 199 | + add({ id, value: { id } }); | |
| 200 | + return { undo: () => remove(id) }; | |
| 201 | + }, | |
| 202 | + }), | |
| 203 | + [add, remove], | |
| 204 | + ); | |
| 205 | +}; | |
| @@ -1,7 +1,11 @@ | ||
| 1 | −import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; | |
| 1 | +import { useAtomSet, Result } from "@effect-atom/atom-react"; | |
| 2 | 2 | import { ConnectionId } from "@executor/sdk"; |
| 3 | 3 | |
| 4 | −import { connectionsAtom, removeConnection } from "../api/atoms"; | |
| 4 | +import { removeConnection } from "../api/atoms"; | |
| 5 | +import { | |
| 6 | + useConnectionsWithPendingRemovals, | |
| 7 | + usePendingConnectionRemovals, | |
| 8 | +} from "../api/optimistic"; | |
| 5 | 9 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 6 | 10 | import { useScope } from "../hooks/use-scope"; |
| 7 | 11 | import { Badge } from "../components/badge"; |
| @@ -103,16 +107,19 @@ function ConnectionRow(props: { · 95 unmodified lines | ||
| 103 | 107 | |
| 104 | 108 | export function ConnectionsPage() { |
| 105 | 109 | const scopeId = useScope(); |
| 106 | − const connections = useAtomValue(connectionsAtom(scopeId)); | |
| 110 | + const connections = useConnectionsWithPendingRemovals(scopeId); | |
| 111 | + const { beginRemove } = usePendingConnectionRemovals(); | |
| 107 | 112 | const doRemove = useAtomSet(removeConnection, { mode: "promise" }); |
| 108 | 113 | |
| 109 | 114 | const handleRemove = async (connectionId: string) => { |
| 115 | + const pending = beginRemove(connectionId); | |
| 110 | 116 | try { |
| 111 | 117 | await doRemove({ |
| 112 | 118 | path: { scopeId, connectionId: ConnectionId.make(connectionId) }, |
| 113 | 119 | reactivityKeys: connectionWriteKeys, |
| 114 | 120 | }); |
| 115 | 121 | } catch { |
| 122 | + pending.undo(); | |
| 116 | 123 | // TODO: toast |
| 117 | 124 | } |
| 118 | 125 | }; |