@tuongaz/seeflow 0.1.101 → 0.1.103

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-CHxyYMbj.js → architectureDiagram-3BPJPVTR-Bw9x8Qs3.js} +1 -1
  2. package/dist/web/assets/{blockDiagram-GPEHLZMM-BEFRoCwf.js → blockDiagram-GPEHLZMM-BnwpSAen.js} +1 -1
  3. package/dist/web/assets/{c4Diagram-AAUBKEIU-Br8d90eB.js → c4Diagram-AAUBKEIU-BVDyhOJ3.js} +1 -1
  4. package/dist/web/assets/channel-DkLku8oP.js +1 -0
  5. package/dist/web/assets/{chart-BIsuELng.js → chart-BcqCvSuE.js} +1 -1
  6. package/dist/web/assets/{chunk-2J33WTMH-kp1HzlkG.js → chunk-2J33WTMH-nZ6teWx5.js} +1 -1
  7. package/dist/web/assets/{chunk-4BX2VUAB-BO5lRpcJ.js → chunk-4BX2VUAB-ldyAyOdq.js} +1 -1
  8. package/dist/web/assets/{chunk-55IACEB6-Brh-Cxxv.js → chunk-55IACEB6-D5jFWCVV.js} +1 -1
  9. package/dist/web/assets/{chunk-727SXJPM-Rng9Q1Qi.js → chunk-727SXJPM-CnsykKyK.js} +1 -1
  10. package/dist/web/assets/{chunk-AQP2D5EJ-64NIU-6E.js → chunk-AQP2D5EJ-Bd02cZe2.js} +1 -1
  11. package/dist/web/assets/{chunk-FMBD7UC4-oWQHD8HT.js → chunk-FMBD7UC4-7QaX2cZq.js} +1 -1
  12. package/dist/web/assets/{chunk-ND2GUHAM-CZuU1Pvz.js → chunk-ND2GUHAM-BdU63yQ1.js} +1 -1
  13. package/dist/web/assets/{chunk-QZHKN3VN-Dtxpr6Lp.js → chunk-QZHKN3VN-BxL7agDu.js} +1 -1
  14. package/dist/web/assets/classDiagram-4FO5ZUOK-BVTmCHkJ.js +1 -0
  15. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BVTmCHkJ.js +1 -0
  16. package/dist/web/assets/{code-block-CXyix7T4.js → code-block-BoWNyy3K.js} +1 -1
  17. package/dist/web/assets/{cose-bilkent-S5V4N54A-Dy3n6Pzg.js → cose-bilkent-S5V4N54A-Do7Ahxc1.js} +1 -1
  18. package/dist/web/assets/{dagre-BM42HDAG--dGIcj7p.js → dagre-BM42HDAG-Da7WeKs9.js} +1 -1
  19. package/dist/web/assets/{diagram-2AECGRRQ-Dt21gwWu.js → diagram-2AECGRRQ-vzafP_ZS.js} +1 -1
  20. package/dist/web/assets/{diagram-5GNKFQAL-Dopd3jNv.js → diagram-5GNKFQAL-n-IXhudG.js} +1 -1
  21. package/dist/web/assets/{diagram-KO2AKTUF-CJEAQsDF.js → diagram-KO2AKTUF-NGDeB_YZ.js} +1 -1
  22. package/dist/web/assets/{diagram-LMA3HP47-CfKPAvzE.js → diagram-LMA3HP47-CF8wirl0.js} +1 -1
  23. package/dist/web/assets/{diagram-OG6HWLK6-C9HTQiVv.js → diagram-OG6HWLK6-BnRo_bhI.js} +1 -1
  24. package/dist/web/assets/{erDiagram-TEJ5UH35-w6pYFBfQ.js → erDiagram-TEJ5UH35-C6izH2L9.js} +1 -1
  25. package/dist/web/assets/{flowDiagram-I6XJVG4X-Cfb85Fie.js → flowDiagram-I6XJVG4X-D7QJkdLn.js} +1 -1
  26. package/dist/web/assets/{ganttDiagram-6RSMTGT7-Dyps7xvy.js → ganttDiagram-6RSMTGT7-CHMbzxxN.js} +1 -1
  27. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-f4JBeh_-.js → gitGraphDiagram-PVQCEYII-DLLTMloz.js} +1 -1
  28. package/dist/web/assets/{iconify-DP0e3Q9S.js → iconify-B6i1sS6t.js} +1 -1
  29. package/dist/web/assets/{index-DZK24AXs.js → index-D0y4g-ET.js} +1743 -1743
  30. package/dist/web/assets/{index.es-DFitEGBR.js → index.es-DpqOAO37.js} +1 -1
  31. package/dist/web/assets/{infoDiagram-5YYISTIA-DjJb_lcW.js → infoDiagram-5YYISTIA-DFC0_4VT.js} +1 -1
  32. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-D-E7Iit6.js → ishikawaDiagram-YF4QCWOH-BUNJEEXj.js} +1 -1
  33. package/dist/web/assets/{journeyDiagram-JHISSGLW-CtwLAgCl.js → journeyDiagram-JHISSGLW-WpYiKdO4.js} +1 -1
  34. package/dist/web/assets/{jspdf.es.min-CP3g_qxA.js → jspdf.es.min-BUpN05U5.js} +3 -3
  35. package/dist/web/assets/{kanban-definition-UN3LZRKU-bx6zf7oK.js → kanban-definition-UN3LZRKU-C3YKo72r.js} +1 -1
  36. package/dist/web/assets/{linear-BotE_b7Y.js → linear-CNjCwNlO.js} +1 -1
  37. package/dist/web/assets/{markdown-Dw6v_KEB.js → markdown-OxD7kcm2.js} +1 -1
  38. package/dist/web/assets/{mermaid.core-DWCabHOC.js → mermaid.core-BMY_XaVJ.js} +4 -4
  39. package/dist/web/assets/{mindmap-definition-RKZ34NQL-HStLvyzJ.js → mindmap-definition-RKZ34NQL-L231FEYw.js} +1 -1
  40. package/dist/web/assets/{pieDiagram-4H26LBE5-BulJN0Sj.js → pieDiagram-4H26LBE5-XQJ6ZE8g.js} +1 -1
  41. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-Bb4MvZWK.js → quadrantDiagram-W4KKPZXB-D2YS3TIj.js} +1 -1
  42. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CQYEBtzI.js → requirementDiagram-4Y6WPE33-DlP1wS6w.js} +1 -1
  43. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-D8YtIq7m.js → sankeyDiagram-5OEKKPKP-CZKS5wN2.js} +1 -1
  44. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-KrSL_EE_.js → sequenceDiagram-3UESZ5HK-Cqzp79DP.js} +1 -1
  45. package/dist/web/assets/{stateDiagram-AJRCARHV-kYBSHT6F.js → stateDiagram-AJRCARHV-kHUSgVSA.js} +1 -1
  46. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-DumcoRrq.js +1 -0
  47. package/dist/web/assets/{time-DmpATWGU.js → time-CN9gB7W3.js} +1 -1
  48. package/dist/web/assets/{timeline-definition-PNZ67QCA-BAMgt9mr.js → timeline-definition-PNZ67QCA-DNgz91eH.js} +1 -1
  49. package/dist/web/assets/{vennDiagram-CIIHVFJN-QVTz6kMx.js → vennDiagram-CIIHVFJN-DKcizV3B.js} +1 -1
  50. package/dist/web/assets/{wardley-L42UT6IY-CVpJdoa4.js → wardley-L42UT6IY-raPmt_do.js} +1 -1
  51. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-DThuC68t.js → wardleyDiagram-YWT4CUSO-B065t95m.js} +1 -1
  52. package/dist/web/assets/{xychartDiagram-2RQKCTM6-CURdkufb.js → xychartDiagram-2RQKCTM6-jufOp8SY.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-Brt3AMYa.js +0 -1
  73. package/dist/web/assets/classDiagram-4FO5ZUOK-DzRSPB2q.js +0 -1
  74. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-DzRSPB2q.js +0 -1
  75. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-CdRvxYCb.js +0 -1
@@ -0,0 +1,595 @@
1
+ /**
2
+ * Host-side `file-upload-intent` / `file-bytes` / `file-upload-done` handler.
3
+ *
4
+ * Peer drops a file onto a node; the relay routes `file-upload-intent` to the
5
+ * host (US-058 amends the intent with `payload.upload = { via, key?, putUrl? }`
6
+ * so the host learns whether the peer will stream chunks over WS or PUT to
7
+ * ephemeral S3). On accept, the host replies with an `rpc-result` carrying the
8
+ * via decision back to the peer so the peer knows which transfer path to take.
9
+ *
10
+ * For the `via: 'ws'` path the peer follows up with one or more `file-bytes`
11
+ * frames; the host appends to an in-flight buffer keyed by `reqId`, hashes
12
+ * incrementally, and on `eof: true` verifies the assembled bytes against the
13
+ * intent's `sha256`, writes atomically under the resolved per-node folder, and
14
+ * broadcasts a `node-patched` diff.
15
+ *
16
+ * For the `via: 's3'` path the peer PUTs the bytes directly to the relay's
17
+ * staging URL and then sends `file-upload-done`. The host fetches the
18
+ * relay-presigned GET URL embedded on the done frame, verifies the sha256,
19
+ * writes atomically, and acks the relay with `rpc-result { ok:true }` so the
20
+ * relay can DeleteObject (US-058).
21
+ *
22
+ * Defense-in-depth: file-bytes are rate-limited per peer to 5 MB / 60 s (the
23
+ * relay tracks the same window but the host re-enforces). Path safety is
24
+ * delegated to `resolveNodeFile` — any traversal-equivalent filename is
25
+ * rejected with `reason: 'path-invalid'` before bytes flow.
26
+ */
27
+
28
+ import { type Hash, createHash } from 'node:crypto';
29
+ import { mkdirSync } from 'node:fs';
30
+ import { dirname, extname } from 'node:path';
31
+ import { writeFileAtomic } from './atomic-write.ts';
32
+ import type { Registry } from './registry.ts';
33
+ import type { FileUploadAuditEntry } from './share-audit.ts';
34
+ import {
35
+ type Envelope,
36
+ FileBytesPayloadSchema,
37
+ FileUploadDonePayloadSchema,
38
+ FileUploadIntentPayloadSchema,
39
+ makeEnvelope,
40
+ } from './share-envelope.ts';
41
+ import { resolveNodeFile } from './share-file-resolver.ts';
42
+
43
+ export type { FileUploadAuditEntry } from './share-audit.ts';
44
+
45
+ const INLINE_LIMIT_BYTES = 256 * 1024;
46
+ const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
47
+ const RATE_LIMIT_BYTES = 5 * 1024 * 1024;
48
+ const RATE_LIMIT_WINDOW_MS = 60_000;
49
+
50
+ // Mirrors `UPLOAD_ALLOWED_EXTS` in api.ts ~line 181. Drift here means a peer
51
+ // upload would accept an extension the canonical /nodes/.../files/upload
52
+ // endpoint refuses (or vice versa) — keep the sets identical.
53
+ const UPLOAD_ALLOWED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
54
+
55
+ const CONTENT_TYPE_MAP: Record<string, string> = {
56
+ '.png': 'image/png',
57
+ '.jpg': 'image/jpeg',
58
+ '.jpeg': 'image/jpeg',
59
+ '.gif': 'image/gif',
60
+ '.webp': 'image/webp',
61
+ '.svg': 'image/svg+xml',
62
+ };
63
+
64
+ const contentTypeForExt = (ext: string): string =>
65
+ CONTENT_TYPE_MAP[ext.toLowerCase()] ?? 'application/octet-stream';
66
+
67
+ export type AppendFileUploadAudit = (sessionId: string, entry: FileUploadAuditEntry) => void;
68
+
69
+ export interface FileUploadDeps {
70
+ registry: Registry;
71
+ // Sends outbound envelopes; wired from share.ts's `broadcast` closure so
72
+ // `to: 'all'` fan-out and direct `to: <connId>` replies share the transport.
73
+ broadcast: (envelope: Envelope) => void;
74
+ // Returns the active session id when the controller is in the `active`
75
+ // state, otherwise null. The handler refuses to write when there is no
76
+ // session (the relay shouldn't be routing frames in that case anyway).
77
+ getSessionId: () => string | null;
78
+ // Test seam: append a one-line JSONL entry to <auditDir>/<sessionId>.jsonl.
79
+ // Defaults to a closure that calls `appendShareAudit`-style writes — but the
80
+ // shape differs from `RpcAuditEntry` (no flowId/ok; uses op:'file-upload'),
81
+ // so callers can also inject a recording stub.
82
+ appendAudit?: AppendFileUploadAudit;
83
+ // Test override: write bytes atomically. Defaults to `writeFileAtomic`.
84
+ writeFile?: (absPath: string, bytes: Uint8Array) => void;
85
+ // Test override: create the parent directory. Defaults to `mkdirSync(p, { recursive: true })`.
86
+ ensureDir?: (dir: string) => void;
87
+ // Test override: fetch the relay-presigned GET URL on the S3-done path.
88
+ fetchFn?: typeof fetch;
89
+ // Test override: monotonic clock for the rate-limit window.
90
+ now?: () => number;
91
+ // Resolve which `flowId` owns a given nodeId. Defaults to: iterate
92
+ // registry.list() and take the first entry whose flowPath can produce a
93
+ // valid resolveNodeFile() result for this nodeId. Tests can inject to
94
+ // disambiguate multi-flow projects.
95
+ flowIdForNode?: (nodeId: string) => string | null;
96
+ }
97
+
98
+ export interface PeerContext {
99
+ peerId: string;
100
+ displayName: string;
101
+ }
102
+
103
+ export interface FileUploadHandler {
104
+ handleIntent(envelope: Envelope, peer: PeerContext): Promise<void>;
105
+ handleBytes(envelope: Envelope, peer: PeerContext): Promise<void>;
106
+ handleDone(envelope: Envelope, peer: PeerContext): Promise<void>;
107
+ // Test helpers — assert in-flight state was released after each request settles.
108
+ inflightCount(connId: string): number;
109
+ windowBytes(connId: string): number;
110
+ }
111
+
112
+ interface InflightUpload {
113
+ reqId: string;
114
+ peerId: string;
115
+ connId: string;
116
+ absPath: string;
117
+ filename: string;
118
+ contentType: string;
119
+ intentSha256: string;
120
+ totalBytes: number;
121
+ receivedBytes: number;
122
+ chunks: Buffer[];
123
+ hash: Hash;
124
+ nodeId: string;
125
+ flowId: string;
126
+ }
127
+
128
+ interface RateWindow {
129
+ startMs: number;
130
+ bytes: number;
131
+ }
132
+
133
+ const defaultEnsureDir = (dir: string): void => {
134
+ mkdirSync(dir, { recursive: true });
135
+ };
136
+
137
+ const defaultWriteFile = (absPath: string, bytes: Uint8Array): void => {
138
+ writeFileAtomic(absPath, bytes);
139
+ };
140
+
141
+ const sendReply = (
142
+ broadcast: (env: Envelope) => void,
143
+ replyTo: string,
144
+ reqId: string,
145
+ payload: unknown,
146
+ ): void => {
147
+ broadcast(makeEnvelope('rpc-result', payload, { to: replyTo, id: reqId }));
148
+ };
149
+
150
+ const sendOk = (
151
+ broadcast: (env: Envelope) => void,
152
+ replyTo: string,
153
+ reqId: string,
154
+ result?: unknown,
155
+ ): void => {
156
+ const payload = result === undefined ? { ok: true as const } : { ok: true as const, result };
157
+ sendReply(broadcast, replyTo, reqId, payload);
158
+ };
159
+
160
+ const sendError = (
161
+ broadcast: (env: Envelope) => void,
162
+ replyTo: string,
163
+ reqId: string,
164
+ reason: string,
165
+ ): void => {
166
+ sendReply(broadcast, replyTo, reqId, { ok: false as const, reason });
167
+ };
168
+
169
+ export function createFileUploadHandler(deps: FileUploadDeps): FileUploadHandler {
170
+ const writeFile = deps.writeFile ?? defaultWriteFile;
171
+ const ensureDir = deps.ensureDir ?? defaultEnsureDir;
172
+ const fetchFn = deps.fetchFn ?? fetch;
173
+ const now = deps.now ?? Date.now;
174
+ const flowIdForNode =
175
+ deps.flowIdForNode ??
176
+ ((nodeId: string): string | null => {
177
+ for (const entry of deps.registry.list()) {
178
+ const r = resolveNodeFile({
179
+ repoPath: entry.repoPath,
180
+ flowPath: entry.flowPath,
181
+ nodeId,
182
+ relPath: 'probe',
183
+ });
184
+ if (!('error' in r)) return entry.id;
185
+ }
186
+ return null;
187
+ });
188
+
189
+ // reqId -> in-flight upload state. Lives for the lifetime of a single
190
+ // upload (intent -> chunks -> verified write, or intent -> done -> verified
191
+ // write). Cleared on terminal outcome (success / mismatch / abandon).
192
+ const inflight = new Map<string, InflightUpload>();
193
+ // connId -> sliding 60 s file-bytes window. Reset (start a new window)
194
+ // whenever the current window has aged past RATE_LIMIT_WINDOW_MS.
195
+ const peerWindows = new Map<string, RateWindow>();
196
+
197
+ const releaseInflight = (reqId: string) => {
198
+ inflight.delete(reqId);
199
+ };
200
+
201
+ const audit = (sessionId: string, entry: FileUploadAuditEntry) => {
202
+ if (!deps.appendAudit) return;
203
+ try {
204
+ deps.appendAudit(sessionId, entry);
205
+ } catch (err) {
206
+ console.warn('[share] file-upload audit append failed:', err);
207
+ }
208
+ };
209
+
210
+ const broadcastNodePatched = (flowId: string, nodeId: string, relPathFromRepo: string): void => {
211
+ deps.broadcast(
212
+ makeEnvelope(
213
+ 'node-patched',
214
+ {
215
+ flowId,
216
+ op: 'file-upload',
217
+ diff: { nodeId, data: { path: relPathFromRepo } },
218
+ },
219
+ { to: 'all' },
220
+ ),
221
+ );
222
+ };
223
+
224
+ // Accumulate `chunkBytes` against the peer's window; return false if the
225
+ // peer has exhausted the 5 MB / 60 s budget. Window resets when the elapsed
226
+ // span exceeds `RATE_LIMIT_WINDOW_MS`.
227
+ const consumeWindow = (connId: string, chunkBytes: number): boolean => {
228
+ const t = now();
229
+ const w = peerWindows.get(connId);
230
+ if (!w || t - w.startMs > RATE_LIMIT_WINDOW_MS) {
231
+ if (chunkBytes > RATE_LIMIT_BYTES) return false;
232
+ peerWindows.set(connId, { startMs: t, bytes: chunkBytes });
233
+ return true;
234
+ }
235
+ if (w.bytes + chunkBytes > RATE_LIMIT_BYTES) return false;
236
+ w.bytes += chunkBytes;
237
+ return true;
238
+ };
239
+
240
+ const handleIntent: FileUploadHandler['handleIntent'] = async (envelope, peer) => {
241
+ const replyTo = envelope.from;
242
+ const parsed = FileUploadIntentPayloadSchema.safeParse(envelope.payload);
243
+ if (!parsed.success) {
244
+ const rawReqId =
245
+ envelope.payload &&
246
+ typeof envelope.payload === 'object' &&
247
+ 'reqId' in envelope.payload &&
248
+ typeof (envelope.payload as { reqId?: unknown }).reqId === 'string' &&
249
+ (envelope.payload as { reqId: string }).reqId.length > 0
250
+ ? (envelope.payload as { reqId: string }).reqId
251
+ : 'invalid';
252
+ sendError(deps.broadcast, replyTo, rawReqId, 'bad-payload');
253
+ return;
254
+ }
255
+ const payload = parsed.data;
256
+ const { reqId, filename, size, nodeId, sha256 } = payload;
257
+
258
+ // Host enforces the intent shape before any state mutation.
259
+ if (size > MAX_UPLOAD_BYTES) {
260
+ sendError(deps.broadcast, replyTo, reqId, 'too-large');
261
+ auditReject(reqId, peer, nodeId, filename, size, sha256, 'too-large');
262
+ return;
263
+ }
264
+ const ext = extname(filename).toLowerCase();
265
+ if (!UPLOAD_ALLOWED_EXTS.has(ext)) {
266
+ sendError(deps.broadcast, replyTo, reqId, 'extension-not-allowed');
267
+ auditReject(reqId, peer, nodeId, filename, size, sha256, 'extension-not-allowed');
268
+ return;
269
+ }
270
+
271
+ const flowId = flowIdForNode(nodeId);
272
+ if (!flowId) {
273
+ sendError(deps.broadcast, replyTo, reqId, 'node-not-found');
274
+ auditReject(reqId, peer, nodeId, filename, size, sha256, 'node-not-found');
275
+ return;
276
+ }
277
+ const entry = deps.registry.list().find((e) => e.id === flowId);
278
+ if (!entry) {
279
+ sendError(deps.broadcast, replyTo, reqId, 'node-not-found');
280
+ auditReject(reqId, peer, nodeId, filename, size, sha256, 'node-not-found');
281
+ return;
282
+ }
283
+
284
+ // Re-run the resolver against the chosen entry using the supplied filename.
285
+ // resolveNodeFile rejects traversal-equivalent payloads (`../../foo.png`,
286
+ // absolute paths, win32 drive letters) so a malicious peer can't escape
287
+ // the per-node folder.
288
+ const resolved = resolveNodeFile({
289
+ repoPath: entry.repoPath,
290
+ flowPath: entry.flowPath,
291
+ nodeId,
292
+ relPath: filename,
293
+ });
294
+ if ('error' in resolved) {
295
+ sendError(deps.broadcast, replyTo, reqId, 'path-invalid');
296
+ auditReject(reqId, peer, nodeId, filename, size, sha256, 'path-invalid');
297
+ return;
298
+ }
299
+
300
+ // Materialise the relay's via decision so the peer knows which transfer
301
+ // path to take. The relay (US-058) amends `payload.upload` before
302
+ // forwarding the intent; absent that, default to ws so small uploads
303
+ // still work in tests that don't model the relay's amendment. Read from
304
+ // the raw `envelope.payload` because `FileUploadIntentPayloadSchema`
305
+ // strips unknown keys, so the schema-parsed `payload` won't carry the
306
+ // `upload` amendment.
307
+ const rawPayload =
308
+ envelope.payload && typeof envelope.payload === 'object'
309
+ ? (envelope.payload as { upload?: { via?: 'ws' | 's3'; key?: string; putUrl?: string } })
310
+ : {};
311
+ const via = rawPayload.upload?.via ?? (size > INLINE_LIMIT_BYTES ? 's3' : 'ws');
312
+ const result: { via: 'ws' | 's3'; key?: string; putUrl?: string } = { via };
313
+ if (rawPayload.upload?.key !== undefined) result.key = rawPayload.upload.key;
314
+ if (rawPayload.upload?.putUrl !== undefined) result.putUrl = rawPayload.upload.putUrl;
315
+
316
+ const contentType = payload.contentType || contentTypeForExt(ext);
317
+ inflight.set(reqId, {
318
+ reqId,
319
+ peerId: peer.peerId,
320
+ connId: replyTo,
321
+ absPath: resolved.absPath,
322
+ filename,
323
+ contentType,
324
+ intentSha256: sha256,
325
+ totalBytes: size,
326
+ receivedBytes: 0,
327
+ chunks: [],
328
+ hash: createHash('sha256'),
329
+ nodeId,
330
+ flowId,
331
+ });
332
+
333
+ sendOk(deps.broadcast, replyTo, reqId, result);
334
+ };
335
+
336
+ const auditReject = (
337
+ _reqId: string,
338
+ peer: PeerContext,
339
+ nodeId: string,
340
+ filename: string,
341
+ size: number,
342
+ sha256: string,
343
+ reason: string,
344
+ ): void => {
345
+ const sessionId = deps.getSessionId();
346
+ if (!sessionId) return;
347
+ audit(sessionId, {
348
+ ts: now(),
349
+ peerId: peer.peerId,
350
+ op: 'file-upload',
351
+ nodeId,
352
+ filename,
353
+ size,
354
+ sha256,
355
+ accept: false,
356
+ reason,
357
+ });
358
+ };
359
+
360
+ const writeAndBroadcast = (
361
+ state: InflightUpload,
362
+ bytes: Buffer,
363
+ finalSha: string,
364
+ peer: PeerContext,
365
+ ): { ok: true } | { ok: false; reason: string } => {
366
+ // Verify the chosen entry is still resolvable + the abs path is still
367
+ // within bounds at write time. resolveNodeFile is cheap; doing it again
368
+ // catches the (vanishingly rare) case where the registry mutated between
369
+ // intent + verify.
370
+ const entry = deps.registry.list().find((e) => e.id === state.flowId);
371
+ if (!entry) return { ok: false, reason: 'node-not-found' };
372
+ const reResolved = resolveNodeFile({
373
+ repoPath: entry.repoPath,
374
+ flowPath: entry.flowPath,
375
+ nodeId: state.nodeId,
376
+ relPath: state.filename,
377
+ });
378
+ if ('error' in reResolved || reResolved.absPath !== state.absPath) {
379
+ return { ok: false, reason: 'path-invalid' };
380
+ }
381
+
382
+ try {
383
+ ensureDir(dirname(state.absPath));
384
+ } catch (err) {
385
+ console.warn('[share] file-upload ensureDir failed:', err);
386
+ return { ok: false, reason: 'write-failed' };
387
+ }
388
+ try {
389
+ writeFile(state.absPath, bytes);
390
+ } catch (err) {
391
+ console.warn('[share] file-upload write failed:', err);
392
+ return { ok: false, reason: 'write-failed' };
393
+ }
394
+
395
+ // PROJECT-ROOT-relative path mirrors the api.ts upload endpoint return
396
+ // shape: legacy single-flow `flow.json` projects collapse `flowDir` to
397
+ // `.`; manifest-driven projects include the flow folder prefix so the
398
+ // peer's canvas can reference the asset by the same path it would have
399
+ // used had the user uploaded via the HTTP endpoint.
400
+ const flowDir = dirname(entry.flowPath);
401
+ const relFromRepo =
402
+ flowDir === '.'
403
+ ? `nodes/${state.nodeId}/${state.filename}`
404
+ : `${flowDir}/nodes/${state.nodeId}/${state.filename}`;
405
+ broadcastNodePatched(state.flowId, state.nodeId, relFromRepo);
406
+
407
+ const sessionId = deps.getSessionId();
408
+ if (sessionId) {
409
+ audit(sessionId, {
410
+ ts: now(),
411
+ peerId: peer.peerId,
412
+ op: 'file-upload',
413
+ nodeId: state.nodeId,
414
+ filename: state.filename,
415
+ size: bytes.length,
416
+ sha256: finalSha,
417
+ accept: true,
418
+ });
419
+ }
420
+ return { ok: true };
421
+ };
422
+
423
+ const handleBytes: FileUploadHandler['handleBytes'] = async (envelope, peer) => {
424
+ const replyTo = envelope.from;
425
+ const parsed = FileBytesPayloadSchema.safeParse(envelope.payload);
426
+ if (!parsed.success) {
427
+ console.warn('[share] file-bytes rejected: bad payload');
428
+ return;
429
+ }
430
+ const payload = parsed.data;
431
+ const state = inflight.get(payload.reqId);
432
+ if (!state) {
433
+ // No matching intent — drop. The peer can retry with a fresh intent.
434
+ console.warn('[share] file-bytes dropped: no in-flight intent', { reqId: payload.reqId });
435
+ return;
436
+ }
437
+ if (state.connId !== replyTo) {
438
+ // Different connection trying to fulfil another peer's upload — drop.
439
+ console.warn('[share] file-bytes dropped: peer mismatch');
440
+ return;
441
+ }
442
+
443
+ // Base64 decode + size accumulate happen before the rate-limit check so
444
+ // we know the exact byte budget the peer is asking to spend.
445
+ let chunk: Buffer;
446
+ try {
447
+ chunk = Buffer.from(payload.base64, 'base64');
448
+ } catch {
449
+ sendError(deps.broadcast, replyTo, state.reqId, 'bad-payload');
450
+ releaseInflight(state.reqId);
451
+ return;
452
+ }
453
+ if (!consumeWindow(replyTo, chunk.length)) {
454
+ sendError(deps.broadcast, replyTo, state.reqId, 'rate-limited');
455
+ releaseInflight(state.reqId);
456
+ return;
457
+ }
458
+ if (state.receivedBytes + chunk.length > state.totalBytes) {
459
+ sendError(deps.broadcast, replyTo, state.reqId, 'oversize-chunk');
460
+ releaseInflight(state.reqId);
461
+ return;
462
+ }
463
+ state.chunks.push(chunk);
464
+ state.hash.update(chunk);
465
+ state.receivedBytes += chunk.length;
466
+ if (!payload.eof) return;
467
+
468
+ const finalBytes = Buffer.concat(state.chunks);
469
+ const finalSha = state.hash.digest('hex');
470
+ if (finalSha !== state.intentSha256) {
471
+ sendError(deps.broadcast, replyTo, state.reqId, 'integrity');
472
+ auditReject(
473
+ state.reqId,
474
+ peer,
475
+ state.nodeId,
476
+ state.filename,
477
+ state.totalBytes,
478
+ state.intentSha256,
479
+ 'integrity',
480
+ );
481
+ releaseInflight(state.reqId);
482
+ return;
483
+ }
484
+ if (finalBytes.length !== state.totalBytes) {
485
+ sendError(deps.broadcast, replyTo, state.reqId, 'integrity');
486
+ releaseInflight(state.reqId);
487
+ return;
488
+ }
489
+
490
+ const outcome = writeAndBroadcast(state, finalBytes, finalSha, peer);
491
+ if (!outcome.ok) {
492
+ sendError(deps.broadcast, replyTo, state.reqId, outcome.reason);
493
+ releaseInflight(state.reqId);
494
+ return;
495
+ }
496
+ sendOk(deps.broadcast, replyTo, state.reqId);
497
+ releaseInflight(state.reqId);
498
+ };
499
+
500
+ const handleDone: FileUploadHandler['handleDone'] = async (envelope, peer) => {
501
+ const replyTo = envelope.from;
502
+ const parsed = FileUploadDonePayloadSchema.safeParse(envelope.payload);
503
+ if (!parsed.success) {
504
+ console.warn('[share] file-upload-done rejected: bad payload');
505
+ return;
506
+ }
507
+ const payload = parsed.data;
508
+ const state = inflight.get(payload.reqId);
509
+ if (!state) {
510
+ console.warn('[share] file-upload-done dropped: no in-flight intent', {
511
+ reqId: payload.reqId,
512
+ });
513
+ return;
514
+ }
515
+ if (state.connId !== replyTo) {
516
+ console.warn('[share] file-upload-done dropped: peer mismatch');
517
+ return;
518
+ }
519
+
520
+ // The relay (US-058) embeds the presigned GET URL alongside the existing
521
+ // `payload.key + payload.sha256` so the host can fetch the staged bytes
522
+ // without the peer ever streaming them through the WS. Read from raw
523
+ // `envelope.payload` because `FileUploadDonePayloadSchema` strips unknown
524
+ // keys.
525
+ const rawPayload =
526
+ envelope.payload && typeof envelope.payload === 'object'
527
+ ? (envelope.payload as { getUrl?: string })
528
+ : {};
529
+ const getUrl = rawPayload.getUrl;
530
+ if (!getUrl) {
531
+ sendError(deps.broadcast, replyTo, state.reqId, 'missing-get-url');
532
+ releaseInflight(state.reqId);
533
+ return;
534
+ }
535
+
536
+ let bytes: Buffer;
537
+ try {
538
+ const res = await fetchFn(getUrl);
539
+ if (!res.ok) {
540
+ sendError(deps.broadcast, replyTo, state.reqId, `fetch-status-${res.status}`);
541
+ releaseInflight(state.reqId);
542
+ return;
543
+ }
544
+ const ab = await res.arrayBuffer();
545
+ bytes = Buffer.from(ab);
546
+ } catch (err) {
547
+ console.warn('[share] file-upload-done fetch failed:', err);
548
+ sendError(deps.broadcast, replyTo, state.reqId, 'fetch-failed');
549
+ releaseInflight(state.reqId);
550
+ return;
551
+ }
552
+
553
+ if (bytes.length !== state.totalBytes) {
554
+ sendError(deps.broadcast, replyTo, state.reqId, 'integrity');
555
+ releaseInflight(state.reqId);
556
+ return;
557
+ }
558
+ const finalSha = createHash('sha256').update(bytes).digest('hex');
559
+ if (finalSha !== state.intentSha256) {
560
+ sendError(deps.broadcast, replyTo, state.reqId, 'integrity');
561
+ auditReject(
562
+ state.reqId,
563
+ peer,
564
+ state.nodeId,
565
+ state.filename,
566
+ state.totalBytes,
567
+ state.intentSha256,
568
+ 'integrity',
569
+ );
570
+ releaseInflight(state.reqId);
571
+ return;
572
+ }
573
+
574
+ const outcome = writeAndBroadcast(state, bytes, finalSha, peer);
575
+ if (!outcome.ok) {
576
+ sendError(deps.broadcast, replyTo, state.reqId, outcome.reason);
577
+ releaseInflight(state.reqId);
578
+ return;
579
+ }
580
+ sendOk(deps.broadcast, replyTo, state.reqId);
581
+ releaseInflight(state.reqId);
582
+ };
583
+
584
+ return {
585
+ handleIntent,
586
+ handleBytes,
587
+ handleDone,
588
+ inflightCount: (connId) => {
589
+ let n = 0;
590
+ for (const v of inflight.values()) if (v.connId === connId) n++;
591
+ return n;
592
+ },
593
+ windowBytes: (connId) => peerWindows.get(connId)?.bytes ?? 0,
594
+ };
595
+ }