Skip to content

Commit 7591c60

Browse files
fix(metadata-protocol)!: a datasource restored or published from a stored body may name only the credentials reference its row holds now (#27860)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DQpvKc4PUUMCDS64Cs8KqKi)_ Fixes #27818 domain:server#3 · os-elon-musk · session_016WV4d1qRCNJhpDgVXFoXkp (card thread) Clause-②: no (narrowing) — the restore doors and the draft publish refuse a stored `datasource` body whose `external.credentialsRef` is not the reference the active row they write into holds now, where they wrote it back as stored before. Nothing is widened. Metadata-protocol: narrowed Skills-docs: none — `skills/**` and `content/docs/**` teach `external.credentialsRef` only on datasources declared in code, as an opaque handle the secret store mints; none teaches restoring or publishing a stored datasource version or draft that names one, and the refusal itself names the door that binds a credential. ## 维护者速读 - 改了什么:把已存的数据源定义重新变成生效版本的三个入口(按历史版本回滚 `POST /api/v1/meta/datasource/:name/rollback`、按提交还原 `POST /api/v1/packages/:id/commits/:commitId/revert` 的恢复分支、草稿发布 `POST /api/v1/meta/datasource/:name/publish` 及整包发布),现在和 #27817 的保存入口用同一条规则:写回的 `external.credentialsRef` 只能是它要写入的那条生效记录现在持有的引用;不是就 400 拒绝,什么都不写,无论引用指向什么答复都相同,消息里不出现引用值。 - 为什么改:卡上第 1 项要求实测恢复入口。实测三个入口都会把历史版本或草稿里存着的引用原样变成生效值,包括别的数据源的引用、无人持有的引用、本数据源轮换前的旧引用,不符合 #27582 的规则。第 2 项(修复前已存的数据)的只读审计已作为卡上评论发出。 - 风险与回滚:拒绝发生在任何写入之前;写回的版本或草稿若带着现在这条记录持有的同一个引用,照常通过;不带这个键的照常写回(不绑定凭据,和保存时省略该键一样)。会被拒的两种正常情形:回滚或还原到一个引用已被轮换掉的旧版本,以及引用轮换后再发布之前存下的草稿;补救是省略该键重新保存,或用数据源管理入口重新绑定凭据。回滚只需 revert 本 PR。 - 先看:`packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts`(判定本身,新增入口措辞),`packages/metadata-protocol/src/protocol-authoring-gate.ts` 中 `refuseRestoredBodyTheSaveDoorRefuses` 的一处调用,`packages/metadata-protocol/src/protocol.ts` 中 `promoteDraftForPublish` 的一处调用。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.datasource-credentials-ref-restore-doors.test.ts`,预期 21/21 通过。 - 需要你做的:无,席位落地;本 PR 是收窄(narrowing),落地前需一次契约复审,由席位安排。 ## What changes The rule is the one #27582 set and #27762 (PR #27817) applied to the metadata save door: **a non-system write may only seal a new value or keep its own record's stored reference.** The doors that make a stored body the active row seal nothing, so the one reference the body they write may name is the one the active row they write into holds now. - **The judge is #27817's, not a copy.** `datasourceCredentialsRefSaveRefusal` gains an optional `door: 'restore' | 'publish'`, which changes only the refusal's words. The save door's words are byte-identical. Each new door asks it as an active write (`mode: 'publish'`) into the row it lands in, with no `source`, so the comparison is with that row's current reference. - **The restore doors.** `rollbackMetaItem` and `revertCommit`'s restore limb both reach the restore path's save-door refusal family, `refuseRestoredBodyTheSaveDoorRefuses` in `protocol-authoring-gate.ts`. The judge is asked there, after `unknownDatasourceSaveRefusal`, in the save door's order, against `binding`, the package binding of the active row the version is restored into. That family runs inside `restoreVersion` before the active row is read and before the put, so a refused version writes nothing. `protocol.ts` gains one host line (`getOverlayRepo`) and one docblock bullet. - **The draft publish.** `promoteDraftForPublish`, shared by the per-item publish and the package publish, re-asks the save door's refusal family on the draft body. The judge is asked there after `unknownDatasourceSaveRefusal`, against `promoteInto`, the binding the active row is written under, before `repo.promoteDraft`. One block in `protocol.ts`; nothing in it moves. - **Who passes.** No server-stated restore or publish exists: the restore doors are reached only from the REST rollback route and the runtime package revert and rollback routes, so none passes through. The save door's own rule and its `source` pass-through are unchanged. - **Unchanged.** A version or draft naming the reference the row holds now is written and keeps it. One naming no reference is written as it stands, with no credential bound, as a save omitting the key is. Rows already stored are not re-judged at rest. ## Why The card's item 1, measured on `origin/main` fec60e0 with #27817's head (4258efa) merged in: each of the three doors wrote a stored datasource body as the active row with whatever `external.credentialsRef` it held. The pin below was committed first and was red on that tree: **15 failed | 6 passed (21)**, every refusal case at every door, with the six accepted cases green. Item 2, the read-only audit of rows stored before the rule, is on the card (comment 6512713420): what `os secret orphans` covers, what it misses, and the operator's reading. It proposes no migration and no repair. ## Tests - `packages/metadata-protocol/src/protocol.datasource-credentials-ref-restore-doors.test.ts` (new, 21 cases): the real `rollbackMetaItem`, `revertCommit` and `publishMetaItem` over a stub engine that stores rows and history, the double of `protocol.restore-doors-save-refusal-family.test.ts`. - Refused at each door, with `code` and `status` asserted, the store byte-identical afterwards and the row's reference kept: a body naming a reference another datasource holds, one no row holds, the reference its row held before and holds no longer, and one where its row holds none now. The four answers are identical, each names the datasource and the key, and none contains any reference. `revertCommit` reports it in `failed[]` with `code: 'VALIDATION_ERROR'`. - Accepted at each door: a body naming the reference its row holds now keeps it; a body naming none stores none. - `packages/qa/dogfood/test/datasource-credentials-ref-restore-doors.dogfood.test.ts` (new, 4 cases): the same rule at `POST /api/v1/meta/datasource/:name/rollback` and `/publish` of a booted stack, as a signed-in non-system caller — a refused rollback and a refused publish, each with the active row unchanged, and one accepted case at each door. - `scripts/engine-double-contract.pinned.json`: the unit pin's stub engine recorded (`--write`; 3 rows added, none lost). ## Acceptance notes - **The draft publish is in scope.** The card names the restore doors; the dispatch order adds "any other restore path that writes a `datasource` row without passing `saveMetaItem`'s judge". The repository has three write sites from the protocol: the save, the draft promotion and the version restore. The draft promotion is the one left, so it is judged too. The seat agreed before the fix. - **A refused publish after a rotation.** A draft saved while its row held one reference cannot be published once the row's reference has moved. Saving the draft again with a different reference is refused too, because the save door judges a draft against its own draft row. The remedy the refusal names is to save the draft with the key omitted, or discard it, and to bind the credential through the datasource admin door. - **The package rollback.** `rollbackToPackageCommit` runs `revertCommit` per commit and counts a commit as reverted when it does not throw. A refused item lands in that inner revert's `failed[]`, which the package rollback does not surface. That is how every per-item refusal of the restore limb already behaves; it is not changed here. - **`protocol.ts`** gains one host line, one docblock bullet and one call-site block; nothing in it moves or reflows (serial queue #25467). The restore call site is in `protocol-authoring-gate.ts`, the restore helper beside it. - **`scripts/engine-double-contract.pinned.json`** is outside the declared file surface: the gate requires the new pin's engine double in the ledger. - **Declarations:** `Metadata-protocol: narrowed` (three doors refuse a body they wrote), hence a `minor` bump on `@objectstack/metadata-protocol` and the `!` title. ADR-0087: `not-required (no-migration-prescription)` — the refusal prescribes a remedy for the request, not a migration of stored data. ## Verification All at the branch head unless stated. Heavy runs under `scripts/pm/os-verify-lock.sh`. - `pnpm --filter @objectstack/metadata-protocol typecheck` exit 0; `vitest run` over the whole package at fd02314: **282 passed | 4 skipped (286) files, 21842 passed | 26 skipped (21868) tests**, exit 0. - `pnpm exec turbo run build --concurrency=2 --filter=!@objectstack/docs` at fd02314: **114/114** tasks, exit 0. - `node scripts/pm/dispatch-gates.mjs --ran` at fd02314: **73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN**, exit 0. `check-plugin-teardown-shape --self-test` first exited 3 (its fixture commit is outside the shallow clone); after fetching that commit it exited 0. `pnpm lint` exit 0. - **Unit ablations** of the call sites, each through `scripts/ablation-replace.mjs` (mutate, run the pin, restore; the restore proven blob-equal to HEAD with `git diff HEAD` empty): - the restore call site never throws → 10 failed | 11 passed (every refusal at both restore doors); - the restore call states a `source`, taking the pass-through → 10 failed | 11 passed; - the publish call site never throws → 5 failed | 16 passed (every refusal at the draft publish); - the publish judges against the draft row instead of the active row it writes into → 5 failed | 16 passed. - **Dist ablation** at the REST doors: both call sites disabled, `@objectstack/metadata-protocol` rebuilt, `scripts/ablation-dist-preflight.mjs` proved both markers in `dist/` (exit 0), the dogfood pin **2 failed | 2 passed (4)** (the two refusals); both files restored (blob equal to HEAD), rebuilt, preflight `--absent` exit 0 for both markers with the tree clean, the pin **4 passed (4)**. - **The save door's words** are unchanged: the message the judge builds with no `door` was compared with #27817's at 4258efa, byte for byte (equal). - **Dogfood battery** (`pnpm --filter @objectstack/dogfood test`, the whole battery, at fd02314): **354 passed | 3 failed | 3 skipped (360) files, 2871 passed | 4 failed | 20 skipped (2895) tests**. The four failures are not this change's; they are red on `main`'s code too: - `error-sentences-live.dogfood.test.ts` (2) and `object-hook-lifecycle-scratch.dogfood.test.ts` (1): the hook-abort answer, tracked as #27806; - `platform-app-object-entry-views.test.ts` (1): a Setup entry names `sys_exchange_rate`, which neither source the test reads declares (the object landed in 194e66c). - To show it, `packages/metadata-protocol/src` was put back to the merge base 7b8bcfe (this change's runtime code absent from the rebuilt `dist/`) and the three files read **4 failed | 56 passed (60)**, the same four tests. The tree was then restored (`git diff HEAD` empty, status clean) and rebuilt. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [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: no. - [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 maintainer brief excepted). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4d1qRCNJhpDgVXFoXkp)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4d1qRCNJhpDgVXFoXkp --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 04740b7 commit 7591c60

7 files changed

Lines changed: 664 additions & 8 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: a datasource restored or published from a stored body may name only the credentials reference its row holds now (#27818)
6+
7+
Clause-②: no (narrowing) — the restore doors and the draft publish refuse a stored `datasource` body whose `external.credentialsRef` is not the reference the active row they write into holds now, where they wrote it back as stored before. Nothing is widened.
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: `DatasourceSchema` keeps `external.credentialsRef` authorable, and the doors that make a stored body active refuse one value of it, a reference the row they write into does not hold now. A stored row is never re-judged at rest: it is read and served as before, and only the write that would make it the active row is judged. There is nothing for a migration to rewrite and no tombstone; the remedy is a door, the datasource admin door's `secret`, which the refusal names. -->
11+
12+
**BREAKING** accept-set narrowing on the doors that make a stored body the active row, completing the rule #27762 applied to the metadata save door (the #27582 own-reference rule: a non-system write may only seal a new value or keep its own record's stored reference). These doors seal nothing, so the one reference the body they write may name is the one the active row holds now.
13+
14+
- **Before.** A history version brought back by `rollbackMetaItem` (`POST /api/v1/meta/datasource/:name/rollback`) or by `revertCommit`'s restore limb (`POST /api/v1/packages/:id/commits/:commitId/revert`), and a draft promoted by the per-item publish (`POST /api/v1/meta/datasource/:name/publish`) or the package publish, became the active row with whatever `external.credentialsRef` it was stored with.
15+
- **Now.** Each is judged as an active write into the row it lands in, with the save door's judge: a body naming a reference that row does not hold now is refused with `VALIDATION_ERROR` / `400` before anything is written. The message names the datasource and the key, never the value, and the answer is the same whatever the value names. A rollback answers the refusal; a commit revert reports it per item in `failed[]`; a package publish aborts on it.
16+
- **Unchanged.** A version or draft naming the reference the row holds now is written and keeps it. One naming no reference is written as it stands, with no credential bound, as a save omitting the key is. The save door's own rule and its server-stated pass-through are unchanged, and no server-stated restore or publish exists. Rows already stored are not re-judged at rest.
17+
- **Who sees it.** An operator who rolls back or reverts a datasource to a version whose reference its row no longer holds, because the reference was rotated through the datasource admin door since, or because the version was stored before the save door judged the key: that restore is refused. An author who publishes a datasource draft after its active row's reference was rotated, or a draft stored before the save door judged the key: that publish is refused.
18+
19+
**The remedy.** For a refused restore, restore a version that names the reference a read of the datasource serves, or save the version's settings through the metadata door with the key omitted. For a refused publish, save the draft again with the key omitted, or discard it, then publish. Either way, to bind a credential, send the secret to the datasource admin door (`POST /api/v1/datasources`, or `PATCH /api/v1/datasources/:name`), which mints and stores the reference.

‎packages/metadata-protocol/src/protocol-authoring-gate.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848

4949
import type { ObjectStackProtocolImplementation } from './protocol.js';
5050
import { permissionSetOwnerBindingRefusal, runtimeHookWithoutBodyRefusal } from './protocol.js';
51+
import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-credentials-ref.js';
5152
import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js';
5253
import type { RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js';
5354
import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name';
@@ -114,7 +115,7 @@ export function restoredBodyWriter(
114115

115116
/** The body of `ObjectStackProtocolImplementation#refuseRestoredBodyTheSaveDoorRefuses`; the method's docblock there is its contract. */
116117
export async function refuseRestoredBodyTheSaveDoorRefuses(
117-
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'unknownDatasourceSaveRefusal'>,
118+
this: Pick<AuthoringGateHost, 'anonymousElevatedFlowRefusal' | 'assertRuntimeAuthoringRules' | 'authoringChannel' | 'credentialChannelFor' | 'defaultRecordPageSaveRefusal' | 'externalBindingSaveRefusal' | 'getOverlayRepo' | 'permissionSetNameCollisionRefusal' | 'runAuthoringGate' | 'unknownDatasourceSaveRefusal'>,
118119
type: string,
119120
name: string,
120121
body: unknown,
@@ -131,6 +132,12 @@ export async function refuseRestoredBodyTheSaveDoorRefuses(
131132
if (bindingRefusal) throw bindingRefusal;
132133
const datasourceRefusal = await this.unknownDatasourceSaveRefusal(type, body, name);
133134
if (datasourceRefusal) throw datasourceRefusal;
135+
const repo = this.getOverlayRepo();
136+
const credentialsRefRefusal = await datasourceCredentialsRefSaveRefusal({
137+
headAt: (...a) => repo.headAt(...a), type, name, item: body,
138+
mode: 'publish', packageId: binding, source: undefined, door: 'restore',
139+
});
140+
if (credentialsRefRefusal) throw credentialsRefRefusal;
134141
const elevatedRefusal = await this.anonymousElevatedFlowRefusal(type, body, name);
135142
if (elevatedRefusal) throw elevatedRefusal;
136143
const channel = this.credentialChannelFor(type);

‎packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@
4040
* reference of their own. The server-stated rewrites of a row this store
4141
* already holds (`source` set: the stored-metadata migration) keep their
4242
* pass-through, as the system principal does under the #27582 rule.
43+
*
44+
* [#27818] The doors that make a STORED body the active row ask the same
45+
* judge, as `door: 'restore'` and `door: 'publish'`: the restore doors
46+
* (`rollbackMetaItem` and `revertCommit`'s restore limb, through the restore
47+
* path's save-door refusal family) on the history body they write back, and
48+
* the draft publish (the per-item publish and the package publish) on the
49+
* draft it promotes. Each is judged as an active write into the row it lands
50+
* in, so the one reference the body may name is the one that row holds NOW:
51+
* a version or draft stored before the save door judged the key, or before
52+
* the row's own reference moved, is refused rather than brought back live. A
53+
* body naming no reference is written as it stands, as a save omitting the
54+
* key is. No server-stated restore or publish exists, so none passes through.
4355
*/
4456

4557
import { itemDiscriminator } from '@objectstack/metadata-core';
@@ -69,6 +81,12 @@ export async function datasourceCredentialsRefSaveRefusal(args: {
6981
packageId: string | null | undefined;
7082
/** The server-stated `source` of a rewrite; `undefined` for every authoring save. */
7183
source: string | undefined;
84+
/**
85+
* [#27818] The door asking: the save (omitted), a restore writing a stored
86+
* version back, or a publish promoting a stored draft. It changes only the
87+
* refusal's words, never what is judged.
88+
*/
89+
door?: 'restore' | 'publish';
7290
}): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> {
7391
if (args.type !== 'datasource' || args.source !== undefined) return undefined;
7492
const named = namedCredentialsRef(args.item);
@@ -83,13 +101,23 @@ export async function datasourceCredentialsRefSaveRefusal(args: {
83101
}
84102
const own = namedCredentialsRef(head?.body);
85103
if (typeof own === 'string' && own === named) return undefined;
86-
const err = new Error(
87-
`Datasource '${args.name}' was not saved: \`external.credentialsRef\` may only name the credentials `
88-
+ `reference this datasource's own stored definition already holds, because a reference is minted by the `
89-
+ `platform when a secret is bound. Send back the reference a read of this datasource serves, or omit the `
90-
+ `key to save it with no credential bound. To bind a credential, send the secret to the datasource admin `
91-
+ `door instead (Setup → Datasources, \`POST /api/v1/datasources\` or \`PATCH /api/v1/datasources/${args.name}\`).`,
92-
) as Error & { code: 'VALIDATION_ERROR'; status: 400 };
104+
const adminDoor = `To bind a credential, send the secret to the datasource admin door instead (Setup → Datasources, `
105+
+ `\`POST /api/v1/datasources\` or \`PATCH /api/v1/datasources/${args.name}\`).`;
106+
const why = 'because a reference is minted by the platform when a secret is bound.';
107+
const message = args.door === 'restore'
108+
? `Datasource '${args.name}' was not restored: the version restored names an \`external.credentialsRef\` `
109+
+ `this datasource's stored definition does not hold now, and a restore may only bring back the credentials `
110+
+ `reference that definition holds, ${why} Restore a version that names the reference a read of this `
111+
+ `datasource serves, or one that names none to restore it with no credential bound. ${adminDoor}`
112+
: args.door === 'publish'
113+
? `Datasource '${args.name}' was not published: its draft names an \`external.credentialsRef\` this `
114+
+ `datasource's active definition does not hold now, and a publish may only make live the credentials `
115+
+ `reference that definition holds, ${why} Save the draft again with the key omitted, or discard it, `
116+
+ `to publish it with no credential bound. ${adminDoor}`
117+
: `Datasource '${args.name}' was not saved: \`external.credentialsRef\` may only name the credentials `
118+
+ `reference this datasource's own stored definition already holds, ${why} Send back the reference a `
119+
+ `read of this datasource serves, or omit the key to save it with no credential bound. ${adminDoor}`;
120+
const err = new Error(message) as Error & { code: 'VALIDATION_ERROR'; status: 400 };
93121
err.code = 'VALIDATION_ERROR';
94122
err.status = 400;
95123
return err;

0 commit comments

Comments
 (0)