|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25467 cut 9] The credential carry-forward of `ObjectStackProtocolImplementation` |
| 5 | + * — the registered credential channel lookup (`credentialChannelFor`), the |
| 6 | + * save's write-path inverse of the read exits' credential redaction |
| 7 | + * (`carryForwardRedactedCredentials`), the stored body it compares against |
| 8 | + * (`storedBodyForCarryForward`) and the runtime authoring gate's half of the |
| 9 | + * same context (`restoredCredentialPathsFor`) — moved out of `protocol.ts` |
| 10 | + * whole. One member of the group stays in the class: |
| 11 | + * `readCodeLayerForCarryForward`, the code-layer read the stored body falls |
| 12 | + * back to, a `lookupArtifactItem(` site that |
| 13 | + * `protocol.lookup-artifact-item-call-sites.test.ts` reads in `protocol.ts`. |
| 14 | + * |
| 15 | + * A MOVE, not a redesign. Each function below is the body of the |
| 16 | + * `ObjectStackProtocolImplementation` method of the same name as it stood, |
| 17 | + * re-indented one level and otherwise unedited: the same reads, in the same |
| 18 | + * order, with no `try`/`catch` added where the carry-forward forbids one. No |
| 19 | + * module-level declaration of `protocol.ts` is read by these bodies, so none |
| 20 | + * moved with them. |
| 21 | + * |
| 22 | + * ## How the moved bodies reach the class |
| 23 | + * |
| 24 | + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` |
| 25 | + * header carries the full reasoning), as `protocol-batch-data.ts` applies it. |
| 26 | + * The class keeps every method — the published `dist/*.d.ts` lists each one's |
| 27 | + * name, docblock and declared signature — and each body is one literal of |
| 28 | + * exactly the {@link CredentialCarryForwardHost} members that method reads, |
| 29 | + * plus a `.call`. A method is handed over as a closure that calls it on the |
| 30 | + * protocol at call time, so a test double or spy set on the instance is still |
| 31 | + * the one the body reaches, and the moved bodies that call one another do so |
| 32 | + * through the protocol, exactly as before. `credentialChannelFor` keeps its |
| 33 | + * other readers in the class, the cut 8 restore doors among them, and they |
| 34 | + * still call the method. |
| 35 | + * |
| 36 | + * The one field the bodies read, `credentialChannels`, goes as a GETTER, so |
| 37 | + * the read happens when the body makes it, as for every field in this series. |
| 38 | + * |
| 39 | + * Everything else the bodies read is imported from the module `protocol.ts` |
| 40 | + * imports it from (`metadata-redaction.ts`, `@objectstack/spec/shared`); from |
| 41 | + * `protocol.ts` itself this module imports types only, so there is no |
| 42 | + * runtime module cycle. Nothing re-exports this module, so the published |
| 43 | + * surface does not move. |
| 44 | + */ |
| 45 | + |
| 46 | +import type { MetadataCredentialChannel, ObjectStackProtocolImplementation } from './protocol.js'; |
| 47 | +import type { SysMetadataRepository } from './sys-metadata-repository.js'; |
| 48 | +import { carryForwardRedactedValues, hasMetadataRedactor, redactedPathsCarriedForward } from './metadata-redaction.js'; |
| 49 | +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; |
| 50 | + |
| 51 | +/** |
| 52 | + * The `ObjectStackProtocolImplementation` members the functions below read — |
| 53 | + * every one of them, and no other; each function's `this` is the `Pick` of the |
| 54 | + * members IT reads. Each is typed AS the class member (an indexed access on |
| 55 | + * the class), so a change to a member's signature on the class is a change |
| 56 | + * here too, and naming a member the class does not have is a compile error. |
| 57 | + * See the module header for which members are getters. |
| 58 | + */ |
| 59 | +export interface CredentialCarryForwardHost { |
| 60 | + credentialChannelFor: ObjectStackProtocolImplementation['credentialChannelFor']; |
| 61 | + readonly credentialChannels: ObjectStackProtocolImplementation['credentialChannels']; |
| 62 | + getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo']; |
| 63 | + readCodeLayerForCarryForward: ObjectStackProtocolImplementation['readCodeLayerForCarryForward']; |
| 64 | + storedBodyForCarryForward: ObjectStackProtocolImplementation['storedBodyForCarryForward']; |
| 65 | +} |
| 66 | + |
| 67 | +/** The body of `ObjectStackProtocolImplementation#credentialChannelFor`; the method's docblock there is its contract. */ |
| 68 | +export function credentialChannelFor( |
| 69 | + this: Pick<CredentialCarryForwardHost, 'credentialChannels'>, |
| 70 | + type: string): MetadataCredentialChannel | undefined { |
| 71 | + return this.credentialChannels.get(PLURAL_TO_SINGULAR[type] ?? type); |
| 72 | +} |
| 73 | + |
| 74 | +/** The body of `ObjectStackProtocolImplementation#carryForwardRedactedCredentials`; the method's docblock there is its contract. */ |
| 75 | +export async function carryForwardRedactedCredentials( |
| 76 | + this: Pick<CredentialCarryForwardHost, 'storedBodyForCarryForward'>, |
| 77 | + args: { |
| 78 | + type: string; |
| 79 | + repo: SysMetadataRepository; |
| 80 | + ref: Parameters<SysMetadataRepository['get']>[0]; |
| 81 | + state: 'draft' | 'active'; |
| 82 | + packageId: string | null; |
| 83 | + item: any; |
| 84 | + /** [#22877] The bundle member the save addresses (its body's). */ |
| 85 | + member?: string; |
| 86 | + }): Promise<any> { |
| 87 | + if (!hasMetadataRedactor(args.type)) return args.item; |
| 88 | + const body = await this.storedBodyForCarryForward(args); |
| 89 | + if (!body) return args.item; |
| 90 | + return carryForwardRedactedValues(args.type, args.item, body); |
| 91 | +} |
| 92 | + |
| 93 | +/** The body of `ObjectStackProtocolImplementation#storedBodyForCarryForward`; the method's docblock there is its contract. */ |
| 94 | +export async function storedBodyForCarryForward( |
| 95 | + this: Pick<CredentialCarryForwardHost, 'readCodeLayerForCarryForward'>, |
| 96 | + args: { |
| 97 | + type: string; |
| 98 | + repo: SysMetadataRepository; |
| 99 | + ref: Parameters<SysMetadataRepository['get']>[0]; |
| 100 | + state: 'draft' | 'active'; |
| 101 | + packageId: string | null; |
| 102 | + member?: string; |
| 103 | + }): Promise<unknown> { |
| 104 | + // [#22128] The row this save overwrites, and with no draft row yet the |
| 105 | + // active row the draft overlays: the one in the package the draft is |
| 106 | + // stamped into (`packageId`, the inherited binding included). |
| 107 | + const target = await args.repo.headAt(args.ref, { |
| 108 | + state: args.state, |
| 109 | + packageId: args.packageId, |
| 110 | + member: args.member, |
| 111 | + }); |
| 112 | + let stored = target.head; |
| 113 | + if (!stored && args.state === 'draft') { |
| 114 | + stored = (await args.repo.headAt(args.ref, { |
| 115 | + state: 'active', |
| 116 | + packageId: target.packageId, |
| 117 | + member: args.member, |
| 118 | + })).head; |
| 119 | + } |
| 120 | + // [#20552] NO overlay row at either state: the body the read served is |
| 121 | + // the CODE layer, so that is what the incoming body is compared with. |
| 122 | + // Without this a code-authored item — an inbound flow whose secret is a |
| 123 | + // literal in the app's source, the shipped reference shape — had its |
| 124 | + // FIRST save persist an overlay row with the credential dropped, and |
| 125 | + // an overlay wins every later merge. |
| 126 | + return stored?.body ?? await this.readCodeLayerForCarryForward(args.type, args.ref.name, args.packageId); |
| 127 | +} |
| 128 | + |
| 129 | +/** The body of `ObjectStackProtocolImplementation#restoredCredentialPathsFor`; the method's docblock there is its contract. */ |
| 130 | +export async function restoredCredentialPathsFor( |
| 131 | + this: Pick<CredentialCarryForwardHost, 'credentialChannelFor' | 'getOverlayRepo' | 'storedBodyForCarryForward'>, |
| 132 | + args: { |
| 133 | + type: string; |
| 134 | + name: string; |
| 135 | + packageId: string | null; |
| 136 | + item: unknown; |
| 137 | + }): Promise<readonly string[]> { |
| 138 | + if (!hasMetadataRedactor(args.type)) return []; |
| 139 | + const repo = this.getOverlayRepo(); |
| 140 | + const ref = { |
| 141 | + type: args.type, |
| 142 | + name: args.name, |
| 143 | + org: 'env', |
| 144 | + } as Parameters<typeof repo.get>[0]; |
| 145 | + const body = await this.storedBodyForCarryForward({ |
| 146 | + type: args.type, |
| 147 | + repo, |
| 148 | + ref, |
| 149 | + // The gate judges `active` writes only (#4463 D1), so this is the |
| 150 | + // state a gated save's carry-forward reads. |
| 151 | + state: 'active', |
| 152 | + packageId: args.packageId, |
| 153 | + }); |
| 154 | + const carried = body ? redactedPathsCarriedForward(args.type, args.item, body) : []; |
| 155 | + // [#20790] A credential the write-only channel holds is withheld from |
| 156 | + // the stored body too, so the carry-forward restores nothing there — |
| 157 | + // and it is still present: the channel keeps it across this save. |
| 158 | + const held = await this.credentialChannelFor(args.type)?.heldPaths({ |
| 159 | + name: args.name, |
| 160 | + state: 'active', |
| 161 | + item: args.item, |
| 162 | + }); |
| 163 | + return held && held.length > 0 ? [...new Set([...carried, ...held])] : carried; |
| 164 | +} |
0 commit comments