History

openapi: stabilize oauth connection id + fix 4xx encoding (#384)

* 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

Rhys Sullivan committed 6 months ago

Commit b028a0b1 parent 030f2f810 files · +546 −67Browse files

Unified · Split

10 files
  1. packages/plugins/openapi/src/api/group.ts +21 −18
  2. packages/plugins/openapi/src/api/handlers.ts +6 −3
  3. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +79 −29
  4. packages/plugins/openapi/src/react/OpenApiSignInButton.tsx +21 −4
  5. packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts +0 −0
  6. packages/plugins/openapi/src/sdk/errors.ts +11 −0
  7. packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +268 −1
  8. packages/plugins/openapi/src/sdk/plugin.ts +69 −8
  9. packages/react/src/api/optimistic.tsx +62 −2
  10. packages/react/src/pages/connections.tsx +10 −3
packages/plugins/openapi/src/api/group.ts+21−18
@@ -40,8 +40,8 @@ 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's43 // Set after a successful re-authenticate to refresh the source's
44 // OAuth2Auth pointer to the freshly minted connection.44 // stored OAuth2 metadata.
45 oauth2: Schema.optional(OAuth2Auth),45 oauth2: Schema.optional(OAuth2Auth),
46});46});
47 47
@@ -62,10 +62,18 @@ const AddSpecResponse = Schema.Struct({ · 14 unmodified lines
62// OAuth payloads / responses62// OAuth payloads / responses
63// ---------------------------------------------------------------------------63// ---------------------------------------------------------------------------
64 64
65// Shared identity fields for both OAuth2 flows. `tokenScope` names which65// Shared identity fields for both OAuth2 flows.
66// executor scope will own the resulting Connection (typically the per-user66//
67// scope). Token secret ids are minted by the SDK from the Connection id —67// `sourceId` (namespace) pins the resulting Connection *name* to a
68// callers never pick them.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.
69const StartOAuthIdentityFields = {77const StartOAuthIdentityFields = {
70 displayName: Schema.String,78 displayName: Schema.String,
71 securitySchemeName: Schema.String,79 securitySchemeName: Schema.String,
@@ -73,6 +81,8 @@ const StartOAuthIdentityFields = { · 1 unmodified line
73 clientIdSecretId: Schema.String,81 clientIdSecretId: Schema.String,
74 scopes: Schema.Array(Schema.String),82 scopes: Schema.Array(Schema.String),
75 tokenScope: Schema.optional(ScopeId),83 tokenScope: Schema.optional(ScopeId),
84 connectionId: Schema.optional(Schema.String),
85 sourceId: Schema.String,
76} as const;86} as const;
77 87
78const StartOAuthPayload = Schema.Union(88const StartOAuthPayload = Schema.Union(
@@ -121,15 +131,8 @@ const OAuthCallbackUrlParams = Schema.Struct({ · 42 unmodified lines
121 error_description: Schema.optional(Schema.String),131 error_description: Schema.optional(Schema.String),
122});132});
123 133
124// ---------------------------------------------------------------------------134// HTTP status on the three domain errors lives on their class
125// Errors with HTTP status135// declarations in `../sdk/errors.ts` — see the comment there.
126// ---------------------------------------------------------------------------
127
128const ParseError = OpenApiParseError.annotations(HttpApiSchema.annotations({ status: 400 }));
129const ExtractionError = OpenApiExtractionError.annotations(
130 HttpApiSchema.annotations({ status: 400 }),
131);
132const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 }));
133 136
134// ---------------------------------------------------------------------------137// ---------------------------------------------------------------------------
135// Group138// Group
@@ -192,6 +195,6 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 56 unmodified lines
192 // `InternalError` is the shared opaque 500 translated at the HTTP195 // `InternalError` is the shared opaque 500 translated at the HTTP
193 // edge by `withCapture`.196 // edge by `withCapture`.
194 .addError(InternalError)197 .addError(InternalError)
195 .addError(ParseError)198 .addError(OpenApiParseError)
196 .addError(ExtractionError)199 .addError(OpenApiExtractionError)
197 .addError(OAuthError) {}200 .addError(OpenApiOAuthError) {}
packages/plugins/openapi/src/api/handlers.ts+6−3
@@ -99,13 +99,13 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 98 unmodified lines
99 .handle("startOAuth", ({ payload }) =>99 .handle("startOAuth", ({ payload }) =>
100 capture(Effect.gen(function* () {100 capture(Effect.gen(function* () {
101 const ext = yield* OpenApiExtensionService;101 const ext = yield* OpenApiExtensionService;
102 // No tokenScope → plugin defaults to ctx.scopes[0].id (innermost).102 // No tokenScope -> plugin defaults to ctx.scopes[0].id
103 // Single-scope executors: only scope in stack.103 // (innermost) for both OAuth flows.
104 // Stacked executors: per-user scope, so tokens shadow by id.
105 const tokenScope = payload.tokenScope as string | undefined;104 const tokenScope = payload.tokenScope as string | undefined;
106 if (payload.flow === "clientCredentials") {105 if (payload.flow === "clientCredentials") {
107 return yield* ext.startOAuth({106 return yield* ext.startOAuth({
108 flow: "clientCredentials",107 flow: "clientCredentials",
108 sourceId: payload.sourceId,
109 displayName: payload.displayName,109 displayName: payload.displayName,
110 securitySchemeName: payload.securitySchemeName,110 securitySchemeName: payload.securitySchemeName,
111 tokenUrl: payload.tokenUrl,111 tokenUrl: payload.tokenUrl,
@@ -113,10 +113,12 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line
113 clientSecretSecretId: payload.clientSecretSecretId,113 clientSecretSecretId: payload.clientSecretSecretId,
114 scopes: [...payload.scopes],114 scopes: [...payload.scopes],
115 tokenScope,115 tokenScope,
116 connectionId: payload.connectionId,
116 });117 });
117 }118 }
118 return yield* ext.startOAuth({119 return yield* ext.startOAuth({
119 flow: "authorizationCode",120 flow: "authorizationCode",
121 sourceId: payload.sourceId,
120 displayName: payload.displayName,122 displayName: payload.displayName,
121 securitySchemeName: payload.securitySchemeName,123 securitySchemeName: payload.securitySchemeName,
122 authorizationUrl: payload.authorizationUrl,124 authorizationUrl: payload.authorizationUrl,
@@ -126,6 +128,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 3 unmodified lines
126 clientSecretSecretId: payload.clientSecretSecretId ?? null,128 clientSecretSecretId: payload.clientSecretSecretId ?? null,
127 scopes: [...payload.scopes],129 scopes: [...payload.scopes],
128 tokenScope,130 tokenScope,
131 connectionId: payload.connectionId,
129 });132 });
130 })),133 })),
131 )134 )
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+79−29
@@ -5,7 +5,20 @@ import { Option } from "effect"; · 4 unmodified lines
5import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";5import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
6 6
7import { useScope } from "@executor/react/api/scope-context";7import { useScope } from "@executor/react/api/scope-context";
8import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";8import {
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.
18const addSpecWriteKeys = [
19 ...sourceWriteKeys,
20 ...connectionWriteKeys,
21] as const;
9import { usePendingSources } from "@executor/react/api/optimistic";22import { usePendingSources } from "@executor/react/api/optimistic";
10import { HeadersList } from "@executor/react/plugins/headers-list";23import { HeadersList } from "@executor/react/plugins/headers-list";
11import {24import {
@@ -69,15 +82,13 @@ const substituteUrlVariables = (url: string, values: Record<string, string>): st · 57 unmodified lines
69 return out;82 return out;
70};83};
71 84
72/**85const openApiOAuthConnectionId = (
73 * Stable placeholder connection id used when saving a source without86 namespaceSlug: string,
74 * completing the OAuth flow. OpenApiSignInButton rewrites `oauth2.connectionId`87 flow: OAuth2Preset["flow"],
75 * to a freshly minted id on first sign-in, so this only needs to be88): string =>
76 * deterministic per-namespace and distinct from any real connection id89 flow === "clientCredentials"
77 * (real ids are `openapi-oauth2-${uuid}`).90 ? `openapi-oauth2-app-${namespaceSlug || "default"}`
78 */91 : `openapi-oauth2-user-${namespaceSlug || "default"}`;
79const openApiOAuthConnectionId = (namespaceSlug: string): string =>
80 `openapi-oauth2-pending-${namespaceSlug || "default"}`;
81 92
82/**93/**
83 * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute,94 * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute,
@@ -195,7 +206,10 @@ export default function AddOpenApiSource(props: { · 111 unmodified lines
195 null,206 null,
196 );207 );
197 const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set());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 const [startingOAuth, setStartingOAuth] = useState(false);213 const [startingOAuth, setStartingOAuth] = useState(false);
200 const [oauth2Error, setOauth2Error] = useState<string | null>(null);214 const [oauth2Error, setOauth2Error] = useState<string | null>(null);
201 const oauthCleanup = useRef<(() => void) | null>(null);215 const oauthCleanup = useRef<(() => void) | null>(null);
@@ -277,8 +291,28 @@ export default function AddOpenApiSource(props: { · 75 unmodified lines
277 typeof window !== "undefined"291 typeof window !== "undefined"
278 ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}`292 ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}`
279 : OPENAPI_OAUTH_CALLBACK_PATH;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 const selectedOAuth2Preset: OAuth2Preset | null =300 const selectedOAuth2Preset: OAuth2Preset | null =
281 strategy.kind === "oauth2" ? (oauth2Presets[strategy.presetIndex] ?? null) : null;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 // OAuth is "ready to save" without completing the flow as long as we have317 // OAuth is "ready to save" without completing the flow as long as we have
284 // enough metadata to run sign-in later: a preset, a client id secret, and —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 setStrategy(next);378 setStrategy(next);
345 // Clear any stale OAuth grant whenever the strategy changes away from oauth2.379 // Clear any stale OAuth grant whenever the strategy changes away from oauth2.
346 if (next.kind !== "oauth2") {380 if (next.kind !== "oauth2") {
347 setOauth2Auth(null);381 setOauth2AuthState(null);
348 setOauth2Error(null);382 setOauth2Error(null);
349 }383 }
350 switch (next.kind) {384 switch (next.kind) {
@@ -389,7 +423,7 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines
389 return copy;423 return copy;
390 });424 });
391 // Changing scopes invalidates any previously-granted token.425 // Changing scopes invalidates any previously-granted token.
392 setOauth2Auth(null);426 setOauth2AuthState(null);
393 };427 };
394 428
395 const handleConnectOAuth2 = useCallback(async () => {429 const handleConnectOAuth2 = useCallback(async () => {
@@ -419,6 +453,11 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines
419 const response = await doStartOAuth({453 const response = await doStartOAuth({
420 path: { scopeId },454 path: { scopeId },
421 payload: {455 payload: {
456 sourceId: resolvedSourceId,
457 connectionId: openApiOAuthConnectionId(
458 resolvedSourceId,
459 selectedOAuth2Preset.flow,
460 ),
422 displayName,461 displayName,
423 securitySchemeName: selectedOAuth2Preset.securitySchemeName,462 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
424 flow: "clientCredentials",463 flow: "clientCredentials",
@@ -433,7 +472,10 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
433 setOauth2Error("Unexpected response flow from server");472 setOauth2Error("Unexpected response flow from server");
434 return;473 return;
435 }474 }
436 setOauth2Auth(response.auth);475 setOauth2AuthState({
476 fingerprint: selectedOAuth2Fingerprint,
477 auth: response.auth,
478 });
437 setOauth2Error(null);479 setOauth2Error(null);
438 return;480 return;
439 }481 }
@@ -446,6 +488,11 @@ export default function AddOpenApiSource(props: { · 6 unmodified lines
446 const response = await doStartOAuth({488 const response = await doStartOAuth({
447 path: { scopeId },489 path: { scopeId },
448 payload: {490 payload: {
491 sourceId: resolvedSourceId,
492 connectionId: openApiOAuthConnectionId(
493 resolvedSourceId,
494 selectedOAuth2Preset.flow,
495 ),
449 displayName,496 displayName,
450 securitySchemeName: selectedOAuth2Preset.securitySchemeName,497 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
451 flow: "authorizationCode",498 flow: "authorizationCode",
@@ -472,8 +519,9 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines
472 oauthCleanup.current = null;519 oauthCleanup.current = null;
473 setStartingOAuth(false);520 setStartingOAuth(false);
474 if (result.ok) {521 if (result.ok) {
475 setOauth2Auth(522 setOauth2AuthState({
476 new OAuth2Auth({523 fingerprint: selectedOAuth2Fingerprint,
524 auth: new OAuth2Auth({
477 kind: "oauth2",525 kind: "oauth2",
478 connectionId: result.connectionId,526 connectionId: result.connectionId,
479 securitySchemeName: result.securitySchemeName,527 securitySchemeName: result.securitySchemeName,
@@ -484,7 +532,7 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines
484 clientSecretSecretId: result.clientSecretSecretId,532 clientSecretSecretId: result.clientSecretSecretId,
485 scopes: result.scopes,533 scopes: result.scopes,
486 }),534 }),
487 );535 });
488 setOauth2Error(null);536 setOauth2Error(null);
489 } else {537 } else {
490 setOauth2Error(result.error);538 setOauth2Error(result.error);
@@ -517,6 +565,8 @@ export default function AddOpenApiSource(props: { · 26 unmodified lines
517 doStartOAuth,565 doStartOAuth,
518 scopeId,566 scopeId,
519 identity.name,567 identity.name,
568 resolvedSourceId,
569 selectedOAuth2Fingerprint,
520 ]);570 ]);
521 571
522 const handleCancelOAuth2 = useCallback(() => {572 const handleCancelOAuth2 = useCallback(() => {
@@ -531,9 +581,7 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
531 const handleAdd = async () => {581 const handleAdd = async () => {
532 setAdding(true);582 setAdding(true);
533 setAddError(null);583 setAddError(null);
534 const namespace =584 const namespace = resolvedSourceId;
535 slugifyNamespace(identity.namespace) ||
536 (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");
537 const displayName =585 const displayName =
538 identity.name.trim() ||586 identity.name.trim() ||
539 (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);587 (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);
@@ -544,9 +592,8 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines
544 url: resolvedBaseUrl || undefined,592 url: resolvedBaseUrl || undefined,
545 });593 });
546 // If the user picked oauth2 but didn't complete sign-in, persist a594 // If the user picked oauth2 but didn't complete sign-in, persist a
547 // deferred OAuth2Auth with a stable placeholder connectionId so the595 // deferred OAuth2Auth with the same stable connection id sign-in will
548 // source lands with its OAuth config intact. OpenApiSignInButton596 // later create. The source config stays stable before and after auth.
549 // rewrites the pointer once a user actually signs in.
550 let oauth2ToSave: OAuth2Auth | null = oauth2Auth;597 let oauth2ToSave: OAuth2Auth | null = oauth2Auth;
551 if (598 if (
552 !oauth2ToSave &&599 !oauth2ToSave &&
@@ -567,7 +614,10 @@ export default function AddOpenApiSource(props: { · 14 unmodified lines
567 : null;614 : null;
568 oauth2ToSave = new OAuth2Auth({615 oauth2ToSave = new OAuth2Auth({
569 kind: "oauth2",616 kind: "oauth2",
570 connectionId: openApiOAuthConnectionId(namespace),617 connectionId: openApiOAuthConnectionId(
618 namespace,
619 selectedOAuth2Preset.flow,
620 ),
571 securitySchemeName: selectedOAuth2Preset.securitySchemeName,621 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
572 flow: selectedOAuth2Preset.flow,622 flow: selectedOAuth2Preset.flow,
573 tokenUrl,623 tokenUrl,
@@ -588,7 +638,7 @@ export default function AddOpenApiSource(props: { · 14 unmodified lines
588 ...(hasHeaders ? { headers: allHeaders } : {}),638 ...(hasHeaders ? { headers: allHeaders } : {}),
589 ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}),639 ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}),
590 },640 },
591 reactivityKeys: sourceWriteKeys,641 reactivityKeys: addSpecWriteKeys,
592 });642 });
593 props.onComplete();643 props.onComplete();
594 } catch (e) {644 } catch (e) {
@@ -624,7 +674,7 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines
624 setVariableSelections({});674 setVariableSelections({});
625 setCustomHeaders([]);675 setCustomHeaders([]);
626 setStrategy({ kind: "none" });676 setStrategy({ kind: "none" });
627 setOauth2Auth(null);677 setOauth2AuthState(null);
628 setOauth2Error(null);678 setOauth2Error(null);
629 }679 }
630 }}680 }}
@@ -930,7 +980,7 @@ export default function AddOpenApiSource(props: { · 299 unmodified lines
930 value={oauth2ClientIdSecretId}980 value={oauth2ClientIdSecretId}
931 onSelect={(id: string) => {981 onSelect={(id: string) => {
932 setOauth2ClientIdSecretId(id);982 setOauth2ClientIdSecretId(id);
933 setOauth2Auth(null);983 setOauth2AuthState(null);
934 }}984 }}
935 secrets={secretList}985 secrets={secretList}
936 sourceName={identity.name}986 sourceName={identity.name}
@@ -948,7 +998,7 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
948 value={oauth2ClientSecretSecretId}998 value={oauth2ClientSecretSecretId}
949 onSelect={(id: string) => {999 onSelect={(id: string) => {
950 setOauth2ClientSecretSecretId(id);1000 setOauth2ClientSecretSecretId(id);
951 setOauth2Auth(null);1001 setOauth2AuthState(null);
952 }}1002 }}
953 secrets={secretList}1003 secrets={secretList}
954 sourceName={identity.name}1004 sourceName={identity.name}
@@ -995,7 +1045,7 @@ export default function AddOpenApiSource(props: { · 40 unmodified lines
995 <Button1045 <Button
996 variant="ghost"1046 variant="ghost"
997 size="sm"1047 size="sm"
998 onClick={() => setOauth2Auth(null)}1048 onClick={() => setOauth2AuthState(null)}
999 >1049 >
1000 Disconnect1050 Disconnect
1001 </Button>1051 </Button>
packages/plugins/openapi/src/react/OpenApiSignInButton.tsx+21−4
@@ -3,10 +3,23 @@ import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; · 2 unmodified lines
3 3
4import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";4import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
5import { useScope } from "@executor/react/api/scope-context";5import { useScope } from "@executor/react/api/scope-context";
6import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";6import {
7 connectionWriteKeys,
8 sourceWriteKeys,
9} from "@executor/react/api/reactivity-keys";
7import { connectionsAtom } from "@executor/react/api/atoms";10import { connectionsAtom } from "@executor/react/api/atoms";
8import { Button } from "@executor/react/components/button";11import { 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.
18const signInWriteKeys = [
19 ...sourceWriteKeys,
20 ...connectionWriteKeys,
21] as const;
22
10import {23import {
11 openApiSourceAtom,24 openApiSourceAtom,
12 startOpenApiOAuth,25 startOpenApiOAuth,
@@ -24,7 +37,7 @@ import { OAuth2Auth } from "../sdk/types"; · 11 unmodified lines
24//37//
25// Reads the source's stored OAuth2Auth, runs the same OAuth flow as Add38// Reads the source's stored OAuth2Auth, runs the same OAuth flow as Add
26// (authorizationCode via popup, clientCredentials inline), and on success39// (authorizationCode via popup, clientCredentials inline), and on success
27// rewrites the source's OAuth2Auth pointer to the freshly minted40// refreshes the source's stored OAuth2Auth while preserving its logical
28// connection id. Works whether or not the previous connection still41// connection id. Works whether or not the previous connection still
29// exists — source-owned OAuth config is the source of truth.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 const response = await doStartOAuth({91 const response = await doStartOAuth({
79 path: { scopeId },92 path: { scopeId },
80 payload: {93 payload: {
94 sourceId: props.sourceId,
95 connectionId: oauth2.connectionId,
81 displayName,96 displayName,
82 securitySchemeName: oauth2.securitySchemeName,97 securitySchemeName: oauth2.securitySchemeName,
83 flow: "clientCredentials",98 flow: "clientCredentials",
@@ -95,7 +110,7 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 11 unmodified lines
95 await doUpdate({110 await doUpdate({
96 path: { scopeId, namespace: props.sourceId },111 path: { scopeId, namespace: props.sourceId },
97 payload: { oauth2: response.auth },112 payload: { oauth2: response.auth },
98 reactivityKeys: sourceWriteKeys,113 reactivityKeys: signInWriteKeys,
99 });114 });
100 setBusy(false);115 setBusy(false);
101 return;116 return;
@@ -110,6 +125,8 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 8 unmodified lines
110 const response = await doStartOAuth({125 const response = await doStartOAuth({
111 path: { scopeId },126 path: { scopeId },
112 payload: {127 payload: {
128 sourceId: props.sourceId,
129 connectionId: oauth2.connectionId,
113 displayName,130 displayName,
114 securitySchemeName: oauth2.securitySchemeName,131 securitySchemeName: oauth2.securitySchemeName,
115 flow: "authorizationCode",132 flow: "authorizationCode",
@@ -154,7 +171,7 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 38 unmodified lines
154 await doUpdate({171 await doUpdate({
155 path: { scopeId, namespace: props.sourceId },172 path: { scopeId, namespace: props.sourceId },
156 payload: { oauth2: nextAuth },173 payload: { oauth2: nextAuth },
157 reactivityKeys: sourceWriteKeys,174 reactivityKeys: signInWriteKeys,
158 });175 });
159 setBusy(false);176 setBusy(false);
160 } catch (e) {177 } catch (e) {
packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts+0−0

Binary file not shown.

packages/plugins/openapi/src/sdk/errors.ts+11−0
@@ -1,11 +1,20 @@
1import { Data, Schema } from "effect";1import { Data, Schema } from "effect";
2import { HttpApiSchema } from "@effect/platform";
2import type { Option } from "effect";3import 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.
4export class OpenApiParseError extends Schema.TaggedError<OpenApiParseError>()(12export class OpenApiParseError extends Schema.TaggedError<OpenApiParseError>()(
5 "OpenApiParseError",13 "OpenApiParseError",
6 {14 {
7 message: Schema.String,15 message: Schema.String,
8 },16 },
17 HttpApiSchema.annotations({ status: 400 }),
9) {}18) {}
10 19
11export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtractionError>()(20export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtractionError>()(
@@ -13,6 +22,7 @@ export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtraction · 1 unmodified line
13 {22 {
14 message: Schema.String,23 message: Schema.String,
15 },24 },
25 HttpApiSchema.annotations({ status: 400 }),
16) {}26) {}
17 27
18export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{28export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{
@@ -26,4 +36,5 @@ export class OpenApiOAuthError extends Schema.TaggedError<OpenApiOAuthError>()( · 7 unmodified lines
26 {36 {
27 message: Schema.String,37 message: Schema.String,
28 },38 },
39 HttpApiSchema.annotations({ status: 400 }),
29) {}40) {}
packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts+268−1
@@ -220,6 +220,7 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 219 unmodified lines
220 });220 });
221 221
222 const startInputFor = (user: string, scope: ScopeId) => ({222 const startInputFor = (user: string, scope: ScopeId) => ({
223 sourceId: "petstore",
223 displayName: `Petstore (${user})`,224 displayName: `Petstore (${user})`,
224 securitySchemeName: "oauth2",225 securitySchemeName: "oauth2",
225 flow: "authorizationCode" as const,226 flow: "authorizationCode" as const,
@@ -254,7 +255,12 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 28 unmodified lines
254 code: "code-bob",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 // 3. Each user adds the spec with the auth they just minted.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});
packages/plugins/openapi/src/sdk/plugin.ts+69−8
@@ -96,8 +96,8 @@ export interface OpenApiUpdateSourceInput { · 95 unmodified lines
96 readonly name?: string;96 readonly name?: string;
97 readonly baseUrl?: string;97 readonly baseUrl?: string;
98 readonly headers?: Record<string, HeaderValue>;98 readonly headers?: Record<string, HeaderValue>;
99 /** Rewrite the source's OAuth2Auth — typically after a successful99 /** Refresh the source's stored OAuth2 metadata after a successful
100 * re-authenticate, to point at a freshly minted connection. */100 * re-authenticate. */
101 readonly oauth2?: OAuth2Auth;101 readonly oauth2?: OAuth2Auth;
102}102}
103 103
@@ -113,11 +113,44 @@ interface StartOAuthIdentity { · 9 unmodified lines
113 readonly securitySchemeName: string;113 readonly securitySchemeName: string;
114 readonly clientIdSecretId: string;114 readonly clientIdSecretId: string;
115 readonly scopes: readonly string[];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 /** Executor scope that will own the resulting Connection (and its139 /** 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 readonly tokenScope?: string;143 readonly tokenScope?: string;
119}144}
120 145
146const defaultOAuthConnectionId = (
147 flow: "authorizationCode" | "clientCredentials",
148 sourceId: string,
149): string =>
150 flow === "clientCredentials"
151 ? `openapi-oauth2-app-${sourceId}`
152 : `openapi-oauth2-user-${sourceId}`;
153
121export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity {154export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity {
122 readonly flow: "authorizationCode";155 readonly flow: "authorizationCode";
123 readonly authorizationUrl: string;156 readonly authorizationUrl: string;
@@ -551,7 +584,14 @@ export const openApiPlugin = definePlugin( · 427 unmodified lines
551 startOAuth: (input) =>584 startOAuth: (input) =>
552 Effect.gen(function* () {585 Effect.gen(function* () {
553 const scopesArray = [...input.scopes];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 const clientId = yield* ctx.secrets.get(input.clientIdSecretId).pipe(596 const clientId = yield* ctx.secrets.get(input.clientIdSecretId).pipe(
557 Effect.mapError((err) => new OpenApiOAuthError({ message: err.message })),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 const expiresAt =648 const expiresAt =
595 typeof tokenResponse.expires_in === "number"649 typeof tokenResponse.expires_in === "number"
596 ? Date.now() + tokenResponse.expires_in * 1000650 ? Date.now() + tokenResponse.expires_in * 1000
@@ -608,7 +662,7 @@ export const openApiPlugin = definePlugin( · 11 unmodified lines
608 .create(662 .create(
609 new CreateConnectionInput({663 new CreateConnectionInput({
610 id: ConnectionId.make(connectionId),664 id: ConnectionId.make(connectionId),
611 scope: ScopeId.make(tokenScope),665 scope: ScopeId.make(connectionScope),
612 provider: OPENAPI_OAUTH2_PROVIDER_KEY,666 provider: OPENAPI_OAUTH2_PROVIDER_KEY,
613 kind: "app",667 kind: "app",
614 identityLabel: input.displayName,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 const sessionId = randomUUID();718 const sessionId = randomUUID();
660 const codeVerifier = createPkceCodeVerifier();719 const codeVerifier = createPkceCodeVerifier();
661 const connectionId = `openapi-oauth2-${randomUUID()}`;720 const connectionId =
721 input.connectionId ??
722 defaultOAuthConnectionId("authorizationCode", input.sourceId);
662 723
663 yield* ctx.storage724 yield* ctx.storage
664 .putOAuthSession(725 .putOAuthSession(
packages/react/src/api/optimistic.tsx+62−2
@@ -2,7 +2,7 @@ import * as React from "react"; · 1 unmodified line
2import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react";2import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react";
3import type { ScopeId } from "@executor/sdk";3import type { ScopeId } from "@executor/sdk";
4 4
5import { sourcesAtom } from "./atoms";5import { connectionsAtom, sourcesAtom } from "./atoms";
6 6
7/**7/**
8 * Client-only optimistic-update layer.8 * Client-only optimistic-update layer.
@@ -21,6 +21,7 @@ import { sourcesAtom } from "./atoms"; · 12 unmodified lines
21 21
22export const PendingResource = {22export const PendingResource = {
23 sources: "sources",23 sources: "sources",
24 connectionRemovals: "connection-removals",
24} as const;25} as const;
25 26
26export interface PendingEntry<T> {27export interface PendingEntry<T> {
@@ -44,7 +45,12 @@ export const usePendingResource = <T,>(resource: string) => { · 17 unmodified lines
44 45
45 const add = React.useCallback(46 const add = React.useCallback(
46 (entry: PendingEntry<T>) =>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 [setPending],54 [setPending],
49 );55 );
50 56
@@ -143,3 +149,57 @@ export const usePendingSources = () => { · 92 unmodified lines
143 [add, remove],149 [add, remove],
144 );150 );
145};151};
152
153// ---------------------------------------------------------------------------
154// Connections — optimistic removals.
155// ---------------------------------------------------------------------------
156
157interface PendingConnectionRemoval {
158 readonly id: string;
159}
160
161export 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
191export 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};
packages/react/src/pages/connections.tsx+10−3
@@ -1,7 +1,11 @@
1import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";1import { useAtomSet, Result } from "@effect-atom/atom-react";
2import { ConnectionId } from "@executor/sdk";2import { ConnectionId } from "@executor/sdk";
3 3
4import { connectionsAtom, removeConnection } from "../api/atoms";4import { removeConnection } from "../api/atoms";
5import {
6 useConnectionsWithPendingRemovals,
7 usePendingConnectionRemovals,
8} from "../api/optimistic";
5import { connectionWriteKeys } from "../api/reactivity-keys";9import { connectionWriteKeys } from "../api/reactivity-keys";
6import { useScope } from "../hooks/use-scope";10import { useScope } from "../hooks/use-scope";
7import { Badge } from "../components/badge";11import { Badge } from "../components/badge";
@@ -103,16 +107,19 @@ function ConnectionRow(props: { · 95 unmodified lines
103 107
104export function ConnectionsPage() {108export function ConnectionsPage() {
105 const scopeId = useScope();109 const scopeId = useScope();
106 const connections = useAtomValue(connectionsAtom(scopeId));110 const connections = useConnectionsWithPendingRemovals(scopeId);
111 const { beginRemove } = usePendingConnectionRemovals();
107 const doRemove = useAtomSet(removeConnection, { mode: "promise" });112 const doRemove = useAtomSet(removeConnection, { mode: "promise" });
108 113
109 const handleRemove = async (connectionId: string) => {114 const handleRemove = async (connectionId: string) => {
115 const pending = beginRemove(connectionId);
110 try {116 try {
111 await doRemove({117 await doRemove({
112 path: { scopeId, connectionId: ConnectionId.make(connectionId) },118 path: { scopeId, connectionId: ConnectionId.make(connectionId) },
113 reactivityKeys: connectionWriteKeys,119 reactivityKeys: connectionWriteKeys,
114 });120 });
115 } catch {121 } catch {
122 pending.undo();
116 // TODO: toast123 // TODO: toast
117 }124 }
118 };125 };

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t