Repository navigation
fix(service-settings,cli): pick the data key file that opens the stored secrets, or refuse in production - #30087
Conversation
…ne that opens its stored secrets, or refuses When the canonical key file and the project's legacy file hold different keys, the process that opens the database trial-opens the newest stored sys_secret ciphertext under each and reads the file whose key opens it. Production refuses to boot when neither opens it; development prints a boot error. One-shot commands resolve the same file the same way, and a leading ~ in OS_HOME is expanded as the CLI home resolution does. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhkoyLG9aL3MMfCgCW
…nt candidate channel, one-shot resolution and ~ expansion Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhkoyLG9aL3MMfCgCW
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhkoyLG9aL3MMfCgCW
…tion refusal Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzhkoyLG9aL3MMfCgCW
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 41 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 691894d22289555241537bb879a35b5309599240 && git checkout 691894d22289555241537bb879a35b5309599240
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d79eecfe5b78598f9117726b542615ca2332d58f ec16e14dd158e592c9e6653372cc18eb64770038 && git checkout -B drift-repro d79eecfe5b78598f9117726b542615ca2332d58f && git merge --no-ff ec16e14dd158e592c9e6653372cc18eb64770038
node scripts/docs-audit/affected-docs.mjs --json d79eecfe5b78598f9117726b542615ca2332d58f
|
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 Contract reviewServed-tier: Provider source at head:
Gates and rules read: AGENTS.md step 3, and Face: a crypto-key boundary change. The CLI parent no longer rules between two data-key files. It hands the ① Derived judgments
② Semver level
③ Boundary flags
④ Narrowing faceWho hits it: a production Implemented-by: VERDICT: PASS |
|
domain:server#6 · os-support-ai · session_01WGufzibcN9oHa8kDbTTnL5 席位核对Verdict: ACCEPT at head
|
Requested by Yibo Wang · project thread
Fixes #30042
维护者速读
$HOME/.objectstack/dev-crypto-key与项目里旧os start留下的那份)不一致时,CLI 不再替子进程挑;serve子进程在数据库打开后,用每把钥匙试开最新一条sys_secret,用能打开的那把并说明选了哪个文件;两把都打不开时,生产启动拒绝启动并点名两个文件,开发启动打印启动错误。一次性命令(os migrate、os secret rewrap/orphans)走同一套解析和试开;OS_HOME里开头的~现在按os start的方式展开。os start的项目若机器上另有别的项目os dev生成的家目录密钥,会用错钥匙启动,所有已存密钥读出来都是"未设置",只留一行日志。这是下个版本会带出去的回归(p1)。LocalCryptoProvider的 fail-loud 约定;serve 的共享 crypto provider 改为首次使用时才构造。回滚:revert 本 PR 即回到 fix(cli,service-settings): os dev and os start share one data-key file #30040 的行为,不改任何密文、不重新封装。packages/services/service-settings/src/local-crypto-provider.ts(chooseKeyFileByTrialOpen/settleKeyFileChannel/keyFilePath)、packages/services/service-settings/src/settings-service-plugin.ts(start()里的选钥)、packages/cli/src/commands/serve.ts(共享 provider 延迟构造)。pnpm --filter @objectstack/cli exec vitest run --project integration test/dev-start-shared-data-key.integration.test.ts,预期 6/6 通过:两把钥匙时os start读回密钥,两把都打不开时生产启动以 "Refusing to start in production" 退出并点名两个文件。Clause-②: yes (narrowing)
Metadata-protocol: none
The
yesis the new@objectstack/service-settingsexports; the(narrowing)arm is the boot refusal: a productionos start/os servewhose two candidate key files both fail to open the newest stored secret now refuses to start, where it used to start and serve every stored secret as unset. No key of any schema, export or stored row changes shape, soMetadata-protocol: noneandpatch; the changeset carries the BREAKING banner, the remedy andnot-required (no-migration-prescription).What changes
applyCryptoKeyFileChannel(CLI parent) still resolvesresolvePersistedKeyFile. Onboth-differit no longer picks: it names the canonical file onOS_INTERNAL_CRYPTO_KEY_FILEand the legacy file on one new parent-owned channel,OS_INTERNAL_CRYPTO_KEY_FILE_CANDIDATE(deleted on every other answer, so an inherited copy never speaks).SettingsServicePlugin.start()settles it (settleKeyFileChannel), in Phase 2: objectql has started (optional dependency), so the table is readable, and every consumer that decrypts does so atkernel:readyor later.storedSecretReaderOverEnginereads the newestsys_secretrows as the system and takes the first whose marker the provider knows;chooseKeyFileByTrialOpenopens it under each file's key (canonical first), once per member ofCRYPTO_CONTEXT_SCOPESbecausesys_secretrecords no producer, and discards the plaintext. The winner is written back to the channel, the candidate is deleted, and the boot prints which file and why.DataKeyOpensNothingError("Refusing to start in production…", both paths, the remedy), which fails the plugin'sstart()and so the boot (rollbackOnFailure); development and test keep the canonical file and print aconsole.errornaming both. No stored row: the canonical file, as today. A read that fails other than a missing table: the canonical file with a boot error naming the read (no verdict is invented from a failed read).serve.tsbuilds the shared provider (datasource binder + secret fields) withdeferredLocalCryptoProvider(), constructed at first use; the post-start secret-field wiring callsresolve()so the constructor's production refusal still fires at boot. A provider built while a candidate is still pending prints a warning naming both files (safety net, never silent).resolveExistingDataKeyresolves the same file with the same legacy path (resolveStartHome+dev-crypto-key), sets the channels on its own env (it is its own parent), reads the project file when it alone holds a key, and onboth-differreturns a deferred provider that the boot's settings service settles.~inOS_HOME/OBJECTSTACK_HOMEis expanded inkeyFilePathexactly asresolveStartHomedoes.protocol.tsandengine.tsare untouched.Tests
service-settingslocal-crypto-provider.test.ts(+21): the case-1 shape picks the legacy key; dev-then-start keeps canonical; every scope's row is judged; production refusal names both paths; development/test error notice; no row → canonical; failed read → not judged; one-file answers pass through untried; settle mutates the channel once for concurrent callers; refusal leaves both channels; provider-before-settle warning; deferred provider reads options at first use; engine reader (newest, unknown marker skipped, missing table → none, other failure rejects);~expansion.service-settingssettings-engine-bind-window.test.ts(+2, realLiteKernel): everykernel:readyreader sees the legacy file chosen; production with a key that opens nothing failsbootstrap()naming both files.cliinternal-crypto-key-file-channel.test.ts: both-differ hands the child both channels; case-1 and dev-then-start shapes read back after the child's settle; inherited candidate removed;~parity withresolveStartHome.clione-shot-settings.pin.test.ts(+3): legacy-only read by a one-shot; both-differ provider follows the trial; nothing minted.clitest/dev-start-shared-data-key.integration.test.ts(+3, public door, realos start): the card's case 1 reads the secret back and names the chosen file; a production boot whose keys open nothing refuses naming both files. Budget re-measured (24.3 s for four boots → 40).Acceptance notes
sys_secretrow chosen for the trial: the newest bycreated_atwith a known marker. A database mixing rows from both keys is judged by its newest row, the key the deployment sealed with last.serve.tsused to catch a provider-construction error and leavesecretsundefined; with the deferred provider that error surfaces at first use instead, and production still refuses at the secret-field wiring as before.Verification
All readings on head
ec16e14d(the branch merged withmainat that point).@objectstack/service-settings0,@objectstack/cli0.service-settings1151/1151 (67 files);cliunit 5365/5365 (382 files);cliintegrationdev-start-shared-data-key.integration.test.ts6/6 (24.3 s of tests).pnpm lint: exit 0.node scripts/pm/dispatch-gates.mjs --commands: 66 derived gates, each run and its exit code recorded;--ran: 66 run, 0 NOT-MEASURED, 0 UNRUN. Three exited 3 on the first pass for environment prerequisites (the teardown self-test's pinned commit absent from the shallow clone;check:dual-build-cjs-loadsandcheck:i18n-coveragebefore every dist existed). After fetching that commit and with the dists built they exited 0, and those are the codes recorded.scripts/ablation-replace.mjswith the dist rebuilt and preflighted, each restored toHEAD(blob equal, diff empty):service-settings12 red,cli2 red.~expansion disabled inkeyFilePath:service-settings2 red,cli2 red.@objectstack/dogfood^...exit 0, thenpnpm --filter @objectstack/dogfood testexit 0: 417 files passed, 3 skipped (420); 3462 tests passed, 28 skipped (3490); 1695 s.