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
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { createHash } from 'node:crypto'
26
26
  import { isVirtual, fromVirtual } from './virtual-id.js'
27
+ import { MAX_UPLOAD_BYTES } from './upload-limits.js'
27
28
  import type { NormalizedBridgeRoute, NormalizedDeclaration } from './plugin-bridge.js'
28
29
  import { BRIDGE_METHODS, DEFAULT_ROUTE_BODY_BYTES } from './plugin-bridge.js'
29
30
 
@@ -146,7 +147,9 @@ export interface AdapterHooks {
146
147
  export interface NormalizedAdapter {
147
148
  plugin: string
148
149
  /** The exact versions this adapter was written against; the INSTALLED
149
- * version must be among them or the adapter does not register. */
150
+ * version must be among them — or its wire surface must be whitelisted in
151
+ * the adapter's `surfaces` table (PB-19) — or the adapter does not
152
+ * register. */
150
153
  versions: readonly string[]
151
154
  routes: NormalizedAdapterRoute[]
152
155
  /** The digest of the ROUTE TABLE (locator and match included) — the
@@ -163,13 +166,12 @@ export interface NormalizedAdapter {
163
166
  const PLUGIN_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
164
167
  const MAX_ROUTES = 64
165
168
 
166
- /** The body cap an ADAPTER route may declare: 500 MiB — the relay's own
167
- * upload budget (lib/relay-server.js `MAX_UPLOAD_BYTES`), which the
168
- * better-sidebar upload route rides. Wider than the cooperative
169
- * declaration's 64 MiB because raw media uploads are exactly what an
170
- * adapter exists to carry; test/adapter-better-sidebar.test.cjs pins the
171
- * two numbers together. */
172
- export const MAX_ADAPTER_BODY_BYTES = 500 * 1024 * 1024
169
+ /** The body cap an ADAPTER route may declare: the relay's own upload
170
+ * budget (`MAX_UPLOAD_BYTES` in upload-limits.ts), which the better-sidebar
171
+ * upload route rides. The same 500 MiB the cooperative declaration's
172
+ * maxBody allows — both read the one constant since PB-20; raw media
173
+ * uploads are exactly what an adapter exists to carry. */
174
+ export const MAX_ADAPTER_BODY_BYTES = MAX_UPLOAD_BYTES
173
175
 
174
176
  function isRecord(value: unknown): value is Record<string, unknown> {
175
177
  return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -186,13 +188,19 @@ function fail(field: string, detail: string): never {
186
188
  * absolute paths, off the host-owned `/_dsh/` and `/api` prefixes) — an
187
189
  * adapter must not be able to reach anything a declaration cannot. The
188
190
  * installed version must be listed EXACTLY (`versions` are what the
189
- * adapter was written against); anything else refuses the adapter whole.
191
+ * adapter was written against) — with ONE out (PB-19): a version outside
192
+ * the list is admitted anyway when `options.surfaceDigest` — the wiring's
193
+ * extraction of the INSTALLED build's wire surface — is a key of the
194
+ * adapter's `surfaces` table; the registration then carries the installed
195
+ * version with the usual route-table digest, so two machines on the same
196
+ * unlisted-but-surface-identical release still agree. A miss refuses with
197
+ * the digest and the table's size in the message, for the logs.
190
198
  * @throws with a field-naming message on any illegal shape.
191
199
  */
192
- export function normalizeAdapter(input: unknown, installedVersion: string): NormalizedAdapter {
200
+ export function normalizeAdapter(input: unknown, installedVersion: string, options?: { surfaceDigest?: string }): NormalizedAdapter {
193
201
  if (!isRecord(input)) fail('adapter', 'expected an object')
194
202
  for (const key of Object.keys(input)) {
195
- if (key !== 'plugin' && key !== 'versions' && key !== 'routes' && key !== 'client' && key !== 'server') {
203
+ if (key !== 'plugin' && key !== 'versions' && key !== 'routes' && key !== 'client' && key !== 'server' && key !== 'surfaces') {
196
204
  fail(key, 'unknown field (typo guard)')
197
205
  }
198
206
  }
@@ -203,8 +211,21 @@ export function normalizeAdapter(input: unknown, installedVersion: string): Norm
203
211
  if (!Array.isArray(versions) || versions.length === 0 || versions.some((v) => typeof v !== 'string' || v === '')) {
204
212
  fail('versions', 'must be a non-empty array of version strings')
205
213
  }
214
+ // The surfaces table is validated whenever present, listed version or not
215
+ // (a malformed credential must not lurk until the one version that needs it).
216
+ if (input.surfaces !== undefined) {
217
+ if (!isRecord(input.surfaces)) fail('surfaces', 'expected an object (digest16 → remark)')
218
+ for (const [key, value] of Object.entries(input.surfaces)) {
219
+ if (!/^[0-9a-f]{16}$/u.test(key)) fail(`surfaces.${key}`, 'the key must be a 16-hex surface digest')
220
+ if (typeof value !== 'string' || value === '') fail(`surfaces.${key}`, 'the remark must be a non-empty string')
221
+ }
222
+ }
206
223
  if (!versions.includes(installedVersion)) {
207
- fail('versions', `installed version "${installedVersion}" is not among the adapter's versions`)
224
+ const surfaceDigest = options?.surfaceDigest
225
+ const surfaces = input.surfaces as Record<string, string> | undefined
226
+ if (!(surfaceDigest !== undefined && surfaces !== undefined && Object.hasOwn(surfaces, surfaceDigest))) {
227
+ fail('versions', `installed version "${installedVersion}" is not among the adapter's versions (surface ${surfaceDigest ?? 'not computed'} vs ${surfaces === undefined ? 0 : Object.keys(surfaces).length} listed)`)
228
+ }
208
229
  }
209
230
  if (!Array.isArray(routes) || routes.length === 0) fail('routes', 'must be a non-empty array')
210
231
  if (routes.length > MAX_ROUTES) fail('routes', `${routes.length} routes exceeds the ${MAX_ROUTES} cap`)
@@ -350,13 +371,47 @@ export function adapterDigestOf(plugin: string, version: string, routes: Normali
350
371
  return createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 16)
351
372
  }
352
373
 
353
- /** The prefix-match rule, shared verbatim by both halves: equality or one
354
- * path segment deeper, on the RAW undecoded pathname. */
374
+ /** The prefix-match rule, shared verbatim by both halves: equality, or any
375
+ * deeper sub-path (`startsWith(path + '/')`), on the RAW undecoded pathname. */
355
376
  export function adapterPathMatches(route: NormalizedAdapterRoute, pathname: string): boolean {
356
377
  if (route.match === 'exact') return pathname === route.path
357
378
  return pathname === route.path || pathname.startsWith(`${route.path}/`)
358
379
  }
359
380
 
381
+ /** The prefix TAIL's shape red line (PB-23b): the part of the RAW pathname
382
+ * AFTER a matched prefix path — the variable tail prefix matching admits —
383
+ * refuses `?`, `#`, `..` and whitespace. Exact equality inherited these
384
+ * declaration-side red lines structurally (the declared path itself passed
385
+ * them at registration); a prefix hit forwards an arbitrary tail straight
386
+ * into the loopback request line, so the tail carries them itself. A `?`
387
+ * would break the parse-once query discipline (the loopback appends ITS
388
+ * `?query`, and a host splits at the FIRST `?` — the unchecked part would
389
+ * ride in as query); a `#` is fragment garbage in the request line;
390
+ * whitespace mirrors the declaration rule. `..` is refused RAW and in its
391
+ * `%2e`/`%2E` encoding — ANY encoded dot, not just the `%2e%2e` pair, since
392
+ * a mixed form like `.%2e.` decodes to dot-dot and would slip a pair-only
393
+ * check. The verdict deliberately runs on the RAW bytes (reject-on-sight),
394
+ * not decode-then-check: `.` is RFC 3986-unreserved, so no legitimate
395
+ * encoder — including this module's own `encodeURIComponent` rewrite — ever
396
+ * produces `%2e`, while a decoded verdict would depend on the decode depth
397
+ * (`%252e%252e` survives one decode) and on malformed-escape handling,
398
+ * i.e. on exactly the host-decoder behavior this red line exists because it
399
+ * is NOT pinned. The check is tail-only because a declaration may legally
400
+ * carry `%` (only these four shapes are refused at registration) — judging
401
+ * the whole pathname would retro-illegalize blessed declarations. Both
402
+ * halves answer the refusal with 400 `bad-request`, the coordinate-shape
403
+ * code the entries already use (no client classifies on it). */
404
+ const TAIL_SHAPE_VIOLATION = /[?#\s]|\.\.|%2e/iu
405
+
406
+ /** Whether {@link adapterPathMatches} hit via a deeper tail whose shape
407
+ * breaks the red line — `false` for a non-match (the caller's routing
408
+ * refusal answers first) and for an equality hit (no tail at all). */
409
+ export function adapterTailShapeViolation(route: NormalizedAdapterRoute, pathname: string): boolean {
410
+ if (!adapterPathMatches(route, pathname)) return false
411
+ if (pathname === route.path) return false
412
+ return TAIL_SHAPE_VIOLATION.test(pathname.slice(route.path.length))
413
+ }
414
+
360
415
  /** The adapter route a forwarded request hit, or `undefined`. `local`
361
416
  * routes never match here — they stay on the sub-client's own machine in
362
417
  * both directions, so the server half must never see (nor serve) them. */
@@ -11,7 +11,9 @@
11
11
  * ({@link createBridgeRegistry}), and the local-vs-server comparison that a
12
12
  * later task feeds handshake data into ({@link compareBridges}).
13
13
  *
14
- * Pure logic by design: no DSH imports, no I/O, no clocks — the digest is
14
+ * Pure logic by design: no DSH imports (the one exception is the
15
+ * pure-constants src/upload-limits.ts, itself zero-import), no I/O, no
16
+ * clocks — the digest is
15
17
  * the only place node:crypto appears, which is fine because this module only
16
18
  * ever runs in the backend process. Nothing here talks to the relay or the
17
19
  * network; the handshake side and the request rewrite side are later tasks
@@ -19,6 +21,7 @@
19
21
  */
20
22
 
21
23
  import { createHash } from 'node:crypto'
24
+ import { MAX_UPLOAD_BYTES } from './upload-limits.js'
22
25
 
23
26
  /** Where a bridged route's caller supplies the session id. `'session'` — the
24
27
  * data is scoped to one session, forwarded after the server checks the share.
@@ -30,12 +33,48 @@ export type BridgeScope = 'session' | 'server'
30
33
  * only; the write methods arrived with the `methods` field. */
31
34
  export const BRIDGE_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
32
35
 
33
- /** The body cap a route may declare (`maxBody`), in bytes: 64 MiB. */
34
- export const MAX_ROUTE_BODY_BYTES = 64 * 1024 * 1024
36
+ /** The body cap a route may declare (`maxBody`), in bytes. This is the SAME
37
+ * single 500 MiB ceiling {@link MAX_UPLOAD_BYTES} defines in
38
+ * src/upload-limits.ts, re-exported under the bridge-local name so existing
39
+ * consumers stay untouched: three ceilings that used to be three numbers —
40
+ * the relay's `/v1/upload` route, the sub-client's streaming upload forward
41
+ * (both reading upload-limits.ts directly) and this bridge `maxBody` cap
42
+ * (hand-copied 64 MiB before declaration-v2) — now share one definition, so
43
+ * they can never drift apart again. */
44
+ export { MAX_UPLOAD_BYTES as MAX_ROUTE_BODY_BYTES }
35
45
 
36
46
  /** The body cap a route gets when it declares none: 1 MiB. */
37
47
  export const DEFAULT_ROUTE_BODY_BYTES = 1024 * 1024
38
48
 
49
+ /** The `fields` form of a route's `rewriteIds` (PB-25a): the DOT PATHS where
50
+ * the response's NEWLY-CREATED session ids sit — ids a creation route mints
51
+ * (`sidechat.start`'s `childId`), which were never in the request and so are
52
+ * invisible to the value deep-scan. Each path walks objects only: segments
53
+ * of `[A-Za-z0-9_-]`, non-empty, no wildcards, no numeric (array-index)
54
+ * segments — array elements stay the value scan's business. */
55
+ export interface BridgeRewriteFields {
56
+ fields: readonly string[]
57
+ }
58
+
59
+ /** A route's `rewriteIds` value: `true` — deep-scan the answer for the
60
+ * request's own ids (the 2.4 echo shape) — or the {@link BridgeRewriteFields}
61
+ * object, which adds the position-based rewrite on top. The two are
62
+ * DIFFERENT declarations: each hashes its own way, and an old `true`
63
+ * declaration's digest is bit-for-bit what it always was. */
64
+ export type BridgeRewriteIds = true | BridgeRewriteFields
65
+
66
+ /** Where a bridged route's caller supplies the session id — the `locator`
67
+ * field (declaration-v2). The same three shapes as the adapter core's
68
+ * `AdapterLocator`: `query` names the search parameter; `path-segment`
69
+ * counts segments AFTER the declared path (a `prefix` route's variable
70
+ * part); `json-body` names TOP-LEVEL fields of the parsed JSON request
71
+ * body. A route without a locator keeps the 2.1 behavior: the session id
72
+ * rides the query parameter named `sessionParam`. */
73
+ export type BridgeLocator =
74
+ | { in: 'query'; name: string }
75
+ | { in: 'path-segment'; index: number }
76
+ | { in: 'json-body'; fields: string[] }
77
+
39
78
  /** One forwardable route as a plugin declares it. `sessionParam` names the
40
79
  * URL query parameter carrying the session id (default `'sessionId'`);
41
80
  * `stream` marks routes answered as an event stream rather than one JSON
@@ -45,7 +84,19 @@ export const DEFAULT_ROUTE_BODY_BYTES = 1024 * 1024
45
84
  * (PB-9) caps the forwarded request body in bytes; omitted means
46
85
  * {@link DEFAULT_ROUTE_BODY_BYTES}. `upgrade` (PB-10) marks the route as a
47
86
  * WebSocket endpoint carried over the byte tunnel — GET-only, mutually
48
- * exclusive with `stream`. */
87
+ * exclusive with `stream`.
88
+ *
89
+ * The four declaration-v2 fields: `locator` replaces the query-parameter
90
+ * session-id convention with an explicit {@link BridgeLocator} (mutually
91
+ * exclusive with `sessionParam`); `prefix: true` admits the route's
92
+ * variable path tail (sub-paths under `path`) — required for a
93
+ * `path-segment` locator, refused together with `upgrade` for now; `deny:
94
+ * true` marks a route the sub-client refuses to serve from a REMOTE
95
+ * is local-only data); `rewriteIds` asks the sub-client to rewrite
96
+ * real↔virtual session ids inside the JSON it forwards — `true` deep-scans
97
+ * for the request's own ids, `{ fields }` additionally wraps the
98
+ * NEWLY-CREATED ids sitting at the declared positions (PB-25a) — never
99
+ * legal on a `stream` route, whose response is never buffered. */
49
100
  export interface BridgeRoute {
50
101
  path: string
51
102
  scope: BridgeScope
@@ -54,6 +105,10 @@ export interface BridgeRoute {
54
105
  methods?: readonly string[]
55
106
  maxBody?: number
56
107
  upgrade?: boolean
108
+ locator?: BridgeLocator
109
+ prefix?: boolean
110
+ deny?: boolean
111
+ rewriteIds?: BridgeRewriteIds
57
112
  }
58
113
 
59
114
  /** What a plugin hands `zenRemote.bridge(...)` — the raw, unvalidated shape. */
@@ -68,7 +123,11 @@ export interface BridgeDeclaration {
68
123
  * explicitly — the distinction is load-bearing for the digest (see the
69
124
  * canonical form in {@link normalizeDeclaration}), while consumers read the
70
125
  * EFFECTIVE values through {@link routeMethodsOf} / {@link routeMaxBodyOf}
71
- * / {@link routeUpgradesOf}. */
126
+ * / {@link routeUpgradesOf}. The four declaration-v2 fields (`locator`,
127
+ * `prefix`, `deny`, `rewriteIds`) follow the same explicit-only rule, and
128
+ * each carries its own effective reader:
129
+ * {@link routeLocatorOf} / {@link routePrefixOf} / {@link routeDeniesRemoteOf}
130
+ * / {@link routeRewritesIdsOf} / {@link routeRewriteFieldsOf}. */
72
131
  export interface NormalizedBridgeRoute {
73
132
  path: string
74
133
  scope: BridgeScope
@@ -77,6 +136,12 @@ export interface NormalizedBridgeRoute {
77
136
  methods: string[] | undefined
78
137
  maxBody: number | undefined
79
138
  upgrade: boolean | undefined
139
+ /** Present only when declared — an ABSENT key (not a `locator: undefined`
140
+ * key) keeps the digest-compatible shape and deep-equality of 2.1 routes. */
141
+ locator?: BridgeLocator
142
+ prefix?: true
143
+ deny?: true
144
+ rewriteIds?: BridgeRewriteIds
80
145
  }
81
146
 
82
147
  /** The effective upgrade mark of one normalized route. */
@@ -94,6 +159,41 @@ export function routeMaxBodyOf(route: NormalizedBridgeRoute): number {
94
159
  return route.maxBody ?? DEFAULT_ROUTE_BODY_BYTES
95
160
  }
96
161
 
162
+ /** The effective session-id locator of one normalized route. A route that
163
+ * declared none keeps the 2.1 convention: the session id rides the query
164
+ * parameter named by `sessionParam`. */
165
+ export function routeLocatorOf(route: NormalizedBridgeRoute): BridgeLocator {
166
+ return route.locator ?? { in: 'query', name: route.sessionParam }
167
+ }
168
+
169
+ /** The effective prefix mark of one normalized route: does the route admit
170
+ * its variable path tail (sub-paths under `path`)? */
171
+ export function routePrefixOf(route: NormalizedBridgeRoute): boolean {
172
+ return route.prefix === true
173
+ }
174
+
175
+ /** The effective remote-deny mark of one normalized route: must this route
176
+ * refuse to serve a REMOTE session (local-only data)? */
177
+ export function routeDeniesRemoteOf(route: NormalizedBridgeRoute): boolean {
178
+ return route.deny === true
179
+ }
180
+
181
+ /** The effective id-rewrite mark of one normalized route: does the
182
+ * sub-client rewrite real↔virtual session ids while forwarding? Both
183
+ * `rewriteIds` forms enable it (`true` and `{ fields }` alike) — which form
184
+ * decides only whether the POSITION rewrite joins the value scan. */
185
+ export function routeRewritesIdsOf(route: NormalizedBridgeRoute): boolean {
186
+ return route.rewriteIds !== undefined
187
+ }
188
+
189
+ /** The declared field paths of a `{ fields }`-form `rewriteIds` route
190
+ * (PB-25a) — where the response's NEWLY-CREATED session ids sit. `undefined`
191
+ * for a `true` route and for a route without the field: the position
192
+ * rewrite exists only when explicitly declared. */
193
+ export function routeRewriteFieldsOf(route: NormalizedBridgeRoute): readonly string[] | undefined {
194
+ return typeof route.rewriteIds === 'object' ? route.rewriteIds.fields : undefined
195
+ }
196
+
97
197
  /** A declaration after validation: every route field filled, routes sorted
98
198
  * by path, and the 16-hex-char digest both ends will compare. */
99
199
  export interface NormalizedDeclaration {
@@ -119,6 +219,13 @@ const PLUGIN_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
119
219
  /** Query-parameter name carrying the session id. */
120
220
  const SESSION_PARAM_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/u
121
221
 
222
+ /** One dot-path segment of a `rewriteIds: { fields }` declaration (PB-25a):
223
+ * `[A-Za-z0-9_-]`, non-empty. The full path splits on `.`; a segment of
224
+ * ONLY digits is an array index and refused separately below — array
225
+ * elements are the value deep-scan's business, and a declaration that could
226
+ * never match (the position rewrite walks objects only) must not register. */
227
+ const REWRITE_FIELD_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/u
228
+
122
229
  function isPlainObject(value: unknown): value is Record<string, unknown> {
123
230
  return typeof value === 'object' && value !== null && !Array.isArray(value)
124
231
  }
@@ -162,11 +269,11 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
162
269
  const normalizedRoutes = routes.map((route, index): NormalizedBridgeRoute => {
163
270
  if (!isPlainObject(route)) fail(`routes[${index}]`, 'expected an object')
164
271
  for (const key of Object.keys(route)) {
165
- if (key !== 'path' && key !== 'scope' && key !== 'sessionParam' && key !== 'stream' && key !== 'methods' && key !== 'maxBody' && key !== 'upgrade') {
272
+ if (key !== 'path' && key !== 'scope' && key !== 'sessionParam' && key !== 'stream' && key !== 'methods' && key !== 'maxBody' && key !== 'upgrade' && key !== 'locator' && key !== 'prefix' && key !== 'deny' && key !== 'rewriteIds') {
166
273
  fail(`routes[${index}].${key}`, 'unknown field (typo guard)')
167
274
  }
168
275
  }
169
- const { path, scope, sessionParam, stream, methods, maxBody, upgrade } = route
276
+ const { path, scope, sessionParam, stream, methods, maxBody, upgrade, locator, prefix, deny, rewriteIds } = route
170
277
  if (typeof path !== 'string' || path === '') fail(`routes[${index}].path`, 'must be a non-empty string')
171
278
  if (!path.startsWith('/')) fail(`routes[${index}].path`, `"${path}" must start with "/"`)
172
279
  if (path.includes('?')) fail(`routes[${index}].path`, `"${path}" must not contain "?"`)
@@ -215,8 +322,8 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
215
322
  // default.
216
323
  let normalizedMaxBody: number | undefined
217
324
  if (maxBody !== undefined) {
218
- if (typeof maxBody !== 'number' || !Number.isInteger(maxBody) || maxBody <= 0 || maxBody > MAX_ROUTE_BODY_BYTES) {
219
- fail(`routes[${index}].maxBody`, `must be a positive integer up to ${MAX_ROUTE_BODY_BYTES}`)
325
+ if (typeof maxBody !== 'number' || !Number.isInteger(maxBody) || maxBody <= 0 || maxBody > MAX_UPLOAD_BYTES) {
326
+ fail(`routes[${index}].maxBody`, `must be a positive integer up to ${MAX_UPLOAD_BYTES}`)
220
327
  }
221
328
  normalizedMaxBody = maxBody
222
329
  }
@@ -224,6 +331,15 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
224
331
  // declaration, not a toggle), it cannot coexist with `stream` (a route
225
332
  // is one thing), and it is GET-only — an explicit `methods` naming
226
333
  // anything else cannot be an upgrade route.
334
+ // PB-20: `prefix` — a declaration that the route admits its variable
335
+ // path tail (sub-paths under `path`), required by a `path-segment`
336
+ // locator below and refused next to `upgrade` (checked in the upgrade
337
+ // block). Only `true` is legal — this is a declaration, not a toggle.
338
+ let normalizedPrefix: true | undefined
339
+ if (prefix !== undefined) {
340
+ if (prefix !== true) fail(`routes[${index}].prefix`, 'must be true or omitted')
341
+ normalizedPrefix = true
342
+ }
227
343
  let normalizedUpgrade: boolean | undefined
228
344
  if (upgrade !== undefined) {
229
345
  if (upgrade !== true) fail(`routes[${index}].upgrade`, 'must be true or omitted')
@@ -231,18 +347,112 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
231
347
  if (normalizedMethods !== undefined && normalizedMethods.some((entry) => entry !== 'GET')) {
232
348
  fail(`routes[${index}].upgrade`, 'upgrade routes are GET-only')
233
349
  }
350
+ if (normalizedPrefix === true) {
351
+ fail(`routes[${index}].prefix`, 'cannot be combined with upgrade — a WebSocket tunnel over a prefix route is left for when a need shows up')
352
+ }
234
353
  normalizedUpgrade = true
235
354
  }
236
- return { path, scope, sessionParam: param, stream: streamFlag, methods: normalizedMethods, maxBody: normalizedMaxBody, upgrade: normalizedUpgrade }
355
+ // PB-20: `locator` — where the session id lives, REPLACING the
356
+ // query-parameter convention (so the two fields can never coexist; a
357
+ // route without one keeps `sessionParam`'s 2.1 semantics). Shapes and
358
+ // the path-segment/prefix pairing mirror the adapter core's locator
359
+ // (PB-11) so the two ends read identically.
360
+ let normalizedLocator: BridgeLocator | undefined
361
+ if (locator !== undefined) {
362
+ if (sessionParam !== undefined) {
363
+ fail(`routes[${index}].locator`, 'cannot be combined with sessionParam — a route locates its session id one way')
364
+ }
365
+ if (!isPlainObject(locator)) fail(`routes[${index}].locator`, 'expected an object')
366
+ if (locator.in === 'query') {
367
+ if (typeof locator.name !== 'string' || !SESSION_PARAM_PATTERN.test(locator.name)) {
368
+ fail(`routes[${index}].locator.name`, `"${String(locator.name)}" must match ${SESSION_PARAM_PATTERN.source}`)
369
+ }
370
+ normalizedLocator = { in: 'query', name: locator.name }
371
+ } else if (locator.in === 'path-segment') {
372
+ if (typeof locator.index !== 'number' || !Number.isInteger(locator.index) || locator.index < 0) {
373
+ fail(`routes[${index}].locator.index`, 'must be a non-negative integer')
374
+ }
375
+ if (normalizedPrefix !== true) {
376
+ fail(`routes[${index}].locator`, 'path-segment locating needs prefix: true (the id lives in the variable tail only a prefix route admits)')
377
+ }
378
+ normalizedLocator = { in: 'path-segment', index: locator.index }
379
+ } else if (locator.in === 'json-body') {
380
+ if (!Array.isArray(locator.fields) || locator.fields.length === 0 || locator.fields.some((entry) => typeof entry !== 'string' || entry === '')) {
381
+ fail(`routes[${index}].locator.fields`, 'must be a non-empty array of field names')
382
+ }
383
+ normalizedLocator = { in: 'json-body', fields: [...locator.fields] }
384
+ } else {
385
+ fail(`routes[${index}].locator.in`, 'must be "query", "path-segment" or "json-body"')
386
+ }
387
+ }
388
+ // PB-20: `deny` — the route must refuse a REMOTE session (its data is
389
+ // local-only). Only `true` is legal.
390
+ let normalizedDeny: true | undefined
391
+ if (deny !== undefined) {
392
+ if (deny !== true) fail(`routes[${index}].deny`, 'must be true or omitted')
393
+ normalizedDeny = true
394
+ }
395
+ // PB-20/PB-25a: `rewriteIds` — the sub-client rewrites real↔virtual
396
+ // session ids while forwarding. Two forms: `true` (the 2.4 echo shape —
397
+ // the value deep-scan over the request's own ids) and `{ fields }`
398
+ // (PB-25a — adds the position rewrite where NEWLY-CREATED ids sit).
399
+ // Never legal on a stream route: a streaming response is never buffered,
400
+ // so there is nothing to rewrite. The two forms hash DIFFERENTLY (see
401
+ // the canonical form below); an old `true` declaration's digest is
402
+ // bit-for-bit what it always was.
403
+ let normalizedRewriteIds: BridgeRewriteIds | undefined
404
+ if (rewriteIds !== undefined) {
405
+ if (rewriteIds === true) {
406
+ normalizedRewriteIds = true
407
+ } else if (isPlainObject(rewriteIds)) {
408
+ for (const key of Object.keys(rewriteIds)) {
409
+ if (key !== 'fields') fail(`routes[${index}].rewriteIds.${key}`, 'unknown field (typo guard)')
410
+ }
411
+ const { fields } = rewriteIds
412
+ if (!Array.isArray(fields) || fields.length === 0 || fields.some((entry) => typeof entry !== 'string')) {
413
+ fail(`routes[${index}].rewriteIds.fields`, 'must be a non-empty array of dot paths')
414
+ }
415
+ for (const entry of fields as string[]) {
416
+ const segments = entry.split('.')
417
+ if (segments.some((segment) => !REWRITE_FIELD_SEGMENT_PATTERN.test(segment))) {
418
+ fail(`routes[${index}].rewriteIds.fields`, `"${entry}" must be a dot path of non-empty [A-Za-z0-9_-] segments`)
419
+ }
420
+ if (segments.some((segment) => /^\d+$/u.test(segment))) {
421
+ fail(`routes[${index}].rewriteIds.fields`, `"${entry}" must not carry an array index — array elements are the value deep-scan's business`)
422
+ }
423
+ }
424
+ normalizedRewriteIds = { fields: [...fields] }
425
+ } else {
426
+ fail(`routes[${index}].rewriteIds`, 'must be true, { fields: [...] } or omitted')
427
+ }
428
+ if (streamFlag) fail(`routes[${index}].rewriteIds`, 'cannot be combined with stream — a streaming response is never buffered, so there are no ids to rewrite')
429
+ }
430
+ // Explicit-only (digest rule): the four v2 fields attach to the
431
+ // normalized route ONLY when declared — an absent key, not an
432
+ // `undefined` one, so the canonical form and the deep-equality shape of
433
+ // 2.1-era routes stay byte-for-byte what they were.
434
+ return {
435
+ path,
436
+ scope,
437
+ sessionParam: param,
438
+ stream: streamFlag,
439
+ methods: normalizedMethods,
440
+ maxBody: normalizedMaxBody,
441
+ upgrade: normalizedUpgrade,
442
+ ...(normalizedLocator !== undefined && { locator: normalizedLocator }),
443
+ ...(normalizedPrefix && { prefix: normalizedPrefix }),
444
+ ...(normalizedDeny && { deny: normalizedDeny }),
445
+ ...(normalizedRewriteIds && { rewriteIds: normalizedRewriteIds }),
446
+ }
237
447
  })
238
448
  normalizedRoutes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
239
449
 
240
450
  // Fixed key order = the canonical form both ends hash; insertion order of
241
451
  // the object literal IS the contract, JSON.stringify never sorts. The
242
- // route entries carry `methods` / `maxBody` ONLY when the plugin declared
243
- // them — `JSON.stringify` drops `undefined` values, but the explicit
244
- // conditional documents the intent and keeps the PB-1 compatibility
245
- // visible instead of incidental.
452
+ // route entries carry `methods` / `maxBody` / `upgrade` and the four v2
453
+ // fields ONLY when the plugin declared them — `JSON.stringify` drops
454
+ // `undefined` values, but the explicit conditional documents the intent
455
+ // and keeps the PB-1 compatibility visible instead of incidental.
246
456
  const canonical = JSON.stringify({
247
457
  plugin,
248
458
  version,
@@ -256,6 +466,10 @@ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
256
466
  if (route.methods !== undefined) entry.methods = route.methods
257
467
  if (route.maxBody !== undefined) entry.maxBody = route.maxBody
258
468
  if (route.upgrade !== undefined) entry.upgrade = route.upgrade
469
+ if (route.locator !== undefined) entry.locator = route.locator
470
+ if (route.prefix !== undefined) entry.prefix = route.prefix
471
+ if (route.deny !== undefined) entry.deny = route.deny
472
+ if (route.rewriteIds !== undefined) entry.rewriteIds = route.rewriteIds
259
473
  return entry
260
474
  }),
261
475
  })