From d8166201f0405d4a500b4ae727117e914415c2ba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:29:42 +0000 Subject: [PATCH 1/5] test(metadata-protocol): pin the own-reference rule for a datasource's credentialsRef on the metadata save door A non-system save of a datasource may name, in external.credentialsRef, only the reference its own stored row already holds. Committed before the fix: on this tree the ten refusal cases are red and the four accepted cases green. Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns Co-authored-by: Claude --- ...urce-credentials-ref-own-reference.test.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts diff --git a/packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts b/packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts new file mode 100644 index 00000000000..e9b6713b84c --- /dev/null +++ b/packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27762] A `datasource` saved through the metadata door may name, in + * `external.credentialsRef`, only the reference its own stored row already + * holds. This is the own-reference rule #27582 set for the engine's 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. The metadata door seals nothing, so its one legal reference is + * the one it keeps. + * + * Pinned at `saveMetaItem`, the one save the REST `PUT /api/v1/meta/:type/:name` + * door and the runtime dispatcher's `/meta` door both call (`meta-envelope`, + * `meta-dispatch`), and the in-process doors that write through it: + * + * - refused, `VALIDATION_ERROR` / 400, before anything is written: a create + * naming any reference; an edit naming a reference other than the one its + * row holds, or naming one where its row holds none; a draft naming one the + * row it overlays does not hold. One answer whatever the reference names, + * and the reference is never echoed; + * - accepted: an edit or a draft that sends back its own stored reference + * (the GET → edit → PUT round trip), and a body that names none; + * - the server-stated rewrites (`source` set, the stored-metadata migration) + * keep their pass-through. + * + * Harness: the real write path over a stub engine, the shape of + * `protocol.metadata-redaction.test.ts`; every row is seeded directly. + */ +import { describe, expect, it } from 'vitest'; +// [#5619] The producer's own dispatch decisions, so the stub cannot accept a call ObjectQL refuses. +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch, hashSpec } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import type { MetadataWriteFace } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum?: string; + version?: number; +} + +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + +const matches = (r: Row, where: Record): boolean => + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as any)[k], v)); + +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +/** A stub engine over one `sys_metadata` map, logging every write it is asked for. */ +function makeStubEngine() { + const rows = new Map(); + const writes: string[] = []; + let nextId = 0; + const findRow = (w: Record) => { + for (const [k, r] of rows) if (matches(r, w)) return { key: k, row: r }; + return null; + }; + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + return findRow(opts.where)?.row ?? null; + }, + async find(_table: string, opts: { where: Record }) { + return Array.from(rows.values()).filter((r) => matches(r, opts.where)); + }, + async insert(table: string, data: Record) { + writes.push(`insert ${table}`); + if (table !== 'sys_metadata') return { id: 'side_table' }; + const row = { id: `r_${++nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(table: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + writes.push(`update ${table}`); + if (table !== 'sys_metadata') return { id: null }; + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as any) }; + rows.delete(found.key); + rows.set(keyOf(merged), merged); + return { id: found.row.id }; + }, + async transaction(cb: (ctx: any, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { /* no DDL in this stub */ }, + registry: { + listItems: () => [], + isPackageDisabled: () => false, + getItem: () => undefined, + registerItem: () => {}, + registerObject: () => {}, + getPackage: () => undefined, + }, + }; + return { engine, rows, writes }; +} + +/** A schema-valid datasource body, so every refusal below is this rule's and never a 422. */ +const datasourceBody = (name: string, label: string, credentialsRef?: string) => ({ + name, + label, + driver: 'postgres', + config: { host: 'db.internal', database: name, username: 'app' }, + ...(credentialsRef === undefined ? {} : { external: { credentialsRef } }), +}); + +/** The reference `ledger`'s own row holds. */ +const OWN_REF = 'sys_secret:ledger_own_27762'; +/** The reference another datasource's row (`vault`) holds. */ +const OTHER_REF = 'sys_secret:vault_own_27762'; +/** A reference no row holds. */ +const UNHELD_REF = 'sys_secret:held_by_no_row_27762'; + +function seed(rows: Map, name: string, credentialsRef?: string) { + const where = { type: 'datasource', name, organization_id: null, package_id: null, state: 'active' }; + const body = datasourceBody(name, name, credentialsRef); + rows.set(keyOf(where), { + id: `r_seed_${name}`, + ...where, + metadata: JSON.stringify(body), + // A real row carries its own checksum, which the save's parent read compares. + checksum: hashSpec(body), + version: 1, + }); +} + +/** `ledger` holds its own reference, `vault` another, `plain` none. */ +function session() { + const stub = makeStubEngine(); + seed(stub.rows, 'ledger', OWN_REF); + seed(stub.rows, 'vault', OTHER_REF); + seed(stub.rows, 'plain'); + return { ...stub, protocol: new ObjectStackProtocolImplementation(stub.engine) }; +} + +const rowOf = (rows: Map, name: string, state: 'active' | 'draft' = 'active') => + Array.from(rows.values()).find((r) => r.type === 'datasource' && r.name === name && r.state === state); + +const storedRefOf = (rows: Map, name: string, state: 'active' | 'draft' = 'active') => { + const row = rowOf(rows, name, state); + return row ? JSON.parse(row.metadata).external?.credentialsRef : undefined; +}; + +/** The store as a comparable value, so "nothing was written" is a reading, not a claim. */ +const snapshot = (rows: Map) => JSON.stringify(Array.from(rows.entries()).sort()); + +/** Runs a save expected to be refused; returns the refusal after asserting nothing was written. */ +async function refused( + s: ReturnType, + request: Parameters[0], +): Promise<{ code: unknown; status: unknown; message: string }> { + const before = snapshot(s.rows); + const writesBefore = s.writes.length; + let caught: any; + try { + await s.protocol.saveMetaItem(request); + } catch (err) { + caught = err; + } + expect(caught, 'the save was accepted').toBeDefined(); + expect(snapshot(s.rows)).toBe(before); + expect(s.writes.slice(writesBefore)).toEqual([]); + return { code: caught.code, status: caught.status, message: String(caught.message) }; +} + +const expectRuleRefusal = (r: { code: unknown; status: unknown; message: string }) => { + expect({ code: r.code, status: r.status }, r.message).toEqual({ code: 'VALIDATION_ERROR', status: 400 }); + for (const ref of [OWN_REF, OTHER_REF, UNHELD_REF]) expect(r.message).not.toContain(ref); +}; + +describe('[#27762] datasource `external.credentialsRef`: a non-system save may only keep its own stored reference', () => { + describe('refused before anything is written, with one answer whatever the reference names', () => { + it('a create naming a reference another datasource holds, and one naming a reference no row holds', async () => { + const s = session(); + const other = await refused(s, { type: 'datasource', name: 'fresh', item: datasourceBody('fresh', 'Fresh', OTHER_REF) }); + const unheld = await refused(s, { type: 'datasource', name: 'fresh', item: datasourceBody('fresh', 'Fresh', UNHELD_REF) }); + expectRuleRefusal(other); + expect(unheld).toEqual(other); + expect(rowOf(s.rows, 'fresh')).toBeUndefined(); + }); + + it('an edit naming a reference other than the one its own row holds', async () => { + const s = session(); + const other = await refused(s, { type: 'datasource', name: 'ledger', item: datasourceBody('ledger', 'Ledger', OTHER_REF) }); + const unheld = await refused(s, { type: 'datasource', name: 'ledger', item: datasourceBody('ledger', 'Ledger', UNHELD_REF) }); + expectRuleRefusal(other); + expect(unheld).toEqual(other); + expect(storedRefOf(s.rows, 'ledger')).toBe(OWN_REF); + }); + + it('an edit naming a reference where its own row holds none', async () => { + const s = session(); + expectRuleRefusal(await refused(s, { type: 'datasource', name: 'plain', item: datasourceBody('plain', 'Plain', OTHER_REF) })); + expect(storedRefOf(s.rows, 'plain')).toBeUndefined(); + }); + + it('a draft naming a reference the active row it overlays does not hold', async () => { + const s = session(); + expectRuleRefusal(await refused(s, { + type: 'datasource', name: 'ledger', mode: 'draft', item: datasourceBody('ledger', 'Ledger', OTHER_REF), + })); + expect(rowOf(s.rows, 'ledger', 'draft')).toBeUndefined(); + }); + + it('a reference with the plural type spelling is judged the same', async () => { + const s = session(); + expectRuleRefusal(await refused(s, { type: 'datasources', name: 'fresh', item: datasourceBody('fresh', 'Fresh', OTHER_REF) })); + }); + + const faces: Array<{ face: string; extra: { writeFace?: MetadataWriteFace; surface?: 'mcp' | 'studio' } }> = [ + { face: 'the REST `PUT` door (`meta-envelope`)', extra: { writeFace: 'meta-envelope' } }, + { face: 'the runtime dispatcher\'s `/meta` door (`meta-dispatch`)', extra: { writeFace: 'meta-dispatch' } }, + { face: 'the package copy (`package-duplicate`)', extra: { writeFace: 'package-duplicate' } }, + { face: 'the unmanaged import (`package-unmanaged-import`)', extra: { writeFace: 'package-unmanaged-import' } }, + { face: 'an MCP save', extra: { surface: 'mcp' } }, + ]; + for (const { face, extra } of faces) { + it(`on ${face}`, async () => { + const s = session(); + expectRuleRefusal(await refused(s, { + type: 'datasource', name: 'fresh', item: datasourceBody('fresh', 'Fresh', OTHER_REF), ...extra, + })); + }); + } + }); + + describe('accepted', () => { + it('an edit that sends back its own stored reference (the GET → edit → PUT round trip) keeps it', async () => { + const s = session(); + const served: any = (await s.protocol.getMetaItem({ type: 'datasource', name: 'ledger' })).item; + expect(served.external?.credentialsRef).toBe(OWN_REF); + await s.protocol.saveMetaItem({ type: 'datasource', name: 'ledger', item: { ...served, label: 'Ledger (edited)' } }); + expect(JSON.parse(rowOf(s.rows, 'ledger')!.metadata).label).toBe('Ledger (edited)'); + expect(storedRefOf(s.rows, 'ledger')).toBe(OWN_REF); + }); + + it('a first draft that sends back the reference the active row holds keeps it on the draft', async () => { + const s = session(); + await s.protocol.saveMetaItem({ + type: 'datasource', name: 'ledger', mode: 'draft', item: datasourceBody('ledger', 'Ledger (draft)', OWN_REF), + }); + expect(storedRefOf(s.rows, 'ledger', 'draft')).toBe(OWN_REF); + }); + + it('a body that names no reference saves as before', async () => { + const s = session(); + await s.protocol.saveMetaItem({ type: 'datasource', name: 'fresh', item: datasourceBody('fresh', 'Fresh') }); + expect(rowOf(s.rows, 'fresh')).toBeDefined(); + expect(storedRefOf(s.rows, 'fresh')).toBeUndefined(); + }); + + it('a server-stated rewrite (`source: \'migrate-stored\'`) keeps its pass-through', async () => { + const s = session(); + await s.protocol.saveMetaItem({ + type: 'datasource', name: 'plain', item: datasourceBody('plain', 'Plain', OTHER_REF), source: 'migrate-stored', + }); + expect(storedRefOf(s.rows, 'plain')).toBe(OTHER_REF); + }); + }); +}); From d1aee72c1a08ab3963c34373c38a41b74b7c180a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:31:26 +0000 Subject: [PATCH 2/5] fix(metadata-protocol)!: a datasource save may name only its own stored credentials reference A non-system save of a datasource through the metadata save door may name, in external.credentialsRef, only the reference its own stored row already holds (the own-reference rule). Anything else is refused with VALIDATION_ERROR / 400 before anything is written, with one answer whatever the reference names. Server-stated rewrites keep their pass-through. The judge is a helper beside protocol.ts; protocol.ts gains its import and one call site. Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns Co-authored-by: Claude --- .../protocol-datasource-credentials-ref.ts | 95 +++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 12 +++ 2 files changed, 107 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts diff --git a/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts b/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts new file mode 100644 index 00000000000..f2289741da5 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27762] The own-reference rule on the metadata save door, for a + * `datasource` body's `external.credentialsRef`. + * + * The rule is the one #27582 set for the engine's 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.** A credentials + * reference is minted by the platform when a secret is bound (the datasource + * admin door's `secret`), and the readers of a stored datasource act on + * whatever reference its row holds. This door seals nothing, so the one + * reference a save through it may name is the one its own stored row already + * holds: + * + * - a create (no stored row) naming any reference is refused; + * - an edit naming a reference other than its row's, or naming one where its + * row holds none, is refused; + * - an edit sending back its row's own reference — the GET → edit → PUT + * round trip, since the read serves the reference — keeps it, and so does a + * first draft sending back the reference of the active row it overlays; + * - a body that names no reference is not judged here (what it stores is + * unchanged by this rule). + * + * "Its own stored row" is the row the save writes, read the way + * {@link authoredLockFamily} reads it for the lock family: 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. Stored rows only: + * a code-defined datasource is refused by the package door before this is + * asked, and its declaration is no stored row. + * + * Refused `VALIDATION_ERROR` / 400 before anything is written, with ONE answer + * whatever the reference names: the judge compares the value with the save's + * own row and nothing else, never looks the reference up, and the message + * never echoes it. + * + * Every save through `saveMetaItem` is judged — the REST `PUT` doors, the + * runtime dispatcher's `/meta` door, MCP and in-process authoring, and the + * package copy and unmanaged import, which write new rows that hold no + * reference of their own. The server-stated rewrites of a row this store + * already holds (`source` set: the stored-metadata migration) keep their + * pass-through, as the system principal does under the #27582 rule. + */ + +import { itemDiscriminator } from '@objectstack/metadata-core'; +import type { SysMetadataRepository } from './sys-metadata-repository.js'; + +/** The `external.credentialsRef` a body names, or `undefined` when it names none. */ +function namedCredentialsRef(body: unknown): unknown { + if (!body || typeof body !== 'object' || Array.isArray(body)) return undefined; + const external = (body as { external?: unknown }).external; + if (!external || typeof external !== 'object' || Array.isArray(external)) return undefined; + return (external as { credentialsRef?: unknown }).credentialsRef; +} + +/** + * The refusal a `datasource` save owes when its body names an + * `external.credentialsRef` its own stored row does not hold, else `undefined`. + * See the module header for the rule, the row it reads and who passes. + */ +export async function datasourceCredentialsRefSaveRefusal(args: { + repo: SysMetadataRepository; + /** Singular type name. */ + type: string; + name: string; + item: unknown; + mode: 'draft' | 'publish'; + packageId: string | null | undefined; + /** The server-stated `source` of a rewrite; `undefined` for every authoring save. */ + source: string | undefined; +}): Promise<(Error & { code: 'VALIDATION_ERROR'; status: 400 }) | undefined> { + if (args.type !== 'datasource' || args.source !== undefined) return undefined; + const named = namedCredentialsRef(args.item); + if (named === undefined) return undefined; + const ref = { type: args.type, name: args.name, org: 'env' } as Parameters[0]; + const state: 'active' | 'draft' = args.mode === 'draft' ? 'draft' : 'active'; + const member = itemDiscriminator(args.type, args.item); + const target = await args.repo.headAt(ref, { state, packageId: args.packageId ?? null, member }); + let head = target.head; + if (!head && state === 'draft') { + head = (await args.repo.headAt(ref, { state: 'active', packageId: target.packageId, member })).head; + } + const own = namedCredentialsRef(head?.body); + if (typeof own === 'string' && own === named) return undefined; + const err = new Error( + `Datasource '${args.name}' was not saved: \`external.credentialsRef\` may only name the credentials ` + + `reference this datasource's own stored definition already holds, because a reference is minted by the ` + + `platform when a secret is bound. Send back the reference a read of this 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 instead (Setup → Datasources, \`POST /api/v1/datasources\` or \`PATCH /api/v1/datasources/${args.name}\`).`, + ) as Error & { code: 'VALIDATION_ERROR'; status: 400 }; + err.code = 'VALIDATION_ERROR'; + err.status = 400; + return err; +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3bb92c0e3cd..1e57b17789c 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -436,6 +436,8 @@ import { isWritablePackage, resolveAppPackageIdKey, shouldDropStorage, uninstall import { refuseUnmintableMetaType } from './protocol-unmintable-meta-type.js'; // [#25467 cut 33] The deployment's SDUI component manifest, and the load-time page `requires` report against it. import { reportPageRequiresAbsentAtLoad, resolveSduiManifest } from './protocol-sdui-manifest.js'; +// [#27762] The own-reference rule for a datasource body's `external.credentialsRef` on the save door. +import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-credentials-ref.js'; /** * Canonical Zod schema per metadata type lives in @@ -21282,6 +21284,16 @@ export class ObjectStackProtocolImplementation implements if (datasourceRefusal) throw datasourceRefusal; } + // [#27762] A `datasource` body may name, in `external.credentialsRef`, only the reference its own stored row + // holds: refused 400 before the authoring gate and every write. See {@link datasourceCredentialsRefSaveRefusal}. + { + const credentialsRefRefusal = await datasourceCredentialsRefSaveRefusal({ + repo: this.getOverlayRepo(), type: singularType, name: request.name, item: request.item, + mode, packageId: request.packageId, source: request.source, + }); + if (credentialsRefRefusal) throw credentialsRefRefusal; + } + // [ADR-0138 D2b] A flow published with `runAs: 'system'` while an // anonymous endpoint of `type: 'flow'` names it is refused, in publish // mode only (a draft does not execute; its publish is judged at the From 0195302f857f30fb87a3350244d7f56ab6f303be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:36:17 +0000 Subject: [PATCH 3/5] test(dogfood): pin the datasource credentialsRef own-reference rule at the REST metadata door; changeset Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns Co-authored-by: Claude --- ...62-datasource-meta-save-credentials-ref.md | 19 +++ ...dentials-ref-own-reference.dogfood.test.ts | 138 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 .changeset/27762-datasource-meta-save-credentials-ref.md create mode 100644 packages/qa/dogfood/test/datasource-credentials-ref-own-reference.dogfood.test.ts diff --git a/.changeset/27762-datasource-meta-save-credentials-ref.md b/.changeset/27762-datasource-meta-save-credentials-ref.md new file mode 100644 index 00000000000..03d3e942536 --- /dev/null +++ b/.changeset/27762-datasource-meta-save-credentials-ref.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a datasource saved through the metadata door may name only the credentials reference its own stored row holds (#27762) + +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 + + + +**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. + +- **Before.** A `datasource` body's `external.credentialsRef` was stored as given. +- **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. +- **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. +- **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. + +**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. diff --git a/packages/qa/dogfood/test/datasource-credentials-ref-own-reference.dogfood.test.ts b/packages/qa/dogfood/test/datasource-credentials-ref-own-reference.dogfood.test.ts new file mode 100644 index 00000000000..429a83011ad --- /dev/null +++ b/packages/qa/dogfood/test/datasource-credentials-ref-own-reference.dogfood.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27762] A datasource saved through the metadata door may name, in +// `external.credentialsRef`, only the reference its own stored row already +// holds — the own-reference rule #27582 set (a non-system write may only seal +// a new value or keep its own record's stored reference), pinned at the REST +// door `PUT /api/v1/meta/datasource/:name` of a booted stack, as a signed-in +// non-system caller. `packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts` +// drives every face of the one save this door calls; this file holds the rule +// through the door a client writes through: +// +// - a create naming a reference is refused (`VALIDATION_ERROR` / 400) before +// anything is written, with one answer whatever the reference names, and +// the reference is never echoed; +// - an edit naming a reference other than its own is refused, and the stored +// reference stays; +// - the GET → edit → PUT round trip keeps the datasource's own reference. +// +// The stored rows are seeded through the protocol's server-stated rewrite (the +// system pass-through the rule keeps): this harness composes no secret binder, +// so no door here mints a reference. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +const fixture = defineStack({ + manifest: { + id: 'com.dogfood.datasource-credentials-ref', namespace: 'dcr', version: '0.0.0', type: 'app', + name: 'Datasource credentials reference fixture', description: 'An app with one object, for its metadata door.', + }, + objects: [ + ObjectSchema.create({ + name: 'dcr_note', label: 'Note', pluralLabel: 'Notes', + fields: { name: Field.text({ label: 'Name', required: true }) }, + }), + ], +} as never); + +/** Holds `OWN_REF`. */ +const OWN = 'dcr_own_27762'; +/** Another datasource, holding `OTHER_REF`. */ +const OTHER = 'dcr_other_27762'; +/** No row until a save makes one. */ +const FRESH = 'dcr_fresh_27762'; +const OWN_REF = 'sys_secret:dcr_own_27762'; +const OTHER_REF = 'sys_secret:dcr_other_27762'; +const UNHELD_REF = 'sys_secret:dcr_held_by_no_row_27762'; + +const SYS = { isSystem: true }; + +/** A schema-valid datasource body, so every refusal below is the rule's and never a 422. */ +const body = (name: string, label: string, credentialsRef?: string) => ({ + name, + label, + driver: 'postgres', + config: { host: 'db.internal', database: name, username: 'app' }, + ...(credentialsRef === undefined ? {} : { external: { credentialsRef } }), +}); + +type Answer = { status: number; json: any; text: string }; + +describe('[#27762] datasource `external.credentialsRef`: a non-system save through the metadata door may only keep its own stored reference', () => { + let stack: VerifyStack; + let ql: any; + let token = ''; + + const call = async (method: string, path: string, payload?: unknown): Promise => { + const res = await stack.apiAs(token, method, path, payload); + const text = await res.text(); + let json: any = {}; + try { json = JSON.parse(text); } catch { /* not JSON */ } + return { status: res.status, json, text }; + }; + /** `{ status, code, message }` from the door's ADR-0112 envelope. */ + const refusalOf = (a: Answer) => { + const error = a.json?.error; + return typeof error === 'object' && error !== null + ? { status: a.status, code: error.code, message: String(error.message ?? '') } + : { status: a.status, code: a.json?.code, message: String(error ?? '') }; + }; + const storedRows = async (name: string): Promise>> => + ql.find('sys_metadata', { where: { type: 'datasource', name }, context: SYS }); + const storedRef = async (name: string) => { + const [row] = await storedRows(name); + const raw = row?.metadata; + return (typeof raw === 'string' ? JSON.parse(raw) : (raw as any))?.external?.credentialsRef; + }; + const expectRuleRefusal = (a: Answer) => { + expect({ status: a.status, code: refusalOf(a).code }, a.text).toEqual({ status: 400, code: 'VALIDATION_ERROR' }); + for (const ref of [OWN_REF, OTHER_REF, UNHELD_REF]) expect(a.text).not.toContain(ref); + }; + + beforeAll(async () => { + stack = await bootStack(fixture); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + const protocol: any = await stack.kernel.getServiceAsync('protocol'); + for (const [name, ref] of [[OWN, OWN_REF], [OTHER, OTHER_REF]] as const) { + await protocol.saveMetaItem({ type: 'datasource', name, item: body(name, name, ref), packageId: null, source: 'migrate-stored' }); + expect(await storedRef(name)).toBe(ref); + } + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('a create naming a reference is refused before anything is written, with one answer whatever it names', async () => { + const other = await call('PUT', `/meta/datasource/${FRESH}`, body(FRESH, 'Fresh', OTHER_REF)); + const unheld = await call('PUT', `/meta/datasource/${FRESH}`, body(FRESH, 'Fresh', UNHELD_REF)); + expectRuleRefusal(other); + expectRuleRefusal(unheld); + expect(refusalOf(unheld)).toEqual(refusalOf(other)); + expect(await storedRows(FRESH)).toEqual([]); + }); + + it('an edit naming a reference other than its own is refused, and the stored reference stays', async () => { + const refused = await call('PUT', `/meta/datasource/${OWN}`, body(OWN, 'Own (edited)', OTHER_REF)); + expectRuleRefusal(refused); + expect(await storedRef(OWN)).toBe(OWN_REF); + const [row] = await storedRows(OWN); + expect(JSON.parse(String(row.metadata)).label).toBe(OWN); + }); + + it('the GET → edit → PUT round trip keeps the datasource\'s own reference', async () => { + const read = await call('GET', `/meta/datasource/${OWN}`); + expect(read.status, read.text).toBe(200); + const served = Object.fromEntries(Object.entries(read.json.item ?? {}).filter(([k]) => !k.startsWith('_'))) as any; + expect(served.external?.credentialsRef).toBe(OWN_REF); + const saved = await call('PUT', `/meta/datasource/${OWN}`, { ...served, label: 'Own (round trip)' }); + expect(saved.status, saved.text).toBe(200); + expect(await storedRef(OWN)).toBe(OWN_REF); + const [row] = await storedRows(OWN); + expect(JSON.parse(String(row.metadata)).label).toBe('Own (round trip)'); + }); +}); From ff6720f22786fef2ee818ab64e1bdafcb6564f5d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:38:32 +0000 Subject: [PATCH 4/5] fix(metadata-protocol): hand the credentialsRef judge the write-address read only The save door binds its repository under the one spelling the stored-row write census reads, and the judge receives only `headAt`, so it can read the save's own row and write nothing. Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns Co-authored-by: Claude --- .../src/protocol-datasource-credentials-ref.ts | 7 ++++--- packages/metadata-protocol/src/protocol.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts b/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts index f2289741da5..50cff4f4131 100644 --- a/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts +++ b/packages/metadata-protocol/src/protocol-datasource-credentials-ref.ts @@ -59,7 +59,8 @@ function namedCredentialsRef(body: unknown): unknown { * See the module header for the rule, the row it reads and who passes. */ export async function datasourceCredentialsRefSaveRefusal(args: { - repo: SysMetadataRepository; + /** The repository's write-address read — the one store access this judge makes; it writes nothing. */ + headAt: SysMetadataRepository['headAt']; /** Singular type name. */ type: string; name: string; @@ -75,10 +76,10 @@ export async function datasourceCredentialsRefSaveRefusal(args: { const ref = { type: args.type, name: args.name, org: 'env' } as Parameters[0]; const state: 'active' | 'draft' = args.mode === 'draft' ? 'draft' : 'active'; const member = itemDiscriminator(args.type, args.item); - const target = await args.repo.headAt(ref, { state, packageId: args.packageId ?? null, member }); + const target = await args.headAt(ref, { state, packageId: args.packageId ?? null, member }); let head = target.head; if (!head && state === 'draft') { - head = (await args.repo.headAt(ref, { state: 'active', packageId: target.packageId, member })).head; + head = (await args.headAt(ref, { state: 'active', packageId: target.packageId, member })).head; } const own = namedCredentialsRef(head?.body); if (typeof own === 'string' && own === named) return undefined; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 1e57b17789c..d6701fd1b66 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -21287,8 +21287,9 @@ export class ObjectStackProtocolImplementation implements // [#27762] A `datasource` body may name, in `external.credentialsRef`, only the reference its own stored row // holds: refused 400 before the authoring gate and every write. See {@link datasourceCredentialsRefSaveRefusal}. { + const repo = this.getOverlayRepo(); const credentialsRefRefusal = await datasourceCredentialsRefSaveRefusal({ - repo: this.getOverlayRepo(), type: singularType, name: request.name, item: request.item, + headAt: (...a) => repo.headAt(...a), type: singularType, name: request.name, item: request.item, mode, packageId: request.packageId, source: request.source, }); if (credentialsRefRefusal) throw credentialsRefRefusal; From 4258efac241ae1e557a984b3487e81b026ac7425 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:32:05 +0000 Subject: [PATCH 5/5] test(metadata-protocol): the credentialsRef pin's find double holds the caller's bound; record its engine double in the ledger The stub engine's `find` applies `limit` after the filter, as the ObjectQL double-limit gate asks of a new double, and the engine-double ledger records the pin's `findOne` and `update` seams (regenerated with `check-engine-double-contract.mjs --write`). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4cx2wNeyC4kD8R4P7Ns --- ...ol.datasource-credentials-ref-own-reference.test.ts | 5 +++-- scripts/engine-double-contract.pinned.json | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts b/packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts index e9b6713b84c..da9b411de71 100644 --- a/packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts +++ b/packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts @@ -70,8 +70,9 @@ function makeStubEngine() { assertEngineFindOnePredicate(table, opts); return findRow(opts.where)?.row ?? null; }, - async find(_table: string, opts: { where: Record }) { - return Array.from(rows.values()).filter((r) => matches(r, opts.where)); + async find(_table: string, opts: { where: Record; limit?: number }) { + const hits = Array.from(rows.values()).filter((r) => matches(r, opts.where)); + return typeof opts?.limit === 'number' ? hits.slice(0, opts.limit) : hits; }, async insert(table: string, data: Record) { writes.push(`insert ${table}`); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 1df4ccdb79e..8aa9cbc1a0e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -606,6 +606,16 @@ "verb": "findOne", "pinned": 2 }, + { + "file": "packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.datasource-delete-prolongation.test.ts", "verb": "delete",