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.
- package/CHANGELOG.md +54 -0
- package/README.md +39 -15
- package/dist/cli.js +2383 -828
- 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/
|
|
947
|
-
import {
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
1839
|
+
return table;
|
|
1840
|
+
})();
|
|
997
1841
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
|
|
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/
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
var
|
|
1061
|
-
var
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
|
1095
|
-
const
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
|
1125
|
-
|
|
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
|
|
1128
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
|
1139
|
-
|
|
1140
|
-
|
|
1969
|
+
function oprfBlind(input) {
|
|
1970
|
+
const blind = randomScalar();
|
|
1971
|
+
const P = hashToGroup(input);
|
|
1972
|
+
return { blind, blindedElement: P.multiply(blind).toRawBytes() };
|
|
1141
1973
|
}
|
|
1142
|
-
|
|
1143
|
-
const
|
|
1144
|
-
const
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
|
1170
|
-
if (
|
|
1171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
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
|
|
1205
|
-
|
|
1206
|
-
|
|
2117
|
+
function sleep(ms) {
|
|
2118
|
+
return new Promise((resolve2) => {
|
|
2119
|
+
setTimeout(resolve2, ms);
|
|
2120
|
+
});
|
|
1207
2121
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1225
|
-
return { target: freeName(at) };
|
|
2155
|
+
throw new ZasError("upload_failed", 409);
|
|
1226
2156
|
}
|
|
1227
|
-
async function
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
1251
|
-
if (
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
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/
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
|
2451
|
+
return webrtc;
|
|
1315
2452
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
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/
|
|
1375
|
-
import {
|
|
1376
|
-
import {
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
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
|
|
2740
|
+
return null;
|
|
1467
2741
|
}
|
|
1468
2742
|
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
|
2761
|
+
async function readGrant(ctx, channel) {
|
|
1546
2762
|
const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
|
|
1547
|
-
if (!grant.
|
|
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
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
|
1556
|
-
|
|
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
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
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
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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
|
-
|
|
2865
|
+
throw new ZasError("write_failed", 0, target);
|
|
1626
2866
|
}
|
|
1627
|
-
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
const
|
|
1631
|
-
|
|
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
|
|
1641
|
-
const
|
|
1642
|
-
|
|
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
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
|
1662
|
-
if (
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
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
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
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
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
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
|
-
|
|
1830
|
-
channel_id:
|
|
1831
|
-
channel_name:
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
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
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
grant,
|
|
1855
|
-
|
|
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
|
-
|
|
1868
|
-
channel_id:
|
|
1869
|
-
channel_name:
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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.
|
|
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"
|
|
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
|
}
|