@tuongaz/seeflow 0.1.109 → 0.1.111

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-CvIAMnMC.js → architectureDiagram-3BPJPVTR-C-2fCGRP.js} +1 -1
  2. package/dist/web/assets/{blockDiagram-GPEHLZMM-C55itdvA.js → blockDiagram-GPEHLZMM-Br3qZBhv.js} +1 -1
  3. package/dist/web/assets/{c4Diagram-AAUBKEIU-BUJz6OcY.js → c4Diagram-AAUBKEIU-C4uX4ZH4.js} +1 -1
  4. package/dist/web/assets/channel-qhQeh_4Q.js +1 -0
  5. package/dist/web/assets/{chart-Ck4M6xxI.js → chart-9kjHxc0V.js} +1 -1
  6. package/dist/web/assets/{chunk-2J33WTMH-CYHRe8M7.js → chunk-2J33WTMH-2Fn5NN7i.js} +1 -1
  7. package/dist/web/assets/{chunk-4BX2VUAB-0G6VpCw_.js → chunk-4BX2VUAB-Df_7ccfg.js} +1 -1
  8. package/dist/web/assets/{chunk-55IACEB6-YeyA7Efg.js → chunk-55IACEB6-DdU4dw35.js} +1 -1
  9. package/dist/web/assets/{chunk-727SXJPM-irL9oAdE.js → chunk-727SXJPM-1at9fA5f.js} +1 -1
  10. package/dist/web/assets/{chunk-AQP2D5EJ-DB0ZGTqs.js → chunk-AQP2D5EJ-CPe9M1hv.js} +1 -1
  11. package/dist/web/assets/{chunk-FMBD7UC4-Z48rYWhG.js → chunk-FMBD7UC4-DCUEmPvJ.js} +1 -1
  12. package/dist/web/assets/{chunk-ND2GUHAM-BgYSDKdi.js → chunk-ND2GUHAM-tE2Ii-jJ.js} +1 -1
  13. package/dist/web/assets/{chunk-QZHKN3VN-CzcjFmi-.js → chunk-QZHKN3VN-Db5o8Kzv.js} +1 -1
  14. package/dist/web/assets/classDiagram-4FO5ZUOK-BCHg-KrI.js +1 -0
  15. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BCHg-KrI.js +1 -0
  16. package/dist/web/assets/{code-block-BwE8Ip6V.js → code-block-CTINSk3z.js} +1 -1
  17. package/dist/web/assets/{cose-bilkent-S5V4N54A-DtufDmle.js → cose-bilkent-S5V4N54A-D7tNx5-t.js} +1 -1
  18. package/dist/web/assets/{dagre-BM42HDAG-DwYVzLme.js → dagre-BM42HDAG-Cdb53Asb.js} +1 -1
  19. package/dist/web/assets/{diagram-2AECGRRQ-DDw_qvjI.js → diagram-2AECGRRQ-HS6pnGrL.js} +1 -1
  20. package/dist/web/assets/{diagram-5GNKFQAL-BYG2VdwF.js → diagram-5GNKFQAL-c9k4swAt.js} +1 -1
  21. package/dist/web/assets/{diagram-KO2AKTUF-Drp2zu_G.js → diagram-KO2AKTUF-CB3k4xVS.js} +1 -1
  22. package/dist/web/assets/{diagram-LMA3HP47-CXmAihfn.js → diagram-LMA3HP47-XKAfR3DB.js} +1 -1
  23. package/dist/web/assets/{diagram-OG6HWLK6-i6-iKnAU.js → diagram-OG6HWLK6-18dGr0GC.js} +1 -1
  24. package/dist/web/assets/{erDiagram-TEJ5UH35-Bo96y9hO.js → erDiagram-TEJ5UH35-9DnFE7wo.js} +1 -1
  25. package/dist/web/assets/{flowDiagram-I6XJVG4X-D9fneMTu.js → flowDiagram-I6XJVG4X-CglLanUO.js} +1 -1
  26. package/dist/web/assets/{ganttDiagram-6RSMTGT7-Cbefa8NE.js → ganttDiagram-6RSMTGT7-_zKCXbXg.js} +1 -1
  27. package/dist/web/assets/{gitGraphDiagram-PVQCEYII-BHYd5nUs.js → gitGraphDiagram-PVQCEYII-D6axSpL3.js} +1 -1
  28. package/dist/web/assets/{iconify-BuhA_8An.js → iconify-CF3Xx8Oq.js} +1 -1
  29. package/dist/web/assets/index-BTL8cVgF.js +8629 -0
  30. package/dist/web/assets/{index.es-pImjWTGX.js → index.es-DyQqI4EJ.js} +1 -1
  31. package/dist/web/assets/{infoDiagram-5YYISTIA-Bw7MIIwj.js → infoDiagram-5YYISTIA-BfnEVJMR.js} +1 -1
  32. package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-CySt1i7t.js → ishikawaDiagram-YF4QCWOH-C-CbbOkc.js} +1 -1
  33. package/dist/web/assets/{journeyDiagram-JHISSGLW-q4YV61wv.js → journeyDiagram-JHISSGLW-CxcYZYhO.js} +1 -1
  34. package/dist/web/assets/{jspdf.es.min-B95ptK5L.js → jspdf.es.min-BoDPBXoT.js} +3 -3
  35. package/dist/web/assets/{kanban-definition-UN3LZRKU-C1oN-zoM.js → kanban-definition-UN3LZRKU-Dw9dGj5D.js} +1 -1
  36. package/dist/web/assets/{linear-DPSv7VcC.js → linear-DJLoiaYn.js} +1 -1
  37. package/dist/web/assets/{markdown-btlM2PIA.js → markdown-BXIhHJY7.js} +1 -1
  38. package/dist/web/assets/{mermaid.core-Cmch2GTm.js → mermaid.core-CTeg8SMj.js} +4 -4
  39. package/dist/web/assets/{mindmap-definition-RKZ34NQL--XOTWr6e.js → mindmap-definition-RKZ34NQL-DG4pYeYK.js} +1 -1
  40. package/dist/web/assets/{pieDiagram-4H26LBE5-DxZgtzul.js → pieDiagram-4H26LBE5-qPHgJ6_d.js} +1 -1
  41. package/dist/web/assets/{quadrantDiagram-W4KKPZXB-bbV2bAO3.js → quadrantDiagram-W4KKPZXB-C-yiyax0.js} +1 -1
  42. package/dist/web/assets/{requirementDiagram-4Y6WPE33-CiJM8hio.js → requirementDiagram-4Y6WPE33-vBgnScGl.js} +1 -1
  43. package/dist/web/assets/{sankeyDiagram-5OEKKPKP-DABBnuaB.js → sankeyDiagram-5OEKKPKP-H-Yoexss.js} +1 -1
  44. package/dist/web/assets/{sequenceDiagram-3UESZ5HK-BTQJk7bM.js → sequenceDiagram-3UESZ5HK-B0PUqbDC.js} +1 -1
  45. package/dist/web/assets/{stateDiagram-AJRCARHV-yL0tAd3x.js → stateDiagram-AJRCARHV-LHZJU6NT.js} +1 -1
  46. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-Jjcvv867.js +1 -0
  47. package/dist/web/assets/{time-hCZUKGxT.js → time-CytNQj8l.js} +1 -1
  48. package/dist/web/assets/{timeline-definition-PNZ67QCA-CG6k16Wj.js → timeline-definition-PNZ67QCA-CNYtzzkX.js} +1 -1
  49. package/dist/web/assets/{vennDiagram-CIIHVFJN-BVH530xf.js → vennDiagram-CIIHVFJN-CR3j3MfQ.js} +1 -1
  50. package/dist/web/assets/{wardley-L42UT6IY-BYVkFMrL.js → wardley-L42UT6IY-DsRtYkk3.js} +1 -1
  51. package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BGMuHejP.js → wardleyDiagram-YWT4CUSO-Bkiae0No.js} +1 -1
  52. package/dist/web/assets/{xychartDiagram-2RQKCTM6-D6MSlnJD.js → xychartDiagram-2RQKCTM6-DFoBCrdz.js} +1 -1
  53. package/dist/web/index.html +1 -1
  54. package/package.json +1 -1
  55. package/src/api.ts +0 -291
  56. package/src/server.ts +0 -20
  57. package/dist/web/assets/channel-DtcQ9fhj.js +0 -1
  58. package/dist/web/assets/classDiagram-4FO5ZUOK-BjYXB41E.js +0 -1
  59. package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BjYXB41E.js +0 -1
  60. package/dist/web/assets/index-08hmlCqO.js +0 -8644
  61. package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BzWj6i1l.js +0 -1
  62. package/src/share/sse-frame.ts +0 -85
  63. package/src/share/sse-outbound-queue.ts +0 -173
  64. package/src/share/sse-rate-limit.ts +0 -205
  65. package/src/share/sse-tap.ts +0 -183
  66. package/src/share-audit.ts +0 -267
  67. package/src/share-envelope.ts +0 -155
  68. package/src/share-file-request.ts +0 -399
  69. package/src/share-file-resolver.ts +0 -68
  70. package/src/share-file-upload.ts +0 -595
  71. package/src/share-files-manifest.ts +0 -232
  72. package/src/share-ratelimit.ts +0 -69
  73. package/src/share-rpc-schema.ts +0 -249
  74. package/src/share-transport.ts +0 -205
  75. package/src/share.ts +0 -1663
@@ -1,399 +0,0 @@
1
- /**
2
- * Host-side `file-request` handler. The relay routes inbound `file-request`
3
- * envelopes (peer -> host) here; the handler either responds inline with a
4
- * single `file-bytes` frame (<=256 KB) or stages the file in the relay's
5
- * ephemeral S3 bucket via `file-upload-intent` and replies with a
6
- * `file-redirect` URL.
7
- *
8
- * Design choice: S3 staging is preferred over multi-chunk WS streaming for
9
- * oversize files because the relay rate-limits `file-bytes` at 5 MB / 60 s per
10
- * peer (US-058); large assets would otherwise stall. Staged objects live for
11
- * <=60 s (the staging bucket has a 1-day lifecycle but presigned URLs expire
12
- * in 60 s) so bytes never persist past the read.
13
- *
14
- * Errors reply with both a sentinel `file-bytes { eof:true, sha256:'' }` so
15
- * the peer's `/files/<path>` proxy can return 4xx, AND an `rpc-result
16
- * { id:reqId, ok:false, reason }` so peer rpc callers see a structured error.
17
- *
18
- * Rate-limit: at most 30 concurrent in-flight file-requests per peer; excess
19
- * are rejected immediately with `reason: 'too-many-in-flight'`.
20
- */
21
-
22
- import { createHash } from 'node:crypto';
23
- import * as fsPromises from 'node:fs/promises';
24
- import { basename, extname } from 'node:path';
25
- import type { FlowEntry, Registry } from './registry.ts';
26
- import {
27
- type Envelope,
28
- type FileBytesPayload,
29
- type FileRedirectPayload,
30
- FileRequestPayloadSchema,
31
- type FileUploadIntentPayload,
32
- makeEnvelope,
33
- } from './share-envelope.ts';
34
- import { resolveNodeFile } from './share-file-resolver.ts';
35
-
36
- const INLINE_LIMIT_BYTES = 256 * 1024;
37
- // Chunk size for the WS-streaming fallback (used when S3 staging is absent).
38
- // Match the inline single-frame budget so the same MTU shape lands on the wire
39
- // either way; the peer's reassembler is size-agnostic.
40
- const FILE_CHUNK_BYTES = 256 * 1024;
41
- // Hard cap on chunked-WS file serving — keeps a single oversize asset from
42
- // dominating the per-peer queue when S3 isn't available. Above this size,
43
- // host returns 'too-large' so the user uploads via a normal HTTP path.
44
- const MAX_WS_BYTES = 10 * 1024 * 1024;
45
- const MAX_INFLIGHT_PER_PEER = 30;
46
-
47
- // Extension -> content type allowlist. Anything not in the map falls back to
48
- // `application/octet-stream`. Lowercased keys; we lowercase the extension at
49
- // lookup so `.PNG` resolves the same as `.png`.
50
- const CONTENT_TYPE_MAP: Record<string, string> = {
51
- '.png': 'image/png',
52
- '.jpg': 'image/jpeg',
53
- '.jpeg': 'image/jpeg',
54
- '.webp': 'image/webp',
55
- '.svg': 'image/svg+xml',
56
- '.gif': 'image/gif',
57
- '.html': 'text/html',
58
- '.htm': 'text/html',
59
- '.md': 'text/markdown',
60
- '.txt': 'text/plain',
61
- '.json': 'application/json',
62
- };
63
-
64
- export function contentTypeFor(filename: string): string {
65
- const ext = extname(filename).toLowerCase();
66
- return CONTENT_TYPE_MAP[ext] ?? 'application/octet-stream';
67
- }
68
-
69
- export interface UploadIntentReply {
70
- // `getUrl`+`expiresAt` are returned alongside `putUrl` so the host can
71
- // immediately reply with `file-redirect` after the PUT succeeds. The cloud
72
- // relay currently embeds only `putUrl` (US-058); the wiring layer is
73
- // responsible for materialising matching getUrl/expiresAt either by
74
- // re-using the staging key with a second presigner call or by extending the
75
- // relay reply. Tests inject this dep wholesale.
76
- putUrl: string;
77
- getUrl: string;
78
- expiresAt: number;
79
- key: string;
80
- }
81
-
82
- export type RequestUploadIntent = (payload: {
83
- reqId: string;
84
- filename: string;
85
- size: number;
86
- contentType: string;
87
- sha256: string;
88
- nodeId: string;
89
- role: 'host-serve';
90
- }) => Promise<UploadIntentReply>;
91
-
92
- export type PutToS3 = (
93
- url: string,
94
- bytes: Buffer,
95
- contentType: string,
96
- ) => Promise<{ ok: boolean; status: number }>;
97
-
98
- export interface FileRequestDeps {
99
- registry: Registry;
100
- // Sends outbound envelopes through the active transport. Defaults wire to
101
- // share.ts's existing `broadcast` closure when integrated.
102
- broadcast: (envelope: Envelope) => void;
103
- // S3 staging path. Both deps must be provided to enable the oversize path;
104
- // if either is absent, oversize requests reply with `reason: 'too-large'`
105
- // and the peer falls back to a 413.
106
- requestUploadIntent?: RequestUploadIntent;
107
- putToS3?: PutToS3;
108
- // Test overrides — production callers leave these on the node:fs defaults.
109
- readFile?: (p: string) => Promise<Buffer>;
110
- fileExists?: (p: string) => Promise<boolean>;
111
- }
112
-
113
- export interface FileRequestHandler {
114
- handle(envelope: Envelope): Promise<void>;
115
- // Exposed for tests so they can assert the in-flight counter is released
116
- // after each request settles.
117
- inflightCount(peerConnId: string): number;
118
- }
119
-
120
- const defaultFileExists = async (p: string): Promise<boolean> => {
121
- try {
122
- const s = await fsPromises.stat(p);
123
- return s.isFile();
124
- } catch {
125
- return false;
126
- }
127
- };
128
-
129
- const defaultReadFile = (p: string): Promise<Buffer> => fsPromises.readFile(p) as Promise<Buffer>;
130
-
131
- // Iterate all registered flow entries and return the first whose resolved
132
- // per-node path exists on disk. nodeIds are globally unique (10-char base62
133
- // shortIds) so collisions across flows are astronomically unlikely; the
134
- // existence probe disambiguates if it ever happens.
135
- type LocateResult =
136
- | { kind: 'ok'; entry: FlowEntry; absPath: string }
137
- | { kind: 'bad-node-id' }
138
- | { kind: 'traversal' }
139
- | { kind: 'not-found' };
140
-
141
- async function locateNodeFile(
142
- registry: Registry,
143
- nodeId: string,
144
- relPath: string,
145
- fileExists: (p: string) => Promise<boolean>,
146
- ): Promise<LocateResult> {
147
- const entries = registry.list();
148
- if (entries.length === 0) return { kind: 'not-found' };
149
-
150
- // Run the resolver against the first entry to surface the static-shape
151
- // errors (bad nodeId / traversal). These depend only on nodeId+relPath, so
152
- // any registry entry yields the same verdict.
153
- const first = entries[0];
154
- if (first) {
155
- const r = resolveNodeFile({
156
- repoPath: first.repoPath,
157
- flowPath: first.flowPath,
158
- nodeId,
159
- relPath,
160
- });
161
- if ('error' in r) {
162
- if (r.error === 'bad-node-id') return { kind: 'bad-node-id' };
163
- return { kind: 'traversal' };
164
- }
165
- }
166
-
167
- for (const entry of entries) {
168
- const r = resolveNodeFile({
169
- repoPath: entry.repoPath,
170
- flowPath: entry.flowPath,
171
- nodeId,
172
- relPath,
173
- });
174
- if ('error' in r) continue;
175
- if (await fileExists(r.absPath)) {
176
- return { kind: 'ok', entry, absPath: r.absPath };
177
- }
178
- }
179
- return { kind: 'not-found' };
180
- }
181
-
182
- export function createFileRequestHandler(deps: FileRequestDeps): FileRequestHandler {
183
- const readFile = deps.readFile ?? defaultReadFile;
184
- const fileExists = deps.fileExists ?? defaultFileExists;
185
- const inflightPerPeer = new Map<string, number>();
186
-
187
- const sendErrorReply = (replyTo: string, reqId: string, reason: string) => {
188
- // Send the sentinel file-bytes so the peer's /files proxy returns 4xx,
189
- // then the structured rpc-result with the actual reason. Order matters
190
- // for the peer-side state machine in US-063 (proxy resolves on the
191
- // file-bytes sentinel, rpc callers resolve on the rpc-result).
192
- const sentinel: FileBytesPayload = {
193
- reqId,
194
- seq: 0,
195
- total: 0,
196
- base64: '',
197
- sha256: '',
198
- eof: true,
199
- };
200
- deps.broadcast(makeEnvelope('file-bytes', sentinel, { to: replyTo }));
201
- deps.broadcast(makeEnvelope('rpc-result', { ok: false, reason }, { to: replyTo, id: reqId }));
202
- };
203
-
204
- const respondInline = (
205
- replyTo: string,
206
- reqId: string,
207
- bytes: Buffer,
208
- contentType: string,
209
- sha256: string,
210
- ) => {
211
- const payload: FileBytesPayload = {
212
- reqId,
213
- seq: 0,
214
- total: 1,
215
- base64: bytes.toString('base64'),
216
- contentType,
217
- sha256,
218
- eof: true,
219
- };
220
- deps.broadcast(makeEnvelope('file-bytes', payload, { to: replyTo }));
221
- };
222
-
223
- // Chunked WS fallback for files larger than INLINE_LIMIT_BYTES when S3 staging
224
- // is not configured. The peer's `handleFileBytesIn` already reassembles by
225
- // (seq, total, eof) and verifies the final sha256 against the assembled bytes,
226
- // so each chunk repeats the same sha256 (it describes the whole file). Used
227
- // by self-hosted deployments + local studios where the relay can't mint a
228
- // presigned PUT.
229
- const respondChunked = (
230
- replyTo: string,
231
- reqId: string,
232
- bytes: Buffer,
233
- contentType: string,
234
- sha256: string,
235
- ) => {
236
- const total = Math.max(1, Math.ceil(bytes.length / FILE_CHUNK_BYTES));
237
- for (let seq = 0; seq < total; seq++) {
238
- const start = seq * FILE_CHUNK_BYTES;
239
- const end = Math.min(start + FILE_CHUNK_BYTES, bytes.length);
240
- const chunk = bytes.subarray(start, end);
241
- const payload: FileBytesPayload = {
242
- reqId,
243
- seq,
244
- total,
245
- base64: chunk.toString('base64'),
246
- contentType,
247
- sha256,
248
- eof: seq === total - 1,
249
- };
250
- deps.broadcast(makeEnvelope('file-bytes', payload, { to: replyTo }));
251
- }
252
- };
253
-
254
- const respondRedirect = (
255
- replyTo: string,
256
- reqId: string,
257
- intent: UploadIntentReply,
258
- sha256: string,
259
- ) => {
260
- const payload: FileRedirectPayload = {
261
- reqId,
262
- getUrl: intent.getUrl,
263
- sha256,
264
- expiresAt: intent.expiresAt,
265
- };
266
- deps.broadcast(makeEnvelope('file-redirect', payload, { to: replyTo }));
267
- };
268
-
269
- const handle = async (envelope: Envelope): Promise<void> => {
270
- const replyTo = envelope.from;
271
- const parsed = FileRequestPayloadSchema.safeParse(envelope.payload);
272
- if (!parsed.success) {
273
- // We can't address the rpc-result without a reqId. Pull a best-effort id
274
- // from the raw payload (relay never inspects it, so anything goes) and
275
- // fall back to a literal `'invalid'` so the wire schema's min(1) holds.
276
- const rawReqId =
277
- envelope.payload &&
278
- typeof envelope.payload === 'object' &&
279
- 'reqId' in envelope.payload &&
280
- typeof (envelope.payload as { reqId?: unknown }).reqId === 'string' &&
281
- (envelope.payload as { reqId: string }).reqId.length > 0
282
- ? (envelope.payload as { reqId: string }).reqId
283
- : 'invalid';
284
- sendErrorReply(replyTo, rawReqId, 'bad-payload');
285
- return;
286
- }
287
- const { reqId, nodeId, relPath } = parsed.data;
288
-
289
- const current = inflightPerPeer.get(replyTo) ?? 0;
290
- if (current >= MAX_INFLIGHT_PER_PEER) {
291
- sendErrorReply(replyTo, reqId, 'too-many-in-flight');
292
- return;
293
- }
294
- inflightPerPeer.set(replyTo, current + 1);
295
-
296
- try {
297
- const located = await locateNodeFile(deps.registry, nodeId, relPath, fileExists);
298
- if (located.kind === 'bad-node-id') {
299
- sendErrorReply(replyTo, reqId, 'bad-node-id');
300
- return;
301
- }
302
- if (located.kind === 'traversal') {
303
- sendErrorReply(replyTo, reqId, 'traversal');
304
- return;
305
- }
306
- if (located.kind === 'not-found') {
307
- sendErrorReply(replyTo, reqId, 'not-found');
308
- return;
309
- }
310
-
311
- let bytes: Buffer;
312
- try {
313
- bytes = await readFile(located.absPath);
314
- } catch (err) {
315
- console.warn('[share] file-request read failed:', {
316
- nodeId,
317
- reason: err instanceof Error ? err.message : String(err),
318
- });
319
- sendErrorReply(replyTo, reqId, 'read-failed');
320
- return;
321
- }
322
-
323
- const sha256 = createHash('sha256').update(bytes).digest('hex');
324
- const filename = basename(located.absPath);
325
- const contentType = contentTypeFor(filename);
326
-
327
- if (bytes.length <= INLINE_LIMIT_BYTES) {
328
- respondInline(replyTo, reqId, bytes, contentType, sha256);
329
- return;
330
- }
331
-
332
- // Oversize path: prefer S3 redirect when staging is wired (avoids
333
- // base64-over-WS overhead). Fall back to chunked WS frames for
334
- // self-hosted / local studios that lack S3 — capped at MAX_WS_BYTES so
335
- // a giant asset doesn't pin the relay's per-peer queue.
336
- if (!deps.requestUploadIntent || !deps.putToS3) {
337
- if (bytes.length > MAX_WS_BYTES) {
338
- sendErrorReply(replyTo, reqId, 'too-large');
339
- return;
340
- }
341
- respondChunked(replyTo, reqId, bytes, contentType, sha256);
342
- return;
343
- }
344
- const intentPayload: FileUploadIntentPayload = {
345
- reqId,
346
- filename,
347
- size: bytes.length,
348
- contentType,
349
- sha256,
350
- nodeId,
351
- role: 'host-serve',
352
- };
353
- let intent: UploadIntentReply;
354
- try {
355
- intent = await deps.requestUploadIntent({
356
- reqId: intentPayload.reqId,
357
- filename: intentPayload.filename,
358
- size: intentPayload.size,
359
- contentType: intentPayload.contentType,
360
- sha256: intentPayload.sha256,
361
- nodeId: intentPayload.nodeId,
362
- role: 'host-serve',
363
- });
364
- } catch (err) {
365
- console.warn('[share] file-request intent failed:', {
366
- nodeId,
367
- reason: err instanceof Error ? err.message : String(err),
368
- });
369
- sendErrorReply(replyTo, reqId, 'intent-failed');
370
- return;
371
- }
372
- let putResult: { ok: boolean; status: number };
373
- try {
374
- putResult = await deps.putToS3(intent.putUrl, bytes, contentType);
375
- } catch (err) {
376
- console.warn('[share] file-request s3 put threw:', {
377
- nodeId,
378
- reason: err instanceof Error ? err.message : String(err),
379
- });
380
- sendErrorReply(replyTo, reqId, 'put-failed');
381
- return;
382
- }
383
- if (!putResult.ok) {
384
- sendErrorReply(replyTo, reqId, `put-status-${putResult.status}`);
385
- return;
386
- }
387
- respondRedirect(replyTo, reqId, intent, sha256);
388
- } finally {
389
- const next = (inflightPerPeer.get(replyTo) ?? 1) - 1;
390
- if (next <= 0) inflightPerPeer.delete(replyTo);
391
- else inflightPerPeer.set(replyTo, next);
392
- }
393
- };
394
-
395
- return {
396
- handle,
397
- inflightCount: (peerConnId) => inflightPerPeer.get(peerConnId) ?? 0,
398
- };
399
- }
@@ -1,68 +0,0 @@
1
- import * as nodePath from 'node:path';
2
-
3
- export type ResolveError = 'bad-node-id' | 'traversal' | 'outside-flow-dir';
4
-
5
- export type ResolveNodeFileResult = { absPath: string } | { error: ResolveError };
6
-
7
- export interface ResolveNodeFileOptions {
8
- repoPath: string;
9
- flowPath: string;
10
- nodeId: string;
11
- relPath: string;
12
- }
13
-
14
- const NODE_ID_RE = /^node-[A-Za-z0-9]{10}$/;
15
-
16
- // Map a peer-supplied `{ nodeId, relPath }` request into a guaranteed-safe
17
- // absolute path under `<repoPath>/<dirname(flowPath)>/nodes/<nodeId>/`. Mirrors
18
- // the per-node upload sink at api.ts /projects/.../nodes/:nodeId/files/upload:
19
- // for manifest-driven projects the base dir is `<flowDir>/nodes/<id>/`; for
20
- // legacy single-flow registrations (flow.json at the project root, flowDir==='.')
21
- // it collapses to `<repoPath>/nodes/<id>/`. Refuses any input that escapes that
22
- // per-node scope.
23
- export const resolveNodeFile = (
24
- opts: ResolveNodeFileOptions,
25
- pathMod: typeof nodePath = nodePath,
26
- ): ResolveNodeFileResult => {
27
- const { repoPath, flowPath, nodeId, relPath } = opts;
28
-
29
- if (!NODE_ID_RE.test(nodeId)) {
30
- return { error: 'bad-node-id' };
31
- }
32
-
33
- if (typeof relPath !== 'string' || relPath.length === 0) {
34
- return { error: 'traversal' };
35
- }
36
- // Refuse absolute paths in either separator style so a host running on POSIX
37
- // still rejects a `C:\…` or `\\server\share` payload from a win32 peer (and
38
- // vice-versa). Win32 absoluteness covers drive letters and UNC paths.
39
- if (relPath.startsWith('/') || relPath.startsWith('\\')) {
40
- return { error: 'traversal' };
41
- }
42
- if (pathMod.isAbsolute(relPath) || nodePath.win32.isAbsolute(relPath)) {
43
- return { error: 'traversal' };
44
- }
45
-
46
- // After normalize, any remaining `..` segment means the relPath tries to
47
- // escape its node folder. Split on BOTH separators so a posix host catches
48
- // `..\\evil` and a win32 host catches `../evil`.
49
- const normalized = pathMod.normalize(relPath);
50
- const segments = normalized.split(/[/\\]/);
51
- if (segments.some((segment) => segment === '..')) {
52
- return { error: 'traversal' };
53
- }
54
-
55
- const flowDir = pathMod.dirname(flowPath);
56
- const baseDir =
57
- flowDir === '.'
58
- ? pathMod.join(repoPath, 'nodes', nodeId)
59
- : pathMod.join(repoPath, flowDir, 'nodes', nodeId);
60
-
61
- const absPath = pathMod.resolve(baseDir, relPath);
62
-
63
- if (absPath !== baseDir && !absPath.startsWith(baseDir + pathMod.sep)) {
64
- return { error: 'outside-flow-dir' };
65
- }
66
-
67
- return { absPath };
68
- };