History

Fix OpenAPI boundary lint

Rhys Sullivan committed 6 months ago

Commit 9494cb41 parent b039b6e3 files · +711 −629Browse files

Unified · Split

3 files
  1. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +272 −246
  2. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +196 −173
  3. packages/plugins/openapi/src/sdk/store.ts +243 −210
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+272−246
@@ -1,6 +1,8 @@
1import { useCallback, useEffect, useRef, useState } from "react";1import { useCallback, useEffect, useRef, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import { Option } from "effect";3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
4 6
5import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core";7import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core";
6import { startOAuth } from "@executor-js/react/api/atoms";8import { startOAuth } from "@executor-js/react/api/atoms";
@@ -81,6 +83,17 @@ import { · 74 unmodified lines
81 type ServerVariable,83 type ServerVariable,
82} from "../sdk/types";84} from "../sdk/types";
83 85
86const ErrorMessage = Schema.Struct({ message: Schema.String });
87
88const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
89 Option.match(
90 Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),
91 {
92 onNone: () => fallback,
93 onSome: ({ message }) => message,
94 },
95 );
96
84export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";97export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
85export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";98export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";
86 99
@@ -109,25 +122,15 @@ export const openApiOAuthConnectionId = ( · 22 unmodified lines
109 */122 */
110export function resolveOAuthUrl(url: string, baseUrl: string): string {123export function resolveOAuthUrl(url: string, baseUrl: string): string {
111 if (!url) return url;124 if (!url) return url;
112 try {125 if (URL.canParse(url)) {
113 new URL(url);
114 return url;126 return url;
115 } catch {
116 if (!baseUrl) return url;
117 try {
118 return new URL(url, baseUrl).toString();
119 } catch {
120 return url;
121 }
122 }127 }
128 if (!baseUrl || !URL.canParse(url, baseUrl)) return url;
129 return new URL(url, baseUrl).toString();
123}130}
124 131
125export function inferOAuthIssuerUrl(authorizationUrl: string): string | null {132export function inferOAuthIssuerUrl(authorizationUrl: string): string | null {
126 try {133 return URL.canParse(authorizationUrl) ? new URL(authorizationUrl).origin : null;
127 return new URL(authorizationUrl).origin;
128 } catch {
129 return null;
130 }
131}134}
132 135
133type StrategySelection =136type StrategySelection =
@@ -242,10 +245,10 @@ export default function AddOpenApiSource(props: { · 108 unmodified lines
242 245
243 const scopeId = useScope();246 const scopeId = useScope();
244 const userScope = useUserScope();247 const userScope = useUserScope();
245 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });248 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
246 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });249 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });
247 const doStartOAuth = useAtomSet(startOAuth, { mode: "promise" });250 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
248 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promise" });251 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });
249 const { beginAdd } = usePendingSources();252 const { beginAdd } = usePendingSources();
250 const secretList = useSecretPickerSecrets();253 const secretList = useSecretPickerSecrets();
251 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({254 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
@@ -278,7 +281,7 @@ export default function AddOpenApiSource(props: { · 26 unmodified lines
278 selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null;281 selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null;
279 282
280 const serverVariables: Record<string, ServerVariable> = selectedServer283 const serverVariables: Record<string, ServerVariable> = selectedServer
281 ? Option.getOrElse(selectedServer.variables, () => ({}) as Record<string, ServerVariable>)284 ? Option.getOrElse(selectedServer.variables, () => ({}))
282 : {};285 : {};
283 const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables);286 const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables);
284 287
@@ -290,10 +293,7 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines
290 // Helper used by analyze + server selection: build a default selection map293 // Helper used by analyze + server selection: build a default selection map
291 // from a server's variable defaults.294 // from a server's variable defaults.
292 const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => {295 const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => {
293 const vars: Record<string, ServerVariable> = Option.getOrElse(296 const vars: Record<string, ServerVariable> = Option.getOrElse(server.variables, () => ({}));
294 server.variables,
295 () => ({}) as Record<string, ServerVariable>,
296 );
297 const out: Record<string, string> = {};297 const out: Record<string, string> = {};
298 for (const [name, v] of Object.entries(vars)) out[name] = v.default;298 for (const [name, v] of Object.entries(vars)) out[name] = v.default;
299 return out;299 return out;
@@ -371,45 +371,47 @@ export default function AddOpenApiSource(props: { · 71 unmodified lines
371 setAnalyzing(true);371 setAnalyzing(true);
372 setAnalyzeError(null);372 setAnalyzeError(null);
373 setAddError(null);373 setAddError(null);
374 try {374 const credentials = serializeHttpCredentials(specFetchCredentials);
375 const credentials = serializeHttpCredentials(specFetchCredentials);375 const exit = await doPreview({
376 const result = await doPreview({376 params: { scopeId },
377 params: { scopeId },377 payload: {
378 payload: {378 spec: specUrl,
379 spec: specUrl,379 specFetchCredentials: credentials,
380 specFetchCredentials: credentials,380 },
381 },381 });
382 });382 if (Exit.isFailure(exit)) {
383 setPreview(result);383 setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec"));
384
385 const firstServer = result.servers[0];
386 if (firstServer) {
387 setSelectedServerIndex(0);
388 setVariableSelections(defaultSelectionsFor(firstServer));
389 setCustomBaseUrl("");
390 } else {
391 setSelectedServerIndex(-1);
392 setVariableSelections({});
393 setCustomBaseUrl("");
394 }
395
396 const firstPreset = result.headerPresets[0];
397 if (firstPreset) {
398 setStrategy({ kind: "header", presetIndex: 0 });
399 setCustomHeaders(entriesFromSpecPreset(firstPreset));
400 } else {
401 // No header presets — default to "custom" so the headers editor is
402 // visible immediately. Specs with no `security` block (e.g. Microsoft
403 // Graph) would otherwise leave the user staring at just the
404 // Authentication heading with no way to add headers.
405 setStrategy({ kind: "custom" });
406 setCustomHeaders([]);
407 }
408 } catch (e) {
409 setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec");
410 } finally {
411 setAnalyzing(false);384 setAnalyzing(false);
385 return;
386 }
387
388 const result = exit.value;
389 setPreview(result);
390
391 const firstServer = result.servers[0];
392 if (firstServer) {
393 setSelectedServerIndex(0);
394 setVariableSelections(defaultSelectionsFor(firstServer));
395 setCustomBaseUrl("");
396 } else {
397 setSelectedServerIndex(-1);
398 setVariableSelections({});
399 setCustomBaseUrl("");
412 }400 }
401
402 const firstPreset = result.headerPresets[0];
403 if (firstPreset) {
404 setStrategy({ kind: "header", presetIndex: 0 });
405 setCustomHeaders(entriesFromSpecPreset(firstPreset));
406 } else {
407 // No header presets — default to "custom" so the headers editor is
408 // visible immediately. Specs with no `security` block (e.g. Microsoft
409 // Graph) would otherwise leave the user staring at just the
410 // Authentication heading with no way to add headers.
411 setStrategy({ kind: "custom" });
412 setCustomHeaders([]);
413 }
414 setAnalyzing(false);
413 };415 };
414 416
415 handleAnalyzeRef.current = handleAnalyze;417 handleAnalyzeRef.current = handleAnalyze;
@@ -470,122 +472,126 @@ export default function AddOpenApiSource(props: { · 54 unmodified lines
470 if (!selectedOAuth2Preset || !oauth2ClientIdSecretId || !preview) return;472 if (!selectedOAuth2Preset || !oauth2ClientIdSecretId || !preview) return;
471 oauth.cancel();473 oauth.cancel();
472 setOauth2Error(null);474 setOauth2Error(null);
473 try {475 const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName;
474 const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName;476
475 477 const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl);
476 const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl);478
477 479 if (selectedOAuth2Preset.flow === "clientCredentials") {
478 if (selectedOAuth2Preset.flow === "clientCredentials") {480 // RFC 6749 §4.4: no user-interactive consent step. The client_secret
479 // RFC 6749 §4.4: no user-interactive consent step. The client_secret481 // is mandatory; the backend exchanges tokens inline and returns a
480 // is mandatory; the backend exchanges tokens inline and returns a482 // completed OAuth2Auth we can attach to the source directly.
481 // completed OAuth2Auth we can attach to the source directly.483 if (!oauth2ClientSecretSecretId) {
482 if (!oauth2ClientSecretSecretId) {484 setOauth2Error("client_credentials requires a client secret");
483 setOauth2Error("client_credentials requires a client secret");485 return;
484 return;486 }
485 }487 setStartingOAuth(true);
486 setStartingOAuth(true);488 const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);
487 const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow);489 const startOAuthExit = await doStartOAuth({
488 const response = await doStartOAuth({490 params: { scopeId },
489 params: { scopeId },491 payload: {
490 payload: {492 endpoint: tokenUrl,
491 endpoint: tokenUrl,493 redirectUrl: tokenUrl,
492 redirectUrl: tokenUrl,494 connectionId,
493 connectionId,495 tokenScope: scopeId,
494 tokenScope: scopeId as string,496 strategy: {
495 strategy: {497 kind: "client-credentials",
496 kind: "client-credentials",498 tokenEndpoint: tokenUrl,
497 tokenEndpoint: tokenUrl,499 clientIdSecretId: oauth2ClientIdSecretId,
498 clientIdSecretId: oauth2ClientIdSecretId,500 clientSecretSecretId: oauth2ClientSecretSecretId,
499 clientSecretSecretId: oauth2ClientSecretSecretId,501 scopes: [...oauth2SelectedScopes],
500 scopes: [...oauth2SelectedScopes],
501 },
502 pluginId: "openapi",
503 identityLabel: `${displayName} OAuth`,
504 },502 },
505 });503 pluginId: "openapi",
506 setStartingOAuth(false);504 identityLabel: `${displayName} OAuth`,
507 if (!response.completedConnection) {505 },
508 setOauth2Error("client_credentials flow did not mint a connection");506 });
509 return;507 setStartingOAuth(false);
510 }508 if (Exit.isFailure(startOAuthExit)) {
509 setOauth2Error(errorMessageFromExit(startOAuthExit, "Failed to start OAuth"));
510 return;
511 }
512 const response = startOAuthExit.value;
513 if (!response.completedConnection) {
514 setOauth2Error("client_credentials flow did not mint a connection");
515 return;
516 }
517 setOauth2AuthState({
518 fingerprint: selectedOAuth2Fingerprint,
519 auth: new OAuth2Auth({
520 kind: "oauth2",
521 connectionId: response.completedConnection.connectionId,
522 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
523 flow: "clientCredentials",
524 tokenUrl,
525 authorizationUrl: null,
526 clientIdSecretId: oauth2ClientIdSecretId,
527 clientSecretSecretId: oauth2ClientSecretSecretId,
528 scopes: [...oauth2SelectedScopes],
529 }),
530 });
531 setOauth2Error(null);
532 return;
533 }
534
535 const authorizationUrl = resolveOAuthUrl(
536 Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),
537 resolvedBaseUrl,
538 );
539 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);
540 const startOAuthExit = await doStartOAuth({
541 params: { scopeId },
542 payload: {
543 endpoint: authorizationUrl,
544 connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),
545 tokenScope: scopeId,
546 redirectUrl: oauth2RedirectUrl,
547 strategy: {
548 kind: "authorization-code",
549 authorizationEndpoint: authorizationUrl,
550 tokenEndpoint: tokenUrl,
551 issuerUrl,
552 clientIdSecretId: oauth2ClientIdSecretId,
553 clientSecretSecretId: oauth2ClientSecretSecretId ?? null,
554 scopes: [...oauth2SelectedScopes],
555 },
556 pluginId: "openapi",
557 identityLabel: `${displayName} OAuth`,
558 },
559 });
560 if (Exit.isFailure(startOAuthExit)) {
561 setOauth2Error(errorMessageFromExit(startOAuthExit, "Failed to start OAuth"));
562 return;
563 }
564 const response = startOAuthExit.value;
565 if (response.authorizationUrl === null) {
566 setOauth2Error("Unexpected response flow from server");
567 return;
568 }
569
570 await oauth.openAuthorization({
571 run: async () => ({
572 sessionId: response.sessionId,
573 authorizationUrl: response.authorizationUrl,
574 }),
575 onSuccess: (result) => {
511 setOauth2AuthState({576 setOauth2AuthState({
512 fingerprint: selectedOAuth2Fingerprint,577 fingerprint: selectedOAuth2Fingerprint,
513 auth: new OAuth2Auth({578 auth: new OAuth2Auth({
514 kind: "oauth2",579 kind: "oauth2",
515 connectionId: response.completedConnection.connectionId,580 connectionId: result.connectionId,
516 securitySchemeName: selectedOAuth2Preset.securitySchemeName,581 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
517 flow: "clientCredentials",582 flow: "authorizationCode",
518 tokenUrl,583 tokenUrl,
519 authorizationUrl: null,584 authorizationUrl,
585 issuerUrl,
520 clientIdSecretId: oauth2ClientIdSecretId,586 clientIdSecretId: oauth2ClientIdSecretId,
521 clientSecretSecretId: oauth2ClientSecretSecretId,587 clientSecretSecretId: oauth2ClientSecretSecretId,
522 scopes: [...oauth2SelectedScopes],588 scopes: [...oauth2SelectedScopes],
523 }),589 }),
524 });590 });
525 setOauth2Error(null);591 setOauth2Error(null);
526 return;592 },
527 }593 onError: setOauth2Error,
528 594 });
529 const authorizationUrl = resolveOAuthUrl(
530 Option.getOrElse(selectedOAuth2Preset.authorizationUrl, () => ""),
531 resolvedBaseUrl,
532 );
533 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);
534
535 await oauth.openAuthorization({
536 run: async () => {
537 const response = await doStartOAuth({
538 params: { scopeId },
539 payload: {
540 endpoint: authorizationUrl,
541 connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),
542 tokenScope: scopeId as string,
543 redirectUrl: oauth2RedirectUrl,
544 strategy: {
545 kind: "authorization-code",
546 authorizationEndpoint: authorizationUrl,
547 tokenEndpoint: tokenUrl,
548 issuerUrl,
549 clientIdSecretId: oauth2ClientIdSecretId,
550 clientSecretSecretId: oauth2ClientSecretSecretId ?? null,
551 scopes: [...oauth2SelectedScopes],
552 },
553 pluginId: "openapi",
554 identityLabel: `${displayName} OAuth`,
555 },
556 });
557 if (response.authorizationUrl === null) {
558 throw new Error("Unexpected response flow from server");
559 }
560 return {
561 sessionId: response.sessionId,
562 authorizationUrl: response.authorizationUrl,
563 };
564 },
565 onSuccess: (result) => {
566 setOauth2AuthState({
567 fingerprint: selectedOAuth2Fingerprint,
568 auth: new OAuth2Auth({
569 kind: "oauth2",
570 connectionId: result.connectionId,
571 securitySchemeName: selectedOAuth2Preset.securitySchemeName,
572 flow: "authorizationCode",
573 tokenUrl,
574 authorizationUrl,
575 issuerUrl,
576 clientIdSecretId: oauth2ClientIdSecretId,
577 clientSecretSecretId: oauth2ClientSecretSecretId,
578 scopes: [...oauth2SelectedScopes],
579 }),
580 });
581 setOauth2Error(null);
582 },
583 onError: setOauth2Error,
584 });
585 } catch (e) {
586 setStartingOAuth(false);
587 setOauth2Error(e instanceof Error ? e.message : "Failed to start OAuth");
588 }
589 }, [595 }, [
590 selectedOAuth2Preset,596 selectedOAuth2Preset,
591 oauth2ClientIdSecretId,597 oauth2ClientIdSecretId,
@@ -621,103 +627,123 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines
621 kind: "openapi",627 kind: "openapi",
622 url: resolvedBaseUrl || undefined,628 url: resolvedBaseUrl || undefined,
623 });629 });
624 try {630 const failAdd = (message: string) => {
625 const result = await doAdd({631 setAddError(message);
632 setAdding(false);
633 placeholder.done();
634 };
635 const resultExit = await doAdd({
636 params: { scopeId },
637 payload: {
638 spec: specUrl,
639 specFetchCredentials: serializeHttpCredentials(specFetchCredentials),
640 name: identity.name.trim() || undefined,
641 namespace: slugifyNamespace(identity.namespace) || undefined,
642 baseUrl: resolvedBaseUrl || undefined,
643 ...(hasHeaders ? { headers: configuredHeaders } : {}),
644 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0
645 ? { queryParams: serializeHttpCredentials(runtimeCredentials).queryParams }
646 : {}),
647 ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}),
648 },
649 reactivityKeys: addSpecWriteKeys,
650 });
651
652 if (Exit.isFailure(resultExit)) {
653 failAdd(errorMessageFromExit(resultExit, "Failed to add source"));
654 return;
655 }
656
657 const sourceId = resultExit.value.namespace;
658 const sourceScope = ScopeId.make(scopeId);
659 const bindingScope = ScopeId.make(userScope);
660
661 for (const binding of headerBindings) {
662 const bindingExit = await doSetBinding({
626 params: { scopeId },663 params: { scopeId },
627 payload: {664 payload: {
628 spec: specUrl,665 sourceId,
629 specFetchCredentials: serializeHttpCredentials(specFetchCredentials),666 sourceScope,
630 name: identity.name.trim() || undefined,667 scope: bindingScope,
631 namespace: slugifyNamespace(identity.namespace) || undefined,668 slot: binding.slot,
632 baseUrl: resolvedBaseUrl || undefined,669 value: {
633 ...(hasHeaders ? { headers: configuredHeaders } : {}),670 kind: "secret",
634 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0671 secretId: SecretId.make(binding.secretId),
635 ? { queryParams: serializeHttpCredentials(runtimeCredentials).queryParams }672 },
636 : {}),
637 ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}),
638 },673 },
639 reactivityKeys: addSpecWriteKeys,674 reactivityKeys: bindingWriteKeys,
640 });675 });
641 676 if (Exit.isFailure(bindingExit)) {
642 const sourceId = result.namespace;677 failAdd(errorMessageFromExit(bindingExit, "Failed to add source"));
643 const sourceScope = ScopeId.make(scopeId);678 return;
644 const bindingScope = ScopeId.make(userScope);
645
646 for (const binding of headerBindings) {
647 await doSetBinding({
648 params: { scopeId },
649 payload: {
650 sourceId,
651 sourceScope,
652 scope: bindingScope,
653 slot: binding.slot,
654 value: {
655 kind: "secret",
656 secretId: SecretId.make(binding.secretId),
657 },
658 },
659 reactivityKeys: bindingWriteKeys,
660 });
661 }679 }
680 }
662 681
663 if (configuredOAuth2 && oauth2ClientIdSecretId) {682 if (configuredOAuth2 && oauth2ClientIdSecretId) {
664 await doSetBinding({683 const clientIdBindingExit = await doSetBinding({
665 params: { scopeId },684 params: { scopeId },
666 payload: {685 payload: {
667 sourceId,686 sourceId,
668 sourceScope,687 sourceScope,
669 scope: bindingScope,688 scope: bindingScope,
670 slot: configuredOAuth2.clientIdSlot,689 slot: configuredOAuth2.clientIdSlot,
671 value: {690 value: {
672 kind: "secret",691 kind: "secret",
673 secretId: SecretId.make(oauth2ClientIdSecretId),692 secretId: SecretId.make(oauth2ClientIdSecretId),
674 },
675 },693 },
676 reactivityKeys: bindingWriteKeys,694 },
677 });695 reactivityKeys: bindingWriteKeys,
696 });
697 if (Exit.isFailure(clientIdBindingExit)) {
698 failAdd(errorMessageFromExit(clientIdBindingExit, "Failed to add source"));
699 return;
678 }700 }
701 }
679 702
680 if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) {703 if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) {
681 await doSetBinding({704 const clientSecretBindingExit = await doSetBinding({
682 params: { scopeId },705 params: { scopeId },
683 payload: {706 payload: {
684 sourceId,707 sourceId,
685 sourceScope,708 sourceScope,
686 scope: bindingScope,709 scope: bindingScope,
687 slot: configuredOAuth2.clientSecretSlot,710 slot: configuredOAuth2.clientSecretSlot,
688 value: {711 value: {
689 kind: "secret",712 kind: "secret",
690 secretId: SecretId.make(oauth2ClientSecretSecretId),713 secretId: SecretId.make(oauth2ClientSecretSecretId),
691 },
692 },714 },
693 reactivityKeys: bindingWriteKeys,715 },
694 });716 reactivityKeys: bindingWriteKeys,
717 });
718 if (Exit.isFailure(clientSecretBindingExit)) {
719 failAdd(errorMessageFromExit(clientSecretBindingExit, "Failed to add source"));
720 return;
695 }721 }
722 }
696 723
697 if (configuredOAuth2 && oauth2Auth) {724 if (configuredOAuth2 && oauth2Auth) {
698 await doSetBinding({725 const connectionBindingExit = await doSetBinding({
699 params: { scopeId },726 params: { scopeId },
700 payload: {727 payload: {
701 sourceId,728 sourceId,
702 sourceScope,729 sourceScope,
703 scope: bindingScope,730 scope: bindingScope,
704 slot: configuredOAuth2.connectionSlot,731 slot: configuredOAuth2.connectionSlot,
705 value: {732 value: {
706 kind: "connection",733 kind: "connection",
707 connectionId: ConnectionId.make(oauth2Auth.connectionId),734 connectionId: ConnectionId.make(oauth2Auth.connectionId),
708 },
709 },735 },
710 reactivityKeys: bindingWriteKeys,736 },
711 });737 reactivityKeys: bindingWriteKeys,
738 });
739 if (Exit.isFailure(connectionBindingExit)) {
740 failAdd(errorMessageFromExit(connectionBindingExit, "Failed to add source"));
741 return;
712 }742 }
713
714 props.onComplete();
715 } catch (e) {
716 setAddError(e instanceof Error ? e.message : "Failed to add source");
717 setAdding(false);
718 } finally {
719 placeholder.done();
720 }743 }
744
745 placeholder.done();
746 props.onComplete();
721 };747 };
722 748
723 // ---- Render ----749 // ---- Render ----
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+196−173
@@ -1,5 +1,8 @@
1import { useEffect, useMemo, useRef, useState } from "react";1import { useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";6import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4 7
5import { connectionsAtom, sourceAtom, startOAuth } from "@executor-js/react/api/atoms";8import { connectionsAtom, sourceAtom, startOAuth } from "@executor-js/react/api/atoms";
@@ -42,7 +45,21 @@ import { · 36 unmodified lines
42 resolveOAuthUrl,45 resolveOAuthUrl,
43} from "./AddOpenApiSource";46} from "./AddOpenApiSource";
44import { oauth2ClientSecretSlot } from "../sdk/store";47import { oauth2ClientSecretSlot } from "../sdk/store";
45import type { OpenApiSourceBindingValue } from "../sdk/types";48import {
49 OpenApiSourceBindingValue,
50 type OpenApiSourceBindingValue as OpenApiSourceBindingValueType,
51} from "../sdk/types";
52
53const ErrorMessage = Schema.Struct({ message: Schema.String });
54
55const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
56 Option.match(
57 Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),
58 {
59 onNone: () => fallback,
60 onSome: ({ message }) => message,
61 },
62 );
46 63
47type SlotDef =64type SlotDef =
48 | {65 | {
@@ -79,7 +96,7 @@ const openApiOAuthConnectionId = ( · 30 unmodified lines
79 targetScope: ScopeId,96 targetScope: ScopeId,
80): ConnectionId =>97): ConnectionId =>
81 ConnectionId.make(98 ConnectionId.make(
82 `openapi-oauth-${slugify(sourceId)}-${slugify(securitySchemeName)}-${shortHash(targetScope as string)}`,99 `openapi-oauth-${slugify(sourceId)}-${slugify(securitySchemeName)}-${shortHash(targetScope)}`,
83 );100 );
84 101
85const bindingSecretId = (sourceId: string, slot: string, scopeId: string): string =>102const bindingSecretId = (sourceId: string, slot: string, scopeId: string): string =>
@@ -101,7 +118,7 @@ const exactBindingForScope = ( · 15 unmodified lines
101) => rows.find((row) => row.slot === slot && row.scopeId === scopeId) ?? null;118) => rows.find((row) => row.slot === slot && row.scopeId === scopeId) ?? null;
102 119
103const scopeRank = (ranks: ReadonlyMap<string, number>, scopeId: ScopeId): number =>120const scopeRank = (ranks: ReadonlyMap<string, number>, scopeId: ScopeId): number =>
104 ranks.get(scopeId as string) ?? Number.MAX_SAFE_INTEGER;121 ranks.get(scopeId) ?? Number.MAX_SAFE_INTEGER;
105 122
106const effectiveBindingForScope = (123const effectiveBindingForScope = (
107 rows: readonly {124 rows: readonly {
@@ -119,21 +136,13 @@ const effectiveBindingForScope = ( · 11 unmodified lines
119 136
120const isSecretBindingValue = (137const isSecretBindingValue = (
121 value: unknown,138 value: unknown,
122): value is Extract<OpenApiSourceBindingValue, { readonly kind: "secret" }> =>139): value is Extract<OpenApiSourceBindingValueType, { readonly kind: "secret" }> =>
123 typeof value === "object" &&140 Schema.is(OpenApiSourceBindingValue)(value) && value.kind === "secret";
124 value !== null &&
125 "kind" in value &&
126 (value as { kind?: unknown }).kind === "secret" &&
127 "secretId" in value;
128 141
129const isConnectionBindingValue = (142const isConnectionBindingValue = (
130 value: unknown,143 value: unknown,
131): value is Extract<OpenApiSourceBindingValue, { readonly kind: "connection" }> =>144): value is Extract<OpenApiSourceBindingValueType, { readonly kind: "connection" }> =>
132 typeof value === "object" &&145 Schema.is(OpenApiSourceBindingValue)(value) && value.kind === "connection";
133 value !== null &&
134 "kind" in value &&
135 (value as { kind?: unknown }).kind === "connection" &&
136 "connectionId" in value;
137 146
138export default function EditOpenApiSource(props: {147export default function EditOpenApiSource(props: {
139 readonly sourceId: string;148 readonly sourceId: string;
@@ -150,7 +159,7 @@ export default function EditOpenApiSource(props: { · 10 unmodified lines
150 const sourceScopeId = sourceSummary?.scopeId ?? displayScope;159 const sourceScopeId = sourceSummary?.scopeId ?? displayScope;
151 const sourceScope = ScopeId.make(sourceScopeId);160 const sourceScope = ScopeId.make(sourceScopeId);
152 const scopeRanks = useMemo(161 const scopeRanks = useMemo(
153 () => new Map(scopeStack.map((scope, index) => [scope.id as string, index] as const)),162 () => new Map(scopeStack.map((scope, index) => [scope.id, index] as const)),
154 [scopeStack],163 [scopeStack],
155 );164 );
156 165
@@ -161,10 +170,10 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines
161 const connectionsResult = useAtomValue(connectionsAtom(displayScope));170 const connectionsResult = useAtomValue(connectionsAtom(displayScope));
162 const secretList = useSecretPickerSecrets();171 const secretList = useSecretPickerSecrets();
163 172
164 const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" });173 const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" });
165 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promise" });174 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });
166 const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { mode: "promise" });175 const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { mode: "promiseExit" });
167 const doStartOAuth = useAtomSet(startOAuth, { mode: "promise" });176 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
168 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({177 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
169 popupName: OPENAPI_OAUTH_POPUP_NAME,178 popupName: OPENAPI_OAUTH_POPUP_NAME,
170 popupBlockedMessage: "OAuth popup was blocked by the browser",179 popupBlockedMessage: "OAuth popup was blocked by the browser",
@@ -224,28 +233,28 @@ export default function EditOpenApiSource(props: { · 53 unmodified lines
224 const seq = ++sourceSaveSeq.current;233 const seq = ++sourceSaveSeq.current;
225 setSourceSaveState("saving");234 setSourceSaveState("saving");
226 setError(null);235 setError(null);
227 void doUpdate({236 void (async () => {
228 params: { scopeId: ScopeId.make(sourceScopeId), namespace: props.sourceId },237 const exit = await doUpdate({
229 payload: {238 params: { scopeId: ScopeId.make(sourceScopeId), namespace: props.sourceId },
230 name: nextName || undefined,239 payload: {
231 baseUrl: nextBaseUrl || undefined,240 name: nextName || undefined,
232 headers: source.config.headers,241 baseUrl: nextBaseUrl || undefined,
233 oauth2: source.config.oauth2,242 headers: source.config.headers,
234 },243 oauth2: source.config.oauth2,
235 reactivityKeys: openApiWriteKeys,244 },
236 })245 reactivityKeys: openApiWriteKeys,
237 .then(() => {
238 if (sourceSaveSeq.current !== seq) return;
239 setSourceSaveState("saved");
240 window.setTimeout(() => {
241 if (sourceSaveSeq.current === seq) setSourceSaveState("idle");
242 }, 1600);
243 })
244 .catch((e: unknown) => {
245 if (sourceSaveSeq.current !== seq) return;
246 setSourceSaveState("idle");
247 setError(e instanceof Error ? e.message : "Failed to save source details");
248 });246 });
247 if (sourceSaveSeq.current !== seq) return;
248 if (Exit.isFailure(exit)) {
249 setSourceSaveState("idle");
250 setError(errorMessageFromExit(exit, "Failed to save source details"));
251 return;
252 }
253 setSourceSaveState("saved");
254 window.setTimeout(() => {
255 if (sourceSaveSeq.current === seq) setSourceSaveState("idle");
256 }, 1600);
257 })();
249 }, 600);258 }, 600);
250 259
251 return () => window.clearTimeout(timeout);260 return () => window.clearTimeout(timeout);
@@ -321,44 +330,40 @@ export default function EditOpenApiSource(props: { · 69 unmodified lines
321 if (!trimmed) return;330 if (!trimmed) return;
322 setBusyKey(inputKey);331 setBusyKey(inputKey);
323 setError(null);332 setError(null);
324 try {333 const exit = await doSetBinding({
325 await doSetBinding({334 params: { scopeId: displayScope },
326 params: { scopeId: displayScope },335 payload: {
327 payload: {336 sourceId: props.sourceId,
328 sourceId: props.sourceId,337 sourceScope,
329 sourceScope,338 scope: targetScope,
330 scope: targetScope,339 slot,
331 slot,340 value: { kind: "secret", secretId: SecretId.make(trimmed) },
332 value: { kind: "secret", secretId: SecretId.make(trimmed) },341 },
333 },342 reactivityKeys: sourceWriteKeys,
334 reactivityKeys: sourceWriteKeys,343 });
335 });344 if (Exit.isFailure(exit)) {
336 } catch (e) {345 setError(errorMessageFromExit(exit, "Failed to save credential binding"));
337 setError(e instanceof Error ? e.message : "Failed to save credential binding");
338 } finally {
339 setBusyKey(null);
340 }346 }
347 setBusyKey(null);
341 };348 };
342 349
343 const clearBinding = async (targetScope: ScopeId, slot: string) => {350 const clearBinding = async (targetScope: ScopeId, slot: string) => {
344 setBusyKey(`${targetScope}:${slot}:clear`);351 setBusyKey(`${targetScope}:${slot}:clear`);
345 setError(null);352 setError(null);
346 try {353 const exit = await doRemoveBinding({
347 await doRemoveBinding({354 params: { scopeId: displayScope },
348 params: { scopeId: displayScope },355 payload: {
349 payload: {356 sourceId: props.sourceId,
350 sourceId: props.sourceId,357 sourceScope,
351 sourceScope,358 slot,
352 slot,359 scope: targetScope,
353 scope: targetScope,360 },
354 },361 reactivityKeys: sourceWriteKeys,
355 reactivityKeys: sourceWriteKeys,362 });
356 });363 if (Exit.isFailure(exit)) {
357 } catch (e) {364 setError(errorMessageFromExit(exit, "Failed to clear credential binding"));
358 setError(e instanceof Error ? e.message : "Failed to clear credential binding");
359 } finally {
360 setBusyKey(null);
361 }365 }
366 setBusyKey(null);
362 };367 };
363 368
364 const connectOAuth = async (targetScope: ScopeId) => {369 const connectOAuth = async (targetScope: ScopeId) => {
@@ -410,35 +415,112 @@ export default function EditOpenApiSource(props: { · 45 unmodified lines
410 setPendingOAuthConnection({415 setPendingOAuthConnection({
411 scopeId: targetScope,416 scopeId: targetScope,
412 slot: oauth2.connectionSlot,417 slot: oauth2.connectionSlot,
413 connectionId: connectionId as string,418 connectionId: connectionId,
414 });419 });
415 setError(null);420 setError(null);
416 try {421 const failConnect = (message: string) => {
417 const displayName = source.name;422 setError(message);
418 const tokenUrl = resolveOAuthUrl(oauth2.tokenUrl, source.config.baseUrl ?? "");423 setPendingOAuthConnection(null);
419 if (oauth2.flow === "clientCredentials") {424 setBusyKey(null);
420 const response = await doStartOAuth({425 };
421 params: { scopeId: displayScope },426 const displayName = source.name;
422 payload: {427 const tokenUrl = resolveOAuthUrl(oauth2.tokenUrl, source.config.baseUrl ?? "");
423 endpoint: tokenUrl,428 if (oauth2.flow === "clientCredentials") {
424 redirectUrl: tokenUrl,429 const startOAuthExit = await doStartOAuth({
425 connectionId: connectionId as string,430 params: { scopeId: displayScope },
426 tokenScope: targetScope as string,431 payload: {
427 strategy: {432 endpoint: tokenUrl,
428 kind: "client-credentials",433 redirectUrl: tokenUrl,
429 tokenEndpoint: tokenUrl,434 connectionId: connectionId,
430 clientIdSecretId,435 tokenScope: targetScope,
431 clientSecretSecretId: clientSecretValue!.secretId,436 strategy: {
432 scopes: [...oauth2.scopes],437 kind: "client-credentials",
433 },438 tokenEndpoint: tokenUrl,
434 pluginId: "openapi",439 clientIdSecretId,
435 identityLabel: `${displayName} OAuth`,440 clientSecretSecretId: clientSecretValue!.secretId,
441 scopes: [...oauth2.scopes],
436 },442 },
437 });443 pluginId: "openapi",
438 if (!response.completedConnection) {444 identityLabel: `${displayName} OAuth`,
439 throw new Error("Unexpected OAuth response");445 },
440 }446 });
441 await doSetBinding({447 if (Exit.isFailure(startOAuthExit)) {
448 failConnect(errorMessageFromExit(startOAuthExit, "Failed to connect OAuth"));
449 return;
450 }
451 const response = startOAuthExit.value;
452 if (!response.completedConnection) {
453 failConnect("Unexpected OAuth response");
454 return;
455 }
456 const setBindingExit = await doSetBinding({
457 params: { scopeId: displayScope },
458 payload: {
459 sourceId: props.sourceId,
460 sourceScope,
461 scope: targetScope,
462 slot: oauth2.connectionSlot,
463 value: {
464 kind: "connection",
465 connectionId: ConnectionId.make(response.completedConnection.connectionId),
466 },
467 },
468 reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys],
469 });
470 if (Exit.isFailure(setBindingExit)) {
471 failConnect(errorMessageFromExit(setBindingExit, "Failed to connect OAuth"));
472 return;
473 }
474 setPendingOAuthConnection(null);
475 setBusyKey(null);
476 return;
477 }
478
479 const authorizationUrl = resolveOAuthUrl(
480 oauth2.authorizationUrl ?? "",
481 source.config.baseUrl ?? "",
482 );
483 const issuerUrl = oauth2.issuerUrl ?? inferOAuthIssuerUrl(authorizationUrl);
484 const startOAuthExit = await doStartOAuth({
485 params: { scopeId: displayScope },
486 payload: {
487 endpoint: authorizationUrl,
488 connectionId,
489 tokenScope: targetScope,
490 redirectUrl: oauth2RedirectUrl,
491 strategy: {
492 kind: "authorization-code",
493 authorizationEndpoint: authorizationUrl,
494 tokenEndpoint: tokenUrl,
495 issuerUrl,
496 clientIdSecretId,
497 clientSecretSecretId:
498 clientSecretBinding && isSecretBindingValue(clientSecretBinding.value)
499 ? clientSecretBinding.value.secretId
500 : null,
501 scopes: [...oauth2.scopes],
502 },
503 pluginId: "openapi",
504 identityLabel: `${displayName} OAuth`,
505 },
506 });
507 if (Exit.isFailure(startOAuthExit)) {
508 failConnect(errorMessageFromExit(startOAuthExit, "Failed to connect OAuth"));
509 return;
510 }
511 const response = startOAuthExit.value;
512 if (response.authorizationUrl === null) {
513 failConnect("Unexpected OAuth response");
514 return;
515 }
516
517 await oauth.openAuthorization({
518 run: async () => ({
519 sessionId: response.sessionId,
520 authorizationUrl: response.authorizationUrl,
521 }),
522 onSuccess: async (result) => {
523 const setBindingExit = await doSetBinding({
442 params: { scopeId: displayScope },524 params: { scopeId: displayScope },
443 payload: {525 payload: {
444 sourceId: props.sourceId,526 sourceId: props.sourceId,
@@ -447,83 +529,24 @@ export default function EditOpenApiSource(props: { · 2 unmodified lines
447 slot: oauth2.connectionSlot,529 slot: oauth2.connectionSlot,
448 value: {530 value: {
449 kind: "connection",531 kind: "connection",
450 connectionId: ConnectionId.make(response.completedConnection.connectionId),532 connectionId: ConnectionId.make(result.connectionId),
451 },533 },
452 },534 },
453 reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys],535 reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys],
454 });536 });
537 if (Exit.isFailure(setBindingExit)) {
538 failConnect(errorMessageFromExit(setBindingExit, "Failed to connect OAuth"));
539 return;
540 }
455 setPendingOAuthConnection(null);541 setPendingOAuthConnection(null);
456 setBusyKey(null);542 setBusyKey(null);
457 return;543 },
458 }544 onError: (message) => {
459 545 setError(message);
460 const authorizationUrl = resolveOAuthUrl(546 setPendingOAuthConnection(null);
461 oauth2.authorizationUrl ?? "",547 setBusyKey(null);
462 source.config.baseUrl ?? "",548 },
463 );549 });
464 const issuerUrl = oauth2.issuerUrl ?? inferOAuthIssuerUrl(authorizationUrl);
465 await oauth.openAuthorization({
466 run: async () => {
467 const response = await doStartOAuth({
468 params: { scopeId: displayScope },
469 payload: {
470 endpoint: authorizationUrl,
471 connectionId: connectionId as string,
472 tokenScope: targetScope as string,
473 redirectUrl: oauth2RedirectUrl,
474 strategy: {
475 kind: "authorization-code",
476 authorizationEndpoint: authorizationUrl,
477 tokenEndpoint: tokenUrl,
478 issuerUrl,
479 clientIdSecretId,
480 clientSecretSecretId:
481 clientSecretBinding && isSecretBindingValue(clientSecretBinding.value)
482 ? clientSecretBinding.value.secretId
483 : null,
484 scopes: [...oauth2.scopes],
485 },
486 pluginId: "openapi",
487 identityLabel: `${displayName} OAuth`,
488 },
489 });
490 if (response.authorizationUrl === null) {
491 throw new Error("Unexpected OAuth response");
492 }
493 return {
494 sessionId: response.sessionId,
495 authorizationUrl: response.authorizationUrl,
496 };
497 },
498 onSuccess: async (result) => {
499 await doSetBinding({
500 params: { scopeId: displayScope },
501 payload: {
502 sourceId: props.sourceId,
503 sourceScope,
504 scope: targetScope,
505 slot: oauth2.connectionSlot,
506 value: {
507 kind: "connection",
508 connectionId: ConnectionId.make(result.connectionId),
509 },
510 },
511 reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys],
512 });
513 setPendingOAuthConnection(null);
514 setBusyKey(null);
515 },
516 onError: (message) => {
517 setError(message);
518 setPendingOAuthConnection(null);
519 setBusyKey(null);
520 },
521 });
522 } catch (e) {
523 setError(e instanceof Error ? e.message : "Failed to connect OAuth");
524 setPendingOAuthConnection(null);
525 setBusyKey(null);
526 }
527 };550 };
528 551
529 return (552 return (
@@ -590,10 +613,10 @@ export default function EditOpenApiSource(props: { · 60 unmodified lines
590 </CardStackEntryContent>613 </CardStackEntryContent>
591 <FilterTabs614 <FilterTabs
592 tabs={credentialScopes.map((entry) => ({615 tabs={credentialScopes.map((entry) => ({
593 value: entry.scopeId as string,616 value: entry.scopeId,
594 label: entry.label,617 label: entry.label,
595 }))}618 }))}
596 value={activeCredentialScopeId as string}619 value={activeCredentialScopeId}
597 onChange={setSelectedCredentialScope}620 onChange={setSelectedCredentialScope}
598 />621 />
599 </CardStackEntry>622 </CardStackEntry>
@@ -618,9 +641,9 @@ export default function EditOpenApiSource(props: { · 18 unmodified lines
618 isSecretBindingValue(effective.value);641 isSecretBindingValue(effective.value);
619 const currentSecretId =642 const currentSecretId =
620 exact && isSecretBindingValue(exact.value)643 exact && isSecretBindingValue(exact.value)
621 ? (exact.value.secretId as string)644 ? exact.value.secretId
622 : inherited && effective && isSecretBindingValue(effective.value)645 : inherited && effective && isSecretBindingValue(effective.value)
623 ? (effective.value.secretId as string)646 ? effective.value.secretId
624 : null;647 : null;
625 return (648 return (
626 <CardStackEntryField649 <CardStackEntryField
packages/plugins/openapi/src/sdk/store.ts+243−210
@@ -1,4 +1,4 @@
1import { Effect, Schema } from "effect";1import { Effect, Option, Schema } from "effect";
2 2
3import {3import {
4 ConnectionId,4 ConnectionId,
@@ -174,9 +174,7 @@ export interface StoredSource { · 169 unmodified lines
174// an encodable/decodable shape for HTTP responses.174// an encodable/decodable shape for HTTP responses.
175// ---------------------------------------------------------------------------175// ---------------------------------------------------------------------------
176 176
177export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>(177export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>("OpenApiStoredSource")({
178 "OpenApiStoredSource",
179)({
180 namespace: Schema.String,178 namespace: Schema.String,
181 name: Schema.String,179 name: Schema.String,
182 config: Schema.Struct({180 config: Schema.Struct({
@@ -184,20 +182,12 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>( · 1 unmodified line
184 sourceUrl: Schema.optional(Schema.String),182 sourceUrl: Schema.optional(Schema.String),
185 baseUrl: Schema.optional(Schema.String),183 baseUrl: Schema.optional(Schema.String),
186 namespace: Schema.optional(Schema.String),184 namespace: Schema.optional(Schema.String),
187 headers: Schema.optional(185 headers: Schema.optional(Schema.Record(Schema.String, ConfiguredHeaderValue)),
188 Schema.Record(Schema.String, ConfiguredHeaderValue),186 queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValue)),
189 ),
190 queryParams: Schema.optional(
191 Schema.Record(Schema.String, HeaderValue),
192 ),
193 specFetchCredentials: Schema.optional(187 specFetchCredentials: Schema.optional(
194 Schema.Struct({188 Schema.Struct({
195 headers: Schema.optional(189 headers: Schema.optional(Schema.Record(Schema.String, HeaderValue)),
196 Schema.Record(Schema.String, HeaderValue),190 queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValue)),
197 ),
198 queryParams: Schema.optional(
199 Schema.Record(Schema.String, HeaderValue),
200 ),
201 }),191 }),
202 ),192 ),
203 // Canonical source-owned OAuth config. Concrete client credentials193 // Canonical source-owned OAuth config. Concrete client credentials
@@ -221,10 +211,85 @@ export interface StoredOperation { · 17 unmodified lines
221 211
222const encodeBinding = Schema.encodeSync(OperationBinding);212const encodeBinding = Schema.encodeSync(OperationBinding);
223const decodeBinding = Schema.decodeUnknownSync(OperationBinding);213const decodeBinding = Schema.decodeUnknownSync(OperationBinding);
214const decodeBindingJson = Schema.decodeUnknownSync(Schema.fromJsonString(OperationBinding));
224 215
225const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth);216const decodeOAuth2 = Schema.decodeUnknownSync(OAuth2Auth);
217const decodeOAuth2Option = Schema.decodeUnknownOption(OAuth2Auth);
218const decodeOAuth2JsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(OAuth2Auth));
219const decodeOAuth2SourceConfigOption = Schema.decodeUnknownOption(OAuth2SourceConfig);
220const decodeOAuth2SourceConfigJsonOption = Schema.decodeUnknownOption(
221 Schema.fromJsonString(OAuth2SourceConfig),
222);
226const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig);223const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig);
227 224
225const decodeHeaderValueOption = Schema.decodeUnknownOption(HeaderValue);
226const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
227const decodeUnknownRecord = Schema.decodeUnknownSync(UnknownRecord);
228const decodeUnknownRecordJson = Schema.decodeUnknownSync(Schema.fromJsonString(UnknownRecord));
229const decodeConfiguredHeaderBindingOption = Schema.decodeUnknownOption(ConfiguredHeaderBinding);
230
231const NullableString = Schema.NullOr(Schema.String);
232const OptionalNullableString = Schema.optional(NullableString);
233
234const ChildStorageRow = Schema.Struct({
235 name: Schema.String,
236 kind: Schema.Literals(["text", "secret"]),
237 text_value: OptionalNullableString,
238 secret_id: OptionalNullableString,
239 secret_prefix: OptionalNullableString,
240});
241const decodeChildStorageRowOption = Schema.decodeUnknownOption(ChildStorageRow);
242
243const SourceBindingStorageRow = Schema.Struct({
244 source_id: Schema.String,
245 source_scope_id: Schema.String,
246 target_scope_id: Schema.String,
247 slot: Schema.String,
248 kind: Schema.Literals(["secret", "connection", "text"]),
249 secret_id: OptionalNullableString,
250 connection_id: OptionalNullableString,
251 text_value: OptionalNullableString,
252 created_at: Schema.Unknown,
253 updated_at: Schema.Unknown,
254});
255const decodeSourceBindingStorageRow = Schema.decodeUnknownSync(SourceBindingStorageRow);
256
257const SourceStorageRow = Schema.Struct({
258 id: Schema.String,
259 scope_id: Schema.String,
260 name: Schema.String,
261 spec: Schema.String,
262 source_url: OptionalNullableString,
263 base_url: OptionalNullableString,
264 headers: Schema.optional(Schema.Unknown),
265 oauth2: Schema.optional(Schema.Unknown),
266});
267const decodeSourceStorageRow = Schema.decodeUnknownSync(SourceStorageRow);
268
269const OperationStorageRow = Schema.Struct({
270 id: Schema.String,
271 source_id: Schema.String,
272 binding: Schema.Unknown,
273});
274const decodeOperationStorageRow = Schema.decodeUnknownSync(OperationStorageRow);
275
276const ChildUsageStorageRow = Schema.Struct({
277 source_id: Schema.String,
278 scope_id: Schema.String,
279 name: Schema.String,
280});
281const decodeChildUsageStorageRow = Schema.decodeUnknownSync(ChildUsageStorageRow);
282
283const SourceNameStorageRow = Schema.Struct({
284 id: Schema.String,
285 scope_id: Schema.String,
286 name: Schema.String,
287});
288const decodeSourceNameStorageRow = Schema.decodeUnknownSync(SourceNameStorageRow);
289
290const decodeStorageDate = (value: unknown): Date =>
291 value instanceof Date ? value : new Date(Schema.decodeUnknownSync(Schema.String)(value));
292
228interface ChildRow {293interface ChildRow {
229 readonly id: string;294 readonly id: string;
230 readonly scope_id: string;295 readonly scope_id: string;
@@ -277,21 +342,24 @@ const childRowsToValueMap = ( · 46 unmodified lines
277): Record<string, HeaderValue> => {342): Record<string, HeaderValue> => {
278 const out: Record<string, HeaderValue> = {};343 const out: Record<string, HeaderValue> = {};
279 for (const row of rows) {344 for (const row of rows) {
280 const name = row.name as string;345 const decoded = decodeChildStorageRowOption(row);
281 if (row.kind === "secret" && typeof row.secret_id === "string") {346 if (Option.isSome(decoded)) {
282 const prefix = row.secret_prefix as string | undefined | null;347 const child = decoded.value;
283 out[name] = prefix348 if (child.kind === "secret" && child.secret_id != null) {
284 ? { secretId: row.secret_id, prefix }349 out[child.name] =
285 : { secretId: row.secret_id };350 child.secret_prefix != null
286 } else if (row.kind === "text" && typeof row.text_value === "string") {351 ? { secretId: child.secret_id, prefix: child.secret_prefix }
287 out[name] = row.text_value;352 : { secretId: child.secret_id };
353 } else if (child.kind === "text" && child.text_value != null) {
354 out[child.name] = child.text_value;
355 }
288 }356 }
289 }357 }
290 return out;358 return out;
291};359};
292 360
293const toJsonRecord = (value: unknown): Record<string, unknown> =>361// oxlint-disable-next-line executor/no-explicit-unknown-record -- boundary: storage adapter accepts JSON object columns
294 value as Record<string, unknown>;362const toJsonRecord = (value: unknown): Record<string, unknown> => value as Record<string, unknown>;
295 363
296const toConfiguredHeaderBinding = (value: {364const toConfiguredHeaderBinding = (value: {
297 readonly slot?: unknown;365 readonly slot?: unknown;
@@ -303,11 +371,10 @@ const toConfiguredHeaderBinding = (value: { · 5 unmodified lines
303 ...(typeof value.prefix === "string" ? { prefix: value.prefix } : {}),371 ...(typeof value.prefix === "string" ? { prefix: value.prefix } : {}),
304 });372 });
305 373
306const decodeHeaders = (value: unknown): Record<string, HeaderValue> => {374const decodeHeaders = (value: unknown): Record<string, unknown> => {
307 if (value == null) return {};375 if (value == null) return {};
308 if (typeof value === "string")376 if (typeof value === "string") return decodeUnknownRecordJson(value);
309 return JSON.parse(value) as Record<string, HeaderValue>;377 return decodeUnknownRecord(value);
310 return value as Record<string, HeaderValue>;
311};378};
312 379
313const slugifySlotPart = (value: string): string =>380const slugifySlotPart = (value: string): string =>
@@ -344,20 +411,18 @@ const normalizeStoredHeaders = ( · 30 unmodified lines
344 legacy[name] = header;411 legacy[name] = header;
345 continue;412 continue;
346 }413 }
347 if (414 const binding = decodeConfiguredHeaderBindingOption(header);
348 header &&415 if (Option.isSome(binding)) {
349 typeof header === "object" &&416 headers[name] = toConfiguredHeaderBinding(binding.value);
350 "kind" in header &&
351 (header as { kind?: unknown }).kind === "binding"
352 ) {
353 headers[name] = toConfiguredHeaderBinding(header);
354 continue;417 continue;
355 }418 }
356 legacy[name] = header;419 const legacyHeader = decodeHeaderValueOption(header);
420 if (Option.isNone(legacyHeader)) continue;
421 legacy[name] = legacyHeader.value;
357 headers[name] = new ConfiguredHeaderBinding({422 headers[name] = new ConfiguredHeaderBinding({
358 kind: "binding",423 kind: "binding",
359 slot: headerBindingSlot(name),424 slot: headerBindingSlot(name),
360 prefix: header.prefix,425 prefix: typeof legacyHeader.value === "string" ? undefined : legacyHeader.value.prefix,
361 });426 });
362 }427 }
363 return { headers, legacy };428 return { headers, legacy };
@@ -370,13 +435,16 @@ const normalizeStoredOAuth2 = ( · 6 unmodified lines
370 readonly legacy?: OAuth2Auth;435 readonly legacy?: OAuth2Auth;
371} => {436} => {
372 if (value == null) return {};437 if (value == null) return {};
373 const parsed = typeof value === "string" ? JSON.parse(value) : value;438 const sourceConfig =
374 if (parsed && typeof parsed === "object" && "connectionSlot" in parsed) {439 typeof value === "string"
375 return {440 ? decodeOAuth2SourceConfigJsonOption(value)
376 oauth2: Schema.decodeUnknownSync(OAuth2SourceConfig)(parsed),441 : decodeOAuth2SourceConfigOption(value);
377 };442 if (Option.isSome(sourceConfig)) {
443 return { oauth2: sourceConfig.value };
378 }444 }
379 const legacy = decodeOAuth2(parsed);445 const legacyOption =
446 typeof value === "string" ? decodeOAuth2JsonOption(value) : decodeOAuth2Option(value);
447 const legacy = Option.isSome(legacyOption) ? legacyOption.value : decodeOAuth2(value);
380 return {448 return {
381 legacy,449 legacy,
382 oauth2: new OAuth2SourceConfig({450 oauth2: new OAuth2SourceConfig({
@@ -438,10 +506,7 @@ export interface OpenapiStore { · 55 unmodified lines
438 scope: string,506 scope: string,
439 ) => Effect.Effect<StoredSource | null, StorageFailure>;507 ) => Effect.Effect<StoredSource | null, StorageFailure>;
440 508
441 readonly listSources: () => Effect.Effect<509 readonly listSources: () => Effect.Effect<readonly StoredSource[], StorageFailure>;
442 readonly StoredSource[],
443 StorageFailure
444 >;
445 510
446 readonly getOperationByToolId: (511 readonly getOperationByToolId: (
447 toolId: string,512 toolId: string,
@@ -453,10 +518,7 @@ export interface OpenapiStore { · 5 unmodified lines
453 scope: string,518 scope: string,
454 ) => Effect.Effect<readonly StoredOperation[], StorageFailure>;519 ) => Effect.Effect<readonly StoredOperation[], StorageFailure>;
455 520
456 readonly removeSource: (521 readonly removeSource: (namespace: string, scope: string) => Effect.Effect<void, StorageFailure>;
457 namespace: string,
458 scope: string,
459 ) => Effect.Effect<void, StorageFailure>;
460 522
461 readonly listSourceBindings: (523 readonly listSourceBindings: (
462 sourceId: string,524 sourceId: string,
@@ -501,10 +563,7 @@ export interface OpenapiStore { · 38 unmodified lines
501 * `query_param:foo` or `spec_fetch_header:Authorization`. */563 * `query_param:foo` or `spec_fetch_header:Authorization`. */
502 readonly findChildRowsBySecret: (secretId: string) => Effect.Effect<564 readonly findChildRowsBySecret: (secretId: string) => Effect.Effect<
503 readonly {565 readonly {
504 readonly kind:566 readonly kind: "query_param" | "spec_fetch_header" | "spec_fetch_query_param";
505 | "query_param"
506 | "spec_fetch_header"
507 | "spec_fetch_query_param";
508 readonly source_id: string;567 readonly source_id: string;
509 readonly scope_id: string;568 readonly scope_id: string;
510 readonly name: string;569 readonly name: string;
@@ -527,14 +586,12 @@ export const makeDefaultOpenapiStore = ({ · 16 unmodified lines
527 adapter,586 adapter,
528 scopes,587 scopes,
529}: StorageDeps<OpenapiSchema>): OpenapiStore => {588}: StorageDeps<OpenapiSchema>): OpenapiStore => {
530 const scopeIds = scopes.map((scope) => scope.id as string);589 const scopeIds = scopes.map((scope) => String(scope.id));
531 const scopePrecedence = new Map<string, number>();590 const scopePrecedence = new Map<string, number>();
532 scopeIds.forEach((scope, index) => scopePrecedence.set(scope, index));591 scopeIds.forEach((scope, index) => scopePrecedence.set(scope, index));
533 const scopeRank = (scopeId: string): number =>592 const scopeRank = (scopeId: string): number => scopePrecedence.get(scopeId) ?? Infinity;
534 scopePrecedence.get(scopeId) ?? Infinity;
535 593
536 const encodeSyntheticRowIdPart = (value: string): string =>594 const encodeSyntheticRowIdPart = (value: string): string => encodeURIComponent(value);
537 encodeURIComponent(value);
538 595
539 const sourceBindingRowId = (596 const sourceBindingRowId = (
540 sourceId: string,597 sourceId: string,
@@ -550,47 +607,47 @@ export const makeDefaultOpenapiStore = ({ · 9 unmodified lines
550 encodeSyntheticRowIdPart(scopeId),607 encodeSyntheticRowIdPart(scopeId),
551 ].join("::");608 ].join("::");
552 609
553 const rowToSourceBindingValue = (610 const rowToSourceBindingValue = (row: Record<string, unknown>): OpenApiSourceBindingValue => {
554 row: Record<string, unknown>,611 const decoded = decodeSourceBindingStorageRow(row);
555 ): OpenApiSourceBindingValue => {612 if (decoded.kind === "secret" && decoded.secret_id != null) {
556 const kind = row.kind as string;613 return { kind: "secret", secretId: SecretId.make(decoded.secret_id) };
557 if (kind === "secret" && typeof row.secret_id === "string") {
558 return { kind: "secret", secretId: SecretId.make(row.secret_id) };
559 }614 }
560 if (kind === "connection" && typeof row.connection_id === "string") {615 if (decoded.kind === "connection" && decoded.connection_id != null) {
561 return {616 return {
562 kind: "connection",617 kind: "connection",
563 connectionId: ConnectionId.make(row.connection_id),618 connectionId: ConnectionId.make(decoded.connection_id),
564 };619 };
565 }620 }
566 // text fallback covers both well-formed text rows and any621 // text fallback covers both well-formed text rows and any
567 // partial/null row that survived a malformed write — `text_value`622 // partial/null row that survived a malformed write — `text_value`
568 // defaults to "" so the type stays satisfied without a throw.623 // defaults to "" so the type stays satisfied without a throw.
569 return { kind: "text", text: (row.text_value as string | null) ?? "" };624 return { kind: "text", text: decoded.text_value ?? "" };
570 };625 };
571 626
572 const rowToSourceBinding = (627 const rowToSourceBinding = (row: Record<string, unknown>): OpenApiSourceBindingRef => {
573 row: Record<string, unknown>,628 const decoded = decodeSourceBindingStorageRow(row);
574 ): OpenApiSourceBindingRef =>629 return new OpenApiSourceBindingRef({
575 new OpenApiSourceBindingRef({630 sourceId: decoded.source_id,
576 sourceId: row.source_id as string,631 sourceScopeId: ScopeId.make(decoded.source_scope_id),
577 sourceScopeId: ScopeId.make(row.source_scope_id as string),632 scopeId: ScopeId.make(decoded.target_scope_id),
578 scopeId: ScopeId.make(row.target_scope_id as string),633 slot: decoded.slot,
579 slot: row.slot as string,
580 value: rowToSourceBindingValue(row),634 value: rowToSourceBindingValue(row),
581 createdAt:635 createdAt: decodeStorageDate(decoded.created_at),
582 row.created_at instanceof Date636 updatedAt: decodeStorageDate(decoded.updated_at),
583 ? row.created_at
584 : new Date(row.created_at as string),
585 updatedAt:
586 row.updated_at instanceof Date
587 ? row.updated_at
588 : new Date(row.updated_at as string),
589 });637 });
638 };
639
640 const sourceBindingTargetScope = (row: Record<string, unknown>): string =>
641 decodeSourceBindingStorageRow(row).target_scope_id;
590 642
591 const sourceBindingValueColumns = (643 const sourceBindingValueColumns = (
592 value: OpenApiSourceBindingValue,644 value: OpenApiSourceBindingValue,
593 ): { kind: string; secret_id?: string; connection_id?: string; text_value?: string } => {645 ): {
646 kind: string;
647 secret_id?: string;
648 connection_id?: string;
649 text_value?: string;
650 } => {
594 if (value.kind === "secret") {651 if (value.kind === "secret") {
595 return { kind: "secret", secret_id: value.secretId };652 return { kind: "secret", secret_id: value.secretId };
596 }653 }
@@ -606,24 +663,20 @@ export const makeDefaultOpenapiStore = ({ · 9 unmodified lines
606 }) =>663 }) =>
607 Effect.gen(function* () {664 Effect.gen(function* () {
608 if (!scopeIds.includes(params.sourceScope)) {665 if (!scopeIds.includes(params.sourceScope)) {
609 return yield* Effect.fail(666 return yield* new StorageError({
610 new StorageError({667 message:
611 message:668 `OpenAPI source binding references source scope "${params.sourceScope}" ` +
612 `OpenAPI source binding references source scope "${params.sourceScope}" ` +669 `which is not in the executor's scope stack [${scopeIds.join(", ")}].`,
613 `which is not in the executor's scope stack [${scopeIds.join(", ")}].`,670 cause: undefined,
614 cause: undefined,671 });
615 }),
616 );
617 }672 }
618 if (!scopeIds.includes(params.targetScope)) {673 if (!scopeIds.includes(params.targetScope)) {
619 return yield* Effect.fail(674 return yield* new StorageError({
620 new StorageError({675 message:
621 message:676 `OpenAPI source binding targets scope "${params.targetScope}" which is not ` +
622 `OpenAPI source binding targets scope "${params.targetScope}" which is not ` +677 `in the executor's scope stack [${scopeIds.join(", ")}].`,
623 `in the executor's scope stack [${scopeIds.join(", ")}].`,678 cause: undefined,
624 cause: undefined,679 });
625 }),
626 );
627 }680 }
628 });681 });
629 682
@@ -645,23 +698,19 @@ export const makeDefaultOpenapiStore = ({ · 15 unmodified lines
645 ],698 ],
646 });699 });
647 if (!source) {700 if (!source) {
648 return yield* Effect.fail(701 return yield* new StorageError({
649 new StorageError({702 message: `OpenAPI source "${params.sourceId}" does not exist at scope "${params.sourceScope}"`,
650 message: `OpenAPI source "${params.sourceId}" does not exist at scope "${params.sourceScope}"`,703 cause: undefined,
651 cause: undefined,704 });
652 }),
653 );
654 }705 }
655 if (scopeRank(params.targetScope) > scopeRank(params.sourceScope)) {706 if (scopeRank(params.targetScope) > scopeRank(params.sourceScope)) {
656 return yield* Effect.fail(707 return yield* new StorageError({
657 new StorageError({708 message:
658 message:709 `OpenAPI source bindings for "${params.sourceId}" cannot be written at ` +
659 `OpenAPI source bindings for "${params.sourceId}" cannot be written at ` +710 `outer scope "${params.targetScope}" because the base source lives at ` +
660 `outer scope "${params.targetScope}" because the base source lives at ` +711 `"${params.sourceScope}"`,
661 `"${params.sourceScope}"`,712 cause: undefined,
662 cause: undefined,713 });
663 }),
664 );
665 }714 }
666 return source;715 return source;
667 });716 });
@@ -684,20 +733,15 @@ export const makeDefaultOpenapiStore = ({ · 16 unmodified lines
684 })733 })
685 .pipe(Effect.map(childRowsToValueMap));734 .pipe(Effect.map(childRowsToValueMap));
686 735
687 const rowToSource = (736 const rowToSource = (row: Record<string, unknown>): Effect.Effect<StoredSource, StorageFailure> =>
688 row: Record<string, unknown>,
689 ): Effect.Effect<StoredSource, StorageFailure> =>
690 Effect.gen(function* () {737 Effect.gen(function* () {
691 const sourceId = row.id as string;738 const sourceRow = decodeSourceStorageRow(row);
692 const scope = row.scope_id as string;739 const sourceId = sourceRow.id;
693 const normalizedHeaders = normalizeStoredHeaders(row.headers);740 const scope = sourceRow.scope_id;
694 const normalizedOAuth2 = normalizeStoredOAuth2(row.oauth2);741 const normalizedHeaders = normalizeStoredHeaders(sourceRow.headers);
742 const normalizedOAuth2 = normalizeStoredOAuth2(sourceRow.oauth2);
695 743
696 const queryParams = yield* loadChildValueMap(744 const queryParams = yield* loadChildValueMap("openapi_source_query_param", sourceId, scope);
697 "openapi_source_query_param",
698 sourceId,
699 scope,
700 );
701 const specFetchHeaders = yield* loadChildValueMap(745 const specFetchHeaders = yield* loadChildValueMap(
702 "openapi_source_spec_fetch_header",746 "openapi_source_spec_fetch_header",
703 sourceId,747 sourceId,
@@ -709,13 +753,10 @@ export const makeDefaultOpenapiStore = ({ · 5 unmodified lines
709 scope,753 scope,
710 );754 );
711 const specFetchCredentials: OpenApiSpecFetchCredentials | undefined =755 const specFetchCredentials: OpenApiSpecFetchCredentials | undefined =
712 Object.keys(specFetchHeaders).length === 0 &&756 Object.keys(specFetchHeaders).length === 0 && Object.keys(specFetchQueryParams).length === 0
713 Object.keys(specFetchQueryParams).length === 0
714 ? undefined757 ? undefined
715 : {758 : {
716 ...(Object.keys(specFetchHeaders).length > 0759 ...(Object.keys(specFetchHeaders).length > 0 ? { headers: specFetchHeaders } : {}),
717 ? { headers: specFetchHeaders }
718 : {}),
719 ...(Object.keys(specFetchQueryParams).length > 0760 ...(Object.keys(specFetchQueryParams).length > 0
720 ? { queryParams: specFetchQueryParams }761 ? { queryParams: specFetchQueryParams }
721 : {}),762 : {}),
@@ -724,38 +765,40 @@ export const makeDefaultOpenapiStore = ({ · 2 unmodified lines
724 return {765 return {
725 namespace: sourceId,766 namespace: sourceId,
726 scope,767 scope,
727 name: row.name as string,768 name: sourceRow.name,
728 config: {769 config: {
729 spec: row.spec as string,770 spec: sourceRow.spec,
730 sourceUrl: (row.source_url as string | null | undefined) ?? undefined,771 sourceUrl: sourceRow.source_url ?? undefined,
731 baseUrl: (row.base_url as string | null | undefined) ?? undefined,772 baseUrl: sourceRow.base_url ?? undefined,
732 headers: normalizedHeaders.headers,773 headers: normalizedHeaders.headers,
733 queryParams,774 queryParams,
734 specFetchCredentials,775 specFetchCredentials,
735 oauth2: normalizedOAuth2.oauth2,776 oauth2: normalizedOAuth2.oauth2,
736 },777 },
737 legacy:778 legacy:
738 Object.keys(normalizedHeaders.legacy).length > 0 ||779 Object.keys(normalizedHeaders.legacy).length > 0 || normalizedOAuth2.legacy
739 normalizedOAuth2.legacy
740 ? {780 ? {
741 ...(Object.keys(normalizedHeaders.legacy).length > 0781 ...(Object.keys(normalizedHeaders.legacy).length > 0
742 ? { headers: normalizedHeaders.legacy }782 ? { headers: normalizedHeaders.legacy }
743 : {}),783 : {}),
744 ...(normalizedOAuth2.legacy784 ...(normalizedOAuth2.legacy ? { oauth2: normalizedOAuth2.legacy } : {}),
745 ? { oauth2: normalizedOAuth2.legacy }
746 : {}),
747 }785 }
748 : undefined,786 : undefined,
749 };787 };
750 });788 });
751 789
752 const rowToOperation = (row: Record<string, unknown>): StoredOperation => ({790 const rowToOperation = (row: Record<string, unknown>): StoredOperation => {
753 toolId: row.id as string,791 const operationRow = decodeOperationStorageRow(row);
754 sourceId: row.source_id as string,792 return {
755 binding: decodeBinding(793 toolId: operationRow.id,
756 typeof row.binding === "string" ? JSON.parse(row.binding) : row.binding,794 sourceId: operationRow.source_id,
757 ),795 binding: decodeBinding(
758 });796 typeof operationRow.binding === "string"
797 ? decodeBindingJson(operationRow.binding)
798 : operationRow.binding,
799 ),
800 };
801 };
759 802
760 // Replace the rows of one child table for a source: delete then bulk803 // Replace the rows of one child table for a source: delete then bulk
761 // insert. Single helper so upsertSource and updateSourceMeta both804 // insert. Single helper so upsertSource and updateSourceMeta both
@@ -845,20 +888,18 @@ export const makeDefaultOpenapiStore = ({ · 83 unmodified lines
845 source_url: input.config.sourceUrl ?? undefined,888 source_url: input.config.sourceUrl ?? undefined,
846 base_url: input.config.baseUrl ?? undefined,889 base_url: input.config.baseUrl ?? undefined,
847 headers: Object.fromEntries(890 headers: Object.fromEntries(
848 Object.entries(input.config.headers ?? {}).map(891 Object.entries(input.config.headers ?? {}).map(([name, value]) => [
849 ([name, value]) => [892 name,
850 name,893 typeof value === "string"
851 typeof value === "string"894 ? value
852 ? value895 : value.kind === "binding"
853 : value.kind === "binding"896 ? {
854 ? {897 kind: value.kind,
855 kind: value.kind,898 slot: value.slot,
856 slot: value.slot,899 ...(value.prefix ? { prefix: value.prefix } : {}),
857 ...(value.prefix ? { prefix: value.prefix } : {}),900 }
858 }901 : value,
859 : value,902 ]),
860 ],
861 ),
862 ) as Record<string, unknown>,903 ) as Record<string, unknown>,
863 oauth2: input.config.oauth2904 oauth2: input.config.oauth2
864 ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2))905 ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2))
@@ -911,14 +952,10 @@ export const makeDefaultOpenapiStore = ({ · 46 unmodified lines
911 const existing = yield* rowToSource(existingRow);952 const existing = yield* rowToSource(existingRow);
912 953
913 const nextName = patch.name?.trim() || existing.name;954 const nextName = patch.name?.trim() || existing.name;
914 const nextBaseUrl =955 const nextBaseUrl = patch.baseUrl !== undefined ? patch.baseUrl : existing.config.baseUrl;
915 patch.baseUrl !== undefined ? patch.baseUrl : existing.config.baseUrl;
916 const nextHeaders =956 const nextHeaders =
917 patch.headers !== undefined957 patch.headers !== undefined ? patch.headers : (existing.config.headers ?? {});
918 ? patch.headers958 const nextOAuth2 = patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2;
919 : (existing.config.headers ?? {});
920 const nextOAuth2 =
921 patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2;
922 959
923 yield* adapter.update({960 yield* adapter.update({
924 model: "openapi_source",961 model: "openapi_source",
@@ -941,9 +978,7 @@ export const makeDefaultOpenapiStore = ({ · 16 unmodified lines
941 },978 },
942 ]),979 ]),
943 ) as Record<string, unknown>,980 ) as Record<string, unknown>,
944 oauth2: nextOAuth2981 oauth2: nextOAuth2 ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2)) : undefined,
945 ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2))
946 : undefined,
947 },982 },
948 });983 });
949 if (patch.queryParams !== undefined) {984 if (patch.queryParams !== undefined) {
@@ -999,8 +1034,7 @@ export const makeDefaultOpenapiStore = ({ · 49 unmodified lines
999 })1034 })
1000 .pipe(Effect.map((rows) => rows.map(rowToOperation))),1035 .pipe(Effect.map((rows) => rows.map(rowToOperation))),
1001 1036
1002 removeSource: (namespace, scope) =>1037 removeSource: (namespace, scope) => deleteSource(namespace, scope, { includeBindings: true }),
1003 deleteSource(namespace, scope, { includeBindings: true }),
1004 1038
1005 listSourceBindings: (sourceId, sourceScope) =>1039 listSourceBindings: (sourceId, sourceScope) =>
1006 Effect.gen(function* () {1040 Effect.gen(function* () {
@@ -1017,14 +1051,10 @@ export const makeDefaultOpenapiStore = ({ · 10 unmodified lines
1017 ],1051 ],
1018 });1052 });
1019 return rows1053 return rows
1020 .filter(1054 .filter((row) => scopeRank(sourceBindingTargetScope(row)) <= sourceScopeRank)
1021 (row) =>
1022 scopeRank(row.target_scope_id as string) <= sourceScopeRank,
1023 )
1024 .sort(1055 .sort(
1025 (a, b) =>1056 (a, b) =>
1026 scopeRank(a.target_scope_id as string) -1057 scopeRank(sourceBindingTargetScope(a)) - scopeRank(sourceBindingTargetScope(b)),
1027 scopeRank(b.target_scope_id as string),
1028 )1058 )
1029 .map(rowToSourceBinding);1059 .map(rowToSourceBinding);
1030 }),1060 }),
@@ -1045,31 +1075,24 @@ export const makeDefaultOpenapiStore = ({ · 14 unmodified lines
1045 });1075 });
1046 const sourceScopeRank = scopeRank(sourceScope);1076 const sourceScopeRank = scopeRank(sourceScope);
1047 const row = rows1077 const row = rows
1048 .filter(1078 .filter((candidate) => scopeRank(sourceBindingTargetScope(candidate)) <= sourceScopeRank)
1049 (candidate) =>
1050 scopeRank(candidate.target_scope_id as string) <= sourceScopeRank,
1051 )
1052 .sort(1079 .sort(
1053 (a, b) =>1080 (a, b) =>
1054 scopeRank(a.target_scope_id as string) -1081 scopeRank(sourceBindingTargetScope(a)) - scopeRank(sourceBindingTargetScope(b)),
1055 scopeRank(b.target_scope_id as string),
1056 )[0];1082 )[0];
1057 return row ? rowToSourceBinding(row) : null;1083 return row ? rowToSourceBinding(row) : null;
1058 }),1084 }),
1059 1085
1060 setSourceBinding: (input) =>1086 setSourceBinding: (input) =>
1061 Effect.gen(function* () {1087 Effect.gen(function* () {
1088 const sourceScope = String(input.sourceScope);
1089 const targetScope = String(input.scope);
1062 yield* validateBindingTarget({1090 yield* validateBindingTarget({
1063 sourceId: input.sourceId,1091 sourceId: input.sourceId,
1064 sourceScope: input.sourceScope as string,1092 sourceScope,
1065 targetScope: input.scope as string,1093 targetScope,
1066 });1094 });
1067 const id = sourceBindingRowId(1095 const id = sourceBindingRowId(input.sourceId, sourceScope, input.slot, targetScope);
1068 input.sourceId,
1069 input.sourceScope as string,
1070 input.slot,
1071 input.scope as string,
1072 );
1073 const now = new Date();1096 const now = new Date();
1074 const valueColumns = sourceBindingValueColumns(input.value);1097 const valueColumns = sourceBindingValueColumns(input.value);
1075 yield* adapter.delete({1098 yield* adapter.delete({
@@ -1081,8 +1104,8 @@ export const makeDefaultOpenapiStore = ({ · 5 unmodified lines
1081 data: {1104 data: {
1082 id,1105 id,
1083 source_id: input.sourceId,1106 source_id: input.sourceId,
1084 source_scope_id: input.sourceScope as string,1107 source_scope_id: sourceScope,
1085 target_scope_id: input.scope as string,1108 target_scope_id: targetScope,
1086 slot: input.slot,1109 slot: input.slot,
1087 ...valueColumns,1110 ...valueColumns,
1088 created_at: now,1111 created_at: now,
@@ -1139,8 +1162,14 @@ export const makeDefaultOpenapiStore = ({ · 50 unmodified lines
1139 Effect.gen(function* () {1162 Effect.gen(function* () {
1140 const tables = [1163 const tables = [
1141 { model: "openapi_source_query_param" as const, kind: "query_param" as const },1164 { model: "openapi_source_query_param" as const, kind: "query_param" as const },
1142 { model: "openapi_source_spec_fetch_header" as const, kind: "spec_fetch_header" as const },1165 {
1143 { model: "openapi_source_spec_fetch_query_param" as const, kind: "spec_fetch_query_param" as const },1166 model: "openapi_source_spec_fetch_header" as const,
1167 kind: "spec_fetch_header" as const,
1168 },
1169 {
1170 model: "openapi_source_spec_fetch_query_param" as const,
1171 kind: "spec_fetch_query_param" as const,
1172 },
1144 ];1173 ];
1145 const perTable = yield* Effect.forEach(1174 const perTable = yield* Effect.forEach(
1146 tables,1175 tables,
@@ -1152,12 +1181,15 @@ export const makeDefaultOpenapiStore = ({ · 5 unmodified lines
1152 })1181 })
1153 .pipe(1182 .pipe(
1154 Effect.map((rows) =>1183 Effect.map((rows) =>
1155 rows.map((r) => ({1184 rows.map((r) => {
1156 kind: t.kind,1185 const row = decodeChildUsageStorageRow(r);
1157 source_id: r.source_id as string,1186 return {
1158 scope_id: r.scope_id as string,1187 kind: t.kind,
1159 name: r.name as string,1188 source_id: row.source_id,
1160 })),1189 scope_id: row.scope_id,
1190 name: row.name,
1191 };
1192 }),
1161 ),1193 ),
1162 ),1194 ),
1163 { concurrency: "unbounded" },1195 { concurrency: "unbounded" },
@@ -1172,8 +1204,9 @@ export const makeDefaultOpenapiStore = ({ · 8 unmodified lines
1172 const requested = new Set(keys);1204 const requested = new Set(keys);
1173 const out = new Map<string, string>();1205 const out = new Map<string, string>();
1174 for (const r of rows) {1206 for (const r of rows) {
1175 const key = `${r.scope_id as string}:${r.id as string}`;1207 const row = decodeSourceNameStorageRow(r);
1176 if (requested.has(key)) out.set(key, r.name as string);1208 const key = `${row.scope_id}:${row.id}`;
1209 if (requested.has(key)) out.set(key, row.name);
1177 }1210 }
1178 return out;1211 return out;
1179 }),1212 }),