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
18 changes: 18 additions & 0 deletions .changeset/connector-read-withholds-undeclared-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

fix(spec): a connector read serves only the keys `retryConfig` and each `actions` element declare, and a save of the served form keeps the rest

Clause-②: no
Metadata-protocol: none

The built-in `connector` read-path redactor now serves, inside `retryConfig` and inside each element of `actions`, only the keys those shapes declare. Every metadata read exit that serves a stored connector applies it. Each withheld position is named in `redactedKeys` (an `actions` element by its index), and the stored row is never changed. A `retryConfig` that is not an object, and an `actions` that is not an array or holds an element that is not an object, are withheld whole (a cleared form excepted).

A save of the served form keeps every withheld nested value: the save door carries each one forward from the stored row. An `actions` element is followed by its `key`, so a reordered list keeps each element's value on that element; for every carried-forward position, an array element with no `id` is now identified by its `key`. A `retryConfig` or an `actions` element the author removes is removed.

`MetadataManager.getPublished` now hands the type's redactor the authored document alone and serves the metadata layer's own scalar bookkeeping beside the answer unchanged (`packageId`, `state`, and the publish keys). A publish snapshot nested under `publishedDefinition` (a package published more than once) is served through the same redaction at every depth; any other bookkeeping value that is not a scalar is not served.

What a save accepts is unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// The write-path inverse of the connector read projection, for the keys a
// connector does not declare: a save of the served form keeps what the read
// withheld (top level, `retryConfig`, and an `actions` element addressed by
// its `key`), and a container the author removed is never minted back.
//
// Every value below is an obviously-fake placeholder.

import { describe, it, expect } from 'vitest';
import { carryForwardRedactedValues, redactMetadataItem } from './metadata-redaction.js';

const P = (n: number) => `TEST_PLACEHOLDER_UNDECLARED_${n}`;

const stored = {
name: 'vendor_catalog',
label: 'Vendor catalog',
type: 'api',
apiKey: P(1),
retryConfig: { strategy: 'exponential_backoff', secret: P(2) },
actions: [
{ key: 'get_invoice', label: 'Get invoice', token: P(3) },
{ key: 'list_invoices', label: 'List invoices' },
],
};

/** What a read exit serves for `stored`: the projection, as a client would send it back. */
const served = () => JSON.parse(JSON.stringify(redactMetadataItem('connector', stored))) as Record<string, any>;

describe('carry-forward of the keys a connector does not declare', () => {
it('the served form withholds them (positive control for every case below)', () => {
const body = served();
expect(JSON.stringify(body)).not.toContain('TEST_PLACEHOLDER_UNDECLARED_');
expect(body.retryConfig).toEqual({ strategy: 'exponential_backoff' });
expect(body.actions[0]).toEqual({ key: 'get_invoice', label: 'Get invoice' });
});

it('a save of the served form keeps every withheld value, the edited label included', () => {
const out = carryForwardRedactedValues('connector', { ...served(), label: 'Vendor catalog (edited)' }, stored);
expect(out).toEqual({ ...stored, label: 'Vendor catalog (edited)' });
});

it('an `actions` element is followed by its `key` when the author reorders the list', () => {
const body = served();
body.actions.reverse();
const out = carryForwardRedactedValues('connector', body, stored) as Record<string, any>;
expect(out.actions).toEqual([
{ key: 'list_invoices', label: 'List invoices' },
{ key: 'get_invoice', label: 'Get invoice', token: P(3) },
]);
});

it('the author\'s own value at a withheld nested position wins', () => {
const body = served();
body.retryConfig.secret = 'author-value';
const out = carryForwardRedactedValues('connector', body, stored) as Record<string, any>;
expect(out.retryConfig.secret).toBe('author-value');
});

it('a removed `retryConfig` or `actions` element is not minted back', () => {
const body = served();
delete body.retryConfig;
body.actions = body.actions.filter((a: { key: string }) => a.key !== 'get_invoice');
const out = carryForwardRedactedValues('connector', body, stored) as Record<string, any>;
expect(out).not.toHaveProperty('retryConfig');
expect(out.actions).toEqual([{ key: 'list_invoices', label: 'List invoices' }]);
expect(out.apiKey).toBe(P(1));
});

it('two `actions` elements sharing a `key` cannot be told apart: neither is carried', () => {
const twin = { ...stored, actions: [stored.actions[0], { key: 'get_invoice', label: 'Twin' }] };
const body = JSON.parse(JSON.stringify(redactMetadataItem('connector', twin)));
const out = carryForwardRedactedValues('connector', body, twin) as Record<string, any>;
expect(JSON.stringify(out.actions)).not.toContain(P(3));
});

it('a malformed `retryConfig` or `actions` withheld whole is kept at rest by a save of the served form', () => {
const malformed = { ...stored, retryConfig: P(4), actions: [stored.actions[0], P(5)] };
const body = JSON.parse(JSON.stringify(redactMetadataItem('connector', malformed))) as Record<string, any>;
expect(body).not.toHaveProperty('retryConfig');
expect(body).not.toHaveProperty('actions');
expect(JSON.stringify(body)).not.toContain('TEST_PLACEHOLDER_UNDECLARED_');
const out = carryForwardRedactedValues('connector', { ...body, label: 'Vendor catalog (edited)' }, malformed);
expect(out).toEqual({ ...malformed, label: 'Vendor catalog (edited)' });
});
});
18 changes: 13 additions & 5 deletions packages/metadata-protocol/src/metadata-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,17 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}

/** An element's identity for an array hop: a non-empty string `id`, or none. */
/**
* An element's identity for an array hop: a non-empty string `id`; for an
* element with no `id`, a non-empty string `key` (a connector action's machine
* name); otherwise none.
*/
function identityOf(element: unknown): string | undefined {
if (!isPlainRecord(element)) return undefined;
const id = element.id;
return typeof id === 'string' && id !== '' ? id : undefined;
if (typeof id === 'string' && id !== '') return id;
const key = element.key;
return typeof key === 'string' && key !== '' ? key : undefined;
}

/**
Expand Down Expand Up @@ -190,7 +196,9 @@ function elementWithIdentity(
* into an identity, and every body — served and incoming — is then walked by
* that identity, never by the index. Two kinds of identity:
*
* - **the element's own `id`** (`{ elementId }`) — a flow node;
* - **the element's own `id`** (`{ elementId }`) — a flow node — or, for an
* element with no `id`, its own `key` — a connector action, whose `key` is
* its machine name;
* - **for an element with no `id`, the identified element below it on the
* same path** (`{ anchor }`, #20590). A `parallel` block's branch carries no
* `id`, and a credential inside one sits at
Expand All @@ -201,8 +209,8 @@ function elementWithIdentity(
* is that walk: the hops from inside the element down to, and including,
* the first identified element beneath it.
*
* An array hop that reaches neither — an element with no `id` and no
* identified element below it on the path, or an `id` shared with a sibling —
* An array hop that reaches neither — an element with no `id` or `key` and no
* identified element below it on the path, or an identity shared with a sibling —
* resolves to nothing, and the path is skipped.
*/
type PathHop =
Expand Down
56 changes: 52 additions & 4 deletions packages/metadata/src/metadata-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import type {
import { EndpointMatcher } from './endpoint-matcher.js';
// [#5309] The stored ENVELOPE / authored BODY split — peeled before any spec
// schema parses a stored row. See `stored-envelope.ts`.
import { peelStoredEnvelope } from './stored-envelope.js';
import { peelStoredEnvelope, STORED_ENVELOPE_KEYS } from './stored-envelope.js';
// [#13913] Registry-free expansion of an aggregated view container reached
// through THIS manager's own store — see `getViewsByObject`.
import { expandRuntimeViewContainer } from './view-container-expansion.js';
Expand Down Expand Up @@ -333,6 +333,38 @@ export interface MetadataManagerOptions extends MetadataManagerConfig {
driver?: IDataDriver;
}

/**
* {@link MetadataManager.redactServedBody}'s walk, for one document and the
* snapshots nested in its bookkeeping. Returns the input by reference when
* nothing at any depth is withheld.
*/
function redactServedDocument(
redactor: NonNullable<ReturnType<typeof getMetadataTypeRedactor>>,
record: Record<string, unknown>,
): Record<string, unknown> {
const bookkeeping: Record<string, unknown> = {};
const authored: Record<string, unknown> = {};
let bookkeepingChanged = false;
for (const [key, value] of Object.entries(record)) {
if (!(STORED_ENVELOPE_KEYS as readonly string[]).includes(key)) {
authored[key] = value;
} else if (value === null || typeof value !== 'object') {
bookkeeping[key] = value;
} else if (key === 'publishedDefinition' && !Array.isArray(value)) {
const served = redactServedDocument(redactor, value as Record<string, unknown>);
if (served !== value) bookkeepingChanged = true;
bookkeeping[key] = served;
} else {
// A non-scalar bookkeeping value that is not a snapshot: not served.
bookkeepingChanged = true;
}
}
const peeled = Object.keys(bookkeeping).length > 0 || bookkeepingChanged;
const result = redactor(peeled ? authored : record);
if (result.redactedKeys.length === 0 && !bookkeepingChanged) return record;
return peeled ? { ...result.item, ...bookkeeping } : result.item;
}

/**
* Main metadata manager class.
* Implements IMetadataService contract for unified metadata management.
Expand Down Expand Up @@ -2285,14 +2317,30 @@ export class MetadataManager implements IMetadataService {
return this.redactServedBody(type, body);
}

/** [#20552] The per-type read-path redaction, applied to one served body. */
/**
* [#20552] The per-type read-path redaction, applied to one served body.
*
* The body can carry this layer's own bookkeeping beside the authored
* document (`packageId`, `state`, and the publish keys: the fallback serves
* the stored row itself, and a publish snapshot copies it whole). Those keys
* are not the type's vocabulary ({@link STORED_ENVELOPE_KEYS}), and a
* redactor that withholds every key its type does not declare would withhold
* them too. So the redactor is handed the authored document alone, and the
* bookkeeping is served beside its answer.
*
* Only a SCALAR bookkeeping value is served unchanged. A publish snapshot
* copies the whole row, so a row published again carries the previous
* snapshot under `publishedDefinition`: that value is a document of the same
* type, and it is served through this same redaction, at every depth. Any
* other bookkeeping value that is not a scalar is not served: no envelope key
* but `publishedDefinition` holds a document, and nothing here can judge one.
*/
private redactServedBody(type: string, body: unknown): unknown {
if (!body || typeof body !== 'object' || Array.isArray(body)) return body;
// Keyed on the singular type name (Prime Directive #3); a plural spelling folds first.
const redactor = getMetadataTypeRedactor(canonicalMetadataServiceType(type));
if (!redactor) return body;
const result = redactor(body as Record<string, unknown>);
return result.redactedKeys.length > 0 ? result.item : body;
return redactServedDocument(redactor, body as Record<string, unknown>);
}

// ==========================================
Expand Down
83 changes: 83 additions & 0 deletions packages/metadata/src/published-body-bookkeeping-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `MetadataManager.getPublished` serves a body that can carry this layer's own
// bookkeeping (`packageId`, `state`, the publish keys) beside the authored
// document. The type's read-path redactor judges the authored document alone:
// a key the type does not declare is withheld, the bookkeeping is served
// unchanged. Measured with the built-in `connector` redactor, which withholds
// every key a connector does not declare.
//
// Every value below is an obviously-fake placeholder.

import { describe, it, expect, vi } from 'vitest';
import { MetadataManager } from './metadata-manager.js';
import { MemoryLoader } from './loaders/memory-loader.js';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';

vi.mock('@objectstack/core', async (orig) => ({
...((await orig()) as object),
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}));

const SECRET = 'TEST_PLACEHOLDER_PUBLISHED_UNDECLARED';
const authored = { label: 'Vendor', type: 'api', retryConfig: { maxAttempts: 2, secret: SECRET } };

function manager(): MetadataManager {
const m = new MetadataManager({ formats: ['json'], loaders: [new MemoryLoader()] });
m.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
return m;
}

describe('getPublished: the redactor judges the authored body, the bookkeeping is served as stored', () => {
it('a never-published row (the row itself is served): undeclared key withheld, packageId and state kept', async () => {
const m = manager();
await m.register('connector', 'vendor', { name: 'vendor', ...authored, apiKey: SECRET, packageId: 'com.acme.x', state: 'draft' });
const served = (await m.getPublished('connector', 'vendor')) as Record<string, unknown>;
expect(JSON.stringify(served)).not.toContain(SECRET);
expect(served).toEqual({ name: 'vendor', label: 'Vendor', type: 'api', retryConfig: { maxAttempts: 2 }, packageId: 'com.acme.x', state: 'draft' });
});

it('a published snapshot that copied the row whole: the same answer', async () => {
const m = manager();
await m.register('connector', 'vendor', { name: 'vendor', ...authored, packageId: 'com.acme.x', state: 'draft' });
await m.publishPackage('com.acme.x', { publishedBy: 'admin' });
const served = (await m.getPublished('connector', 'vendor')) as Record<string, unknown>;
expect(JSON.stringify(served)).not.toContain(SECRET);
expect(served.packageId).toBe('com.acme.x');
expect(served).toHaveProperty('state');
expect(served.retryConfig).toEqual({ maxAttempts: 2 });
});

it('a package published twice (the second snapshot nests the first): withheld at every depth, scalar bookkeeping kept', async () => {
const m = manager();
await m.register('connector', 'vendor', { name: 'vendor', ...authored, apiKey: SECRET, packageId: 'com.acme.x', state: 'draft' });
await m.publishPackage('com.acme.x', { publishedBy: 'admin' });
await m.publishPackage('com.acme.x', { publishedBy: 'admin' });
// The stored row does nest: the second snapshot holds the first under its own `publishedDefinition`.
const stored = (await m.get('connector', 'vendor')) as Record<string, any>;
expect(stored.publishedDefinition).toHaveProperty('publishedDefinition');
expect(JSON.stringify(stored)).toContain(SECRET);
const served = (await m.getPublished('connector', 'vendor')) as Record<string, any>;
expect(JSON.stringify(served)).not.toContain(SECRET);
expect(served.packageId).toBe('com.acme.x');
expect(served.state).toBe('active');
expect(served.retryConfig).toEqual({ maxAttempts: 2 });
expect(served.publishedDefinition).toMatchObject({ name: 'vendor', retryConfig: { maxAttempts: 2 }, packageId: 'com.acme.x' });
expect(served.publishedDefinition).not.toHaveProperty('apiKey');
});

it('a non-scalar bookkeeping value that is not a snapshot is not served', async () => {
const m = manager();
await m.register('connector', 'vendor', { name: 'vendor', label: 'Vendor', type: 'api', packageId: 'com.acme.x', package: { secret: SECRET } });
const served = (await m.getPublished('connector', 'vendor')) as Record<string, unknown>;
expect(JSON.stringify(served)).not.toContain(SECRET);
expect(served).toEqual({ name: 'vendor', label: 'Vendor', type: 'api', packageId: 'com.acme.x' });
});

it('control: a body with nothing to withhold is served by reference-equal content, bookkeeping included', async () => {
const m = manager();
const row = { name: 'clean', label: 'Clean', type: 'api', packageId: 'com.acme.x', state: 'draft' };
await m.register('connector', 'clean', row);
expect(await m.getPublished('connector', 'clean')).toEqual(row);
});
});
Loading
Loading