Skip to content

Commit 159930a

Browse 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>
1 parent 797f925 commit 159930a

11 files changed

Lines changed: 306 additions & 84 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/service-settings': patch
4+
---
5+
6+
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".

‎content/docs/deployment/backup-restore.mdx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,22 @@ Escrow the key in your secret manager the day you generate it, and treat
2929
key + database as one backup unit.
3030
</Callout>
3131

32+
**Two key files on one host.** Without `OS_SECRET_KEY` (or `OS_DEV_CRYPTO_KEY`),
33+
the data key comes from a `dev-crypto-key` file: the canonical one under the
34+
ObjectStack home dir (`OS_HOME`, else `~/.objectstack`), which `os dev` and
35+
`os start` share, or a project's own `<project>/.objectstack/dev-crypto-key`,
36+
left by an earlier `os start`, when that is the only file holding a key. When
37+
both files exist and hold **different** keys, the boot trial-opens the newest
38+
stored secrets (up to ten rows) under each key once the database is open, uses
39+
the file whose key opens more of them, and prints which file it chose and how
40+
many rows each key opened. If each key opens some of the rows, the database
41+
holds secrets under both keys: the boot prints an error naming both files, and
42+
the secrets under the unused key read as unset until they are saved again. If
43+
neither key opens any of them, a production boot refuses to start and names
44+
both files. To converge on one key, keep a copy of both files, save again any
45+
secret that reads as unset, then put the key that opens them at the canonical
46+
path (or set `OS_SECRET_KEY` to it) and back that one key up with the database.
47+
3248
## Backing up the database
3349

3450
Use the native tooling of whatever `OS_DATABASE_URL` points at — ObjectStack

‎packages/cli/src/commands/serve.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5320,7 +5320,7 @@ export default class Serve extends Command {
53205320
};
53215321

53225322
// Fail-closed binder over the shared dev crypto provider. If the
5323-
// provider can't be created, leave `secrets` undefined — the plugin
5323+
// binder can't be built, leave `secrets` undefined — the plugin
53245324
// then rejects secret-bearing create/update instead of storing
53255325
// cleartext (by design).
53265326
let secrets: any = undefined;
@@ -5341,12 +5341,14 @@ export default class Serve extends Command {
53415341
cryptoProvider: sharedCryptoProvider,
53425342
});
53435343
} catch (cryptoErr: any) {
5344-
// Best-effort fail-closed: leave `secrets` undefined so the plugin
5345-
// rejects secret-bearing create/update rather than storing
5346-
// cleartext. A production deployment with no stable key still
5347-
// aborts boot loudly at the secret-field wiring below (where
5348-
// LocalCryptoProvider's "Refusing to start in production" error is
5349-
// rethrown), so we don't duplicate that abort here.
5344+
// Reached only when the module or the binder fails to load: the
5345+
// deferred provider constructs nothing here, so a key problem
5346+
// never lands in this catch. Leave `secrets` undefined so the
5347+
// plugin rejects secret-bearing create/update rather than storing
5348+
// cleartext. A key problem surfaces where the provider is first
5349+
// built, the secret-field wiring below (`resolve()`), which
5350+
// rethrows a production "Refusing to start in production" error,
5351+
// so the boot still refuses and nothing is duplicated here.
53505352
console.warn(
53515353
chalk.yellow(
53525354
` ⚠ datasource admin: no CryptoProvider (${cryptoErr?.message ?? cryptoErr}); secret-bearing datasource create/update will fail closed`,

‎packages/cli/src/utils/internal-crypto-key-file-channel.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ describe('#29934 — the key file an earlier `os start` kept in the project', ()
123123
] as const)('%s: the child reads its secret back with the %s key', async (_shape, sealer) => {
124124
const keys = { legacy: writeKey(legacyFile()), canonical: writeKey(canonicalFile()) };
125125
const handle = await new LocalCryptoProvider({ key: keys[sealer] }).encrypt('sk-30042', ctx);
126-
const stored = async () => ({ namespace: ctx.namespace, key: ctx.key, ciphertext: handle.ciphertext });
126+
const stored = async () => [{ namespace: ctx.namespace, key: ctx.key, ciphertext: handle.ciphertext }];
127127
for (const { child } of [await startChild(), await devChild()]) {
128128
const quiet = vi.spyOn(console, 'warn').mockImplementation(() => {});
129129
// What the child's settings service does at start(), the database open.

‎packages/cli/src/utils/one-shot-settings.pin.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ describe('[#30042] one-shot commands take the same key file, by the same resolut
255255
// What the boot's settings service does at start(), the database open.
256256
await settleKeyFileChannel({
257257
env,
258-
readStoredSecret: async () => ({ namespace: ctx.namespace, key: ctx.key, ciphertext: handle.ciphertext }),
258+
readStoredSecret: async () => [{ namespace: ctx.namespace, key: ctx.key, ciphertext: handle.ciphertext }],
259259
});
260260
expect(await key.provider!.decrypt(handle, ctx)).toBe('sk-start');
261261
expect(key.provider!.keySource).toBe('file');

‎packages/cli/src/utils/one-shot-settings.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ export interface ExistingDataKey {
5959
/**
6060
* Resolve the data key this host already has, never minting one. A key that
6161
* is missing, or set but unusable, is an answer here, not a throw.
62+
*
63+
* ⚠ It writes the key-file channels (`KEY_FILE_CHANNEL_ENV`,
64+
* `KEY_FILE_CANDIDATE_CHANNEL_ENV`) on `opts.env`, which defaults to the real
65+
* `process.env`: it sets or deletes both, so this process and every child it
66+
* spawns afterwards read the file this call resolved. Pass an env map of your
67+
* own to keep `process.env` untouched (the tests do).
6268
*/
6369
export async function resolveExistingDataKey(
6470
opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
@@ -77,11 +83,11 @@ export async function resolveExistingDataKey(
7783
// [#30042] The SAME file a `serve` child of `os dev` / `os start` reads for
7884
// this project, chosen by the same resolution: the canonical file, the
7985
// project's legacy file when it alone holds a key, and — when the two hold
80-
// different keys — whichever opens a stored secret, ruled by the settings
81-
// service in this run's own boot (`settleKeyFileChannel`). This process
82-
// opens the database itself, so it is its own parent: it sets both channels
83-
// on its own env, exactly as `applyCryptoKeyFileChannel` sets them on a
84-
// child's.
86+
// different keys — whichever opens more of the newest stored secrets, ruled
87+
// by the settings service in this run's own boot (`settleKeyFileChannel`).
88+
// This process opens the database itself, so it is its own parent: it sets
89+
// both channels on its own env, exactly as `applyCryptoKeyFileChannel` sets
90+
// them on a child's.
8591
const startHome = resolveStartHome(undefined, {
8692
hasProjectConfig: existsSync(path.resolve(cwd, 'objectstack.config.ts')),
8793
cwd,

‎packages/services/service-settings/README.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,18 @@ The default is `LocalCryptoProvider` — AES-256-GCM keyed off a single
5252
across every restart and every node, or previously-encrypted secrets
5353
become undecryptable.
5454
2. `OS_DEV_CRYPTO_KEY` — dev convenience key (same format).
55-
3. A persisted file at `~/.objectstack/dev-crypto-key` (mode 0600). In
55+
3. A persisted file, `dev-crypto-key` (mode 0600) under `OS_HOME` (legacy
56+
`OBJECTSTACK_HOME`, a leading `~` expanded), else `~/.objectstack`. In
5657
development this is auto-created so single-host dev loops survive
57-
restarts; in production it is only *read*, never minted.
58+
restarts; in production it is only *read*, never minted. Under the CLI
59+
(`os dev`, `os start`, the one-shot commands) a project whose own
60+
`.objectstack/dev-crypto-key` (left by an earlier `os start`) is the only
61+
file holding a key reads that one instead. When both files hold
62+
*different* keys, the boot trial-opens the newest stored `sys_secret`
63+
rows (up to ten) under each key once the database is open, and reads the
64+
file whose key opens more of them; a split between the two keys is a boot
65+
error naming both files, and a production boot where neither key opens any
66+
of them refuses to start (`DataKeyOpensNothingError`).
5867

5968
**Fail-loud in production.** When `NODE_ENV=production` and no stable key
6069
source (env var or pre-existing file) is available, the provider refuses

‎packages/services/service-settings/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export {
3838
type PersistedKeyFile,
3939
KEY_FILE_CHANNEL_ENV,
4040
// When those two files hold different keys, the process that opens the
41-
// database rules between them by trial-opening a stored secret, or refuses.
41+
// database rules between them by trial-opening its newest stored secrets, or refuses.
4242
KEY_FILE_CANDIDATE_CHANNEL_ENV,
4343
chooseKeyFileByTrialOpen,
4444
settleKeyFileChannel,

‎packages/services/service-settings/src/local-crypto-provider.test.ts‎

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,8 @@ describe('#30042 — two key files: the boot takes the one that opens a stored s
271271
const h = await new LocalCryptoProvider({ key }).encrypt('sk-30042', { scope, namespace: 'ai', key: 'openai_api_key' });
272272
return { namespace: 'ai', key: 'openai_api_key', ciphertext: h.ciphertext };
273273
};
274-
const reader = (sample: StoredSecretSample | undefined) => async () => sample;
274+
/** A reader returning this page of stored rows, newest first. */
275+
const reader = (...page: StoredSecretSample[]) => async () => page;
275276

276277
it('the case-1 shape: a start-sealed database with a foreign home key reads with the legacy key', async () => {
277278
writeKey(canonical()); // `os dev` in another project minted this one
@@ -343,7 +344,7 @@ describe('#30042 — two key files: the boot takes the one that opens a stored s
343344
it('no stored secret: nothing to judge, the canonical file stands', async () => {
344345
writeKey(canonical());
345346
writeKey(legacy());
346-
const choice = await chooseKeyFileByTrialOpen({ answer: bothDiffer(), readStoredSecret: reader(undefined), mode: 'production' });
347+
const choice = await chooseKeyFileByTrialOpen({ answer: bothDiffer(), readStoredSecret: reader(), mode: 'production' });
347348
expect(choice).toMatchObject({ path: canonical(), reason: 'no-stored-secret' });
348349
});
349350

@@ -362,8 +363,101 @@ describe('#30042 — two key files: the boot takes the one that opens a stored s
362363
expect(choice.notice?.message).toContain('connection reset');
363364
});
364365

366+
describe('#30091 — the trial scores both keys over the whole page, not its newest row', () => {
367+
it.each(['production', 'development'] as const)(
368+
'%s: rows split between the keys is a mixed-key boot ERROR naming both paths, on the key that opens more',
369+
async (mode) => {
370+
const canonicalKey = writeKey(canonical());
371+
const legacyKey = writeKey(legacy());
372+
// Newest first: the newest row is the canonical key's, the three older ones the legacy key's.
373+
const page = [await sealed(canonicalKey), await sealed(legacyKey), await sealed(legacyKey), await sealed(legacyKey)];
374+
const choice = await chooseKeyFileByTrialOpen({ answer: bothDiffer(), readStoredSecret: reader(...page), mode });
375+
expect(choice).toMatchObject({
376+
path: legacy(),
377+
reason: 'mixed',
378+
other: canonical(),
379+
tally: { judged: 4, chosen: 3, other: 1 },
380+
});
381+
expect(choice.notice?.level).toBe('error');
382+
expect(choice.notice?.message).toContain(canonical());
383+
expect(choice.notice?.message).toContain(legacy());
384+
},
385+
);
386+
387+
it.each([
388+
['canonical', 'legacy'],
389+
['legacy', 'canonical'],
390+
] as const)('a tie goes to the key that opens the newest row (%s newest)', async (newest, older) => {
391+
const keys = { canonical: writeKey(canonical()), legacy: writeKey(legacy()) };
392+
const files = { canonical: canonical(), legacy: legacy() };
393+
const choice = await chooseKeyFileByTrialOpen({
394+
answer: bothDiffer(),
395+
readStoredSecret: reader(await sealed(keys[newest]), await sealed(keys[older])),
396+
mode: 'production',
397+
});
398+
expect(choice).toMatchObject({ path: files[newest], reason: 'mixed', tally: { judged: 2, chosen: 1, other: 1 } });
399+
});
400+
401+
it('a corrupt newest row no longer refuses a production boot: an older row settles the choice', async () => {
402+
writeKey(canonical());
403+
const startKey = writeKey(legacy());
404+
const corrupt = await sealed(randomBytes(32)); // opens under neither key
405+
const choice = await chooseKeyFileByTrialOpen({
406+
answer: bothDiffer(),
407+
readStoredSecret: reader(corrupt, await sealed(startKey), await sealed(startKey, 'object_secret_field')),
408+
mode: 'production',
409+
});
410+
expect(choice).toMatchObject({ path: legacy(), reason: 'opens', tally: { judged: 3, chosen: 2, other: 0 } });
411+
expect(choice.notice?.level).toBe('warn');
412+
});
413+
414+
it('one key opening every judged row is `opens`, with the count it was measured on', async () => {
415+
const devKey = writeKey(canonical());
416+
writeKey(legacy());
417+
const choice = await chooseKeyFileByTrialOpen({
418+
answer: bothDiffer(),
419+
readStoredSecret: reader(await sealed(devKey), await sealed(devKey, 'datasource_credential')),
420+
mode: 'production',
421+
});
422+
expect(choice).toMatchObject({ path: canonical(), reason: 'opens', tally: { judged: 2, chosen: 2, other: 0 } });
423+
});
424+
425+
it('a page where no row opens under either key keeps the production refusal, counting the page', async () => {
426+
writeKey(canonical());
427+
writeKey(legacy());
428+
const refusal = await chooseKeyFileByTrialOpen({
429+
answer: bothDiffer(),
430+
readStoredSecret: reader(await sealed(randomBytes(32)), await sealed(randomBytes(32))),
431+
mode: 'production',
432+
}).catch((e: unknown) => e);
433+
expect(refusal).toBeInstanceOf(DataKeyOpensNothingError);
434+
expect((refusal as DataKeyOpensNothingError).judged).toBe(2);
435+
expect((refusal as Error).message).toContain('Refusing to start in production');
436+
});
437+
438+
it('settleKeyFileChannel: a mixed page settles on the majority key and says so as console.error, once', async () => {
439+
const canonicalKey = writeKey(canonical());
440+
const legacyKey = writeKey(legacy());
441+
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
442+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
443+
const env: Record<string, string | undefined> = {
444+
NODE_ENV: 'production',
445+
[KEY_FILE_CHANNEL_ENV]: canonical(),
446+
[KEY_FILE_CANDIDATE_CHANNEL_ENV]: legacy(),
447+
};
448+
const page = [await sealed(legacyKey), await sealed(canonicalKey), await sealed(canonicalKey)];
449+
const choice = await settleKeyFileChannel({ env, readStoredSecret: reader(...page) });
450+
expect(choice).toMatchObject({ path: canonical(), reason: 'mixed' });
451+
expect(env[KEY_FILE_CHANNEL_ENV]).toBe(canonical());
452+
expect(env[KEY_FILE_CANDIDATE_CHANNEL_ENV]).toBeUndefined();
453+
expect(error).toHaveBeenCalledTimes(1);
454+
expect(String(error.mock.calls[0]?.[0])).toContain(legacy());
455+
expect(warn).not.toHaveBeenCalled();
456+
});
457+
});
458+
365459
it('every one-file answer passes through untried', async () => {
366-
const read = vi.fn(async () => undefined);
460+
const read = vi.fn(async () => [] as StoredSecretSample[]);
367461
for (const answer of [
368462
{ path: canonical(), kind: 'canonical', canonical: canonical() },
369463
{ path: legacy(), kind: 'legacy-only', canonical: canonical(), legacy: legacy() },
@@ -431,7 +525,7 @@ describe('#30042 — two key files: the boot takes the one that opens a stored s
431525
});
432526

433527
it('nothing to settle without a candidate', async () => {
434-
const read = vi.fn(reader(undefined));
528+
const read = vi.fn(reader());
435529
expect(await settleKeyFileChannel({ env: { [KEY_FILE_CHANNEL_ENV]: canonical() }, readStoredSecret: read })).toBeUndefined();
436530
expect(read).not.toHaveBeenCalled();
437531
});
@@ -460,12 +554,17 @@ describe('#30042 — two key files: the boot takes the one that opens a stored s
460554

461555
describe('#30042 — the reader over the engine', () => {
462556
const v2 = 'v2:' + randomBytes(40).toString('base64');
463-
it('reads the newest rows as the system and skips a marker the provider does not know', async () => {
557+
it('reads the newest page as the system, keeps its order, and skips a marker the provider does not know', async () => {
558+
const older = 'v2:' + randomBytes(40).toString('base64');
464559
const find = vi.fn(async () => [
465560
{ namespace: 'x', key: 'y', ciphertext: 'v9:abc' },
466561
{ namespace: 'ai', key: 'openai_api_key', ciphertext: v2 },
562+
{ namespace: 'mail', key: 'smtp_password', ciphertext: older },
563+
]);
564+
expect(await storedSecretReaderOverEngine({ find })()).toEqual([
565+
{ namespace: 'ai', key: 'openai_api_key', ciphertext: v2 },
566+
{ namespace: 'mail', key: 'smtp_password', ciphertext: older },
467567
]);
468-
expect(await storedSecretReaderOverEngine({ find })()).toEqual({ namespace: 'ai', key: 'openai_api_key', ciphertext: v2 });
469568
expect(find).toHaveBeenCalledWith('sys_secret', expect.objectContaining({
470569
orderBy: [{ field: 'created_at', order: 'desc' }],
471570
context: { isSystem: true },
@@ -474,7 +573,7 @@ describe('#30042 — the reader over the engine', () => {
474573

475574
it('a table that does not exist holds no row; any other failure rejects', async () => {
476575
const missing = storedSecretReaderOverEngine({ find: async () => { throw new Error('no such table: sys_secret'); } });
477-
expect(await missing()).toBeUndefined();
576+
expect(await missing()).toEqual([]);
478577
const broken = storedSecretReaderOverEngine({ find: async () => { throw new Error('connection reset'); } });
479578
await expect(broken()).rejects.toThrow('connection reset');
480579
});

0 commit comments

Comments
 (0)