13 KiB
13 KiB
Tasks: enterprise-macos-keychain
Change ID: enterprise-macos-keychain
Package: @suaveplan/macos-keychain
Target path: universal/auth/macos-keychain
Prerequisite packages on Verdaccio: @suaveplan/error, @suaveplan/types, @suaveplan/crypto
1. Scaffold
- Create directory
universal/auth/macos-keychain/withsrc/,tests/,src/__benchmarks__/, and all feature subdirectories per the layout inproposal.md. - Write
package.jsonat version0.1.0with:"type": "module","sideEffects": falseexportsmap with top-level"."plus subpaths:"./core/keychain-bridge/keychain-bridge","./core/error-classifier/error-classifier","./core/output-parser/output-parser","./errors/keychain-errors","./schemas/macos-keychain.schemas","./types/macos-keychain.types"dependencies:@suaveplan/error workspace:^,@suaveplan/types workspace:^,@suaveplan/crypto workspace:^,zod catalog:forms,@opentelemetry/api catalog:observability,tslib catalog:builddevDependencies:vitest catalog:testing,@vitest/coverage-istanbul catalog:testing,fast-check catalog:testing,mitata catalog:testing,@suaveplan/testing workspace:^
- Write
tsconfig.jsonextending@suaveplan/typescript-config/library.json. - Write
vitest.config.tsusingcreateTestConfig()from@suaveplan/testing/config(pool:forks). - Add
lint,typecheck,test,test:coverage,build,docs:check,benchscripts topackage.json.
2. Types and errors
2a. Types (src/types/macos-keychain.types.ts + .md)
- Define all public interfaces:
KeychainConfig,KeychainItem,KeychainResult,CommandExecutor.CommandExecutorsignature:(command: string, args: string[]) => Promise<{ stdout: string; stderr: string; exitCode: number }>.- The default
CommandExecutorimplementation usesBun.spawn(Bun runtime) ornode:child_processexecFile(notexec— shell injection prevention); all arguments are passed as an array, never interpolated into a shell string.
- Add genesis-only optional fields:
clock?: Clockandlogger?: LoggerLikeonKeychainConfig. - Write
macos-keychain.types.md(≥200 words, API reference for every type, security note onCommandExecutorusingexecFile/Bun.spawnnotexec).
2b. Error codes (src/error-codes.ts)
- Define
KeychainErrorCodeunion — eight codes:KEYCHAIN_NOT_FOUND,KEYCHAIN_ITEM_EXISTS,KEYCHAIN_ACCESS_FAILED,KEYCHAIN_LOCKED,KEYCHAIN_UNAVAILABLE,KEYCHAIN_PERMISSION_DENIED,KEYCHAIN_ITEM_INVALID,KEYCHAIN_TIMEOUT(genesis addition). - Define
KEYCHAIN_ERROR_METADATAwithhttpStatus,severity,retryable,userMessageKey,category,descriptionper code. - Export
registerKeychainErrorCodes()as idempotent eager side-effect; call it at file bottom. - Export
KeychainError extends SuaveplanError<KeychainErrorCode>.
2c. Zod schemas (src/schemas/macos-keychain.schemas.ts + .md)
- Write Zod schemas:
KeychainConfigSchema,KeychainItemSchema,KeychainResultSchema. - Write
macos-keychain.schemas.md. - Write
macos-keychain.schemas.test.ts— parse valid/invalid for each schema.
3. Constants (src/constants/index.ts)
- Export:
SECURITY_BINARY = "/usr/bin/security"— path to the macOS security CLI.DEFAULT_TIMEOUT_MS = 10_000.EXIT_ITEM_NOT_FOUND = 44—errSecItemNotFound.EXIT_DUPLICATE_ITEM = 45—errSecDuplicateItem.EXIT_AUTH_FAILED = 36—errAuthFailed.EXIT_LOCKED_PATTERNS: string[]— stderr substrings that indicate a locked keychain.
4. Error classifier (src/core/error-classifier/)
- Implement
classifyError(exitCode, stderr, service, account): KeychainError | null:- Map
EXIT_ITEM_NOT_FOUND+ "could not be found" / "no such keychain" stderr patterns →keychainNotFound. - Map
EXIT_DUPLICATE_ITEM+ "duplicate" / "already exists" →keychainItemExists. - Map
EXIT_AUTH_FAILED+ "auth" patterns →keychainAccessFailed. - Map locked-keychain stderr patterns →
keychainLocked. - Map "permission" / "denied" / "not authorized" →
keychainPermissionDenied. - Map "security: SecKeychainItemRef" invalid type →
keychainItemInvalid. - Return
nullfor unrecognized combinations (caller wraps in a genericKEYCHAIN_ACCESS_FAILED).
- Map
- Write
error-classifier.test.ts— each known exit code + stderr combo, unknown combo returns null, stderr precedence over exit code when ambiguous. Eachithas{ timeout: 5000 }. - Write
error-classifier.md.
5. Error factories (src/errors/keychain-errors.ts)
- Implement named factory helpers:
keychainNotFound,keychainItemExists,keychainAccessFailed,keychainLocked,keychainUnavailable,keychainPermissionDenied,keychainItemInvalid,keychainTimeout. - Each factory returns a
KeychainErrorwith the corresponding code, a human-readable message, and a structured context object{ service, account }(where applicable). - Write
keychain-errors.test.ts— construction of each factory, correct code, message non-empty, context fields present. Eachithas{ timeout: 5000 }. - Write
keychain-errors.md.
6. Output parser (src/core/output-parser/)
- Implement
parseKeychainItem(stdout: string, service: string, account: string): KeychainItem:- Extract
passwordfromsecurity find-generic-password -goutput (appears afterpassword:line, possibly base-64 encoded for binary passwords). - Extract optional
label,comment,createdAt,modifiedAtfrom structured output lines. - Return
KeychainItemwith all available fields. - Throw
KeychainError(KEYCHAIN_ITEM_INVALID)if the output cannot be parsed.
- Extract
- Implement
parseKeychainList(stdout: string): Array<{ service: string; account: string }>:- Parse
security dump-keychainoutput for service + account pairs.
- Parse
- Write
output-parser.test.ts— valid stdout for each supported format, missing password line, list parsing, malformed output triggersKEYCHAIN_ITEM_INVALID. Eachithas{ timeout: 5000 }. - Write
output-parser.md.
7. MacOSKeychainBridge (src/core/keychain-bridge/)
- Implement
MacOSKeychainBridge:- Constructor:
constructor(config?: KeychainConfig, executor?: CommandExecutor).- If
executoris not provided, use a defaultCommandExecutorthat invokesSECURITY_BINARYviaBun.spawn(on Bun runtime) ornode:child_processexecFile(notexec) — arguments are always passed as an array to prevent shell injection. - Validate
configwithKeychainConfigSchema; register error codes eagerly.
- If
addItem(item):- Build args array:
["add-generic-password", "-s", service, "-a", account, "-w", password, ...]— never interpolate into a shell string. - Execute via
executor; wrap withAbortSignal.timeout(timeoutMs)and translateTimeoutErrortokeychainTimeout. - On
exitCode !== 0, classify viaclassifyError; throw the result (or generickeychainAccessFailedfor unrecognized codes). - On duplicate, throw
keychainItemExists. - Instrument with
keychain.addspan andkeychain.ops_totalcounter.
- Build args array:
findItem(service, account):- Build args array:
["find-generic-password", "-s", service, "-a", account, "-g"]. - Parse stdout via
parseKeychainItem. zeroizethe internal temporary stdout buffer after extraction.- Instrument with
keychain.findspan and counter.
- Build args array:
updateItem(service, account, newPassword):- Delete existing item, then re-add with new password (macOS has no native in-place update for generic passwords).
- Instrument with
keychain.updatespan and counter.
deleteItem(service, account):- Build args array:
["delete-generic-password", "-s", service, "-a", account]. - Instrument with
keychain.deletespan and counter.
- Build args array:
listItems(service?):- Build args for
find-generic-passwordordump-keychain. - Parse via
parseKeychainList. - Instrument with
keychain.listspan and histogramkeychain.items_returned.
- Build args for
itemExists(service, account):- Calls
findItem; returnstrueon success,falseonKEYCHAIN_NOT_FOUND, re-throws other errors.
- Calls
- All methods accept optional
signal?: AbortSignal; pass toexecutorand reject withkeychainTimeouton abort. - Log all operations at
debuglevel viaconfig.logger(if provided).
- Constructor:
- Write
keychain-bridge.test.ts(≤500 lines):- Use a stub
CommandExecutor(plain object literal — novi.mock) that returns preset{ stdout, stderr, exitCode }values. - Test each CRUD operation: success path, each error code classification, AbortSignal cancellation, logger calls, OTel counter increments (in-memory exporter).
{ timeout: 5000 }on eachit.
- Use a stub
- Write
keychain-bridge.md(≥200 words — architecture overview, security model notingexecFile/Bun.spawn+ args-array usage, platform limitations, stub executor guide for tests).
8. OTel instrumentation (cross-cutting review)
- Confirm all spans, counters, and histograms from
proposal.md §4are wired. - Add
tests/otel.test.ts— in-memory exporter; assertkeychain.ops_totalincrements for add/find/update/delete,keychain.cli_duration_msrecords,keychain.items_returnedhistogram records on list.
9. Property-based tests (tests/property.test.ts)
Use fast-check. All test timeouts { timeout: 30000 }.
- Error classification coverage: for each documented macOS exit code constant,
classifyErrorreturns a non-nullKeychainErrorwith the expectedcode. - Error factory idempotency: calling
keychainNotFound(s, a)twice returns errors with identical.codeand.message. - Round-trip via stub executor:
addItemthenfindItemusing a deterministic stub that echoes stored data — result password equals input password. - Timeout detection: stub executor that delays beyond
timeoutMs(AbortSignal + FakeClock) triggersKEYCHAIN_TIMEOUTerror.
10. Benchmarks (src/__benchmarks__/macos-keychain.bench.ts)
Use mitata. All benchmarks use stub CommandExecutor (no real keychain access).
findItem_stub: 100 000findItemcalls via a stub executor returning a static valid stdout string — measures parse + error-classification overhead.classifyError_10_patterns: 1 000 000 calls toclassifyErrorcycling through 10 exit-code/stderr pairs — pure function overhead.- Document placeholder numbers in
README.mdunder## Benchmarks.
11. Integration tests (tests/integration.test.ts)
- Tag all tests with
// @integrationheader comment. - Guard every test with a runtime skip if not on macOS:
if (process.platform !== 'darwin') { it.skip(...) }. - Tests: real
addItem,findItem,updateItem,deleteItem,listItems,itemExists. Clean up added items inafterEach. - Exclude from coverage threshold in
vitest.config.ts.
12. Docs
Per-file .md (all ≥200 words):
src/types/macos-keychain.types.mdsrc/core/keychain-bridge/keychain-bridge.mdsrc/core/error-classifier/error-classifier.mdsrc/core/output-parser/output-parser.mdsrc/errors/keychain-errors.mdsrc/schemas/macos-keychain.schemas.md
Root README:
- Write
universal/auth/macos-keychain/README.md: purpose, install, quick-start (add + find + delete), security model (execFilearray args, password zeroize, access group), stub executor guide for tests, platform requirements (macOS only for real operations), modules table, benchmarks placeholder.
13. Verify and publish
- Run
bunx turbo lint --filter=@suaveplan/macos-keychain— zero errors. - Run
bunx turbo typecheck --filter=@suaveplan/macos-keychain— zero errors. - Run
bunx turbo test --filter=@suaveplan/macos-keychain— all unit + property tests pass (integration skipped on non-macOS). - Run
bunx turbo test:coverage --filter=@suaveplan/macos-keychain— 100/100/100/100 on non-integration source files. - Run
bun run docs:check --filter=@suaveplan/macos-keychain— zero errors. - Confirm no
TODO,FIXME,@deprecated, untypedany,ts-ignoreinsrc/. Platform-guarded.skipintests/integration.test.tsis permissible. - Confirm
"sideEffects": falseinpackage.json. - Run
bun scripts/publish.ts --package @suaveplan/macos-keychain. - Update
CLAUDE.mdpackage listing to include@suaveplan/macos-keychain. - Archive: move
openspec/changes/enterprise-macos-keychain/toopenspec/archive/2026-04-24-enterprise-macos-keychain/and fold spec delta intoopenspec/specs/macos-keychain/spec.md.