@@ -19,7 +19,8 @@ import { runRuntimeAuthoringRules } from './runtime-gate.js';
1919/**
2020 * [#20553] The flow-trigger family as the CLI table runs it: two registry
2121 * entries over one file — `validateFlowTriggerReadiness` and, split out because
22- * it is CLI-only until #20611, `validateFlowApiTriggerSecret`. Cases that are
22+ * at the runtime gate it reads the host's restored-credential positions
23+ * (#20611), `validateFlowApiTriggerSecret`. Cases that are
2324 * about the WHOLE family's verdict on a stack (the severity map, the clean-stack
2425 * floor, the api-secret cases' exhaustive assertions) judge through this.
2526 */
@@ -1160,34 +1161,31 @@ describe('validateFlowTriggerReadiness', () => {
11601161 expect ( FLOW_API_TRIGGER_SECRET_MISSING ) . toBe ( 'flow-api-trigger-secret-missing' ) ;
11611162 } ) ;
11621163
1163- // [#20611] The id is CLI-only until the runtime publish gate judges the
1164- // carried-forward body: a `/meta` save is gated BEFORE `saveMetaItem`
1165- // restores the `config.secret` the flow read path withholds (#20552), so a
1166- // signed flow's GET → edit → PUT would reach the gate secretless. Each side
1167- // of the wall is pinned, and the gate pin carries its own positive control .
1168- describe ( 'one rule id on ONE side of the runtime wall — CLI-only until #20611 ' , ( ) => {
1164+ // [#20611] The id crosses the runtime wall. A `/meta` save is gated BEFORE
1165+ // `saveMetaItem` restores the `config.secret` the flow read path withholds
1166+ // (#20552), so the gate is handed the positions that restore fills, and a
1167+ // withheld-and-stored secret reads as present. Both sides of the wall are
1168+ // pinned, and each gate verdict is paired with the input that flips it .
1169+ describe ( '#20611 — on both sides of the runtime wall; the gate reads restored positions as present ' , ( ) => {
11691170 const secretless = apiFlow ( { hookId : 'intake' } ) ;
11701171 const stack = { objects : [ candidateObject ] , flows : [ secretless ] } ;
11711172
1172- it ( 'the split is whole: validateFlowTriggerReadiness alone no longer emits the id' , ( ) => {
1173+ it ( 'the split is whole: validateFlowTriggerReadiness alone does not emit the id' , ( ) => {
11731174 expect ( validateFlowTriggerReadiness ( stack ) . map ( ( f ) => f . rule ) ) . not . toContain (
11741175 FLOW_API_TRIGGER_SECRET_MISSING ,
11751176 ) ;
11761177 expect ( validateFlowApiTriggerSecret ( stack ) . map ( ( f ) => f . rule ) ) . toEqual ( [ FLOW_API_TRIGGER_SECRET_MISSING ] ) ;
11771178 } ) ;
11781179
1179- it ( 'its registry entry gates all three commands on the cli surface only, with a reason ' , ( ) => {
1180+ it ( 'its registry entry gates all three commands AND the runtime publish gate for `flow` writes ' , ( ) => {
11801181 const entry = AUTHORING_RULES . find ( ( r ) => r . name === 'validateFlowApiTriggerSecret' ) ;
11811182 expect ( entry ) . toBeDefined ( ) ;
11821183 expect ( entry ! . tier ) . toBe ( 'gating' ) ;
11831184 expect ( [ ...entry ! . commands ] . sort ( ) ) . toEqual ( [ ...AUTHORING_COMMANDS ] . sort ( ) ) ;
1184- expect ( entry ! . surfaces ) . toEqual ( [ 'cli' ] ) ;
1185- expect ( entry ! . runtimeTypes ) . toBeUndefined ( ) ;
1186- expect ( ( entry ! . surfaceReason ?? '' ) . trim ( ) . length ) . toBeGreaterThan ( 40 ) ;
1187- // …while the family's shared entry still crosses the wall.
1188- expect ( AUTHORING_RULES . find ( ( r ) => r . name === 'validateFlowTriggerReadiness' ) ! . surfaces ) . toContain (
1189- 'runtime-publish' ,
1190- ) ;
1185+ expect ( [ ...entry ! . surfaces ] . sort ( ) ) . toEqual ( [ 'cli' , 'runtime-publish' ] ) ;
1186+ expect ( entry ! . runtimeTypes ) . toEqual ( [ 'flow' ] ) ;
1187+ // The reason that held it CLI-only is gone with the wall.
1188+ expect ( entry ! . surfaceReason ) . toBeUndefined ( ) ;
11911189 } ) ;
11921190
11931191 it . each ( [ ...AUTHORING_COMMANDS ] ) ( 'os %s still refuses a secretless api flow through the table' , ( command ) => {
@@ -1197,26 +1195,64 @@ describe('validateFlowTriggerReadiness', () => {
11971195 expect ( hits . map ( ( f ) => [ f . severity , f . path ] ) ) . toEqual ( [ [ 'error' , 'flows[0].nodes[0].config.secret' ] ] ) ;
11981196 } ) ;
11991197
1200- it ( 'the runtime publish gate does not emit it — and still runs the rest of the family ' , ( ) => {
1198+ it ( 'the runtime publish gate refuses a secretless api flow — absent and not stored is missing ' , ( ) => {
12011199 const gated = runRuntimeAuthoringRules ( { type : 'flow' , item : secretless } ) ;
1202- expect ( gated . rulesRun ) . toContain ( 'validateFlowTriggerReadiness ' ) ;
1203- expect ( gated . rulesRun ) . not . toContain ( 'validateFlowApiTriggerSecret' ) ;
1204- expect ( [ ... gated . errors , ... gated . advisories ] . map ( ( f ) => f . rule ) ) . not . toContain (
1205- FLOW_API_TRIGGER_SECRET_MISSING ,
1206- ) ;
1207- // Positive control: the same door still refuses a flow the family
1208- // proves dead, so the absence above is the wall, not a gate that ran
1209- // nothing .
1210- const dead = runRuntimeAuthoringRules ( {
1200+ expect ( gated . rulesRun ) . toContain ( 'validateFlowApiTriggerSecret ' ) ;
1201+ expect ( gated . errors . map ( ( f ) => [ f . rule , f . severity , f . path ] ) ) . toEqual ( [
1202+ [ FLOW_API_TRIGGER_SECRET_MISSING , 'error' , 'flows[0].nodes[0].config.secret' ] ,
1203+ ] ) ;
1204+ } ) ;
1205+
1206+ it ( '…and passes the same body when the host restores the secret at that position — withheld and stored is present' , ( ) => {
1207+ // The redactor registry's item-relative spelling, as the host states it .
1208+ const restored = runRuntimeAuthoringRules ( {
12111209 type : 'flow' ,
1212- item : {
1213- name : 'declared_dead' ,
1214- type : 'record_change' ,
1215- status : 'active' ,
1216- nodes : [ { id : 'start' , type : 'start' , config : { objectName : 'app_candidate' , triggerType : 'onCreate' } } ] ,
1217- } ,
1210+ item : secretless ,
1211+ restoredCredentialPaths : [ 'nodes.0.config.secret' ] ,
1212+ } ) ;
1213+ // The rule RAN — the pass is its verdict, not a gate that ran nothing.
1214+ expect ( restored . rulesRun ) . toContain ( 'validateFlowApiTriggerSecret' ) ;
1215+ expect ( restored . errors ) . toEqual ( [ ] ) ;
1216+ } ) ;
1217+
1218+ it ( 'a restored position excuses that position only — spelled against where the start node really sits' , ( ) => {
1219+ // The start node second: the host names `nodes.1…`, and the gate spells
1220+ // it `flows[0].nodes[1].config.secret`, the rule's own finding path.
1221+ const startSecond = apiFlow ( { hookId : 'intake' } , {
1222+ nodes : [
1223+ { id : 'end' , type : 'end' } ,
1224+ { id : 'start' , type : 'start' , config : { hookId : 'intake' } } ,
1225+ ] ,
12181226 } ) ;
1219- expect ( dead . errors . map ( ( f ) => f . rule ) ) . toContain ( FLOW_TRIGGER_UNROUTABLE ) ;
1227+ const at = ( paths : string [ ] ) =>
1228+ runRuntimeAuthoringRules ( { type : 'flow' , item : startSecond , restoredCredentialPaths : paths } ) . errors . map (
1229+ ( f ) => [ f . rule , f . path ] ,
1230+ ) ;
1231+ expect ( at ( [ 'nodes.1.config.secret' ] ) ) . toEqual ( [ ] ) ;
1232+ // A position that is not the start node's secret excuses nothing.
1233+ for ( const other of [ [ 'nodes.0.config.secret' ] , [ 'nodes.1.config.signingSecret' ] , [ 'nodes.1.config' ] ] ) {
1234+ expect ( at ( other ) , other . join ( ) ) . toEqual ( [ [ FLOW_API_TRIGGER_SECRET_MISSING , 'flows[0].nodes[1].config.secret' ] ] ) ;
1235+ }
1236+ } ) ;
1237+
1238+ it ( 'the rule reads the set in its own finding-path spelling, and the CLI table never forwards it' , ( ) => {
1239+ const findingPath = 'flows[0].nodes[0].config.secret' ;
1240+ expect ( validateFlowApiTriggerSecret ( stack , { restoredCredentialPaths : new Set ( [ findingPath ] ) } ) ) . toEqual ( [ ] ) ;
1241+ expect (
1242+ validateFlowApiTriggerSecret ( stack , { restoredCredentialPaths : new Set ( [ 'nodes.0.config.secret' ] ) } ) . map (
1243+ ( f ) => f . rule ,
1244+ ) ,
1245+ ) . toEqual ( [ FLOW_API_TRIGGER_SECRET_MISSING ] ) ;
1246+ // A CLI stack carries the author's own secret; the table does not hand
1247+ // a caller-stated set to any rule, so the refusal stands.
1248+ for ( const command of AUTHORING_COMMANDS ) {
1249+ const hits = runAuthoringRules ( command , {
1250+ normalized : stack ,
1251+ parsed : stack ,
1252+ restoredCredentialPaths : new Set ( [ findingPath ] ) ,
1253+ } ) . filter ( ( f ) => f . rule === FLOW_API_TRIGGER_SECRET_MISSING ) ;
1254+ expect ( hits . map ( ( f ) => f . path ) , command ) . toEqual ( [ findingPath ] ) ;
1255+ }
12201256 } ) ;
12211257 } ) ;
12221258 } ) ;
0 commit comments