Skip to content

Commit 0195302

Browse files
committed
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 <noreply@anthropic.com>
1 parent d1aee72 commit 0195302

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

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: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#27762] A datasource saved through the metadata door may name, in
4+
// `external.credentialsRef`, only the reference its own stored row already
5+
// holds — the own-reference rule #27582 set (a non-system write may only seal
6+
// a new value or keep its own record's stored reference), pinned at the REST
7+
// door `PUT /api/v1/meta/datasource/:name` of a booted stack, as a signed-in
8+
// non-system caller. `packages/metadata-protocol/src/protocol.datasource-credentials-ref-own-reference.test.ts`
9+
// drives every face of the one save this door calls; this file holds the rule
10+
// through the door a client writes through:
11+
//
12+
// - a create naming a reference is refused (`VALIDATION_ERROR` / 400) before
13+
// anything is written, with one answer whatever the reference names, and
14+
// the reference is never echoed;
15+
// - an edit naming a reference other than its own is refused, and the stored
16+
// reference stays;
17+
// - the GET → edit → PUT round trip keeps the datasource's own reference.
18+
//
19+
// The stored rows are seeded through the protocol's server-stated rewrite (the
20+
// system pass-through the rule keeps): this harness composes no secret binder,
21+
// so no door here mints a reference.
22+
23+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
24+
import { bootStack, type VerifyStack } from '@objectstack/verify';
25+
import { defineStack } from '@objectstack/spec';
26+
import { ObjectSchema, Field } from '@objectstack/spec/data';
27+
28+
const fixture = defineStack({
29+
manifest: {
30+
id: 'com.dogfood.datasource-credentials-ref', namespace: 'dcr', version: '0.0.0', type: 'app',
31+
name: 'Datasource credentials reference fixture', description: 'An app with one object, for its metadata door.',
32+
},
33+
objects: [
34+
ObjectSchema.create({
35+
name: 'dcr_note', label: 'Note', pluralLabel: 'Notes',
36+
fields: { name: Field.text({ label: 'Name', required: true }) },
37+
}),
38+
],
39+
} as never);
40+
41+
/** Holds `OWN_REF`. */
42+
const OWN = 'dcr_own_27762';
43+
/** Another datasource, holding `OTHER_REF`. */
44+
const OTHER = 'dcr_other_27762';
45+
/** No row until a save makes one. */
46+
const FRESH = 'dcr_fresh_27762';
47+
const OWN_REF = 'sys_secret:dcr_own_27762';
48+
const OTHER_REF = 'sys_secret:dcr_other_27762';
49+
const UNHELD_REF = 'sys_secret:dcr_held_by_no_row_27762';
50+
51+
const SYS = { isSystem: true };
52+
53+
/** A schema-valid datasource body, so every refusal below is the rule's and never a 422. */
54+
const body = (name: string, label: string, credentialsRef?: string) => ({
55+
name,
56+
label,
57+
driver: 'postgres',
58+
config: { host: 'db.internal', database: name, username: 'app' },
59+
...(credentialsRef === undefined ? {} : { external: { credentialsRef } }),
60+
});
61+
62+
type Answer = { status: number; json: any; text: string };
63+
64+
describe('[#27762] datasource `external.credentialsRef`: a non-system save through the metadata door may only keep its own stored reference', () => {
65+
let stack: VerifyStack;
66+
let ql: any;
67+
let token = '';
68+
69+
const call = async (method: string, path: string, payload?: unknown): Promise<Answer> => {
70+
const res = await stack.apiAs(token, method, path, payload);
71+
const text = await res.text();
72+
let json: any = {};
73+
try { json = JSON.parse(text); } catch { /* not JSON */ }
74+
return { status: res.status, json, text };
75+
};
76+
/** `{ status, code, message }` from the door's ADR-0112 envelope. */
77+
const refusalOf = (a: Answer) => {
78+
const error = a.json?.error;
79+
return typeof error === 'object' && error !== null
80+
? { status: a.status, code: error.code, message: String(error.message ?? '') }
81+
: { status: a.status, code: a.json?.code, message: String(error ?? '') };
82+
};
83+
const storedRows = async (name: string): Promise<Array<Record<string, unknown>>> =>
84+
ql.find('sys_metadata', { where: { type: 'datasource', name }, context: SYS });
85+
const storedRef = async (name: string) => {
86+
const [row] = await storedRows(name);
87+
const raw = row?.metadata;
88+
return (typeof raw === 'string' ? JSON.parse(raw) : (raw as any))?.external?.credentialsRef;
89+
};
90+
const expectRuleRefusal = (a: Answer) => {
91+
expect({ status: a.status, code: refusalOf(a).code }, a.text).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
92+
for (const ref of [OWN_REF, OTHER_REF, UNHELD_REF]) expect(a.text).not.toContain(ref);
93+
};
94+
95+
beforeAll(async () => {
96+
stack = await bootStack(fixture);
97+
token = await stack.signIn();
98+
ql = await stack.kernel.getServiceAsync('objectql');
99+
const protocol: any = await stack.kernel.getServiceAsync('protocol');
100+
for (const [name, ref] of [[OWN, OWN_REF], [OTHER, OTHER_REF]] as const) {
101+
await protocol.saveMetaItem({ type: 'datasource', name, item: body(name, name, ref), packageId: null, source: 'migrate-stored' });
102+
expect(await storedRef(name)).toBe(ref);
103+
}
104+
}, 180_000);
105+
106+
afterAll(async () => {
107+
await stack?.stop();
108+
});
109+
110+
it('a create naming a reference is refused before anything is written, with one answer whatever it names', async () => {
111+
const other = await call('PUT', `/meta/datasource/${FRESH}`, body(FRESH, 'Fresh', OTHER_REF));
112+
const unheld = await call('PUT', `/meta/datasource/${FRESH}`, body(FRESH, 'Fresh', UNHELD_REF));
113+
expectRuleRefusal(other);
114+
expectRuleRefusal(unheld);
115+
expect(refusalOf(unheld)).toEqual(refusalOf(other));
116+
expect(await storedRows(FRESH)).toEqual([]);
117+
});
118+
119+
it('an edit naming a reference other than its own is refused, and the stored reference stays', async () => {
120+
const refused = await call('PUT', `/meta/datasource/${OWN}`, body(OWN, 'Own (edited)', OTHER_REF));
121+
expectRuleRefusal(refused);
122+
expect(await storedRef(OWN)).toBe(OWN_REF);
123+
const [row] = await storedRows(OWN);
124+
expect(JSON.parse(String(row.metadata)).label).toBe(OWN);
125+
});
126+
127+
it('the GET → edit → PUT round trip keeps the datasource\'s own reference', async () => {
128+
const read = await call('GET', `/meta/datasource/${OWN}`);
129+
expect(read.status, read.text).toBe(200);
130+
const served = Object.fromEntries(Object.entries(read.json.item ?? {}).filter(([k]) => !k.startsWith('_'))) as any;
131+
expect(served.external?.credentialsRef).toBe(OWN_REF);
132+
const saved = await call('PUT', `/meta/datasource/${OWN}`, { ...served, label: 'Own (round trip)' });
133+
expect(saved.status, saved.text).toBe(200);
134+
expect(await storedRef(OWN)).toBe(OWN_REF);
135+
const [row] = await storedRows(OWN);
136+
expect(JSON.parse(String(row.metadata)).label).toBe('Own (round trip)');
137+
});
138+
});

0 commit comments

Comments
 (0)