Skip to content

Commit c96beb2

Browse files
fix(service-automation,metadata-protocol,metadata,runtime): withhold a flow's inbound-hook secret from every served definition, and keep it on a round trip (#20552) (#20585)
Fixes #20552 Clause-②: yes (widening) ## What this changes An `api` flow's start node carries its inbound hook's HMAC secret (`config.secret`, ADR-0041), the only credential that hook has. Every read that served the flow's definition served the secret with it, to any authenticated caller. This PR withholds it from every **served** flow definition, with one helper applied where each surface's definition leaves the process, and keeps it everywhere the engine **executes**. - **One helper.** `redactFlowCredentials` (`packages/services/service-automation/src/flow-credential-projection.ts`) removes `config.secret` from every `start` node and nothing else. The automation plugin registers it at `init` as the `flow` entry of the existing per-type read-path redactor registry (`@objectstack/spec/kernel`). That is the seam the datasource credential fix (#13405's class) already uses, so no second redaction dialect exists. The key is **dropped, not masked**. A mask is a non-blank string that `validateApiTriggerSecret` would accept, so a write path that missed the carry-forward would silently store the mask as the HMAC secret. An absent key is refused loudly by every registration door. - **Metadata plane.** Every protocol read exit already applies the registry: item, list, layered, draft preview, diff, audit and package export. The one exit that did not was `MetadataManager.getPublished`, the body both `/published` doors serve when no runtime overlay exists. It now applies the type's registered redactor too, which also covers the built-in datasource redactor. - **Automation domain.** The four exits that answer with a flow definition go through one function, `servedFlowDefinition`: the definition read, the `POST /` and `PUT /:name` write answers (#12206: a write answers what the read serves), and the clone answer. That function applies the same registry entry through `redactMetadataItem('flow', …)`, so the two planes cannot disagree about what is withheld. `automationService.getFlow` itself stays the raw in-process read. The clone door copies a **whole** definition through it (ADR-0126 §7.1), secret included, and redaction is a serving act. - **The round trip.** `carryForwardRedactedValues` is the one inverse on both planes: the metadata save door, and now the automation `PUT /:name` and `POST /` onto an existing name. A body that carries the projected form keeps the stored secret, and an explicit value replaces it. The inverse now follows a path through an **array** (a flow's `nodes`) by the stored element's `id`, not by its index. An edit that reorders `nodes` therefore still carries the secret back onto the start node, not onto whichever node now sits at the old position. An element with no `id`, or one whose `id` is shared with a sibling, is never carried into. - **The engine binds from the execution face.** This is the half the dispatch's route did not foresee (A3 below). The automation plugin (re)binds flows from the protocol at `kernel:ready` and on every `metadata:reloaded`, which covers every Studio publish. It read the **served** `getMetaItems`, which no longer holds the secret. `ObjectStackProtocolImplementation` therefore gains `getMetaItemsForExecution`: the same body, sources and merge as `getMetaItems`, returned without the serving decorations (no `_diagnostics`, no redaction). The plugin reads flows (and connectors) through it. Its docblock forbids any door that answers a caller from using it. ## The dispatch's mechanism assumptions, measured - **A1 — held.** The secret is a literal in the start node's `config`, and nothing projected it anywhere. - **A2 — census, from a composed showcase boot at `1c761c0d`, read as a member-level user.** Surfaces that served the secret: the automation domain's definition read; the metadata-plane item, list, layered, draft-preview and published reads; and, for an administrator, the package export. After the change, at `25362c12`'s code, no response from any of them contains it, for the member or the administrator. The automation write and clone answers, and the metadata diff and audit reads, carry none of it either. - **A2, cross-organization — measured under an `isolated` posture.** A member of another organization reads the same definitions. Flows are environment-wide metadata (`allowOrgOverride: false`, ADR-0005), and an organization administrator without `manage_metadata` is refused authoring (measured 403 on both write doors). So the definition read across organizations is the environment-wide design, not a tenant leak by itself. The credential it carried was the leak, and this projection closes it for that reader too. - **A3 — FALSIFIED.** Projecting at the metadata-plane read source DOES break verification, because the engine registers flows from that same served list. Ablation A3 below measures it: with the plugin reading the served face, an inbound flow is never armed, and a republish or rotation never reaches the hook. `getMetaItemsForExecution` is the fix. - **A4 — reused.** The precedent's registry (`registerMetadataTypeRedactor`), its generic inverse (`carryForwardRedactedValues`) and its drop-not-mask posture are all reused. The one extension is the array hop above. - **A5 — NOT MEASURED.** The Studio designer's round trip was not measured, because objectui is not reachable from this session. Believed path: Studio's `nav_flows` entry is `componentRef: 'metadata:resource'` with `type: 'flow'` (`platform-objects/src/apps/studio.app.ts`), that is, the metadata plane's item read and draft save + publish. That path is covered by the metadata-plane carry-forward and the execution-face bind, and both were measured live through the same API the component calls. ## Live measurement (local, composed showcase, after the change) As an administrator, a definition read through each plane was edited and saved back in its projected form, then republished. The hook kept verifying with the original secret, and a wrong secret was refused. On the metadata plane the edit reordered `nodes`, and the stored row kept the secret on the start node. An explicit rotation made the old secret fail and the new one verify. No served read carried either value at any point. The boot binds the same 20 of 30 flows as the baseline boot. ## Tests Pins, one file per package (all new behaviour, all green at `25362c12`): - `service-automation/src/flow-credential-projection.test.ts` covers four things: what the helper withholds; that the plugin registers it; that an inbound hook is armed with the stored secret while the served face withholds it; and that a republish keeps it while a rotation replaces it. The binding `config` asserted is the object `trigger-api`'s `start()` reads the HMAC secret from. - `metadata-protocol/src/protocol.metadata-redaction.test.ts` covers the array-hop carry-forward (reorder, rotation, removed container, missing or duplicate id), every read exit against the execution face, and the save round trip. - `runtime/src/domains/automation-flow-credential-projection.test.ts` covers the four automation exits for a member-level caller, and the `PUT`/`POST` round trip with a rotation. - `metadata/src/metadata-service.test.ts` covers `getPublished`. Package suites: `metadata` 828 passed; `metadata-protocol` 2765 passed, 19 skipped; `service-automation` 1851 passed; `runtime` (unit project) 4190 passed, 1 skipped. `typecheck` is green on all four. The 65 gate commands `dispatch-gates.mjs --commands` derives from this diff all exit 0 at `25362c12`. `eslint --no-inline-config` over the 16 changed source files reports 0 findings. The config lints every `*.ts` outside the never-linted build dirs and enables no type-aware rule (`eslint.config.mjs`, "never enables type-aware linting"), so the diff cannot move an untouched file's verdict. ### Ablations: each forbidden behaviour put back, committed tree, restore proven Every mutation went through `scripts/ablation-replace.mjs`. The anchor hit once, the blob changed, and the restore was proven as "blob == HEAD and `git diff HEAD` empty". Each subject is loaded from the package's own `src`, so no `dist` was involved. | # | Put back | Pins that went red | |---|---|---| | A1 | `servedFlowDefinition` returns the flow unredacted | 4 of 7: the definition read, create answer, clone answer and `PUT` answer. `expected '{"success":true,"data":{"name":"inbou…' not to contain 'stored-hook-secret-20552'` | | A2 | `redactFlowCredentials` withholds nothing | 3 of 6: `expected [] to deeply equal [ 'nodes.1.config.secret' ]`; the served-face control `expected '{"items":[{"name":"inbound_hook","lab…' not to contain 'stored-hook-secret-20552'` | | A3 | the plugin binds from the served `getMetaItems` | 2 of 6: the arm and republish pins, `expected undefined to be 'stored-hook-secret-20552'` | | A4 | the protocol's served list skips the redaction | 2 of 23: the flow read exits `expected [ 'flow', 'inbound_hook', …(14) ] to not include 'stored-hook-secret-20552'`, and the datasource list pin `expected 'hunter2' to be undefined` | | A5 | `getPublished` returns the body unredacted | 1 of 72: `expected '{"name":"inbound_hook","label":"Inbou…' not to contain 'stored-hook-secret-20552'` | | A6 | an array hop resolves nothing (the pre-change walk) | 2 of 23: the reorder carry-forward and the save round trip, `expected undefined to be 'stored-hook-secret-20552'` | ## Deviations - **A new public method on a published package's exported class.** `ObjectStackProtocolImplementation.getMetaItemsForExecution` (`@objectstack/metadata-protocol`) is reachable from the package entry. The `IAutomationService` and `ObjectStackProtocol` contracts in `packages/spec` are untouched, and no key is added to any wire payload. `Clause-②: no` is copied from the claim as dispatched. The seat may correct it to `yes (widening)`, in which case `@objectstack/metadata-protocol` moves to `minor` in the changeset. - **`packages/metadata` is touched** (`getPublished`). The claim's file surface names "`packages/metadata*` or `packages/rest`" for the metadata-plane read, so this is inside it. - **The automation domain projects at its exits, not inside the engine's `getFlow`.** The reasons are the clone and in-process readers given above. The dispatch's route suggested projecting where the definition leaves the engine. This PR projects where it leaves the process, through one function and the one registry entry. ## Acceptance notes - **Consequence stated in the changeset.** A package export no longer carries an inbound flow's secret. Re-importing it elsewhere registers its `api` flows only once a secret is set again, and until then they are refused at registration, loudly. - **A clone still shares its source's secret** (the whole-definition copy is unchanged). Whether a per-flow secret should survive a clone belongs with the durable write-only secret seam that triage routed to the maintainer after this lands. - **A rotation is invisible in the metadata diff**, because both sides are redacted. This is the datasource precedent's posture. - **An author who deletes `secret` from a projected body gets the stored one back.** The wire cannot tell that from a round trip. This is the ambiguity the datasource inverse documents, and rotating the secret or deleting the flow is the unambiguous door. - Not changed, noted only: the runtime twin of the metadata list read falls back to `metadataService.list()` (raw) when the protocol read throws. This is source-read and unreached on a composed boot, and it applies to datasources as much as flows. ## Seat append (domain:services seat, `session_01XY5uCwTjZj7884yYtyur4H`) - The `Clause-②` line above was corrected from `no` to `yes (widening)` by the seat, not by the dev. `getMetaItemsForExecution` is a new public method on `ObjectStackProtocolImplementation`, which `@objectstack/metadata-protocol` exports from its entry. The claim was corrected in place in the same act. The changeset follows in patch round 1: `@objectstack/metadata-protocol` moves to `minor`, and the changeset carries the same line. ## Seat append — patch round 2 (the dev's text, appended by the `domain:services` seat) ### The first metadata-plane save of a code-authored item (contract review F1) **Measured first.** Three new pins in `packages/metadata-protocol/src/protocol.metadata-redaction.test.ts` seed a registry-only (code-authored) `api` flow with no `sys_metadata` row. Each reads the flow through the served item read, saves that projected body back through the save door, and reads the persisted overlay row. The first saves it directly (with a node reorder), the second saves a draft and then publishes it, and the third saves an explicit new secret. They were committed at `004f70bd` and run against the unfixed save door. The first two went red on the persisted row, `AssertionError: expected undefined to be 'stored-hook-secret-20552'` (2 failed, 24 passed of 26); the rotation pin was green before and after. **Fix** (`02b73b05`). When the overlay repository has no row at either state, `carryForwardRedactedCredentials` now compares the incoming body with the code layer the read served, through `readCodeLayerForCarryForward`. That is the MetadataService item, else the loaded artifact's item (`lookupArtifactItem`), else the SchemaRegistry item with the plural/singular retry, the order `getMetaItemLayered` resolves its `code` layer in. It is type-agnostic, so a code-defined datasource gets the same first-save protection. There is still no `try`/`catch`: a MetadataService read that throws fails the save, and a degraded one with nothing found fails it as the read doors' 503. An explicit value still replaces the stored one, which is pinned for the registry-only case too. The changeset gains one sentence stating this. **Ablation.** The fallback was removed through `scripts/ablation-replace.mjs` on the committed tree: the line became `stored?.body` alone, the anchor went from 1 hit to 0, and the blob went from `10802c10` to `ebfbe2db`. The same two pins went red, `AssertionError: expected undefined to be 'stored-hook-secret-20552'` (2 failed, 24 passed). The restore was proven: blob == HEAD and `git diff HEAD` empty. **Live** (local, composed showcase, one persistent store across two boots, after the fix). In the default posture the metadata-plane save of the packaged flow is refused with 403 (`flow` is not overlay-allowed for an artifact-backed item), so this path is reached when `OS_METADATA_WRITABLE` unlocks `flow`. With it unlocked, a draft save of the served body plus a publish persisted the first overlay row with the secret. The hook kept verifying in that process. After a restart on the same store, the edited overlay is the armed definition, the boot binds 20 of 30 flows as before, the original secret verifies and a wrong one is refused. **Runs at `ce8475ea`** (the source is identical to `02b73b05`; the one later commit is the changeset sentence). `metadata-protocol`: 2768 passed, 19 skipped. `service-automation`: 1851 passed. `typecheck` is green on both. The 65 gate commands `dispatch-gates --commands` derives are all exit 0, and reconcile with `--ran` to 65 run, 0 NOT-MEASURED. `origin/main` was merged first (`dc1e281f`). --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7001918 commit c96beb2

17 files changed

Lines changed: 1276 additions & 91 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/metadata': patch
5+
'@objectstack/runtime': patch
6+
---
7+
8+
fix(security): a flow's inbound-hook secret is withheld from every served flow definition, and a read → edit → republish round trip keeps it (#20552)
9+
10+
Clause-②: yes (widening)
11+
12+
**The widening.** `@objectstack/metadata-protocol` gains one public method,
13+
`ObjectStackProtocolImplementation.getMetaItemsForExecution`. It returns the stored
14+
bodies without the serving decorations, for in-process binders that execute what they
15+
read. No door that answers a caller may use it.
16+
17+
An `api` flow's start node carries its inbound hook's HMAC secret (`config.secret`,
18+
ADR-0041), the one credential that hook has. Every read that served the flow's
19+
definition served the secret with it, to any authenticated caller. It is now
20+
withheld from what is SERVED, and from nothing the engine executes.
21+
22+
**What no longer carries the secret.** The automation domain's flow-definition read
23+
and the flow its `POST` / `PUT` / clone doors answer with; and on the metadata plane,
24+
every read of a flow — item, list, layered, draft preview, published snapshot, diff,
25+
audit — plus a package export. The key is removed, not masked: a mask is a non-blank
26+
string the registration gate would accept as the secret.
27+
28+
**Consequence for a reader.** A client that read the secret back from a definition
29+
no longer can. A package exported from one deployment and imported into another
30+
arrives without it, and its `api` flows are refused at registration until a secret is
31+
set on the start node again.
32+
33+
**The round trip.** A save that carries the projected form — no `secret` where the
34+
read served none — keeps the stored secret, on both authoring surfaces (the metadata
35+
plane's save door and the automation domain's `PUT` / `POST`). Only an explicit value
36+
replaces it, so a rotation is written as before. The start node is matched by its
37+
`id`, not its position, so an edit that reorders `nodes` keeps it too. The first save of an item that has no stored row yet, such as a code-authored flow or datasource, takes the value from the code layer the read served, for every type with a registered redactor.
38+
39+
- `@objectstack/service-automation` owns the projection (`redactFlowCredentials`) and
40+
registers it as the `flow` read-path redactor at plugin `init`. The engine keeps
41+
binding with the stored secret: it now reads flows from the protocol's execution
42+
face, because the served face no longer holds the credential its hooks verify
43+
against.
44+
- `@objectstack/metadata-protocol` gains `getMetaItemsForExecution` on
45+
`ObjectStackProtocolImplementation` — the same flattened list `getMetaItems`
46+
serves, without the serving decorations (no `_diagnostics`, no credential
47+
redaction). It is for in-process engines that execute what they read; every door
48+
that answers a caller keeps serving `getMetaItems`. `carryForwardRedactedValues`
49+
now follows a redacted path through an array by the element's `id`.
50+
- `@objectstack/metadata`'s `getPublished` applies the type's registered read-path
51+
redactor to the body it returns. It was the one metadata read exit that served a
52+
stored body without it.

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

Lines changed: 125 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -131,46 +131,129 @@ export function redactMetadataItems<T>(type: string, items: T[]): T[] {
131131
return items.map((item) => redactMetadataItem(type, item));
132132
}
133133

134+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
135+
return !!value && typeof value === 'object' && !Array.isArray(value);
136+
}
137+
138+
/** An element's identity for an array hop: a non-empty string `id`, or none. */
139+
function identityOf(element: unknown): string | undefined {
140+
if (!isPlainRecord(element)) return undefined;
141+
const id = element.id;
142+
return typeof id === 'string' && id !== '' ? id : undefined;
143+
}
144+
145+
/**
146+
* The ONE element of `array` whose identity is `elementId` — `undefined` when
147+
* none carries it or more than one does. Two elements sharing an id cannot be
148+
* told apart, so neither is chosen: guessing would graft a credential onto
149+
* whichever of the two happened to come first.
150+
*/
151+
function elementWithIdentity(
152+
array: readonly unknown[],
153+
elementId: string,
154+
): { index: number; element: Record<string, unknown> } | undefined {
155+
let found: { index: number; element: Record<string, unknown> } | undefined;
156+
for (let index = 0; index < array.length; index += 1) {
157+
if (identityOf(array[index]) !== elementId) continue;
158+
if (found) return undefined;
159+
found = { index, element: array[index] as Record<string, unknown> };
160+
}
161+
return found;
162+
}
163+
164+
/**
165+
* One container hop of a redacted path, resolved against the STORED body.
166+
*
167+
* A `redactedKeys` entry is dotted and item-relative, and a segment that lands
168+
* on an ARRAY is the element's index in the body the redactor was handed — the
169+
* row at rest (a flow's credential sits on `nodes.<i>.config`, #20552). An
170+
* index is a position, and a position is not an identity: an author who
171+
* reorders a flow's `nodes` sends the same start node back at another index,
172+
* and grafting by position would put its credential on whichever node now sits
173+
* where it used to. So an array hop is resolved ONCE, against the stored body,
174+
* into the element's `id`, and every body — served and incoming — is then
175+
* walked by that identity, never by the index.
176+
*
177+
* An array hop whose stored element carries no string `id`, or shares it with
178+
* a sibling, resolves to nothing: the path is skipped, exactly as every array
179+
* hop was skipped before identity resolution existed.
180+
*/
181+
type PathHop = { readonly key: string } | { readonly elementId: string };
182+
134183
/**
135-
* Walk to the plain object that OWNS the last segment of `segments`.
184+
* Resolve every CONTAINER hop of `segments` (all but the last, which names the
185+
* redacted key itself) against `stored`. `undefined` when the stored body does
186+
* not reach that far — which the caller reads as "nothing at rest to carry".
187+
*/
188+
function resolveHops(stored: unknown, segments: readonly string[]): PathHop[] | undefined {
189+
const hops: PathHop[] = [];
190+
let node: unknown = stored;
191+
for (let i = 0; i < segments.length - 1; i += 1) {
192+
const segment = segments[i] as string;
193+
if (Array.isArray(node)) {
194+
if (!/^(0|[1-9][0-9]*)$/.test(segment)) return undefined;
195+
const element = node[Number(segment)];
196+
const elementId = identityOf(element);
197+
if (elementId === undefined || !elementWithIdentity(node, elementId)) return undefined;
198+
hops.push({ elementId });
199+
node = element;
200+
continue;
201+
}
202+
if (!isPlainRecord(node)) return undefined;
203+
hops.push({ key: segment });
204+
node = node[segment];
205+
}
206+
return hops;
207+
}
208+
209+
/**
210+
* Walk to the plain object that OWNS the redacted key, hop by hop.
136211
*
137-
* `undefined` when any hop along the way is absent or is not a plain object —
138-
* which the caller must read as "this body does not speak to that path at all",
139-
* never as "the value is absent". The distinction is the whole guard: a PUT
140-
* body carrying no `config` key is an author removing the container, and
212+
* `undefined` when any hop along the way is absent, is the wrong kind of
213+
* container, or (for an array hop) holds no single element with that identity
214+
* — which the caller must read as "this body does not speak to that path at
215+
* all", never as "the value is absent". The distinction is the whole guard: a
216+
* PUT body carrying no `config` key is an author removing the container, and
141217
* grafting `config.password` back onto it would MINT a config that holds
142218
* nothing but a credential.
143219
*/
144-
function containerAt(root: unknown, segments: string[]): Record<string, unknown> | undefined {
220+
function containerAt(root: unknown, hops: readonly PathHop[]): Record<string, unknown> | undefined {
145221
let node: unknown = root;
146-
for (let i = 0; i < segments.length - 1; i += 1) {
147-
if (!node || typeof node !== 'object' || Array.isArray(node)) return undefined;
148-
node = (node as Record<string, unknown>)[segments[i] as string];
222+
for (const hop of hops) {
223+
if ('key' in hop) {
224+
if (!isPlainRecord(node)) return undefined;
225+
node = node[hop.key];
226+
continue;
227+
}
228+
if (!Array.isArray(node)) return undefined;
229+
const found = elementWithIdentity(node, hop.elementId);
230+
if (!found) return undefined;
231+
node = found.element;
149232
}
150-
if (!node || typeof node !== 'object' || Array.isArray(node)) return undefined;
151-
return node as Record<string, unknown>;
233+
return isPlainRecord(node) ? node : undefined;
152234
}
153235

154236
/**
155-
* Copy-on-write set of `value` at `segments`, returning a new root and copying
156-
* only the containers along the path.
237+
* Copy-on-write set of `value` under `key` at the end of `hops`, returning a
238+
* new root and copying only the containers along the path — an array hop
239+
* copies the array and replaces the one element it names.
157240
*
158-
* The incoming request body belongs to the caller (`saveMetaItem` hands the
159-
* same object to the audit trail and the registry write-through), so the
160-
* carry-forward must not mutate it in place.
241+
* Called only after {@link containerAt} found the container in `root`, so
242+
* every hop resolves. The incoming request body belongs to the caller
243+
* (`saveMetaItem` hands the same object to the audit trail and the registry
244+
* write-through), so the carry-forward must not mutate it in place.
161245
*/
162-
function withValueAt(
163-
root: Record<string, unknown>,
164-
segments: string[],
165-
value: unknown,
166-
): Record<string, unknown> {
167-
const [head, ...rest] = segments as [string, ...string[]];
168-
const next: Record<string, unknown> = { ...root };
169-
if (rest.length === 0) {
170-
next[head] = value;
171-
return next;
246+
function withValueAt(root: unknown, hops: readonly PathHop[], key: string, value: unknown): unknown {
247+
if (hops.length === 0) return { ...(root as Record<string, unknown>), [key]: value };
248+
const [hop, ...rest] = hops as [PathHop, ...PathHop[]];
249+
if ('key' in hop) {
250+
const record = root as Record<string, unknown>;
251+
return { ...record, [hop.key]: withValueAt(record[hop.key], rest, key, value) };
172252
}
173-
next[head] = withValueAt(root[head] as Record<string, unknown>, rest, value);
253+
const array = root as unknown[];
254+
const found = elementWithIdentity(array, hop.elementId) as { index: number; element: Record<string, unknown> };
255+
const next = array.slice();
256+
next[found.index] = withValueAt(found.element, rest, key, value);
174257
return next;
175258
}
176259

@@ -209,6 +292,12 @@ function sameValue(a: unknown, b: unknown): boolean {
209292
* - the incoming body has no container for that path at all ⇒ nothing is
210293
* grafted, because a removed container is also the author's word.
211294
*
295+
* A path through an ARRAY (a flow's start node, `nodes.<i>.config.secret`,
296+
* #20552) is walked by the stored element's `id`, not by its index, so a body
297+
* that reorders the array still carries the value onto the element it came
298+
* from — see {@link resolveHops}. An element with no `id`, or an `id` shared
299+
* with a sibling, is never carried into.
300+
*
212301
* ⚠️ The first case is genuinely INDISTINGUISHABLE, not merely treated as
213302
* equal: an author who hand-deletes `:password` from a URL sends exactly the
214303
* bytes the redaction served, and this function restores the stored password.
@@ -238,21 +327,25 @@ export function carryForwardRedactedValues<T>(type: string, incoming: T, stored:
238327
let out = incoming as unknown as Record<string, unknown>;
239328
for (const path of served.redactedKeys) {
240329
// Dotted, item-relative — the registry's documented contract for
241-
// `redactedKeys` (`config.password`).
330+
// `redactedKeys` (`config.password`; an array hop is an index into the
331+
// stored body, `nodes.0.config.secret`, resolved to an identity by
332+
// {@link resolveHops}).
242333
const segments = path.split('.');
243334
const key = segments[segments.length - 1] as string;
335+
const hops = resolveHops(stored, segments);
336+
if (!hops) continue;
244337

245-
const storedParent = containerAt(stored, segments);
338+
const storedParent = containerAt(stored, hops);
246339
const storedValue = storedParent?.[key];
247340
if (storedValue === undefined) continue;
248341

249-
const incomingParent = containerAt(out, segments);
342+
const incomingParent = containerAt(out, hops);
250343
if (!incomingParent) continue;
251344

252-
const servedParent = containerAt(served.item, segments);
345+
const servedParent = containerAt(served.item, hops);
253346
if (!sameValue(incomingParent[key], servedParent?.[key])) continue;
254347

255-
out = withValueAt(out, segments, storedValue);
348+
out = withValueAt(out, hops, key, storedValue) as Record<string, unknown>;
256349
}
257350
return out as unknown as T;
258351
}

0 commit comments

Comments
 (0)