dsh-zen-remote 2.0.2 → 2.2.0

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 (118) hide show
  1. package/AGENTS.md +15 -1
  2. package/README.md +11 -5
  3. package/README.zh-CN.md +11 -5
  4. package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +15 -0
  5. package/docs/adr/0002-relay-emulates-websocket.md +8 -0
  6. package/docs/interface.md +3 -1
  7. package/docs/plugin-bridge.md +140 -0
  8. package/docs/remote-access.en.md +57 -0
  9. package/docs/remote-access.md +63 -0
  10. package/lib/adapters/dsh-better-sidebar/index.js +144 -0
  11. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -0
  12. package/lib/adapters/dsh-better-sidebar/methods.js +198 -0
  13. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -0
  14. package/lib/adapters/index.js +17 -0
  15. package/lib/adapters/index.js.map +1 -0
  16. package/lib/adapters/types.js +17 -0
  17. package/lib/adapters/types.js.map +1 -0
  18. package/lib/admin-routes.js +51 -0
  19. package/lib/admin-routes.js.map +1 -1
  20. package/lib/client-routes.js +18 -1
  21. package/lib/client-routes.js.map +1 -1
  22. package/lib/client.js +449 -45
  23. package/lib/config.js +27 -0
  24. package/lib/config.js.map +1 -1
  25. package/lib/http.js +10 -0
  26. package/lib/http.js.map +1 -1
  27. package/lib/index.js +246 -0
  28. package/lib/index.js.map +1 -1
  29. package/lib/intercept.js +5 -2
  30. package/lib/intercept.js.map +1 -1
  31. package/lib/plugin-adapter-core.js +385 -0
  32. package/lib/plugin-adapter-core.js.map +1 -0
  33. package/lib/plugin-bridge.js +318 -0
  34. package/lib/plugin-bridge.js.map +1 -0
  35. package/lib/plugin-loopback.js +207 -0
  36. package/lib/plugin-loopback.js.map +1 -0
  37. package/lib/plugin-route-intercept.js +1103 -0
  38. package/lib/plugin-route-intercept.js.map +1 -0
  39. package/lib/plugin-tunnel.js +416 -0
  40. package/lib/plugin-tunnel.js.map +1 -0
  41. package/lib/relay-client.js +340 -4
  42. package/lib/relay-client.js.map +1 -1
  43. package/lib/relay-server.js +665 -7
  44. package/lib/relay-server.js.map +1 -1
  45. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +27 -0
  46. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -0
  47. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +51 -0
  48. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -0
  49. package/lib/types/adapters/index.d.ts +17 -0
  50. package/lib/types/adapters/index.d.ts.map +1 -0
  51. package/lib/types/adapters/types.d.ts +42 -0
  52. package/lib/types/adapters/types.d.ts.map +1 -0
  53. package/lib/types/admin-routes.d.ts +16 -0
  54. package/lib/types/admin-routes.d.ts.map +1 -1
  55. package/lib/types/client/effects/remote-session.d.ts +0 -22
  56. package/lib/types/client/effects/remote-session.d.ts.map +1 -1
  57. package/lib/types/client/locales.d.ts +16 -0
  58. package/lib/types/client/locales.d.ts.map +1 -1
  59. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  60. package/lib/types/client/styles/remote-session.css.d.ts +1 -1
  61. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
  62. package/lib/types/client-data/plugin-bridge.d.ts +79 -0
  63. package/lib/types/client-data/plugin-bridge.d.ts.map +1 -0
  64. package/lib/types/client-data/remote-status.d.ts +20 -0
  65. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  66. package/lib/types/client-data/settings-form.d.ts +37 -1
  67. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  68. package/lib/types/client-routes.d.ts +13 -0
  69. package/lib/types/client-routes.d.ts.map +1 -1
  70. package/lib/types/config.d.ts +8 -0
  71. package/lib/types/config.d.ts.map +1 -1
  72. package/lib/types/http.d.ts +7 -0
  73. package/lib/types/http.d.ts.map +1 -1
  74. package/lib/types/index.d.ts +4 -0
  75. package/lib/types/index.d.ts.map +1 -1
  76. package/lib/types/intercept.d.ts.map +1 -1
  77. package/lib/types/plugin-adapter-core.d.ts +263 -0
  78. package/lib/types/plugin-adapter-core.d.ts.map +1 -0
  79. package/lib/types/plugin-bridge.d.ts +187 -0
  80. package/lib/types/plugin-bridge.d.ts.map +1 -0
  81. package/lib/types/plugin-loopback.d.ts +104 -0
  82. package/lib/types/plugin-loopback.d.ts.map +1 -0
  83. package/lib/types/plugin-route-intercept.d.ts +114 -0
  84. package/lib/types/plugin-route-intercept.d.ts.map +1 -0
  85. package/lib/types/plugin-tunnel.d.ts +96 -0
  86. package/lib/types/plugin-tunnel.d.ts.map +1 -0
  87. package/lib/types/relay-client.d.ts +81 -1
  88. package/lib/types/relay-client.d.ts.map +1 -1
  89. package/lib/types/relay-server.d.ts +32 -1
  90. package/lib/types/relay-server.d.ts.map +1 -1
  91. package/llms.txt +1 -0
  92. package/package.json +2 -2
  93. package/scripts/check-plugin-bridge-ui.mjs +168 -0
  94. package/scripts/check-settings-form.mjs +3 -1
  95. package/src/adapters/dsh-better-sidebar/index.ts +144 -0
  96. package/src/adapters/dsh-better-sidebar/methods.ts +215 -0
  97. package/src/adapters/index.ts +19 -0
  98. package/src/adapters/types.ts +44 -0
  99. package/src/admin-routes.ts +64 -0
  100. package/src/client/effects/remote-session.ts +82 -10
  101. package/src/client/locales.ts +36 -0
  102. package/src/client/settings/SettingsSection.tsx +107 -3
  103. package/src/client/styles/remote-session.css.ts +70 -31
  104. package/src/client-data/plugin-bridge.ts +150 -0
  105. package/src/client-data/remote-status.ts +30 -0
  106. package/src/client-data/settings-form.ts +78 -0
  107. package/src/client-routes.ts +29 -1
  108. package/src/config.ts +32 -0
  109. package/src/http.ts +12 -0
  110. package/src/index.ts +252 -0
  111. package/src/intercept.ts +5 -2
  112. package/src/plugin-adapter-core.ts +530 -0
  113. package/src/plugin-bridge.ts +435 -0
  114. package/src/plugin-loopback.ts +299 -0
  115. package/src/plugin-route-intercept.ts +1193 -0
  116. package/src/plugin-tunnel.ts +470 -0
  117. package/src/relay-client.ts +413 -4
  118. package/src/relay-server.ts +687 -9
@@ -0,0 +1,435 @@
1
+ /**
2
+ * The plugin bridge's registration and comparison half (PB-1).
3
+ *
4
+ * Other DSH plugins can hand zen-remote a "bridge declaration": the list of
5
+ * THEIR OWN HTTP routes that may be forwarded to the main server inside a
6
+ * remote session (docs/adr/0001). A plugin counts as "bridged" only when
7
+ * BOTH ends agree on the plugin name, the exact version AND the declaration
8
+ * digest — everything here is the pure logic behind that agreement: the
9
+ * declaration shape and its validation ({@link normalizeDeclaration}), the
10
+ * per-process registry backing the `zenRemote` service
11
+ * ({@link createBridgeRegistry}), and the local-vs-server comparison that a
12
+ * later task feeds handshake data into ({@link compareBridges}).
13
+ *
14
+ * Pure logic by design: no DSH imports, no I/O, no clocks — the digest is
15
+ * the only place node:crypto appears, which is fine because this module only
16
+ * ever runs in the backend process. Nothing here talks to the relay or the
17
+ * network; the handshake side and the request rewrite side are later tasks
18
+ * that consume a {@link BridgeRegistry} through src/index.ts.
19
+ */
20
+
21
+ import { createHash } from 'node:crypto'
22
+
23
+ /** Where a bridged route's caller supplies the session id. `'session'` — the
24
+ * data is scoped to one session, forwarded after the server checks the share.
25
+ * `'server'` — whole-machine data (e.g. every zcode task), which the server
26
+ * owner must consent to per plugin (the `pluginBridgeConsent` row knob). */
27
+ export type BridgeScope = 'session' | 'server'
28
+
29
+ /** The HTTP methods a bridged route may forward (PB-9). 2.1 accepted GET
30
+ * only; the write methods arrived with the `methods` field. */
31
+ export const BRIDGE_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const
32
+
33
+ /** The body cap a route may declare (`maxBody`), in bytes: 64 MiB. */
34
+ export const MAX_ROUTE_BODY_BYTES = 64 * 1024 * 1024
35
+
36
+ /** The body cap a route gets when it declares none: 1 MiB. */
37
+ export const DEFAULT_ROUTE_BODY_BYTES = 1024 * 1024
38
+
39
+ /** One forwardable route as a plugin declares it. `sessionParam` names the
40
+ * URL query parameter carrying the session id (default `'sessionId'`);
41
+ * `stream` marks routes answered as an event stream rather than one JSON
42
+ * body. `methods` (PB-9) lists the forwardable HTTP methods — uppercase
43
+ * members of {@link BRIDGE_METHODS}, deduplicated and sorted at
44
+ * normalization; omitted means `['GET']` (the 2.1 behavior). `maxBody`
45
+ * (PB-9) caps the forwarded request body in bytes; omitted means
46
+ * {@link DEFAULT_ROUTE_BODY_BYTES}. `upgrade` (PB-10) marks the route as a
47
+ * WebSocket endpoint carried over the byte tunnel — GET-only, mutually
48
+ * exclusive with `stream`. */
49
+ export interface BridgeRoute {
50
+ path: string
51
+ scope: BridgeScope
52
+ sessionParam?: string
53
+ stream?: boolean
54
+ methods?: readonly string[]
55
+ maxBody?: number
56
+ upgrade?: boolean
57
+ }
58
+
59
+ /** What a plugin hands `zenRemote.bridge(...)` — the raw, unvalidated shape. */
60
+ export interface BridgeDeclaration {
61
+ plugin: string
62
+ version: string
63
+ routes: BridgeRoute[]
64
+ }
65
+
66
+ /** A route after validation: every scalar field filled, `methods`,
67
+ * `maxBody` and `upgrade` present ONLY when the plugin declared them
68
+ * explicitly — the distinction is load-bearing for the digest (see the
69
+ * canonical form in {@link normalizeDeclaration}), while consumers read the
70
+ * EFFECTIVE values through {@link routeMethodsOf} / {@link routeMaxBodyOf}
71
+ * / {@link routeUpgradesOf}. */
72
+ export interface NormalizedBridgeRoute {
73
+ path: string
74
+ scope: BridgeScope
75
+ sessionParam: string
76
+ stream: boolean
77
+ methods: string[] | undefined
78
+ maxBody: number | undefined
79
+ upgrade: boolean | undefined
80
+ }
81
+
82
+ /** The effective upgrade mark of one normalized route. */
83
+ export function routeUpgradesOf(route: NormalizedBridgeRoute): boolean {
84
+ return route.upgrade === true
85
+ }
86
+
87
+ /** The effective forwardable methods of one normalized route. */
88
+ export function routeMethodsOf(route: NormalizedBridgeRoute): string[] {
89
+ return route.methods ?? ['GET']
90
+ }
91
+
92
+ /** The effective body cap of one normalized route, in bytes. */
93
+ export function routeMaxBodyOf(route: NormalizedBridgeRoute): number {
94
+ return route.maxBody ?? DEFAULT_ROUTE_BODY_BYTES
95
+ }
96
+
97
+ /** A declaration after validation: every route field filled, routes sorted
98
+ * by path, and the 16-hex-char digest both ends will compare. */
99
+ export interface NormalizedDeclaration {
100
+ plugin: string
101
+ version: string
102
+ routes: NormalizedBridgeRoute[]
103
+ digest: string
104
+ }
105
+
106
+ /** Longest legal `plugin` — the npm package name cap. */
107
+ const MAX_PLUGIN_LENGTH = 214
108
+
109
+ /** Most routes one declaration may carry. */
110
+ const MAX_ROUTES = 64
111
+
112
+ /** npm package name shape: lowercase, URL-safe run of `[a-z0-9._-]` that
113
+ * starts with a letter or digit, optionally `@scope/name` with the same body
114
+ * rules on both halves. Not the full official validator (reserved names,
115
+ * edge punctuation) — enough that a typo'd string cannot masquerade as a
116
+ * package name and that the two ends normalize identically. */
117
+ const PLUGIN_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u
118
+
119
+ /** Query-parameter name carrying the session id. */
120
+ const SESSION_PARAM_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/u
121
+
122
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
123
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
124
+ }
125
+
126
+ /** Every rejection says WHICH field failed and why. */
127
+ function fail(field: string, detail: string): never {
128
+ throw new Error(`invalid bridge declaration, ${field}: ${detail}`)
129
+ }
130
+
131
+ /**
132
+ * Validate one declaration and fold it into its comparable form.
133
+ *
134
+ * Route paths must be plain absolute paths (start with `/`, no `?`, `#`,
135
+ * `..` or whitespace) and must stay off the two prefixes zen-remote does not
136
+ * own: `/_dsh/` (the plugin's own surface) and `/api` (the host's shared
137
+ * fetch channel) — bridging either would let a third plugin put words in the
138
+ * host's mouth. The `/api` check is the literal string prefix, so a plugin
139
+ * route like `/apifoo` is refused too; that overreach is deliberate, the
140
+ * rule must stay trivially predictable on both ends.
141
+ *
142
+ * The digest is sha256 over a canonical JSON of `{plugin, version, routes}`
143
+ * with a fixed key order, hex, first 16 characters — route ORDER is not part
144
+ * of it (two ends listing the same routes in different orders still match),
145
+ * any field VALUE is.
146
+ * @throws with a field-naming message on any illegal shape.
147
+ */
148
+ export function normalizeDeclaration(input: unknown): NormalizedDeclaration {
149
+ if (!isPlainObject(input)) fail('declaration', 'expected an object')
150
+ for (const key of Object.keys(input)) {
151
+ if (key !== 'plugin' && key !== 'version' && key !== 'routes') fail(key, 'unknown field (typo guard)')
152
+ }
153
+ const { plugin, version, routes } = input
154
+ if (typeof plugin !== 'string' || plugin === '') fail('plugin', 'must be a non-empty string')
155
+ if (plugin.length > MAX_PLUGIN_LENGTH) fail('plugin', `longer than ${MAX_PLUGIN_LENGTH} characters`)
156
+ if (!PLUGIN_PATTERN.test(plugin)) fail('plugin', `"${plugin}" is not an npm package name`)
157
+ if (typeof version !== 'string' || version.trim() === '') fail('version', 'must be a non-empty string')
158
+ if (!Array.isArray(routes) || routes.length === 0) fail('routes', 'must be a non-empty array')
159
+ if (routes.length > MAX_ROUTES) fail('routes', `${routes.length} routes exceeds the ${MAX_ROUTES} cap`)
160
+
161
+ const seen = new Set<string>()
162
+ const normalizedRoutes = routes.map((route, index): NormalizedBridgeRoute => {
163
+ if (!isPlainObject(route)) fail(`routes[${index}]`, 'expected an object')
164
+ for (const key of Object.keys(route)) {
165
+ if (key !== 'path' && key !== 'scope' && key !== 'sessionParam' && key !== 'stream' && key !== 'methods' && key !== 'maxBody' && key !== 'upgrade') {
166
+ fail(`routes[${index}].${key}`, 'unknown field (typo guard)')
167
+ }
168
+ }
169
+ const { path, scope, sessionParam, stream, methods, maxBody, upgrade } = route
170
+ if (typeof path !== 'string' || path === '') fail(`routes[${index}].path`, 'must be a non-empty string')
171
+ if (!path.startsWith('/')) fail(`routes[${index}].path`, `"${path}" must start with "/"`)
172
+ if (path.includes('?')) fail(`routes[${index}].path`, `"${path}" must not contain "?"`)
173
+ if (path.includes('#')) fail(`routes[${index}].path`, `"${path}" must not contain "#"`)
174
+ if (path.includes('..')) fail(`routes[${index}].path`, `"${path}" must not contain ".."`)
175
+ if (/\s/u.test(path)) fail(`routes[${index}].path`, `"${path}" must not contain whitespace`)
176
+ if (path.startsWith('/_dsh/') || path.startsWith('/api')) {
177
+ fail(`routes[${index}].path`, `"${path}" starts with a host-owned prefix (/_dsh/ or /api) and cannot be bridged`)
178
+ }
179
+ if (scope !== 'session' && scope !== 'server') {
180
+ fail(`routes[${index}].scope`, 'must be "session" or "server"')
181
+ }
182
+ const param = sessionParam === undefined ? 'sessionId' : sessionParam
183
+ if (typeof param !== 'string' || !SESSION_PARAM_PATTERN.test(param)) {
184
+ fail(`routes[${index}].sessionParam`, `"${String(param)}" must match ${SESSION_PARAM_PATTERN.source}`)
185
+ }
186
+ const streamFlag = stream === undefined ? false : stream
187
+ if (typeof streamFlag !== 'boolean') fail(`routes[${index}].stream`, 'must be a boolean')
188
+ if (seen.has(path)) fail(`routes[${index}].path`, `duplicate path "${path}"`)
189
+ seen.add(path)
190
+ // PB-9: the forwardable methods — uppercase members of the fixed
191
+ // vocabulary only (method names are case-sensitive on the wire; a
192
+ // lowercase or mixed-case entry is exactly the typo the strictness
193
+ // exists to catch), deduplicated and sorted so order never moves the
194
+ // digest. Omitted stays `undefined` — the digest-preserving choice: a
195
+ // 2.1-era declaration must hash EXACTLY as it did before this field
196
+ // existed, so defaults never enter the canonical form.
197
+ let normalizedMethods: string[] | undefined
198
+ if (methods !== undefined) {
199
+ if (!Array.isArray(methods) || methods.length === 0) {
200
+ fail(`routes[${index}].methods`, 'must be a non-empty array of HTTP methods')
201
+ }
202
+ const upper: string[] = []
203
+ for (const entry of methods) {
204
+ if (typeof entry !== 'string' || !(BRIDGE_METHODS as readonly string[]).includes(entry)) {
205
+ fail(`routes[${index}].methods`, `${JSON.stringify(entry ?? null)} is not one of ${BRIDGE_METHODS.join(', ')}`)
206
+ }
207
+ if (!upper.includes(entry)) upper.push(entry)
208
+ }
209
+ upper.sort()
210
+ normalizedMethods = upper
211
+ }
212
+ // PB-9: the body cap — a positive integer, at most
213
+ // {@link MAX_ROUTE_BODY_BYTES}. Same digest rule as `methods`: only an
214
+ // EXPLICIT maxBody enters the canonical form, even when it equals the
215
+ // default.
216
+ let normalizedMaxBody: number | undefined
217
+ 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}`)
220
+ }
221
+ normalizedMaxBody = maxBody
222
+ }
223
+ // PB-10: the WebSocket tunnel mark. Only `true` is legal (this is a
224
+ // declaration, not a toggle), it cannot coexist with `stream` (a route
225
+ // is one thing), and it is GET-only — an explicit `methods` naming
226
+ // anything else cannot be an upgrade route.
227
+ let normalizedUpgrade: boolean | undefined
228
+ if (upgrade !== undefined) {
229
+ if (upgrade !== true) fail(`routes[${index}].upgrade`, 'must be true or omitted')
230
+ if (streamFlag) fail(`routes[${index}].upgrade`, 'cannot be combined with stream')
231
+ if (normalizedMethods !== undefined && normalizedMethods.some((entry) => entry !== 'GET')) {
232
+ fail(`routes[${index}].upgrade`, 'upgrade routes are GET-only')
233
+ }
234
+ normalizedUpgrade = true
235
+ }
236
+ return { path, scope, sessionParam: param, stream: streamFlag, methods: normalizedMethods, maxBody: normalizedMaxBody, upgrade: normalizedUpgrade }
237
+ })
238
+ normalizedRoutes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
239
+
240
+ // Fixed key order = the canonical form both ends hash; insertion order of
241
+ // 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.
246
+ const canonical = JSON.stringify({
247
+ plugin,
248
+ version,
249
+ routes: normalizedRoutes.map((route) => {
250
+ const entry: Record<string, unknown> = {
251
+ path: route.path,
252
+ scope: route.scope,
253
+ sessionParam: route.sessionParam,
254
+ stream: route.stream,
255
+ }
256
+ if (route.methods !== undefined) entry.methods = route.methods
257
+ if (route.maxBody !== undefined) entry.maxBody = route.maxBody
258
+ if (route.upgrade !== undefined) entry.upgrade = route.upgrade
259
+ return entry
260
+ }),
261
+ })
262
+ const digest = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 16)
263
+ return { plugin, version, routes: normalizedRoutes, digest }
264
+ }
265
+
266
+ /** The service provided on the row context as `zenRemote`, both roles. Other
267
+ * plugins reach it with `ctx.inject(['zenRemote'], (c) => …)`; when
268
+ * zen-remote is not installed the inject never fires, so dependents need no
269
+ * conditional code. Exported as a TYPE only — no Context augmentation, so
270
+ * nothing here leaks into compositions without this plugin. */
271
+ export interface ZenRemoteService {
272
+ /** Register this process's bridge declaration. Throws on an illegal
273
+ * declaration (nothing is registered); on success returns the dispose that
274
+ * removes exactly this registration. */
275
+ bridge(declaration: BridgeDeclaration): () => void
276
+ }
277
+
278
+ /** How `register` reports a same-plugin overwrite. Injectable so tests (and
279
+ * src/index.ts, via the context logger) can observe it; absent = silent. */
280
+ export interface CreateBridgeRegistryOptions {
281
+ warn?: (message: string) => void
282
+ }
283
+
284
+ /** The per-process declaration table behind the `zenRemote` service. */
285
+ export interface BridgeRegistry {
286
+ /** Validate and record one declaration (throws pass through, nothing is
287
+ * registered). A same-plugin registration REPLACES the earlier one and
288
+ * reports through `warn`. The returned dispose removes only the
289
+ * registration it came from — after an overwrite the STALE dispose is a
290
+ * no-op and must not remove the replacement. */
291
+ register(input: unknown): () => void
292
+ /** Record an ALREADY-normalized declaration verbatim — no validation, no
293
+ * re-digest (PB-11). This is the adapter registration door: an adapter
294
+ * arrives with its own digest (over its route table, locator included)
295
+ * and its own normalized routes, shapes the cooperative validator cannot
296
+ * produce. The same-plugin replace/report/dispose semantics of
297
+ * {@link register} apply unchanged — which is exactly the yielding rule:
298
+ * a plugin's own later `zenRemote.bridge(...)` call replaces the adapter's
299
+ * entry and a warn notes the takeover. */
300
+ registerNormalized(normalized: NormalizedDeclaration): () => void
301
+ /** All live declarations, sorted by plugin name. */
302
+ list(): NormalizedDeclaration[]
303
+ get(plugin: string): NormalizedDeclaration | undefined
304
+ /** Fires once per successful register and per effective removal (an
305
+ * overwritten stale dispose fires nothing). Returns the unsubscribe. */
306
+ onChange(listener: () => void): () => void
307
+ }
308
+
309
+ /**
310
+ * Create the registry one DSH process (one plugin row) owns.
311
+ *
312
+ * Overwrite bookkeeping is identity-based, not flag-based: each dispose
313
+ * closes over the exact declaration object it registered and only deletes
314
+ * the table entry while it is STILL that object — a stale dispose meeting a
315
+ * newer registration finds someone else's identity and does nothing.
316
+ */
317
+ export function createBridgeRegistry(options: CreateBridgeRegistryOptions = {}): BridgeRegistry {
318
+ const byPlugin = new Map<string, NormalizedDeclaration>()
319
+ const listeners = new Set<() => void>()
320
+ const warn = options.warn
321
+ // A throwing listener must not break the register/remove that invoked it —
322
+ // registration is bookkeeping other plugins depend on, a bad observer is
323
+ // that observer's problem. Failures surface through warn when there is one.
324
+ const emit = (): void => {
325
+ for (const listener of [...listeners]) {
326
+ try {
327
+ listener()
328
+ } catch (error) {
329
+ warn?.(`a zenRemote onChange listener failed: ${error instanceof Error ? error.message : String(error)}`)
330
+ }
331
+ }
332
+ }
333
+ return {
334
+ register(input) {
335
+ const normalized = normalizeDeclaration(input)
336
+ const replaced = byPlugin.has(normalized.plugin)
337
+ byPlugin.set(normalized.plugin, normalized)
338
+ if (replaced) warn?.(`plugin "${normalized.plugin}" re-registered its bridge declaration; the earlier one was replaced`)
339
+ emit()
340
+ return () => {
341
+ if (byPlugin.get(normalized.plugin) !== normalized) return
342
+ byPlugin.delete(normalized.plugin)
343
+ emit()
344
+ }
345
+ },
346
+ registerNormalized(normalized) {
347
+ const replaced = byPlugin.has(normalized.plugin)
348
+ byPlugin.set(normalized.plugin, normalized)
349
+ if (replaced) warn?.(`plugin "${normalized.plugin}" re-registered its bridge entry; the earlier one was replaced`)
350
+ emit()
351
+ return () => {
352
+ if (byPlugin.get(normalized.plugin) !== normalized) return
353
+ byPlugin.delete(normalized.plugin)
354
+ emit()
355
+ }
356
+ },
357
+ list() {
358
+ return [...byPlugin.values()].sort((a, b) => (a.plugin < b.plugin ? -1 : a.plugin > b.plugin ? 1 : 0))
359
+ },
360
+ get(plugin) {
361
+ return byPlugin.get(plugin)
362
+ },
363
+ onChange(listener) {
364
+ listeners.add(listener)
365
+ return () => {
366
+ listeners.delete(listener)
367
+ }
368
+ },
369
+ }
370
+ }
371
+
372
+ /** Why a plugin is NOT bridged. The order of the cases below is the order
373
+ * {@link compareBridges} judges them in. */
374
+ export type BridgeReason =
375
+ | 'not-on-server'
376
+ | 'version-mismatch'
377
+ | 'declaration-mismatch'
378
+ | 'consent-off'
379
+ | 'offline'
380
+ | 'server-too-old'
381
+
382
+ /** One plugin's bridge verdict on this device. */
383
+ export interface BridgeStatus {
384
+ plugin: string
385
+ bridged: boolean
386
+ reason?: BridgeReason
387
+ localVersion: string
388
+ serverVersion?: string
389
+ }
390
+
391
+ /** What the server reports about one plugin at handshake time (a later task
392
+ * carries it in the relay handshake). `serverScopeConsent` records whether
393
+ * the server owner consented to this plugin's whole-machine-data routes. */
394
+ export interface ServerPluginEntry {
395
+ version: string
396
+ digest: string
397
+ serverScopeConsent: boolean
398
+ }
399
+
400
+ /**
401
+ * Compare the local registry against the server's handshake table — one
402
+ * {@link BridgeStatus} per LOCAL declaration, in the input order.
403
+ *
404
+ * Judged in this order, first hit wins: offline → the server predates the
405
+ * plugins handshake field (`undefined`, so nothing can match) → the plugin
406
+ * is absent server-side → version differs → version equal but digest
407
+ * differs (same release, different declaration — a skew worth naming) → a
408
+ * `scope:'server'` route without the server owner's consent → bridged.
409
+ *
410
+ * Consent is judged `!== true` on purpose, not `=== false`: an entry without
411
+ * the field (an older server that learned the field later) means UNKNOWN
412
+ * consent, and whole-machine data must stay home until the server says yes.
413
+ */
414
+ export function compareBridges(
415
+ local: NormalizedDeclaration[],
416
+ server: Record<string, ServerPluginEntry> | undefined,
417
+ online: boolean,
418
+ ): BridgeStatus[] {
419
+ return local.map((declaration): BridgeStatus => {
420
+ const localStatus: BridgeStatus = { plugin: declaration.plugin, bridged: false, localVersion: declaration.version }
421
+ if (!online) return { ...localStatus, reason: 'offline' }
422
+ if (server === undefined) return { ...localStatus, reason: 'server-too-old' }
423
+ const entry: unknown = server[declaration.plugin]
424
+ if (typeof entry !== 'object' || entry === null) return { ...localStatus, reason: 'not-on-server' }
425
+ const record = entry as Partial<ServerPluginEntry>
426
+ const serverVersion = typeof record.version === 'string' ? record.version : undefined
427
+ if (serverVersion !== declaration.version) return { ...localStatus, reason: 'version-mismatch', serverVersion }
428
+ if (record.digest !== declaration.digest) return { ...localStatus, reason: 'declaration-mismatch', serverVersion }
429
+ const hasServerScopeRoute = declaration.routes.some((route) => route.scope === 'server')
430
+ if (hasServerScopeRoute && record.serverScopeConsent !== true) {
431
+ return { ...localStatus, reason: 'consent-off', serverVersion }
432
+ }
433
+ return { plugin: declaration.plugin, bridged: true, localVersion: declaration.version, serverVersion }
434
+ })
435
+ }