Skip to content

Commit 4e63f11

Browse files
committed
fix(metadata-protocol): the credential carry-forward reads the row the draft save overwrites (#22128)
storedBodyForCarryForward read the stored body at the key the caller named. For a package-less draft save of a package-owned datasource that is neither the inherited draft nor the package-bound active row, so the comparison fell to the code layer and the stored credential was persisted away (measured on the real route). Both of its reads now go through SysMetadataRepository.headAt, which also answers the binding the write targets, so the active fallback reads the row the draft overlays. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07c2290 commit 4e63f11

4 files changed

Lines changed: 151 additions & 43 deletions

File tree

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

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8586,12 +8586,20 @@ export class ObjectStackProtocolImplementation implements
85868586
* back. Here the stakes are higher than a phantom customization — the
85878587
* subtracted material is a credential nobody can retype from the wire.
85888588
*
8589-
* Reads the row at rest through the overlay repository, whose `get()` is
8590-
* documented VERBATIM (no ADR-0087 conversion): the comparison must be
8589+
* Reads the row at rest through the overlay repository, whose rows are
8590+
* served VERBATIM (no ADR-0087 conversion): the comparison must be
85918591
* against the bytes that were written, because those are the bytes the read
85928592
* exit redacted. {@link carryForwardRedactedValues} then decides per
85938593
* redacted path — see its docblock for the three outcomes.
85948594
*
8595+
* [#22128] The row is the one THIS save overwrites, from the repository's
8596+
* write-address resolution ({@link SysMetadataRepository.headAt}) — ⛔ never
8597+
* a read at the key the caller named. A draft save that names no package is
8598+
* stored in the package of the item's active row (#11087); read at the
8599+
* package-unbound key, neither that draft nor that active row was found,
8600+
* the comparison fell to the code layer, and the first package-less draft
8601+
* save of a package-owned datasource persisted its stored credential away.
8602+
*
85958603
* ⚠️ THE DRAFT FALLBACK IS LOAD-BEARING, not defensive. A `?mode=draft`
85968604
* save of an item with no draft row yet has nothing at its own state to
85978605
* compare against, while the body the author edited came from the ACTIVE
@@ -8621,8 +8629,9 @@ export class ObjectStackProtocolImplementation implements
86218629
}
86228630

86238631
/**
8624-
* The body a carry-forward compares against: the overlay row at the write's
8625-
* own state, the ACTIVE row for a draft save with no draft row yet (see
8632+
* The body a carry-forward compares against: the row the write overwrites
8633+
* at its own state ({@link SysMetadataRepository.headAt}), the ACTIVE row
8634+
* the draft overlays for a draft save with no draft row yet (see
86268635
* {@link carryForwardRedactedCredentials} for why that fallback is
86278636
* load-bearing), else the CODE layer. RAW in every case — the bytes the
86288637
* read exits redacted — and read with no `try`/`catch`, for the reason the
@@ -8635,15 +8644,19 @@ export class ObjectStackProtocolImplementation implements
86358644
state: 'draft' | 'active';
86368645
packageId: string | null;
86378646
}): Promise<unknown> {
8638-
let stored = await args.repo.get(args.ref, {
8647+
// [#22128] The row this save overwrites, and with no draft row yet the
8648+
// active row the draft overlays: the one in the package the draft is
8649+
// stamped into (`packageId`, the inherited binding included).
8650+
const target = await args.repo.headAt(args.ref, {
86398651
state: args.state,
86408652
packageId: args.packageId,
86418653
});
8654+
let stored = target.head;
86428655
if (!stored && args.state === 'draft') {
8643-
stored = await args.repo.get(args.ref, {
8656+
stored = (await args.repo.headAt(args.ref, {
86448657
state: 'active',
8645-
packageId: args.packageId,
8646-
});
8658+
packageId: target.packageId,
8659+
})).head;
86478660
}
86488661
// [#20552] NO overlay row at either state: the body the read served is
86498662
// the CODE layer, so that is what the incoming body is compared with.
@@ -18065,7 +18078,7 @@ export class ObjectStackProtocolImplementation implements
1806518078
state: 'active' | 'draft',
1806618079
packageId: string | null,
1806718080
): Promise<string | null> {
18068-
return (await repo.headAt(ref, { state, packageId }))?.hash ?? null;
18081+
return (await repo.headAt(ref, { state, packageId })).head?.hash ?? null;
1806918082
}
1807018083

1807118084
/**

‎packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,17 @@ describe('SysMetadataRepository.headAt — the row put locks against (#22128)',
195195
const repo = makeRepo(engine);
196196
const first = await repo.put(REF, { label: 'draft 1' }, { parentVersion: null, actor: 't', state: 'draft' as const });
197197

198-
const head = await repo.headAt(REF, { state: 'draft', packageId: null });
198+
const { head, packageId } = await repo.headAt(REF, { state: 'draft', packageId: null });
199199
expect(head?.hash).toBe(first.version);
200+
// The binding the put writes under: the inherited package.
201+
expect(packageId).toBe('app.k9qk');
200202
// The key the caller named holds no draft: that read is not the parent.
201203
expect(await repo.get(REF, { state: 'draft', packageId: null })).toBeNull();
202204

203205
await expect(repo.put(REF, { label: 'from null' }, { parentVersion: null, actor: 't', state: 'draft' as const }))
204206
.rejects.toBeInstanceOf(ConflictError);
205207
const second = await repo.put(REF, { label: 'draft 2' }, { parentVersion: head!.hash, actor: 't', state: 'draft' as const });
206-
expect((await repo.headAt(REF, { state: 'draft' }))?.hash).toBe(second.version);
208+
expect((await repo.headAt(REF, { state: 'draft' })).head?.hash).toBe(second.version);
207209
expect(engine.rows.filter((r) => r.state === 'draft').map((r) => r.package_id)).toEqual(['app.k9qk']);
208210
});
209211

@@ -216,23 +218,26 @@ describe('SysMetadataRepository.headAt — the row put locks against (#22128)',
216218
},
217219
]);
218220
const repo = makeRepo(engine);
219-
expect((await repo.headAt(REF, { state: 'draft', packageId: null }))?.hash).toBe('sha-orphan');
221+
const adopted = await repo.headAt(REF, { state: 'draft', packageId: null });
222+
expect(adopted.head?.hash).toBe('sha-orphan');
223+
// The orphan is updated INTO the package: the put's binding is the inherited one.
224+
expect(adopted.packageId).toBe('app.k9qk');
220225
});
221226

222227
it('an explicit package is its own address: never another package\'s row', async () => {
223228
const engine = makeFakeEngine([seededActive('app.k9qk')]);
224229
const repo = makeRepo(engine);
225230
await repo.put(REF, { label: 'draft' }, { parentVersion: null, actor: 't', state: 'draft' as const });
226231

227-
expect(await repo.headAt(REF, { state: 'draft', packageId: 'app.other' })).toBeNull();
228-
expect((await repo.headAt(REF, { state: 'draft', packageId: 'app.k9qk' }))?.hash).toBeDefined();
232+
expect(await repo.headAt(REF, { state: 'draft', packageId: 'app.other' })).toEqual({ head: null, packageId: 'app.other' });
233+
expect((await repo.headAt(REF, { state: 'draft', packageId: 'app.k9qk' })).head?.hash).toBeDefined();
229234
});
230235

231236
it('an active address inherits nothing: the unbound key is the head, as put writes it', async () => {
232237
const engine = makeFakeEngine([seededActive('app.k9qk')]);
233238
const repo = makeRepo(engine);
234-
expect(await repo.headAt(REF, { state: 'active' })).toBeNull();
235-
expect((await repo.headAt(REF, { state: 'active', packageId: 'app.k9qk' }))?.hash).toBe('sha-active');
239+
expect(await repo.headAt(REF, { state: 'active' })).toEqual({ head: null, packageId: null });
240+
expect((await repo.headAt(REF, { state: 'active', packageId: 'app.k9qk' })).head?.hash).toBe('sha-active');
236241
});
237242

238243
it('an org-scoped draft address resolves across the ADR-0005 reach, as the inheriting put does', async () => {
@@ -243,6 +248,11 @@ describe('SysMetadataRepository.headAt — the row put locks against (#22128)',
243248
orgLabel: 'org_1',
244249
} as never);
245250
const first = await repo.put(REF, { label: 'org draft' }, { parentVersion: null, actor: 't', state: 'draft' as const });
246-
expect((await repo.headAt(REF, { state: 'draft' }))?.hash).toBe(first.version);
251+
expect((await repo.headAt(REF, { state: 'draft' })).head?.hash).toBe(first.version);
252+
});
253+
254+
it('a brand-new item drafted first: no head, and the package-less binding (nothing to inherit)', async () => {
255+
const repo = makeRepo(makeFakeEngine());
256+
expect(await repo.headAt(REF, { state: 'draft' })).toEqual({ head: null, packageId: null });
247257
});
248258
});

‎packages/metadata-protocol/src/sys-metadata-repository.ts‎

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -558,28 +558,30 @@ export class SysMetadataRepository implements MetadataRepository {
558558
* [#22128] The stored HEAD of one write address: the row a {@link put} with
559559
* the same `state` and `packageId` upserts — and so the row its optimistic
560560
* lock judges a parent against — served as {@link get} serves a row (its
561-
* version is the one {@link lockAccepts} accepts). `null` when that put
562-
* would create.
563-
*
564-
* Ask this, never {@link get}, for the parent of a write. `get` reads a row
565-
* at the key it is handed; a write's key is not always the key its caller
566-
* named. A `draft` put that names no package inherits the package of the
567-
* item's active row (#11087), so `get(ref, { state: 'draft', packageId:
568-
* null })` reads the package-UNBOUND row while the put upserts, and locks
569-
* against, the inherited one. A parent taken from that read is `null` for a
570-
* draft that exists, and an unpinned (ADR-0008 last-writer-wins) save was
571-
* refused 409 by its own lock.
572-
*
573-
* Both answers come from ONE resolution, {@link resolveWriteHead}.
561+
* version is the one {@link lockAccepts} accepts), or `null` when that put
562+
* would create; and the package binding that put writes under (`packageId`
563+
* here: the named package, the one a package-less draft inherits, or `null`
564+
* for the package-unbound row).
565+
*
566+
* Ask this, never {@link get}, for anything a write at an address compares
567+
* against — its parent, or the stored body it carries a withheld credential
568+
* forward from. `get` reads a row at the key it is handed; a write's key is
569+
* not always the key its caller named. A `draft` put that names no package
570+
* inherits the package of the item's active row (#11087), so
571+
* `get(ref, { state: 'draft', packageId: null })` reads the package-UNBOUND
572+
* row while the put upserts, and locks against, the inherited one. A parent
573+
* taken from that read is `null` for a draft that exists, and an unpinned
574+
* (ADR-0008 last-writer-wins) save was refused 409 by its own lock.
575+
*
576+
* Every answer comes from ONE resolution, {@link resolveWriteHead}.
574577
*/
575578
async headAt(
576579
ref: MetaRef,
577580
opts: { state?: OverlayState; packageId?: string | null },
578-
): Promise<MetadataItem | null> {
581+
): Promise<{ head: MetadataItem | null; packageId: string | null }> {
579582
this.assertOpen();
580-
const { row } = await this.resolveWriteHead(ref, opts.state ?? 'active', opts.packageId);
581-
if (!row) return null;
582-
return this.rowToItem(ref, row);
583+
const { row, targetPackageId } = await this.resolveWriteHead(ref, opts.state ?? 'active', opts.packageId);
584+
return { head: row ? this.rowToItem(ref, row) : null, packageId: targetPackageId };
583585
}
584586

585587
/**

‎packages/rest/src/meta-draft-head-package-inheritance.test.ts‎

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
*
1818
* `?package=all` is the second member of that class. The save door folds it
1919
* to the env-local overlay; the read door forwarded the literal `all`, so its
20-
* `version` was resolved at a package address no save writes.
20+
* `version` was resolved at a package address no save writes. The credential
21+
* carry-forward (#8154) is the third: it compared the served body against the
22+
* row at the named key, not the row the draft save overwrites.
2123
*
2224
* Driven at the HTTP door on the real stack: a better-sqlite3 `:memory:`
2325
* engine, the real `sys_metadata*` objects, a real
@@ -38,6 +40,7 @@ import { ObjectQL } from '@objectstack/objectql';
3840
import { SqlDriver } from '@objectstack/driver-sql';
3941
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
4042
import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata';
43+
import { hashSpec } from '@objectstack/metadata-core';
4144
import { RestServer } from './rest-server.js';
4245

4346
/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
@@ -92,8 +95,11 @@ function makeRes() {
9295
/**
9396
* Boot the real stack with the cache off: the default cached arm of the plain
9497
* read publishes no OCC carriers, so the active-row pins read the uncached arm.
98+
* `item` names the one item the helpers address; `permissions` the caller's
99+
* system capabilities (a `datasource` is read and written under
100+
* `manage_platform_settings`).
95101
*/
96-
async function boot() {
102+
async function boot(opts: { item?: { type: string; name: string }; permissions?: string[] } = {}) {
97103
const engine = new ObjectQL();
98104
liveEngines.push(engine);
99105
engine.registerDriver(new SqlDriver({
@@ -112,7 +118,8 @@ async function boot() {
112118
const config = { api: { requireAuth: false }, metadata: { enableCache: false } };
113119
const rest: any = new RestServer(createMockServer() as any, protocol as any, config as any);
114120
// An author: `manage_metadata` saves and reads drafts.
115-
rest.resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] });
121+
const systemPermissions = opts.permissions ?? ['manage_metadata'];
122+
rest.resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions });
116123
rest.registerRoutes();
117124

118125
const route = (method: string, path: string) => {
@@ -125,8 +132,8 @@ async function boot() {
125132
await route(method, routePath).handler({ method, headers: {}, query: {}, params: {}, ...req }, res);
126133
return res;
127134
};
128-
const item = { type: 'view', name: 'case_grid' };
129-
const path = `${META}/view/case_grid`;
135+
const item = opts.item ?? { type: 'view', name: 'case_grid' };
136+
const path = `${META}/${item.type}/${item.name}`;
130137

131138
/** `GET /meta/view/case_grid`. */
132139
const read = (query: Record<string, string> = {}) =>
@@ -136,15 +143,31 @@ async function boot() {
136143
call('PUT', `${META}/:type/:name`, { path, params: item, query, headers, body });
137144

138145
/** The stored rows of the item at one lifecycle, with their binding — the store, not the door's word for it. */
139-
const stored = async (state: 'active' | 'draft') => {
140-
const rows = await engine.find('sys_metadata', { where: { type: 'view', name: 'case_grid', state } });
146+
const storedBodies = async (state: 'active' | 'draft') => {
147+
const rows = await engine.find('sys_metadata', { where: { type: item.type, name: item.name, state } });
141148
return (rows ?? []).map((r: any) => ({
142-
label: JSON.parse(String(r.metadata)).label as string,
149+
body: JSON.parse(String(r.metadata)) as Record<string, any>,
143150
packageId: (r.package_id ?? null) as string | null,
144151
}));
145152
};
153+
const stored = async (state: 'active' | 'draft') =>
154+
(await storedBodies(state)).map(({ body, packageId }) => ({ label: body.label as string, packageId }));
155+
156+
/**
157+
* A row as it exists at rest from before the write gates — the only way a
158+
* stored credential is still at rest today, since the save door refuses
159+
* one — written straight to the store, stamped as `put` stamps a row.
160+
*/
161+
const seed = async (state: 'active' | 'draft', packageId: string | null, body: Record<string, unknown>) => {
162+
const now = new Date().toISOString();
163+
await engine.insert('sys_metadata', {
164+
id: `seed_${state}_${packageId ?? 'unbound'}`, type: item.type, name: item.name, organization_id: null,
165+
package_id: packageId, state, metadata: JSON.stringify(body), checksum: hashSpec(body, item.type),
166+
version: 1, created_at: now, updated_at: now,
167+
}, { context: { isSystem: true } } as any);
168+
};
146169

147-
return { read, save, stored };
170+
return { read, save, stored, storedBodies, seed };
148171
}
149172

150173
const ok = (res: any) => {
@@ -267,3 +290,63 @@ describe('[#22128] `?package=all`: the read resolves `all` the way the save does
267290
expect(await h.stored('draft')).toEqual([{ label: 'draft 2', packageId: PKG }]);
268291
}, 60_000);
269292
});
293+
294+
/**
295+
* [#22128] The credential carry-forward (#8154) compares the served body
296+
* against the row the save OVERWRITES. It read at the key the caller named, so
297+
* a package-less draft save of a package-owned item found neither the
298+
* inherited draft nor the package-bound active row, compared against the code
299+
* layer, and persisted the stored credential away. Measured on the real route
300+
* before the fix: the stored draft's `config.url` no longer held it.
301+
*/
302+
describe('[#22128] the credential carry-forward reads the row the draft save overwrites', () => {
303+
/** A fixture value standing in for the userinfo password a legacy row holds. */
304+
const URL_CREDENTIAL = 'fixture-not-a-credential';
305+
const DATASOURCE = { type: 'datasource', name: 'warehouse' };
306+
const legacyDatasource = (label: string) => ({
307+
name: 'warehouse',
308+
label,
309+
driver: 'postgres',
310+
config: {
311+
host: 'db.internal',
312+
port: 5432,
313+
database: 'warehouse',
314+
username: 'reporting',
315+
url: `postgresql://reporting:${URL_CREDENTIAL}@db.internal:5432/warehouse`,
316+
},
317+
});
318+
const holdsCredential = (body: Record<string, any>) => String(body?.config?.url ?? '').includes(URL_CREDENTIAL);
319+
const bootDatasource = () => boot({ item: DATASOURCE, permissions: ['manage_metadata', 'manage_platform_settings'] });
320+
321+
it('package-owned: the redacted read saved back as a package-less draft, twice, keeps the stored credential', async () => {
322+
const h = await bootDatasource();
323+
await h.seed('active', PKG, legacyDatasource('live'));
324+
325+
const served = ok(await h.read()).item;
326+
expect(holdsCredential(served)).toBe(false);
327+
ok(await h.save({ ...served, label: 'draft 1' }, { mode: 'draft' }));
328+
const first = await h.storedBodies('draft');
329+
expect(first.map((r) => r.packageId)).toEqual([PKG]);
330+
expect(first.map((r) => holdsCredential(r.body))).toEqual([true]);
331+
332+
const servedDraft = ok(await h.read({ state: 'draft' })).item;
333+
expect(holdsCredential(servedDraft)).toBe(false);
334+
ok(await h.save({ ...servedDraft, label: 'draft 2' }, { mode: 'draft' }));
335+
const second = await h.storedBodies('draft');
336+
expect(second.map((r) => [r.body.label, r.packageId, holdsCredential(r.body)])).toEqual([['draft 2', PKG, true]]);
337+
// The active row is untouched.
338+
expect((await h.storedBodies('active')).map((r) => holdsCredential(r.body))).toEqual([true]);
339+
}, 60_000);
340+
341+
it('control: env-local, the same round trip keeps the stored credential, as before', async () => {
342+
const h = await bootDatasource();
343+
await h.seed('active', null, legacyDatasource('live'));
344+
345+
const served = ok(await h.read()).item;
346+
ok(await h.save({ ...served, label: 'draft 1' }, { mode: 'draft' }));
347+
const servedDraft = ok(await h.read({ state: 'draft' })).item;
348+
ok(await h.save({ ...servedDraft, label: 'draft 2' }, { mode: 'draft' }));
349+
const drafts = await h.storedBodies('draft');
350+
expect(drafts.map((r) => [r.body.label, r.packageId, holdsCredential(r.body)])).toEqual([['draft 2', null, true]]);
351+
}, 60_000);
352+
});

0 commit comments

Comments
 (0)