dsh-zen-remote 2.3.3 → 2.3.4

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 (71) 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 +1 -1
  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/index.d.ts +2 -2
  33. package/lib/types/index.d.ts.map +1 -1
  34. package/lib/types/intercept.d.ts +19 -0
  35. package/lib/types/intercept.d.ts.map +1 -1
  36. package/lib/types/merge-streams.d.ts +12 -0
  37. package/lib/types/merge-streams.d.ts.map +1 -1
  38. package/lib/types/plugin-adapter-core.d.ts +24 -11
  39. package/lib/types/plugin-adapter-core.d.ts.map +1 -1
  40. package/lib/types/plugin-bridge.d.ts +93 -5
  41. package/lib/types/plugin-bridge.d.ts.map +1 -1
  42. package/lib/types/plugin-route-intercept.d.ts +52 -3
  43. package/lib/types/plugin-route-intercept.d.ts.map +1 -1
  44. package/lib/types/plugin-tunnel.d.ts +27 -3
  45. package/lib/types/plugin-tunnel.d.ts.map +1 -1
  46. package/lib/types/relay-server.d.ts +2 -25
  47. package/lib/types/relay-server.d.ts.map +1 -1
  48. package/lib/types/upload-limits.d.ts +67 -0
  49. package/lib/types/upload-limits.d.ts.map +1 -0
  50. package/lib/types/wizard/proxy-snippets.d.ts +4 -3
  51. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
  52. package/lib/upload-limits.js +67 -0
  53. package/lib/upload-limits.js.map +1 -0
  54. package/lib/wizard/proxy-snippets.js +5 -4
  55. package/lib/wizard/proxy-snippets.js.map +1 -1
  56. package/package.json +2 -2
  57. package/scripts/check-upload-limits.mjs +167 -0
  58. package/src/adapters/dsh-better-sidebar/index.ts +21 -9
  59. package/src/adapters/dsh-better-sidebar/surface.ts +90 -0
  60. package/src/adapters/types.ts +14 -3
  61. package/src/client-data/wizard.ts +1 -1
  62. package/src/index.ts +99 -12
  63. package/src/intercept.ts +47 -1
  64. package/src/merge-streams.ts +41 -1
  65. package/src/plugin-adapter-core.ts +69 -14
  66. package/src/plugin-bridge.ts +228 -14
  67. package/src/plugin-route-intercept.ts +677 -82
  68. package/src/plugin-tunnel.ts +95 -13
  69. package/src/relay-server.ts +60 -48
  70. package/src/upload-limits.ts +69 -0
  71. package/src/wizard/proxy-snippets.ts +5 -4
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { createOwnerPeerSource, createSharedApiFetchGetter } from './owner-peer.js'
19
+ import { readFileSync } from 'node:fs'
19
20
  import { lstat, mkdir, open, realpath, rename, rm, writeFile } from 'node:fs/promises'
20
21
  import type { FileHandle } from 'node:fs/promises'
21
22
  import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -55,7 +56,7 @@ import { createRelayClient, RelayError, volatileUpdateTouchesServerName } from '
55
56
  import type { RelayClient } from './relay-client.js'
56
57
  import { computeFingerprints } from './fingerprint.js'
57
58
  import { runSelfCheck, installIntercept } from './intercept.js'
58
- import type { CurrentSessionRead, InterceptDiagnostics, InterceptHandle } from './intercept.js'
59
+ import type { CurrentSessionRead, InterceptDiagnostics, InterceptHandle, SessionInventorySlot } from './intercept.js'
59
60
  import { checkGatewayShape } from './intercept-shape.js'
60
61
  import type { GatewayShapeCheck } from './intercept-shape.js'
61
62
  import { checkFetchRouteShape, installFetchRouteIntercept } from './fetch-route-intercept.js'
@@ -65,6 +66,12 @@ import type { PluginRouteInterceptDiagnostics, PluginRouteInterceptHandle, WebSe
65
66
  import { normalizeAdapter, adapterOwnsRegistryEntry } from './plugin-adapter-core.js'
66
67
  import type { NormalizedAdapter } from './plugin-adapter-core.js'
67
68
  import { ADAPTERS } from './adapters/index.js'
69
+ // PB-19: the better-sidebar surface extractor, a PURE utility inside the
70
+ // adapters directory — the one subdirectory import the wiring makes besides
71
+ // the ADAPTERS array (test/adapters-pluggable.test.cjs pins both to this
72
+ // file). The fs read that feeds it also lives HERE: nothing inside
73
+ // src/adapters/ touches the disk.
74
+ import { betterSidebarSurfaceDigest, extractBetterSidebarSurface, surfaceLooksHealthy } from './adapters/dsh-better-sidebar/surface.js'
68
75
  import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } from './relay-server.js'
69
76
  import type { RelayGateway, RelayHandler } from './relay-server.js'
70
77
  import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
@@ -80,6 +87,7 @@ import { createVerificationManager } from './wizard/verify.js'
80
87
  import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
81
88
  import { createPluginLoopback } from './plugin-loopback.js'
82
89
  import { createPluginTunnel } from './plugin-tunnel.js'
90
+ import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js'
83
91
 
84
92
  // The plugin bridge's public types (PB-1), re-exported so another plugin's
85
93
  // author can `import type { … } from 'dsh-zen-remote'` without reaching into
@@ -127,8 +135,11 @@ export const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config'
127
135
  /** Workspace-relative directory uploads land in (also the `@` prefix the composer inserts). */
128
136
  export const UPLOAD_DIR = '.dsh-uploads'
129
137
 
130
- /** Body cap when the plugin row sets no `maxUploadBytes`. */
131
- export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
138
+ // The phone composer's default body cap lives in upload-limits.ts with the
139
+ // plugin's other upload numbers (the comment there spells out why this must
140
+ // stay phone-only and never size the relay); re-exported so the main entry's
141
+ // public surface keeps the name.
142
+ export { DEFAULT_MAX_UPLOAD_BYTES }
132
143
 
133
144
  /** Longest filename, in bytes, that survives sanitization (ext4/APFS leaf limit is 255). */
134
145
  const MAX_NAME_BYTES = 180
@@ -588,12 +599,18 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
588
599
  // (`packageOf(name, ctx.baseUrl)` — verified on 0.2.0-rc.2 to answer
589
600
  // `{name, version, dir, …}` or undefined; any other shape, a missing
590
601
  // service or a probe failure reads as "not installed" and the adapter
591
- // stays out). A plugin that registers its OWN cooperative declaration
592
- // always wins: the adapter yields at registration time (logged), and the
593
- // runtime re-checks the registry digest on every request. Both roles run
594
- // this loop — the registry entries drive the handshake table, the bridge
595
- // verdicts and the settings listing on either side, while the returned
596
- // array feeds the two request halves.
602
+ // stays out). PB-19 exception: a version OUTSIDE the list still registers
603
+ // when the installed build's wire-surface digest (extracted here from its
604
+ // lib/index.js, the wiring being the only place the feature touches the
605
+ // disk) is whitelisted in the adapter's `surfaces` table — a wire-identical
606
+ // patch release bridges without a zen-remote release, and every failure of
607
+ // the extraction is logged apart from the digest-mismatch refusal. A plugin
608
+ // that registers its OWN cooperative declaration always wins: the adapter
609
+ // yields at registration time (logged), and the runtime re-checks the
610
+ // registry digest on every request. Both roles run this loop — the registry
611
+ // entries drive the handshake table, the bridge verdicts and the settings
612
+ // listing on either side, while the returned array feeds the two request
613
+ // halves.
597
614
  const adapters: NormalizedAdapter[] = []
598
615
  {
599
616
  const adapterWarn = (text: string): void => { ctx.logger?.warn('dsh-zen-remote adapter: %s', text) }
@@ -601,7 +618,7 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
601
618
  try {
602
619
  const holder = ctx as unknown as { get?: (name: string) => unknown; baseUrl?: string }
603
620
  const packages = holder.get?.('pluginPackages') as { packageOf?: (specifier: string, parentURL: string) => unknown } | undefined
604
- const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown } | undefined
621
+ const found = packages?.packageOf?.(adapter.plugin, holder.baseUrl ?? '') as { version?: unknown; dir?: unknown } | undefined
605
622
  const installedVersion = found !== null && typeof found === 'object' && typeof (found as { version?: unknown }).version === 'string'
606
623
  ? (found as { version: string }).version
607
624
  : undefined
@@ -609,14 +626,44 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
609
626
  adapterWarn(`${adapter.plugin}: target package (or its version) not found; not registered`)
610
627
  continue
611
628
  }
612
- const normalized = normalizeAdapter(adapter, installedVersion)
629
+ // PB-19: a version outside the adapter's list gets ONE more chance —
630
+ // its wire surface. Extract it, hash it, and let normalizeAdapter
631
+ // judge the digest against the adapter's `surfaces` table. Anything
632
+ // that goes wrong here leaves the digest undefined — the core then
633
+ // refuses as a plain unlisted version, and the warns below name WHY
634
+ // no digest was offered (no dir, unreadable file, lost dispatch
635
+ // table), distinct from a digest that simply missed.
636
+ const unlisted = !adapter.versions.includes(installedVersion)
637
+ let surfaceDigest: string | undefined
638
+ if (unlisted) {
639
+ const dir = typeof found?.dir === 'string' && found.dir !== '' ? found.dir : undefined
640
+ if (dir === undefined) {
641
+ adapterWarn(`${adapter.plugin}: version ${installedVersion} is unlisted and packageOf gave no dir; no surface digest to offer`)
642
+ } else {
643
+ try {
644
+ const surface = extractBetterSidebarSurface(readFileSync(join(dir, 'lib/index.js'), 'utf8'))
645
+ if (!surfaceLooksHealthy(surface)) {
646
+ adapterWarn(`${adapter.plugin}: surface extractor lost the dispatch table (${surface.methods.length} methods) — treating as unknown surface`)
647
+ } else {
648
+ surfaceDigest = betterSidebarSurfaceDigest(surface)
649
+ }
650
+ } catch (error) {
651
+ adapterWarn(`${adapter.plugin}: cannot read ${join(dir, 'lib/index.js')} for a surface digest: ${error instanceof Error ? error.message : String(error)}`)
652
+ }
653
+ }
654
+ }
655
+ const normalized = normalizeAdapter(adapter, installedVersion, surfaceDigest === undefined ? undefined : { surfaceDigest })
613
656
  if (registry.get(normalized.plugin) !== undefined) {
614
657
  adapterWarn(`${normalized.plugin}: the plugin registers its own bridge declaration; the adapter yields`)
615
658
  continue
616
659
  }
617
660
  registry.registerNormalized(normalized.declaration)
618
661
  adapters.push(normalized)
619
- ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
662
+ if (unlisted && surfaceDigest !== undefined) {
663
+ ctx.logger?.info('dsh-zen-remote adapter: %s bridged by surface digest %s (version %s unlisted)', normalized.plugin, surfaceDigest, installedVersion)
664
+ } else {
665
+ ctx.logger?.info('dsh-zen-remote adapter: %s adapted at version %s', normalized.plugin, installedVersion)
666
+ }
620
667
  } catch (error) {
621
668
  adapterWarn(`${adapter.plugin} refused: ${error instanceof Error ? error.message : String(error)}`)
622
669
  }
@@ -1301,6 +1348,39 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1301
1348
  // carries nothing new costs one compare.
1302
1349
  interceptHandle?.currentSessionChanged()
1303
1350
  }
1351
+ // PB-25b: the KNOWN-real-session inventory — the union of the merged
1352
+ // global streams' session snapshots (the workspace merger's sessionHome:
1353
+ // every real id the sidebar currently holds as a `zr~` row). Each merged
1354
+ // stream owns one slot; a report REPLACES that stream's share (never a
1355
+ // delta — merge-streams' contract) and the union rebuilds wholesale on
1356
+ // every report and dispose (stream count is single digits). The
1357
+ // plugin-route rewrite reads the union LIVE per answer through the
1358
+ // getter — the same live-read discipline as getServerId/getStatus: a
1359
+ // snapshot captured at install time would be permanently empty (the
1360
+ // streams start reporting after the install), and an id that appears
1361
+ // between two stream refreshes waits for the next refresh by design
1362
+ // (the documented ceiling in merge-streams.ts). Read-only consumption:
1363
+ // nothing below ever mutates what a merger handed over.
1364
+ const knownSessionShares = new Map<SessionInventorySlot, ReadonlySet<string>>()
1365
+ const knownRealIds = new Set<string>()
1366
+ const rebuildKnownRealIds = (): void => {
1367
+ knownRealIds.clear()
1368
+ for (const share of knownSessionShares.values()) {
1369
+ for (const id of share) knownRealIds.add(id)
1370
+ }
1371
+ }
1372
+ const createSessionInventorySlot = (): SessionInventorySlot => {
1373
+ const slot: SessionInventorySlot = {
1374
+ replace(ids) {
1375
+ knownSessionShares.set(slot, ids)
1376
+ rebuildKnownRealIds()
1377
+ },
1378
+ dispose() {
1379
+ if (knownSessionShares.delete(slot)) rebuildKnownRealIds()
1380
+ },
1381
+ }
1382
+ return slot
1383
+ }
1304
1384
  const interceptDiagnostics = (): InterceptDiagnostics | undefined => {
1305
1385
  if (interceptHandle !== undefined) return interceptHandle.diagnostics()
1306
1386
  if (interceptRefused !== undefined) {
@@ -1334,6 +1414,9 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1334
1414
  relay: relayClient,
1335
1415
  getServerId: () => relayClient.handshakeInfo?.serverId,
1336
1416
  getCurrentSession: () => reportedCurrentSession,
1417
+ // PB-25b: each merged stream reports its real-session inventory
1418
+ // share into the union above.
1419
+ createSessionInventorySlot,
1337
1420
  log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1338
1421
  })
1339
1422
  interceptHandle = handle
@@ -1491,6 +1574,10 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
1491
1574
  // routes' deny hooks read (better-sidebar's open.external carries
1492
1575
  // no session field of its own).
1493
1576
  getCurrentSession: () => (reportedCurrentSession.kind === 'open' ? reportedCurrentSession.sessionId : undefined),
1577
+ // PB-25b: the known-real-session union, read LIVE per rewriteIds
1578
+ // answer — the rewriteIds deep scan rewrites an id that already sits
1579
+ // in the browser's view even when this request never carried it.
1580
+ getKnownRealIds: () => knownRealIds,
1494
1581
  log: (format, ...args) => { ctx.logger?.info(`dsh-zen-remote ${format}`, ...args) },
1495
1582
  })
1496
1583
  pluginRouteHandle = handle
package/src/intercept.ts CHANGED
@@ -605,6 +605,19 @@ export interface InterceptDiagnostics {
605
605
  closedSessions: ClosedSessionRecord[]
606
606
  }
607
607
 
608
+ /** PB-25b: one merged global stream's share of the KNOWN-real-session
609
+ * inventory. REPLACE semantics: each `replace` call carries that stream's
610
+ * FULL current set (the workspace merger's sessionHome snapshot — every real
611
+ * id the sidebar currently holds as a `zr~` id), never a delta; `dispose`
612
+ * drops the share (the stream ended — its merger is gone, its ids may no
613
+ * longer be in the UI's view). The wiring unions the live shares into the
614
+ * read-only set the plugin-route rewrite consults; nothing here hands out a
615
+ * mutable reference to merge state. */
616
+ export interface SessionInventorySlot {
617
+ replace(ids: ReadonlySet<string>): void
618
+ dispose(): void
619
+ }
620
+
608
621
  export interface InstallInterceptOptions {
609
622
  /** The RAW gateway instance (`ctx.typertGateway[symbols.original]`), not
610
623
  * the cordis proxy — own properties on the proxy are ignored by everyone. */
@@ -629,6 +642,13 @@ export interface InstallInterceptOptions {
629
642
  * read got wrong — so the value must be carried in. Default: permanently
630
643
  * `unavailable` (the conservative fallback — tombstones stay). */
631
644
  getCurrentSession?: () => CurrentSessionRead
645
+ /** PB-25b: the inventory slot factory — one slot per merged global stream
646
+ * (only the workspace merger ever reports; the control merger has no
647
+ * session inventory). The wiring unions the live slots' shares into the
648
+ * set the plugin-route rewrite reads. Absent: no shares are ever reported
649
+ * and the plugin-route value scan stays exactly the request-local
650
+ * behavior. */
651
+ createSessionInventorySlot?: () => SessionInventorySlot
632
652
  }
633
653
 
634
654
  export interface InterceptHandle {
@@ -1339,6 +1359,10 @@ interface MergedGlobalStreamDeps {
1339
1359
  * sync can announce list rows the UI has no summary for; the control
1340
1360
  * route carries no session lists and never calls it. */
1341
1361
  summarySync: SessionSummarySync
1362
+ /** PB-25b: the inventory slot factory (the wiring's union feed — see
1363
+ * {@link SessionInventorySlot}). One slot per merged stream, disposed with
1364
+ * it; absent when the wiring passed no factory. */
1365
+ createSessionInventorySlot?: () => SessionInventorySlot
1342
1366
  }
1343
1367
 
1344
1368
  /**
@@ -1366,13 +1390,25 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1366
1390
  log?.('client merge dropped a frame on %s: %s', endpoint, message)
1367
1391
  }
1368
1392
  const initial = relayIdentityOf(relay)
1393
+ // PB-25b: this stream's inventory share — the workspace merger reports its
1394
+ // full real-id set through it (replace-style), and the share dies with the
1395
+ // stream (both exits below: the degraded local-only path and the main
1396
+ // teardown). The control merger has no session inventory and never
1397
+ // reports.
1398
+ const sessionSlot = deps.createSessionInventorySlot?.()
1369
1399
  // Created eagerly so the local leg is observed from its first frame on.
1370
1400
  // The empty identity is a placeholder: the remote pump retargets before
1371
1401
  // any remote frame is ever fed (it only runs under a real handshake).
1372
1402
  let merger: WorkspaceMerger | ControlMerger =
1373
1403
  endpoint === 'session/control'
1374
1404
  ? createControlMerger({ serverId: initial?.serverId ?? '', serverName: initial?.serverName ?? '', onDiagnostic })
1375
- : createWorkspaceMerger({ serverId: initial?.serverId ?? '', serverName: initial?.serverName ?? '', onDiagnostic, now: clock.now })
1405
+ : createWorkspaceMerger({
1406
+ serverId: initial?.serverId ?? '',
1407
+ serverName: initial?.serverName ?? '',
1408
+ onDiagnostic,
1409
+ now: clock.now,
1410
+ ...(sessionSlot !== undefined ? { onKnownSessions: (ids: ReadonlySet<string>) => sessionSlot.replace(ids) } : {}),
1411
+ })
1376
1412
 
1377
1413
  /**
1378
1414
  * The status annotation (T34) the CURRENT relay state maps onto, applied
@@ -1563,6 +1599,9 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1563
1599
  // Remote-side setup failed: degrade to a pure local passthrough so the
1564
1600
  // UI's own stream is untouched and the remote leg never starts.
1565
1601
  log?.('client merge on %s degraded to local-only: %s', endpoint, messageOf(error))
1602
+ // PB-25b: this stream never reports (no remote leg) — drop its share
1603
+ // before the degraded passthrough takes over.
1604
+ sessionSlot?.dispose()
1566
1605
  yield* localOnly(local)
1567
1606
  return
1568
1607
  }
@@ -1740,6 +1779,9 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
1740
1779
  if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
1741
1780
  currentController?.abort()
1742
1781
  flushWaiters()
1782
+ // PB-25b: the stream is over — its inventory share leaves the wiring's
1783
+ // union (its ids may no longer be in the UI's view).
1784
+ sessionSlot?.dispose()
1743
1785
  // Best-effort half-close of the local stream the consumer walked away
1744
1786
  // from — never awaited: an upstream that ignores return() must not
1745
1787
  // hang the teardown.
@@ -2425,6 +2467,9 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2425
2467
  // outlives individual stream generations so a nudge from a workspace leg
2426
2468
  // reaches whichever $events generation is open.
2427
2469
  const summarySync = createSessionSummarySync()
2470
+ // PB-25b: the inventory slot factory (the wiring's union feed) — passed
2471
+ // down to every merged stream, which owns one slot for its lifetime.
2472
+ const createSessionInventorySlot = options.createSessionInventorySlot
2428
2473
  // T62: the current-session getter and the live-merged-stream listener
2429
2474
  // registry behind handle.currentSessionChanged().
2430
2475
  const getCurrentSession = options.getCurrentSession ?? (() => ({ kind: 'unavailable' }) as const)
@@ -3341,6 +3386,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
3341
3386
  watchCurrentSession,
3342
3387
  watchUninstall,
3343
3388
  summarySync,
3389
+ createSessionInventorySlot,
3344
3390
  })
3345
3391
  })()
3346
3392
  }
@@ -220,6 +220,18 @@ export interface WorkspaceMergerOptions extends MergerIdentity {
220
220
  /** T65: the clock the tombstone hide-grace runs on. Default Date.now;
221
221
  * tests advance an injectable fake to cross the grace. */
222
222
  now?: () => number
223
+ /** PB-25b: the known-real-session inventory report — every time the
224
+ * session registry changes, the FULL current set of real ids this merger
225
+ * knows (the sessionHome keys: live ids and tombstones alike, each one the
226
+ * UI holds as a `zr~` id under this identity) is handed over as a fresh
227
+ * snapshot. REPLACE semantics, never a delta: the consumer's share is
228
+ * redefined wholesale by every report, so no invalidation bookkeeping
229
+ * exists on either side. Documented ceiling: an id that appears (or
230
+ * leaves) between two registry changes is invisible to the consumer until
231
+ * the next change reports — dumb but correct, and the wiring owns the
232
+ * other half of the contract (read-only consumption; the plugin-route
233
+ * rewrite never mutates merge state). */
234
+ onKnownSessions?: (ids: ReadonlySet<string>) => void
223
235
  }
224
236
 
225
237
  export interface WorkspaceMerger {
@@ -285,6 +297,7 @@ export interface WorkspaceMerger {
285
297
  */
286
298
  export function createWorkspaceMerger(options: WorkspaceMergerOptions): WorkspaceMerger {
287
299
  const onDiagnostic = options.onDiagnostic
300
+ const onKnownSessions = options.onKnownSessions
288
301
  let identity: MergerIdentity = { serverId: options.serverId, serverName: options.serverName }
289
302
 
290
303
  // The locally observed truth, updated from every local baseline / order /
@@ -359,19 +372,43 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
359
372
  * remembered under THIS workspace as its last home (originals, cumulative). */
360
373
  function learnSessions(record: Record<string, unknown>, workspaceId: string): void {
361
374
  if (!Array.isArray(record.sessionIds)) return
362
- for (const id of record.sessionIds) if (typeof id === 'string') sessionHome.set(id, workspaceId)
375
+ let learned = false
376
+ for (const id of record.sessionIds) {
377
+ if (typeof id === 'string') {
378
+ sessionHome.set(id, workspaceId)
379
+ learned = true
380
+ }
381
+ }
382
+ // PB-25b: the inventory grew — re-report the full set (replace-style;
383
+ // see onKnownSessions). Once per record with string ids, never per id.
384
+ if (learned) reportKnownSessions()
363
385
  }
364
386
 
365
387
  /** Drop the ids whose last home was this workspace — tombstones die with
366
388
  * their group (a server remove / a workspace diffed away; a session that
367
389
  * moved on lives under its NEW home and survives this). */
368
390
  function forgetWorkspace(workspaceId: string): void {
391
+ let forgot = false
369
392
  for (const [id, home] of sessionHome) {
370
393
  if (home === workspaceId) {
371
394
  sessionHome.delete(id)
372
395
  closedAt.delete(id)
396
+ forgot = true
373
397
  }
374
398
  }
399
+ // PB-25b: the inventory shrank — the gone ids must leave the consumer's
400
+ // share with this refresh, or a session that left the sub-client's view
401
+ // would keep being rewritten.
402
+ if (forgot) reportKnownSessions()
403
+ }
404
+
405
+ /** PB-25b: the inventory report — the FULL sessionHome key set as a fresh
406
+ * snapshot, handed to the callback replace-style. Fired at the three
407
+ * registry mutation points (learn / forget / the identity end) rather than
408
+ * once per frame: a frame that changes nothing costs nothing, and any
409
+ * future frame type that touches the registry reports automatically. */
410
+ function reportKnownSessions(): void {
411
+ onKnownSessions?.(new Set(sessionHome.keys()))
375
412
  }
376
413
 
377
414
  /** T65: stamp the ids that JUST left a workspace's live list (their
@@ -885,6 +922,9 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
885
922
  // So does the shown set: a NEW server may reuse original workspace ids,
886
923
  // and a stale entry would forward the new server's empty groups.
887
924
  shown.clear()
925
+ // PB-25b: the inventory died with the identity — the empty report is
926
+ // what empties the consumer's share (a NEW server mints new ids).
927
+ reportKnownSessions()
888
928
  return out
889
929
  },
890
930
  }
@@ -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. */