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
164 changes: 164 additions & 0 deletions packages/metadata-protocol/src/protocol-credential-carry-forward.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25467 cut 9] The credential carry-forward of `ObjectStackProtocolImplementation`
* — the registered credential channel lookup (`credentialChannelFor`), the
* save's write-path inverse of the read exits' credential redaction
* (`carryForwardRedactedCredentials`), the stored body it compares against
* (`storedBodyForCarryForward`) and the runtime authoring gate's half of the
* same context (`restoredCredentialPathsFor`) — moved out of `protocol.ts`
* whole. One member of the group stays in the class:
* `readCodeLayerForCarryForward`, the code-layer read the stored body falls
* back to, a `lookupArtifactItem(` site that
* `protocol.lookup-artifact-item-call-sites.test.ts` reads in `protocol.ts`.
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
* re-indented one level and otherwise unedited: the same reads, in the same
* order, with no `try`/`catch` added where the carry-forward forbids one. No
* module-level declaration of `protocol.ts` is read by these bodies, so none
* moved with them.
*
* ## How the moved bodies reach the class
*
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
* The class keeps every method — the published `dist/*.d.ts` lists each one's
* name, docblock and declared signature — and each body is one literal of
* exactly the {@link CredentialCarryForwardHost} members that method reads,
* plus a `.call`. A method is handed over as a closure that calls it on the
* protocol at call time, so a test double or spy set on the instance is still
* the one the body reaches, and the moved bodies that call one another do so
* through the protocol, exactly as before. `credentialChannelFor` keeps its
* other readers in the class, the cut 8 restore doors among them, and they
* still call the method.
*
* The one field the bodies read, `credentialChannels`, goes as a GETTER, so
* the read happens when the body makes it, as for every field in this series.
*
* Everything else the bodies read is imported from the module `protocol.ts`
* imports it from (`metadata-redaction.ts`, `@objectstack/spec/shared`); from
* `protocol.ts` itself this module imports types only, so there is no
* runtime module cycle. Nothing re-exports this module, so the published
* surface does not move.
*/

import type { MetadataCredentialChannel, ObjectStackProtocolImplementation } from './protocol.js';
import type { SysMetadataRepository } from './sys-metadata-repository.js';
import { carryForwardRedactedValues, hasMetadataRedactor, redactedPathsCarriedForward } from './metadata-redaction.js';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';

/**
* The `ObjectStackProtocolImplementation` members the functions below read —
* every one of them, and no other; each function's `this` is the `Pick` of the
* members IT reads. Each is typed AS the class member (an indexed access on
* the class), so a change to a member's signature on the class is a change
* here too, and naming a member the class does not have is a compile error.
* See the module header for which members are getters.
*/
export interface CredentialCarryForwardHost {
credentialChannelFor: ObjectStackProtocolImplementation['credentialChannelFor'];
readonly credentialChannels: ObjectStackProtocolImplementation['credentialChannels'];
getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo'];
readCodeLayerForCarryForward: ObjectStackProtocolImplementation['readCodeLayerForCarryForward'];
storedBodyForCarryForward: ObjectStackProtocolImplementation['storedBodyForCarryForward'];
}

/** The body of `ObjectStackProtocolImplementation#credentialChannelFor`; the method's docblock there is its contract. */
export function credentialChannelFor(
this: Pick<CredentialCarryForwardHost, 'credentialChannels'>,
type: string): MetadataCredentialChannel | undefined {
return this.credentialChannels.get(PLURAL_TO_SINGULAR[type] ?? type);
}

/** The body of `ObjectStackProtocolImplementation#carryForwardRedactedCredentials`; the method's docblock there is its contract. */
export async function carryForwardRedactedCredentials(
this: Pick<CredentialCarryForwardHost, 'storedBodyForCarryForward'>,
args: {
type: string;
repo: SysMetadataRepository;
ref: Parameters<SysMetadataRepository['get']>[0];
state: 'draft' | 'active';
packageId: string | null;
item: any;
/** [#22877] The bundle member the save addresses (its body's). */
member?: string;
}): Promise<any> {
if (!hasMetadataRedactor(args.type)) return args.item;
const body = await this.storedBodyForCarryForward(args);
if (!body) return args.item;
return carryForwardRedactedValues(args.type, args.item, body);
}

/** The body of `ObjectStackProtocolImplementation#storedBodyForCarryForward`; the method's docblock there is its contract. */
export async function storedBodyForCarryForward(
this: Pick<CredentialCarryForwardHost, 'readCodeLayerForCarryForward'>,
args: {
type: string;
repo: SysMetadataRepository;
ref: Parameters<SysMetadataRepository['get']>[0];
state: 'draft' | 'active';
packageId: string | null;
member?: string;
}): Promise<unknown> {
// [#22128] The row this save overwrites, and with no draft row yet the
// active row the draft overlays: the one in the package the draft is
// stamped into (`packageId`, the inherited binding included).
const target = await args.repo.headAt(args.ref, {
state: args.state,
packageId: args.packageId,
member: args.member,
});
let stored = target.head;
if (!stored && args.state === 'draft') {
stored = (await args.repo.headAt(args.ref, {
state: 'active',
packageId: target.packageId,
member: args.member,
})).head;
}
// [#20552] NO overlay row at either state: the body the read served is
// the CODE layer, so that is what the incoming body is compared with.
// Without this a code-authored item — an inbound flow whose secret is a
// literal in the app's source, the shipped reference shape — had its
// FIRST save persist an overlay row with the credential dropped, and
// an overlay wins every later merge.
return stored?.body ?? await this.readCodeLayerForCarryForward(args.type, args.ref.name, args.packageId);
}

/** The body of `ObjectStackProtocolImplementation#restoredCredentialPathsFor`; the method's docblock there is its contract. */
export async function restoredCredentialPathsFor(
this: Pick<CredentialCarryForwardHost, 'credentialChannelFor' | 'getOverlayRepo' | 'storedBodyForCarryForward'>,
args: {
type: string;
name: string;
packageId: string | null;
item: unknown;
}): Promise<readonly string[]> {
if (!hasMetadataRedactor(args.type)) return [];
const repo = this.getOverlayRepo();
const ref = {
type: args.type,
name: args.name,
org: 'env',
} as Parameters<typeof repo.get>[0];
const body = await this.storedBodyForCarryForward({
type: args.type,
repo,
ref,
// The gate judges `active` writes only (#4463 D1), so this is the
// state a gated save's carry-forward reads.
state: 'active',
packageId: args.packageId,
});
const carried = body ? redactedPathsCarriedForward(args.type, args.item, body) : [];
// [#20790] A credential the write-only channel holds is withheld from
// the stored body too, so the carry-forward restores nothing there —
// and it is still present: the channel keeps it across this save.
const held = await this.credentialChannelFor(args.type)?.heldPaths({
name: args.name,
state: 'active',
item: args.item,
});
return held && held.length > 0 ? [...new Set([...carried, ...held])] : carried;
}
43 changes: 42 additions & 1 deletion packages/metadata-protocol/src/protocol-package-install.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,47 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/** [#25467 cut 7] HEADER PLACEHOLDER */
/**
* [#25467 cut 7] Package install and update of `ObjectStackProtocolImplementation`
* — the install primitive (`installPackage`), the manifest edit behind
* `PATCH /packages/:id` (`updatePackage`), the `package` service's write half
* both of them persist through (`packagePersistService`) and the undo a failed
* install runs (`packageInstallUndo`) — moved out of `protocol.ts` whole. They
* were the tail of the class.
*
* A MOVE, not a redesign. Each function below is the body of the
* `ObjectStackProtocolImplementation` method of the same name as it stood,
* re-indented one level and otherwise unedited: the same parses, writes and
* undo, refusing exactly where they did with the same codes and messages. The
* module-level declarations only these bodies read (`persistPackageManifest`,
* `snapshotPackageRow`, `restorePackageRow`) moved with them, also unedited.
*
* ## How the moved bodies reach the class
*
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
* header carries the full reasoning), as `protocol-batch-data.ts` applies it.
* The class keeps every method — the published `dist/*.d.ts` lists each one's
* name, docblock and declared signature — and each body is one literal of
* exactly the {@link PackageInstallHost} members that method reads, plus a
* `.call`. A method is handed over as a closure that calls it on the protocol
* at call time, so a test double or spy set on the instance is still the one
* the body reaches, and the moved bodies that call one another
* (`packagePersistService`, `packageInstallUndo`) do so through the protocol,
* exactly as before.
*
* The fields the bodies read, `engine` and `getServicesRegistry`, go as
* GETTERS, so each read happens when the body makes it: tests replace them on
* a constructed protocol.
*
* The other module-level declarations the bodies read stay in `protocol.ts`,
* where code that did not move reads them too, and are imported from there:
* `packagePersistFailureError`, which the package delete also throws, and the
* type `PackagePersistService`, which the class's own signatures name. The
* module cycle is evaluation-safe because the one runtime import is read only
* inside a function body, never while the two modules are first evaluated.
* `index.ts` names what it publishes from `protocol.ts` one by one, so the
* `export` they gained for this keeps them off the published surface, and
* nothing re-exports this module, so the published surface does not move.
*/

import type { ObjectStackProtocolImplementation, PackagePersistService } from './protocol.js';
import { packagePersistFailureError } from './protocol.js';
Expand Down
92 changes: 33 additions & 59 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,8 @@ import {
// and `getData` serve `sys_metadata` / `sys_metadata_history` rows, whose
// `metadata` column is the same stored body, through the same redactor.
import {
carryForwardRedactedValues,
hasMetadataRedactor,
redactMetadataItem,
redactedPathsCarriedForward,
redactStoredMetadataRow,
redactStoredMetadataRows,
storedMetadataBodyGroupingRefusal,
Expand Down Expand Up @@ -376,6 +374,13 @@ import {
restoredBodyWriter,
runAuthoringGate,
} from './protocol-authoring-gate.js';
// [#25467 cut 9] The credential carry-forward and the credential channel lookup.
import {
carryForwardRedactedCredentials,
credentialChannelFor,
restoredCredentialPathsFor,
storedBodyForCarryForward,
} from './protocol-credential-carry-forward.js';

/**
* Canonical Zod schema per metadata type lives in
Expand Down Expand Up @@ -6870,7 +6875,12 @@ export class ObjectStackProtocolImplementation implements

/** [#20790] The registered credential channel of `type`, if any. */
private credentialChannelFor(type: string): MetadataCredentialChannel | undefined {
return this.credentialChannels.get(PLURAL_TO_SINGULAR[type] ?? type);
// [#25467 cut 9] The body moved to `protocol-credential-carry-forward.ts` (see `CredentialCarryForwardHost`).
const readCredentialChannels = () => this.credentialChannels;
const host: ThisParameterType<typeof credentialChannelFor> = {
get credentialChannels() { return readCredentialChannels(); },
};
return credentialChannelFor.call(host, type);
}

/**
Expand Down Expand Up @@ -9187,7 +9197,7 @@ export class ObjectStackProtocolImplementation implements
* type has no registered redactor, which is every type but `datasource`
* (built in) and `flow` (registered by the automation plugin, #20552).
*/
private async carryForwardRedactedCredentials(args: {
private carryForwardRedactedCredentials(args: {
type: string;
repo: SysMetadataRepository;
ref: Parameters<SysMetadataRepository['get']>[0];
Expand All @@ -9197,10 +9207,11 @@ export class ObjectStackProtocolImplementation implements
/** [#22877] The bundle member the save addresses (its body's). */
member?: string;
}): Promise<any> {
if (!hasMetadataRedactor(args.type)) return args.item;
const body = await this.storedBodyForCarryForward(args);
if (!body) return args.item;
return carryForwardRedactedValues(args.type, args.item, body);
// [#25467 cut 9] The body moved to `protocol-credential-carry-forward.ts` (see `CredentialCarryForwardHost`).
const host: ThisParameterType<typeof carryForwardRedactedCredentials> = {
storedBodyForCarryForward: (...a) => this.storedBodyForCarryForward(...a),
};
return carryForwardRedactedCredentials.call(host, args);
}

/**
Expand All @@ -9212,37 +9223,19 @@ export class ObjectStackProtocolImplementation implements
* read exits redacted — and read with no `try`/`catch`, for the reason the
* carry-forward gives.
*/
private async storedBodyForCarryForward(args: {
private storedBodyForCarryForward(args: {
type: string;
repo: SysMetadataRepository;
ref: Parameters<SysMetadataRepository['get']>[0];
state: 'draft' | 'active';
packageId: string | null;
member?: string;
}): Promise<unknown> {
// [#22128] The row this save overwrites, and with no draft row yet the
// active row the draft overlays: the one in the package the draft is
// stamped into (`packageId`, the inherited binding included).
const target = await args.repo.headAt(args.ref, {
state: args.state,
packageId: args.packageId,
member: args.member,
});
let stored = target.head;
if (!stored && args.state === 'draft') {
stored = (await args.repo.headAt(args.ref, {
state: 'active',
packageId: target.packageId,
member: args.member,
})).head;
}
// [#20552] NO overlay row at either state: the body the read served is
// the CODE layer, so that is what the incoming body is compared with.
// Without this a code-authored item — an inbound flow whose secret is a
// literal in the app's source, the shipped reference shape — had its
// FIRST save persist an overlay row with the credential dropped, and
// an overlay wins every later merge.
return stored?.body ?? await this.readCodeLayerForCarryForward(args.type, args.ref.name, args.packageId);
// [#25467 cut 9] The body moved to `protocol-credential-carry-forward.ts` (see `CredentialCarryForwardHost`).
const host: ThisParameterType<typeof storedBodyForCarryForward> = {
readCodeLayerForCarryForward: (...a) => this.readCodeLayerForCarryForward(...a),
};
return storedBodyForCarryForward.call(host, args);
}

/**
Expand All @@ -9261,38 +9254,19 @@ export class ObjectStackProtocolImplementation implements
* row. The cost is one more indexed read on an active save of a type with a
* registered redactor, and nothing for any other type.
*/
private async restoredCredentialPathsFor(args: {
private restoredCredentialPathsFor(args: {
type: string;
name: string;
packageId: string | null;
item: unknown;
}): Promise<readonly string[]> {
if (!hasMetadataRedactor(args.type)) return [];
const repo = this.getOverlayRepo();
const ref = {
type: args.type,
name: args.name,
org: 'env',
} as Parameters<typeof repo.get>[0];
const body = await this.storedBodyForCarryForward({
type: args.type,
repo,
ref,
// The gate judges `active` writes only (#4463 D1), so this is the
// state a gated save's carry-forward reads.
state: 'active',
packageId: args.packageId,
});
const carried = body ? redactedPathsCarriedForward(args.type, args.item, body) : [];
// [#20790] A credential the write-only channel holds is withheld from
// the stored body too, so the carry-forward restores nothing there —
// and it is still present: the channel keeps it across this save.
const held = await this.credentialChannelFor(args.type)?.heldPaths({
name: args.name,
state: 'active',
item: args.item,
});
return held && held.length > 0 ? [...new Set([...carried, ...held])] : carried;
// [#25467 cut 9] The body moved to `protocol-credential-carry-forward.ts` (see `CredentialCarryForwardHost`).
const host: ThisParameterType<typeof restoredCredentialPathsFor> = {
credentialChannelFor: (...a) => this.credentialChannelFor(...a),
getOverlayRepo: (...a) => this.getOverlayRepo(...a),
storedBodyForCarryForward: (...a) => this.storedBodyForCarryForward(...a),
};
return restoredCredentialPathsFor.call(host, args);
}

/**
Expand Down
Loading