diff --git a/packages/metadata-protocol/src/protocol-credential-carry-forward.ts b/packages/metadata-protocol/src/protocol-credential-carry-forward.ts new file mode 100644 index 00000000000..34deefa4f48 --- /dev/null +++ b/packages/metadata-protocol/src/protocol-credential-carry-forward.ts @@ -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, + 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, + args: { + type: string; + repo: SysMetadataRepository; + ref: Parameters[0]; + state: 'draft' | 'active'; + packageId: string | null; + item: any; + /** [#22877] The bundle member the save addresses (its body's). */ + member?: string; + }): Promise { + 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, + args: { + type: string; + repo: SysMetadataRepository; + ref: Parameters[0]; + state: 'draft' | 'active'; + packageId: string | null; + member?: string; + }): Promise { + // [#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, + args: { + type: string; + name: string; + packageId: string | null; + item: unknown; + }): Promise { + if (!hasMetadataRedactor(args.type)) return []; + const repo = this.getOverlayRepo(); + const ref = { + type: args.type, + name: args.name, + org: 'env', + } as Parameters[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; +} diff --git a/packages/metadata-protocol/src/protocol-package-install.ts b/packages/metadata-protocol/src/protocol-package-install.ts index 0b6225644c5..f4612b590be 100644 --- a/packages/metadata-protocol/src/protocol-package-install.ts +++ b/packages/metadata-protocol/src/protocol-package-install.ts @@ -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'; diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ac24f6c0ab3..5b545a0f722 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -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, @@ -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 @@ -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 = { + get credentialChannels() { return readCredentialChannels(); }, + }; + return credentialChannelFor.call(host, type); } /** @@ -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[0]; @@ -9197,10 +9207,11 @@ export class ObjectStackProtocolImplementation implements /** [#22877] The bundle member the save addresses (its body's). */ member?: string; }): Promise { - 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 = { + storedBodyForCarryForward: (...a) => this.storedBodyForCarryForward(...a), + }; + return carryForwardRedactedCredentials.call(host, args); } /** @@ -9212,7 +9223,7 @@ 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[0]; @@ -9220,29 +9231,11 @@ export class ObjectStackProtocolImplementation implements packageId: string | null; member?: string; }): Promise { - // [#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 = { + readCodeLayerForCarryForward: (...a) => this.readCodeLayerForCarryForward(...a), + }; + return storedBodyForCarryForward.call(host, args); } /** @@ -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 { - if (!hasMetadataRedactor(args.type)) return []; - const repo = this.getOverlayRepo(); - const ref = { - type: args.type, - name: args.name, - org: 'env', - } as Parameters[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 = { + credentialChannelFor: (...a) => this.credentialChannelFor(...a), + getOverlayRepo: (...a) => this.getOverlayRepo(...a), + storedBodyForCarryForward: (...a) => this.storedBodyForCarryForward(...a), + }; + return restoredCredentialPathsFor.call(host, args); } /**