History

Add dashboard credential flows and encrypted provider handling

Rhys Sullivan committed 9 months ago

Commit f3fb3181 parent c7a024613 files · +1347 −104Browse files

Unified · Split

13 files
  1. executor/README.md +16 −0
  2. executor/apps/web/src/components/tools-view.tsx +721 −12
  3. executor/apps/web/src/lib/types.ts +1 −0
  4. executor/convex/_generated/api.d.ts +6 −0
  5. executor/convex/credentialsNode.ts +212 −0
  6. executor/convex/database.test.ts +54 −0
  7. executor/convex/database.ts +28 −0
  8. executor/convex/executorNode.ts +65 −92
  9. executor/convex/lib/credential-providers.test.ts +65 −0
  10. executor/convex/lib/credential_providers.ts +161 −0
  11. executor/convex/lib/types.ts +1 −0
  12. executor/convex/schema.ts +14 −0
  13. executor/packages/contracts/src/index.ts +3 −0
executor/README.md+16−0
@@ -41,3 +41,19 @@ bun test convex/database.test.ts convex/executor-mcp.e2e.test.ts · 40 unmodified lines
41- Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`.41- Internal runtime callback routes are served by Convex HTTP routes at `/internal/runs/:runId/*`.
42- `run_code` supports TypeScript typechecking and runtime transpilation before execution.42- `run_code` supports TypeScript typechecking and runtime transpilation before execution.
43- `run_code` now attempts MCP form elicitation for pending tool approvals when the MCP client advertises `elicitation.form`; clients without elicitation support continue using the existing out-of-band approval flow.43- `run_code` now attempts MCP form elicitation for pending tool approvals when the MCP client advertises `elicitation.form`; clients without elicitation support continue using the existing out-of-band approval flow.
44
45## Credential Providers
46
47`sourceCredentials` now supports pluggable providers:
48
49- `managed` (default): stores `secretJson` in Convex.
50- `workos-vault`: stores credential payload in encrypted external storage and keeps only a reference in Convex.
51
52`workos-vault` uses the standard `WORKOS_API_KEY` environment variable for Vault reads.
53
54Examples for `upsertCredential.secretJson`:
55
56- `managed`: `{ "token": "ghp_..." }`
57- `workos-vault`: `{ "token": "ghp_..." }` (backend stores encrypted object)
58
59Compatibility note: `workos-vault` also accepts `{ "objectId": "secret_..." }` when importing existing references.
executor/apps/web/src/components/tools-view.tsx+721−12
@@ -12,11 +12,14 @@ import { · 11 unmodified lines
12 Search,12 Search,
13 ChevronRight,13 ChevronRight,
14 AlertTriangle,14 AlertTriangle,
15 KeyRound,
16 Pencil,
15} from "lucide-react";17} from "lucide-react";
16import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";18import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
17import { Button } from "@/components/ui/button";19import { Button } from "@/components/ui/button";
18import { Input } from "@/components/ui/input";20import { Input } from "@/components/ui/input";
19import { Label } from "@/components/ui/label";21import { Label } from "@/components/ui/label";
22import { Textarea } from "@/components/ui/textarea";
20import { Badge } from "@/components/ui/badge";23import { Badge } from "@/components/ui/badge";
21import {24import {
22 Select,25 Select,
@@ -44,9 +47,9 @@ import { PageHeader } from "@/components/page-header"; · 21 unmodified lines
44import { McpSetupCard } from "@/components/mcp-setup-card";47import { McpSetupCard } from "@/components/mcp-setup-card";
45import { useSession } from "@/lib/session-context";48import { useSession } from "@/lib/session-context";
46import { useWorkspaceTools } from "@/hooks/use-workspace-tools";49import { useWorkspaceTools } from "@/hooks/use-workspace-tools";
47import { useMutation, useQuery } from "convex/react";50import { useAction, useMutation, useQuery } from "convex/react";
48import { convexApi } from "@/lib/convex-api";51import { convexApi } from "@/lib/convex-api";
49import type { ToolSourceRecord, ToolDescriptor } from "@/lib/types";52import type { ToolSourceRecord, ToolDescriptor, CredentialRecord, CredentialScope } from "@/lib/types";
50import { parse as parseDomain } from "tldts";53import { parse as parseDomain } from "tldts";
51import { toast } from "sonner";54import { toast } from "sonner";
52import { cn } from "@/lib/utils";55import { cn } from "@/lib/utils";
@@ -219,6 +222,53 @@ function getSourceFavicon(source: ToolSourceRecord): string | null { · 166 unmodified lines
219 return faviconForUrl(url);222 return faviconForUrl(url);
220}223}
221 224
225function sourceKeyForSource(source: ToolSourceRecord): string | null {
226 if (source.type === "openapi") return `openapi:${source.name}`;
227 if (source.type === "graphql") return `graphql:${source.name}`;
228 return null;
229}
230
231type SourceAuthType = "none" | "bearer" | "apiKey" | "basic";
232type SourceAuthMode = "workspace" | "actor";
233
234function readSourceAuth(source: ToolSourceRecord): {
235 type: SourceAuthType;
236 mode?: SourceAuthMode;
237 header?: string;
238} {
239 if (source.type !== "openapi" && source.type !== "graphql") {
240 return { type: "none" };
241 }
242
243 const auth = source.config.auth as Record<string, unknown> | undefined;
244 const type =
245 auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic"].includes(auth.type)
246 ? (auth.type as SourceAuthType)
247 : "none";
248
249 const mode =
250 auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor")
251 ? (auth.mode as SourceAuthMode)
252 : undefined;
253
254 const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0
255 ? auth.header.trim()
256 : undefined;
257
258 return {
259 type,
260 ...(mode ? { mode } : {}),
261 ...(header ? { header } : {}),
262 };
263}
264
265function formatSourceAuthBadge(source: ToolSourceRecord): string | null {
266 const auth = readSourceAuth(source);
267 if (auth.type === "none") return null;
268 const mode = auth.mode ?? "workspace";
269 return `${auth.type}:${mode}`;
270}
271
222// ── Add Source Dialog ──272// ── Add Source Dialog ──
223 273
224const RAW_HOSTS = new Set([274const RAW_HOSTS = new Set([
@@ -562,6 +612,148 @@ function AddSourceDialog({ · 337 unmodified lines
562 612
563// ── Source Card ──613// ── Source Card ──
564 614
615function ConfigureSourceAuthDialog({
616 source,
617}: {
618 source: ToolSourceRecord;
619}) {
620 const { context } = useSession();
621 const upsertToolSource = useMutation(convexApi.database.upsertToolSource);
622 const [open, setOpen] = useState(false);
623 const [saving, setSaving] = useState(false);
624
625 const currentAuth = readSourceAuth(source);
626 const [authType, setAuthType] = useState<SourceAuthType>(currentAuth.type);
627 const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace");
628 const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key");
629
630 const configurable = source.type === "openapi" || source.type === "graphql";
631
632 const resetFromSource = () => {
633 const auth = readSourceAuth(source);
634 setAuthType(auth.type);
635 setAuthMode(auth.mode ?? "workspace");
636 setApiKeyHeader(auth.header ?? "x-api-key");
637 };
638
639 const handleOpenChange = (isOpen: boolean) => {
640 setOpen(isOpen);
641 if (isOpen) {
642 resetFromSource();
643 }
644 };
645
646 const handleSave = async () => {
647 if (!context || !configurable) return;
648 setSaving(true);
649 try {
650 const authConfig: Record<string, unknown> =
651 authType === "none"
652 ? { type: "none" }
653 : authType === "apiKey"
654 ? { type: "apiKey", mode: authMode, header: apiKeyHeader.trim() || "x-api-key" }
655 : { type: authType, mode: authMode };
656
657 await upsertToolSource({
658 id: source.id,
659 workspaceId: context.workspaceId,
660 name: source.name,
661 type: source.type,
662 config: {
663 ...source.config,
664 auth: authConfig,
665 },
666 });
667
668 toast.success(`Updated auth for ${source.name}`);
669 setOpen(false);
670 } catch (err) {
671 toast.error(err instanceof Error ? err.message : "Failed to update auth");
672 } finally {
673 setSaving(false);
674 }
675 };
676
677 if (!configurable) {
678 return null;
679 }
680
681 return (
682 <Dialog open={open} onOpenChange={handleOpenChange}>
683 <DialogTrigger asChild>
684 <Button variant="outline" size="sm" className="h-7 text-[11px]">
685 <Pencil className="h-3 w-3 mr-1.5" />
686 Auth
687 </Button>
688 </DialogTrigger>
689 <DialogContent className="bg-card border-border sm:max-w-md">
690 <DialogHeader>
691 <DialogTitle className="text-sm font-medium">Configure Source Auth</DialogTitle>
692 </DialogHeader>
693 <div className="space-y-3">
694 <div className="space-y-1.5">
695 <Label className="text-xs text-muted-foreground">Source</Label>
696 <Input value={source.name} readOnly className="h-8 text-xs font-mono bg-background" />
697 </div>
698
699 <div className="space-y-1.5">
700 <Label className="text-xs text-muted-foreground">Auth Type</Label>
701 <Select value={authType} onValueChange={(value) => setAuthType(value as SourceAuthType)}>
702 <SelectTrigger className="h-8 text-xs bg-background">
703 <SelectValue />
704 </SelectTrigger>
705 <SelectContent>
706 <SelectItem value="none" className="text-xs">None</SelectItem>
707 <SelectItem value="bearer" className="text-xs">Bearer token</SelectItem>
708 <SelectItem value="apiKey" className="text-xs">API key header</SelectItem>
709 <SelectItem value="basic" className="text-xs">Basic auth</SelectItem>
710 </SelectContent>
711 </Select>
712 </div>
713
714 {authType !== "none" && (
715 <>
716 <div className="space-y-1.5">
717 <Label className="text-xs text-muted-foreground">Credential Scope</Label>
718 <Select value={authMode} onValueChange={(value) => setAuthMode(value as SourceAuthMode)}>
719 <SelectTrigger className="h-8 text-xs bg-background">
720 <SelectValue />
721 </SelectTrigger>
722 <SelectContent>
723 <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
724 <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem>
725 </SelectContent>
726 </Select>
727 </div>
728
729 {authType === "apiKey" && (
730 <div className="space-y-1.5">
731 <Label className="text-xs text-muted-foreground">Header Name</Label>
732 <Input
733 value={apiKeyHeader}
734 onChange={(e) => setApiKeyHeader(e.target.value)}
735 placeholder="x-api-key"
736 className="h-8 text-xs font-mono bg-background"
737 />
738 </div>
739 )}
740
741 <p className="text-[11px] text-muted-foreground">
742 Save this first, then add credentials in the Credentials tab using source key
743 <code className="ml-1">{sourceKeyForSource(source)}</code>.
744 </p>
745 </>
746 )}
747
748 <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm">
749 {saving ? "Saving..." : "Save Auth"}
750 </Button>
751 </div>
752 </DialogContent>
753 </Dialog>
754 );
755}
756
565function SourceCard({757function SourceCard({
566 source,758 source,
567}: {759}: {
@@ -586,6 +778,7 @@ function SourceCard({ · 18 unmodified lines
586 778
587 const TypeIcon = source.type === "mcp" ? Server : Globe;779 const TypeIcon = source.type === "mcp" ? Server : Globe;
588 const favicon = getSourceFavicon(source);780 const favicon = getSourceFavicon(source);
781 const authBadge = formatSourceAuthBadge(source);
589 782
590 return (783 return (
591 <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group">784 <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group">
@@ -615,6 +808,14 @@ function SourceCard({ · 23 unmodified lines
615 disabled808 disabled
616 </Badge>809 </Badge>
617 )}810 )}
811 {authBadge && (
812 <Badge
813 variant="outline"
814 className="text-[9px] font-mono uppercase tracking-wider text-primary border-primary/30"
815 >
816 {authBadge}
817 </Badge>
818 )}
618 </div>819 </div>
619 <span className="text-[11px] text-muted-foreground font-mono truncate block">820 <span className="text-[11px] text-muted-foreground font-mono truncate block">
620 {source.type === "mcp"821 {source.type === "mcp"
@@ -624,19 +825,505 @@ function SourceCard({ · 3 unmodified lines
624 : (source.config.spec as string)}825 : (source.config.spec as string)}
625 </span>826 </span>
626 </div>827 </div>
627 <Button828 <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0">
628 variant="ghost"829 <ConfigureSourceAuthDialog source={source} />
629 size="icon"830 <Button
630 className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-terminal-red shrink-0"831 variant="ghost"
631 onClick={handleDelete}832 size="icon"
632 disabled={deleting}833 className="h-7 w-7 text-muted-foreground hover:text-terminal-red"
633 >834 onClick={handleDelete}
634 <Trash2 className="h-3.5 w-3.5" />835 disabled={deleting}
635 </Button>836 >
837 <Trash2 className="h-3.5 w-3.5" />
838 </Button>
839 </div>
636 </div>840 </div>
637 );841 );
638}842}
639 843
844function formatCredentialSecret(secretJson: Record<string, unknown>): string {
845 try {
846 return JSON.stringify(secretJson, null, 2);
847 } catch {
848 return "{}";
849 }
850}
851
852type SourceOption = { source: ToolSourceRecord; key: string };
853
854function sourceAuthForKey(sourceOptions: SourceOption[], key: string): {
855 type: SourceAuthType;
856 mode?: SourceAuthMode;
857 header?: string;
858} {
859 const match = sourceOptions.find((entry) => entry.key === key);
860 if (!match) {
861 return { type: "bearer" };
862 }
863 return readSourceAuth(match.source);
864}
865
866function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } {
867 try {
868 const parsed = JSON.parse(text);
869 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
870 return { error: "Credential JSON must be an object" };
871 }
872 return { value: parsed as Record<string, unknown> };
873 } catch (error) {
874 return { error: error instanceof Error ? error.message : "Invalid credential JSON" };
875 }
876}
877
878function asString(value: unknown): string {
879 return typeof value === "string" ? value : "";
880}
881
882function providerLabel(provider: "managed" | "workos-vault"): string {
883 return provider === "workos-vault" ? "encrypted" : "managed";
884}
885
886function CredentialsPanel({
887 sources,
888 credentials,
889 loading,
890}: {
891 sources: ToolSourceRecord[];
892 credentials: CredentialRecord[];
893 loading: boolean;
894}) {
895 const { context } = useSession();
896 const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential);
897 const [open, setOpen] = useState(false);
898 const [saving, setSaving] = useState(false);
899 const [editing, setEditing] = useState<CredentialRecord | null>(null);
900 const [sourceKey, setSourceKey] = useState("");
901 const [scope, setScope] = useState<CredentialScope>("workspace");
902 const [actorId, setActorId] = useState("");
903 const [provider, setProvider] = useState<"managed" | "workos-vault">("managed");
904 const [managedToken, setManagedToken] = useState("");
905 const [apiKeyValue, setApiKeyValue] = useState("");
906 const [basicUsername, setBasicUsername] = useState("");
907 const [basicPassword, setBasicPassword] = useState("");
908 const [advancedMode, setAdvancedMode] = useState(false);
909 const [secretJsonText, setSecretJsonText] = useState("{}");
910
911 const sourceOptions = sources
912 .map((source) => ({ source, key: sourceKeyForSource(source) }))
913 .filter((entry): entry is { source: ToolSourceRecord; key: string } => entry.key !== null);
914
915 const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey);
916 const selectedAuthBadge = selectedAuth.type === "none"
917 ? "none"
918 : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`;
919
920 const buildDraftSecretFromInputs = (): Record<string, unknown> => {
921 if (selectedAuth.type === "apiKey") {
922 return { value: apiKeyValue.trim() };
923 }
924 if (selectedAuth.type === "basic") {
925 return {
926 username: basicUsername,
927 password: basicPassword,
928 };
929 }
930 return { token: managedToken.trim() };
931 };
932
933 const setFormFromCredential = (credential: CredentialRecord) => {
934 const secret = credential.secretJson;
935 setManagedToken(asString(secret.token) || asString(secret.value));
936 setApiKeyValue(asString(secret.value) || asString(secret.token));
937 setBasicUsername(asString(secret.username));
938 setBasicPassword(asString(secret.password));
939 setSecretJsonText(formatCredentialSecret(secret));
940 setAdvancedMode(false);
941 };
942
943 const resetForm = () => {
944 const defaultSourceKey = sourceOptions[0]?.key ?? "";
945 setSourceKey(defaultSourceKey);
946 const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey);
947 setScope(defaultAuth.mode ?? "workspace");
948 setActorId(context?.actorId ?? "");
949 setProvider("managed");
950 setManagedToken("");
951 setApiKeyValue("");
952 setBasicUsername("");
953 setBasicPassword("");
954 setAdvancedMode(false);
955 setSecretJsonText("{}");
956 setEditing(null);
957 };
958
959 const openForCreate = () => {
960 resetForm();
961 setOpen(true);
962 };
963
964 const openForEdit = (credential: CredentialRecord) => {
965 setEditing(credential);
966 setSourceKey(credential.sourceKey);
967 setScope(credential.scope);
968 setActorId(credential.actorId ?? context?.actorId ?? "");
969 setProvider(credential.provider === "workos-vault" ? "workos-vault" : "managed");
970 setFormFromCredential(credential);
971 setOpen(true);
972 };
973
974 const handleSourceKeyChange = (nextSourceKey: string) => {
975 setSourceKey(nextSourceKey);
976 const auth = sourceAuthForKey(sourceOptions, nextSourceKey);
977 if (!editing) {
978 setScope(auth.mode ?? "workspace");
979 }
980 };
981
982 const handleProviderChange = (value: "managed" | "workos-vault") => {
983 setProvider(value);
984 };
985
986 const handleAdvancedModeChange = (next: boolean) => {
987 setAdvancedMode(next);
988 if (next) {
989 setSecretJsonText(formatCredentialSecret(buildDraftSecretFromInputs()));
990 }
991 };
992
993 const handleSave = async () => {
994 if (!context) return;
995 if (!sourceKey.trim()) {
996 toast.error("Source key is required");
997 return;
998 }
999 if (scope === "actor" && !actorId.trim()) {
1000 toast.error("Actor ID is required for actor-scoped credentials");
1001 return;
1002 }
1003
1004 let secretJson: Record<string, unknown> = {};
1005 const keepExistingEncryptedSecret = provider === "workos-vault" && Boolean(editing);
1006
1007 if (advancedMode) {
1008 const parsed = parseJsonObject(secretJsonText);
1009 if (!parsed.value) {
1010 toast.error(parsed.error ?? "Invalid credential JSON");
1011 return;
1012 }
1013 secretJson = parsed.value;
1014 } else {
1015 if (selectedAuth.type === "none") {
1016 toast.error("Configure source auth before saving credentials");
1017 return;
1018 }
1019 if (selectedAuth.type === "basic") {
1020 const hasUsername = basicUsername.trim().length > 0;
1021 const hasPassword = basicPassword.trim().length > 0;
1022 if (!hasUsername && !hasPassword && keepExistingEncryptedSecret) {
1023 secretJson = {};
1024 } else if (!hasUsername || !hasPassword) {
1025 toast.error("Username and password are required for basic auth");
1026 return;
1027 } else {
1028 secretJson = {
1029 username: basicUsername,
1030 password: basicPassword,
1031 };
1032 }
1033 } else if (selectedAuth.type === "apiKey") {
1034 if (!apiKeyValue.trim()) {
1035 if (keepExistingEncryptedSecret) {
1036 secretJson = {};
1037 } else {
1038 toast.error("API key value is required");
1039 return;
1040 }
1041 } else {
1042 secretJson = { value: apiKeyValue.trim() };
1043 }
1044 } else {
1045 if (!managedToken.trim()) {
1046 if (keepExistingEncryptedSecret) {
1047 secretJson = {};
1048 } else {
1049 toast.error("Token is required");
1050 return;
1051 }
1052 } else {
1053 secretJson = { token: managedToken.trim() };
1054 }
1055 }
1056 }
1057
1058 if (provider === "workos-vault" && !editing && Object.keys(secretJson).length === 0) {
1059 if (selectedAuth.type === "basic") {
1060 toast.error("Username and password are required");
1061 } else if (selectedAuth.type === "apiKey") {
1062 toast.error("API key value is required");
1063 } else {
1064 toast.error("Token is required");
1065 }
1066 return;
1067 }
1068
1069 setSaving(true);
1070 try {
1071 await upsertCredential({
1072 workspaceId: context.workspaceId,
1073 sourceKey: sourceKey.trim(),
1074 scope,
1075 ...(scope === "actor" ? { actorId: actorId.trim() } : {}),
1076 provider,
1077 secretJson,
1078 });
1079
1080 toast.success(editing ? "Credential updated" : "Credential saved");
1081 setOpen(false);
1082 resetForm();
1083 } catch (err) {
1084 toast.error(err instanceof Error ? err.message : "Failed to save credential");
1085 } finally {
1086 setSaving(false);
1087 }
1088 };
1089
1090 return (
1091 <Card className="bg-card border-border">
1092 <CardHeader className="pb-3">
1093 <div className="flex items-center justify-between">
1094 <CardTitle className="text-sm font-medium flex items-center gap-2">
1095 <KeyRound className="h-4 w-4 text-muted-foreground" />
1096 Credentials
1097 </CardTitle>
1098 <Button size="sm" className="h-8 text-xs" onClick={openForCreate}>
1099 <Plus className="h-3.5 w-3.5 mr-1.5" />
1100 Add Credential
1101 </Button>
1102 </div>
1103 </CardHeader>
1104 <CardContent className="pt-0 space-y-3">
1105 {loading ? (
1106 <div className="space-y-2">
1107 {Array.from({ length: 3 }).map((_, i) => (
1108 <Skeleton key={i} className="h-14" />
1109 ))}
1110 </div>
1111 ) : credentials.length === 0 ? (
1112 <div className="flex flex-col items-center justify-center py-10 gap-2">
1113 <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
1114 <KeyRound className="h-5 w-5 text-muted-foreground/50" />
1115 </div>
1116 <p className="text-sm text-muted-foreground">No credentials configured</p>
1117 <p className="text-[11px] text-muted-foreground/70 text-center max-w-md">
1118 Configure source auth on an OpenAPI or GraphQL source, then add workspace or actor credentials.
1119 </p>
1120 </div>
1121 ) : (
1122 <div className="space-y-2">
1123 {credentials.map((credential) => (
1124 <div
1125 key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`}
1126 className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40"
1127 >
1128 <div className="flex-1 min-w-0">
1129 <div className="flex items-center gap-2 flex-wrap">
1130 <span className="text-sm font-mono font-medium">{credential.sourceKey}</span>
1131 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1132 {credential.scope}
1133 </Badge>
1134 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1135 {providerLabel(credential.provider === "workos-vault" ? "workos-vault" : "managed")}
1136 </Badge>
1137 {credential.scope === "actor" && credential.actorId && (
1138 <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
1139 {credential.actorId}
1140 </span>
1141 )}
1142 </div>
1143 <p className="text-[11px] text-muted-foreground mt-0.5">
1144 Updated {new Date(credential.updatedAt).toLocaleString()}
1145 </p>
1146 </div>
1147 <Button
1148 variant="outline"
1149 size="sm"
1150 className="h-7 text-[11px]"
1151 onClick={() => openForEdit(credential)}
1152 >
1153 Edit
1154 </Button>
1155 </div>
1156 ))}
1157 </div>
1158 )}
1159 </CardContent>
1160
1161 <Dialog open={open} onOpenChange={setOpen}>
1162 <DialogContent className="bg-card border-border sm:max-w-lg">
1163 <DialogHeader>
1164 <DialogTitle className="text-sm font-medium">
1165 {editing ? "Edit Credential" : "Add Credential"}
1166 </DialogTitle>
1167 </DialogHeader>
1168 <div className="space-y-3">
1169 <div className="space-y-1.5">
1170 <Label className="text-xs text-muted-foreground">Source Key</Label>
1171 {sourceOptions.length > 0 ? (
1172 <Select value={sourceKey} onValueChange={handleSourceKeyChange}>
1173 <SelectTrigger className="h-8 text-xs bg-background">
1174 <SelectValue />
1175 </SelectTrigger>
1176 <SelectContent>
1177 {sourceOptions.map((entry) => (
1178 <SelectItem key={entry.key} value={entry.key} className="text-xs font-mono">
1179 {entry.key}
1180 </SelectItem>
1181 ))}
1182 </SelectContent>
1183 </Select>
1184 ) : (
1185 <Input
1186 value={sourceKey}
1187 onChange={(e) => handleSourceKeyChange(e.target.value)}
1188 placeholder="openapi:github"
1189 className="h-8 text-xs font-mono bg-background"
1190 />
1191 )}
1192 </div>
1193
1194 <div className="flex items-center gap-2 flex-wrap">
1195 <span className="text-[11px] text-muted-foreground">Detected auth</span>
1196 <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider">
1197 {selectedAuthBadge}
1198 </Badge>
1199 {selectedAuth.type === "apiKey" && selectedAuth.header && (
1200 <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
1201 header: {selectedAuth.header}
1202 </span>
1203 )}
1204 </div>
1205
1206 <div className="grid grid-cols-2 gap-3">
1207 <div className="space-y-1.5">
1208 <Label className="text-xs text-muted-foreground">Scope</Label>
1209 <Select value={scope} onValueChange={(value) => setScope(value as CredentialScope)}>
1210 <SelectTrigger className="h-8 text-xs bg-background">
1211 <SelectValue />
1212 </SelectTrigger>
1213 <SelectContent>
1214 <SelectItem value="workspace" className="text-xs">Workspace</SelectItem>
1215 <SelectItem value="actor" className="text-xs">Per-user (actor)</SelectItem>
1216 </SelectContent>
1217 </Select>
1218 </div>
1219
1220 <div className="space-y-1.5">
1221 <Label className="text-xs text-muted-foreground">Provider</Label>
1222 <Select value={provider} onValueChange={(value) => handleProviderChange(value as "managed" | "workos-vault") }>
1223 <SelectTrigger className="h-8 text-xs bg-background">
1224 <SelectValue />
1225 </SelectTrigger>
1226 <SelectContent>
1227 <SelectItem value="managed" className="text-xs">Managed storage</SelectItem>
1228 <SelectItem value="workos-vault" className="text-xs">Encrypted storage</SelectItem>
1229 </SelectContent>
1230 </Select>
1231 </div>
1232 </div>
1233
1234 {scope === "actor" && (
1235 <div className="space-y-1.5">
1236 <Label className="text-xs text-muted-foreground">Actor ID</Label>
1237 <Input
1238 value={actorId}
1239 onChange={(e) => setActorId(e.target.value)}
1240 placeholder="actor_123"
1241 className="h-8 text-xs font-mono bg-background"
1242 />
1243 </div>
1244 )}
1245
1246 {provider === "workos-vault" && editing && (
1247 <p className="text-[11px] text-muted-foreground">
1248 Stored secret is hidden. Enter a new value below to rotate, or leave fields blank to keep existing.
1249 </p>
1250 )}
1251
1252 {selectedAuth.type === "none" ? (
1253 <p className="text-[11px] text-terminal-amber">
1254 This source has auth set to <code>none</code>. Configure source auth first.
1255 </p>
1256 ) : selectedAuth.type === "apiKey" ? (
1257 <div className="space-y-1.5">
1258 <Label className="text-xs text-muted-foreground">API Key Value</Label>
1259 <Input
1260 value={apiKeyValue}
1261 onChange={(e) => setApiKeyValue(e.target.value)}
1262 placeholder="sk_live_..."
1263 className="h-8 text-xs font-mono bg-background"
1264 />
1265 </div>
1266 ) : selectedAuth.type === "basic" ? (
1267 <div className="grid grid-cols-2 gap-3">
1268 <div className="space-y-1.5">
1269 <Label className="text-xs text-muted-foreground">Username</Label>
1270 <Input
1271 value={basicUsername}
1272 onChange={(e) => setBasicUsername(e.target.value)}
1273 className="h-8 text-xs font-mono bg-background"
1274 />
1275 </div>
1276 <div className="space-y-1.5">
1277 <Label className="text-xs text-muted-foreground">Password</Label>
1278 <Input
1279 type="password"
1280 value={basicPassword}
1281 onChange={(e) => setBasicPassword(e.target.value)}
1282 className="h-8 text-xs font-mono bg-background"
1283 />
1284 </div>
1285 </div>
1286 ) : (
1287 <div className="space-y-1.5">
1288 <Label className="text-xs text-muted-foreground">Bearer Token</Label>
1289 <Input
1290 type="password"
1291 value={managedToken}
1292 onChange={(e) => setManagedToken(e.target.value)}
1293 placeholder="ghp_..."
1294 className="h-8 text-xs font-mono bg-background"
1295 />
1296 </div>
1297 )}
1298
1299 <Collapsible open={advancedMode} onOpenChange={handleAdvancedModeChange}>
1300 <CollapsibleTrigger asChild>
1301 <Button variant="outline" size="sm" className="h-7 text-[11px]">
1302 Advanced JSON
1303 <ChevronRight className={cn("ml-1.5 h-3 w-3 transition-transform", advancedMode && "rotate-90")} />
1304 </Button>
1305 </CollapsibleTrigger>
1306 <CollapsibleContent className="mt-2 space-y-1.5">
1307 <Label className="text-xs text-muted-foreground">Override Secret JSON</Label>
1308 <Textarea
1309 value={secretJsonText}
1310 onChange={(e) => setSecretJsonText(e.target.value)}
1311 rows={6}
1312 className="text-xs font-mono bg-background"
1313 />
1314 </CollapsibleContent>
1315 </Collapsible>
1316
1317 <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm">
1318 {saving ? "Saving..." : editing ? "Update Credential" : "Save Credential"}
1319 </Button>
1320 </div>
1321 </DialogContent>
1322 </Dialog>
1323 </Card>
1324 );
1325}
1326
640// ── Tool Inventory ──1327// ── Tool Inventory ──
641 1328
642function ToolInventory({ tools }: { tools: ToolDescriptor[] }) {1329function ToolInventory({ tools }: { tools: ToolDescriptor[] }) {
@@ -711,6 +1398,12 @@ export function ToolsView() { · 68 unmodified lines
711 );1398 );
712 const sourcesLoading = !!context && sources === undefined;1399 const sourcesLoading = !!context && sources === undefined;
713 1400
1401 const credentials = useQuery(
1402 convexApi.database.listCredentials,
1403 context ? { workspaceId: context.workspaceId } : "skip",
1404 );
1405 const credentialsLoading = !!context && credentials === undefined;
1406
714 const { tools, warnings, loading: toolsLoading } = useWorkspaceTools(context ?? null);1407 const { tools, warnings, loading: toolsLoading } = useWorkspaceTools(context ?? null);
715 1408
716 if (sessionLoading) {1409 if (sessionLoading) {
@@ -726,7 +1419,7 @@ export function ToolsView() { · 9 unmodified lines
726 <div className="space-y-6">1419 <div className="space-y-6">
727 <PageHeader1420 <PageHeader
728 title="Tools"1421 title="Tools"
729 description="Manage tool sources and view available tools"1422 description="Manage sources, auth, credentials, and available tools"
730 />1423 />
731 1424
732 <Tabs defaultValue="sources" className="w-full">1425 <Tabs defaultValue="sources" className="w-full">
@@ -739,6 +1432,14 @@ export function ToolsView() { · 6 unmodified lines
739 </span>1432 </span>
740 )}1433 )}
741 </TabsTrigger>1434 </TabsTrigger>
1435 <TabsTrigger value="credentials" className="text-xs data-[state=active]:bg-background">
1436 Credentials
1437 {credentials && (
1438 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
1439 {credentials.length}
1440 </span>
1441 )}
1442 </TabsTrigger>
742 <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background">1443 <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background">
743 Inventory1444 Inventory
744 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">1445 <span className="ml-1.5 text-[10px] font-mono text-muted-foreground">
@@ -811,6 +1512,14 @@ export function ToolsView() { · 66 unmodified lines
811 </Card>1512 </Card>
812 </TabsContent>1513 </TabsContent>
813 1514
1515 <TabsContent value="credentials" className="mt-4">
1516 <CredentialsPanel
1517 sources={sources ?? []}
1518 credentials={credentials ?? []}
1519 loading={credentialsLoading || sourcesLoading}
1520 />
1521 </TabsContent>
1522
814 <TabsContent value="inventory" className="mt-4">1523 <TabsContent value="inventory" className="mt-4">
815 <Card className="bg-card border-border">1524 <Card className="bg-card border-border">
816 <CardHeader className="pb-3">1525 <CardHeader className="pb-3">
executor/apps/web/src/lib/types.ts+1−0
@@ -4,6 +4,7 @@ export type { · 3 unmodified lines
4 ApprovalStatus,4 ApprovalStatus,
5 PolicyDecision,5 PolicyDecision,
6 CredentialScope,6 CredentialScope,
7 CredentialProvider,
7 ToolApprovalMode,8 ToolApprovalMode,
8 ToolSourceType,9 ToolSourceType,
9 TaskRecord,10 TaskRecord,
executor/convex/_generated/api.d.ts+6−0
@@ -13,12 +13,14 @@ import type * as auth from "../auth.js"; · 12 unmodified lines
13import type * as billing from "../billing.js";13import type * as billing from "../billing.js";
14import type * as billingInternal from "../billingInternal.js";14import type * as billingInternal from "../billingInternal.js";
15import type * as billingSync from "../billingSync.js";15import type * as billingSync from "../billingSync.js";
16import type * as credentialsNode from "../credentialsNode.js";
16import type * as database from "../database.js";17import type * as database from "../database.js";
17import type * as executor from "../executor.js";18import type * as executor from "../executor.js";
18import type * as executorNode from "../executorNode.js";19import type * as executorNode from "../executorNode.js";
19import type * as http from "../http.js";20import type * as http from "../http.js";
20import type * as invites from "../invites.js";21import type * as invites from "../invites.js";
21import type * as lib_adapters_in_process_execution_adapter from "../lib/adapters/in_process_execution_adapter.js";22import type * as lib_adapters_in_process_execution_adapter from "../lib/adapters/in_process_execution_adapter.js";
23import type * as lib_credential_providers from "../lib/credential_providers.js";
22import type * as lib_events from "../lib/events.js";24import type * as lib_events from "../lib/events.js";
23import type * as lib_execution_constants from "../lib/execution_constants.js";25import type * as lib_execution_constants from "../lib/execution_constants.js";
24import type * as lib_functionBuilders from "../lib/functionBuilders.js";26import type * as lib_functionBuilders from "../lib/functionBuilders.js";
@@ -32,6 +34,7 @@ import type * as lib_tools from "../lib/tools.js"; · 7 unmodified lines
32import type * as lib_typechecker from "../lib/typechecker.js";34import type * as lib_typechecker from "../lib/typechecker.js";
33import type * as lib_types from "../lib/types.js";35import type * as lib_types from "../lib/types.js";
34import type * as lib_utils from "../lib/utils.js";36import type * as lib_utils from "../lib/utils.js";
37import type * as openApiSpecCache from "../openApiSpecCache.js";
35import type * as organizationMembers from "../organizationMembers.js";38import type * as organizationMembers from "../organizationMembers.js";
36import type * as organizations from "../organizations.js";39import type * as organizations from "../organizations.js";
37import type * as workspaces from "../workspaces.js";40import type * as workspaces from "../workspaces.js";
@@ -48,12 +51,14 @@ declare const fullApi: ApiFromModules<{ · 10 unmodified lines
48 billing: typeof billing;51 billing: typeof billing;
49 billingInternal: typeof billingInternal;52 billingInternal: typeof billingInternal;
50 billingSync: typeof billingSync;53 billingSync: typeof billingSync;
54 credentialsNode: typeof credentialsNode;
51 database: typeof database;55 database: typeof database;
52 executor: typeof executor;56 executor: typeof executor;
53 executorNode: typeof executorNode;57 executorNode: typeof executorNode;
54 http: typeof http;58 http: typeof http;
55 invites: typeof invites;59 invites: typeof invites;
56 "lib/adapters/in_process_execution_adapter": typeof lib_adapters_in_process_execution_adapter;60 "lib/adapters/in_process_execution_adapter": typeof lib_adapters_in_process_execution_adapter;
61 "lib/credential_providers": typeof lib_credential_providers;
57 "lib/events": typeof lib_events;62 "lib/events": typeof lib_events;
58 "lib/execution_constants": typeof lib_execution_constants;63 "lib/execution_constants": typeof lib_execution_constants;
59 "lib/functionBuilders": typeof lib_functionBuilders;64 "lib/functionBuilders": typeof lib_functionBuilders;
@@ -67,6 +72,7 @@ declare const fullApi: ApiFromModules<{ · 7 unmodified lines
67 "lib/typechecker": typeof lib_typechecker;72 "lib/typechecker": typeof lib_typechecker;
68 "lib/types": typeof lib_types;73 "lib/types": typeof lib_types;
69 "lib/utils": typeof lib_utils;74 "lib/utils": typeof lib_utils;
75 openApiSpecCache: typeof openApiSpecCache;
70 organizationMembers: typeof organizationMembers;76 organizationMembers: typeof organizationMembers;
71 organizations: typeof organizations;77 organizations: typeof organizations;
72 workspaces: typeof workspaces;78 workspaces: typeof workspaces;
executor/convex/credentialsNode.ts+212−0
@@ -0,0 +1,212 @@
1"use node";
2
3import { WorkOS } from "@workos-inc/node";
4import { v } from "convex/values";
5import { api } from "./_generated/api";
6import { action } from "./_generated/server";
7
8const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
9const credentialProviderValidator = v.union(v.literal("managed"), v.literal("workos-vault"));
10
11function asRecord(value: unknown): Record<string, unknown> {
12 if (value && typeof value === "object" && !Array.isArray(value)) {
13 return value as Record<string, unknown>;
14 }
15 return {};
16}
17
18function normalizedActorId(scope: "workspace" | "actor", actorId?: string): string {
19 if (scope !== "actor") return "";
20 if (typeof actorId !== "string") return "";
21 return actorId.trim();
22}
23
24function extractObjectId(secretJson: Record<string, unknown>): string | null {
25 const candidate =
26 (typeof secretJson.objectId === "string" ? secretJson.objectId : "") ||
27 (typeof secretJson.id === "string" ? secretJson.id : "");
28 const trimmed = candidate.trim();
29 return trimmed.length > 0 ? trimmed : null;
30}
31
32function stripObjectRef(secretJson: Record<string, unknown>): Record<string, unknown> {
33 const { objectId: _objectId, id: _id, ...rest } = secretJson;
34 return asRecord(rest);
35}
36
37function hasPayload(secretJson: Record<string, unknown>): boolean {
38 return Object.keys(stripObjectRef(secretJson)).length > 0;
39}
40
41function buildVaultObjectName(args: {
42 workspaceId: string;
43 sourceKey: string;
44 scope: "workspace" | "actor";
45 actorId: string;
46}): string {
47 const actorSegment = args.scope === "actor" ? args.actorId || "actor" : "workspace";
48 const sourceSegment = args.sourceKey
49 .replace(/[^a-zA-Z0-9_-]+/g, "-")
50 .replace(/^-+|-+$/g, "")
51 .slice(0, 64);
52
53 return `executor-cred-${args.workspaceId.slice(0, 24)}-${sourceSegment}-${actorSegment.slice(0, 24)}-${crypto.randomUUID().slice(0, 8)}`;
54}
55
56function workosClient(): WorkOS {
57 const key = process.env.WORKOS_API_KEY?.trim();
58 if (!key) {
59 throw new Error("Encrypted storage requires WORKOS_API_KEY");
60 }
61 return new WorkOS(key);
62}
63
64function isRetryableVaultError(error: unknown): boolean {
65 if (!(error instanceof Error)) {
66 return false;
67 }
68
69 const message = error.message.toLowerCase();
70 return (
71 message.includes("not yet ready") ||
72 message.includes("can be retried") ||
73 (message.includes("kek") && message.includes("ready"))
74 );
75}
76
77async function sleep(ms: number): Promise<void> {
78 await new Promise((resolve) => setTimeout(resolve, ms));
79}
80
81async function withVaultRetry<T>(operation: () => Promise<T>): Promise<T> {
82 const maxAttempts = 10;
83 const maxDelayMs = 10_000;
84 let delayMs = 500;
85
86 for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
87 try {
88 return await operation();
89 } catch (error) {
90 if (!isRetryableVaultError(error)) {
91 throw error;
92 }
93 if (attempt === maxAttempts) {
94 throw new Error(
95 "Encrypted storage is still initializing in WorkOS. Please wait about 60 seconds and retry.",
96 );
97 }
98 await sleep(delayMs);
99 delayMs = Math.min(delayMs * 2, maxDelayMs);
100 }
101 }
102
103 throw new Error("Unreachable retry state");
104}
105
106async function upsertVaultObject(args: {
107 workspaceId: string;
108 sourceKey: string;
109 scope: "workspace" | "actor";
110 actorId: string;
111 existingObjectId: string | null;
112 payload: Record<string, unknown>;
113}): Promise<string> {
114 const workos = workosClient();
115 const value = JSON.stringify(args.payload);
116
117 if (args.existingObjectId) {
118 const updated = await withVaultRetry(async () => {
119 return await workos.vault.updateObject({
120 id: args.existingObjectId,
121 value,
122 });
123 });
124 return updated.id;
125 }
126
127 const created = await withVaultRetry(async () => {
128 return await workos.vault.createObject({
129 name: buildVaultObjectName(args),
130 value,
131 context: {
132 workspace_id: args.workspaceId,
133 },
134 });
135 });
136
137 return created.id;
138}
139
140export const upsertCredential = action({
141 args: {
142 id: v.optional(v.string()),
143 workspaceId: v.string(),
144 sourceKey: v.string(),
145 scope: credentialScopeValidator,
146 actorId: v.optional(v.string()),
147 provider: v.optional(credentialProviderValidator),
148 secretJson: v.any(),
149 },
150 handler: async (ctx, args) => {
151 const provider = args.provider ?? "managed";
152 const actorId = normalizedActorId(args.scope, args.actorId);
153 const submittedSecret = asRecord(args.secretJson);
154
155 if (provider === "managed") {
156 return await ctx.runMutation(api.database.upsertCredential, {
157 ...args,
158 actorId,
159 provider,
160 secretJson: submittedSecret,
161 });
162 }
163
164 const submittedObjectId = extractObjectId(submittedSecret);
165 if (submittedObjectId && /^gh[opu]_/.test(submittedObjectId)) {
166 throw new Error("Encrypted storage value looks like a GitHub token. Paste the token in the token field.");
167 }
168
169 const existing = await ctx.runQuery(api.database.resolveCredential, {
170 workspaceId: args.workspaceId,
171 sourceKey: args.sourceKey,
172 scope: args.scope,
173 ...(args.scope === "actor" ? { actorId } : {}),
174 });
175 const existingObjectId = existing ? extractObjectId(asRecord(existing.secretJson)) : null;
176
177 const objectId = (() => {
178 if (submittedObjectId) return submittedObjectId;
179 return null;
180 })();
181
182 let finalObjectId = objectId;
183 if (!finalObjectId && hasPayload(submittedSecret)) {
184 finalObjectId = await upsertVaultObject({
185 workspaceId: args.workspaceId,
186 sourceKey: args.sourceKey,
187 scope: args.scope,
188 actorId,
189 existingObjectId,
190 payload: stripObjectRef(submittedSecret),
191 });
192 }
193
194 if (!finalObjectId && existingObjectId) {
195 finalObjectId = existingObjectId;
196 }
197
198 if (!finalObjectId) {
199 throw new Error("Encrypted storage requires credential values");
200 }
201
202 return await ctx.runMutation(api.database.upsertCredential, {
203 id: args.id,
204 workspaceId: args.workspaceId,
205 sourceKey: args.sourceKey,
206 scope: args.scope,
207 ...(args.scope === "actor" ? { actorId } : {}),
208 provider,
209 secretJson: { objectId: finalObjectId },
210 });
211 },
212});
executor/convex/database.test.ts+54−0
@@ -120,3 +120,57 @@ test("bootstrap honors caller-provided session id", async () => { · 119 unmodified lines
120 expect(again.workspaceId).toBe(seeded.workspaceId);120 expect(again.workspaceId).toBe(seeded.workspaceId);
121 expect(again.actorId).toBe(seeded.actorId);121 expect(again.actorId).toBe(seeded.actorId);
122});122});
123
124test("credentials persist provider and resolve by scope", async () => {
125 const t = setup();
126
127 const workspaceCredential = await t.mutation(api.database.upsertCredential, {
128 workspaceId: "ws_cred",
129 sourceKey: "openapi:github",
130 scope: "workspace",
131 provider: "managed",
132 secretJson: { token: "workspace-token" },
133 });
134
135 expect(workspaceCredential.provider).toBe("managed");
136
137 const actorCredential = await t.mutation(api.database.upsertCredential, {
138 workspaceId: "ws_cred",
139 sourceKey: "openapi:github",
140 scope: "actor",
141 actorId: "actor_cred",
142 provider: "workos-vault",
143 secretJson: { objectId: "secret_actor_github" },
144 });
145
146 expect(actorCredential.provider).toBe("workos-vault");
147 expect(actorCredential.actorId).toBe("actor_cred");
148
149 const resolvedWorkspace = await t.query(api.database.resolveCredential, {
150 workspaceId: "ws_cred",
151 sourceKey: "openapi:github",
152 scope: "workspace",
153 });
154 expect(resolvedWorkspace?.provider).toBe("managed");
155
156 const resolvedActor = await t.query(api.database.resolveCredential, {
157 workspaceId: "ws_cred",
158 sourceKey: "openapi:github",
159 scope: "actor",
160 actorId: "actor_cred",
161 });
162 expect(resolvedActor?.provider).toBe("workos-vault");
163});
164
165test("upsertCredential defaults provider to managed", async () => {
166 const t = setup();
167
168 const credential = await t.mutation(api.database.upsertCredential, {
169 workspaceId: "ws_default_provider",
170 sourceKey: "openapi:stripe",
171 scope: "workspace",
172 secretJson: { token: "sk_test_123" },
173 });
174
175 expect(credential.provider).toBe("managed");
176});
executor/convex/database.ts+28−0
@@ -17,6 +17,10 @@ const completedTaskStatusValidator = v.union( · 16 unmodified lines
17const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));17const approvalStatusValidator = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
18const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));18const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
19const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));19const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));
20const credentialProviderValidator = v.union(
21 v.literal("managed"),
22 v.literal("workos-vault"),
23);
20const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));24const toolSourceTypeValidator = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
21const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));25const agentTaskStatusValidator = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
22 26
@@ -159,6 +163,7 @@ function mapCredential(doc: Doc<"sourceCredentials">) { · 136 unmodified lines
159 sourceKey: doc.sourceKey,163 sourceKey: doc.sourceKey,
160 scope: doc.scope,164 scope: doc.scope,
161 actorId: optionalFromNormalized(doc.actorId),165 actorId: optionalFromNormalized(doc.actorId),
166 provider: doc.provider ?? "managed",
162 secretJson: asRecord(doc.secretJson),167 secretJson: asRecord(doc.secretJson),
163 createdAt: doc.createdAt,168 createdAt: doc.createdAt,
164 updatedAt: doc.updatedAt,169 updatedAt: doc.updatedAt,
@@ -856,6 +861,7 @@ export const upsertCredential = mutation({ · 691 unmodified lines
856 sourceKey: v.string(),861 sourceKey: v.string(),
857 scope: credentialScopeValidator,862 scope: credentialScopeValidator,
858 actorId: v.optional(v.string()),863 actorId: v.optional(v.string()),
864 provider: v.optional(credentialProviderValidator),
859 secretJson: v.any(),865 secretJson: v.any(),
860 },866 },
861 handler: async (ctx, args) => {867 handler: async (ctx, args) => {
@@ -873,8 +879,11 @@ export const upsertCredential = mutation({ · 11 unmodified lines
873 )879 )
874 .unique();880 .unique();
875 881
882 const provider = args.provider ?? existing?.provider ?? "managed";
883
876 if (existing) {884 if (existing) {
877 await ctx.db.patch(existing._id, {885 await ctx.db.patch(existing._id, {
886 provider,
878 secretJson: asRecord(args.secretJson),887 secretJson: asRecord(args.secretJson),
879 updatedAt: now,888 updatedAt: now,
880 });889 });
@@ -885,6 +894,7 @@ export const upsertCredential = mutation({ · 4 unmodified lines
885 sourceKey: args.sourceKey,894 sourceKey: args.sourceKey,
886 scope: args.scope,895 scope: args.scope,
887 actorId,896 actorId,
897 provider,
888 secretJson: asRecord(args.secretJson),898 secretJson: asRecord(args.secretJson),
889 createdAt: now,899 createdAt: now,
890 updatedAt: now,900 updatedAt: now,
@@ -922,6 +932,24 @@ export const listCredentials = query({ · 31 unmodified lines
922 },932 },
923});933});
924 934
935export const listCredentialProviders = query({
936 args: {},
937 handler: async () => {
938 return [
939 {
940 id: "managed",
941 label: "Managed",
942 description: "Store credential payload in Executor's sourceCredentials table.",
943 },
944 {
945 id: "workos-vault",
946 label: "Encrypted",
947 description: "Store credential payload in encrypted external storage.",
948 },
949 ] as const;
950 },
951});
952
925export const resolveCredential = query({953export const resolveCredential = query({
926 args: {954 args: {
927 workspaceId: v.string(),955 workspaceId: v.string(),
executor/convex/executorNode.ts+65−92
@@ -1,10 +1,10 @@
1"use node";1"use node";
2 2
3import { v } from "convex/values";3import { v } from "convex/values";
4import { ActionCache } from "@convex-dev/action-cache";4import { api, internal } from "./_generated/api";
5import { api, components, internal } from "./_generated/api";
6import { action, internalAction } from "./_generated/server";5import { action, internalAction } from "./_generated/server";
7import { InProcessExecutionAdapter } from "./lib/adapters/in_process_execution_adapter";6import { InProcessExecutionAdapter } from "./lib/adapters/in_process_execution_adapter";
7import { resolveCredentialPayload } from "./lib/credential_providers";
8import { APPROVAL_DENIED_PREFIX } from "./lib/execution_constants";8import { APPROVAL_DENIED_PREFIX } from "./lib/execution_constants";
9import { runCodeWithAdapter } from "./lib/runtimes/runtime_core";9import { runCodeWithAdapter } from "./lib/runtimes/runtime_core";
10import { createDiscoverTool } from "./lib/tool_discovery";10import { createDiscoverTool } from "./lib/tool_discovery";
@@ -135,23 +135,8 @@ const OPENAPI_SPEC_CACHE_TTL_MS = 5 * 60 * 60_000; · 124 unmodified lines
135const WORKSPACE_TOOL_CACHE_TTL_MS = 5 * 60 * 60_000;135const WORKSPACE_TOOL_CACHE_TTL_MS = 5 * 60 * 60_000;
136const WORKSPACE_TOOL_CACHE_RETRY_TTL_MS = 5 * 60 * 60_000;136const WORKSPACE_TOOL_CACHE_RETRY_TTL_MS = 5 * 60 * 60_000;
137 137
138const actionCacheComponent = components as unknown as { actionCache: unknown };138/** Cache version — bump when PreparedOpenApiSpec shape changes. */
139const openApiSpecCache = new ActionCache(actionCacheComponent.actionCache as never, {139const OPENAPI_CACHE_VERSION = "v2";
140 action: internal.executorNode.prepareOpenApiSpecForCache,
141 name: "openapi-spec-transform-v1",
142 ttl: OPENAPI_SPEC_CACHE_TTL_MS,
143});
144const MAX_ACTION_CACHE_VALUE_BYTES = 900_000;
145const OVERSIZED_OPENAPI_LOCAL_CACHE_TTL_MS = 5 * 60 * 60_000;
146const oversizedOpenApiSpecCache = new Map<
147 string,
148 { loadedAt: number; prepared: PreparedOpenApiSpec }
149>();
150
151interface PreparedOpenApiSpecCacheValue {
152 preparedJson: string;
153 oversized?: boolean;
154}
155 140
156function isWorkspaceToolCacheFresh(141function isWorkspaceToolCacheFresh(
157 cached: { signature: string; loadedAt: number; warnings: string[] },142 cached: { signature: string; loadedAt: number; warnings: string[] },
@@ -200,92 +185,78 @@ async function waitForApproval(ctx: any, approvalId: string): Promise<"approved" · 42 unmodified lines
200 }185 }
201}186}
202 187
203export const prepareOpenApiSpecForCache = internalAction({188/**
204 args: {189 * Load a prepared OpenAPI spec, using Convex file storage as a persistent cache.
205 specUrl: v.string(),190 *
206 },191 * Flow:
207 handler: async (_ctx, args): Promise<PreparedOpenApiSpecCacheValue> => {192 * 1. Check the `openApiSpecCache` table for a valid entry → fetch blob from storage.
208 const prepared = await prepareOpenApiSpec(args.specUrl, args.specUrl);193 * 2. On miss: prepare the spec from scratch, store the JSON blob, write the metadata row.
209 // Action cache values must be valid Convex values. Raw OpenAPI objects can194 *
210 // contain keys like "$ref", which Convex object fields disallow, so we195 * No size limits — Convex file storage handles multi-MB specs without issue.
211 // store the prepared payload as a JSON string.196 */
212 let preparedJson = JSON.stringify(prepared);197async function loadCachedOpenApiSpec(
213 198 ctx: any,
214 if (preparedJson.length > MAX_ACTION_CACHE_VALUE_BYTES) {199 specUrl: string,
215 const reduced: PreparedOpenApiSpec = {200 sourceName: string,
216 servers: prepared.servers,201): Promise<PreparedOpenApiSpec> {
217 paths: prepared.paths,202 // 1. Persistent cache (Convex table + file storage)
218 warnings: [203 try {
219 ...(prepared.warnings ?? []),204 const entry = await ctx.runQuery(internal.openApiSpecCache.getEntry, {
220 "OpenAPI type metadata omitted due cache size limits.",205 specUrl,
221 ],206 version: OPENAPI_CACHE_VERSION,
222 };207 maxAgeMs: OPENAPI_SPEC_CACHE_TTL_MS,
223 preparedJson = JSON.stringify(reduced);208 });
224 }
225 209
226 if (preparedJson.length > MAX_ACTION_CACHE_VALUE_BYTES) {210 if (entry) {
227 return { preparedJson: "", oversized: true };211 const blob = await ctx.storage.get(entry.storageId);
212 if (blob) {
213 const json = await blob.text();
214 return JSON.parse(json) as PreparedOpenApiSpec;
215 }
216 // Blob missing (deleted externally?) — fall through to re-prepare
228 }217 }
218 } catch (error) {
219 // Cache read failed — log and fall through to fresh preparation
220 const message = error instanceof Error ? error.message : String(error);
221 console.warn(`[executor] OpenAPI cache read failed for '${sourceName}': ${message}`);
222 }
229 223
230 return { preparedJson };224 // 2. Cache miss — prepare from scratch
231 },225 const prepared = await prepareOpenApiSpec(specUrl, sourceName);
232});226
227 // Store in file storage + metadata table (best-effort, don't block on failure)
228 try {
229 const json = JSON.stringify(prepared);
230 const blob = new Blob([json], { type: "application/json" });
231 const storageId = await ctx.storage.store(blob);
232 await ctx.runMutation(internal.openApiSpecCache.putEntry, {
233 specUrl,
234 version: OPENAPI_CACHE_VERSION,
235 storageId,
236 sizeBytes: json.length,
237 });
238 } catch (error) {
239 const message = error instanceof Error ? error.message : String(error);
240 console.warn(`[executor] OpenAPI cache write failed for '${sourceName}': ${message}`);
241 }
242
243 return prepared;
244}
233 245
234async function loadSourceTools(246async function loadSourceTools(
235 ctx: any,247 ctx: any,
236 source: ExternalToolSourceConfig,248 source: ExternalToolSourceConfig,
237): Promise<{ tools: ToolDefinition[]; warnings: string[] }> {249): Promise<{ tools: ToolDefinition[]; warnings: string[] }> {
238 const loadOpenApiDirect = async (extraWarnings: string[] = []) => {
239 const specUrl = source.spec;
240 const now = Date.now();
241 const cached = oversizedOpenApiSpecCache.get(specUrl);
242 const prepared = (cached && (now - cached.loadedAt) < OVERSIZED_OPENAPI_LOCAL_CACHE_TTL_MS)
243 ? cached.prepared
244 : await prepareOpenApiSpec(specUrl, source.name);
245
246 if (!cached || (now - cached.loadedAt) >= OVERSIZED_OPENAPI_LOCAL_CACHE_TTL_MS) {
247 oversizedOpenApiSpecCache.set(specUrl, { loadedAt: now, prepared });
248 }
249
250 const tools = buildOpenApiToolsFromPrepared(source, prepared);
251 return {
252 tools,
253 warnings: [
254 ...extraWarnings,
255 ...(prepared.warnings ?? []).map((warning) => `Source '${source.name}': ${warning}`),
256 ],
257 };
258 };
259
260 if (source.type === "openapi" && typeof source.spec === "string") {250 if (source.type === "openapi" && typeof source.spec === "string") {
261 try {251 try {
262 const cached = await openApiSpecCache.fetch(ctx, { specUrl: source.spec }) as PreparedOpenApiSpecCacheValue;252 const prepared = await loadCachedOpenApiSpec(ctx, source.spec, source.name);
263 if (cached.oversized || !cached.preparedJson) {
264 return await loadOpenApiDirect([
265 `Source '${source.name}': OpenAPI metadata is too large for global cache; using direct load.`,
266 ]);
267 }
268 const prepared = JSON.parse(cached.preparedJson) as PreparedOpenApiSpec;
269 const tools = buildOpenApiToolsFromPrepared(source, prepared);253 const tools = buildOpenApiToolsFromPrepared(source, prepared);
270 const warnings = (prepared.warnings ?? []).map((warning) => `Source '${source.name}': ${warning}`);254 const warnings = (prepared.warnings ?? []).map(
255 (warning) => `Source '${source.name}': ${warning}`,
256 );
271 return { tools, warnings };257 return { tools, warnings };
272 } catch (error) {258 } catch (error) {
273 const message = error instanceof Error ? error.message : String(error);259 const message = error instanceof Error ? error.message : String(error);
274
275 if (/Value is too large/i.test(message)) {
276 try {
277 return await loadOpenApiDirect([
278 `Source '${source.name}': OpenAPI metadata exceeded global cache limits; using direct load.`,
279 ]);
280 } catch (fallbackError) {
281 const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
282 return {
283 tools: [],
284 warnings: [`Failed to load openapi source '${source.name}': ${fallbackMessage}`],
285 };
286 }
287 }
288
289 return {260 return {
290 tools: [],261 tools: [],
291 warnings: [`Failed to load openapi source '${source.name}': ${message}`],262 warnings: [`Failed to load openapi source '${source.name}': ${message}`],
@@ -449,7 +420,9 @@ async function resolveCredentialHeaders( · 157 unmodified lines
449 actorId: task.actorId,420 actorId: task.actorId,
450 });421 });
451 422
452 const source = record?.secretJson ?? spec.staticSecretJson ?? null;423 const source = record
424 ? await resolveCredentialPayload(record)
425 : spec.staticSecretJson ?? null;
453 if (!source) {426 if (!source) {
454 return null;427 return null;
455 }428 }
executor/convex/lib/credential-providers.test.ts+65−0
@@ -0,0 +1,65 @@
1import { expect, test } from "bun:test";
2import { resolveCredentialPayload } from "./credential_providers";
3
4test("managed provider returns stored payload", async () => {
5 const payload = await resolveCredentialPayload({
6 provider: "managed",
7 secretJson: { token: "managed-token" },
8 });
9
10 expect(payload).toEqual({ token: "managed-token" });
11});
12
13test("WorkOS Vault provider reads object and parses JSON", async () => {
14 let capturedObjectId = "";
15 const payload = await resolveCredentialPayload(
16 {
17 provider: "workos-vault",
18 secretJson: { objectId: "secret_123" },
19 },
20 {
21 readVaultObject: async ({ objectId }) => {
22 capturedObjectId = objectId;
23 return '{"token":"vault-token","headerName":"x-api-key"}';
24 },
25 },
26 );
27
28 expect(capturedObjectId).toBe("secret_123");
29 expect(payload).toEqual({ token: "vault-token", headerName: "x-api-key" });
30});
31
32test("WorkOS Vault provider falls back to id key and raw token", async () => {
33 const payload = await resolveCredentialPayload(
34 {
35 provider: "workos-vault",
36 secretJson: { id: "secret_456" },
37 },
38 {
39 readVaultObject: async ({ objectId }) => {
40 expect(objectId).toBe("secret_456");
41 return "ghp_raw_token";
42 },
43 },
44 );
45
46 expect(payload).toEqual({ token: "ghp_raw_token" });
47});
48
49test("WorkOS Vault provider supports legacy inline payload", async () => {
50 const payload = await resolveCredentialPayload({
51 provider: "workos-vault",
52 secretJson: { token: "legacy-token" },
53 });
54
55 expect(payload).toEqual({ token: "legacy-token" });
56});
57
58test("WorkOS Vault provider gives actionable error on missing reference", async () => {
59 await expect(
60 resolveCredentialPayload({
61 provider: "workos-vault",
62 secretJson: {},
63 }),
64 ).rejects.toThrow("Re-save this credential");
65});
executor/convex/lib/credential_providers.ts+161−0
@@ -0,0 +1,161 @@
1"use node";
2
3import { WorkOS } from "@workos-inc/node";
4import type { CredentialProvider, CredentialRecord } from "./types";
5import { asRecord } from "./utils";
6
7type CredentialPayload = Record<string, unknown>;
8
9interface VaultReadInput {
10 objectId: string;
11 apiKey?: string;
12}
13
14type VaultObjectReader = (input: VaultReadInput) => Promise<string>;
15
16type ProviderResolver = (
17 record: Pick<CredentialRecord, "provider" | "secretJson">,
18 readVaultObject: VaultObjectReader,
19) => Promise<CredentialPayload | null>;
20
21function resolveWorkosApiKey(explicitApiKey?: string): string {
22 const candidate =
23 explicitApiKey?.trim() ||
24 process.env.WORKOS_API_KEY?.trim() ||
25 "";
26
27 if (!candidate) {
28 throw new Error("WorkOS Vault provider requires WORKOS_API_KEY");
29 }
30
31 return candidate;
32}
33
34function isRetryableVaultError(error: unknown): boolean {
35 if (!(error instanceof Error)) {
36 return false;
37 }
38 const message = error.message.toLowerCase();
39 return (
40 message.includes("not yet ready") ||
41 message.includes("can be retried") ||
42 (message.includes("kek") && message.includes("ready"))
43 );
44}
45
46async function sleep(ms: number): Promise<void> {
47 await new Promise((resolve) => setTimeout(resolve, ms));
48}
49
50async function withVaultRetry<T>(operation: () => Promise<T>): Promise<T> {
51 const maxAttempts = 5;
52 let delayMs = 250;
53
54 for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
55 try {
56 return await operation();
57 } catch (error) {
58 if (!isRetryableVaultError(error) || attempt === maxAttempts) {
59 throw error;
60 }
61 await sleep(delayMs);
62 delayMs *= 2;
63 }
64 }
65
66 throw new Error("Unreachable retry state");
67}
68
69async function defaultReadVaultObject(input: VaultReadInput): Promise<string> {
70 const workos = new WorkOS(resolveWorkosApiKey(input.apiKey));
71 const object = await withVaultRetry(async () => {
72 return await workos.vault.readObject({ id: input.objectId });
73 });
74 const value = typeof object.value === "string" ? object.value : "";
75 if (!value.trim()) {
76 throw new Error(`WorkOS Vault object '${input.objectId}' returned an empty value`);
77 }
78 return value;
79}
80
81function parseSecretValue(raw: string): CredentialPayload {
82 const value = raw.trim();
83 if (!value) {
84 return {};
85 }
86
87 try {
88 const parsed = JSON.parse(value);
89 const asObject = asRecord(parsed);
90 if (Object.keys(asObject).length > 0) {
91 return asObject;
92 }
93 } catch {
94 // not JSON; fall through
95 }
96
97 return { token: value };
98}
99
100function requireString(config: Record<string, unknown>, key: string, providerName: string): string {
101 const value = config[key];
102 if (typeof value !== "string" || value.trim().length === 0) {
103 throw new Error(`Credential provider '${providerName}' requires secretJson.${key}`);
104 }
105 return value.trim();
106}
107
108async function resolveManaged(
109 record: Pick<CredentialRecord, "secretJson">,
110): Promise<CredentialPayload | null> {
111 return asRecord(record.secretJson);
112}
113
114async function resolveWorkosVault(
115 record: Pick<CredentialRecord, "secretJson">,
116 readVaultObject: VaultObjectReader,
117): Promise<CredentialPayload | null> {
118 const config = asRecord(record.secretJson);
119 const objectId =
120 typeof config.objectId === "string" && config.objectId.trim()
121 ? config.objectId.trim()
122 : typeof config.id === "string" && config.id.trim()
123 ? config.id.trim()
124 : "";
125
126 if (!objectId) {
127 // Backward compatibility: older rows may have stored credential payload directly
128 // under the encrypted provider before object references were persisted.
129 const { objectId: _objectId, id: _id, apiKey: _apiKey, ...legacyPayload } = config;
130 const normalized = asRecord(legacyPayload);
131 if (Object.keys(normalized).length > 0) {
132 return normalized;
133 }
134
135 throw new Error(
136 "Encrypted credential is missing its secure reference. Re-save this credential in the dashboard.",
137 );
138 }
139 const apiKey = typeof config.apiKey === "string" && config.apiKey.trim() ? config.apiKey.trim() : undefined;
140
141 const raw = await readVaultObject({ objectId, apiKey });
142 return parseSecretValue(raw);
143}
144
145const providers: Record<CredentialProvider, ProviderResolver> = {
146 managed: resolveManaged,
147 "workos-vault": resolveWorkosVault,
148};
149
150export async function resolveCredentialPayload(
151 record: Pick<CredentialRecord, "provider" | "secretJson">,
152 options?: { readVaultObject?: VaultObjectReader },
153): Promise<CredentialPayload | null> {
154 const provider = (record.provider ?? "managed") as CredentialProvider;
155 const resolver = providers[provider];
156 if (!resolver) {
157 throw new Error(`Unsupported credential provider: ${provider}`);
158 }
159
160 return await resolver(record, options?.readVaultObject ?? defaultReadVaultObject);
161}
executor/convex/lib/types.ts+1−0
@@ -4,6 +4,7 @@ export type { · 3 unmodified lines
4 ApprovalStatus,4 ApprovalStatus,
5 PolicyDecision,5 PolicyDecision,
6 CredentialScope,6 CredentialScope,
7 CredentialProvider,
7 ToolApprovalMode,8 ToolApprovalMode,
8 ToolSourceType,9 ToolSourceType,
9 TaskRecord,10 TaskRecord,
executor/convex/schema.ts+14−0
@@ -37,6 +37,10 @@ const taskStatus = v.union( · 36 unmodified lines
37const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));37const approvalStatus = v.union(v.literal("pending"), v.literal("approved"), v.literal("denied"));
38const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));38const policyDecision = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
39const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));39const credentialScope = v.union(v.literal("workspace"), v.literal("actor"));
40const credentialProvider = v.union(
41 v.literal("managed"),
42 v.literal("workos-vault"),
43);
40const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));44const toolSourceType = v.union(v.literal("mcp"), v.literal("openapi"), v.literal("graphql"));
41const agentTaskStatus = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));45const agentTaskStatus = v.union(v.literal("running"), v.literal("completed"), v.literal("failed"));
42 46
@@ -244,6 +248,7 @@ export default defineSchema({ · 201 unmodified lines
244 sourceKey: v.string(),248 sourceKey: v.string(),
245 scope: credentialScope,249 scope: credentialScope,
246 actorId: v.string(),250 actorId: v.string(),
251 provider: v.optional(credentialProvider),
247 secretJson: v.any(),252 secretJson: v.any(),
248 createdAt: v.number(),253 createdAt: v.number(),
249 updatedAt: v.number(),254 updatedAt: v.number(),
@@ -284,6 +289,15 @@ export default defineSchema({ · 34 unmodified lines
284 .index("by_workspace_created", ["workspaceId", "createdAt"])289 .index("by_workspace_created", ["workspaceId", "createdAt"])
285 .index("by_requester_created", ["requesterId", "createdAt"]),290 .index("by_requester_created", ["requesterId", "createdAt"]),
286 291
292 openApiSpecCache: defineTable({
293 specUrl: v.string(),
294 storageId: v.id("_storage"),
295 version: v.string(),
296 sizeBytes: v.number(),
297 createdAt: v.number(),
298 })
299 .index("by_spec_url_version", ["specUrl", "version"]),
300
287 anonymousSessions: defineTable({301 anonymousSessions: defineTable({
288 sessionId: v.string(),302 sessionId: v.string(),
289 workspaceId: v.id("workspaces"),303 workspaceId: v.id("workspaces"),
executor/packages/contracts/src/index.ts+3−0
@@ -14,6 +14,8 @@ export type PolicyDecision = "allow" | "require_approval" | "deny"; · 13 unmodified lines
14 14
15export type CredentialScope = "workspace" | "actor";15export type CredentialScope = "workspace" | "actor";
16 16
17export type CredentialProvider = "managed" | "workos-vault";
18
17export type ToolApprovalMode = "auto" | "required";19export type ToolApprovalMode = "auto" | "required";
18 20
19export type ToolSourceType = "mcp" | "openapi" | "graphql";21export type ToolSourceType = "mcp" | "openapi" | "graphql";
@@ -83,6 +85,7 @@ export interface CredentialRecord { · 63 unmodified lines
83 sourceKey: string;85 sourceKey: string;
84 scope: CredentialScope;86 scope: CredentialScope;
85 actorId?: string;87 actorId?: string;
88 provider: CredentialProvider;
86 secretJson: Record<string, unknown>;89 secretJson: Record<string, unknown>;
87 createdAt: number;90 createdAt: number;
88 updatedAt: number;91 updatedAt: number;