@tuongaz/seeflow 0.1.102 → 0.1.104

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 (75) hide show
  1. package/dist/web/assets/{architectureDiagram-3BPJPVTR-Cy5G99GE.js → architectureDiagram-3BPJPVTR-S06hgR1x.js} +1 -1
  2. package/dist/web/assets/{blockDiagram-GPEHLZMM-B1gN1ugF.js → blockDiagram-GPEHLZMM-ZdAkZGrj.js} +1 -1
  3. package/dist/web/assets/{c4Diagram-AAUBKEIU-CYuuPtx8.js → c4Diagram-AAUBKEIU-C5MfRz02.js} +1 -1
  4. package/dist/web/assets/channel-D-VTcFiH.js +1 -0
  5. package/dist/web/assets/{chart-YfoAGRGq.js → chart-BxTNHoKY.js} +1 -1
  6. package/dist/web/assets/{chunk-2J33WTMH-B4sFED7K.js → chunk-2J33WTMH-Bn46MReV.js} +1 -1
  7. package/dist/web/assets/{chunk-4BX2VUAB-D2Pr1i6l.js → chunk-4BX2VUAB-CXHVHaLs.js} +1 -1
  8. package/dist/web/assets/{chunk-55IACEB6-B0QJVegf.js → chunk-55IACEB6-CRgBKrhO.js} +1 -1
  9. package/dist/web/assets/{chunk-727SXJPM-Dc2HoDqb.js → chunk-727SXJPM-BH62wjtB.js} +1 -1
  10. package/dist/web/assets/{chunk-AQP2D5EJ-D30cXxxk.js → chunk-AQP2D5EJ-RxGAqK2A.js} +1 -1
  11. package/dist/web/assets/{chunk-FMBD7UC4-DsgHMGgF.js → chunk-FMBD7UC4-Cb-wgtwe.js} +1 -1
  12. package/dist/web/assets/{chunk-ND2GUHAM-n6Sr5izJ.js → chunk-ND2GUHAM-CJlFQBdh.js} +1 -1
  13. package/dist/web/assets/{chunk-QZHKN3VN-BA1s2pgD.js → chunk-QZHKN3VN-DjoTSKYv.js} +1 -1
  14. package/dist/web/assets/classDiagram-4FO5ZUOK-DoPcjHFc.js +1 -0
  15. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DoPcjHFc.js +1 -0
  16. package/dist/web/assets/{code-block-CeM4DS5T.js → code-block-DwqzBBWk.js} +1 -1
  17. package/dist/web/assets/{cose-bilkent-S5V4N54A-gJFCrqp6.js → cose-bilkent-S5V4N54A-DpGzn2ro.js} +1 -1
  18. package/dist/web/assets/{dagre-BM42HDAG-DJgtzuMT.js → dagre-BM42HDAG-CUapj-lK.js} +1 -1
  19. package/dist/web/assets/{diagram-2AECGRRQ-DfqMYNjs.js → diagram-2AECGRRQ-7ISjO-i-.js} +1 -1
  20. package/dist/web/assets/{diagram-5GNKFQAL-Bqivakd0.js → diagram-5GNKFQAL-DCPWI1Ok.js} +1 -1
  21. package/dist/web/assets/{diagram-KO2AKTUF-Cm0eKgu4.js → diagram-KO2AKTUF-zSsOIiq1.js} +1 -1
  22. package/dist/web/assets/{diagram-LMA3HP47-DCzPDQwE.js → diagram-LMA3HP47-BcqSq_nr.js} +1 -1
  23. package/dist/web/assets/{diagram-OG6HWLK6-L5YX65FQ.js → diagram-OG6HWLK6-Dw6nG7MD.js} +1 -1
  24. package/dist/web/assets/{erDiagram-TEJ5UH35-Ds6LiAGH.js → erDiagram-TEJ5UH35-B3QPOZAY.js} +1 -1
  25. package/dist/web/assets/{flowDiagram-I6XJVG4X-D50Ubn70.js → flowDiagram-I6XJVG4X-Ceu3sQuX.js} +1 -1
  26. package/dist/web/assets/{ganttDiagram-6RSMTGT7-Dkaswtst.js → ganttDiagram-6RSMTGT7-B26peYjY.js} +1 -1
  27. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-OaI-_pfD.js → gitGraphDiagram-PVQCEYII-CX8DOFLm.js} +1 -1
  28. package/dist/web/assets/{iconify-MlxYu4AY.js → iconify-DAxofKes.js} +1 -1
  29. package/dist/web/assets/{index-B6U4bGj0.js → index-B7r8KKap.js} +1758 -1743
  30. package/dist/web/assets/{index.es-BNr31JLP.js → index.es-wzlW-y-I.js} +1 -1
  31. package/dist/web/assets/{infoDiagram-5YYISTIA-dIvLrSFw.js → infoDiagram-5YYISTIA-DDVBGLt8.js} +1 -1
  32. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-LveMmZUw.js → ishikawaDiagram-YF4QCWOH-C6qCp-Zn.js} +1 -1
  33. package/dist/web/assets/{journeyDiagram-JHISSGLW-B0NT14UG.js → journeyDiagram-JHISSGLW-DX8AxLaA.js} +1 -1
  34. package/dist/web/assets/{jspdf.es.min-DIDY-jNM.js → jspdf.es.min-ykdbTFYz.js} +3 -3
  35. package/dist/web/assets/{kanban-definition-UN3LZRKU-6Ze2WLaL.js → kanban-definition-UN3LZRKU-B8-itdXZ.js} +1 -1
  36. package/dist/web/assets/{linear-lr2_gH5v.js → linear-Dqxq5R6C.js} +1 -1
  37. package/dist/web/assets/{markdown-71DgXg9K.js → markdown-D9XDrg2s.js} +1 -1
  38. package/dist/web/assets/{mermaid.core-CSskwtFd.js → mermaid.core-DujK2Rzn.js} +4 -4
  39. package/dist/web/assets/{mindmap-definition-RKZ34NQL-55BhA-Da.js → mindmap-definition-RKZ34NQL-CXa-5n8a.js} +1 -1
  40. package/dist/web/assets/{pieDiagram-4H26LBE5-CMysRCVC.js → pieDiagram-4H26LBE5-CGlnQV5E.js} +1 -1
  41. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-BDfPYcaj.js → quadrantDiagram-W4KKPZXB-L_Ap8kcw.js} +1 -1
  42. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CNfdldEP.js → requirementDiagram-4Y6WPE33-B2Io1TQi.js} +1 -1
  43. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-C_wMGAZ1.js → sankeyDiagram-5OEKKPKP-DeARBi7s.js} +1 -1
  44. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-CbGM_Yv-.js → sequenceDiagram-3UESZ5HK-BR0jm0Tk.js} +1 -1
  45. package/dist/web/assets/{stateDiagram-AJRCARHV-BCSHXjtA.js → stateDiagram-AJRCARHV-Ck_33KQ3.js} +1 -1
  46. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-D122sbeB.js +1 -0
  47. package/dist/web/assets/{time-Dm45mmVJ.js → time-DY4vPLRk.js} +1 -1
  48. package/dist/web/assets/{timeline-definition-PNZ67QCA-Cv6Hh2hS.js → timeline-definition-PNZ67QCA-DMm8bfvY.js} +1 -1
  49. package/dist/web/assets/{vennDiagram-CIIHVFJN-CNaR42oY.js → vennDiagram-CIIHVFJN-BEfqdSFT.js} +1 -1
  50. package/dist/web/assets/{wardley-L42UT6IY-BDpknWMg.js → wardley-L42UT6IY-Bmxjf0CJ.js} +1 -1
  51. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BxIDc-nV.js → wardleyDiagram-YWT4CUSO-D4ialTz3.js} +1 -1
  52. package/dist/web/assets/{xychartDiagram-2RQKCTM6-8Ir0d3kF.js → xychartDiagram-2RQKCTM6-nm_TLxlx.js} +1 -1
  53. package/dist/web/index.html +1 -1
  54. package/package.json +1 -1
  55. package/src/api.ts +241 -0
  56. package/src/atomic-write.ts +1 -1
  57. package/src/server.ts +19 -0
  58. package/src/share/sse-frame.ts +85 -0
  59. package/src/share/sse-outbound-queue.ts +173 -0
  60. package/src/share/sse-rate-limit.ts +205 -0
  61. package/src/share/sse-tap.ts +183 -0
  62. package/src/share-audit.ts +267 -0
  63. package/src/share-envelope.ts +152 -0
  64. package/src/share-file-request.ts +353 -0
  65. package/src/share-file-resolver.ts +68 -0
  66. package/src/share-file-upload.ts +595 -0
  67. package/src/share-files-manifest.ts +232 -0
  68. package/src/share-ratelimit.ts +69 -0
  69. package/src/share-rpc-schema.ts +249 -0
  70. package/src/share-transport.ts +205 -0
  71. package/src/share.ts +1561 -0
  72. package/dist/web/assets/channel-VQJmKMDU.js +0 -1
  73. package/dist/web/assets/classDiagram-4FO5ZUOK-Biiw_jnD.js +0 -1
  74. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-Biiw_jnD.js +0 -1
  75. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Bbabof8W.js +0 -1
package/src/share.ts ADDED
@@ -0,0 +1,1561 @@
1
+ /**
2
+ * Live Share controller. Owns the state machine for a host-side share session:
3
+ * idle -> starting -> active -> stopping -> idle.
4
+ *
5
+ * This module is the local-API surface that the studio HTTP routes and toolbar
6
+ * UI delegate to. start() drives the relay handshake (POST /api/share/sessions)
7
+ * and boots a WebSocket transport; transport state events drive the controller
8
+ * state machine. stop() tears the session down cleanly (and aborts a mid-boot
9
+ * start), kick() sends a kick envelope to a peer, rotateUrl() stops + restarts
10
+ * so an abused share link can be invalidated.
11
+ *
12
+ * `node-patched` broadcasts include the originator — peer SPAs reconcile their
13
+ * optimistic state against the canonical diff; suppress reconcile-only
14
+ * re-renders via the rpc-result `id` correlation in US-041.
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
18
+ import { homedir, userInfo } from 'node:os';
19
+ import { dirname, join } from 'node:path';
20
+ import { z } from 'zod';
21
+ import { writeFileAtomic } from './atomic-write.ts';
22
+ import type { EventBus } from './events.ts';
23
+ import {
24
+ ConnectorPatchBodySchema,
25
+ FLOW_BULK_NON_EMPTY_MESSAGE,
26
+ NodePatchBodySchema,
27
+ type OperationsDeps,
28
+ addConnectorImpl,
29
+ addFlowBulkImpl,
30
+ addNodeImpl,
31
+ deleteConnectorImpl,
32
+ deleteNodeImpl,
33
+ flowBulkNonEmpty,
34
+ moveNodeImpl,
35
+ patchConnectorImpl,
36
+ patchNodeImpl,
37
+ reorderNodeImpl,
38
+ } from './operations.ts';
39
+ import {
40
+ type AuditEntry,
41
+ type AuditLog,
42
+ type AuditLogOpts,
43
+ type AuditLogger,
44
+ type FrameAuditEntry,
45
+ type RpcAuditEntry,
46
+ appendShareAudit,
47
+ createAuditLog,
48
+ createAuditLogger,
49
+ } from './share-audit.ts';
50
+ import { type Envelope, makeEnvelope } from './share-envelope.ts';
51
+ import {
52
+ type FileRequestHandler,
53
+ type PutToS3,
54
+ type RequestUploadIntent,
55
+ createFileRequestHandler,
56
+ } from './share-file-request.ts';
57
+ import {
58
+ type AppendFileUploadAudit,
59
+ type FileUploadHandler,
60
+ createFileUploadHandler,
61
+ } from './share-file-upload.ts';
62
+ import { type FilesManifestBuilder, createFilesManifestBuilder } from './share-files-manifest.ts';
63
+ import { type RateLimiter, createRateLimiter } from './share-ratelimit.ts';
64
+ import { RpcFrameSchema, type RpcOp, type RpcResultFrame } from './share-rpc-schema.ts';
65
+ import {
66
+ type ShareTransport,
67
+ type ShareTransportOpts,
68
+ type ShareTransportState,
69
+ createShareTransport,
70
+ } from './share-transport.ts';
71
+ import type { SsePayload, SseSnapshotPayload } from './share/sse-frame.ts';
72
+ import {
73
+ type PeerSseQueue,
74
+ type PeerSseQueueMetrics,
75
+ createPeerSseQueue,
76
+ } from './share/sse-outbound-queue.ts';
77
+ import type { RateLimitOptions } from './share/sse-rate-limit.ts';
78
+ import { type SseTap, createSseTap } from './share/sse-tap.ts';
79
+
80
+ // Presence frame payloads. We validate `kind` against the base shape, then
81
+ // re-validate join/leave with their strict required fields so malformed
82
+ // join/leave frames are dropped rather than treated as sideband. Cursor,
83
+ // viewport, and any other future `kind` no-op in v1 per the design doc.
84
+ const PresenceBaseSchema = z.object({ kind: z.string() }).passthrough();
85
+ const PresenceJoinSchema = z.object({
86
+ kind: z.literal('join'),
87
+ peerId: z.string(),
88
+ displayName: z.string(),
89
+ });
90
+ const PresenceLeaveSchema = z.object({ kind: z.literal('leave'), peerId: z.string() });
91
+
92
+ export interface PeerSummary {
93
+ peerId: string;
94
+ displayName: string;
95
+ joinedAt: number;
96
+ /**
97
+ * Per-peer SSE outbound queue metrics surfaced for the LiveShareDialog's
98
+ * "slow peer" warning (US-072). Absent when the peer has no SSE queue
99
+ * (e.g. before the bridge has wired up, or when the session was started
100
+ * without an `eventBus`).
101
+ */
102
+ outboundSse?: PeerSseQueueMetrics;
103
+ }
104
+
105
+ export type ShareState =
106
+ | { status: 'idle' }
107
+ | { status: 'starting' }
108
+ | {
109
+ status: 'active';
110
+ sessionId: string;
111
+ token: string;
112
+ url: string;
113
+ peers: PeerSummary[];
114
+ startedAt: number;
115
+ // Display label used as `attributedTo.displayName` for host-originated
116
+ // node-patched broadcasts. Derived from `ShareDeps.hostDisplayName`
117
+ // (defaults to 'Host') and exposed on state so the SSE bridge and local
118
+ // studio UI can render the host's own suppressed self-attribution.
119
+ hostDisplayName: string;
120
+ // US-082: count of sessions currently tracked in active.json. Surfaced
121
+ // on state so the apps/web LiveShareDialog can disable the kill-switch
122
+ // button (and render "Active sessions: N") without a side-channel fetch.
123
+ recentSessionCount: number;
124
+ }
125
+ | { status: 'stopping' }
126
+ | { status: 'error'; reason: string };
127
+
128
+ export interface ShareController {
129
+ start(): Promise<{ url: string; sessionId: string }>;
130
+ stop(): Promise<void>;
131
+ kick(peerId: string): Promise<void>;
132
+ rotateUrl(): Promise<{ url: string }>;
133
+ /**
134
+ * Host kill-switch (US-081). Revokes every session this studio has ever
135
+ * opened — not just the currently active one. Tracked sessions live in
136
+ * `<auditDir>/active.json`; killAll POSTs `/api/share/end` to the relay
137
+ * for each entry, appends a `kill-switch` audit entry to each affected
138
+ * session's JSONL log, then truncates `active.json`. Returns counts so
139
+ * the UI toast can show "Ended N live sessions".
140
+ */
141
+ killAll(): Promise<{ revoked: number; failed: number }>;
142
+ state(): ShareState;
143
+ subscribe(fn: (s: ShareState) => void): () => void;
144
+ /**
145
+ * Dispatch an inbound `rpc` envelope as if it came from `fromPeerId`. Tests
146
+ * call this directly without driving the transport. When the originator is
147
+ * a known peer (registered via presence/join), pass `displayName` so the
148
+ * outgoing `node-patched` broadcast's `attributedTo` field carries the
149
+ * human-readable label; if omitted, `fromPeerId` is used as the fallback.
150
+ */
151
+ handleRpcFrame(frame: unknown, fromPeerId: string, displayName?: string): Promise<RpcResultFrame>;
152
+ /**
153
+ * Broadcast a `node-patched` frame for an edit applied by the host's own UI
154
+ * (no peer rpc). `attributedTo.peerId` is the literal `'host'` and
155
+ * `displayName` is the active state's `hostDisplayName`. No-ops when the
156
+ * controller is not active or the outcome was not `kind: 'ok'`. Returns the
157
+ * monotonic per-flow version assigned (or `null` when no broadcast fired).
158
+ */
159
+ broadcastHostEdit(op: RpcOp, outcome: RpcDispatchOutcome): number | null;
160
+ /**
161
+ * Subscribe to attribution events fired for every accepted op the host
162
+ * broadcasts (peer-originated AND host-originated). Used by the host
163
+ * studio's apps/web UI to render attribution toasts (US-053); the SSE
164
+ * bridge in `apps/studio/src/api.ts` fans these out to /api/share/attributions.
165
+ */
166
+ subscribeAttributions(fn: (event: AttributionEvent) => void): () => void;
167
+ /**
168
+ * Read access to the per-session `AuditEntry` JSONL stream (US-079). The
169
+ * /api/share/audit endpoint delegates to this; when no session is active
170
+ * `list()` returns an empty page so the endpoint can still answer 200
171
+ * (the endpoint itself gates on state before calling).
172
+ */
173
+ audit: {
174
+ list(opts?: {
175
+ limit?: number;
176
+ cursor?: number;
177
+ }): Promise<{ entries: AuditEntry[]; nextCursor: number | null }>;
178
+ };
179
+ }
180
+
181
+ export interface AttributionEvent {
182
+ flowId: string;
183
+ op: string;
184
+ diff: unknown;
185
+ version: number;
186
+ attributedTo: { peerId: string; displayName: string };
187
+ ts: number;
188
+ }
189
+
190
+ // Generic outcome shape returned by a dispatcher entry. Each operations.ts
191
+ // impl already conforms (`{ kind: 'ok'; data?: ... } | { kind: ...; message?: ... }`),
192
+ // so the default dispatcher passes them through. Tests inject custom
193
+ // dispatchers that return whatever they want for the per-op happy paths.
194
+ export interface RpcDispatchOutcome {
195
+ kind: string;
196
+ data?: unknown;
197
+ message?: string;
198
+ }
199
+
200
+ export type RpcDispatcher = (op: RpcOp) => Promise<RpcDispatchOutcome>;
201
+
202
+ // Op allowlist — duplicated from `RpcOpSchema`'s discriminator literals as a
203
+ // runtime defense-in-depth. The Zod schema rejects unknown ops up front; this
204
+ // secondary gate catches a tampered runtime injection (e.g. a future code path
205
+ // that skips the schema parse) before it reaches the impl.
206
+ const ALLOWED_RPC_OPS = new Set<RpcOp['op']>([
207
+ 'addNode',
208
+ 'patchNode',
209
+ 'moveNode',
210
+ 'reorderNode',
211
+ 'deleteNode',
212
+ 'addConnector',
213
+ 'patchConnector',
214
+ 'deleteConnector',
215
+ 'addBulk',
216
+ ]);
217
+
218
+ export function createDefaultRpcDispatcher(deps: OperationsDeps): RpcDispatcher {
219
+ return async (op) => {
220
+ switch (op.op) {
221
+ case 'addNode':
222
+ return addNodeImpl(deps, op.flowId, op.node);
223
+ case 'patchNode': {
224
+ const parsed = NodePatchBodySchema.safeParse(op.patch);
225
+ if (!parsed.success) return { kind: 'badSchema', message: parsed.error.message };
226
+ return patchNodeImpl(deps, op.flowId, op.nodeId, parsed.data);
227
+ }
228
+ case 'moveNode':
229
+ return moveNodeImpl(deps, op.flowId, op.nodeId, op.position);
230
+ case 'reorderNode':
231
+ return reorderNodeImpl(deps, op.flowId, op.nodeId, op.reorder);
232
+ case 'deleteNode':
233
+ return deleteNodeImpl(deps, op.flowId, op.nodeId);
234
+ case 'addConnector':
235
+ return addConnectorImpl(deps, op.flowId, op.connector);
236
+ case 'patchConnector': {
237
+ const parsed = ConnectorPatchBodySchema.safeParse(op.patch);
238
+ if (!parsed.success) return { kind: 'badSchema', message: parsed.error.message };
239
+ return patchConnectorImpl(deps, op.flowId, op.connectorId, parsed.data);
240
+ }
241
+ case 'deleteConnector':
242
+ return deleteConnectorImpl(deps, op.flowId, op.connectorId);
243
+ case 'addBulk': {
244
+ const body = { nodes: op.nodes, connectors: op.connectors };
245
+ // The non-empty refine couldn't be expressed in the wire schema (a
246
+ // ZodEffects member can't participate in a discriminatedUnion). It's
247
+ // enforced here before dispatch so the wire stays loose.
248
+ if (!flowBulkNonEmpty(body)) {
249
+ return { kind: 'invalid', message: FLOW_BULK_NON_EMPTY_MESSAGE };
250
+ }
251
+ return addFlowBulkImpl(deps, op.flowId, body);
252
+ }
253
+ }
254
+ };
255
+ }
256
+
257
+ export interface ShareDeps {
258
+ relayHttpUrl: string;
259
+ shareUrlBase: string;
260
+ fetch?: typeof fetch;
261
+ transportFactory?: (opts: ShareTransportOpts) => ShareTransport;
262
+ // Rate-limiter is shared across the controller's lifetime so per-peer
263
+ // buckets survive across kick/rejoin within a single session. Defaults to
264
+ // 30 ops/sec / burst 30 per the design doc.
265
+ rateLimiter?: RateLimiter;
266
+ // Audit log lives one-per-session: created on transition to active, closed
267
+ // by stop(). Path defaults to ~/.seeflow/share-history.
268
+ auditDir?: string;
269
+ /**
270
+ * Path to the active-sessions tracking file (US-081 kill-switch). Defaults
271
+ * to `<auditDir>/active.json`. The host appends `{sessionId, hostKey}` on
272
+ * each start() and removes it on stop(); `killAll()` reads the file,
273
+ * revokes every entry via POST /api/share/end, then truncates.
274
+ */
275
+ activeSessionsPath?: string;
276
+ auditLogFactory?: (opts: AuditLogOpts) => AuditLog;
277
+ // Phase-8 audit logger (US-078 shape: AuditEntry with kind). Created on
278
+ // idle -> active alongside the legacy per-frame `auditLog`. Tests inject a
279
+ // capturing factory; production callers leave it undefined and the real
280
+ // `createAuditLogger` writes to `auditDir`.
281
+ auditLoggerFactory?: (sessionId: string, root?: string) => AuditLogger;
282
+ // Local EventBus to bridge onto outbound 'sse' envelopes so peers see live
283
+ // runtime events (node:running, node:done, etc.) the same way the studio's
284
+ // own SSE listeners do. On transition idle -> active we subscribe to each
285
+ // flowId returned by flowIdsForBroadcast() and forward every StudioEvent as
286
+ // makeEnvelope('sse', event, { to: 'all', from: 'host' }). Subscriptions are
287
+ // torn down on teardown(). If either dep is absent the bridge is a no-op.
288
+ eventBus?: EventBus;
289
+ flowIdsForBroadcast?: () => string[];
290
+ // OperationsDeps used by the default RPC dispatcher. Required when
291
+ // `rpcDispatcher` is omitted; tests inject `rpcDispatcher` directly and can
292
+ // skip this.
293
+ operationsDeps?: OperationsDeps;
294
+ // Per-op dispatcher invoked by `handleRpcFrame` after envelope validation.
295
+ // Defaults to `createDefaultRpcDispatcher(operationsDeps)`. Tests inject
296
+ // stubs to assert dispatch contract without touching the real impls.
297
+ rpcDispatcher?: RpcDispatcher;
298
+ // Injected for tests so they can capture audit writes without hitting disk.
299
+ // Defaults to the real `appendShareAudit` writing to `auditDir`.
300
+ appendShareAuditFn?: (sessionId: string, entry: RpcAuditEntry) => void;
301
+ // Outbound broadcast seam. Defaults to forwarding the envelope through the
302
+ // active transport when open. Tests inject a spy to assert the
303
+ // node-patched fan-out without standing up a relay.
304
+ broadcast?: (envelope: Envelope) => void;
305
+ // Display label used as `attributedTo.displayName` for host-originated
306
+ // node-patched broadcasts (and surfaced on `state().hostDisplayName` while
307
+ // active). Defaults to `'Host'`. US-054 supplies the real OS username.
308
+ hostDisplayName?: string;
309
+ // Optional S3 staging deps for the oversize file-request path (US-060).
310
+ // Both must be present to enable >256 KB file serving; otherwise the
311
+ // handler replies `too-large` for big files. Tests can wire stubs.
312
+ requestUploadIntent?: RequestUploadIntent;
313
+ putToS3?: PutToS3;
314
+ // Test seam: swap the entire file-request handler. Production callers
315
+ // leave this undefined and the controller builds one from registry +
316
+ // broadcast + the S3 deps above.
317
+ fileRequestHandler?: FileRequestHandler;
318
+ // Test seam: swap the entire file-upload handler. Production callers leave
319
+ // this undefined and the controller builds one from registry + broadcast +
320
+ // the active session id.
321
+ fileUploadHandler?: FileUploadHandler;
322
+ // Injected for tests so they can capture the upload audit entry without
323
+ // hitting disk. Defaults to `appendShareAudit` writing to `auditDir`.
324
+ appendFileUploadAuditFn?: AppendFileUploadAudit;
325
+ // Test seam: swap the entire files-manifest builder (US-062). Production
326
+ // callers leave this undefined and the controller builds one from
327
+ // `operationsDeps.registry`. Without a registry the manifest is empty.
328
+ filesManifestBuilder?: FilesManifestBuilder;
329
+ /**
330
+ * Per-peer outbound SSE send seam (US-072). Awaited per-frame inside the
331
+ * per-peer queue so a slow consumer creates backpressure HERE rather than
332
+ * in the producer. Defaults to a sync wrapper around `broadcast` that
333
+ * sends an `sse` envelope addressed to the peer's connId. Tests inject a
334
+ * delayed stub to exercise queue overflow + drop policy without standing
335
+ * up a transport.
336
+ */
337
+ outboundSseSend?: (payload: SsePayload, peerConnId: string) => Promise<void> | void;
338
+ /** Max in-queue frames per peer before eviction kicks in. Defaults to 256. */
339
+ outboundSseMaxFrames?: number;
340
+ /** Lifetime drops within this rolling window trigger a resync. Default 60000. */
341
+ outboundSseDropResyncWindowMs?: number;
342
+ /** Default 100. */
343
+ outboundSseDropResyncThreshold?: number;
344
+ /**
345
+ * Tap-level rate-limit override (US-068). Passed through to `createSseTap`
346
+ * as its `rateLimit` option. Defaults to the tap's own defaults (60/sec,
347
+ * burst 120). Pass `false` to disable when testing per-peer backpressure
348
+ * (US-072) so frames reach the queues without the tap's coalescer
349
+ * absorbing the storm first.
350
+ */
351
+ sseTapRateLimit?: false | Omit<RateLimitOptions, 'onEmit'>;
352
+ }
353
+
354
+ interface RelaySessionResponse {
355
+ sessionId: string;
356
+ token: string;
357
+ hostKey: string;
358
+ wsUrl: string;
359
+ }
360
+
361
+ interface BootHandle {
362
+ settled: boolean;
363
+ cancelTimer: () => void;
364
+ rejectStart: (err: Error) => void;
365
+ }
366
+
367
+ const BOOT_TIMEOUT_MS = 10_000;
368
+
369
+ // Per-frame snapshot payload size cap. The host splits the snapshot into
370
+ // per-flow chunks so no single `sse-snapshot` frame's serialized JSON exceeds
371
+ // this size. A flow whose own snapshot is larger than the cap is still
372
+ // emitted as one chunk — per-flow is the indivisible unit per the PRD.
373
+ export const SSE_SNAPSHOT_CHUNK_BYTES = 256 * 1024;
374
+
375
+ /**
376
+ * Split a per-flow snapshot map into chunks so each chunk's serialized JSON
377
+ * fits within `SSE_SNAPSHOT_CHUNK_BYTES`. Returns at least one chunk even
378
+ * when the input is empty (callers gate on that separately). Always preserves
379
+ * full flows — a single flow that overflows the cap occupies its own chunk
380
+ * regardless of size.
381
+ */
382
+ export function chunkSnapshotByFlow<T>(
383
+ snap: Record<string, Record<string, T>>,
384
+ ): Record<string, Record<string, T>>[] {
385
+ const entries = Object.entries(snap);
386
+ if (entries.length === 0) return [];
387
+
388
+ const chunks: Record<string, Record<string, T>>[] = [];
389
+ let current: Record<string, Record<string, T>> = {};
390
+
391
+ for (const [flowId, nodes] of entries) {
392
+ const candidate: Record<string, Record<string, T>> = { ...current, [flowId]: nodes };
393
+ const candidateSize = JSON.stringify({ flows: candidate }).length;
394
+ const isCurrentEmpty = Object.keys(current).length === 0;
395
+ if (!isCurrentEmpty && candidateSize > SSE_SNAPSHOT_CHUNK_BYTES) {
396
+ chunks.push(current);
397
+ current = { [flowId]: nodes };
398
+ continue;
399
+ }
400
+ current = candidate;
401
+ }
402
+ if (Object.keys(current).length > 0) chunks.push(current);
403
+ return chunks;
404
+ }
405
+
406
+ /**
407
+ * Resolve the host's display label for `attributedTo.displayName` on
408
+ * host-originated `node-patched` broadcasts (US-054). Tries the running OS
409
+ * user's `username` first; falls back to literal `'Host'` if the syscall
410
+ * throws (sandboxed envs) or returns a blank value. Trimmed so a username
411
+ * with whitespace doesn't propagate through to UI.
412
+ */
413
+ export function resolveHostDisplayName(): string {
414
+ try {
415
+ const name = userInfo().username;
416
+ if (typeof name === 'string') {
417
+ const trimmed = name.trim();
418
+ if (trimmed.length > 0) return trimmed;
419
+ }
420
+ } catch {
421
+ // userInfo() throws on some sandboxed runtimes (e.g. Bun in a sealed
422
+ // container with no /etc/passwd). Fall through to the literal default.
423
+ }
424
+ return 'Host';
425
+ }
426
+
427
+ // Fallback frame id surfaced when the inbound envelope failed validation
428
+ // hard enough that we can't recover `frame.id`. Anything is fine as long as
429
+ // it satisfies the wire schema's `min(1)` constraint on the `id` field.
430
+ const INVALID_FRAME_ID = 'invalid';
431
+
432
+ const extractFrameId = (frame: unknown): string => {
433
+ if (frame && typeof frame === 'object' && 'id' in frame) {
434
+ const raw = (frame as { id?: unknown }).id;
435
+ if (typeof raw === 'string' && raw.length > 0) return raw;
436
+ }
437
+ return INVALID_FRAME_ID;
438
+ };
439
+
440
+ const makeRpcResultFrame = (id: string, payload: RpcResultFrame['payload']): RpcResultFrame => ({
441
+ v: 1,
442
+ type: 'rpc-result',
443
+ id,
444
+ payload,
445
+ });
446
+
447
+ // Build the diff payload broadcast as `node-patched` after a successful op.
448
+ // Each branch follows the contract documented on US-039:
449
+ // moveNode -> { kind: 'move', nodeId, position }
450
+ // patchNode -> { kind: 'patch', nodeId, patch }
451
+ // addNode -> { kind: 'add', node } (full new node from outcome)
452
+ // deleteNode -> { kind: 'delete', nodeId }
453
+ // reorderNode -> { kind: 'reorder', nodeId, op }
454
+ // Connector variants mirror the same kinds with `connectorId` instead of
455
+ // `nodeId`. `addBulk` reports `{ kind: 'bulk', result }` so peers can decide
456
+ // whether to refetch or apply incrementally.
457
+ function computeNodePatchedDiff(op: RpcOp, outcome: RpcDispatchOutcome): unknown {
458
+ switch (op.op) {
459
+ case 'moveNode':
460
+ return { kind: 'move', nodeId: op.nodeId, position: op.position };
461
+ case 'patchNode':
462
+ return { kind: 'patch', nodeId: op.nodeId, patch: op.patch };
463
+ case 'addNode': {
464
+ const data = outcome.data as { node?: unknown } | undefined;
465
+ return { kind: 'add', node: data?.node };
466
+ }
467
+ case 'deleteNode':
468
+ return { kind: 'delete', nodeId: op.nodeId };
469
+ case 'reorderNode':
470
+ return { kind: 'reorder', nodeId: op.nodeId, op: op.reorder };
471
+ case 'addConnector': {
472
+ const data = outcome.data as { id?: string } | undefined;
473
+ const connector =
474
+ data?.id !== undefined ? { ...op.connector, id: data.id } : { ...op.connector };
475
+ return { kind: 'add', connector };
476
+ }
477
+ case 'patchConnector':
478
+ return { kind: 'patch', connectorId: op.connectorId, patch: op.patch };
479
+ case 'deleteConnector':
480
+ return { kind: 'delete', connectorId: op.connectorId };
481
+ case 'addBulk':
482
+ return { kind: 'bulk', result: outcome.data };
483
+ }
484
+ }
485
+
486
+ export function createShareController(deps: ShareDeps): ShareController {
487
+ // current is mutated through setState() so subscribers fan-out on every
488
+ // transition. hostKey + transport live in closure scope — hostKey is never
489
+ // returned by state() or logged. bootHandle is non-null only while start()
490
+ // is in flight; stop() consults it to abort a mid-boot start.
491
+ let current: ShareState = { status: 'idle' };
492
+ const subscribers = new Set<(s: ShareState) => void>();
493
+ const attributionSubscribers = new Set<(event: AttributionEvent) => void>();
494
+ const fetchFn = deps.fetch ?? fetch;
495
+ const transportFactory = deps.transportFactory ?? createShareTransport;
496
+ const rateLimiter = deps.rateLimiter ?? createRateLimiter({ ratePerSec: 30, burst: 30 });
497
+ const auditDir = deps.auditDir ?? join(homedir(), '.seeflow', 'share-history');
498
+ const activeSessionsPath = deps.activeSessionsPath ?? join(auditDir, 'active.json');
499
+ const auditLogFactory = deps.auditLogFactory ?? createAuditLog;
500
+ const auditLoggerFactory = deps.auditLoggerFactory ?? createAuditLogger;
501
+ const hostDisplayName = deps.hostDisplayName ?? resolveHostDisplayName();
502
+ const rpcDispatcher: RpcDispatcher | null =
503
+ deps.rpcDispatcher ??
504
+ (deps.operationsDeps ? createDefaultRpcDispatcher(deps.operationsDeps) : null);
505
+ const appendShareAuditFn =
506
+ deps.appendShareAuditFn ??
507
+ ((sessionId, entry) => appendShareAudit(sessionId, entry, { dir: auditDir }));
508
+ // Per-flow monotonic counter bumped just before every accepted node-patched
509
+ // broadcast. Peers use this only for tie-breaking out-of-order frames in a
510
+ // future story; for now we only assert monotonic-increasing in tests.
511
+ const flowVersions = new Map<string, number>();
512
+
513
+ let hostKey: string | null = null;
514
+ let transport: ShareTransport | null = null;
515
+ let bootHandle: BootHandle | null = null;
516
+ let auditLog: AuditLog | null = null;
517
+ let auditLogger: AuditLogger | null = null;
518
+ // SSE tap (US-066/US-067): single per-controller instance owning the
519
+ // EventBus -> outbound `sse` envelope bridge. Created on idle -> active,
520
+ // torn down on stop()/teardown(). Null when inactive or when no eventBus
521
+ // dep was provided.
522
+ let sseTap: SseTap | null = null;
523
+ // Unsubscribe handle for the `__registry__` listener that drives
524
+ // `sseTap.refreshFlows()` on registry:reload events. Captured at active
525
+ // time so teardown can drop the subscription cleanly.
526
+ let registryUnsubscribe: (() => void) | null = null;
527
+ // peerId -> connId. Populated by presence/join frames; consulted by kick()
528
+ // so a host can address a peer by stable peerId while the relay routes on
529
+ // the per-connection connId. Cleared on teardown.
530
+ const peerConnIds = new Map<string, string>();
531
+ // connId -> peer info. Inverse of peerConnIds, kept in lockstep. Used by
532
+ // handleFrame to resolve env.from -> peerId before rate-limiting/auditing.
533
+ const connPeers = new Map<string, { peerId: string; displayName: string }>();
534
+ // Per-peer outbound SSE queues (US-072), keyed by connId. Created on
535
+ // presence/join, disposed on presence/leave + teardown. Absent before the
536
+ // first peer joins; the SSE bridge falls back to a `to: 'all'` broadcast
537
+ // when this map is empty so the relay's own fan-out still reaches
538
+ // unattributed peers (and so the legacy 0-peer tests keep passing).
539
+ const peerSseQueues = new Map<string, PeerSseQueue>();
540
+ const outboundSseMaxFrames = deps.outboundSseMaxFrames;
541
+ const outboundSseDropResyncThreshold = deps.outboundSseDropResyncThreshold;
542
+ const outboundSseDropResyncWindowMs = deps.outboundSseDropResyncWindowMs;
543
+
544
+ const broadcast =
545
+ deps.broadcast ??
546
+ ((envelope: Envelope) => {
547
+ if (!transport || !transport.isOpen()) return;
548
+ try {
549
+ transport.send(envelope);
550
+ } catch (err) {
551
+ console.warn('[share] broadcast send failed:', err);
552
+ }
553
+ });
554
+
555
+ // Default per-peer SSE send: emit an addressed `sse` envelope through the
556
+ // broadcast seam. Tests override with a stub that returns a delayed Promise
557
+ // to exercise queue backpressure without touching the transport.
558
+ const outboundSseSend: (payload: SsePayload, peerConnId: string) => Promise<void> | void =
559
+ deps.outboundSseSend ??
560
+ ((payload, peerConnId) => {
561
+ broadcast(makeEnvelope('sse', payload, { to: peerConnId, from: 'host' }));
562
+ });
563
+
564
+ // File-request handler (US-060). Instantiated lazily so test deps that pass
565
+ // an explicit `fileRequestHandler` win; otherwise we build one from the
566
+ // registry on `operationsDeps`. Without a registry there is no resolver
567
+ // surface and file-request envelopes are dropped with a warn.
568
+ const fileRequestHandler: FileRequestHandler | null =
569
+ deps.fileRequestHandler ??
570
+ (deps.operationsDeps
571
+ ? createFileRequestHandler({
572
+ registry: deps.operationsDeps.registry,
573
+ broadcast: (env) => broadcast(env),
574
+ requestUploadIntent: deps.requestUploadIntent,
575
+ putToS3: deps.putToS3,
576
+ })
577
+ : null);
578
+
579
+ // File-upload handler (US-061). Mirrors the file-request lazy-instantiation
580
+ // contract: tests supply an explicit `fileUploadHandler` to short-circuit;
581
+ // production callers thread `operationsDeps.registry`. Without a registry
582
+ // upload frames are dropped with a warn.
583
+ const appendFileUploadAuditFn: AppendFileUploadAudit =
584
+ deps.appendFileUploadAuditFn ??
585
+ ((sessionId, entry) => appendShareAudit(sessionId, entry, { dir: auditDir }));
586
+ const fileUploadHandler: FileUploadHandler | null =
587
+ deps.fileUploadHandler ??
588
+ (deps.operationsDeps
589
+ ? createFileUploadHandler({
590
+ registry: deps.operationsDeps.registry,
591
+ broadcast: (env) => broadcast(env),
592
+ getSessionId: () => (current.status === 'active' ? current.sessionId : null),
593
+ appendAudit: appendFileUploadAuditFn,
594
+ })
595
+ : null);
596
+
597
+ // Files-manifest builder (US-062). Lazy-instantiated from the registry, with
598
+ // the same test-seam pattern as file-request / file-upload handlers. The
599
+ // controller drives `init()` on transition idle -> active and emits one
600
+ // `files-manifest` frame per accepted presence/join.
601
+ const filesManifestBuilder: FilesManifestBuilder | null =
602
+ deps.filesManifestBuilder ??
603
+ (deps.operationsDeps
604
+ ? createFilesManifestBuilder({ registry: deps.operationsDeps.registry })
605
+ : null);
606
+ // Resolves when `filesManifestBuilder.init()` has finished for the current
607
+ // session. Set on idle -> active; cleared on teardown. A presence/join that
608
+ // races with init awaits this before broadcasting.
609
+ let filesManifestReady: Promise<void> | null = null;
610
+
611
+ // Enrich an active ShareState with current per-peer outbound SSE metrics
612
+ // (US-072) and the live tracked-session count (US-082). Idempotent —
613
+ // re-reading state() at any time picks up the latest queue depths/drops AND
614
+ // the latest `active.json` count without requiring a setState transition.
615
+ const enrichWithSseMetrics = (s: ShareState): ShareState => {
616
+ if (s.status !== 'active') return s;
617
+ const enrichedPeers = s.peers.map((p) => {
618
+ const connId = peerConnIds.get(p.peerId);
619
+ const queue = connId ? peerSseQueues.get(connId) : undefined;
620
+ if (!queue) return p;
621
+ return { ...p, outboundSse: queue.metrics() };
622
+ });
623
+ return {
624
+ ...s,
625
+ peers: enrichedPeers,
626
+ recentSessionCount: readTrackedSessions().length,
627
+ };
628
+ };
629
+
630
+ const setState = (next: ShareState) => {
631
+ current = next;
632
+ const enriched = enrichWithSseMetrics(next);
633
+ for (const fn of subscribers) {
634
+ try {
635
+ fn(enriched);
636
+ } catch (err) {
637
+ console.error('[share] subscriber threw on transition:', err);
638
+ }
639
+ }
640
+ };
641
+
642
+ const subscribeToEventBus = () => {
643
+ if (!deps.eventBus || !deps.flowIdsForBroadcast) return;
644
+ const flowIdsForBroadcast = deps.flowIdsForBroadcast;
645
+ // Build the SSE tap with a fresh per-session monotonic seq counter (owned
646
+ // by createSseTap). onEvent forwards the validated SsePayload as the
647
+ // `payload` of an outbound `sse` envelope; the buffer + snapshot are
648
+ // private to the tap and used by US-069 / US-070 for join replay.
649
+ const tap = createSseTap(deps.eventBus, {
650
+ flowIds: () => flowIdsForBroadcast(),
651
+ ...(deps.sseTapRateLimit !== undefined ? { rateLimit: deps.sseTapRateLimit } : {}),
652
+ onEvent: (payload) => {
653
+ // With at least one connected peer, fan out per-peer through bounded
654
+ // queues so a slow consumer can't stall the host event loop (US-072).
655
+ // With zero known peers, fall back to the legacy `to: 'all'` broadcast
656
+ // so the relay still fans out to any peer the host hasn't seen
657
+ // presence/join for yet (e.g. first frame after auth-peer races with
658
+ // the bridge).
659
+ if (peerSseQueues.size === 0) {
660
+ try {
661
+ broadcast(makeEnvelope('sse', payload, { to: 'all', from: 'host' }));
662
+ } catch (err) {
663
+ console.warn('[share] sse fan-out broadcast failed:', err);
664
+ }
665
+ return;
666
+ }
667
+ for (const queue of peerSseQueues.values()) {
668
+ try {
669
+ queue.enqueue(payload);
670
+ } catch (err) {
671
+ console.warn('[share] sse enqueue failed:', err);
672
+ }
673
+ }
674
+ },
675
+ });
676
+ sseTap = tap;
677
+ tap.start();
678
+ // Watch the registry channel so adds/removes flow through to the tap's
679
+ // subscription set without restarting the share session. Listens on the
680
+ // sentinel flowId used by `apps/studio/src/api.ts` registry mutators.
681
+ registryUnsubscribe = deps.eventBus.subscribe('__registry__', (event) => {
682
+ if (event.type !== 'registry:reload') return;
683
+ try {
684
+ tap.refreshFlows();
685
+ } catch (err) {
686
+ console.warn('[share] sse tap refreshFlows failed:', err);
687
+ }
688
+ });
689
+ };
690
+
691
+ const unsubscribeFromEventBus = () => {
692
+ const off = registryUnsubscribe;
693
+ registryUnsubscribe = null;
694
+ if (off) {
695
+ try {
696
+ off();
697
+ } catch (err) {
698
+ console.warn('[share] registry unsubscribe failed:', err);
699
+ }
700
+ }
701
+ const tap = sseTap;
702
+ sseTap = null;
703
+ if (tap) {
704
+ try {
705
+ tap.stop();
706
+ } catch (err) {
707
+ console.warn('[share] sse tap stop failed:', err);
708
+ }
709
+ }
710
+ };
711
+
712
+ // Build a per-peer SSE outbound queue (US-072). On threshold drops the queue
713
+ // fires our onResyncTriggered, which we use to emit a one-shot `sse-snapshot`
714
+ // addressed to the peer so its canvas catches back up after the host stops
715
+ // sending live frames (or, more typically, after the peer's network gets
716
+ // back enough headroom to drain).
717
+ const buildPeerSseQueue = (peerConnId: string): PeerSseQueue =>
718
+ createPeerSseQueue({
719
+ peerConnId,
720
+ send: outboundSseSend,
721
+ maxFrames: outboundSseMaxFrames,
722
+ dropResyncThreshold: outboundSseDropResyncThreshold,
723
+ dropResyncWindowMs: outboundSseDropResyncWindowMs,
724
+ onResyncTriggered: () => {
725
+ try {
726
+ emitSseSnapshotForPeer(peerConnId);
727
+ } catch (err) {
728
+ console.warn('[share] sse resync emit failed:', err);
729
+ }
730
+ },
731
+ });
732
+
733
+ const disposePeerSseQueue = (peerConnId: string): void => {
734
+ const q = peerSseQueues.get(peerConnId);
735
+ if (!q) return;
736
+ peerSseQueues.delete(peerConnId);
737
+ try {
738
+ q.dispose();
739
+ } catch (err) {
740
+ console.warn('[share] sse queue dispose failed:', err);
741
+ }
742
+ };
743
+
744
+ const teardown = () => {
745
+ const t = transport;
746
+ const log = auditLog;
747
+ const klogger = auditLogger;
748
+ transport = null;
749
+ hostKey = null;
750
+ auditLog = null;
751
+ auditLogger = null;
752
+ filesManifestReady = null;
753
+ unsubscribeFromEventBus();
754
+ for (const q of peerSseQueues.values()) {
755
+ try {
756
+ q.dispose();
757
+ } catch (err) {
758
+ console.warn('[share] sse queue dispose failed:', err);
759
+ }
760
+ }
761
+ peerSseQueues.clear();
762
+ peerConnIds.clear();
763
+ connPeers.clear();
764
+ if (t) {
765
+ try {
766
+ t.close('user');
767
+ } catch (err) {
768
+ console.warn('[share] close failed during teardown:', err);
769
+ }
770
+ }
771
+ if (log) {
772
+ log.close().catch((err) => {
773
+ console.warn('[share] audit log close failed:', err);
774
+ });
775
+ }
776
+ if (klogger) {
777
+ klogger.close().catch((err) => {
778
+ console.warn('[share] audit kind log close failed:', err);
779
+ });
780
+ }
781
+ };
782
+
783
+ const audit = (entry: FrameAuditEntry) => {
784
+ if (!auditLog) return;
785
+ try {
786
+ auditLog.append(entry);
787
+ } catch (err) {
788
+ console.warn('[share] audit append failed:', err);
789
+ }
790
+ };
791
+
792
+ // Fire-and-forget append of a Phase-8 `AuditEntry` to the per-session JSONL
793
+ // log. Errors only warn — auditing is best-effort; never block hot paths
794
+ // (RPC dispatch, kick, rotate) on disk I/O.
795
+ const auditKind = (entry: Omit<AuditEntry, 'ts'>): void => {
796
+ const logger = auditLogger;
797
+ if (!logger) return;
798
+ logger.append(entry).catch((err) => {
799
+ console.warn('[share] audit kind append failed:', err);
800
+ });
801
+ };
802
+
803
+ // Build + emit a `node-patched` envelope for an accepted op. Centralized so
804
+ // peer-rpc and host-local edits assemble the wire payload identically (only
805
+ // the `attributedTo` value differs). Returns the version assigned so callers
806
+ // can echo it back into rpc-result if needed.
807
+ const broadcastNodePatched = (
808
+ op: RpcOp,
809
+ outcome: RpcDispatchOutcome,
810
+ attributedTo: { peerId: string; displayName: string },
811
+ ): number => {
812
+ const nextVersion = (flowVersions.get(op.flowId) ?? 0) + 1;
813
+ flowVersions.set(op.flowId, nextVersion);
814
+ const diff = computeNodePatchedDiff(op, outcome);
815
+ try {
816
+ broadcast(
817
+ makeEnvelope(
818
+ 'node-patched',
819
+ { flowId: op.flowId, op: op.op, diff, version: nextVersion, attributedTo },
820
+ { to: 'all' },
821
+ ),
822
+ );
823
+ } catch (err) {
824
+ console.warn('[share] node-patched broadcast failed:', err);
825
+ }
826
+ const event: AttributionEvent = {
827
+ flowId: op.flowId,
828
+ op: op.op,
829
+ diff,
830
+ version: nextVersion,
831
+ attributedTo,
832
+ ts: Date.now(),
833
+ };
834
+ for (const fn of attributionSubscribers) {
835
+ try {
836
+ fn(event);
837
+ } catch (err) {
838
+ console.error('[share] attribution subscriber threw:', err);
839
+ }
840
+ }
841
+ return nextVersion;
842
+ };
843
+
844
+ const dispatchRpcFrame = async (
845
+ frame: unknown,
846
+ actor: { peerId: string; displayName: string },
847
+ ): Promise<RpcResultFrame> => {
848
+ const fallbackId = extractFrameId(frame);
849
+ const parsed = RpcFrameSchema.safeParse(frame);
850
+ if (!parsed.success) {
851
+ // Never include payload in the log: a tampered envelope is hostile by
852
+ // assumption.
853
+ console.warn('[share] rpc frame rejected:', {
854
+ type: 'rpc',
855
+ from: actor.peerId,
856
+ reason: 'invalid_envelope',
857
+ });
858
+ return makeRpcResultFrame(fallbackId, { ok: false, reason: 'invalid_envelope' });
859
+ }
860
+ const op = parsed.data.payload;
861
+ if (!ALLOWED_RPC_OPS.has(op.op)) {
862
+ // Defense-in-depth: unreachable past the schema, but guards a future
863
+ // path that bypasses validation.
864
+ console.warn('[share] rpc frame rejected:', {
865
+ type: 'rpc',
866
+ from: actor.peerId,
867
+ reason: 'op_not_allowed',
868
+ });
869
+ return makeRpcResultFrame(parsed.data.id, { ok: false, reason: 'op_not_allowed' });
870
+ }
871
+ if (current.status !== 'active') {
872
+ return makeRpcResultFrame(parsed.data.id, { ok: false, reason: 'not_active' });
873
+ }
874
+ if (!rpcDispatcher) {
875
+ return makeRpcResultFrame(parsed.data.id, { ok: false, reason: 'no_dispatcher' });
876
+ }
877
+ const sessionId = current.sessionId;
878
+ let outcome: RpcDispatchOutcome;
879
+ try {
880
+ outcome = await rpcDispatcher(op);
881
+ } catch (err) {
882
+ const message = err instanceof Error ? err.message : String(err);
883
+ outcome = { kind: 'dispatcherThrew', message };
884
+ }
885
+ const ok = outcome.kind === 'ok';
886
+ const reason = ok ? undefined : outcome.kind + (outcome.message ? `: ${outcome.message}` : '');
887
+ const attributedTo = { peerId: actor.peerId, displayName: actor.displayName };
888
+ try {
889
+ const entry: RpcAuditEntry = {
890
+ ts: Date.now(),
891
+ peerId: actor.peerId,
892
+ op: op.op,
893
+ flowId: op.flowId,
894
+ ok,
895
+ ...(reason ? { reason } : {}),
896
+ attributedTo,
897
+ };
898
+ appendShareAuditFn(sessionId, entry);
899
+ } catch (err) {
900
+ console.warn('[share] rpc audit append failed:', err);
901
+ }
902
+ if (ok) {
903
+ // Phase-8 kind-shaped audit (US-079). Coexists with the RpcAuditEntry
904
+ // line written above so consumers can filter by either schema.
905
+ // `details` carries flowId + (for node-targeted ops) the nodeId so the
906
+ // audit drawer can render the target without re-reading the op union.
907
+ const rpcDetails: Record<string, unknown> = { flowId: op.flowId };
908
+ if ('nodeId' in op && typeof op.nodeId === 'string') {
909
+ rpcDetails.nodeId = op.nodeId;
910
+ }
911
+ auditKind({
912
+ kind: 'rpc-accept',
913
+ peerId: actor.peerId,
914
+ displayName: actor.displayName,
915
+ op: op.op,
916
+ details: rpcDetails,
917
+ });
918
+ // Broadcast the canonical diff BEFORE resolving rpc-result so peers
919
+ // (including the originator) see the patch first; the originator's
920
+ // optimistic reconcile then folds into a no-op.
921
+ broadcastNodePatched(op, outcome, attributedTo);
922
+ return makeRpcResultFrame(parsed.data.id, {
923
+ ok: true,
924
+ ...(outcome.data !== undefined ? { result: outcome.data } : {}),
925
+ attributedTo,
926
+ });
927
+ }
928
+ auditKind({
929
+ kind: 'rpc-reject',
930
+ peerId: actor.peerId,
931
+ displayName: actor.displayName,
932
+ op: op.op,
933
+ reason: reason ?? outcome.kind,
934
+ });
935
+ return makeRpcResultFrame(parsed.data.id, {
936
+ ok: false,
937
+ reason: reason ?? outcome.kind,
938
+ });
939
+ };
940
+
941
+ // Emit one or more `sse-snapshot` frames to a freshly-joined peer so its
942
+ // canvas badges / play-button rings match the host's live state without
943
+ // waiting for the next tick. Caps each frame's serialized payload at 256 KB
944
+ // by splitting per-flow into chunks (chunk + total stamped on each frame).
945
+ // A single flow whose own snapshot exceeds the cap is still emitted as one
946
+ // chunk — per the PRD, per-flow is the indivisible unit.
947
+ const emitSseSnapshotForPeer = (peerConnId: string): void => {
948
+ if (!sseTap) return;
949
+ let snap: Record<string, Record<string, SsePayload>>;
950
+ try {
951
+ snap = sseTap.snapshot();
952
+ } catch (err) {
953
+ console.warn('[share] sse-snapshot read failed:', err);
954
+ return;
955
+ }
956
+ const entries = Object.entries(snap);
957
+ if (entries.length === 0) return;
958
+
959
+ const chunks = chunkSnapshotByFlow(snap);
960
+ const total = chunks.length;
961
+ for (let i = 0; i < chunks.length; i += 1) {
962
+ const chunkFlows = chunks[i];
963
+ if (!chunkFlows) continue;
964
+ const payload: SseSnapshotPayload =
965
+ total === 1 ? { flows: chunkFlows } : { flows: chunkFlows, chunk: i, total };
966
+ try {
967
+ broadcast(makeEnvelope('sse-snapshot', payload, { to: peerConnId }));
968
+ } catch (err) {
969
+ console.warn('[share] sse-snapshot broadcast failed:', err);
970
+ }
971
+ }
972
+ };
973
+
974
+ // Emit the files-manifest frame to a freshly-joined peer. Awaits init() so a
975
+ // racing join (e.g. peer auth-peer'd before the walk completed) still gets a
976
+ // populated manifest. Errors only warn — the manifest is a hint, not a
977
+ // gating signal; the peer can fall back to file-request on cache miss.
978
+ const emitFilesManifestForPeer = async (peerConnId: string): Promise<void> => {
979
+ if (!filesManifestBuilder) return;
980
+ try {
981
+ if (filesManifestReady) await filesManifestReady;
982
+ const payload = filesManifestBuilder.build();
983
+ broadcast(makeEnvelope('files-manifest', payload, { to: peerConnId }));
984
+ } catch (err) {
985
+ console.warn('[share] files-manifest emit failed:', err);
986
+ }
987
+ };
988
+
989
+ const handleFrame = (env: Envelope) => {
990
+ if (env.type === 'presence') {
991
+ const base = PresenceBaseSchema.safeParse(env.payload);
992
+ if (!base.success) {
993
+ console.warn('[share] dropped presence frame: invalid payload');
994
+ return;
995
+ }
996
+ const kind = base.data.kind;
997
+ if (kind === 'join') {
998
+ const parsed = PresenceJoinSchema.safeParse(env.payload);
999
+ if (!parsed.success) {
1000
+ console.warn('[share] dropped presence/join: invalid fields');
1001
+ return;
1002
+ }
1003
+ const { peerId, displayName } = parsed.data;
1004
+ peerConnIds.set(peerId, env.from);
1005
+ connPeers.set(env.from, { peerId, displayName });
1006
+ // Spin up the per-peer outbound SSE queue alongside the connId
1007
+ // bookkeeping so any live frame that fires after presence/join lands
1008
+ // on a bounded queue rather than hitting the legacy `to: 'all'` path.
1009
+ if (!peerSseQueues.has(env.from)) {
1010
+ peerSseQueues.set(env.from, buildPeerSseQueue(env.from));
1011
+ }
1012
+ // Join itself is always accepted — the peer becomes "known" only as
1013
+ // a result of this frame, so rate-limiting it would be a chicken-and-
1014
+ // egg problem. Audit it as accept so the trail shows who joined when.
1015
+ audit({ ts: Date.now(), peerId, displayName, type: 'presence', verdict: 'accept' });
1016
+ auditKind({ kind: 'peer-join', peerId, displayName });
1017
+ if (current.status !== 'active') return;
1018
+ if (current.peers.some((peer) => peer.peerId === peerId)) return;
1019
+ setState({
1020
+ ...current,
1021
+ peers: [...current.peers, { peerId, displayName, joinedAt: Date.now() }],
1022
+ });
1023
+ // Prime the new peer with a one-shot files-manifest snapshot so its
1024
+ // canvas can render placeholder sizing before any file-request fires.
1025
+ // Fire-and-forget; emit failures are warned, never propagated.
1026
+ void emitFilesManifestForPeer(env.from);
1027
+ // Replay the SSE tap's last-seen per-node status so the joiner's
1028
+ // canvas badges + play-button rings match the host within one render.
1029
+ // Addressed to the joiner's connId only (not broadcast). Chunked
1030
+ // per-flow when the serialized payload exceeds 256 KB.
1031
+ emitSseSnapshotForPeer(env.from);
1032
+ return;
1033
+ }
1034
+ if (kind === 'leave') {
1035
+ const parsed = PresenceLeaveSchema.safeParse(env.payload);
1036
+ if (!parsed.success) {
1037
+ console.warn('[share] dropped presence/leave: invalid fields');
1038
+ return;
1039
+ }
1040
+ const { peerId } = parsed.data;
1041
+ const known = connPeers.get(env.from);
1042
+ peerConnIds.delete(peerId);
1043
+ connPeers.delete(env.from);
1044
+ disposePeerSseQueue(env.from);
1045
+ if (known) {
1046
+ audit({
1047
+ ts: Date.now(),
1048
+ peerId: known.peerId,
1049
+ displayName: known.displayName,
1050
+ type: 'presence',
1051
+ verdict: 'accept',
1052
+ });
1053
+ auditKind({
1054
+ kind: 'peer-leave',
1055
+ peerId: known.peerId,
1056
+ displayName: known.displayName,
1057
+ });
1058
+ }
1059
+ if (current.status !== 'active') return;
1060
+ if (!current.peers.some((peer) => peer.peerId === peerId)) return;
1061
+ setState({
1062
+ ...current,
1063
+ peers: current.peers.filter((peer) => peer.peerId !== peerId),
1064
+ });
1065
+ return;
1066
+ }
1067
+ // Other presence kinds (cursor, viewport, etc.) are sideband-only in v1.
1068
+ return;
1069
+ }
1070
+
1071
+ // Non-presence frames must come from a known peer (introduced earlier via
1072
+ // presence/join). Frames from an unknown connId are dropped silently — we
1073
+ // have no peerId/displayName to attribute the audit entry to.
1074
+ const peer = connPeers.get(env.from);
1075
+ if (!peer) {
1076
+ console.debug('[share] dropped frame from unknown peer:', {
1077
+ type: env.type,
1078
+ from: env.from,
1079
+ });
1080
+ return;
1081
+ }
1082
+
1083
+ const verdict = rateLimiter.check(peer.peerId);
1084
+ if (!verdict.ok) {
1085
+ audit({
1086
+ ts: Date.now(),
1087
+ peerId: peer.peerId,
1088
+ displayName: peer.displayName,
1089
+ type: env.type,
1090
+ verdict: 'reject',
1091
+ reason: 'rate-limited',
1092
+ });
1093
+ return;
1094
+ }
1095
+
1096
+ audit({
1097
+ ts: Date.now(),
1098
+ peerId: peer.peerId,
1099
+ displayName: peer.displayName,
1100
+ type: env.type,
1101
+ verdict: 'accept',
1102
+ });
1103
+
1104
+ if (env.type === 'file-request') {
1105
+ if (!fileRequestHandler) {
1106
+ console.warn('[share] dropped file-request: no handler configured');
1107
+ return;
1108
+ }
1109
+ fileRequestHandler.handle(env).catch((err) => {
1110
+ console.warn('[share] file-request handler threw:', err);
1111
+ });
1112
+ return;
1113
+ }
1114
+
1115
+ if (
1116
+ env.type === 'file-upload-intent' ||
1117
+ env.type === 'file-bytes' ||
1118
+ env.type === 'file-upload-done'
1119
+ ) {
1120
+ if (!fileUploadHandler) {
1121
+ console.warn(`[share] dropped ${env.type}: no upload handler configured`);
1122
+ return;
1123
+ }
1124
+ const peerCtx = { peerId: peer.peerId, displayName: peer.displayName };
1125
+ const dispatch =
1126
+ env.type === 'file-upload-intent'
1127
+ ? fileUploadHandler.handleIntent(env, peerCtx)
1128
+ : env.type === 'file-bytes'
1129
+ ? fileUploadHandler.handleBytes(env, peerCtx)
1130
+ : fileUploadHandler.handleDone(env, peerCtx);
1131
+ dispatch.catch((err) => {
1132
+ console.warn(`[share] ${env.type} handler threw:`, err);
1133
+ });
1134
+ return;
1135
+ }
1136
+
1137
+ if (env.type === 'rpc') {
1138
+ // The wire-side `RpcFrameSchema` is strict, so strip envelope-only fields
1139
+ // (`from`, `to`) before dispatch. Missing fields surface as
1140
+ // 'invalid_envelope' below.
1141
+ const wireFrame = {
1142
+ v: env.v,
1143
+ type: env.type,
1144
+ id: env.id,
1145
+ payload: env.payload,
1146
+ };
1147
+ const replyTo = env.from;
1148
+ dispatchRpcFrame(wireFrame, { peerId: peer.peerId, displayName: peer.displayName })
1149
+ .then((result) => {
1150
+ if (!transport || !transport.isOpen()) return;
1151
+ try {
1152
+ transport.send(
1153
+ makeEnvelope('rpc-result', result.payload, { to: replyTo, id: result.id }),
1154
+ );
1155
+ } catch (err) {
1156
+ console.warn('[share] rpc-result send failed:', err);
1157
+ }
1158
+ })
1159
+ .catch((err) => {
1160
+ console.warn('[share] dispatchRpcFrame threw:', err);
1161
+ });
1162
+ return;
1163
+ }
1164
+
1165
+ // All other envelope types are accepted-and-dropped in v1; real handling
1166
+ // (file streaming, etc.) lands in phase 6+. Log type+from only — never
1167
+ // the payload.
1168
+ console.debug('[share] dropped frame:', { type: env.type, from: env.from });
1169
+ };
1170
+
1171
+ // Active-sessions tracker (US-081). Persists `{sessionId, hostKey}` per
1172
+ // session in `<auditDir>/active.json` so a single `killAll()` call can
1173
+ // revoke every share link this studio has ever opened — not just the
1174
+ // currently active one. Writes are atomic; reads tolerate a missing or
1175
+ // corrupted file by returning an empty array.
1176
+ interface TrackedSession {
1177
+ sessionId: string;
1178
+ hostKey: string;
1179
+ }
1180
+ const readTrackedSessions = (): TrackedSession[] => {
1181
+ try {
1182
+ if (!existsSync(activeSessionsPath)) return [];
1183
+ const raw = readFileSync(activeSessionsPath, 'utf8');
1184
+ const parsed = JSON.parse(raw) as unknown;
1185
+ if (!Array.isArray(parsed)) return [];
1186
+ return parsed.filter(
1187
+ (s): s is TrackedSession =>
1188
+ typeof s === 'object' &&
1189
+ s !== null &&
1190
+ typeof (s as { sessionId?: unknown }).sessionId === 'string' &&
1191
+ typeof (s as { hostKey?: unknown }).hostKey === 'string',
1192
+ );
1193
+ } catch {
1194
+ return [];
1195
+ }
1196
+ };
1197
+ const writeTrackedSessions = (sessions: TrackedSession[]): void => {
1198
+ try {
1199
+ mkdirSync(dirname(activeSessionsPath), { recursive: true });
1200
+ writeFileAtomic(activeSessionsPath, JSON.stringify(sessions));
1201
+ } catch (err) {
1202
+ console.warn('[share] active sessions write failed:', err);
1203
+ }
1204
+ };
1205
+ const addTrackedSession = (sessionId: string, hk: string): void => {
1206
+ const tracked = readTrackedSessions();
1207
+ if (tracked.some((s) => s.sessionId === sessionId)) return;
1208
+ writeTrackedSessions([...tracked, { sessionId, hostKey: hk }]);
1209
+ };
1210
+ const removeTrackedSession = (sessionId: string): void => {
1211
+ const tracked = readTrackedSessions();
1212
+ const next = tracked.filter((s) => s.sessionId !== sessionId);
1213
+ if (next.length === tracked.length) return;
1214
+ writeTrackedSessions(next);
1215
+ };
1216
+
1217
+ const controller: ShareController = {
1218
+ async start() {
1219
+ if (current.status !== 'idle') {
1220
+ throw new Error('share-already-active');
1221
+ }
1222
+ const res = await fetchFn(`${deps.relayHttpUrl}/api/share/sessions`, { method: 'POST' });
1223
+ if (!res.ok) {
1224
+ throw new Error(`share-relay-http-${res.status}`);
1225
+ }
1226
+ const body = (await res.json()) as RelaySessionResponse;
1227
+
1228
+ hostKey = body.hostKey;
1229
+
1230
+ return await new Promise<{ url: string; sessionId: string }>((resolve, reject) => {
1231
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
1232
+
1233
+ const handle: BootHandle = {
1234
+ settled: false,
1235
+ cancelTimer: () => {
1236
+ if (timeoutHandle !== null) {
1237
+ clearTimeout(timeoutHandle);
1238
+ timeoutHandle = null;
1239
+ }
1240
+ },
1241
+ rejectStart: (err) => {
1242
+ handle.settled = true;
1243
+ reject(err);
1244
+ },
1245
+ };
1246
+ bootHandle = handle;
1247
+
1248
+ timeoutHandle = setTimeout(() => {
1249
+ if (handle.settled) return;
1250
+ handle.settled = true;
1251
+ timeoutHandle = null;
1252
+ bootHandle = null;
1253
+ teardown();
1254
+ setState({ status: 'idle' });
1255
+ reject(new Error('share-boot-timeout'));
1256
+ }, BOOT_TIMEOUT_MS);
1257
+
1258
+ const onTransportState = (s: ShareTransportState) => {
1259
+ if (handle.settled) return;
1260
+ if (s === 'connecting' || s === 'reconnecting') {
1261
+ if (current.status !== 'starting') setState({ status: 'starting' });
1262
+ return;
1263
+ }
1264
+ if (s === 'open') {
1265
+ handle.settled = true;
1266
+ handle.cancelTimer();
1267
+ bootHandle = null;
1268
+ const url = `${deps.shareUrlBase}/${body.token}`;
1269
+ // Open the per-session audit log before transitioning state so
1270
+ // any frame that races in (e.g. immediate presence/join from a
1271
+ // pre-connected peer) is captured.
1272
+ try {
1273
+ auditLog = auditLogFactory({ dir: auditDir, sessionId: body.sessionId });
1274
+ } catch (err) {
1275
+ console.warn('[share] audit log open failed:', err);
1276
+ auditLog = null;
1277
+ }
1278
+ try {
1279
+ auditLogger = auditLoggerFactory(body.sessionId, auditDir);
1280
+ } catch (err) {
1281
+ console.warn('[share] audit kind log open failed:', err);
1282
+ auditLogger = null;
1283
+ }
1284
+ // host-start is the first kind-shaped entry written to the file
1285
+ // so a consumer reading from cursor:0 sees the session boundary.
1286
+ auditKind({ kind: 'host-start', peerId: null, displayName: null });
1287
+ // Track the session in active.json so killAll() can revoke it
1288
+ // later — even after the controller goes idle.
1289
+ if (hostKey) addTrackedSession(body.sessionId, hostKey);
1290
+ // Subscribe to local runtime events BEFORE transitioning state so
1291
+ // any event that fires synchronously from a state subscriber finds
1292
+ // the bridge wired up.
1293
+ subscribeToEventBus();
1294
+ // Kick off the files-manifest disk walk. presence/join handlers
1295
+ // await this before emitting so the first joiner gets a populated
1296
+ // payload even if it races init.
1297
+ if (filesManifestBuilder) {
1298
+ filesManifestReady = filesManifestBuilder.init().catch((err) => {
1299
+ console.warn('[share] files-manifest init failed:', err);
1300
+ });
1301
+ }
1302
+ setState({
1303
+ status: 'active',
1304
+ sessionId: body.sessionId,
1305
+ token: body.token,
1306
+ url,
1307
+ peers: [],
1308
+ startedAt: Date.now(),
1309
+ hostDisplayName,
1310
+ recentSessionCount: 0,
1311
+ });
1312
+ resolve({ url, sessionId: body.sessionId });
1313
+ return;
1314
+ }
1315
+ // s === 'closed' before reaching 'open' => boot failed.
1316
+ handle.settled = true;
1317
+ handle.cancelTimer();
1318
+ bootHandle = null;
1319
+ transport = null;
1320
+ hostKey = null;
1321
+ setState({ status: 'idle' });
1322
+ reject(new Error('share-transport-closed-during-boot'));
1323
+ };
1324
+
1325
+ transport = transportFactory({
1326
+ wsUrl: body.wsUrl,
1327
+ sessionId: body.sessionId,
1328
+ hostKey: body.hostKey,
1329
+ onFrame: handleFrame,
1330
+ onStateChange: onTransportState,
1331
+ });
1332
+ });
1333
+ },
1334
+ async stop() {
1335
+ if (current.status === 'idle') return;
1336
+
1337
+ if (current.status === 'starting') {
1338
+ // Abort the in-flight start: mark its boot settled BEFORE closing the
1339
+ // transport so a synchronous 'closed' emit can't double-reject with
1340
+ // 'share-transport-closed-during-boot'.
1341
+ const handle = bootHandle;
1342
+ bootHandle = null;
1343
+ if (handle) {
1344
+ handle.settled = true;
1345
+ handle.cancelTimer();
1346
+ }
1347
+ setState({ status: 'stopping' });
1348
+ teardown();
1349
+ setState({ status: 'idle' });
1350
+ if (handle) handle.rejectStart(new Error('share-stopped-during-start'));
1351
+ return;
1352
+ }
1353
+
1354
+ if (current.status === 'active') {
1355
+ // host-stop fires BEFORE teardown so the audit append lands on the
1356
+ // still-open logger; teardown closes it after.
1357
+ auditKind({ kind: 'host-stop', peerId: null, displayName: null });
1358
+ removeTrackedSession(current.sessionId);
1359
+ setState({ status: 'stopping' });
1360
+ teardown();
1361
+ setState({ status: 'idle' });
1362
+ return;
1363
+ }
1364
+
1365
+ // status === 'stopping' or 'error' — treat as no-op; the in-flight
1366
+ // stop will complete on its own and a fresh start() can follow.
1367
+ },
1368
+ handleRpcFrame: (frame, fromPeerId, displayName) =>
1369
+ dispatchRpcFrame(frame, {
1370
+ peerId: fromPeerId,
1371
+ // Prefer the controller's known peer record so test/UI callers that
1372
+ // only pass a peerId still attribute correctly when the peer is
1373
+ // already known via presence/join. Falls back to the passed
1374
+ // displayName, then the peerId itself, so attribution is always
1375
+ // non-empty (AttributionSchema requires `displayName.min(1)`).
1376
+ displayName:
1377
+ [...connPeers.values()].find((p) => p.peerId === fromPeerId)?.displayName ??
1378
+ displayName ??
1379
+ fromPeerId,
1380
+ }),
1381
+ broadcastHostEdit(op, outcome) {
1382
+ if (current.status !== 'active') return null;
1383
+ if (outcome.kind !== 'ok') return null;
1384
+ const attributedTo = { peerId: 'host', displayName: current.hostDisplayName };
1385
+ // Audit the host-local edit so the JSONL file reflects every accepted
1386
+ // mutation regardless of origin. peerId mirrors `attributedTo.peerId`
1387
+ // ('host') so consumers can filter by attribution without a join.
1388
+ try {
1389
+ const entry: RpcAuditEntry = {
1390
+ ts: Date.now(),
1391
+ peerId: 'host',
1392
+ op: op.op,
1393
+ flowId: op.flowId,
1394
+ ok: true,
1395
+ attributedTo,
1396
+ };
1397
+ appendShareAuditFn(current.sessionId, entry);
1398
+ } catch (err) {
1399
+ console.warn('[share] host-edit audit append failed:', err);
1400
+ }
1401
+ return broadcastNodePatched(op, outcome, attributedTo);
1402
+ },
1403
+ async kick(peerId: string) {
1404
+ if (current.status !== 'active' || !hostKey) {
1405
+ throw new Error('share-not-active');
1406
+ }
1407
+ // Look up displayName from connPeers so the audit entry records the
1408
+ // human-readable label even though the relay endpoint only takes a
1409
+ // peerId. Falls back to null when the peer is unknown — but in that
1410
+ // case we throw share-peer-not-found before issuing the RPC.
1411
+ const known = [...connPeers.values()].find((p) => p.peerId === peerId);
1412
+ if (!known) {
1413
+ const reason = 'share-peer-not-found';
1414
+ auditKind({
1415
+ kind: 'rpc-reject',
1416
+ peerId,
1417
+ displayName: null,
1418
+ op: 'kick',
1419
+ reason,
1420
+ });
1421
+ throw new Error(reason);
1422
+ }
1423
+ const sessionId = current.sessionId;
1424
+ try {
1425
+ const res = await fetchFn(`${deps.relayHttpUrl}/api/share/kick`, {
1426
+ method: 'POST',
1427
+ headers: { 'content-type': 'application/json' },
1428
+ body: JSON.stringify({ sessionId, hostKey, peerId }),
1429
+ });
1430
+ if (!res.ok) {
1431
+ throw new Error(`share-relay-http-${res.status}`);
1432
+ }
1433
+ } catch (err) {
1434
+ const reason = err instanceof Error ? err.message : String(err);
1435
+ auditKind({
1436
+ kind: 'rpc-reject',
1437
+ peerId,
1438
+ displayName: known.displayName,
1439
+ op: 'kick',
1440
+ reason,
1441
+ });
1442
+ throw err;
1443
+ }
1444
+ auditKind({
1445
+ kind: 'kick',
1446
+ peerId,
1447
+ displayName: known.displayName,
1448
+ });
1449
+ },
1450
+ async rotateUrl() {
1451
+ if (current.status !== 'active' || !hostKey) {
1452
+ throw new Error('share-not-active');
1453
+ }
1454
+ const sessionId = current.sessionId;
1455
+ const res = await fetchFn(`${deps.relayHttpUrl}/api/share/rotate`, {
1456
+ method: 'POST',
1457
+ headers: { 'content-type': 'application/json' },
1458
+ body: JSON.stringify({ sessionId, hostKey }),
1459
+ });
1460
+ if (!res.ok) {
1461
+ throw new Error(`share-relay-http-${res.status}`);
1462
+ }
1463
+ const body = (await res.json()) as { token: string };
1464
+ const newUrl = `${deps.shareUrlBase}/${body.token}`;
1465
+ // The relay's rotate endpoint kicks every peer connection as part of
1466
+ // the rotation — none of them will reconnect with the old token. Clear
1467
+ // our local peer book-keeping so state() reflects the empty roster the
1468
+ // next subscriber tick will read.
1469
+ for (const q of peerSseQueues.values()) {
1470
+ try {
1471
+ q.dispose();
1472
+ } catch (err) {
1473
+ console.warn('[share] sse queue dispose failed during rotate:', err);
1474
+ }
1475
+ }
1476
+ peerSseQueues.clear();
1477
+ peerConnIds.clear();
1478
+ connPeers.clear();
1479
+ if (current.status === 'active') {
1480
+ setState({ ...current, token: body.token, url: newUrl, peers: [] });
1481
+ }
1482
+ auditKind({ kind: 'rotate', peerId: null, displayName: null });
1483
+ return { url: newUrl };
1484
+ },
1485
+ async killAll() {
1486
+ const tracked = readTrackedSessions();
1487
+ let revoked = 0;
1488
+ let failed = 0;
1489
+ for (const { sessionId, hostKey: hk } of tracked) {
1490
+ try {
1491
+ const res = await fetchFn(`${deps.relayHttpUrl}/api/share/end`, {
1492
+ method: 'POST',
1493
+ headers: { 'content-type': 'application/json' },
1494
+ body: JSON.stringify({ sessionId, hostKey: hk }),
1495
+ });
1496
+ if (!res.ok) {
1497
+ failed += 1;
1498
+ continue;
1499
+ }
1500
+ revoked += 1;
1501
+ } catch {
1502
+ failed += 1;
1503
+ }
1504
+ }
1505
+ // Per-session audit append. Each session gets a `kill-switch` entry so
1506
+ // a reader paging through that file sees the boundary. Use the factory
1507
+ // (not the active controller's `auditLogger`) so we can target any
1508
+ // tracked session — including ones whose controller is long since
1509
+ // torn down.
1510
+ for (const { sessionId } of tracked) {
1511
+ try {
1512
+ const logger = auditLoggerFactory(sessionId, auditDir);
1513
+ await logger.append({
1514
+ kind: 'kill-switch',
1515
+ peerId: null,
1516
+ displayName: null,
1517
+ details: { revoked, failed },
1518
+ });
1519
+ await logger.close();
1520
+ } catch (err) {
1521
+ console.warn('[share] kill-switch audit append failed:', err);
1522
+ }
1523
+ }
1524
+ writeTrackedSessions([]);
1525
+ return { revoked, failed };
1526
+ },
1527
+ state() {
1528
+ return enrichWithSseMetrics(current);
1529
+ },
1530
+ subscribe(fn) {
1531
+ subscribers.add(fn);
1532
+ try {
1533
+ fn(enrichWithSseMetrics(current));
1534
+ } catch (err) {
1535
+ console.error('[share] subscriber threw on initial deliver, dropping:', err);
1536
+ }
1537
+ return () => {
1538
+ subscribers.delete(fn);
1539
+ };
1540
+ },
1541
+ subscribeAttributions(fn) {
1542
+ attributionSubscribers.add(fn);
1543
+ return () => {
1544
+ attributionSubscribers.delete(fn);
1545
+ };
1546
+ },
1547
+ audit: {
1548
+ // Delegates to the per-session AuditLogger. Returns an empty page
1549
+ // when no session is active so the endpoint can answer without a
1550
+ // file-read; the endpoint itself returns 400 in that case before
1551
+ // calling in, but stay safe-by-default here too.
1552
+ async list(opts) {
1553
+ const logger = auditLogger;
1554
+ if (!logger) return { entries: [], nextCursor: null };
1555
+ return logger.list(opts);
1556
+ },
1557
+ },
1558
+ };
1559
+
1560
+ return controller;
1561
+ }