You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 159930a
Browse filesBrowse the repository at this point in the historyBrowse files
fix(service-settings): judge the whole fetched page when two data-key files differ, and report a mixed-key database (#30232)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg9Xp3HcRyRtEiueJAgv7qVo)_
domain:server#2 · os-bill · session_01Utp6CahkbD22YHPM8hMrA8
Fixes#30091
Clause-②: no — an internal boot heuristic and its notice text. The
shapes it changes (`StoredSecretReader` answers the page,
`KeyFileChoice` gains the `mixed` reason and a `tally`,
`DataKeyOpensNothingError` gains `judged`) were added by #30042, whose
changeset is still pending, so they first ship together in the same
release; no released export changes, and no new error code is added.
Metadata-protocol: none
## 维护者速读
- 改了什么:两个数据密钥文件不一致时,启动试开不再只看最新一条 `sys_secret`,而是对已取回的最新一页(最多 10
条)逐条用两把钥匙试开,用打开条数更多的那把(平局取能打开最新一条的那把);两把各开一部分时,按「混合密钥」打一条启动错误,点名两个文件、各开几条和修法;最新一条坏了但较早的行能开时,照常选出钥匙,不再在生产拒启。提示文案改为只说实测到的(「打开最新
3 条中的 2 条」),不再说「打开本库全部已存密钥」。另修三个评审小问题,并在运维文档加一段两文件规则。
- 为什么改:#30087
的试开只判一行。库里两把钥匙的密文并存时,没选中那把下的密钥照样读成「未设置」且无提示;最新一行损坏时,生产启动会拿钥匙问题拒启,即使较早的行足以定夺。
-
风险与回滚:只改启动时选哪个密钥文件的判法与提示,不改任何密文、不重新封装;生产拒启只剩「整页没有一行能被任一把打开」这一种。混合密钥选择提示而非拒启(理由见
Acceptance notes)。回滚即撤本 PR,回到只判最新一行。
- 先看:`packages/services/service-settings/src/local-crypto-provider.ts` 的
`chooseKeyFileByTrialOpen`(平局与 `mixed` 分支)与 `scoreKeyOverPage`;同文件
`storedSecretReaderOverEngine`(现返回整页);`content/docs/deployment/backup-restore.mdx`
新增的那一段。
- 两分钟验证:`pnpm --filter @objectstack/service-settings exec vitest run
src/local-crypto-provider.test.ts`,预期 74/74 通过,其中 `#30091 — the trial
scores both keys over the whole page` 一组 8 条。
- 需要你做的:无,席位落地。
## What changes
- **The trial scores the page, not one row.** `StoredSecretReader` now
answers the newest page, newest first (empty = no stored secret);
`storedSecretReaderOverEngine` returns every known-marker row of the
page it already fetched (`limit: 10`, unchanged) instead of the first.
`chooseKeyFileByTrialOpen` opens each row under both keys
(`scoreKeyOverPage`, each scope as before) and counts.
- **The verdicts.** One key opens rows and the other none: `opens`, that
key's file. Both keys open rows: the new `mixed` reason, the file whose
key opens more of them (a tie goes to the key that opens the newest of
them, the key sealed with last), with an `error` notice naming both
paths, both counts, the consequence and the fix, in every posture. No
row opens under either key: unchanged (`opens-nothing`; production
throws `DataKeyOpensNothingError`, which now records and prints how many
rows it judged). A row that opens under neither key no longer decides
anything while another row opens: a corrupt newest row with an older row
under one key settles on that key.
- **`KeyFileChoice.tally`** (`judged`, `chosen`, `other`) records what
was measured whenever rows were judged.
- **Notice wording.** The two `opens` notices said the chosen file
"opens this database's stored secrets"; they now say what was measured
("opens the 3 newest stored secrets", "opens 2 of the 3 newest stored
secrets", plus how many open under neither). The refusal reads "neither
data key opens any of the N newest stored secrets".
- **Review nits from #30087.** `serve.ts`: the datasource-binder comment
and catch no longer claim a provider-construction error lands there (the
deferred provider constructs nothing at that point; a key problem
surfaces at the secret-field wiring's `resolve()`, which rethrows the
production refusal). `service-settings` README: the file tier names
`OS_HOME` / `~` expansion, the project's legacy file and the two-file
trial. `one-shot-settings.ts`: JSDoc that `resolveExistingDataKey` sets
or deletes both channels on `opts.env`, which defaults to the real
`process.env`.
- **Operator docs.** One paragraph, "Two key files on one host", in
`content/docs/deployment/backup-restore.mdx` right after the existing "A
database backup without `OS_SECRET_KEY` is an incomplete backup"
callout, the one page that already tells operators the home dir holds
the dev-minted key: the two files, what the boot does when they differ,
the split and refusal cases, and the remedy. `content/docs/` is
devx-owned; this PR adds that one paragraph and nothing else there.
- Changeset `.changeset/30091-data-key-trial-page.md` (`patch`), with a
"Supersedes, in the same release" paragraph for #30042's pending note,
which quotes the old refusal text.
## Tests
- `service-settings` `local-crypto-provider.test.ts` (+8, describe
`#30091 — the trial scores both keys over the whole page, not its newest
row`): a split page is `mixed` on the majority key with an error naming
both paths, in production and in development (no refusal); a tie goes to
the key that opens the newest row, both orders; a corrupt newest row
with older rows under one key settles on that key in production; one key
opening every row is `opens` with its tally; a page where nothing opens
keeps the production refusal and records `judged`;
`settleKeyFileChannel` on a mixed page points the channel at the
majority file and prints one `console.error`, no warning. The
reader-over-engine test now pins the whole page in order, the unknown
marker still skipped. Existing trial tests moved to the page reader
unchanged in what they assert.
- `cli` `internal-crypto-key-file-channel.test.ts` and
`one-shot-settings.pin.test.ts`: their stub readers answer a one-row
page (the type change), assertions unchanged.
## Acceptance notes
- **Mixed-key is an error notice, not a production refusal.** A split
database is measured, not guessed: the chosen key opens real rows, and
refusing would leave the operator no running server through which to
save the minority's secrets again (no tool re-seals from a second key).
The refusal posture from #30087 (production refuses when no key opens
anything) is unchanged. If the maintainer wants a split database refused
in production as well, that is a one-branch change in
`chooseKeyFileByTrialOpen` plus a `(narrowing)` declaration; it is
raised in the dev report.
- **The page is ten rows.** A database whose split lies only beyond its
newest ten rows is still judged by those ten; the notices say "of the N
newest", so they claim no more than that.
- No new error class or code: `mixed` is a `KeyFileChoice` reason, and
the refusal stays `DataKeyOpensNothingError`.
## Verification
All readings on head `26eb6119` (branch merged with `main` at
`ff12aed3`) unless named.
<details><summary>Build, typecheck, tests</summary>
- Build: `pnpm --filter '@objectstack/cli^...' --filter
@objectstack/service-settings build`, VERDICT command-exit 0; then
`@objectstack/cli` build, exit 0.
- Typecheck: `@objectstack/service-settings` 0 (its `tsconfig` includes
`src/**`, tests included), `@objectstack/cli` 0 (with
`check:test-typecheck`).
- `service-settings`: 1159/1159 (67 files), on `26eb6119`.
- `cli` unit (`--project unit`): 380/382 files on the first run, the two
red files (`published-subpath-console.pin`,
`published-subpath-hook-body.pin`) refusing because `packages/cli/dist`
was not yet built; after the cli build both pass, 29/29. On `26eb6119`
the two cli files this diff edits: 23/23. The cli `integration` layer is
declared to CI (no integration file or spawn entry is touched).
</details>
<details><summary>Ablations (each through scripts/ablation-replace.mjs;
restored blob == HEAD, `git diff HEAD` empty)</summary>
The pins live in `service-settings`, whose tests import
`./local-crypto-provider.js` from `src`, so no dist leg applies.
- A, the trial judges only the newest row (`page = (await
opts.readStoredSecret()).slice(0, 1)`): 8 red, the whole new `#30091`
group.
- B, the production refusal removed (the `throw new
DataKeyOpensNothingError(...)` line deleted): 4 red across
`local-crypto-provider.test.ts` and
`settings-engine-bind-window.test.ts` (#30087's refusal pin and its
channels-left-as-they-were pin, the new page refusal pin, and the
real-kernel bind-window refusal).
- C, a split page reported as a quiet `opens` (`if (rejected.opens ===
0)` forced true): 5 red (mixed ×2, tie ×2, settle-mixed).
</details>
<details><summary>Gates</summary>
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 95 derived families, the same list on the
pre-merge head and on `26eb6119`. Each run with its exit code recorded
before any pipe; on `26eb6119` all 95 exit 0, and `--ran` answers: 95
derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass three exited
3 for environment prerequisites (`check:skill-examples` and
`check:dual-build-cjs-loads` before `client-react` and seven other dists
existed, `check:i18n-coverage` before `connector-slack`'s); after
building those packages all three exited 0.
- `pnpm lint`: exit 0 on `26eb6119`.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic:
`packages/spec` untouched.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: n/a.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block is
Chinese by the Communication rule).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Utp6CahkbD22YHPM8hMrA8
Co-authored-by: Claude <noreply@anthropic.com>
fix(service-settings): when two data-key files hold different keys, the boot judges the newest stored secrets as a page, not only the newest one, and reports a database split between the two keys
7
+
8
+
Clause-②: no — an internal boot heuristic and its notice text. The shapes it changes (`StoredSecretReader` now answers the page, `KeyFileChoice` gains the `mixed` reason and a `tally`, `DataKeyOpensNothingError` gains `judged`) were added by #30042, whose note is still pending, so they first ship together in this release; no released export changes.
9
+
Metadata-protocol: none
10
+
11
+
-**`@objectstack/service-settings`.** When the shared key file and the project's own key file hold different keys, the boot now trial-opens each of the newest stored `sys_secret` rows (up to ten) under both keys and reads with the key that opens more of them, on a tie the one that opens the newest. A row that opens under neither key (a corrupt or foreign row) no longer decides the boot: if an older row opens under one key, that key is used, and only a page where no row opens under either key keeps the production refusal. When each key opens some of the rows, the database holds secrets under both keys: the boot uses the key that opens more of them and prints an error naming both files, how many rows each key opened, and the fix. The boot's notices now say what was measured ("opens the 3 newest stored secrets", "opens 2 of the 3 newest stored secrets") instead of claiming the chosen file opens all of the database's stored secrets.
12
+
-**`@objectstack/cli`.** The one-shot commands (`os migrate`, `os secret rewrap`, `os secret orphans`, `os storage orphans`) share the same trial, so they make the same choice.
13
+
14
+
**The remedy for a split database.** Keep a copy of both files, then save again each secret that reads as unset: it is sealed under the key the boot uses. Once nothing reads as unset, put that key at the shared file (or set `OS_SECRET_KEY` to it).
15
+
16
+
Supersedes, in the same release: the note for #30042 says the boot opens the newest stored secret under each key and quotes the refusal as "neither data key opens this database's stored secrets". After this change the boot judges the newest rows as a page, and the refusal reads "neither data key opens any of the N newest stored secrets".
0 commit comments