@@ -644,3 +644,85 @@ describe('#20552 — the protocol serves the projection and executes the stored
644644 expect ( allStrings ( one ) ) . toContain ( FLOW_SECRET ) ;
645645 } ) ;
646646} ) ;
647+
648+ // ---------------------------------------------------------------------------
649+ // #20552 — the FIRST save of a CODE-AUTHORED flow (no `sys_metadata` row yet)
650+ // ---------------------------------------------------------------------------
651+ //
652+ // The dominant authoring shape for an inbound flow: the secret is a literal in
653+ // the app's own source, the flow reaches the protocol through the registry, and
654+ // NO overlay row exists until the first Studio save. The read serves the
655+ // registry body projected; the save door must carry the secret forward from
656+ // that same body, or the first overlay row is persisted WITHOUT it — and, since
657+ // an overlay wins every merge, the next boot registers a secretless `api` flow
658+ // the engine refuses, while the save said it succeeded.
659+
660+ /** A stub engine whose REGISTRY holds the code-authored flow, and whose `sys_metadata` holds nothing. */
661+ function makeRegistryOnlyEngine ( ) {
662+ const stub = makeStubEngine ( ) ;
663+ const registryFlow = storedInboundFlow ( ) ;
664+ stub . engine . registry . getItem = ( type : string , name : string ) =>
665+ ( type === 'flow' || type === 'flows' ) && name === 'inbound_hook' ? structuredClone ( registryFlow ) : undefined ;
666+ stub . engine . registry . listItems = ( type : string ) =>
667+ ( type === 'flow' || type === 'flows' ) ? [ structuredClone ( registryFlow ) ] : [ ] ;
668+ return stub ;
669+ }
670+
671+ const overlayRow = ( rows : Map < string , Row > , state : 'active' | 'draft' ) =>
672+ Array . from ( rows . values ( ) ) . find ( ( r ) => r . type === 'flow' && r . name === 'inbound_hook' && r . state === state ) ;
673+
674+ describe ( '#20552 — first save of a registry-only (code-authored) flow keeps its secret' , ( ) => {
675+ beforeEach ( ( ) => registerMetadataTypeRedactor ( 'flow' , flowStandInRedactor ) ) ;
676+
677+ it ( 'the served body saved straight back persists the overlay row WITH the stored secret' , async ( ) => {
678+ const { engine, rows } = makeRegistryOnlyEngine ( ) ;
679+ const protocol = new ObjectStackProtocolImplementation ( engine ) ;
680+ expect ( overlayRow ( rows , 'active' ) ) . toBeUndefined ( ) ;
681+
682+ const served : any = ( await protocol . getMetaItem ( { type : 'flow' , name : 'inbound_hook' } ) ) . item ;
683+ expect ( allStrings ( served ) ) . not . toContain ( FLOW_SECRET ) ;
684+ const { _diagnostics : _d , ...editable } = served ;
685+ void _d ;
686+ await protocol . saveMetaItem ( {
687+ type : 'flow' ,
688+ name : 'inbound_hook' ,
689+ item : { ...editable , label : 'Edited in the designer' , nodes : [ editable . nodes [ 1 ] , editable . nodes [ 0 ] ] } ,
690+ } ) ;
691+
692+ const row = overlayRow ( rows , 'active' ) ;
693+ expect ( row ) . toBeDefined ( ) ;
694+ const persisted = JSON . parse ( row ! . metadata ) ;
695+ expect ( persisted . label ) . toBe ( 'Edited in the designer' ) ;
696+ expect ( startNodeOf ( persisted ) . config . secret ) . toBe ( FLOW_SECRET ) ;
697+ } ) ;
698+
699+ it ( 'a DRAFT save then a publish carries the secret into the active row' , async ( ) => {
700+ const { engine, rows } = makeRegistryOnlyEngine ( ) ;
701+ const protocol = new ObjectStackProtocolImplementation ( engine ) ;
702+
703+ const served : any = ( await protocol . getMetaItem ( { type : 'flow' , name : 'inbound_hook' } ) ) . item ;
704+ const { _diagnostics : _d , ...editable } = served ;
705+ void _d ;
706+ await protocol . saveMetaItem ( { type : 'flow' , name : 'inbound_hook' , mode : 'draft' , item : { ...editable , label : 'Draft edit' } } ) ;
707+ expect ( startNodeOf ( JSON . parse ( overlayRow ( rows , 'draft' ) ! . metadata ) ) . config . secret ) . toBe ( FLOW_SECRET ) ;
708+
709+ await protocol . publishMetaItem ( { type : 'flow' , name : 'inbound_hook' } ) ;
710+ const active = overlayRow ( rows , 'active' ) ;
711+ expect ( active ) . toBeDefined ( ) ;
712+ const published = JSON . parse ( active ! . metadata ) ;
713+ expect ( published . label ) . toBe ( 'Draft edit' ) ;
714+ expect ( startNodeOf ( published ) . config . secret ) . toBe ( FLOW_SECRET ) ;
715+ } ) ;
716+
717+ it ( 'an explicit value on that first save is the author`s word — a rotation replaces it' , async ( ) => {
718+ const { engine, rows } = makeRegistryOnlyEngine ( ) ;
719+ const protocol = new ObjectStackProtocolImplementation ( engine ) ;
720+
721+ const served : any = ( await protocol . getMetaItem ( { type : 'flow' , name : 'inbound_hook' } ) ) . item ;
722+ const { _diagnostics : _d , ...rotated } = served ;
723+ void _d ;
724+ startNodeOf ( rotated ) . config . secret = ROTATED ;
725+ await protocol . saveMetaItem ( { type : 'flow' , name : 'inbound_hook' , item : rotated } ) ;
726+ expect ( startNodeOf ( JSON . parse ( overlayRow ( rows , 'active' ) ! . metadata ) ) . config . secret ) . toBe ( ROTATED ) ;
727+ } ) ;
728+ } ) ;
0 commit comments