Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/connector-read-undeclared-root-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': patch
'@objectstack/metadata-protocol': patch
---

fix(spec): the connector read withholds root keys the entry shape does not declare

Clause-②: no
Metadata-protocol: none

Read-side hardening of the built-in `connector` redactor. A stored connector body is served without any top-level key the authored connector entry (`DeclarativeConnectorEntrySchema`) does not declare — including a cleared one (`null`, `''`, `{}`) and one whose own name carries a dot; the declared set is read from that schema's own shape, and the read decorations (`_diagnostics`, `_draft`) are left alone. Under `providerConfig`, an array, or a key whose own name carries a dot, that holds a URL with credential parts is withheld whole rather than served. Each withheld position is named in `redactedKeys`, and the metadata protocol's carry-forward keeps the stored value at rest when the served form is saved — resolving a dotted key by the whole-string fallback when the dotted split resolves nowhere. The runtime's execution read is unchanged, and nothing an author can write changes.
49 changes: 44 additions & 5 deletions packages/metadata-protocol/src/metadata-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,41 @@ export function redactedPathsCarriedForward(type: string, incoming: unknown, sto
* {@link redactedPathsCarriedForward}: the grafted body, and where each graft
* landed. `landed` is empty exactly when `out` is `incoming` by reference.
*/
/**
* A `redactedKeys` entry resolved against the STORED body into the container
* hops that reach the key and the key itself — which may carry dots.
*
* `redactedKeys` joins each path on dots, so a key whose own name carries a dot
* (a connector root key spelled `v1.url`, or one held under `providerConfig`)
* is indistinguishable from a nested path by the string alone. The two are told
* apart by what the stored body actually holds, trying the partitions in a
* fixed order: the standard split FIRST — every dot a separator, the only
* reading before dotted keys were withheld — then progressively longer trailing
* keys, ending with the whole string as a single key. The first partition whose
* stored container holds the key wins, so a nested position always shadows a
* dotted-key alias of the same spelling (the alias is then left to the author's
* word, never carried onto the wrong position). `undefined` when no partition
* reaches a present value — the caller reads that as "nothing at rest to carry".
*/
function resolveRedactedPath(
stored: unknown,
path: string,
): { hops: PathHop[]; key: string } | undefined {
const segments = path.split('.');
for (let cut = segments.length - 1; cut >= 0; cut -= 1) {
const key = segments.slice(cut).join('.');
// resolveHops reads the last entry as the key and the rest as container
// hops, so a placeholder last entry leaves exactly `cut` container hops.
const hops = resolveHops(stored, [...segments.slice(0, cut), key]);
if (!hops) continue;
const container = containerAt(stored, hops);
if (container && Object.prototype.hasOwnProperty.call(container, key) && container[key] !== undefined) {
return { hops, key };
}
}
return undefined;
}

function planCarryForward<T>(type: string, incoming: T, stored: unknown): { out: T; landed: string[] } {
const unchanged = { out: incoming, landed: [] as string[] };
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return unchanged;
Expand All @@ -535,11 +570,15 @@ function planCarryForward<T>(type: string, incoming: T, stored: unknown): { out:
// Dotted, item-relative — the registry's documented contract for
// `redactedKeys` (`config.password`; an array hop is an index into the
// stored body, `nodes.0.config.secret`, resolved to an identity by
// {@link resolveHops}).
const segments = path.split('.');
const key = segments[segments.length - 1] as string;
const hops = resolveHops(stored, segments);
if (!hops) continue;
// {@link resolveHops}). A key whose OWN name carries a dot (a connector
// root key spelled `v1.url`, or one under `providerConfig`) cannot be
// told from a nested path by the join alone, so the split is tried
// first — every dot a separator, the existing reading — and only when
// that resolves to nothing at rest is a longer trailing key tried,
// ending with the whole string as a single key. {@link resolveRedactedPath}.
const resolved = resolveRedactedPath(stored, path);
if (!resolved) continue;
const { hops, key } = resolved;

const storedParent = containerAt(stored, hops);
const storedValue = storedParent?.[key];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,75 @@ describe('#8671 — anti-vacuity: ablate the redactor', () => {
expect(label.to).toBe('B');
});
});

describe('the connector history diff withholds root keys the entry shape does not declare', () => {
const ROOT_V1 = 'TEST_PLACEHOLDER_ROOT_KEY_V1';
const ROOT_V2 = 'TEST_PLACEHOLDER_ROOT_KEY_V2';
const connector = (label: string, extra: Record<string, unknown>) => ({
name: 'vendor_api',
label,
type: 'api',
provider: 'rest',
providerConfig: { baseUrl: 'https://api.vendor.example/v1' },
auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' },
...extra,
});
const BUILTIN_CONNECTOR_REDACTOR = getMetadataTypeRedactor('connector')!;
afterEach(() => registerMetadataTypeRedactor('connector', BUILTIN_CONNECTOR_REDACTOR));

async function connectorDiff(from: number, to: number) {
const { engine, tables } = makeStubEngine();
const base = { organization_id: null, type: 'connector', name: 'vendor_api' };
const bodies = [
connector('Vendor', {}),
connector('Vendor', { apiKey: ROOT_V1 }),
connector('Vendor API', { apiKey: ROOT_V2 }),
];
bodies.forEach((body, i) => {
tables.sys_metadata_history!.push({
...base,
id: `h_${i + 1}`,
version: i + 1,
event_seq: i + 1,
operation_type: i === 0 ? 'create' : 'update',
metadata: JSON.stringify(body),
checksum: hashSpec(body),
recorded_at: new Date(i + 1).toISOString(),
});
});
const protocol = new ObjectStackProtocolImplementation(engine);
return await protocol.diffMetaItem({ type: 'connector', name: 'vendor_api', fromVersion: from, toVersion: to }) as any;
}

const holdsRootValue = (payload: unknown) =>
allStrings(payload).some((s) => s.includes(ROOT_V1) || s.includes(ROOT_V2));

it('withholds undeclared root keys on read: the history diff, all three buckets', async () => {
const changed = await connectorDiff(2, 3);
const rotated = entry(changed.changed, 'apiKey') as any;
// The change is still reported; neither value is served.
expect(rotated).toBeDefined();
expect(rotated.from).toBeUndefined();
expect(rotated.to).toBeUndefined();
expect((entry(changed.changed, 'label') as any).to).toBe('Vendor API');
expect(holdsRootValue(changed)).toBe(false);

const added = await connectorDiff(1, 2);
expect(entry(added.added, 'apiKey')).toBeDefined();
expect((entry(added.added, 'apiKey') as any).value).toBeUndefined();
expect(holdsRootValue(added)).toBe(false);

const removed = await connectorDiff(2, 1);
expect(entry(removed.removed, 'apiKey')).toBeDefined();
expect((entry(removed.removed, 'apiKey') as any).value).toBeUndefined();
expect(holdsRootValue(removed)).toBe(false);
});

it('ABLATION — with an identity `connector` redactor the diff serves the values again', async () => {
registerMetadataTypeRedactor('connector', (item) => ({ item, redactedKeys: [] }));
const res = await connectorDiff(2, 3);
const rotated = entry(res.changed, 'apiKey') as any;
expect(rotated.from).toBe(ROOT_V1);
expect(rotated.to).toBe(ROOT_V2);
});
});
Loading
Loading