Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | 1Password: multiple named accounts. The provider now holds any number of named accounts — a work account next to a personal one, or a service-account token next to desktop-app biometrics — each scoping its own set of vaults. The settings card lists every account with independent edit and disconnect, existing single-account configs upgrade in place, and `op://` refs keep their vault-first addressing: a vault name that exists in more than one account is an explicit ambiguity error, never a silent pick. | ||
| @@ -3,24 +3,28 @@ import { Schema } from "effect"; · 2 unmodified lines | |||
| 3 | import { InternalError } from "@executor-js/sdk/shared"; | 3 | import { InternalError } from "@executor-js/sdk/shared"; |
| 4 | 4 | ||
| 5 | import { OnePasswordError } from "../sdk/errors"; | 5 | import { OnePasswordError } from "../sdk/errors"; |
| 6 | import { | 6 | import { OnePasswordAccountUpsert } from "../sdk/plugin"; |
| 7 | OnePasswordConfig, | 7 | import { RedactedOnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types"; |
| 8 | RedactedOnePasswordConfig, | ||
| 9 | Vault, | ||
| 10 | ConnectionStatus, | ||
| 11 | } from "../sdk/types"; | ||
| 12 | 8 | ||
| 13 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| 14 | // Payloads | 10 | // Payloads |
| 15 | // | 11 | // |
| 16 | // v2: config is a single per-owner binding the extension derives from the | 12 | // v2: config is a single per-owner binding the extension derives from the |
| 17 | // executor's owner binding — there are no scope segments in the path. The | 13 | // executor's owner binding — there are no scope segments in the path. The |
| 18 | // configure payload carries the full config (including the service-account | 14 | // configure payload carries one account upsert (including the |
| 19 | // token); reads return the redacted projection so the token never leaves the | 15 | // service-account token); reads return the redacted projection so the token |
| 20 | // plugin. | 16 | // never leaves the plugin. |
| 21 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 22 | 18 | ||
| 23 | const ConfigurePayload = OnePasswordConfig; | 19 | const ConfigurePayload = OnePasswordAccountUpsert; |
| 20 | |||
| 21 | const ConfigureResponse = Schema.Struct({ | ||
| 22 | accountId: Schema.String, | ||
| 23 | }); | ||
| 24 | |||
| 25 | const RemoveConfigParams = Schema.Struct({ | ||
| 26 | accountId: Schema.optional(Schema.String), | ||
| 27 | }); | ||
| 24 | 28 | ||
| 25 | const ListVaultsParams = Schema.Struct({ | 29 | const ListVaultsParams = Schema.Struct({ |
| 26 | authKind: Schema.Literals(["desktop-app", "service-account"]), | 30 | authKind: Schema.Literals(["desktop-app", "service-account"]), |
| @@ -60,12 +64,13 @@ export const OnePasswordGroup = HttpApiGroup.make("onepassword") · 33 unmodified lines | |||
| 60 | .add( | 64 | .add( |
| 61 | HttpApiEndpoint.put("configure", "/onepassword/config", { | 65 | HttpApiEndpoint.put("configure", "/onepassword/config", { |
| 62 | payload: ConfigurePayload, | 66 | payload: ConfigurePayload, |
| 63 | success: Schema.Void, | 67 | success: ConfigureResponse, |
| 64 | error: [InternalError, OnePasswordError], | 68 | error: [InternalError, OnePasswordError], |
| 65 | }), | 69 | }), |
| 66 | ) | 70 | ) |
| 67 | .add( | 71 | .add( |
| 68 | HttpApiEndpoint.delete("removeConfig", "/onepassword/config", { | 72 | HttpApiEndpoint.delete("removeConfig", "/onepassword/config", { |
| 73 | query: RemoveConfigParams, | ||
| 69 | success: Schema.Void, | 74 | success: Schema.Void, |
| 70 | error: [InternalError, OnePasswordError], | 75 | error: [InternalError, OnePasswordError], |
| 71 | }), | 76 | }), |
| @@ -54,15 +54,15 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 53 unmodified lines | |||
| 54 | capture( | 54 | capture( |
| 55 | Effect.gen(function* () { | 55 | Effect.gen(function* () { |
| 56 | const ext = yield* OnePasswordExtensionService; | 56 | const ext = yield* OnePasswordExtensionService; |
| 57 | yield* ext.configure(payload); | 57 | return yield* ext.configure(payload); |
| 58 | }), | 58 | }), |
| 59 | ), | 59 | ), |
| 60 | ) | 60 | ) |
| 61 | .handle("removeConfig", () => | 61 | .handle("removeConfig", ({ query }) => |
| 62 | capture( | 62 | capture( |
| 63 | Effect.gen(function* () { | 63 | Effect.gen(function* () { |
| 64 | const ext = yield* OnePasswordExtensionService; | 64 | const ext = yield* OnePasswordExtensionService; |
| 65 | yield* ext.removeConfig(); | 65 | yield* ext.removeConfig(query.accountId); |
| 66 | }), | 66 | }), |
| 67 | ), | 67 | ), |
| 68 | ) | 68 | ) |
| @@ -36,7 +36,7 @@ import { · 35 unmodified lines | |||
| 36 | removeOnePasswordConfig, | 36 | removeOnePasswordConfig, |
| 37 | onepasswordWriteKeys, | 37 | onepasswordWriteKeys, |
| 38 | } from "./atoms"; | 38 | } from "./atoms"; |
| 39 | import type { RedactedOnePasswordConfig, Vault } from "../sdk/types"; | 39 | import type { RedactedOnePasswordAccount, RedactedOnePasswordConfig, Vault } from "../sdk/types"; |
| 40 | 40 | ||
| 41 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| 42 | // Vault picker — multi-select | 42 | // Vault picker — multi-select |
| @@ -170,13 +170,14 @@ function VaultPicker(props: { · 127 unmodified lines | |||
| 170 | } | 170 | } |
| 171 | 171 | ||
| 172 | // --------------------------------------------------------------------------- | 172 | // --------------------------------------------------------------------------- |
| 173 | // Config dialog | 173 | // Account dialog — add a new account, or edit one by id |
| 174 | // --------------------------------------------------------------------------- | 174 | // --------------------------------------------------------------------------- |
| 175 | 175 | ||
| 176 | function ConfigDialog(props: { | 176 | function AccountDialog(props: { |
| 177 | open: boolean; | 177 | open: boolean; |
| 178 | onOpenChange: (v: boolean) => void; | 178 | onOpenChange: (v: boolean) => void; |
| 179 | initial?: { | 179 | initial?: { |
| 180 | id: string; | ||
| 180 | authKind: string; | 181 | authKind: string; |
| 181 | accountName: string; | 182 | accountName: string; |
| 182 | vaults: ReadonlyArray<Vault>; | 183 | vaults: ReadonlyArray<Vault>; |
| @@ -221,6 +222,7 @@ function ConfigDialog(props: { · 38 unmodified lines | |||
| 221 | 222 | ||
| 222 | const exit = await doConfigure({ | 223 | const exit = await doConfigure({ |
| 223 | payload: { | 224 | payload: { |
| 225 | ...(props.initial ? { id: props.initial.id } : {}), | ||
| 224 | auth, | 226 | auth, |
| 225 | vaults: [firstVault, ...restVaults], | 227 | vaults: [firstVault, ...restVaults], |
| 226 | name: displayName.trim() || "1Password", | 228 | name: displayName.trim() || "1Password", |
| @@ -248,11 +250,11 @@ function ConfigDialog(props: { · 21 unmodified lines | |||
| 248 | <DialogContent className="sm:max-w-[420px]"> | 250 | <DialogContent className="sm:max-w-[420px]"> |
| 249 | <DialogHeader> | 251 | <DialogHeader> |
| 250 | <DialogTitle className="font-display text-xl"> | 252 | <DialogTitle className="font-display text-xl"> |
| 251 | {isEdit ? "Edit 1Password" : "Connect 1Password"} | 253 | {isEdit ? "Edit 1Password account" : "Add 1Password account"} |
| 252 | </DialogTitle> | 254 | </DialogTitle> |
| 253 | <DialogDescription className="text-[13px] leading-relaxed"> | 255 | <DialogDescription className="text-[13px] leading-relaxed"> |
| 254 | Link one or more vaults to resolve secrets via the 1Password desktop app or a service | 256 | Link one or more vaults to resolve secrets via the 1Password desktop app or a service |
| 255 | account. | 257 | account. Add more accounts to keep work and personal credentials separate. |
| 256 | </DialogDescription> | 258 | </DialogDescription> |
| 257 | </DialogHeader> | 259 | </DialogHeader> |
| 258 | 260 | ||
| @@ -310,10 +312,10 @@ function ConfigDialog(props: { · 51 unmodified lines | |||
| 310 | {/* Display name */} | 312 | {/* Display name */} |
| 311 | <div className="grid gap-1.5"> | 313 | <div className="grid gap-1.5"> |
| 312 | <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | 314 | <Label className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> |
| 313 | Display name | 315 | Name |
| 314 | </Label> | 316 | </Label> |
| 315 | <Input | 317 | <Input |
| 316 | placeholder="1Password" | 318 | placeholder="Work" |
| 317 | value={displayName} | 319 | value={displayName} |
| 318 | onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)} | 320 | onChange={(e) => setDisplayName((e.target as HTMLInputElement).value)} |
| 319 | className="text-[13px] h-9" | 321 | className="text-[13px] h-9" |
| @@ -347,18 +349,65 @@ function ConfigDialog(props: { · 27 unmodified lines | |||
| 347 | } | 349 | } |
| 348 | 350 | ||
| 349 | // --------------------------------------------------------------------------- | 351 | // --------------------------------------------------------------------------- |
| 350 | // Settings card | 352 | // Settings entries — one card per account, plus the add action |
| 351 | // --------------------------------------------------------------------------- | 353 | // --------------------------------------------------------------------------- |
| 352 | 354 | ||
| 353 | export default function OnePasswordSettings() { | 355 | function AccountEntry(props: { account: RedactedOnePasswordAccount; onEdit: () => void }) { |
| 354 | const [configOpen, setConfigOpen] = useState(false); | ||
| 355 | const configResult = useAtomValue(onepasswordConfigAtom); | ||
| 356 | const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" }); | 356 | const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promiseExit" }); |
| 357 | const [removing, setRemoving] = useState(false); | ||
| 357 | 358 | ||
| 358 | const handleRemove = async () => { | 359 | const handleRemove = async () => { |
| 359 | await doRemove({ reactivityKeys: onepasswordWriteKeys }); | 360 | setRemoving(true); |
| 361 | await doRemove({ | ||
| 362 | query: { accountId: props.account.id }, | ||
| 363 | reactivityKeys: onepasswordWriteKeys, | ||
| 364 | }); | ||
| 365 | setRemoving(false); | ||
| 360 | }; | 366 | }; |
| 361 | 367 | ||
| 368 | return ( | ||
| 369 | <CardStackEntry> | ||
| 370 | <CardStackEntryContent> | ||
| 371 | <div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[12px]"> | ||
| 372 | <span className="text-muted-foreground/60">Name</span> | ||
| 373 | <span className="text-foreground/80 truncate">{props.account.name}</span> | ||
| 374 | <span className="text-muted-foreground/60">Auth</span> | ||
| 375 | <span className="font-mono text-foreground/80 truncate"> | ||
| 376 | {props.account.auth.kind === "desktop-app" | ||
| 377 | ? props.account.auth.accountName | ||
| 378 | : "service-account"} | ||
| 379 | </span> | ||
| 380 | <span className="text-muted-foreground/60"> | ||
| 381 | {props.account.vaults.length === 1 ? "Vault" : "Vaults"} | ||
| 382 | </span> | ||
| 383 | <span className="text-foreground/80 truncate"> | ||
| 384 | {props.account.vaults.map((vault) => vault.name).join(", ")} | ||
| 385 | </span> | ||
| 386 | </div> | ||
| 387 | </CardStackEntryContent> | ||
| 388 | <CardStackEntryActions> | ||
| 389 | <Button variant="ghost" size="sm" className="h-7 px-2.5 text-[12px]" onClick={props.onEdit}> | ||
| 390 | Edit | ||
| 391 | </Button> | ||
| 392 | <Button | ||
| 393 | variant="ghost" | ||
| 394 | size="sm" | ||
| 395 | className="h-7 px-2.5 text-[12px] text-destructive/70 hover:text-destructive" | ||
| 396 | disabled={removing} | ||
| 397 | onClick={handleRemove} | ||
| 398 | > | ||
| 399 | Disconnect | ||
| 400 | </Button> | ||
| 401 | </CardStackEntryActions> | ||
| 402 | </CardStackEntry> | ||
| 403 | ); | ||
| 404 | } | ||
| 405 | |||
| 406 | export default function OnePasswordSettings() { | ||
| 407 | // null = closed; "new" = add flow; otherwise the account being edited. | ||
| 408 | const [dialogTarget, setDialogTarget] = useState<RedactedOnePasswordAccount | "new" | null>(null); | ||
| 409 | const configResult = useAtomValue(onepasswordConfigAtom); | ||
| 410 | |||
| 362 | const config: RedactedOnePasswordConfig | null = AsyncResult.match( | 411 | const config: RedactedOnePasswordConfig | null = AsyncResult.match( |
| 363 | configResult as AsyncResult.AsyncResult<RedactedOnePasswordConfig | null, unknown>, | 412 | configResult as AsyncResult.AsyncResult<RedactedOnePasswordConfig | null, unknown>, |
| 364 | { | 413 | { |
| @@ -384,88 +433,81 @@ export default function OnePasswordSettings() { · 19 unmodified lines | |||
| 384 | }, | 433 | }, |
| 385 | ); | 434 | ); |
| 386 | 435 | ||
| 436 | const accounts = config?.accounts ?? []; | ||
| 437 | |||
| 387 | return ( | 438 | return ( |
| 388 | <> | 439 | <> |
| 389 | <CardStackEntry> | 440 | {isLoading || isError || accounts.length === 0 ? ( |
| 390 | <CardStackEntryContent> | 441 | <CardStackEntry> |
| 391 | {isLoading ? ( | 442 | <CardStackEntryContent> |
| 392 | <CardStackEntryDescription>Loading…</CardStackEntryDescription> | 443 | {isLoading ? ( |
| 393 | ) : isError ? ( | 444 | <CardStackEntryDescription>Loading…</CardStackEntryDescription> |
| 394 | <CardStackEntryDescription className="text-destructive"> | 445 | ) : isError ? ( |
| 395 | Failed to load configuration | 446 | <CardStackEntryDescription className="text-destructive"> |
| 396 | </CardStackEntryDescription> | 447 | Failed to load configuration |
| 397 | ) : config ? ( | 448 | </CardStackEntryDescription> |
| 398 | <div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-[12px]"> | 449 | ) : ( |
| 399 | <span className="text-muted-foreground/60">Auth</span> | 450 | <CardStackEntryDescription> |
| 400 | <span className="font-mono text-foreground/80 truncate"> | 451 | Resolve secrets from your 1Password vaults. |
| 401 | {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"} | 452 | </CardStackEntryDescription> |
| 402 | </span> | 453 | )} |
| 403 | <span className="text-muted-foreground/60"> | 454 | </CardStackEntryContent> |
| 404 | {config.vaults.length === 1 ? "Vault" : "Vaults"} | 455 | <CardStackEntryActions> |
| 405 | </span> | 456 | {!isLoading && !isError && ( |
| 406 | <div className="flex items-center gap-2 min-w-0"> | ||
| 407 | <span className="text-foreground/80 truncate"> | ||
| 408 | {config.vaults.map((vault) => vault.name).join(", ")} | ||
| 409 | </span> | ||
| 410 | </div> | ||
| 411 | </div> | ||
| 412 | ) : ( | ||
| 413 | <CardStackEntryDescription> | ||
| 414 | Resolve secrets from your 1Password vaults. | ||
| 415 | </CardStackEntryDescription> | ||
| 416 | )} | ||
| 417 | </CardStackEntryContent> | ||
| 418 | <CardStackEntryActions> | ||
| 419 | {config ? ( | ||
| 420 | <> | ||
| 421 | <Button | 457 | <Button |
| 422 | variant="ghost" | 458 | variant="link" |
| 423 | size="sm" | ||
| 424 | className="h-7 px-2.5 text-[12px]" | ||
| 425 | onClick={() => setConfigOpen(true)} | ||
| 426 | > | ||
| 427 | Edit | ||
| 428 | </Button> | ||
| 429 | <Button | ||
| 430 | variant="ghost" | ||
| 431 | size="sm" | 459 | size="sm" |
| 432 | className="h-7 px-2.5 text-[12px] text-destructive/70 hover:text-destructive" | 460 | className="h-7 px-0 text-[12px] shrink-0" |
| 433 | onClick={handleRemove} | 461 | onClick={() => setDialogTarget("new")} |
| 434 | > | 462 | > |
| 435 | Disconnect | 463 | Add 1Password |
| 436 | </Button> | 464 | </Button> |
| 437 | </> | 465 | )} |
| 438 | ) : ( | 466 | </CardStackEntryActions> |
| 439 | !isLoading && | 467 | </CardStackEntry> |
| 440 | !isError && ( | 468 | ) : ( |
| 469 | <> | ||
| 470 | {accounts.map((account) => ( | ||
| 471 | <AccountEntry | ||
| 472 | key={account.id} | ||
| 473 | account={account} | ||
| 474 | onEdit={() => setDialogTarget(account)} | ||
| 475 | /> | ||
| 476 | ))} | ||
| 477 | <CardStackEntry> | ||
| 478 | <CardStackEntryContent> | ||
| 441 | <Button | 479 | <Button |
| 442 | variant="link" | 480 | variant="link" |
| 443 | size="sm" | 481 | size="sm" |
| 444 | className="h-7 px-0 text-[12px] shrink-0" | 482 | className="h-7 px-0 text-[12px] shrink-0" |
| 445 | onClick={() => setConfigOpen(true)} | 483 | onClick={() => setDialogTarget("new")} |
| 446 | > | 484 | > |
| 447 | Add 1Password | 485 | + Add 1Password account |
| 448 | </Button> | 486 | </Button> |
| 449 | ) | 487 | </CardStackEntryContent> |
| 450 | )} | 488 | </CardStackEntry> |
| 451 | </CardStackEntryActions> | 489 | </> |
| 452 | </CardStackEntry> | 490 | )} |
| 453 | 491 | ||
| 454 | {configOpen && ( | 492 | {dialogTarget !== null && ( |
| 455 | <ConfigDialog | 493 | <AccountDialog |
| 456 | open={configOpen} | 494 | open={dialogTarget !== null} |
| 457 | onOpenChange={setConfigOpen} | 495 | onOpenChange={(v) => { |
| 496 | if (!v) setDialogTarget(null); | ||
| 497 | }} | ||
| 458 | initial={ | 498 | initial={ |
| 459 | config | 499 | dialogTarget === "new" |
| 460 | ? { | 500 | ? undefined |
| 461 | authKind: config.auth.kind, | 501 | : { |
| 502 | id: dialogTarget.id, | ||
| 503 | authKind: dialogTarget.auth.kind, | ||
| 462 | // Service-account tokens are never surfaced (redacted); the | 504 | // Service-account tokens are never surfaced (redacted); the |
| 463 | // user re-enters the token when editing that auth method. | 505 | // user re-enters the token when editing that auth method. |
| 464 | accountName: config.auth.kind === "desktop-app" ? config.auth.accountName : "", | 506 | accountName: |
| 465 | vaults: config.vaults, | 507 | dialogTarget.auth.kind === "desktop-app" ? dialogTarget.auth.accountName : "", |
| 466 | name: config.name, | 508 | vaults: dialogTarget.vaults, |
| 509 | name: dialogTarget.name, | ||
| 467 | } | 510 | } |
| 468 | : undefined | ||
| 469 | } | 511 | } |
| 470 | /> | 512 | /> |
| 471 | )} | 513 | )} |
| @@ -3,20 +3,28 @@ export { · 2 unmodified lines | |||
| 3 | makeOnePasswordStore, | 3 | makeOnePasswordStore, |
| 4 | resolveConfiguredRef, | 4 | resolveConfiguredRef, |
| 5 | ambiguityMessage, | 5 | ambiguityMessage, |
| 6 | vaultAmbiguityMessage, | ||
| 7 | OnePasswordAccountUpsert, | ||
| 6 | type RefResolution, | 8 | type RefResolution, |
| 7 | type OnePasswordExtension, | 9 | type OnePasswordExtension, |
| 8 | type OnePasswordPluginOptions, | 10 | type OnePasswordPluginOptions, |
| 9 | type OnePasswordStore, | 11 | type OnePasswordStore, |
| 10 | } from "./plugin"; | 12 | } from "./plugin"; |
| 11 | export { | 13 | export { |
| 14 | OnePasswordAccount, | ||
| 12 | OnePasswordConfig, | 15 | OnePasswordConfig, |
| 16 | SingleAccountOnePasswordConfig, | ||
| 13 | LegacyOnePasswordConfig, | 17 | LegacyOnePasswordConfig, |
| 14 | StoredOnePasswordConfig, | 18 | StoredOnePasswordConfig, |
| 19 | DEFAULT_ACCOUNT_ID, | ||
| 15 | normalizeStoredConfig, | 20 | normalizeStoredConfig, |
| 21 | RedactedOnePasswordAccount, | ||
| 16 | RedactedOnePasswordConfig, | 22 | RedactedOnePasswordConfig, |
| 17 | RedactedOnePasswordAuth, | 23 | RedactedOnePasswordAuth, |
| 24 | redactAccount, | ||
| 18 | redactConfig, | 25 | redactConfig, |
| 19 | Vault, | 26 | Vault, |
| 27 | AccountStatus, | ||
| 20 | ConnectionStatus, | 28 | ConnectionStatus, |
| 21 | OnePasswordAuth, | 29 | OnePasswordAuth, |
| 22 | DesktopAppAuth, | 30 | DesktopAppAuth, |
| @@ -8,7 +8,7 @@ import { makeTestConfig } from "@executor-js/sdk/testing"; · 7 unmodified lines | |||
| 8 | import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin"; | 8 | import { makeOnePasswordStore, onepasswordPlugin, resolveConfiguredRef } from "./plugin"; |
| 9 | import type { OnePasswordService } from "./service"; | 9 | import type { OnePasswordService } from "./service"; |
| 10 | import { OnePasswordError } from "./errors"; | 10 | import { OnePasswordError } from "./errors"; |
| 11 | import { OnePasswordConfig, DesktopAppAuth } from "./types"; | 11 | import { OnePasswordAccount, OnePasswordConfig, DesktopAppAuth } from "./types"; |
| 12 | 12 | ||
| 13 | // removed: v1 routed configure/removeConfig through an explicit `ScopeId` | 13 | // removed: v1 routed configure/removeConfig through an explicit `ScopeId` |
| 14 | // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and | 14 | // (`executor.onepassword.configure(config, ScopeId.make("test-scope"))`) and |
| @@ -19,16 +19,38 @@ import { OnePasswordConfig, DesktopAppAuth } from "./types"; · 4 unmodified lines | |||
| 19 | 19 | ||
| 20 | const ONEPASSWORD = ProviderKey.make("onepassword"); | 20 | const ONEPASSWORD = ProviderKey.make("onepassword"); |
| 21 | 21 | ||
| 22 | const twoVaultConfig = OnePasswordConfig.make({ | 22 | const desktopAuth = DesktopAppAuth.make({ |
| 23 | auth: DesktopAppAuth.make({ | 23 | kind: "desktop-app", |
| 24 | kind: "desktop-app", | 24 | accountName: "my.1password.com", |
| 25 | accountName: "my.1password.com", | 25 | }); |
| 26 | }), | 26 | |
| 27 | const twoVaultAccount = OnePasswordAccount.make({ | ||
| 28 | id: "acct-default", | ||
| 29 | name: "1Password", | ||
| 30 | auth: desktopAuth, | ||
| 27 | vaults: [ | 31 | vaults: [ |
| 28 | { id: "vault-123", name: "Personal" }, | 32 | { id: "vault-123", name: "Personal" }, |
| 29 | { id: "vault-456", name: "Work" }, | 33 | { id: "vault-456", name: "Work" }, |
| 30 | ], | 34 | ], |
| 31 | name: "1Password", | 35 | }); |
| 36 | |||
| 37 | const oneAccountConfig = OnePasswordConfig.make({ accounts: [twoVaultAccount] }); | ||
| 38 | |||
| 39 | const twoAccountConfig = OnePasswordConfig.make({ | ||
| 40 | accounts: [ | ||
| 41 | OnePasswordAccount.make({ | ||
| 42 | id: "acct-work", | ||
| 43 | name: "Work", | ||
| 44 | auth: desktopAuth, | ||
| 45 | vaults: [{ id: "vault-eng", name: "Engineering" }], | ||
| 46 | }), | ||
| 47 | OnePasswordAccount.make({ | ||
| 48 | id: "acct-personal", | ||
| 49 | name: "Personal", | ||
| 50 | auth: DesktopAppAuth.make({ kind: "desktop-app", accountName: "family.1password.com" }), | ||
| 51 | vaults: [{ id: "vault-home", name: "Home" }], | ||
| 52 | }), | ||
| 53 | ], | ||
| 32 | }); | 54 | }); |
| 33 | 55 | ||
| 34 | describe("onepassword plugin", () => { | 56 | describe("onepassword plugin", () => { |
| @@ -42,7 +64,7 @@ describe("onepassword plugin", () => { · 7 unmodified lines | |||
| 42 | }), | 64 | }), |
| 43 | ); | 65 | ); |
| 44 | 66 | ||
| 45 | it.effect("configure / getConfig / removeConfig round-trip via blob store", () => | 67 | it.effect("configure upserts accounts by id and removeConfig removes them one by one", () => |
| 46 | Effect.gen(function* () { | 68 | Effect.gen(function* () { |
| 47 | const executor = yield* createExecutor( | 69 | const executor = yield* createExecutor( |
| 48 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | 70 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), |
| @@ -51,40 +73,92 @@ describe("onepassword plugin", () => { · 2 unmodified lines | |||
| 51 | const initial = yield* executor.onepassword.getConfig(); | 73 | const initial = yield* executor.onepassword.getConfig(); |
| 52 | expect(initial).toBeNull(); | 74 | expect(initial).toBeNull(); |
| 53 | 75 | ||
| 54 | yield* executor.onepassword.configure(twoVaultConfig); | 76 | const first = yield* executor.onepassword.configure({ |
| 77 | name: "Work", | ||
| 78 | auth: desktopAuth, | ||
| 79 | vaults: [{ id: "vault-eng", name: "Engineering" }], | ||
| 80 | }); | ||
| 81 | const second = yield* executor.onepassword.configure({ | ||
| 82 | name: "Personal", | ||
| 83 | auth: desktopAuth, | ||
| 84 | vaults: [{ id: "vault-home", name: "Home" }], | ||
| 85 | }); | ||
| 86 | expect(first.accountId).not.toBe(second.accountId); | ||
| 55 | 87 | ||
| 56 | const loaded = yield* executor.onepassword.getConfig(); | 88 | const loaded = yield* executor.onepassword.getConfig(); |
| 57 | expect(loaded?.vaults).toEqual([ | 89 | expect(loaded?.accounts.map((account) => account.name).sort()).toEqual(["Personal", "Work"]); |
| 58 | { id: "vault-123", name: "Personal" }, | 90 | |
| 59 | { id: "vault-456", name: "Work" }, | 91 | // Re-saving with an id replaces that account in place. |
| 60 | ]); | 92 | yield* executor.onepassword.configure({ |
| 61 | expect(loaded?.name).toBe("1Password"); | 93 | id: first.accountId, |
| 62 | expect(loaded?.auth.kind).toBe("desktop-app"); | 94 | name: "Work (renamed)", |
| 63 | 95 | auth: desktopAuth, | |
| 64 | yield* executor.onepassword.removeConfig(); | 96 | vaults: [ |
| 97 | { id: "vault-eng", name: "Engineering" }, | ||
| 98 | { id: "vault-infra", name: "Infra" }, | ||
| 99 | ], | ||
| 100 | }); | ||
| 101 | const afterEdit = yield* executor.onepassword.getConfig(); | ||
| 102 | expect(afterEdit?.accounts.length).toBe(2); | ||
| 103 | const edited = afterEdit?.accounts.find((account) => account.id === first.accountId); | ||
| 104 | expect(edited?.name).toBe("Work (renamed)"); | ||
| 105 | expect(edited?.vaults.length).toBe(2); | ||
| 106 | |||
| 107 | // Removing one account keeps the other. | ||
| 108 | yield* executor.onepassword.removeConfig(first.accountId); | ||
| 65 | const afterRemove = yield* executor.onepassword.getConfig(); | 109 | const afterRemove = yield* executor.onepassword.getConfig(); |
| 66 | expect(afterRemove).toBeNull(); | 110 | expect(afterRemove?.accounts.map((account) => account.id)).toEqual([second.accountId]); |
| 111 | |||
| 112 | // Removing the last account deletes the config entirely. | ||
| 113 | yield* executor.onepassword.removeConfig(second.accountId); | ||
| 114 | expect(yield* executor.onepassword.getConfig()).toBeNull(); | ||
| 67 | }), | 115 | }), |
| 68 | ); | 116 | ); |
| 69 | 117 | ||
| 70 | it.effect("getConfig redacts the service-account token", () => | 118 | it.effect("removeConfig without an id removes everything", () => |
| 71 | Effect.gen(function* () { | 119 | Effect.gen(function* () { |
| 72 | const executor = yield* createExecutor( | 120 | const executor = yield* createExecutor( |
| 73 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), | 121 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), |
| 74 | ); | 122 | ); |
| 123 | yield* executor.onepassword.configure({ | ||
| 124 | name: "Work", | ||
| 125 | auth: desktopAuth, | ||
| 126 | vaults: [{ id: "vault-eng", name: "Engineering" }], | ||
| 127 | }); | ||
| 128 | yield* executor.onepassword.configure({ | ||
| 129 | name: "Personal", | ||
| 130 | auth: desktopAuth, | ||
| 131 | vaults: [{ id: "vault-home", name: "Home" }], | ||
| 132 | }); | ||
| 133 | yield* executor.onepassword.removeConfig(); | ||
| 134 | expect(yield* executor.onepassword.getConfig()).toBeNull(); | ||
| 135 | }), | ||
| 136 | ); | ||
| 75 | 137 | ||
| 76 | yield* executor.onepassword.configure( | 138 | it.effect("getConfig redacts every account's service-account token", () => |
| 77 | OnePasswordConfig.make({ | 139 | Effect.gen(function* () { |
| 78 | auth: { kind: "service-account", token: "super-secret-token" }, | 140 | const executor = yield* createExecutor( |
| 79 | vaults: [{ id: "vault-123", name: "CI" }], | 141 | makeTestConfig({ plugins: [onepasswordPlugin()] as const }), |
| 80 | name: "CI", | ||
| 81 | }), | ||
| 82 | ); | 142 | ); |
| 83 | 143 | ||
| 144 | yield* executor.onepassword.configure({ | ||
| 145 | name: "CI", | ||
| 146 | auth: { kind: "service-account", token: "super-secret-token" }, | ||
| 147 | vaults: [{ id: "vault-123", name: "CI" }], | ||
| 148 | }); | ||
| 149 | yield* executor.onepassword.configure({ | ||
| 150 | name: "Deploys", | ||
| 151 | auth: { kind: "service-account", token: "other-secret-token" }, | ||
| 152 | vaults: [{ id: "vault-456", name: "Deploys" }], | ||
| 153 | }); | ||
| 154 | |||
| 84 | const loaded = yield* executor.onepassword.getConfig(); | 155 | const loaded = yield* executor.onepassword.getConfig(); |
| 85 | expect(loaded?.auth.kind).toBe("service-account"); | 156 | expect(loaded?.accounts.every((account) => account.auth.kind === "service-account")).toBe( |
| 86 | // The token must never be surfaced through the redacted projection. | 157 | true, |
| 158 | ); | ||
| 159 | // Tokens must never be surfaced through the redacted projection. | ||
| 87 | expect(JSON.stringify(loaded)).not.toContain("super-secret-token"); | 160 | expect(JSON.stringify(loaded)).not.toContain("super-secret-token"); |
| 161 | expect(JSON.stringify(loaded)).not.toContain("other-secret-token"); | ||
| 88 | }), | 162 | }), |
| 89 | ); | 163 | ); |
| 90 | 164 | ||
| @@ -97,35 +171,43 @@ describe("onepassword plugin", () => { · 6 unmodified lines | |||
| 97 | const configured = yield* executor.execute( | 171 | const configured = yield* executor.execute( |
| 98 | ToolAddress.make("executor.onepassword.configure"), | 172 | ToolAddress.make("executor.onepassword.configure"), |
| 99 | { | 173 | { |
| 174 | name: "1Password", | ||
| 100 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, | 175 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, |
| 101 | vaults: [ | 176 | vaults: [ |
| 102 | { id: "vault-123", name: "Personal" }, | 177 | { id: "vault-123", name: "Personal" }, |
| 103 | { id: "vault-456", name: "Work" }, | 178 | { id: "vault-456", name: "Work" }, |
| 104 | ], | 179 | ], |
| 105 | name: "1Password", | ||
| 106 | }, | 180 | }, |
| 107 | { onElicitation: "accept-all" }, | 181 | { onElicitation: "accept-all" }, |
| 108 | ); | 182 | ); |
| 109 | 183 | ||
| 110 | expect(configured).toEqual({ ok: true, data: { configured: true } }); | 184 | expect(configured).toMatchObject({ ok: true, data: { configured: true } }); |
| 185 | const accountId = (configured as { data: { accountId: string } }).data.accountId; | ||
| 186 | expect(typeof accountId).toBe("string"); | ||
| 187 | |||
| 111 | expect( | 188 | expect( |
| 112 | yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}), | 189 | yield* executor.execute(ToolAddress.make("executor.onepassword.getConfig"), {}), |
| 113 | ).toMatchObject({ | 190 | ).toMatchObject({ |
| 114 | ok: true, | 191 | ok: true, |
| 115 | data: { | 192 | data: { |
| 116 | config: { | 193 | config: { |
| 117 | vaults: [ | 194 | accounts: [ |
| 118 | { id: "vault-123", name: "Personal" }, | 195 | { |
| 119 | { id: "vault-456", name: "Work" }, | 196 | id: accountId, |
| 197 | name: "1Password", | ||
| 198 | vaults: [ | ||
| 199 | { id: "vault-123", name: "Personal" }, | ||
| 200 | { id: "vault-456", name: "Work" }, | ||
| 201 | ], | ||
| 202 | }, | ||
| 120 | ], | 203 | ], |
| 121 | name: "1Password", | ||
| 122 | }, | 204 | }, |
| 123 | }, | 205 | }, |
| 124 | }); | 206 | }); |
| 125 | 207 | ||
| 126 | const removed = yield* executor.execute( | 208 | const removed = yield* executor.execute( |
| 127 | ToolAddress.make("executor.onepassword.removeConfig"), | 209 | ToolAddress.make("executor.onepassword.removeConfig"), |
| 128 | {}, | 210 | { accountId }, |
| 129 | { onElicitation: "accept-all" }, | 211 | { onElicitation: "accept-all" }, |
| 130 | ); | 212 | ); |
| 131 | 213 | ||
| @@ -141,15 +223,17 @@ describe("onepassword plugin", () => { · 9 unmodified lines | |||
| 141 | ); | 223 | ); |
| 142 | const status = yield* executor.onepassword.status(); | 224 | const status = yield* executor.onepassword.status(); |
| 143 | expect(status.connected).toBe(false); | 225 | expect(status.connected).toBe(false); |
| 226 | expect(status.accounts).toEqual([]); | ||
| 144 | expect(status.error).toBe("Not configured"); | 227 | expect(status.error).toBe("Not configured"); |
| 145 | }), | 228 | }), |
| 146 | ); | 229 | ); |
| 147 | }); | 230 | }); |
| 148 | 231 | ||
| 149 | // --------------------------------------------------------------------------- | 232 | // --------------------------------------------------------------------------- |
| 150 | // Stored-config compatibility — blobs written before multi-vault support hold | 233 | // Stored-config compatibility — blobs written before multi-account support |
| 151 | // `{ auth, vaultId, name }`. Reads normalize that to a one-element vaults | 234 | // hold either `{ auth, vaultId, name }` (original) or `{ auth, vaults, name }` |
| 152 | // array; the next save writes the current shape. | 235 | // (multi-vault). Reads normalize both onto a single "default" account; the |
| 236 | // next save writes the current shape. | ||
| 153 | // --------------------------------------------------------------------------- | 237 | // --------------------------------------------------------------------------- |
| 154 | 238 | ||
| 155 | describe("onepassword store", () => { | 239 | describe("onepassword store", () => { |
| @@ -177,27 +261,65 @@ describe("onepassword store", () => { · 21 unmodified lines | |||
| 177 | 261 | ||
| 178 | const config = yield* store.getConfig(); | 262 | const config = yield* store.getConfig(); |
| 179 | expect(config).toEqual({ | 263 | expect(config).toEqual({ |
| 180 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, | 264 | accounts: [ |
| 181 | vaults: [{ id: "vault-123", name: "Personal" }], | 265 | { |
| 182 | name: "Personal", | 266 | id: "default", |
| 267 | name: "Personal", | ||
| 268 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, | ||
| 269 | vaults: [{ id: "vault-123", name: "Personal" }], | ||
| 270 | }, | ||
| 271 | ], | ||
| 272 | }); | ||
| 273 | }), | ||
| 274 | ); | ||
| 275 | |||
| 276 | it.effect("upgrades a single-account multi-vault blob on read", () => | ||
| 277 | Effect.gen(function* () { | ||
| 278 | const { blobs, store } = makeStore(); | ||
| 279 | yield* blobs.put( | ||
| 280 | "config", | ||
| 281 | JSON.stringify({ | ||
| 282 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, | ||
| 283 | vaults: [ | ||
| 284 | { id: "vault-123", name: "Personal" }, | ||
| 285 | { id: "vault-456", name: "Work" }, | ||
| 286 | ], | ||
| 287 | name: "1Password", | ||
| 288 | }), | ||
| 289 | { owner: "org" }, | ||
| 290 | ); | ||
| 291 | |||
| 292 | const config = yield* store.getConfig(); | ||
| 293 | expect(config).toEqual({ | ||
| 294 | accounts: [ | ||
| 295 | { | ||
| 296 | id: "default", | ||
| 297 | name: "1Password", | ||
| 298 | auth: { kind: "desktop-app", accountName: "my.1password.com" }, | ||
| 299 | vaults: [ | ||
| 300 | { id: "vault-123", name: "Personal" }, | ||
| 301 | { id: "vault-456", name: "Work" }, | ||
| 302 | ], | ||
| 303 | }, | ||
| 304 | ], | ||
| 183 | }); | 305 | }); |
| 184 | }), | 306 | }), |
| 185 | ); | 307 | ); |
| 186 | 308 | ||
| 187 | it.effect("persists and reads back the multi-vault shape", () => | 309 | it.effect("persists and reads back the multi-account shape", () => |
| 188 | Effect.gen(function* () { | 310 | Effect.gen(function* () { |
| 189 | const { store } = makeStore(); | 311 | const { store } = makeStore(); |
| 190 | yield* store.saveConfig(twoVaultConfig, "org"); | 312 | yield* store.saveConfig(twoAccountConfig, "org"); |
| 191 | const config = yield* store.getConfig(); | 313 | const config = yield* store.getConfig(); |
| 192 | expect(config).toEqual(twoVaultConfig); | 314 | expect(config).toEqual(twoAccountConfig); |
| 193 | }), | 315 | }), |
| 194 | ); | 316 | ); |
| 195 | }); | 317 | }); |
| 196 | 318 | ||
| 197 | // --------------------------------------------------------------------------- | 319 | // --------------------------------------------------------------------------- |
| 198 | // Explicit ref resolution — vault-qualified refs resolve directly; bare refs | 320 | // Explicit ref resolution — vault-qualified refs resolve directly via the |
| 199 | // must locate exactly one item, and a multi-vault match is an explicit | 321 | // owning account; bare refs must locate exactly one item, and a multi-vault |
| 200 | // ambiguity, never a precedence pick. | 322 | // match is an explicit ambiguity, never a precedence pick. |
| 201 | // --------------------------------------------------------------------------- | 323 | // --------------------------------------------------------------------------- |
| 202 | 324 | ||
| 203 | const fakeService = ( | 325 | const fakeService = ( |
| @@ -217,84 +339,160 @@ const fakeService = ( · 13 unmodified lines | |||
| 217 | }, | 339 | }, |
| 218 | }); | 340 | }); |
| 219 | 341 | ||
| 342 | /** One fake backend per account id; secrets resolve tagged with the serving | ||
| 343 | * account so tests can assert which account's auth handled a ref. */ | ||
| 344 | const fakeServiceFor = | ||
| 345 | ( | ||
| 346 | itemsByAccount: Readonly< | ||
| 347 | Record<string, Readonly<Record<string, readonly { id: string; title: string }[]>>> | ||
| 348 | >, | ||
| 349 | ) => | ||
| 350 | (account: OnePasswordAccount) => { | ||
| 351 | const items = itemsByAccount[account.id] ?? {}; | ||
| 352 | return Effect.succeed<OnePasswordService>({ | ||
| 353 | ...fakeService(items), | ||
| 354 | resolveSecret: (uri) => Effect.succeed(`secret:${account.id}:${uri}`), | ||
| 355 | }); | ||
| 356 | }; | ||
| 357 | |||
| 220 | describe("resolveConfiguredRef", () => { | 358 | describe("resolveConfiguredRef", () => { |
| 359 | const serviceFor = fakeServiceFor({}); | ||
| 360 | |||
| 221 | it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () => | 361 | it.effect("resolves a fully-qualified op:// URI in a configured vault as-is", () => |
| 222 | Effect.gen(function* () { | 362 | Effect.gen(function* () { |
| 223 | const svc = fakeService({}); | ||
| 224 | const result = yield* resolveConfiguredRef( | 363 | const result = yield* resolveConfiguredRef( |
| 225 | svc, | 364 | oneAccountConfig, |
| 226 | twoVaultConfig, | 365 | serviceFor, |
| 227 | "op://vault-456/item-abc/password", | 366 | "op://vault-456/item-abc/password", |
| 228 | ); | 367 | ); |
| 229 | expect(result).toEqual({ | 368 | expect(result).toEqual({ |
| 230 | kind: "resolved", | 369 | kind: "resolved", |
| 231 | value: "secret:op://vault-456/item-abc/password", | 370 | value: "secret:acct-default:op://vault-456/item-abc/password", |
| 232 | }); | 371 | }); |
| 233 | }), | 372 | }), |
| 234 | ); | 373 | ); |
| 235 | 374 | ||
| 236 | it.effect("appends the credential field to a picker-shaped op://vault/item ref", () => | 375 | it.effect("appends the credential field to a picker-shaped op://vault/item ref", () => |
| 237 | Effect.gen(function* () { | 376 | Effect.gen(function* () { |
| 238 | const svc = fakeService({}); | 377 | const result = yield* resolveConfiguredRef( |
| 239 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://vault-123/item-abc"); | 378 | oneAccountConfig, |
| 379 | serviceFor, | ||
| 380 | "op://vault-123/item-abc", | ||
| 381 | ); | ||
| 240 | expect(result).toEqual({ | 382 | expect(result).toEqual({ |
| 241 | kind: "resolved", | 383 | kind: "resolved", |
| 242 | value: "secret:op://vault-123/item-abc/credential", | 384 | value: "secret:acct-default:op://vault-123/item-abc/credential", |
| 243 | }); | 385 | }); |
| 244 | }), | 386 | }), |
| 245 | ); | 387 | ); |
| 246 | 388 | ||
| 247 | it.effect("accepts an op:// URI addressed by vault name", () => | 389 | it.effect("accepts an op:// URI addressed by vault name", () => |
| 248 | Effect.gen(function* () { | 390 | Effect.gen(function* () { |
| 249 | const svc = fakeService({}); | 391 | const result = yield* resolveConfiguredRef( |
| 250 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "op://Work/item/password"); | 392 | oneAccountConfig, |
| 251 | expect(result).toEqual({ kind: "resolved", value: "secret:op://Work/item/password" }); | 393 | serviceFor, |
| 394 | "op://Work/item/password", | ||
| 395 | ); | ||
| 396 | expect(result).toEqual({ | ||
| 397 | kind: "resolved", | ||
| 398 | value: "secret:acct-default:op://Work/item/password", | ||
| 399 | }); | ||
| 400 | }), | ||
| 401 | ); | ||
| 402 | |||
| 403 | it.effect("routes a vault-qualified ref through the account that owns the vault", () => | ||
| 404 | Effect.gen(function* () { | ||
| 405 | const result = yield* resolveConfiguredRef( | ||
| 406 | twoAccountConfig, | ||
| 407 | serviceFor, | ||
| 408 | "op://vault-home/item-abc/password", | ||
| 409 | ); | ||
| 410 | expect(result).toEqual({ | ||
| 411 | kind: "resolved", | ||
| 412 | value: "secret:acct-personal:op://vault-home/item-abc/password", | ||
| 413 | }); | ||
| 414 | }), | ||
| 415 | ); | ||
| 416 | |||
| 417 | it.effect("reports a vault name configured in more than one account as ambiguous", () => | ||
| 418 | Effect.gen(function* () { | ||
| 419 | const config = OnePasswordConfig.make({ | ||
| 420 | accounts: [ | ||
| 421 | OnePasswordAccount.make({ | ||
| 422 | id: "acct-a", | ||
| 423 | name: "Work", | ||
| 424 | auth: desktopAuth, | ||
| 425 | vaults: [{ id: "vault-a", name: "Shared" }], | ||
| 426 | }), | ||
| 427 | OnePasswordAccount.make({ | ||
| 428 | id: "acct-b", | ||
| 429 | name: "Personal", | ||
| 430 | auth: desktopAuth, | ||
| 431 | vaults: [{ id: "vault-b", name: "Shared" }], | ||
| 432 | }), | ||
| 433 | ], | ||
| 434 | }); | ||
| 435 | const result = yield* resolveConfiguredRef(config, serviceFor, "op://Shared/item"); | ||
| 436 | expect(result).toEqual({ | ||
| 437 | kind: "ambiguous-vault", | ||
| 438 | vaultName: "Shared", | ||
| 439 | matches: [ | ||
| 440 | { accountName: "Work", vaultId: "vault-a" }, | ||
| 441 | { accountName: "Personal", vaultId: "vault-b" }, | ||
| 442 | ], | ||
| 443 | }); | ||
| 252 | }), | 444 | }), |
| 253 | ); | 445 | ); |
| 254 | 446 | ||
| 255 | it.effect("reports an op:// URI outside the configured vaults", () => | 447 | it.effect("reports an op:// URI outside the configured vaults", () => |
| 256 | Effect.gen(function* () { | 448 | Effect.gen(function* () { |
| 257 | const svc = fakeService({}); | ||
| 258 | const result = yield* resolveConfiguredRef( | 449 | const result = yield* resolveConfiguredRef( |
| 259 | svc, | 450 | oneAccountConfig, |
| 260 | twoVaultConfig, | 451 | serviceFor, |
| 261 | "op://vault-999/item/password", | 452 | "op://vault-999/item/password", |
| 262 | ); | 453 | ); |
| 263 | expect(result).toEqual({ kind: "outside-vaults" }); | 454 | expect(result).toEqual({ kind: "outside-vaults" }); |
| 264 | }), | 455 | }), |
| 265 | ); | 456 | ); |
| 266 | 457 | ||
| 267 | it.effect("resolves a bare ref that matches exactly one item across vaults", () => | 458 | it.effect("resolves a bare ref that matches exactly one item across accounts", () => |
| 268 | Effect.gen(function* () { | 459 | Effect.gen(function* () { |
| 269 | const svc = fakeService({ | 460 | const withItems = fakeServiceFor({ |
| 270 | "vault-123": [{ id: "item-1", title: "GitHub Token" }], | 461 | "acct-work": { "vault-eng": [{ id: "item-1", title: "GitHub Token" }] }, |
| 271 | "vault-456": [{ id: "item-2", title: "Stripe Key" }], | 462 | "acct-personal": { "vault-home": [{ id: "item-2", title: "Stripe Key" }] }, |
| 272 | }); | 463 | }); |
| 273 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); | 464 | const result = yield* resolveConfiguredRef(twoAccountConfig, withItems, "GitHub Token"); |
| 274 | expect(result).toEqual({ | 465 | expect(result).toEqual({ |
| 275 | kind: "resolved", | 466 | kind: "resolved", |
| 276 | value: "secret:op://vault-123/item-1/credential", | 467 | value: "secret:acct-work:op://vault-eng/item-1/credential", |
| 277 | }); | 468 | }); |
| 278 | }), | 469 | }), |
| 279 | ); | 470 | ); |
| 280 | 471 | ||
| 281 | it.effect("fails a bare ref that matches in two vaults with the vaults named", () => | 472 | it.effect("fails a bare ref that matches in two accounts with the matches named", () => |
| 282 | Effect.gen(function* () { | 473 | Effect.gen(function* () { |
| 283 | const svc = fakeService({ | 474 | const withItems = fakeServiceFor({ |
| 284 | "vault-123": [{ id: "item-1", title: "GitHub Token" }], | 475 | "acct-work": { "vault-eng": [{ id: "item-1", title: "GitHub Token" }] }, |
| 285 | "vault-456": [{ id: "item-2", title: "GitHub Token" }], | 476 | "acct-personal": { "vault-home": [{ id: "item-2", title: "GitHub Token" }] }, |
| 286 | }); | 477 | }); |
| 287 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); | 478 | const result = yield* resolveConfiguredRef(twoAccountConfig, withItems, "GitHub Token"); |
| 288 | expect(result).toEqual({ | 479 | expect(result).toEqual({ |
| 289 | kind: "ambiguous", | 480 | kind: "ambiguous", |
| 290 | matches: [ | 481 | matches: [ |
| 291 | { | 482 | { |
| 292 | vaultId: "vault-123", | 483 | accountName: "Work", |
| 293 | vaultName: "Personal", | 484 | vaultId: "vault-eng", |
| 485 | vaultName: "Engineering", | ||
| 294 | itemId: "item-1", | 486 | itemId: "item-1", |
| 295 | itemTitle: "GitHub Token", | 487 | itemTitle: "GitHub Token", |
| 296 | }, | 488 | }, |
| 297 | { vaultId: "vault-456", vaultName: "Work", itemId: "item-2", itemTitle: "GitHub Token" }, | 489 | { |
| 490 | accountName: "Personal", | ||
| 491 | vaultId: "vault-home", | ||
| 492 | vaultName: "Home", | ||
| 493 | itemId: "item-2", | ||
| 494 | itemTitle: "GitHub Token", | ||
| 495 | }, | ||
| 298 | ], | 496 | ], |
| 299 | }); | 497 | }); |
| 300 | }), | 498 | }), |
| @@ -302,22 +500,26 @@ describe("resolveConfiguredRef", () => { · 1 unmodified line | |||
| 302 | 500 | ||
| 303 | it.effect("treats duplicate titles inside one vault as ambiguous too", () => | 501 | it.effect("treats duplicate titles inside one vault as ambiguous too", () => |
| 304 | Effect.gen(function* () { | 502 | Effect.gen(function* () { |
| 305 | const svc = fakeService({ | 503 | const withItems = fakeServiceFor({ |
| 306 | "vault-123": [ | 504 | "acct-default": { |
| 307 | { id: "item-1", title: "GitHub Token" }, | 505 | "vault-123": [ |
| 308 | { id: "item-9", title: "GitHub Token" }, | 506 | { id: "item-1", title: "GitHub Token" }, |
| 309 | ], | 507 | { id: "item-9", title: "GitHub Token" }, |
| 310 | "vault-456": [], | 508 | ], |
| 509 | "vault-456": [], | ||
| 510 | }, | ||
| 311 | }); | 511 | }); |
| 312 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "GitHub Token"); | 512 | const result = yield* resolveConfiguredRef(oneAccountConfig, withItems, "GitHub Token"); |
| 313 | expect(result.kind).toBe("ambiguous"); | 513 | expect(result.kind).toBe("ambiguous"); |
| 314 | }), | 514 | }), |
| 315 | ); | 515 | ); |
| 316 | 516 | ||
| 317 | it.effect("reports not-found for a bare ref matching nothing", () => | 517 | it.effect("reports not-found for a bare ref matching nothing", () => |
| 318 | Effect.gen(function* () { | 518 | Effect.gen(function* () { |
| 319 | const svc = fakeService({ "vault-123": [], "vault-456": [] }); | 519 | const withItems = fakeServiceFor({ |
| 320 | const result = yield* resolveConfiguredRef(svc, twoVaultConfig, "missing"); | 520 | "acct-default": { "vault-123": [], "vault-456": [] }, |
| 521 | }); | ||
| 522 | const result = yield* resolveConfiguredRef(oneAccountConfig, withItems, "missing"); | ||
| 321 | expect(result).toEqual({ kind: "not-found" }); | 523 | expect(result).toEqual({ kind: "not-found" }); |
| 322 | }), | 524 | }), |
| 323 | ); | 525 | ); |
| @@ -17,12 +17,14 @@ import { · 16 unmodified lines | |||
| 17 | } from "@executor-js/sdk/core"; | 17 | } from "@executor-js/sdk/core"; |
| 18 | 18 | ||
| 19 | import { | 19 | import { |
| 20 | OnePasswordAccount, | ||
| 20 | OnePasswordAuth, | 21 | OnePasswordAuth, |
| 21 | OnePasswordConfig, | 22 | OnePasswordConfig, |
| 22 | RedactedOnePasswordConfig, | 23 | RedactedOnePasswordConfig, |
| 23 | StoredOnePasswordConfig, | 24 | StoredOnePasswordConfig, |
| 24 | Vault, | 25 | Vault, |
| 25 | ConnectionStatus, | 26 | ConnectionStatus, |
| 27 | AccountStatus, | ||
| 26 | normalizeStoredConfig, | 28 | normalizeStoredConfig, |
| 27 | redactConfig, | 29 | redactConfig, |
| 28 | } from "./types"; | 30 | } from "./types"; |
| @@ -44,10 +46,25 @@ const schemaToStaticToolSchema = <A, I>(schema: Schema.Decoder<A, I>): StaticToo · 15 unmodified lines | |||
| 44 | I | 46 | I |
| 45 | >; | 47 | >; |
| 46 | 48 | ||
| 47 | const OnePasswordConfigureInput = OnePasswordConfig; | 49 | // --------------------------------------------------------------------------- |
| 50 | // Upsert payload — `configure` adds or replaces one account. The id is | ||
| 51 | // generated on first save so the caller can address the account later | ||
| 52 | // (edit, remove) without the name doubling as an identifier. | ||
| 53 | // --------------------------------------------------------------------------- | ||
| 54 | |||
| 55 | export const OnePasswordAccountUpsert = Schema.Struct({ | ||
| 56 | id: Schema.optional(Schema.String), | ||
| 57 | name: Schema.String, | ||
| 58 | auth: OnePasswordAuth, | ||
| 59 | vaults: Schema.NonEmptyArray(Vault), | ||
| 60 | }); | ||
| 61 | export type OnePasswordAccountUpsert = typeof OnePasswordAccountUpsert.Type; | ||
| 62 | |||
| 63 | const OnePasswordConfigureInput = OnePasswordAccountUpsert; | ||
| 48 | 64 | ||
| 49 | const OnePasswordConfigureOutput = Schema.Struct({ | 65 | const OnePasswordConfigureOutput = Schema.Struct({ |
| 50 | configured: Schema.Boolean, | 66 | configured: Schema.Boolean, |
| 67 | accountId: Schema.String, | ||
| 51 | }); | 68 | }); |
| 52 | 69 | ||
| 53 | const OnePasswordGetConfigOutput = Schema.Struct({ | 70 | const OnePasswordGetConfigOutput = Schema.Struct({ |
| @@ -60,6 +77,10 @@ const OnePasswordListVaultsOutput = Schema.Struct({ · 6 unmodified lines | |||
| 60 | vaults: Schema.Array(Vault), | 77 | vaults: Schema.Array(Vault), |
| 61 | }); | 78 | }); |
| 62 | 79 | ||
| 80 | const OnePasswordRemoveConfigInput = Schema.Struct({ | ||
| 81 | accountId: Schema.optional(Schema.String), | ||
| 82 | }); | ||
| 83 | |||
| 63 | const OnePasswordRemoveConfigOutput = Schema.Struct({ | 84 | const OnePasswordRemoveConfigOutput = Schema.Struct({ |
| 64 | removed: Schema.Boolean, | 85 | removed: Schema.Boolean, |
| 65 | }); | 86 | }); |
| @@ -77,6 +98,10 @@ const OnePasswordListVaultsInputStd = schemaToStaticToolSchema< · 11 unmodified lines | |||
| 77 | typeof OnePasswordListVaultsInput.Encoded | 98 | typeof OnePasswordListVaultsInput.Encoded |
| 78 | >(OnePasswordListVaultsInput); | 99 | >(OnePasswordListVaultsInput); |
| 79 | const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput); | 100 | const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput); |
| 101 | const OnePasswordRemoveConfigInputStd = schemaToStaticToolSchema< | ||
| 102 | typeof OnePasswordRemoveConfigInput.Type, | ||
| 103 | typeof OnePasswordRemoveConfigInput.Encoded | ||
| 104 | >(OnePasswordRemoveConfigInput); | ||
| 80 | const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput); | 105 | const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput); |
| 81 | const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput); | 106 | const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput); |
| 82 | 107 | ||
| @@ -95,13 +120,13 @@ export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; · 12 unmodified lines | |||
| 95 | 120 | ||
| 96 | // --------------------------------------------------------------------------- | 121 | // --------------------------------------------------------------------------- |
| 97 | // Typed config store — single blob, JSON encoded, owner-partitioned. The | 122 | // Typed config store — single blob, JSON encoded, owner-partitioned. The |
| 98 | // stored config carries the auth credential (desktop account name, or | 123 | // stored config carries every account's auth credential (desktop account |
| 99 | // service-account token) plus the selected vaults. v1 keyed this by executor | 124 | // name, or service-account token) plus its selected vaults. v1 keyed this by |
| 100 | // scope; v2 partitions by `owner` — the plugin-owned config row owns the | 125 | // executor scope; v2 partitions by `owner` — the plugin-owned config row owns |
| 101 | // partition, mirroring the connection model. Reads also accept the legacy | 126 | // the partition, mirroring the connection model. Reads also accept the two |
| 102 | // single-`vaultId` shape and normalize it to the vaults array; saves always | 127 | // pre-multi-account shapes and normalize them onto a single "default" |
| 103 | // write the current shape. Blob I/O failures surface as `StorageError`; | 128 | // account; saves always write the current shape. Blob I/O failures surface as |
| 104 | // decode failures stay `OnePasswordError`. | 129 | // `StorageError`; decode failures stay `OnePasswordError`. |
| 105 | // --------------------------------------------------------------------------- | 130 | // --------------------------------------------------------------------------- |
| 106 | 131 | ||
| 107 | export interface OnePasswordStore { | 132 | export interface OnePasswordStore { |
| @@ -147,15 +172,7 @@ export const makeOnePasswordStore = (blobs: PluginBlobStore): OnePasswordStore = · 39 unmodified lines | |||
| 147 | 172 | ||
| 148 | saveConfig: (config, owner) => | 173 | saveConfig: (config, owner) => |
| 149 | blobs | 174 | blobs |
| 150 | .put( | 175 | .put(CONFIG_KEY, JSON.stringify({ accounts: config.accounts }), { owner }) |
| 151 | CONFIG_KEY, | ||
| 152 | JSON.stringify({ | ||
| 153 | auth: config.auth, | ||
| 154 | vaults: config.vaults, | ||
| 155 | name: config.name, | ||
| 156 | }), | ||
| 157 | { owner }, | ||
| 158 | ) | ||
| 159 | .pipe(Effect.mapError(blobStorageError("write"))), | 176 | .pipe(Effect.mapError(blobStorageError("write"))), |
| 160 | 177 | ||
| 161 | deleteConfig: (owner) => | 178 | deleteConfig: (owner) => |
| @@ -171,26 +188,32 @@ const resolveAuth = (auth: OnePasswordAuth): ResolvedAuth => · 9 unmodified lines | |||
| 171 | ? { kind: "desktop-app", accountName: auth.accountName } | 188 | ? { kind: "desktop-app", accountName: auth.accountName } |
| 172 | : { kind: "service-account", token: auth.token }; | 189 | : { kind: "service-account", token: auth.token }; |
| 173 | 190 | ||
| 174 | const getServiceFromConfig = ( | 191 | /** One service per account: each account carries its own auth, so a shared |
| 175 | config: OnePasswordConfig, | 192 | * client can never leak one account's credential into another's calls. */ |
| 176 | timeoutMs: number, | 193 | const serviceForAccount = |
| 177 | preferSdk: boolean | undefined, | 194 | (timeoutMs: number, preferSdk: boolean | undefined) => |
| 178 | ): Effect.Effect<OnePasswordService, OnePasswordError> => | 195 | (account: OnePasswordAccount): Effect.Effect<OnePasswordService, OnePasswordError> => |
| 179 | makeOnePasswordService(resolveAuth(config.auth), { timeoutMs, preferSdk }); | 196 | makeOnePasswordService(resolveAuth(account.auth), { timeoutMs, preferSdk }); |
| 180 | 197 | ||
| 181 | // --------------------------------------------------------------------------- | 198 | // --------------------------------------------------------------------------- |
| 182 | // Explicit ref resolution. | 199 | // Explicit ref resolution. |
| 183 | // | 200 | // |
| 184 | // A ref is one of: | 201 | // A ref is one of: |
| 185 | // - `op://vault/item/field...` — fully qualified, resolved as-is. | 202 | // - `op://vault/item/field...` — fully qualified, resolved as-is via the |
| 203 | // account that owns the vault. The first | ||
| 204 | // segment stays a VAULT (id or name), | ||
| 205 | // matching 1Password's own op:// semantics — | ||
| 206 | // account selection is derived, never | ||
| 207 | // encoded into the ref. | ||
| 186 | // - `op://vault/item` — picker-shaped; the default credential field | 208 | // - `op://vault/item` — picker-shaped; the default credential field |
| 187 | // is appended. This is the id shape `list()` | 209 | // is appended. This is the id shape `list()` |
| 188 | // hands out, so every picked item permanently | 210 | // hands out, so every picked item permanently |
| 189 | // records which vault it came from. | 211 | // records which vault it came from. |
| 190 | // - a bare item id or title — located by listing the configured vaults. | 212 | // - a bare item id or title — located by listing every account's |
| 191 | // Exactly one match resolves; several matches | 213 | // configured vaults. Exactly one match |
| 192 | // are an explicit ambiguity failure naming the | 214 | // resolves; several matches are an explicit |
| 193 | // vaults — never a silent precedence pick. | 215 | // ambiguity failure naming the vaults — never |
| 216 | // a precedence pick. | ||
| 194 | // --------------------------------------------------------------------------- | 217 | // --------------------------------------------------------------------------- |
| 195 | 218 | ||
| 196 | export type RefResolution = | 219 | export type RefResolution = |
| @@ -200,11 +223,20 @@ export type RefResolution = · 3 unmodified lines | |||
| 200 | | { | 223 | | { |
| 201 | readonly kind: "ambiguous"; | 224 | readonly kind: "ambiguous"; |
| 202 | readonly matches: readonly { | 225 | readonly matches: readonly { |
| 226 | readonly accountName: string; | ||
| 203 | readonly vaultId: string; | 227 | readonly vaultId: string; |
| 204 | readonly vaultName: string; | 228 | readonly vaultName: string; |
| 205 | readonly itemId: string; | 229 | readonly itemId: string; |
| 206 | readonly itemTitle: string; | 230 | readonly itemTitle: string; |
| 207 | }[]; | 231 | }[]; |
| 232 | } | ||
| 233 | | { | ||
| 234 | readonly kind: "ambiguous-vault"; | ||
| 235 | readonly vaultName: string; | ||
| 236 | readonly matches: readonly { | ||
| 237 | readonly accountName: string; | ||
| 238 | readonly vaultId: string; | ||
| 239 | }[]; | ||
| 208 | }; | 240 | }; |
| 209 | 241 | ||
| 210 | export const ambiguityMessage = ( | 242 | export const ambiguityMessage = ( |
| @@ -213,18 +245,28 @@ export const ambiguityMessage = ( · 2 unmodified lines | |||
| 213 | ): string => | 245 | ): string => |
| 214 | [ | 246 | [ |
| 215 | `1Password ref "${ref}" is ambiguous: it matches`, | 247 | `1Password ref "${ref}" is ambiguous: it matches`, |
| 216 | matches.map((m) => `"${m.itemTitle}" in vault "${m.vaultName}"`).join(", "), | 248 | matches |
| 249 | .map((m) => `"${m.itemTitle}" in vault "${m.vaultName}" (account "${m.accountName}")`) | ||
| 250 | .join(", "), | ||
| 217 | `. Use op://<vaultId>/<itemId> to pick one.`, | 251 | `. Use op://<vaultId>/<itemId> to pick one.`, |
| 218 | ].join(" "); | 252 | ].join(" "); |
| 219 | 253 | ||
| 220 | const isConfiguredVaultSegment = (config: OnePasswordConfig, segment: string): boolean => | 254 | export const vaultAmbiguityMessage = ( |
| 221 | config.vaults.some((vault) => vault.id === segment || vault.name === segment); | 255 | resolution: Extract<RefResolution, { kind: "ambiguous-vault" }>, |
| 256 | ): string => | ||
| 257 | [ | ||
| 258 | `1Password vault name "${resolution.vaultName}" is configured in more than one account:`, | ||
| 259 | resolution.matches.map((m) => `"${m.accountName}" (vault id ${m.vaultId})`).join(", "), | ||
| 260 | `. Use op://<vaultId>/... to pick one.`, | ||
| 261 | ].join(" "); | ||
| 222 | 262 | ||
| 223 | /** Resolve a ref against the configured vaults. Backend failures stay on the | 263 | /** Resolve a ref against every configured account. Backend failures stay on |
| 224 | * error channel; every addressing outcome is an explicit `RefResolution`. */ | 264 | * the error channel; every addressing outcome is an explicit |
| 265 | * `RefResolution`. Services are built per account and only for the accounts | ||
| 266 | * a ref actually needs. */ | ||
| 225 | export const resolveConfiguredRef = ( | 267 | export const resolveConfiguredRef = ( |
| 226 | svc: OnePasswordService, | ||
| 227 | config: OnePasswordConfig, | 268 | config: OnePasswordConfig, |
| 269 | serviceFor: (account: OnePasswordAccount) => Effect.Effect<OnePasswordService, OnePasswordError>, | ||
| 228 | ref: string, | 270 | ref: string, |
| 229 | ): Effect.Effect<RefResolution, OnePasswordError> => { | 271 | ): Effect.Effect<RefResolution, OnePasswordError> => { |
| 230 | if (ref.startsWith("op://")) { | 272 | if (ref.startsWith("op://")) { |
| @@ -233,35 +275,83 @@ export const resolveConfiguredRef = ( · 2 unmodified lines | |||
| 233 | if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) { | 275 | if (segments.length < 2 || vaultSegment === undefined || segments.includes("")) { |
| 234 | return Effect.succeed({ kind: "not-found" }); | 276 | return Effect.succeed({ kind: "not-found" }); |
| 235 | } | 277 | } |
| 236 | if (!isConfiguredVaultSegment(config, vaultSegment)) { | ||
| 237 | return Effect.succeed({ kind: "outside-vaults" }); | ||
| 238 | } | ||
| 239 | const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref; | 278 | const uri = segments.length === 2 ? `${ref}/${CREDENTIAL_FIELD}` : ref; |
| 240 | return svc | 279 | |
| 241 | .resolveSecret(uri) | 280 | // Vault ids are globally unique, so an id-addressed ref names one vault no |
| 242 | .pipe(Effect.map((value): RefResolution => ({ kind: "resolved", value }))); | 281 | // matter how many accounts configure it — any owning account can serve it. |
| 282 | const idOwner = config.accounts.find((account) => | ||
| 283 | account.vaults.some((vault) => vault.id === vaultSegment), | ||
| 284 | ); | ||
| 285 | if (idOwner !== undefined) { | ||
| 286 | return serviceFor(idOwner).pipe( | ||
| 287 | Effect.flatMap((svc) => svc.resolveSecret(uri)), | ||
| 288 | Effect.map((value): RefResolution => ({ kind: "resolved", value })), | ||
| 289 | ); | ||
| 290 | } | ||
| 291 | |||
| 292 | // Vault names are only unique per account: the same name in two accounts | ||
| 293 | // is two different vaults, so a name-addressed ref must be an explicit | ||
| 294 | // ambiguity, never a precedence pick. | ||
| 295 | const nameOwners = config.accounts.flatMap((account) => | ||
| 296 | account.vaults | ||
| 297 | .filter((vault) => vault.name === vaultSegment) | ||
| 298 | .map((vault) => ({ account, vault })), | ||
| 299 | ); | ||
| 300 | const [onlyOwner, ...extraOwners] = nameOwners; | ||
| 301 | if (onlyOwner === undefined) return Effect.succeed({ kind: "outside-vaults" }); | ||
| 302 | if (extraOwners.length > 0) { | ||
| 303 | return Effect.succeed({ | ||
| 304 | kind: "ambiguous-vault", | ||
| 305 | vaultName: vaultSegment, | ||
| 306 | matches: nameOwners.map((owner) => ({ | ||
| 307 | accountName: owner.account.name, | ||
| 308 | vaultId: owner.vault.id, | ||
| 309 | })), | ||
| 310 | }); | ||
| 311 | } | ||
| 312 | return serviceFor(onlyOwner.account).pipe( | ||
| 313 | Effect.flatMap((svc) => svc.resolveSecret(uri)), | ||
| 314 | Effect.map((value): RefResolution => ({ kind: "resolved", value })), | ||
| 315 | ); | ||
| 243 | } | 316 | } |
| 244 | 317 | ||
| 245 | return Effect.gen(function* () { | 318 | return Effect.gen(function* () { |
| 246 | const matches = (yield* Effect.forEach(config.vaults, (vault) => | 319 | const matches = (yield* Effect.forEach(config.accounts, (account) => |
| 247 | svc.listItems(vault.id).pipe( | 320 | serviceFor(account).pipe( |
| 248 | Effect.map((items) => | 321 | Effect.flatMap((svc) => |
| 249 | items | 322 | Effect.forEach(account.vaults, (vault) => |
| 250 | .filter((item) => item.id === ref || item.title === ref) | 323 | svc.listItems(vault.id).pipe( |
| 251 | .map((item) => ({ | 324 | Effect.map((items) => |
| 252 | vaultId: vault.id, | 325 | items |
| 253 | vaultName: vault.name, | 326 | .filter((item) => item.id === ref || item.title === ref) |
| 254 | itemId: item.id, | 327 | .map((item) => ({ |
| 255 | itemTitle: item.title, | 328 | accountId: account.id, |
| 256 | })), | 329 | accountName: account.name, |
| 330 | vaultId: vault.id, | ||
| 331 | vaultName: vault.name, | ||
| 332 | itemId: item.id, | ||
| 333 | itemTitle: item.title, | ||
| 334 | })), | ||
| 335 | ), | ||
| 336 | ), | ||
| 337 | ), | ||
| 257 | ), | 338 | ), |
| 339 | Effect.map((groups) => groups.flat()), | ||
| 258 | ), | 340 | ), |
| 259 | )).flat(); | 341 | )).flat(); |
| 260 | 342 | ||
| 261 | const [only, ...extra] = matches; | 343 | const [only, ...extra] = matches; |
| 262 | if (only === undefined) return { kind: "not-found" } as const; | 344 | if (only === undefined) return { kind: "not-found" } as const; |
| 263 | if (extra.length > 0) return { kind: "ambiguous", matches } as const; | 345 | if (extra.length > 0) { |
| 346 | return { | ||
| 347 | kind: "ambiguous", | ||
| 348 | matches: matches.map(({ accountId: _accountId, ...match }) => match), | ||
| 349 | } as const; | ||
| 350 | } | ||
| 264 | 351 | ||
| 352 | const owner = config.accounts.find((account) => account.id === only.accountId); | ||
| 353 | if (owner === undefined) return { kind: "not-found" } as const; | ||
| 354 | const svc = yield* serviceFor(owner); | ||
| 265 | const value = yield* svc.resolveSecret( | 355 | const value = yield* svc.resolveSecret( |
| 266 | `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`, | 356 | `op://${only.vaultId}/${only.itemId}/${CREDENTIAL_FIELD}`, |
| 267 | ); | 357 | ); |
| @@ -274,78 +364,93 @@ export const resolveConfiguredRef = ( · 6 unmodified lines | |||
| 274 | // | 364 | // |
| 275 | // v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is | 365 | // v2: `get(id)` receives only an opaque `ProviderItemId` — no scope. The id is |
| 276 | // a vault-qualified `op://` ref (what `list()` hands out) or a bare item | 366 | // a vault-qualified `op://` ref (what `list()` hands out) or a bare item |
| 277 | // id/title that must locate exactly one item across the configured vaults. | 367 | // id/title that must locate exactly one item across every account's |
| 278 | // The plugin's stored config supplies the auth + vault bindings; the provider | 368 | // configured vaults. The plugin's stored config supplies the auth + vault |
| 279 | // never writes (writable: false). | 369 | // bindings; the provider never writes (writable: false). |
| 280 | // --------------------------------------------------------------------------- | 370 | // --------------------------------------------------------------------------- |
| 281 | 371 | ||
| 282 | const makeProvider = ( | 372 | const makeProvider = ( |
| 283 | ctx: PluginCtx<OnePasswordStore>, | 373 | ctx: PluginCtx<OnePasswordStore>, |
| 284 | timeoutMs: number, | 374 | timeoutMs: number, |
| 285 | preferSdk: boolean | undefined, | 375 | preferSdk: boolean | undefined, |
| 286 | ): CredentialProvider => ({ | 376 | ): CredentialProvider => { |
| 287 | key: PROVIDER_KEY, | 377 | const serviceFor = serviceForAccount(timeoutMs, preferSdk); |
| 288 | writable: false, | 378 | return { |
| 289 | 379 | key: PROVIDER_KEY, | |
| 290 | get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> => | 380 | writable: false, |
| 291 | ctx.storage.getConfig().pipe( | 381 | |
| 292 | // An undecodable stored config reads as "not configured" here; the | 382 | get: (id: ProviderItemId): Effect.Effect<string | null, StorageFailure> => |
| 293 | // settings surface reports the decode problem. | 383 | ctx.storage.getConfig().pipe( |
| 294 | Effect.catchTag("OnePasswordError", () => Effect.succeed(null)), | 384 | // An undecodable stored config reads as "not configured" here; the |
| 295 | Effect.flatMap((config) => { | 385 | // settings surface reports the decode problem. |
| 296 | if (!config) return Effect.succeed(null as string | null); | 386 | Effect.catchTag("OnePasswordError", () => Effect.succeed(null)), |
| 297 | 387 | Effect.flatMap((config) => { | |
| 298 | return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( | 388 | if (!config) return Effect.succeed(null as string | null); |
| 299 | Effect.flatMap((svc) => resolveConfiguredRef(svc, config, id)), | 389 | |
| 300 | // Backend unreachability degrades to "no value", matching the other | 390 | return resolveConfiguredRef(config, serviceFor, id).pipe( |
| 301 | // providers. Ambiguity does NOT: silently picking a vault (or | 391 | // Backend unreachability degrades to "no value", matching the other |
| 302 | // silently failing) hides a real conflict, so it surfaces as a | 392 | // providers. Ambiguity does NOT: silently picking a vault (or |
| 303 | // typed failure with the full explanation. | 393 | // silently failing) hides a real conflict, so it surfaces as a |
| 304 | Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)), | 394 | // typed failure with the full explanation. |
| 305 | Effect.flatMap( | 395 | Effect.catch(() => Effect.succeed({ kind: "not-found" } as RefResolution)), |
| 306 | (resolution): Effect.Effect<string | null, StorageError> => | 396 | Effect.flatMap((resolution): Effect.Effect<string | null, StorageError> => { |
| 307 | resolution.kind === "ambiguous" | 397 | if (resolution.kind === "ambiguous") { |
| 308 | ? Effect.fail( | 398 | return Effect.fail( |
| 309 | new StorageError({ | 399 | new StorageError({ |
| 310 | message: ambiguityMessage(id, resolution.matches), | 400 | message: ambiguityMessage(id, resolution.matches), |
| 311 | cause: undefined, | 401 | cause: undefined, |
| 312 | }), | 402 | }), |
| 313 | ) | 403 | ); |
| 314 | : Effect.succeed(resolution.kind === "resolved" ? resolution.value : null), | 404 | } |
| 315 | ), | 405 | if (resolution.kind === "ambiguous-vault") { |
| 316 | ); | 406 | return Effect.fail( |
| 317 | }), | 407 | new StorageError({ |
| 318 | ), | 408 | message: vaultAmbiguityMessage(resolution), |
| 409 | cause: undefined, | ||
| 410 | }), | ||
| 411 | ); | ||
| 412 | } | ||
| 413 | return Effect.succeed(resolution.kind === "resolved" ? resolution.value : null); | ||
| 414 | }), | ||
| 415 | ); | ||
| 416 | }), | ||
| 417 | ), | ||
| 319 | 418 | ||
| 320 | list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> => | 419 | list: (): Effect.Effect<readonly ProviderEntry[], StorageFailure> => |
| 321 | ctx.storage.getConfig().pipe( | 420 | ctx.storage.getConfig().pipe( |
| 322 | Effect.flatMap((config) => { | 421 | Effect.flatMap((config) => { |
| 323 | if (!config) return Effect.succeed([] as readonly ProviderEntry[]); | 422 | if (!config) return Effect.succeed([] as readonly ProviderEntry[]); |
| 324 | return getServiceFromConfig(config, timeoutMs, preferSdk).pipe( | 423 | const multipleAccounts = config.accounts.length > 1; |
| 325 | Effect.flatMap((svc) => | 424 | return Effect.forEach(config.accounts, (account) => |
| 326 | Effect.forEach(config.vaults, (vault) => | 425 | serviceFor(account).pipe( |
| 327 | svc.listItems(vault.id).pipe( | 426 | Effect.flatMap((svc) => |
| 328 | Effect.map((items) => | 427 | Effect.forEach(account.vaults, (vault) => |
| 329 | items.map( | 428 | svc.listItems(vault.id).pipe( |
| 330 | // Vault-qualified ids: picking an entry permanently | 429 | Effect.map((items) => |
| 331 | // records which vault it came from, so identically-titled | 430 | items.map( |
| 332 | // items in different vaults can never collide. | 431 | // Vault-qualified ids: picking an entry permanently |
| 333 | (item): ProviderEntry => ({ | 432 | // records which vault it came from, so identically-titled |
| 334 | id: ProviderItemId.make(`op://${vault.id}/${item.id}`), | 433 | // items in different vaults can never collide. |
| 335 | name: item.title, | 434 | (item): ProviderEntry => ({ |
| 336 | group: vault.name, | 435 | id: ProviderItemId.make(`op://${vault.id}/${item.id}`), |
| 337 | }), | 436 | name: item.title, |
| 437 | group: multipleAccounts ? `${account.name} · ${vault.name}` : vault.name, | ||
| 438 | }), | ||
| 439 | ), | ||
| 440 | ), | ||
| 338 | ), | 441 | ), |
| 339 | ), | 442 | ), |
| 340 | ), | 443 | ), |
| 444 | Effect.map((groups) => groups.flat()), | ||
| 445 | // One unreachable account must not hide the others' items. | ||
| 446 | Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), | ||
| 341 | ), | 447 | ), |
| 342 | ), | 448 | ).pipe(Effect.map((groups): readonly ProviderEntry[] => groups.flat())); |
| 343 | Effect.map((groups): readonly ProviderEntry[] => groups.flat()), | 449 | }), |
| 344 | ); | 450 | Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), |
| 345 | }), | 451 | ), |
| 346 | Effect.catch(() => Effect.succeed([] as readonly ProviderEntry[])), | 452 | }; |
| 347 | ), | 453 | }; |
| 348 | }); | ||
| 349 | 454 | ||
| 350 | // --------------------------------------------------------------------------- | 455 | // --------------------------------------------------------------------------- |
| 351 | // Owner resolution — config is a single shared 1Password binding. We persist | 456 | // Owner resolution — config is a single shared 1Password binding. We persist |
| @@ -361,8 +466,73 @@ const makeOnePasswordExtension = ( · 9 unmodified lines | |||
| 361 | timeoutMs: number, | 466 | timeoutMs: number, |
| 362 | preferSdk: boolean | undefined, | 467 | preferSdk: boolean | undefined, |
| 363 | ) => { | 468 | ) => { |
| 469 | const serviceFor = serviceForAccount(timeoutMs, preferSdk); | ||
| 470 | |||
| 471 | const accountStatus = (account: OnePasswordAccount): Effect.Effect<AccountStatus, never> => | ||
| 472 | serviceFor(account).pipe( | ||
| 473 | Effect.flatMap((svc) => svc.listVaults()), | ||
| 474 | Effect.map((live) => { | ||
| 475 | const liveById = new Map(live.map((v) => [v.id, v.title])); | ||
| 476 | const missing = account.vaults.filter((vault) => !liveById.has(vault.id)); | ||
| 477 | return AccountStatus.make({ | ||
| 478 | id: account.id, | ||
| 479 | name: account.name, | ||
| 480 | connected: true, | ||
| 481 | vaultNames: account.vaults.map((vault) => liveById.get(vault.id) ?? vault.name), | ||
| 482 | ...(missing.length > 0 | ||
| 483 | ? { | ||
| 484 | error: `Configured vaults not found: ${missing | ||
| 485 | .map((vault) => vault.name) | ||
| 486 | .join(", ")}`, | ||
| 487 | } | ||
| 488 | : {}), | ||
| 489 | }); | ||
| 490 | }), | ||
| 491 | Effect.catchTag("OnePasswordError", (error) => | ||
| 492 | Effect.succeed( | ||
| 493 | AccountStatus.make({ | ||
| 494 | id: account.id, | ||
| 495 | name: account.name, | ||
| 496 | connected: false, | ||
| 497 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OnePasswordError carries a typed `message` | ||
| 498 | error: error.message, | ||
| 499 | }), | ||
| 500 | ), | ||
| 501 | ), | ||
| 502 | ); | ||
| 503 | |||
| 364 | return { | 504 | return { |
| 365 | configure: (config: OnePasswordConfig) => ctx.storage.saveConfig(config, ownerForCtx(ctx)), | 505 | /** Add or replace one account. A payload without an id creates a new |
| 506 | * account; with an id it replaces the matching account in place. */ | ||
| 507 | configure: ( | ||
| 508 | upsert: OnePasswordAccountUpsert, | ||
| 509 | ): Effect.Effect<{ readonly accountId: string }, StorageError | OnePasswordError> => | ||
| 510 | Effect.gen(function* () { | ||
| 511 | const existing = yield* ctx.storage.getConfig().pipe( | ||
| 512 | // An undecodable blob must not brick configuration: the next save | ||
| 513 | // rewrites the whole config in the current shape. | ||
| 514 | Effect.catchTag("OnePasswordError", () => Effect.succeed(null)), | ||
| 515 | ); | ||
| 516 | const accountId = upsert.id ?? crypto.randomUUID(); | ||
| 517 | const account = OnePasswordAccount.make({ | ||
| 518 | id: accountId, | ||
| 519 | name: upsert.name, | ||
| 520 | auth: upsert.auth, | ||
| 521 | vaults: upsert.vaults, | ||
| 522 | }); | ||
| 523 | // Position-preserving upsert: an edit replaces the account in place, | ||
| 524 | // a new account appends — the settings list never reshuffles. | ||
| 525 | const current = existing === null ? [] : existing.accounts; | ||
| 526 | const accounts = current.some((candidate) => candidate.id === accountId) | ||
| 527 | ? current.map((candidate) => (candidate.id === accountId ? account : candidate)) | ||
| 528 | : [...current, account]; | ||
| 529 | const [first, ...rest] = accounts; | ||
| 530 | yield* ctx.storage.saveConfig( | ||
| 531 | { accounts: first === undefined ? [account] : [first, ...rest] }, | ||
| 532 | ownerForCtx(ctx), | ||
| 533 | ); | ||
| 534 | return { accountId }; | ||
| 535 | }), | ||
| 366 | 536 | ||
| 367 | getConfig: (): Effect.Effect< | 537 | getConfig: (): Effect.Effect< |
| 368 | RedactedOnePasswordConfig | null, | 538 | RedactedOnePasswordConfig | null, |
| @@ -370,7 +540,22 @@ const makeOnePasswordExtension = ( · 1 unmodified line | |||
| 370 | > => | 540 | > => |
| 371 | ctx.storage.getConfig().pipe(Effect.map((config) => (config ? redactConfig(config) : null))), | 541 | ctx.storage.getConfig().pipe(Effect.map((config) => (config ? redactConfig(config) : null))), |
| 372 | 542 | ||
| 373 | removeConfig: () => ctx.storage.deleteConfig(ownerForCtx(ctx)), | 543 | /** Remove one account by id, or the whole configuration when no id is |
| 544 | * given. Removing the last account deletes the blob. */ | ||
| 545 | removeConfig: (accountId?: string): Effect.Effect<void, StorageError | OnePasswordError> => { | ||
| 546 | if (accountId === undefined) return ctx.storage.deleteConfig(ownerForCtx(ctx)); | ||
| 547 | return Effect.gen(function* () { | ||
| 548 | const existing = yield* ctx.storage.getConfig(); | ||
| 549 | if (existing === null) return; | ||
| 550 | const remaining = existing.accounts.filter((account) => account.id !== accountId); | ||
| 551 | const [first, ...rest] = remaining; | ||
| 552 | if (first === undefined) { | ||
| 553 | yield* ctx.storage.deleteConfig(ownerForCtx(ctx)); | ||
| 554 | return; | ||
| 555 | } | ||
| 556 | yield* ctx.storage.saveConfig({ accounts: [first, ...rest] }, ownerForCtx(ctx)); | ||
| 557 | }); | ||
| 558 | }, | ||
| 374 | 559 | ||
| 375 | status: () => | 560 | status: () => |
| 376 | Effect.gen(function* () { | 561 | Effect.gen(function* () { |
| @@ -378,21 +563,18 @@ const makeOnePasswordExtension = ( · 1 unmodified line | |||
| 378 | if (!config) { | 563 | if (!config) { |
| 379 | return ConnectionStatus.make({ | 564 | return ConnectionStatus.make({ |
| 380 | connected: false, | 565 | connected: false, |
| 566 | accounts: [], | ||
| 381 | error: "Not configured", | 567 | error: "Not configured", |
| 382 | }); | 568 | }); |
| 383 | } | 569 | } |
| 384 | const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); | 570 | const accounts = yield* Effect.forEach(config.accounts, accountStatus); |
| 385 | const live = yield* svc.listVaults(); | 571 | const broken = accounts.filter((account) => !account.connected); |
| 386 | const liveById = new Map(live.map((v) => [v.id, v.title])); | ||
| 387 | const missing = config.vaults.filter((vault) => !liveById.has(vault.id)); | ||
| 388 | return ConnectionStatus.make({ | 572 | return ConnectionStatus.make({ |
| 389 | connected: true, | 573 | connected: broken.length === 0, |
| 390 | vaultNames: config.vaults.map((vault) => liveById.get(vault.id) ?? vault.name), | 574 | accounts, |
| 391 | ...(missing.length > 0 | 575 | ...(broken.length > 0 |
| 392 | ? { | 576 | ? { |
| 393 | error: `Configured vaults not found: ${missing | 577 | error: `Unreachable accounts: ${broken.map((account) => account.name).join(", ")}`, |
| 394 | .map((vault) => vault.name) | ||
| 395 | .join(", ")}`, | ||
| 396 | } | 578 | } |
| 397 | : {}), | 579 | : {}), |
| 398 | }); | 580 | }); |
| @@ -419,8 +601,7 @@ const makeOnePasswordExtension = ( · 20 unmodified lines | |||
| 419 | message: "1Password is not configured", | 601 | message: "1Password is not configured", |
| 420 | }); | 602 | }); |
| 421 | } | 603 | } |
| 422 | const svc = yield* getServiceFromConfig(config, timeoutMs, preferSdk); | 604 | const resolution = yield* resolveConfiguredRef(config, serviceFor, uri); |
| 423 | const resolution = yield* resolveConfiguredRef(svc, config, uri); | ||
| 424 | if (resolution.kind === "resolved") return resolution.value; | 605 | if (resolution.kind === "resolved") return resolution.value; |
| 425 | if (resolution.kind === "outside-vaults") { | 606 | if (resolution.kind === "outside-vaults") { |
| 426 | return yield* new OnePasswordError({ | 607 | return yield* new OnePasswordError({ |
| @@ -434,6 +615,12 @@ const makeOnePasswordExtension = ( · 7 unmodified lines | |||
| 434 | message: ambiguityMessage(uri, resolution.matches), | 615 | message: ambiguityMessage(uri, resolution.matches), |
| 435 | }); | 616 | }); |
| 436 | } | 617 | } |
| 618 | if (resolution.kind === "ambiguous-vault") { | ||
| 619 | return yield* new OnePasswordError({ | ||
| 620 | operation: "resolve", | ||
| 621 | message: vaultAmbiguityMessage(resolution), | ||
| 622 | }); | ||
| 623 | } | ||
| 437 | return yield* new OnePasswordError({ | 624 | return yield* new OnePasswordError({ |
| 438 | operation: "resolve", | 625 | operation: "resolve", |
| 439 | message: `1Password item "${uri}" was not found in the configured vaults`, | 626 | message: `1Password item "${uri}" was not found in the configured vaults`, |
| @@ -475,7 +662,7 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 35 unmodified lines | |||
| 475 | tool({ | 662 | tool({ |
| 476 | name: "status", | 663 | name: "status", |
| 477 | description: | 664 | description: |
| 478 | "Check whether the 1Password credential provider is configured and can reach its selected vaults. This returns status only, never secret values.", | 665 | "Check whether the 1Password credential provider is configured and can reach each account's selected vaults. This returns status only, never secret values.", |
| 479 | outputSchema: OnePasswordStatusOutputStd, | 666 | outputSchema: OnePasswordStatusOutputStd, |
| 480 | execute: () => Effect.map(self.status(), ToolResult.ok), | 667 | execute: () => Effect.map(self.status(), ToolResult.ok), |
| 481 | }), | 668 | }), |
| @@ -498,29 +685,36 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption · 16 unmodified lines | |||
| 498 | tool({ | 685 | tool({ |
| 499 | name: "configure", | 686 | name: "configure", |
| 500 | description: | 687 | description: |
| 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.", | 688 | "Add or update a named 1Password account for the acting owner, each scoping 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. Pass the account id to update an existing account; omit it to add a new one.", |
| 502 | annotations: { | 689 | annotations: { |
| 503 | requiresApproval: true, | 690 | requiresApproval: true, |
| 504 | approvalDescription: "Configure the 1Password credential provider", | 691 | approvalDescription: "Configure a 1Password credential provider account", |
| 505 | }, | 692 | }, |
| 506 | inputSchema: OnePasswordConfigureInputStd, | 693 | inputSchema: OnePasswordConfigureInputStd, |
| 507 | outputSchema: OnePasswordConfigureOutputStd, | 694 | outputSchema: OnePasswordConfigureOutputStd, |
| 508 | execute: (input) => | 695 | execute: (input) => |
| 509 | Effect.as( | 696 | Effect.map( |
| 510 | self.configure({ auth: input.auth, vaults: input.vaults, name: input.name }), | 697 | self.configure({ |
| 511 | ToolResult.ok({ configured: true }), | 698 | ...(input.id !== undefined ? { id: input.id } : {}), |
| 699 | name: input.name, | ||
| 700 | auth: input.auth, | ||
| 701 | vaults: input.vaults, | ||
| 702 | }), | ||
| 703 | ({ accountId }) => ToolResult.ok({ configured: true, accountId }), | ||
| 512 | ), | 704 | ), |
| 513 | }), | 705 | }), |
| 514 | tool({ | 706 | tool({ |
| 515 | name: "removeConfig", | 707 | name: "removeConfig", |
| 516 | description: | 708 | description: |
| 517 | "Remove the 1Password provider configuration for the acting owner. Future 1Password secret resolution stops until reconfigured.", | 709 | "Remove one 1Password account by id, or the whole provider configuration when no id is given. Secret resolution through the removed account stops until reconfigured.", |
| 518 | annotations: { | 710 | annotations: { |
| 519 | requiresApproval: true, | 711 | requiresApproval: true, |
| 520 | approvalDescription: "Remove the 1Password credential provider configuration", | 712 | approvalDescription: "Remove 1Password credential provider configuration", |
| 521 | }, | 713 | }, |
| 714 | inputSchema: OnePasswordRemoveConfigInputStd, | ||
| 522 | outputSchema: OnePasswordRemoveConfigOutputStd, | 715 | outputSchema: OnePasswordRemoveConfigOutputStd, |
| 523 | execute: () => Effect.as(self.removeConfig(), ToolResult.ok({ removed: true })), | 716 | execute: (input) => |
| 717 | Effect.as(self.removeConfig(input.accountId), ToolResult.ok({ removed: true })), | ||
| 524 | }), | 718 | }), |
| 525 | ], | 719 | ], |
| 526 | }, | 720 | }, |
| @@ -35,23 +35,50 @@ export const Vault = Schema.Struct({ · 34 unmodified lines | |||
| 35 | export type Vault = typeof Vault.Type; | 35 | export type Vault = typeof Vault.Type; |
| 36 | 36 | ||
| 37 | // --------------------------------------------------------------------------- | 37 | // --------------------------------------------------------------------------- |
| 38 | // Stored config — persisted via KV | 38 | // Account — one named auth binding plus the vaults it scopes to. An owner can |
| 39 | // hold several (a work account and a personal one, or a service-account token | ||
| 40 | // next to desktop-app biometrics), each addressed by a stable generated id. | ||
| 39 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| 40 | 42 | ||
| 41 | export const OnePasswordConfig = Schema.Struct({ | 43 | export const OnePasswordAccount = Schema.Struct({ |
| 44 | /** Stable identifier, generated when the account is first saved. Refs never | ||
| 45 | * embed it — `op://` addressing stays vault-first — so renaming or | ||
| 46 | * re-authing an account never invalidates a stored ref. */ | ||
| 47 | id: Schema.String, | ||
| 48 | /** Human label for this account, e.g. "Work" */ | ||
| 49 | name: Schema.String, | ||
| 42 | auth: OnePasswordAuth, | 50 | auth: OnePasswordAuth, |
| 43 | /** Vaults to scope operations to. Order is presentational only: refs are | 51 | /** Vaults to scope operations to. Order is presentational only: refs are |
| 44 | * vault-qualified, and a bare ref that matches in more than one vault is an | 52 | * vault-qualified, and a bare ref that matches in more than one vault is an |
| 45 | * explicit ambiguity failure, never a precedence pick. */ | 53 | * explicit ambiguity failure, never a precedence pick. */ |
| 46 | vaults: Schema.NonEmptyArray(Vault), | 54 | vaults: Schema.NonEmptyArray(Vault), |
| 47 | /** Human label for the whole connection */ | 55 | }); |
| 48 | name: Schema.String, | 56 | export type OnePasswordAccount = typeof OnePasswordAccount.Type; |
| 57 | |||
| 58 | /** The account id every pre-multi-account config normalizes onto. */ | ||
| 59 | export const DEFAULT_ACCOUNT_ID = "default"; | ||
| 60 | |||
| 61 | // --------------------------------------------------------------------------- | ||
| 62 | // Stored config — persisted via KV | ||
| 63 | // --------------------------------------------------------------------------- | ||
| 64 | |||
| 65 | export const OnePasswordConfig = Schema.Struct({ | ||
| 66 | accounts: Schema.NonEmptyArray(OnePasswordAccount), | ||
| 49 | }); | 67 | }); |
| 50 | export type OnePasswordConfig = typeof OnePasswordConfig.Type; | 68 | export type OnePasswordConfig = typeof OnePasswordConfig.Type; |
| 51 | 69 | ||
| 52 | /** Pre-multi-vault stored shape: a single vault id whose display name doubled | 70 | /** Single-account stored shape (multi-vault, pre-multi-account). Still |
| 53 | * as the connection label. Still accepted on read; every save writes the | 71 | * accepted on read; every save writes the current shape, so a config row |
| 54 | * current shape, so a config row upgrades the first time it is re-saved. */ | 72 | * upgrades the first time it is re-saved. */ |
| 73 | export const SingleAccountOnePasswordConfig = Schema.Struct({ | ||
| 74 | auth: OnePasswordAuth, | ||
| 75 | vaults: Schema.NonEmptyArray(Vault), | ||
| 76 | name: Schema.String, | ||
| 77 | }); | ||
| 78 | export type SingleAccountOnePasswordConfig = typeof SingleAccountOnePasswordConfig.Type; | ||
| 79 | |||
| 80 | /** Original stored shape: a single vault id whose display name doubled as the | ||
| 81 | * connection label. */ | ||
| 55 | export const LegacyOnePasswordConfig = Schema.Struct({ | 82 | export const LegacyOnePasswordConfig = Schema.Struct({ |
| 56 | auth: OnePasswordAuth, | 83 | auth: OnePasswordAuth, |
| 57 | vaultId: Schema.String, | 84 | vaultId: Schema.String, |
| @@ -59,17 +86,33 @@ export const LegacyOnePasswordConfig = Schema.Struct({ · 1 unmodified line | |||
| 59 | }); | 86 | }); |
| 60 | export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type; | 87 | export type LegacyOnePasswordConfig = typeof LegacyOnePasswordConfig.Type; |
| 61 | 88 | ||
| 62 | export const StoredOnePasswordConfig = Schema.Union([OnePasswordConfig, LegacyOnePasswordConfig]); | 89 | export const StoredOnePasswordConfig = Schema.Union([ |
| 90 | OnePasswordConfig, | ||
| 91 | SingleAccountOnePasswordConfig, | ||
| 92 | LegacyOnePasswordConfig, | ||
| 93 | ]); | ||
| 63 | export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type; | 94 | export type StoredOnePasswordConfig = typeof StoredOnePasswordConfig.Type; |
| 64 | 95 | ||
| 65 | export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig => | 96 | export const normalizeStoredConfig = (stored: StoredOnePasswordConfig): OnePasswordConfig => { |
| 66 | "vaultId" in stored | 97 | if ("accounts" in stored) return stored; |
| 67 | ? { | 98 | if ("vaultId" in stored) { |
| 68 | auth: stored.auth, | 99 | return { |
| 69 | vaults: [{ id: stored.vaultId, name: stored.name }], | 100 | accounts: [ |
| 70 | name: stored.name, | 101 | { |
| 71 | } | 102 | id: DEFAULT_ACCOUNT_ID, |
| 72 | : stored; | 103 | name: stored.name, |
| 104 | auth: stored.auth, | ||
| 105 | vaults: [{ id: stored.vaultId, name: stored.name }], | ||
| 106 | }, | ||
| 107 | ], | ||
| 108 | }; | ||
| 109 | } | ||
| 110 | return { | ||
| 111 | accounts: [ | ||
| 112 | { id: DEFAULT_ACCOUNT_ID, name: stored.name, auth: stored.auth, vaults: stored.vaults }, | ||
| 113 | ], | ||
| 114 | }; | ||
| 115 | }; | ||
| 73 | 116 | ||
| 74 | // --------------------------------------------------------------------------- | 117 | // --------------------------------------------------------------------------- |
| 75 | // Redacted config — what `getConfig` returns to agents / the UI. The | 118 | // Redacted config — what `getConfig` returns to agents / the UI. The |
| @@ -88,30 +131,55 @@ export const RedactedOnePasswordAuth = Schema.Union([ · 12 unmodified lines | |||
| 88 | RedactedServiceAccountAuth, | 131 | RedactedServiceAccountAuth, |
| 89 | ]); | 132 | ]); |
| 90 | 133 | ||
| 91 | export const RedactedOnePasswordConfig = Schema.Struct({ | 134 | export const RedactedOnePasswordAccount = Schema.Struct({ |
| 135 | id: Schema.String, | ||
| 136 | name: Schema.String, | ||
| 92 | auth: RedactedOnePasswordAuth, | 137 | auth: RedactedOnePasswordAuth, |
| 93 | vaults: Schema.NonEmptyArray(Vault), | 138 | vaults: Schema.NonEmptyArray(Vault), |
| 94 | name: Schema.String, | 139 | }); |
| 140 | export type RedactedOnePasswordAccount = typeof RedactedOnePasswordAccount.Type; | ||
| 141 | |||
| 142 | export const RedactedOnePasswordConfig = Schema.Struct({ | ||
| 143 | accounts: Schema.NonEmptyArray(RedactedOnePasswordAccount), | ||
| 95 | }); | 144 | }); |
| 96 | export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type; | 145 | export type RedactedOnePasswordConfig = typeof RedactedOnePasswordConfig.Type; |
| 97 | 146 | ||
| 98 | /** Strip the service-account token from a stored config for external exposure. */ | 147 | const redactAuth = (auth: OnePasswordAuth): typeof RedactedOnePasswordAuth.Type => |
| 99 | export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => ({ | 148 | auth.kind === "desktop-app" |
| 100 | auth: | 149 | ? { kind: "desktop-app", accountName: auth.accountName } |
| 101 | config.auth.kind === "desktop-app" | 150 | : { kind: "service-account" }; |
| 102 | ? { kind: "desktop-app", accountName: config.auth.accountName } | 151 | |
| 103 | : { kind: "service-account" }, | 152 | export const redactAccount = (account: OnePasswordAccount): RedactedOnePasswordAccount => ({ |
| 104 | vaults: config.vaults, | 153 | id: account.id, |
| 105 | name: config.name, | 154 | name: account.name, |
| 155 | auth: redactAuth(account.auth), | ||
| 156 | vaults: account.vaults, | ||
| 106 | }); | 157 | }); |
| 107 | 158 | ||
| 159 | /** Strip the service-account tokens from a stored config for external exposure. */ | ||
| 160 | export const redactConfig = (config: OnePasswordConfig): RedactedOnePasswordConfig => { | ||
| 161 | const [first, ...rest] = config.accounts; | ||
| 162 | return { accounts: [redactAccount(first), ...rest.map(redactAccount)] }; | ||
| 163 | }; | ||
| 164 | |||
| 108 | // --------------------------------------------------------------------------- | 165 | // --------------------------------------------------------------------------- |
| 109 | // Connection status | 166 | // Connection status — reported per account so one unreachable account never |
| 167 | // masks (or fakes) the health of another. | ||
| 110 | // --------------------------------------------------------------------------- | 168 | // --------------------------------------------------------------------------- |
| 111 | 169 | ||
| 112 | export const ConnectionStatus = Schema.Struct({ | 170 | export const AccountStatus = Schema.Struct({ |
| 171 | id: Schema.String, | ||
| 172 | name: Schema.String, | ||
| 113 | connected: Schema.Boolean, | 173 | connected: Schema.Boolean, |
| 114 | vaultNames: Schema.optional(Schema.Array(Schema.String)), | 174 | vaultNames: Schema.optional(Schema.Array(Schema.String)), |
| 115 | error: Schema.optional(Schema.String), | 175 | error: Schema.optional(Schema.String), |
| 116 | }); | 176 | }); |
| 177 | export type AccountStatus = typeof AccountStatus.Type; | ||
| 178 | |||
| 179 | export const ConnectionStatus = Schema.Struct({ | ||
| 180 | /** True only when configured and every account is reachable. */ | ||
| 181 | connected: Schema.Boolean, | ||
| 182 | accounts: Schema.Array(AccountStatus), | ||
| 183 | error: Schema.optional(Schema.String), | ||
| 184 | }); | ||
| 117 | export type ConnectionStatus = typeof ConnectionStatus.Type; | 185 | export type ConnectionStatus = typeof ConnectionStatus.Type; |