srift-transfer 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +33 -20
  2. package/dist/daemon.js +349 -158
  3. package/dist/index.js +650 -426
  4. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -1184,12 +1184,13 @@ function withQuery(url, q) {
1184
1184
  return `${url}${url.includes("?") ? "&" : "?"}${q}`;
1185
1185
  }
1186
1186
  function explainStatus(status, body) {
1187
- const msg = body.replace(/^SRIFT:\s*/, "").trim();
1187
+ let msg = body.replace(/^SRIFT:\s*/, "").trim();
1188
+ if (/^\s*<(!doctype|html)/i.test(msg)) msg = status >= 500 ? "The SRIFT server or a proxy in front of it is temporarily unavailable \u2014 retry shortly" : "";
1189
+ if (status === 424 || status === 502 && /sender error: (the shared file|cannot read the shared file)/i.test(msg)) return new GetError(msg || "The sender can no longer serve this file.", "ESENDERFILE", status);
1188
1190
  if (status === 404) return new GetError(msg || "Link not found \u2014 it expired, was revoked, or the sender went offline.", "ENOTFOUND_LINK", 404);
1189
1191
  if (status === 410) return new GetError(msg || "Link expired or reached its download limit.", "EGONE", 410);
1190
1192
  if (status === 503) return new GetError(msg || "Sender is offline \u2014 their daemon must be running for relay links.", "EOFFLINE", 503);
1191
1193
  if (status === 409) return new GetError(msg || "The sender is still uploading \u2014 retry shortly.", "ENOTREADY", 409);
1192
- if (status === 502 && /sender error: (the shared file|cannot read the shared file)/i.test(msg)) return new GetError(msg, "ESENDERFILE", 502);
1193
1194
  return new GetError(`${msg || "Download failed"} (HTTP ${status})`, "EHTTP", status);
1194
1195
  }
1195
1196
  async function readSmallBody(res, max = 4096) {
@@ -1376,7 +1377,7 @@ async function getLink(input, opts = {}) {
1376
1377
  }
1377
1378
  const serverName = filenameFromDisposition(head["content-disposition"]) || "download.bin";
1378
1379
  const mode = head["x-srift-mode"] ? String(head["x-srift-mode"]) : null;
1379
- const total = parseInt(String(head["content-length"] || ""), 10) || 0;
1380
+ const total = parseInt(String(head["x-srift-size"] || head["content-length"] || ""), 10) || 0;
1380
1381
  let received = 0;
1381
1382
  let lastPrint = 0;
1382
1383
  const progress = (n) => {
@@ -1699,10 +1700,10 @@ var init_skills_data = __esm({
1699
1700
  "../../lib/mcp/skills-data.mjs"() {
1700
1701
  "use strict";
1701
1702
  SOURCES = [
1702
- { name: "encrypted-chat", content: "---\nname: encrypted-chat\ndescription: Send and read end-to-end encrypted chat messages and manage in-session file transfers within an active SRIFT session\n---\n\n# encrypted-chat\n\nSend and read end-to-end encrypted chat messages within an active SRIFT session, plus manage in-session file transfers, via the local MCP daemon.\n\n## Requirements\n\nAn active SRIFT session (see the `session-management` skill to open or join one first). No special credentials required.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_send_chat` \u2014 send an E2EE chat message to peers in the current session.\n- `srift_chat_history` \u2014 read decrypted chat history for the current session.\n- `srift_send_file` \u2014 offer a file to peers already inside the session (in-session transfer, distinct from `quick-share`'s standalone link flow).\n- `srift_accept_transfer` \u2014 accept and download an inbound file offer.\n- `srift_list_transfers` \u2014 list all active and recent transfers with progress.\n- `srift_read_state` \u2014 read the raw `.srift-state.json` snapshot for debugging/observability.\n\nAll messages and file payloads are encrypted client-side with AES-256-GCM before leaving the device; the daemon never has access to plaintext.\n" },
1703
+ { name: "encrypted-chat", content: "---\nname: encrypted-chat\ndescription: Send and read end-to-end encrypted chat messages and manage in-session file transfers within an active SRIFT session\n---\n\n# encrypted-chat\n\nSend and read end-to-end encrypted chat messages within an active SRIFT session, plus manage in-session file transfers, via the local MCP daemon.\n\n## Requirements\n\nAn active SRIFT session (see the `session-management` skill to open or join one first). No special credentials required.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_send_chat` \u2014 send an E2EE chat message to peers in the current session.\n- `srift_chat_history` \u2014 read decrypted chat history for the current session.\n- `srift_send_file` \u2014 offer a file to peers already inside the session (in-session transfer, distinct from `quick-share`'s standalone link flow).\n- `srift_accept_transfer` \u2014 accept and download an inbound file offer.\n- `srift_list_transfers` \u2014 list all active and recent transfers with progress.\n- `srift_read_state` \u2014 read the raw `.srift-state.json` snapshot for debugging/observability.\n\nYour local daemon (or the browser) encrypts messages and file chunks with AES-256-GCM before they leave the device, using keys derived from the session (plus the optional `roomSecret`); the relay server only forwards ciphertext and never receives the key. Without a `roomSecret` the key is derivable from the session ID (which the server sees); set one on both sides to make it participant-only.\n" },
1703
1704
  { name: "get-company-info", content: "---\nname: get-company-info\ndescription: Retrieve overview and core metadata for SRIFT and SRIPTO Corporation\n---\n\n# get-company-info\n\nRetrieve high-level overview and specifications about SRIFT and SRIPTO Corporation.\n\n## Requirements\n\nNo special credentials required.\n\n## Instructions\n\nRead the home page or `llms.txt` to find details about Srift (secure P2P transport), LetMeTeach, SRIX, and A\u039EI.\n" },
1704
1705
  { name: "quick-share", content: "---\nname: quick-share\ndescription: Zero-install file delivery: seed a file and get a public download URL, no session setup required on the recipient's end\n---\n\n# quick-share\n\nZero-install file delivery via SRIFT's local MCP server. Returns a public `https://srift.app/d/<token>` relay link that any recipient can open in a browser \u2014 no SRIFT install, no signup, no session-join UI required on their end. The bytes stream from the sender's machine through the srift.app relay on demand (pass-through, `Cache-Control: no-store`); nothing is stored on any server, so the link works while the sender's daemon (or process) runs.\n\n## Requirements\n\nNo credentials. The SRIFT local daemon binds to `127.0.0.1:3822` with zero authentication. Only outbound HTTPS/WSS on port 443 is needed.\n\n## Instructions\n\n1. Call the MCP tool `srift_quick_share` with `{ filePath: \"/absolute/path/to/file\" }` (single file) or `{ filePaths: [...] }` (up to 500 files/folders). Optional params: `bundle` (default: true = one .tar.gz link; false = one link per path, created in parallel), `bundleName`, `exclude` (string[] for glob patterns to skip), `sessionName`, `maxDownloads`, `ttlMs`, `encrypt`, `password`.\n2. The tool returns a `downloadUrl` (e.g. `https://srift.app/d/<token>`) when bundled, or `{ links: [], errors: [] }` when separate. With `encrypt: true` (or a `password`) each link is end-to-end encrypted with AES-256-GCM: the key rides in the `#k=` URL fragment, is never sent to a server, and the browser decrypts locally. Without it, links are protected by TLS in transit only \u2014 use `encrypt` for anything sensitive.\n3. Hand the URL(s) to the recipient. In order of preference, they can run:\n - `srift get <url> [<url> ...]` (or `npx -y srift-transfer get <url>`; proxy-aware, resumable, decrypts `#k=` links, supports multiple URLs with `--concurrency N`, works where curl is broken)\n - `wget --content-disposition <url>`\n - PowerShell `iwr <url> -OutFile <name>`\n - `curl -OJ <url>`\n - or just open it in any browser.\n4. Keep the sending side running until the recipient has downloaded. From a shell, `srift quick-share <file> [<file> ...] --wait` blocks until then (`--wait-timeout 30m` exits with code 3 on timeout); `--keep-alive` serves until the TTL expires.\n\n## Sandboxed and datacenter agents\n\nIf the background daemon cannot start (the sandbox forbids local servers, blocks detached spawns or loopback, or the port is taken), `srift mcp` and `srift quick-share` host the daemon inside their own process: no local port, outbound HTTPS/WSS on 443 only. The MCP server switches automatically, so this tool keeps working. From a shell, run quick-share in the background (it exits after the download) or with `--wait`; `--foreground` forces in-process serving. Blocked UDP never breaks links. Run `srift doctor` (or the `srift_net_diagnose` tool) to diagnose connectivity; `HTTPS_PROXY`/`NO_PROXY` and `NODE_EXTRA_CA_CERTS` are honoured.\n\nSee `/openapi.json` and `/.well-known/mcp/server-card.json` for the full tool contract.\n" },
1705
- { name: "session-management", content: '---\nname: session-management\ndescription: Create, join, monitor, and control SRIFT end-to-end encrypted P2P sessions via the local MCP daemon\n---\n\n# session-management\n\nCreate, join, monitor, and control SRIFT end-to-end encrypted P2P sessions ("rooms") via the local MCP daemon. Covers the full session lifecycle used for in-session file transfer and encrypted chat.\n\n## Requirements\n\nNo special credentials required. The SRIFT local daemon binds to `127.0.0.1:3822` and requires zero authentication.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_start_session` \u2014 open a new E2EE room as host.\n- `srift_join_session` \u2014 join an existing room by its 7-character session ID.\n- `srift_session_status` \u2014 read current session, role, connected peers, and pending join requests.\n- `srift_approve_join` / `srift_reject_join` \u2014 host-only: approve or reject a guest\'s join request.\n- `srift_kick_user` \u2014 host-only: disconnect and ban a peer.\n- `srift_close_session` \u2014 tear down the session and flush all encryption keys.\n\nAll session state is ephemeral and in-memory; nothing is persisted server-side. Keys are derived locally via PBKDF2-SHA256 and never leave the client device.\n' }
1706
+ { name: "session-management", content: '---\nname: session-management\ndescription: Create, join, monitor, and control SRIFT end-to-end encrypted P2P sessions via the local MCP daemon\n---\n\n# session-management\n\nCreate, join, monitor, and control SRIFT end-to-end encrypted P2P sessions ("rooms") via the local MCP daemon. Covers the full session lifecycle used for in-session file transfer and encrypted chat.\n\n## Requirements\n\nNo special credentials required. The SRIFT local daemon binds to `127.0.0.1:3822` and requires zero authentication.\n\n## Instructions\n\nAvailable MCP tools (see `/.well-known/mcp/server-card.json` for full schemas):\n\n- `srift_start_session` \u2014 open a new E2EE room as host.\n- `srift_join_session` \u2014 join an existing room by its 7-character session ID.\n- `srift_session_status` \u2014 read current session, role, connected peers, and pending join requests.\n- `srift_approve_join` / `srift_reject_join` \u2014 host-only: approve or reject a guest\'s join request.\n- `srift_kick_user` \u2014 host-only: remove a peer. Get their `userId` from `srift_session_status` \u2192 `participants`.\n- `srift_close_session` \u2014 tear down the session and flush all encryption keys.\n\nSessions are ephemeral: session metadata and chat ciphertext are kept server-side only until the session is deleted (host close or 7 days of inactivity), and files are never stored. Keys are derived locally via PBKDF2-SHA256 from the session ID (plus the optional `roomSecret`) and are never sent to the server; without a `roomSecret` the key is derivable from the session ID, which the server sees, so pass one to `srift_start_session` / `srift_join_session` to make it participant-only.\n' }
1706
1707
  ];
1707
1708
  MCP_SKILLS = SOURCES.map(({ name, content }) => {
1708
1709
  const uri = `skill://${name}/SKILL.md`;
@@ -1961,7 +1962,7 @@ var init_core = __esm({
1961
1962
  type: "object",
1962
1963
  properties: {
1963
1964
  sessionName: { type: "string", description: 'Human-readable session name (optional, e.g. "Project Collab")' },
1964
- roomSecret: { type: "string", description: "Optional shared secret for extra-strong E2EE key derivation. Never sent to server." }
1965
+ roomSecret: { type: "string", description: "Optional shared secret mixed into key derivation. Never sent to server. Without it the key is derivable from the session ID (which the server sees); set it to make the key participant-only." }
1965
1966
  }
1966
1967
  },
1967
1968
  outputSchema: {
@@ -2012,15 +2013,37 @@ var init_core = __esm({
2012
2013
  {
2013
2014
  name: "srift_session_status",
2014
2015
  title: "Get Session Status",
2015
- description: "Get current session details: id, role (host/guest), connection state, pending join requests, peer count.",
2016
+ description: "Get current session details: session { id, role (host/guest), connection state, your userId }, pending join requests (approve with srift_approve_join), and members with their userIds (remove one with srift_kick_user).",
2016
2017
  inputSchema: { type: "object", properties: {} },
2017
2018
  outputSchema: {
2018
2019
  type: "object",
2019
2020
  properties: {
2020
- sessionId: { type: "string", description: "Active session ID, if any" },
2021
- role: { type: "string", description: 'Active role: "host", "guest", or null' },
2022
- isConnected: { type: "boolean", description: "True if connected to signaling server" },
2023
- peerCount: { type: "number", description: "Number of connected peers in the room" },
2021
+ session: {
2022
+ type: "object",
2023
+ description: "The active session (id is null when there is none)",
2024
+ properties: {
2025
+ id: { type: ["string", "null"], description: "Active session ID" },
2026
+ name: { type: ["string", "null"], description: "Session name" },
2027
+ role: { type: ["string", "null"], description: '"host", "guest", or null' },
2028
+ isConnected: { type: "boolean", description: "True if connected to the signaling server" },
2029
+ userId: { type: ["string", "null"], description: "Your user id in the session" },
2030
+ status: { type: "string", description: 'Guest only: "pending_approval" or "approved" (hosted endpoint)' }
2031
+ }
2032
+ },
2033
+ peerCount: { type: "number", description: "Number of connected sockets in the room (hosted endpoint)" },
2034
+ participants: {
2035
+ type: "array",
2036
+ description: "Session members (local daemon): pass userId to srift_kick_user to remove one",
2037
+ items: {
2038
+ type: "object",
2039
+ properties: {
2040
+ userId: { type: "string" },
2041
+ username: { type: "string" },
2042
+ isHost: { type: "boolean" },
2043
+ online: { type: "boolean" }
2044
+ }
2045
+ }
2046
+ },
2024
2047
  pendingJoins: {
2025
2048
  type: "array",
2026
2049
  description: "List of pending guest join requests waiting for host approval",
@@ -2114,7 +2137,7 @@ var init_core = __esm({
2114
2137
  {
2115
2138
  name: "srift_kick_user",
2116
2139
  title: "Kick Peer",
2117
- description: "Host-only. Disconnect and ban a peer from the active room.",
2140
+ description: "Host-only. Disconnect and remove a peer from the active room. Get the peer's userId from srift_session_status (participants).",
2118
2141
  inputSchema: {
2119
2142
  type: "object",
2120
2143
  properties: {
@@ -2382,6 +2405,8 @@ var init_core = __esm({
2382
2405
  type: "object",
2383
2406
  properties: {
2384
2407
  ok: { type: "boolean", description: "True if the diagnostic ran successfully (independent of what it found)" },
2408
+ base: { type: "string", description: "The SRIFT server that was probed" },
2409
+ checkedAt: { type: "string", description: "When the probe ran (ISO 8601); cached results show an older time" },
2385
2410
  verdict: { type: "string", description: '"ok", "degraded", or "blocked"' },
2386
2411
  rungs: {
2387
2412
  type: "array",
@@ -2527,8 +2552,13 @@ session-management, and get-company-info, \`skills/get\` to fetch one by URI, an
2527
2552
  ## Security model
2528
2553
  - AES-256-GCM, 12-byte random IV per message.
2529
2554
  - PBKDF2-SHA256, 100,000 iterations.
2530
- - All keys derive locally from sessionId (+ optional roomSecret). Server never sees them.
2531
- - File chunks transit P2P (WebTorrent for >10MB, WebSocket-relay fallback otherwise).
2555
+ - All keys derive locally from sessionId (+ optional roomSecret). The key is never sent to the
2556
+ server, but the server sees the sessionId: without a roomSecret the key is derivable from it.
2557
+ Pass roomSecret to srift_start_session / srift_join_session to make the key participant-only.
2558
+ - Quick-share links are end-to-end encrypted only with encrypt: true (key in the #k= fragment);
2559
+ otherwise TLS in transit only, zero retention.
2560
+ - The CLI daemon moves file chunks over the AES-256-GCM WebSocket relay (browsers use WebRTC for
2561
+ files >5 MB, with the relay as fallback).
2532
2562
 
2533
2563
  ## Local daemon vs hosted MCP
2534
2564
  If you are talking to the hosted endpoint (https://srift.app/mcp) instead of a local
@@ -2545,6 +2575,8 @@ for full file-transfer + chat capability.
2545
2575
 
2546
2576
  // ../../cli/mcp.ts
2547
2577
  import http3 from "http";
2578
+ import fs6 from "fs";
2579
+ import path5 from "path";
2548
2580
  function callDaemon(endpoint, method, body) {
2549
2581
  const emb = embeddedCall(method, endpoint, body);
2550
2582
  if (emb) return emb.then((r) => {
@@ -2595,43 +2627,60 @@ async function callTool(name, args) {
2595
2627
  sessionName: args.sessionName,
2596
2628
  roomSecret: args.roomSecret
2597
2629
  });
2598
- return `Session created.
2630
+ const joinUrl = res.joinUrl || `https://srift.app/join-session?id=${res.sessionId}`;
2631
+ return {
2632
+ text: `Session created.
2599
2633
  Session ID: ${res.sessionId}
2600
- Share URL: https://srift.app/join-session?id=${res.sessionId}
2601
- You are the HOST. Approve incoming joins with srift_approve_join.`;
2634
+ Share URL: ${joinUrl}
2635
+ You are the HOST. Approve incoming joins with srift_approve_join.`,
2636
+ structured: { sessionId: res.sessionId, url: joinUrl, role: "host" }
2637
+ };
2602
2638
  }
2603
2639
  case "srift_join_session": {
2604
2640
  const res = await callDaemon("/session/join", "POST", args);
2605
- return `Join requested for session ${args.sessionId}. Status: ${res.success ? "awaiting host approval" : "failed"}.`;
2641
+ return {
2642
+ text: `Join requested for session ${args.sessionId}. Status: ${res.success ? "awaiting host approval" : "failed"}.`,
2643
+ structured: { sessionId: args.sessionId, status: res.success ? "awaiting_approval" : "failed" }
2644
+ };
2606
2645
  }
2607
2646
  case "srift_session_status": {
2608
2647
  const res = await callDaemon("/status", "GET");
2609
- return JSON.stringify({ session: res.session, pendingJoins: res.pendingJoins }, null, 2);
2648
+ return JSON.stringify({ session: res.session, pendingJoins: res.pendingJoins, participants: res.participants || [] }, null, 2);
2610
2649
  }
2611
2650
  case "srift_close_session":
2612
2651
  await callDaemon("/session/close", "POST");
2613
- return "Session closed. Keys flushed.";
2652
+ return { text: "Session closed. Keys flushed.", structured: { success: true } };
2614
2653
  case "srift_approve_join":
2615
2654
  await callDaemon("/session/approve", "POST", args);
2616
- return `Approved ${args.tempUserId}.`;
2655
+ return { text: `Approved ${args.tempUserId}.`, structured: { success: true, tempUserId: args.tempUserId } };
2617
2656
  case "srift_reject_join":
2618
2657
  await callDaemon("/session/reject", "POST", args);
2619
- return `Rejected ${args.tempUserId}.`;
2658
+ return { text: `Rejected ${args.tempUserId}.`, structured: { success: true, tempUserId: args.tempUserId } };
2620
2659
  case "srift_kick_user":
2621
2660
  await callDaemon("/session/kick", "POST", args);
2622
- return `Kicked ${args.userId}.`;
2661
+ return { text: `Kicked ${args.userId}.`, structured: { success: true, userId: args.userId } };
2623
2662
  case "srift_send_file": {
2624
2663
  const res = await callDaemon("/send", "POST", args);
2625
- return `File offered.
2664
+ return {
2665
+ text: `File offered.
2626
2666
  File ID: ${res.fileId}
2627
- The peer must call srift_accept_transfer with this fileId.`;
2667
+ The peer must call srift_accept_transfer with this fileId.`,
2668
+ structured: { fileId: res.fileId, fileName: path5.basename(String(args.filePath || "")), fileSize: (() => {
2669
+ try {
2670
+ return fs6.statSync(args.filePath).size;
2671
+ } catch {
2672
+ return void 0;
2673
+ }
2674
+ })() }
2675
+ };
2628
2676
  }
2629
2677
  case "srift_accept_transfer":
2630
2678
  await callDaemon("/receive", "POST", args);
2631
- return `Transfer ${args.fileId} accepted. Downloading\u2026`;
2679
+ return { text: `Transfer ${args.fileId} accepted. Downloading\u2026`, structured: { success: true, fileId: args.fileId } };
2632
2680
  case "srift_list_transfers": {
2633
2681
  const res = await callDaemon("/status", "GET");
2634
- return JSON.stringify(res.activeTransfers || [], null, 2);
2682
+ const transfers = res.activeTransfers || [];
2683
+ return { text: JSON.stringify(transfers, null, 2), structured: { transfers } };
2635
2684
  }
2636
2685
  case "srift_quick_share": {
2637
2686
  const multi = Array.isArray(args?.filePaths) && args.filePaths.length > 0;
@@ -2725,10 +2774,11 @@ The peer must call srift_accept_transfer with this fileId.`;
2725
2774
  }
2726
2775
  case "srift_send_chat":
2727
2776
  await callDaemon("/chat/send", "POST", args);
2728
- return `Sent (E2EE): "${args.message}"`;
2777
+ return { text: `Sent (E2EE): "${args.message}"`, structured: { success: true, timestamp: (/* @__PURE__ */ new Date()).toISOString() } };
2729
2778
  case "srift_chat_history": {
2730
2779
  const res = await callDaemon("/chat/history", "GET");
2731
- return JSON.stringify(res, null, 2);
2780
+ const messages = Array.isArray(res) ? res : res?.messages || [];
2781
+ return { text: JSON.stringify(res, null, 2), structured: { messages } };
2732
2782
  }
2733
2783
  case "srift_read_state": {
2734
2784
  const res = await callDaemon("/state", "GET");
@@ -2774,7 +2824,7 @@ var init_mcp = __esm({
2774
2824
  SERVER_INFO = {
2775
2825
  name: "srift-mcp-server",
2776
2826
  title: "SRIFT Secure P2P File Transfer",
2777
- version: "4.0.0"
2827
+ version: "4.1.0"
2778
2828
  };
2779
2829
  localDaemonBackend = { callTool, readResource };
2780
2830
  handleMcpMessage = createMcpHandler({
@@ -2786,8 +2836,8 @@ var init_mcp = __esm({
2786
2836
  });
2787
2837
 
2788
2838
  // ../../cli/pack.ts
2789
- import fs6 from "fs";
2790
- import path5 from "path";
2839
+ import fs7 from "fs";
2840
+ import path6 from "path";
2791
2841
  import zlib from "zlib";
2792
2842
  import { pipeline } from "stream/promises";
2793
2843
  import { Readable } from "stream";
@@ -2811,16 +2861,16 @@ function normFileMode(m) {
2811
2861
  }
2812
2862
  function listFiles(root, excludes = DEFAULT_EXCLUDES) {
2813
2863
  const pats = excludes.map(globToRegExp);
2814
- const excluded = (rel) => pats.some((p) => p.test(rel) || p.test(path5.posix.basename(rel)));
2864
+ const excluded = (rel) => pats.some((p) => p.test(rel) || p.test(path6.posix.basename(rel)));
2815
2865
  const out = [];
2816
2866
  const walk = (dirAbs, dirRel) => {
2817
- const ents = fs6.readdirSync(dirAbs, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
2867
+ const ents = fs7.readdirSync(dirAbs, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
2818
2868
  for (const e of ents) {
2819
2869
  const rel = dirRel ? `${dirRel}/${e.name}` : e.name;
2820
2870
  if (excluded(rel)) continue;
2821
- const abs = path5.join(dirAbs, e.name);
2871
+ const abs = path6.join(dirAbs, e.name);
2822
2872
  if (e.isSymbolicLink()) continue;
2823
- const st = fs6.lstatSync(abs);
2873
+ const st = fs7.lstatSync(abs);
2824
2874
  if (e.isDirectory()) {
2825
2875
  out.push({ abs, rel, size: 0, mode: 493, mtime: Math.floor(st.mtimeMs / 1e3), dir: true });
2826
2876
  walk(abs, rel);
@@ -2882,7 +2932,7 @@ async function* tarStream(entries, prefix) {
2882
2932
  yield* entryHeaders(e, prefix);
2883
2933
  if (e.dir) continue;
2884
2934
  let written = 0;
2885
- const stream = fs6.createReadStream(e.abs);
2935
+ const stream = fs7.createReadStream(e.abs);
2886
2936
  stream.on("error", () => {
2887
2937
  });
2888
2938
  try {
@@ -2903,17 +2953,17 @@ async function* tarStream(entries, prefix) {
2903
2953
  yield Buffer.alloc(BLOCK * 2);
2904
2954
  }
2905
2955
  async function packDirectory(dir, outFile, excludes = DEFAULT_EXCLUDES) {
2906
- const root = path5.resolve(dir);
2956
+ const root = path6.resolve(dir);
2907
2957
  const entries = listFiles(root, excludes);
2908
2958
  const files = entries.filter((e) => !e.dir);
2909
2959
  if (!files.length) throw new Error(`Nothing to share in ${root} (empty, or everything was excluded)`);
2910
- const prefix = path5.basename(root) || "folder";
2911
- await pipeline(Readable.from(tarStream(entries, prefix)), zlib.createGzip({ level: 6 }), fs6.createWriteStream(outFile, { mode: 384 }));
2960
+ const prefix = path6.basename(root) || "folder";
2961
+ await pipeline(Readable.from(tarStream(entries, prefix)), zlib.createGzip({ level: 6 }), fs7.createWriteStream(outFile, { mode: 384 }));
2912
2962
  return { files: files.length, bytes: files.reduce((a, e) => a + e.size, 0) };
2913
2963
  }
2914
2964
  function readable(p) {
2915
2965
  try {
2916
- fs6.accessSync(p, fs6.constants.R_OK);
2966
+ fs7.accessSync(p, fs7.constants.R_OK);
2917
2967
  return null;
2918
2968
  } catch (e) {
2919
2969
  return e?.code || "unreadable";
@@ -2927,7 +2977,7 @@ function listPaths(paths, excludes = DEFAULT_EXCLUDES, skipped = []) {
2927
2977
  used.add(name.toLowerCase());
2928
2978
  return name;
2929
2979
  }
2930
- const ext = path5.extname(name);
2980
+ const ext = path6.extname(name);
2931
2981
  const stem = name.slice(0, name.length - ext.length);
2932
2982
  for (let i = 2; ; i++) {
2933
2983
  const c = `${stem} (${i})${ext}`;
@@ -2938,10 +2988,10 @@ function listPaths(paths, excludes = DEFAULT_EXCLUDES, skipped = []) {
2938
2988
  }
2939
2989
  };
2940
2990
  for (const p of paths) {
2941
- const abs = path5.resolve(p);
2991
+ const abs = path6.resolve(p);
2942
2992
  let st;
2943
2993
  try {
2944
- st = fs6.statSync(abs);
2994
+ st = fs7.statSync(abs);
2945
2995
  } catch (e) {
2946
2996
  skipped.push({ path: abs, reason: e?.code === "ENOENT" ? "not found" : e?.code || "stat failed" });
2947
2997
  continue;
@@ -2954,7 +3004,7 @@ function listPaths(paths, excludes = DEFAULT_EXCLUDES, skipped = []) {
2954
3004
  skipped.push({ path: abs, reason: e?.code || "unreadable folder" });
2955
3005
  continue;
2956
3006
  }
2957
- const name = unique(path5.basename(abs) || "item");
3007
+ const name = unique(path6.basename(abs) || "item");
2958
3008
  out.push({ abs, rel: name, size: 0, mode: 493, mtime: Math.floor(st.mtimeMs / 1e3), dir: true });
2959
3009
  for (const e of inner) {
2960
3010
  const why = e.dir ? null : readable(e.abs);
@@ -2970,7 +3020,7 @@ function listPaths(paths, excludes = DEFAULT_EXCLUDES, skipped = []) {
2970
3020
  skipped.push({ path: abs, reason: why });
2971
3021
  continue;
2972
3022
  }
2973
- out.push({ abs, rel: unique(path5.basename(abs) || "item"), size: st.size, mode: normFileMode(st.mode), mtime: Math.floor(st.mtimeMs / 1e3), dir: false });
3023
+ out.push({ abs, rel: unique(path6.basename(abs) || "item"), size: st.size, mode: normFileMode(st.mode), mtime: Math.floor(st.mtimeMs / 1e3), dir: false });
2974
3024
  } else {
2975
3025
  skipped.push({ path: abs, reason: "not a regular file or folder" });
2976
3026
  }
@@ -2987,7 +3037,7 @@ async function packPaths(paths, outFile, prefix, excludes = DEFAULT_EXCLUDES) {
2987
3037
  throw new Error(`Nothing to share${skipped.length ? ` \u2014 skipped: ${skipped.map((x) => `${x.path} (${x.reason})`).join(", ")}` : " (empty folders, or everything was excluded)"}`);
2988
3038
  }
2989
3039
  try {
2990
- await pipeline(Readable.from(tarStream(entries, prefix)), zlib.createGzip({ level: 6 }), fs6.createWriteStream(outFile, { mode: 384 }));
3040
+ await pipeline(Readable.from(tarStream(entries, prefix)), zlib.createGzip({ level: 6 }), fs7.createWriteStream(outFile, { mode: 384 }));
2991
3041
  return { files: files.length, bytes: files.reduce((a, e) => a + e.size, 0), skipped };
2992
3042
  } catch (err) {
2993
3043
  const bad = err?.packEntry;
@@ -3028,10 +3078,10 @@ import express from "express";
3028
3078
  import cors from "cors";
3029
3079
  import { WebSocket as WebSocket2 } from "ws";
3030
3080
  import { webcrypto } from "crypto";
3031
- import fs7 from "fs";
3081
+ import fs8 from "fs";
3032
3082
  import http4 from "http";
3033
3083
  import { Duplex } from "stream";
3034
- import path6 from "path";
3084
+ import path7 from "path";
3035
3085
  import os4 from "os";
3036
3086
  import { v4 as uuidv4 } from "uuid";
3037
3087
  import dotenv from "dotenv";
@@ -3162,6 +3212,46 @@ async function decrypt(encB64) {
3162
3212
  const decrypted = await crypto5.subtle.decrypt({ name: "AES-GCM", iv: iv2 }, encryptionKey, ciphertext);
3163
3213
  return new TextDecoder().decode(decrypted);
3164
3214
  }
3215
+ function relayKey() {
3216
+ if (!session.id) return Promise.reject(new Error("No active session"));
3217
+ if (relayKeyCache?.sessionId !== session.id) relayKeyCache = { sessionId: session.id, key: deriveKey(session.id) };
3218
+ return relayKeyCache.key;
3219
+ }
3220
+ function relayAad(fileId, chunkIndex, totalChunks) {
3221
+ return new TextEncoder().encode(`${RELAY_ENC}|${fileId}|${chunkIndex}|${totalChunks}`);
3222
+ }
3223
+ async function sealRelayChunk(plain, fileId, chunkIndex, totalChunks) {
3224
+ const iv2 = crypto5.getRandomValues(new Uint8Array(IV_LEN));
3225
+ const ct = new Uint8Array(await crypto5.subtle.encrypt(
3226
+ { name: "AES-GCM", iv: iv2, additionalData: relayAad(fileId, chunkIndex, totalChunks) },
3227
+ await relayKey(),
3228
+ plain
3229
+ ));
3230
+ const out = new Uint8Array(IV_LEN + ct.length);
3231
+ out.set(iv2);
3232
+ out.set(ct, IV_LEN);
3233
+ return Array.from(out);
3234
+ }
3235
+ async function openRelayChunk(p) {
3236
+ if (p.enc === void 0 || p.enc === null) {
3237
+ if (typeof p.chunk === "string") return Buffer.from(p.chunk, "base64");
3238
+ if (Array.isArray(p.chunk)) return Buffer.from(p.chunk);
3239
+ throw new Error("relay chunk is neither base64 nor a byte array");
3240
+ }
3241
+ if (p.enc !== RELAY_ENC) throw new Error(`unsupported relay encryption scheme: ${String(p.enc)}`);
3242
+ if (!Array.isArray(p.chunk)) throw new Error("sealed relay chunk is not a byte array");
3243
+ const data = Uint8Array.from(p.chunk);
3244
+ if (data.length < IV_LEN + 16) throw new Error("sealed relay chunk too short");
3245
+ try {
3246
+ return Buffer.from(await crypto5.subtle.decrypt(
3247
+ { name: "AES-GCM", iv: data.slice(0, IV_LEN), additionalData: relayAad(p.fileId, p.chunkIndex, p.totalChunks) },
3248
+ await relayKey(),
3249
+ data.slice(IV_LEN)
3250
+ ));
3251
+ } catch {
3252
+ throw new Error(`relay chunk ${p.chunkIndex} of ${p.fileId} failed authentication (wrong session key or tampered data)`);
3253
+ }
3254
+ }
3165
3255
  function getTrackers(sessionId) {
3166
3256
  const list = [];
3167
3257
  try {
@@ -3200,13 +3290,13 @@ function writeStateFile() {
3200
3290
  })),
3201
3291
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
3202
3292
  };
3203
- fs7.writeFileSync(STATE_PATH, JSON.stringify(stateData, null, 2), "utf-8");
3293
+ fs8.writeFileSync(STATE_PATH, JSON.stringify(stateData, null, 2), "utf-8");
3204
3294
  }
3205
3295
  function pruneStaleStateOnStartup() {
3206
- if (!fs7.existsSync(STATE_PATH)) return;
3296
+ if (!fs8.existsSync(STATE_PATH)) return;
3207
3297
  let prunedCount = 0;
3208
3298
  try {
3209
- const raw = fs7.readFileSync(STATE_PATH, "utf-8");
3299
+ const raw = fs8.readFileSync(STATE_PATH, "utf-8");
3210
3300
  const parsed = JSON.parse(raw);
3211
3301
  const now = Date.now();
3212
3302
  const lastUpdatedMs = parsed?.lastUpdated ? Date.parse(parsed.lastUpdated) : NaN;
@@ -3225,7 +3315,7 @@ function pruneStaleStateOnStartup() {
3225
3315
  if (sessionStale || freshTransfers.length !== transfers.length) {
3226
3316
  parsed.activeTransfers = freshTransfers;
3227
3317
  parsed.lastUpdated = (/* @__PURE__ */ new Date()).toISOString();
3228
- fs7.writeFileSync(STATE_PATH, JSON.stringify(parsed, null, 2), "utf-8");
3318
+ fs8.writeFileSync(STATE_PATH, JSON.stringify(parsed, null, 2), "utf-8");
3229
3319
  }
3230
3320
  if (prunedCount > 0) {
3231
3321
  console.log(
@@ -3235,7 +3325,7 @@ function pruneStaleStateOnStartup() {
3235
3325
  } catch (err) {
3236
3326
  console.warn("[DAEMON] Stale state file was unreadable, removing it:", err?.message || err);
3237
3327
  try {
3238
- fs7.unlinkSync(STATE_PATH);
3328
+ fs8.unlinkSync(STATE_PATH);
3239
3329
  } catch {
3240
3330
  }
3241
3331
  }
@@ -3256,8 +3346,11 @@ function connectWebSocket(wsTargetUrl) {
3256
3346
  }
3257
3347
  csrfToken = null;
3258
3348
  console.log(`[DAEMON] Connecting to Signaler WS: ${wsTargetUrl}`);
3259
- wsConn = new WebSocket2(wsTargetUrl, { agent: agentFor(wsTargetUrl) });
3260
- wsConn.on("open", () => {
3349
+ const sock = new WebSocket2(wsTargetUrl, { agent: agentFor(wsTargetUrl) });
3350
+ wsConn = sock;
3351
+ const current = () => wsConn === sock;
3352
+ sock.on("open", () => {
3353
+ if (!current()) return;
3261
3354
  console.log("[DAEMON] WebSocket connection open");
3262
3355
  session.isConnected = true;
3263
3356
  writeStateFile();
@@ -3295,7 +3388,8 @@ function connectWebSocket(wsTargetUrl) {
3295
3388
  }
3296
3389
  }, 3e4);
3297
3390
  });
3298
- wsConn.on("message", async (dataStr) => {
3391
+ sock.on("message", async (dataStr) => {
3392
+ if (!current()) return;
3299
3393
  try {
3300
3394
  const msg = JSON.parse(dataStr);
3301
3395
  const { type, payload } = msg;
@@ -3328,6 +3422,30 @@ function connectWebSocket(wsTargetUrl) {
3328
3422
  case "heartbeat_ack":
3329
3423
  lastHeartbeatAckAt = Date.now();
3330
3424
  break;
3425
+ case "user_list": {
3426
+ const users = Array.isArray(payload?.users) ? payload.users : [];
3427
+ participants = users.map((u) => ({ userId: String(u.id), username: String(u.username || ""), isHost: !!u.is_host, online: !!u.isOnline }));
3428
+ broadcastSSE("participants", { participants });
3429
+ if (payload?.messageId) {
3430
+ try {
3431
+ sock.send(JSON.stringify({ type: "user_list_ack", payload: { messageId: payload.messageId } }));
3432
+ } catch {
3433
+ }
3434
+ }
3435
+ break;
3436
+ }
3437
+ case "kicked_from_session":
3438
+ console.log(`[DAEMON] Kicked from session: ${payload?.reason || ""}`);
3439
+ sessionTerminated = true;
3440
+ terminationReason = payload?.reason || "You were removed from the session by the host";
3441
+ session.isConnected = false;
3442
+ session.userId = null;
3443
+ csrfToken = null;
3444
+ pendingJoins = [];
3445
+ participants = [];
3446
+ writeStateFile();
3447
+ broadcastSSE("session_terminated", { reason: terminationReason });
3448
+ break;
3331
3449
  case "session_deleted":
3332
3450
  console.log(`[DAEMON] Session deleted by host: ${payload.reason || ""}`);
3333
3451
  sessionTerminated = true;
@@ -3335,6 +3453,7 @@ function connectWebSocket(wsTargetUrl) {
3335
3453
  session = { id: null, name: null, role: null, isConnected: false, userId: null };
3336
3454
  activeTransfers = [];
3337
3455
  pendingJoins = [];
3456
+ participants = [];
3338
3457
  encryptionKey = null;
3339
3458
  writeStateFile();
3340
3459
  broadcastSSE("session_terminated", { reason: terminationReason });
@@ -3351,6 +3470,7 @@ function connectWebSocket(wsTargetUrl) {
3351
3470
  session.isConnected = false;
3352
3471
  session.userId = null;
3353
3472
  pendingJoins = [];
3473
+ participants = [];
3354
3474
  writeStateFile();
3355
3475
  broadcastSSE("session_terminated", { reason: terminationReason });
3356
3476
  }
@@ -3405,14 +3525,41 @@ function connectWebSocket(wsTargetUrl) {
3405
3525
  break;
3406
3526
  case "file_accept":
3407
3527
  console.log(`[DAEMON] File offer accepted by peer! ID: ${payload.fileId}`);
3528
+ {
3529
+ const acceptedTx = activeTransfers.find((t) => t.fileId === payload.fileId);
3530
+ if (acceptedTx) acceptedTx.peerRelayEnc = payload.relayEnc === RELAY_ENC ? RELAY_ENC : void 0;
3531
+ }
3408
3532
  startUpload(payload.fileId, payload.userId);
3409
3533
  break;
3410
3534
  case "webtorrent_info_hash":
3411
3535
  handleWebTorrentInfoHash(payload);
3412
3536
  break;
3413
3537
  case "file_chunk":
3414
- handleIncomingChunk(payload);
3538
+ handleIncomingChunk(payload).catch((e) => {
3539
+ console.error(`[DAEMON] Relay chunk rejected: ${e?.message || e}`);
3540
+ const bad = activeTransfers.find((t) => t.fileId === payload?.fileId);
3541
+ if (bad) {
3542
+ bad.status = "error";
3543
+ writeStateFile();
3544
+ }
3545
+ try {
3546
+ sock.send(JSON.stringify({ type: "file_cancel_by_receiver", payload: { fileId: payload?.fileId } }));
3547
+ } catch {
3548
+ }
3549
+ });
3550
+ break;
3551
+ case "file_cancelled_by_receiver":
3552
+ case "file_cancelled_by_sender":
3553
+ case "file_cancelled_all": {
3554
+ const gone = activeTransfers.find((t) => t.fileId === payload?.fileId);
3555
+ activeUploads.delete(payload?.fileId);
3556
+ if (gone && gone.status !== "completed") {
3557
+ gone.status = "cancelled";
3558
+ writeStateFile();
3559
+ broadcastSSE("transfer_progress", { fileId: gone.fileId, fileName: gone.name, size: gone.size, bytesTransferred: gone.bytesTransferred, progress: gone.progress, status: "cancelled" });
3560
+ }
3415
3561
  break;
3562
+ }
3416
3563
  case "file_chunk_ack":
3417
3564
  handleChunkAck(payload);
3418
3565
  break;
@@ -3424,6 +3571,7 @@ function connectWebSocket(wsTargetUrl) {
3424
3571
  if (entry) {
3425
3572
  entry.token = payload.token;
3426
3573
  entry.downloadUrl = payload.downloadUrl;
3574
+ if (typeof payload.claim === "string") entry.claim = payload.claim;
3427
3575
  pubsharesByToken.set(payload.token, entry);
3428
3576
  const resolver = pubshareRegResolvers.get(payload.fileId);
3429
3577
  if (resolver) {
@@ -3457,7 +3605,8 @@ function connectWebSocket(wsTargetUrl) {
3457
3605
  console.error("[DAEMON] Error processing WS message:", err);
3458
3606
  }
3459
3607
  });
3460
- wsConn.on("close", () => {
3608
+ sock.on("close", () => {
3609
+ if (!current()) return;
3461
3610
  console.log("[DAEMON] WebSocket connection closed");
3462
3611
  session.isConnected = false;
3463
3612
  csrfToken = null;
@@ -3477,7 +3626,8 @@ function connectWebSocket(wsTargetUrl) {
3477
3626
  }
3478
3627
  }, 3e3);
3479
3628
  });
3480
- wsConn.on("error", (err) => {
3629
+ sock.on("error", (err) => {
3630
+ if (!current()) return;
3481
3631
  console.error("[DAEMON] WebSocket error:", err);
3482
3632
  });
3483
3633
  }
@@ -3615,7 +3765,7 @@ async function startWebSocketUpload(fileId, targetUserId) {
3615
3765
  const tx = activeTransfers.find((t) => t.fileId === fileId);
3616
3766
  if (!tx || !tx.filePath) return;
3617
3767
  const chunkSize = 64 * 1024;
3618
- const stat = fs7.statSync(tx.filePath);
3768
+ const stat = fs8.statSync(tx.filePath);
3619
3769
  const totalChunks = Math.max(1, Math.ceil(stat.size / chunkSize));
3620
3770
  tx.status = "uploading";
3621
3771
  tx.startTime = Date.now();
@@ -3633,6 +3783,17 @@ async function startWebSocketUpload(fileId, targetUserId) {
3633
3783
  sendNextChunk(fileId);
3634
3784
  }
3635
3785
  function sendNextChunk(fileId) {
3786
+ sendNextChunkAsync(fileId).catch((e) => {
3787
+ console.error(`[DAEMON] Relay upload failed for ${fileId}: ${e?.message || e}`);
3788
+ activeUploads.delete(fileId);
3789
+ const failed = activeTransfers.find((t) => t.fileId === fileId);
3790
+ if (failed) {
3791
+ failed.status = "error";
3792
+ writeStateFile();
3793
+ }
3794
+ });
3795
+ }
3796
+ async function sendNextChunkAsync(fileId) {
3636
3797
  const upload = activeUploads.get(fileId);
3637
3798
  if (!upload) return;
3638
3799
  const tx = activeTransfers.find((t) => t.fileId === fileId);
@@ -3652,18 +3813,18 @@ function sendNextChunk(fileId) {
3652
3813
  return;
3653
3814
  }
3654
3815
  const start = upload.currentChunk * upload.chunkSize;
3655
- const stat = fs7.statSync(upload.filePath);
3816
+ const stat = fs8.statSync(upload.filePath);
3656
3817
  const end = Math.min(start + upload.chunkSize, stat.size);
3657
3818
  const buffer = Buffer.alloc(end - start);
3658
- const fd = fs7.openSync(upload.filePath, "r");
3659
- fs7.readSync(fd, buffer, 0, end - start, start);
3660
- fs7.closeSync(fd);
3661
- const chunkBase64 = buffer.toString("base64");
3819
+ const fd = fs8.openSync(upload.filePath, "r");
3820
+ fs8.readSync(fd, buffer, 0, end - start, start);
3821
+ fs8.closeSync(fd);
3822
+ const frame = tx.peerRelayEnc === RELAY_ENC ? { chunk: await sealRelayChunk(buffer, fileId, upload.currentChunk, upload.totalChunks), enc: RELAY_ENC } : { chunk: buffer.toString("base64") };
3662
3823
  wsConn?.send(JSON.stringify({
3663
3824
  type: "file_chunk",
3664
3825
  payload: {
3665
3826
  fileId,
3666
- chunk: chunkBase64,
3827
+ ...frame,
3667
3828
  chunkIndex: upload.currentChunk,
3668
3829
  totalChunks: upload.totalChunks,
3669
3830
  targetUserId: upload.targetUserId
@@ -3697,18 +3858,21 @@ function handleChunkAck(payload) {
3697
3858
  sendNextChunk(fileId);
3698
3859
  }
3699
3860
  }
3700
- function handleIncomingChunk(payload) {
3701
- const { fileId, chunk, chunkIndex, totalChunks, from } = payload;
3861
+ async function handleIncomingChunk(payload) {
3862
+ const { fileId, chunkIndex, totalChunks, from } = payload;
3702
3863
  const tx = activeTransfers.find((t) => t.fileId === fileId);
3703
3864
  if (!tx) return;
3865
+ const mode = payload.enc === void 0 || payload.enc === null ? "plain" : "sealed";
3866
+ if (tx.relayMode && tx.relayMode !== mode) throw new Error(`transfer ${fileId} mixed sealed and plaintext chunks`);
3867
+ tx.relayMode = mode;
3868
+ const chunkBuffer = await openRelayChunk(payload);
3704
3869
  tx.status = "downloading";
3705
3870
  tx.totalChunks = totalChunks;
3706
3871
  tx.peerId = from;
3707
- const chunkBuffer = Buffer.from(chunk, "base64");
3708
3872
  tx.bytesTransferred += chunkBuffer.length;
3709
3873
  tx.chunksCount++;
3710
- const chunkPath = path6.join(TEMP_DIR, `${fileId}_chunk_${chunkIndex}`);
3711
- fs7.writeFileSync(chunkPath, chunkBuffer);
3874
+ const chunkPath = path7.join(TEMP_DIR, `${fileId}_chunk_${chunkIndex}`);
3875
+ fs8.writeFileSync(chunkPath, chunkBuffer);
3712
3876
  tx.progress = tx.bytesTransferred / tx.size * 100;
3713
3877
  const elapsed = (Date.now() - tx.startTime) / 1e3;
3714
3878
  if (elapsed > 0) {
@@ -3736,10 +3900,10 @@ function handleFileComplete(payload) {
3736
3900
  const tx = activeTransfers.find((t) => t.fileId === fileId);
3737
3901
  if (!tx) return;
3738
3902
  const destDir = tx.saveDir || process.cwd();
3739
- const destPath = path6.join(destDir, tx.name);
3903
+ const destPath = path7.join(destDir, tx.name);
3740
3904
  try {
3741
- if (!fs7.existsSync(destDir)) {
3742
- fs7.mkdirSync(destDir, { recursive: true });
3905
+ if (!fs8.existsSync(destDir)) {
3906
+ fs8.mkdirSync(destDir, { recursive: true });
3743
3907
  }
3744
3908
  } catch (err) {
3745
3909
  console.error(`[DAEMON] Failed to create destination directory ${destDir}:`, err);
@@ -3747,7 +3911,7 @@ function handleFileComplete(payload) {
3747
3911
  writeStateFile();
3748
3912
  return;
3749
3913
  }
3750
- const writeStream = fs7.createWriteStream(destPath);
3914
+ const writeStream = fs8.createWriteStream(destPath);
3751
3915
  writeStream.on("error", (err) => {
3752
3916
  console.error(`[DAEMON] Write stream error for ${destPath}:`, err);
3753
3917
  tx.status = "error";
@@ -3782,12 +3946,12 @@ function handleFileComplete(payload) {
3782
3946
  });
3783
3947
  });
3784
3948
  for (let i = 0; i < tx.totalChunks; i++) {
3785
- const chunkPath = path6.join(TEMP_DIR, `${fileId}_chunk_${i}`);
3786
- if (fs7.existsSync(chunkPath)) {
3949
+ const chunkPath = path7.join(TEMP_DIR, `${fileId}_chunk_${i}`);
3950
+ if (fs8.existsSync(chunkPath)) {
3787
3951
  try {
3788
- const chunkBuf = fs7.readFileSync(chunkPath);
3952
+ const chunkBuf = fs8.readFileSync(chunkPath);
3789
3953
  writeStream.write(chunkBuf);
3790
- fs7.unlinkSync(chunkPath);
3954
+ fs8.unlinkSync(chunkPath);
3791
3955
  } catch (err) {
3792
3956
  console.error(`[DAEMON] Error reading/writing chunk ${i}:`, err);
3793
3957
  writeStream.emit("error", err);
@@ -3797,6 +3961,20 @@ function handleFileComplete(payload) {
3797
3961
  }
3798
3962
  writeStream.end();
3799
3963
  }
3964
+ async function waitSignalReady(maxMs = 8e3) {
3965
+ if (!session.id) return { status: 409, body: { success: false, error: "No active session. Call /session/start first." } };
3966
+ if (sessionTerminated) return { status: 410, body: { success: false, error: terminationReason || "The session ended." } };
3967
+ if (session.role === "guest" && !session.userId) {
3968
+ return { status: 409, body: { success: false, error: "Waiting for the host to approve your join request.", retryAfterMs: 2e3 } };
3969
+ }
3970
+ const ready = () => !!encryptionKey && !!csrfToken && wsConn?.readyState === WebSocket2.OPEN;
3971
+ for (let waited = 0; waited < maxMs && !ready() && session.id; waited += 100) {
3972
+ await new Promise((r) => setTimeout(r, 100));
3973
+ }
3974
+ if (ready()) return null;
3975
+ if (!encryptionKey) return { status: 409, body: { success: false, error: "Encryption key not ready. Session still initialising." } };
3976
+ return { status: 503, body: { success: false, error: "Signaling connection not ready (WebSocket is connecting). Retry in 1\u20132 seconds.", retryAfterMs: 1500 } };
3977
+ }
3800
3978
  async function registerPubshare(fileId, absPath, filename, size, mime, opts = {}, timeoutMs = 5e3) {
3801
3979
  if (!wsConn || wsConn.readyState !== WebSocket2.OPEN) {
3802
3980
  throw new Error("Signaling WebSocket not connected");
@@ -3819,7 +3997,8 @@ async function registerPubshare(fileId, absPath, filename, size, mime, opts = {}
3819
3997
  linkKey: opts.linkKey,
3820
3998
  tempFile: opts.tempFile,
3821
3999
  displayName: opts.displayName || filename,
3822
- prefixLen: opts.prefixLen
4000
+ prefixLen: opts.prefixLen,
4001
+ claim: opts.preferClaim
3823
4002
  };
3824
4003
  pubshares.set(fileId, entry);
3825
4004
  const ackP = new Promise((resolve, reject) => {
@@ -3844,6 +4023,7 @@ async function registerPubshare(fileId, absPath, filename, size, mime, opts = {}
3844
4023
  maxDownloads,
3845
4024
  expiresAt,
3846
4025
  preferToken: opts.preferToken || void 0,
4026
+ preferClaim: opts.preferToken && opts.preferClaim ? opts.preferClaim : void 0,
3847
4027
  encrypted: opts.encrypted ? true : void 0,
3848
4028
  prefixLen: opts.encrypted && opts.prefixLen ? opts.prefixLen : void 0
3849
4029
  }
@@ -3860,7 +4040,7 @@ async function reregisterPubsharesAfterReconnect() {
3860
4040
  pubsharesByToken.delete(entry.token);
3861
4041
  if (entry.tempFile) {
3862
4042
  try {
3863
- fs7.unlinkSync(entry.tempFile);
4043
+ fs8.unlinkSync(entry.tempFile);
3864
4044
  } catch {
3865
4045
  }
3866
4046
  }
@@ -3871,7 +4051,7 @@ async function reregisterPubsharesAfterReconnect() {
3871
4051
  pubsharesByToken.delete(entry.token);
3872
4052
  if (entry.tempFile) {
3873
4053
  try {
3874
- fs7.unlinkSync(entry.tempFile);
4054
+ fs8.unlinkSync(entry.tempFile);
3875
4055
  } catch {
3876
4056
  }
3877
4057
  }
@@ -3890,6 +4070,7 @@ async function reregisterPubsharesAfterReconnect() {
3890
4070
  maxDownloads: remainingMax,
3891
4071
  ttlMs: remainingTtl,
3892
4072
  preferToken: entry.token,
4073
+ preferClaim: entry.claim,
3893
4074
  encrypted: entry.encrypted,
3894
4075
  linkKey: entry.linkKey,
3895
4076
  tempFile: entry.tempFile,
@@ -3915,16 +4096,16 @@ async function handlePubsharePull(payload) {
3915
4096
  }
3916
4097
  let fd;
3917
4098
  try {
3918
- fd = fs7.openSync(entry.filePath, "r");
4099
+ fd = fs8.openSync(entry.filePath, "r");
3919
4100
  } catch (e) {
3920
4101
  throw new Error(e?.code === "ENOENT" ? "the shared file no longer exists on the sender" : `cannot read the shared file (${e?.code || e?.message})`);
3921
4102
  }
3922
4103
  try {
3923
- const size = fs7.fstatSync(fd).size;
4104
+ const size = fs8.fstatSync(fd).size;
3924
4105
  if (size !== entry.size) throw new Error("the shared file changed after the link was created");
3925
4106
  } catch (e) {
3926
4107
  try {
3927
- fs7.closeSync(fd);
4108
+ fs8.closeSync(fd);
3928
4109
  } catch {
3929
4110
  }
3930
4111
  throw e;
@@ -3944,7 +4125,7 @@ async function handlePubsharePull(payload) {
3944
4125
  }
3945
4126
  const len = Math.min(CHUNK, end - pos + 1);
3946
4127
  const buf = Buffer.alloc(len);
3947
- const got = fs7.readSync(fd, buf, 0, len, pos);
4128
+ const got = fs8.readSync(fd, buf, 0, len, pos);
3948
4129
  if (got !== len) throw new Error("the shared file changed while it was being sent");
3949
4130
  const dataB64 = buf.toString("base64");
3950
4131
  wsConn.send(JSON.stringify({
@@ -3968,7 +4149,7 @@ async function handlePubsharePull(payload) {
3968
4149
  }
3969
4150
  } finally {
3970
4151
  try {
3971
- fs7.closeSync(fd);
4152
+ fs8.closeSync(fd);
3972
4153
  } catch {
3973
4154
  }
3974
4155
  activePulls.delete(requestId);
@@ -4021,7 +4202,7 @@ async function ensureHostSessionOnce(sessionName) {
4021
4202
  }
4022
4203
  async function shareViaRelay(absPath, stat, reqBody, encrypt2) {
4023
4204
  await ensureHostSession(reqBody.sessionName);
4024
- const filename = reqBody.name || path6.basename(absPath);
4205
+ const filename = reqBody.name || path7.basename(absPath);
4025
4206
  const fileId = `cli_file_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
4026
4207
  let servePath = absPath;
4027
4208
  let serveSize = stat.size;
@@ -4031,13 +4212,13 @@ async function shareViaRelay(absPath, stat, reqBody, encrypt2) {
4031
4212
  if (encrypt2) {
4032
4213
  const key = newLinkKey();
4033
4214
  linkKey = b64url(key);
4034
- tempFile = path6.join(TEMP_DIR, `${fileId}.sre1`);
4215
+ tempFile = path7.join(TEMP_DIR, `${fileId}.sre1`);
4035
4216
  const meta = { name: filename, size: stat.size, mime: "application/octet-stream" };
4036
- const fd = fs7.openSync(tempFile, "w", 384);
4217
+ const fd = fs8.openSync(tempFile, "w", 384);
4037
4218
  try {
4038
- for await (const part of encryptFile(absPath, key, meta, { password: reqBody.password })) fs7.writeSync(fd, part);
4219
+ for await (const part of encryptFile(absPath, key, meta, { password: reqBody.password })) fs8.writeSync(fd, part);
4039
4220
  } finally {
4040
- fs7.closeSync(fd);
4221
+ fs8.closeSync(fd);
4041
4222
  }
4042
4223
  servePath = tempFile;
4043
4224
  serveSize = encryptedSize(stat.size, meta);
@@ -4069,7 +4250,7 @@ async function shareViaRelay(absPath, stat, reqBody, encrypt2) {
4069
4250
  if (reqBody._ownedPath) {
4070
4251
  if (encrypt2) {
4071
4252
  try {
4072
- fs7.unlinkSync(reqBody._ownedPath);
4253
+ fs8.unlinkSync(reqBody._ownedPath);
4073
4254
  } catch {
4074
4255
  }
4075
4256
  } else tempFile = reqBody._ownedPath;
@@ -4108,7 +4289,7 @@ async function shareViaRelay(absPath, stat, reqBody, encrypt2) {
4108
4289
  } catch (e) {
4109
4290
  if (tempFile) {
4110
4291
  try {
4111
- fs7.unlinkSync(tempFile);
4292
+ fs8.unlinkSync(tempFile);
4112
4293
  } catch {
4113
4294
  }
4114
4295
  }
@@ -4140,26 +4321,26 @@ function safeArchiveBase(name, fallback) {
4140
4321
  async function createShare(reqBody) {
4141
4322
  if (!reqBody?.filePath) throw Object.assign(new Error("filePath (or filePaths) required"), { status: 400 });
4142
4323
  assertRelayMode(reqBody);
4143
- const absPath = path6.resolve(reqBody.filePath);
4324
+ const absPath = path7.resolve(reqBody.filePath);
4144
4325
  let stat;
4145
4326
  try {
4146
- stat = fs7.statSync(absPath);
4327
+ stat = fs8.statSync(absPath);
4147
4328
  } catch {
4148
4329
  throw Object.assign(new Error(`File not found: ${absPath}`), { status: 404 });
4149
4330
  }
4150
4331
  if (stat.isDirectory()) {
4151
- const base = safeArchiveBase(path6.basename(absPath), "folder");
4152
- const out = path6.join(TEMP_DIR, `${uuidv4()}-${base}.tar.gz`);
4332
+ const base = safeArchiveBase(path7.basename(absPath), "folder");
4333
+ const out = path7.join(TEMP_DIR, `${uuidv4()}-${base}.tar.gz`);
4153
4334
  try {
4154
4335
  await packDirectory(absPath, out, excludesFor(reqBody));
4155
4336
  } catch (e) {
4156
4337
  try {
4157
- fs7.unlinkSync(out);
4338
+ fs8.unlinkSync(out);
4158
4339
  } catch {
4159
4340
  }
4160
4341
  throw Object.assign(new Error(e?.message || String(e)), { status: 400 });
4161
4342
  }
4162
- return shareViaRelay(out, fs7.statSync(out), { ...reqBody, name: reqBody.name || `${base}.tar.gz`, _ownedPath: out }, shareEncrypt(reqBody));
4343
+ return shareViaRelay(out, fs8.statSync(out), { ...reqBody, name: reqBody.name || `${base}.tar.gz`, _ownedPath: out }, shareEncrypt(reqBody));
4163
4344
  }
4164
4345
  if (!stat.isFile()) throw Object.assign(new Error(`Not a regular file or folder: ${absPath}`), { status: 400 });
4165
4346
  return shareViaRelay(absPath, stat, reqBody, shareEncrypt(reqBody));
@@ -4167,26 +4348,26 @@ async function createShare(reqBody) {
4167
4348
  async function createMultiShare(reqBody) {
4168
4349
  assertRelayMode(reqBody);
4169
4350
  const raw = Array.isArray(reqBody.filePaths) ? reqBody.filePaths : [];
4170
- const paths = Array.from(new Set(raw.filter((p) => typeof p === "string" && p.trim()).map((p) => path6.resolve(p))));
4351
+ const paths = Array.from(new Set(raw.filter((p) => typeof p === "string" && p.trim()).map((p) => path7.resolve(p))));
4171
4352
  if (!paths.length) throw Object.assign(new Error("filePaths must be a non-empty array of paths"), { status: 400 });
4172
4353
  if (paths.length > MAX_MULTI_PATHS) throw Object.assign(new Error(`Too many paths (${paths.length}); max ${MAX_MULTI_PATHS}. Share a folder instead.`), { status: 400 });
4173
- const missing = paths.filter((p) => !fs7.existsSync(p));
4354
+ const missing = paths.filter((p) => !fs8.existsSync(p));
4174
4355
  const bundle = reqBody.bundle !== false;
4175
4356
  if (bundle) {
4176
4357
  const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace(/[-:T]/g, "");
4177
4358
  const base = safeArchiveBase(reqBody.bundleName, `srift-${paths.length}-items-${stamp}`);
4178
- const out = path6.join(TEMP_DIR, `${uuidv4()}-${base}.tar.gz`);
4359
+ const out = path7.join(TEMP_DIR, `${uuidv4()}-${base}.tar.gz`);
4179
4360
  let packed;
4180
4361
  try {
4181
4362
  packed = await packPaths(paths, out, base, excludesFor(reqBody));
4182
4363
  } catch (e) {
4183
4364
  try {
4184
- fs7.unlinkSync(out);
4365
+ fs8.unlinkSync(out);
4185
4366
  } catch {
4186
4367
  }
4187
4368
  throw Object.assign(new Error(e?.message || String(e)), { status: 400 });
4188
4369
  }
4189
- const r = await shareViaRelay(out, fs7.statSync(out), { ...reqBody, name: `${base}.tar.gz`, _ownedPath: out }, shareEncrypt(reqBody));
4370
+ const r = await shareViaRelay(out, fs8.statSync(out), { ...reqBody, name: `${base}.tar.gz`, _ownedPath: out }, shareEncrypt(reqBody));
4190
4371
  if (packed.skipped.length) console.warn(`[DAEMON] bundle skipped ${packed.skipped.length} item(s): ${packed.skipped.map((x) => x.path).join(", ")}`);
4191
4372
  return { ...r, bundle: true, files: packed.files, bytes: packed.bytes, paths: paths.length, skipped: packed.skipped };
4192
4373
  }
@@ -4255,7 +4436,7 @@ function embeddedDispatch(method, pathname, body) {
4255
4436
  }
4256
4437
  });
4257
4438
  }
4258
- var _WebTorrentCtor, _webTorrentLoadError, SRIFT_HOME, EMBEDDED, STATE_PATH, logPath, logStream, logMessage, fmtLogArg, crypto5, PORT, DEFAULT_SIGNALER_URL, activeSignalerUrl, lastDiag, session, activeTransfers, chatHistory, pendingJoins, sseClients, wsConn, wsUrl, csrfToken, heartbeatInterval, lastHeartbeatAckAt, encryptionKey, sessionTerminated, terminationReason, activeTorrents, wtClient, pubshares, pubsharesByToken, activePulls, pubshareRegResolvers, TEMP_DIR, KDF_ITERATIONS, IV_LEN, STATE_STALE_MS, activeUploads, app, LOCAL_HOSTS, DAEMON_START_TIME, PACKAGE_VERSION, hostSessionInflight, MAX_MULTI_PATHS, MULTI_CONCURRENCY, embeddedReady, httpServer;
4439
+ var _WebTorrentCtor, _webTorrentLoadError, SRIFT_HOME, EMBEDDED, STATE_PATH, logPath, logStream, logMessage, fmtLogArg, crypto5, PORT, DEFAULT_SIGNALER_URL, activeSignalerUrl, lastDiag, session, activeTransfers, chatHistory, pendingJoins, participants, sseClients, wsConn, wsUrl, csrfToken, heartbeatInterval, lastHeartbeatAckAt, encryptionKey, sessionTerminated, terminationReason, activeTorrents, wtClient, pubshares, pubsharesByToken, activePulls, pubshareRegResolvers, TEMP_DIR, KDF_ITERATIONS, IV_LEN, RELAY_ENC, relayKeyCache, STATE_STALE_MS, activeUploads, app, LOCAL_HOSTS, DAEMON_START_TIME, PACKAGE_VERSION, hostSessionInflight, MAX_MULTI_PATHS, MULTI_CONCURRENCY, embeddedReady, httpServer;
4259
4440
  var init_daemon = __esm({
4260
4441
  "../../cli/daemon.ts"() {
4261
4442
  init_mcp();
@@ -4266,25 +4447,25 @@ var init_daemon = __esm({
4266
4447
  init_pack();
4267
4448
  _WebTorrentCtor = null;
4268
4449
  _webTorrentLoadError = null;
4269
- SRIFT_HOME = path6.join(os4.homedir(), ".srift");
4450
+ SRIFT_HOME = path7.join(os4.homedir(), ".srift");
4270
4451
  try {
4271
- fs7.mkdirSync(SRIFT_HOME, { recursive: true, mode: 448 });
4452
+ fs8.mkdirSync(SRIFT_HOME, { recursive: true, mode: 448 });
4272
4453
  try {
4273
- fs7.chmodSync(SRIFT_HOME, 448);
4454
+ fs8.chmodSync(SRIFT_HOME, 448);
4274
4455
  } catch {
4275
4456
  }
4276
4457
  } catch (e) {
4277
4458
  }
4278
4459
  EMBEDDED = process.env.SRIFT_DAEMON_EMBEDDED === "1";
4279
- STATE_PATH = path6.join(SRIFT_HOME, EMBEDDED ? "state-embedded.json" : "state.json");
4280
- logPath = path6.join(SRIFT_HOME, "daemon.log");
4460
+ STATE_PATH = path7.join(SRIFT_HOME, EMBEDDED ? "state-embedded.json" : "state.json");
4461
+ logPath = path7.join(SRIFT_HOME, "daemon.log");
4281
4462
  try {
4282
- if (fs7.statSync(logPath).size > 5 * 1024 * 1024) fs7.renameSync(logPath, `${logPath}.1`);
4463
+ if (fs8.statSync(logPath).size > 5 * 1024 * 1024) fs8.renameSync(logPath, `${logPath}.1`);
4283
4464
  } catch {
4284
4465
  }
4285
- logStream = fs7.createWriteStream(logPath, { flags: "a", mode: 384 });
4466
+ logStream = fs8.createWriteStream(logPath, { flags: "a", mode: 384 });
4286
4467
  try {
4287
- fs7.chmodSync(logPath, 384);
4468
+ fs8.chmodSync(logPath, 384);
4288
4469
  } catch {
4289
4470
  }
4290
4471
  logMessage = (level, message) => {
@@ -4331,6 +4512,7 @@ var init_daemon = __esm({
4331
4512
  activeTransfers = [];
4332
4513
  chatHistory = [];
4333
4514
  pendingJoins = [];
4515
+ participants = [];
4334
4516
  sseClients = [];
4335
4517
  wsConn = null;
4336
4518
  wsUrl = null;
@@ -4346,15 +4528,15 @@ var init_daemon = __esm({
4346
4528
  pubsharesByToken = /* @__PURE__ */ new Map();
4347
4529
  activePulls = /* @__PURE__ */ new Map();
4348
4530
  pubshareRegResolvers = /* @__PURE__ */ new Map();
4349
- TEMP_DIR = path6.join(SRIFT_HOME, "tmp");
4350
- if (!fs7.existsSync(TEMP_DIR)) {
4351
- fs7.mkdirSync(TEMP_DIR, { recursive: true, mode: 448 });
4531
+ TEMP_DIR = path7.join(SRIFT_HOME, "tmp");
4532
+ if (!fs8.existsSync(TEMP_DIR)) {
4533
+ fs8.mkdirSync(TEMP_DIR, { recursive: true, mode: 448 });
4352
4534
  }
4353
4535
  try {
4354
- for (const f of fs7.readdirSync(TEMP_DIR)) {
4536
+ for (const f of fs8.readdirSync(TEMP_DIR)) {
4355
4537
  if (f.endsWith(".sre1")) {
4356
4538
  try {
4357
- fs7.unlinkSync(path6.join(TEMP_DIR, f));
4539
+ fs8.unlinkSync(path7.join(TEMP_DIR, f));
4358
4540
  } catch {
4359
4541
  }
4360
4542
  }
@@ -4363,6 +4545,8 @@ var init_daemon = __esm({
4363
4545
  }
4364
4546
  KDF_ITERATIONS = 1e5;
4365
4547
  IV_LEN = 12;
4548
+ RELAY_ENC = "sgcm1";
4549
+ relayKeyCache = null;
4366
4550
  STATE_STALE_MS = 24 * 60 * 60 * 1e3;
4367
4551
  pruneStaleStateOnStartup();
4368
4552
  activeUploads = /* @__PURE__ */ new Map();
@@ -4381,7 +4565,7 @@ var init_daemon = __esm({
4381
4565
  app.use(cors({ origin: "*", maxAge: 600 }));
4382
4566
  app.use(express.json());
4383
4567
  DAEMON_START_TIME = Date.now();
4384
- PACKAGE_VERSION = "4.0.0";
4568
+ PACKAGE_VERSION = "4.1.0";
4385
4569
  app.get("/health", (req, res) => {
4386
4570
  res.json({
4387
4571
  ok: true,
@@ -4405,6 +4589,7 @@ var init_daemon = __esm({
4405
4589
  },
4406
4590
  activeTransfers,
4407
4591
  pendingJoins,
4592
+ participants,
4408
4593
  lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4409
4594
  });
4410
4595
  });
@@ -4432,7 +4617,7 @@ var init_daemon = __esm({
4432
4617
  wsUrl = data.wsUrl || `${activeSignalerUrl.replace(/^http/, "ws")}/ws`;
4433
4618
  if (wsUrl) connectWebSocket(wsUrl);
4434
4619
  writeStateFile();
4435
- res.json({ success: true, sessionId: data.sessionId });
4620
+ res.json({ success: true, sessionId: data.sessionId, joinUrl: `${activeSignalerUrl.replace(/\/+$/, "")}/join-session?id=${data.sessionId}` });
4436
4621
  } catch (err) {
4437
4622
  console.error("[DAEMON] Failed to start session:", err);
4438
4623
  res.status(500).json({ success: false, error: err.message });
@@ -4477,7 +4662,7 @@ var init_daemon = __esm({
4477
4662
  res.status(500).json({ success: false, error: err.message });
4478
4663
  }
4479
4664
  });
4480
- app.post("/session/approve", (req, res) => {
4665
+ app.post("/session/approve", async (req, res) => {
4481
4666
  const { tempUserId } = req.body;
4482
4667
  if (!tempUserId) {
4483
4668
  return res.status(400).json({ success: false, error: "tempUserId required" });
@@ -4495,7 +4680,13 @@ var init_daemon = __esm({
4495
4680
  payload: { sessionId: session.id, tempUserId }
4496
4681
  }));
4497
4682
  pendingJoins = pendingJoins.filter((j) => j.tempUserId !== tempUserId);
4498
- res.json({ success: true });
4683
+ const before = new Set(participants.filter((p) => p.online).map((p) => p.userId));
4684
+ const joined = () => participants.find((p) => p.online && !p.isHost && p.username === pending.username && !before.has(p.userId));
4685
+ for (let waited = 0; waited < 8e3 && !joined() && session.id; waited += 100) {
4686
+ await new Promise((r) => setTimeout(r, 100));
4687
+ }
4688
+ const member = joined();
4689
+ res.json({ success: true, joined: !!member, userId: member?.userId ?? null, username: pending.username });
4499
4690
  });
4500
4691
  app.post("/session/reject", (req, res) => {
4501
4692
  const { tempUserId, reason } = req.body;
@@ -4572,13 +4763,14 @@ var init_daemon = __esm({
4572
4763
  activeTransfers = [];
4573
4764
  chatHistory = [];
4574
4765
  pendingJoins = [];
4766
+ participants = [];
4575
4767
  csrfToken = null;
4576
4768
  encryptionKey = null;
4577
4769
  try {
4578
- if (fs7.existsSync(TEMP_DIR)) {
4579
- for (const f of fs7.readdirSync(TEMP_DIR)) {
4770
+ if (fs8.existsSync(TEMP_DIR)) {
4771
+ for (const f of fs8.readdirSync(TEMP_DIR)) {
4580
4772
  try {
4581
- fs7.unlinkSync(path6.join(TEMP_DIR, f));
4773
+ fs8.unlinkSync(path7.join(TEMP_DIR, f));
4582
4774
  } catch {
4583
4775
  }
4584
4776
  }
@@ -4594,14 +4786,16 @@ var init_daemon = __esm({
4594
4786
  if (!filePath) {
4595
4787
  return res.status(400).json({ success: false, error: "filePath is required" });
4596
4788
  }
4597
- const absPath = path6.resolve(filePath);
4598
- if (!fs7.existsSync(absPath)) {
4789
+ const absPath = path7.resolve(filePath);
4790
+ if (!fs8.existsSync(absPath)) {
4599
4791
  return res.status(404).json({ success: false, error: `File not found: ${absPath}` });
4600
4792
  }
4601
- const stat = fs7.statSync(absPath);
4602
- const filename = path6.basename(absPath);
4793
+ const stat = fs8.statSync(absPath);
4794
+ const filename = path7.basename(absPath);
4603
4795
  const fileId = `cli_file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
4604
4796
  const targetProtocol = webTorrentAllowed() && (protocol === "webtorrent" || stat.size > 10 * 1024 * 1024 && protocol !== "websocket") ? "webtorrent" : "websocket";
4797
+ const notReady = await waitSignalReady();
4798
+ if (notReady) return res.status(notReady.status).json(notReady.body);
4605
4799
  const newTx = {
4606
4800
  fileId,
4607
4801
  name: filename,
@@ -4647,13 +4841,14 @@ var init_daemon = __esm({
4647
4841
  if (!tx) {
4648
4842
  return res.status(404).json({ success: false, error: "No offer found for this fileId" });
4649
4843
  }
4650
- tx.saveDir = saveDir ? path6.resolve(saveDir) : process.cwd();
4844
+ tx.saveDir = saveDir ? path7.resolve(saveDir) : process.cwd();
4651
4845
  tx.status = "downloading";
4652
4846
  tx.startTime = Date.now();
4653
4847
  console.log(`[DAEMON] Accepting file offer ${fileId}, saving to ${tx.saveDir}`);
4654
4848
  wsConn?.send(JSON.stringify({
4655
4849
  type: "file_accept",
4656
- payload: { fileId }
4850
+ // relayEnc: this daemon decrypts sealed relay chunks (see openRelayChunk)
4851
+ payload: { fileId, relayEnc: RELAY_ENC }
4657
4852
  }));
4658
4853
  res.json({ success: true });
4659
4854
  });
@@ -4666,16 +4861,8 @@ var init_daemon = __esm({
4666
4861
  if (!session.id) {
4667
4862
  return res.status(409).json({ success: false, error: "No active session. Call /session/start first." });
4668
4863
  }
4669
- if (!encryptionKey) {
4670
- return res.status(409).json({ success: false, error: "Encryption key not ready. Session still initialising." });
4671
- }
4672
- if (!wsConn || wsConn.readyState !== 1) {
4673
- return res.status(503).json({
4674
- success: false,
4675
- error: "Signaling connection not ready (WebSocket is connecting). Retry in 1\u20132 seconds.",
4676
- retryAfterMs: 1500
4677
- });
4678
- }
4864
+ const notReady = await waitSignalReady();
4865
+ if (notReady) return res.status(notReady.status).json(notReady.body);
4679
4866
  const encContent = await encrypt(message);
4680
4867
  wsConn.send(JSON.stringify({
4681
4868
  type: "chat",
@@ -4698,8 +4885,8 @@ var init_daemon = __esm({
4698
4885
  });
4699
4886
  app.get("/state", (req, res) => {
4700
4887
  try {
4701
- if (fs7.existsSync(STATE_PATH)) {
4702
- const raw = fs7.readFileSync(STATE_PATH, "utf-8");
4888
+ if (fs8.existsSync(STATE_PATH)) {
4889
+ const raw = fs8.readFileSync(STATE_PATH, "utf-8");
4703
4890
  res.type("application/json").send(raw);
4704
4891
  } else {
4705
4892
  res.json({ session: { id: null, isConnected: false }, activeTransfers: [], lastUpdated: null });
@@ -4779,7 +4966,7 @@ var init_daemon = __esm({
4779
4966
  pubshares.delete(entry.fileId);
4780
4967
  if (entry.tempFile) {
4781
4968
  try {
4782
- fs7.unlinkSync(entry.tempFile);
4969
+ fs8.unlinkSync(entry.tempFile);
4783
4970
  } catch {
4784
4971
  }
4785
4972
  }
@@ -4891,7 +5078,7 @@ data: /mcp/messages
4891
5078
  servers: [{ url: `http://127.0.0.1:${PORT}` }],
4892
5079
  paths: {
4893
5080
  "/health": { get: { summary: "Liveness probe", description: "Returns {ok,version,uptime_ms,mcp,webrtc,webtorrent}", responses: { "200": { description: "OK" } } } },
4894
- "/status": { get: { summary: "Get session + transfers + pending joins", responses: { "200": { description: "OK" } } } },
5081
+ "/status": { get: { summary: "Get session, transfers, pending joins and participants (members with userId, for /session/kick)", responses: { "200": { description: "OK" } } } },
4895
5082
  "/state": { get: { summary: "Workspace state snapshot (.srift-state.json)", responses: { "200": { description: "OK" } } } },
4896
5083
  "/transfers": { get: { summary: "Live transfer list with speed and ETA. Optional ?fileId= to filter.", responses: { "200": { description: "OK" } } } },
4897
5084
  "/transfers/{fileId}": { get: { summary: "Per-transfer drill-down", parameters: [{ name: "fileId", in: "path", required: true, schema: { type: "string" } }], responses: { "200": { description: "OK" }, "404": { description: "Transfer not found" } } } },
@@ -4899,9 +5086,11 @@ data: /mcp/messages
4899
5086
  "/metrics": { get: { summary: "Prometheus-format counters (no auth)", responses: { "200": { description: "text/plain Prometheus format" } } } },
4900
5087
  "/logs": { get: { summary: "NDJSON daemon log tail. ?lines=N (default 100)", responses: { "200": { description: "application/x-ndjson" } } } },
4901
5088
  "/reset": { post: { summary: "Wipe all state and flush encryption keys", responses: { "200": { description: "{ok:true}" } } } },
4902
- "/session/start": { post: { summary: "Create new session (becomes host)", requestBody: { content: { "application/json": { schema: { type: "object", properties: { sessionName: { type: "string" }, roomSecret: { type: "string" } } } } } }, responses: { "200": { description: "{success:true,sessionId}" } } } },
5089
+ "/v1/diag": { get: { summary: "Network diagnosis (same as `srift doctor --json`); ?fresh=1 re-probes", responses: { "200": { description: "Diagnosis report" } } } },
5090
+ "/daemon/stop": { post: { summary: "Stop this daemon", responses: { "200": { description: "OK" } } } },
5091
+ "/session/start": { post: { summary: "Create new session (becomes host); returns sessionId and joinUrl", requestBody: { content: { "application/json": { schema: { type: "object", properties: { sessionName: { type: "string" }, roomSecret: { type: "string" } } } } } }, responses: { "200": { description: "{success:true,sessionId}" } } } },
4903
5092
  "/session/join": { post: { summary: "Join existing session", requestBody: { content: { "application/json": { schema: { type: "object", required: ["sessionId"], properties: { sessionId: { type: "string" }, username: { type: "string" }, roomSecret: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "Missing sessionId" }, "404": { description: "Session not found" } } } },
4904
- "/session/approve": { post: { summary: "Host approves a pending join request", requestBody: { content: { "application/json": { schema: { type: "object", required: ["tempUserId"], properties: { tempUserId: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "tempUserId required" }, "403": { description: "Not host" }, "404": { description: "No such pending request" } } } },
5093
+ "/session/approve": { post: { summary: "Host approves a pending join request; returns {success, joined, userId} once the guest is in the room (max 8 s)", requestBody: { content: { "application/json": { schema: { type: "object", required: ["tempUserId"], properties: { tempUserId: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "tempUserId required" }, "403": { description: "Not host" }, "404": { description: "No such pending request" } } } },
4905
5094
  "/session/reject": { post: { summary: "Host rejects a pending join request", requestBody: { content: { "application/json": { schema: { type: "object", required: ["tempUserId"], properties: { tempUserId: { type: "string" }, reason: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "tempUserId required" }, "403": { description: "Not host" } } } },
4906
5095
  "/session/kick": { post: { summary: "Host kicks a peer", requestBody: { content: { "application/json": { schema: { type: "object", required: ["userId"], properties: { userId: { type: "string" } } } } } }, responses: { "200": { description: "OK" }, "400": { description: "userId required" }, "403": { description: "Not host" } } } },
4907
5096
  "/session/close": { post: { summary: "Tear down the session and flush keys", responses: { "200": { description: "OK" } } } },
@@ -4982,8 +5171,8 @@ data: /mcp/messages
4982
5171
  app.get("/logs", (req, res) => {
4983
5172
  const lines = parseInt(req.query.lines || "100", 10);
4984
5173
  try {
4985
- if (!fs7.existsSync(logPath)) return res.json([]);
4986
- const content = fs7.readFileSync(logPath, "utf-8");
5174
+ if (!fs8.existsSync(logPath)) return res.json([]);
5175
+ const content = fs8.readFileSync(logPath, "utf-8");
4987
5176
  const all = content.split("\n").filter(Boolean);
4988
5177
  const tail = all.slice(-Math.min(lines, all.length));
4989
5178
  res.set("Content-Type", "application/x-ndjson");
@@ -5018,15 +5207,16 @@ data: /mcp/messages
5018
5207
  activeTransfers = [];
5019
5208
  chatHistory = [];
5020
5209
  pendingJoins = [];
5210
+ participants = [];
5021
5211
  csrfToken = null;
5022
5212
  encryptionKey = null;
5023
5213
  wsUrl = null;
5024
5214
  let cleanedChunks = 0;
5025
5215
  try {
5026
- if (fs7.existsSync(TEMP_DIR)) {
5027
- for (const f of fs7.readdirSync(TEMP_DIR)) {
5216
+ if (fs8.existsSync(TEMP_DIR)) {
5217
+ for (const f of fs8.readdirSync(TEMP_DIR)) {
5028
5218
  try {
5029
- fs7.unlinkSync(path6.join(TEMP_DIR, f));
5219
+ fs8.unlinkSync(path7.join(TEMP_DIR, f));
5030
5220
  cleanedChunks++;
5031
5221
  } catch {
5032
5222
  }
@@ -5088,16 +5278,17 @@ data: /mcp/messages
5088
5278
  activeTransfers = [];
5089
5279
  chatHistory = [];
5090
5280
  pendingJoins = [];
5281
+ participants = [];
5091
5282
  encryptionKey = null;
5092
5283
  wsUrl = null;
5093
5284
  writeStateFile();
5094
5285
  } catch {
5095
5286
  }
5096
5287
  try {
5097
- if (fs7.existsSync(TEMP_DIR)) {
5098
- for (const f of fs7.readdirSync(TEMP_DIR)) {
5288
+ if (fs8.existsSync(TEMP_DIR)) {
5289
+ for (const f of fs8.readdirSync(TEMP_DIR)) {
5099
5290
  try {
5100
- fs7.unlinkSync(path6.join(TEMP_DIR, f));
5291
+ fs8.unlinkSync(path7.join(TEMP_DIR, f));
5101
5292
  } catch {
5102
5293
  }
5103
5294
  }
@@ -5112,7 +5303,7 @@ data: /mcp/messages
5112
5303
  process.on("exit", () => {
5113
5304
  for (const e of pubshares.values()) if (e.tempFile) {
5114
5305
  try {
5115
- fs7.unlinkSync(e.tempFile);
5306
+ fs8.unlinkSync(e.tempFile);
5116
5307
  } catch {
5117
5308
  }
5118
5309
  }