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
4040 name: Schema.optional(Schema.String),
4141 baseUrl: Schema.optional(Schema.String),
4242 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.
4545 oauth2: Schema.optional(OAuth2Auth),
4646 });
4747
@@ -62,10 +62,18 @@ const AddSpecResponse = Schema.Struct({ · 14 unmodified lines
6262 // OAuth payloads / responses
6363 // ---------------------------------------------------------------------------
6464
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.
6977 const StartOAuthIdentityFields = {
7078 displayName: Schema.String,
7179 securitySchemeName: Schema.String,
@@ -73,6 +81,8 @@ const StartOAuthIdentityFields = { · 1 unmodified line
7381 clientIdSecretId: Schema.String,
7482 scopes: Schema.Array(Schema.String),
7583 tokenScope: Schema.optional(ScopeId),
84+ connectionId: Schema.optional(Schema.String),
85+ sourceId: Schema.String,
7686 } as const;
7787
7888 const StartOAuthPayload = Schema.Union(
@@ -121,15 +131,8 @@ const OAuthCallbackUrlParams = Schema.Struct({ · 42 unmodified lines
121131 error_description: Schema.optional(Schema.String),
122132 });
123133
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.
133136
134137 // ---------------------------------------------------------------------------
135138 // Group
@@ -192,6 +195,6 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 56 unmodified lines
192195 // `InternalError` is the shared opaque 500 translated at the HTTP
193196 // edge by `withCapture`.
194197 .addError(InternalError)
195− .addError(ParseError)
196− .addError(ExtractionError)
197− .addError(OAuthError) {}
198+ .addError(OpenApiParseError)
199+ .addError(OpenApiExtractionError)
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
9999 .handle("startOAuth", ({ payload }) =>
100100 capture(Effect.gen(function* () {
101101 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.
105104 const tokenScope = payload.tokenScope as string | undefined;
106105 if (payload.flow === "clientCredentials") {
107106 return yield* ext.startOAuth({
108107 flow: "clientCredentials",
108+ sourceId: payload.sourceId,
109109 displayName: payload.displayName,
110110 securitySchemeName: payload.securitySchemeName,
111111 tokenUrl: payload.tokenUrl,
@@ -113,10 +113,12 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line
113113 clientSecretSecretId: payload.clientSecretSecretId,
114114 scopes: [...payload.scopes],
115115 tokenScope,
116+ connectionId: payload.connectionId,
116117 });
117118 }
118119 return yield* ext.startOAuth({
119120 flow: "authorizationCode",
121+ sourceId: payload.sourceId,
120122 displayName: payload.displayName,
121123 securitySchemeName: payload.securitySchemeName,
122124 authorizationUrl: payload.authorizationUrl,
@@ -126,6 +128,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 3 unmodified lines
126128 clientSecretSecretId: payload.clientSecretSecretId ?? null,
127129 scopes: [...payload.scopes],
128130 tokenScope,
131+ connectionId: payload.connectionId,
129132 });
130133 })),
131134 )
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+79−29
@@ -5,7 +5,20 @@ import { Option } from "effect"; · 4 unmodified lines
55 import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
66
77 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;
922 import { usePendingSources } from "@executor/react/api/optimistic";
1023 import { HeadersList } from "@executor/react/plugins/headers-list";
1124 import {
@@ -69,15 +82,13 @@ const substituteUrlVariables = (url: string, values: Record<string, string>): st · 57 unmodified lines
6982 return out;
7083 };
7184
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"}`;
8192
8293 /**
8394 * OpenAPI 3.x requires OAuth2 tokenUrl/authorizationUrl to be absolute,
@@ -195,7 +206,10 @@ export default function AddOpenApiSource(props: { · 111 unmodified lines
195206 null,
196207 );
197208 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);
199213 const [startingOAuth, setStartingOAuth] = useState(false);
200214 const [oauth2Error, setOauth2Error] = useState<string | null>(null);
201215 const oauthCleanup = useRef<(() => void) | null>(null);
@@ -277,8 +291,28 @@ export default function AddOpenApiSource(props: { · 75 unmodified lines
277291 typeof window !== "undefined"
278292 ? `${window.location.origin}${OPENAPI_OAUTH_CALLBACK_PATH}`
279293 : 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");
280300 const selectedOAuth2Preset: OAuth2Preset | null =
281301 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;
282316
283317 // OAuth is "ready to save" without completing the flow as long as we have
284318 // 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
344378 setStrategy(next);
345379 // Clear any stale OAuth grant whenever the strategy changes away from oauth2.
346380 if (next.kind !== "oauth2") {
347− setOauth2Auth(null);
381+ setOauth2AuthState(null);
348382 setOauth2Error(null);
349383 }
350384 switch (next.kind) {
@@ -389,7 +423,7 @@ export default function AddOpenApiSource(props: { · 38 unmodified lines
389423 return copy;
390424 });
391425 // Changing scopes invalidates any previously-granted token.
392− setOauth2Auth(null);
426+ setOauth2AuthState(null);
393427 };
394428
395429 const handleConnectOAuth2 = useCallback(async () => {
@@ -419,6 +453,11 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines
419453 const response = await doStartOAuth({
420454 path: { scopeId },
421455 payload: {
456+ sourceId: resolvedSourceId,
457+ connectionId: openApiOAuthConnectionId(
458+ resolvedSourceId,
459+ selectedOAuth2Preset.flow,
460+ ),
422461 displayName,
423462 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
424463 flow: "clientCredentials",
@@ -433,7 +472,10 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
433472 setOauth2Error("Unexpected response flow from server");
434473 return;
435474 }
436− setOauth2Auth(response.auth);
475+ setOauth2AuthState({
476+ fingerprint: selectedOAuth2Fingerprint,
477+ auth: response.auth,
478+ });
437479 setOauth2Error(null);
438480 return;
439481 }
@@ -446,6 +488,11 @@ export default function AddOpenApiSource(props: { · 6 unmodified lines
446488 const response = await doStartOAuth({
447489 path: { scopeId },
448490 payload: {
491+ sourceId: resolvedSourceId,
492+ connectionId: openApiOAuthConnectionId(
493+ resolvedSourceId,
494+ selectedOAuth2Preset.flow,
495+ ),
449496 displayName,
450497 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
451498 flow: "authorizationCode",
@@ -472,8 +519,9 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines
472519 oauthCleanup.current = null;
473520 setStartingOAuth(false);
474521 if (result.ok) {
475− setOauth2Auth(
476− new OAuth2Auth({
522+ setOauth2AuthState({
523+ fingerprint: selectedOAuth2Fingerprint,
524+ auth: new OAuth2Auth({
477525 kind: "oauth2",
478526 connectionId: result.connectionId,
479527 securitySchemeName: result.securitySchemeName,
@@ -484,7 +532,7 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines
484532 clientSecretSecretId: result.clientSecretSecretId,
485533 scopes: result.scopes,
486534 }),
487− );
535+ });
488536 setOauth2Error(null);
489537 } else {
490538 setOauth2Error(result.error);
@@ -517,6 +565,8 @@ export default function AddOpenApiSource(props: { · 26 unmodified lines
517565 doStartOAuth,
518566 scopeId,
519567 identity.name,
568+ resolvedSourceId,
569+ selectedOAuth2Fingerprint,
520570 ]);
521571
522572 const handleCancelOAuth2 = useCallback(() => {
@@ -531,9 +581,7 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
531581 const handleAdd = async () => {
532582 setAdding(true);
533583 setAddError(null);
534− const namespace =
535− slugifyNamespace(identity.namespace) ||
536− (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");
584+ const namespace = resolvedSourceId;
537585 const displayName =
538586 identity.name.trim() ||
539587 (preview ? Option.getOrElse(preview.title, () => namespace) : namespace);
@@ -544,9 +592,8 @@ export default function AddOpenApiSource(props: { · 4 unmodified lines
544592 url: resolvedBaseUrl || undefined,
545593 });
546594 // 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.
550597 let oauth2ToSave: OAuth2Auth | null = oauth2Auth;
551598 if (
552599 !oauth2ToSave &&
@@ -567,7 +614,10 @@ export default function AddOpenApiSource(props: { · 14 unmodified lines
567614 : null;
568615 oauth2ToSave = new OAuth2Auth({
569616 kind: "oauth2",
570− connectionId: openApiOAuthConnectionId(namespace),
617+ connectionId: openApiOAuthConnectionId(
618+ namespace,
619+ selectedOAuth2Preset.flow,
620+ ),
571621 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
572622 flow: selectedOAuth2Preset.flow,
573623 tokenUrl,
@@ -588,7 +638,7 @@ export default function AddOpenApiSource(props: { · 14 unmodified lines
588638 ...(hasHeaders ? { headers: allHeaders } : {}),
589639 ...(oauth2ToSave ? { oauth2: oauth2ToSave } : {}),
590640 },
591− reactivityKeys: sourceWriteKeys,
641+ reactivityKeys: addSpecWriteKeys,
592642 });
593643 props.onComplete();
594644 } catch (e) {
@@ -624,7 +674,7 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines
624674 setVariableSelections({});
625675 setCustomHeaders([]);
626676 setStrategy({ kind: "none" });
627− setOauth2Auth(null);
677+ setOauth2AuthState(null);
628678 setOauth2Error(null);
629679 }
630680 }}
@@ -930,7 +980,7 @@ export default function AddOpenApiSource(props: { · 299 unmodified lines
930980 value={oauth2ClientIdSecretId}
931981 onSelect={(id: string) => {
932982 setOauth2ClientIdSecretId(id);
933− setOauth2Auth(null);
983+ setOauth2AuthState(null);
934984 }}
935985 secrets={secretList}
936986 sourceName={identity.name}
@@ -948,7 +998,7 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
948998 value={oauth2ClientSecretSecretId}
949999 onSelect={(id: string) => {
9501000 setOauth2ClientSecretSecretId(id);
951− setOauth2Auth(null);
1001+ setOauth2AuthState(null);
9521002 }}
9531003 secrets={secretList}
9541004 sourceName={identity.name}
@@ -995,7 +1045,7 @@ export default function AddOpenApiSource(props: { · 40 unmodified lines
9951045 <Button
9961046 variant="ghost"
9971047 size="sm"
998− onClick={() => setOauth2Auth(null)}
1048+ onClick={() => setOauth2AuthState(null)}
9991049 >
10001050 Disconnect
10011051 </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
33
44 import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react";
55 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";
710 import { connectionsAtom } from "@executor/react/api/atoms";
811 import { Button } from "@executor/react/components/button";
912
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+
1023 import {
1124 openApiSourceAtom,
1225 startOpenApiOAuth,
@@ -24,7 +37,7 @@ import { OAuth2Auth } from "../sdk/types"; · 11 unmodified lines
2437 //
2538 // Reads the source's stored OAuth2Auth, runs the same OAuth flow as Add
2639 // (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
2841 // connection id. Works whether or not the previous connection still
2942 // exists — source-owned OAuth config is the source of truth.
3043 // ---------------------------------------------------------------------------
@@ -78,6 +91,8 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 47 unmodified lines
7891 const response = await doStartOAuth({
7992 path: { scopeId },
8093 payload: {
94+ sourceId: props.sourceId,
95+ connectionId: oauth2.connectionId,
8196 displayName,
8297 securitySchemeName: oauth2.securitySchemeName,
8398 flow: "clientCredentials",
@@ -95,7 +110,7 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 11 unmodified lines
95110 await doUpdate({
96111 path: { scopeId, namespace: props.sourceId },
97112 payload: { oauth2: response.auth },
98− reactivityKeys: sourceWriteKeys,
113+ reactivityKeys: signInWriteKeys,
99114 });
100115 setBusy(false);
101116 return;
@@ -110,6 +125,8 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 8 unmodified lines
110125 const response = await doStartOAuth({
111126 path: { scopeId },
112127 payload: {
128+ sourceId: props.sourceId,
129+ connectionId: oauth2.connectionId,
113130 displayName,
114131 securitySchemeName: oauth2.securitySchemeName,
115132 flow: "authorizationCode",
@@ -154,7 +171,7 @@ export default function OpenApiSignInButton(props: { sourceId: string }) { · 38 unmodified lines
154171 await doUpdate({
155172 path: { scopeId, namespace: props.sourceId },
156173 payload: { oauth2: nextAuth },
157− reactivityKeys: sourceWriteKeys,
174+ reactivityKeys: signInWriteKeys,
158175 });
159176 setBusy(false);
160177 } 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 @@
11 import { Data, Schema } from "effect";
2+import { HttpApiSchema } from "@effect/platform";
23 import type { Option } from "effect";
34
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.
412 export class OpenApiParseError extends Schema.TaggedError<OpenApiParseError>()(
513 "OpenApiParseError",
614 {
715 message: Schema.String,
816 },
17+ HttpApiSchema.annotations({ status: 400 }),
918 ) {}
1019
1120 export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtractionError>()(
@@ -13,6 +22,7 @@ export class OpenApiExtractionError extends Schema.TaggedError<OpenApiExtraction · 1 unmodified line
1322 {
1423 message: Schema.String,
1524 },
25+ HttpApiSchema.annotations({ status: 400 }),
1626 ) {}
1727
1828 export class OpenApiInvocationError extends Data.TaggedError("OpenApiInvocationError")<{
@@ -26,4 +36,5 @@ export class OpenApiOAuthError extends Schema.TaggedError<OpenApiOAuthError>()( · 7 unmodified lines
2636 {
2737 message: Schema.String,
2838 },
39+ HttpApiSchema.annotations({ status: 400 }),
2940 ) {}
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
220220 });
221221
222222 const startInputFor = (user: string, scope: ScopeId) => ({
223+ sourceId: "petstore",
223224 displayName: `Petstore (${user})`,
224225 securitySchemeName: "oauth2",
225226 flow: "authorizationCode" as const,
@@ -254,7 +255,12 @@ layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => { · 28 unmodified lines
254255 code: "code-bob",
255256 });
256257
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);
258264
259265 // -------------------------------------------------------------
260266 // 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
340346 );
341347 }),
342348 );
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+ );
343610 });
packages/plugins/openapi/src/sdk/plugin.ts+69−8
@@ -96,8 +96,8 @@ export interface OpenApiUpdateSourceInput { · 95 unmodified lines
9696 readonly name?: string;
9797 readonly baseUrl?: string;
9898 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. */
101101 readonly oauth2?: OAuth2Auth;
102102 }
103103
@@ -113,11 +113,44 @@ interface StartOAuthIdentity { · 9 unmodified lines
113113 readonly securitySchemeName: string;
114114 readonly clientIdSecretId: string;
115115 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;
116139 /** 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). */
118143 readonly tokenScope?: string;
119144 }
120145
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+
121154 export interface StartAuthorizationCodeOAuthInput extends StartOAuthIdentity {
122155 readonly flow: "authorizationCode";
123156 readonly authorizationUrl: string;
@@ -551,7 +584,14 @@ export const openApiPlugin = definePlugin( · 427 unmodified lines
551584 startOAuth: (input) =>
552585 Effect.gen(function* () {
553586 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;
555595
556596 const clientId = yield* ctx.secrets.get(input.clientIdSecretId).pipe(
557597 Effect.mapError((err) => new OpenApiOAuthError({ message: err.message })),
@@ -590,7 +630,21 @@ export const openApiPlugin = definePlugin( · 32 unmodified lines
590630 ),
591631 );
592632
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;
594648 const expiresAt =
595649 typeof tokenResponse.expires_in === "number"
596650 ? Date.now() + tokenResponse.expires_in * 1000
@@ -608,7 +662,7 @@ export const openApiPlugin = definePlugin( · 11 unmodified lines
608662 .create(
609663 new CreateConnectionInput({
610664 id: ConnectionId.make(connectionId),
611− scope: ScopeId.make(tokenScope),
665+ scope: ScopeId.make(connectionScope),
612666 provider: OPENAPI_OAUTH2_PROVIDER_KEY,
613667 kind: "app",
614668 identityLabel: input.displayName,
@@ -655,10 +709,17 @@ export const openApiPlugin = definePlugin( · 40 unmodified lines
655709 };
656710 }
657711
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;
659718 const sessionId = randomUUID();
660719 const codeVerifier = createPkceCodeVerifier();
661− const connectionId = `openapi-oauth2-${randomUUID()}`;
720+ const connectionId =
721+ input.connectionId ??
722+ defaultOAuthConnectionId("authorizationCode", input.sourceId);
662723
663724 yield* ctx.storage
664725 .putOAuthSession(
packages/react/src/api/optimistic.tsx+62−2
@@ -2,7 +2,7 @@ import * as React from "react"; · 1 unmodified line
22 import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react";
33 import type { ScopeId } from "@executor/sdk";
44
5−import { sourcesAtom } from "./atoms";
5+import { connectionsAtom, sourcesAtom } from "./atoms";
66
77 /**
88 * Client-only optimistic-update layer.
@@ -21,6 +21,7 @@ import { sourcesAtom } from "./atoms"; · 12 unmodified lines
2121
2222 export const PendingResource = {
2323 sources: "sources",
24+ connectionRemovals: "connection-removals",
2425 } as const;
2526
2627 export interface PendingEntry<T> {
@@ -44,7 +45,12 @@ export const usePendingResource = <T,>(resource: string) => { · 17 unmodified lines
4445
4546 const add = React.useCallback(
4647 (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+ ]),
4854 [setPending],
4955 );
5056
@@ -143,3 +149,57 @@ export const usePendingSources = () => { · 92 unmodified lines
143149 [add, remove],
144150 );
145151 };
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+};
packages/react/src/pages/connections.tsx+10−3
@@ -1,7 +1,11 @@
1−import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
1+import { useAtomSet, Result } from "@effect-atom/atom-react";
22 import { ConnectionId } from "@executor/sdk";
33
4−import { connectionsAtom, removeConnection } from "../api/atoms";
4+import { removeConnection } from "../api/atoms";
5+import {
6+ useConnectionsWithPendingRemovals,
7+ usePendingConnectionRemovals,
8+} from "../api/optimistic";
59 import { connectionWriteKeys } from "../api/reactivity-keys";
610 import { useScope } from "../hooks/use-scope";
711 import { Badge } from "../components/badge";
@@ -103,16 +107,19 @@ function ConnectionRow(props: { · 95 unmodified lines
103107
104108 export function ConnectionsPage() {
105109 const scopeId = useScope();
106− const connections = useAtomValue(connectionsAtom(scopeId));
110+ const connections = useConnectionsWithPendingRemovals(scopeId);
111+ const { beginRemove } = usePendingConnectionRemovals();
107112 const doRemove = useAtomSet(removeConnection, { mode: "promise" });
108113
109114 const handleRemove = async (connectionId: string) => {
115+ const pending = beginRemove(connectionId);
110116 try {
111117 await doRemove({
112118 path: { scopeId, connectionId: ConnectionId.make(connectionId) },
113119 reactivityKeys: connectionWriteKeys,
114120 });
115121 } catch {
122+ pending.undo();
116123 // TODO: toast
117124 }
118125 };

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