Unified · Split
| @@ -1,10 +1,11 @@ | ||
| 1 | −import { useState, type ReactNode } from "react"; | |
| 1 | +import { useEffect, useState, type ReactNode } from "react"; | |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { |
| 4 | 4 | type CompleteSourceOAuthResult, |
| 5 | 5 | type ConnectSourcePayload, |
| 6 | 6 | type ConnectSourceResult, |
| 7 | 7 | type DiscoverSourcePayload, |
| 8 | + type InstanceConfig, | |
| 8 | 9 | type Loadable, |
| 9 | 10 | type SecretListItem, |
| 10 | 11 | type Source, |
| @@ -12,6 +13,7 @@ import { · 1 unmodified line | ||
| 12 | 13 | useConnectSource, |
| 13 | 14 | useCreateSecret, |
| 14 | 15 | useDiscoverSource, |
| 16 | + useInstanceConfig, | |
| 15 | 17 | useRefreshSecrets, |
| 16 | 18 | useSecrets, |
| 17 | 19 | useStartSourceOAuth, |
| @@ -590,6 +592,7 @@ export function AddSourcePage() { · 572 unmodified lines | ||
| 590 | 592 | const navigate = useNavigate(); |
| 591 | 593 | const discoverSource = useDiscoverSource(); |
| 592 | 594 | const connectSource = useConnectSource(); |
| 595 | + const instanceConfig = useInstanceConfig(); | |
| 593 | 596 | const secrets = useSecrets(); |
| 594 | 597 | const refreshSecrets = useRefreshSecrets(); |
| 595 | 598 | const startSourceOAuth = useStartSourceOAuth(); |
| @@ -1209,6 +1212,7 @@ export function AddSourcePage() { · 613 unmodified lines | ||
| 1209 | 1212 | {connectForm.authKind === "bearer" && ( |
| 1210 | 1213 | <Field label="Token" className="sm:col-span-2"> |
| 1211 | 1214 | <SecretOrTokenInput |
| 1215 | + instanceConfig={instanceConfig} | |
| 1212 | 1216 | secrets={secrets} |
| 1213 | 1217 | providerId={connectForm.bearerProviderId} |
| 1214 | 1218 | handle={connectForm.bearerHandle} |
| @@ -1408,6 +1412,7 @@ const CREATE_NEW_VALUE = "__create_new__"; · 193 unmodified lines | ||
| 1408 | 1412 | const INLINE_TOKEN_VALUE = "__inline_token__"; |
| 1409 | 1413 | |
| 1410 | 1414 | function SecretOrTokenInput(props: { |
| 1415 | + instanceConfig: Loadable<InstanceConfig>; | |
| 1411 | 1416 | secrets: Loadable<ReadonlyArray<SecretListItem>>; |
| 1412 | 1417 | providerId: string; |
| 1413 | 1418 | handle: string; |
| @@ -1415,13 +1420,35 @@ function SecretOrTokenInput(props: { · 1 unmodified line | ||
| 1415 | 1420 | onSelectSecret: (providerId: string, handle: string) => void; |
| 1416 | 1421 | onChangeToken: (token: string) => void; |
| 1417 | 1422 | }) { |
| 1418 | − const { secrets, providerId, handle, inlineToken, onSelectSecret, onChangeToken } = props; | |
| 1423 | + const { | |
| 1424 | + instanceConfig, | |
| 1425 | + secrets, | |
| 1426 | + providerId, | |
| 1427 | + handle, | |
| 1428 | + inlineToken, | |
| 1429 | + onSelectSecret, | |
| 1430 | + onChangeToken, | |
| 1431 | + } = props; | |
| 1419 | 1432 | const createSecret = useCreateSecret(); |
| 1420 | 1433 | const [showCreate, setShowCreate] = useState(false); |
| 1421 | 1434 | const [useInline, setUseInline] = useState(false); |
| 1422 | 1435 | const [newName, setNewName] = useState(""); |
| 1423 | 1436 | const [newValue, setNewValue] = useState(""); |
| 1437 | + const [newProviderId, setNewProviderId] = useState(""); | |
| 1424 | 1438 | const [createError, setCreateError] = useState<string | null>(null); |
| 1439 | + const storableProviders = | |
| 1440 | + instanceConfig.status === "ready" | |
| 1441 | + ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore) | |
| 1442 | + : []; | |
| 1443 | + | |
| 1444 | + useEffect(() => { | |
| 1445 | + if (newProviderId.length > 0) { | |
| 1446 | + return; | |
| 1447 | + } | |
| 1448 | + if (instanceConfig.status === "ready") { | |
| 1449 | + setNewProviderId(instanceConfig.data.defaultSecretStoreProvider); | |
| 1450 | + } | |
| 1451 | + }, [instanceConfig, newProviderId]); | |
| 1425 | 1452 | |
| 1426 | 1453 | const selectedValue = showCreate ? CREATE_NEW_VALUE : useInline ? INLINE_TOKEN_VALUE : (handle || ""); |
| 1427 | 1454 | |
| @@ -1431,6 +1458,11 @@ function SecretOrTokenInput(props: { · 3 unmodified lines | ||
| 1431 | 1458 | setUseInline(false); |
| 1432 | 1459 | setNewName(""); |
| 1433 | 1460 | setNewValue(""); |
| 1461 | + setNewProviderId( | |
| 1462 | + instanceConfig.status === "ready" | |
| 1463 | + ? instanceConfig.data.defaultSecretStoreProvider | |
| 1464 | + : "", | |
| 1465 | + ); | |
| 1434 | 1466 | setCreateError(null); |
| 1435 | 1467 | return; |
| 1436 | 1468 | } |
| @@ -1448,7 +1480,10 @@ function SecretOrTokenInput(props: { · 11 unmodified lines | ||
| 1448 | 1480 | } |
| 1449 | 1481 | setUseInline(false); |
| 1450 | 1482 | setShowCreate(false); |
| 1451 | − onSelectSecret("postgres", value); | |
| 1483 | + const matchedSecret = secrets.status === "ready" | |
| 1484 | + ? secrets.data.find((secret) => secret.id === value) | |
| 1485 | + : null; | |
| 1486 | + onSelectSecret(matchedSecret?.providerId ?? "local", value); | |
| 1452 | 1487 | }; |
| 1453 | 1488 | |
| 1454 | 1489 | const handleCreate = async () => { |
| @@ -1464,7 +1499,11 @@ function SecretOrTokenInput(props: { · 9 unmodified lines | ||
| 1464 | 1499 | } |
| 1465 | 1500 | |
| 1466 | 1501 | try { |
| 1467 | − const result = await createSecret.mutateAsync({ name: trimmedName, value: newValue }); | |
| 1502 | + const result = await createSecret.mutateAsync({ | |
| 1503 | + name: trimmedName, | |
| 1504 | + value: newValue, | |
| 1505 | + ...(newProviderId ? { providerId: newProviderId } : {}), | |
| 1506 | + }); | |
| 1468 | 1507 | onSelectSecret(result.providerId, result.id); |
| 1469 | 1508 | setShowCreate(false); |
| 1470 | 1509 | } catch (err) { |
| @@ -1495,7 +1534,7 @@ function SecretOrTokenInput(props: { · 24 unmodified lines | ||
| 1495 | 1534 | <option value="">Select a secret...</option> |
| 1496 | 1535 | {items.map((secret) => ( |
| 1497 | 1536 | <option key={secret.id} value={secret.id}> |
| 1498 | − {secret.name || secret.id} | |
| 1537 | + {secret.name || secret.id} ({secret.providerId}) | |
| 1499 | 1538 | </option> |
| 1500 | 1539 | ))} |
| 1501 | 1540 | <option value={INLINE_TOKEN_VALUE}>Paste token directly</option> |
| @@ -1541,6 +1580,20 @@ function SecretOrTokenInput(props: { · 39 unmodified lines | ||
| 1541 | 1580 | className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 1542 | 1581 | /> |
| 1543 | 1582 | </label> |
| 1583 | + <label className="block space-y-1"> | |
| 1584 | + <span className="text-[11px] font-medium text-muted-foreground">Store in</span> | |
| 1585 | + <select | |
| 1586 | + value={newProviderId} | |
| 1587 | + onChange={(e) => setNewProviderId(e.target.value)} | |
| 1588 | + className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1589 | + > | |
| 1590 | + {storableProviders.map((provider) => ( | |
| 1591 | + <option key={provider.id} value={provider.id}> | |
| 1592 | + {provider.name} | |
| 1593 | + </option> | |
| 1594 | + ))} | |
| 1595 | + </select> | |
| 1596 | + </label> | |
| 1544 | 1597 | </div> |
| 1545 | 1598 | <div className="flex items-center justify-end gap-2"> |
| 1546 | 1599 | <button |
| @@ -1,13 +1,14 @@ | ||
| 1 | −import { useState, type ReactNode } from "react"; | |
| 1 | +import { useEffect, useState, type ReactNode } from "react"; | |
| 2 | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | 3 | import { |
| 4 | + type InstanceConfig, | |
| 4 | 5 | type SecretListItem, |
| 5 | 6 | type Loadable, |
| 6 | 7 | useSecrets, |
| 7 | 8 | useCreateSecret, |
| 8 | 9 | useUpdateSecret, |
| 9 | 10 | useDeleteSecret, |
| 10 | − useRefreshSecrets, | |
| 11 | + useInstanceConfig, | |
| 11 | 12 | } from "@executor/react"; |
| 12 | 13 | import { LoadableBlock } from "../components/loadable"; |
| 13 | 14 | import { Button } from "../components/ui/button"; |
| @@ -19,10 +20,26 @@ import { cn } from "../lib/utils"; · 5 unmodified lines | ||
| 19 | 20 | // Page |
| 20 | 21 | // --------------------------------------------------------------------------- |
| 21 | 22 | |
| 23 | +const storableProvidersFromConfig = (config: Loadable<InstanceConfig>) => | |
| 24 | + config.status === "ready" | |
| 25 | + ? config.data.secretProviders.filter((provider) => provider.canStore) | |
| 26 | + : []; | |
| 27 | + | |
| 28 | +const providerLabelForId = (config: Loadable<InstanceConfig>, providerId: string): string => | |
| 29 | + config.status === "ready" | |
| 30 | + ? config.data.secretProviders.find((provider) => provider.id === providerId)?.name ?? providerId | |
| 31 | + : providerId; | |
| 32 | + | |
| 22 | 33 | export function SecretsPage() { |
| 34 | + const instanceConfig = useInstanceConfig(); | |
| 23 | 35 | const secrets = useSecrets(); |
| 24 | 36 | const [showCreate, setShowCreate] = useState(false); |
| 25 | 37 | const [editingId, setEditingId] = useState<string | null>(null); |
| 38 | + const storableProviders = storableProvidersFromConfig(instanceConfig); | |
| 39 | + const defaultProviderLabel = | |
| 40 | + instanceConfig.status === "ready" | |
| 41 | + ? providerLabelForId(instanceConfig, instanceConfig.data.defaultSecretStoreProvider) | |
| 42 | + : null; | |
| 26 | 43 | |
| 27 | 44 | return ( |
| 28 | 45 | <div className="flex-1 overflow-y-auto"> |
| @@ -34,7 +51,8 @@ export function SecretsPage() { · 5 unmodified lines | ||
| 34 | 51 | Secrets |
| 35 | 52 | </h1> |
| 36 | 53 | <p className="mt-1.5 text-[14px] text-muted-foreground"> |
| 37 | − Tokens and credentials stored in this instance. | |
| 54 | + Tokens and credentials stored by executor. | |
| 55 | + {defaultProviderLabel ? ` New secrets default to ${defaultProviderLabel}.` : ""} | |
| 38 | 56 | </p> |
| 39 | 57 | </div> |
| 40 | 58 | <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}> |
| @@ -48,6 +66,12 @@ export function SecretsPage() { · 7 unmodified lines | ||
| 48 | 66 | <CreateSecretForm |
| 49 | 67 | onClose={() => setShowCreate(false)} |
| 50 | 68 | className="mb-6" |
| 69 | + providerOptions={storableProviders} | |
| 70 | + defaultProviderId={ | |
| 71 | + instanceConfig.status === "ready" | |
| 72 | + ? instanceConfig.data.defaultSecretStoreProvider | |
| 73 | + : null | |
| 74 | + } | |
| 51 | 75 | /> |
| 52 | 76 | )} |
| 53 | 77 | |
| @@ -62,6 +86,7 @@ export function SecretsPage() { · 8 unmodified lines | ||
| 62 | 86 | <SecretRow |
| 63 | 87 | key={secret.id} |
| 64 | 88 | secret={secret} |
| 89 | + providerLabel={providerLabelForId(instanceConfig, secret.providerId)} | |
| 65 | 90 | isEditing={editingId === secret.id} |
| 66 | 91 | onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)} |
| 67 | 92 | onCancelEdit={() => setEditingId(null)} |
| @@ -95,7 +120,7 @@ function EmptyState(props: { onAdd: () => void }) { · 27 unmodified lines | ||
| 95 | 120 | No secrets stored |
| 96 | 121 | </p> |
| 97 | 122 | <p className="text-[13px] text-muted-foreground/60 mb-5"> |
| 98 | − Secrets let you securely provide tokens for source authentication. | |
| 123 | + Secrets let you securely provide tokens for source authentication without putting them in workspace config. | |
| 99 | 124 | </p> |
| 100 | 125 | <Button size="sm" onClick={props.onAdd}> |
| 101 | 126 | <IconPlus className="size-3.5" /> |
| @@ -109,12 +134,27 @@ function EmptyState(props: { onAdd: () => void }) { · 7 unmodified lines | ||
| 109 | 134 | // Create form |
| 110 | 135 | // --------------------------------------------------------------------------- |
| 111 | 136 | |
| 112 | −function CreateSecretForm(props: { onClose: () => void; className?: string }) { | |
| 137 | +function CreateSecretForm(props: { | |
| 138 | + onClose: () => void; | |
| 139 | + className?: string; | |
| 140 | + providerOptions: ReadonlyArray<{ id: string; name: string }>; | |
| 141 | + defaultProviderId: string | null; | |
| 142 | +}) { | |
| 113 | 143 | const createSecret = useCreateSecret(); |
| 114 | 144 | const [name, setName] = useState(""); |
| 115 | 145 | const [value, setValue] = useState(""); |
| 146 | + const [providerId, setProviderId] = useState(props.defaultProviderId ?? ""); | |
| 116 | 147 | const [error, setError] = useState<string | null>(null); |
| 117 | 148 | |
| 149 | + useEffect(() => { | |
| 150 | + if (providerId.length > 0) { | |
| 151 | + return; | |
| 152 | + } | |
| 153 | + if (props.defaultProviderId) { | |
| 154 | + setProviderId(props.defaultProviderId); | |
| 155 | + } | |
| 156 | + }, [props.defaultProviderId, providerId]); | |
| 157 | + | |
| 118 | 158 | const handleSubmit = async () => { |
| 119 | 159 | setError(null); |
| 120 | 160 | const trimmedName = name.trim(); |
| @@ -128,7 +168,11 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 7 unmodified lines | ||
| 128 | 168 | } |
| 129 | 169 | |
| 130 | 170 | try { |
| 131 | − await createSecret.mutateAsync({ name: trimmedName, value }); | |
| 171 | + await createSecret.mutateAsync({ | |
| 172 | + name: trimmedName, | |
| 173 | + value, | |
| 174 | + ...(providerId ? { providerId } : {}), | |
| 175 | + }); | |
| 132 | 176 | props.onClose(); |
| 133 | 177 | } catch (err) { |
| 134 | 178 | setError(err instanceof Error ? err.message : "Failed creating secret."); |
| @@ -172,6 +216,19 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 37 unmodified lines | ||
| 172 | 216 | className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 173 | 217 | /> |
| 174 | 218 | </FieldLabel> |
| 219 | + <FieldLabel label="Store in"> | |
| 220 | + <select | |
| 221 | + value={providerId} | |
| 222 | + onChange={(e) => setProviderId(e.target.value)} | |
| 223 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 224 | + > | |
| 225 | + {props.providerOptions.map((provider) => ( | |
| 226 | + <option key={provider.id} value={provider.id}> | |
| 227 | + {provider.name} | |
| 228 | + </option> | |
| 229 | + ))} | |
| 230 | + </select> | |
| 231 | + </FieldLabel> | |
| 175 | 232 | </div> |
| 176 | 233 | <div className="flex justify-end"> |
| 177 | 234 | <Button |
| @@ -194,11 +251,12 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 16 unmodified lines | ||
| 194 | 251 | |
| 195 | 252 | function SecretRow(props: { |
| 196 | 253 | secret: SecretListItem; |
| 254 | + providerLabel: string; | |
| 197 | 255 | isEditing: boolean; |
| 198 | 256 | onEdit: () => void; |
| 199 | 257 | onCancelEdit: () => void; |
| 200 | 258 | }) { |
| 201 | − const { secret, isEditing } = props; | |
| 259 | + const { secret, providerLabel, isEditing } = props; | |
| 202 | 260 | const deleteSecret = useDeleteSecret(); |
| 203 | 261 | const [isDeleting, setIsDeleting] = useState(false); |
| 204 | 262 | |
| @@ -239,6 +297,9 @@ function SecretRow(props: { · 34 unmodified lines | ||
| 239 | 297 | <Badge variant="outline" className="text-[9px] shrink-0"> |
| 240 | 298 | {secret.purpose.replace(/_/g, " ")} |
| 241 | 299 | </Badge> |
| 300 | + <Badge variant="outline" className="text-[9px] shrink-0"> | |
| 301 | + {providerLabel} | |
| 302 | + </Badge> | |
| 242 | 303 | </div> |
| 243 | 304 | <div className="flex items-center gap-2 mt-0.5"> |
| 244 | 305 | <span className="text-[11px] font-mono text-muted-foreground/50 truncate"> |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useState, type ReactNode } from "react"; | |
| 1 | +import { useEffect, useState, type ReactNode } from "react"; | |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { |
| 4 | 4 | type CompleteSourceOAuthResult, |
| @@ -1012,6 +1012,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 1007 unmodified lines | ||
| 1012 | 1012 | {formState.authKind === "bearer" && ( |
| 1013 | 1013 | <Field label="Token" className="sm:col-span-2"> |
| 1014 | 1014 | <SecretPicker |
| 1015 | + instanceConfig={instanceConfig} | |
| 1015 | 1016 | secrets={secrets} |
| 1016 | 1017 | providerId={formState.bearerProviderId} |
| 1017 | 1018 | handle={formState.bearerHandle} |
| @@ -1030,6 +1031,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines | ||
| 1030 | 1031 | <> |
| 1031 | 1032 | <Field label="Access token" className="sm:col-span-2"> |
| 1032 | 1033 | <SecretPicker |
| 1034 | + instanceConfig={instanceConfig} | |
| 1033 | 1035 | secrets={secrets} |
| 1034 | 1036 | providerId={formState.oauthAccessProviderId} |
| 1035 | 1037 | handle={formState.oauthAccessHandle} |
| @@ -1041,6 +1043,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines | ||
| 1041 | 1043 | </Field> |
| 1042 | 1044 | <Field label="Refresh token (optional)" className="sm:col-span-2"> |
| 1043 | 1045 | <SecretPicker |
| 1046 | + instanceConfig={instanceConfig} | |
| 1044 | 1047 | secrets={secrets} |
| 1045 | 1048 | providerId={formState.oauthRefreshProviderId} |
| 1046 | 1049 | handle={formState.oauthRefreshHandle} |
| @@ -1218,18 +1221,33 @@ function StatusBanner(props: { state: StatusBannerState; className?: string }) { · 171 unmodified lines | ||
| 1218 | 1221 | const CREATE_NEW_VALUE = "__create_new__"; |
| 1219 | 1222 | |
| 1220 | 1223 | function SecretPicker(props: { |
| 1224 | + instanceConfig: Loadable<InstanceConfig>; | |
| 1221 | 1225 | secrets: Loadable<ReadonlyArray<SecretListItem>>; |
| 1222 | 1226 | providerId: string; |
| 1223 | 1227 | handle: string; |
| 1224 | 1228 | onSelect: (providerId: string, handle: string) => void; |
| 1225 | 1229 | allowEmpty?: boolean; |
| 1226 | 1230 | }) { |
| 1227 | − const { secrets, providerId, handle, onSelect, allowEmpty } = props; | |
| 1231 | + const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } = props; | |
| 1228 | 1232 | const createSecret = useCreateSecret(); |
| 1229 | 1233 | const [showCreate, setShowCreate] = useState(false); |
| 1230 | 1234 | const [newName, setNewName] = useState(""); |
| 1231 | 1235 | const [newValue, setNewValue] = useState(""); |
| 1236 | + const [newProviderId, setNewProviderId] = useState(""); | |
| 1232 | 1237 | const [createError, setCreateError] = useState<string | null>(null); |
| 1238 | + const storableProviders = | |
| 1239 | + instanceConfig.status === "ready" | |
| 1240 | + ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore) | |
| 1241 | + : []; | |
| 1242 | + | |
| 1243 | + useEffect(() => { | |
| 1244 | + if (newProviderId.length > 0) { | |
| 1245 | + return; | |
| 1246 | + } | |
| 1247 | + if (instanceConfig.status === "ready") { | |
| 1248 | + setNewProviderId(instanceConfig.data.defaultSecretStoreProvider); | |
| 1249 | + } | |
| 1250 | + }, [instanceConfig, newProviderId]); | |
| 1233 | 1251 | |
| 1234 | 1252 | // Build the selected value key: if handle is set, use it (it's the secret ID) |
| 1235 | 1253 | const selectedValue = handle || ""; |
| @@ -1239,6 +1257,11 @@ function SecretPicker(props: { · 3 unmodified lines | ||
| 1239 | 1257 | setShowCreate(true); |
| 1240 | 1258 | setNewName(""); |
| 1241 | 1259 | setNewValue(""); |
| 1260 | + setNewProviderId( | |
| 1261 | + instanceConfig.status === "ready" | |
| 1262 | + ? instanceConfig.data.defaultSecretStoreProvider | |
| 1263 | + : "", | |
| 1264 | + ); | |
| 1242 | 1265 | setCreateError(null); |
| 1243 | 1266 | return; |
| 1244 | 1267 | } |
| @@ -1246,8 +1269,10 @@ function SecretPicker(props: { · 1 unmodified line | ||
| 1246 | 1269 | onSelect("", ""); |
| 1247 | 1270 | return; |
| 1248 | 1271 | } |
| 1249 | − // Selecting an existing secret: providerId is always "postgres" | |
| 1250 | − onSelect("postgres", value); | |
| 1272 | + const matchedSecret = secrets.status === "ready" | |
| 1273 | + ? secrets.data.find((secret) => secret.id === value) | |
| 1274 | + : null; | |
| 1275 | + onSelect(matchedSecret?.providerId ?? "local", value); | |
| 1251 | 1276 | }; |
| 1252 | 1277 | |
| 1253 | 1278 | const handleCreate = async () => { |
| @@ -1263,7 +1288,11 @@ function SecretPicker(props: { · 9 unmodified lines | ||
| 1263 | 1288 | } |
| 1264 | 1289 | |
| 1265 | 1290 | try { |
| 1266 | − const result = await createSecret.mutateAsync({ name: trimmedName, value: newValue }); | |
| 1291 | + const result = await createSecret.mutateAsync({ | |
| 1292 | + name: trimmedName, | |
| 1293 | + value: newValue, | |
| 1294 | + ...(newProviderId ? { providerId: newProviderId } : {}), | |
| 1295 | + }); | |
| 1267 | 1296 | onSelect(result.providerId, result.id); |
| 1268 | 1297 | setShowCreate(false); |
| 1269 | 1298 | } catch (err) { |
| @@ -1286,7 +1315,7 @@ function SecretPicker(props: { · 16 unmodified lines | ||
| 1286 | 1315 | |
| 1287 | 1316 | // Check if the current handle matches a known secret |
| 1288 | 1317 | const matchedSecret = items.find((s) => s.id === handle); |
| 1289 | − const isExternalRef = handle && !matchedSecret && providerId !== "postgres"; | |
| 1318 | + const isExternalRef = handle && !matchedSecret && providerId !== "local"; | |
| 1290 | 1319 | |
| 1291 | 1320 | return ( |
| 1292 | 1321 | <div className="space-y-2"> |
| @@ -1299,7 +1328,7 @@ function SecretPicker(props: { · 6 unmodified lines | ||
| 1299 | 1328 | {!allowEmpty && !selectedValue && <option value="">Select a secret…</option>} |
| 1300 | 1329 | {items.map((secret) => ( |
| 1301 | 1330 | <option key={secret.id} value={secret.id}> |
| 1302 | − {secret.name || secret.id} | |
| 1331 | + {secret.name || secret.id} ({secret.providerId}) | |
| 1303 | 1332 | </option> |
| 1304 | 1333 | ))} |
| 1305 | 1334 | {isExternalRef && ( |
| @@ -1338,6 +1367,20 @@ function SecretPicker(props: { · 32 unmodified lines | ||
| 1338 | 1367 | className="h-8 w-full rounded-lg border border-input bg-background px-3 font-mono text-[11px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 1339 | 1368 | /> |
| 1340 | 1369 | </label> |
| 1370 | + <label className="block space-y-1"> | |
| 1371 | + <span className="text-[11px] font-medium text-muted-foreground">Store in</span> | |
| 1372 | + <select | |
| 1373 | + value={newProviderId} | |
| 1374 | + onChange={(e) => setNewProviderId(e.target.value)} | |
| 1375 | + className="h-8 w-full rounded-lg border border-input bg-background px-3 text-[12px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 1376 | + > | |
| 1377 | + {storableProviders.map((provider) => ( | |
| 1378 | + <option key={provider.id} value={provider.id}> | |
| 1379 | + {provider.name} | |
| 1380 | + </option> | |
| 1381 | + ))} | |
| 1382 | + </select> | |
| 1383 | + </label> | |
| 1341 | 1384 | </div> |
| 1342 | 1385 | <div className="flex items-center justify-end gap-2"> |
| 1343 | 1386 | <button |
| @@ -34,6 +34,7 @@ export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type; · 33 unmodified lines | ||
| 34 | 34 | |
| 35 | 35 | export const SecretListItemSchema = Schema.Struct({ |
| 36 | 36 | id: Schema.String, |
| 37 | + providerId: Schema.String, | |
| 37 | 38 | name: Schema.NullOr(Schema.String), |
| 38 | 39 | purpose: Schema.String, |
| 39 | 40 | createdAt: Schema.Number, |
| @@ -47,6 +48,7 @@ export const CreateSecretPayloadSchema = Schema.Struct({ · 7 unmodified lines | ||
| 47 | 48 | name: Schema.String, |
| 48 | 49 | value: Schema.String, |
| 49 | 50 | purpose: Schema.optional(SecretMaterialPurposeSchema), |
| 51 | + providerId: Schema.optional(Schema.String), | |
| 50 | 52 | }); |
| 51 | 53 | |
| 52 | 54 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; |
| @@ -71,6 +73,7 @@ export type UpdateSecretPayload = typeof UpdateSecretPayloadSchema.Type; · 18 unmodified lines | ||
| 71 | 73 | |
| 72 | 74 | export const UpdateSecretResultSchema = Schema.Struct({ |
| 73 | 75 | id: Schema.String, |
| 76 | + providerId: Schema.String, | |
| 74 | 77 | name: Schema.NullOr(Schema.String), |
| 75 | 78 | purpose: Schema.String, |
| 76 | 79 | createdAt: Schema.Number, |
| @@ -3,7 +3,6 @@ import { · 2 unmodified lines | ||
| 3 | 3 | } from "@effect/platform"; |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | −import { randomUUID } from "node:crypto"; | |
| 7 | 6 | |
| 8 | 7 | import { SecretMaterialIdSchema } from "#schema"; |
| 9 | 8 | import { |
| @@ -11,10 +10,14 @@ import { · 1 unmodified line | ||
| 11 | 10 | } from "../../runtime/local-operations"; |
| 12 | 11 | import { requireRuntimeLocalWorkspace } from "../../runtime/local-runtime-context"; |
| 13 | 12 | import { |
| 13 | + createDefaultSecretMaterialDeleter, | |
| 14 | + createDefaultSecretMaterialStorer, | |
| 15 | + createDefaultSecretMaterialUpdater, | |
| 14 | 16 | ENV_SECRET_PROVIDER_ID, |
| 15 | 17 | KEYCHAIN_SECRET_PROVIDER_ID, |
| 16 | 18 | LOCAL_SECRET_PROVIDER_ID, |
| 17 | 19 | parseSecretStoreProviderId, |
| 20 | + resolveDefaultSecretStoreProviderId, | |
| 18 | 21 | } from "../../runtime/secret-material-providers"; |
| 19 | 22 | import { RuntimeSourceStoreService } from "../../runtime/source-store"; |
| 20 | 23 | import { ControlPlaneStore } from "../../runtime/store"; |
| @@ -35,6 +38,8 @@ import { · 14 unmodified lines | ||
| 35 | 38 | const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; |
| 36 | 39 | |
| 37 | 40 | const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { |
| 41 | + const explicitDefaultStoreProvider = | |
| 42 | + parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV]); | |
| 38 | 43 | const providers: SecretProvider[] = [ |
| 39 | 44 | { |
| 40 | 45 | id: LOCAL_SECRET_PROVIDER_ID, |
| @@ -47,7 +52,9 @@ const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { · 6 unmodified lines | ||
| 47 | 52 | providers.push({ |
| 48 | 53 | id: KEYCHAIN_SECRET_PROVIDER_ID, |
| 49 | 54 | name: process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring", |
| 50 | − canStore: true, | |
| 55 | + canStore: | |
| 56 | + process.platform === "darwin" | |
| 57 | + || explicitDefaultStoreProvider === KEYCHAIN_SECRET_PROVIDER_ID, | |
| 51 | 58 | }); |
| 52 | 59 | } |
| 53 | 60 | |
| @@ -57,15 +64,15 @@ const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { · 3 unmodified lines | ||
| 57 | 64 | canStore: false, |
| 58 | 65 | }); |
| 59 | 66 | |
| 60 | − const defaultStoreProvider = | |
| 61 | − parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV]) | |
| 62 | − ?? LOCAL_SECRET_PROVIDER_ID; | |
| 63 | − | |
| 64 | − return Effect.succeed({ | |
| 65 | − platform: process.platform, | |
| 66 | − secretProviders: providers, | |
| 67 | − defaultSecretStoreProvider: defaultStoreProvider, | |
| 68 | − }); | |
| 67 | + return resolveDefaultSecretStoreProviderId({ | |
| 68 | + storeProviderId: explicitDefaultStoreProvider ?? undefined, | |
| 69 | + }).pipe( | |
| 70 | + Effect.map((resolvedDefaultStoreProvider) => ({ | |
| 71 | + platform: process.platform, | |
| 72 | + secretProviders: providers, | |
| 73 | + defaultSecretStoreProvider: resolvedDefaultStoreProvider, | |
| 74 | + })), | |
| 75 | + ); | |
| 69 | 76 | }; |
| 70 | 77 | |
| 71 | 78 | const storageError = (message: string) => |
| @@ -105,16 +112,19 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 33 unmodified lines | ||
| 105 | 112 | Effect.mapError(() => storageError("Failed loading linked sources.")), |
| 106 | 113 | ); |
| 107 | 114 | return rows.map((row) => ({ |
| 108 | − ...row, | |
| 109 | − linkedSources: linkedSourcesMap.get(row.id) ?? [], | |
| 110 | − })); | |
| 111 | − }), | |
| 115 | + ...row, | |
| 116 | + linkedSources: linkedSourcesMap.get(row.id) ?? [], | |
| 117 | + })); | |
| 118 | + }), | |
| 112 | 119 | ) |
| 113 | 120 | .handle("createSecret", ({ payload }) => |
| 114 | 121 | Effect.gen(function* () { |
| 115 | 122 | const name = payload.name.trim(); |
| 116 | 123 | const value = payload.value; |
| 117 | 124 | const purpose = payload.purpose ?? "auth_material"; |
| 125 | + const requestedProviderId = payload.providerId === undefined | |
| 126 | + ? null | |
| 127 | + : parseSecretStoreProviderId(payload.providerId); | |
| 118 | 128 | |
| 119 | 129 | if (name.length === 0) { |
| 120 | 130 | return yield* Effect.fail( |
| @@ -125,29 +135,46 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 4 unmodified lines | ||
| 125 | 135 | }), |
| 126 | 136 | ); |
| 127 | 137 | } |
| 138 | + if (payload.providerId !== undefined && requestedProviderId === null) { | |
| 139 | + return yield* Effect.fail( | |
| 140 | + new ControlPlaneBadRequestError({ | |
| 141 | + operation: "secrets.create", | |
| 142 | + message: `Unsupported secret provider: ${payload.providerId}`, | |
| 143 | + details: `Unsupported secret provider: ${payload.providerId}`, | |
| 144 | + }), | |
| 145 | + ); | |
| 146 | + } | |
| 128 | 147 | |
| 129 | 148 | const store = yield* ControlPlaneStore; |
| 130 | − const now = Date.now(); | |
| 131 | − const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`); | |
| 132 | − | |
| 133 | − yield* store.secretMaterials.upsert({ | |
| 134 | − id, | |
| 149 | + const storeSecretMaterial = createDefaultSecretMaterialStorer({ | |
| 150 | + rows: store, | |
| 151 | + ...(requestedProviderId ? { storeProviderId: requestedProviderId } : {}), | |
| 152 | + }); | |
| 153 | + const ref = yield* storeSecretMaterial({ | |
| 135 | 154 | name, |
| 136 | 155 | purpose, |
| 137 | 156 | value, |
| 138 | − createdAt: now, | |
| 139 | − updatedAt: now, | |
| 140 | 157 | }).pipe( |
| 141 | − Effect.mapError(() => storageError("Failed creating secret.")), | |
| 158 | + Effect.mapError((cause) => storageError( | |
| 159 | + cause instanceof Error ? cause.message : "Failed creating secret.", | |
| 160 | + )), | |
| 161 | + ); | |
| 162 | + const secretId = SecretMaterialIdSchema.make(ref.handle); | |
| 163 | + const created = yield* store.secretMaterials.getById(secretId).pipe( | |
| 164 | + Effect.mapError(() => storageError("Failed loading created secret.")), | |
| 142 | 165 | ); |
| 143 | 166 | |
| 167 | + if (Option.isNone(created)) { | |
| 168 | + return yield* Effect.fail(storageError(`Created secret not found: ${ref.handle}`)); | |
| 169 | + } | |
| 170 | + | |
| 144 | 171 | return { |
| 145 | − id, | |
| 146 | − name, | |
| 147 | − providerId: LOCAL_SECRET_PROVIDER_ID, | |
| 148 | − purpose, | |
| 149 | − createdAt: now, | |
| 150 | − updatedAt: now, | |
| 172 | + id: created.value.id, | |
| 173 | + name: created.value.name, | |
| 174 | + providerId: created.value.providerId, | |
| 175 | + purpose: created.value.purpose, | |
| 176 | + createdAt: created.value.createdAt, | |
| 177 | + updatedAt: created.value.updatedAt, | |
| 151 | 178 | } satisfies CreateSecretResult; |
| 152 | 179 | }), |
| 153 | 180 | ) |
| @@ -174,26 +201,26 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 20 unmodified lines | ||
| 174 | 201 | if (payload.name !== undefined) update.name = payload.name.trim() || null; |
| 175 | 202 | if (payload.value !== undefined) update.value = payload.value; |
| 176 | 203 | |
| 177 | − const updated = yield* store.secretMaterials.updateById(secretId, update).pipe( | |
| 204 | + const updateSecretMaterial = createDefaultSecretMaterialUpdater({ | |
| 205 | + rows: store, | |
| 206 | + }); | |
| 207 | + const updated = yield* updateSecretMaterial({ | |
| 208 | + ref: { | |
| 209 | + providerId: existing.value.providerId, | |
| 210 | + handle: existing.value.id, | |
| 211 | + }, | |
| 212 | + ...update, | |
| 213 | + }).pipe( | |
| 178 | 214 | Effect.mapError(() => storageError("Failed updating secret.")), |
| 179 | 215 | ); |
| 180 | 216 | |
| 181 | − if (Option.isNone(updated)) { | |
| 182 | − return yield* Effect.fail( | |
| 183 | − new ControlPlaneNotFoundError({ | |
| 184 | − operation: "secrets.update", | |
| 185 | − message: `Secret not found after update: ${path.secretId}`, | |
| 186 | − details: `Secret not found after update: ${path.secretId}`, | |
| 187 | − }), | |
| 188 | − ); | |
| 189 | − } | |
| 190 | − | |
| 191 | 217 | return { |
| 192 | − id: updated.value.id, | |
| 193 | − name: updated.value.name, | |
| 194 | − purpose: updated.value.purpose, | |
| 195 | − createdAt: updated.value.createdAt, | |
| 196 | − updatedAt: updated.value.updatedAt, | |
| 218 | + id: updated.id, | |
| 219 | + providerId: updated.providerId, | |
| 220 | + name: updated.name, | |
| 221 | + purpose: updated.purpose, | |
| 222 | + createdAt: updated.createdAt, | |
| 223 | + updatedAt: updated.updatedAt, | |
| 197 | 224 | } satisfies UpdateSecretResult; |
| 198 | 225 | }), |
| 199 | 226 | ) |
| @@ -202,11 +229,11 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 2 unmodified lines | ||
| 202 | 229 | const secretId = SecretMaterialIdSchema.make(path.secretId); |
| 203 | 230 | const store = yield* ControlPlaneStore; |
| 204 | 231 | |
| 205 | − const removed = yield* store.secretMaterials.removeById(secretId).pipe( | |
| 206 | − Effect.mapError(() => storageError("Failed removing secret.")), | |
| 232 | + const existing = yield* store.secretMaterials.getById(secretId).pipe( | |
| 233 | + Effect.mapError(() => storageError("Failed looking up secret.")), | |
| 207 | 234 | ); |
| 208 | 235 | |
| 209 | − if (!removed) { | |
| 236 | + if (Option.isNone(existing)) { | |
| 210 | 237 | return yield* Effect.fail( |
| 211 | 238 | new ControlPlaneNotFoundError({ |
| 212 | 239 | operation: "secrets.delete", |
| @@ -216,6 +243,20 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 3 unmodified lines | ||
| 216 | 243 | ); |
| 217 | 244 | } |
| 218 | 245 | |
| 246 | + const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ | |
| 247 | + rows: store, | |
| 248 | + }); | |
| 249 | + const removed = yield* deleteSecretMaterial({ | |
| 250 | + providerId: existing.value.providerId, | |
| 251 | + handle: existing.value.id, | |
| 252 | + }).pipe( | |
| 253 | + Effect.mapError(() => storageError("Failed removing secret.")), | |
| 254 | + ); | |
| 255 | + | |
| 256 | + if (!removed) { | |
| 257 | + return yield* Effect.fail(storageError(`Failed removing secret: ${path.secretId}`)); | |
| 258 | + } | |
| 259 | + | |
| 219 | 260 | return { removed: true }; |
| 220 | 261 | }), |
| 221 | 262 | ), |
| @@ -197,6 +197,94 @@ describe("control-plane-runtime", () => { · 196 unmodified lines | ||
| 197 | 197 | 60_000, |
| 198 | 198 | ); |
| 199 | 199 | |
| 200 | + it.scoped("manages local secrets through provider-backed metadata rows", () => | |
| 201 | + Effect.gen(function* () { | |
| 202 | + const runtime = yield* makeRuntime; | |
| 203 | + | |
| 204 | + const created = yield* withControlPlaneClient( | |
| 205 | + { runtime }, | |
| 206 | + (client) => | |
| 207 | + client.local.createSecret({ | |
| 208 | + payload: { | |
| 209 | + name: "GitHub PAT", | |
| 210 | + value: "ghp_test_token", | |
| 211 | + providerId: "local", | |
| 212 | + }, | |
| 213 | + }), | |
| 214 | + ); | |
| 215 | + | |
| 216 | + expect(created.providerId).toBe("local"); | |
| 217 | + | |
| 218 | + const listed = yield* withControlPlaneClient( | |
| 219 | + { runtime }, | |
| 220 | + (client) => client.local.listSecrets({}), | |
| 221 | + ); | |
| 222 | + expect(listed).toEqual([ | |
| 223 | + expect.objectContaining({ | |
| 224 | + id: created.id, | |
| 225 | + providerId: "local", | |
| 226 | + name: "GitHub PAT", | |
| 227 | + }), | |
| 228 | + ]); | |
| 229 | + | |
| 230 | + const updated = yield* withControlPlaneClient( | |
| 231 | + { runtime }, | |
| 232 | + (client) => | |
| 233 | + client.local.updateSecret({ | |
| 234 | + path: { secretId: created.id }, | |
| 235 | + payload: { | |
| 236 | + name: "GitHub PAT Updated", | |
| 237 | + value: "ghp_test_token_v2", | |
| 238 | + }, | |
| 239 | + }), | |
| 240 | + ); | |
| 241 | + expect(updated.providerId).toBe("local"); | |
| 242 | + expect(updated.name).toBe("GitHub PAT Updated"); | |
| 243 | + | |
| 244 | + const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById( | |
| 245 | + SecretMaterialIdSchema.make(created.id), | |
| 246 | + ); | |
| 247 | + assertTrue(Option.isSome(storedSecret)); | |
| 248 | + expect(storedSecret.value.providerId).toBe("local"); | |
| 249 | + expect(storedSecret.value.value).toBe("ghp_test_token_v2"); | |
| 250 | + | |
| 251 | + const removed = yield* withControlPlaneClient( | |
| 252 | + { runtime }, | |
| 253 | + (client) => | |
| 254 | + client.local.deleteSecret({ | |
| 255 | + path: { secretId: created.id }, | |
| 256 | + }), | |
| 257 | + ); | |
| 258 | + expect(removed.removed).toBe(true); | |
| 259 | + }), | |
| 260 | + ); | |
| 261 | + | |
| 262 | + it.scoped("reports safe secret storage options for the current platform", () => | |
| 263 | + Effect.gen(function* () { | |
| 264 | + const runtime = yield* makeRuntime; | |
| 265 | + | |
| 266 | + const config = yield* withControlPlaneClient( | |
| 267 | + { runtime }, | |
| 268 | + (client) => client.local.config({}), | |
| 269 | + ); | |
| 270 | + | |
| 271 | + const keychainProvider = | |
| 272 | + config.secretProviders.find((provider) => provider.id === "keychain") ?? null; | |
| 273 | + | |
| 274 | + if (process.platform === "linux") { | |
| 275 | + expect(config.defaultSecretStoreProvider).toBe("local"); | |
| 276 | + expect(keychainProvider?.canStore).toBe(false); | |
| 277 | + return; | |
| 278 | + } | |
| 279 | + | |
| 280 | + if (process.platform === "darwin") { | |
| 281 | + expect(keychainProvider?.canStore).toBe(true); | |
| 282 | + return; | |
| 283 | + } | |
| 284 | + | |
| 285 | + expect(keychainProvider).toBeNull(); | |
| 286 | + }), | |
| 287 | + ); | |
| 200 | 288 | |
| 201 | 289 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => |
| 202 | 290 | Effect.gen(function* () { |
| @@ -1,6 +1,6 @@ | ||
| 1 | −import { existsSync, mkdtempSync, statSync } from "node:fs"; | |
| 1 | +import { existsSync, mkdtempSync, mkdirSync, statSync, writeFileSync } from "node:fs"; | |
| 2 | 2 | import { tmpdir } from "node:os"; |
| 3 | −import { join } from "node:path"; | |
| 3 | +import { dirname, join } from "node:path"; | |
| 4 | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | 6 | |
| @@ -62,4 +62,50 @@ describe("local-control-plane-store", () => { · 55 unmodified lines | ||
| 62 | 62 | } |
| 63 | 63 | }), |
| 64 | 64 | ); |
| 65 | + | |
| 66 | + it.effect("decodes legacy local secret rows without provider metadata", () => | |
| 67 | + Effect.gen(function* () { | |
| 68 | + const context = makeContext(); | |
| 69 | + const expectedPath = localControlPlaneStatePath(context); | |
| 70 | + mkdirSync(dirname(expectedPath), { recursive: true }); | |
| 71 | + | |
| 72 | + writeFileSync( | |
| 73 | + expectedPath, | |
| 74 | + JSON.stringify({ | |
| 75 | + version: 1, | |
| 76 | + authArtifacts: [], | |
| 77 | + authLeases: [], | |
| 78 | + sourceOauthClients: [], | |
| 79 | + sourceAuthSessions: [], | |
| 80 | + secretMaterials: [ | |
| 81 | + { | |
| 82 | + id: "sec_legacy", | |
| 83 | + name: "Legacy token", | |
| 84 | + purpose: "auth_material", | |
| 85 | + value: "secret-value", | |
| 86 | + createdAt: 1, | |
| 87 | + updatedAt: 2, | |
| 88 | + }, | |
| 89 | + ], | |
| 90 | + executions: [], | |
| 91 | + executionInteractions: [], | |
| 92 | + executionSteps: [], | |
| 93 | + }), | |
| 94 | + ); | |
| 95 | + | |
| 96 | + const loaded = yield* loadLocalControlPlaneState(context); | |
| 97 | + expect(loaded.secretMaterials).toEqual([ | |
| 98 | + { | |
| 99 | + id: "sec_legacy", | |
| 100 | + providerId: "local", | |
| 101 | + handle: "sec_legacy", | |
| 102 | + name: "Legacy token", | |
| 103 | + purpose: "auth_material", | |
| 104 | + value: "secret-value", | |
| 105 | + createdAt: 1, | |
| 106 | + updatedAt: 2, | |
| 107 | + }, | |
| 108 | + ]); | |
| 109 | + }), | |
| 110 | + ); | |
| 65 | 111 | }); |
| @@ -7,6 +7,7 @@ import { isAbsolute } from "node:path"; · 6 unmodified lines | ||
| 7 | 7 | import { |
| 8 | 8 | type LocalConfigSecretProvider, |
| 9 | 9 | type LocalExecutorConfig, |
| 10 | + type SecretMaterial, | |
| 10 | 11 | type SecretMaterialPurpose, |
| 11 | 12 | SecretMaterialIdSchema, |
| 12 | 13 | type SecretRef, |
| @@ -50,6 +51,19 @@ export type DeleteSecretMaterial = ( · 37 unmodified lines | ||
| 50 | 51 | ref: SecretRef, |
| 51 | 52 | ) => Effect.Effect<boolean, Error, never>; |
| 52 | 53 | |
| 54 | +export type UpdateSecretMaterial = (input: { | |
| 55 | + ref: SecretRef; | |
| 56 | + name?: string | null; | |
| 57 | + value?: string; | |
| 58 | +}) => Effect.Effect<{ | |
| 59 | + id: string; | |
| 60 | + providerId: string; | |
| 61 | + name: string | null; | |
| 62 | + purpose: string; | |
| 63 | + createdAt: number; | |
| 64 | + updatedAt: number; | |
| 65 | +}, Error, never>; | |
| 66 | + | |
| 53 | 67 | export class SecretMaterialResolverService extends Context.Tag( |
| 54 | 68 | "#runtime/SecretMaterialResolverService", |
| 55 | 69 | )<SecretMaterialResolverService, ResolveSecretMaterial>() {} |
| @@ -62,6 +76,10 @@ export class SecretMaterialDeleterService extends Context.Tag( · 6 unmodified lines | ||
| 62 | 76 | "#runtime/SecretMaterialDeleterService", |
| 63 | 77 | )<SecretMaterialDeleterService, DeleteSecretMaterial>() {} |
| 64 | 78 | |
| 79 | +export class SecretMaterialUpdaterService extends Context.Tag( | |
| 80 | + "#runtime/SecretMaterialUpdaterService", | |
| 81 | +)<SecretMaterialUpdaterService, UpdateSecretMaterial>() {} | |
| 82 | + | |
| 65 | 83 | type SecretMaterialProviderRuntime = { |
| 66 | 84 | rows: ControlPlaneStoreShape; |
| 67 | 85 | env: NodeJS.ProcessEnv; |
| @@ -83,6 +101,19 @@ type SecretMaterialProvider = { · 15 unmodified lines | ||
| 83 | 101 | name?: string | null; |
| 84 | 102 | runtime: SecretMaterialProviderRuntime; |
| 85 | 103 | }) => Effect.Effect<SecretRef, Error, never>; |
| 104 | + update?: (input: { | |
| 105 | + ref: SecretRef; | |
| 106 | + name?: string | null; | |
| 107 | + value?: string; | |
| 108 | + runtime: SecretMaterialProviderRuntime; | |
| 109 | + }) => Effect.Effect<{ | |
| 110 | + id: string; | |
| 111 | + providerId: string; | |
| 112 | + name: string | null; | |
| 113 | + purpose: string; | |
| 114 | + createdAt: number; | |
| 115 | + updatedAt: number; | |
| 116 | + }, Error, never>; | |
| 86 | 117 | remove?: (input: { |
| 87 | 118 | ref: SecretRef; |
| 88 | 119 | runtime: SecretMaterialProviderRuntime; |
| @@ -98,10 +129,20 @@ type SpawnResult = { · 9 unmodified lines | ||
| 98 | 129 | }; |
| 99 | 130 | |
| 100 | 131 | const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor"; |
| 132 | +const KEYCHAIN_COMMAND_TIMEOUT_MS = 5_000; | |
| 101 | 133 | const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS"; |
| 102 | 134 | const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; |
| 103 | 135 | const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME"; |
| 104 | 136 | |
| 137 | +type SecretMaterialSummary = { | |
| 138 | + id: string; | |
| 139 | + providerId: string; | |
| 140 | + name: string | null; | |
| 141 | + purpose: string; | |
| 142 | + createdAt: number; | |
| 143 | + updatedAt: number; | |
| 144 | +}; | |
| 145 | + | |
| 105 | 146 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 106 | 147 | if (value === null || value === undefined) { |
| 107 | 148 | return null; |
| @@ -134,11 +175,6 @@ export const parseSecretStoreProviderId = (value: string | undefined): SecretSto · 26 unmodified lines | ||
| 134 | 175 | const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean => |
| 135 | 176 | value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]); |
| 136 | 177 | |
| 137 | −const resolveSecretStoreProviderId = (value: SecretStoreProviderId | undefined): SecretStoreProviderId => | |
| 138 | − value | |
| 139 | − ?? parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV]) | |
| 140 | − ?? LOCAL_SECRET_PROVIDER_ID; | |
| 141 | − | |
| 142 | 178 | const resolveKeychainServiceName = (value: string | undefined): string => |
| 143 | 179 | trimOrNull(value) |
| 144 | 180 | ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV]) |
| @@ -149,12 +185,38 @@ const ensureNonEmptyString = (value: string | undefined): string | null => { · 4 unmodified lines | ||
| 149 | 185 | return trimmed && trimmed.length > 0 ? trimmed : null; |
| 150 | 186 | }; |
| 151 | 187 | |
| 188 | +const toSecretMaterialSummary = ( | |
| 189 | + material: Pick<SecretMaterial, "id" | "providerId" | "name" | "purpose" | "createdAt" | "updatedAt">, | |
| 190 | +): SecretMaterialSummary => ({ | |
| 191 | + id: material.id, | |
| 192 | + providerId: material.providerId, | |
| 193 | + name: material.name, | |
| 194 | + purpose: material.purpose, | |
| 195 | + createdAt: material.createdAt, | |
| 196 | + updatedAt: material.updatedAt, | |
| 197 | +}); | |
| 198 | + | |
| 199 | +const keychainCommandForPlatform = ( | |
| 200 | + platform: NodeJS.Platform = process.platform, | |
| 201 | +): string | null => { | |
| 202 | + if (platform === "darwin") { | |
| 203 | + return "security"; | |
| 204 | + } | |
| 205 | + | |
| 206 | + if (platform === "linux") { | |
| 207 | + return "secret-tool"; | |
| 208 | + } | |
| 209 | + | |
| 210 | + return null; | |
| 211 | +}; | |
| 212 | + | |
| 152 | 213 | const runCommand = (input: { |
| 153 | 214 | command: string; |
| 154 | 215 | args: ReadonlyArray<string>; |
| 155 | 216 | stdin?: string; |
| 156 | 217 | env?: NodeJS.ProcessEnv; |
| 157 | 218 | operation: string; |
| 219 | + timeoutMs?: number; | |
| 158 | 220 | }): Effect.Effect<SpawnResult, Error, never> => |
| 159 | 221 | Effect.tryPromise({ |
| 160 | 222 | try: () => |
| @@ -166,6 +228,22 @@ const runCommand = (input: { · 5 unmodified lines | ||
| 166 | 228 | |
| 167 | 229 | let stdout = ""; |
| 168 | 230 | let stderr = ""; |
| 231 | + let settled = false; | |
| 232 | + const timeout = input.timeoutMs === undefined | |
| 233 | + ? null | |
| 234 | + : setTimeout(() => { | |
| 235 | + if (settled) { | |
| 236 | + return; | |
| 237 | + } | |
| 238 | + | |
| 239 | + settled = true; | |
| 240 | + child.kill("SIGKILL"); | |
| 241 | + reject( | |
| 242 | + new Error( | |
| 243 | + `${input.operation}: '${input.command}' timed out after ${input.timeoutMs}ms`, | |
| 244 | + ), | |
| 245 | + ); | |
| 246 | + }, input.timeoutMs); | |
| 169 | 247 | |
| 170 | 248 | child.stdout.on("data", (chunk) => { |
| 171 | 249 | stdout += chunk.toString("utf8"); |
| @@ -176,10 +254,24 @@ const runCommand = (input: { · 4 unmodified lines | ||
| 176 | 254 | }); |
| 177 | 255 | |
| 178 | 256 | child.on("error", (error) => { |
| 257 | + if (settled) { | |
| 258 | + return; | |
| 259 | + } | |
| 260 | + settled = true; | |
| 261 | + if (timeout) { | |
| 262 | + clearTimeout(timeout); | |
| 263 | + } | |
| 179 | 264 | reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`)); |
| 180 | 265 | }); |
| 181 | 266 | |
| 182 | 267 | child.on("close", (code) => { |
| 268 | + if (settled) { | |
| 269 | + return; | |
| 270 | + } | |
| 271 | + settled = true; | |
| 272 | + if (timeout) { | |
| 273 | + clearTimeout(timeout); | |
| 274 | + } | |
| 183 | 275 | resolve({ |
| 184 | 276 | exitCode: code ?? 0, |
| 185 | 277 | stdout, |
| @@ -215,6 +307,79 @@ const ensureCommandSuccess = (input: { · 29 unmodified lines | ||
| 215 | 307 | return Effect.fail(new Error(`${input.operation}: ${input.message}: ${details}`)); |
| 216 | 308 | }; |
| 217 | 309 | |
| 310 | +const commandAvailabilityCache = new Map<string, Promise<boolean>>(); | |
| 311 | + | |
| 312 | +const commandExists = (command: string): Effect.Effect<boolean, Error, never> => | |
| 313 | + Effect.tryPromise({ | |
| 314 | + try: async () => { | |
| 315 | + const cached = commandAvailabilityCache.get(command); | |
| 316 | + if (cached) { | |
| 317 | + return cached; | |
| 318 | + } | |
| 319 | + | |
| 320 | + const probe = new Promise<boolean>((resolve) => { | |
| 321 | + const child = spawn(command, ["--help"], { | |
| 322 | + stdio: "ignore", | |
| 323 | + }); | |
| 324 | + const timeout = setTimeout(() => { | |
| 325 | + child.kill("SIGKILL"); | |
| 326 | + resolve(false); | |
| 327 | + }, 2_000); | |
| 328 | + | |
| 329 | + child.on("error", () => { | |
| 330 | + clearTimeout(timeout); | |
| 331 | + resolve(false); | |
| 332 | + }); | |
| 333 | + child.on("close", () => { | |
| 334 | + clearTimeout(timeout); | |
| 335 | + resolve(true); | |
| 336 | + }); | |
| 337 | + }); | |
| 338 | + | |
| 339 | + commandAvailabilityCache.set(command, probe); | |
| 340 | + const available = await probe; | |
| 341 | + if (!available) { | |
| 342 | + commandAvailabilityCache.delete(command); | |
| 343 | + } | |
| 344 | + return available; | |
| 345 | + }, | |
| 346 | + catch: (cause) => | |
| 347 | + cause instanceof Error | |
| 348 | + ? cause | |
| 349 | + : new Error(`command.exists: failed checking '${command}': ${String(cause)}`), | |
| 350 | + }); | |
| 351 | + | |
| 352 | +const isKeychainProviderAvailable = ( | |
| 353 | + platform: NodeJS.Platform = process.platform, | |
| 354 | +): Effect.Effect<boolean, Error, never> => { | |
| 355 | + const command = keychainCommandForPlatform(platform); | |
| 356 | + return command === null ? Effect.succeed(false) : commandExists(command); | |
| 357 | +}; | |
| 358 | + | |
| 359 | +export const resolveDefaultSecretStoreProviderId = (input: { | |
| 360 | + storeProviderId?: SecretStoreProviderId; | |
| 361 | + env?: NodeJS.ProcessEnv; | |
| 362 | + platform?: NodeJS.Platform; | |
| 363 | +} = {}): Effect.Effect<SecretStoreProviderId, never, never> => { | |
| 364 | + const localProviderId = LOCAL_SECRET_PROVIDER_ID as SecretStoreProviderId; | |
| 365 | + const explicit = | |
| 366 | + input.storeProviderId | |
| 367 | + ?? parseSecretStoreProviderId((input.env ?? process.env)[SECRET_STORE_PROVIDER_ENV]); | |
| 368 | + if (explicit) { | |
| 369 | + return Effect.succeed(explicit); | |
| 370 | + } | |
| 371 | + | |
| 372 | + if ((input.platform ?? process.platform) !== "darwin") { | |
| 373 | + return Effect.succeed(localProviderId); | |
| 374 | + } | |
| 375 | + | |
| 376 | + return isKeychainProviderAvailable(input.platform).pipe( | |
| 377 | + Effect.map((available): SecretStoreProviderId => | |
| 378 | + available ? KEYCHAIN_SECRET_PROVIDER_ID : localProviderId), | |
| 379 | + Effect.catchAll(() => Effect.succeed(localProviderId)), | |
| 380 | + ) as Effect.Effect<SecretStoreProviderId, never, never>; | |
| 381 | +}; | |
| 382 | + | |
| 218 | 383 | const parseKeychainHandle = (handle: string): string | null => { |
| 219 | 384 | if (!handle.startsWith(`${KEYCHAIN_SECRET_PROVIDER_ID}:`)) { |
| 220 | 385 | return null; |
| @@ -223,6 +388,248 @@ const parseKeychainHandle = (handle: string): string | null => { · 2 unmodified lines | ||
| 223 | 388 | return trimOrNull(handle.slice(`${KEYCHAIN_SECRET_PROVIDER_ID}:`.length)); |
| 224 | 389 | }; |
| 225 | 390 | |
| 391 | +const loadStoredSecretMaterial = (input: { | |
| 392 | + id: string; | |
| 393 | + runtime: SecretMaterialProviderRuntime; | |
| 394 | + operation: string; | |
| 395 | +}) => | |
| 396 | + Effect.gen(function* () { | |
| 397 | + const materialId = SecretMaterialIdSchema.make(input.id); | |
| 398 | + const stored = yield* input.runtime.rows.secretMaterials.getById(materialId); | |
| 399 | + if (Option.isNone(stored)) { | |
| 400 | + return yield* Effect.fail( | |
| 401 | + new Error(`${input.operation}: secret material not found: ${input.id}`), | |
| 402 | + ); | |
| 403 | + } | |
| 404 | + | |
| 405 | + return stored.value; | |
| 406 | + }); | |
| 407 | + | |
| 408 | +const createSecretMaterialMetadata = (input: { | |
| 409 | + providerId: SecretStoreProviderId; | |
| 410 | + providerHandle: string; | |
| 411 | + purpose: SecretMaterialPurpose; | |
| 412 | + value: string | null; | |
| 413 | + name?: string | null; | |
| 414 | + runtime: SecretMaterialProviderRuntime; | |
| 415 | +}) => | |
| 416 | + Effect.gen(function* () { | |
| 417 | + const now = Date.now(); | |
| 418 | + const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`); | |
| 419 | + yield* input.runtime.rows.secretMaterials.upsert({ | |
| 420 | + id, | |
| 421 | + providerId: input.providerId, | |
| 422 | + handle: input.providerHandle, | |
| 423 | + name: trimOrNull(input.name), | |
| 424 | + purpose: input.purpose, | |
| 425 | + value: input.value, | |
| 426 | + createdAt: now, | |
| 427 | + updatedAt: now, | |
| 428 | + }); | |
| 429 | + | |
| 430 | + return { | |
| 431 | + providerId: input.providerId, | |
| 432 | + handle: id, | |
| 433 | + } satisfies SecretRef; | |
| 434 | + }); | |
| 435 | + | |
| 436 | +const loadManagedKeychainRef = (input: { | |
| 437 | + ref: SecretRef; | |
| 438 | + runtime: SecretMaterialProviderRuntime; | |
| 439 | + operation: string; | |
| 440 | +}) => | |
| 441 | + Effect.gen(function* () { | |
| 442 | + const legacyHandle = parseKeychainHandle(input.ref.handle); | |
| 443 | + if (legacyHandle !== null) { | |
| 444 | + return { | |
| 445 | + providerHandle: legacyHandle, | |
| 446 | + material: null, | |
| 447 | + }; | |
| 448 | + } | |
| 449 | + | |
| 450 | + const material = yield* loadStoredSecretMaterial({ | |
| 451 | + id: input.ref.handle, | |
| 452 | + runtime: input.runtime, | |
| 453 | + operation: input.operation, | |
| 454 | + }); | |
| 455 | + if (material.providerId !== KEYCHAIN_SECRET_PROVIDER_ID) { | |
| 456 | + return yield* Effect.fail( | |
| 457 | + new Error( | |
| 458 | + `${input.operation}: secret ${material.id} is stored in provider '${material.providerId}', not '${KEYCHAIN_SECRET_PROVIDER_ID}'`, | |
| 459 | + ), | |
| 460 | + ); | |
| 461 | + } | |
| 462 | + | |
| 463 | + return { | |
| 464 | + providerHandle: material.handle, | |
| 465 | + material, | |
| 466 | + }; | |
| 467 | + }); | |
| 468 | + | |
| 469 | +const readKeychainSecretValue = (input: { | |
| 470 | + providerHandle: string; | |
| 471 | + runtime: SecretMaterialProviderRuntime; | |
| 472 | +}) => { | |
| 473 | + switch (process.platform) { | |
| 474 | + case "darwin": | |
| 475 | + return runCommand({ | |
| 476 | + command: "security", | |
| 477 | + args: [ | |
| 478 | + "find-generic-password", | |
| 479 | + "-a", | |
| 480 | + input.providerHandle, | |
| 481 | + "-s", | |
| 482 | + input.runtime.keychainServiceName, | |
| 483 | + "-w", | |
| 484 | + ], | |
| 485 | + operation: "keychain.get", | |
| 486 | + timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | |
| 487 | + }).pipe( | |
| 488 | + Effect.flatMap((result) => | |
| 489 | + ensureCommandSuccess({ | |
| 490 | + result, | |
| 491 | + operation: "keychain.get", | |
| 492 | + message: "Failed loading secret from macOS keychain", | |
| 493 | + }), | |
| 494 | + ), | |
| 495 | + Effect.map((result) => result.stdout.trimEnd()), | |
| 496 | + ); | |
| 497 | + case "linux": | |
| 498 | + return runCommand({ | |
| 499 | + command: "secret-tool", | |
| 500 | + args: [ | |
| 501 | + "lookup", | |
| 502 | + "service", | |
| 503 | + input.runtime.keychainServiceName, | |
| 504 | + "account", | |
| 505 | + input.providerHandle, | |
| 506 | + ], | |
| 507 | + operation: "keychain.get", | |
| 508 | + timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | |
| 509 | + }).pipe( | |
| 510 | + Effect.flatMap((result) => | |
| 511 | + ensureCommandSuccess({ | |
| 512 | + result, | |
| 513 | + operation: "keychain.get", | |
| 514 | + message: "Failed loading secret from desktop keyring", | |
| 515 | + }), | |
| 516 | + ), | |
| 517 | + Effect.map((result) => result.stdout.trimEnd()), | |
| 518 | + ); | |
| 519 | + default: | |
| 520 | + return Effect.fail( | |
| 521 | + new Error(`keychain.get: keychain provider is unsupported on platform '${process.platform}'`), | |
| 522 | + ); | |
| 523 | + } | |
| 524 | +}; | |
| 525 | + | |
| 526 | +const writeKeychainSecretValue = (input: { | |
| 527 | + providerHandle: string; | |
| 528 | + name?: string | null; | |
| 529 | + value: string; | |
| 530 | + runtime: SecretMaterialProviderRuntime; | |
| 531 | +}) => { | |
| 532 | + const secretName = trimOrNull(input.name); | |
| 533 | + | |
| 534 | + switch (process.platform) { | |
| 535 | + case "darwin": | |
| 536 | + return runCommand({ | |
| 537 | + command: "security", | |
| 538 | + args: [ | |
| 539 | + "add-generic-password", | |
| 540 | + "-a", | |
| 541 | + input.providerHandle, | |
| 542 | + "-s", | |
| 543 | + input.runtime.keychainServiceName, | |
| 544 | + "-w", | |
| 545 | + input.value, | |
| 546 | + ...(secretName ? ["-l", secretName] : []), | |
| 547 | + "-U", | |
| 548 | + ], | |
| 549 | + operation: "keychain.put", | |
| 550 | + timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | |
| 551 | + }).pipe( | |
| 552 | + Effect.flatMap((result) => | |
| 553 | + ensureCommandSuccess({ | |
| 554 | + result, | |
| 555 | + operation: "keychain.put", | |
| 556 | + message: "Failed storing secret in macOS keychain", | |
| 557 | + }), | |
| 558 | + ), | |
| 559 | + ); | |
| 560 | + case "linux": | |
| 561 | + return runCommand({ | |
| 562 | + command: "secret-tool", | |
| 563 | + args: [ | |
| 564 | + "store", | |
| 565 | + "--label", | |
| 566 | + secretName ?? input.runtime.keychainServiceName, | |
| 567 | + "service", | |
| 568 | + input.runtime.keychainServiceName, | |
| 569 | + "account", | |
| 570 | + input.providerHandle, | |
| 571 | + ], | |
| 572 | + stdin: input.value, | |
| 573 | + operation: "keychain.put", | |
| 574 | + timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | |
| 575 | + }).pipe( | |
| 576 | + Effect.flatMap((result) => | |
| 577 | + ensureCommandSuccess({ | |
| 578 | + result, | |
| 579 | + operation: "keychain.put", | |
| 580 | + message: "Failed storing secret in desktop keyring", | |
| 581 | + }), | |
| 582 | + ), | |
| 583 | + ); | |
| 584 | + default: | |
| 585 | + return Effect.fail( | |
| 586 | + new Error(`keychain.put: keychain provider is unsupported on platform '${process.platform}'`), | |
| 587 | + ); | |
| 588 | + } | |
| 589 | +}; | |
| 590 | + | |
| 591 | +const deleteKeychainSecretValue = (input: { | |
| 592 | + providerHandle: string; | |
| 593 | + runtime: SecretMaterialProviderRuntime; | |
| 594 | +}) => { | |
| 595 | + switch (process.platform) { | |
| 596 | + case "darwin": | |
| 597 | + return runCommand({ | |
| 598 | + command: "security", | |
| 599 | + args: [ | |
| 600 | + "delete-generic-password", | |
| 601 | + "-a", | |
| 602 | + input.providerHandle, | |
| 603 | + "-s", | |
| 604 | + input.runtime.keychainServiceName, | |
| 605 | + ], | |
| 606 | + operation: "keychain.delete", | |
| 607 | + timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | |
| 608 | + }).pipe( | |
| 609 | + Effect.map((result) => result.exitCode === 0), | |
| 610 | + ); | |
| 611 | + case "linux": | |
| 612 | + return runCommand({ | |
| 613 | + command: "secret-tool", | |
| 614 | + args: [ | |
| 615 | + "clear", | |
| 616 | + "service", | |
| 617 | + input.runtime.keychainServiceName, | |
| 618 | + "account", | |
| 619 | + input.providerHandle, | |
| 620 | + ], | |
| 621 | + operation: "keychain.delete", | |
| 622 | + timeoutMs: KEYCHAIN_COMMAND_TIMEOUT_MS, | |
| 623 | + }).pipe( | |
| 624 | + Effect.map((result) => result.exitCode === 0), | |
| 625 | + ); | |
| 626 | + default: | |
| 627 | + return Effect.fail( | |
| 628 | + new Error(`keychain.delete: keychain provider is unsupported on platform '${process.platform}'`), | |
| 629 | + ); | |
| 630 | + } | |
| 631 | +}; | |
| 632 | + | |
| 226 | 633 | const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({ |
| 227 | 634 | resolve: ({ ref, context }) => { |
| 228 | 635 | const value = ensureNonEmptyString(context.params?.[ref.handle]); |
| @@ -260,32 +667,75 @@ const createEnvSecretMaterialProvider = (): SecretMaterialProvider => ({ · 31 unmodified lines | ||
| 260 | 667 | const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({ |
| 261 | 668 | resolve: ({ ref, runtime }) => |
| 262 | 669 | Effect.gen(function* () { |
| 263 | − const materialId = SecretMaterialIdSchema.make(ref.handle); | |
| 264 | − const stored = yield* runtime.rows.secretMaterials.getById(materialId); | |
| 265 | − if (Option.isNone(stored)) { | |
| 266 | − return yield* Effect.fail(new Error(`Secret material not found: ${ref.handle}`)); | |
| 670 | + const stored = yield* loadStoredSecretMaterial({ | |
| 671 | + id: ref.handle, | |
| 672 | + runtime, | |
| 673 | + operation: "local.get", | |
| 674 | + }); | |
| 675 | + if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) { | |
| 676 | + return yield* Effect.fail( | |
| 677 | + new Error( | |
| 678 | + `local.get: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`, | |
| 679 | + ), | |
| 680 | + ); | |
| 681 | + } | |
| 682 | + if (stored.value === null) { | |
| 683 | + return yield* Effect.fail( | |
| 684 | + new Error(`local.get: secret ${stored.id} does not have a local value`), | |
| 685 | + ); | |
| 267 | 686 | } |
| 268 | 687 | |
| 269 | − return stored.value.value; | |
| 688 | + return stored.value; | |
| 270 | 689 | }), |
| 271 | 690 | |
| 272 | 691 | store: ({ purpose, value, name, runtime }) => |
| 692 | + createSecretMaterialMetadata({ | |
| 693 | + providerId: LOCAL_SECRET_PROVIDER_ID, | |
| 694 | + providerHandle: `local:${randomUUID()}`, | |
| 695 | + purpose, | |
| 696 | + value, | |
| 697 | + name, | |
| 698 | + runtime, | |
| 699 | + }), | |
| 700 | + | |
| 701 | + update: ({ ref, name, value, runtime }) => | |
| 273 | 702 | Effect.gen(function* () { |
| 274 | − const now = Date.now(); | |
| 275 | − const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`); | |
| 276 | − yield* runtime.rows.secretMaterials.upsert({ | |
| 277 | − id, | |
| 278 | − name: trimOrNull(name), | |
| 279 | − purpose, | |
| 280 | − value, | |
| 281 | − createdAt: now, | |
| 282 | − updatedAt: now, | |
| 703 | + const stored = yield* loadStoredSecretMaterial({ | |
| 704 | + id: ref.handle, | |
| 705 | + runtime, | |
| 706 | + operation: "local.update", | |
| 283 | 707 | }); |
| 708 | + if (stored.providerId !== LOCAL_SECRET_PROVIDER_ID) { | |
| 709 | + return yield* Effect.fail( | |
| 710 | + new Error( | |
| 711 | + `local.update: secret ${stored.id} is stored in provider '${stored.providerId}', not '${LOCAL_SECRET_PROVIDER_ID}'`, | |
| 712 | + ), | |
| 713 | + ); | |
| 714 | + } | |
| 715 | + | |
| 716 | + if (name === undefined && value === undefined) { | |
| 717 | + return toSecretMaterialSummary(stored); | |
| 718 | + } | |
| 719 | + | |
| 720 | + const updated = yield* runtime.rows.secretMaterials.updateById( | |
| 721 | + stored.id, | |
| 722 | + { | |
| 723 | + ...(name !== undefined ? { name } : {}), | |
| 724 | + ...(value !== undefined ? { value } : {}), | |
| 725 | + }, | |
| 726 | + ); | |
| 727 | + if (Option.isNone(updated)) { | |
| 728 | + return yield* Effect.fail(new Error(`local.update: secret material not found: ${stored.id}`)); | |
| 729 | + } | |
| 284 | 730 | |
| 285 | 731 | return { |
| 286 | − providerId: LOCAL_SECRET_PROVIDER_ID, | |
| 287 | − handle: id, | |
| 288 | − } satisfies SecretRef; | |
| 732 | + id: updated.value.id, | |
| 733 | + providerId: updated.value.providerId, | |
| 734 | + name: updated.value.name, | |
| 735 | + purpose: updated.value.purpose, | |
| 736 | + createdAt: updated.value.createdAt, | |
| 737 | + updatedAt: updated.value.updatedAt, | |
| 738 | + } satisfies SecretMaterialSummary; | |
| 289 | 739 | }), |
| 290 | 740 | |
| 291 | 741 | remove: ({ ref, runtime }) => |
| @@ -295,190 +745,117 @@ const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({ · 3 unmodified lines | ||
| 295 | 745 | }), |
| 296 | 746 | }); |
| 297 | 747 | |
| 298 | −const keychainStoreWithSecurityCli = (): SecretMaterialProvider => ({ | |
| 299 | − resolve: ({ ref, runtime }) => { | |
| 300 | − const id = parseKeychainHandle(ref.handle); | |
| 301 | − if (id === null) { | |
| 302 | − return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`)); | |
| 303 | − } | |
| 748 | +const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => ({ | |
| 749 | + resolve: ({ ref, runtime }) => | |
| 750 | + Effect.gen(function* () { | |
| 751 | + const loaded = yield* loadManagedKeychainRef({ | |
| 752 | + ref, | |
| 753 | + runtime, | |
| 754 | + operation: "keychain.get", | |
| 755 | + }); | |
| 304 | 756 | |
| 305 | − return runCommand({ | |
| 306 | − command: "security", | |
| 307 | − args: [ | |
| 308 | − "find-generic-password", | |
| 309 | − "-a", | |
| 310 | − id, | |
| 311 | − "-s", | |
| 312 | − runtime.keychainServiceName, | |
| 313 | − "-w", | |
| 314 | − ], | |
| 315 | − operation: "keychain.get", | |
| 316 | − }).pipe( | |
| 317 | − Effect.flatMap((result) => | |
| 318 | − ensureCommandSuccess({ | |
| 319 | − result, | |
| 320 | − operation: "keychain.get", | |
| 321 | − message: "Failed loading secret from macOS keychain", | |
| 322 | − }), | |
| 323 | − ), | |
| 324 | − Effect.map((result) => result.stdout.trimEnd()), | |
| 325 | − ); | |
| 326 | − }, | |
| 757 | + return yield* readKeychainSecretValue({ | |
| 758 | + providerHandle: loaded.providerHandle, | |
| 759 | + runtime, | |
| 760 | + }); | |
| 761 | + }), | |
| 327 | 762 | |
| 328 | − store: ({ name, value, runtime }) => { | |
| 329 | − const id = randomUUID(); | |
| 330 | − | |
| 331 | − return runCommand({ | |
| 332 | − command: "security", | |
| 333 | − args: [ | |
| 334 | − "add-generic-password", | |
| 335 | − "-a", | |
| 336 | − id, | |
| 337 | − "-s", | |
| 338 | − runtime.keychainServiceName, | |
| 339 | − "-w", | |
| 763 | + store: ({ purpose, value, name, runtime }) => | |
| 764 | + Effect.gen(function* () { | |
| 765 | + const providerHandle = randomUUID(); | |
| 766 | + yield* writeKeychainSecretValue({ | |
| 767 | + providerHandle, | |
| 768 | + name, | |
| 340 | 769 | value, |
| 341 | − ...(trimOrNull(name) ? ["-l", trimOrNull(name)!] : []), | |
| 342 | − "-U", | |
| 343 | − ], | |
| 344 | − operation: "keychain.put", | |
| 345 | − }).pipe( | |
| 346 | − Effect.flatMap((result) => | |
| 347 | − ensureCommandSuccess({ | |
| 348 | − result, | |
| 349 | − operation: "keychain.put", | |
| 350 | − message: "Failed storing secret in macOS keychain", | |
| 351 | − }), | |
| 352 | − ), | |
| 353 | − Effect.as({ | |
| 354 | − providerId: KEYCHAIN_SECRET_PROVIDER_ID, | |
| 355 | − handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`, | |
| 356 | − } satisfies SecretRef), | |
| 357 | − ); | |
| 358 | − }, | |
| 770 | + runtime, | |
| 771 | + }); | |
| 359 | 772 | |
| 360 | − remove: ({ ref, runtime }) => { | |
| 361 | − const id = parseKeychainHandle(ref.handle); | |
| 362 | − if (id === null) { | |
| 363 | − return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`)); | |
| 364 | − } | |
| 773 | + return yield* createSecretMaterialMetadata({ | |
| 774 | + providerId: KEYCHAIN_SECRET_PROVIDER_ID, | |
| 775 | + providerHandle, | |
| 776 | + purpose, | |
| 777 | + value: null, | |
| 778 | + name, | |
| 779 | + runtime, | |
| 780 | + }); | |
| 781 | + }), | |
| 365 | 782 | |
| 366 | − return runCommand({ | |
| 367 | − command: "security", | |
| 368 | − args: [ | |
| 369 | − "delete-generic-password", | |
| 370 | − "-a", | |
| 371 | − id, | |
| 372 | − "-s", | |
| 373 | − runtime.keychainServiceName, | |
| 374 | − ], | |
| 375 | − operation: "keychain.delete", | |
| 376 | − }).pipe( | |
| 377 | − Effect.map((result) => result.exitCode === 0), | |
| 378 | − ); | |
| 379 | − }, | |
| 380 | −}); | |
| 783 | + update: ({ ref, name, value, runtime }) => | |
| 784 | + Effect.gen(function* () { | |
| 785 | + const loaded = yield* loadManagedKeychainRef({ | |
| 786 | + ref, | |
| 787 | + runtime, | |
| 788 | + operation: "keychain.update", | |
| 789 | + }); | |
| 790 | + if (loaded.material === null) { | |
| 791 | + return yield* Effect.fail( | |
| 792 | + new Error(`keychain.update: legacy keychain refs cannot be updated: ${ref.handle}`), | |
| 793 | + ); | |
| 794 | + } | |
| 381 | 795 | |
| 382 | −const keychainStoreWithSecretTool = (): SecretMaterialProvider => ({ | |
| 383 | − resolve: ({ ref, runtime }) => { | |
| 384 | − const id = parseKeychainHandle(ref.handle); | |
| 385 | − if (id === null) { | |
| 386 | − return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`)); | |
| 387 | − } | |
| 796 | + if (name === undefined && value === undefined) { | |
| 797 | + return toSecretMaterialSummary(loaded.material); | |
| 798 | + } | |
| 388 | 799 | |
| 389 | − return runCommand({ | |
| 390 | − command: "secret-tool", | |
| 391 | − args: [ | |
| 392 | − "lookup", | |
| 393 | − "service", | |
| 394 | − runtime.keychainServiceName, | |
| 395 | − "account", | |
| 396 | − id, | |
| 397 | − ], | |
| 398 | − operation: "keychain.get", | |
| 399 | − }).pipe( | |
| 400 | − Effect.flatMap((result) => | |
| 401 | − ensureCommandSuccess({ | |
| 402 | − result, | |
| 403 | − operation: "keychain.get", | |
| 404 | − message: "Failed loading secret from desktop keyring", | |
| 405 | − }), | |
| 406 | − ), | |
| 407 | − Effect.map((result) => result.stdout.trimEnd()), | |
| 408 | − ); | |
| 409 | − }, | |
| 800 | + const nextName = name ?? loaded.material.name; | |
| 801 | + const nextValue = value | |
| 802 | + ?? (yield* readKeychainSecretValue({ | |
| 803 | + providerHandle: loaded.providerHandle, | |
| 804 | + runtime, | |
| 805 | + })); | |
| 410 | 806 | |
| 411 | − store: ({ name, value, runtime }) => { | |
| 412 | − const id = randomUUID(); | |
| 413 | − | |
| 414 | − return runCommand({ | |
| 415 | − command: "secret-tool", | |
| 416 | − args: [ | |
| 417 | − "store", | |
| 418 | − "--label", | |
| 419 | − trimOrNull(name) ?? runtime.keychainServiceName, | |
| 420 | − "service", | |
| 421 | − runtime.keychainServiceName, | |
| 422 | − "account", | |
| 423 | − id, | |
| 424 | − ], | |
| 425 | − stdin: value, | |
| 426 | − operation: "keychain.put", | |
| 427 | − }).pipe( | |
| 428 | − Effect.flatMap((result) => | |
| 429 | − ensureCommandSuccess({ | |
| 430 | − result, | |
| 431 | − operation: "keychain.put", | |
| 432 | − message: "Failed storing secret in desktop keyring", | |
| 433 | − }), | |
| 434 | − ), | |
| 435 | − Effect.as({ | |
| 436 | − providerId: KEYCHAIN_SECRET_PROVIDER_ID, | |
| 437 | − handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`, | |
| 438 | − } satisfies SecretRef), | |
| 439 | − ); | |
| 440 | − }, | |
| 807 | + yield* writeKeychainSecretValue({ | |
| 808 | + providerHandle: loaded.providerHandle, | |
| 809 | + name: nextName, | |
| 810 | + value: nextValue, | |
| 811 | + runtime, | |
| 812 | + }); | |
| 441 | 813 | |
| 442 | − remove: ({ ref, runtime }) => { | |
| 443 | − const id = parseKeychainHandle(ref.handle); | |
| 444 | − if (id === null) { | |
| 445 | − return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`)); | |
| 446 | − } | |
| 814 | + const updated = yield* runtime.rows.secretMaterials.updateById( | |
| 815 | + loaded.material.id, | |
| 816 | + { | |
| 817 | + name: nextName, | |
| 818 | + }, | |
| 819 | + ); | |
| 820 | + if (Option.isNone(updated)) { | |
| 821 | + return yield* Effect.fail( | |
| 822 | + new Error(`keychain.update: secret material not found: ${loaded.material.id}`), | |
| 823 | + ); | |
| 824 | + } | |
| 447 | 825 | |
| 448 | − return runCommand({ | |
| 449 | − command: "secret-tool", | |
| 450 | − args: [ | |
| 451 | − "clear", | |
| 452 | − "service", | |
| 453 | − runtime.keychainServiceName, | |
| 454 | − "account", | |
| 455 | − id, | |
| 456 | − ], | |
| 457 | − operation: "keychain.delete", | |
| 458 | − }).pipe( | |
| 459 | − Effect.map((result) => result.exitCode === 0), | |
| 460 | − ); | |
| 461 | − }, | |
| 462 | −}); | |
| 826 | + return { | |
| 827 | + id: updated.value.id, | |
| 828 | + providerId: updated.value.providerId, | |
| 829 | + name: updated.value.name, | |
| 830 | + purpose: updated.value.purpose, | |
| 831 | + createdAt: updated.value.createdAt, | |
| 832 | + updatedAt: updated.value.updatedAt, | |
| 833 | + } satisfies SecretMaterialSummary; | |
| 834 | + }), | |
| 463 | 835 | |
| 464 | −const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => { | |
| 465 | − if (process.platform === "darwin") { | |
| 466 | − return keychainStoreWithSecurityCli(); | |
| 467 | − } | |
| 836 | + remove: ({ ref, runtime }) => | |
| 837 | + Effect.gen(function* () { | |
| 838 | + const loaded = yield* loadManagedKeychainRef({ | |
| 839 | + ref, | |
| 840 | + runtime, | |
| 841 | + operation: "keychain.delete", | |
| 842 | + }); | |
| 843 | + const deleted = yield* deleteKeychainSecretValue({ | |
| 844 | + providerHandle: loaded.providerHandle, | |
| 845 | + runtime, | |
| 846 | + }); | |
| 468 | 847 | |
| 469 | − if (process.platform === "linux") { | |
| 470 | − return keychainStoreWithSecretTool(); | |
| 471 | − } | |
| 848 | + if (loaded.material === null) { | |
| 849 | + return deleted; | |
| 850 | + } | |
| 472 | 851 | |
| 473 | − const unsupported = (operation: string) => | |
| 474 | − Effect.fail(new Error(`${operation}: keychain provider is unsupported on platform '${process.platform}'`)); | |
| 852 | + if (!deleted) { | |
| 853 | + return false; | |
| 854 | + } | |
| 475 | 855 | |
| 476 | − return { | |
| 477 | − resolve: () => unsupported("keychain.get"), | |
| 478 | − store: () => unsupported("keychain.put"), | |
| 479 | − remove: () => Effect.succeed(false), | |
| 480 | − } satisfies SecretMaterialProvider; | |
| 481 | −}; | |
| 856 | + return yield* runtime.rows.secretMaterials.removeById(loaded.material.id); | |
| 857 | + }), | |
| 858 | +}); | |
| 482 | 859 | |
| 483 | 860 | const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry => |
| 484 | 861 | new Map([ |
| @@ -707,10 +1084,13 @@ export const createDefaultSecretMaterialStorer = (input: { · 222 unmodified lines | ||
| 707 | 1084 | }): StoreSecretMaterial => { |
| 708 | 1085 | const providers = createSecretMaterialProviderRegistry(); |
| 709 | 1086 | const runtime = createSecretMaterialProviderRuntime(input); |
| 710 | − const defaultStoreProviderId = resolveSecretStoreProviderId(input.storeProviderId); | |
| 711 | 1087 | |
| 712 | 1088 | return ({ purpose, value, name }) => |
| 713 | 1089 | Effect.gen(function* () { |
| 1090 | + const defaultStoreProviderId = yield* resolveDefaultSecretStoreProviderId({ | |
| 1091 | + storeProviderId: input.storeProviderId, | |
| 1092 | + env: runtime.env, | |
| 1093 | + }); | |
| 714 | 1094 | const provider = yield* getSecretMaterialProvider({ |
| 715 | 1095 | providers, |
| 716 | 1096 | providerId: defaultStoreProviderId, |
| @@ -731,6 +1111,36 @@ export const createDefaultSecretMaterialStorer = (input: { · 14 unmodified lines | ||
| 731 | 1111 | }); |
| 732 | 1112 | }; |
| 733 | 1113 | |
| 1114 | +export const createDefaultSecretMaterialUpdater = (input: { | |
| 1115 | + rows: ControlPlaneStoreShape; | |
| 1116 | + dangerouslyAllowEnvSecrets?: boolean; | |
| 1117 | + keychainServiceName?: string; | |
| 1118 | +}): UpdateSecretMaterial => { | |
| 1119 | + const providers = createSecretMaterialProviderRegistry(); | |
| 1120 | + const runtime = createSecretMaterialProviderRuntime(input); | |
| 1121 | + | |
| 1122 | + return ({ ref, name, value }) => | |
| 1123 | + Effect.gen(function* () { | |
| 1124 | + const provider = yield* getSecretMaterialProvider({ | |
| 1125 | + providers, | |
| 1126 | + providerId: ref.providerId, | |
| 1127 | + }); | |
| 1128 | + | |
| 1129 | + if (!provider.update) { | |
| 1130 | + return yield* Effect.fail( | |
| 1131 | + new Error(`Secret provider ${ref.providerId} does not support updating secret material`), | |
| 1132 | + ); | |
| 1133 | + } | |
| 1134 | + | |
| 1135 | + return yield* provider.update({ | |
| 1136 | + ref, | |
| 1137 | + name, | |
| 1138 | + value, | |
| 1139 | + runtime, | |
| 1140 | + }); | |
| 1141 | + }); | |
| 1142 | +}; | |
| 1143 | + | |
| 734 | 1144 | export const createDefaultSecretMaterialDeleter = (input: { |
| 735 | 1145 | rows: ControlPlaneStoreShape; |
| 736 | 1146 | dangerouslyAllowEnvSecrets?: boolean; |
| @@ -835,6 +1245,23 @@ export const SecretMaterialDeleterLive = (input: { · 98 unmodified lines | ||
| 835 | 1245 | }), |
| 836 | 1246 | ); |
| 837 | 1247 | |
| 1248 | +export const SecretMaterialUpdaterLive = (input: { | |
| 1249 | + dangerouslyAllowEnvSecrets?: boolean; | |
| 1250 | + keychainServiceName?: string; | |
| 1251 | +} = {}) => | |
| 1252 | + Layer.effect( | |
| 1253 | + SecretMaterialUpdaterService, | |
| 1254 | + Effect.gen(function* () { | |
| 1255 | + const rows = yield* ControlPlaneStore; | |
| 1256 | + | |
| 1257 | + return createDefaultSecretMaterialUpdater({ | |
| 1258 | + rows, | |
| 1259 | + dangerouslyAllowEnvSecrets: input.dangerouslyAllowEnvSecrets, | |
| 1260 | + keychainServiceName: input.keychainServiceName, | |
| 1261 | + }); | |
| 1262 | + }), | |
| 1263 | + ); | |
| 1264 | + | |
| 838 | 1265 | export const SecretMaterialLive = (input: { |
| 839 | 1266 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 840 | 1267 | storeProviderId?: SecretStoreProviderId; |
| @@ -847,4 +1274,5 @@ export const SecretMaterialLive = (input: { · 6 unmodified lines | ||
| 847 | 1274 | SecretMaterialResolverLive(input), |
| 848 | 1275 | SecretMaterialStorerLive(input), |
| 849 | 1276 | SecretMaterialDeleterLive(input), |
| 1277 | + SecretMaterialUpdaterLive(input), | |
| 850 | 1278 | ); |
| @@ -14,6 +14,7 @@ import type { · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | type SecretMaterialSummary = { |
| 16 | 16 | id: string; |
| 17 | + providerId: string; | |
| 17 | 18 | name: string | null; |
| 18 | 19 | purpose: string; |
| 19 | 20 | createdAt: number; |
| @@ -10,7 +10,7 @@ export const SecretMaterialPurposeSchema = Schema.Literal( · 9 unmodified lines | ||
| 10 | 10 | "oauth_client_info", |
| 11 | 11 | ); |
| 12 | 12 | |
| 13 | −export const SecretMaterialSchema = Schema.Struct({ | |
| 13 | +const LegacySecretMaterialSchema = Schema.Struct({ | |
| 14 | 14 | id: SecretMaterialIdSchema, |
| 15 | 15 | name: Schema.NullOr(Schema.String), |
| 16 | 16 | purpose: SecretMaterialPurposeSchema, |
| @@ -19,5 +19,44 @@ export const SecretMaterialSchema = Schema.Struct({ · 2 unmodified lines | ||
| 19 | 19 | updatedAt: TimestampMsSchema, |
| 20 | 20 | }); |
| 21 | 21 | |
| 22 | +const SecretMaterialStorageSchema = Schema.Struct({ | |
| 23 | + id: SecretMaterialIdSchema, | |
| 24 | + name: Schema.NullOr(Schema.String), | |
| 25 | + purpose: SecretMaterialPurposeSchema, | |
| 26 | + providerId: Schema.String, | |
| 27 | + handle: Schema.String, | |
| 28 | + value: Schema.NullOr(Schema.String), | |
| 29 | + createdAt: TimestampMsSchema, | |
| 30 | + updatedAt: TimestampMsSchema, | |
| 31 | +}); | |
| 32 | + | |
| 33 | +export const SecretMaterialSchema = Schema.transform( | |
| 34 | + Schema.Union(LegacySecretMaterialSchema, SecretMaterialStorageSchema), | |
| 35 | + SecretMaterialStorageSchema, | |
| 36 | + { | |
| 37 | + strict: false, | |
| 38 | + decode: (row) => ({ | |
| 39 | + id: row.id, | |
| 40 | + name: row.name, | |
| 41 | + purpose: row.purpose, | |
| 42 | + providerId: "providerId" in row ? row.providerId : "local", | |
| 43 | + handle: "handle" in row ? row.handle : row.id, | |
| 44 | + value: row.value, | |
| 45 | + createdAt: row.createdAt, | |
| 46 | + updatedAt: row.updatedAt, | |
| 47 | + }), | |
| 48 | + encode: (material) => ({ | |
| 49 | + id: material.id, | |
| 50 | + name: material.name, | |
| 51 | + purpose: material.purpose, | |
| 52 | + providerId: material.providerId, | |
| 53 | + handle: material.handle, | |
| 54 | + value: material.value, | |
| 55 | + createdAt: material.createdAt, | |
| 56 | + updatedAt: material.updatedAt, | |
| 57 | + }), | |
| 58 | + }, | |
| 59 | +); | |
| 60 | + | |
| 22 | 61 | export type SecretMaterialPurpose = typeof SecretMaterialPurposeSchema.Type; |
| 23 | 62 | export type SecretMaterial = typeof SecretMaterialSchema.Type; |