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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/plugin-bridge.md +14 -7
- package/docs/remote-access.en.md +8 -8
- package/docs/remote-access.md +10 -10
- package/lib/adapters/dsh-better-sidebar/index.js +21 -8
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/surface.js +79 -0
- package/lib/adapters/dsh-better-sidebar/surface.js.map +1 -0
- package/lib/client.js +102 -30
- package/lib/index.js +103 -10
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +23 -1
- package/lib/intercept.js.map +1 -1
- package/lib/merge-streams.js +29 -2
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-adapter-core.js +69 -13
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-bridge.js +179 -12
- package/lib/plugin-bridge.js.map +1 -1
- package/lib/plugin-route-intercept.js +631 -79
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +84 -12
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/relay-server.js +65 -47
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/surface.d.ts +23 -0
- package/lib/types/adapters/dsh-better-sidebar/surface.d.ts.map +1 -0
- package/lib/types/adapters/types.d.ts +14 -3
- package/lib/types/adapters/types.d.ts.map +1 -1
- package/lib/types/client/effects/turn-fold.d.ts +42 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -1
- package/lib/types/index.d.ts +2 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +19 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +12 -0
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-adapter-core.d.ts +24 -11
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-bridge.d.ts +93 -5
- package/lib/types/plugin-bridge.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +52 -3
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +27 -3
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts +2 -25
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/upload-limits.d.ts +67 -0
- package/lib/types/upload-limits.d.ts.map +1 -0
- package/lib/types/wizard/proxy-snippets.d.ts +4 -3
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/upload-limits.js +67 -0
- package/lib/upload-limits.js.map +1 -0
- package/lib/wizard/proxy-snippets.js +5 -4
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/package.json +38 -38
- package/scripts/check-upload-limits.mjs +167 -0
- package/src/adapters/dsh-better-sidebar/index.ts +21 -9
- package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
- package/src/adapters/types.ts +14 -3
- package/src/client/effects/turn-fold.ts +97 -26
- package/src/client/styles/turn-fold.css.ts +9 -0
- package/src/client-data/wizard.ts +1 -1
- package/src/index.ts +99 -12
- package/src/intercept.ts +47 -1
- package/src/merge-streams.ts +41 -1
- package/src/plugin-adapter-core.ts +69 -14
- package/src/plugin-bridge.ts +228 -14
- package/src/plugin-route-intercept.ts +677 -82
- package/src/plugin-tunnel.ts +95 -13
- package/src/relay-server.ts +60 -48
- package/src/upload-limits.ts +69 -0
- 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
|
|
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:
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* adapter exists to carry
|
|
171
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
354
|
-
* path
|
|
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. */
|
package/src/plugin-bridge.ts
CHANGED
|
@@ -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
|
|
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
|
|
34
|
-
|
|
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 >
|
|
219
|
-
fail(`routes[${index}].maxBody`, `must be a positive integer up to ${
|
|
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
|
-
|
|
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`
|
|
243
|
-
// them — `JSON.stringify` drops
|
|
244
|
-
//
|
|
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
|
})
|