Skip to content

Commit 642dd4f

Browse files
fix(metadata-protocol)!: a datasource saved through the metadata door may name only its own stored credentials reference (#27817)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D47r79fQtBBe4WAGvjKrwVi)_ Fixes #27762 domain:server#3 · os-elon-musk · session_016WV4cx2wNeyC4kD8R4P7Ns (card thread) Clause-②: no (narrowing) — the metadata save door refuses a `datasource` body whose `external.credentialsRef` is not the reference its own stored row already holds, where it stored the value as given before. Nothing is widened. Metadata-protocol: narrowed Skills-docs: none — `skills/**` and `content/docs/**` teach `external.credentialsRef` only on datasources declared in code (`defineDatasource`, `*.datasource.ts`), which the metadata save door does not serve (a code-defined name is refused there by the package door); none teaches naming a reference on a runtime metadata save, and the refusal itself names the door that binds one. ## 维护者速读 - 改了什么:通过元数据保存门(`PUT /api/v1/meta/datasource/:name`、运行时 `/meta` 门、MCP 与进程内保存,同一个保存入口)保存数据源时,`external.credentialsRef` 只能是这个数据源自己已存记录里的那个引用;新建时带引用、编辑时换成别的引用、或记录本没有引用却带了一个,一律 400 拒绝,且什么都不写,无论引用指向什么答复都相同,消息里不出现引用值。 - 为什么改:卡上要求按 #27582 的规则审第三个凭据引用写入口:「非系统写入只能封存新值,或保留本记录自己已存的引用」。实测这个门会原样保存请求带来的引用,不符合规则。 - 风险与回滚:拒绝发生在任何写入之前;读出再原样存回(GET → 编辑 → PUT)会带回自己的引用,照常通过;不带这个键的保存不受影响;系统改写(已存元数据迁移)照常放行;已存记录不会被重新判定。包复制与非托管导入也走这个保存入口,复制出的新数据源若带着源数据源的引用,该项会被拒(复制结果里列为失败项)。控制台的数据源页面走数据源管理入口,不受影响。回滚只需 revert 本 PR。 - 先看:`packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts`(规则本身),`packages/metadata-protocol/src/protocol.ts` 中 `saveMetaItem` 的那一处调用,两个新测试文件。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.datasource-credentials-ref-own-reference.test.ts`,预期 14/14 通过。 - 需要你做的:无,席位落地;本 PR 是收窄(narrowing),落地前需一次契约复审,由席位安排。 ## What changes The rule, as #27582 set it for `secret` fields (PR #27695) and #27722 applied it to the datasource admin door's create (PR #27761): **a non-system write may only seal a new value or keep its own record's stored reference.** The metadata save door seals nothing, so the one reference a save through it may name is the one its own stored row already holds. - **The judge.** `datasourceCredentialsRefSaveRefusal`, a new module beside `protocol.ts` (`protocol-datasource-credentials-ref.ts`). For a `datasource` body that names `external.credentialsRef`, it reads the row the save writes, the way the lock-family judge reads it: the repository's write address for the save's state, package and bundle member, and for a draft with no draft row yet, the active row it overlays. The save is accepted only when that row holds exactly the same reference. It is handed the repository's `headAt` read alone, so it can read and cannot write. - **The refusal.** `VALIDATION_ERROR` / 400, the standard-catalog pair this door's own name refusal and the #27582 refusal already use. It is thrown before anything is written. The message names the key and the datasource, never the value, and nothing about the reference is looked up, so the answer is the same whatever the reference names. It names the remedy: send back the reference a read serves, omit the key, or bind a secret through the datasource admin door. - **Where it is asked.** `saveMetaItem`, beside the other datasource-family save refusals: after the schema gate (the body is a valid datasource, so `credentialsRef` is a string) and before the authoring gates and every write. `protocol.ts` gains its import and one call site; nothing in it moves. - **Who passes.** The server-stated rewrites of a row the store already holds (`source` set: the stored-metadata migration) keep the pass-through, as the system principal does under the #27582 rule. Every other save is judged: the REST `PUT` doors (`meta-envelope`), the runtime dispatcher's `/meta` door (`meta-dispatch`), MCP and in-process authoring, and the package copy (`package-duplicate`) and unmanaged import (`package-unmanaged-import`), which write new rows that hold no reference of their own. - **Unchanged.** The round trip that sends back its own reference keeps it. A body naming no reference saves as before. Rows already stored are not re-judged. ## Why The card's measurement, on `origin/main` at 968cacf: a non-system save through this door stored a datasource body's `external.credentialsRef` as given — on a create, on an edit naming another datasource's reference, and on a draft. The pin below was committed first (d816620) and was red on that tree in all ten refusal cases. ## Tests - `packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts` (new, 14 cases): the real `saveMetaItem` over a stub engine. - Refused, with `code` and `status` asserted, the store byte-identical afterwards and no write issued: a create naming a reference another datasource holds, and one naming a reference no row holds (identical answers); an edit naming a reference other than its own (identical answers for a held and an unheld one); an edit naming one where its row holds none; a draft naming one the active row does not hold; the plural type spelling; and each face — `meta-envelope`, `meta-dispatch`, `package-duplicate`, `package-unmanaged-import`, an MCP save. No refusal message contains any reference. - Accepted: the GET → edit → PUT round trip keeps the row's own reference; a first draft sending back the active row's reference keeps it; a body with no reference; the `source: 'migrate-stored'` pass-through. - `packages/qa/dogfood/test/datasource-credentials-ref-own-reference.dogfood.test.ts` (new, 3 cases): the same rule at `PUT /api/v1/meta/datasource/:name` of a booted stack, as a signed-in non-system caller — a create refused with one answer and nothing stored, an edit refused with the stored reference kept, and the round trip accepted with the reference kept. - The pin's stub engine is held to the engine-double gates: its `find` applies the caller's `limit` after the filter, and `scripts/engine-double-contract.pinned.json` records its `findOne` and `update` seams (regenerated with `--write`; 2 rows added, none lost). Readings: the unit pin on `origin/main` 968cacf, before the fix, **10 failed | 4 passed (14)** (the four that passed are the accepted cases). With the fix: **14 passed (14)**; the dogfood pin **3 passed (3)**. ## Acceptance notes - **Keeping the stored reference** is implemented as: a save that sends back its own row's reference keeps it. A save that omits the key stores the body without one, exactly as before this change; carrying a reference forward on omission would change what an omitted key means, so it is not done here. - **The package copy and the unmanaged import are judged too.** Both write a new row, which holds no reference of its own, so a copied or imported datasource body that names a reference is refused. In a package copy that item lands in the copy response's `failed[]` with the refusal text and the rest of the package copies on; in an import the item is refused the same way. - **Not changed here:** rows already stored are not re-judged, and the restore doors (rollback, revert) restore an item's own stored history as before. Both are outside this card's door; a follow-up is the seat's call. - **`protocol.ts`** gains one import and one call-site block; nothing in it moves or reflows (serial queue #25467). - **Declarations:** `Metadata-protocol: narrowed` (the save door refuses a body it accepted), 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 7e9b721 (main merged in): **281 passed | 4 skipped (285) files, 21816 passed | 26 skipped tests**, exit 0. - `pnpm exec turbo run build --concurrency=2 --filter=!@objectstack/docs` at 7e9b721: **114/114** tasks, exit 0. - `node scripts/pm/dispatch-gates.mjs --ran`: **73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN**, exit 0. 63 families ran at 7e9b721; `check:engine-double-contract` and `check:objectql-double-limit` were red there on the new pin's stub engine, were fixed in 7256944 and re-ran green with the 8 families that commit's `scripts/` file adds. `pnpm lint` exit 0. - **Unit ablations** of the judge, each through `scripts/ablation-replace.mjs` (mutate, run the pin, restore; the restore proven blob-equal to HEAD): - every caller takes the pass-through → 10 failed | 4 passed; - any stored reference counts, not only the same one → 2 failed | 12 passed; - a first draft no longer reads the active row it overlays → 1 failed | 13 passed; - the refusal message carries the value → 10 failed | 4 passed; - the server-stated rewrite loses its pass-through → 1 failed | 13 passed. (The first attempt at this leg was refused by `ablation-replace` before any test ran — its replacement was a substring of its anchor, so the count could not rise — and applied no mutation; it was redone with a distinct anchor.) - **Dist ablation** at the REST door: the call site disabled in `protocol.ts`, `@objectstack/metadata-protocol` rebuilt, `scripts/ablation-dist-preflight.mjs` proved the marker in `dist/` (exit 0), the dogfood pin **3 failed (3)**; restored (`git diff` empty), rebuilt, preflight `--absent` exit 0, the pin **3 passed (3)**; `protocol.ts` blob equal to HEAD. - **Dogfood battery** (`pnpm --filter @objectstack/dogfood test`, the whole battery, at 7e9b721): **354 passed | 2 failed | 3 skipped (359) files, 2867 passed | 3 failed | 20 skipped (2890) tests**. The three failures are not this change's: `error-sentences-live.dogfood.test.ts` (2) and `object-hook-lifecycle-scratch.dogfood.test.ts` (1) assert that an undeclared hook abort on the `/data` door answers with no `code`, and #27667 (21c4bdd, on `main`) now answers `HOOK_ABORTED` there; neither test is changed on `origin/main` at c5f4a06. The commit after 7e9b721 touches only a unit-test double and a gate ledger, nothing the battery loads. ## 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_016WV4cx2wNeyC4kD8R4P7Ns)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 755e285 commit 642dd4f

6 files changed

Lines changed: 549 additions & 0 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 saved through the metadata door may name only the credentials reference its own stored row holds (#27762)
6+
7+
Clause-②: no (narrowing) — the metadata save door refuses a `datasource` body whose `external.credentialsRef` is not the reference its own stored row already holds, where it stored the value as given 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 save door refuses one value of it, a reference the save's own stored row does not hold. A stored row is never re-judged: it is read and served as before, and a save that sends its reference back keeps it. 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 metadata save door (`saveMetaItem`: `PUT /api/v1/meta/datasource/:name`, the runtime dispatcher's `/meta` door, MCP and in-process saves), the rule #27582 set for `secret` fields and #27722 applied to the datasource admin door's create: a non-system write may only seal a new value or keep its own record's stored reference.
13+
14+
- **Before.** A `datasource` body's `external.credentialsRef` was stored as given.
15+
- **Now.** A save may name only the reference its own stored row already holds. A create (no stored row), an edit naming another reference or naming one where its row holds none, and a draft naming one the active row it overlays does not hold, are refused with `VALIDATION_ERROR` / `400` before anything is written. The message names the key and never the value, and the answer is the same whatever the value names. The package copy and the unmanaged import, which save new rows through the same door, are judged the same way.
16+
- **Unchanged.** The GET → edit → PUT round trip, which sends back the reference the read serves, keeps it. A body that names no reference saves as before. The server-stated rewrites (`source` set, the stored-metadata migration) keep their pass-through. Rows already stored are not re-judged.
17+
- **Who sees it.** A caller of the metadata save door that writes a datasource's `external.credentialsRef` by hand. The Console's datasource page saves through the datasource admin door, which this does not change.
18+
19+
**The remedy.** Send back the reference a read of the datasource serves, or omit the key to save it with no credential bound. 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.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27762] The own-reference rule on the metadata save door, for a
5+
* `datasource` body's `external.credentialsRef`.
6+
*
7+
* The rule is the one #27582 set for the engine's secret fields and #27722
8+
* applied to the datasource admin door's create: **a non-system write may only
9+
* seal a new value or keep its own record's stored reference.** A credentials
10+
* reference is minted by the platform when a secret is bound (the datasource
11+
* admin door's `secret`), and the readers of a stored datasource act on
12+
* whatever reference its row holds. This door seals nothing, so the one
13+
* reference a save through it may name is the one its own stored row already
14+
* holds:
15+
*
16+
* - a create (no stored row) naming any reference is refused;
17+
* - an edit naming a reference other than its row's, or naming one where its
18+
* row holds none, is refused;
19+
* - an edit sending back its row's own reference — the GET → edit → PUT
20+
* round trip, since the read serves the reference — keeps it, and so does a
21+
* first draft sending back the reference of the active row it overlays;
22+
* - a body that names no reference is not judged here (what it stores is
23+
* unchanged by this rule).
24+
*
25+
* "Its own stored row" is the row the save writes, read the way
26+
* {@link authoredLockFamily} reads it for the lock family: the repository's
27+
* write address for the save's state, package and bundle member, and for a
28+
* draft with no draft row yet, the active row it overlays. Stored rows only:
29+
* a code-defined datasource is refused by the package door before this is
30+
* asked, and its declaration is no stored row.
31+
*
32+
* Refused `VALIDATION_ERROR` / 400 before anything is written, with ONE answer
33+
* whatever the reference names: the judge compares the value with the save's
34+
* own row and nothing else, never looks the reference up, and the message
35+
* never echoes it.
36+
*
37+
* Every save through `saveMetaItem` is judged — the REST `PUT` doors, the
38+
* runtime dispatcher's `/meta` door, MCP and in-process authoring, and the
39+
* package copy and unmanaged import, which write new rows that hold no
40+
* reference of their own. The server-stated rewrites of a row this store
41+
* already holds (`source` set: the stored-metadata migration) keep their
42+
* pass-through, as the system principal does under the #27582 rule.
43+
*/
44+
45+
import { itemDiscriminator } from '@objectstack/metadata-core';
46+
import type { SysMetadataRepository } from './sys-metadata-repository.js';
47+
48+
/** The `external.credentialsRef` a body names, or `undefined` when it names none. */
49+
function namedCredentialsRef(body: unknown): unknown {
50+
if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined;
51+
const external = (body as { external?: unknown }).external;
52+
if (!external || typeof external !== 'object' || Array.isArray(external)) return undefined;
53+
return (external as { credentialsRef?: unknown }).credentialsRef;
54+
}
55+
56+
/**
57+
* The refusal a `datasource` save owes when its body names an
58+
* `external.credentialsRef` its own stored row does not hold, else `undefined`.
59+
* See the module header for the rule, the row it reads and who passes.
60+
*/
61+
export async function datasourceCredentialsRefSaveRefusal(args: {
62+
/** The repository's write-address read — the one store access this judge makes; it writes nothing. */
63+
headAt: SysMetadataRepository['headAt'];
64+
/** Singular type name. */
65+
type: string;
66+
name: string;
67+
item: unknown;
68+
mode: 'draft' | 'publish';
69+
packageId: string | null | undefined;
70+
/** The server-stated `source` of a rewrite; `undefined` for every authoring save. */
71+
source: string | undefined;
72+
}): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> {
73+
if (args.type !== 'datasource' || args.source !== undefined) return undefined;
74+
const named = namedCredentialsRef(args.item);
75+
if (named === undefined) return undefined;
76+
const ref = { type: args.type, name: args.name, org: 'env' } as Parameters<SysMetadataRepository['headAt']>[0];
77+
const state: 'active' | 'draft' = args.mode === 'draft' ? 'draft' : 'active';
78+
const member = itemDiscriminator(args.type, args.item);
79+
const target = await args.headAt(ref, { state, packageId: args.packageId ?? null, member });
80+
let head = target.head;
81+
if (!head && state === 'draft') {
82+
head = (await args.headAt(ref, { state: 'active', packageId: target.packageId, member })).head;
83+
}
84+
const own = namedCredentialsRef(head?.body);
85+
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 };
93+
err.code = 'VALIDATION_ERROR';
94+
err.status = 400;
95+
return err;
96+
}

0 commit comments

Comments
 (0)