dsh-zen-remote 2.3.3 → 2.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/docs/plugin-bridge.md +14 -7
  4. package/docs/remote-access.en.md +8 -8
  5. package/docs/remote-access.md +10 -10
  6. package/lib/adapters/dsh-better-sidebar/index.js +21 -8
  7. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  8. package/lib/adapters/dsh-better-sidebar/surface.js +79 -0
  9. package/lib/adapters/dsh-better-sidebar/surface.js.map +1 -0
  10. package/lib/client.js +102 -30
  11. package/lib/index.js +103 -10
  12. package/lib/index.js.map +1 -1
  13. package/lib/intercept.js +23 -1
  14. package/lib/intercept.js.map +1 -1
  15. package/lib/merge-streams.js +29 -2
  16. package/lib/merge-streams.js.map +1 -1
  17. package/lib/plugin-adapter-core.js +69 -13
  18. package/lib/plugin-adapter-core.js.map +1 -1
  19. package/lib/plugin-bridge.js +179 -12
  20. package/lib/plugin-bridge.js.map +1 -1
  21. package/lib/plugin-route-intercept.js +631 -79
  22. package/lib/plugin-route-intercept.js.map +1 -1
  23. package/lib/plugin-tunnel.js +84 -12
  24. package/lib/plugin-tunnel.js.map +1 -1
  25. package/lib/relay-server.js +65 -47
  26. package/lib/relay-server.js.map +1 -1
  27. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  28. package/lib/types/adapters/dsh-better-sidebar/surface.d.ts +23 -0
  29. package/lib/types/adapters/dsh-better-sidebar/surface.d.ts.map +1 -0
  30. package/lib/types/adapters/types.d.ts +14 -3
  31. package/lib/types/adapters/types.d.ts.map +1 -1
  32. package/lib/types/client/effects/turn-fold.d.ts +42 -0
  33. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  34. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
  35. package/lib/types/index.d.ts +2 -2
  36. package/lib/types/index.d.ts.map +1 -1
  37. package/lib/types/intercept.d.ts +19 -0
  38. package/lib/types/intercept.d.ts.map +1 -1
  39. package/lib/types/merge-streams.d.ts +12 -0
  40. package/lib/types/merge-streams.d.ts.map +1 -1
  41. package/lib/types/plugin-adapter-core.d.ts +24 -11
  42. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  43. package/lib/types/plugin-bridge.d.ts +93 -5
  44. package/lib/types/plugin-bridge.d.ts.map +1 -1
  45. package/lib/types/plugin-route-intercept.d.ts +52 -3
  46. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  47. package/lib/types/plugin-tunnel.d.ts +27 -3
  48. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  49. package/lib/types/relay-server.d.ts +2 -25
  50. package/lib/types/relay-server.d.ts.map +1 -1
  51. package/lib/types/upload-limits.d.ts +67 -0
  52. package/lib/types/upload-limits.d.ts.map +1 -0
  53. package/lib/types/wizard/proxy-snippets.d.ts +4 -3
  54. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  55. package/lib/upload-limits.js +67 -0
  56. package/lib/upload-limits.js.map +1 -0
  57. package/lib/wizard/proxy-snippets.js +5 -4
  58. package/lib/wizard/proxy-snippets.js.map +1 -1
  59. package/package.json +38 -38
  60. package/scripts/check-upload-limits.mjs +167 -0
  61. package/src/adapters/dsh-better-sidebar/index.ts +21 -9
  62. package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
  63. package/src/adapters/types.ts +14 -3
  64. package/src/client/effects/turn-fold.ts +97 -26
  65. package/src/client/styles/turn-fold.css.ts +9 -0
  66. package/src/client-data/wizard.ts +1 -1
  67. package/src/index.ts +99 -12
  68. package/src/intercept.ts +47 -1
  69. package/src/merge-streams.ts +41 -1
  70. package/src/plugin-adapter-core.ts +69 -14
  71. package/src/plugin-bridge.ts +228 -14
  72. package/src/plugin-route-intercept.ts +677 -82
  73. package/src/plugin-tunnel.ts +95 -13
  74. package/src/relay-server.ts +60 -48
  75. package/src/upload-limits.ts +69 -0
  76. package/src/wizard/proxy-snippets.ts +5 -4
@@ -9,9 +9,44 @@
9
9
  * cannot be served locally (the session lives on the server): the surrogate
10
10
  * handler forwards it through `relay.pluginRequest` (PB-3's
11
11
  * `relay/v1/plugin`) with the real id restored, and streams the answer
12
- * back. Everything else — undeclared routes, local sessions — flows to the
12
+ * back (PB-21: where the id lives is the route's own declaration — the 2.1
13
+ * query parameter, or a declaration-v2 locator in a prefix route's path
14
+ * segment or the JSON body; the v2 shapes ride the SAME adapter-core
15
+ * locate → classify → rewrite functions the adapters run, and a
16
+ * `prefix: true` route forwards its full matched, rewritten path).
17
+ * Everything else — undeclared routes, local sessions — flows to the
13
18
  * original route untouched.
14
19
  *
20
+ * PB-22 gives the two remaining declaration-v2 flags their client meaning:
21
+ * a `deny: true` route answers the uniform 503 with reason `route-denied`
22
+ * whenever a REMOTE context touches it — the request locating a virtual id,
23
+ * or the browser's CURRENT session being one (T62's signal) — while a local
24
+ * session passes through exactly as before; a `rewriteIds: true` route's
25
+ * JSON answer is buffered (the 8 MB adapter cap) and deep-scanned, every
26
+ * string EXACTLY equal to one of the request's own real session ids
27
+ * rewritten to its virtual form (`zr~` carries the real id inside, so the
28
+ * request's own hits ARE the read-only mapping — no table is consulted or
29
+ * written), unknown strings passing through untouched.
30
+ *
31
+ * PB-25a adds the position form: `rewriteIds: { fields }` declares the dot
32
+ * paths where the response's NEWLY-CREATED session ids sit (a creation
33
+ * route's `childId` was never in the request, so no value scan can catch
34
+ * it). On top of the value scan, each declared path's non-empty, non-virtual
35
+ * string gains the virtual shell of THIS request's server id; missing paths
36
+ * are no-ops and nothing is ever guessed beyond the declared positions.
37
+ *
38
+ * PB-25b widens the value scan's lookup from the request-local mapping to
39
+ * the request-local mapping UNION the KNOWN-real-session inventory: the
40
+ * merged global streams report every real id the sub-client currently holds
41
+ * (the sidebar's `zr~` rows — subagents.live's object keys, workflows.list
42
+ * rows: ids in the browser's view but not in THIS request), and a string
43
+ * exactly equal to one of those is rewritten too, in the same request-sourced
44
+ * virtual shell. The inventory is READ-ONLY consumption — the scan never
45
+ * touches merge state — and it is read live per answer: a stream refresh
46
+ * replaces its share, and the next answer sees the fresh union (ids that
47
+ * appear between two refreshes wait for the next refresh — the documented
48
+ * ceiling). Without the option the scan is exactly the PB-22 behavior.
49
+ *
15
50
  * The un-bridged refusals answer a UNIFORM 503 shape (header
16
51
  * `x-zen-remote: unbridged`, JSON body naming the reason) — and, decisive
17
52
  * for the security posture, they answer WITHOUT the local plugin handler
@@ -26,8 +61,8 @@
26
61
  */
27
62
 
28
63
  import type { IncomingMessage, ServerResponse } from 'node:http'
29
- import type { BridgeRegistry, BridgeStatus } from './plugin-bridge.js'
30
- import { routeMethodsOf, routeMaxBodyOf, routeUpgradesOf } from './plugin-bridge.js'
64
+ import type { BridgeLocator, BridgeRegistry, BridgeStatus, NormalizedBridgeRoute } from './plugin-bridge.js'
65
+ import { routeDeniesRemoteOf, routeLocatorOf, routeMaxBodyOf, routeMethodsOf, routePrefixOf, routeRewriteFieldsOf, routeRewritesIdsOf, routeUpgradesOf } from './plugin-bridge.js'
31
66
  import { PassThrough, Readable, Transform } from 'node:stream'
32
67
  import type { Socket } from 'node:net'
33
68
  import type { RelayClient } from './relay-client.js'
@@ -47,6 +82,179 @@ function isRecord(value: unknown): value is Record<string, unknown> {
47
82
  return typeof value === 'object' && value !== null && !Array.isArray(value)
48
83
  }
49
84
 
85
+ /** PB-22: the rewriteIds deep scan — every string in the parsed JSON that is
86
+ * EXACTLY a key of `known` becomes its virtual form; everything else passes
87
+ * through untouched. The mapping's membership is the ONLY rewrite trigger —
88
+ * no shape guessing (a session-id-LOOKING string the request never carried
89
+ * is not ours to rewrite), and object KEYS are left alone: the scan walks
90
+ * object values, array items and bare strings, exactly the positions a
91
+ * response can hand an id back to the browser in. Containers are rebuilt
92
+ * unconditionally, which keeps the rule obvious at the cost of allocations
93
+ * bounded by the 8 MB buffer cap.
94
+ *
95
+ * PB-25b adds the second lookup: a string exactly equal to a member of
96
+ * `knownIds` (the merged streams' known-real-session inventory, `undefined`
97
+ * when the wiring passed none — the scan then stays exactly the request-local
98
+ * behavior) is rewritten into THIS request's virtual shell, same source as
99
+ * the position rewrite's serverId. Already-virtual strings are never
100
+ * re-wrapped, and the set is read-only: the scan consults it, never mutates
101
+ * it. */
102
+ function deepRewriteIds(
103
+ value: unknown,
104
+ known: ReadonlyMap<string, string>,
105
+ knownIds: ReadonlySet<string> | undefined,
106
+ serverId: string,
107
+ ): unknown {
108
+ if (typeof value === 'string') {
109
+ const mapped = known.get(value)
110
+ if (mapped !== undefined) return mapped
111
+ if (knownIds !== undefined && !isVirtual(value) && knownIds.has(value)) return toVirtual(serverId, value)
112
+ return value
113
+ }
114
+ if (Array.isArray(value)) return value.map((item) => deepRewriteIds(item, known, knownIds, serverId))
115
+ if (isRecord(value)) {
116
+ const out: Record<string, unknown> = {}
117
+ for (const [key, item] of Object.entries(value)) out[key] = deepRewriteIds(item, known, knownIds, serverId)
118
+ return out
119
+ }
120
+ return value
121
+ }
122
+
123
+ /** PB-25a: the position-based half of a `rewriteIds: { fields }` route's
124
+ * rewrite. Runs AFTER the value scan above, on the (rebuilt, private) parsed
125
+ * answer: each declared dot path is walked object-by-object, and a path that
126
+ * exists and holds a NON-EMPTY, NON-virtual string gains its virtual shell
127
+ * (`zr~<serverId>~<id>`, the serverId of the request this answer belongs to
128
+ * — a forward always located one). A path that does not exist — or that
129
+ * would walk through an array or a scalar — is a no-op: the answer's shape
130
+ * is the plugin's business, never a rewrite trigger (no shape guessing), and
131
+ * array elements stay the value scan's business (the declaration refuses
132
+ * numeric segments outright). Already-virtual values are skipped, so a known
133
+ * id sitting on a declared path is rewritten exactly once, by whichever half
134
+ * reached it first. */
135
+ function rewriteIdsAtFields(root: unknown, fields: readonly string[], serverId: string): void {
136
+ for (const field of fields) {
137
+ const segments = field.split('.')
138
+ let node: unknown = root
139
+ for (const segment of segments.slice(0, -1)) {
140
+ node = isRecord(node) ? node[segment] : undefined
141
+ }
142
+ if (!isRecord(node)) continue
143
+ const key = segments[segments.length - 1]
144
+ const value = node[key]
145
+ if (typeof value === 'string' && value !== '' && !isVirtual(value)) node[key] = toVirtual(serverId, value)
146
+ }
147
+ }
148
+
149
+ /** Pump a response body reader into the browser response with real
150
+ * backpressure — the one discipline every verbatim passthrough runs. The
151
+ * `close` listener settles the wait when the browser vanishes mid-body (a
152
+ * lost drain event would otherwise hang the write loop). */
153
+ async function pumpResponse(
154
+ res: ServerResponse,
155
+ reader: { read(): Promise<{ done: boolean; value?: Uint8Array }> },
156
+ ): Promise<void> {
157
+ while (true) {
158
+ const { done, value } = await reader.read()
159
+ if (done) break
160
+ if (!res.write(value)) {
161
+ await new Promise<void>((resolve) => {
162
+ const settled = (): void => {
163
+ res.off('drain', settled)
164
+ res.off('close', settled)
165
+ resolve()
166
+ }
167
+ res.once('drain', settled)
168
+ res.once('close', settled)
169
+ })
170
+ }
171
+ }
172
+ }
173
+
174
+ /** PB-22/PB-25a/PB-25b: a rewriteIds route's JSON answer — buffered whole
175
+ * (the 8 MB adapter cap), deep-scanned with the request's own real→virtual
176
+ * mapping PLUS the known-real-session inventory (read live by the caller),
177
+ * plus, when the route declared field paths, the position rewrite on those
178
+ * paths, re-serialized with a corrected content-length. Past the cap, a
179
+ * non-JSON content type, or a parse failure the answer passes through
180
+ * untouched (the adapter discipline): a rewrite is strictly
181
+ * better-than-nothing, never a reason to mangle or refuse an answer. The
182
+ * caller has already handled the relay's refusal marker — this owns the
183
+ * entire normal answer. */
184
+ async function answerRewriteIds(
185
+ res: ServerResponse,
186
+ response: Response,
187
+ known: ReadonlyMap<string, string>,
188
+ fields: readonly string[] | undefined,
189
+ serverId: string,
190
+ knownIds: ReadonlySet<string> | undefined,
191
+ ): Promise<void> {
192
+ const contentType = response.headers.get('content-type')
193
+ const verbatimHeaders = {
194
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
195
+ ...(response.headers.get('content-length') !== null ? { 'content-length': response.headers.get('content-length')! } : {}),
196
+ ...(response.headers.get('cache-control') !== null ? { 'cache-control': response.headers.get('cache-control')! } : {}),
197
+ }
198
+ const reader = response.body?.getReader()
199
+ if (reader === undefined || contentType === null || !contentType.includes('application/json')) {
200
+ res.writeHead(response.status, verbatimHeaders)
201
+ if (reader !== undefined) await pumpResponse(res, reader)
202
+ res.end()
203
+ return
204
+ }
205
+ const chunks: Buffer[] = []
206
+ let size = 0
207
+ let overflow = false
208
+ while (true) {
209
+ const { done, value } = await reader.read()
210
+ if (done) break
211
+ size += value.length
212
+ // The over-cap chunk ITSELF is body content — it lands in the
213
+ // passthrough buffer too; only the REWRITE is cancelled.
214
+ chunks.push(Buffer.from(value))
215
+ if (size > MAX_ADAPTER_RESPONSE_BUFFER_BYTES) {
216
+ overflow = true
217
+ break
218
+ }
219
+ }
220
+ if (overflow) {
221
+ res.writeHead(response.status, verbatimHeaders)
222
+ for (const chunk of chunks) {
223
+ if (!res.write(chunk)) {
224
+ await new Promise<void>((resolve) => {
225
+ const settled = (): void => {
226
+ res.off('drain', settled)
227
+ res.off('close', settled)
228
+ resolve()
229
+ }
230
+ res.once('drain', settled)
231
+ res.once('close', settled)
232
+ })
233
+ }
234
+ }
235
+ await pumpResponse(res, reader)
236
+ res.end()
237
+ return
238
+ }
239
+ const text = Buffer.concat(chunks).toString('utf8')
240
+ let outBody: Buffer
241
+ try {
242
+ const scanned = deepRewriteIds(JSON.parse(text), known, knownIds, serverId)
243
+ if (fields !== undefined) rewriteIdsAtFields(scanned, fields, serverId)
244
+ outBody = Buffer.from(JSON.stringify(scanned), 'utf8')
245
+ } catch {
246
+ // Not JSON after all (or the scan hit something unserializable): the
247
+ // original bytes pass through, length recomputed for the corrected
248
+ // header set.
249
+ outBody = Buffer.from(text, 'utf8')
250
+ res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) })
251
+ res.end(outBody)
252
+ return
253
+ }
254
+ res.writeHead(response.status, { ...verbatimHeaders, 'content-length': String(outBody.length) })
255
+ res.end(outBody)
256
+ }
257
+
50
258
  /** Pure inspection of the RAW webServer instance — nothing is mutated, so a
51
259
  * failed check leaves every route exactly as it was. */
52
260
  export function checkWebServerShape(webServer: unknown): WebServerShapeCheck {
@@ -118,9 +326,23 @@ export interface InstallPluginRouteInterceptOptions {
118
326
  * read live per call). A VIRTUAL id means a remote session's panel is
119
327
  * the request's origin — the signal an adapter route's `deny` hook uses
120
328
  * to refuse requests (like better-sidebar's `open.external`) that carry
121
- * no session field of their own. Absent → deny hooks see `undefined`
122
- * and judge conservatively. */
329
+ * no session field of their own, and (PB-22) the same-origin wall a
330
+ * cooperative route's `deny: true` raises. Absent → deny judges
331
+ * conservatively (only the request's own ids speak). */
123
332
  getCurrentSession?: () => string | undefined
333
+ /** PB-25b: the KNOWN real session ids of this sub-client — the merged
334
+ * global streams' session inventories, unioned by the wiring. Read LIVE
335
+ * per rewriteIds answer, not captured at install: the streams report after
336
+ * the intercept installs, and a stream refresh replaces its share
337
+ * replace-style, so a getter is the only form that sees the fresh union —
338
+ * the same live-read discipline as getServerId/getStatus/getCurrentSession
339
+ * above. A rewriteIds route's value scan rewrites a string exactly equal
340
+ * to a member even when THIS request never carried it (the id already sits
341
+ * in the browser's view — subagents.live's object keys, workflows.list
342
+ * rows), wrapped in THIS request's virtual shell. The set is READ-ONLY
343
+ * consumption: nothing here mutates merge state. Absent → the scan is
344
+ * exactly the request-local PB-22 behavior. */
345
+ getKnownRealIds?: () => ReadonlySet<string>
124
346
  /** Progress logging, wired to the context logger by index.ts. */
125
347
  log?: (format: string, ...args: unknown[]) => void
126
348
  }
@@ -141,14 +363,59 @@ interface DeclaredHit {
141
363
  plugin: string
142
364
  version: string
143
365
  digest: string
144
- /** The declared route path — the exact string the forward uses. */
366
+ /** The declared route path — what an exact route forwards verbatim (a
367
+ * prefix route forwards its full matched pathname instead). */
145
368
  path: string
146
- sessionParam: string
369
+ /** The EFFECTIVE session-id locator (PB-21, {@link routeLocatorOf}): the
370
+ * declared locator, or the 2.1 query-parameter convention read as one —
371
+ * a route never runs without a locating rule. */
372
+ locator: BridgeLocator
373
+ /** The EFFECTIVE prefix mark (PB-21, {@link routePrefixOf}): the route
374
+ * admits its variable path tail. */
375
+ prefix: boolean
147
376
  /** The EFFECTIVE forwardable methods (PB-9): the declared list, or
148
377
  * `['GET']` when the route declared none. */
149
378
  methods: string[]
150
379
  /** The EFFECTIVE body cap (PB-9), in bytes. */
151
380
  maxBody: number
381
+ /** PB-22: the route refuses a REMOTE context outright (`deny: true` —
382
+ * local-only data): the surrogate answers `route-denied` instead of
383
+ * forwarding or passing through whenever the request carries a virtual id
384
+ * or the browser's current session is one. */
385
+ deny: boolean
386
+ /** PB-22: the route's JSON answers are deep-scanned for the request's own
387
+ * real session ids and re-serialized with their virtual forms. Both
388
+ * `rewriteIds` forms mark it true. */
389
+ rewriteIds: boolean
390
+ /** PB-25a: the declared field paths of a `{ fields }`-form `rewriteIds`
391
+ * route — where the response's NEWLY-CREATED session ids sit. `undefined`
392
+ * for a `true` route (value scan only) and for a route without the field. */
393
+ rewriteFields: readonly string[] | undefined
394
+ /** The same effective values folded into the adapter-core route shape —
395
+ * the object locate/classify/rewrite consume (PB-21, see
396
+ * {@link bridgeRouteAsAdapterRoute}). */
397
+ view: NormalizedAdapterRoute
398
+ }
399
+
400
+ /** The NormalizedBridgeRoute read through the adapter-core route shapes
401
+ * (PB-21): the declaration-v2 locators must ride the SAME locate →
402
+ * classify → rewrite functions the adapters run — never a second copy of
403
+ * those rules — so the cooperative route is handed to them as the
404
+ * NormalizedAdapterRoute those functions consume. Every field comes from a
405
+ * plugin-bridge effective reader; `deny` stays unset — the cooperative
406
+ * `deny: true` flag is a static declaration, not an adapter-style hook, so
407
+ * the surrogate halves judge it themselves (PB-22). */
408
+ function bridgeRouteAsAdapterRoute(route: NormalizedBridgeRoute): NormalizedAdapterRoute {
409
+ return {
410
+ path: route.path,
411
+ match: routePrefixOf(route) ? 'prefix' : 'exact',
412
+ methods: routeMethodsOf(route),
413
+ maxBody: routeMaxBodyOf(route),
414
+ stream: route.stream,
415
+ upgrade: routeUpgradesOf(route),
416
+ locator: routeLocatorOf(route),
417
+ local: false,
418
+ }
152
419
  }
153
420
 
154
421
  export function installPluginRouteIntercept(options: InstallPluginRouteInterceptOptions): PluginRouteInterceptHandle {
@@ -182,6 +449,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
182
449
  const { webServer, registry, relay, getServerId, getStatus, log } = options
183
450
  const adapters = options.adapters ?? []
184
451
  const getCurrentSession = options.getCurrentSession
452
+ const getKnownRealIds = options.getKnownRealIds
185
453
  const target = webServer as { match: (this: unknown, pathname: string) => unknown }
186
454
  const savedMatch = target.match
187
455
 
@@ -244,30 +512,42 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
244
512
  ? (route as { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void> }).handler
245
513
  : undefined
246
514
 
247
- /** The declared route this pathname hits — EXACT equality only this task
248
- * (a declared prefix does not cover longer paths; that arrives with
249
- * PB-12). First plugin in registry order wins, deterministically. */
515
+ /** The declared route this pathname hits — exact equality, or, for a
516
+ * route declared `prefix: true` (PB-21), equality or one path segment
517
+ * deeper on the RAW undecoded pathname. The rule is `adapterPathMatches`
518
+ * itself, run against the route's adapter-core view, so the cooperative
519
+ * match can never drift from the adapters'. First plugin in registry
520
+ * order wins, and within it the first route in declaration order — a
521
+ * pathname two routes of one plugin can both hit belongs to the earlier
522
+ * one, exactly as the exact-only lookup always did. */
250
523
  const declaredRouteOf = (pathname: string): DeclaredHit | undefined => {
251
524
  for (const declaration of registry.list()) {
252
525
  for (const route of declaration.routes) {
253
- if (route.path === pathname) {
254
- return {
255
- plugin: declaration.plugin,
256
- version: declaration.version,
257
- digest: declaration.digest,
258
- path: route.path,
259
- sessionParam: route.sessionParam,
260
- methods: routeMethodsOf(route),
261
- maxBody: routeMaxBodyOf(route),
262
- }
526
+ const view = bridgeRouteAsAdapterRoute(route)
527
+ if (!adapterPathMatches(view, pathname)) continue
528
+ return {
529
+ plugin: declaration.plugin,
530
+ version: declaration.version,
531
+ digest: declaration.digest,
532
+ path: route.path,
533
+ locator: routeLocatorOf(route),
534
+ prefix: routePrefixOf(route),
535
+ methods: routeMethodsOf(route),
536
+ maxBody: routeMaxBodyOf(route),
537
+ deny: routeDeniesRemoteOf(route),
538
+ rewriteIds: routeRewritesIdsOf(route),
539
+ rewriteFields: routeRewriteFieldsOf(route),
540
+ view,
263
541
  }
264
542
  }
265
543
  }
266
544
  return undefined
267
545
  }
268
546
 
269
- /** The surrogate handler: ONE query parse, the virtual-id gate, the
270
- * unbridged walls in contract order, then the relay round-trip. */
547
+ /** The surrogate handler, query locator's half (every 2.1 route and an
548
+ * explicit query declaration): ONE query parse, the virtual-id gate, the
549
+ * unbridged walls in contract order, then the relay round-trip. A
550
+ * declaration-v2 locator dispatches to the PB-21 half below. */
271
551
  const surrogateHandler = (declared: DeclaredHit, originalHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined) =>
272
552
  async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
273
553
  const passthrough = async (): Promise<void> => {
@@ -284,16 +564,28 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
284
564
  await passthrough()
285
565
  return
286
566
  }
567
+ // PB-21: a v2 locator (path-segment / json-body) runs the adapter-core
568
+ // pipeline in its own half — the query locator keeps THIS code path
569
+ // verbatim. The dispatch sits after the installed check, so the v2
570
+ // half never runs chained-over either.
571
+ if (declared.locator.in !== 'query') {
572
+ await surrogateV2Handler(declared, originalHandler)(req, res)
573
+ return
574
+ }
287
575
  // ONE parse of the query (the session parameter's home — `match` only
288
- // ever saw the pathname): everything below reads THIS parse.
289
- let searchParams: URLSearchParams
576
+ // ever saw the pathname): everything below reads THIS parse. The
577
+ // parameter NAME is the effective locator's (the 2.1 sessionParam for
578
+ // a route that declared no locator — the same value routeLocatorOf
579
+ // reads back out).
580
+ let requestUrl: URL
290
581
  try {
291
- searchParams = new URL(req.url ?? '/', 'http://local').searchParams
582
+ requestUrl = new URL(req.url ?? '/', 'http://local')
292
583
  } catch {
293
584
  answerUnbridged(res, declared.plugin, 'bad-query')
294
585
  return
295
586
  }
296
- const values = searchParams.getAll(declared.sessionParam)
587
+ const searchParams = requestUrl.searchParams
588
+ const values = searchParams.getAll(declared.locator.name)
297
589
  // The parameter appearing MORE than once is a shape violation the
298
590
  // bridge refuses (bad-query) — never a passthrough, and never a
299
591
  // forward of whichever duplicate a parser happens to keep. Its TOTAL
@@ -304,6 +596,20 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
304
596
  return
305
597
  }
306
598
  const value = values.length === 1 ? values[0] : undefined
599
+ // PB-22: a `deny: true` route refuses a REMOTE context outright — the
600
+ // request locating a virtual id, or the browser's CURRENT session
601
+ // being one (T62's signal; requests like open.external carry no id of
602
+ // their own). The uniform 503 with the route's own reason: the local
603
+ // handler is never reached, and the forward path below is unreachable
604
+ // for this route. A local session's request without a virtual id falls
605
+ // through — `deny` must not touch local behavior.
606
+ if (declared.deny) {
607
+ const current = getCurrentSession?.()
608
+ if ((current !== undefined && isVirtual(current)) || (value !== undefined && value.startsWith('zr~'))) {
609
+ answerUnbridged(res, declared.plugin, 'route-denied')
610
+ return
611
+ }
612
+ }
307
613
  // A LOCAL id (or none at all): the original route's business — its
308
614
  // handler runs exactly as if we were never here.
309
615
  if (value === undefined || value === '' || !value.startsWith('zr~')) {
@@ -354,7 +660,15 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
354
660
  log?.('plugin route %s forwards for %s', declared.path, declared.plugin)
355
661
  // The session id rides the CHECKED parse, restored to its ORIGINAL
356
662
  // form, re-serialized once — what was judged is what is forwarded.
357
- searchParams.set(declared.sessionParam, parts.id)
663
+ searchParams.set(declared.locator.name, parts.id)
664
+ // PB-22: the request's own real→virtual pair — the READ-ONLY mapping a
665
+ // rewriteIds route's answer scan consults (`zr~` carries the real id
666
+ // inside, so the ids this very request named are exactly the ones the
667
+ // answer may hand back). PB-25b: the scan consults ONE more source
668
+ // beside this mapping — the known-real-session inventory, read live at
669
+ // the answer (never copied here) and consumed read-only; nothing here
670
+ // writes any session table.
671
+ const idMapping: ReadonlyMap<string, string> = new Map([[parts.id, value]])
358
672
  const controller = new AbortController()
359
673
  // The counting gate's failure has its OWN abort signal (PB-9-fix, the
360
674
  // mirror of the relay route's bodyGateError): tripping the gate is a
@@ -408,7 +722,11 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
408
722
  plugin: declared.plugin,
409
723
  version: declared.version,
410
724
  digest: declared.digest,
411
- path: declared.path,
725
+ // A prefix route (PB-21) forwards its FULL matched pathname —
726
+ // the declared base alone would lose the variable tail; an
727
+ // exact route's pathname IS the declared path, the bytes that
728
+ // always travelled.
729
+ path: declared.prefix ? requestUrl.pathname : declared.path,
412
730
  query: searchParams.toString(),
413
731
  method,
414
732
  ...(hasBody ? { body: bodyStream } : {}),
@@ -428,6 +746,17 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
428
746
  answerUnbridged(res, declared.plugin, refusal)
429
747
  return
430
748
  }
749
+ // PB-22: a rewriteIds route's answer is buffered and deep-scanned
750
+ // (the ids this request named come back virtual); everything else —
751
+ // non-JSON answers included — streams verbatim. PB-25a: a
752
+ // `{ fields }` declaration adds the position rewrite, under THIS
753
+ // request's own server id. PB-25b: the scan's lookup widens to the
754
+ // known inventory, read live right here (undefined when the wiring
755
+ // passed no getter — the scan stays request-local).
756
+ if (declared.rewriteIds) {
757
+ await answerRewriteIds(res, response, idMapping, declared.rewriteFields, parts.serverId, getKnownRealIds?.())
758
+ return
759
+ }
431
760
  const contentType = response.headers.get('content-type')
432
761
  const contentLength = response.headers.get('content-length')
433
762
  const cacheControl = response.headers.get('cache-control')
@@ -439,23 +768,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
439
768
  // Stream the body through with real backpressure; a mid-stream
440
769
  // failure with headers already out can only cut the response.
441
770
  const reader = response.body?.getReader()
442
- if (reader !== undefined) {
443
- while (true) {
444
- const { done, value } = await reader.read()
445
- if (done) break
446
- if (!res.write(value)) {
447
- await new Promise<void>((resolve) => {
448
- const settled = (): void => {
449
- res.off('drain', settled)
450
- res.off('close', settled)
451
- resolve()
452
- }
453
- res.once('drain', settled)
454
- res.once('close', settled)
455
- })
456
- }
457
- }
458
- }
771
+ if (reader !== undefined) await pumpResponse(res, reader)
459
772
  res.end()
460
773
  } catch (error) {
461
774
  // The gate's local flag FIRST (PB-9-fix): the counting gate tripped
@@ -492,6 +805,304 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
492
805
  }
493
806
  }
494
807
 
808
+ /** The PB-21 surrogate half for the declaration-v2 locators — json-body,
809
+ * and path-segment on a prefix route: the adapter surrogate's pipeline
810
+ * (locate → classify → walls → rewrite → the relay round-trip) run
811
+ * through the SAME adapter-core functions against the route's
812
+ * adapter-core view. The response discipline stays the query half's —
813
+ * three headers, no hooks: `deny` and `rewriteIds` are PB-22's business.
814
+ * Dispatch above guarantees `installed` and a non-query locator. */
815
+ const surrogateV2Handler = (declared: DeclaredHit, originalHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined) =>
816
+ async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
817
+ const plugin = declared.plugin
818
+ // The local passthrough. A json-body route has ALREADY consumed the
819
+ // request body by the time this runs (the classify needs the parsed
820
+ // body), so the replay hands the buffered bytes back behind the
821
+ // original request's metadata — the shape a local handler actually
822
+ // reads (method/url/headers/pipe).
823
+ const passthrough = async (replayBody?: Buffer): Promise<void> => {
824
+ if (originalHandler === undefined) {
825
+ res.writeHead(404)
826
+ res.end()
827
+ return
828
+ }
829
+ if (replayBody !== undefined) {
830
+ const replay = new PassThrough()
831
+ replay.end(replayBody)
832
+ Object.assign(replay, { method: req.method, url: req.url, headers: req.headers })
833
+ await originalHandler(replay as unknown as IncomingMessage, res)
834
+ return
835
+ }
836
+ await originalHandler(req, res)
837
+ }
838
+ let controller: AbortController | undefined
839
+ const onClientGone = (): void => {
840
+ if (controller !== undefined && !res.writableEnded) controller.abort()
841
+ }
842
+ res.on('close', onClientGone)
843
+ // The counting gate's LOCAL flag (the query half's PB-9-fix shape): a
844
+ // tripped gate answers the local 413 in the catch below — never an
845
+ // offline verdict, never a half-written response.
846
+ let bodyGateFailed = false
847
+ const bodyAbort = new AbortController()
848
+ try {
849
+ let url: URL
850
+ try {
851
+ url = new URL(req.url ?? '/', 'http://local')
852
+ } catch {
853
+ answerUnbridged(res, plugin, 'bad-query')
854
+ return
855
+ }
856
+ const pathname = url.pathname
857
+ const searchParams = url.searchParams
858
+ const method = req.method ?? 'GET'
859
+ const hasBody = method !== 'GET' && method !== 'HEAD'
860
+
861
+ // PB-22: the deny wall's current-session half — a remote panel is
862
+ // the request's origin even when it carries no id of its own
863
+ // (open.external's shape). Refused before any body byte is read.
864
+ if (declared.deny) {
865
+ const current = getCurrentSession?.()
866
+ if (current !== undefined && isVirtual(current)) {
867
+ answerUnbridged(res, plugin, 'route-denied')
868
+ return
869
+ }
870
+ }
871
+
872
+ // A json-body route buffers the request body up front, bounded by
873
+ // the route's own maxBody — the cooperative 413 shape, answered
874
+ // locally, the plugin never reached. A path-segment route's body is
875
+ // never consumed by locating; it streams through the counting gate
876
+ // at the forward.
877
+ let bufferedBody: Buffer | undefined
878
+ if (hasBody && declared.locator.in === 'json-body') {
879
+ const chunks: Buffer[] = []
880
+ let size = 0
881
+ let over = false
882
+ for await (const chunk of req) {
883
+ size += (chunk as Buffer).length
884
+ if (size > declared.maxBody) {
885
+ over = true
886
+ break
887
+ }
888
+ chunks.push(Buffer.from(chunk as Uint8Array))
889
+ }
890
+ if (over) {
891
+ res.setHeader('content-type', 'application/json; charset=utf-8')
892
+ res.writeHead(413)
893
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
894
+ recordRefusal(plugin, 'body-too-large')
895
+ log?.('plugin request for %s refused locally: body-too-large (%s buffered)', declared.path, size)
896
+ return
897
+ }
898
+ bufferedBody = Buffer.concat(chunks)
899
+ }
900
+ // A present body must parse as JSON when the route locates in it —
901
+ // a corrupted body refuses rather than falling through to the local
902
+ // plugin with a body the locate could not judge (the adapter half's
903
+ // shared rule).
904
+ let parsedBody: unknown
905
+ let bodyGiven = false
906
+ if (hasBody && declared.locator.in === 'json-body' && bufferedBody !== undefined && bufferedBody.length > 0) {
907
+ try {
908
+ parsedBody = JSON.parse(bufferedBody.toString('utf8'))
909
+ bodyGiven = true
910
+ } catch {
911
+ answerUnbridged(res, plugin, 'bad-query')
912
+ return
913
+ }
914
+ }
915
+ // The remote/local verdict on the ids as they arrived — the
916
+ // adapter-core classify (locate inside), no second copy of the
917
+ // rule: every id virtual → forward, none → local, a mix → the
918
+ // uniform refusal (docs/adr/0001: a remote session never reaches a
919
+ // local plugin).
920
+ let verdict: AdapterRemoteVerdict
921
+ try {
922
+ verdict = classifyAdapterRequest(declared.view, pathname, searchParams, bodyGiven ? parsedBody : undefined)
923
+ } catch (error) {
924
+ if (error instanceof AdapterMultiValueQueryError) {
925
+ answerUnbridged(res, plugin, 'bad-query')
926
+ return
927
+ }
928
+ throw error
929
+ }
930
+ // PB-22: the deny wall's request half — the locate found a virtual
931
+ // id (a remote verdict, or a mixed one: that too means a virtual id
932
+ // sat in the request). This route never serves a remote context, so
933
+ // the answer is the route's own refusal, not the mixed shape.
934
+ if (declared.deny && verdict.kind !== 'local') {
935
+ answerUnbridged(res, plugin, 'route-denied')
936
+ return
937
+ }
938
+ if (verdict.kind === 'local') {
939
+ await passthrough(bufferedBody)
940
+ return
941
+ }
942
+ if (verdict.kind === 'mixed') {
943
+ answerUnbridged(res, plugin, 'bad-query')
944
+ return
945
+ }
946
+ // Remote: the route's method wall, then the per-id server walls —
947
+ // the query half's gates, re-run per located id.
948
+ if (!declared.methods.includes(method)) {
949
+ answerUnbridged(res, plugin, 'method-not-bridged')
950
+ return
951
+ }
952
+ const serverId = getServerId()
953
+ // PB-22: the request's own real→virtual pairs, collected while the
954
+ // wall validates each hit — the READ-ONLY mapping a rewriteIds
955
+ // route's answer scan consults (`zr~` carries the real id inside, so
956
+ // the ids this very request named are exactly the ones the answer
957
+ // may hand back). PB-25a: the SAME unwrap hands the position rewrite
958
+ // its server id — the ids this request located live under it, so a
959
+ // NEWLY-created id joins them (the query half's source verbatim;
960
+ // `remote` always has at least one hit). PB-25b: the scan consults
961
+ // ONE more source beside this mapping — the known-real-session
962
+ // inventory, read live at the answer (never copied here) and
963
+ // consumed read-only; nothing here writes any session table.
964
+ const idMapping = new Map<string, string>()
965
+ let locatedServerId: string | undefined
966
+ for (const idHit of verdict.hits) {
967
+ const parts = fromVirtual(idHit.value)
968
+ if (parts === undefined || parts.id === '') {
969
+ answerUnbridged(res, plugin, 'bad-query')
970
+ return
971
+ }
972
+ if (parts.serverId !== serverId) {
973
+ answerUnbridged(res, plugin, 'server-changed')
974
+ return
975
+ }
976
+ idMapping.set(parts.id, idHit.value)
977
+ locatedServerId = parts.serverId
978
+ }
979
+ // The bridge verdicts judged from the SAME live table the query
980
+ // half reads.
981
+ const status = getStatus().find((entry) => entry.plugin === plugin)
982
+ if (status === undefined || !status.bridged) {
983
+ answerUnbridged(res, plugin, status?.reason ?? 'not-on-server')
984
+ return
985
+ }
986
+ // Real ids in, coordinates rewritten — the adapter-core rewrite:
987
+ // the query re-serialized, the path segment re-encoded, the JSON
988
+ // body re-serialized. The forward carries the REWRITTEN pathname —
989
+ // a prefix route's full matched path, never the declared base.
990
+ const rewrite = rewriteAdapterRequest(declared.view, pathname, searchParams, bodyGiven ? parsedBody : undefined, verdict.hits, (virtualId) => fromVirtual(virtualId)!.id)
991
+ forwarded += 1
992
+ log?.('plugin route %s forwards for %s', rewrite.pathname, plugin)
993
+ controller = new AbortController()
994
+ const forwardSignal = AbortSignal.any([controller.signal, bodyAbort.signal])
995
+ const accept = typeof req.headers.accept === 'string' ? req.headers.accept : undefined
996
+ const range = typeof req.headers.range === 'string' ? req.headers.range : undefined
997
+ const requestContentType = typeof req.headers['content-type'] === 'string' ? req.headers['content-type'] : undefined
998
+ const declaredLength = Number(req.headers['content-length'])
999
+ let bodyStream: ReadableStream<Uint8Array> | null = null
1000
+ let contentLength: number | undefined
1001
+ if (hasBody && declared.locator.in === 'json-body') {
1002
+ // The buffer forwards in full: the (possibly rewritten)
1003
+ // re-serialization when ids sat in it, the original bytes
1004
+ // otherwise. The REWRITTEN byte count is the declared length —
1005
+ // the budget reads what actually travels.
1006
+ const payload = rewrite.bodyText !== undefined ? Buffer.from(rewrite.bodyText, 'utf8') : bufferedBody ?? Buffer.alloc(0)
1007
+ contentLength = payload.length
1008
+ bodyStream = Readable.toWeb(Readable.from(payload)) as ReadableStream<Uint8Array>
1009
+ } else if (hasBody) {
1010
+ // The pre-check first — a DECLARED Content-Length past the cap
1011
+ // never starts a round-trip — then the counting gate for a
1012
+ // chunked body, exactly the query half's PB-9 discipline.
1013
+ if (Number.isFinite(declaredLength) && declaredLength > declared.maxBody) {
1014
+ res.setHeader('content-type', 'application/json; charset=utf-8')
1015
+ res.writeHead(413)
1016
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
1017
+ recordRefusal(plugin, 'body-too-large')
1018
+ log?.('plugin request for %s refused locally: body-too-large (%s declared)', declared.path, req.headers['content-length'])
1019
+ return
1020
+ }
1021
+ let pumped = 0
1022
+ const gate = new Transform({
1023
+ transform(chunk: Buffer, _encoding, callback) {
1024
+ pumped += chunk.length
1025
+ if (pumped > declared.maxBody) {
1026
+ callback(new Error('zr-plugin-body-too-large'))
1027
+ return
1028
+ }
1029
+ callback(null, chunk)
1030
+ },
1031
+ })
1032
+ req.on('error', (error) => gate.destroy(error))
1033
+ gate.on('error', () => {
1034
+ if (!bodyGateFailed) bodyGateFailed = true
1035
+ bodyAbort.abort()
1036
+ })
1037
+ bodyStream = Readable.toWeb(gate) as ReadableStream<Uint8Array>
1038
+ req.pipe(gate)
1039
+ if (Number.isFinite(declaredLength) && declaredLength > 0) contentLength = declaredLength
1040
+ }
1041
+ const response = await relay.pluginRequest(
1042
+ {
1043
+ plugin,
1044
+ version: declared.version,
1045
+ digest: declared.digest,
1046
+ path: rewrite.pathname,
1047
+ query: rewrite.query.toString(),
1048
+ method,
1049
+ ...(bodyStream !== null ? { body: bodyStream } : {}),
1050
+ ...(bodyStream !== null && requestContentType !== undefined ? { contentType: requestContentType } : {}),
1051
+ ...(bodyStream !== null && contentLength !== undefined ? { contentLength } : {}),
1052
+ ...(accept !== undefined ? { accept } : {}),
1053
+ ...(range !== undefined ? { range } : {}),
1054
+ },
1055
+ forwardSignal,
1056
+ )
1057
+ // Down from here the query half's response discipline verbatim: the
1058
+ // relay refusal marker, the three cooperative headers, the pump
1059
+ // with real backpressure — plus (PB-22) the rewriteIds branch.
1060
+ const refusal = response.headers.get('x-zen-remote-error')
1061
+ if (refusal !== null) {
1062
+ await response.body?.cancel().catch(() => {})
1063
+ answerUnbridged(res, plugin, refusal)
1064
+ return
1065
+ }
1066
+ if (declared.rewriteIds) {
1067
+ await answerRewriteIds(res, response, idMapping, declared.rewriteFields, locatedServerId!, getKnownRealIds?.())
1068
+ return
1069
+ }
1070
+ const contentType = response.headers.get('content-type')
1071
+ const responseLength = response.headers.get('content-length')
1072
+ const cacheControl = response.headers.get('cache-control')
1073
+ res.writeHead(response.status, {
1074
+ ...(contentType !== null ? { 'content-type': contentType } : {}),
1075
+ ...(responseLength !== null ? { 'content-length': responseLength } : {}),
1076
+ ...(cacheControl !== null ? { 'cache-control': cacheControl } : {}),
1077
+ })
1078
+ const reader = response.body?.getReader()
1079
+ if (reader !== undefined) await pumpResponse(res, reader)
1080
+ res.end()
1081
+ } catch (error) {
1082
+ // The gate's local flag first (the query half's shape): the 413
1083
+ // still fits while the browser's headers are not out.
1084
+ if (bodyGateFailed && !res.headersSent) {
1085
+ res.setHeader('content-type', 'application/json; charset=utf-8')
1086
+ res.writeHead(413)
1087
+ res.end(JSON.stringify({ ok: false, error: { code: 'zr-plugin-body-too-large' } }))
1088
+ recordRefusal(plugin, 'body-too-large')
1089
+ return
1090
+ }
1091
+ if (controller?.signal.aborted || res.destroyed || res.headersSent) {
1092
+ try {
1093
+ res.destroy()
1094
+ } catch {
1095
+ // Already gone.
1096
+ }
1097
+ return
1098
+ }
1099
+ const code = typeof (error as { code?: unknown } | null)?.code === 'string' ? (error as { code: string }).code : 'offline'
1100
+ answerUnbridged(res, plugin, code === 'aborted' ? 'offline' : code)
1101
+ } finally {
1102
+ res.off('close', onClientGone)
1103
+ }
1104
+ }
1105
+
495
1106
  // ---- PB-11: the adapter interception ---------------------------------------
496
1107
  //
497
1108
  // An adapter (src/adapters/, validated into a NormalizedAdapter by the
@@ -769,23 +1380,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
769
1380
  ...Object.assign({}, ...allowlistedHeaders),
770
1381
  })
771
1382
  const reader = response.body?.getReader()
772
- if (reader !== undefined) {
773
- while (true) {
774
- const { done, value } = await reader.read()
775
- if (done) break
776
- if (!res.write(value)) {
777
- await new Promise<void>((resolve) => {
778
- const settled = (): void => {
779
- res.off('drain', settled)
780
- res.off('close', settled)
781
- resolve()
782
- }
783
- res.once('drain', settled)
784
- res.once('close', settled)
785
- })
786
- }
787
- }
788
- }
1383
+ if (reader !== undefined) await pumpResponse(res, reader)
789
1384
  res.end()
790
1385
  return
791
1386
  }
@@ -829,21 +1424,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
829
1424
  })
830
1425
  }
831
1426
  }
832
- while (true) {
833
- const { done, value } = await reader.read()
834
- if (done) break
835
- if (!res.write(value)) {
836
- await new Promise<void>((resolve) => {
837
- const settled = (): void => {
838
- res.off('drain', settled)
839
- res.off('close', settled)
840
- resolve()
841
- }
842
- res.once('drain', settled)
843
- res.once('close', settled)
844
- })
845
- }
846
- }
1427
+ await pumpResponse(res, reader)
847
1428
  res.end()
848
1429
  return
849
1430
  }
@@ -897,9 +1478,10 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
897
1478
  /** The wrapped match: original verdict first, then the adapter routes
898
1479
  * (PB-11 — BEFORE the declaration lookup: the adapter's registry entry
899
1480
  * IS a declaration, and the cooperative surrogate's query-sessionParam
900
- * logic is not how an adapter request is judged), then the exact
901
- * declaration lookup — an undeclared pathname returns whatever the
902
- * original match answered, untouched. */
1481
+ * logic is not how an adapter request is judged), then the declaration
1482
+ * lookup — exact paths plus (PB-21) a declared prefix route's full
1483
+ * match. An undeclared pathname returns whatever the original match
1484
+ * answered, untouched. */
903
1485
  const wrappedMatch = function match(this: unknown, pathname: string): unknown {
904
1486
  const route = savedMatch.call(this, pathname)
905
1487
  if (!installed) return route
@@ -937,7 +1519,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
937
1519
 
938
1520
  const targetWithUpgrades = webServer as { upgrades: Map<string, unknown> }
939
1521
  const savedUpgradesGet = targetWithUpgrades.upgrades.get
940
- const upgradeRoutesOf = (pathname: string): { plugin: string; version: string; digest: string; path: string; sessionParam: string } | undefined => {
1522
+ const upgradeRoutesOf = (pathname: string): { plugin: string; version: string; digest: string; path: string; sessionParam: string; deny: boolean } | undefined => {
941
1523
  if (!installed) return undefined
942
1524
  for (const declaration of registry.list()) {
943
1525
  for (const route of declaration.routes) {
@@ -948,6 +1530,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
948
1530
  digest: declaration.digest,
949
1531
  path: route.path,
950
1532
  sessionParam: route.sessionParam,
1533
+ deny: routeDeniesRemoteOf(route),
951
1534
  }
952
1535
  }
953
1536
  }
@@ -957,7 +1540,7 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
957
1540
 
958
1541
  /** The tunnel's surrogate upgrade handler. Every outcome — refusal,
959
1542
  * teardown, the byte pumping — owns the socket's whole remaining life. */
960
- const surrogateUpgrade = (declared: { plugin: string; version: string; digest: string; path: string; sessionParam: string }, originalHandler: unknown) =>
1543
+ const surrogateUpgrade = (declared: { plugin: string; version: string; digest: string; path: string; sessionParam: string; deny: boolean }, originalHandler: unknown) =>
961
1544
  (req: IncomingMessage, socket: Socket, head: Buffer): void => {
962
1545
  // The host never awaits an upgrade handler — the async body runs on
963
1546
  // its own and every failure path owns the socket's teardown.
@@ -990,6 +1573,18 @@ export function installPluginRouteIntercept(options: InstallPluginRouteIntercept
990
1573
  return
991
1574
  }
992
1575
  const value = values.length === 1 ? values[0] : undefined
1576
+ // PB-22: the deny wall on the tunnel — the HTTP surrogate's rule
1577
+ // verbatim. A remote context (the request's own virtual id, or the
1578
+ // browser's reported current session) never opens a byte tunnel into
1579
+ // a route declared `deny: true`; a local session's upgrade passes
1580
+ // through untouched.
1581
+ if (declared.deny) {
1582
+ const current = getCurrentSession?.()
1583
+ if ((current !== undefined && isVirtual(current)) || (value !== undefined && value.startsWith('zr~'))) {
1584
+ answerUnbridgedSocket(socket, declared.plugin, 'route-denied')
1585
+ return
1586
+ }
1587
+ }
993
1588
  // A LOCAL session (or no parameter at all): the original upgrade
994
1589
  // handler's business, the head bytes untouched.
995
1590
  if (value === undefined || value === '' || !value.startsWith('zr~')) {