Skip to content

Commit ceef714

Browse files
fix(spec): a connector read serves only the declared nested keys, and a save of the served form keeps the rest (#28997)
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X1zG3Xn4JonaKvDLLr5gnjL)_ Refs #28284 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:读取 connector 时,`retryConfig` 里和 `actions` 每个元素里只返回各自 schema 声明过的键,其余整键不返回;形状不对的 `retryConfig` / `actions` 整体不返回;库里的行不变。原样保存时这些值从库里带回不丢;`actions` 元素按 `key` 对应,重排也不串位;作者删掉 `retryConfig` 或某个 action 时才删。`getPublished` 对作者文档脱敏,元数据层的标量记账键原样返回,多次发布留下的嵌套快照同样逐层脱敏。 - **为什么改**:QA 第三轮发现的安全加固(细节按披露裁决不公开) - **风险与回滚**:spec / metadata-protocol / metadata 三个 patch。保存接受什么不变。连带变化:carry-forward 中没有 `id` 的数组元素现在按 `key` 识别(目前只有 connector 的 action 有这种形状)。回滚即 revert 本 PR。 - **先看**:`packages/spec/src/integration/connector-credential-redaction.ts`(模块说明第 8 项与 `undeclaredNestedKeysOf`) → `packages/metadata-protocol/src/metadata-redaction.ts` 的 `identityOf` → `packages/metadata/src/metadata-manager.ts` 的 `redactServedBody`。 - **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run src/integration/connector-credential-redaction.test.ts`,预期 52 passed。 - **需要你做的**:无,席位做 contract review 后落地。 ## What changes - **Connector read, nested shapes.** Inside `retryConfig` and inside each `actions` element, every metadata read exit serves only the keys `RetryConfigSchema` and `ConnectorActionSchema` declare. The declared sets are read from the schemas' own shapes. Each withheld position is named in `redactedKeys` (`retryConfig.<key>`, `actions.<index>.<key>`). 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 and named by the container (a cleared form and an empty list are served as written; an element is never removed on its own). The stored row is never changed. - **Carry-forward follows an action by its `key`.** `identityOf` in the protocol's carry-forward returns `id` when present, and otherwise a non-empty string `key`. A reordered `actions` list keeps each element's withheld value on that element; twin keys carry neither, the existing ambiguity rule. A removed `retryConfig` or element is not minted back. - **`MetadataManager.getPublished`** hands the type's redactor the authored document alone, and serves the metadata layer's own scalar bookkeeping (`packageId`, `state`, the publish keys) beside the answer unchanged. 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. A body with nothing to withhold at any depth is served as before. Nothing an author can write changes; no public export changes. ## Why QA 第三轮发现的安全加固(细节按披露裁决不公开) ## Verification Every reading below is on head `8daa64f1` (two commits on `main` at `3ac22c9d`) unless it names `1fed7aac`, the first commit. <details><summary>Package suites and typecheck</summary> - `pnpm --filter @objectstack/spec test`: 23335 passed, 1 todo. `typecheck` exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: 22113 passed, 26 skipped. `typecheck` exit 0. - `pnpm --filter @objectstack/metadata test`: 929 passed. `typecheck` exit 0. - `@objectstack/dogfood` `typecheck` exit 0. The connector door file reads 9 passed (`--maxWorkers=1`, under the verify lock). - Whole dogfood battery (`pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=1`, under the verify lock): on `1fed7aac`, 384 files passed, 3 skipped, 3171 tests passed, exit 0. On `8daa64f1`: NOT MEASURED. The run was still in progress when work stopped; CI's Dogfood Regression Gate is green on this head. - `pnpm --filter @objectstack/spec check:generated`: exit 0 (on `1fed7aac`; the second commit moves no schema). - CI on this head: Test Core (8 shards), Type Check (debt ledger included), Lint, Check Changeset, Dogfood Regression Gate all green; no red. </details> <details><summary>New tests</summary> - `packages/spec/src/integration/connector-credential-redaction.test.ts`: nested keys withheld under `retryConfig` and per `actions` element; cleared values; the declared nested sets equal the schemas' shapes; a malformed `retryConfig` or `actions` withheld whole (four shapes) with the cleared-form control; input never mutated. The file reads 52 passed. - `packages/metadata-protocol/src/metadata-redaction.connector-undeclared-keys.test.ts` (7): the served form withholds the keys (control); a served-form save keeps every value; a reordered `actions` list follows each element by `key`; the author's own value wins; a removed `retryConfig` or element is not minted back; twin keys carry neither; a malformed container withheld whole is kept at rest by a save of the served form. - `packages/metadata/src/published-body-bookkeeping-redaction.test.ts` (5): a never-published row and a published snapshot keep their scalar bookkeeping beside the redacted answer; a package published twice is redacted at every depth; a non-scalar bookkeeping value that is not a snapshot is not served; a clean body is served as stored. - `packages/qa/dogfood/test/connector-meta-door-undeclared-keys-withheld.dogfood.test.ts` (9): the HTTP read doors (item, `/layers`, `/published`, both list spellings) for a member and an administrator, plus the published read of a package published twice; served-form saves keep the values at rest, including a reordered `actions` list; a removed `retryConfig` is cleared. CONTROL: a `credentialRef` instance saves, reaches ready and dispatches. </details> <details><summary>Ablation (rebuilt, proved on disk)</summary> Markers use the `String('…').length` form so the build cannot fold them. `scripts/ablation-dist-preflight.mjs` ran on each package. | Fix ablated | Head | Result while ablated | |:---|:---|:---| | all three (nested withholding, `key` identity, bookkeeping peel) | `1fed7aac` | spec 3 failed / 48 · metadata-protocol 2 failed / 6 · metadata 3 failed / 3 · dogfood 2 failed / 8 | | `key` identity alone | `1fed7aac` | metadata-protocol 2 failed / 6 (served-form save, reorder) · dogfood 1 failed / 8 (reorder) | | `getPublished` at every depth (`metadata-manager.ts` set back to its `1fed7aac` blob) | `8daa64f1` | metadata 2 failed / 5 (published twice; non-scalar bookkeeping) · dogfood 1 failed / 9 (published twice) | Restore leg each time: blob equals HEAD, `git diff HEAD` empty, rebuild exit 0, and the same files green again (the first two legs also read the marker absent from every built file: spec 588, metadata-protocol 24, metadata 42). </details> <details><summary>Derived gate families</summary> `node scripts/pm/dispatch-gates.mjs --commands` on `8daa64f1` derives 81 families. 80 ran locally, each with exit 0. `check:type-check-debt` is NOT MEASURED locally: it queued behind the dogfood hold on the verify lock and timed out (exit 99) before it got a turn. Its CI family, Type Check · debt ledger, is green on this head. The `--ran` reconciliation reads 81 derived, 80 run, 1 NOT-MEASURED. Repo-wide scans (`pnpm lint` and the rest of the lint workflow) are left to CI. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic beyond the existing read-path redactor module this extends. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is left to CI. - [x] 4. One branch off an up-to-date `main`, one pull request (no issue: `Refs #28284`). - [x] 5. Changeset present; both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: `check:generated` reads every generated artifact up to date (no schema changed). - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title and body sections other than the maintainer brief are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mgePB9NQ4PNhvYVxk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fea2dbb commit ceef714

8 files changed

Lines changed: 695 additions & 19 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/metadata': patch
5+
---
6+
7+
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
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
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).
13+
14+
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.
15+
16+
`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.
17+
18+
What a save accepts is unchanged.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The write-path inverse of the connector read projection, for the keys a
4+
// connector does not declare: a save of the served form keeps what the read
5+
// withheld (top level, `retryConfig`, and an `actions` element addressed by
6+
// its `key`), and a container the author removed is never minted back.
7+
//
8+
// Every value below is an obviously-fake placeholder.
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { carryForwardRedactedValues, redactMetadataItem } from './metadata-redaction.js';
12+
13+
const P = (n: number) => `TEST_PLACEHOLDER_UNDECLARED_${n}`;
14+
15+
const stored = {
16+
name: 'vendor_catalog',
17+
label: 'Vendor catalog',
18+
type: 'api',
19+
apiKey: P(1),
20+
retryConfig: { strategy: 'exponential_backoff', secret: P(2) },
21+
actions: [
22+
{ key: 'get_invoice', label: 'Get invoice', token: P(3) },
23+
{ key: 'list_invoices', label: 'List invoices' },
24+
],
25+
};
26+
27+
/** What a read exit serves for `stored`: the projection, as a client would send it back. */
28+
const served = () => JSON.parse(JSON.stringify(redactMetadataItem('connector', stored))) as Record<string, any>;
29+
30+
describe('carry-forward of the keys a connector does not declare', () => {
31+
it('the served form withholds them (positive control for every case below)', () => {
32+
const body = served();
33+
expect(JSON.stringify(body)).not.toContain('TEST_PLACEHOLDER_UNDECLARED_');
34+
expect(body.retryConfig).toEqual({ strategy: 'exponential_backoff' });
35+
expect(body.actions[0]).toEqual({ key: 'get_invoice', label: 'Get invoice' });
36+
});
37+
38+
it('a save of the served form keeps every withheld value, the edited label included', () => {
39+
const out = carryForwardRedactedValues('connector', { ...served(), label: 'Vendor catalog (edited)' }, stored);
40+
expect(out).toEqual({ ...stored, label: 'Vendor catalog (edited)' });
41+
});
42+
43+
it('an `actions` element is followed by its `key` when the author reorders the list', () => {
44+
const body = served();
45+
body.actions.reverse();
46+
const out = carryForwardRedactedValues('connector', body, stored) as Record<string, any>;
47+
expect(out.actions).toEqual([
48+
{ key: 'list_invoices', label: 'List invoices' },
49+
{ key: 'get_invoice', label: 'Get invoice', token: P(3) },
50+
]);
51+
});
52+
53+
it('the author\'s own value at a withheld nested position wins', () => {
54+
const body = served();
55+
body.retryConfig.secret = 'author-value';
56+
const out = carryForwardRedactedValues('connector', body, stored) as Record<string, any>;
57+
expect(out.retryConfig.secret).toBe('author-value');
58+
});
59+
60+
it('a removed `retryConfig` or `actions` element is not minted back', () => {
61+
const body = served();
62+
delete body.retryConfig;
63+
body.actions = body.actions.filter((a: { key: string }) => a.key !== 'get_invoice');
64+
const out = carryForwardRedactedValues('connector', body, stored) as Record<string, any>;
65+
expect(out).not.toHaveProperty('retryConfig');
66+
expect(out.actions).toEqual([{ key: 'list_invoices', label: 'List invoices' }]);
67+
expect(out.apiKey).toBe(P(1));
68+
});
69+
70+
it('two `actions` elements sharing a `key` cannot be told apart: neither is carried', () => {
71+
const twin = { ...stored, actions: [stored.actions[0], { key: 'get_invoice', label: 'Twin' }] };
72+
const body = JSON.parse(JSON.stringify(redactMetadataItem('connector', twin)));
73+
const out = carryForwardRedactedValues('connector', body, twin) as Record<string, any>;
74+
expect(JSON.stringify(out.actions)).not.toContain(P(3));
75+
});
76+
77+
it('a malformed `retryConfig` or `actions` withheld whole is kept at rest by a save of the served form', () => {
78+
const malformed = { ...stored, retryConfig: P(4), actions: [stored.actions[0], P(5)] };
79+
const body = JSON.parse(JSON.stringify(redactMetadataItem('connector', malformed))) as Record<string, any>;
80+
expect(body).not.toHaveProperty('retryConfig');
81+
expect(body).not.toHaveProperty('actions');
82+
expect(JSON.stringify(body)).not.toContain('TEST_PLACEHOLDER_UNDECLARED_');
83+
const out = carryForwardRedactedValues('connector', { ...body, label: 'Vendor catalog (edited)' }, malformed);
84+
expect(out).toEqual({ ...malformed, label: 'Vendor catalog (edited)' });
85+
});
86+
});

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,17 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
151151
return !!value && typeof value === 'object' && !Array.isArray(value);
152152
}
153153

154-
/** An element's identity for an array hop: a non-empty string `id`, or none. */
154+
/**
155+
* An element's identity for an array hop: a non-empty string `id`; for an
156+
* element with no `id`, a non-empty string `key` (a connector action's machine
157+
* name); otherwise none.
158+
*/
155159
function identityOf(element: unknown): string | undefined {
156160
if (!isPlainRecord(element)) return undefined;
157161
const id = element.id;
158-
return typeof id === 'string' && id !== '' ? id : undefined;
162+
if (typeof id === 'string' && id !== '') return id;
163+
const key = element.key;
164+
return typeof key === 'string' && key !== '' ? key : undefined;
159165
}
160166

161167
/**
@@ -190,7 +196,9 @@ function elementWithIdentity(
190196
* into an identity, and every body — served and incoming — is then walked by
191197
* that identity, never by the index. Two kinds of identity:
192198
*
193-
* - **the element's own `id`** (`{ elementId }`) — a flow node;
199+
* - **the element's own `id`** (`{ elementId }`) — a flow node — or, for an
200+
* element with no `id`, its own `key` — a connector action, whose `key` is
201+
* its machine name;
194202
* - **for an element with no `id`, the identified element below it on the
195203
* same path** (`{ anchor }`, #20590). A `parallel` block's branch carries no
196204
* `id`, and a credential inside one sits at
@@ -201,8 +209,8 @@ function elementWithIdentity(
201209
* is that walk: the hops from inside the element down to, and including,
202210
* the first identified element beneath it.
203211
*
204-
* An array hop that reaches neither — an element with no `id` and no
205-
* identified element below it on the path, or an `id` shared with a sibling —
212+
* An array hop that reaches neither — an element with no `id` or `key` and no
213+
* identified element below it on the path, or an identity shared with a sibling —
206214
* resolves to nothing, and the path is skipped.
207215
*/
208216
type PathHop =

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

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ import type {
8787
import { EndpointMatcher } from './endpoint-matcher.js';
8888
// [#5309] The stored ENVELOPE / authored BODY split — peeled before any spec
8989
// schema parses a stored row. See `stored-envelope.ts`.
90-
import { peelStoredEnvelope } from './stored-envelope.js';
90+
import { peelStoredEnvelope, STORED_ENVELOPE_KEYS } from './stored-envelope.js';
9191
// [#13913] Registry-free expansion of an aggregated view container reached
9292
// through THIS manager's own store — see `getViewsByObject`.
9393
import { expandRuntimeViewContainer } from './view-container-expansion.js';
@@ -333,6 +333,38 @@ export interface MetadataManagerOptions extends MetadataManagerConfig {
333333
driver?: IDataDriver;
334334
}
335335

336+
/**
337+
* {@link MetadataManager.redactServedBody}'s walk, for one document and the
338+
* snapshots nested in its bookkeeping. Returns the input by reference when
339+
* nothing at any depth is withheld.
340+
*/
341+
function redactServedDocument(
342+
redactor: NonNullable<ReturnType<typeof getMetadataTypeRedactor>>,
343+
record: Record<string, unknown>,
344+
): Record<string, unknown> {
345+
const bookkeeping: Record<string, unknown> = {};
346+
const authored: Record<string, unknown> = {};
347+
let bookkeepingChanged = false;
348+
for (const [key, value] of Object.entries(record)) {
349+
if (!(STORED_ENVELOPE_KEYS as readonly string[]).includes(key)) {
350+
authored[key] = value;
351+
} else if (value === null || typeof value !== 'object') {
352+
bookkeeping[key] = value;
353+
} else if (key === 'publishedDefinition' && !Array.isArray(value)) {
354+
const served = redactServedDocument(redactor, value as Record<string, unknown>);
355+
if (served !== value) bookkeepingChanged = true;
356+
bookkeeping[key] = served;
357+
} else {
358+
// A non-scalar bookkeeping value that is not a snapshot: not served.
359+
bookkeepingChanged = true;
360+
}
361+
}
362+
const peeled = Object.keys(bookkeeping).length > 0 || bookkeepingChanged;
363+
const result = redactor(peeled ? authored : record);
364+
if (result.redactedKeys.length === 0 && !bookkeepingChanged) return record;
365+
return peeled ? { ...result.item, ...bookkeeping } : result.item;
366+
}
367+
336368
/**
337369
* Main metadata manager class.
338370
* Implements IMetadataService contract for unified metadata management.
@@ -2285,14 +2317,30 @@ export class MetadataManager implements IMetadataService {
22852317
return this.redactServedBody(type, body);
22862318
}
22872319

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

22982346
// ==========================================
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `MetadataManager.getPublished` serves a body that can carry this layer's own
4+
// bookkeeping (`packageId`, `state`, the publish keys) beside the authored
5+
// document. The type's read-path redactor judges the authored document alone:
6+
// a key the type does not declare is withheld, the bookkeeping is served
7+
// unchanged. Measured with the built-in `connector` redactor, which withholds
8+
// every key a connector does not declare.
9+
//
10+
// Every value below is an obviously-fake placeholder.
11+
12+
import { describe, it, expect, vi } from 'vitest';
13+
import { MetadataManager } from './metadata-manager.js';
14+
import { MemoryLoader } from './loaders/memory-loader.js';
15+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
16+
17+
vi.mock('@objectstack/core', async (orig) => ({
18+
...((await orig()) as object),
19+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
20+
}));
21+
22+
const SECRET = 'TEST_PLACEHOLDER_PUBLISHED_UNDECLARED';
23+
const authored = { label: 'Vendor', type: 'api', retryConfig: { maxAttempts: 2, secret: SECRET } };
24+
25+
function manager(): MetadataManager {
26+
const m = new MetadataManager({ formats: ['json'], loaders: [new MemoryLoader()] });
27+
m.setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY);
28+
return m;
29+
}
30+
31+
describe('getPublished: the redactor judges the authored body, the bookkeeping is served as stored', () => {
32+
it('a never-published row (the row itself is served): undeclared key withheld, packageId and state kept', async () => {
33+
const m = manager();
34+
await m.register('connector', 'vendor', { name: 'vendor', ...authored, apiKey: SECRET, packageId: 'com.acme.x', state: 'draft' });
35+
const served = (await m.getPublished('connector', 'vendor')) as Record<string, unknown>;
36+
expect(JSON.stringify(served)).not.toContain(SECRET);
37+
expect(served).toEqual({ name: 'vendor', label: 'Vendor', type: 'api', retryConfig: { maxAttempts: 2 }, packageId: 'com.acme.x', state: 'draft' });
38+
});
39+
40+
it('a published snapshot that copied the row whole: the same answer', async () => {
41+
const m = manager();
42+
await m.register('connector', 'vendor', { name: 'vendor', ...authored, packageId: 'com.acme.x', state: 'draft' });
43+
await m.publishPackage('com.acme.x', { publishedBy: 'admin' });
44+
const served = (await m.getPublished('connector', 'vendor')) as Record<string, unknown>;
45+
expect(JSON.stringify(served)).not.toContain(SECRET);
46+
expect(served.packageId).toBe('com.acme.x');
47+
expect(served).toHaveProperty('state');
48+
expect(served.retryConfig).toEqual({ maxAttempts: 2 });
49+
});
50+
51+
it('a package published twice (the second snapshot nests the first): withheld at every depth, scalar bookkeeping kept', async () => {
52+
const m = manager();
53+
await m.register('connector', 'vendor', { name: 'vendor', ...authored, apiKey: SECRET, packageId: 'com.acme.x', state: 'draft' });
54+
await m.publishPackage('com.acme.x', { publishedBy: 'admin' });
55+
await m.publishPackage('com.acme.x', { publishedBy: 'admin' });
56+
// The stored row does nest: the second snapshot holds the first under its own `publishedDefinition`.
57+
const stored = (await m.get('connector', 'vendor')) as Record<string, any>;
58+
expect(stored.publishedDefinition).toHaveProperty('publishedDefinition');
59+
expect(JSON.stringify(stored)).toContain(SECRET);
60+
const served = (await m.getPublished('connector', 'vendor')) as Record<string, any>;
61+
expect(JSON.stringify(served)).not.toContain(SECRET);
62+
expect(served.packageId).toBe('com.acme.x');
63+
expect(served.state).toBe('active');
64+
expect(served.retryConfig).toEqual({ maxAttempts: 2 });
65+
expect(served.publishedDefinition).toMatchObject({ name: 'vendor', retryConfig: { maxAttempts: 2 }, packageId: 'com.acme.x' });
66+
expect(served.publishedDefinition).not.toHaveProperty('apiKey');
67+
});
68+
69+
it('a non-scalar bookkeeping value that is not a snapshot is not served', async () => {
70+
const m = manager();
71+
await m.register('connector', 'vendor', { name: 'vendor', label: 'Vendor', type: 'api', packageId: 'com.acme.x', package: { secret: SECRET } });
72+
const served = (await m.getPublished('connector', 'vendor')) as Record<string, unknown>;
73+
expect(JSON.stringify(served)).not.toContain(SECRET);
74+
expect(served).toEqual({ name: 'vendor', label: 'Vendor', type: 'api', packageId: 'com.acme.x' });
75+
});
76+
77+
it('control: a body with nothing to withhold is served by reference-equal content, bookkeeping included', async () => {
78+
const m = manager();
79+
const row = { name: 'clean', label: 'Clean', type: 'api', packageId: 'com.acme.x', state: 'draft' };
80+
await m.register('connector', 'clean', row);
81+
expect(await m.getPublished('connector', 'clean')).toEqual(row);
82+
});
83+
});

0 commit comments

Comments
 (0)