zas-agent 0.3.0 → 0.5.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/CHANGELOG.md +54 -0
  2. package/README.md +39 -15
  3. package/dist/cli.js +2383 -828
  4. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -118,7 +118,12 @@ var RENAMES = {
118
118
  file_too_large: "file_too_big",
119
119
  read_only: "send_forbidden",
120
120
  unknown_channel: "grant_missing",
121
- no_account: "grant_missing"
121
+ no_account: "grant_missing",
122
+ // The Directo routes' words. `not_live` is an exchange that ended under the
123
+ // agent (expired, or cancelled from the receiving side); `direct_too_big`
124
+ // is the plan's Directo ceiling.
125
+ not_live: "direct_cancelled",
126
+ direct_too_big: "file_too_big"
122
127
  };
123
128
  var ZasError = class extends Error {
124
129
  constructor(code, status, message, retryAfterMs, serverCode) {
@@ -161,6 +166,15 @@ var SENTENCES = {
161
166
  read_forbidden: "This agent cannot read that channel.",
162
167
  direct_mode: "That channel is in Directo mode. Use zas_send_direct.",
163
168
  not_direct_mode: "That channel is not in Directo mode. Use zas_send_file.",
169
+ not_claimed: "Nobody received the file within ten minutes. The offer was withdrawn.",
170
+ no_offer: "Nobody offered a file through Directo while this call waited. Ask the owner to press Send Direct, then call again.",
171
+ offer_taken: "Another device received that file first.",
172
+ direct_cancelled: "The offer was cancelled from the other side.",
173
+ direct_failed: "The Directo transfer failed ({path}). Ask the owner before you use zas_send_direct_fallback or zas_receive_direct_fallback.",
174
+ direct_not_failed: "That job is not a Directo transfer that failed in flight.",
175
+ file_changed: "The file changed since the Directo offer. Send it again.",
176
+ webrtc_unavailable: "The WebRTC engine (node-datachannel) could not be loaded on this machine.",
177
+ fallback_unavailable: "Reliable delivery is not available right now. Try again later.",
164
178
  key_stale: "The channel key changed. The owner refreshes it by opening Zas.",
165
179
  quota_exceeded: "The account reached its storage limit.",
166
180
  rate_limited: "Too many sends in a row. Wait a moment.",
@@ -550,6 +564,17 @@ var ZasClient = class _ZasClient {
550
564
  if (!Array.isArray(parsed)) return [];
551
565
  return parsed.filter((row) => !!row && typeof row === "object" && "document" in row).map((row) => row.document);
552
566
  }
567
+ /** Firestore REST get of one document, `null` when it is not there. The
568
+ * rules decide what an agent may read; here that is its own Directo offer,
569
+ * which an offer's sender may read without a grant on the channel. */
570
+ async firestoreGet(path) {
571
+ const base = _ZasClient.firestoreBase(this.identity.firestore_project);
572
+ const res = await this.authed(`${base}/${path}`, "GET", void 0, {});
573
+ const parsed = await readBody2(res);
574
+ if (res.status === 404) return null;
575
+ if (!res.ok) throw errorFromResponse(res.status, parsed);
576
+ return parsed;
577
+ }
553
578
  /** The web app's public config value. It identifies the project, it is not a secret. */
554
579
  static apiKey() {
555
580
  return process.env.ZAS_FIREBASE_API_KEY || "AIzaSyAiZbAPrxH7EKaJftJoGcEVEL0h6rAVcvE";
@@ -797,6 +822,11 @@ async function runPair(opts) {
797
822
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
798
823
  import { z } from "zod";
799
824
 
825
+ // src/direct.ts
826
+ import { randomBytes } from "node:crypto";
827
+ import { openAsBlob } from "node:fs";
828
+ import { basename as basename2 } from "node:path";
829
+
800
830
  // src/shared/manifest.ts
801
831
  import { xchacha20poly1305 } from "@noble/ciphers/chacha";
802
832
 
@@ -861,6 +891,8 @@ function openSealed(key, sealed) {
861
891
  }
862
892
  return xchacha20poly1305(key, sealed.slice(0, 24)).decrypt(sealed.slice(24));
863
893
  }
894
+ var sealRaw = sealBytes;
895
+ var openRaw = openSealed;
864
896
  function sealManifest(channelKey, manifest, version = KEY_VERSION_LEGACY) {
865
897
  return sealBytes(channelKey, version, new TextEncoder().encode(JSON.stringify(manifest)));
866
898
  }
@@ -873,6 +905,852 @@ function decryptChannelName(channelKey, sealed) {
873
905
  return new TextDecoder().decode(openSealed(channelKey, sealed));
874
906
  }
875
907
 
908
+ // src/shared/direct.ts
909
+ var DIRECT_OFFER_TTL_MS = 10 * 60 * 1e3;
910
+ var DIRECT_CLAIMED_TTL_MS = 6 * 60 * 60 * 1e3;
911
+ var DIRECT_DONE_TTL_MS = 15 * 1e3;
912
+ var DIRECT_FILE_MAX_BYTES = 20 * 1024 * 1024 * 1024;
913
+ var DIRECT_FREE_FILE_MAX_BYTES = 10 * 1024 * 1024 * 1024;
914
+ var DIRECT_ANON_FILE_MAX_BYTES = 5 * 1024 * 1024 * 1024;
915
+ var DIRECT_ENTERPRISE_FILE_MAX_BYTES = 100 * 1024 * 1024 * 1024;
916
+ var DIRECT_CHUNK_BYTES = 64 * 1024;
917
+ var DIRECT_BUFFERED_HIGH = 8 * 1024 * 1024;
918
+ var DIRECT_RECEIVE_WINDOW_BYTES = 4 * 1024 * 1024;
919
+ var DIRECT_MEMORY_SINK_MAX_BYTES = 128 * 1024 * 1024;
920
+ var DIRECT_FALLBACK_PART_BYTES = 16 * 1024 * 1024;
921
+ var DIRECT_FALLBACK_TAG_BYTES = 16;
922
+ var DIRECT_FALLBACK_TTL_MS = 24 * 60 * 60 * 1e3;
923
+ function directFallbackPartCount(size) {
924
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error("bad_size");
925
+ return Math.max(1, Math.ceil(size / DIRECT_FALLBACK_PART_BYTES));
926
+ }
927
+ function directFallbackCipherSize(size) {
928
+ return size + directFallbackPartCount(size) * DIRECT_FALLBACK_TAG_BYTES;
929
+ }
930
+ var DIRECT_CONNECT_TIMEOUT_MS = 30 * 1e3;
931
+ var DIRECT_STALL_GRACE_MS = 5 * 1e3;
932
+ var DIRECT_SIGNAL_WAIT_MS = 90 * 1e3;
933
+
934
+ // src/shared/direct-engine.ts
935
+ import { createSHA256 } from "hash-wasm";
936
+
937
+ // src/shared/direct-protocol.ts
938
+ var FRAME_META = 1;
939
+ var FRAME_CHUNK = 2;
940
+ var FRAME_DONE = 3;
941
+ var FRAME_ABORT = 4;
942
+ var FRAME_CREDIT = 5;
943
+ function encodeMeta(meta) {
944
+ const body = new TextEncoder().encode(JSON.stringify(meta));
945
+ const frame = new Uint8Array(1 + body.length);
946
+ frame[0] = FRAME_META;
947
+ frame.set(body, 1);
948
+ return frame;
949
+ }
950
+ function encodeChunk(bytes) {
951
+ if (bytes.length > DIRECT_CHUNK_BYTES - 1) throw new Error("chunk_too_big");
952
+ const frame = new Uint8Array(1 + bytes.length);
953
+ frame[0] = FRAME_CHUNK;
954
+ frame.set(bytes, 1);
955
+ return frame;
956
+ }
957
+ function encodeDone(digest) {
958
+ if (!digest) return new Uint8Array([FRAME_DONE]);
959
+ const body = new TextEncoder().encode(JSON.stringify(digest));
960
+ const frame = new Uint8Array(1 + body.length);
961
+ frame[0] = FRAME_DONE;
962
+ frame.set(body, 1);
963
+ return frame;
964
+ }
965
+ function encodeCredit(received) {
966
+ if (!Number.isSafeInteger(received) || received < 0) throw new Error("bad_credit");
967
+ const body = new TextEncoder().encode(String(received));
968
+ const frame = new Uint8Array(1 + body.length);
969
+ frame[0] = FRAME_CREDIT;
970
+ frame.set(body, 1);
971
+ return frame;
972
+ }
973
+ var DONE_FRAME = encodeDone();
974
+ var ABORT_FRAME = new Uint8Array([FRAME_ABORT]);
975
+ function parseFrame(data) {
976
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
977
+ if (bytes.length === 0) throw new Error("empty_frame");
978
+ const payload = bytes.subarray(1);
979
+ switch (bytes[0]) {
980
+ case FRAME_META: {
981
+ const meta = JSON.parse(new TextDecoder().decode(payload));
982
+ if (typeof meta.name !== "string" || typeof meta.size !== "number") {
983
+ throw new Error("bad_meta");
984
+ }
985
+ return { type: "meta", meta };
986
+ }
987
+ case FRAME_CHUNK:
988
+ return { type: "chunk", payload };
989
+ case FRAME_DONE: {
990
+ if (payload.length === 0) return { type: "done" };
991
+ const digest = JSON.parse(new TextDecoder().decode(payload));
992
+ if (!Number.isSafeInteger(digest.size) || digest.size < 0 || typeof digest.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(digest.sha256)) throw new Error("bad_digest");
993
+ return { type: "done", digest };
994
+ }
995
+ case FRAME_ABORT:
996
+ return { type: "abort" };
997
+ case FRAME_CREDIT: {
998
+ const received = Number(new TextDecoder().decode(payload));
999
+ if (!Number.isSafeInteger(received) || received < 0) throw new Error("bad_credit");
1000
+ return { type: "credit", received };
1001
+ }
1002
+ default:
1003
+ throw new Error("unknown_frame");
1004
+ }
1005
+ }
1006
+
1007
+ // src/shared/direct-engine.ts
1008
+ var DIRECT_ICE = [
1009
+ { urls: ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"] }
1010
+ ];
1011
+ function candType(candidate) {
1012
+ const m = /\styp\s+(\S+)/.exec(candidate?.candidate ?? "");
1013
+ return m ? m[1] : "other";
1014
+ }
1015
+ function countCand(diag, side, c) {
1016
+ if (!c) return;
1017
+ const t = candType(c);
1018
+ if (t === "host") diag[side === "local" ? "localHost" : "remoteHost"]++;
1019
+ else if (t === "srflx" || t === "prflx") diag[side === "local" ? "localSrflx" : "remoteSrflx"]++;
1020
+ else if (t === "relay") diag[side === "local" ? "localRelay" : "remoteRelay"]++;
1021
+ }
1022
+ function countTurnUrls(servers) {
1023
+ let n = 0;
1024
+ for (const server of servers ?? []) {
1025
+ const urls = typeof server.urls === "string" ? [server.urls] : server.urls;
1026
+ for (const url of urls ?? []) {
1027
+ if (/^turns?:/i.test(url)) n++;
1028
+ }
1029
+ const legacy = server.url;
1030
+ if (typeof legacy === "string" && /^turns?:/i.test(legacy)) n++;
1031
+ }
1032
+ return n;
1033
+ }
1034
+ function pairFromStats(stats) {
1035
+ let pair;
1036
+ stats.forEach((s) => {
1037
+ if (s.type === "candidate-pair" && s.state === "succeeded" && (s.nominated || s.selected)) {
1038
+ pair = s;
1039
+ }
1040
+ });
1041
+ if (!pair?.localCandidateId || !pair.remoteCandidateId) return void 0;
1042
+ const local = stats.get(pair.localCandidateId);
1043
+ const remote = stats.get(pair.remoteCandidateId);
1044
+ if (!local?.candidateType || !remote?.candidateType) return void 0;
1045
+ return { local: local.candidateType, remote: remote.candidateType };
1046
+ }
1047
+ function pathOf(pair) {
1048
+ if (pair.local === "relay" || pair.remote === "relay") return "relay";
1049
+ return pair.local === "host" && pair.remote === "host" ? "lan" : "wan";
1050
+ }
1051
+ function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
1052
+ const initialIce = ice ?? DIRECT_ICE;
1053
+ const pc = new RTCPeerConnection({
1054
+ iceServers: initialIce,
1055
+ iceTransportPolicy
1056
+ });
1057
+ let phase = "connecting";
1058
+ let disconnectTimer;
1059
+ let restartTimer;
1060
+ let stallState = "idle";
1061
+ const startedAt = Date.now();
1062
+ const diag = {
1063
+ reason: "",
1064
+ ms: 0,
1065
+ iceState: "",
1066
+ gatherState: "",
1067
+ hadRemoteDesc: false,
1068
+ localHost: 0,
1069
+ localSrflx: 0,
1070
+ localRelay: 0,
1071
+ remoteHost: 0,
1072
+ remoteSrflx: 0,
1073
+ remoteRelay: 0,
1074
+ turnUrlsSupplied: countTurnUrls(initialIce),
1075
+ turnUrlsConfigured: 0,
1076
+ bytes: 0,
1077
+ restarts: 0
1078
+ };
1079
+ const cleanup = () => {
1080
+ clearTimeout(waitTimer);
1081
+ clearTimeout(connectTimer);
1082
+ clearTimeout(disconnectTimer);
1083
+ clearTimeout(restartTimer);
1084
+ pc.onicecandidate = null;
1085
+ pc.oniceconnectionstatechange = null;
1086
+ pc.close();
1087
+ };
1088
+ const setPhase = (p) => {
1089
+ if (phase === "done" || phase === "failed") return;
1090
+ phase = p;
1091
+ if (p === "done" || p === "failed") {
1092
+ diag.ms = Date.now() - startedAt;
1093
+ diag.iceState = String(pc.iceConnectionState ?? "");
1094
+ diag.gatherState = String(pc.iceGatheringState ?? "");
1095
+ diag.hadRemoteDesc = !!pc.remoteDescription;
1096
+ diag.turnUrlsConfigured = countTurnUrls(pc.getConfiguration().iceServers);
1097
+ onDiag?.(diag);
1098
+ onPhase(p);
1099
+ cleanup();
1100
+ return;
1101
+ }
1102
+ onPhase(p);
1103
+ };
1104
+ const fail = (reason) => {
1105
+ if (phase === "done" || phase === "failed") return;
1106
+ if (!diag.reason) diag.reason = reason;
1107
+ setPhase("failed");
1108
+ };
1109
+ const waitTimer = setTimeout(() => fail("peer_silent"), DIRECT_SIGNAL_WAIT_MS);
1110
+ let connectTimer;
1111
+ let heard = false;
1112
+ const signaled = () => {
1113
+ if (heard) return;
1114
+ heard = true;
1115
+ clearTimeout(waitTimer);
1116
+ connectTimer = setTimeout(() => fail("connect_timeout"), DIRECT_CONNECT_TIMEOUT_MS);
1117
+ };
1118
+ let onStall;
1119
+ let stallWindowMs = DIRECT_CONNECT_TIMEOUT_MS;
1120
+ const stalled = () => {
1121
+ if (phase === "done" || phase === "failed") return;
1122
+ clearTimeout(disconnectTimer);
1123
+ if (stallState === "restarting") return;
1124
+ if (diag.msConnect === void 0 || !onStall) {
1125
+ fail("disconnected");
1126
+ return;
1127
+ }
1128
+ stallState = "restarting";
1129
+ diag.restarts++;
1130
+ clearTimeout(restartTimer);
1131
+ restartTimer = setTimeout(() => fail("disconnected"), stallWindowMs);
1132
+ onStall();
1133
+ };
1134
+ pc.oniceconnectionstatechange = () => {
1135
+ const s = pc.iceConnectionState;
1136
+ if (s === "failed") {
1137
+ if (diag.msConnect !== void 0 && onStall) stalled();
1138
+ else fail("ice_failed");
1139
+ }
1140
+ if (s === "disconnected" && stallState === "idle") {
1141
+ stallState = "grace";
1142
+ disconnectTimer = setTimeout(stalled, DIRECT_STALL_GRACE_MS);
1143
+ }
1144
+ if (s === "connected" || s === "completed") {
1145
+ clearTimeout(disconnectTimer);
1146
+ clearTimeout(restartTimer);
1147
+ stallState = "idle";
1148
+ }
1149
+ };
1150
+ return {
1151
+ pc,
1152
+ diag,
1153
+ setPhase,
1154
+ fail,
1155
+ cleanup,
1156
+ signaled,
1157
+ connected: () => {
1158
+ clearTimeout(waitTimer);
1159
+ clearTimeout(connectTimer);
1160
+ if (diag.msConnect === void 0) diag.msConnect = Date.now() - startedAt;
1161
+ },
1162
+ isTerminal: () => phase === "done" || phase === "failed",
1163
+ setStall: (fn, windowMs) => {
1164
+ onStall = fn;
1165
+ stallWindowMs = windowMs;
1166
+ },
1167
+ setIceServers: (iceServers) => {
1168
+ diag.turnUrlsSupplied = countTurnUrls(iceServers);
1169
+ pc.setConfiguration({ iceServers });
1170
+ }
1171
+ };
1172
+ }
1173
+ function signalPlumbing(pc, send, onSendError, diag) {
1174
+ let localGeneration = 1;
1175
+ let remoteGeneration = 0;
1176
+ let remoteReady = false;
1177
+ const pendingByGeneration = /* @__PURE__ */ new Map();
1178
+ let pendingLegacy = [];
1179
+ pc.onicecandidate = (e) => {
1180
+ const c = e.candidate;
1181
+ countCand(diag, "local", c);
1182
+ void send({
1183
+ kind: "ice",
1184
+ candidate: c ? c.toJSON ? c.toJSON() : c : null,
1185
+ protocol: 2,
1186
+ generation: localGeneration
1187
+ }).catch(onSendError);
1188
+ };
1189
+ const apply = (candidate) => {
1190
+ void pc.addIceCandidate(candidate ?? void 0).catch(() => void 0);
1191
+ };
1192
+ const applyIce = (candidate, generation) => {
1193
+ countCand(diag, "remote", candidate);
1194
+ const normalized = candidate ?? null;
1195
+ if (generation === void 0) {
1196
+ if (remoteReady) apply(normalized);
1197
+ else pendingLegacy.push(normalized);
1198
+ return;
1199
+ }
1200
+ if (generation < remoteGeneration) return;
1201
+ if (remoteReady && generation === remoteGeneration) {
1202
+ apply(normalized);
1203
+ return;
1204
+ }
1205
+ const queued = pendingByGeneration.get(generation) ?? [];
1206
+ queued.push(normalized);
1207
+ pendingByGeneration.set(generation, queued);
1208
+ };
1209
+ const expectRemoteGeneration = (generation) => {
1210
+ remoteGeneration = generation;
1211
+ remoteReady = false;
1212
+ };
1213
+ const remoteDescriptionSet = (generation) => {
1214
+ remoteGeneration = generation;
1215
+ remoteReady = true;
1216
+ const queued = pendingByGeneration.get(generation) ?? [];
1217
+ pendingByGeneration.delete(generation);
1218
+ const legacy = pendingLegacy;
1219
+ pendingLegacy = [];
1220
+ queued.forEach(apply);
1221
+ legacy.forEach(apply);
1222
+ };
1223
+ return {
1224
+ applyIce,
1225
+ expectRemoteGeneration,
1226
+ remoteDescriptionSet,
1227
+ setLocalGeneration: (generation) => void (localGeneration = generation)
1228
+ };
1229
+ }
1230
+ function startSender(opts) {
1231
+ const s = session(opts.ice, opts.onPhase, opts.onDiag, opts.iceTransportPolicy);
1232
+ opts.onPhase("connecting");
1233
+ const { pc } = s;
1234
+ const plumbing = signalPlumbing(pc, opts.send, () => s.fail("signal_send"), s.diag);
1235
+ let generation = 1;
1236
+ let peerProtocol = 1;
1237
+ s.setStall(() => {
1238
+ void (async () => {
1239
+ generation++;
1240
+ plumbing.setLocalGeneration(generation);
1241
+ plumbing.expectRemoteGeneration(generation);
1242
+ if (opts.refreshIce) {
1243
+ const iceServers = await opts.refreshIce();
1244
+ s.setIceServers(iceServers);
1245
+ }
1246
+ pc.restartIce?.();
1247
+ const offer = await pc.createOffer({ iceRestart: true });
1248
+ await pc.setLocalDescription(offer);
1249
+ await opts.send({ kind: "offer", sdp: offer.sdp, protocol: 2, generation });
1250
+ })().catch(() => s.fail("signaling"));
1251
+ }, DIRECT_CONNECT_TIMEOUT_MS);
1252
+ const dc = pc.createDataChannel("zas-direct", { ordered: true });
1253
+ dc.binaryType = "arraybuffer";
1254
+ dc.bufferedAmountLowThreshold = DIRECT_BUFFERED_HIGH / 8;
1255
+ const waitLow = () => new Promise((resolve2) => {
1256
+ const done = () => {
1257
+ dc.removeEventListener("bufferedamountlow", done);
1258
+ resolve2();
1259
+ };
1260
+ dc.addEventListener("bufferedamountlow", done);
1261
+ });
1262
+ let persisted = 0;
1263
+ const creditWaiters = [];
1264
+ const wakeCredits = () => {
1265
+ for (let i = creditWaiters.length - 1; i >= 0; i--) {
1266
+ if (s.isTerminal() || persisted >= creditWaiters[i].target) {
1267
+ creditWaiters.splice(i, 1)[0].resolve();
1268
+ }
1269
+ }
1270
+ };
1271
+ const waitPersisted = (target) => {
1272
+ if (peerProtocol < 2 || persisted >= target || s.isTerminal()) return Promise.resolve();
1273
+ return new Promise((resolve2) => creditWaiters.push({ target, resolve: resolve2 }));
1274
+ };
1275
+ const pump = async () => {
1276
+ const meta = {
1277
+ name: opts.file.name ?? opts.name ?? "zas",
1278
+ size: opts.file.size,
1279
+ mime: opts.file.type || "application/octet-stream",
1280
+ ...opts.label !== void 0 ? { label: opts.label } : {}
1281
+ };
1282
+ dc.send(encodeMeta(meta));
1283
+ const hasher = await createSHA256();
1284
+ let sent = 0;
1285
+ for (let off = 0; off < opts.file.size; off += DIRECT_RECEIVE_WINDOW_BYTES) {
1286
+ const window = new Uint8Array(
1287
+ await opts.file.slice(
1288
+ off,
1289
+ Math.min(off + DIRECT_RECEIVE_WINDOW_BYTES, opts.file.size)
1290
+ ).arrayBuffer()
1291
+ );
1292
+ hasher.update(window);
1293
+ if (s.isTerminal()) return;
1294
+ for (let at = 0; at < window.length; at += DIRECT_CHUNK_BYTES - 1) {
1295
+ if (dc.bufferedAmount > DIRECT_BUFFERED_HIGH) await waitLow();
1296
+ if (s.isTerminal()) return;
1297
+ const slice = window.subarray(at, Math.min(at + DIRECT_CHUNK_BYTES - 1, window.length));
1298
+ dc.send(encodeChunk(slice));
1299
+ sent += slice.length;
1300
+ s.diag.bytes = sent;
1301
+ opts.onProgress?.(sent, opts.file.size);
1302
+ }
1303
+ await waitPersisted(sent);
1304
+ if (s.isTerminal()) return;
1305
+ }
1306
+ dc.send(encodeDone({ size: sent, sha256: hasher.digest() }));
1307
+ };
1308
+ dc.onopen = () => {
1309
+ s.connected();
1310
+ s.setPhase("flight");
1311
+ void pc.getStats().then((stats) => {
1312
+ const pair = pairFromStats(stats);
1313
+ if (!pair) return;
1314
+ s.diag.pairLocal = pair.local;
1315
+ s.diag.pairRemote = pair.remote;
1316
+ opts.onPath?.(pathOf(pair));
1317
+ }).catch(() => void 0);
1318
+ void pump().catch(() => s.fail("transfer"));
1319
+ };
1320
+ dc.onmessage = (e) => {
1321
+ try {
1322
+ const frame = parseFrame(e.data);
1323
+ if (frame.type === "done") s.setPhase("done");
1324
+ if (frame.type === "abort") s.fail("peer_abort");
1325
+ if (frame.type === "credit") {
1326
+ if (frame.received < persisted || frame.received > opts.file.size) throw new Error("bad_credit");
1327
+ persisted = frame.received;
1328
+ wakeCredits();
1329
+ }
1330
+ } catch {
1331
+ s.fail("protocol");
1332
+ }
1333
+ };
1334
+ dc.onclose = () => {
1335
+ wakeCredits();
1336
+ if (!s.isTerminal()) s.fail("peer_closed");
1337
+ };
1338
+ void (async () => {
1339
+ const offer = await pc.createOffer();
1340
+ await pc.setLocalDescription(offer);
1341
+ plumbing.setLocalGeneration(generation);
1342
+ plumbing.expectRemoteGeneration(generation);
1343
+ await opts.send({ kind: "offer", sdp: offer.sdp, protocol: 2, generation });
1344
+ })().catch(() => s.fail("signaling"));
1345
+ return {
1346
+ accept: (msg) => {
1347
+ if (s.isTerminal()) return;
1348
+ s.signaled();
1349
+ if (msg.kind === "answer" && pc.signalingState === "have-local-offer") {
1350
+ const answerGeneration = msg.generation ?? generation;
1351
+ if (msg.generation !== void 0 && answerGeneration !== generation) return;
1352
+ peerProtocol = msg.protocol === 2 ? 2 : 1;
1353
+ plumbing.expectRemoteGeneration(answerGeneration);
1354
+ void pc.setRemoteDescription({ type: "answer", sdp: msg.sdp }).then(() => plumbing.remoteDescriptionSet(answerGeneration)).catch(() => s.fail("signaling"));
1355
+ }
1356
+ if (msg.kind === "ice") plumbing.applyIce(msg.candidate, msg.generation);
1357
+ },
1358
+ close: () => {
1359
+ if (!s.isTerminal() && dc.readyState === "open") {
1360
+ try {
1361
+ dc.send(ABORT_FRAME);
1362
+ } catch {
1363
+ }
1364
+ }
1365
+ wakeCredits();
1366
+ s.cleanup();
1367
+ }
1368
+ };
1369
+ }
1370
+ function startReceiver(opts) {
1371
+ let sinkRef = null;
1372
+ const s = session(
1373
+ opts.ice,
1374
+ (p) => {
1375
+ if (p === "failed") {
1376
+ try {
1377
+ sinkRef?.abort?.();
1378
+ } catch {
1379
+ }
1380
+ sinkRef = null;
1381
+ }
1382
+ opts.onPhase(p);
1383
+ },
1384
+ opts.onDiag,
1385
+ opts.iceTransportPolicy
1386
+ );
1387
+ s.setStall(() => void 0, DIRECT_CONNECT_TIMEOUT_MS * 2);
1388
+ opts.onPhase("connecting");
1389
+ const { pc } = s;
1390
+ const plumbing = signalPlumbing(pc, opts.send, () => s.fail("signal_send"), s.diag);
1391
+ let protocol = 1;
1392
+ let generation = 1;
1393
+ let dc;
1394
+ let pipeline = null;
1395
+ let total = 0;
1396
+ let got = 0;
1397
+ let queued = 0;
1398
+ let nextCredit = DIRECT_RECEIVE_WINDOW_BYTES;
1399
+ let hasherPromise = null;
1400
+ let finished = false;
1401
+ pc.ondatachannel = (e) => {
1402
+ dc = e.channel;
1403
+ dc.binaryType = "arraybuffer";
1404
+ dc.onopen = () => {
1405
+ s.connected();
1406
+ s.setPhase("flight");
1407
+ void pc.getStats().then((stats) => {
1408
+ const pair = pairFromStats(stats);
1409
+ if (!pair) return;
1410
+ s.diag.pairLocal = pair.local;
1411
+ s.diag.pairRemote = pair.remote;
1412
+ opts.onPath?.(pathOf(pair));
1413
+ }).catch(() => void 0);
1414
+ };
1415
+ dc.onmessage = (e2) => {
1416
+ try {
1417
+ const frame = parseFrame(e2.data);
1418
+ if (frame.type === "meta") {
1419
+ if (pipeline || !Number.isSafeInteger(frame.meta.size) || frame.meta.size < 0 || frame.meta.size > DIRECT_FILE_MAX_BYTES || typeof frame.meta.name !== "string" || frame.meta.name.length === 0 || typeof frame.meta.mime !== "string") {
1420
+ try {
1421
+ dc?.send(ABORT_FRAME);
1422
+ } catch {
1423
+ }
1424
+ s.fail(frame.meta.size > DIRECT_FILE_MAX_BYTES ? "too_big" : "protocol");
1425
+ return;
1426
+ }
1427
+ if (opts.expectedMeta && (frame.meta.name !== opts.expectedMeta.name || frame.meta.size !== opts.expectedMeta.size || frame.meta.mime !== opts.expectedMeta.mime)) {
1428
+ dc?.send(ABORT_FRAME);
1429
+ s.fail("protocol");
1430
+ return;
1431
+ }
1432
+ total = frame.meta.size;
1433
+ hasherPromise = createSHA256();
1434
+ opts.onMeta?.(frame.meta);
1435
+ pipeline = opts.sink(frame.meta).then((sink) => {
1436
+ sinkRef = sink;
1437
+ return sink;
1438
+ });
1439
+ pipeline.catch(() => s.fail("sink"));
1440
+ } else if (frame.type === "chunk") {
1441
+ if (!pipeline || finished) throw new Error("chunk_before_meta");
1442
+ queued += frame.payload.length;
1443
+ if (queued > total) throw new Error("too_many_bytes");
1444
+ pipeline = pipeline.then(async (sink) => {
1445
+ await sink.write(frame.payload);
1446
+ const hasher = await hasherPromise;
1447
+ hasher.update(frame.payload);
1448
+ got += frame.payload.length;
1449
+ s.diag.bytes = got;
1450
+ opts.onProgress?.(got, total);
1451
+ if (protocol >= 2 && (got >= nextCredit || got === total)) {
1452
+ dc?.send(encodeCredit(got));
1453
+ while (nextCredit <= got) nextCredit += DIRECT_RECEIVE_WINDOW_BYTES;
1454
+ }
1455
+ return sink;
1456
+ });
1457
+ pipeline.catch(() => s.fail("sink"));
1458
+ } else if (frame.type === "done") {
1459
+ if (!pipeline || finished) throw new Error("done_before_meta");
1460
+ finished = true;
1461
+ void pipeline.then(async (sink) => {
1462
+ if (got !== total || queued !== total) throw new Error("size_mismatch");
1463
+ const sha2563 = (await hasherPromise).digest();
1464
+ if (protocol >= 2 && (!frame.digest || frame.digest.size !== got || frame.digest.sha256 !== sha2563)) throw new Error("digest_mismatch");
1465
+ await sink.close();
1466
+ dc?.send(DONE_FRAME);
1467
+ s.setPhase("done");
1468
+ }).catch(() => s.fail("integrity"));
1469
+ } else if (frame.type === "abort") {
1470
+ s.fail("peer_abort");
1471
+ }
1472
+ } catch {
1473
+ s.fail("protocol");
1474
+ }
1475
+ };
1476
+ dc.onclose = () => {
1477
+ if (!s.isTerminal()) s.fail("peer_closed");
1478
+ };
1479
+ };
1480
+ return {
1481
+ accept: (msg) => {
1482
+ if (s.isTerminal()) return;
1483
+ s.signaled();
1484
+ if (msg.kind === "offer" && (!pc.remoteDescription || pc.signalingState === "stable")) {
1485
+ const isRestart = !!pc.remoteDescription;
1486
+ const offerGeneration = msg.generation ?? (isRestart ? generation + 1 : 1);
1487
+ if (isRestart && msg.generation !== void 0 && offerGeneration <= generation) return;
1488
+ generation = offerGeneration;
1489
+ protocol = msg.protocol === 2 ? 2 : 1;
1490
+ plumbing.expectRemoteGeneration(generation);
1491
+ plumbing.setLocalGeneration(generation);
1492
+ void (async () => {
1493
+ if (isRestart && opts.refreshIce) {
1494
+ const iceServers = await opts.refreshIce();
1495
+ s.setIceServers(iceServers);
1496
+ }
1497
+ await pc.setRemoteDescription({ type: "offer", sdp: msg.sdp });
1498
+ plumbing.remoteDescriptionSet(generation);
1499
+ const answer = await pc.createAnswer();
1500
+ await pc.setLocalDescription(answer);
1501
+ await opts.send({ kind: "answer", sdp: answer.sdp, protocol: 2, generation });
1502
+ })().catch(() => s.fail("signaling"));
1503
+ }
1504
+ if (msg.kind === "ice") plumbing.applyIce(msg.candidate, msg.generation);
1505
+ },
1506
+ close: () => {
1507
+ if (!s.isTerminal() && dc?.readyState === "open") {
1508
+ try {
1509
+ dc.send(ABORT_FRAME);
1510
+ } catch {
1511
+ }
1512
+ }
1513
+ sinkRef?.abort?.();
1514
+ s.cleanup();
1515
+ }
1516
+ };
1517
+ }
1518
+
1519
+ // src/shared/direct-fallback.ts
1520
+ function createFallbackMeta(file) {
1521
+ const key = crypto.getRandomValues(new Uint8Array(32));
1522
+ const nonce = crypto.getRandomValues(new Uint8Array(8));
1523
+ return {
1524
+ v: 1,
1525
+ name: file.name,
1526
+ size: file.size,
1527
+ mime: file.type,
1528
+ key_b64: bytesToB64(key),
1529
+ nonce_b64: bytesToB64(nonce),
1530
+ part_bytes: DIRECT_FALLBACK_PART_BYTES,
1531
+ tag_bytes: DIRECT_FALLBACK_TAG_BYTES,
1532
+ part_count: directFallbackPartCount(file.size),
1533
+ cipher_size: directFallbackCipherSize(file.size)
1534
+ };
1535
+ }
1536
+ function fallbackMetaOf(value) {
1537
+ const meta = value;
1538
+ if (!meta || meta.v !== 1 || typeof meta.name !== "string" || meta.name.length === 0 || typeof meta.mime !== "string" || !Number.isSafeInteger(meta.size) || (meta.size ?? -1) < 0 || typeof meta.key_b64 !== "string" || typeof meta.nonce_b64 !== "string" || meta.part_bytes !== DIRECT_FALLBACK_PART_BYTES || meta.tag_bytes !== DIRECT_FALLBACK_TAG_BYTES || meta.part_count !== directFallbackPartCount(meta.size) || meta.cipher_size !== directFallbackCipherSize(meta.size)) throw new Error("bad_fallback_meta");
1539
+ let key;
1540
+ let nonce;
1541
+ try {
1542
+ key = b64ToBytes(meta.key_b64);
1543
+ nonce = b64ToBytes(meta.nonce_b64);
1544
+ } catch {
1545
+ throw new Error("bad_fallback_meta");
1546
+ }
1547
+ if (key.length !== 32 || nonce.length !== 8) throw new Error("bad_fallback_meta");
1548
+ return meta;
1549
+ }
1550
+ function partPlainBytes(meta, partNumber) {
1551
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > meta.part_count) {
1552
+ throw new Error("bad_fallback_part");
1553
+ }
1554
+ if (partNumber < meta.part_count) return meta.part_bytes;
1555
+ return meta.size - meta.part_bytes * (meta.part_count - 1);
1556
+ }
1557
+ function fallbackCipherPartBytes(meta, partNumber) {
1558
+ return partPlainBytes(meta, partNumber) + meta.tag_bytes;
1559
+ }
1560
+ function fallbackCipherOffset(meta, partNumber) {
1561
+ if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > meta.part_count) {
1562
+ throw new Error("bad_fallback_part");
1563
+ }
1564
+ return (partNumber - 1) * (meta.part_bytes + meta.tag_bytes);
1565
+ }
1566
+ function ivFor(meta, partNumber) {
1567
+ const iv = new Uint8Array(12);
1568
+ iv.set(b64ToBytes(meta.nonce_b64), 0);
1569
+ new DataView(iv.buffer).setUint32(8, partNumber, false);
1570
+ return iv;
1571
+ }
1572
+ function aadFor(offerId, partNumber, plainBytes) {
1573
+ return new TextEncoder().encode(
1574
+ `zas-direct-fallback-v1\0${offerId}\0${partNumber}\0${plainBytes}`
1575
+ );
1576
+ }
1577
+ async function aesKey(meta) {
1578
+ return crypto.subtle.importKey(
1579
+ "raw",
1580
+ b64ToBytes(meta.key_b64),
1581
+ { name: "AES-GCM" },
1582
+ false,
1583
+ ["encrypt", "decrypt"]
1584
+ );
1585
+ }
1586
+ async function encryptFallbackPart(meta, offerId, partNumber, plain, key) {
1587
+ const expected = partPlainBytes(meta, partNumber);
1588
+ if (plain.length !== expected) throw new Error("bad_fallback_part_size");
1589
+ const out = await crypto.subtle.encrypt(
1590
+ {
1591
+ name: "AES-GCM",
1592
+ iv: ivFor(meta, partNumber),
1593
+ additionalData: aadFor(offerId, partNumber, plain.length),
1594
+ tagLength: 128
1595
+ },
1596
+ key ?? await aesKey(meta),
1597
+ plain
1598
+ );
1599
+ return new Uint8Array(out);
1600
+ }
1601
+ async function decryptFallbackPart(meta, offerId, partNumber, cipher, key) {
1602
+ const plainBytes = partPlainBytes(meta, partNumber);
1603
+ if (cipher.length !== plainBytes + meta.tag_bytes) throw new Error("bad_fallback_part_size");
1604
+ const out = await crypto.subtle.decrypt(
1605
+ {
1606
+ name: "AES-GCM",
1607
+ iv: ivFor(meta, partNumber),
1608
+ additionalData: aadFor(offerId, partNumber, plainBytes),
1609
+ tagLength: 128
1610
+ },
1611
+ key ?? await aesKey(meta),
1612
+ cipher
1613
+ );
1614
+ return new Uint8Array(out);
1615
+ }
1616
+ var RETRIES = 3;
1617
+ var URL_BATCH = 16;
1618
+ var CONCURRENCY = 2;
1619
+ function aborted(signal) {
1620
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
1621
+ }
1622
+ var pause = (ms, signal) => new Promise((resolve2, reject) => {
1623
+ const timer = setTimeout(resolve2, ms);
1624
+ signal?.addEventListener("abort", () => {
1625
+ clearTimeout(timer);
1626
+ reject(new DOMException("Aborted", "AbortError"));
1627
+ }, { once: true });
1628
+ });
1629
+ async function uploadFallback(options) {
1630
+ const { file, offerId, meta, signal } = options;
1631
+ fallbackMetaOf(meta);
1632
+ if (file.size !== meta.size || file.name !== meta.name) throw new Error("fallback_file_changed");
1633
+ const key = await aesKey(meta);
1634
+ const completed = [];
1635
+ const partProgress = /* @__PURE__ */ new Map();
1636
+ const report = () => options.onProgress?.(
1637
+ Math.min(meta.cipher_size, [...partProgress.values()].reduce((sum, value) => sum + value, 0)),
1638
+ meta.cipher_size
1639
+ );
1640
+ for (let first = 1; first <= meta.part_count; first += URL_BATCH) {
1641
+ aborted(signal);
1642
+ const count = Math.min(URL_BATCH, meta.part_count - first + 1);
1643
+ const signed = await options.getUrls(first, count);
1644
+ if (signed.length !== count || signed.some((entry, i) => entry.part_number !== first + i || typeof entry.url !== "string")) throw new Error("bad_fallback_urls");
1645
+ let cursor = 0;
1646
+ const workers = Array.from({ length: Math.min(CONCURRENCY, signed.length) }, async () => {
1647
+ for (; ; ) {
1648
+ const at = cursor++;
1649
+ if (at >= signed.length) return;
1650
+ const entry = signed[at];
1651
+ const partNumber = entry.part_number;
1652
+ const plainStart = (partNumber - 1) * meta.part_bytes;
1653
+ const plainEnd = Math.min(file.size, plainStart + meta.part_bytes);
1654
+ const plain = new Uint8Array(await file.slice(plainStart, plainEnd).arrayBuffer());
1655
+ const cipher = await encryptFallbackPart(meta, offerId, partNumber, plain, key);
1656
+ let etag = "";
1657
+ let lastError;
1658
+ for (let attempt = 0; attempt < RETRIES; attempt++) {
1659
+ aborted(signal);
1660
+ partProgress.set(partNumber, 0);
1661
+ report();
1662
+ try {
1663
+ etag = await options.put(entry.url, cipher, signal, (loaded) => {
1664
+ partProgress.set(partNumber, loaded);
1665
+ report();
1666
+ });
1667
+ break;
1668
+ } catch (error) {
1669
+ lastError = error;
1670
+ if (error.name === "AbortError") throw error;
1671
+ if (attempt + 1 < RETRIES) {
1672
+ options.onRetry?.();
1673
+ await pause(250 * 2 ** attempt, signal);
1674
+ }
1675
+ }
1676
+ }
1677
+ if (!etag) throw lastError ?? new Error("fallback_put_failed");
1678
+ completed.push({ partNumber, etag });
1679
+ }
1680
+ });
1681
+ await Promise.all(workers);
1682
+ }
1683
+ return completed.sort((a, b) => a.partNumber - b.partNumber);
1684
+ }
1685
+ async function downloadFallback(options) {
1686
+ const { meta, offerId, sink, signal } = options;
1687
+ fallbackMetaOf(meta);
1688
+ const key = await aesKey(meta);
1689
+ const fetcher = options.fetcher ?? fetch;
1690
+ let partNumber = 1;
1691
+ let plainDone = 0;
1692
+ let attempts = 0;
1693
+ try {
1694
+ while (partNumber <= meta.part_count) {
1695
+ aborted(signal);
1696
+ const cipherOffset = fallbackCipherOffset(meta, partNumber);
1697
+ try {
1698
+ const url = await options.getUrl();
1699
+ const response = await fetcher(url, {
1700
+ headers: cipherOffset > 0 ? { Range: `bytes=${cipherOffset}-` } : void 0,
1701
+ signal
1702
+ });
1703
+ if (!response.ok || cipherOffset > 0 && response.status !== 206 || !response.body) {
1704
+ throw new Error(`fallback_get_${response.status}`);
1705
+ }
1706
+ const reader = response.body.getReader();
1707
+ const held = [];
1708
+ let heldBytes = 0;
1709
+ while (partNumber <= meta.part_count) {
1710
+ const needed = fallbackCipherPartBytes(meta, partNumber);
1711
+ while (heldBytes < needed) {
1712
+ const next = await reader.read();
1713
+ if (next.done) throw new Error("fallback_get_short");
1714
+ held.push(next.value);
1715
+ heldBytes += next.value.length;
1716
+ }
1717
+ const cipher = new Uint8Array(needed);
1718
+ let copied = 0;
1719
+ while (copied < needed) {
1720
+ const chunk = held.shift();
1721
+ const take = Math.min(chunk.length, needed - copied);
1722
+ cipher.set(chunk.subarray(0, take), copied);
1723
+ copied += take;
1724
+ heldBytes -= take;
1725
+ if (take < chunk.length) held.unshift(chunk.subarray(take));
1726
+ }
1727
+ const plain = await decryptFallbackPart(meta, offerId, partNumber, cipher, key);
1728
+ await sink.write(plain);
1729
+ plainDone += plain.length;
1730
+ partNumber++;
1731
+ attempts = 0;
1732
+ options.onProgress?.(plainDone, meta.size);
1733
+ }
1734
+ if (heldBytes > 0) throw new Error("fallback_get_long");
1735
+ } catch (error) {
1736
+ if (error.name === "AbortError") throw error;
1737
+ if (error.name === "OperationError" || error.message === "fallback_get_long") {
1738
+ throw error;
1739
+ }
1740
+ attempts++;
1741
+ if (attempts >= RETRIES) throw error;
1742
+ options.onRetry?.();
1743
+ await pause(250 * 2 ** (attempts - 1), signal);
1744
+ }
1745
+ }
1746
+ if (plainDone !== meta.size) throw new Error("fallback_plain_size");
1747
+ await sink.close();
1748
+ } catch (error) {
1749
+ sink.abort?.();
1750
+ throw error;
1751
+ }
1752
+ }
1753
+
876
1754
  // src/shared/sharedchannel.ts
877
1755
  import { xchacha20poly1305 as xchacha20poly13052 } from "@noble/ciphers/chacha";
878
1756
  import { x25519 as x255192 } from "@noble/curves/ed25519";
@@ -943,97 +1821,83 @@ function resolveChannel(identity, grants, channel) {
943
1821
  throw new ZasError("grant_missing", 0);
944
1822
  }
945
1823
 
946
- // src/jobs.ts
947
- import { randomUUID } from "node:crypto";
948
- var DEFAULT_WAIT_MS = 6e4;
949
- var HISTORY = 50;
950
- var JobRunner = class {
951
- now;
952
- waitMs;
953
- /** Newest first, and trimmed. */
954
- jobs = [];
955
- /** Keyed on the job object, not its id, so a caller holding a job that has
956
- * already aged out of the list can still wait for it to finish. */
957
- settled = /* @__PURE__ */ new WeakMap();
958
- constructor(opts = {}) {
959
- this.now = opts.now ?? (() => Date.now());
960
- this.waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
961
- }
962
- start(kind, title, channel, work) {
963
- const job = {
964
- id: randomUUID(),
965
- kind,
966
- title,
967
- channel,
968
- started_at: this.now(),
969
- phase: null,
970
- status: "running"
971
- };
972
- this.jobs.unshift(job);
973
- this.jobs.length = Math.min(this.jobs.length, HISTORY);
974
- const report = (phase) => {
975
- if (job.status === "running") job.phase = phase;
976
- };
977
- this.settled.set(job, work(report).then(
978
- (result) => {
979
- job.status = "done";
980
- job.result = result;
981
- return job;
982
- },
983
- (error) => {
984
- job.status = "failed";
985
- job.error = error instanceof ZasError ? {
986
- code: error.code,
987
- status: error.status,
988
- sentence: humanSentence(error),
989
- message: error.message,
990
- ...error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {},
991
- ...error.serverCode !== void 0 ? { serverCode: error.serverCode } : {}
992
- } : { code: "upload_failed", status: 0, sentence: humanSentence(new ZasError("upload_failed", 0)) };
993
- return job;
1824
+ // src/send.ts
1825
+ import { promises as fsp } from "node:fs";
1826
+ import { basename, extname } from "node:path";
1827
+
1828
+ // src/shared/chunker.ts
1829
+ import { blake3 as blake32 } from "hash-wasm";
1830
+ var gearPromise = null;
1831
+ function gearTable() {
1832
+ if (!gearPromise) {
1833
+ gearPromise = (async () => {
1834
+ const table = new Uint32Array(256);
1835
+ for (let i = 0; i < 256; i++) {
1836
+ const hex = await blake32(new TextEncoder().encode(GEAR_SEED + i), 256);
1837
+ table[i] = parseInt(hex.slice(0, 8), 16) >>> 0;
994
1838
  }
995
- ));
996
- return job;
1839
+ return table;
1840
+ })();
997
1841
  }
998
- /** Resolves when the work settles, or when the wait runs out — the same job
999
- * object either way, so the caller reads `status` rather than guessing. */
1000
- async wait(job) {
1001
- const settled = this.settled.get(job);
1002
- if (!settled) return job;
1003
- let timer;
1004
- const deadline = new Promise((resolve2) => {
1005
- timer = setTimeout(() => resolve2(job), this.waitMs);
1006
- });
1007
- try {
1008
- return await Promise.race([settled, deadline]);
1009
- } finally {
1010
- clearTimeout(timer);
1011
- }
1842
+ return gearPromise;
1843
+ }
1844
+ var AVG = 1 << CHUNK_AVG_BITS;
1845
+ var MASK_S = (1 << CHUNK_AVG_BITS + 2) - 1;
1846
+ var MASK_L = (1 << CHUNK_AVG_BITS - 2) - 1;
1847
+ function cutPoint(buf, gear, eof) {
1848
+ const len = Math.min(buf.length, CHUNK_MAX);
1849
+ if (buf.length < CHUNK_MAX && !eof) {
1850
+ if (buf.length <= CHUNK_MIN) return null;
1012
1851
  }
1013
- list() {
1014
- return this.jobs.slice(0, HISTORY);
1852
+ if (eof && len <= CHUNK_MIN) return len > 0 ? len : null;
1853
+ let hash = 0;
1854
+ const normal = Math.min(AVG, len);
1855
+ let i = CHUNK_MIN;
1856
+ for (; i < normal; i++) {
1857
+ hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
1858
+ if ((hash & MASK_S) === 0) return i + 1;
1015
1859
  }
1016
- get(id) {
1017
- return this.jobs.find((job) => job.id === id);
1860
+ for (; i < len; i++) {
1861
+ hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
1862
+ if ((hash & MASK_L) === 0) return i + 1;
1018
1863
  }
1019
- };
1020
-
1021
- // src/read.ts
1022
- import { randomBytes } from "node:crypto";
1023
- import {
1024
- closeSync,
1025
- existsSync as existsSync2,
1026
- mkdirSync as mkdirSync2,
1027
- mkdtempSync,
1028
- openSync,
1029
- renameSync as renameSync2,
1030
- rmdirSync,
1031
- statSync,
1032
- unlinkSync,
1033
- writeSync
1034
- } from "node:fs";
1035
- import { tmpdir } from "node:os";
1036
- import { dirname, extname, join as join2 } from "node:path";
1864
+ if (len === CHUNK_MAX) return CHUNK_MAX;
1865
+ if (eof) return len > 0 ? len : null;
1866
+ return null;
1867
+ }
1868
+ async function* chunkStream(source) {
1869
+ const gear = await gearTable();
1870
+ let pending = [];
1871
+ let pendingLen = 0;
1872
+ const compact = () => {
1873
+ if (pending.length === 1) return pending[0];
1874
+ const merged = new Uint8Array(pendingLen);
1875
+ let off = 0;
1876
+ for (const p of pending) {
1877
+ merged.set(p, off);
1878
+ off += p.length;
1879
+ }
1880
+ pending = [merged];
1881
+ return merged;
1882
+ };
1883
+ const drain = function* (eof) {
1884
+ while (pendingLen > 0) {
1885
+ const buf = compact();
1886
+ const cut = cutPoint(buf, gear, eof);
1887
+ if (cut === null) return;
1888
+ yield buf.slice(0, cut);
1889
+ pending = cut < buf.length ? [buf.slice(cut)] : [];
1890
+ pendingLen = buf.length - cut;
1891
+ }
1892
+ };
1893
+ for await (const piece of source) {
1894
+ if (piece.length === 0) continue;
1895
+ pending.push(piece);
1896
+ pendingLen += piece.length;
1897
+ if (pendingLen >= CHUNK_MAX) yield* drain(false);
1898
+ }
1899
+ yield* drain(true);
1900
+ }
1037
1901
 
1038
1902
  // src/shared/mle.ts
1039
1903
  import { xchacha20poly1305 as xchacha20poly13053 } from "@noble/ciphers/chacha";
@@ -1053,830 +1917,1417 @@ function decryptChunk(key, nonce, ciphertext) {
1053
1917
  return xchacha20poly13053(key, nonce).decrypt(ciphertext);
1054
1918
  }
1055
1919
 
1056
- // src/read.ts
1057
- var DEFAULT_LIMIT = 20;
1058
- var MAX_LIMIT = 50;
1059
- var DOWNLOAD_PREFIX = "zas-agent-";
1060
- var ID_SEGMENT = /^(?!__)[A-Za-z0-9_-]{1,128}$/;
1061
- var MAX_CHUNKS = 8192;
1062
- var MAX_DUPLICATES = 100;
1063
- function stringOf(value) {
1064
- return typeof value?.stringValue === "string" ? value.stringValue : void 0;
1065
- }
1066
- function timeOf(value) {
1067
- const parsed = typeof value?.timestampValue === "string" ? Date.parse(value.timestampValue) : NaN;
1068
- return Number.isFinite(parsed) ? parsed : null;
1069
- }
1070
- function rowOf(doc) {
1071
- const document = doc ?? {};
1072
- const name = typeof document.name === "string" ? document.name : "";
1073
- const fields = document.fields && typeof document.fields === "object" ? document.fields : {};
1074
- return {
1075
- id: name.slice(name.lastIndexOf("/") + 1),
1076
- manifestEnc: stringOf(fields.manifest_enc),
1077
- agent: stringOf(fields.agent),
1078
- createdAt: timeOf(fields.created_at),
1079
- expiresAt: timeOf(fields.expires_at),
1080
- bar: fields.bar?.booleanValue === true
1081
- };
1082
- }
1083
- function readable(row) {
1084
- if (!row.id || row.bar || !row.manifestEnc) return false;
1085
- return row.expiresAt === null || row.expiresAt > Date.now();
1086
- }
1087
- function openFor(channelKey, row) {
1088
- try {
1089
- return openManifest(channelKey, b64ToBytes(row.manifestEnc));
1090
- } catch {
1091
- return null;
1920
+ // src/shared/oprf.ts
1921
+ import { RistrettoPoint } from "@noble/curves/ed25519";
1922
+ import { sha512 as sha5122 } from "@noble/hashes/sha2";
1923
+ import { invert, mod } from "@noble/curves/abstract/modular";
1924
+ var ORDER = 2n ** 252n + 27742317777372353535851937790883648493n;
1925
+ var te = new TextEncoder();
1926
+ function i2osp(value, length) {
1927
+ const out = new Uint8Array(length);
1928
+ for (let i = length - 1; i >= 0; i--) {
1929
+ out[i] = value & 255;
1930
+ value >>>= 8;
1092
1931
  }
1932
+ return out;
1093
1933
  }
1094
- function summaryOf(row, manifest) {
1095
- const kind = manifest.kind === "text" ? "text" : "file";
1096
- const name = typeof manifest.name === "string" ? manifest.name : "";
1097
- return {
1098
- id: row.id,
1099
- kind,
1100
- // Absence means "show the file name": the sender chose no title.
1101
- title: manifest.title ?? name,
1102
- name,
1103
- mime: typeof manifest.mime === "string" ? manifest.mime : "application/octet-stream",
1104
- size: typeof manifest.size === "number" ? manifest.size : 0,
1105
- // The sealed time is the sender's own; the row's is the server's, and it
1106
- // only answers for a manifest that carries none.
1107
- created_at: typeof manifest.created_at === "string" && manifest.created_at ? manifest.created_at : row.createdAt !== null ? new Date(row.createdAt).toISOString() : "",
1108
- by_agent: row.agent !== void 0,
1109
- ...kind === "text" ? { text: manifest.text ?? "" } : {}
1110
- };
1111
- }
1112
- async function readGrant(ctx, channel) {
1113
- const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
1114
- if (!grant.read) throw new ZasError("read_forbidden", 403);
1115
- return grant;
1116
- }
1117
- function nameFrom(channelKey, grant) {
1118
- try {
1119
- return decryptChannelName(channelKey, b64ToBytes(grant.name_enc));
1120
- } catch {
1121
- throw new ZasError("key_stale", 0);
1934
+ function expandMessageXmd(msg, dst, lenInBytes) {
1935
+ const bInBytes = 64;
1936
+ const rInBytes = 128;
1937
+ const ell = Math.ceil(lenInBytes / bInBytes);
1938
+ if (ell > 255) throw new Error("expand_message_xmd: ell too large");
1939
+ const dstPrime = concatBytes(dst, i2osp(dst.length, 1));
1940
+ const zPad = new Uint8Array(rInBytes);
1941
+ const lIbStr = i2osp(lenInBytes, 2);
1942
+ const msgPrime = concatBytes(zPad, msg, lIbStr, i2osp(0, 1), dstPrime);
1943
+ const b0 = sha5122(msgPrime);
1944
+ const b = [];
1945
+ b[0] = sha5122(concatBytes(b0, i2osp(1, 1), dstPrime));
1946
+ for (let i = 2; i <= ell; i++) {
1947
+ const xored = new Uint8Array(bInBytes);
1948
+ for (let j = 0; j < bInBytes; j++) xored[j] = b0[j] ^ b[i - 2][j];
1949
+ b[i - 1] = sha5122(concatBytes(xored, i2osp(i, 1), dstPrime));
1122
1950
  }
1951
+ return concatBytes(...b).slice(0, lenInBytes);
1123
1952
  }
1124
- function linksParent(identity, grant) {
1125
- return `accounts/${identity.owner_uid}/channels/${grant.channel_id}`;
1953
+ function bytesToBigIntBE(bytes) {
1954
+ let v = 0n;
1955
+ for (const byte of bytes) v = v << 8n | BigInt(byte);
1956
+ return v;
1126
1957
  }
1127
- function linkPath(identity, grant, id) {
1128
- return `projects/${identity.firestore_project}/databases/(default)/documents/${linksParent(identity, grant)}/links/${id}`;
1958
+ function hashToGroup(input) {
1959
+ const dst = te.encode("HashToGroup-" + OPRF_CONTEXT);
1960
+ const uniform = expandMessageXmd(input, dst, 64);
1961
+ return RistrettoPoint.hashToCurve(uniform);
1129
1962
  }
1130
- async function queryLinks(ctx, grant, query) {
1131
- try {
1132
- return await ctx.client.firestoreRunQuery(linksParent(ctx.identity, grant), query);
1133
- } catch (err) {
1134
- if (err instanceof ZasError && err.status === 403) throw new ZasError("read_forbidden", 403);
1135
- throw err;
1136
- }
1963
+ function randomScalar() {
1964
+ const bytes = new Uint8Array(64);
1965
+ crypto.getRandomValues(bytes);
1966
+ const s = mod(bytesToBigIntBE(bytes), ORDER);
1967
+ return s === 0n ? 1n : s;
1137
1968
  }
1138
- function clampLimit(limit) {
1139
- if (limit === void 0 || !Number.isFinite(limit)) return DEFAULT_LIMIT;
1140
- return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
1969
+ function oprfBlind(input) {
1970
+ const blind = randomScalar();
1971
+ const P = hashToGroup(input);
1972
+ return { blind, blindedElement: P.multiply(blind).toRawBytes() };
1141
1973
  }
1142
- async function listItems(ctx, channel, limit) {
1143
- const grant = await readGrant(ctx, channel);
1144
- const channelKey = channelKeyOf(ctx.identity, grant);
1145
- const docs = await queryLinks(ctx, grant, {
1146
- from: [{ collectionId: "links" }],
1147
- orderBy: [{ field: { fieldPath: "created_at" }, direction: "DESCENDING" }],
1148
- limit: clampLimit(limit)
1149
- });
1150
- const items = [];
1151
- for (const doc of docs) {
1152
- const row = rowOf(doc);
1153
- if (!readable(row)) continue;
1154
- const manifest = openFor(channelKey, row);
1155
- if (!manifest) continue;
1156
- items.push(summaryOf(row, manifest));
1157
- }
1158
- return {
1159
- channel_id: grant.channel_id,
1160
- channel_name: nameFrom(channelKey, grant),
1161
- items
1162
- };
1974
+ function oprfFinalize(input, blind, evaluatedElement) {
1975
+ const E = RistrettoPoint.fromHex(evaluatedElement);
1976
+ const N = E.multiply(invert(blind, ORDER));
1977
+ const unblinded = N.toRawBytes();
1978
+ const hashInput = concatBytes(
1979
+ i2osp(input.length, 2),
1980
+ input,
1981
+ i2osp(unblinded.length, 2),
1982
+ unblinded,
1983
+ te.encode("Finalize")
1984
+ );
1985
+ return sha5122(hashInput);
1163
1986
  }
1164
- function redeemFailure(status) {
1165
- if (status === 429) return new ZasError("rate_limited", 429);
1166
- if (status >= 500) return new ZasError("network", status);
1167
- return new ZasError("invalid_cap", 403);
1987
+
1988
+ // src/thumbnail.ts
1989
+ import { Jimp } from "jimp";
1990
+ var PREFIX = "data:image/jpeg;base64,";
1991
+ var READABLE = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/bmp", "image/gif", "image/tiff"]);
1992
+ var QUALITY_STEPS = [85, 70, 55, 40];
1993
+ var MAX_INPUT_BYTES = 80 * 1024 * 1024;
1994
+ function thumbnailable(mime) {
1995
+ return READABLE.has(mime.split(";")[0].trim().toLowerCase());
1168
1996
  }
1169
- async function fetchChunk(ctx, chunk) {
1170
- if (typeof chunk.cap !== "string" || chunk.cap === "") throw new ZasError("invalid_cap", 403);
1171
- let headers = {};
1997
+ async function thumbnailFor(bytes, mime) {
1998
+ if (!thumbnailable(mime)) return void 0;
1999
+ if (bytes.length > MAX_INPUT_BYTES) return void 0;
1172
2000
  try {
1173
- headers = { Authorization: `Bearer ${await ctx.client.idToken()}` };
2001
+ const image = await Jimp.read(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
2002
+ if (Math.max(image.width, image.height) > THUMBNAIL_MAX_EDGE) {
2003
+ image.scaleToFit({ w: THUMBNAIL_MAX_EDGE, h: THUMBNAIL_MAX_EDGE });
2004
+ }
2005
+ for (const quality of QUALITY_STEPS) {
2006
+ const jpeg = await image.getBuffer("image/jpeg", { quality });
2007
+ const uri = PREFIX + Buffer.from(jpeg).toString("base64");
2008
+ if (uri.length <= THUMBNAIL_MAX_BYTES) return uri;
2009
+ }
2010
+ return void 0;
1174
2011
  } catch {
1175
- headers = {};
1176
- }
1177
- let url;
1178
- try {
1179
- const redeemed = await apiPublic(
1180
- ctx.identity.api_base,
1181
- "POST",
1182
- "/v1/blobs/redeem",
1183
- { cap: chunk.cap },
1184
- headers
1185
- );
1186
- url = typeof redeemed.url === "string" ? redeemed.url : "";
1187
- } catch (err) {
1188
- if (err instanceof ZasError) throw redeemFailure(err.status);
1189
- throw err;
2012
+ return void 0;
1190
2013
  }
1191
- if (url === "") throw new ZasError("invalid_cap", 403);
1192
- const res = await fetch(url);
1193
- if (!res.ok) {
1194
- await res.body?.cancel().catch(() => void 0);
1195
- throw res.status >= 500 ? new ZasError("network", res.status) : new ZasError("invalid_cap", 403);
1196
- }
1197
- const ciphertext = new Uint8Array(await res.arrayBuffer());
1198
- try {
1199
- return decryptChunk(b64ToBytes(chunk.key), b64ToBytes(chunk.nonce), ciphertext);
1200
- } catch {
1201
- throw new ZasError("invalid_cap", 403);
2014
+ }
2015
+
2016
+ // src/send.ts
2017
+ var MAX_FILE_BYTES = 5 * 1024 * 1024 * 1024;
2018
+ var REPLAY_WINDOW_MS = 10 * 60 * 1e3;
2019
+ var UPLOAD_CONCURRENCY = 4;
2020
+ var OPRF_PROBE_BATCH = 200;
2021
+ var MAX_REFUSALS_PER_SEND = 8;
2022
+ var NOTE_NAME_MAX = 40;
2023
+ function batches(items, size) {
2024
+ const out = [];
2025
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
2026
+ return out;
2027
+ }
2028
+ function sliceSize(ctx) {
2029
+ return Math.max(1, Math.floor(ctx.batch ?? OPRF_PROBE_BATCH));
2030
+ }
2031
+ var MIME = new Map(Object.entries({
2032
+ jpg: "image/jpeg",
2033
+ jpeg: "image/jpeg",
2034
+ png: "image/png",
2035
+ gif: "image/gif",
2036
+ webp: "image/webp",
2037
+ bmp: "image/bmp",
2038
+ tif: "image/tiff",
2039
+ tiff: "image/tiff",
2040
+ svg: "image/svg+xml",
2041
+ pdf: "application/pdf",
2042
+ txt: "text/plain",
2043
+ md: "text/markdown",
2044
+ csv: "text/csv",
2045
+ json: "application/json",
2046
+ html: "text/html",
2047
+ zip: "application/zip",
2048
+ gz: "application/gzip",
2049
+ tar: "application/x-tar",
2050
+ mp4: "video/mp4",
2051
+ mov: "video/quicktime",
2052
+ mp3: "audio/mpeg",
2053
+ wav: "audio/wav",
2054
+ doc: "application/msword",
2055
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
2056
+ xls: "application/vnd.ms-excel",
2057
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2058
+ ppt: "application/vnd.ms-powerpoint",
2059
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
2060
+ ts: "text/typescript",
2061
+ js: "text/javascript",
2062
+ py: "text/x-python",
2063
+ go: "text/x-go",
2064
+ rs: "text/x-rust",
2065
+ java: "text/x-java",
2066
+ c: "text/x-c",
2067
+ h: "text/x-c",
2068
+ cpp: "text/x-c++",
2069
+ sh: "application/x-sh",
2070
+ yml: "application/yaml",
2071
+ yaml: "application/yaml",
2072
+ toml: "application/toml",
2073
+ xml: "application/xml"
2074
+ }));
2075
+ function mimeFor(path) {
2076
+ return MIME.get(extname(path).slice(1).toLowerCase()) ?? "application/octet-stream";
2077
+ }
2078
+ async function mapLimit(items, limit, work) {
2079
+ const out = new Array(items.length);
2080
+ let next = 0;
2081
+ const runner = async () => {
2082
+ for (; ; ) {
2083
+ const index = next++;
2084
+ if (index >= items.length) return;
2085
+ out[index] = await work(items[index], index);
2086
+ }
2087
+ };
2088
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner));
2089
+ return out;
2090
+ }
2091
+ async function grantFor(ctx, channel) {
2092
+ const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
2093
+ if (!grant.send || grant.mode === "view") throw new ZasError("send_forbidden", 403);
2094
+ if (grant.direct_mode) throw new ZasError("direct_mode", 409);
2095
+ return grant;
2096
+ }
2097
+ async function receiptKey(ctx, channelId, contentHash, title) {
2098
+ const raw = `${ctx.identity.agent_uid}\0${channelId}\0${contentHash}\0${title}`;
2099
+ return blake3Hex(new TextEncoder().encode(raw));
2100
+ }
2101
+ function receiptFor(ctx, key) {
2102
+ const entries = loadFingerprints(ctx.profile).entries;
2103
+ if (!Object.prototype.hasOwnProperty.call(entries, key)) return void 0;
2104
+ const entry = entries[key];
2105
+ if (!entry || typeof entry.at !== "number" || Date.now() - entry.at >= REPLAY_WINDOW_MS) return void 0;
2106
+ return entry;
2107
+ }
2108
+ function remember(ctx, key, receipt) {
2109
+ const cutoff = Date.now() - REPLAY_WINDOW_MS;
2110
+ const entries = {};
2111
+ for (const [k, v] of Object.entries(loadFingerprints(ctx.profile).entries)) {
2112
+ if (v && typeof v.at === "number" && v.at >= cutoff) entries[k] = v;
1202
2113
  }
2114
+ entries[key] = receipt;
2115
+ saveFingerprints(ctx.profile, { entries });
1203
2116
  }
1204
- function safeName(name, fallback) {
1205
- const base = (typeof name === "string" ? name : "").split(/[\\/]/).pop() ?? "";
1206
- return base.replace(/^\.+/, "").trim() || fallback;
2117
+ function sleep(ms) {
2118
+ return new Promise((resolve2) => {
2119
+ setTimeout(resolve2, ms);
2120
+ });
1207
2121
  }
1208
- function freeName(target) {
1209
- if (!existsSync2(target)) return target;
1210
- const ext = extname(target);
1211
- const stem = target.slice(0, target.length - ext.length);
1212
- for (let n = 1; n <= MAX_DUPLICATES; n++) {
1213
- const candidate = `${stem} (${n})${ext}`;
1214
- if (!existsSync2(candidate)) return candidate;
2122
+ var CHUNK_PUT_RETRY_DELAY_MS = 1e3;
2123
+ async function putWithRetry(url, body) {
2124
+ for (let attempt = 0; ; attempt++) {
2125
+ let res;
2126
+ try {
2127
+ res = await fetch(url, { method: "PUT", body });
2128
+ } catch {
2129
+ if (attempt > 0) throw new ZasError("upload_failed", 0);
2130
+ await sleep(CHUNK_PUT_RETRY_DELAY_MS);
2131
+ continue;
2132
+ }
2133
+ if (res.ok || res.status < 500 || attempt > 0) return res;
2134
+ await res.body?.cancel().catch(() => void 0);
2135
+ await sleep(CHUNK_PUT_RETRY_DELAY_MS);
1215
2136
  }
1216
- throw new ZasError("write_failed", 0, target);
1217
2137
  }
1218
- function destinationOf(dest, name, fallback) {
1219
- const base = safeName(name, fallback);
1220
- if (dest === void 0) {
1221
- const created = mkdtempSync(join2(tmpdir(), DOWNLOAD_PREFIX));
1222
- return { target: freeName(join2(created, base)), created };
2138
+ var COMMIT_ATTEMPTS = 4;
2139
+ var COMMIT_RETRY_BASE_MS = 250;
2140
+ async function commitChunk(ctx, blobId, uploadId) {
2141
+ for (let attempt = 0; attempt < COMMIT_ATTEMPTS; attempt++) {
2142
+ try {
2143
+ const { cap } = await ctx.client.api(
2144
+ "POST",
2145
+ `/blobs/${blobId}/commit`,
2146
+ { upload_id: uploadId }
2147
+ );
2148
+ return cap;
2149
+ } catch (err) {
2150
+ if (!(err instanceof ZasError) || err.serverCode !== "commit_pending") throw err;
2151
+ if (attempt === COMMIT_ATTEMPTS - 1) break;
2152
+ await sleep(COMMIT_RETRY_BASE_MS * 2 ** attempt);
2153
+ }
1223
2154
  }
1224
- const at = statSync(dest, { throwIfNoEntry: false })?.isDirectory() ? join2(dest, base) : dest;
1225
- return { target: freeName(at) };
2155
+ throw new ZasError("upload_failed", 409);
1226
2156
  }
1227
- async function getItem(ctx, channel, id, dest) {
1228
- const grant = await readGrant(ctx, channel);
1229
- if (!ID_SEGMENT.test(id)) throw new ZasError("not_found", 404);
1230
- const channelKey = channelKeyOf(ctx.identity, grant);
1231
- const docs = await queryLinks(ctx, grant, {
1232
- from: [{ collectionId: "links" }],
1233
- where: {
1234
- fieldFilter: {
1235
- field: { fieldPath: "__name__" },
1236
- op: "EQUAL",
1237
- value: { referenceValue: linkPath(ctx.identity, grant, id) }
1238
- }
1239
- },
1240
- limit: 1
1241
- });
1242
- const row = docs.length > 0 ? rowOf(docs[0]) : null;
1243
- if (!row || !readable(row)) throw new ZasError("not_found", 404);
1244
- const manifest = openFor(channelKey, row);
1245
- if (!manifest) throw new ZasError("not_found", 404);
1246
- if (manifest.kind === "text") {
1247
- const text2 = manifest.text ?? "";
1248
- return { text: text2, bytes: new TextEncoder().encode(text2).length };
2157
+ async function uploadChunk(ctx, enc) {
2158
+ const reserved = await ctx.client.api(
2159
+ "POST",
2160
+ `/blobs/${enc.blobId}/upload-url`,
2161
+ { size: enc.ciphertext.length }
2162
+ );
2163
+ if (typeof reserved.url !== "string" || typeof reserved.upload_id !== "string") {
2164
+ throw new ZasError("upload_failed", 0);
1249
2165
  }
1250
- if (manifest.chunks.length === 0) throw new ZasError("not_found", 404);
1251
- if (manifest.chunks.length > MAX_CHUNKS) throw new ZasError("not_found", 404);
1252
- let target;
1253
- let created;
1254
- let tmp;
1255
- let fd;
1256
- let written = 0;
2166
+ const put = await putWithRetry(reserved.url, enc.ciphertext);
2167
+ if (!put.ok) {
2168
+ await put.body?.cancel().catch(() => void 0);
2169
+ throw new ZasError("upload_failed", put.status);
2170
+ }
2171
+ return commitChunk(ctx, enc.blobId, reserved.upload_id);
2172
+ }
2173
+ async function proveChunk(ctx, enc, challenge) {
2174
+ const samples = challenge.offsets.map((o) => enc.ciphertext.slice(o, o + challenge.sample_len));
1257
2175
  try {
1258
- const chosen = destinationOf(dest, manifest.name, row.id);
1259
- target = chosen.target;
1260
- created = chosen.created;
1261
- mkdirSync2(dirname(target), { recursive: true, mode: 448 });
1262
- tmp = `${target}.${randomBytes(6).toString("hex")}.tmp`;
1263
- fd = openSync(tmp, "wx", 384);
1264
- for (const chunk of manifest.chunks) {
1265
- written += writeSync(fd, await fetchChunk(ctx, chunk));
1266
- }
1267
- const size = typeof manifest.size === "number" ? manifest.size : 0;
1268
- if (size > 0 && written !== size) throw new ZasError("not_found", 404);
1269
- closeSync(fd);
1270
- fd = void 0;
1271
- renameSync2(tmp, target);
2176
+ const { cap } = await ctx.client.api("POST", `/blobs/${enc.blobId}/prove`, {
2177
+ challenge_id: challenge.challenge_id,
2178
+ mac: bytesToHex(hmacSha256(b64ToBytes(challenge.nonce), concatBytes(...samples)))
2179
+ });
2180
+ return cap;
1272
2181
  } catch (err) {
1273
- if (err instanceof ZasError) throw err;
1274
- const failure = err;
1275
- if (typeof failure.syscall === "string" || typeof failure.errno === "number") {
1276
- throw new ZasError("write_failed", 0, failure.message);
1277
- }
1278
- throw new ZasError("network", 0, String(err?.message ?? err));
1279
- } finally {
1280
- if (fd !== void 0) try {
1281
- closeSync(fd);
1282
- } catch {
2182
+ if (!(err instanceof ZasError) || err.serverCode !== "proof_failed") throw err;
2183
+ return null;
2184
+ }
2185
+ }
2186
+ async function placeSlice(ctx, slice, into, budget) {
2187
+ const probe = await ctx.client.api("POST", "/blobs/probe", {
2188
+ ids: slice.map((e) => e.blobId)
2189
+ });
2190
+ const results = probe.results ?? {};
2191
+ const challenges = probe.challenges ?? {};
2192
+ for (const enc of slice) {
2193
+ if (results[enc.blobId] === "blocked") throw new ZasError("upload_failed", 451);
2194
+ }
2195
+ const provable = [];
2196
+ const upload = [];
2197
+ for (const enc of slice) {
2198
+ if (results[enc.blobId] === "prove" && challenges[enc.blobId]) provable.push(enc);
2199
+ else upload.push(enc);
2200
+ }
2201
+ let refused = budget.refusals >= MAX_REFUSALS_PER_SEND;
2202
+ const prove = async (enc) => {
2203
+ if (refused) {
2204
+ upload.push(enc);
2205
+ return;
1283
2206
  }
1284
- if (tmp !== void 0 && existsSync2(tmp)) try {
1285
- unlinkSync(tmp);
1286
- } catch {
2207
+ const cap = await proveChunk(ctx, enc, challenges[enc.blobId]);
2208
+ if (cap === null) {
2209
+ refused = true;
2210
+ budget.refusals += 1;
2211
+ upload.push(enc);
2212
+ return;
1287
2213
  }
1288
- if (created !== void 0) try {
1289
- rmdirSync(created);
1290
- } catch {
2214
+ into.set(enc.blobId, { cap, proven: true });
2215
+ };
2216
+ if (provable.length > 0) await prove(provable[0]);
2217
+ await mapLimit(provable.slice(1), UPLOAD_CONCURRENCY, prove);
2218
+ await mapLimit(upload, UPLOAD_CONCURRENCY, async (enc) => {
2219
+ into.set(enc.blobId, { cap: await uploadChunk(ctx, enc), proven: false });
2220
+ });
2221
+ }
2222
+ async function placeChunks(ctx, encs) {
2223
+ if (encs.length === 0) return [];
2224
+ const distinct = [];
2225
+ const seen = /* @__PURE__ */ new Set();
2226
+ for (const enc of encs) {
2227
+ if (seen.has(enc.blobId)) continue;
2228
+ seen.add(enc.blobId);
2229
+ distinct.push(enc);
2230
+ }
2231
+ const placements = /* @__PURE__ */ new Map();
2232
+ const budget = { refusals: 0 };
2233
+ for (const slice of batches(distinct, sliceSize(ctx))) await placeSlice(ctx, slice, placements, budget);
2234
+ return encs.map((enc) => {
2235
+ const placement = placements.get(enc.blobId);
2236
+ if (!placement) throw new ZasError("upload_failed", 0);
2237
+ return {
2238
+ entry: {
2239
+ blob_id: enc.blobId,
2240
+ key: bytesToB64(enc.key),
2241
+ nonce: bytesToB64(enc.nonce),
2242
+ size: enc.ciphertext.length,
2243
+ cap: placement.cap
2244
+ },
2245
+ proven: placement.proven
2246
+ };
2247
+ });
2248
+ }
2249
+ async function postLink(ctx, grant, manifest, placed, idempotencyKey) {
2250
+ const channelKey = channelKeyOf(ctx.identity, grant);
2251
+ const sealed = sealManifest(channelKey, manifest, grant.key_version);
2252
+ const created = await ctx.client.api("POST", "/links", {
2253
+ channel_id: grant.channel_id,
2254
+ manifest_enc: bytesToB64(sealed),
2255
+ caps: placed.map((p) => p.entry.cap).filter((cap) => typeof cap === "string"),
2256
+ // Every chunk now arrives with a cap, proven or uploaded, so there is
2257
+ // nothing left for the server to verify here. The field stays because the
2258
+ // server reads it, and an absent array is not the same as an empty one.
2259
+ proofs: [],
2260
+ idempotency_key: idempotencyKey
2261
+ });
2262
+ const bound = created.caps ?? {};
2263
+ let patched = false;
2264
+ for (const p of placed) {
2265
+ const cap = bound[p.entry.blob_id];
2266
+ if (cap) {
2267
+ p.entry.cap = cap;
2268
+ patched = true;
1291
2269
  }
1292
2270
  }
1293
- return { path: target, bytes: written };
2271
+ if (patched) {
2272
+ await ctx.client.api("PATCH", `/links/${grant.channel_id}/${created.link_id}`, {
2273
+ manifest_enc: bytesToB64(sealManifest(channelKey, manifest, grant.key_version))
2274
+ });
2275
+ }
2276
+ return created.link_id;
2277
+ }
2278
+ async function sendFile(ctx, input, onPhase) {
2279
+ const stat = await fsp.stat(input.path).catch(() => {
2280
+ throw new ZasError("upload_failed", 400);
2281
+ });
2282
+ if (!stat.isFile()) throw new ZasError("upload_failed", 400);
2283
+ if (stat.size > MAX_FILE_BYTES) throw new ZasError("file_too_big", 413);
2284
+ const grant = await grantFor(ctx, input.channel);
2285
+ const name = basename(input.path);
2286
+ const title = input.title ?? name;
2287
+ const file = await fsp.readFile(input.path);
2288
+ if (file.byteLength > MAX_FILE_BYTES) throw new ZasError("file_too_big", 413);
2289
+ const bytes = new Uint8Array(file.buffer, file.byteOffset, file.byteLength);
2290
+ const contentHash = await blake3Hex(bytes);
2291
+ const key = await receiptKey(ctx, grant.channel_id, contentHash, title);
2292
+ const stored = receiptFor(ctx, key);
2293
+ if (stored) {
2294
+ return {
2295
+ link_id: stored.link_id,
2296
+ channel_id: grant.channel_id,
2297
+ channel_name: channelNameOf(ctx.identity, grant),
2298
+ bytes: stored.bytes,
2299
+ chunks: stored.chunks,
2300
+ deduplicated: stored.deduplicated,
2301
+ replayed: true
2302
+ };
2303
+ }
2304
+ onPhase?.("hashing");
2305
+ const plains = [];
2306
+ for await (const piece of chunkStream([bytes])) plains.push(piece);
2307
+ const hashes = await Promise.all(plains.map((p) => blake3Bytes(p)));
2308
+ const blinds = hashes.map((h) => oprfBlind(h));
2309
+ const evaluated = [];
2310
+ for (const slice of batches(blinds.map((b) => bytesToB64(b.blindedElement)), sliceSize(ctx))) {
2311
+ const answers = await ctx.client.oprfEvaluate(slice);
2312
+ if (answers.length !== slice.length) throw new ZasError("oprf_failed", 0);
2313
+ for (const one of answers) evaluated.push(one);
2314
+ }
2315
+ onPhase?.("encrypting");
2316
+ const encs = await Promise.all(plains.map(
2317
+ (plain, i) => encryptChunk(oprfFinalize(hashes[i], blinds[i].blind, b64ToBytes(evaluated[i])), plain)
2318
+ ));
2319
+ onPhase?.("uploading");
2320
+ const placed = await placeChunks(ctx, encs);
2321
+ onPhase?.("finishing");
2322
+ const mime = mimeFor(input.path);
2323
+ const thumb = await thumbnailFor(bytes, mime);
2324
+ const manifest = newManifest({
2325
+ kind: "file",
2326
+ name,
2327
+ // Only when the caller chose one: absence means "show the file name", and
2328
+ // writing the file name into `title` would make a rename look deliberate.
2329
+ ...input.title !== void 0 ? { title: input.title } : {},
2330
+ mime,
2331
+ size: bytes.length,
2332
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
2333
+ ...thumb ? { thumb_data: thumb } : {},
2334
+ chunks: placed.map((p) => p.entry)
2335
+ });
2336
+ const linkId = await postLink(
2337
+ ctx,
2338
+ grant,
2339
+ manifest,
2340
+ placed,
2341
+ agentSendIdempotencyKey(grant.channel_id, contentHash, title)
2342
+ );
2343
+ const deduplicated = placed.filter((p) => p.proven).length;
2344
+ remember(ctx, key, {
2345
+ link_id: linkId,
2346
+ bytes: bytes.length,
2347
+ chunks: placed.length,
2348
+ deduplicated,
2349
+ at: Date.now()
2350
+ });
2351
+ return {
2352
+ link_id: linkId,
2353
+ channel_id: grant.channel_id,
2354
+ channel_name: channelNameOf(ctx.identity, grant),
2355
+ bytes: bytes.length,
2356
+ chunks: placed.length,
2357
+ deduplicated,
2358
+ replayed: false
2359
+ };
2360
+ }
2361
+ function noteName(text2) {
2362
+ return text2.split("\n", 1)[0].trim().slice(0, NOTE_NAME_MAX) || "nota";
2363
+ }
2364
+ async function sendNote(ctx, input) {
2365
+ const grant = await grantFor(ctx, input.channel);
2366
+ const name = input.title ?? noteName(input.text);
2367
+ const encoded = new TextEncoder().encode(input.text);
2368
+ const contentHash = await blake3Hex(new TextEncoder().encode(
2369
+ `${input.lang ?? ""}\0${input.secret ? "1" : "0"}\0${input.text}`
2370
+ ));
2371
+ const key = await receiptKey(ctx, grant.channel_id, contentHash, name);
2372
+ const stored = receiptFor(ctx, key);
2373
+ if (stored) {
2374
+ return {
2375
+ link_id: stored.link_id,
2376
+ channel_id: grant.channel_id,
2377
+ channel_name: channelNameOf(ctx.identity, grant),
2378
+ bytes: stored.bytes,
2379
+ chunks: 0,
2380
+ deduplicated: 0,
2381
+ replayed: true
2382
+ };
2383
+ }
2384
+ const manifest = newManifest({
2385
+ kind: "text",
2386
+ name,
2387
+ ...input.title !== void 0 ? { title: input.title } : {},
2388
+ mime: "text/plain",
2389
+ size: encoded.length,
2390
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
2391
+ text: input.text,
2392
+ ...input.lang ? { code: { lang: input.lang, auto: false } } : {},
2393
+ // Only ever true or absent, exactly as the note cover is defined: absent is
2394
+ // "the sender did not classify", which no receiver may read as safe.
2395
+ ...input.secret ? { sensitive: true } : {},
2396
+ chunks: []
2397
+ });
2398
+ const linkId = await postLink(
2399
+ ctx,
2400
+ grant,
2401
+ manifest,
2402
+ [],
2403
+ agentSendIdempotencyKey(grant.channel_id, contentHash, name)
2404
+ );
2405
+ remember(ctx, key, {
2406
+ link_id: linkId,
2407
+ bytes: encoded.length,
2408
+ chunks: 0,
2409
+ deduplicated: 0,
2410
+ at: Date.now()
2411
+ });
2412
+ return {
2413
+ link_id: linkId,
2414
+ channel_id: grant.channel_id,
2415
+ channel_name: channelNameOf(ctx.identity, grant),
2416
+ bytes: encoded.length,
2417
+ chunks: 0,
2418
+ deduplicated: 0,
2419
+ replayed: false
2420
+ };
1294
2421
  }
1295
2422
 
1296
- // src/send.ts
1297
- import { promises as fsp } from "node:fs";
1298
- import { basename, extname as extname2 } from "node:path";
1299
-
1300
- // src/shared/chunker.ts
1301
- import { blake3 as blake32 } from "hash-wasm";
1302
- var gearPromise = null;
1303
- function gearTable() {
1304
- if (!gearPromise) {
1305
- gearPromise = (async () => {
1306
- const table = new Uint32Array(256);
1307
- for (let i = 0; i < 256; i++) {
1308
- const hex = await blake32(new TextEncoder().encode(GEAR_SEED + i), 256);
1309
- table[i] = parseInt(hex.slice(0, 8), 16) >>> 0;
1310
- }
1311
- return table;
1312
- })();
2423
+ // src/direct.ts
2424
+ var OFFER_WAIT_MS = 9.5 * 60 * 1e3;
2425
+ var OFFER_POLL_MS = 1e3;
2426
+ var SIGNAL_POLL_MS = 400;
2427
+ var HEARTBEAT_MS = 4 * 60 * 1e3;
2428
+ var SIGNALS_FOR_SENDER = {
2429
+ from: [{ collectionId: "signals" }],
2430
+ where: { fieldFilter: { field: { fieldPath: "for" }, op: "EQUAL", value: { stringValue: "sender" } } }
2431
+ };
2432
+ var stringField = (doc, key) => doc?.fields?.[key]?.stringValue;
2433
+ var defaultSleep2 = (ms) => new Promise((resolve2) => {
2434
+ setTimeout(resolve2, ms);
2435
+ });
2436
+ var newDeviceToken = () => randomBytes(16).toString("base64url");
2437
+ var webrtc = null;
2438
+ function installWebRtc() {
2439
+ if (!webrtc) {
2440
+ webrtc = import("node-datachannel/polyfill").then((poly) => {
2441
+ const g = globalThis;
2442
+ g.RTCPeerConnection ??= poly.RTCPeerConnection;
2443
+ g.RTCIceCandidate ??= poly.RTCIceCandidate;
2444
+ g.RTCSessionDescription ??= poly.RTCSessionDescription;
2445
+ g.RTCDataChannel ??= poly.RTCDataChannel;
2446
+ }, (error) => {
2447
+ webrtc = null;
2448
+ throw new ZasError("webrtc_unavailable", 0, String(error));
2449
+ });
1313
2450
  }
1314
- return gearPromise;
2451
+ return webrtc;
1315
2452
  }
1316
- var AVG = 1 << CHUNK_AVG_BITS;
1317
- var MASK_S = (1 << CHUNK_AVG_BITS + 2) - 1;
1318
- var MASK_L = (1 << CHUNK_AVG_BITS - 2) - 1;
1319
- function cutPoint(buf, gear, eof) {
1320
- const len = Math.min(buf.length, CHUNK_MAX);
1321
- if (buf.length < CHUNK_MAX && !eof) {
1322
- if (buf.length <= CHUNK_MIN) return null;
1323
- }
1324
- if (eof && len <= CHUNK_MIN) return len > 0 ? len : null;
1325
- let hash = 0;
1326
- const normal = Math.min(AVG, len);
1327
- let i = CHUNK_MIN;
1328
- for (; i < normal; i++) {
1329
- hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
1330
- if ((hash & MASK_S) === 0) return i + 1;
1331
- }
1332
- for (; i < len; i++) {
1333
- hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
1334
- if ((hash & MASK_L) === 0) return i + 1;
2453
+ async function directGrantFor(ctx, channel) {
2454
+ const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
2455
+ if (!grant.send || grant.mode === "view") throw new ZasError("send_forbidden", 403);
2456
+ if (!grant.direct_mode) throw new ZasError("not_direct_mode", 409);
2457
+ return grant;
2458
+ }
2459
+ function channelLabel(ctx, grant) {
2460
+ try {
2461
+ return channelNameOf(ctx.identity, grant);
2462
+ } catch {
2463
+ return grant.channel_id;
1335
2464
  }
1336
- if (len === CHUNK_MAX) return CHUNK_MAX;
1337
- if (eof) return len > 0 ? len : null;
1338
- return null;
1339
2465
  }
1340
- async function* chunkStream(source) {
1341
- const gear = await gearTable();
1342
- let pending = [];
1343
- let pendingLen = 0;
1344
- const compact = () => {
1345
- if (pending.length === 1) return pending[0];
1346
- const merged = new Uint8Array(pendingLen);
1347
- let off = 0;
1348
- for (const p of pending) {
1349
- merged.set(p, off);
1350
- off += p.length;
1351
- }
1352
- pending = [merged];
1353
- return merged;
2466
+ function sealer(key, keyVersion) {
2467
+ return {
2468
+ seal: (value) => bytesToB64(sealRaw(key, keyVersion, new TextEncoder().encode(JSON.stringify(value)))),
2469
+ open: (enc) => JSON.parse(new TextDecoder().decode(openRaw(key, b64ToBytes(enc))))
1354
2470
  };
1355
- const drain = function* (eof) {
1356
- while (pendingLen > 0) {
1357
- const buf = compact();
1358
- const cut = cutPoint(buf, gear, eof);
1359
- if (cut === null) return;
1360
- yield buf.slice(0, cut);
1361
- pending = cut < buf.length ? [buf.slice(cut)] : [];
1362
- pendingLen = buf.length - cut;
2471
+ }
2472
+ async function sendDirect(ctx, input, report, deps = {}) {
2473
+ const now = deps.now ?? (() => Date.now());
2474
+ const sleep2 = deps.sleep ?? defaultSleep2;
2475
+ const grant = await directGrantFor(ctx, input.channel);
2476
+ const key = channelKeyOf(ctx.identity, grant);
2477
+ const channelName = channelLabel(ctx, grant);
2478
+ const file = await (deps.openFile ?? openAsBlob)(input.path).catch(() => {
2479
+ throw new ZasError("upload_failed", 400);
2480
+ });
2481
+ if (file.size > DIRECT_FILE_MAX_BYTES) throw new ZasError("file_too_big", 413);
2482
+ const name = basename2(input.path);
2483
+ const cid = grant.channel_id;
2484
+ const owner = ctx.identity.owner_uid;
2485
+ const device = deps.device ?? newDeviceToken();
2486
+ const { seal, open } = sealer(key, grant.key_version);
2487
+ const stamp = { device, owner_uid: owner };
2488
+ const startedAt = now();
2489
+ report("offer");
2490
+ const meta = { name, size: file.size, mime: mimeFor(input.path) };
2491
+ const { id } = await ctx.client.api("POST", `/direct/${cid}`, {
2492
+ meta_enc: seal(meta),
2493
+ key_version: grant.key_version,
2494
+ sender_label_enc: seal(ctx.identity.name),
2495
+ size_bytes: file.size,
2496
+ ...stamp
2497
+ });
2498
+ const route = `/direct/${cid}/${id}`;
2499
+ const offerPath = `accounts/${owner}/channels/${cid}/direct/${id}`;
2500
+ const setState = (state) => ctx.client.api("POST", `${route}/state`, { state, ...stamp }).then(() => void 0, () => void 0);
2501
+ const claimBy = startedAt + (deps.offerWaitMs ?? OFFER_WAIT_MS);
2502
+ for (; ; ) {
2503
+ const doc = await ctx.client.firestoreGet(offerPath);
2504
+ const state = doc === null ? "cancelled" : stringField(doc, "state") ?? "open";
2505
+ if (state === "claimed") break;
2506
+ if (state !== "open") throw new ZasError("direct_cancelled", 0);
2507
+ if (now() >= claimBy) {
2508
+ await setState("cancelled");
2509
+ throw new ZasError("not_claimed", 0);
2510
+ }
2511
+ await sleep2(deps.offerPollMs ?? OFFER_POLL_MS);
2512
+ }
2513
+ await (deps.installWebRtc ?? installWebRtc)();
2514
+ const fetchIce = async () => {
2515
+ try {
2516
+ const r = await ctx.client.api("POST", `${route}/ice`, stamp);
2517
+ return Array.isArray(r.ice) ? r.ice : [];
2518
+ } catch {
2519
+ return [];
1363
2520
  }
1364
2521
  };
1365
- for await (const piece of source) {
1366
- if (piece.length === 0) continue;
1367
- pending.push(piece);
1368
- pendingLen += piece.length;
1369
- if (pendingLen >= CHUNK_MAX) yield* drain(false);
2522
+ const turn = await fetchIce();
2523
+ report("connecting");
2524
+ let resolveOutcome;
2525
+ const outcome = new Promise((resolve2) => {
2526
+ resolveOutcome = resolve2;
2527
+ });
2528
+ let diag;
2529
+ let path;
2530
+ let heartbeat;
2531
+ const handle = (deps.engine ?? startSender)({
2532
+ file,
2533
+ name,
2534
+ label: ctx.identity.name,
2535
+ ice: [...DIRECT_ICE, ...turn],
2536
+ refreshIce: async () => [...DIRECT_ICE, ...await fetchIce()],
2537
+ send: (msg) => ctx.client.api("POST", `${route}/signal`, { payload_enc: seal(msg), for: "receiver", ...stamp }).then(() => void 0),
2538
+ onPhase: (phase2) => {
2539
+ if (phase2 === "flight") {
2540
+ report("flight");
2541
+ heartbeat ??= setInterval(() => {
2542
+ void ctx.client.api("POST", `${route}/heartbeat`, stamp).catch(() => void 0);
2543
+ }, deps.heartbeatMs ?? HEARTBEAT_MS);
2544
+ }
2545
+ if (phase2 === "done" || phase2 === "failed") resolveOutcome(phase2);
2546
+ },
2547
+ onPath: (p) => {
2548
+ path = p;
2549
+ },
2550
+ onDiag: (d) => {
2551
+ diag = d;
2552
+ }
2553
+ });
2554
+ let over = false;
2555
+ const pump = (async () => {
2556
+ const seen = /* @__PURE__ */ new Set();
2557
+ while (!over) {
2558
+ let rows = [];
2559
+ try {
2560
+ rows = await ctx.client.firestoreRunQuery(offerPath, SIGNALS_FOR_SENDER);
2561
+ } catch {
2562
+ }
2563
+ for (const row of rows) {
2564
+ if (!row?.name || seen.has(row.name)) continue;
2565
+ seen.add(row.name);
2566
+ const enc = stringField(row, "payload_enc");
2567
+ if (!enc) continue;
2568
+ try {
2569
+ handle.accept(open(enc));
2570
+ } catch {
2571
+ }
2572
+ }
2573
+ if (!over) await Promise.race([outcome, sleep2(deps.signalPollMs ?? SIGNAL_POLL_MS)]);
2574
+ }
2575
+ })();
2576
+ const phase = await outcome;
2577
+ over = true;
2578
+ clearInterval(heartbeat);
2579
+ await pump;
2580
+ report("finishing");
2581
+ if (phase === "done") {
2582
+ await setState("done");
2583
+ return {
2584
+ offer_id: id,
2585
+ channel_id: cid,
2586
+ channel_name: channelName,
2587
+ bytes: file.size,
2588
+ ...path ? { path } : {},
2589
+ duration_ms: now() - startedAt
2590
+ };
1370
2591
  }
1371
- yield* drain(true);
2592
+ await setState("failed");
2593
+ const reason = diag?.reason || "unknown";
2594
+ deps.onFailed?.({
2595
+ channel_id: cid,
2596
+ channel_name: channelName,
2597
+ offer_id: id,
2598
+ owner_uid: owner,
2599
+ device,
2600
+ path: input.path,
2601
+ name,
2602
+ size: file.size,
2603
+ key,
2604
+ key_version: grant.key_version,
2605
+ reason
2606
+ });
2607
+ throw new ZasError("direct_failed", 0, reason);
2608
+ }
2609
+ var fetchPut = async (url, bytes, signal, onLoaded) => {
2610
+ const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
2611
+ const res = await fetch(url, { method: "PUT", body, signal });
2612
+ await res.body?.cancel().catch(() => void 0);
2613
+ if (!res.ok) throw new Error(`fallback_put_${res.status}`);
2614
+ const etag = res.headers.get("etag");
2615
+ if (!etag) throw new Error("fallback_etag_missing");
2616
+ onLoaded(bytes.length);
2617
+ return etag;
2618
+ };
2619
+ async function sendDirectFallback(ctx, record, report, deps = {}) {
2620
+ const now = deps.now ?? (() => Date.now());
2621
+ const startedAt = now();
2622
+ const opened = await (deps.openFile ?? openAsBlob)(record.path).catch(() => {
2623
+ throw new ZasError("upload_failed", 400);
2624
+ });
2625
+ if (opened.size !== record.size) throw new ZasError("file_changed", 0);
2626
+ const file = new File([opened], record.name, { type: mimeFor(record.path) });
2627
+ const { seal } = sealer(record.key, record.key_version);
2628
+ const stamp = { device: record.device, owner_uid: record.owner_uid };
2629
+ const base = `/direct/${record.channel_id}/${record.offer_id}/fallback`;
2630
+ report("encrypting");
2631
+ const meta = createFallbackMeta(file);
2632
+ await ctx.client.api("POST", `${base}/start`, {
2633
+ meta_enc: seal(meta),
2634
+ plain_size: meta.size,
2635
+ cipher_size: meta.cipher_size,
2636
+ part_count: meta.part_count,
2637
+ ...stamp
2638
+ });
2639
+ report("uploading");
2640
+ let parts;
2641
+ try {
2642
+ parts = await uploadFallback({
2643
+ file,
2644
+ offerId: record.offer_id,
2645
+ meta,
2646
+ put: deps.put ?? fetchPut,
2647
+ getUrls: async (from, count) => {
2648
+ const r = await ctx.client.api(
2649
+ "POST",
2650
+ `${base}/parts`,
2651
+ { from, count, ...stamp }
2652
+ );
2653
+ return r.urls;
2654
+ }
2655
+ });
2656
+ } catch (error) {
2657
+ await ctx.client.api("POST", `${base}/abort`, stamp).catch(() => void 0);
2658
+ if (error instanceof ZasError) throw error;
2659
+ throw new ZasError("upload_failed", 0, error instanceof Error ? error.message : String(error));
2660
+ }
2661
+ report("finishing");
2662
+ await ctx.client.api("POST", `${base}/complete`, {
2663
+ parts: parts.map((part) => ({ part_number: part.partNumber, etag: part.etag })),
2664
+ ...stamp
2665
+ });
2666
+ return {
2667
+ offer_id: record.offer_id,
2668
+ channel_id: record.channel_id,
2669
+ channel_name: record.channel_name,
2670
+ bytes: record.size,
2671
+ duration_ms: now() - startedAt,
2672
+ parts: parts.length
2673
+ };
1372
2674
  }
1373
2675
 
1374
- // src/shared/oprf.ts
1375
- import { RistrettoPoint } from "@noble/curves/ed25519";
1376
- import { sha512 as sha5122 } from "@noble/hashes/sha2";
1377
- import { invert, mod } from "@noble/curves/abstract/modular";
1378
- var ORDER = 2n ** 252n + 27742317777372353535851937790883648493n;
1379
- var te = new TextEncoder();
1380
- function i2osp(value, length) {
1381
- const out = new Uint8Array(length);
1382
- for (let i = length - 1; i >= 0; i--) {
1383
- out[i] = value & 255;
1384
- value >>>= 8;
1385
- }
1386
- return out;
1387
- }
1388
- function expandMessageXmd(msg, dst, lenInBytes) {
1389
- const bInBytes = 64;
1390
- const rInBytes = 128;
1391
- const ell = Math.ceil(lenInBytes / bInBytes);
1392
- if (ell > 255) throw new Error("expand_message_xmd: ell too large");
1393
- const dstPrime = concatBytes(dst, i2osp(dst.length, 1));
1394
- const zPad = new Uint8Array(rInBytes);
1395
- const lIbStr = i2osp(lenInBytes, 2);
1396
- const msgPrime = concatBytes(zPad, msg, lIbStr, i2osp(0, 1), dstPrime);
1397
- const b0 = sha5122(msgPrime);
1398
- const b = [];
1399
- b[0] = sha5122(concatBytes(b0, i2osp(1, 1), dstPrime));
1400
- for (let i = 2; i <= ell; i++) {
1401
- const xored = new Uint8Array(bInBytes);
1402
- for (let j = 0; j < bInBytes; j++) xored[j] = b0[j] ^ b[i - 2][j];
1403
- b[i - 1] = sha5122(concatBytes(xored, i2osp(i, 1), dstPrime));
1404
- }
1405
- return concatBytes(...b).slice(0, lenInBytes);
1406
- }
1407
- function bytesToBigIntBE(bytes) {
1408
- let v = 0n;
1409
- for (const byte of bytes) v = v << 8n | BigInt(byte);
1410
- return v;
1411
- }
1412
- function hashToGroup(input) {
1413
- const dst = te.encode("HashToGroup-" + OPRF_CONTEXT);
1414
- const uniform = expandMessageXmd(input, dst, 64);
1415
- return RistrettoPoint.hashToCurve(uniform);
1416
- }
1417
- function randomScalar() {
1418
- const bytes = new Uint8Array(64);
1419
- crypto.getRandomValues(bytes);
1420
- const s = mod(bytesToBigIntBE(bytes), ORDER);
1421
- return s === 0n ? 1n : s;
1422
- }
1423
- function oprfBlind(input) {
1424
- const blind = randomScalar();
1425
- const P = hashToGroup(input);
1426
- return { blind, blindedElement: P.multiply(blind).toRawBytes() };
1427
- }
1428
- function oprfFinalize(input, blind, evaluatedElement) {
1429
- const E = RistrettoPoint.fromHex(evaluatedElement);
1430
- const N = E.multiply(invert(blind, ORDER));
1431
- const unblinded = N.toRawBytes();
1432
- const hashInput = concatBytes(
1433
- i2osp(input.length, 2),
1434
- input,
1435
- i2osp(unblinded.length, 2),
1436
- unblinded,
1437
- te.encode("Finalize")
1438
- );
1439
- return sha5122(hashInput);
2676
+ // src/receive.ts
2677
+ import { randomBytes as randomBytes3 } from "node:crypto";
2678
+ import {
2679
+ closeSync as closeSync2,
2680
+ existsSync as existsSync3,
2681
+ mkdirSync as mkdirSync3,
2682
+ openSync as openSync2,
2683
+ renameSync as renameSync3,
2684
+ rmdirSync as rmdirSync2,
2685
+ unlinkSync as unlinkSync2,
2686
+ writeSync as writeSync2
2687
+ } from "node:fs";
2688
+ import { dirname as dirname2 } from "node:path";
2689
+
2690
+ // src/read.ts
2691
+ import { randomBytes as randomBytes2 } from "node:crypto";
2692
+ import {
2693
+ closeSync,
2694
+ existsSync as existsSync2,
2695
+ mkdirSync as mkdirSync2,
2696
+ mkdtempSync,
2697
+ openSync,
2698
+ renameSync as renameSync2,
2699
+ rmdirSync,
2700
+ statSync,
2701
+ unlinkSync,
2702
+ writeSync
2703
+ } from "node:fs";
2704
+ import { tmpdir } from "node:os";
2705
+ import { dirname, extname as extname2, join as join2 } from "node:path";
2706
+ var DEFAULT_LIMIT = 20;
2707
+ var MAX_LIMIT = 50;
2708
+ var DOWNLOAD_PREFIX = "zas-agent-";
2709
+ var ID_SEGMENT = /^(?!__)[A-Za-z0-9_-]{1,128}$/;
2710
+ var MAX_CHUNKS = 8192;
2711
+ var MAX_DUPLICATES = 100;
2712
+ function stringOf(value) {
2713
+ return typeof value?.stringValue === "string" ? value.stringValue : void 0;
1440
2714
  }
1441
-
1442
- // src/thumbnail.ts
1443
- import { Jimp } from "jimp";
1444
- var PREFIX = "data:image/jpeg;base64,";
1445
- var READABLE = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/bmp", "image/gif", "image/tiff"]);
1446
- var QUALITY_STEPS = [85, 70, 55, 40];
1447
- var MAX_INPUT_BYTES = 80 * 1024 * 1024;
1448
- function thumbnailable(mime) {
1449
- return READABLE.has(mime.split(";")[0].trim().toLowerCase());
2715
+ function timeOf(value) {
2716
+ const parsed = typeof value?.timestampValue === "string" ? Date.parse(value.timestampValue) : NaN;
2717
+ return Number.isFinite(parsed) ? parsed : null;
1450
2718
  }
1451
- async function thumbnailFor(bytes, mime) {
1452
- if (!thumbnailable(mime)) return void 0;
1453
- if (bytes.length > MAX_INPUT_BYTES) return void 0;
2719
+ function rowOf(doc) {
2720
+ const document = doc ?? {};
2721
+ const name = typeof document.name === "string" ? document.name : "";
2722
+ const fields = document.fields && typeof document.fields === "object" ? document.fields : {};
2723
+ return {
2724
+ id: name.slice(name.lastIndexOf("/") + 1),
2725
+ manifestEnc: stringOf(fields.manifest_enc),
2726
+ agent: stringOf(fields.agent),
2727
+ createdAt: timeOf(fields.created_at),
2728
+ expiresAt: timeOf(fields.expires_at),
2729
+ bar: fields.bar?.booleanValue === true
2730
+ };
2731
+ }
2732
+ function readable(row) {
2733
+ if (!row.id || row.bar || !row.manifestEnc) return false;
2734
+ return row.expiresAt === null || row.expiresAt > Date.now();
2735
+ }
2736
+ function openFor(channelKey, row) {
1454
2737
  try {
1455
- const image = await Jimp.read(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
1456
- if (Math.max(image.width, image.height) > THUMBNAIL_MAX_EDGE) {
1457
- image.scaleToFit({ w: THUMBNAIL_MAX_EDGE, h: THUMBNAIL_MAX_EDGE });
1458
- }
1459
- for (const quality of QUALITY_STEPS) {
1460
- const jpeg = await image.getBuffer("image/jpeg", { quality });
1461
- const uri = PREFIX + Buffer.from(jpeg).toString("base64");
1462
- if (uri.length <= THUMBNAIL_MAX_BYTES) return uri;
1463
- }
1464
- return void 0;
2738
+ return openManifest(channelKey, b64ToBytes(row.manifestEnc));
1465
2739
  } catch {
1466
- return void 0;
2740
+ return null;
1467
2741
  }
1468
2742
  }
1469
-
1470
- // src/send.ts
1471
- var MAX_FILE_BYTES = 5 * 1024 * 1024 * 1024;
1472
- var REPLAY_WINDOW_MS = 10 * 60 * 1e3;
1473
- var UPLOAD_CONCURRENCY = 4;
1474
- var OPRF_PROBE_BATCH = 200;
1475
- var MAX_REFUSALS_PER_SEND = 8;
1476
- var NOTE_NAME_MAX = 40;
1477
- function batches(items, size) {
1478
- const out = [];
1479
- for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
1480
- return out;
1481
- }
1482
- function sliceSize(ctx) {
1483
- return Math.max(1, Math.floor(ctx.batch ?? OPRF_PROBE_BATCH));
1484
- }
1485
- var MIME = new Map(Object.entries({
1486
- jpg: "image/jpeg",
1487
- jpeg: "image/jpeg",
1488
- png: "image/png",
1489
- gif: "image/gif",
1490
- webp: "image/webp",
1491
- bmp: "image/bmp",
1492
- tif: "image/tiff",
1493
- tiff: "image/tiff",
1494
- svg: "image/svg+xml",
1495
- pdf: "application/pdf",
1496
- txt: "text/plain",
1497
- md: "text/markdown",
1498
- csv: "text/csv",
1499
- json: "application/json",
1500
- html: "text/html",
1501
- zip: "application/zip",
1502
- gz: "application/gzip",
1503
- tar: "application/x-tar",
1504
- mp4: "video/mp4",
1505
- mov: "video/quicktime",
1506
- mp3: "audio/mpeg",
1507
- wav: "audio/wav",
1508
- doc: "application/msword",
1509
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
1510
- xls: "application/vnd.ms-excel",
1511
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1512
- ppt: "application/vnd.ms-powerpoint",
1513
- pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1514
- ts: "text/typescript",
1515
- js: "text/javascript",
1516
- py: "text/x-python",
1517
- go: "text/x-go",
1518
- rs: "text/x-rust",
1519
- java: "text/x-java",
1520
- c: "text/x-c",
1521
- h: "text/x-c",
1522
- cpp: "text/x-c++",
1523
- sh: "application/x-sh",
1524
- yml: "application/yaml",
1525
- yaml: "application/yaml",
1526
- toml: "application/toml",
1527
- xml: "application/xml"
1528
- }));
1529
- function mimeFor(path) {
1530
- return MIME.get(extname2(path).slice(1).toLowerCase()) ?? "application/octet-stream";
1531
- }
1532
- async function mapLimit(items, limit, work) {
1533
- const out = new Array(items.length);
1534
- let next = 0;
1535
- const runner = async () => {
1536
- for (; ; ) {
1537
- const index = next++;
1538
- if (index >= items.length) return;
1539
- out[index] = await work(items[index], index);
1540
- }
2743
+ function summaryOf(row, manifest) {
2744
+ const kind = manifest.kind === "text" ? "text" : "file";
2745
+ const name = typeof manifest.name === "string" ? manifest.name : "";
2746
+ return {
2747
+ id: row.id,
2748
+ kind,
2749
+ // Absence means "show the file name": the sender chose no title.
2750
+ title: manifest.title ?? name,
2751
+ name,
2752
+ mime: typeof manifest.mime === "string" ? manifest.mime : "application/octet-stream",
2753
+ size: typeof manifest.size === "number" ? manifest.size : 0,
2754
+ // The sealed time is the sender's own; the row's is the server's, and it
2755
+ // only answers for a manifest that carries none.
2756
+ created_at: typeof manifest.created_at === "string" && manifest.created_at ? manifest.created_at : row.createdAt !== null ? new Date(row.createdAt).toISOString() : "",
2757
+ by_agent: row.agent !== void 0,
2758
+ ...kind === "text" ? { text: manifest.text ?? "" } : {}
1541
2759
  };
1542
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner));
1543
- return out;
1544
2760
  }
1545
- async function grantFor(ctx, channel) {
2761
+ async function readGrant(ctx, channel) {
1546
2762
  const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
1547
- if (!grant.send || grant.mode === "view") throw new ZasError("send_forbidden", 403);
1548
- if (grant.direct_mode) throw new ZasError("direct_mode", 409);
2763
+ if (!grant.read) throw new ZasError("read_forbidden", 403);
1549
2764
  return grant;
1550
2765
  }
1551
- async function receiptKey(ctx, channelId, contentHash, title) {
1552
- const raw = `${ctx.identity.agent_uid}\0${channelId}\0${contentHash}\0${title}`;
1553
- return blake3Hex(new TextEncoder().encode(raw));
2766
+ function nameFrom(channelKey, grant) {
2767
+ try {
2768
+ return decryptChannelName(channelKey, b64ToBytes(grant.name_enc));
2769
+ } catch {
2770
+ throw new ZasError("key_stale", 0);
2771
+ }
1554
2772
  }
1555
- function receiptFor(ctx, key) {
1556
- const entries = loadFingerprints(ctx.profile).entries;
1557
- if (!Object.prototype.hasOwnProperty.call(entries, key)) return void 0;
1558
- const entry = entries[key];
1559
- if (!entry || typeof entry.at !== "number" || Date.now() - entry.at >= REPLAY_WINDOW_MS) return void 0;
1560
- return entry;
2773
+ function linksParent(identity, grant) {
2774
+ return `accounts/${identity.owner_uid}/channels/${grant.channel_id}`;
1561
2775
  }
1562
- function remember(ctx, key, receipt) {
1563
- const cutoff = Date.now() - REPLAY_WINDOW_MS;
1564
- const entries = {};
1565
- for (const [k, v] of Object.entries(loadFingerprints(ctx.profile).entries)) {
1566
- if (v && typeof v.at === "number" && v.at >= cutoff) entries[k] = v;
2776
+ function linkPath(identity, grant, id) {
2777
+ return `projects/${identity.firestore_project}/databases/(default)/documents/${linksParent(identity, grant)}/links/${id}`;
2778
+ }
2779
+ async function queryLinks(ctx, grant, query) {
2780
+ try {
2781
+ return await ctx.client.firestoreRunQuery(linksParent(ctx.identity, grant), query);
2782
+ } catch (err) {
2783
+ if (err instanceof ZasError && err.status === 403) throw new ZasError("read_forbidden", 403);
2784
+ throw err;
1567
2785
  }
1568
- entries[key] = receipt;
1569
- saveFingerprints(ctx.profile, { entries });
1570
2786
  }
1571
- function sleep(ms) {
1572
- return new Promise((resolve2) => {
1573
- setTimeout(resolve2, ms);
2787
+ function clampLimit(limit) {
2788
+ if (limit === void 0 || !Number.isFinite(limit)) return DEFAULT_LIMIT;
2789
+ return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
2790
+ }
2791
+ async function listItems(ctx, channel, limit) {
2792
+ const grant = await readGrant(ctx, channel);
2793
+ const channelKey = channelKeyOf(ctx.identity, grant);
2794
+ const docs = await queryLinks(ctx, grant, {
2795
+ from: [{ collectionId: "links" }],
2796
+ orderBy: [{ field: { fieldPath: "created_at" }, direction: "DESCENDING" }],
2797
+ limit: clampLimit(limit)
1574
2798
  });
2799
+ const items = [];
2800
+ for (const doc of docs) {
2801
+ const row = rowOf(doc);
2802
+ if (!readable(row)) continue;
2803
+ const manifest = openFor(channelKey, row);
2804
+ if (!manifest) continue;
2805
+ items.push(summaryOf(row, manifest));
2806
+ }
2807
+ return {
2808
+ channel_id: grant.channel_id,
2809
+ channel_name: nameFrom(channelKey, grant),
2810
+ items
2811
+ };
1575
2812
  }
1576
- var CHUNK_PUT_RETRY_DELAY_MS = 1e3;
1577
- async function putWithRetry(url, body) {
1578
- for (let attempt = 0; ; attempt++) {
1579
- let res;
1580
- try {
1581
- res = await fetch(url, { method: "PUT", body });
1582
- } catch {
1583
- if (attempt > 0) throw new ZasError("upload_failed", 0);
1584
- await sleep(CHUNK_PUT_RETRY_DELAY_MS);
1585
- continue;
1586
- }
1587
- if (res.ok || res.status < 500 || attempt > 0) return res;
2813
+ function redeemFailure(status) {
2814
+ if (status === 429) return new ZasError("rate_limited", 429);
2815
+ if (status >= 500) return new ZasError("network", status);
2816
+ return new ZasError("invalid_cap", 403);
2817
+ }
2818
+ async function fetchChunk(ctx, chunk) {
2819
+ if (typeof chunk.cap !== "string" || chunk.cap === "") throw new ZasError("invalid_cap", 403);
2820
+ let headers = {};
2821
+ try {
2822
+ headers = { Authorization: `Bearer ${await ctx.client.idToken()}` };
2823
+ } catch {
2824
+ headers = {};
2825
+ }
2826
+ let url;
2827
+ try {
2828
+ const redeemed = await apiPublic(
2829
+ ctx.identity.api_base,
2830
+ "POST",
2831
+ "/v1/blobs/redeem",
2832
+ { cap: chunk.cap },
2833
+ headers
2834
+ );
2835
+ url = typeof redeemed.url === "string" ? redeemed.url : "";
2836
+ } catch (err) {
2837
+ if (err instanceof ZasError) throw redeemFailure(err.status);
2838
+ throw err;
2839
+ }
2840
+ if (url === "") throw new ZasError("invalid_cap", 403);
2841
+ const res = await fetch(url);
2842
+ if (!res.ok) {
1588
2843
  await res.body?.cancel().catch(() => void 0);
1589
- await sleep(CHUNK_PUT_RETRY_DELAY_MS);
2844
+ throw res.status >= 500 ? new ZasError("network", res.status) : new ZasError("invalid_cap", 403);
2845
+ }
2846
+ const ciphertext = new Uint8Array(await res.arrayBuffer());
2847
+ try {
2848
+ return decryptChunk(b64ToBytes(chunk.key), b64ToBytes(chunk.nonce), ciphertext);
2849
+ } catch {
2850
+ throw new ZasError("invalid_cap", 403);
1590
2851
  }
1591
2852
  }
1592
- var COMMIT_ATTEMPTS = 4;
1593
- var COMMIT_RETRY_BASE_MS = 250;
1594
- async function commitChunk(ctx, blobId, uploadId) {
1595
- for (let attempt = 0; attempt < COMMIT_ATTEMPTS; attempt++) {
1596
- try {
1597
- const { cap } = await ctx.client.api(
1598
- "POST",
1599
- `/blobs/${blobId}/commit`,
1600
- { upload_id: uploadId }
1601
- );
1602
- return cap;
1603
- } catch (err) {
1604
- if (!(err instanceof ZasError) || err.serverCode !== "commit_pending") throw err;
1605
- if (attempt === COMMIT_ATTEMPTS - 1) break;
1606
- await sleep(COMMIT_RETRY_BASE_MS * 2 ** attempt);
1607
- }
1608
- }
1609
- throw new ZasError("upload_failed", 409);
2853
+ function safeName(name, fallback) {
2854
+ const base = (typeof name === "string" ? name : "").split(/[\\/]/).pop() ?? "";
2855
+ return base.replace(/^\.+/, "").trim() || fallback;
1610
2856
  }
1611
- async function uploadChunk(ctx, enc) {
1612
- const reserved = await ctx.client.api(
1613
- "POST",
1614
- `/blobs/${enc.blobId}/upload-url`,
1615
- { size: enc.ciphertext.length }
1616
- );
1617
- if (typeof reserved.url !== "string" || typeof reserved.upload_id !== "string") {
1618
- throw new ZasError("upload_failed", 0);
1619
- }
1620
- const put = await putWithRetry(reserved.url, enc.ciphertext);
1621
- if (!put.ok) {
1622
- await put.body?.cancel().catch(() => void 0);
1623
- throw new ZasError("upload_failed", put.status);
2857
+ function freeName(target) {
2858
+ if (!existsSync2(target)) return target;
2859
+ const ext = extname2(target);
2860
+ const stem = target.slice(0, target.length - ext.length);
2861
+ for (let n = 1; n <= MAX_DUPLICATES; n++) {
2862
+ const candidate = `${stem} (${n})${ext}`;
2863
+ if (!existsSync2(candidate)) return candidate;
1624
2864
  }
1625
- return commitChunk(ctx, enc.blobId, reserved.upload_id);
2865
+ throw new ZasError("write_failed", 0, target);
1626
2866
  }
1627
- async function proveChunk(ctx, enc, challenge) {
1628
- const samples = challenge.offsets.map((o) => enc.ciphertext.slice(o, o + challenge.sample_len));
1629
- try {
1630
- const { cap } = await ctx.client.api("POST", `/blobs/${enc.blobId}/prove`, {
1631
- challenge_id: challenge.challenge_id,
1632
- mac: bytesToHex(hmacSha256(b64ToBytes(challenge.nonce), concatBytes(...samples)))
1633
- });
1634
- return cap;
1635
- } catch (err) {
1636
- if (!(err instanceof ZasError) || err.serverCode !== "proof_failed") throw err;
1637
- return null;
2867
+ function destinationOf(dest, name, fallback) {
2868
+ const base = safeName(name, fallback);
2869
+ if (dest === void 0) {
2870
+ const created = mkdtempSync(join2(tmpdir(), DOWNLOAD_PREFIX));
2871
+ return { target: freeName(join2(created, base)), created };
1638
2872
  }
2873
+ const at = statSync(dest, { throwIfNoEntry: false })?.isDirectory() ? join2(dest, base) : dest;
2874
+ return { target: freeName(at) };
1639
2875
  }
1640
- async function placeSlice(ctx, slice, into, budget) {
1641
- const probe = await ctx.client.api("POST", "/blobs/probe", {
1642
- ids: slice.map((e) => e.blobId)
2876
+ async function getItem(ctx, channel, id, dest) {
2877
+ const grant = await readGrant(ctx, channel);
2878
+ if (!ID_SEGMENT.test(id)) throw new ZasError("not_found", 404);
2879
+ const channelKey = channelKeyOf(ctx.identity, grant);
2880
+ const docs = await queryLinks(ctx, grant, {
2881
+ from: [{ collectionId: "links" }],
2882
+ where: {
2883
+ fieldFilter: {
2884
+ field: { fieldPath: "__name__" },
2885
+ op: "EQUAL",
2886
+ value: { referenceValue: linkPath(ctx.identity, grant, id) }
2887
+ }
2888
+ },
2889
+ limit: 1
1643
2890
  });
1644
- const results = probe.results ?? {};
1645
- const challenges = probe.challenges ?? {};
1646
- for (const enc of slice) {
1647
- if (results[enc.blobId] === "blocked") throw new ZasError("upload_failed", 451);
1648
- }
1649
- const provable = [];
1650
- const upload = [];
1651
- for (const enc of slice) {
1652
- if (results[enc.blobId] === "prove" && challenges[enc.blobId]) provable.push(enc);
1653
- else upload.push(enc);
2891
+ const row = docs.length > 0 ? rowOf(docs[0]) : null;
2892
+ if (!row || !readable(row)) throw new ZasError("not_found", 404);
2893
+ const manifest = openFor(channelKey, row);
2894
+ if (!manifest) throw new ZasError("not_found", 404);
2895
+ if (manifest.kind === "text") {
2896
+ const text2 = manifest.text ?? "";
2897
+ return { text: text2, bytes: new TextEncoder().encode(text2).length };
1654
2898
  }
1655
- let refused = budget.refusals >= MAX_REFUSALS_PER_SEND;
1656
- const prove = async (enc) => {
1657
- if (refused) {
1658
- upload.push(enc);
1659
- return;
2899
+ if (manifest.chunks.length === 0) throw new ZasError("not_found", 404);
2900
+ if (manifest.chunks.length > MAX_CHUNKS) throw new ZasError("not_found", 404);
2901
+ let target;
2902
+ let created;
2903
+ let tmp;
2904
+ let fd;
2905
+ let written = 0;
2906
+ try {
2907
+ const chosen = destinationOf(dest, manifest.name, row.id);
2908
+ target = chosen.target;
2909
+ created = chosen.created;
2910
+ mkdirSync2(dirname(target), { recursive: true, mode: 448 });
2911
+ tmp = `${target}.${randomBytes2(6).toString("hex")}.tmp`;
2912
+ fd = openSync(tmp, "wx", 384);
2913
+ for (const chunk of manifest.chunks) {
2914
+ written += writeSync(fd, await fetchChunk(ctx, chunk));
1660
2915
  }
1661
- const cap = await proveChunk(ctx, enc, challenges[enc.blobId]);
1662
- if (cap === null) {
1663
- refused = true;
1664
- budget.refusals += 1;
1665
- upload.push(enc);
1666
- return;
2916
+ const size = typeof manifest.size === "number" ? manifest.size : 0;
2917
+ if (size > 0 && written !== size) throw new ZasError("not_found", 404);
2918
+ closeSync(fd);
2919
+ fd = void 0;
2920
+ renameSync2(tmp, target);
2921
+ } catch (err) {
2922
+ if (err instanceof ZasError) throw err;
2923
+ const failure = err;
2924
+ if (typeof failure.syscall === "string" || typeof failure.errno === "number") {
2925
+ throw new ZasError("write_failed", 0, failure.message);
2926
+ }
2927
+ throw new ZasError("network", 0, String(err?.message ?? err));
2928
+ } finally {
2929
+ if (fd !== void 0) try {
2930
+ closeSync(fd);
2931
+ } catch {
2932
+ }
2933
+ if (tmp !== void 0 && existsSync2(tmp)) try {
2934
+ unlinkSync(tmp);
2935
+ } catch {
2936
+ }
2937
+ if (created !== void 0) try {
2938
+ rmdirSync(created);
2939
+ } catch {
1667
2940
  }
1668
- into.set(enc.blobId, { cap, proven: true });
1669
- };
1670
- if (provable.length > 0) await prove(provable[0]);
1671
- await mapLimit(provable.slice(1), UPLOAD_CONCURRENCY, prove);
1672
- await mapLimit(upload, UPLOAD_CONCURRENCY, async (enc) => {
1673
- into.set(enc.blobId, { cap: await uploadChunk(ctx, enc), proven: false });
1674
- });
1675
- }
1676
- async function placeChunks(ctx, encs) {
1677
- if (encs.length === 0) return [];
1678
- const distinct = [];
1679
- const seen = /* @__PURE__ */ new Set();
1680
- for (const enc of encs) {
1681
- if (seen.has(enc.blobId)) continue;
1682
- seen.add(enc.blobId);
1683
- distinct.push(enc);
1684
2941
  }
1685
- const placements = /* @__PURE__ */ new Map();
1686
- const budget = { refusals: 0 };
1687
- for (const slice of batches(distinct, sliceSize(ctx))) await placeSlice(ctx, slice, placements, budget);
1688
- return encs.map((enc) => {
1689
- const placement = placements.get(enc.blobId);
1690
- if (!placement) throw new ZasError("upload_failed", 0);
1691
- return {
1692
- entry: {
1693
- blob_id: enc.blobId,
1694
- key: bytesToB64(enc.key),
1695
- nonce: bytesToB64(enc.nonce),
1696
- size: enc.ciphertext.length,
1697
- cap: placement.cap
1698
- },
1699
- proven: placement.proven
1700
- };
1701
- });
2942
+ return { path: target, bytes: written };
1702
2943
  }
1703
- async function postLink(ctx, grant, manifest, placed, idempotencyKey) {
1704
- const channelKey = channelKeyOf(ctx.identity, grant);
1705
- const sealed = sealManifest(channelKey, manifest, grant.key_version);
1706
- const created = await ctx.client.api("POST", "/links", {
1707
- channel_id: grant.channel_id,
1708
- manifest_enc: bytesToB64(sealed),
1709
- caps: placed.map((p) => p.entry.cap).filter((cap) => typeof cap === "string"),
1710
- // Every chunk now arrives with a cap, proven or uploaded, so there is
1711
- // nothing left for the server to verify here. The field stays because the
1712
- // server reads it, and an absent array is not the same as an empty one.
1713
- proofs: [],
1714
- idempotency_key: idempotencyKey
1715
- });
1716
- const bound = created.caps ?? {};
1717
- let patched = false;
1718
- for (const p of placed) {
1719
- const cap = bound[p.entry.blob_id];
1720
- if (cap) {
1721
- p.entry.cap = cap;
1722
- patched = true;
2944
+
2945
+ // src/receive.ts
2946
+ var OFFER_WAIT_MS2 = 9.5 * 60 * 1e3;
2947
+ var OFFER_POLL_MS2 = 1e3;
2948
+ var SIGNAL_POLL_MS2 = 400;
2949
+ var OFFER_PAGE = 10;
2950
+ var OPEN_OFFERS = {
2951
+ from: [{ collectionId: "direct" }],
2952
+ orderBy: [{ field: { fieldPath: "created_at" }, direction: "DESCENDING" }],
2953
+ limit: OFFER_PAGE
2954
+ };
2955
+ var SIGNALS_FOR_RECEIVER = {
2956
+ from: [{ collectionId: "signals" }],
2957
+ where: { fieldFilter: { field: { fieldPath: "for" }, op: "EQUAL", value: { stringValue: "receiver" } } }
2958
+ };
2959
+ var stringField2 = (doc, key) => doc?.fields?.[key]?.stringValue;
2960
+ var idOf = (doc) => (doc.name ?? "").split("/").pop() ?? "";
2961
+ async function receiveGrantFor(ctx, channel) {
2962
+ const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
2963
+ if (!grant.read) throw new ZasError("read_forbidden", 403);
2964
+ if (!grant.direct_mode) throw new ZasError("not_direct_mode", 409);
2965
+ return grant;
2966
+ }
2967
+ var DiskSink = class {
2968
+ constructor(dest, fallbackName) {
2969
+ this.dest = dest;
2970
+ this.fallbackName = fallbackName;
2971
+ }
2972
+ /** Where the bytes will be once `close` has run. */
2973
+ target;
2974
+ written = 0;
2975
+ /** A filesystem refusal, kept so the run can answer `write_failed` rather
2976
+ * than reporting the engine's generic sink failure. */
2977
+ failure;
2978
+ created;
2979
+ tmp;
2980
+ fd;
2981
+ open(meta) {
2982
+ try {
2983
+ const chosen = destinationOf(this.dest, meta.name, this.fallbackName);
2984
+ this.target = chosen.target;
2985
+ this.created = chosen.created;
2986
+ mkdirSync3(dirname2(this.target), { recursive: true, mode: 448 });
2987
+ this.tmp = `${this.target}.${randomBytes3(6).toString("hex")}.tmp`;
2988
+ this.fd = openSync2(this.tmp, "wx", 384);
2989
+ } catch (error) {
2990
+ throw this.remember(error);
1723
2991
  }
1724
2992
  }
1725
- if (patched) {
1726
- await ctx.client.api("PATCH", `/links/${grant.channel_id}/${created.link_id}`, {
1727
- manifest_enc: bytesToB64(sealManifest(channelKey, manifest, grant.key_version))
1728
- });
2993
+ write(bytes) {
2994
+ try {
2995
+ if (this.fd === void 0) throw new Error("sink_closed");
2996
+ this.written += writeSync2(this.fd, bytes);
2997
+ } catch (error) {
2998
+ throw this.remember(error);
2999
+ }
1729
3000
  }
1730
- return created.link_id;
1731
- }
1732
- async function sendFile(ctx, input, onPhase) {
1733
- const stat = await fsp.stat(input.path).catch(() => {
1734
- throw new ZasError("upload_failed", 400);
1735
- });
1736
- if (!stat.isFile()) throw new ZasError("upload_failed", 400);
1737
- if (stat.size > MAX_FILE_BYTES) throw new ZasError("file_too_big", 413);
1738
- const grant = await grantFor(ctx, input.channel);
1739
- const name = basename(input.path);
1740
- const title = input.title ?? name;
1741
- const file = await fsp.readFile(input.path);
1742
- if (file.byteLength > MAX_FILE_BYTES) throw new ZasError("file_too_big", 413);
1743
- const bytes = new Uint8Array(file.buffer, file.byteOffset, file.byteLength);
1744
- const contentHash = await blake3Hex(bytes);
1745
- const key = await receiptKey(ctx, grant.channel_id, contentHash, title);
1746
- const stored = receiptFor(ctx, key);
1747
- if (stored) {
1748
- return {
1749
- link_id: stored.link_id,
1750
- channel_id: grant.channel_id,
1751
- channel_name: channelNameOf(ctx.identity, grant),
1752
- bytes: stored.bytes,
1753
- chunks: stored.chunks,
1754
- deduplicated: stored.deduplicated,
1755
- replayed: true
1756
- };
3001
+ close() {
3002
+ try {
3003
+ if (this.fd === void 0) return;
3004
+ closeSync2(this.fd);
3005
+ this.fd = void 0;
3006
+ renameSync3(this.tmp, this.target);
3007
+ this.tmp = void 0;
3008
+ } catch (error) {
3009
+ throw this.remember(error);
3010
+ }
1757
3011
  }
1758
- onPhase?.("hashing");
1759
- const plains = [];
1760
- for await (const piece of chunkStream([bytes])) plains.push(piece);
1761
- const hashes = await Promise.all(plains.map((p) => blake3Bytes(p)));
1762
- const blinds = hashes.map((h) => oprfBlind(h));
1763
- const evaluated = [];
1764
- for (const slice of batches(blinds.map((b) => bytesToB64(b.blindedElement)), sliceSize(ctx))) {
1765
- const answers = await ctx.client.oprfEvaluate(slice);
1766
- if (answers.length !== slice.length) throw new ZasError("oprf_failed", 0);
1767
- for (const one of answers) evaluated.push(one);
3012
+ /** Best effort, and safe to call twice: the engine aborts on every failure
3013
+ * path of its own, and the job aborts again on the way out. */
3014
+ abort() {
3015
+ if (this.fd !== void 0) {
3016
+ try {
3017
+ closeSync2(this.fd);
3018
+ } catch {
3019
+ }
3020
+ this.fd = void 0;
3021
+ }
3022
+ if (this.tmp !== void 0) {
3023
+ if (existsSync3(this.tmp)) try {
3024
+ unlinkSync2(this.tmp);
3025
+ } catch {
3026
+ }
3027
+ this.tmp = void 0;
3028
+ }
3029
+ if (this.created !== void 0) {
3030
+ try {
3031
+ rmdirSync2(this.created);
3032
+ } catch {
3033
+ }
3034
+ this.created = void 0;
3035
+ }
1768
3036
  }
1769
- onPhase?.("encrypting");
1770
- const encs = await Promise.all(plains.map(
1771
- (plain, i) => encryptChunk(oprfFinalize(hashes[i], blinds[i].blind, b64ToBytes(evaluated[i])), plain)
1772
- ));
1773
- onPhase?.("uploading");
1774
- const placed = await placeChunks(ctx, encs);
1775
- onPhase?.("finishing");
1776
- const mime = mimeFor(input.path);
1777
- const thumb = await thumbnailFor(bytes, mime);
1778
- const manifest = newManifest({
1779
- kind: "file",
1780
- name,
1781
- // Only when the caller chose one: absence means "show the file name", and
1782
- // writing the file name into `title` would make a rename look deliberate.
1783
- ...input.title !== void 0 ? { title: input.title } : {},
1784
- mime,
1785
- size: bytes.length,
1786
- created_at: (/* @__PURE__ */ new Date()).toISOString(),
1787
- ...thumb ? { thumb_data: thumb } : {},
1788
- chunks: placed.map((p) => p.entry)
3037
+ /** A `ZasError` says what happened already. An errno does not, so it is kept
3038
+ * and turned into one sentence by the caller — recognised by the shape Node
3039
+ * puts on it (`syscall`/`errno`) rather than by `code`, which `ZasError`
3040
+ * also carries. */
3041
+ remember(error) {
3042
+ const failure = error;
3043
+ if (typeof failure?.syscall === "string" || typeof failure?.errno === "number") this.failure = failure;
3044
+ return error;
3045
+ }
3046
+ };
3047
+ async function receiveDirect(ctx, input, report, deps = {}) {
3048
+ const now = deps.now ?? (() => Date.now());
3049
+ const sleep2 = deps.sleep ?? defaultSleep2;
3050
+ const grant = await receiveGrantFor(ctx, input.channel);
3051
+ const key = channelKeyOf(ctx.identity, grant);
3052
+ const channelName = channelLabel(ctx, grant);
3053
+ const cid = grant.channel_id;
3054
+ const owner = ctx.identity.owner_uid;
3055
+ const device = deps.device ?? newDeviceToken();
3056
+ const { seal, open } = sealer(key, grant.key_version);
3057
+ const stamp = { device, owner_uid: owner };
3058
+ const startedAt = now();
3059
+ const channelPath = `accounts/${owner}/channels/${cid}`;
3060
+ report("waiting");
3061
+ const waitUntil = startedAt + (deps.offerWaitMs ?? OFFER_WAIT_MS2);
3062
+ let id;
3063
+ let meta;
3064
+ while (id === void 0) {
3065
+ const rows = await ctx.client.firestoreRunQuery(channelPath, OPEN_OFFERS).catch(() => []);
3066
+ for (const row of rows) {
3067
+ if (stringField2(row, "state") !== "open") continue;
3068
+ if (stringField2(row, "sender") === ctx.identity.agent_uid) continue;
3069
+ const enc = stringField2(row, "meta_enc");
3070
+ if (enc === void 0) continue;
3071
+ let candidate;
3072
+ try {
3073
+ candidate = open(enc);
3074
+ } catch {
3075
+ continue;
3076
+ }
3077
+ if (typeof candidate?.name !== "string" || typeof candidate?.size !== "number") continue;
3078
+ if (candidate.size > DIRECT_FILE_MAX_BYTES) throw new ZasError("file_too_big", 413);
3079
+ const offerId2 = idOf(row);
3080
+ if (offerId2 === "") continue;
3081
+ try {
3082
+ await ctx.client.api("POST", `/direct/${cid}/${offerId2}/claim`, { size_bytes: candidate.size, ...stamp });
3083
+ } catch (error) {
3084
+ const word = error instanceof ZasError ? error.serverCode ?? error.code : "";
3085
+ if (word === "claimed") throw new ZasError("offer_taken", 409);
3086
+ if (word === "expired" || word === "own_offer" || word === "not_found") continue;
3087
+ throw error;
3088
+ }
3089
+ id = offerId2;
3090
+ meta = candidate;
3091
+ break;
3092
+ }
3093
+ if (id !== void 0) break;
3094
+ if (now() >= waitUntil) throw new ZasError("no_offer", 0);
3095
+ await sleep2(deps.offerPollMs ?? OFFER_POLL_MS2);
3096
+ }
3097
+ const offered = meta;
3098
+ const offerId = id;
3099
+ const route = `/direct/${cid}/${offerId}`;
3100
+ const offerPath = `${channelPath}/direct/${offerId}`;
3101
+ const setState = (state) => ctx.client.api("POST", `${route}/state`, { state, ...stamp }).then(() => void 0, () => void 0);
3102
+ await (deps.installWebRtc ?? installWebRtc)();
3103
+ const fetchIce = async () => {
3104
+ try {
3105
+ const r = await ctx.client.api("POST", `${route}/ice`, stamp);
3106
+ return Array.isArray(r.ice) ? r.ice : [];
3107
+ } catch {
3108
+ return [];
3109
+ }
3110
+ };
3111
+ const turn = await fetchIce();
3112
+ report("connecting");
3113
+ const sink = new DiskSink(input.dest, offerId);
3114
+ let resolveOutcome;
3115
+ const outcome = new Promise((resolve2) => {
3116
+ resolveOutcome = resolve2;
1789
3117
  });
1790
- const linkId = await postLink(
1791
- ctx,
1792
- grant,
1793
- manifest,
1794
- placed,
1795
- agentSendIdempotencyKey(grant.channel_id, contentHash, title)
1796
- );
1797
- const deduplicated = placed.filter((p) => p.proven).length;
1798
- remember(ctx, key, {
1799
- link_id: linkId,
1800
- bytes: bytes.length,
1801
- chunks: placed.length,
1802
- deduplicated,
1803
- at: Date.now()
3118
+ let diag;
3119
+ let via;
3120
+ const handle = (deps.engine ?? startReceiver)({
3121
+ ice: [...DIRECT_ICE, ...turn],
3122
+ refreshIce: async () => [...DIRECT_ICE, ...await fetchIce()],
3123
+ // The name and size this end read off the offer. The engine refuses a
3124
+ // sender that announces anything else.
3125
+ expectedMeta: offered,
3126
+ send: (msg) => ctx.client.api("POST", `${route}/signal`, { payload_enc: seal(msg), for: "sender", ...stamp }).then(() => void 0),
3127
+ onPhase: (phase2) => {
3128
+ if (phase2 === "flight") report("flight");
3129
+ if (phase2 === "done" || phase2 === "failed") resolveOutcome(phase2);
3130
+ },
3131
+ onPath: (p) => {
3132
+ via = p;
3133
+ },
3134
+ onDiag: (d) => {
3135
+ diag = d;
3136
+ },
3137
+ sink: async (m) => {
3138
+ sink.open(m);
3139
+ return sink;
3140
+ }
1804
3141
  });
1805
- return {
1806
- link_id: linkId,
1807
- channel_id: grant.channel_id,
1808
- channel_name: channelNameOf(ctx.identity, grant),
1809
- bytes: bytes.length,
1810
- chunks: placed.length,
1811
- deduplicated,
1812
- replayed: false
1813
- };
1814
- }
1815
- function noteName(text2) {
1816
- return text2.split("\n", 1)[0].trim().slice(0, NOTE_NAME_MAX) || "nota";
1817
- }
1818
- async function sendNote(ctx, input) {
1819
- const grant = await grantFor(ctx, input.channel);
1820
- const name = input.title ?? noteName(input.text);
1821
- const encoded = new TextEncoder().encode(input.text);
1822
- const contentHash = await blake3Hex(new TextEncoder().encode(
1823
- `${input.lang ?? ""}\0${input.secret ? "1" : "0"}\0${input.text}`
1824
- ));
1825
- const key = await receiptKey(ctx, grant.channel_id, contentHash, name);
1826
- const stored = receiptFor(ctx, key);
1827
- if (stored) {
3142
+ let over = false;
3143
+ const pump = (async () => {
3144
+ const seen = /* @__PURE__ */ new Set();
3145
+ while (!over) {
3146
+ let rows = [];
3147
+ try {
3148
+ rows = await ctx.client.firestoreRunQuery(offerPath, SIGNALS_FOR_RECEIVER);
3149
+ } catch {
3150
+ }
3151
+ for (const row of rows) {
3152
+ if (!row?.name || seen.has(row.name)) continue;
3153
+ seen.add(row.name);
3154
+ const enc = stringField2(row, "payload_enc");
3155
+ if (!enc) continue;
3156
+ try {
3157
+ handle.accept(open(enc));
3158
+ } catch {
3159
+ }
3160
+ }
3161
+ if (!over) await Promise.race([outcome, sleep2(deps.signalPollMs ?? SIGNAL_POLL_MS2)]);
3162
+ }
3163
+ })();
3164
+ const phase = await outcome;
3165
+ over = true;
3166
+ await pump;
3167
+ report("finishing");
3168
+ if (phase === "done") {
3169
+ await setState("done");
1828
3170
  return {
1829
- link_id: stored.link_id,
1830
- channel_id: grant.channel_id,
1831
- channel_name: channelNameOf(ctx.identity, grant),
1832
- bytes: stored.bytes,
1833
- chunks: 0,
1834
- deduplicated: 0,
1835
- replayed: true
3171
+ offer_id: offerId,
3172
+ channel_id: cid,
3173
+ channel_name: channelName,
3174
+ name: offered.name,
3175
+ bytes: sink.written,
3176
+ path: sink.target,
3177
+ ...via ? { via } : {},
3178
+ duration_ms: now() - startedAt
1836
3179
  };
1837
3180
  }
1838
- const manifest = newManifest({
1839
- kind: "text",
1840
- name,
1841
- ...input.title !== void 0 ? { title: input.title } : {},
1842
- mime: "text/plain",
1843
- size: encoded.length,
1844
- created_at: (/* @__PURE__ */ new Date()).toISOString(),
1845
- text: input.text,
1846
- ...input.lang ? { code: { lang: input.lang, auto: false } } : {},
1847
- // Only ever true or absent, exactly as the note cover is defined: absent is
1848
- // "the sender did not classify", which no receiver may read as safe.
1849
- ...input.secret ? { sensitive: true } : {},
1850
- chunks: []
1851
- });
1852
- const linkId = await postLink(
1853
- ctx,
1854
- grant,
1855
- manifest,
1856
- [],
1857
- agentSendIdempotencyKey(grant.channel_id, contentHash, name)
1858
- );
1859
- remember(ctx, key, {
1860
- link_id: linkId,
1861
- bytes: encoded.length,
1862
- chunks: 0,
1863
- deduplicated: 0,
1864
- at: Date.now()
3181
+ sink.abort();
3182
+ const doc = await ctx.client.firestoreGet(offerPath).catch(() => null);
3183
+ const withdrawn = doc === null || stringField2(doc, "state") === "cancelled";
3184
+ await setState("failed");
3185
+ if (sink.failure) throw new ZasError("write_failed", 0, sink.failure.message);
3186
+ if (withdrawn) throw new ZasError("direct_cancelled", 0);
3187
+ const reason = diag?.reason || "unknown";
3188
+ deps.onFailed?.({
3189
+ channel_id: cid,
3190
+ channel_name: channelName,
3191
+ offer_id: offerId,
3192
+ owner_uid: owner,
3193
+ device,
3194
+ ...input.dest !== void 0 ? { dest: input.dest } : {},
3195
+ meta: offered,
3196
+ key,
3197
+ key_version: grant.key_version,
3198
+ reason
1865
3199
  });
3200
+ throw new ZasError("direct_failed", 0, reason);
3201
+ }
3202
+ async function receiveDirectFallback(ctx, record, report, deps = {}) {
3203
+ const now = deps.now ?? (() => Date.now());
3204
+ const startedAt = now();
3205
+ const { open } = sealer(record.key, record.key_version);
3206
+ const stamp = { device: record.device, owner_uid: record.owner_uid };
3207
+ const base = `/direct/${record.channel_id}/${record.offer_id}/fallback`;
3208
+ const offerPath = `accounts/${record.owner_uid}/channels/${record.channel_id}/direct/${record.offer_id}`;
3209
+ const doc = await ctx.client.firestoreGet(offerPath);
3210
+ const enc = stringField2(doc, "fallback_meta_enc");
3211
+ let meta;
3212
+ if (enc !== void 0) {
3213
+ try {
3214
+ meta = fallbackMetaOf(open(enc));
3215
+ } catch {
3216
+ meta = void 0;
3217
+ }
3218
+ }
3219
+ if (!meta) throw new ZasError("fallback_unavailable", 409);
3220
+ if (meta.name !== record.meta.name || meta.size !== record.meta.size || meta.mime !== record.meta.mime) {
3221
+ throw new ZasError("file_changed", 0);
3222
+ }
3223
+ const sink = new DiskSink(record.dest, record.offer_id);
3224
+ report("downloading");
3225
+ try {
3226
+ sink.open(meta);
3227
+ await downloadFallback({
3228
+ offerId: record.offer_id,
3229
+ meta,
3230
+ sink,
3231
+ getUrl: async () => (await ctx.client.api("POST", `${base}/download`, stamp)).url,
3232
+ ...deps.fetcher ? { fetcher: deps.fetcher } : {}
3233
+ });
3234
+ } catch (error) {
3235
+ sink.abort();
3236
+ if (sink.failure) throw new ZasError("write_failed", 0, sink.failure.message);
3237
+ if (error instanceof ZasError) throw error;
3238
+ throw new ZasError("network", 0, error instanceof Error ? error.message : String(error));
3239
+ }
3240
+ report("finishing");
3241
+ await ctx.client.api("POST", `${base}/received`, stamp);
1866
3242
  return {
1867
- link_id: linkId,
1868
- channel_id: grant.channel_id,
1869
- channel_name: channelNameOf(ctx.identity, grant),
1870
- bytes: encoded.length,
1871
- chunks: 0,
1872
- deduplicated: 0,
1873
- replayed: false
3243
+ offer_id: record.offer_id,
3244
+ channel_id: record.channel_id,
3245
+ channel_name: record.channel_name,
3246
+ name: meta.name,
3247
+ bytes: sink.written,
3248
+ path: sink.target,
3249
+ duration_ms: now() - startedAt
1874
3250
  };
1875
3251
  }
1876
3252
 
3253
+ // src/jobs.ts
3254
+ import { randomUUID } from "node:crypto";
3255
+ var DEFAULT_WAIT_MS = 6e4;
3256
+ var HISTORY = 50;
3257
+ var JobRunner = class {
3258
+ now;
3259
+ waitMs;
3260
+ /** Newest first, and trimmed. */
3261
+ jobs = [];
3262
+ /** Keyed on the job object, not its id, so a caller holding a job that has
3263
+ * already aged out of the list can still wait for it to finish. */
3264
+ settled = /* @__PURE__ */ new WeakMap();
3265
+ constructor(opts = {}) {
3266
+ this.now = opts.now ?? (() => Date.now());
3267
+ this.waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
3268
+ }
3269
+ start(kind, title, channel, work) {
3270
+ const job = {
3271
+ id: randomUUID(),
3272
+ kind,
3273
+ title,
3274
+ channel,
3275
+ started_at: this.now(),
3276
+ phase: null,
3277
+ status: "running"
3278
+ };
3279
+ this.jobs.unshift(job);
3280
+ this.jobs.length = Math.min(this.jobs.length, HISTORY);
3281
+ const report = (phase) => {
3282
+ if (job.status === "running") job.phase = phase;
3283
+ };
3284
+ this.settled.set(job, work(report).then(
3285
+ (result) => {
3286
+ job.status = "done";
3287
+ job.result = result;
3288
+ return job;
3289
+ },
3290
+ (error) => {
3291
+ job.status = "failed";
3292
+ job.error = error instanceof ZasError ? {
3293
+ code: error.code,
3294
+ status: error.status,
3295
+ sentence: humanSentence(error),
3296
+ message: error.message,
3297
+ ...error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {},
3298
+ ...error.serverCode !== void 0 ? { serverCode: error.serverCode } : {}
3299
+ } : { code: "upload_failed", status: 0, sentence: humanSentence(new ZasError("upload_failed", 0)) };
3300
+ return job;
3301
+ }
3302
+ ));
3303
+ return job;
3304
+ }
3305
+ /** Resolves when the work settles, or when the wait runs out — the same job
3306
+ * object either way, so the caller reads `status` rather than guessing. */
3307
+ async wait(job) {
3308
+ const settled = this.settled.get(job);
3309
+ if (!settled) return job;
3310
+ let timer;
3311
+ const deadline = new Promise((resolve2) => {
3312
+ timer = setTimeout(() => resolve2(job), this.waitMs);
3313
+ });
3314
+ try {
3315
+ return await Promise.race([settled, deadline]);
3316
+ } finally {
3317
+ clearTimeout(timer);
3318
+ }
3319
+ }
3320
+ list() {
3321
+ return this.jobs.slice(0, HISTORY);
3322
+ }
3323
+ get(id) {
3324
+ return this.jobs.find((job) => job.id === id);
3325
+ }
3326
+ };
3327
+
1877
3328
  // src/server.ts
1878
3329
  function agentVersion() {
1879
- return true ? "0.3.0" : "0.0.0-dev";
3330
+ return true ? "0.5.0" : "0.0.0-dev";
1880
3331
  }
1881
3332
  var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
1882
3333
  var PAIR_ANNOUNCE_MS = 15e3;
@@ -1886,8 +3337,8 @@ var delay = (ms) => new Promise((resolve2) => {
1886
3337
  });
1887
3338
  function rightsOf(grant) {
1888
3339
  const rights = [];
1889
- if (grant.send && grant.mode !== "view" && !grant.direct_mode) rights.push("send");
1890
- if (grant.read) rights.push("read");
3340
+ if (grant.send && grant.mode !== "view") rights.push(grant.direct_mode ? "send (Directo)" : "send");
3341
+ if (grant.read) rights.push(grant.direct_mode ? "receive (Directo)" : "read");
1891
3342
  return rights.length > 0 ? rights.join(" \xB7 ") : "no access";
1892
3343
  }
1893
3344
  function buildServer(profile, deps = {}) {
@@ -2021,7 +3472,7 @@ function buildServer(profile, deps = {}) {
2021
3472
  return text2(["pending", ...pairing.logs].join("\n"));
2022
3473
  });
2023
3474
  server.registerTool("zas_send_file", {
2024
- description: "Send a file from this machine into one of the owner's Zas channels. Returns the item id, or a job id when the upload takes longer than a minute. Sends any file this process can read; confirm with the owner before sending secrets, keys or credentials." + AGENT_CUE,
3475
+ description: "Send a file from this machine into one of the owner's Zas channels. Returns the item id, or a job id when the upload takes longer than a minute. A channel in Directo mode refuses this tool: use zas_send_direct there. Sends any file this process can read; confirm with the owner before sending secrets, keys or credentials." + AGENT_CUE,
2025
3476
  inputSchema: {
2026
3477
  path: z.string().describe("Absolute or relative path of the file to send."),
2027
3478
  channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel."),
@@ -2041,6 +3492,110 @@ function buildServer(profile, deps = {}) {
2041
3492
  return failed(e);
2042
3493
  }
2043
3494
  });
3495
+ const failedDirects = /* @__PURE__ */ new Map();
3496
+ const rememberFailed = (jobId, record) => {
3497
+ failedDirects.set(jobId, record);
3498
+ if (failedDirects.size > 50) failedDirects.delete(failedDirects.keys().next().value);
3499
+ };
3500
+ server.registerTool("zas_send_direct", {
3501
+ description: "Send a file from this machine through Directo: a live, device-to-device transfer into one of the owner's channels that is in Directo mode. Nothing is stored. The owner has to press Receive on another device within ten minutes; the call waits a minute and then returns a job id to check with zas_jobs. Returns the transfer result, or a job id. Sends any file this process can read; confirm with the owner before sending secrets, keys or credentials." + AGENT_CUE,
3502
+ inputSchema: {
3503
+ path: z.string().describe("Absolute or relative path of the file to send."),
3504
+ channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel.")
3505
+ }
3506
+ }, async (input) => {
3507
+ try {
3508
+ const c = ctx();
3509
+ let job;
3510
+ job = runner.start(
3511
+ "direct",
3512
+ input.path,
3513
+ input.channel ?? "",
3514
+ (report) => sendDirect(c, input, report, {
3515
+ ...deps.direct ?? {},
3516
+ onFailed: (record) => rememberFailed(job.id, record)
3517
+ })
3518
+ );
3519
+ return settled(await runner.wait(job));
3520
+ } catch (e) {
3521
+ return failed(e);
3522
+ }
3523
+ });
3524
+ server.registerTool("zas_send_direct_fallback", {
3525
+ description: "After a zas_send_direct job failed in flight, deliver the same file through reliable delivery instead. Zas encrypts the file on this machine and stores only that encrypted copy in Cloudflare R2 for up to 24 hours; it uses none of the owner's space, and the device that claimed the offer can download it later. This stops being Directo: the encrypted bytes pass through storage. Ask the owner before you use it; it is their choice. Pass the failed job's id." + AGENT_CUE,
3526
+ inputSchema: {
3527
+ job: z.string().describe("The job id zas_send_direct or zas_jobs reported for the Directo send that failed.")
3528
+ }
3529
+ }, async (input) => {
3530
+ try {
3531
+ const c = ctx();
3532
+ const record = failedDirects.get(input.job);
3533
+ if (!record) return failed(new ZasError("direct_not_failed", 0));
3534
+ const job = runner.start(
3535
+ "fallback",
3536
+ record.name,
3537
+ record.channel_name,
3538
+ (report) => sendDirectFallback(c, record, report, deps.direct)
3539
+ );
3540
+ const outcome = await runner.wait(job);
3541
+ if (outcome.status === "done") failedDirects.delete(input.job);
3542
+ return settled(outcome);
3543
+ } catch (e) {
3544
+ return failed(e);
3545
+ }
3546
+ });
3547
+ const failedReceives = /* @__PURE__ */ new Map();
3548
+ const rememberFailedReceive = (jobId, record) => {
3549
+ failedReceives.set(jobId, record);
3550
+ if (failedReceives.size > 50) failedReceives.delete(failedReceives.keys().next().value);
3551
+ };
3552
+ server.registerTool("zas_receive_direct", {
3553
+ description: "Receive a file the owner sends through Directo, straight onto this machine. Call it when the owner says they are sending you something: it waits for the offer, takes it, and writes the file to disk. Nothing is stored anywhere. Only for a channel in Directo mode, and only with a grant that includes reading. The call waits a minute and then returns a job id to check with zas_jobs; the wait for an offer alone can take ten minutes. Returns the path written; it never overwrites an existing file." + AGENT_CUE,
3554
+ inputSchema: {
3555
+ channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel."),
3556
+ dest: z.string().optional().describe('Where to write the file. A directory means "inside it". Defaults to a fresh temporary directory.')
3557
+ }
3558
+ }, async (input) => {
3559
+ try {
3560
+ const c = ctx();
3561
+ let job;
3562
+ job = runner.start(
3563
+ "receive",
3564
+ input.dest ?? "Directo",
3565
+ input.channel ?? "",
3566
+ (report) => receiveDirect(c, input, report, {
3567
+ ...deps.receive ?? {},
3568
+ onFailed: (record) => rememberFailedReceive(job.id, record)
3569
+ })
3570
+ );
3571
+ return settled(await runner.wait(job));
3572
+ } catch (e) {
3573
+ return failed(e);
3574
+ }
3575
+ });
3576
+ server.registerTool("zas_receive_direct_fallback", {
3577
+ description: "After a zas_receive_direct job failed in flight, download the encrypted copy the sender chose to store instead. It works only if the person who was sending picked reliable delivery for that transfer. The file is decrypted on this machine and written to the same destination. Pass the failed job\u2019s id." + AGENT_CUE,
3578
+ inputSchema: {
3579
+ job: z.string().describe("The job id zas_receive_direct or zas_jobs reported for the receive that failed.")
3580
+ }
3581
+ }, async (input) => {
3582
+ try {
3583
+ const c = ctx();
3584
+ const record = failedReceives.get(input.job);
3585
+ if (!record) return failed(new ZasError("direct_not_failed", 0));
3586
+ const job = runner.start(
3587
+ "fallback",
3588
+ record.meta.name,
3589
+ record.channel_name,
3590
+ (report) => receiveDirectFallback(c, record, report, deps.receive)
3591
+ );
3592
+ const outcome = await runner.wait(job);
3593
+ if (outcome.status === "done") failedReceives.delete(input.job);
3594
+ return settled(outcome);
3595
+ } catch (e) {
3596
+ return failed(e);
3597
+ }
3598
+ });
2044
3599
  server.registerTool("zas_send_note", {
2045
3600
  description: "Send a note \u2014 plain text, or a code snippet with its language \u2014 into one of the owner's Zas channels." + AGENT_CUE,
2046
3601
  inputSchema: {
@@ -2092,7 +3647,7 @@ function buildServer(profile, deps = {}) {
2092
3647
  }
2093
3648
  });
2094
3649
  server.registerTool("zas_jobs", {
2095
- description: "List the sends this server started, newest first, with the phase each one reached and how it ended \u2014 including any `job_id` a send returned; a finished job keeps its result here." + AGENT_CUE
3650
+ description: "List the sends and Directo transfers this server started, newest first, with the phase each one reached and how it ended \u2014 including any `job_id` a send returned; a finished job keeps its result here." + AGENT_CUE
2096
3651
  }, async () => text2(runner.list()));
2097
3652
  return server;
2098
3653
  }