Unified · Split
| @@ -1,10 +1,11 @@ | |||
| 1 | import { useState, type ReactNode } from "react"; | 1 | import { useEffect, useState, type ReactNode } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { | 3 | import { |
| 4 | type CompleteSourceOAuthResult, | 4 | type CompleteSourceOAuthResult, |
| 5 | type ConnectSourcePayload, | 5 | type ConnectSourcePayload, |
| 6 | type ConnectSourceResult, | 6 | type ConnectSourceResult, |
| 7 | type DiscoverSourcePayload, | 7 | type DiscoverSourcePayload, |
| 8 | type InstanceConfig, | ||
| 8 | type Loadable, | 9 | type Loadable, |
| 9 | type SecretListItem, | 10 | type SecretListItem, |
| 10 | type Source, | 11 | type Source, |
| @@ -12,6 +13,7 @@ import { · 1 unmodified line | |||
| 12 | useConnectSource, | 13 | useConnectSource, |
| 13 | useCreateSecret, | 14 | useCreateSecret, |
| 14 | useDiscoverSource, | 15 | useDiscoverSource, |
| 16 | useInstanceConfig, | ||
| 15 | useRefreshSecrets, | 17 | useRefreshSecrets, |
| 16 | useSecrets, | 18 | useSecrets, |
| 17 | useStartSourceOAuth, | 19 | useStartSourceOAuth, |
| @@ -590,6 +592,7 @@ export function AddSourcePage() { · 572 unmodified lines | |||
| 590 | const navigate = useNavigate(); | 592 | const navigate = useNavigate(); |
| 591 | const discoverSource = useDiscoverSource(); | 593 | const discoverSource = useDiscoverSource(); |
| 592 | const connectSource = useConnectSource(); | 594 | const connectSource = useConnectSource(); |
| 595 | const instanceConfig = useInstanceConfig(); | ||
| 593 | const secrets = useSecrets(); | 596 | const secrets = useSecrets(); |
| 594 | const refreshSecrets = useRefreshSecrets(); | 597 | const refreshSecrets = useRefreshSecrets(); |
| 595 | const startSourceOAuth = useStartSourceOAuth(); | 598 | const startSourceOAuth = useStartSourceOAuth(); |
| @@ -1209,6 +1212,7 @@ export function AddSourcePage() { · 613 unmodified lines | |||
| 1209 | {connectForm.authKind === "bearer" && ( | 1212 | {connectForm.authKind === "bearer" && ( |
| 1210 | <Field label="Token" className="sm:col-span-2"> | 1213 | <Field label="Token" className="sm:col-span-2"> |
| 1211 | <SecretOrTokenInput | 1214 | <SecretOrTokenInput |
| 1215 | instanceConfig={instanceConfig} | ||
| 1212 | secrets={secrets} | 1216 | secrets={secrets} |
| 1213 | providerId={connectForm.bearerProviderId} | 1217 | providerId={connectForm.bearerProviderId} |
| 1214 | handle={connectForm.bearerHandle} | 1218 | handle={connectForm.bearerHandle} |
| @@ -1408,6 +1412,7 @@ const CREATE_NEW_VALUE = "__create_new__"; · 193 unmodified lines | |||
| 1408 | const INLINE_TOKEN_VALUE = "__inline_token__"; | 1412 | const INLINE_TOKEN_VALUE = "__inline_token__"; |
| 1409 | 1413 | ||
| 1410 | function SecretOrTokenInput(props: { | 1414 | function SecretOrTokenInput(props: { |
| 1415 | instanceConfig: Loadable<InstanceConfig>; | ||
| 1411 | secrets: Loadable<ReadonlyArray<SecretListItem>>; | 1416 | secrets: Loadable<ReadonlyArray<SecretListItem>>; |
| 1412 | providerId: string; | 1417 | providerId: string; |
| 1413 | handle: string; | 1418 | handle: string; |
| @@ -1415,13 +1420,35 @@ function SecretOrTokenInput(props: { · 1 unmodified line | |||
| 1415 | onSelectSecret: (providerId: string, handle: string) => void; | 1420 | onSelectSecret: (providerId: string, handle: string) => void; |
| 1416 | onChangeToken: (token: string) => void; | 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 | const createSecret = useCreateSecret(); | 1432 | const createSecret = useCreateSecret(); |
| 1420 | const [showCreate, setShowCreate] = useState(false); | 1433 | const [showCreate, setShowCreate] = useState(false); |
| 1421 | const [useInline, setUseInline] = useState(false); | 1434 | const [useInline, setUseInline] = useState(false); |
| 1422 | const [newName, setNewName] = useState(""); | 1435 | const [newName, setNewName] = useState(""); |
| 1423 | const [newValue, setNewValue] = useState(""); | 1436 | const [newValue, setNewValue] = useState(""); |
| 1437 | const [newProviderId, setNewProviderId] = useState(""); | ||
| 1424 | const [createError, setCreateError] = useState<string | null>(null); | 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 | const selectedValue = showCreate ? CREATE_NEW_VALUE : useInline ? INLINE_TOKEN_VALUE : (handle || ""); | 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 | setUseInline(false); | 1458 | setUseInline(false); |
| 1432 | setNewName(""); | 1459 | setNewName(""); |
| 1433 | setNewValue(""); | 1460 | setNewValue(""); |
| 1461 | setNewProviderId( | ||
| 1462 | instanceConfig.status === "ready" | ||
| 1463 | ? instanceConfig.data.defaultSecretStoreProvider | ||
| 1464 | : "", | ||
| 1465 | ); | ||
| 1434 | setCreateError(null); | 1466 | setCreateError(null); |
| 1435 | return; | 1467 | return; |
| 1436 | } | 1468 | } |
| @@ -1448,7 +1480,10 @@ function SecretOrTokenInput(props: { · 11 unmodified lines | |||
| 1448 | } | 1480 | } |
| 1449 | setUseInline(false); | 1481 | setUseInline(false); |
| 1450 | setShowCreate(false); | 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 | const handleCreate = async () => { | 1489 | const handleCreate = async () => { |
| @@ -1464,7 +1499,11 @@ function SecretOrTokenInput(props: { · 9 unmodified lines | |||
| 1464 | } | 1499 | } |
| 1465 | 1500 | ||
| 1466 | try { | 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 | onSelectSecret(result.providerId, result.id); | 1507 | onSelectSecret(result.providerId, result.id); |
| 1469 | setShowCreate(false); | 1508 | setShowCreate(false); |
| 1470 | } catch (err) { | 1509 | } catch (err) { |
| @@ -1495,7 +1534,7 @@ function SecretOrTokenInput(props: { · 24 unmodified lines | |||
| 1495 | <option value="">Select a secret...</option> | 1534 | <option value="">Select a secret...</option> |
| 1496 | {items.map((secret) => ( | 1535 | {items.map((secret) => ( |
| 1497 | <option key={secret.id} value={secret.id}> | 1536 | <option key={secret.id} value={secret.id}> |
| 1498 | {secret.name || secret.id} | 1537 | {secret.name || secret.id} ({secret.providerId}) |
| 1499 | </option> | 1538 | </option> |
| 1500 | ))} | 1539 | ))} |
| 1501 | <option value={INLINE_TOKEN_VALUE}>Paste token directly</option> | 1540 | <option value={INLINE_TOKEN_VALUE}>Paste token directly</option> |
| @@ -1541,6 +1580,20 @@ function SecretOrTokenInput(props: { · 39 unmodified lines | |||
| 1541 | 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" | 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 | </label> | 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 | </div> | 1597 | </div> |
| 1545 | <div className="flex items-center justify-end gap-2"> | 1598 | <div className="flex items-center justify-end gap-2"> |
| 1546 | <button | 1599 | <button |
| @@ -1,13 +1,14 @@ | |||
| 1 | import { useState, type ReactNode } from "react"; | 1 | import { useEffect, useState, type ReactNode } from "react"; |
| 2 | import { Link } from "@tanstack/react-router"; | 2 | import { Link } from "@tanstack/react-router"; |
| 3 | import { | 3 | import { |
| 4 | type InstanceConfig, | ||
| 4 | type SecretListItem, | 5 | type SecretListItem, |
| 5 | type Loadable, | 6 | type Loadable, |
| 6 | useSecrets, | 7 | useSecrets, |
| 7 | useCreateSecret, | 8 | useCreateSecret, |
| 8 | useUpdateSecret, | 9 | useUpdateSecret, |
| 9 | useDeleteSecret, | 10 | useDeleteSecret, |
| 10 | useRefreshSecrets, | 11 | useInstanceConfig, |
| 11 | } from "@executor/react"; | 12 | } from "@executor/react"; |
| 12 | import { LoadableBlock } from "../components/loadable"; | 13 | import { LoadableBlock } from "../components/loadable"; |
| 13 | import { Button } from "../components/ui/button"; | 14 | import { Button } from "../components/ui/button"; |
| @@ -19,10 +20,26 @@ import { cn } from "../lib/utils"; · 5 unmodified lines | |||
| 19 | // Page | 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 | export function SecretsPage() { | 33 | export function SecretsPage() { |
| 34 | const instanceConfig = useInstanceConfig(); | ||
| 23 | const secrets = useSecrets(); | 35 | const secrets = useSecrets(); |
| 24 | const [showCreate, setShowCreate] = useState(false); | 36 | const [showCreate, setShowCreate] = useState(false); |
| 25 | const [editingId, setEditingId] = useState<string | null>(null); | 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 | return ( | 44 | return ( |
| 28 | <div className="flex-1 overflow-y-auto"> | 45 | <div className="flex-1 overflow-y-auto"> |
| @@ -34,7 +51,8 @@ export function SecretsPage() { · 5 unmodified lines | |||
| 34 | Secrets | 51 | Secrets |
| 35 | </h1> | 52 | </h1> |
| 36 | <p className="mt-1.5 text-[14px] text-muted-foreground"> | 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 | </p> | 56 | </p> |
| 39 | </div> | 57 | </div> |
| 40 | <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}> | 58 | <Button size="sm" onClick={() => { setShowCreate(true); setEditingId(null); }}> |
| @@ -48,6 +66,12 @@ export function SecretsPage() { · 7 unmodified lines | |||
| 48 | <CreateSecretForm | 66 | <CreateSecretForm |
| 49 | onClose={() => setShowCreate(false)} | 67 | onClose={() => setShowCreate(false)} |
| 50 | className="mb-6" | 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 | <SecretRow | 86 | <SecretRow |
| 63 | key={secret.id} | 87 | key={secret.id} |
| 64 | secret={secret} | 88 | secret={secret} |
| 89 | providerLabel={providerLabelForId(instanceConfig, secret.providerId)} | ||
| 65 | isEditing={editingId === secret.id} | 90 | isEditing={editingId === secret.id} |
| 66 | onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)} | 91 | onEdit={() => setEditingId(editingId === secret.id ? null : secret.id)} |
| 67 | onCancelEdit={() => setEditingId(null)} | 92 | onCancelEdit={() => setEditingId(null)} |
| @@ -95,7 +120,7 @@ function EmptyState(props: { onAdd: () => void }) { · 27 unmodified lines | |||
| 95 | No secrets stored | 120 | No secrets stored |
| 96 | </p> | 121 | </p> |
| 97 | <p className="text-[13px] text-muted-foreground/60 mb-5"> | 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 | </p> | 124 | </p> |
| 100 | <Button size="sm" onClick={props.onAdd}> | 125 | <Button size="sm" onClick={props.onAdd}> |
| 101 | <IconPlus className="size-3.5" /> | 126 | <IconPlus className="size-3.5" /> |
| @@ -109,12 +134,27 @@ function EmptyState(props: { onAdd: () => void }) { · 7 unmodified lines | |||
| 109 | // Create form | 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 | const createSecret = useCreateSecret(); | 143 | const createSecret = useCreateSecret(); |
| 114 | const [name, setName] = useState(""); | 144 | const [name, setName] = useState(""); |
| 115 | const [value, setValue] = useState(""); | 145 | const [value, setValue] = useState(""); |
| 146 | const [providerId, setProviderId] = useState(props.defaultProviderId ?? ""); | ||
| 116 | const [error, setError] = useState<string | null>(null); | 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 | const handleSubmit = async () => { | 158 | const handleSubmit = async () => { |
| 119 | setError(null); | 159 | setError(null); |
| 120 | const trimmedName = name.trim(); | 160 | const trimmedName = name.trim(); |
| @@ -128,7 +168,11 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 7 unmodified lines | |||
| 128 | } | 168 | } |
| 129 | 169 | ||
| 130 | try { | 170 | try { |
| 131 | await createSecret.mutateAsync({ name: trimmedName, value }); | 171 | await createSecret.mutateAsync({ |
| 172 | name: trimmedName, | ||
| 173 | value, | ||
| 174 | ...(providerId ? { providerId } : {}), | ||
| 175 | }); | ||
| 132 | props.onClose(); | 176 | props.onClose(); |
| 133 | } catch (err) { | 177 | } catch (err) { |
| 134 | setError(err instanceof Error ? err.message : "Failed creating secret."); | 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 | 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" | 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 | </FieldLabel> | 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 | </div> | 232 | </div> |
| 176 | <div className="flex justify-end"> | 233 | <div className="flex justify-end"> |
| 177 | <Button | 234 | <Button |
| @@ -194,11 +251,12 @@ function CreateSecretForm(props: { onClose: () => void; className?: string }) { · 16 unmodified lines | |||
| 194 | 251 | ||
| 195 | function SecretRow(props: { | 252 | function SecretRow(props: { |
| 196 | secret: SecretListItem; | 253 | secret: SecretListItem; |
| 254 | providerLabel: string; | ||
| 197 | isEditing: boolean; | 255 | isEditing: boolean; |
| 198 | onEdit: () => void; | 256 | onEdit: () => void; |
| 199 | onCancelEdit: () => void; | 257 | onCancelEdit: () => void; |
| 200 | }) { | 258 | }) { |
| 201 | const { secret, isEditing } = props; | 259 | const { secret, providerLabel, isEditing } = props; |
| 202 | const deleteSecret = useDeleteSecret(); | 260 | const deleteSecret = useDeleteSecret(); |
| 203 | const [isDeleting, setIsDeleting] = useState(false); | 261 | const [isDeleting, setIsDeleting] = useState(false); |
| 204 | 262 | ||
| @@ -239,6 +297,9 @@ function SecretRow(props: { · 34 unmodified lines | |||
| 239 | <Badge variant="outline" className="text-[9px] shrink-0"> | 297 | <Badge variant="outline" className="text-[9px] shrink-0"> |
| 240 | {secret.purpose.replace(/_/g, " ")} | 298 | {secret.purpose.replace(/_/g, " ")} |
| 241 | </Badge> | 299 | </Badge> |
| 300 | <Badge variant="outline" className="text-[9px] shrink-0"> | ||
| 301 | {providerLabel} | ||
| 302 | </Badge> | ||
| 242 | </div> | 303 | </div> |
| 243 | <div className="flex items-center gap-2 mt-0.5"> | 304 | <div className="flex items-center gap-2 mt-0.5"> |
| 244 | <span className="text-[11px] font-mono text-muted-foreground/50 truncate"> | 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 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { | 3 | import { |
| 4 | type CompleteSourceOAuthResult, | 4 | type CompleteSourceOAuthResult, |
| @@ -1012,6 +1012,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 1007 unmodified lines | |||
| 1012 | {formState.authKind === "bearer" && ( | 1012 | {formState.authKind === "bearer" && ( |
| 1013 | <Field label="Token" className="sm:col-span-2"> | 1013 | <Field label="Token" className="sm:col-span-2"> |
| 1014 | <SecretPicker | 1014 | <SecretPicker |
| 1015 | instanceConfig={instanceConfig} | ||
| 1015 | secrets={secrets} | 1016 | secrets={secrets} |
| 1016 | providerId={formState.bearerProviderId} | 1017 | providerId={formState.bearerProviderId} |
| 1017 | handle={formState.bearerHandle} | 1018 | handle={formState.bearerHandle} |
| @@ -1030,6 +1031,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines | |||
| 1030 | <> | 1031 | <> |
| 1031 | <Field label="Access token" className="sm:col-span-2"> | 1032 | <Field label="Access token" className="sm:col-span-2"> |
| 1032 | <SecretPicker | 1033 | <SecretPicker |
| 1034 | instanceConfig={instanceConfig} | ||
| 1033 | secrets={secrets} | 1035 | secrets={secrets} |
| 1034 | providerId={formState.oauthAccessProviderId} | 1036 | providerId={formState.oauthAccessProviderId} |
| 1035 | handle={formState.oauthAccessHandle} | 1037 | handle={formState.oauthAccessHandle} |
| @@ -1041,6 +1043,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines | |||
| 1041 | </Field> | 1043 | </Field> |
| 1042 | <Field label="Refresh token (optional)" className="sm:col-span-2"> | 1044 | <Field label="Refresh token (optional)" className="sm:col-span-2"> |
| 1043 | <SecretPicker | 1045 | <SecretPicker |
| 1046 | instanceConfig={instanceConfig} | ||
| 1044 | secrets={secrets} | 1047 | secrets={secrets} |
| 1045 | providerId={formState.oauthRefreshProviderId} | 1048 | providerId={formState.oauthRefreshProviderId} |
| 1046 | handle={formState.oauthRefreshHandle} | 1049 | handle={formState.oauthRefreshHandle} |
| @@ -1218,18 +1221,33 @@ function StatusBanner(props: { state: StatusBannerState; className?: string }) { · 171 unmodified lines | |||
| 1218 | const CREATE_NEW_VALUE = "__create_new__"; | 1221 | const CREATE_NEW_VALUE = "__create_new__"; |
| 1219 | 1222 | ||
| 1220 | function SecretPicker(props: { | 1223 | function SecretPicker(props: { |
| 1224 | instanceConfig: Loadable<InstanceConfig>; | ||
| 1221 | secrets: Loadable<ReadonlyArray<SecretListItem>>; | 1225 | secrets: Loadable<ReadonlyArray<SecretListItem>>; |
| 1222 | providerId: string; | 1226 | providerId: string; |
| 1223 | handle: string; | 1227 | handle: string; |
| 1224 | onSelect: (providerId: string, handle: string) => void; | 1228 | onSelect: (providerId: string, handle: string) => void; |
| 1225 | allowEmpty?: boolean; | 1229 | allowEmpty?: boolean; |
| 1226 | }) { | 1230 | }) { |
| 1227 | const { secrets, providerId, handle, onSelect, allowEmpty } = props; | 1231 | const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } = props; |
| 1228 | const createSecret = useCreateSecret(); | 1232 | const createSecret = useCreateSecret(); |
| 1229 | const [showCreate, setShowCreate] = useState(false); | 1233 | const [showCreate, setShowCreate] = useState(false); |
| 1230 | const [newName, setNewName] = useState(""); | 1234 | const [newName, setNewName] = useState(""); |
| 1231 | const [newValue, setNewValue] = useState(""); | 1235 | const [newValue, setNewValue] = useState(""); |
| 1236 | const [newProviderId, setNewProviderId] = useState(""); | ||
| 1232 | const [createError, setCreateError] = useState<string | null>(null); | 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 | // Build the selected value key: if handle is set, use it (it's the secret ID) | 1252 | // Build the selected value key: if handle is set, use it (it's the secret ID) |
| 1235 | const selectedValue = handle || ""; | 1253 | const selectedValue = handle || ""; |
| @@ -1239,6 +1257,11 @@ function SecretPicker(props: { · 3 unmodified lines | |||
| 1239 | setShowCreate(true); | 1257 | setShowCreate(true); |
| 1240 | setNewName(""); | 1258 | setNewName(""); |
| 1241 | setNewValue(""); | 1259 | setNewValue(""); |
| 1260 | setNewProviderId( | ||
| 1261 | instanceConfig.status === "ready" | ||
| 1262 | ? instanceConfig.data.defaultSecretStoreProvider | ||
| 1263 | : "", | ||
| 1264 | ); | ||
| 1242 | setCreateError(null); | 1265 | setCreateError(null); |
| 1243 | return; | 1266 | return; |
| 1244 | } | 1267 | } |
| @@ -1246,8 +1269,10 @@ function SecretPicker(props: { · 1 unmodified line | |||
| 1246 | onSelect("", ""); | 1269 | onSelect("", ""); |
| 1247 | return; | 1270 | return; |
| 1248 | } | 1271 | } |
| 1249 | // Selecting an existing secret: providerId is always "postgres" | 1272 | const matchedSecret = secrets.status === "ready" |
| 1250 | onSelect("postgres", value); | 1273 | ? secrets.data.find((secret) => secret.id === value) |
| 1274 | : null; | ||
| 1275 | onSelect(matchedSecret?.providerId ?? "local", value); | ||
| 1251 | }; | 1276 | }; |
| 1252 | 1277 | ||
| 1253 | const handleCreate = async () => { | 1278 | const handleCreate = async () => { |
| @@ -1263,7 +1288,11 @@ function SecretPicker(props: { · 9 unmodified lines | |||
| 1263 | } | 1288 | } |
| 1264 | 1289 | ||
| 1265 | try { | 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 | onSelect(result.providerId, result.id); | 1296 | onSelect(result.providerId, result.id); |
| 1268 | setShowCreate(false); | 1297 | setShowCreate(false); |
| 1269 | } catch (err) { | 1298 | } catch (err) { |
| @@ -1286,7 +1315,7 @@ function SecretPicker(props: { · 16 unmodified lines | |||
| 1286 | 1315 | ||
| 1287 | // Check if the current handle matches a known secret | 1316 | // Check if the current handle matches a known secret |
| 1288 | const matchedSecret = items.find((s) => s.id === handle); | 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 | return ( | 1320 | return ( |
| 1292 | <div className="space-y-2"> | 1321 | <div className="space-y-2"> |
| @@ -1299,7 +1328,7 @@ function SecretPicker(props: { · 6 unmodified lines | |||
| 1299 | {!allowEmpty && !selectedValue && <option value="">Select a secret…</option>} | 1328 | {!allowEmpty && !selectedValue && <option value="">Select a secret…</option>} |
| 1300 | {items.map((secret) => ( | 1329 | {items.map((secret) => ( |
| 1301 | <option key={secret.id} value={secret.id}> | 1330 | <option key={secret.id} value={secret.id}> |
| 1302 | {secret.name || secret.id} | 1331 | {secret.name || secret.id} ({secret.providerId}) |
| 1303 | </option> | 1332 | </option> |
| 1304 | ))} | 1333 | ))} |
| 1305 | {isExternalRef && ( | 1334 | {isExternalRef && ( |
| @@ -1338,6 +1367,20 @@ function SecretPicker(props: { · 32 unmodified lines | |||
| 1338 | 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" | 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 | </label> | 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 | </div> | 1384 | </div> |
| 1342 | <div className="flex items-center justify-end gap-2"> | 1385 | <div className="flex items-center justify-end gap-2"> |
| 1343 | <button | 1386 | <button |
| @@ -34,6 +34,7 @@ export type SecretLinkedSource = typeof SecretLinkedSourceSchema.Type; · 33 unmodified lines | |||
| 34 | 34 | ||
| 35 | export const SecretListItemSchema = Schema.Struct({ | 35 | export const SecretListItemSchema = Schema.Struct({ |
| 36 | id: Schema.String, | 36 | id: Schema.String, |
| 37 | providerId: Schema.String, | ||
| 37 | name: Schema.NullOr(Schema.String), | 38 | name: Schema.NullOr(Schema.String), |
| 38 | purpose: Schema.String, | 39 | purpose: Schema.String, |
| 39 | createdAt: Schema.Number, | 40 | createdAt: Schema.Number, |
| @@ -47,6 +48,7 @@ export const CreateSecretPayloadSchema = Schema.Struct({ · 7 unmodified lines | |||
| 47 | name: Schema.String, | 48 | name: Schema.String, |
| 48 | value: Schema.String, | 49 | value: Schema.String, |
| 49 | purpose: Schema.optional(SecretMaterialPurposeSchema), | 50 | purpose: Schema.optional(SecretMaterialPurposeSchema), |
| 51 | providerId: Schema.optional(Schema.String), | ||
| 50 | }); | 52 | }); |
| 51 | 53 | ||
| 52 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; | 54 | export type CreateSecretPayload = typeof CreateSecretPayloadSchema.Type; |
| @@ -71,6 +73,7 @@ export type UpdateSecretPayload = typeof UpdateSecretPayloadSchema.Type; · 18 unmodified lines | |||
| 71 | 73 | ||
| 72 | export const UpdateSecretResultSchema = Schema.Struct({ | 74 | export const UpdateSecretResultSchema = Schema.Struct({ |
| 73 | id: Schema.String, | 75 | id: Schema.String, |
| 76 | providerId: Schema.String, | ||
| 74 | name: Schema.NullOr(Schema.String), | 77 | name: Schema.NullOr(Schema.String), |
| 75 | purpose: Schema.String, | 78 | purpose: Schema.String, |
| 76 | createdAt: Schema.Number, | 79 | createdAt: Schema.Number, |
| @@ -3,7 +3,6 @@ import { · 2 unmodified lines | |||
| 3 | } from "@effect/platform"; | 3 | } from "@effect/platform"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import { randomUUID } from "node:crypto"; | ||
| 7 | 6 | ||
| 8 | import { SecretMaterialIdSchema } from "#schema"; | 7 | import { SecretMaterialIdSchema } from "#schema"; |
| 9 | import { | 8 | import { |
| @@ -11,10 +10,14 @@ import { · 1 unmodified line | |||
| 11 | } from "../../runtime/local-operations"; | 10 | } from "../../runtime/local-operations"; |
| 12 | import { requireRuntimeLocalWorkspace } from "../../runtime/local-runtime-context"; | 11 | import { requireRuntimeLocalWorkspace } from "../../runtime/local-runtime-context"; |
| 13 | import { | 12 | import { |
| 13 | createDefaultSecretMaterialDeleter, | ||
| 14 | createDefaultSecretMaterialStorer, | ||
| 15 | createDefaultSecretMaterialUpdater, | ||
| 14 | ENV_SECRET_PROVIDER_ID, | 16 | ENV_SECRET_PROVIDER_ID, |
| 15 | KEYCHAIN_SECRET_PROVIDER_ID, | 17 | KEYCHAIN_SECRET_PROVIDER_ID, |
| 16 | LOCAL_SECRET_PROVIDER_ID, | 18 | LOCAL_SECRET_PROVIDER_ID, |
| 17 | parseSecretStoreProviderId, | 19 | parseSecretStoreProviderId, |
| 20 | resolveDefaultSecretStoreProviderId, | ||
| 18 | } from "../../runtime/secret-material-providers"; | 21 | } from "../../runtime/secret-material-providers"; |
| 19 | import { RuntimeSourceStoreService } from "../../runtime/source-store"; | 22 | import { RuntimeSourceStoreService } from "../../runtime/source-store"; |
| 20 | import { ControlPlaneStore } from "../../runtime/store"; | 23 | import { ControlPlaneStore } from "../../runtime/store"; |
| @@ -35,6 +38,8 @@ import { · 14 unmodified lines | |||
| 35 | const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; | 38 | const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; |
| 36 | 39 | ||
| 37 | const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { | 40 | const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { |
| 41 | const explicitDefaultStoreProvider = | ||
| 42 | parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV]); | ||
| 38 | const providers: SecretProvider[] = [ | 43 | const providers: SecretProvider[] = [ |
| 39 | { | 44 | { |
| 40 | id: LOCAL_SECRET_PROVIDER_ID, | 45 | id: LOCAL_SECRET_PROVIDER_ID, |
| @@ -47,7 +52,9 @@ const getInstanceConfig = (): Effect.Effect<InstanceConfig> => { · 6 unmodified lines | |||
| 47 | providers.push({ | 52 | providers.push({ |
| 48 | id: KEYCHAIN_SECRET_PROVIDER_ID, | 53 | id: KEYCHAIN_SECRET_PROVIDER_ID, |
| 49 | name: process.platform === "darwin" ? "macOS Keychain" : "Desktop Keyring", | 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 | canStore: false, | 64 | canStore: false, |
| 58 | }); | 65 | }); |
| 59 | 66 | ||
| 60 | const defaultStoreProvider = | 67 | return resolveDefaultSecretStoreProviderId({ |
| 61 | parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV]) | 68 | storeProviderId: explicitDefaultStoreProvider ?? undefined, |
| 62 | ?? LOCAL_SECRET_PROVIDER_ID; | 69 | }).pipe( |
| 63 | 70 | Effect.map((resolvedDefaultStoreProvider) => ({ | |
| 64 | return Effect.succeed({ | 71 | platform: process.platform, |
| 65 | platform: process.platform, | 72 | secretProviders: providers, |
| 66 | secretProviders: providers, | 73 | defaultSecretStoreProvider: resolvedDefaultStoreProvider, |
| 67 | defaultSecretStoreProvider: defaultStoreProvider, | 74 | })), |
| 68 | }); | 75 | ); |
| 69 | }; | 76 | }; |
| 70 | 77 | ||
| 71 | const storageError = (message: string) => | 78 | const storageError = (message: string) => |
| @@ -105,16 +112,19 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 33 unmodified lines | |||
| 105 | Effect.mapError(() => storageError("Failed loading linked sources.")), | 112 | Effect.mapError(() => storageError("Failed loading linked sources.")), |
| 106 | ); | 113 | ); |
| 107 | return rows.map((row) => ({ | 114 | return rows.map((row) => ({ |
| 108 | ...row, | 115 | ...row, |
| 109 | linkedSources: linkedSourcesMap.get(row.id) ?? [], | 116 | linkedSources: linkedSourcesMap.get(row.id) ?? [], |
| 110 | })); | 117 | })); |
| 111 | }), | 118 | }), |
| 112 | ) | 119 | ) |
| 113 | .handle("createSecret", ({ payload }) => | 120 | .handle("createSecret", ({ payload }) => |
| 114 | Effect.gen(function* () { | 121 | Effect.gen(function* () { |
| 115 | const name = payload.name.trim(); | 122 | const name = payload.name.trim(); |
| 116 | const value = payload.value; | 123 | const value = payload.value; |
| 117 | const purpose = payload.purpose ?? "auth_material"; | 124 | const purpose = payload.purpose ?? "auth_material"; |
| 125 | const requestedProviderId = payload.providerId === undefined | ||
| 126 | ? null | ||
| 127 | : parseSecretStoreProviderId(payload.providerId); | ||
| 118 | 128 | ||
| 119 | if (name.length === 0) { | 129 | if (name.length === 0) { |
| 120 | return yield* Effect.fail( | 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 | const store = yield* ControlPlaneStore; | 148 | const store = yield* ControlPlaneStore; |
| 130 | const now = Date.now(); | 149 | const storeSecretMaterial = createDefaultSecretMaterialStorer({ |
| 131 | const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`); | 150 | rows: store, |
| 132 | 151 | ...(requestedProviderId ? { storeProviderId: requestedProviderId } : {}), | |
| 133 | yield* store.secretMaterials.upsert({ | 152 | }); |
| 134 | id, | 153 | const ref = yield* storeSecretMaterial({ |
| 135 | name, | 154 | name, |
| 136 | purpose, | 155 | purpose, |
| 137 | value, | 156 | value, |
| 138 | createdAt: now, | ||
| 139 | updatedAt: now, | ||
| 140 | }).pipe( | 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 | return { | 171 | return { |
| 145 | id, | 172 | id: created.value.id, |
| 146 | name, | 173 | name: created.value.name, |
| 147 | providerId: LOCAL_SECRET_PROVIDER_ID, | 174 | providerId: created.value.providerId, |
| 148 | purpose, | 175 | purpose: created.value.purpose, |
| 149 | createdAt: now, | 176 | createdAt: created.value.createdAt, |
| 150 | updatedAt: now, | 177 | updatedAt: created.value.updatedAt, |
| 151 | } satisfies CreateSecretResult; | 178 | } satisfies CreateSecretResult; |
| 152 | }), | 179 | }), |
| 153 | ) | 180 | ) |
| @@ -174,26 +201,26 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 20 unmodified lines | |||
| 174 | if (payload.name !== undefined) update.name = payload.name.trim() || null; | 201 | if (payload.name !== undefined) update.name = payload.name.trim() || null; |
| 175 | if (payload.value !== undefined) update.value = payload.value; | 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 | Effect.mapError(() => storageError("Failed updating secret.")), | 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 | return { | 217 | return { |
| 192 | id: updated.value.id, | 218 | id: updated.id, |
| 193 | name: updated.value.name, | 219 | providerId: updated.providerId, |
| 194 | purpose: updated.value.purpose, | 220 | name: updated.name, |
| 195 | createdAt: updated.value.createdAt, | 221 | purpose: updated.purpose, |
| 196 | updatedAt: updated.value.updatedAt, | 222 | createdAt: updated.createdAt, |
| 223 | updatedAt: updated.updatedAt, | ||
| 197 | } satisfies UpdateSecretResult; | 224 | } satisfies UpdateSecretResult; |
| 198 | }), | 225 | }), |
| 199 | ) | 226 | ) |
| @@ -202,11 +229,11 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 2 unmodified lines | |||
| 202 | const secretId = SecretMaterialIdSchema.make(path.secretId); | 229 | const secretId = SecretMaterialIdSchema.make(path.secretId); |
| 203 | const store = yield* ControlPlaneStore; | 230 | const store = yield* ControlPlaneStore; |
| 204 | 231 | ||
| 205 | const removed = yield* store.secretMaterials.removeById(secretId).pipe( | 232 | const existing = yield* store.secretMaterials.getById(secretId).pipe( |
| 206 | Effect.mapError(() => storageError("Failed removing secret.")), | 233 | Effect.mapError(() => storageError("Failed looking up secret.")), |
| 207 | ); | 234 | ); |
| 208 | 235 | ||
| 209 | if (!removed) { | 236 | if (Option.isNone(existing)) { |
| 210 | return yield* Effect.fail( | 237 | return yield* Effect.fail( |
| 211 | new ControlPlaneNotFoundError({ | 238 | new ControlPlaneNotFoundError({ |
| 212 | operation: "secrets.delete", | 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 | return { removed: true }; | 260 | return { removed: true }; |
| 220 | }), | 261 | }), |
| 221 | ), | 262 | ), |
| @@ -197,6 +197,94 @@ describe("control-plane-runtime", () => { · 196 unmodified lines | |||
| 197 | 60_000, | 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 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => | 289 | it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => |
| 202 | Effect.gen(function* () { | 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 | import { tmpdir } from "node:os"; | 2 | import { tmpdir } from "node:os"; |
| 3 | import { join } from "node:path"; | 3 | import { dirname, join } from "node:path"; |
| 4 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as Effect from "effect/Effect"; | 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 | import { | 7 | import { |
| 8 | type LocalConfigSecretProvider, | 8 | type LocalConfigSecretProvider, |
| 9 | type LocalExecutorConfig, | 9 | type LocalExecutorConfig, |
| 10 | type SecretMaterial, | ||
| 10 | type SecretMaterialPurpose, | 11 | type SecretMaterialPurpose, |
| 11 | SecretMaterialIdSchema, | 12 | SecretMaterialIdSchema, |
| 12 | type SecretRef, | 13 | type SecretRef, |
| @@ -50,6 +51,19 @@ export type DeleteSecretMaterial = ( · 37 unmodified lines | |||
| 50 | ref: SecretRef, | 51 | ref: SecretRef, |
| 51 | ) => Effect.Effect<boolean, Error, never>; | 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 | export class SecretMaterialResolverService extends Context.Tag( | 67 | export class SecretMaterialResolverService extends Context.Tag( |
| 54 | "#runtime/SecretMaterialResolverService", | 68 | "#runtime/SecretMaterialResolverService", |
| 55 | )<SecretMaterialResolverService, ResolveSecretMaterial>() {} | 69 | )<SecretMaterialResolverService, ResolveSecretMaterial>() {} |
| @@ -62,6 +76,10 @@ export class SecretMaterialDeleterService extends Context.Tag( · 6 unmodified lines | |||
| 62 | "#runtime/SecretMaterialDeleterService", | 76 | "#runtime/SecretMaterialDeleterService", |
| 63 | )<SecretMaterialDeleterService, DeleteSecretMaterial>() {} | 77 | )<SecretMaterialDeleterService, DeleteSecretMaterial>() {} |
| 64 | 78 | ||
| 79 | export class SecretMaterialUpdaterService extends Context.Tag( | ||
| 80 | "#runtime/SecretMaterialUpdaterService", | ||
| 81 | )<SecretMaterialUpdaterService, UpdateSecretMaterial>() {} | ||
| 82 | |||
| 65 | type SecretMaterialProviderRuntime = { | 83 | type SecretMaterialProviderRuntime = { |
| 66 | rows: ControlPlaneStoreShape; | 84 | rows: ControlPlaneStoreShape; |
| 67 | env: NodeJS.ProcessEnv; | 85 | env: NodeJS.ProcessEnv; |
| @@ -83,6 +101,19 @@ type SecretMaterialProvider = { · 15 unmodified lines | |||
| 83 | name?: string | null; | 101 | name?: string | null; |
| 84 | runtime: SecretMaterialProviderRuntime; | 102 | runtime: SecretMaterialProviderRuntime; |
| 85 | }) => Effect.Effect<SecretRef, Error, never>; | 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 | remove?: (input: { | 117 | remove?: (input: { |
| 87 | ref: SecretRef; | 118 | ref: SecretRef; |
| 88 | runtime: SecretMaterialProviderRuntime; | 119 | runtime: SecretMaterialProviderRuntime; |
| @@ -98,10 +129,20 @@ type SpawnResult = { · 9 unmodified lines | |||
| 98 | }; | 129 | }; |
| 99 | 130 | ||
| 100 | const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor"; | 131 | const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor"; |
| 132 | const KEYCHAIN_COMMAND_TIMEOUT_MS = 5_000; | ||
| 101 | const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS"; | 133 | const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS"; |
| 102 | const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; | 134 | const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; |
| 103 | const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME"; | 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 | const trimOrNull = (value: string | null | undefined): string | null => { | 146 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 106 | if (value === null || value === undefined) { | 147 | if (value === null || value === undefined) { |
| 107 | return null; | 148 | return null; |
| @@ -134,11 +175,6 @@ export const parseSecretStoreProviderId = (value: string | undefined): SecretSto · 26 unmodified lines | |||
| 134 | const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean => | 175 | const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean => |
| 135 | value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]); | 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 | const resolveKeychainServiceName = (value: string | undefined): string => | 178 | const resolveKeychainServiceName = (value: string | undefined): string => |
| 143 | trimOrNull(value) | 179 | trimOrNull(value) |
| 144 | ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV]) | 180 | ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV]) |
| @@ -149,12 +185,38 @@ const ensureNonEmptyString = (value: string | undefined): string | null => { · 4 unmodified lines | |||
| 149 | return trimmed && trimmed.length > 0 ? trimmed : null; | 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 | const runCommand = (input: { | 213 | const runCommand = (input: { |
| 153 | command: string; | 214 | command: string; |
| 154 | args: ReadonlyArray<string>; | 215 | args: ReadonlyArray<string>; |
| 155 | stdin?: string; | 216 | stdin?: string; |
| 156 | env?: NodeJS.ProcessEnv; | 217 | env?: NodeJS.ProcessEnv; |
| 157 | operation: string; | 218 | operation: string; |
| 219 | timeoutMs?: number; | ||
| 158 | }): Effect.Effect<SpawnResult, Error, never> => | 220 | }): Effect.Effect<SpawnResult, Error, never> => |
| 159 | Effect.tryPromise({ | 221 | Effect.tryPromise({ |
| 160 | try: () => | 222 | try: () => |
| @@ -166,6 +228,22 @@ const runCommand = (input: { · 5 unmodified lines | |||
| 166 | 228 | ||
| 167 | let stdout = ""; | 229 | let stdout = ""; |
| 168 | let stderr = ""; | 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 | child.stdout.on("data", (chunk) => { | 248 | child.stdout.on("data", (chunk) => { |
| 171 | stdout += chunk.toString("utf8"); | 249 | stdout += chunk.toString("utf8"); |
| @@ -176,10 +254,24 @@ const runCommand = (input: { · 4 unmodified lines | |||
| 176 | }); | 254 | }); |
| 177 | 255 | ||
| 178 | child.on("error", (error) => { | 256 | child.on("error", (error) => { |
| 257 | if (settled) { | ||
| 258 | return; | ||
| 259 | } | ||
| 260 | settled = true; | ||
| 261 | if (timeout) { | ||
| 262 | clearTimeout(timeout); | ||
| 263 | } | ||
| 179 | reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`)); | 264 | reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`)); |
| 180 | }); | 265 | }); |
| 181 | 266 | ||
| 182 | child.on("close", (code) => { | 267 | child.on("close", (code) => { |
| 268 | if (settled) { | ||
| 269 | return; | ||
| 270 | } | ||
| 271 | settled = true; | ||
| 272 | if (timeout) { | ||
| 273 | clearTimeout(timeout); | ||
| 274 | } | ||
| 183 | resolve({ | 275 | resolve({ |
| 184 | exitCode: code ?? 0, | 276 | exitCode: code ?? 0, |
| 185 | stdout, | 277 | stdout, |
| @@ -215,6 +307,79 @@ const ensureCommandSuccess = (input: { · 29 unmodified lines | |||
| 215 | return Effect.fail(new Error(`${input.operation}: ${input.message}: ${details}`)); | 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 | const parseKeychainHandle = (handle: string): string | null => { | 383 | const parseKeychainHandle = (handle: string): string | null => { |
| 219 | if (!handle.startsWith(`${KEYCHAIN_SECRET_PROVIDER_ID}:`)) { | 384 | if (!handle.startsWith(`${KEYCHAIN_SECRET_PROVIDER_ID}:`)) { |
| 220 | return null; | 385 | return null; |
| @@ -223,6 +388,248 @@ const parseKeychainHandle = (handle: string): string | null => { · 2 unmodified lines | |||
| 223 | return trimOrNull(handle.slice(`${KEYCHAIN_SECRET_PROVIDER_ID}:`.length)); | 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 | const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({ | 633 | const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({ |
| 227 | resolve: ({ ref, context }) => { | 634 | resolve: ({ ref, context }) => { |
| 228 | const value = ensureNonEmptyString(context.params?.[ref.handle]); | 635 | const value = ensureNonEmptyString(context.params?.[ref.handle]); |
| @@ -260,32 +667,75 @@ const createEnvSecretMaterialProvider = (): SecretMaterialProvider => ({ · 31 unmodified lines | |||
| 260 | const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({ | 667 | const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({ |
| 261 | resolve: ({ ref, runtime }) => | 668 | resolve: ({ ref, runtime }) => |
| 262 | Effect.gen(function* () { | 669 | Effect.gen(function* () { |
| 263 | const materialId = SecretMaterialIdSchema.make(ref.handle); | 670 | const stored = yield* loadStoredSecretMaterial({ |
| 264 | const stored = yield* runtime.rows.secretMaterials.getById(materialId); | 671 | id: ref.handle, |
| 265 | if (Option.isNone(stored)) { | 672 | runtime, |
| 266 | return yield* Effect.fail(new Error(`Secret material not found: ${ref.handle}`)); | 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 | store: ({ purpose, value, name, runtime }) => | 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 | Effect.gen(function* () { | 702 | Effect.gen(function* () { |
| 274 | const now = Date.now(); | 703 | const stored = yield* loadStoredSecretMaterial({ |
| 275 | const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`); | 704 | id: ref.handle, |
| 276 | yield* runtime.rows.secretMaterials.upsert({ | 705 | runtime, |
| 277 | id, | 706 | operation: "local.update", |
| 278 | name: trimOrNull(name), | ||
| 279 | purpose, | ||
| 280 | value, | ||
| 281 | createdAt: now, | ||
| 282 | updatedAt: now, | ||
| 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 | return { | 731 | return { |
| 286 | providerId: LOCAL_SECRET_PROVIDER_ID, | 732 | id: updated.value.id, |
| 287 | handle: id, | 733 | providerId: updated.value.providerId, |
| 288 | } satisfies SecretRef; | 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 | remove: ({ ref, runtime }) => | 741 | remove: ({ ref, runtime }) => |
| @@ -295,190 +745,117 @@ const createLocalSecretMaterialProvider = (): SecretMaterialProvider => ({ · 3 unmodified lines | |||
| 295 | }), | 745 | }), |
| 296 | }); | 746 | }); |
| 297 | 747 | ||
| 298 | const keychainStoreWithSecurityCli = (): SecretMaterialProvider => ({ | 748 | const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => ({ |
| 299 | resolve: ({ ref, runtime }) => { | 749 | resolve: ({ ref, runtime }) => |
| 300 | const id = parseKeychainHandle(ref.handle); | 750 | Effect.gen(function* () { |
| 301 | if (id === null) { | 751 | const loaded = yield* loadManagedKeychainRef({ |
| 302 | return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`)); | 752 | ref, |
| 303 | } | 753 | runtime, |
| 754 | operation: "keychain.get", | ||
| 755 | }); | ||
| 304 | 756 | ||
| 305 | return runCommand({ | 757 | return yield* readKeychainSecretValue({ |
| 306 | command: "security", | 758 | providerHandle: loaded.providerHandle, |
| 307 | args: [ | 759 | runtime, |
| 308 | "find-generic-password", | 760 | }); |
| 309 | "-a", | 761 | }), |
| 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 | }, | ||
| 327 | 762 | ||
| 328 | store: ({ name, value, runtime }) => { | 763 | store: ({ purpose, value, name, runtime }) => |
| 329 | const id = randomUUID(); | 764 | Effect.gen(function* () { |
| 330 | 765 | const providerHandle = randomUUID(); | |
| 331 | return runCommand({ | 766 | yield* writeKeychainSecretValue({ |
| 332 | command: "security", | 767 | providerHandle, |
| 333 | args: [ | 768 | name, |
| 334 | "add-generic-password", | ||
| 335 | "-a", | ||
| 336 | id, | ||
| 337 | "-s", | ||
| 338 | runtime.keychainServiceName, | ||
| 339 | "-w", | ||
| 340 | value, | 769 | value, |
| 341 | ...(trimOrNull(name) ? ["-l", trimOrNull(name)!] : []), | 770 | runtime, |
| 342 | "-U", | 771 | }); |
| 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 | }, | ||
| 359 | 772 | ||
| 360 | remove: ({ ref, runtime }) => { | 773 | return yield* createSecretMaterialMetadata({ |
| 361 | const id = parseKeychainHandle(ref.handle); | 774 | providerId: KEYCHAIN_SECRET_PROVIDER_ID, |
| 362 | if (id === null) { | 775 | providerHandle, |
| 363 | return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`)); | 776 | purpose, |
| 364 | } | 777 | value: null, |
| 778 | name, | ||
| 779 | runtime, | ||
| 780 | }); | ||
| 781 | }), | ||
| 365 | 782 | ||
| 366 | return runCommand({ | 783 | update: ({ ref, name, value, runtime }) => |
| 367 | command: "security", | 784 | Effect.gen(function* () { |
| 368 | args: [ | 785 | const loaded = yield* loadManagedKeychainRef({ |
| 369 | "delete-generic-password", | 786 | ref, |
| 370 | "-a", | 787 | runtime, |
| 371 | id, | 788 | operation: "keychain.update", |
| 372 | "-s", | 789 | }); |
| 373 | runtime.keychainServiceName, | 790 | if (loaded.material === null) { |
| 374 | ], | 791 | return yield* Effect.fail( |
| 375 | operation: "keychain.delete", | 792 | new Error(`keychain.update: legacy keychain refs cannot be updated: ${ref.handle}`), |
| 376 | }).pipe( | 793 | ); |
| 377 | Effect.map((result) => result.exitCode === 0), | 794 | } |
| 378 | ); | ||
| 379 | }, | ||
| 380 | }); | ||
| 381 | 795 | ||
| 382 | const keychainStoreWithSecretTool = (): SecretMaterialProvider => ({ | 796 | if (name === undefined && value === undefined) { |
| 383 | resolve: ({ ref, runtime }) => { | 797 | return toSecretMaterialSummary(loaded.material); |
| 384 | const id = parseKeychainHandle(ref.handle); | 798 | } |
| 385 | if (id === null) { | ||
| 386 | return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`)); | ||
| 387 | } | ||
| 388 | 799 | ||
| 389 | return runCommand({ | 800 | const nextName = name ?? loaded.material.name; |
| 390 | command: "secret-tool", | 801 | const nextValue = value |
| 391 | args: [ | 802 | ?? (yield* readKeychainSecretValue({ |
| 392 | "lookup", | 803 | providerHandle: loaded.providerHandle, |
| 393 | "service", | 804 | runtime, |
| 394 | runtime.keychainServiceName, | 805 | })); |
| 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 | }, | ||
| 410 | 806 | ||
| 411 | store: ({ name, value, runtime }) => { | 807 | yield* writeKeychainSecretValue({ |
| 412 | const id = randomUUID(); | 808 | providerHandle: loaded.providerHandle, |
| 413 | 809 | name: nextName, | |
| 414 | return runCommand({ | 810 | value: nextValue, |
| 415 | command: "secret-tool", | 811 | runtime, |
| 416 | args: [ | 812 | }); |
| 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 | }, | ||
| 441 | 813 | ||
| 442 | remove: ({ ref, runtime }) => { | 814 | const updated = yield* runtime.rows.secretMaterials.updateById( |
| 443 | const id = parseKeychainHandle(ref.handle); | 815 | loaded.material.id, |
| 444 | if (id === null) { | 816 | { |
| 445 | return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`)); | 817 | name: nextName, |
| 446 | } | 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({ | 826 | return { |
| 449 | command: "secret-tool", | 827 | id: updated.value.id, |
| 450 | args: [ | 828 | providerId: updated.value.providerId, |
| 451 | "clear", | 829 | name: updated.value.name, |
| 452 | "service", | 830 | purpose: updated.value.purpose, |
| 453 | runtime.keychainServiceName, | 831 | createdAt: updated.value.createdAt, |
| 454 | "account", | 832 | updatedAt: updated.value.updatedAt, |
| 455 | id, | 833 | } satisfies SecretMaterialSummary; |
| 456 | ], | 834 | }), |
| 457 | operation: "keychain.delete", | ||
| 458 | }).pipe( | ||
| 459 | Effect.map((result) => result.exitCode === 0), | ||
| 460 | ); | ||
| 461 | }, | ||
| 462 | }); | ||
| 463 | 835 | ||
| 464 | const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => { | 836 | remove: ({ ref, runtime }) => |
| 465 | if (process.platform === "darwin") { | 837 | Effect.gen(function* () { |
| 466 | return keychainStoreWithSecurityCli(); | 838 | const loaded = yield* loadManagedKeychainRef({ |
| 467 | } | 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") { | 848 | if (loaded.material === null) { |
| 470 | return keychainStoreWithSecretTool(); | 849 | return deleted; |
| 471 | } | 850 | } |
| 472 | 851 | ||
| 473 | const unsupported = (operation: string) => | 852 | if (!deleted) { |
| 474 | Effect.fail(new Error(`${operation}: keychain provider is unsupported on platform '${process.platform}'`)); | 853 | return false; |
| 854 | } | ||
| 475 | 855 | ||
| 476 | return { | 856 | return yield* runtime.rows.secretMaterials.removeById(loaded.material.id); |
| 477 | resolve: () => unsupported("keychain.get"), | 857 | }), |
| 478 | store: () => unsupported("keychain.put"), | 858 | }); |
| 479 | remove: () => Effect.succeed(false), | ||
| 480 | } satisfies SecretMaterialProvider; | ||
| 481 | }; | ||
| 482 | 859 | ||
| 483 | const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry => | 860 | const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry => |
| 484 | new Map([ | 861 | new Map([ |
| @@ -707,10 +1084,13 @@ export const createDefaultSecretMaterialStorer = (input: { · 222 unmodified lines | |||
| 707 | }): StoreSecretMaterial => { | 1084 | }): StoreSecretMaterial => { |
| 708 | const providers = createSecretMaterialProviderRegistry(); | 1085 | const providers = createSecretMaterialProviderRegistry(); |
| 709 | const runtime = createSecretMaterialProviderRuntime(input); | 1086 | const runtime = createSecretMaterialProviderRuntime(input); |
| 710 | const defaultStoreProviderId = resolveSecretStoreProviderId(input.storeProviderId); | ||
| 711 | 1087 | ||
| 712 | return ({ purpose, value, name }) => | 1088 | return ({ purpose, value, name }) => |
| 713 | Effect.gen(function* () { | 1089 | Effect.gen(function* () { |
| 1090 | const defaultStoreProviderId = yield* resolveDefaultSecretStoreProviderId({ | ||
| 1091 | storeProviderId: input.storeProviderId, | ||
| 1092 | env: runtime.env, | ||
| 1093 | }); | ||
| 714 | const provider = yield* getSecretMaterialProvider({ | 1094 | const provider = yield* getSecretMaterialProvider({ |
| 715 | providers, | 1095 | providers, |
| 716 | providerId: defaultStoreProviderId, | 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 | export const createDefaultSecretMaterialDeleter = (input: { | 1144 | export const createDefaultSecretMaterialDeleter = (input: { |
| 735 | rows: ControlPlaneStoreShape; | 1145 | rows: ControlPlaneStoreShape; |
| 736 | dangerouslyAllowEnvSecrets?: boolean; | 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 | export const SecretMaterialLive = (input: { | 1265 | export const SecretMaterialLive = (input: { |
| 839 | resolveSecretMaterial?: ResolveSecretMaterial; | 1266 | resolveSecretMaterial?: ResolveSecretMaterial; |
| 840 | storeProviderId?: SecretStoreProviderId; | 1267 | storeProviderId?: SecretStoreProviderId; |
| @@ -847,4 +1274,5 @@ export const SecretMaterialLive = (input: { · 6 unmodified lines | |||
| 847 | SecretMaterialResolverLive(input), | 1274 | SecretMaterialResolverLive(input), |
| 848 | SecretMaterialStorerLive(input), | 1275 | SecretMaterialStorerLive(input), |
| 849 | SecretMaterialDeleterLive(input), | 1276 | SecretMaterialDeleterLive(input), |
| 1277 | SecretMaterialUpdaterLive(input), | ||
| 850 | ); | 1278 | ); |
| @@ -14,6 +14,7 @@ import type { · 13 unmodified lines | |||
| 14 | 14 | ||
| 15 | type SecretMaterialSummary = { | 15 | type SecretMaterialSummary = { |
| 16 | id: string; | 16 | id: string; |
| 17 | providerId: string; | ||
| 17 | name: string | null; | 18 | name: string | null; |
| 18 | purpose: string; | 19 | purpose: string; |
| 19 | createdAt: number; | 20 | createdAt: number; |
| @@ -10,7 +10,7 @@ export const SecretMaterialPurposeSchema = Schema.Literal( · 9 unmodified lines | |||
| 10 | "oauth_client_info", | 10 | "oauth_client_info", |
| 11 | ); | 11 | ); |
| 12 | 12 | ||
| 13 | export const SecretMaterialSchema = Schema.Struct({ | 13 | const LegacySecretMaterialSchema = Schema.Struct({ |
| 14 | id: SecretMaterialIdSchema, | 14 | id: SecretMaterialIdSchema, |
| 15 | name: Schema.NullOr(Schema.String), | 15 | name: Schema.NullOr(Schema.String), |
| 16 | purpose: SecretMaterialPurposeSchema, | 16 | purpose: SecretMaterialPurposeSchema, |
| @@ -19,5 +19,44 @@ export const SecretMaterialSchema = Schema.Struct({ · 2 unmodified lines | |||
| 19 | updatedAt: TimestampMsSchema, | 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 | export type SecretMaterialPurpose = typeof SecretMaterialPurposeSchema.Type; | 61 | export type SecretMaterialPurpose = typeof SecretMaterialPurposeSchema.Type; |
| 23 | export type SecretMaterial = typeof SecretMaterialSchema.Type; | 62 | export type SecretMaterial = typeof SecretMaterialSchema.Type; |