* Support multiple 1Password vaults per configuration * Make multi-vault 1Password refs explicit and item picker searchable * Serve cached provider listings while revalidating in pickers * Ship pending changesets as a patch release
Unified · Split
| @@ -1,5 +1,5 @@ | |||
| 1 | --- | 1 | --- |
| 2 | "executor": minor | 2 | "executor": patch |
| 3 | --- | 3 | --- |
| 4 | 4 | ||
| 5 | **Irreversible cleanup now waits for the transaction to commit, and plugins can do the same** | 5 | **Irreversible cleanup now waits for the transaction to commit, and plugins can do the same** |
| @@ -0,0 +1,11 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | **The 1Password provider can now be scoped to several vaults, with explicit per-vault addressing** | ||
| 6 | |||
| 7 | The provider previously bound exactly one vault. The configuration now holds a set of vaults selected with checkboxes, and every reference is explicit about which vault it means: the item picker is a searchable list that shows each item's vault and stores a vault-qualified `op://` reference, so identically-titled items in different vaults can never collide. A bare item name is accepted only when it matches exactly one item across the selected vaults — a name that exists in more than one place fails with an error naming the matching vaults instead of silently picking one. | ||
| 8 | |||
| 9 | Reopening the vault or item pickers no longer flashes a loading state: listings are retained and re-validated in the background, so the last-known list renders instantly. | ||
| 10 | |||
| 11 | Configurations saved before this change keep working: the stored single-vault shape is read as a one-vault list and upgrades to the new shape the next time it is saved. The `status` tool reports `vaultNames` for all configured vaults and flags any configured vault the account can no longer see. Provider entries also gained an optional `group` label, which pickers use to show where an item lives. | ||
| @@ -21,7 +21,11 @@ export const ProvidersHandlers = HttpApiBuilder.group(ExecutorApi, "providers", · 20 unmodified lines | |||
| 21 | Effect.gen(function* () { | 21 | Effect.gen(function* () { |
| 22 | const executor = yield* ExecutorService; | 22 | const executor = yield* ExecutorService; |
| 23 | const entries = yield* executor.providers.items(path.key); | 23 | const entries = yield* executor.providers.items(path.key); |
| 24 | return entries.map((entry) => ({ id: entry.id, name: entry.name })); | 24 | return entries.map((entry) => ({ |
| 25 | id: entry.id, | ||
| 26 | name: entry.name, | ||
| 27 | ...(entry.group !== undefined ? { group: entry.group } : {}), | ||
| 28 | })); | ||
| 25 | }), | 29 | }), |
| 26 | ), | 30 | ), |
| 27 | ), | 31 | ), |
| @@ -26,6 +26,7 @@ const ProviderParams = { key: ProviderKey }; · 25 unmodified lines | |||
| 26 | const ProviderEntryResponse = Schema.Struct({ | 26 | const ProviderEntryResponse = Schema.Struct({ |
| 27 | id: ProviderItemId, | 27 | id: ProviderItemId, |
| 28 | name: Schema.String, | 28 | name: Schema.String, |
| 29 | group: Schema.optional(Schema.String), | ||
| 29 | }); | 30 | }); |
| 30 | 31 | ||
| 31 | // --------------------------------------------------------------------------- | 32 | // --------------------------------------------------------------------------- |
| @@ -16,6 +16,9 @@ export interface ProviderEntry { · 15 unmodified lines | |||
| 16 | * a connection can reference it without core knowing its internal shape. */ | 16 | * a connection can reference it without core knowing its internal shape. */ |
| 17 | readonly id: ProviderItemId; | 17 | readonly id: ProviderItemId; |
| 18 | readonly name: string; | 18 | readonly name: string; |
| 19 | /** Optional provenance label for pickers when a provider spans several | ||
| 20 | * containers (a 1Password vault name). Purely presentational. */ | ||
| 21 | readonly group?: string; | ||
| 19 | } | 22 | } |
| 20 | 23 | ||
| 21 | export interface CredentialProvider { | 24 | export interface CredentialProvider { |
| @@ -1,8 +1,9 @@ | |||
| 1 | import { useState } from "react"; | 1 | import { useEffect, useRef, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import { Button } from "@executor-js/react/components/button"; | 5 | import { Button } from "@executor-js/react/components/button"; |
| 6 | import { Checkbox } from "@executor-js/react/components/checkbox"; | ||
| 6 | import { Input } from "@executor-js/react/components/input"; | 7 | import { Input } from "@executor-js/react/components/input"; |
| 7 | import { Label } from "@executor-js/react/components/label"; | 8 | import { Label } from "@executor-js/react/components/label"; |
| 8 | import { | 9 | import { |
| @@ -35,10 +36,10 @@ import { · 26 unmodified lines | |||
| 35 | removeOnePasswordConfig, | 36 | removeOnePasswordConfig, |
| 36 | onepasswordWriteKeys, | 37 | onepasswordWriteKeys, |
| 37 | } from "./atoms"; | 38 | } from "./atoms"; |
| 38 | import type { RedactedOnePasswordConfig } from "../sdk/types"; | 39 | import type { RedactedOnePasswordConfig, Vault } from "../sdk/types"; |
| 39 | 40 | ||
| 40 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| 41 | // Vault picker | 42 | // Vault picker — multi-select |
| 42 | // --------------------------------------------------------------------------- | 43 | // --------------------------------------------------------------------------- |
| 43 | 44 | ||
| 44 | const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults"; | 45 | const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults"; |
| @@ -52,11 +53,24 @@ const formatVaultListError = (error: Error): string => { · 7 unmodified lines | |||
| 52 | function VaultPicker(props: { | 53 | function VaultPicker(props: { |
| 53 | authKind: "desktop-app" | "service-account"; | 54 | authKind: "desktop-app" | "service-account"; |
| 54 | accountName: string; | 55 | accountName: string; |
| 55 | vaultId: string; | 56 | selected: ReadonlyArray<Vault>; |
| 56 | onVaultSelect: (id: string, name: string) => void; | 57 | onSelectedChange: (vaults: ReadonlyArray<Vault>) => void; |
| 57 | }) { | 58 | }) { |
| 58 | const account = props.accountName.trim(); | 59 | const account = props.accountName.trim(); |
| 59 | const vaultsResult = useAtomValue(onepasswordVaultsAtom(props.authKind, account)); | 60 | const vaultsAtom = onepasswordVaultsAtom(props.authKind, account); |
| 61 | const vaultsResult = useAtomValue(vaultsAtom); | ||
| 62 | const refreshVaults = useAtomRefresh(vaultsAtom); | ||
| 63 | |||
| 64 | // Stale-while-revalidate: with a retained value the vault list renders | ||
| 65 | // instantly and one background refresh per atom key picks up changes | ||
| 66 | // (refreshing keeps the previous value, so nothing flashes). A cold key is | ||
| 67 | // already fetching — refreshing it would only restart the request. The ref | ||
| 68 | // carries the latest cached-ness into the effect without re-running it. | ||
| 69 | const isCachedRef = useRef(false); | ||
| 70 | isCachedRef.current = AsyncResult.isSuccess(vaultsResult); | ||
| 71 | useEffect(() => { | ||
| 72 | if (isCachedRef.current) refreshVaults(); | ||
| 73 | }, [refreshVaults]); | ||
| 60 | 74 | ||
| 61 | const { vaults, isLoading, error } = AsyncResult.matchWithError( | 75 | const { vaults, isLoading, error } = AsyncResult.matchWithError( |
| 62 | vaultsResult as AsyncResult.AsyncResult< | 76 | vaultsResult as AsyncResult.AsyncResult< |
| @@ -81,12 +95,9 @@ function VaultPicker(props: { · 18 unmodified lines | |||
| 81 | }), | 95 | }), |
| 82 | onSuccess: ({ value }) => { | 96 | onSuccess: ({ value }) => { |
| 83 | const v = value.vaults; | 97 | const v = value.vaults; |
| 84 | const defaultVault = v[0]; | 98 | const onlyVault = v.length === 1 ? v[0] : undefined; |
| 85 | if ( | 99 | if (onlyVault && props.selected.length === 0) { |
| 86 | defaultVault && | 100 | queueMicrotask(() => props.onSelectedChange([onlyVault])); |
| 87 | (!props.vaultId || (v.length === 1 && props.vaultId !== defaultVault.id)) | ||
| 88 | ) { | ||
| 89 | queueMicrotask(() => props.onVaultSelect(defaultVault.id, defaultVault.name)); | ||
| 90 | } | 101 | } |
| 91 | return { vaults: [...v], isLoading: false, error: null }; | 102 | return { vaults: [...v], isLoading: false, error: null }; |
| 92 | }, | 103 | }, |
| @@ -101,34 +112,51 @@ function VaultPicker(props: { · 8 unmodified lines | |||
| 101 | ); | 112 | ); |
| 102 | } | 113 | } |
| 103 | 114 | ||
| 104 | const singleVault = vaults.length === 1 ? vaults[0] : null; | 115 | // Selected vaults missing from the loaded list (renamed, revoked, or the |
| 116 | // list failed to load while editing) stay visible so they can be unchecked. | ||
| 117 | const loadedIds = new Set(vaults.map((v) => v.id)); | ||
| 118 | const stale = props.selected.filter((v) => !loadedIds.has(v.id)); | ||
| 119 | const rows = [...vaults, ...stale]; | ||
| 120 | |||
| 121 | const toggle = (vault: Vault, checked: boolean) => { | ||
| 122 | if (checked) { | ||
| 123 | if (!props.selected.some((v) => v.id === vault.id)) { | ||
| 124 | props.onSelectedChange([...props.selected, vault]); | ||
| 125 | } | ||
| 126 | return; | ||
| 127 | } | ||
| 128 | props.onSelectedChange(props.selected.filter((v) => v.id !== vault.id)); | ||
| 129 | }; | ||
| 105 | 130 | ||
| 106 | return ( | 131 | return ( |
| 107 | <div className="grid gap-2"> | 132 | <div className="grid gap-2"> |
| 108 | {singleVault ? ( | 133 | {isLoading ? ( |
| 109 | <div className="flex h-9 items-center rounded-md border border-input bg-muted/30 px-3 text-[13px] text-foreground"> | 134 | <p className="text-[11px] text-muted-foreground/50 py-1">Loading vaults…</p> |
| 110 | <span className="truncate">{singleVault.name}</span> | 135 | ) : rows.length === 0 ? ( |
| 111 | </div> | 136 | <p className="text-[11px] text-muted-foreground/50 py-1">No vaults found.</p> |
| 112 | ) : ( | 137 | ) : ( |
| 113 | <Select | 138 | <div className="grid max-h-44 gap-0.5 overflow-y-auto rounded-md border border-input p-1"> |
| 114 | disabled={isLoading || vaults.length === 0} | 139 | {rows.map((vault) => { |
| 115 | value={props.vaultId} | 140 | const checked = props.selected.some((v) => v.id === vault.id); |
| 116 | onValueChange={(id) => { | 141 | return ( |
| 117 | const v = vaults.find((vault) => vault.id === id); | 142 | <Label |
| 118 | if (v) props.onVaultSelect(v.id, v.name); | 143 | key={vault.id} |
| 119 | }} | 144 | className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 font-normal hover:bg-muted/40" |
| 120 | > | 145 | > |
| 121 | <SelectTrigger className="h-9 text-[13px]"> | 146 | <Checkbox |
| 122 | <SelectValue placeholder={isLoading ? "Loading…" : "Select a vault"} /> | 147 | checked={checked} |
| 123 | </SelectTrigger> | 148 | onCheckedChange={(value) => toggle(vault, value === true)} |
| 124 | <SelectContent> | 149 | /> |
| 125 | {vaults.map((v) => ( | 150 | <span className="truncate text-[13px] text-foreground">{vault.name}</span> |
| 126 | <SelectItem key={v.id} value={v.id}> | 151 | {!loadedIds.has(vault.id) && ( |
| 127 | {v.name} | 152 | <span className="ml-auto shrink-0 text-[11px] text-muted-foreground/50"> |
| 128 | </SelectItem> | 153 | not found |
| 129 | ))} | 154 | </span> |
| 130 | </SelectContent> | 155 | )} |
| 131 | </Select> | 156 | </Label> |
| 157 | ); | ||
| 158 | })} | ||
| 159 | </div> | ||
| 132 | )} | 160 | )} |
| 133 | {error && ( | 161 | {error && ( |
| 134 | <div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5"> | 162 | <div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5"> |
| @@ -151,7 +179,7 @@ function ConfigDialog(props: { · 16 unmodified lines | |||
| 151 | initial?: { | 179 | initial?: { |
| 152 | authKind: string; | 180 | authKind: string; |
| 153 | accountName: string; | 181 | accountName: string; |
| 154 | vaultId: string; | 182 | vaults: ReadonlyArray<Vault>; |
| 155 | name: string; | 183 | name: string; |
| 156 | }; | 184 | }; |
| 157 | }) { | 185 | }) { |
| @@ -160,8 +188,10 @@ function ConfigDialog(props: { · 2 unmodified lines | |||
| 160 | (props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app", | 188 | (props.initial?.authKind as "desktop-app" | "service-account") ?? "desktop-app", |
| 161 | ); | 189 | ); |
| 162 | const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com"); | 190 | const [accountName, setAccountName] = useState(props.initial?.accountName ?? "my.1password.com"); |
| 163 | const [vaultId, setVaultId] = useState(props.initial?.vaultId ?? ""); | 191 | const [selectedVaults, setSelectedVaults] = useState<ReadonlyArray<Vault>>( |
| 164 | const [vaultName, setVaultName] = useState(props.initial?.name ?? ""); | 192 | props.initial?.vaults ?? [], |
| 193 | ); | ||
| 194 | const [displayName, setDisplayName] = useState(props.initial?.name ?? ""); | ||
| 165 | const [saving, setSaving] = useState(false); | 195 | const [saving, setSaving] = useState(false); |
| 166 | const [error, setError] = useState<string | null>(null); | 196 | const [error, setError] = useState<string | null>(null); |
| 167 | 197 | ||
| @@ -171,15 +201,16 @@ function ConfigDialog(props: { · 3 unmodified lines | |||
| 171 | if (!isEdit) { | 201 | if (!isEdit) { |
| 172 | setAuthKind("desktop-app"); | 202 | setAuthKind("desktop-app"); |
| 173 | setAccountName("my.1password.com"); | 203 | setAccountName("my.1password.com"); |
| 174 | setVaultId(""); | 204 | setSelectedVaults([]); |
| 175 | setVaultName(""); | 205 | setDisplayName(""); |
| 176 | } | 206 | } |
| 177 | setError(null); | 207 | setError(null); |
| 178 | setSaving(false); | 208 | setSaving(false); |
| 179 | }; | 209 | }; |
| 180 | 210 | ||
| 181 | const handleSave = async () => { | 211 | const handleSave = async () => { |
| 182 | if (!accountName.trim() || !vaultId.trim()) return; | 212 | const [firstVault, ...restVaults] = selectedVaults; |
| 213 | if (!accountName.trim() || firstVault === undefined) return; | ||
| 183 | setSaving(true); | 214 | setSaving(true); |
| 184 | setError(null); | 215 | setError(null); |
| 185 | 216 | ||
| @@ -191,8 +222,8 @@ function ConfigDialog(props: { · 5 unmodified lines | |||
| 191 | const exit = await doConfigure({ | 222 | const exit = await doConfigure({ |
| 192 | payload: { | 223 | payload: { |
| 193 | auth, | 224 | auth, |
| 194 | vaultId: vaultId.trim(), | 225 | vaults: [firstVault, ...restVaults], |
| 195 | name: vaultName.trim() || "1Password", | 226 | name: displayName.trim() || "1Password", |
| 196 | }, | 227 | }, |
| 197 | reactivityKeys: onepasswordWriteKeys, | 228 | reactivityKeys: onepasswordWriteKeys, |
| 198 | }); | 229 | }); |
| @@ -220,7 +251,8 @@ function ConfigDialog(props: { · 21 unmodified lines | |||
| 220 | {isEdit ? "Edit 1Password" : "Connect 1Password"} | 251 | {isEdit ? "Edit 1Password" : "Connect 1Password"} |
| 221 | </DialogTitle> | 252 | </DialogTitle> |
| 222 | <DialogDescription className="text-[13px] leading-relaxed"> | 253 | <DialogDescription className="text-[13px] leading-relaxed"> |
| 223 | Link a vault to resolve secrets via the 1Password desktop app or a service account. | 254 | Link one or more vaults to resolve secrets via the 1Password desktop app or a service |
| 255 | account. | ||
| 224 | </DialogDescription> | 256 | </DialogDescription> |
| 225 | </DialogHeader> | 257 | </DialogHeader> |
| 226 | 258 | ||
| @@ -262,19 +294,16 @@ function ConfigDialog(props: { · 35 unmodified lines | |||
| 262 | </p> | 294 | </p> |
| 263 | </div> | 295 | </div> |
| 264 | 296 | ||
| 265 | {/* Vault */} | 297 | {/* Vaults */} |
| 266 | <div className="grid gap-1.5"> | 298 | <div className="grid gap-1.5"> |
| 267 | <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | 299 | <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> |
| 268 | Vault | 300 | Vaults |
| 269 | </Label> | 301 | </Label> |
| 270 | <VaultPicker | 302 | <VaultPicker |
| 271 | authKind={authKind} | 303 | authKind={authKind} |
| 272 | accountName={accountName} | 304 | accountName={accountName} |
| 273 | vaultId={vaultId} | 305 | selected={selectedVaults} |
| 274 | onVaultSelect={(id, name) => { | 306 | onSelectedChange={setSelectedVaults} |
| 275 | setVaultId(id); | ||
| 276 | setVaultName(name); | ||
| 277 | }} | ||
| 278 | /> | 307 | /> |
| 279 | </div> | 308 | </div> |
| 280 | 309 | ||
| @@ -285,8 +314,8 @@ function ConfigDialog(props: { · 4 unmodified lines | |||
| 285 | </Label> | 314 | </Label> |
| 286 | <Input | 315 | <Input |
| 287 | placeholder="1Password" | 316 | placeholder="1Password" |
| 288 | value={vaultName} | 317 | value={displayName} |
| 289 | onChange={(e) => setVaultName((e.target as HTMLInputElement).value)} | 318 | onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)} |
| 290 | className="text-[13px] h-9" | 319 | className="text-[13px] h-9" |
| 291 | /> | 320 | /> |
| 292 | </div> | 321 | </div> |
| @@ -307,7 +336,7 @@ function ConfigDialog(props: { · 14 unmodified lines | |||
| 307 | <Button | 336 | <Button |
| 308 | size="sm" | 337 | size="sm" |
| 309 | onClick={handleSave} | 338 | onClick={handleSave} |
| 310 | disabled={!accountName.trim() || !vaultId.trim() || saving} | 339 | disabled={!accountName.trim() || selectedVaults.length === 0 || saving} |
| 311 | > | 340 | > |
| 312 | {saving ? "Saving…" : isEdit ? "Update" : "Connect"} | 341 | {saving ? "Saving…" : isEdit ? "Update" : "Connect"} |
| 313 | </Button> | 342 | </Button> |
| @@ -371,14 +400,18 @@ export default function OnePasswordSettings() { · 57 unmodified lines | |||
| 371 | <span className="font-mono text-foreground/80 truncate"> | 400 | <span className="font-mono text-foreground/80 truncate"> |
| 372 | {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"} | 401 | {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"} |
| 373 | </span> | 402 | </span> |
| 374 | <span className="text-muted-foreground/60">Vault</span> | 403 | <span className="text-muted-foreground/60"> |
| 404 | {config.vaults.length === 1 ? "Vault" : "Vaults"} | ||
| 405 | </span> | ||
| 375 | <div className="flex items-center gap-2 min-w-0"> | 406 | <div className="flex items-center gap-2 min-w-0"> |
| 376 | <span className="text-foreground/80 truncate">{config.name}</span> | 407 | <span className="text-foreground/80 truncate"> |
| 408 | {config.vaults.map((vault) => vault.name).join(", ")} | ||
| 409 | </span> | ||
| 377 | </div> | 410 | </div> |
| 378 | </div> | 411 | </div> |
| 379 | ) : ( | 412 | ) : ( |
| 380 | <CardStackEntryDescription> | 413 | <CardStackEntryDescription> |
| 381 | Resolve secrets from your 1Password vault. | 414 | Resolve secrets from your 1Password vaults. |
| 382 | </CardStackEntryDescription> | 415 | </CardStackEntryDescription> |
| 383 | )} | 416 | )} |
| 384 | </CardStackEntryContent> | 417 | </CardStackEntryContent> |
| @@ -429,7 +462,7 @@ export default function OnePasswordSettings() { · 44 unmodified lines | |||
| 429 | // Service-account tokens are never surfaced (redacted); the | 462 | // Service-account tokens are never surfaced (redacted); the |
| 430 | // user re-enters the token when editing that auth method. | 463 | // user re-enters the token when editing that auth method. |
| 431 | accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "", | 464 | accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "", |
| 432 | vaultId: config.vaultId, | 465 | vaults: config.vaults, |
| 433 | name: config.name, | 466 | name: config.name, |
| 434 | } | 467 | } |
| 435 | : undefined | 468 | : undefined |
| @@ -33,7 +33,10 @@ export const onepasswordVaultsAtom = ( · 32 unmodified lines | |||
| 33 | ) => | 33 | ) => |
| 34 | OnePasswordClient.query("onepassword", "listVaults", { | 34 | OnePasswordClient.query("onepassword", "listVaults", { |
| 35 | query: { authKind, account }, | 35 | query: { authKind, account }, |
| 36 | timeToLive: "30 seconds", | 36 | // Long retention on purpose: vault listing goes through the op CLI/SDK and |
| 37 | // is slow, so a reopened dialog renders the last-known vaults instantly | ||
| 38 | // and revalidates in the background instead of flashing a loading state. | ||
| 39 | timeToLive: "10 minutes", | ||
| 37 | reactivityKeys: [ReactivityKey.providers], | 40 | reactivityKeys: [ReactivityKey.providers], |
| 38 | }); | 41 | }); |
| 39 | 42 | ||
| @@ -1,12 +1,18 @@ | |||
| 1 | export { | 1 | export { |
| 2 | onepasswordPlugin, | 2 | onepasswordPlugin, |
| 3 | makeOnePasswordStore, | 3 | makeOnePasswordStore, |
| 4 | resolveConfiguredRef, | ||
| 5 | ambiguityMessage, | ||
| 6 | type RefResolution, | ||
| 4 | type OnePasswordExtension, | 7 | type OnePasswordExtension, |
| 5 | type OnePasswordPluginOptions, | 8 | type OnePasswordPluginOptions, |
| 6 | type OnePasswordStore, | 9 | type OnePasswordStore, |
| 7 | } from "./plugin"; | 10 | } from "./plugin"; |
| 8 | export { | 11 | export { |
| 9 | OnePasswordConfig, | 12 | OnePasswordConfig, |
| 13 | LegacyOnePasswordConfig, | ||
| 14 | StoredOnePasswordConfig, | ||
| 15 | normalizeStoredConfig, | ||
| 10 | RedactedOnePasswordConfig, | 16 | RedactedOnePasswordConfig, |
| 11 | RedactedOnePasswordAuth, | 17 | RedactedOnePasswordAuth, |
| 12 | redactConfig, | 18 | redactConfig, |
| @@ -2,9 +2,12 @@ import { describe, it, expect } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { ProviderKey, ToolAddress, createExecutor } from "@executor-js/sdk"; | 4 | import { ProviderKey, ToolAddress, createExecutor } from "@executor-js/sdk"; |
| 5 | import { makeInMemoryBlobStore, pluginBlobStore } from "@executor-js/sdk/core"; | ||
| 5 | import { makeTestConfig } from "@executor-js/sdk/testing"; | 6 | import { makeTestConfig } from "@executor-js/sdk/testing"; |
| 6 | 7 | ||
| 7 | import { onepasswordPlugin } from "./plugin"; | 8 | import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin"; |
| 9 | import type { OnePasswordService } from "./service"; | ||
| 10 | import { OnePasswordError } from "./errors"; | ||
| 8 | import { OnePasswordConfig, DesktopAppAuth } from "./types"; | 11 | import { OnePasswordConfig, DesktopAppAuth } from "./types"; |
| 9 | 12 | ||
| 10 | // removed: v1 routed configure/removeConfig through an explicit `ScopeId` | 13 | // removed: v1 routed configure/removeConfig through an explicit `ScopeId` |
| @@ -16,6 +19,18 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; · 5 unmodified lines | |||
| 16 | 19 | ||
| 17 | const ONEPASSWORD = ProviderKey.make("onepassword"); | 20 | const ONEPASSWORD = ProviderKey.make("onepassword"); |
| 18 | 21 | ||
| 22 | const twoVaultConfig = OnePasswordConfig.make({ | ||
| 23 | auth: DesktopAppAuth.make({ | ||
| 24 | kind: "desktop-app", | ||
| 25 | accountName: "my.1password.com", | ||
| 26 | }), | ||
| 27 | vaults: [ | ||
| 28 | { id: "vault-123", name: "Personal" }, | ||
| 29 | { id: "vault-456", name: "Work" }, | ||
| 30 | ], | ||
| 31 | name: "1Password", | ||
| 32 | }); | ||
| 33 | |||
| 19 | describe("onepassword plugin", () => { | 34 | describe("onepassword plugin", () => { |
| 20 | it.effect("registers onepassword as a credential provider", () => | 35 | it.effect("registers onepassword as a credential provider", () => |
| 21 | Effect.gen(function* () { | 36 | Effect.gen(function* () { |
| @@ -36,20 +51,14 @@ describe("onepassword plugin", () => { · 14 unmodified lines | |||
| 36 | const initial = yield* executor.onepassword.getConfig(); | 51 | const initial = yield* executor.onepassword.getConfig(); |
| 37 | expect(initial).toBeNull(); | 52 | expect(initial).toBeNull(); |
| 38 | 53 | ||
| 39 | const config = OnePasswordConfig.make({ | 54 | yield* executor.onepassword.configure(twoVaultConfig); |
| 40 | auth: DesktopAppAuth.make({ | ||
| 41 | kind: "desktop-app", | ||
| 42 | accountName: "my.1password.com", | ||
| 43 | }), | ||
| 44 | vaultId: "vault-123", | ||
| 45 | name: "Personal", | ||
| 46 | }); | ||
| 47 | |||
| 48 | yield* executor.onepassword.configure(config); | ||
| 49 | 55 | ||
| 50 | const loaded = yield* executor.onepassword.getConfig(); | 56 | const loaded = yield* executor.onepassword.getConfig(); |
| 51 | expect(loaded?.vaultId).toBe("vault-123"); | 57 | expect(loaded?.vaults).toEqual([ |
| 52 | expect(loaded?.name).toBe("Personal"); | 58 | { id: "vault-123", name: "Personal" }, |
| 59 | { id: "vault-456", name: "Work" }, | ||
| 60 | ]); | ||
| 61 | expect(loaded?.name).toBe("1Password"); | ||
| 53 | expect(loaded?.auth.kind).toBe("desktop-app"); | 62 | expect(loaded?.auth.kind).toBe("desktop-app"); |
| 54 | 63 | ||
| 55 | yield* executor.onepassword.removeConfig(); | 64 | yield* executor.onepassword.removeConfig(); |
| @@ -67,7 +76,7 @@ describe("onepassword plugin", () => { · 11 unmodified lines | |||
| 67 | yield* executor.onepassword.configure( | 76 | yield* executor.onepassword.configure( |
| 68 | OnePasswordConfig.make({ | 77 | OnePasswordConfig.make({ |
| 69 | auth: { kind: "service-account", token: "super-secret-token" }, | 78 | auth: { kind: "service-account", token: "super-secret-token" }, |
| 70 | vaultId: "vault-123", | 79 | vaults: [{ id: "vault-123", name: "CI" }], |
| 71 | name: "CI", | 80 | name: "CI", |
| 72 | }), | 81 | }), |
| 73 | ); | 82 | ); |
| @@ -89,8 +98,11 @@ describe("onepassword plugin", () => { · 15 unmodified lines | |||
| 89 | ToolAddress.make("executor.onepassword.configure"), | 98 | ToolAddress.make("executor.onepassword.configure"), |
| 90 | { | 99 | { |
| 91 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, | 100 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, |
| 92 | vaultId: "vault-123", | 101 | vaults: [ |
| 93 | name: "Personal", | 102 | { id: "vault-123", name: "Personal" }, |
| 103 | { id: "vault-456", name: "Work" }, | ||
| 104 | ], | ||
| 105 | name: "1Password", | ||
| 94 | }, | 106 | }, |
| 95 | { onElicitation: "accept-all" }, | 107 | { onElicitation: "accept-all" }, |
| 96 | ); | 108 | ); |
| @@ -100,7 +112,15 @@ describe("onepassword plugin", () => { · 3 unmodified lines | |||
| 100 | yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}), | 112 | yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}), |
| 101 | ).toMatchObject({ | 113 | ).toMatchObject({ |
| 102 | ok: true, | 114 | ok: true, |
| 103 | data: { config: { vaultId: "vault-123", name: "Personal" } }, | 115 | data: { |
| 116 | config: { | ||
| 117 | vaults: [ | ||
| 118 | { id: "vault-123", name: "Personal" }, | ||
| 119 | { id: "vault-456", name: "Work" }, | ||
| 120 | ], | ||
| 121 | name: "1Password", | ||
| 122 | }, | ||
| 123 | }, | ||
| 104 | }); | 124 | }); |
| 105 | 125 | ||
| 106 | const removed = yield* executor.execute( | 126 | const removed = yield* executor.execute( |
| @@ -125,3 +145,180 @@ describe("onepassword plugin", () => { · 18 unmodified lines | |||
| 125 | }), | 145 | }), |
| 126 | ); | 146 | ); |
| 127 | }); | 147 | }); |
| 148 | |||
| 149 | // --------------------------------------------------------------------------- | ||
| 150 | // Stored-config compatibility — blobs written before multi-vault support hold | ||
| 151 | // `{ auth, vaultId, name }`. Reads normalize that to a one-element vaults | ||
| 152 | // array; the next save writes the current shape. | ||
| 153 | // --------------------------------------------------------------------------- | ||
| 154 | |||
| 155 | describe("onepassword store", () => { | ||
| 156 | const makeStore = () => { | ||
| 157 | const blobs = pluginBlobStore( | ||
| 158 | makeInMemoryBlobStore(), | ||
| 159 | { org: "org_test", user: null }, | ||
| 160 | "onepassword", | ||
| 161 | ); | ||
| 162 | return { blobs, store: makeOnePasswordStore(blobs) }; | ||
| 163 | }; | ||
| 164 | |||
| 165 | it.effect("upgrades a legacy single-vault blob on read", () => | ||
| 166 | Effect.gen(function* () { | ||
| 167 | const { blobs, store } = makeStore(); | ||
| 168 | yield* blobs.put( | ||
| 169 | "config", | ||
| 170 | JSON.stringify({ | ||
| 171 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, | ||
| 172 | vaultId: "vault-123", | ||
| 173 | name: "Personal", | ||
| 174 | }), | ||
| 175 | { owner: "org" }, | ||
| 176 | ); | ||
| 177 | |||
| 178 | const config = yield* store.getConfig(); | ||
| 179 | expect(config).toEqual({ | ||
| 180 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, | ||
| 181 | vaults: [{ id: "vault-123", name: "Personal" }], | ||
| 182 | name: "Personal", | ||
| 183 | }); | ||
| 184 | }), | ||
| 185 | ); | ||
| 186 | |||
| 187 | it.effect("persists and reads back the multi-vault shape", () => | ||
| 188 | Effect.gen(function* () { | ||
| 189 | const { store } = makeStore(); | ||
| 190 | yield* store.saveConfig(twoVaultConfig, "org"); | ||
| 191 | const config = yield* store.getConfig(); | ||
| 192 | expect(config).toEqual(twoVaultConfig); | ||
| 193 | }), | ||
| 194 | ); | ||
| 195 | }); | ||
| 196 | |||
| 197 | // --------------------------------------------------------------------------- | ||
| 198 | // Explicit ref resolution — vault-qualified refs resolve directly; bare refs | ||
| 199 | // must locate exactly one item, and a multi-vault match is an explicit | ||
| 200 | // ambiguity, never a precedence pick. | ||
| 201 | // --------------------------------------------------------------------------- | ||
| 202 | |||
| 203 | const fakeService = ( | ||
| 204 | itemsByVault: Readonly<Record<string, readonly { id: string; title: string }[]>>, | ||
| 205 | onResolve?: (uri: string) => void, | ||
| 206 | ): OnePasswordService => ({ | ||
| 207 | resolveSecret: (uri) => { | ||
| 208 | onResolve?.(uri); | ||
| 209 | return Effect.succeed(`secret:${uri}`); | ||
| 210 | }, | ||
| 211 | listVaults: () => Effect.succeed(Object.keys(itemsByVault).map((id) => ({ id, title: id }))), | ||
| 212 | listItems: (vaultId) => { | ||
| 213 | const items = itemsByVault[vaultId]; | ||
| 214 | return items === undefined | ||
| 215 | ? Effect.fail(new OnePasswordError({ operation: "item listing", message: "no such vault" })) | ||
| 216 | : Effect.succeed(items); | ||
| 217 | }, | ||
| 218 | }); | ||
| 219 | |||
| 220 | describe("resolveConfiguredRef", () => { | ||
| 221 | it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () => | ||
| 222 | Effect.gen(function* () { | ||
| 223 | const svc = fakeService({}); | ||
| 224 | const result = yield* resolveConfiguredRef( | ||
| 225 | svc, | ||
| 226 | twoVaultConfig, | ||
| 227 | "op://vault-456/item-abc/password", | ||
| 228 | ); | ||
| 229 | expect(result).toEqual({ | ||
| 230 | kind: "resolved", | ||
| 231 | value: "secret:op://vault-456/item-abc/password", | ||
| 232 | }); | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | |||
| 236 | it.effect("appends the credential field to a picker-shaped op://vault/item ref", () => | ||
| 237 | Effect.gen(function* () { | ||
| 238 | const svc = fakeService({}); | ||
| 239 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://vault-123/item-abc"); | ||
| 240 | expect(result).toEqual({ | ||
| 241 | kind: "resolved", | ||
| 242 | value: "secret:op://vault-123/item-abc/credential", | ||
| 243 | }); | ||
| 244 | }), | ||
| 245 | ); | ||
| 246 | |||
| 247 | it.effect("accepts an op:// URI addressed by vault name", () => | ||
| 248 | Effect.gen(function* () { | ||
| 249 | const svc = fakeService({}); | ||
| 250 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://Work/item/password"); | ||
| 251 | expect(result).toEqual({ kind: "resolved", value: "secret:op://Work/item/password" }); | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | |||
| 255 | it.effect("reports an op:// URI outside the configured vaults", () => | ||
| 256 | Effect.gen(function* () { | ||
| 257 | const svc = fakeService({}); | ||
| 258 | const result = yield* resolveConfiguredRef( | ||
| 259 | svc, | ||
| 260 | twoVaultConfig, | ||
| 261 | "op://vault-999/item/password", | ||
| 262 | ); | ||
| 263 | expect(result).toEqual({ kind: "outside-vaults" }); | ||
| 264 | }), | ||
| 265 | ); | ||
| 266 | |||
| 267 | it.effect("resolves a bare ref that matches exactly one item across vaults", () => | ||
| 268 | Effect.gen(function* () { | ||
| 269 | const svc = fakeService({ | ||
| 270 | "vault-123": [{ id: "item-1", title: "GitHub Token" }], | ||
| 271 | "vault-456": [{ id: "item-2", title: "Stripe Key" }], | ||
| 272 | }); | ||
| 273 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); | ||
| 274 | expect(result).toEqual({ | ||
| 275 | kind: "resolved", | ||
| 276 | value: "secret:op://vault-123/item-1/credential", | ||
| 277 | }); | ||
| 278 | }), | ||
| 279 | ); | ||
| 280 | |||
| 281 | it.effect("fails a bare ref that matches in two vaults with the vaults named", () => | ||
| 282 | Effect.gen(function* () { | ||
| 283 | const svc = fakeService({ | ||
| 284 | "vault-123": [{ id: "item-1", title: "GitHub Token" }], | ||
| 285 | "vault-456": [{ id: "item-2", title: "GitHub Token" }], | ||
| 286 | }); | ||
| 287 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); | ||
| 288 | expect(result).toEqual({ | ||
| 289 | kind: "ambiguous", | ||
| 290 | matches: [ | ||
| 291 | { | ||
| 292 | vaultId: "vault-123", | ||
| 293 | vaultName: "Personal", | ||
| 294 | itemId: "item-1", | ||
| 295 | itemTitle: "GitHub Token", | ||
| 296 | }, | ||
| 297 | { vaultId: "vault-456", vaultName: "Work", itemId: "item-2", itemTitle: "GitHub Token" }, | ||
| 298 | ], | ||
| 299 | }); | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | |||
| 303 | it.effect("treats duplicate titles inside one vault as ambiguous too", () => | ||
| 304 | Effect.gen(function* () { | ||
| 305 | const svc = fakeService({ | ||
| 306 | "vault-123": [ | ||
| 307 | { id: "item-1", title: "GitHub Token" }, | ||
| 308 | { id: "item-9", title: "GitHub Token" }, | ||
| 309 | ], | ||
| 310 | "vault-456": [], | ||
| 311 | }); | ||
| 312 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); | ||
| 313 | expect(result.kind).toBe("ambiguous"); | ||
| 314 | }), | ||
| 315 | ); | ||
| 316 | |||
| 317 | it.effect("reports not-found for a bare ref matching nothing", () => | ||
| 318 | Effect.gen(function* () { | ||
| 319 | const svc = fakeService({ "vault-123": [], "vault-456": [] }); | ||
| 320 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "missing"); | ||
| 321 | expect(result).toEqual({ kind: "not-found" }); | ||
| 322 | }), | ||
| 323 | ); | ||
| 324 | }); | ||
| @@ -20,8 +20,10 @@ import { · 19 unmodified lines | |||
| 20 | OnePasswordAuth, | 20 | OnePasswordAuth, |
| 21 | OnePasswordConfig, | 21 | OnePasswordConfig, |
| 22 | RedactedOnePasswordConfig, | 22 | RedactedOnePasswordConfig, |
| 23 | StoredOnePasswordConfig, | ||
| 23 | Vault, | 24 | Vault, |
| 24 | ConnectionStatus, | 25 | ConnectionStatus, |
| 26 | normalizeStoredConfig, | ||
| 25 | redactConfig, | 27 | redactConfig, |
| 26 | } from "./types"; | 28 | } from "./types"; |
| 27 | import { OnePasswordError } from "./errors"; | 29 | import { OnePasswordError } from "./errors"; |
| @@ -42,11 +44,7 @@ const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToo · 14 unmodified lines | |||
| 42 | I | 44 | I |
| 43 | >; | 45 | >; |
| 44 | 46 | ||
| 45 | const OnePasswordConfigureInput = Schema.Struct({ | 47 | const OnePasswordConfigureInput = OnePasswordConfig; |
| 46 | auth: OnePasswordAuth, | ||
| 47 | vaultId: Schema.String, | ||
| 48 | name: Schema.String, | ||
| 49 | }); | ||
| 50 | 48 | ||
| 51 | const OnePasswordConfigureOutput = Schema.Struct({ | 49 | const OnePasswordConfigureOutput = Schema.Struct({ |
| 52 | configured: Schema.Boolean, | 50 | configured: Schema.Boolean, |
| @@ -98,10 +96,12 @@ export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; · 45 unmodified lines | |||
| 98 | // --------------------------------------------------------------------------- | 96 | // --------------------------------------------------------------------------- |
| 99 | // Typed config store — single blob, JSON encoded, owner-partitioned. The | 97 | // Typed config store — single blob, JSON encoded, owner-partitioned. The |
| 100 | // stored config carries the auth credential (desktop account name, or | 98 | // stored config carries the auth credential (desktop account name, or |
| 101 | // service-account token) plus the selected vault. v1 keyed this by executor | 99 | // service-account token) plus the selected vaults. v1 keyed this by executor |
| 102 | // scope; v2 partitions by `owner` — the plugin-owned config row owns the | 100 | // scope; v2 partitions by `owner` — the plugin-owned config row owns the |
| 103 | // partition, mirroring the connection model. Blob I/O failures surface as | 101 | // partition, mirroring the connection model. Reads also accept the legacy |
| 104 | // `StorageError`; decode failures stay `OnePasswordError`. | 102 | // single-`vaultId` shape and normalize it to the vaults array; saves always |
| 103 | // write the current shape. Blob I/O failures surface as `StorageError`; | ||
| 104 | // decode failures stay `OnePasswordError`. | ||
| 105 | // --------------------------------------------------------------------------- | 105 | // --------------------------------------------------------------------------- |
| 106 | 106 | ||
| 107 | export interface OnePasswordStore { | 107 | export interface OnePasswordStore { |
| @@ -116,7 +116,7 @@ export interface OnePasswordStore { · 8 unmodified lines | |||
| 116 | readonly deleteConfig: (owner: Owner) => Effect.Effect<void, StorageError>; | 116 | readonly deleteConfig: (owner: Owner) => Effect.Effect<void, StorageError>; |
| 117 | } | 117 | } |
| 118 | 118 | ||
| 119 | const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(OnePasswordConfig)); | 119 | const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredOnePasswordConfig)); |
| 120 | 120 | ||
| 121 | const blobStorageError = | 121 | const blobStorageError = |
| 122 | (operation: string) => | 122 | (operation: string) => |
| @@ -133,6 +133,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 10 unmodified lines | |||
| 133 | Effect.flatMap((raw) => { | 133 | Effect.flatMap((raw) => { |
| 134 | if (raw === null) return Effect.succeed(null); | 134 | if (raw === null) return Effect.succeed(null); |
| 135 | return decodeConfig(raw).pipe( | 135 | return decodeConfig(raw).pipe( |
| 136 | Effect.map(normalizeStoredConfig), | ||
| 136 | Effect.mapError( | 137 | Effect.mapError( |
| 137 | () => | 138 | () => |
| 138 | new OnePasswordError({ | 139 | new OnePasswordError({ |
| @@ -150,7 +151,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 11 unmodified lines | |||
| 150 | CONFIG_KEY, | 151 | CONFIG_KEY, |
| 151 | JSON.stringify({ | 152 | JSON.stringify({ |
| 152 | auth: config.auth, | 153 | auth: config.auth, |
| 153 | vaultId: config.vaultId, | 154 | vaults: config.vaults, |
| 154 | name: config.name, | 155 | name: config.name, |
| 155 | }), | 156 | }), |
| 156 | { owner }, | 157 | { owner }, |
| @@ -177,22 +178,105 @@ const getServiceFromConfig = ( · 20 unmodified lines | |||
| 177 | ): Effect.Effect<OnePasswordService, OnePasswordError> => | 178 | ): Effect.Effect<OnePasswordService, OnePasswordError> => |
| 178 | makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk }); | 179 | makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk }); |
| 179 | 180 | ||
| 180 | const configuredVaultUri = (config: OnePasswordConfig, itemId: string): string | null => { | 181 | // --------------------------------------------------------------------------- |
| 181 | if (!itemId.startsWith("op://")) { | 182 | // Explicit ref resolution. |
| 182 | return `op://${config.vaultId}/${itemId}/${CREDENTIAL_FIELD}`; | 183 | // |
| 184 | // A ref is one of: | ||
| 185 | // - `op://vault/item/field...` — fully qualified, resolved as-is. | ||
| 186 | // - `op://vault/item` — picker-shaped; the default credential field | ||
| 187 | // is appended. This is the id shape `list()` | ||
| 188 | // hands out, so every picked item permanently | ||
| 189 | // records which vault it came from. | ||
| 190 | // - a bare item id or title — located by listing the configured vaults. | ||
| 191 | // Exactly one match resolves; several matches | ||
| 192 | // are an explicit ambiguity failure naming the | ||
| 193 | // vaults — never a silent precedence pick. | ||
| 194 | // --------------------------------------------------------------------------- | ||
| 195 | |||
| 196 | export type RefResolution = | ||
| 197 | | { readonly kind: "resolved"; readonly value: string } | ||
| 198 | | { readonly kind: "not-found" } | ||
| 199 | | { readonly kind: "outside-vaults" } | ||
| 200 | | { | ||
| 201 | readonly kind: "ambiguous"; | ||
| 202 | readonly matches: readonly { | ||
| 203 | readonly vaultId: string; | ||
| 204 | readonly vaultName: string; | ||
| 205 | readonly itemId: string; | ||
| 206 | readonly itemTitle: string; | ||
| 207 | }[]; | ||
| 208 | }; | ||
| 209 | |||
| 210 | export const ambiguityMessage = ( | ||
| 211 | ref: string, | ||
| 212 | matches: Extract<RefResolution, { kind: "ambiguous" }>["matches"], | ||
| 213 | ): string => | ||
| 214 | [ | ||
| 215 | `1Password ref "${ref}" is ambiguous: it matches`, | ||
| 216 | matches.map((m) => `"${m.itemTitle}" in vault "${m.vaultName}"`).join(", "), | ||
| 217 | `. Use op://<vaultId>/<itemId> to pick one.`, | ||
| 218 | ].join(" "); | ||
| 219 | |||
| 220 | const isConfiguredVaultSegment = (config: OnePasswordConfig, segment: string): boolean => | ||
| 221 | config.vaults.some((vault) => vault.id === segment || vault.name === segment); | ||
| 222 | |||
| 223 | /** Resolve a ref against the configured vaults. Backend failures stay on the | ||
| 224 | * error channel; every addressing outcome is an explicit `RefResolution`. */ | ||
| 225 | export const resolveConfiguredRef = ( | ||
| 226 | svc: OnePasswordService, | ||
| 227 | config: OnePasswordConfig, | ||
| 228 | ref: string, | ||
| 229 | ): Effect.Effect<RefResolution, OnePasswordError> => { | ||
| 230 | if (ref.startsWith("op://")) { | ||
| 231 | const segments = ref.slice("op://".length).split("/"); | ||
| 232 | const vaultSegment = segments[0]; | ||
| 233 | if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) { | ||
| 234 | return Effect.succeed({ kind: "not-found" }); | ||
| 235 | } | ||
| 236 | if (!isConfiguredVaultSegment(config, vaultSegment)) { | ||
| 237 | return Effect.succeed({ kind: "outside-vaults" }); | ||
| 238 | } | ||
| 239 | const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref; | ||
| 240 | return svc | ||
| 241 | .resolveSecret(uri) | ||
| 242 | .pipe(Effect.map((value): RefResolution => ({ kind: "resolved", value }))); | ||
| 183 | } | 243 | } |
| 184 | const match = itemId.match(/^op:\/\/([^/]+)\/.+/); | 244 | |
| 185 | if (!match || match[1] !== config.vaultId) return null; | 245 | return Effect.gen(function* () { |
| 186 | return itemId; | 246 | const matches = (yield* Effect.forEach(config.vaults, (vault) => |
| 247 | svc.listItems(vault.id).pipe( | ||
| 248 | Effect.map((items) => | ||
| 249 | items | ||
| 250 | .filter((item) => item.id === ref || item.title === ref) | ||
| 251 | .map((item) => ({ | ||
| 252 | vaultId: vault.id, | ||
| 253 | vaultName: vault.name, | ||
| 254 | itemId: item.id, | ||
| 255 | itemTitle: item.title, | ||
| 256 | })), | ||
| 257 | ), | ||
| 258 | ), | ||
| 259 | )).flat(); | ||
| 260 | |||
| 261 | const [only, ...extra] = matches; | ||
| 262 | if (only === undefined) return { kind: "not-found" } as const; | ||
| 263 | if (extra.length > 0) return { kind: "ambiguous", matches } as const; | ||
| 264 | |||
| 265 | const value = yield* svc.resolveSecret( | ||
| 266 | `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`, | ||
| 267 | ); | ||
| 268 | return { kind: "resolved", value } as const; | ||
| 269 | }); | ||
| 187 | }; | 270 | }; |
| 188 | 271 | ||
| 189 | // --------------------------------------------------------------------------- | 272 | // --------------------------------------------------------------------------- |
| 190 | // CredentialProvider — read-only, resolves op:// URIs or vaultId-based lookups. | 273 | // CredentialProvider — read-only, resolves op:// URIs or vault-scoped lookups. |
| 191 | // | 274 | // |
| 192 | // v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is | 275 | // v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is |
| 193 | // either a fully-qualified `op://vault/item/field` URI or a bare item id that | 276 | // a vault-qualified `op://` ref (what `list()` hands out) or a bare item |
| 194 | // the stored config's vault scopes. The plugin's stored config supplies the | 277 | // id/title that must locate exactly one item across the configured vaults. |
| 195 | // auth + vault binding; the provider never writes (writable: false). | 278 | // The plugin's stored config supplies the auth + vault bindings; the provider |
| 279 | // never writes (writable: false). | ||
| 196 | // --------------------------------------------------------------------------- | 280 | // --------------------------------------------------------------------------- |
| 197 | 281 | ||
| 198 | const makeProvider = ( | 282 | const makeProvider = ( |
| @@ -205,19 +289,32 @@ const makeProvider = ( · 6 unmodified lines | |||
| 205 | 289 | ||
| 206 | get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> => | 290 | get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> => |
| 207 | ctx.storage.getConfig().pipe( | 291 | ctx.storage.getConfig().pipe( |
| 292 | // An undecodable stored config reads as "not configured" here; the | ||
| 293 | // settings surface reports the decode problem. | ||
| 294 | Effect.catchTag("OnePasswordError", () => Effect.succeed(null)), | ||
| 208 | Effect.flatMap((config) => { | 295 | Effect.flatMap((config) => { |
| 209 | if (!config) return Effect.succeed(null as string | null); | 296 | if (!config) return Effect.succeed(null as string | null); |
| 210 | 297 | ||
| 211 | const uri = configuredVaultUri(config, id); | ||
| 212 | if (uri === null) return Effect.succeed(null as string | null); | ||
| 213 | |||
| 214 | return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( | 298 | return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( |
| 215 | Effect.flatMap((svc) => svc.resolveSecret(uri)), | 299 | Effect.flatMap((svc) => resolveConfiguredRef(svc, config, id)), |
| 216 | Effect.map((v): string | null => v), | 300 | // Backend unreachability degrades to "no value", matching the other |
| 217 | Effect.orElseSucceed(() => null), | 301 | // providers. Ambiguity does NOT: silently picking a vault (or |
| 302 | // silently failing) hides a real conflict, so it surfaces as a | ||
| 303 | // typed failure with the full explanation. | ||
| 304 | Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)), | ||
| 305 | Effect.flatMap( | ||
| 306 | (resolution): Effect.Effect<string | null, StorageError> => | ||
| 307 | resolution.kind === "ambiguous" | ||
| 308 | ? Effect.fail( | ||
| 309 | new StorageError({ | ||
| 310 | message: ambiguityMessage(id, resolution.matches), | ||
| 311 | cause: undefined, | ||
| 312 | }), | ||
| 313 | ) | ||
| 314 | : Effect.succeed(resolution.kind === "resolved" ? resolution.value : null), | ||
| 315 | ), | ||
| 218 | ); | 316 | ); |
| 219 | }), | 317 | }), |
| 220 | Effect.catch(() => Effect.succeed(null as string | null)), | ||
| 221 | ), | 318 | ), |
| 222 | 319 | ||
| 223 | list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> => | 320 | list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> => |
| @@ -225,10 +322,25 @@ const makeProvider = ( · 1 unmodified line | |||
| 225 | Effect.flatMap((config) => { | 322 | Effect.flatMap((config) => { |
| 226 | if (!config) return Effect.succeed([] as readonly ProviderEntry[]); | 323 | if (!config) return Effect.succeed([] as readonly ProviderEntry[]); |
| 227 | return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( | 324 | return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( |
| 228 | Effect.flatMap((svc) => svc.listItems(config.vaultId)), | 325 | Effect.flatMap((svc) => |
| 229 | Effect.map((items): readonly ProviderEntry[] => | 326 | Effect.forEach(config.vaults, (vault) => |
| 230 | items.map((item) => ({ id: ProviderItemId.make(item.id), name: item.title })), | 327 | svc.listItems(vault.id).pipe( |
| 328 | Effect.map((items) => | ||
| 329 | items.map( | ||
| 330 | // Vault-qualified ids: picking an entry permanently | ||
| 331 | // records which vault it came from, so identically-titled | ||
| 332 | // items in different vaults can never collide. | ||
| 333 | (item): ProviderEntry => ({ | ||
| 334 | id: ProviderItemId.make(`op://${vault.id}/${item.id}`), | ||
| 335 | name: item.title, | ||
| 336 | group: vault.name, | ||
| 337 | }), | ||
| 338 | ), | ||
| 339 | ), | ||
| 340 | ), | ||
| 341 | ), | ||
| 231 | ), | 342 | ), |
| 343 | Effect.map((groups): readonly ProviderEntry[] => groups.flat()), | ||
| 232 | ); | 344 | ); |
| 233 | }), | 345 | }), |
| 234 | Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), | 346 | Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), |
| @@ -270,11 +382,19 @@ const makeOnePasswordExtension = ( · 35 unmodified lines | |||
| 270 | }); | 382 | }); |
| 271 | } | 383 | } |
| 272 | const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); | 384 | const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); |
| 273 | const vaults = yield* svc.listVaults(); | 385 | const live = yield* svc.listVaults(); |
| 274 | const vault = vaults.find((v) => v.id === config.vaultId); | 386 | const liveById = new Map(live.map((v) => [v.id, v.title])); |
| 387 | const missing = config.vaults.filter((vault) => !liveById.has(vault.id)); | ||
| 275 | return ConnectionStatus.make({ | 388 | return ConnectionStatus.make({ |
| 276 | connected: true, | 389 | connected: true, |
| 277 | vaultName: vault?.title, | 390 | vaultNames: config.vaults.map((vault) => liveById.get(vault.id) ?? vault.name), |
| 391 | ...(missing.length > 0 | ||
| 392 | ? { | ||
| 393 | error: `Configured vaults not found: ${missing | ||
| 394 | .map((vault) => vault.name) | ||
| 395 | .join(", ")}`, | ||
| 396 | } | ||
| 397 | : {}), | ||
| 278 | }); | 398 | }); |
| 279 | }), | 399 | }), |
| 280 | 400 | ||
| @@ -299,15 +419,25 @@ const makeOnePasswordExtension = ( · 18 unmodified lines | |||
| 299 | message: "1Password is not configured", | 419 | message: "1Password is not configured", |
| 300 | }); | 420 | }); |
| 301 | } | 421 | } |
| 302 | const scopedUri = configuredVaultUri(config, uri); | 422 | const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); |
| 303 | if (scopedUri === null) { | 423 | const resolution = yield* resolveConfiguredRef(svc, config, uri); |
| 424 | if (resolution.kind === "resolved") return resolution.value; | ||
| 425 | if (resolution.kind === "outside-vaults") { | ||
| 304 | return yield* new OnePasswordError({ | 426 | return yield* new OnePasswordError({ |
| 305 | operation: "resolve", | 427 | operation: "resolve", |
| 306 | message: "1Password secret URI is outside the configured vault", | 428 | message: "1Password secret URI is outside the configured vaults", |
| 307 | }); | 429 | }); |
| 308 | } | 430 | } |
| 309 | const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); | 431 | if (resolution.kind === "ambiguous") { |
| 310 | return yield* svc.resolveSecret(scopedUri); | 432 | return yield* new OnePasswordError({ |
| 433 | operation: "resolve", | ||
| 434 | message: ambiguityMessage(uri, resolution.matches), | ||
| 435 | }); | ||
| 436 | } | ||
| 437 | return yield* new OnePasswordError({ | ||
| 438 | operation: "resolve", | ||
| 439 | message: `1Password item "${uri}" was not found in the configured vaults`, | ||
| 440 | }); | ||
| 311 | }), | 441 | }), |
| 312 | }; | 442 | }; |
| 313 | }; | 443 | }; |
| @@ -345,7 +475,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 31 unmodified lines | |||
| 345 | tool({ | 475 | tool({ |
| 346 | name: "status", | 476 | name: "status", |
| 347 | description: | 477 | description: |
| 348 | "Check whether the 1Password credential provider is configured and can reach its selected vault. This returns status only, never secret values.", | 478 | "Check whether the 1Password credential provider is configured and can reach its selected vaults. This returns status only, never secret values.", |
| 349 | outputSchema: OnePasswordStatusOutputStd, | 479 | outputSchema: OnePasswordStatusOutputStd, |
| 350 | execute: () => Effect.map(self.status(), ToolResult.ok), | 480 | execute: () => Effect.map(self.status(), ToolResult.ok), |
| 351 | }), | 481 | }), |
| @@ -368,7 +498,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 16 unmodified lines | |||
| 368 | tool({ | 498 | tool({ |
| 369 | name: "configure", | 499 | name: "configure", |
| 370 | description: | 500 | description: |
| 371 | "Configure the 1Password credential provider for the acting owner. Use desktop-app auth for local biometric access, or service-account auth with the token. The token is stored in the plugin's owner-partitioned config and never surfaced again.", | 501 | "Configure the 1Password credential provider for the acting owner with one or more vaults. Use desktop-app auth for local biometric access, or service-account auth with the token. The token is stored in the plugin's owner-partitioned config and never surfaced again.", |
| 372 | annotations: { | 502 | annotations: { |
| 373 | requiresApproval: true, | 503 | requiresApproval: true, |
| 374 | approvalDescription: "Configure the 1Password credential provider", | 504 | approvalDescription: "Configure the 1Password credential provider", |
| @@ -377,7 +507,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 2 unmodified lines | |||
| 377 | outputSchema: OnePasswordConfigureOutputStd, | 507 | outputSchema: OnePasswordConfigureOutputStd, |
| 378 | execute: (input) => | 508 | execute: (input) => |
| 379 | Effect.as( | 509 | Effect.as( |
| 380 | self.configure({ auth: input.auth, vaultId: input.vaultId, name: input.name }), | 510 | self.configure({ auth: input.auth, vaults: input.vaults, name: input.name }), |
| 381 | ToolResult.ok({ configured: true }), | 511 | ToolResult.ok({ configured: true }), |
| 382 | ), | 512 | ), |
| 383 | }), | 513 | }), |
| @@ -24,19 +24,53 @@ export type ServiceAccountAuth = typeof ServiceAccountAuth.Type; · 23 unmodified lines | |||
| 24 | export const OnePasswordAuth = Schema.Union([DesktopAppAuth, ServiceAccountAuth]); | 24 | export const OnePasswordAuth = Schema.Union([DesktopAppAuth, ServiceAccountAuth]); |
| 25 | export type OnePasswordAuth = typeof OnePasswordAuth.Type; | 25 | export type OnePasswordAuth = typeof OnePasswordAuth.Type; |
| 26 | 26 | ||
| 27 | // --------------------------------------------------------------------------- | ||
| 28 | // Vault | ||
| 29 | // --------------------------------------------------------------------------- | ||
| 30 | |||
| 31 | export const Vault = Schema.Struct({ | ||
| 32 | id: Schema.String, | ||
| 33 | name: Schema.String, | ||
| 34 | }); | ||
| 35 | export type Vault = typeof Vault.Type; | ||
| 36 | |||
| 27 | // --------------------------------------------------------------------------- | 37 | // --------------------------------------------------------------------------- |
| 28 | // Stored config — persisted via KV | 38 | // Stored config — persisted via KV |
| 29 | // --------------------------------------------------------------------------- | 39 | // --------------------------------------------------------------------------- |
| 30 | 40 | ||
| 31 | export const OnePasswordConfig = Schema.Struct({ | 41 | export const OnePasswordConfig = Schema.Struct({ |
| 32 | auth: OnePasswordAuth, | 42 | auth: OnePasswordAuth, |
| 33 | /** Vault to scope operations to */ | 43 | /** Vaults to scope operations to. Order is presentational only: refs are |
| 34 | vaultId: Schema.String, | 44 | * vault-qualified, and a bare ref that matches in more than one vault is an |
| 35 | /** Human label */ | 45 | * explicit ambiguity failure, never a precedence pick. */ |
| 46 | vaults: Schema.NonEmptyArray(Vault), | ||
| 47 | /** Human label for the whole connection */ | ||
| 36 | name: Schema.String, | 48 | name: Schema.String, |
| 37 | }); | 49 | }); |
| 38 | export type OnePasswordConfig = typeof OnePasswordConfig.Type; | 50 | export type OnePasswordConfig = typeof OnePasswordConfig.Type; |
| 39 | 51 | ||
| 52 | /** Pre-multi-vault stored shape: a single vault id whose display name doubled | ||
| 53 | * as the connection label. Still accepted on read; every save writes the | ||
| 54 | * current shape, so a config row upgrades the first time it is re-saved. */ | ||
| 55 | export const LegacyOnePasswordConfig = Schema.Struct({ | ||
| 56 | auth: OnePasswordAuth, | ||
| 57 | vaultId: Schema.String, | ||
| 58 | name: Schema.String, | ||
| 59 | }); | ||
| 60 | export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type; | ||
| 61 | |||
| 62 | export const StoredOnePasswordConfig = Schema.Union([OnePasswordConfig, LegacyOnePasswordConfig]); | ||
| 63 | export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type; | ||
| 64 | |||
| 65 | export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig => | ||
| 66 | "vaultId" in stored | ||
| 67 | ? { | ||
| 68 | auth: stored.auth, | ||
| 69 | vaults: [{ id: stored.vaultId, name: stored.name }], | ||
| 70 | name: stored.name, | ||
| 71 | } | ||
| 72 | : stored; | ||
| 73 | |||
| 40 | // --------------------------------------------------------------------------- | 74 | // --------------------------------------------------------------------------- |
| 41 | // Redacted config — what `getConfig` returns to agents / the UI. The | 75 | // Redacted config — what `getConfig` returns to agents / the UI. The |
| 42 | // service-account token is stripped; only the auth kind + account metadata is | 76 | // service-account token is stripped; only the auth kind + account metadata is |
| @@ -56,7 +90,7 @@ export const RedactedOnePasswordAuth = Schema.Union([ · 13 unmodified lines | |||
| 56 | 90 | ||
| 57 | export const RedactedOnePasswordConfig = Schema.Struct({ | 91 | export const RedactedOnePasswordConfig = Schema.Struct({ |
| 58 | auth: RedactedOnePasswordAuth, | 92 | auth: RedactedOnePasswordAuth, |
| 59 | vaultId: Schema.String, | 93 | vaults: Schema.NonEmptyArray(Vault), |
| 60 | name: Schema.String, | 94 | name: Schema.String, |
| 61 | }); | 95 | }); |
| 62 | export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type; | 96 | export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type; |
| @@ -67,27 +101,17 @@ export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConf · 4 unmodified lines | |||
| 67 | config.auth.kind === "desktop-app" | 101 | config.auth.kind === "desktop-app" |
| 68 | ? { kind: "desktop-app", accountName: config.auth.accountName } | 102 | ? { kind: "desktop-app", accountName: config.auth.accountName } |
| 69 | : { kind: "service-account" }, | 103 | : { kind: "service-account" }, |
| 70 | vaultId: config.vaultId, | 104 | vaults: config.vaults, |
| 71 | name: config.name, | 105 | name: config.name, |
| 72 | }); | 106 | }); |
| 73 | 107 | ||
| 74 | // --------------------------------------------------------------------------- | ||
| 75 | // Vault | ||
| 76 | // --------------------------------------------------------------------------- | ||
| 77 | |||
| 78 | export const Vault = Schema.Struct({ | ||
| 79 | id: Schema.String, | ||
| 80 | name: Schema.String, | ||
| 81 | }); | ||
| 82 | export type Vault = typeof Vault.Type; | ||
| 83 | |||
| 84 | // --------------------------------------------------------------------------- | 108 | // --------------------------------------------------------------------------- |
| 85 | // Connection status | 109 | // Connection status |
| 86 | // --------------------------------------------------------------------------- | 110 | // --------------------------------------------------------------------------- |
| 87 | 111 | ||
| 88 | export const ConnectionStatus = Schema.Struct({ | 112 | export const ConnectionStatus = Schema.Struct({ |
| 89 | connected: Schema.Boolean, | 113 | connected: Schema.Boolean, |
| 90 | vaultName: Schema.optional(Schema.String), | 114 | vaultNames: Schema.optional(Schema.Array(Schema.String)), |
| 91 | error: Schema.optional(Schema.String), | 115 | error: Schema.optional(Schema.String), |
| 92 | }); | 116 | }); |
| 93 | export type ConnectionStatus = typeof ConnectionStatus.Type; | 117 | export type ConnectionStatus = typeof ConnectionStatus.Type; |
| @@ -132,7 +132,10 @@ export const providersAtom = ExecutorApiClient.query("providers", "list", { · 131 unmodified lines | |||
| 132 | export const providerItemsAtom = (key: ProviderKey) => | 132 | export const providerItemsAtom = (key: ProviderKey) => |
| 133 | ExecutorApiClient.query("providers", "items", { | 133 | ExecutorApiClient.query("providers", "items", { |
| 134 | params: { key }, | 134 | params: { key }, |
| 135 | timeToLive: "30 seconds", | 135 | // Long retention on purpose: external-provider listings (1Password) are |
| 136 | // slow, so pickers render the last-known list instantly and revalidate in | ||
| 137 | // the background on mount instead of flashing a loading state each open. | ||
| 138 | timeToLive: "10 minutes", | ||
| 136 | reactivityKeys: [ReactivityKey.providers], | 139 | reactivityKeys: [ReactivityKey.providers], |
| 137 | }); | 140 | }); |
| 138 | 141 | ||
| @@ -1,5 +1,5 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import { | 5 | import { |
| @@ -106,7 +106,14 @@ import { · 100 unmodified lines | |||
| 106 | import { Input } from "./input"; | 106 | import { Input } from "./input"; |
| 107 | import { Label } from "./label"; | 107 | import { Label } from "./label"; |
| 108 | import { RadioGroup, RadioGroupItem } from "./radio-group"; | 108 | import { RadioGroup, RadioGroupItem } from "./radio-group"; |
| 109 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"; | 109 | import { |
| 110 | Combobox, | ||
| 111 | ComboboxContent, | ||
| 112 | ComboboxEmpty, | ||
| 113 | ComboboxInput, | ||
| 114 | ComboboxItem, | ||
| 115 | ComboboxList, | ||
| 116 | } from "./combobox"; | ||
| 110 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; | 117 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; |
| 111 | 118 | ||
| 112 | // --------------------------------------------------------------------------- | 119 | // --------------------------------------------------------------------------- |
| @@ -335,38 +342,45 @@ function PasteCredentialInputs(props: { · 222 unmodified lines | |||
| 335 | ); | 342 | ); |
| 336 | } | 343 | } |
| 337 | 344 | ||
| 345 | type OnePasswordItem = { | ||
| 346 | readonly id: ProviderItemId; | ||
| 347 | readonly name: string; | ||
| 348 | readonly group?: string; | ||
| 349 | }; | ||
| 350 | |||
| 338 | function OnePasswordItemSelect(props: { | 351 | function OnePasswordItemSelect(props: { |
| 339 | readonly value: string; | 352 | readonly value: string; |
| 340 | readonly onChange: (value: string) => void; | 353 | readonly onChange: (value: string) => void; |
| 341 | }) { | 354 | }) { |
| 342 | const itemsResult = useAtomValue(providerItemsAtom(ONEPASSWORD_PROVIDER)); | 355 | const itemsAtom = providerItemsAtom(ONEPASSWORD_PROVIDER); |
| 356 | const itemsResult = useAtomValue(itemsAtom); | ||
| 357 | const refreshItems = useAtomRefresh(itemsAtom); | ||
| 358 | |||
| 359 | // Stale-while-revalidate: with a retained value the list renders instantly | ||
| 360 | // and one background refresh per mount picks up vault changes (refreshing | ||
| 361 | // keeps the previous value, so nothing flashes). A cold mount is already | ||
| 362 | // fetching — refreshing it would only restart the request. The ref carries | ||
| 363 | // the latest cached-ness into the effect without re-running it. | ||
| 364 | const isCachedRef = useRef(false); | ||
| 365 | isCachedRef.current = AsyncResult.isSuccess(itemsResult); | ||
| 366 | useEffect(() => { | ||
| 367 | if (isCachedRef.current) refreshItems(); | ||
| 368 | }, [refreshItems]); | ||
| 343 | const state = AsyncResult.matchWithError( | 369 | const state = AsyncResult.matchWithError( |
| 344 | itemsResult as AsyncResult.AsyncResult< | 370 | itemsResult as AsyncResult.AsyncResult<readonly OnePasswordItem[], Error>, |
| 345 | readonly { readonly id: ProviderItemId; readonly name: string }[], | ||
| 346 | Error | ||
| 347 | >, | ||
| 348 | { | 371 | { |
| 349 | onInitial: () => ({ | 372 | onInitial: () => ({ |
| 350 | items: [] as readonly { | 373 | items: [] as readonly OnePasswordItem[], |
| 351 | readonly id: ProviderItemId; | ||
| 352 | readonly name: string; | ||
| 353 | }[], | ||
| 354 | loading: true, | 374 | loading: true, |
| 355 | error: null as string | null, | 375 | error: null as string | null, |
| 356 | }), | 376 | }), |
| 357 | onError: () => ({ | 377 | onError: () => ({ |
| 358 | items: [] as readonly { | 378 | items: [] as readonly OnePasswordItem[], |
| 359 | readonly id: ProviderItemId; | ||
| 360 | readonly name: string; | ||
| 361 | }[], | ||
| 362 | loading: false, | 379 | loading: false, |
| 363 | error: "Failed to load 1Password items", | 380 | error: "Failed to load 1Password items", |
| 364 | }), | 381 | }), |
| 365 | onDefect: () => ({ | 382 | onDefect: () => ({ |
| 366 | items: [] as readonly { | 383 | items: [] as readonly OnePasswordItem[], |
| 367 | readonly id: ProviderItemId; | ||
| 368 | readonly name: string; | ||
| 369 | }[], | ||
| 370 | loading: false, | 384 | loading: false, |
| 371 | error: "Failed to load 1Password items", | 385 | error: "Failed to load 1Password items", |
| 372 | }), | 386 | }), |
| @@ -374,6 +388,33 @@ function OnePasswordItemSelect(props: { · 1 unmodified line | |||
| 374 | }, | 388 | }, |
| 375 | ); | 389 | ); |
| 376 | 390 | ||
| 391 | const stateItems = state.items; | ||
| 392 | const sorted = useMemo( | ||
| 393 | () => | ||
| 394 | [...stateItems].sort( | ||
| 395 | (a, b) => a.name.localeCompare(b.name) || (a.group ?? "").localeCompare(b.group ?? ""), | ||
| 396 | ), | ||
| 397 | [stateItems], | ||
| 398 | ); | ||
| 399 | const byId = useMemo(() => { | ||
| 400 | const map = new Map<string, OnePasswordItem>(); | ||
| 401 | for (const item of sorted) map.set(String(item.id), item); | ||
| 402 | return map; | ||
| 403 | }, [sorted]); | ||
| 404 | const ids = useMemo(() => sorted.map((item) => String(item.id)), [sorted]); | ||
| 405 | |||
| 406 | const filter = useCallback( | ||
| 407 | (id: string, query: string) => { | ||
| 408 | const needle = query.trim().toLowerCase(); | ||
| 409 | if (needle === "") return true; | ||
| 410 | const item = byId.get(id); | ||
| 411 | return [item?.name ?? "", item?.group ?? ""].some((part) => | ||
| 412 | part.toLowerCase().includes(needle), | ||
| 413 | ); | ||
| 414 | }, | ||
| 415 | [byId], | ||
| 416 | ); | ||
| 417 | |||
| 377 | if (state.loading) { | 418 | if (state.loading) { |
| 378 | return <p className="text-xs text-muted-foreground">Loading 1Password items…</p>; | 419 | return <p className="text-xs text-muted-foreground">Loading 1Password items…</p>; |
| 379 | } | 420 | } |
| @@ -386,18 +427,36 @@ function OnePasswordItemSelect(props: { · 6 unmodified lines | |||
| 386 | 427 | ||
| 387 | return ( | 428 | return ( |
| 388 | <div className="space-y-1" data-ph-block> | 429 | <div className="space-y-1" data-ph-block> |
| 389 | <Select value={props.value} onValueChange={props.onChange}> | 430 | <Combobox |
| 390 | <SelectTrigger> | 431 | items={ids} |
| 391 | <SelectValue placeholder="Select secret" /> | 432 | value={props.value.length > 0 ? props.value : null} |
| 392 | </SelectTrigger> | 433 | filter={filter} |
| 393 | <SelectContent> | 434 | limit={100} |
| 394 | {state.items.map((item) => ( | 435 | itemToStringLabel={(id: string) => byId.get(id)?.name ?? id} |
| 395 | <SelectItem key={String(item.id)} value={String(item.id)}> | 436 | onValueChange={(id) => { |
| 396 | {item.name} | 437 | if (id !== null) props.onChange(id); |
| 397 | </SelectItem> | 438 | }} |
| 398 | ))} | 439 | > |
| 399 | </SelectContent> | 440 | <ComboboxInput placeholder="Search 1Password items…" className="w-full" /> |
| 400 | </Select> | 441 | <ComboboxContent> |
| 442 | <ComboboxEmpty>No items match.</ComboboxEmpty> | ||
| 443 | <ComboboxList> | ||
| 444 | {(id: string) => { | ||
| 445 | const item = byId.get(id); | ||
| 446 | return ( | ||
| 447 | <ComboboxItem key={id} value={id}> | ||
| 448 | <span className="min-w-0 flex-1 truncate">{item?.name ?? id}</span> | ||
| 449 | {item?.group && ( | ||
| 450 | <span className="shrink-0 font-mono text-[11px] text-muted-foreground"> | ||
| 451 | {item.group} | ||
| 452 | </span> | ||
| 453 | )} | ||
| 454 | </ComboboxItem> | ||
| 455 | ); | ||
| 456 | }} | ||
| 457 | </ComboboxList> | ||
| 458 | </ComboboxContent> | ||
| 459 | </Combobox> | ||
| 401 | </div> | 460 | </div> |
| 402 | ); | 461 | ); |
| 403 | } | 462 | } |