@@ -42,6 +42,13 @@ import { createDispatcherPlugin } from './dispatcher-plugin.js';
4242 * (`rest-server.ts:834` constructs it; `route-manager.ts:191-213` calls
4343 * `server.get/post/put/delete/patch`). Modelling the mount would have been
4444 * weaker: this is the production registrar itself.
45+ *
46+ * ## What a fix has to invert
47+ *
48+ * The two §1 assertions spelled "does NOT count" are this card's acceptance
49+ * criterion in executable form. Whichever seam is chosen, BOTH have to flip
50+ * together — a change that flips one and leaves the other reproduces the
51+ * defect one surface over, which is the thing this card exists about.
4552 */
4653
4754const AUTH_BASE = '/api/v1/auth' ;
@@ -212,97 +219,226 @@ describe('#9650 §2 — SEAM A: registering the instrumented proxy back as `http
212219 expect ( calls ) . toContain ( 'patch:/q' ) ;
213220 } ) ;
214221
222+ it ( 'MEASURED: `registerService` REFUSES a second `http.server`; the seam needs `replaceService`' , async ( ) => {
223+ const kernel = new LiteKernel ( ) ;
224+ let registerError : string | undefined ;
225+ kernel . use ( {
226+ name : 'com.objectstack.test.provider' ,
227+ version : '1.0.0' ,
228+ providesServices : [ 'http.server' ] ,
229+ init : async ( ctx : PluginContext ) => { ctx . registerService ( 'http.server' , { id : 'raw' } ) ; } ,
230+ } as Plugin ) ;
231+ kernel . use ( {
232+ name : 'com.objectstack.test.reregistrar' ,
233+ version : '1.0.0' ,
234+ init : async ( ) => { } ,
235+ start : async ( ctx : PluginContext ) => {
236+ try {
237+ ctx . registerService ( 'http.server' , { id : 'proxy' } ) ;
238+ } catch ( err : any ) {
239+ registerError = String ( err ?. message ?? err ) ;
240+ }
241+ } ,
242+ } as Plugin ) ;
243+ await kernel . bootstrap ( ) ;
244+ await kernel . shutdown ( ) ;
245+
246+ // Not a detail: "register the proxy back as the service" cannot be
247+ // spelled with registerService at all (kernel-base.ts:79-81).
248+ expect ( registerError ) . toContain ( "already registered" ) ;
249+ } ) ;
250+
215251 it ( 'MEASURED: a consumer that resolved `http.server` in an EARLIER start() keeps the raw handle' , async ( ) => {
216252 const resolved : string [ ] = [ ] ;
217- const kernel = new LiteKernel ( ) ;
218253 const raw = { id : 'raw' } ;
219- const providerPlugin : Plugin = {
254+ const kernel = new LiteKernel ( ) ;
255+ kernel . use ( {
220256 name : 'com.objectstack.test.provider' ,
221257 version : '1.0.0' ,
222258 providesServices : [ 'http.server' ] ,
223259 init : async ( ctx : PluginContext ) => { ctx . registerService ( 'http.server' , raw ) ; } ,
224- } ;
225- const earlyConsumer : Plugin = {
260+ } as Plugin ) ;
261+ kernel . use ( {
226262 name : 'com.objectstack.test.early-consumer' ,
227263 version : '1.0.0' ,
228264 init : async ( ) => { } ,
229265 start : async ( ctx : PluginContext ) => {
230- resolved . push ( ( ctx . getService < any > ( 'http.server' ) as any ) . id ?? 'proxy' ) ;
266+ resolved . push ( ctx . getService < any > ( 'http.server' ) . id ) ;
231267 } ,
232- } ;
233- const lateRegistrar : Plugin = {
268+ } as Plugin ) ;
269+ kernel . use ( {
234270 name : 'com.objectstack.test.late-registrar' ,
235271 version : '1.0.0' ,
236272 init : async ( ) => { } ,
237273 start : async ( ctx : PluginContext ) => {
238274 const proxy = new Proxy ( raw , {
239275 get : ( t , p , r ) => ( p === 'id' ? 'proxy' : Reflect . get ( t , p , r ) ) ,
240276 } ) ;
241- ctx . registerService ( 'http.server' , proxy as any ) ;
277+ ctx . replaceService ( 'http.server' , proxy ) ;
242278 } ,
243- } ;
244- const lateConsumer : Plugin = {
279+ } as Plugin ) ;
280+ kernel . use ( {
245281 name : 'com.objectstack.test.late-consumer' ,
246282 version : '1.0.0' ,
247283 init : async ( ) => { } ,
248284 start : async ( ctx : PluginContext ) => {
249- resolved . push ( ( ctx . getService < any > ( 'http.server' ) as any ) . id ?? 'proxy' ) ;
285+ resolved . push ( ctx . getService < any > ( 'http.server' ) . id ) ;
250286 } ,
251- } ;
252- kernel . use ( providerPlugin ) ;
253- kernel . use ( earlyConsumer ) ;
254- kernel . use ( lateRegistrar ) ;
255- kernel . use ( lateConsumer ) ;
287+ } as Plugin ) ;
256288 await kernel . bootstrap ( ) ;
257289 await kernel . shutdown ( ) ;
258290
259- // This is ruling ②'s condition, measured: correctness of seam A is a
260- // function of Phase-2 position, and in the shipped composition REST
261- // (serve.ts:2548) starts BEFORE the dispatcher (serve.ts:2566).
291+ // Ruling ②'s condition, measured: seam A's correctness is a function of
292+ // Phase-2 position. In the shipped composition REST (serve.ts:2548)
293+ // starts BEFORE the dispatcher (serve.ts:2566), i.e. it is the 'raw'
294+ // row here — the unfavourable one.
262295 expect ( resolved ) . toEqual ( [ 'raw' , 'proxy' ] ) ;
263296 } ) ;
264297} ) ;
265298
266299describe ( '#9650 §3 — SEAM B/C: a raw-app Hono middleware, and when it is installed' , ( ) => {
267- it ( 'MEASURED: a middleware installed AFTER a route does not observe that route (seam B)' , async ( ) => {
268- const { Hono } = await import ( 'hono' ) ;
269- const app = new Hono ( ) ;
300+ /**
301+ * Seam B — installed during the dispatcher's `start()`, i.e. AFTER the
302+ * plugins that mount auth and REST have already run their own `start()`.
303+ * Measured on the REAL booted app, not a synthetic Hono instance.
304+ */
305+ it ( 'MEASURED: a middleware installed AFTER the routes does not observe them (seam B)' , async ( ) => {
306+ const metrics = new InMemoryMetricsRegistry ( ) ;
307+ const { kernel, baseUrl } = await bootMeasurementKernel ( metrics ) ;
308+ const httpServer = kernel . getService < IHttpServer > ( 'http.server' ) ;
309+ const rawApp = ( httpServer as unknown as { getRawApp ( ) : any } ) . getRawApp ( ) ;
310+
270311 const seen : string [ ] = [ ] ;
271- app . get ( '/early' , ( c : any ) => c . json ( { ok : true } ) ) ;
272- app . use ( '*' , async ( c : any , next : any ) => {
312+ // Registered now = after every plugin's start() mounted its routes.
313+ rawApp . use ( '*' , async ( c : any , next : any ) => {
273314 await next ( ) ;
274315 seen . push ( c . req . path ) ;
275316 } ) ;
276- app . get ( '/late' , ( c : any ) => c . json ( { ok : true } ) ) ;
317+ // A route mounted after the middleware, as the positive control.
318+ rawApp . get ( '/late-probe' , ( c : any ) => c . json ( { ok : true } ) ) ;
277319
278- await app . request ( '/early' ) ;
279- await app . request ( '/late' ) ;
320+ await fetch ( `${ baseUrl } ${ AUTH_PROBE } ` , { method : 'POST' } ) ;
321+ await fetch ( `${ baseUrl } ${ REST_PROBE } ` ) ;
322+ await fetch ( `${ baseUrl } ${ DISPATCHER_PROBE } ` ) ;
323+ await fetch ( `${ baseUrl } /late-probe` ) ;
324+ await kernel . shutdown ( ) ;
280325
281- // Hono composes the handlers that matched in REGISTRATION order; the
326+ // Hono composes the handlers that matched in REGISTRATION order, so a
282327 // route registered first answers and never calls next().
283- expect ( seen ) . toEqual ( [ '/late' ] ) ;
284- } ) ;
328+ expect ( seen ) . toContain ( '/late-probe' ) ;
329+ expect ( seen ) . not . toContain ( AUTH_PROBE ) ;
330+ expect ( seen ) . not . toContain ( REST_PROBE ) ;
331+ expect ( seen ) . not . toContain ( DISPATCHER_PROBE ) ;
332+ } , 30_000 ) ;
285333
286- it ( 'MEASURED: a middleware installed BEFORE every route observes all of them, incl. status (seam C)' , async ( ) => {
287- const { Hono } = await import ( 'hono' ) ;
288- const app = new Hono ( ) ;
334+ /**
335+ * Seam C — installed during Phase 1 (`init()`), before any route exists,
336+ * which is exactly where the adapter puts its own `installMiddlewareSeam()`.
337+ */
338+ it ( 'MEASURED: a middleware installed in Phase 1 observes BOTH mounts, with status (seam C)' , async ( ) => {
289339 const seen : Array < { route : string ; status : number } > = [ ] ;
290- app . use ( '*' , async ( c : any , next : any ) => {
291- await next ( ) ;
292- seen . push ( { route : c . req . routePath , status : c . res . status } ) ;
293- } ) ;
294- // Both mount styles, on the one app, after the middleware.
295- app . all ( `${ AUTH_BASE } /*` , ( c : any ) => c . json ( { ok : true } , 200 ) ) ;
296- app . get ( REST_PROBE , ( c : any ) => c . json ( { ok : true } , 201 ) ) ;
340+ const probePlugin : Plugin = {
341+ name : 'com.objectstack.test.phase1-observer' ,
342+ version : '1.0.0' ,
343+ requiresServices : [ 'http.server' ] ,
344+ init : async ( ctx : PluginContext ) => {
345+ const httpServer = ctx . getService < IHttpServer > ( 'http.server' ) ;
346+ const rawApp = ( httpServer as unknown as { getRawApp ( ) : any } ) . getRawApp ( ) ;
347+ rawApp . use ( '*' , async ( c : any , next : any ) => {
348+ await next ( ) ;
349+ seen . push ( { route : c . req . routePath , status : c . res . status } ) ;
350+ } ) ;
351+ } ,
352+ } ;
297353
298- await app . request ( AUTH_PROBE , { method : 'POST' } ) ;
299- await app . request ( REST_PROBE ) ;
354+ const kernel = new LiteKernel ( ) ;
355+ kernel . use ( new HonoServerPlugin ( { port : 0 , cors : false } ) ) ;
356+ kernel . use ( probePlugin ) ;
357+ kernel . use ( authLikePlugin ( ) ) ;
358+ kernel . use ( restLikePlugin ( ) ) ;
359+ kernel . use (
360+ createDispatcherPlugin ( {
361+ prefix : '/api/v1' ,
362+ securityHeaders : false ,
363+ observability : { metrics : new InMemoryMetricsRegistry ( ) } ,
364+ } as any ) ,
365+ ) ;
366+ await kernel . bootstrap ( ) ;
367+ const httpServer = kernel . getService < IHttpServer > ( 'http.server' ) ;
368+ const baseUrl = `http://127.0.0.1:${ httpServer . getPort ! ( ) } ` ;
300369
301- expect ( seen ) . toEqual ( [
302- { route : `${ AUTH_BASE } /*` , status : 200 } ,
303- { route : REST_PROBE , status : 201 } ,
304- ] ) ;
305- } ) ;
370+ await fetch ( `${ baseUrl } ${ AUTH_PROBE } ` , { method : 'POST' } ) ;
371+ await fetch ( `${ baseUrl } ${ REST_PROBE } ` ) ;
372+ await fetch ( `${ baseUrl } ${ DISPATCHER_PROBE } ` ) ;
373+ await kernel . shutdown ( ) ;
374+
375+ const routes = seen . map ( ( s ) => s . route ) ;
376+ expect ( routes ) . toContain ( `${ AUTH_BASE } /*` ) ;
377+ expect ( routes ) . toContain ( REST_PROBE ) ;
378+ expect ( routes ) . toContain ( DISPATCHER_PROBE ) ;
379+ // The status label the counter needs is readable here, which is what
380+ // the framework-agnostic `use()` seam cannot offer.
381+ expect ( seen . every ( ( s ) => typeof s . status === 'number' ) ) . toBe ( true ) ;
382+ } , 30_000 ) ;
383+
384+ /**
385+ * The exact boundary `serve.ts:3429-3435` claims is sufficient. That
386+ * comment governs how the unknown-hostname guard (and anything copied
387+ * from it) is installed, so the claim is measured rather than trusted:
388+ *
389+ * "Hono's `app.use('*')` is order-independent for matching, so as long
390+ * as the middleware is added before kernel:listening fires, it
391+ * intercepts every request regardless of which plugin registered its
392+ * handler."
393+ *
394+ * Every route in the platform is mounted in a plugin's `start()`, i.e.
395+ * in Phase 2 — strictly BEFORE `kernel:bootstrapped` and `kernel:listening`.
396+ */
397+ it ( 'MEASURED: a middleware installed at `kernel:bootstrapped` — before kernel:listening — still observes nothing' , async ( ) => {
398+ const seen : string [ ] = [ ] ;
399+ const lateObserver : Plugin = {
400+ name : 'com.objectstack.test.late-hook-observer' ,
401+ version : '1.0.0' ,
402+ requiresServices : [ 'http.server' ] ,
403+ init : async ( ctx : PluginContext ) => {
404+ const httpServer = ctx . getService < IHttpServer > ( 'http.server' ) ;
405+ const rawApp = ( httpServer as unknown as { getRawApp ( ) : any } ) . getRawApp ( ) ;
406+ // Subscribed in init(), but the middleware is INSTALLED from
407+ // the hook — after every plugin's start() has mounted routes,
408+ // and still before kernel:listening.
409+ ( ctx as any ) . hook ( 'kernel:bootstrapped' , async ( ) => {
410+ rawApp . use ( '*' , async ( c : any , next : any ) => {
411+ await next ( ) ;
412+ seen . push ( c . req . path ) ;
413+ } ) ;
414+ } ) ;
415+ } ,
416+ } ;
417+
418+ const kernel = new LiteKernel ( ) ;
419+ kernel . use ( new HonoServerPlugin ( { port : 0 , cors : false } ) ) ;
420+ kernel . use ( lateObserver ) ;
421+ kernel . use ( authLikePlugin ( ) ) ;
422+ kernel . use ( restLikePlugin ( ) ) ;
423+ kernel . use (
424+ createDispatcherPlugin ( {
425+ prefix : '/api/v1' ,
426+ securityHeaders : false ,
427+ observability : { metrics : new InMemoryMetricsRegistry ( ) } ,
428+ } as any ) ,
429+ ) ;
430+ await kernel . bootstrap ( ) ;
431+ const httpServer = kernel . getService < IHttpServer > ( 'http.server' ) ;
432+ const baseUrl = `http://127.0.0.1:${ httpServer . getPort ! ( ) } ` ;
433+
434+ await fetch ( `${ baseUrl } ${ AUTH_PROBE } ` , { method : 'POST' } ) ;
435+ await fetch ( `${ baseUrl } ${ REST_PROBE } ` ) ;
436+ await fetch ( `${ baseUrl } ${ DISPATCHER_PROBE } ` ) ;
437+ await kernel . shutdown ( ) ;
438+
439+ // "Before kernel:listening" is NOT sufficient — Phase 1 is.
440+ expect ( seen ) . toEqual ( [ ] ) ;
441+ } , 30_000 ) ;
306442
307443 it ( 'MEASURED: the IHttpServer `use()` seam cannot observe status — it runs BEFORE dispatch' , async ( ) => {
308444 const metrics = new InMemoryMetricsRegistry ( ) ;
@@ -323,5 +459,5 @@ describe('#9650 §3 — SEAM B/C: a raw-app Hono middleware, and when it is inst
323459 // order-independent seam cannot carry a `{status}` counter.
324460 expect ( observed [ 0 ] ) . not . toHaveProperty ( 'status' ) ;
325461 expect ( observed [ 0 ] . body ) . toBeUndefined ( ) ;
326- } ) ;
462+ } , 30_000 ) ;
327463} ) ;
0 commit comments