diff --git a/README.md b/README.md index 249554a..2f50dfc 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,20 @@ npm install @doist/cli-core ## What's in it -| Module | Key exports | Purpose | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth` (subpath) | `attachLoginCommand`, `attachLogoutCommand`, `attachStatusCommand`, `attachTokenViewCommand`, `runOAuthFlow`, `createPkceProvider`, `createSecureStore`, PKCE helpers, `AuthProvider` / `TokenStore` / `AccountRef` / `SecureStore` types, `AttachLogoutRevokeContext` | OAuth runtime plus the Commander attachers for ` [auth] login` / `logout` / `status` / `token`. `attachLogoutCommand` accepts an optional `revokeToken` hook for best-effort server-side token revocation. Ships the standard public-client PKCE flow (`createPkceProvider`) and a thin cross-platform OS-keyring wrapper (`createSecureStore`) backed by `@napi-rs/keyring`. `AuthProvider` and `TokenStore` are the escape hatches for DCR, OS-keychain, multi-account, etc. — consumers implement `TokenStore` directly (single-user store implements `list` / `setDefault` trivially against the one account). `logout` / `status` / `token` always attach `--user ` and thread the parsed ref to `store.active(ref)` (and `store.clear(ref)` on `logout`). `commander` (when using the attachers), `open` (browser launch), and `@napi-rs/keyring` (when using `createSecureStore`) are optional peer/optional deps. | -| `commands` (subpath) | `registerChangelogCommand`, `registerUpdateCommand` (+ semver helpers) | Commander wiring for cli-core's standard commands (e.g. ` changelog`, ` update`, ` update switch`). **Requires** `commander` as an optional peer-dep. | -| `config` | `getConfigPath`, `readConfig`, `readConfigStrict`, `writeConfig`, `updateConfig`, `CoreConfig`, `UpdateChannel` | Read / write a per-CLI JSON config file with typed error codes; `CoreConfig` is the shape of fields cli-core itself owns (extend it for per-CLI fields). | -| `empty` | `printEmpty` | Print an empty-state message gated on `--json` / `--ndjson` so machine consumers never see human strings on stdout. | -| `errors` | `CliError` | Typed CLI error class with `code` and exit-code mapping. | -| `global-args` | `parseGlobalArgs`, `stripUserFlag`, `createGlobalArgsStore`, `createAccessibleGate`, `createSpinnerGate`, `getProgressJsonlPath`, `isProgressJsonlEnabled` | Parse well-known global flags (`--json`, `--ndjson`, `--quiet`, `--verbose`, `--accessible`, `--no-spinner`, `--progress-jsonl`, `--user `) and derive predicates from them. `stripUserFlag` removes `--user` tokens from argv so the cleaned array can be forwarded to Commander when the flag has no root-program attachment. | -| `json` | `formatJson`, `formatNdjson` | Stable JSON / newline-delimited JSON formatting for stdout. | -| `markdown` (subpath) | `preloadMarkdown`, `renderMarkdown`, `TerminalRendererOptions` | Lazy-init terminal markdown renderer. **Requires** `marked` and `marked-terminal-renderer` as peer-deps — install only if your CLI uses this subpath. | -| `options` | `ViewOptions` | Type contract for `{ json?, ndjson? }` per-command options that machine-output gates derive from. | -| `spinner` | `createSpinner` | Loading spinner factory wrapping `yocto-spinner` with disable gates. | -| `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. | -| `testing` (subpath) | `describeEmptyMachineOutput` | Vitest helpers reusable by consuming CLIs (e.g. parametrised empty-state suite covering `--json` / `--ndjson` / human modes). | +| Module | Key exports | Purpose | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth` (subpath) | `attachLoginCommand`, `attachLogoutCommand`, `attachStatusCommand`, `attachTokenViewCommand`, `runOAuthFlow`, `createPkceProvider`, `createSecureStore`, `createKeyringTokenStore`, PKCE helpers, `AuthProvider` / `TokenStore` / `AccountRef` / `SecureStore` / `UserRecordStore` types, `AttachLogoutRevokeContext` | OAuth runtime plus the Commander attachers for ` [auth] login` / `logout` / `status` / `token`. `attachLogoutCommand` accepts an optional `revokeToken` hook for best-effort server-side token revocation. Ships the standard public-client PKCE flow (`createPkceProvider`), a thin cross-platform OS-keyring wrapper (`createSecureStore`), and a multi-account keyring-backed `TokenStore` (`createKeyringTokenStore`) that stores secrets in the OS credential manager and degrades to plaintext in the consumer's config when the keyring is unavailable (WSL/headless Linux/containers). `AuthProvider` and `TokenStore` remain the escape hatches for DCR or fully bespoke backends. `logout` / `status` / `token` always attach `--user ` and thread the parsed ref to `store.active(ref)` (and `store.clear(ref)` on `logout`). `commander` (when using the attachers), `open` (browser launch), and `@napi-rs/keyring` (when using `createSecureStore` or the keyring `TokenStore`) are optional peer/optional deps. | +| `commands` (subpath) | `registerChangelogCommand`, `registerUpdateCommand` (+ semver helpers) | Commander wiring for cli-core's standard commands (e.g. ` changelog`, ` update`, ` update switch`). **Requires** `commander` as an optional peer-dep. | +| `config` | `getConfigPath`, `readConfig`, `readConfigStrict`, `writeConfig`, `updateConfig`, `CoreConfig`, `UpdateChannel` | Read / write a per-CLI JSON config file with typed error codes; `CoreConfig` is the shape of fields cli-core itself owns (extend it for per-CLI fields). | +| `empty` | `printEmpty` | Print an empty-state message gated on `--json` / `--ndjson` so machine consumers never see human strings on stdout. | +| `errors` | `CliError` | Typed CLI error class with `code` and exit-code mapping. | +| `global-args` | `parseGlobalArgs`, `stripUserFlag`, `createGlobalArgsStore`, `createAccessibleGate`, `createSpinnerGate`, `getProgressJsonlPath`, `isProgressJsonlEnabled` | Parse well-known global flags (`--json`, `--ndjson`, `--quiet`, `--verbose`, `--accessible`, `--no-spinner`, `--progress-jsonl`, `--user `) and derive predicates from them. `stripUserFlag` removes `--user` tokens from argv so the cleaned array can be forwarded to Commander when the flag has no root-program attachment. | +| `json` | `formatJson`, `formatNdjson` | Stable JSON / newline-delimited JSON formatting for stdout. | +| `markdown` (subpath) | `preloadMarkdown`, `renderMarkdown`, `TerminalRendererOptions` | Lazy-init terminal markdown renderer. **Requires** `marked` and `marked-terminal-renderer` as peer-deps — install only if your CLI uses this subpath. | +| `options` | `ViewOptions` | Type contract for `{ json?, ndjson? }` per-command options that machine-output gates derive from. | +| `spinner` | `createSpinner` | Loading spinner factory wrapping `yocto-spinner` with disable gates. | +| `terminal` | `isCI`, `isStderrTTY`, `isStdinTTY`, `isStdoutTTY` | TTY / CI detection helpers. | +| `testing` (subpath) | `describeEmptyMachineOutput` | Vitest helpers reusable by consuming CLIs (e.g. parametrised empty-state suite covering `--json` / `--ndjson` / human modes). | ## Usage @@ -315,6 +315,53 @@ Every failure mode — `@napi-rs/keyring` failing to load on an arch without a p `@napi-rs/keyring` is declared in cli-core's own `optionalDependencies`, so npm pulls it in transitively when you install `@doist/cli-core` — your consumer CLI does not need to add it explicitly. The library ships pre-built native binaries for Windows (Credential Manager), macOS (Keychain), and Linux glibc + musl (libsecret / Secret Service). +#### Multi-account keyring-backed `TokenStore` + +`createKeyringTokenStore` wires `createSecureStore` into the `TokenStore` contract for multi-account CLIs. Secrets live in the OS credential manager; per-user metadata stays in the consumer's config via a small `UserRecordStore` port the consumer implements. When the keyring is unreachable the store transparently falls back to a `fallbackToken` field on the user record and exposes a warning on `getLastStorageResult()` for the login command to surface. + +```ts +import { createKeyringTokenStore, type UserRecordStore } from '@doist/cli-core/auth' + +type Account = { id: string; label?: string; email: string } + +// Adapter over the consumer's existing config.json shape. +const userRecords: UserRecordStore = { + async list() { + /* read from config */ + }, + async upsert(record) { + /* replace, do not merge — see UserRecordStore docs */ + }, + async remove(id) { + /* … */ + }, + async getDefaultId() { + /* … */ + }, + async setDefaultId(id) { + /* … */ + }, + describeLocation() { + return '~/.config/todoist-cli/config.json' + }, +} + +export const tokenStore = createKeyringTokenStore({ + serviceName: 'todoist-cli', + userRecords, +}) + +// In your login command's onSuccess: +const storage = tokenStore.getLastStorageResult() +if (storage?.warning) console.error('Warning:', storage.warning) +``` + +The returned store satisfies the full `TokenStore` contract — including `list()` / `setDefault(ref)` / `ref`-aware `active` / `clear` — so it plugs straight into the `logout` / `status` / `token` attachers. Default ref matching is `account.id === ref || account.label === ref`; override `matchAccount` to broaden it (e.g. case-insensitive email). + +When a matching record exists but the keyring read fails, `active(ref)` throws `CliError('AUTH_STORE_READ_FAILED', …)`. `attachLogoutCommand` catches it specifically so `logout --user ` still clears the local record even with the keyring offline; status / token-view propagate it because they can't render without the token. + +For sync/lazy-decrypt or fully bespoke backends, implement `TokenStore` directly. + #### `--user ` and multi-user wiring The three account-touching attachers (`attachLogoutCommand` / `attachStatusCommand` / `attachTokenViewCommand`) always attach `--user ` on their subcommand. `attachLogoutCommand` threads the parsed ref to both `store.active(ref)` and `store.clear(ref)`; `attachStatusCommand` and `attachTokenViewCommand` only call `store.active(ref)`. When `--user` is supplied but `store.active(ref)` returns `null`, each attacher throws `CliError('ACCOUNT_NOT_FOUND', …)` so the user sees a typed miss rather than `NOT_AUTHENTICATED` or a silent `✓ Logged out`. Single-user stores returning `null` for a non-matching ref is the supported way to feed this guard. diff --git a/src/auth/index.ts b/src/auth/index.ts index cf0f89a..4ab7f9e 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -35,5 +35,18 @@ export type { TokenStore, ValidateInput, } from './types.js' -export { SecureStoreUnavailableError, createSecureStore } from './keyring/index.js' -export type { CreateSecureStoreOptions, SecureStore } from './keyring/index.js' +export { + SecureStoreUnavailableError, + createKeyringTokenStore, + createSecureStore, +} from './keyring/index.js' +export type { + CreateKeyringTokenStoreOptions, + CreateSecureStoreOptions, + KeyringTokenStore, + SecureStore, + TokenStorageLocation, + TokenStorageResult, + UserRecord, + UserRecordStore, +} from './keyring/index.js' diff --git a/src/auth/keyring/index.ts b/src/auth/keyring/index.ts index 22ed4d7..f4913a7 100644 --- a/src/auth/keyring/index.ts +++ b/src/auth/keyring/index.ts @@ -1,2 +1,12 @@ export { SecureStoreUnavailableError, createSecureStore } from './secure-store.js' export type { CreateSecureStoreOptions, SecureStore } from './secure-store.js' + +export { createKeyringTokenStore } from './token-store.js' +export type { CreateKeyringTokenStoreOptions, KeyringTokenStore } from './token-store.js' + +export type { + TokenStorageLocation, + TokenStorageResult, + UserRecord, + UserRecordStore, +} from './types.js' diff --git a/src/auth/keyring/record-write.test.ts b/src/auth/keyring/record-write.test.ts new file mode 100644 index 0000000..9d79478 --- /dev/null +++ b/src/auth/keyring/record-write.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { buildSingleSlot, buildUserRecords } from '../../test-support/keyring-mocks.js' +import { writeRecordWithKeyringFallback } from './record-write.js' +import { SecureStoreUnavailableError } from './secure-store.js' + +type Account = { id: string; label?: string; email: string } + +const account: Account = { id: '42', label: 'me', email: 'a@b.c' } + +describe('writeRecordWithKeyringFallback', () => { + it('writes to the keyring slot and upserts a record with no fallbackToken on the happy path', async () => { + const secureStore = buildSingleSlot() + const { store: userRecords, state, upsertSpy } = buildUserRecords() + + const result = await writeRecordWithKeyringFallback({ + secureStore, + userRecords, + account, + token: ' tok_secret ', + }) + + expect(result.storedSecurely).toBe(true) + expect(secureStore.setSpy).toHaveBeenCalledWith('tok_secret') + expect(upsertSpy).toHaveBeenCalledWith({ account }) + expect(state.records.get('42')?.fallbackToken).toBeUndefined() + }) + + it('falls back to fallbackToken on the user record when the keyring is offline', async () => { + const secureStore = buildSingleSlot() + secureStore.setSpy.mockRejectedValueOnce(new SecureStoreUnavailableError('no dbus')) + const { store: userRecords, state } = buildUserRecords() + + const result = await writeRecordWithKeyringFallback({ + secureStore, + userRecords, + account, + token: 'tok_plain', + }) + + expect(result.storedSecurely).toBe(false) + expect(state.records.get('42')?.fallbackToken).toBe('tok_plain') + }) + + it('rethrows non-keyring errors from setSecret without writing the record', async () => { + const secureStore = buildSingleSlot() + const cause = new Error('unexpected backend explosion') + secureStore.setSpy.mockRejectedValueOnce(cause) + const { store: userRecords, state } = buildUserRecords() + + await expect( + writeRecordWithKeyringFallback({ + secureStore, + userRecords, + account, + token: 'tok', + }), + ).rejects.toBe(cause) + expect(state.records.size).toBe(0) + }) + + it('rolls back the keyring write when upsert fails (no orphan credential)', async () => { + const secureStore = buildSingleSlot() + const { store: userRecords, upsertSpy } = buildUserRecords() + upsertSpy.mockRejectedValueOnce(new Error('disk full')) + + await expect( + writeRecordWithKeyringFallback({ + secureStore, + userRecords, + account, + token: 'tok', + }), + ).rejects.toThrow('disk full') + expect(secureStore.deleteSpy).toHaveBeenCalledTimes(1) + }) + + it('does not rollback the keyring on upsert failure when the write went to fallbackToken', async () => { + // No successful keyring write happened, so there is nothing to roll + // back. Verify the helper doesn't accidentally call deleteSecret + // in this branch. + const secureStore = buildSingleSlot() + secureStore.setSpy.mockRejectedValueOnce(new SecureStoreUnavailableError('no dbus')) + const { store: userRecords, upsertSpy } = buildUserRecords() + upsertSpy.mockRejectedValueOnce(new Error('disk full')) + + await expect( + writeRecordWithKeyringFallback({ + secureStore, + userRecords, + account, + token: 'tok', + }), + ).rejects.toThrow('disk full') + expect(secureStore.deleteSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/auth/keyring/record-write.ts b/src/auth/keyring/record-write.ts new file mode 100644 index 0000000..c8c850b --- /dev/null +++ b/src/auth/keyring/record-write.ts @@ -0,0 +1,67 @@ +import type { AuthAccount } from '../types.js' +import { type SecureStore, SecureStoreUnavailableError } from './secure-store.js' +import type { UserRecord, UserRecordStore } from './types.js' + +type WriteRecordOptions = { + /** Per-account keyring slot, already configured by the caller (e.g. via `createSecureStore`). */ + secureStore: SecureStore + userRecords: UserRecordStore + account: TAccount + token: string +} + +type WriteRecordResult = { + /** `true` when the secret landed in the OS keyring; `false` when the keyring was unavailable and the token was written to `fallbackToken` on the user record. */ + storedSecurely: boolean +} + +/** + * Shared keyring-then-record write used by `createKeyringTokenStore.set` and + * `migrateLegacyAuth`. Encapsulates the order-of-operations contract that + * matters for credential safety: + * + * 1. Keyring `setSecret` first. On `SecureStoreUnavailableError`, swallow + * the failure and record a `fallbackToken` on the user record instead. + * Any other error rethrows. + * 2. `userRecords.upsert(record)`. On failure, best-effort rollback the + * keyring write so we don't leave an orphan credential for an account + * cli-core never managed to register. Original error rethrows. + * + * Default promotion (`setDefaultId`) is intentionally **not** in here — both + * call sites do it best-effort outside the critical section because it is a + * preference, not a correctness requirement, and an error there must not + * dirty up a successful credential write. + */ +export async function writeRecordWithKeyringFallback( + options: WriteRecordOptions, +): Promise { + const { secureStore, userRecords, account, token } = options + const trimmed = token.trim() + + let storedSecurely = false + try { + await secureStore.setSecret(trimmed) + storedSecurely = true + } catch (error) { + if (!(error instanceof SecureStoreUnavailableError)) throw error + } + + const record: UserRecord = storedSecurely + ? { account } + : { account, fallbackToken: trimmed } + + try { + await userRecords.upsert(record) + } catch (error) { + if (storedSecurely) { + try { + await secureStore.deleteSecret() + } catch { + // best-effort — the user record failure is the real cause + } + } + throw error + } + + return { storedSecurely } +} diff --git a/src/auth/keyring/secure-store.ts b/src/auth/keyring/secure-store.ts index cd0e745..57ff2de 100644 --- a/src/auth/keyring/secure-store.ts +++ b/src/auth/keyring/secure-store.ts @@ -1,5 +1,20 @@ import { getErrorMessage } from '../../errors.js' +/** + * User-facing label for the OS credential manager. Used by the keyring + * `TokenStore`'s fallback-warning composition; internal to the keyring + * module until a public caller asks for it. + */ +export const SECURE_STORE_DESCRIPTION = 'system credential manager' + +/** + * Default keyring `account` slug for a stored user. Lives here so every + * caller that derives a per-user slot name agrees on the wire format — a + * future rename can't silently park tokens in a slot the runtime no longer + * reads from. + */ +export const DEFAULT_ACCOUNT_FOR_USER = (id: string): string => `user-${id}` + /** * Thrown when the OS credential manager cannot be reached — missing native * binary for the current architecture, libsecret/D-Bus unavailable diff --git a/src/auth/keyring/token-store.test.ts b/src/auth/keyring/token-store.test.ts new file mode 100644 index 0000000..cfbfec5 --- /dev/null +++ b/src/auth/keyring/token-store.test.ts @@ -0,0 +1,412 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + buildKeyringMap, + buildSingleSlot, + buildUserRecords, +} from '../../test-support/keyring-mocks.js' +import { SecureStoreUnavailableError } from './secure-store.js' +import { type CreateKeyringTokenStoreOptions, createKeyringTokenStore } from './token-store.js' +import type { UserRecord } from './types.js' + +vi.mock('./secure-store.js', async () => { + const actual = await vi.importActual('./secure-store.js') + return { + ...actual, + createSecureStore: vi.fn(), + } +}) + +const { createSecureStore } = await import('./secure-store.js') +const mockedCreateSecureStore = vi.mocked(createSecureStore) + +type Account = { + id: string + label?: string + email: string +} + +const SERVICE = 'cli-core-test' +const LOCATION = '/tmp/fake/config.json' +const account: Account = { id: '42', label: 'me', email: 'a@b.c' } + +type SingleSlot = ReturnType + +/** + * One-shot setup: wire `createSecureStore` to return `keyring`, seed any + * `records` / `defaultId` into a fresh `buildUserRecords` harness, and + * construct a store with the standard factory options. Returns the harness + * pieces tests typically reach for. + */ +function fixture( + opts: { + keyring?: SingleSlot + records?: Record> + defaultId?: string | null + factoryOpts?: Partial> + } = {}, +) { + const keyring = opts.keyring ?? buildSingleSlot() + mockedCreateSecureStore.mockReturnValue(keyring) + const harness = buildUserRecords() + for (const [id, rec] of Object.entries(opts.records ?? {})) { + harness.state.records.set(id, rec) + } + if (opts.defaultId !== undefined) harness.state.defaultId = opts.defaultId + const store = createKeyringTokenStore({ + serviceName: SERVICE, + userRecords: harness.store, + recordsLocation: LOCATION, + ...opts.factoryOpts, + }) + return { + keyring, + store, + state: harness.state, + upsertSpy: harness.upsertSpy, + removeSpy: harness.removeSpy, + setDefaultSpy: harness.setDefaultSpy, + } +} + +describe('createKeyringTokenStore', () => { + beforeEach(() => { + mockedCreateSecureStore.mockReset() + }) + + it('round-trips set → active → clear when the keyring is online', async () => { + const { keyring, store, state, upsertSpy } = fixture() + + await store.set(account, 'tok_secret') + expect(keyring.setSpy).toHaveBeenCalledWith('tok_secret') + expect(upsertSpy).toHaveBeenCalledWith({ account }) + expect(state.defaultId).toBe('42') + expect(store.getLastStorageResult()).toEqual({ storage: 'secure-store' }) + + await expect(store.active()).resolves.toEqual({ token: 'tok_secret', account }) + + await store.clear() + expect(keyring.deleteSpy).toHaveBeenCalledTimes(1) + expect(state.records.size).toBe(0) + expect(state.defaultId).toBeNull() + expect(store.getLastClearResult()).toEqual({ storage: 'secure-store' }) + }) + + it('falls back to a plaintext token on the user record when the keyring is offline', async () => { + const keyring = buildSingleSlot() + keyring.setSpy.mockRejectedValueOnce(new SecureStoreUnavailableError('no dbus')) + const { store, state } = fixture({ keyring }) + + await store.set(account, 'tok_plain') + + expect(state.records.get('42')?.fallbackToken).toBe('tok_plain') + expect(store.getLastStorageResult()).toEqual({ + storage: 'config-file', + warning: + 'system credential manager unavailable; token saved as plaintext in /tmp/fake/config.json', + }) + + await expect(store.active()).resolves.toEqual({ token: 'tok_plain', account }) + expect(keyring.getSpy).not.toHaveBeenCalled() + }) + + it('set() still succeeds when the best-effort default promotion fails', async () => { + const { store, state, setDefaultSpy } = fixture() + setDefaultSpy.mockRejectedValueOnce(new Error('default-write blew up')) + + await expect(store.set(account, 'tok')).resolves.toBeUndefined() + expect(state.records.get('42')?.account).toEqual(account) + // Default never got set because the write failed, but the user record is durable. + expect(state.defaultId).toBeNull() + }) + + it('resets getLastStorageResult to undefined when set() throws', async () => { + const { store, upsertSpy } = fixture() + await store.set(account, 'tok') + expect(store.getLastStorageResult()).toEqual({ storage: 'secure-store' }) + + // Second call throws — the previous result must not leak through. + upsertSpy.mockRejectedValueOnce(new Error('disk full')) + await expect(store.set({ ...account, id: '99' }, 'tok2')).rejects.toThrow('disk full') + expect(store.getLastStorageResult()).toBeUndefined() + }) + + it.each([ + [ + 'the keyring read throws', + (k: SingleSlot) => { + k.getSpy.mockRejectedValueOnce(new SecureStoreUnavailableError('locked')) + }, + ], + [ + 'the keyring slot is empty (out-of-band deletion)', + () => { + // default null secret — collapsing to `null` would surface as + // ACCOUNT_NOT_FOUND on `--user ` and hide the corruption. + }, + ], + ])('throws AUTH_STORE_READ_FAILED when a record matches but %s', async (_label, setup) => { + const keyring = buildSingleSlot() + setup(keyring) + const { store } = fixture({ keyring, records: { '42': { account } }, defaultId: '42' }) + + await expect(store.active()).rejects.toMatchObject({ code: 'AUTH_STORE_READ_FAILED' }) + }) + + it('picks the lone user when no default is set', async () => { + const keyring = buildSingleSlot({ secret: 'tok' }) + const { store } = fixture({ keyring, records: { '42': { account } } }) + + await expect(store.active()).resolves.toEqual({ token: 'tok', account }) + }) + + it('throws NO_ACCOUNT_SELECTED when multiple users exist and no default is set', async () => { + // `setDefaultId` is best-effort during `set()`, so this state IS + // reachable in practice. Collapsing to `null` would surface as + // `NOT_AUTHENTICATED` and hide the real recovery action. + const { store } = fixture({ + records: { + '1': { account: { ...account, id: '1' } }, + '2': { account: { ...account, id: '2' } }, + }, + }) + + await expect(store.active()).rejects.toMatchObject({ code: 'NO_ACCOUNT_SELECTED' }) + }) + + it('throws NO_ACCOUNT_SELECTED when --user matches more than one record (ambiguous)', async () => { + // Default matcher considers `account.label`, which the contract + // doesn't require to be unique. Silently picking the first match + // would act on whichever record `list()` returned first. + const { store } = fixture({ + records: { + '1': { account: { id: '1', label: 'shared', email: 'a@b' } }, + '2': { account: { id: '2', label: 'shared', email: 'c@d' } }, + }, + }) + + await expect(store.active('shared')).rejects.toMatchObject({ code: 'NO_ACCOUNT_SELECTED' }) + }) + + it('does not overwrite an existing default when a second user is added', async () => { + const { store, state } = fixture({ + records: { '1': { account: { ...account, id: '1' } } }, + defaultId: '1', + }) + + await store.set({ ...account, id: '2' }, 'tok_b') + + expect(state.defaultId).toBe('1') + }) + + it('clear() still calls the keyring delete when a fallbackToken is present (orphan cleanup)', async () => { + const keyring = buildSingleSlot({ secret: 'orphan_from_earlier_write' }) + const { store, state } = fixture({ + keyring, + records: { '42': { account, fallbackToken: 'tok_plain' } }, + defaultId: '42', + }) + + await store.clear() + + expect(keyring.deleteSpy).toHaveBeenCalledTimes(1) + expect(state.records.size).toBe(0) + expect(store.getLastClearResult()).toEqual({ + storage: 'config-file', + warning: + 'system credential manager unavailable; local auth state cleared in /tmp/fake/config.json', + }) + }) + + it('clear() downgrades a non-keyring delete error to a warning (local state is already gone)', async () => { + // After `remove()` the record is gone locally; re-throwing the + // `deleteSecret()` error would corrupt the caller's mental model. + const keyring = buildSingleSlot({ secret: 'tok' }) + keyring.deleteSpy.mockRejectedValueOnce(new Error('IPC stalled')) + const { store, state } = fixture({ + keyring, + records: { '42': { account } }, + defaultId: '42', + }) + + await store.clear() + + expect(state.records.size).toBe(0) + expect(store.getLastClearResult()).toMatchObject({ storage: 'config-file' }) + }) + + it('clear() does not attempt the keyring delete when userRecords.remove() rejects', async () => { + // Record-first contract: if the source-of-truth removal fails, the + // keyring entry must remain so a retry stays consistent. + const keyring = buildSingleSlot({ secret: 'tok' }) + const { store, state, removeSpy } = fixture({ + keyring, + records: { '42': { account } }, + defaultId: '42', + }) + removeSpy.mockRejectedValueOnce(new Error('disk full')) + + await expect(store.clear()).rejects.toThrow('disk full') + expect(keyring.deleteSpy).not.toHaveBeenCalled() + expect(state.records.has('42')).toBe(true) + }) + + it('clear() still deletes the keyring slot even when setDefaultId(null) throws', async () => { + const keyring = buildSingleSlot({ secret: 'tok' }) + const { store, state, setDefaultSpy } = fixture({ + keyring, + records: { '42': { account } }, + defaultId: '42', + }) + setDefaultSpy.mockRejectedValueOnce(new Error('disk full')) + + await store.clear() + + // Default pointer write blew up, but the keyring entry was still + // cleaned up — otherwise the credential becomes an unreachable orphan. + expect(keyring.deleteSpy).toHaveBeenCalledTimes(1) + expect(state.records.size).toBe(0) + }) + + it('uses a custom accountForUser slug when provided', async () => { + const { store } = fixture({ factoryOpts: { accountForUser: (id) => `custom-${id}` } }) + + await store.set(account, 'tok') + + expect(mockedCreateSecureStore).toHaveBeenCalledWith({ + serviceName: SERVICE, + account: 'custom-42', + }) + }) + + describe('AccountRef support (keyed per-user slots)', () => { + function multiUserFixture() { + const km = buildKeyringMap() + mockedCreateSecureStore.mockImplementation(km.create) + const harness = buildUserRecords() + harness.state.records.set('1', { account: { id: '1', label: 'alice', email: 'a@b' } }) + harness.state.records.set('2', { + account: { id: '2', label: 'bob', email: 'c@d' }, + fallbackToken: 'tok_b', + }) + km.slots.set('user-1', { secret: 'tok_a' }) + const store = createKeyringTokenStore({ + serviceName: SERVICE, + userRecords: harness.store, + recordsLocation: LOCATION, + }) + return { km, store, state: harness.state } + } + + it('active(ref) reads from the matching per-user slot', async () => { + const { km, store } = multiUserFixture() + + const snapshot = await store.active('1') + expect(snapshot?.account.id).toBe('1') + expect(snapshot?.token).toBe('tok_a') + // Sanity check: user 2's keyring slot was never touched (its + // record carries `fallbackToken`). + expect(km.slots.has('user-2')).toBe(false) + }) + + it('active(ref) prefers the fallbackToken over a stale keyring entry', async () => { + const { km, store } = multiUserFixture() + // Simulate an orphan keyring entry left from a prior online write. + km.slots.set('user-2', { secret: 'tok_b_stale' }) + + await expect(store.active('2')).resolves.toMatchObject({ token: 'tok_b' }) + }) + + it('active(ref) returns null on a miss (attacher translates to ACCOUNT_NOT_FOUND)', async () => { + const { store } = multiUserFixture() + await expect(store.active('does-not-exist')).resolves.toBeNull() + }) + + it('clear(ref) removes the matching record and deletes only its keyring slot', async () => { + const { km, store, state } = multiUserFixture() + state.defaultId = '1' + + await store.clear('1') + + expect(state.records.has('1')).toBe(false) + expect(state.records.has('2')).toBe(true) + expect(state.defaultId).toBeNull() + expect(km.slots.get('user-1')?.secret).toBeNull() + expect((km.deleteCalls.get('user-1') ?? 0) > 0).toBe(true) + expect(km.deleteCalls.has('user-2')).toBe(false) + }) + + it('honours a custom matchAccount predicate', async () => { + const km = buildKeyringMap() + mockedCreateSecureStore.mockImplementation(km.create) + const harness = buildUserRecords() + harness.state.records.set('1', { account: { id: '1', email: 'Alice@x.io' } }) + km.slots.set('user-1', { secret: 'tok' }) + + const store = createKeyringTokenStore({ + serviceName: SERVICE, + userRecords: harness.store, + recordsLocation: LOCATION, + matchAccount: (acc, ref) => acc.email.toLowerCase() === ref.toLowerCase(), + }) + + await expect(store.active('alice@x.io')).resolves.toMatchObject({ + account: { id: '1' }, + }) + }) + }) + + describe('list() + setDefault()', () => { + const a: Account = { id: '1', label: 'a', email: 'a@b' } + const b: Account = { id: '2', label: 'b', email: 'c@d' } + + it('returns every account with the default marker', async () => { + const { store } = fixture({ + records: { '1': { account: a }, '2': { account: b } }, + defaultId: '2', + }) + + const all = await store.list() + expect(all).toHaveLength(2) + expect(all.find((entry) => entry.account.id === '2')?.isDefault).toBe(true) + expect(all.find((entry) => entry.account.id === '1')?.isDefault).toBe(false) + }) + + it('marks a single record as default even when no defaultId is pinned (matches active())', async () => { + const { store } = fixture({ records: { '42': { account } } }) + + await expect(store.list()).resolves.toEqual([{ account, isDefault: true }]) + }) + + it('returns every account with isDefault:false when multiple records exist and no default is pinned', async () => { + // `active()` throws `NO_ACCOUNT_SELECTED` in this state, but + // `list()` is a diagnostic operation that must keep working. + const { store } = fixture({ + records: { '1': { account: a }, '2': { account: b } }, + }) + + const all = await store.list() + expect(all).toHaveLength(2) + expect(all.every((entry) => entry.isDefault === false)).toBe(true) + }) + + it('setDefault(ref) marks the matching account as default', async () => { + const { store, state } = fixture({ + records: { '1': { account: a }, '2': { account: b } }, + defaultId: '1', + }) + + await store.setDefault('b') + expect(state.defaultId).toBe('2') + expect(mockedCreateSecureStore).not.toHaveBeenCalled() + }) + + it('setDefault(ref) throws ACCOUNT_NOT_FOUND on a miss', async () => { + const { store } = fixture({ records: { '1': { account } } }) + + await expect(store.setDefault('nope')).rejects.toMatchObject({ + code: 'ACCOUNT_NOT_FOUND', + }) + }) + }) +}) diff --git a/src/auth/keyring/token-store.ts b/src/auth/keyring/token-store.ts new file mode 100644 index 0000000..3a67895 --- /dev/null +++ b/src/auth/keyring/token-store.ts @@ -0,0 +1,317 @@ +import { CliError } from '../../errors.js' +import type { AccountRef, AuthAccount, TokenStore } from '../types.js' +import { accountNotFoundError } from '../user-flag.js' +import { writeRecordWithKeyringFallback } from './record-write.js' +import { + createSecureStore, + DEFAULT_ACCOUNT_FOR_USER, + SECURE_STORE_DESCRIPTION, + SecureStoreUnavailableError, + type SecureStore, +} from './secure-store.js' +import type { TokenStorageResult, UserRecord, UserRecordStore } from './types.js' + +export type CreateKeyringTokenStoreOptions = { + /** Application identifier used for every keyring entry (e.g. `'todoist-cli'`). */ + serviceName: string + /** Consumer-owned per-user record store (typically backed by their config file). */ + userRecords: UserRecordStore + /** + * Human-readable location of the record store, used in the fallback-warning + * text (e.g. `~/.config/todoist-cli/config.json`). Plain string; cli-core + * does not interpret it. + */ + recordsLocation: string + /** + * Builds the keyring `account` slug for a user id. Defaults to + * `user-${id}`. Override only when migrating from a legacy naming scheme. + */ + accountForUser?: (id: string) => string + /** + * Decides whether an account matches an `AccountRef` supplied via + * `--user `. Defaults to id-or-label equality. Override to broaden + * (e.g. case-insensitive email, alias map). + */ + matchAccount?: (account: TAccount, ref: AccountRef) => boolean +} + +export type KeyringTokenStore = TokenStore & { + /** Storage result from the most recent `set()` call, or `undefined` before any (and reset to `undefined` when the most recent `set()` threw). */ + getLastStorageResult(): TokenStorageResult | undefined + /** Storage result from the most recent `clear()` call, or `undefined` before any (and reset to `undefined` when the most recent `clear()` threw or was a no-op). */ + getLastClearResult(): TokenStorageResult | undefined +} + +const DEFAULT_MATCH_ACCOUNT = ( + account: TAccount, + ref: AccountRef, +): boolean => account.id === ref || account.label === ref + +/** + * Multi-account `TokenStore` that keeps secrets in the OS credential manager + * and per-user metadata in the consumer's `UserRecordStore`. Falls back to a + * plaintext token on the user record when the keyring is unreachable (WSL + * without D-Bus, missing native binary, locked Keychain, …) so the CLI keeps + * working at the cost of a visible warning. + * + * Read order in `active()` is `fallbackToken` first, then the keyring. That + * matches the write semantics in `writeRecordWithKeyringFallback`: when the + * keyring is online the record is written with no `fallbackToken`, so the + * keyring read is the only path. When the keyring is offline the token is + * parked on the record and must be reachable on every subsequent read. + * + * Write order is keyring first, then `userRecords.upsert`. If the upsert + * fails after a successful keyring write, the keyring entry is rolled back + * via `deleteSecret()` to avoid orphan credentials for a user that cli-core + * never managed to record. + * + * Clear order is the inverse: record removal first (the source of truth that + * the rest of the CLI reads), then keyring delete. Any keyring delete + * failure after a successful removal is downgraded to a warning — the orphan + * secret is harmless because no record references it anymore, and surfacing + * the error would corrupt local state (record gone, but caller sees a thrown + * exception and assumes the clear failed). + */ +export function createKeyringTokenStore( + options: CreateKeyringTokenStoreOptions, +): KeyringTokenStore { + const { serviceName, userRecords, recordsLocation } = options + const accountForUser = options.accountForUser ?? DEFAULT_ACCOUNT_FOR_USER + const matchAccount = options.matchAccount ?? DEFAULT_MATCH_ACCOUNT + + let lastStorageResult: TokenStorageResult | undefined + let lastClearResult: TokenStorageResult | undefined + + function secureStoreFor(account: TAccount): SecureStore { + return createSecureStore({ serviceName, account: accountForUser(account.id) }) + } + + type Snapshot = { records: UserRecord[]; defaultId: string | null } + + /** + * Read both `list()` and `getDefaultId()` concurrently. Used by paths + * that need the pinned default (no-ref `active`/`clear`, `list`, and + * `clear`'s default-unpin check). + */ + async function readFullSnapshot(): Promise { + const [records, defaultId] = await Promise.all([ + userRecords.list(), + userRecords.getDefaultId(), + ]) + return { records, defaultId } + } + + /** + * Resolve the snapshot target for a given ref (or the implicit default + * when `ref === undefined`). Two failure modes: + * + * - Multiple records match the `ref`: ambiguous (the default matcher + * includes `account.label`, and labels aren't guaranteed unique). + * Throws `NO_ACCOUNT_SELECTED` so the user picks a tighter ref instead + * of silently acting on whichever record `list()` returned first. + * - `ref === undefined`, no `defaultId` pinned, and more than one record + * exists. Same code — `setDefaultId` is best-effort during `set()`, + * so a typed failure here is the only non-misleading signal for "you + * have multiple accounts; pick one". + */ + function resolveTarget( + snapshot: Snapshot, + ref: AccountRef | undefined, + ): UserRecord | null { + if (ref !== undefined) { + const matches = snapshot.records.filter((record) => matchAccount(record.account, ref)) + if (matches.length > 1) { + throw new CliError( + 'NO_ACCOUNT_SELECTED', + `Multiple stored accounts match "${ref}". Pass a more specific --user (e.g. a unique account id).`, + ) + } + return matches[0] ?? null + } + if (snapshot.defaultId) { + const pinned = snapshot.records.find((r) => r.account.id === snapshot.defaultId) + if (pinned) return pinned + } + if (snapshot.records.length === 1) return snapshot.records[0] + if (snapshot.records.length === 0) return null + throw new CliError( + 'NO_ACCOUNT_SELECTED', + 'Multiple accounts are stored but none is set as the default. Pass --user , or set a default in your CLI.', + ) + } + + function fallbackResult(action: string): TokenStorageResult { + return { + storage: 'config-file', + warning: `${SECURE_STORE_DESCRIPTION} unavailable; ${action} ${recordsLocation}`, + } + } + + return { + async active(ref) { + // Ref-only path skips `getDefaultId()` — `resolveTarget` never + // touches it when `ref` is supplied, so the extra read would be + // pure latency on every authenticated command. + const snapshot: Snapshot = + ref === undefined + ? await readFullSnapshot() + : { records: await userRecords.list(), defaultId: null } + const record = resolveTarget(snapshot, ref) + if (!record) return null + + const fallback = record.fallbackToken?.trim() + if (fallback) { + return { token: fallback, account: record.account } + } + + let raw: string | null + try { + raw = await secureStoreFor(record.account).getSecret() + } catch (error) { + // A matching record exists but the keyring can't be read. + // Surface a typed failure instead of returning `null`, which + // would otherwise be indistinguishable from "no stored + // account" and trigger `ACCOUNT_NOT_FOUND` on `--user `. + // `attachLogoutCommand` catches this specific code so an + // explicit `logout --user ` can still clear the matching + // record without needing the unreadable token. + if (error instanceof SecureStoreUnavailableError) { + throw new CliError( + 'AUTH_STORE_READ_FAILED', + `${SECURE_STORE_DESCRIPTION} unavailable; could not read stored token (${error.message})`, + ) + } + throw error + } + + const token = raw?.trim() + if (token) { + return { token, account: record.account } + } + + // Record exists, no `fallbackToken`, and the keyring slot is + // empty — the credential was deleted out-of-band (user ran + // `security delete-generic-password`, `secret-tool clear`, …). + // This is corrupted state, not a miss; collapsing it to `null` + // would make `--user ` surface as `ACCOUNT_NOT_FOUND` and + // hide the real problem. + throw new CliError( + 'AUTH_STORE_READ_FAILED', + `${SECURE_STORE_DESCRIPTION} returned no credential for the stored account; the keyring entry may have been removed externally.`, + ) + }, + + async set(account, token) { + // Reset the cached storage result up front so a caller that + // catches a thrown `set()` doesn't observe the previous call's + // warning leaking through `getLastStorageResult`. + lastStorageResult = undefined + + const { storedSecurely } = await writeRecordWithKeyringFallback({ + secureStore: secureStoreFor(account), + userRecords, + account, + token, + }) + + // Best-effort default promotion: the record is already persisted, + // so a failure here must not turn into `AUTH_STORE_WRITE_FAILED` + // (the user can recover by setting a default later). + try { + const existingDefault = await userRecords.getDefaultId() + if (!existingDefault) { + await userRecords.setDefaultId(account.id) + } + } catch { + // best-effort + } + + lastStorageResult = storedSecurely + ? { storage: 'secure-store' } + : fallbackResult('token saved as plaintext in') + }, + + async clear(ref) { + // Reset up front for the same reason as `set` — and so a no-op + // (no matching record) clears any stale result from a previous + // call. + lastClearResult = undefined + + // `clear` always needs the pinned default to decide whether to + // un-pin after the removal, so we can't skip `getDefaultId()` + // even on the explicit-ref path. + const snapshot = await readFullSnapshot() + const record = resolveTarget(snapshot, ref) + if (!record) return + + await userRecords.remove(record.account.id) + + // Default un-pinning is best-effort: a failure here must not + // skip the keyring delete below, otherwise we leave an + // unreachable orphan secret behind for the just-removed record. + if (snapshot.defaultId === record.account.id) { + try { + await userRecords.setDefaultId(null) + } catch { + // best-effort + } + } + + const fallbackClear = fallbackResult('local auth state cleared in') + + // Always attempt the keyring delete. Even when the record carried + // a `fallbackToken`, an older keyring entry may still be parked + // there from a prior keyring-online write that was later replaced + // by an offline-fallback write — skipping the delete would leak + // that orphan. Downgrade *any* failure to a warning: the record + // is already gone, so re-throwing would corrupt local state + // (caller sees an exception and assumes nothing was cleared, + // even though the next `account list` will show the user gone). + try { + await secureStoreFor(record.account).deleteSecret() + lastClearResult = + record.fallbackToken !== undefined ? fallbackClear : { storage: 'secure-store' } + } catch { + lastClearResult = fallbackClear + } + }, + + async list() { + const snapshot = await readFullSnapshot() + // Use `resolveTarget` to compute the *effective* default so the + // `isDefault` markers match what `active()` would resolve — that + // includes the implicit single-record case. `resolveTarget` can + // throw `NO_ACCOUNT_SELECTED`, which we want to swallow here + // (listing accounts is a diagnostic operation that must work + // even when no default is pinned). + let implicitDefault: UserRecord | null = null + try { + implicitDefault = resolveTarget(snapshot, undefined) + } catch { + // multiple records, no default → `isDefault: false` for all + } + return snapshot.records.map((record) => ({ + account: record.account, + isDefault: record.account.id === implicitDefault?.account.id, + })) + }, + + async setDefault(ref) { + // Ref-only path — skip `getDefaultId()` like `active(ref)`. + const snapshot: Snapshot = { records: await userRecords.list(), defaultId: null } + const record = resolveTarget(snapshot, ref) + if (!record) { + throw accountNotFoundError(ref) + } + await userRecords.setDefaultId(record.account.id) + }, + + getLastStorageResult() { + return lastStorageResult + }, + + getLastClearResult() { + return lastClearResult + }, + } +} diff --git a/src/auth/keyring/types.ts b/src/auth/keyring/types.ts new file mode 100644 index 0000000..c873dcc --- /dev/null +++ b/src/auth/keyring/types.ts @@ -0,0 +1,51 @@ +import type { AuthAccount } from '../types.js' + +/** Where a token was (or wasn't) persisted on the most recent write/clear. */ +export type TokenStorageLocation = 'secure-store' | 'config-file' + +export type TokenStorageResult = { + storage: TokenStorageLocation + /** + * Present when the OS keyring was unavailable and the operation fell back + * to (or left state in) the consumer's config file. Suitable for surfacing + * to the user as a `Warning:` line on stderr. + */ + warning?: string +} + +export type UserRecord = { + account: TAccount + /** + * Plaintext token, present only when the keyring was unavailable at write + * time. The runtime reads it in preference to the keyring slot, so a + * stale fallback would mask a fresh keyring-backed write — consumers + * implementing `upsert` as replace-not-merge (per the contract below) + * guarantees the field is cleared on every successful keyring write. + * Surface its presence as security-relevant: it is the same material + * that would otherwise live in the OS credential manager. + */ + fallbackToken?: string +} + +/** + * Port the consumer implements to expose their per-user config records to + * cli-core's keyring-backed `TokenStore`. The shape of the record map (file + * format, path, schema versioning) stays in the consumer — cli-core only + * needs CRUD on these primitives plus a default-user pointer. + */ +export type UserRecordStore = { + list(): Promise[]> + /** + * **Replace**, do not merge. The persisted record must equal `record` field + * for field — an absent `fallbackToken` means "no plaintext token", and a + * merge-style implementation would let a stale plaintext token outlive a + * later keyring-backed write (the runtime preferentially reads + * `fallbackToken` over the keyring). Records are keyed by `account.id`. + */ + upsert(record: UserRecord): Promise + /** Remove the record whose `account.id` matches. */ + remove(id: string): Promise + /** The pinned default's `account.id`, or `null` when nothing is pinned. */ + getDefaultId(): Promise + setDefaultId(id: string | null): Promise +} diff --git a/src/auth/user-flag.ts b/src/auth/user-flag.ts index 280402e..23f63fb 100644 --- a/src/auth/user-flag.ts +++ b/src/auth/user-flag.ts @@ -18,6 +18,11 @@ export function extractUserRef(cmd: Record): AccountRef | undef return typeof cmd.user === 'string' ? cmd.user : undefined } +/** Shared constructor so multiple call sites can't drift on the `ACCOUNT_NOT_FOUND` wording. */ +export function accountNotFoundError(ref: AccountRef): CliError { + return new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`) +} + /** * Read `store.active(ref)` and throw `ACCOUNT_NOT_FOUND` if the explicit * `ref` doesn't match. With `ref === undefined` returns the snapshot @@ -29,7 +34,7 @@ export async function requireSnapshotForRef( ): Promise<{ token: string; account: TAccount } | null> { const snapshot = await store.active(ref) if (ref !== undefined && snapshot === null) { - throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`) + throw accountNotFoundError(ref) } return snapshot } diff --git a/src/test-support/keyring-mocks.ts b/src/test-support/keyring-mocks.ts new file mode 100644 index 0000000..7125b31 --- /dev/null +++ b/src/test-support/keyring-mocks.ts @@ -0,0 +1,143 @@ +import { vi } from 'vitest' + +import type { SecureStore } from '../auth/keyring/secure-store.js' +import type { UserRecord, UserRecordStore } from '../auth/keyring/types.js' +import type { AuthAccount } from '../auth/types.js' + +// Test mocks shared between the keyring unit suites. Lives under +// `src/test-support/` so it's excluded from the build (per +// `tsconfig.build.json`) and never reaches consumers via `dist/`. + +type KeyringSlot = { + secret: string | null + getErr?: unknown + setErr?: unknown + delErr?: unknown +} + +type KeyringMap = { + create: (args: { serviceName: string; account: string }) => SecureStore + slots: Map + deleteCalls: Map +} + +/** + * Keyed multi-slot keyring mock. Each `account` (slug) gets its own state + * blob so tests can verify that `active()` / `clear()` actually route to the + * right per-user slot. Errors can be pre-seeded per slot. + */ +export function buildKeyringMap(): KeyringMap { + const slots = new Map() + const deleteCalls = new Map() + function getSlot(account: string): KeyringSlot { + let slot = slots.get(account) + if (!slot) { + slot = { secret: null } + slots.set(account, slot) + } + return slot + } + return { + slots, + deleteCalls, + create({ account }) { + return { + async getSecret() { + const slot = getSlot(account) + if (slot.getErr) throw slot.getErr + return slot.secret + }, + async setSecret(secret) { + const slot = getSlot(account) + if (slot.setErr) throw slot.setErr + slot.secret = secret + }, + async deleteSecret() { + deleteCalls.set(account, (deleteCalls.get(account) ?? 0) + 1) + const slot = getSlot(account) + if (slot.delErr) throw slot.delErr + const had = slot.secret !== null + slot.secret = null + return had + }, + } + }, + } +} + +type SingleSlotMock = SecureStore & { + getSpy: ReturnType + setSpy: ReturnType + deleteSpy: ReturnType +} + +/** + * Simple single-slot keyring mock with spies on each method. Use for tests + * that don't care about per-user slot routing. + */ +export function buildSingleSlot(initial: { secret?: string | null } = {}): SingleSlotMock { + const state = { secret: initial.secret ?? null } + const getSpy = vi.fn(async () => state.secret) + const setSpy = vi.fn(async (secret: string) => { + state.secret = secret + }) + const deleteSpy = vi.fn(async () => { + const had = state.secret !== null + state.secret = null + return had + }) + return { + getSpy, + setSpy, + deleteSpy, + async getSecret() { + return getSpy() + }, + async setSecret(secret: string) { + return setSpy(secret) + }, + async deleteSecret() { + return deleteSpy() + }, + } +} + +type UserRecordsHarness = { + store: UserRecordStore + state: { + records: Map> + defaultId: string | null + } + upsertSpy: ReturnType + removeSpy: ReturnType + setDefaultSpy: ReturnType +} + +/** In-memory `UserRecordStore` with spies on the mutating methods. */ +export function buildUserRecords(): UserRecordsHarness { + const state = { + records: new Map>(), + defaultId: null as string | null, + } + const upsertSpy = vi.fn(async (record: UserRecord) => { + state.records.set(record.account.id, record) + }) + const removeSpy = vi.fn(async (id: string) => { + state.records.delete(id) + }) + const setDefaultSpy = vi.fn(async (id: string | null) => { + state.defaultId = id + }) + const store: UserRecordStore = { + async list() { + return [...state.records.values()] + }, + upsert: upsertSpy, + remove: removeSpy, + async getDefaultId() { + return state.defaultId + }, + setDefaultId: setDefaultSpy, + } + return { store, state, upsertSpy, removeSpy, setDefaultSpy } +}