zas-agent 0.3.0 → 0.4.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 +26 -0
- package/README.md +27 -9
- package/dist/cli.js +1415 -484
- 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,13 @@ 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
|
+
direct_cancelled: "The offer was cancelled from the receiving side.",
|
|
171
|
+
direct_failed: "The Directo transfer failed ({path}). Ask the owner before you use zas_send_direct_fallback.",
|
|
172
|
+
direct_not_failed: "That job is not a Directo send that failed in flight.",
|
|
173
|
+
file_changed: "The file changed since the Directo offer. Send it again.",
|
|
174
|
+
webrtc_unavailable: "The WebRTC engine (node-datachannel) could not be loaded on this machine.",
|
|
175
|
+
fallback_unavailable: "Reliable delivery is not available right now. Try again later.",
|
|
164
176
|
key_stale: "The channel key changed. The owner refreshes it by opening Zas.",
|
|
165
177
|
quota_exceeded: "The account reached its storage limit.",
|
|
166
178
|
rate_limited: "Too many sends in a row. Wait a moment.",
|
|
@@ -550,6 +562,17 @@ var ZasClient = class _ZasClient {
|
|
|
550
562
|
if (!Array.isArray(parsed)) return [];
|
|
551
563
|
return parsed.filter((row) => !!row && typeof row === "object" && "document" in row).map((row) => row.document);
|
|
552
564
|
}
|
|
565
|
+
/** Firestore REST get of one document, `null` when it is not there. The
|
|
566
|
+
* rules decide what an agent may read; here that is its own Directo offer,
|
|
567
|
+
* which an offer's sender may read without a grant on the channel. */
|
|
568
|
+
async firestoreGet(path) {
|
|
569
|
+
const base = _ZasClient.firestoreBase(this.identity.firestore_project);
|
|
570
|
+
const res = await this.authed(`${base}/${path}`, "GET", void 0, {});
|
|
571
|
+
const parsed = await readBody2(res);
|
|
572
|
+
if (res.status === 404) return null;
|
|
573
|
+
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
574
|
+
return parsed;
|
|
575
|
+
}
|
|
553
576
|
/** The web app's public config value. It identifies the project, it is not a secret. */
|
|
554
577
|
static apiKey() {
|
|
555
578
|
return process.env.ZAS_FIREBASE_API_KEY || "AIzaSyAiZbAPrxH7EKaJftJoGcEVEL0h6rAVcvE";
|
|
@@ -797,6 +820,11 @@ async function runPair(opts) {
|
|
|
797
820
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
798
821
|
import { z } from "zod";
|
|
799
822
|
|
|
823
|
+
// src/direct.ts
|
|
824
|
+
import { randomBytes } from "node:crypto";
|
|
825
|
+
import { openAsBlob } from "node:fs";
|
|
826
|
+
import { basename as basename2 } from "node:path";
|
|
827
|
+
|
|
800
828
|
// src/shared/manifest.ts
|
|
801
829
|
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
|
802
830
|
|
|
@@ -861,6 +889,8 @@ function openSealed(key, sealed) {
|
|
|
861
889
|
}
|
|
862
890
|
return xchacha20poly1305(key, sealed.slice(0, 24)).decrypt(sealed.slice(24));
|
|
863
891
|
}
|
|
892
|
+
var sealRaw = sealBytes;
|
|
893
|
+
var openRaw = openSealed;
|
|
864
894
|
function sealManifest(channelKey, manifest, version = KEY_VERSION_LEGACY) {
|
|
865
895
|
return sealBytes(channelKey, version, new TextEncoder().encode(JSON.stringify(manifest)));
|
|
866
896
|
}
|
|
@@ -873,6 +903,604 @@ function decryptChannelName(channelKey, sealed) {
|
|
|
873
903
|
return new TextDecoder().decode(openSealed(channelKey, sealed));
|
|
874
904
|
}
|
|
875
905
|
|
|
906
|
+
// src/shared/direct.ts
|
|
907
|
+
var DIRECT_OFFER_TTL_MS = 10 * 60 * 1e3;
|
|
908
|
+
var DIRECT_CLAIMED_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
909
|
+
var DIRECT_DONE_TTL_MS = 15 * 1e3;
|
|
910
|
+
var DIRECT_FILE_MAX_BYTES = 20 * 1024 * 1024 * 1024;
|
|
911
|
+
var DIRECT_FREE_FILE_MAX_BYTES = 10 * 1024 * 1024 * 1024;
|
|
912
|
+
var DIRECT_ANON_FILE_MAX_BYTES = 5 * 1024 * 1024 * 1024;
|
|
913
|
+
var DIRECT_ENTERPRISE_FILE_MAX_BYTES = 100 * 1024 * 1024 * 1024;
|
|
914
|
+
var DIRECT_CHUNK_BYTES = 64 * 1024;
|
|
915
|
+
var DIRECT_BUFFERED_HIGH = 8 * 1024 * 1024;
|
|
916
|
+
var DIRECT_RECEIVE_WINDOW_BYTES = 4 * 1024 * 1024;
|
|
917
|
+
var DIRECT_MEMORY_SINK_MAX_BYTES = 128 * 1024 * 1024;
|
|
918
|
+
var DIRECT_FALLBACK_PART_BYTES = 16 * 1024 * 1024;
|
|
919
|
+
var DIRECT_FALLBACK_TAG_BYTES = 16;
|
|
920
|
+
var DIRECT_FALLBACK_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
921
|
+
function directFallbackPartCount(size) {
|
|
922
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Error("bad_size");
|
|
923
|
+
return Math.max(1, Math.ceil(size / DIRECT_FALLBACK_PART_BYTES));
|
|
924
|
+
}
|
|
925
|
+
function directFallbackCipherSize(size) {
|
|
926
|
+
return size + directFallbackPartCount(size) * DIRECT_FALLBACK_TAG_BYTES;
|
|
927
|
+
}
|
|
928
|
+
var DIRECT_CONNECT_TIMEOUT_MS = 30 * 1e3;
|
|
929
|
+
var DIRECT_STALL_GRACE_MS = 5 * 1e3;
|
|
930
|
+
var DIRECT_SIGNAL_WAIT_MS = 90 * 1e3;
|
|
931
|
+
|
|
932
|
+
// src/shared/direct-engine.ts
|
|
933
|
+
import { createSHA256 } from "hash-wasm";
|
|
934
|
+
|
|
935
|
+
// src/shared/direct-protocol.ts
|
|
936
|
+
var FRAME_META = 1;
|
|
937
|
+
var FRAME_CHUNK = 2;
|
|
938
|
+
var FRAME_DONE = 3;
|
|
939
|
+
var FRAME_ABORT = 4;
|
|
940
|
+
var FRAME_CREDIT = 5;
|
|
941
|
+
function encodeMeta(meta) {
|
|
942
|
+
const body = new TextEncoder().encode(JSON.stringify(meta));
|
|
943
|
+
const frame = new Uint8Array(1 + body.length);
|
|
944
|
+
frame[0] = FRAME_META;
|
|
945
|
+
frame.set(body, 1);
|
|
946
|
+
return frame;
|
|
947
|
+
}
|
|
948
|
+
function encodeChunk(bytes) {
|
|
949
|
+
if (bytes.length > DIRECT_CHUNK_BYTES - 1) throw new Error("chunk_too_big");
|
|
950
|
+
const frame = new Uint8Array(1 + bytes.length);
|
|
951
|
+
frame[0] = FRAME_CHUNK;
|
|
952
|
+
frame.set(bytes, 1);
|
|
953
|
+
return frame;
|
|
954
|
+
}
|
|
955
|
+
function encodeDone(digest) {
|
|
956
|
+
if (!digest) return new Uint8Array([FRAME_DONE]);
|
|
957
|
+
const body = new TextEncoder().encode(JSON.stringify(digest));
|
|
958
|
+
const frame = new Uint8Array(1 + body.length);
|
|
959
|
+
frame[0] = FRAME_DONE;
|
|
960
|
+
frame.set(body, 1);
|
|
961
|
+
return frame;
|
|
962
|
+
}
|
|
963
|
+
var DONE_FRAME = encodeDone();
|
|
964
|
+
var ABORT_FRAME = new Uint8Array([FRAME_ABORT]);
|
|
965
|
+
function parseFrame(data) {
|
|
966
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
967
|
+
if (bytes.length === 0) throw new Error("empty_frame");
|
|
968
|
+
const payload = bytes.subarray(1);
|
|
969
|
+
switch (bytes[0]) {
|
|
970
|
+
case FRAME_META: {
|
|
971
|
+
const meta = JSON.parse(new TextDecoder().decode(payload));
|
|
972
|
+
if (typeof meta.name !== "string" || typeof meta.size !== "number") {
|
|
973
|
+
throw new Error("bad_meta");
|
|
974
|
+
}
|
|
975
|
+
return { type: "meta", meta };
|
|
976
|
+
}
|
|
977
|
+
case FRAME_CHUNK:
|
|
978
|
+
return { type: "chunk", payload };
|
|
979
|
+
case FRAME_DONE: {
|
|
980
|
+
if (payload.length === 0) return { type: "done" };
|
|
981
|
+
const digest = JSON.parse(new TextDecoder().decode(payload));
|
|
982
|
+
if (!Number.isSafeInteger(digest.size) || digest.size < 0 || typeof digest.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(digest.sha256)) throw new Error("bad_digest");
|
|
983
|
+
return { type: "done", digest };
|
|
984
|
+
}
|
|
985
|
+
case FRAME_ABORT:
|
|
986
|
+
return { type: "abort" };
|
|
987
|
+
case FRAME_CREDIT: {
|
|
988
|
+
const received = Number(new TextDecoder().decode(payload));
|
|
989
|
+
if (!Number.isSafeInteger(received) || received < 0) throw new Error("bad_credit");
|
|
990
|
+
return { type: "credit", received };
|
|
991
|
+
}
|
|
992
|
+
default:
|
|
993
|
+
throw new Error("unknown_frame");
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/shared/direct-engine.ts
|
|
998
|
+
var DIRECT_ICE = [
|
|
999
|
+
{ urls: ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"] }
|
|
1000
|
+
];
|
|
1001
|
+
function candType(candidate) {
|
|
1002
|
+
const m = /\styp\s+(\S+)/.exec(candidate?.candidate ?? "");
|
|
1003
|
+
return m ? m[1] : "other";
|
|
1004
|
+
}
|
|
1005
|
+
function countCand(diag, side, c) {
|
|
1006
|
+
if (!c) return;
|
|
1007
|
+
const t = candType(c);
|
|
1008
|
+
if (t === "host") diag[side === "local" ? "localHost" : "remoteHost"]++;
|
|
1009
|
+
else if (t === "srflx" || t === "prflx") diag[side === "local" ? "localSrflx" : "remoteSrflx"]++;
|
|
1010
|
+
else if (t === "relay") diag[side === "local" ? "localRelay" : "remoteRelay"]++;
|
|
1011
|
+
}
|
|
1012
|
+
function countTurnUrls(servers) {
|
|
1013
|
+
let n = 0;
|
|
1014
|
+
for (const server of servers ?? []) {
|
|
1015
|
+
const urls = typeof server.urls === "string" ? [server.urls] : server.urls;
|
|
1016
|
+
for (const url of urls ?? []) {
|
|
1017
|
+
if (/^turns?:/i.test(url)) n++;
|
|
1018
|
+
}
|
|
1019
|
+
const legacy = server.url;
|
|
1020
|
+
if (typeof legacy === "string" && /^turns?:/i.test(legacy)) n++;
|
|
1021
|
+
}
|
|
1022
|
+
return n;
|
|
1023
|
+
}
|
|
1024
|
+
function pairFromStats(stats) {
|
|
1025
|
+
let pair;
|
|
1026
|
+
stats.forEach((s) => {
|
|
1027
|
+
if (s.type === "candidate-pair" && s.state === "succeeded" && (s.nominated || s.selected)) {
|
|
1028
|
+
pair = s;
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
if (!pair?.localCandidateId || !pair.remoteCandidateId) return void 0;
|
|
1032
|
+
const local = stats.get(pair.localCandidateId);
|
|
1033
|
+
const remote = stats.get(pair.remoteCandidateId);
|
|
1034
|
+
if (!local?.candidateType || !remote?.candidateType) return void 0;
|
|
1035
|
+
return { local: local.candidateType, remote: remote.candidateType };
|
|
1036
|
+
}
|
|
1037
|
+
function pathOf(pair) {
|
|
1038
|
+
if (pair.local === "relay" || pair.remote === "relay") return "relay";
|
|
1039
|
+
return pair.local === "host" && pair.remote === "host" ? "lan" : "wan";
|
|
1040
|
+
}
|
|
1041
|
+
function session(ice, onPhase, onDiag, iceTransportPolicy = "all") {
|
|
1042
|
+
const initialIce = ice ?? DIRECT_ICE;
|
|
1043
|
+
const pc = new RTCPeerConnection({
|
|
1044
|
+
iceServers: initialIce,
|
|
1045
|
+
iceTransportPolicy
|
|
1046
|
+
});
|
|
1047
|
+
let phase = "connecting";
|
|
1048
|
+
let disconnectTimer;
|
|
1049
|
+
let restartTimer;
|
|
1050
|
+
let stallState = "idle";
|
|
1051
|
+
const startedAt = Date.now();
|
|
1052
|
+
const diag = {
|
|
1053
|
+
reason: "",
|
|
1054
|
+
ms: 0,
|
|
1055
|
+
iceState: "",
|
|
1056
|
+
gatherState: "",
|
|
1057
|
+
hadRemoteDesc: false,
|
|
1058
|
+
localHost: 0,
|
|
1059
|
+
localSrflx: 0,
|
|
1060
|
+
localRelay: 0,
|
|
1061
|
+
remoteHost: 0,
|
|
1062
|
+
remoteSrflx: 0,
|
|
1063
|
+
remoteRelay: 0,
|
|
1064
|
+
turnUrlsSupplied: countTurnUrls(initialIce),
|
|
1065
|
+
turnUrlsConfigured: 0,
|
|
1066
|
+
bytes: 0,
|
|
1067
|
+
restarts: 0
|
|
1068
|
+
};
|
|
1069
|
+
const cleanup = () => {
|
|
1070
|
+
clearTimeout(waitTimer);
|
|
1071
|
+
clearTimeout(connectTimer);
|
|
1072
|
+
clearTimeout(disconnectTimer);
|
|
1073
|
+
clearTimeout(restartTimer);
|
|
1074
|
+
pc.onicecandidate = null;
|
|
1075
|
+
pc.oniceconnectionstatechange = null;
|
|
1076
|
+
pc.close();
|
|
1077
|
+
};
|
|
1078
|
+
const setPhase = (p) => {
|
|
1079
|
+
if (phase === "done" || phase === "failed") return;
|
|
1080
|
+
phase = p;
|
|
1081
|
+
if (p === "done" || p === "failed") {
|
|
1082
|
+
diag.ms = Date.now() - startedAt;
|
|
1083
|
+
diag.iceState = String(pc.iceConnectionState ?? "");
|
|
1084
|
+
diag.gatherState = String(pc.iceGatheringState ?? "");
|
|
1085
|
+
diag.hadRemoteDesc = !!pc.remoteDescription;
|
|
1086
|
+
diag.turnUrlsConfigured = countTurnUrls(pc.getConfiguration().iceServers);
|
|
1087
|
+
onDiag?.(diag);
|
|
1088
|
+
onPhase(p);
|
|
1089
|
+
cleanup();
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
onPhase(p);
|
|
1093
|
+
};
|
|
1094
|
+
const fail = (reason) => {
|
|
1095
|
+
if (phase === "done" || phase === "failed") return;
|
|
1096
|
+
if (!diag.reason) diag.reason = reason;
|
|
1097
|
+
setPhase("failed");
|
|
1098
|
+
};
|
|
1099
|
+
const waitTimer = setTimeout(() => fail("peer_silent"), DIRECT_SIGNAL_WAIT_MS);
|
|
1100
|
+
let connectTimer;
|
|
1101
|
+
let heard = false;
|
|
1102
|
+
const signaled = () => {
|
|
1103
|
+
if (heard) return;
|
|
1104
|
+
heard = true;
|
|
1105
|
+
clearTimeout(waitTimer);
|
|
1106
|
+
connectTimer = setTimeout(() => fail("connect_timeout"), DIRECT_CONNECT_TIMEOUT_MS);
|
|
1107
|
+
};
|
|
1108
|
+
let onStall;
|
|
1109
|
+
let stallWindowMs = DIRECT_CONNECT_TIMEOUT_MS;
|
|
1110
|
+
const stalled = () => {
|
|
1111
|
+
if (phase === "done" || phase === "failed") return;
|
|
1112
|
+
clearTimeout(disconnectTimer);
|
|
1113
|
+
if (stallState === "restarting") return;
|
|
1114
|
+
if (diag.msConnect === void 0 || !onStall) {
|
|
1115
|
+
fail("disconnected");
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
stallState = "restarting";
|
|
1119
|
+
diag.restarts++;
|
|
1120
|
+
clearTimeout(restartTimer);
|
|
1121
|
+
restartTimer = setTimeout(() => fail("disconnected"), stallWindowMs);
|
|
1122
|
+
onStall();
|
|
1123
|
+
};
|
|
1124
|
+
pc.oniceconnectionstatechange = () => {
|
|
1125
|
+
const s = pc.iceConnectionState;
|
|
1126
|
+
if (s === "failed") {
|
|
1127
|
+
if (diag.msConnect !== void 0 && onStall) stalled();
|
|
1128
|
+
else fail("ice_failed");
|
|
1129
|
+
}
|
|
1130
|
+
if (s === "disconnected" && stallState === "idle") {
|
|
1131
|
+
stallState = "grace";
|
|
1132
|
+
disconnectTimer = setTimeout(stalled, DIRECT_STALL_GRACE_MS);
|
|
1133
|
+
}
|
|
1134
|
+
if (s === "connected" || s === "completed") {
|
|
1135
|
+
clearTimeout(disconnectTimer);
|
|
1136
|
+
clearTimeout(restartTimer);
|
|
1137
|
+
stallState = "idle";
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
return {
|
|
1141
|
+
pc,
|
|
1142
|
+
diag,
|
|
1143
|
+
setPhase,
|
|
1144
|
+
fail,
|
|
1145
|
+
cleanup,
|
|
1146
|
+
signaled,
|
|
1147
|
+
connected: () => {
|
|
1148
|
+
clearTimeout(waitTimer);
|
|
1149
|
+
clearTimeout(connectTimer);
|
|
1150
|
+
if (diag.msConnect === void 0) diag.msConnect = Date.now() - startedAt;
|
|
1151
|
+
},
|
|
1152
|
+
isTerminal: () => phase === "done" || phase === "failed",
|
|
1153
|
+
setStall: (fn, windowMs) => {
|
|
1154
|
+
onStall = fn;
|
|
1155
|
+
stallWindowMs = windowMs;
|
|
1156
|
+
},
|
|
1157
|
+
setIceServers: (iceServers) => {
|
|
1158
|
+
diag.turnUrlsSupplied = countTurnUrls(iceServers);
|
|
1159
|
+
pc.setConfiguration({ iceServers });
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
function signalPlumbing(pc, send, onSendError, diag) {
|
|
1164
|
+
let localGeneration = 1;
|
|
1165
|
+
let remoteGeneration = 0;
|
|
1166
|
+
let remoteReady = false;
|
|
1167
|
+
const pendingByGeneration = /* @__PURE__ */ new Map();
|
|
1168
|
+
let pendingLegacy = [];
|
|
1169
|
+
pc.onicecandidate = (e) => {
|
|
1170
|
+
const c = e.candidate;
|
|
1171
|
+
countCand(diag, "local", c);
|
|
1172
|
+
void send({
|
|
1173
|
+
kind: "ice",
|
|
1174
|
+
candidate: c ? c.toJSON ? c.toJSON() : c : null,
|
|
1175
|
+
protocol: 2,
|
|
1176
|
+
generation: localGeneration
|
|
1177
|
+
}).catch(onSendError);
|
|
1178
|
+
};
|
|
1179
|
+
const apply = (candidate) => {
|
|
1180
|
+
void pc.addIceCandidate(candidate ?? void 0).catch(() => void 0);
|
|
1181
|
+
};
|
|
1182
|
+
const applyIce = (candidate, generation) => {
|
|
1183
|
+
countCand(diag, "remote", candidate);
|
|
1184
|
+
const normalized = candidate ?? null;
|
|
1185
|
+
if (generation === void 0) {
|
|
1186
|
+
if (remoteReady) apply(normalized);
|
|
1187
|
+
else pendingLegacy.push(normalized);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (generation < remoteGeneration) return;
|
|
1191
|
+
if (remoteReady && generation === remoteGeneration) {
|
|
1192
|
+
apply(normalized);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const queued = pendingByGeneration.get(generation) ?? [];
|
|
1196
|
+
queued.push(normalized);
|
|
1197
|
+
pendingByGeneration.set(generation, queued);
|
|
1198
|
+
};
|
|
1199
|
+
const expectRemoteGeneration = (generation) => {
|
|
1200
|
+
remoteGeneration = generation;
|
|
1201
|
+
remoteReady = false;
|
|
1202
|
+
};
|
|
1203
|
+
const remoteDescriptionSet = (generation) => {
|
|
1204
|
+
remoteGeneration = generation;
|
|
1205
|
+
remoteReady = true;
|
|
1206
|
+
const queued = pendingByGeneration.get(generation) ?? [];
|
|
1207
|
+
pendingByGeneration.delete(generation);
|
|
1208
|
+
const legacy = pendingLegacy;
|
|
1209
|
+
pendingLegacy = [];
|
|
1210
|
+
queued.forEach(apply);
|
|
1211
|
+
legacy.forEach(apply);
|
|
1212
|
+
};
|
|
1213
|
+
return {
|
|
1214
|
+
applyIce,
|
|
1215
|
+
expectRemoteGeneration,
|
|
1216
|
+
remoteDescriptionSet,
|
|
1217
|
+
setLocalGeneration: (generation) => void (localGeneration = generation)
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
function startSender(opts) {
|
|
1221
|
+
const s = session(opts.ice, opts.onPhase, opts.onDiag, opts.iceTransportPolicy);
|
|
1222
|
+
opts.onPhase("connecting");
|
|
1223
|
+
const { pc } = s;
|
|
1224
|
+
const plumbing = signalPlumbing(pc, opts.send, () => s.fail("signal_send"), s.diag);
|
|
1225
|
+
let generation = 1;
|
|
1226
|
+
let peerProtocol = 1;
|
|
1227
|
+
s.setStall(() => {
|
|
1228
|
+
void (async () => {
|
|
1229
|
+
generation++;
|
|
1230
|
+
plumbing.setLocalGeneration(generation);
|
|
1231
|
+
plumbing.expectRemoteGeneration(generation);
|
|
1232
|
+
if (opts.refreshIce) {
|
|
1233
|
+
const iceServers = await opts.refreshIce();
|
|
1234
|
+
s.setIceServers(iceServers);
|
|
1235
|
+
}
|
|
1236
|
+
pc.restartIce?.();
|
|
1237
|
+
const offer = await pc.createOffer({ iceRestart: true });
|
|
1238
|
+
await pc.setLocalDescription(offer);
|
|
1239
|
+
await opts.send({ kind: "offer", sdp: offer.sdp, protocol: 2, generation });
|
|
1240
|
+
})().catch(() => s.fail("signaling"));
|
|
1241
|
+
}, DIRECT_CONNECT_TIMEOUT_MS);
|
|
1242
|
+
const dc = pc.createDataChannel("zas-direct", { ordered: true });
|
|
1243
|
+
dc.binaryType = "arraybuffer";
|
|
1244
|
+
dc.bufferedAmountLowThreshold = DIRECT_BUFFERED_HIGH / 8;
|
|
1245
|
+
const waitLow = () => new Promise((resolve2) => {
|
|
1246
|
+
const done = () => {
|
|
1247
|
+
dc.removeEventListener("bufferedamountlow", done);
|
|
1248
|
+
resolve2();
|
|
1249
|
+
};
|
|
1250
|
+
dc.addEventListener("bufferedamountlow", done);
|
|
1251
|
+
});
|
|
1252
|
+
let persisted = 0;
|
|
1253
|
+
const creditWaiters = [];
|
|
1254
|
+
const wakeCredits = () => {
|
|
1255
|
+
for (let i = creditWaiters.length - 1; i >= 0; i--) {
|
|
1256
|
+
if (s.isTerminal() || persisted >= creditWaiters[i].target) {
|
|
1257
|
+
creditWaiters.splice(i, 1)[0].resolve();
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
const waitPersisted = (target) => {
|
|
1262
|
+
if (peerProtocol < 2 || persisted >= target || s.isTerminal()) return Promise.resolve();
|
|
1263
|
+
return new Promise((resolve2) => creditWaiters.push({ target, resolve: resolve2 }));
|
|
1264
|
+
};
|
|
1265
|
+
const pump = async () => {
|
|
1266
|
+
const meta = {
|
|
1267
|
+
name: opts.file.name ?? opts.name ?? "zas",
|
|
1268
|
+
size: opts.file.size,
|
|
1269
|
+
mime: opts.file.type || "application/octet-stream",
|
|
1270
|
+
...opts.label !== void 0 ? { label: opts.label } : {}
|
|
1271
|
+
};
|
|
1272
|
+
dc.send(encodeMeta(meta));
|
|
1273
|
+
const hasher = await createSHA256();
|
|
1274
|
+
let sent = 0;
|
|
1275
|
+
for (let off = 0; off < opts.file.size; off += DIRECT_RECEIVE_WINDOW_BYTES) {
|
|
1276
|
+
const window = new Uint8Array(
|
|
1277
|
+
await opts.file.slice(
|
|
1278
|
+
off,
|
|
1279
|
+
Math.min(off + DIRECT_RECEIVE_WINDOW_BYTES, opts.file.size)
|
|
1280
|
+
).arrayBuffer()
|
|
1281
|
+
);
|
|
1282
|
+
hasher.update(window);
|
|
1283
|
+
if (s.isTerminal()) return;
|
|
1284
|
+
for (let at = 0; at < window.length; at += DIRECT_CHUNK_BYTES - 1) {
|
|
1285
|
+
if (dc.bufferedAmount > DIRECT_BUFFERED_HIGH) await waitLow();
|
|
1286
|
+
if (s.isTerminal()) return;
|
|
1287
|
+
const slice = window.subarray(at, Math.min(at + DIRECT_CHUNK_BYTES - 1, window.length));
|
|
1288
|
+
dc.send(encodeChunk(slice));
|
|
1289
|
+
sent += slice.length;
|
|
1290
|
+
s.diag.bytes = sent;
|
|
1291
|
+
opts.onProgress?.(sent, opts.file.size);
|
|
1292
|
+
}
|
|
1293
|
+
await waitPersisted(sent);
|
|
1294
|
+
if (s.isTerminal()) return;
|
|
1295
|
+
}
|
|
1296
|
+
dc.send(encodeDone({ size: sent, sha256: hasher.digest() }));
|
|
1297
|
+
};
|
|
1298
|
+
dc.onopen = () => {
|
|
1299
|
+
s.connected();
|
|
1300
|
+
s.setPhase("flight");
|
|
1301
|
+
void pc.getStats().then((stats) => {
|
|
1302
|
+
const pair = pairFromStats(stats);
|
|
1303
|
+
if (!pair) return;
|
|
1304
|
+
s.diag.pairLocal = pair.local;
|
|
1305
|
+
s.diag.pairRemote = pair.remote;
|
|
1306
|
+
opts.onPath?.(pathOf(pair));
|
|
1307
|
+
}).catch(() => void 0);
|
|
1308
|
+
void pump().catch(() => s.fail("transfer"));
|
|
1309
|
+
};
|
|
1310
|
+
dc.onmessage = (e) => {
|
|
1311
|
+
try {
|
|
1312
|
+
const frame = parseFrame(e.data);
|
|
1313
|
+
if (frame.type === "done") s.setPhase("done");
|
|
1314
|
+
if (frame.type === "abort") s.fail("peer_abort");
|
|
1315
|
+
if (frame.type === "credit") {
|
|
1316
|
+
if (frame.received < persisted || frame.received > opts.file.size) throw new Error("bad_credit");
|
|
1317
|
+
persisted = frame.received;
|
|
1318
|
+
wakeCredits();
|
|
1319
|
+
}
|
|
1320
|
+
} catch {
|
|
1321
|
+
s.fail("protocol");
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
dc.onclose = () => {
|
|
1325
|
+
wakeCredits();
|
|
1326
|
+
if (!s.isTerminal()) s.fail("peer_closed");
|
|
1327
|
+
};
|
|
1328
|
+
void (async () => {
|
|
1329
|
+
const offer = await pc.createOffer();
|
|
1330
|
+
await pc.setLocalDescription(offer);
|
|
1331
|
+
plumbing.setLocalGeneration(generation);
|
|
1332
|
+
plumbing.expectRemoteGeneration(generation);
|
|
1333
|
+
await opts.send({ kind: "offer", sdp: offer.sdp, protocol: 2, generation });
|
|
1334
|
+
})().catch(() => s.fail("signaling"));
|
|
1335
|
+
return {
|
|
1336
|
+
accept: (msg) => {
|
|
1337
|
+
if (s.isTerminal()) return;
|
|
1338
|
+
s.signaled();
|
|
1339
|
+
if (msg.kind === "answer" && pc.signalingState === "have-local-offer") {
|
|
1340
|
+
const answerGeneration = msg.generation ?? generation;
|
|
1341
|
+
if (msg.generation !== void 0 && answerGeneration !== generation) return;
|
|
1342
|
+
peerProtocol = msg.protocol === 2 ? 2 : 1;
|
|
1343
|
+
plumbing.expectRemoteGeneration(answerGeneration);
|
|
1344
|
+
void pc.setRemoteDescription({ type: "answer", sdp: msg.sdp }).then(() => plumbing.remoteDescriptionSet(answerGeneration)).catch(() => s.fail("signaling"));
|
|
1345
|
+
}
|
|
1346
|
+
if (msg.kind === "ice") plumbing.applyIce(msg.candidate, msg.generation);
|
|
1347
|
+
},
|
|
1348
|
+
close: () => {
|
|
1349
|
+
if (!s.isTerminal() && dc.readyState === "open") {
|
|
1350
|
+
try {
|
|
1351
|
+
dc.send(ABORT_FRAME);
|
|
1352
|
+
} catch {
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
wakeCredits();
|
|
1356
|
+
s.cleanup();
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/shared/direct-fallback.ts
|
|
1362
|
+
function createFallbackMeta(file) {
|
|
1363
|
+
const key = crypto.getRandomValues(new Uint8Array(32));
|
|
1364
|
+
const nonce = crypto.getRandomValues(new Uint8Array(8));
|
|
1365
|
+
return {
|
|
1366
|
+
v: 1,
|
|
1367
|
+
name: file.name,
|
|
1368
|
+
size: file.size,
|
|
1369
|
+
mime: file.type,
|
|
1370
|
+
key_b64: bytesToB64(key),
|
|
1371
|
+
nonce_b64: bytesToB64(nonce),
|
|
1372
|
+
part_bytes: DIRECT_FALLBACK_PART_BYTES,
|
|
1373
|
+
tag_bytes: DIRECT_FALLBACK_TAG_BYTES,
|
|
1374
|
+
part_count: directFallbackPartCount(file.size),
|
|
1375
|
+
cipher_size: directFallbackCipherSize(file.size)
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
function fallbackMetaOf(value) {
|
|
1379
|
+
const meta = value;
|
|
1380
|
+
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");
|
|
1381
|
+
let key;
|
|
1382
|
+
let nonce;
|
|
1383
|
+
try {
|
|
1384
|
+
key = b64ToBytes(meta.key_b64);
|
|
1385
|
+
nonce = b64ToBytes(meta.nonce_b64);
|
|
1386
|
+
} catch {
|
|
1387
|
+
throw new Error("bad_fallback_meta");
|
|
1388
|
+
}
|
|
1389
|
+
if (key.length !== 32 || nonce.length !== 8) throw new Error("bad_fallback_meta");
|
|
1390
|
+
return meta;
|
|
1391
|
+
}
|
|
1392
|
+
function partPlainBytes(meta, partNumber) {
|
|
1393
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > meta.part_count) {
|
|
1394
|
+
throw new Error("bad_fallback_part");
|
|
1395
|
+
}
|
|
1396
|
+
if (partNumber < meta.part_count) return meta.part_bytes;
|
|
1397
|
+
return meta.size - meta.part_bytes * (meta.part_count - 1);
|
|
1398
|
+
}
|
|
1399
|
+
function ivFor(meta, partNumber) {
|
|
1400
|
+
const iv = new Uint8Array(12);
|
|
1401
|
+
iv.set(b64ToBytes(meta.nonce_b64), 0);
|
|
1402
|
+
new DataView(iv.buffer).setUint32(8, partNumber, false);
|
|
1403
|
+
return iv;
|
|
1404
|
+
}
|
|
1405
|
+
function aadFor(offerId, partNumber, plainBytes) {
|
|
1406
|
+
return new TextEncoder().encode(
|
|
1407
|
+
`zas-direct-fallback-v1\0${offerId}\0${partNumber}\0${plainBytes}`
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
async function aesKey(meta) {
|
|
1411
|
+
return crypto.subtle.importKey(
|
|
1412
|
+
"raw",
|
|
1413
|
+
b64ToBytes(meta.key_b64),
|
|
1414
|
+
{ name: "AES-GCM" },
|
|
1415
|
+
false,
|
|
1416
|
+
["encrypt", "decrypt"]
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
async function encryptFallbackPart(meta, offerId, partNumber, plain, key) {
|
|
1420
|
+
const expected = partPlainBytes(meta, partNumber);
|
|
1421
|
+
if (plain.length !== expected) throw new Error("bad_fallback_part_size");
|
|
1422
|
+
const out = await crypto.subtle.encrypt(
|
|
1423
|
+
{
|
|
1424
|
+
name: "AES-GCM",
|
|
1425
|
+
iv: ivFor(meta, partNumber),
|
|
1426
|
+
additionalData: aadFor(offerId, partNumber, plain.length),
|
|
1427
|
+
tagLength: 128
|
|
1428
|
+
},
|
|
1429
|
+
key ?? await aesKey(meta),
|
|
1430
|
+
plain
|
|
1431
|
+
);
|
|
1432
|
+
return new Uint8Array(out);
|
|
1433
|
+
}
|
|
1434
|
+
var RETRIES = 3;
|
|
1435
|
+
var URL_BATCH = 16;
|
|
1436
|
+
var CONCURRENCY = 2;
|
|
1437
|
+
function aborted(signal) {
|
|
1438
|
+
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
|
1439
|
+
}
|
|
1440
|
+
var pause = (ms, signal) => new Promise((resolve2, reject) => {
|
|
1441
|
+
const timer = setTimeout(resolve2, ms);
|
|
1442
|
+
signal?.addEventListener("abort", () => {
|
|
1443
|
+
clearTimeout(timer);
|
|
1444
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
1445
|
+
}, { once: true });
|
|
1446
|
+
});
|
|
1447
|
+
async function uploadFallback(options) {
|
|
1448
|
+
const { file, offerId, meta, signal } = options;
|
|
1449
|
+
fallbackMetaOf(meta);
|
|
1450
|
+
if (file.size !== meta.size || file.name !== meta.name) throw new Error("fallback_file_changed");
|
|
1451
|
+
const key = await aesKey(meta);
|
|
1452
|
+
const completed = [];
|
|
1453
|
+
const partProgress = /* @__PURE__ */ new Map();
|
|
1454
|
+
const report = () => options.onProgress?.(
|
|
1455
|
+
Math.min(meta.cipher_size, [...partProgress.values()].reduce((sum, value) => sum + value, 0)),
|
|
1456
|
+
meta.cipher_size
|
|
1457
|
+
);
|
|
1458
|
+
for (let first = 1; first <= meta.part_count; first += URL_BATCH) {
|
|
1459
|
+
aborted(signal);
|
|
1460
|
+
const count = Math.min(URL_BATCH, meta.part_count - first + 1);
|
|
1461
|
+
const signed = await options.getUrls(first, count);
|
|
1462
|
+
if (signed.length !== count || signed.some((entry, i) => entry.part_number !== first + i || typeof entry.url !== "string")) throw new Error("bad_fallback_urls");
|
|
1463
|
+
let cursor = 0;
|
|
1464
|
+
const workers = Array.from({ length: Math.min(CONCURRENCY, signed.length) }, async () => {
|
|
1465
|
+
for (; ; ) {
|
|
1466
|
+
const at = cursor++;
|
|
1467
|
+
if (at >= signed.length) return;
|
|
1468
|
+
const entry = signed[at];
|
|
1469
|
+
const partNumber = entry.part_number;
|
|
1470
|
+
const plainStart = (partNumber - 1) * meta.part_bytes;
|
|
1471
|
+
const plainEnd = Math.min(file.size, plainStart + meta.part_bytes);
|
|
1472
|
+
const plain = new Uint8Array(await file.slice(plainStart, plainEnd).arrayBuffer());
|
|
1473
|
+
const cipher = await encryptFallbackPart(meta, offerId, partNumber, plain, key);
|
|
1474
|
+
let etag = "";
|
|
1475
|
+
let lastError;
|
|
1476
|
+
for (let attempt = 0; attempt < RETRIES; attempt++) {
|
|
1477
|
+
aborted(signal);
|
|
1478
|
+
partProgress.set(partNumber, 0);
|
|
1479
|
+
report();
|
|
1480
|
+
try {
|
|
1481
|
+
etag = await options.put(entry.url, cipher, signal, (loaded) => {
|
|
1482
|
+
partProgress.set(partNumber, loaded);
|
|
1483
|
+
report();
|
|
1484
|
+
});
|
|
1485
|
+
break;
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
lastError = error;
|
|
1488
|
+
if (error.name === "AbortError") throw error;
|
|
1489
|
+
if (attempt + 1 < RETRIES) {
|
|
1490
|
+
options.onRetry?.();
|
|
1491
|
+
await pause(250 * 2 ** attempt, signal);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (!etag) throw lastError ?? new Error("fallback_put_failed");
|
|
1496
|
+
completed.push({ partNumber, etag });
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
await Promise.all(workers);
|
|
1500
|
+
}
|
|
1501
|
+
return completed.sort((a, b) => a.partNumber - b.partNumber);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
876
1504
|
// src/shared/sharedchannel.ts
|
|
877
1505
|
import { xchacha20poly1305 as xchacha20poly13052 } from "@noble/ciphers/chacha";
|
|
878
1506
|
import { x25519 as x255192 } from "@noble/curves/ed25519";
|
|
@@ -943,399 +1571,49 @@ function resolveChannel(identity, grants, channel) {
|
|
|
943
1571
|
throw new ZasError("grant_missing", 0);
|
|
944
1572
|
}
|
|
945
1573
|
|
|
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;
|
|
1574
|
+
// src/send.ts
|
|
1575
|
+
import { promises as fsp } from "node:fs";
|
|
1576
|
+
import { basename, extname } from "node:path";
|
|
1577
|
+
|
|
1578
|
+
// src/shared/chunker.ts
|
|
1579
|
+
import { blake3 as blake32 } from "hash-wasm";
|
|
1580
|
+
var gearPromise = null;
|
|
1581
|
+
function gearTable() {
|
|
1582
|
+
if (!gearPromise) {
|
|
1583
|
+
gearPromise = (async () => {
|
|
1584
|
+
const table = new Uint32Array(256);
|
|
1585
|
+
for (let i = 0; i < 256; i++) {
|
|
1586
|
+
const hex = await blake32(new TextEncoder().encode(GEAR_SEED + i), 256);
|
|
1587
|
+
table[i] = parseInt(hex.slice(0, 8), 16) >>> 0;
|
|
994
1588
|
}
|
|
995
|
-
|
|
996
|
-
|
|
1589
|
+
return table;
|
|
1590
|
+
})();
|
|
997
1591
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
try {
|
|
1008
|
-
return await Promise.race([settled, deadline]);
|
|
1009
|
-
} finally {
|
|
1010
|
-
clearTimeout(timer);
|
|
1011
|
-
}
|
|
1592
|
+
return gearPromise;
|
|
1593
|
+
}
|
|
1594
|
+
var AVG = 1 << CHUNK_AVG_BITS;
|
|
1595
|
+
var MASK_S = (1 << CHUNK_AVG_BITS + 2) - 1;
|
|
1596
|
+
var MASK_L = (1 << CHUNK_AVG_BITS - 2) - 1;
|
|
1597
|
+
function cutPoint(buf, gear, eof) {
|
|
1598
|
+
const len = Math.min(buf.length, CHUNK_MAX);
|
|
1599
|
+
if (buf.length < CHUNK_MAX && !eof) {
|
|
1600
|
+
if (buf.length <= CHUNK_MIN) return null;
|
|
1012
1601
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1602
|
+
if (eof && len <= CHUNK_MIN) return len > 0 ? len : null;
|
|
1603
|
+
let hash = 0;
|
|
1604
|
+
const normal = Math.min(AVG, len);
|
|
1605
|
+
let i = CHUNK_MIN;
|
|
1606
|
+
for (; i < normal; i++) {
|
|
1607
|
+
hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
|
|
1608
|
+
if ((hash & MASK_S) === 0) return i + 1;
|
|
1015
1609
|
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1610
|
+
for (; i < len; i++) {
|
|
1611
|
+
hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
|
|
1612
|
+
if ((hash & MASK_L) === 0) return i + 1;
|
|
1018
1613
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
import { randomBytes } from "node:crypto";
|
|
1023
|
-
import {
|
|
1024
|
-
closeSync,
|
|
1025
|
-
existsSync as existsSync2,
|
|
1026
|
-
mkdirSync as mkdirSync2,
|
|
1027
|
-
mkdtempSync,
|
|
1028
|
-
openSync,
|
|
1029
|
-
renameSync as renameSync2,
|
|
1030
|
-
rmdirSync,
|
|
1031
|
-
statSync,
|
|
1032
|
-
unlinkSync,
|
|
1033
|
-
writeSync
|
|
1034
|
-
} from "node:fs";
|
|
1035
|
-
import { tmpdir } from "node:os";
|
|
1036
|
-
import { dirname, extname, join as join2 } from "node:path";
|
|
1037
|
-
|
|
1038
|
-
// src/shared/mle.ts
|
|
1039
|
-
import { xchacha20poly1305 as xchacha20poly13053 } from "@noble/ciphers/chacha";
|
|
1040
|
-
function chunkKeyFromF(f) {
|
|
1041
|
-
return {
|
|
1042
|
-
key: hkdf512(f, HKDF_INFO_CHUNK_KEY, 32),
|
|
1043
|
-
nonce: hkdf512(f, HKDF_INFO_CHUNK_NONCE, 24)
|
|
1044
|
-
};
|
|
1045
|
-
}
|
|
1046
|
-
async function encryptChunk(f, plaintext) {
|
|
1047
|
-
const { key, nonce } = chunkKeyFromF(f);
|
|
1048
|
-
const ciphertext = xchacha20poly13053(key, nonce).encrypt(plaintext);
|
|
1049
|
-
const blobId = await blake3Hex(ciphertext);
|
|
1050
|
-
return { ciphertext, blobId, key, nonce };
|
|
1051
|
-
}
|
|
1052
|
-
function decryptChunk(key, nonce, ciphertext) {
|
|
1053
|
-
return xchacha20poly13053(key, nonce).decrypt(ciphertext);
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
// src/read.ts
|
|
1057
|
-
var DEFAULT_LIMIT = 20;
|
|
1058
|
-
var MAX_LIMIT = 50;
|
|
1059
|
-
var DOWNLOAD_PREFIX = "zas-agent-";
|
|
1060
|
-
var ID_SEGMENT = /^(?!__)[A-Za-z0-9_-]{1,128}$/;
|
|
1061
|
-
var MAX_CHUNKS = 8192;
|
|
1062
|
-
var MAX_DUPLICATES = 100;
|
|
1063
|
-
function stringOf(value) {
|
|
1064
|
-
return typeof value?.stringValue === "string" ? value.stringValue : void 0;
|
|
1065
|
-
}
|
|
1066
|
-
function timeOf(value) {
|
|
1067
|
-
const parsed = typeof value?.timestampValue === "string" ? Date.parse(value.timestampValue) : NaN;
|
|
1068
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
1069
|
-
}
|
|
1070
|
-
function rowOf(doc) {
|
|
1071
|
-
const document = doc ?? {};
|
|
1072
|
-
const name = typeof document.name === "string" ? document.name : "";
|
|
1073
|
-
const fields = document.fields && typeof document.fields === "object" ? document.fields : {};
|
|
1074
|
-
return {
|
|
1075
|
-
id: name.slice(name.lastIndexOf("/") + 1),
|
|
1076
|
-
manifestEnc: stringOf(fields.manifest_enc),
|
|
1077
|
-
agent: stringOf(fields.agent),
|
|
1078
|
-
createdAt: timeOf(fields.created_at),
|
|
1079
|
-
expiresAt: timeOf(fields.expires_at),
|
|
1080
|
-
bar: fields.bar?.booleanValue === true
|
|
1081
|
-
};
|
|
1082
|
-
}
|
|
1083
|
-
function readable(row) {
|
|
1084
|
-
if (!row.id || row.bar || !row.manifestEnc) return false;
|
|
1085
|
-
return row.expiresAt === null || row.expiresAt > Date.now();
|
|
1086
|
-
}
|
|
1087
|
-
function openFor(channelKey, row) {
|
|
1088
|
-
try {
|
|
1089
|
-
return openManifest(channelKey, b64ToBytes(row.manifestEnc));
|
|
1090
|
-
} catch {
|
|
1091
|
-
return null;
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
function summaryOf(row, manifest) {
|
|
1095
|
-
const kind = manifest.kind === "text" ? "text" : "file";
|
|
1096
|
-
const name = typeof manifest.name === "string" ? manifest.name : "";
|
|
1097
|
-
return {
|
|
1098
|
-
id: row.id,
|
|
1099
|
-
kind,
|
|
1100
|
-
// Absence means "show the file name": the sender chose no title.
|
|
1101
|
-
title: manifest.title ?? name,
|
|
1102
|
-
name,
|
|
1103
|
-
mime: typeof manifest.mime === "string" ? manifest.mime : "application/octet-stream",
|
|
1104
|
-
size: typeof manifest.size === "number" ? manifest.size : 0,
|
|
1105
|
-
// The sealed time is the sender's own; the row's is the server's, and it
|
|
1106
|
-
// only answers for a manifest that carries none.
|
|
1107
|
-
created_at: typeof manifest.created_at === "string" && manifest.created_at ? manifest.created_at : row.createdAt !== null ? new Date(row.createdAt).toISOString() : "",
|
|
1108
|
-
by_agent: row.agent !== void 0,
|
|
1109
|
-
...kind === "text" ? { text: manifest.text ?? "" } : {}
|
|
1110
|
-
};
|
|
1111
|
-
}
|
|
1112
|
-
async function readGrant(ctx, channel) {
|
|
1113
|
-
const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
|
|
1114
|
-
if (!grant.read) throw new ZasError("read_forbidden", 403);
|
|
1115
|
-
return grant;
|
|
1116
|
-
}
|
|
1117
|
-
function nameFrom(channelKey, grant) {
|
|
1118
|
-
try {
|
|
1119
|
-
return decryptChannelName(channelKey, b64ToBytes(grant.name_enc));
|
|
1120
|
-
} catch {
|
|
1121
|
-
throw new ZasError("key_stale", 0);
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
function linksParent(identity, grant) {
|
|
1125
|
-
return `accounts/${identity.owner_uid}/channels/${grant.channel_id}`;
|
|
1126
|
-
}
|
|
1127
|
-
function linkPath(identity, grant, id) {
|
|
1128
|
-
return `projects/${identity.firestore_project}/databases/(default)/documents/${linksParent(identity, grant)}/links/${id}`;
|
|
1129
|
-
}
|
|
1130
|
-
async function queryLinks(ctx, grant, query) {
|
|
1131
|
-
try {
|
|
1132
|
-
return await ctx.client.firestoreRunQuery(linksParent(ctx.identity, grant), query);
|
|
1133
|
-
} catch (err) {
|
|
1134
|
-
if (err instanceof ZasError && err.status === 403) throw new ZasError("read_forbidden", 403);
|
|
1135
|
-
throw err;
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
function clampLimit(limit) {
|
|
1139
|
-
if (limit === void 0 || !Number.isFinite(limit)) return DEFAULT_LIMIT;
|
|
1140
|
-
return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
|
|
1141
|
-
}
|
|
1142
|
-
async function listItems(ctx, channel, limit) {
|
|
1143
|
-
const grant = await readGrant(ctx, channel);
|
|
1144
|
-
const channelKey = channelKeyOf(ctx.identity, grant);
|
|
1145
|
-
const docs = await queryLinks(ctx, grant, {
|
|
1146
|
-
from: [{ collectionId: "links" }],
|
|
1147
|
-
orderBy: [{ field: { fieldPath: "created_at" }, direction: "DESCENDING" }],
|
|
1148
|
-
limit: clampLimit(limit)
|
|
1149
|
-
});
|
|
1150
|
-
const items = [];
|
|
1151
|
-
for (const doc of docs) {
|
|
1152
|
-
const row = rowOf(doc);
|
|
1153
|
-
if (!readable(row)) continue;
|
|
1154
|
-
const manifest = openFor(channelKey, row);
|
|
1155
|
-
if (!manifest) continue;
|
|
1156
|
-
items.push(summaryOf(row, manifest));
|
|
1157
|
-
}
|
|
1158
|
-
return {
|
|
1159
|
-
channel_id: grant.channel_id,
|
|
1160
|
-
channel_name: nameFrom(channelKey, grant),
|
|
1161
|
-
items
|
|
1162
|
-
};
|
|
1163
|
-
}
|
|
1164
|
-
function redeemFailure(status) {
|
|
1165
|
-
if (status === 429) return new ZasError("rate_limited", 429);
|
|
1166
|
-
if (status >= 500) return new ZasError("network", status);
|
|
1167
|
-
return new ZasError("invalid_cap", 403);
|
|
1168
|
-
}
|
|
1169
|
-
async function fetchChunk(ctx, chunk) {
|
|
1170
|
-
if (typeof chunk.cap !== "string" || chunk.cap === "") throw new ZasError("invalid_cap", 403);
|
|
1171
|
-
let headers = {};
|
|
1172
|
-
try {
|
|
1173
|
-
headers = { Authorization: `Bearer ${await ctx.client.idToken()}` };
|
|
1174
|
-
} catch {
|
|
1175
|
-
headers = {};
|
|
1176
|
-
}
|
|
1177
|
-
let url;
|
|
1178
|
-
try {
|
|
1179
|
-
const redeemed = await apiPublic(
|
|
1180
|
-
ctx.identity.api_base,
|
|
1181
|
-
"POST",
|
|
1182
|
-
"/v1/blobs/redeem",
|
|
1183
|
-
{ cap: chunk.cap },
|
|
1184
|
-
headers
|
|
1185
|
-
);
|
|
1186
|
-
url = typeof redeemed.url === "string" ? redeemed.url : "";
|
|
1187
|
-
} catch (err) {
|
|
1188
|
-
if (err instanceof ZasError) throw redeemFailure(err.status);
|
|
1189
|
-
throw err;
|
|
1190
|
-
}
|
|
1191
|
-
if (url === "") throw new ZasError("invalid_cap", 403);
|
|
1192
|
-
const res = await fetch(url);
|
|
1193
|
-
if (!res.ok) {
|
|
1194
|
-
await res.body?.cancel().catch(() => void 0);
|
|
1195
|
-
throw res.status >= 500 ? new ZasError("network", res.status) : new ZasError("invalid_cap", 403);
|
|
1196
|
-
}
|
|
1197
|
-
const ciphertext = new Uint8Array(await res.arrayBuffer());
|
|
1198
|
-
try {
|
|
1199
|
-
return decryptChunk(b64ToBytes(chunk.key), b64ToBytes(chunk.nonce), ciphertext);
|
|
1200
|
-
} catch {
|
|
1201
|
-
throw new ZasError("invalid_cap", 403);
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
function safeName(name, fallback) {
|
|
1205
|
-
const base = (typeof name === "string" ? name : "").split(/[\\/]/).pop() ?? "";
|
|
1206
|
-
return base.replace(/^\.+/, "").trim() || fallback;
|
|
1207
|
-
}
|
|
1208
|
-
function freeName(target) {
|
|
1209
|
-
if (!existsSync2(target)) return target;
|
|
1210
|
-
const ext = extname(target);
|
|
1211
|
-
const stem = target.slice(0, target.length - ext.length);
|
|
1212
|
-
for (let n = 1; n <= MAX_DUPLICATES; n++) {
|
|
1213
|
-
const candidate = `${stem} (${n})${ext}`;
|
|
1214
|
-
if (!existsSync2(candidate)) return candidate;
|
|
1215
|
-
}
|
|
1216
|
-
throw new ZasError("write_failed", 0, target);
|
|
1217
|
-
}
|
|
1218
|
-
function destinationOf(dest, name, fallback) {
|
|
1219
|
-
const base = safeName(name, fallback);
|
|
1220
|
-
if (dest === void 0) {
|
|
1221
|
-
const created = mkdtempSync(join2(tmpdir(), DOWNLOAD_PREFIX));
|
|
1222
|
-
return { target: freeName(join2(created, base)), created };
|
|
1223
|
-
}
|
|
1224
|
-
const at = statSync(dest, { throwIfNoEntry: false })?.isDirectory() ? join2(dest, base) : dest;
|
|
1225
|
-
return { target: freeName(at) };
|
|
1226
|
-
}
|
|
1227
|
-
async function getItem(ctx, channel, id, dest) {
|
|
1228
|
-
const grant = await readGrant(ctx, channel);
|
|
1229
|
-
if (!ID_SEGMENT.test(id)) throw new ZasError("not_found", 404);
|
|
1230
|
-
const channelKey = channelKeyOf(ctx.identity, grant);
|
|
1231
|
-
const docs = await queryLinks(ctx, grant, {
|
|
1232
|
-
from: [{ collectionId: "links" }],
|
|
1233
|
-
where: {
|
|
1234
|
-
fieldFilter: {
|
|
1235
|
-
field: { fieldPath: "__name__" },
|
|
1236
|
-
op: "EQUAL",
|
|
1237
|
-
value: { referenceValue: linkPath(ctx.identity, grant, id) }
|
|
1238
|
-
}
|
|
1239
|
-
},
|
|
1240
|
-
limit: 1
|
|
1241
|
-
});
|
|
1242
|
-
const row = docs.length > 0 ? rowOf(docs[0]) : null;
|
|
1243
|
-
if (!row || !readable(row)) throw new ZasError("not_found", 404);
|
|
1244
|
-
const manifest = openFor(channelKey, row);
|
|
1245
|
-
if (!manifest) throw new ZasError("not_found", 404);
|
|
1246
|
-
if (manifest.kind === "text") {
|
|
1247
|
-
const text2 = manifest.text ?? "";
|
|
1248
|
-
return { text: text2, bytes: new TextEncoder().encode(text2).length };
|
|
1249
|
-
}
|
|
1250
|
-
if (manifest.chunks.length === 0) throw new ZasError("not_found", 404);
|
|
1251
|
-
if (manifest.chunks.length > MAX_CHUNKS) throw new ZasError("not_found", 404);
|
|
1252
|
-
let target;
|
|
1253
|
-
let created;
|
|
1254
|
-
let tmp;
|
|
1255
|
-
let fd;
|
|
1256
|
-
let written = 0;
|
|
1257
|
-
try {
|
|
1258
|
-
const chosen = destinationOf(dest, manifest.name, row.id);
|
|
1259
|
-
target = chosen.target;
|
|
1260
|
-
created = chosen.created;
|
|
1261
|
-
mkdirSync2(dirname(target), { recursive: true, mode: 448 });
|
|
1262
|
-
tmp = `${target}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1263
|
-
fd = openSync(tmp, "wx", 384);
|
|
1264
|
-
for (const chunk of manifest.chunks) {
|
|
1265
|
-
written += writeSync(fd, await fetchChunk(ctx, chunk));
|
|
1266
|
-
}
|
|
1267
|
-
const size = typeof manifest.size === "number" ? manifest.size : 0;
|
|
1268
|
-
if (size > 0 && written !== size) throw new ZasError("not_found", 404);
|
|
1269
|
-
closeSync(fd);
|
|
1270
|
-
fd = void 0;
|
|
1271
|
-
renameSync2(tmp, target);
|
|
1272
|
-
} catch (err) {
|
|
1273
|
-
if (err instanceof ZasError) throw err;
|
|
1274
|
-
const failure = err;
|
|
1275
|
-
if (typeof failure.syscall === "string" || typeof failure.errno === "number") {
|
|
1276
|
-
throw new ZasError("write_failed", 0, failure.message);
|
|
1277
|
-
}
|
|
1278
|
-
throw new ZasError("network", 0, String(err?.message ?? err));
|
|
1279
|
-
} finally {
|
|
1280
|
-
if (fd !== void 0) try {
|
|
1281
|
-
closeSync(fd);
|
|
1282
|
-
} catch {
|
|
1283
|
-
}
|
|
1284
|
-
if (tmp !== void 0 && existsSync2(tmp)) try {
|
|
1285
|
-
unlinkSync(tmp);
|
|
1286
|
-
} catch {
|
|
1287
|
-
}
|
|
1288
|
-
if (created !== void 0) try {
|
|
1289
|
-
rmdirSync(created);
|
|
1290
|
-
} catch {
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
return { path: target, bytes: written };
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// src/send.ts
|
|
1297
|
-
import { promises as fsp } from "node:fs";
|
|
1298
|
-
import { basename, extname as extname2 } from "node:path";
|
|
1299
|
-
|
|
1300
|
-
// src/shared/chunker.ts
|
|
1301
|
-
import { blake3 as blake32 } from "hash-wasm";
|
|
1302
|
-
var gearPromise = null;
|
|
1303
|
-
function gearTable() {
|
|
1304
|
-
if (!gearPromise) {
|
|
1305
|
-
gearPromise = (async () => {
|
|
1306
|
-
const table = new Uint32Array(256);
|
|
1307
|
-
for (let i = 0; i < 256; i++) {
|
|
1308
|
-
const hex = await blake32(new TextEncoder().encode(GEAR_SEED + i), 256);
|
|
1309
|
-
table[i] = parseInt(hex.slice(0, 8), 16) >>> 0;
|
|
1310
|
-
}
|
|
1311
|
-
return table;
|
|
1312
|
-
})();
|
|
1313
|
-
}
|
|
1314
|
-
return gearPromise;
|
|
1315
|
-
}
|
|
1316
|
-
var AVG = 1 << CHUNK_AVG_BITS;
|
|
1317
|
-
var MASK_S = (1 << CHUNK_AVG_BITS + 2) - 1;
|
|
1318
|
-
var MASK_L = (1 << CHUNK_AVG_BITS - 2) - 1;
|
|
1319
|
-
function cutPoint(buf, gear, eof) {
|
|
1320
|
-
const len = Math.min(buf.length, CHUNK_MAX);
|
|
1321
|
-
if (buf.length < CHUNK_MAX && !eof) {
|
|
1322
|
-
if (buf.length <= CHUNK_MIN) return null;
|
|
1323
|
-
}
|
|
1324
|
-
if (eof && len <= CHUNK_MIN) return len > 0 ? len : null;
|
|
1325
|
-
let hash = 0;
|
|
1326
|
-
const normal = Math.min(AVG, len);
|
|
1327
|
-
let i = CHUNK_MIN;
|
|
1328
|
-
for (; i < normal; i++) {
|
|
1329
|
-
hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
|
|
1330
|
-
if ((hash & MASK_S) === 0) return i + 1;
|
|
1331
|
-
}
|
|
1332
|
-
for (; i < len; i++) {
|
|
1333
|
-
hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
|
|
1334
|
-
if ((hash & MASK_L) === 0) return i + 1;
|
|
1335
|
-
}
|
|
1336
|
-
if (len === CHUNK_MAX) return CHUNK_MAX;
|
|
1337
|
-
if (eof) return len > 0 ? len : null;
|
|
1338
|
-
return null;
|
|
1614
|
+
if (len === CHUNK_MAX) return CHUNK_MAX;
|
|
1615
|
+
if (eof) return len > 0 ? len : null;
|
|
1616
|
+
return null;
|
|
1339
1617
|
}
|
|
1340
1618
|
async function* chunkStream(source) {
|
|
1341
1619
|
const gear = await gearTable();
|
|
@@ -1371,6 +1649,24 @@ async function* chunkStream(source) {
|
|
|
1371
1649
|
yield* drain(true);
|
|
1372
1650
|
}
|
|
1373
1651
|
|
|
1652
|
+
// src/shared/mle.ts
|
|
1653
|
+
import { xchacha20poly1305 as xchacha20poly13053 } from "@noble/ciphers/chacha";
|
|
1654
|
+
function chunkKeyFromF(f) {
|
|
1655
|
+
return {
|
|
1656
|
+
key: hkdf512(f, HKDF_INFO_CHUNK_KEY, 32),
|
|
1657
|
+
nonce: hkdf512(f, HKDF_INFO_CHUNK_NONCE, 24)
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
async function encryptChunk(f, plaintext) {
|
|
1661
|
+
const { key, nonce } = chunkKeyFromF(f);
|
|
1662
|
+
const ciphertext = xchacha20poly13053(key, nonce).encrypt(plaintext);
|
|
1663
|
+
const blobId = await blake3Hex(ciphertext);
|
|
1664
|
+
return { ciphertext, blobId, key, nonce };
|
|
1665
|
+
}
|
|
1666
|
+
function decryptChunk(key, nonce, ciphertext) {
|
|
1667
|
+
return xchacha20poly13053(key, nonce).decrypt(ciphertext);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1374
1670
|
// src/shared/oprf.ts
|
|
1375
1671
|
import { RistrettoPoint } from "@noble/curves/ed25519";
|
|
1376
1672
|
import { sha512 as sha5122 } from "@noble/hashes/sha2";
|
|
@@ -1527,7 +1823,7 @@ var MIME = new Map(Object.entries({
|
|
|
1527
1823
|
xml: "application/xml"
|
|
1528
1824
|
}));
|
|
1529
1825
|
function mimeFor(path) {
|
|
1530
|
-
return MIME.get(
|
|
1826
|
+
return MIME.get(extname(path).slice(1).toLowerCase()) ?? "application/octet-stream";
|
|
1531
1827
|
}
|
|
1532
1828
|
async function mapLimit(items, limit, work) {
|
|
1533
1829
|
const out = new Array(items.length);
|
|
@@ -1755,128 +2051,711 @@ async function sendFile(ctx, input, onPhase) {
|
|
|
1755
2051
|
replayed: true
|
|
1756
2052
|
};
|
|
1757
2053
|
}
|
|
1758
|
-
onPhase?.("hashing");
|
|
1759
|
-
const plains = [];
|
|
1760
|
-
for await (const piece of chunkStream([bytes])) plains.push(piece);
|
|
1761
|
-
const hashes = await Promise.all(plains.map((p) => blake3Bytes(p)));
|
|
1762
|
-
const blinds = hashes.map((h) => oprfBlind(h));
|
|
1763
|
-
const evaluated = [];
|
|
1764
|
-
for (const slice of batches(blinds.map((b) => bytesToB64(b.blindedElement)), sliceSize(ctx))) {
|
|
1765
|
-
const answers = await ctx.client.oprfEvaluate(slice);
|
|
1766
|
-
if (answers.length !== slice.length) throw new ZasError("oprf_failed", 0);
|
|
1767
|
-
for (const one of answers) evaluated.push(one);
|
|
1768
|
-
}
|
|
1769
|
-
onPhase?.("encrypting");
|
|
1770
|
-
const encs = await Promise.all(plains.map(
|
|
1771
|
-
(plain, i) => encryptChunk(oprfFinalize(hashes[i], blinds[i].blind, b64ToBytes(evaluated[i])), plain)
|
|
1772
|
-
));
|
|
1773
|
-
onPhase?.("uploading");
|
|
1774
|
-
const placed = await placeChunks(ctx, encs);
|
|
1775
|
-
onPhase?.("finishing");
|
|
1776
|
-
const mime = mimeFor(input.path);
|
|
1777
|
-
const thumb = await thumbnailFor(bytes, mime);
|
|
2054
|
+
onPhase?.("hashing");
|
|
2055
|
+
const plains = [];
|
|
2056
|
+
for await (const piece of chunkStream([bytes])) plains.push(piece);
|
|
2057
|
+
const hashes = await Promise.all(plains.map((p) => blake3Bytes(p)));
|
|
2058
|
+
const blinds = hashes.map((h) => oprfBlind(h));
|
|
2059
|
+
const evaluated = [];
|
|
2060
|
+
for (const slice of batches(blinds.map((b) => bytesToB64(b.blindedElement)), sliceSize(ctx))) {
|
|
2061
|
+
const answers = await ctx.client.oprfEvaluate(slice);
|
|
2062
|
+
if (answers.length !== slice.length) throw new ZasError("oprf_failed", 0);
|
|
2063
|
+
for (const one of answers) evaluated.push(one);
|
|
2064
|
+
}
|
|
2065
|
+
onPhase?.("encrypting");
|
|
2066
|
+
const encs = await Promise.all(plains.map(
|
|
2067
|
+
(plain, i) => encryptChunk(oprfFinalize(hashes[i], blinds[i].blind, b64ToBytes(evaluated[i])), plain)
|
|
2068
|
+
));
|
|
2069
|
+
onPhase?.("uploading");
|
|
2070
|
+
const placed = await placeChunks(ctx, encs);
|
|
2071
|
+
onPhase?.("finishing");
|
|
2072
|
+
const mime = mimeFor(input.path);
|
|
2073
|
+
const thumb = await thumbnailFor(bytes, mime);
|
|
2074
|
+
const manifest = newManifest({
|
|
2075
|
+
kind: "file",
|
|
2076
|
+
name,
|
|
2077
|
+
// Only when the caller chose one: absence means "show the file name", and
|
|
2078
|
+
// writing the file name into `title` would make a rename look deliberate.
|
|
2079
|
+
...input.title !== void 0 ? { title: input.title } : {},
|
|
2080
|
+
mime,
|
|
2081
|
+
size: bytes.length,
|
|
2082
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2083
|
+
...thumb ? { thumb_data: thumb } : {},
|
|
2084
|
+
chunks: placed.map((p) => p.entry)
|
|
2085
|
+
});
|
|
2086
|
+
const linkId = await postLink(
|
|
2087
|
+
ctx,
|
|
2088
|
+
grant,
|
|
2089
|
+
manifest,
|
|
2090
|
+
placed,
|
|
2091
|
+
agentSendIdempotencyKey(grant.channel_id, contentHash, title)
|
|
2092
|
+
);
|
|
2093
|
+
const deduplicated = placed.filter((p) => p.proven).length;
|
|
2094
|
+
remember(ctx, key, {
|
|
2095
|
+
link_id: linkId,
|
|
2096
|
+
bytes: bytes.length,
|
|
2097
|
+
chunks: placed.length,
|
|
2098
|
+
deduplicated,
|
|
2099
|
+
at: Date.now()
|
|
2100
|
+
});
|
|
2101
|
+
return {
|
|
2102
|
+
link_id: linkId,
|
|
2103
|
+
channel_id: grant.channel_id,
|
|
2104
|
+
channel_name: channelNameOf(ctx.identity, grant),
|
|
2105
|
+
bytes: bytes.length,
|
|
2106
|
+
chunks: placed.length,
|
|
2107
|
+
deduplicated,
|
|
2108
|
+
replayed: false
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
function noteName(text2) {
|
|
2112
|
+
return text2.split("\n", 1)[0].trim().slice(0, NOTE_NAME_MAX) || "nota";
|
|
2113
|
+
}
|
|
2114
|
+
async function sendNote(ctx, input) {
|
|
2115
|
+
const grant = await grantFor(ctx, input.channel);
|
|
2116
|
+
const name = input.title ?? noteName(input.text);
|
|
2117
|
+
const encoded = new TextEncoder().encode(input.text);
|
|
2118
|
+
const contentHash = await blake3Hex(new TextEncoder().encode(
|
|
2119
|
+
`${input.lang ?? ""}\0${input.secret ? "1" : "0"}\0${input.text}`
|
|
2120
|
+
));
|
|
2121
|
+
const key = await receiptKey(ctx, grant.channel_id, contentHash, name);
|
|
2122
|
+
const stored = receiptFor(ctx, key);
|
|
2123
|
+
if (stored) {
|
|
2124
|
+
return {
|
|
2125
|
+
link_id: stored.link_id,
|
|
2126
|
+
channel_id: grant.channel_id,
|
|
2127
|
+
channel_name: channelNameOf(ctx.identity, grant),
|
|
2128
|
+
bytes: stored.bytes,
|
|
2129
|
+
chunks: 0,
|
|
2130
|
+
deduplicated: 0,
|
|
2131
|
+
replayed: true
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
1778
2134
|
const manifest = newManifest({
|
|
1779
|
-
kind: "
|
|
2135
|
+
kind: "text",
|
|
1780
2136
|
name,
|
|
1781
|
-
// Only when the caller chose one: absence means "show the file name", and
|
|
1782
|
-
// writing the file name into `title` would make a rename look deliberate.
|
|
1783
2137
|
...input.title !== void 0 ? { title: input.title } : {},
|
|
1784
|
-
mime,
|
|
1785
|
-
size:
|
|
2138
|
+
mime: "text/plain",
|
|
2139
|
+
size: encoded.length,
|
|
1786
2140
|
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1787
|
-
|
|
1788
|
-
|
|
2141
|
+
text: input.text,
|
|
2142
|
+
...input.lang ? { code: { lang: input.lang, auto: false } } : {},
|
|
2143
|
+
// Only ever true or absent, exactly as the note cover is defined: absent is
|
|
2144
|
+
// "the sender did not classify", which no receiver may read as safe.
|
|
2145
|
+
...input.secret ? { sensitive: true } : {},
|
|
2146
|
+
chunks: []
|
|
1789
2147
|
});
|
|
1790
2148
|
const linkId = await postLink(
|
|
1791
2149
|
ctx,
|
|
1792
2150
|
grant,
|
|
1793
2151
|
manifest,
|
|
1794
|
-
|
|
1795
|
-
agentSendIdempotencyKey(grant.channel_id, contentHash,
|
|
2152
|
+
[],
|
|
2153
|
+
agentSendIdempotencyKey(grant.channel_id, contentHash, name)
|
|
1796
2154
|
);
|
|
1797
|
-
const deduplicated = placed.filter((p) => p.proven).length;
|
|
1798
2155
|
remember(ctx, key, {
|
|
1799
2156
|
link_id: linkId,
|
|
1800
|
-
bytes:
|
|
1801
|
-
chunks:
|
|
1802
|
-
deduplicated,
|
|
2157
|
+
bytes: encoded.length,
|
|
2158
|
+
chunks: 0,
|
|
2159
|
+
deduplicated: 0,
|
|
1803
2160
|
at: Date.now()
|
|
1804
2161
|
});
|
|
1805
2162
|
return {
|
|
1806
2163
|
link_id: linkId,
|
|
1807
2164
|
channel_id: grant.channel_id,
|
|
1808
2165
|
channel_name: channelNameOf(ctx.identity, grant),
|
|
1809
|
-
bytes:
|
|
1810
|
-
chunks:
|
|
1811
|
-
deduplicated,
|
|
2166
|
+
bytes: encoded.length,
|
|
2167
|
+
chunks: 0,
|
|
2168
|
+
deduplicated: 0,
|
|
1812
2169
|
replayed: false
|
|
1813
2170
|
};
|
|
1814
2171
|
}
|
|
1815
|
-
|
|
1816
|
-
|
|
2172
|
+
|
|
2173
|
+
// src/direct.ts
|
|
2174
|
+
var OFFER_WAIT_MS = 9.5 * 60 * 1e3;
|
|
2175
|
+
var OFFER_POLL_MS = 1e3;
|
|
2176
|
+
var SIGNAL_POLL_MS = 400;
|
|
2177
|
+
var HEARTBEAT_MS = 4 * 60 * 1e3;
|
|
2178
|
+
var SIGNALS_FOR_SENDER = {
|
|
2179
|
+
from: [{ collectionId: "signals" }],
|
|
2180
|
+
where: { fieldFilter: { field: { fieldPath: "for" }, op: "EQUAL", value: { stringValue: "sender" } } }
|
|
2181
|
+
};
|
|
2182
|
+
var stringField = (doc, key) => doc?.fields?.[key]?.stringValue;
|
|
2183
|
+
var defaultSleep2 = (ms) => new Promise((resolve2) => {
|
|
2184
|
+
setTimeout(resolve2, ms);
|
|
2185
|
+
});
|
|
2186
|
+
var newDeviceToken = () => randomBytes(16).toString("base64url");
|
|
2187
|
+
var webrtc = null;
|
|
2188
|
+
function installWebRtc() {
|
|
2189
|
+
if (!webrtc) {
|
|
2190
|
+
webrtc = import("node-datachannel/polyfill").then((poly) => {
|
|
2191
|
+
const g = globalThis;
|
|
2192
|
+
g.RTCPeerConnection ??= poly.RTCPeerConnection;
|
|
2193
|
+
g.RTCIceCandidate ??= poly.RTCIceCandidate;
|
|
2194
|
+
g.RTCSessionDescription ??= poly.RTCSessionDescription;
|
|
2195
|
+
g.RTCDataChannel ??= poly.RTCDataChannel;
|
|
2196
|
+
}, (error) => {
|
|
2197
|
+
webrtc = null;
|
|
2198
|
+
throw new ZasError("webrtc_unavailable", 0, String(error));
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
return webrtc;
|
|
2202
|
+
}
|
|
2203
|
+
async function directGrantFor(ctx, channel) {
|
|
2204
|
+
const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
|
|
2205
|
+
if (!grant.send || grant.mode === "view") throw new ZasError("send_forbidden", 403);
|
|
2206
|
+
if (!grant.direct_mode) throw new ZasError("not_direct_mode", 409);
|
|
2207
|
+
return grant;
|
|
2208
|
+
}
|
|
2209
|
+
function nameOf(ctx, grant) {
|
|
2210
|
+
try {
|
|
2211
|
+
return channelNameOf(ctx.identity, grant);
|
|
2212
|
+
} catch {
|
|
2213
|
+
return grant.channel_id;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
function sealer(key, keyVersion) {
|
|
2217
|
+
return {
|
|
2218
|
+
seal: (value) => bytesToB64(sealRaw(key, keyVersion, new TextEncoder().encode(JSON.stringify(value)))),
|
|
2219
|
+
open: (enc) => JSON.parse(new TextDecoder().decode(openRaw(key, b64ToBytes(enc))))
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
async function sendDirect(ctx, input, report, deps = {}) {
|
|
2223
|
+
const now = deps.now ?? (() => Date.now());
|
|
2224
|
+
const sleep2 = deps.sleep ?? defaultSleep2;
|
|
2225
|
+
const grant = await directGrantFor(ctx, input.channel);
|
|
2226
|
+
const key = channelKeyOf(ctx.identity, grant);
|
|
2227
|
+
const channelName = nameOf(ctx, grant);
|
|
2228
|
+
const file = await (deps.openFile ?? openAsBlob)(input.path).catch(() => {
|
|
2229
|
+
throw new ZasError("upload_failed", 400);
|
|
2230
|
+
});
|
|
2231
|
+
if (file.size > DIRECT_FILE_MAX_BYTES) throw new ZasError("file_too_big", 413);
|
|
2232
|
+
const name = basename2(input.path);
|
|
2233
|
+
const cid = grant.channel_id;
|
|
2234
|
+
const owner = ctx.identity.owner_uid;
|
|
2235
|
+
const device = deps.device ?? newDeviceToken();
|
|
2236
|
+
const { seal, open } = sealer(key, grant.key_version);
|
|
2237
|
+
const stamp = { device, owner_uid: owner };
|
|
2238
|
+
const startedAt = now();
|
|
2239
|
+
report("offer");
|
|
2240
|
+
const meta = { name, size: file.size, mime: mimeFor(input.path) };
|
|
2241
|
+
const { id } = await ctx.client.api("POST", `/direct/${cid}`, {
|
|
2242
|
+
meta_enc: seal(meta),
|
|
2243
|
+
key_version: grant.key_version,
|
|
2244
|
+
sender_label_enc: seal(ctx.identity.name),
|
|
2245
|
+
size_bytes: file.size,
|
|
2246
|
+
...stamp
|
|
2247
|
+
});
|
|
2248
|
+
const route = `/direct/${cid}/${id}`;
|
|
2249
|
+
const offerPath = `accounts/${owner}/channels/${cid}/direct/${id}`;
|
|
2250
|
+
const setState = (state) => ctx.client.api("POST", `${route}/state`, { state, ...stamp }).then(() => void 0, () => void 0);
|
|
2251
|
+
const claimBy = startedAt + (deps.offerWaitMs ?? OFFER_WAIT_MS);
|
|
2252
|
+
for (; ; ) {
|
|
2253
|
+
const doc = await ctx.client.firestoreGet(offerPath);
|
|
2254
|
+
const state = doc === null ? "cancelled" : stringField(doc, "state") ?? "open";
|
|
2255
|
+
if (state === "claimed") break;
|
|
2256
|
+
if (state !== "open") throw new ZasError("direct_cancelled", 0);
|
|
2257
|
+
if (now() >= claimBy) {
|
|
2258
|
+
await setState("cancelled");
|
|
2259
|
+
throw new ZasError("not_claimed", 0);
|
|
2260
|
+
}
|
|
2261
|
+
await sleep2(deps.offerPollMs ?? OFFER_POLL_MS);
|
|
2262
|
+
}
|
|
2263
|
+
await (deps.installWebRtc ?? installWebRtc)();
|
|
2264
|
+
const fetchIce = async () => {
|
|
2265
|
+
try {
|
|
2266
|
+
const r = await ctx.client.api("POST", `${route}/ice`, stamp);
|
|
2267
|
+
return Array.isArray(r.ice) ? r.ice : [];
|
|
2268
|
+
} catch {
|
|
2269
|
+
return [];
|
|
2270
|
+
}
|
|
2271
|
+
};
|
|
2272
|
+
const turn = await fetchIce();
|
|
2273
|
+
report("connecting");
|
|
2274
|
+
let resolveOutcome;
|
|
2275
|
+
const outcome = new Promise((resolve2) => {
|
|
2276
|
+
resolveOutcome = resolve2;
|
|
2277
|
+
});
|
|
2278
|
+
let diag;
|
|
2279
|
+
let path;
|
|
2280
|
+
let heartbeat;
|
|
2281
|
+
const handle = (deps.engine ?? startSender)({
|
|
2282
|
+
file,
|
|
2283
|
+
name,
|
|
2284
|
+
label: ctx.identity.name,
|
|
2285
|
+
ice: [...DIRECT_ICE, ...turn],
|
|
2286
|
+
refreshIce: async () => [...DIRECT_ICE, ...await fetchIce()],
|
|
2287
|
+
send: (msg) => ctx.client.api("POST", `${route}/signal`, { payload_enc: seal(msg), for: "receiver", ...stamp }).then(() => void 0),
|
|
2288
|
+
onPhase: (phase2) => {
|
|
2289
|
+
if (phase2 === "flight") {
|
|
2290
|
+
report("flight");
|
|
2291
|
+
heartbeat ??= setInterval(() => {
|
|
2292
|
+
void ctx.client.api("POST", `${route}/heartbeat`, stamp).catch(() => void 0);
|
|
2293
|
+
}, deps.heartbeatMs ?? HEARTBEAT_MS);
|
|
2294
|
+
}
|
|
2295
|
+
if (phase2 === "done" || phase2 === "failed") resolveOutcome(phase2);
|
|
2296
|
+
},
|
|
2297
|
+
onPath: (p) => {
|
|
2298
|
+
path = p;
|
|
2299
|
+
},
|
|
2300
|
+
onDiag: (d) => {
|
|
2301
|
+
diag = d;
|
|
2302
|
+
}
|
|
2303
|
+
});
|
|
2304
|
+
let over = false;
|
|
2305
|
+
const pump = (async () => {
|
|
2306
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2307
|
+
while (!over) {
|
|
2308
|
+
let rows = [];
|
|
2309
|
+
try {
|
|
2310
|
+
rows = await ctx.client.firestoreRunQuery(offerPath, SIGNALS_FOR_SENDER);
|
|
2311
|
+
} catch {
|
|
2312
|
+
}
|
|
2313
|
+
for (const row of rows) {
|
|
2314
|
+
if (!row?.name || seen.has(row.name)) continue;
|
|
2315
|
+
seen.add(row.name);
|
|
2316
|
+
const enc = stringField(row, "payload_enc");
|
|
2317
|
+
if (!enc) continue;
|
|
2318
|
+
try {
|
|
2319
|
+
handle.accept(open(enc));
|
|
2320
|
+
} catch {
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
if (!over) await Promise.race([outcome, sleep2(deps.signalPollMs ?? SIGNAL_POLL_MS)]);
|
|
2324
|
+
}
|
|
2325
|
+
})();
|
|
2326
|
+
const phase = await outcome;
|
|
2327
|
+
over = true;
|
|
2328
|
+
clearInterval(heartbeat);
|
|
2329
|
+
await pump;
|
|
2330
|
+
report("finishing");
|
|
2331
|
+
if (phase === "done") {
|
|
2332
|
+
await setState("done");
|
|
2333
|
+
return {
|
|
2334
|
+
offer_id: id,
|
|
2335
|
+
channel_id: cid,
|
|
2336
|
+
channel_name: channelName,
|
|
2337
|
+
bytes: file.size,
|
|
2338
|
+
...path ? { path } : {},
|
|
2339
|
+
duration_ms: now() - startedAt
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
await setState("failed");
|
|
2343
|
+
const reason = diag?.reason || "unknown";
|
|
2344
|
+
deps.onFailed?.({
|
|
2345
|
+
channel_id: cid,
|
|
2346
|
+
channel_name: channelName,
|
|
2347
|
+
offer_id: id,
|
|
2348
|
+
owner_uid: owner,
|
|
2349
|
+
device,
|
|
2350
|
+
path: input.path,
|
|
2351
|
+
name,
|
|
2352
|
+
size: file.size,
|
|
2353
|
+
key,
|
|
2354
|
+
key_version: grant.key_version,
|
|
2355
|
+
reason
|
|
2356
|
+
});
|
|
2357
|
+
throw new ZasError("direct_failed", 0, reason);
|
|
2358
|
+
}
|
|
2359
|
+
var fetchPut = async (url, bytes, signal, onLoaded) => {
|
|
2360
|
+
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
2361
|
+
const res = await fetch(url, { method: "PUT", body, signal });
|
|
2362
|
+
await res.body?.cancel().catch(() => void 0);
|
|
2363
|
+
if (!res.ok) throw new Error(`fallback_put_${res.status}`);
|
|
2364
|
+
const etag = res.headers.get("etag");
|
|
2365
|
+
if (!etag) throw new Error("fallback_etag_missing");
|
|
2366
|
+
onLoaded(bytes.length);
|
|
2367
|
+
return etag;
|
|
2368
|
+
};
|
|
2369
|
+
async function sendDirectFallback(ctx, record, report, deps = {}) {
|
|
2370
|
+
const now = deps.now ?? (() => Date.now());
|
|
2371
|
+
const startedAt = now();
|
|
2372
|
+
const opened = await (deps.openFile ?? openAsBlob)(record.path).catch(() => {
|
|
2373
|
+
throw new ZasError("upload_failed", 400);
|
|
2374
|
+
});
|
|
2375
|
+
if (opened.size !== record.size) throw new ZasError("file_changed", 0);
|
|
2376
|
+
const file = new File([opened], record.name, { type: mimeFor(record.path) });
|
|
2377
|
+
const { seal } = sealer(record.key, record.key_version);
|
|
2378
|
+
const stamp = { device: record.device, owner_uid: record.owner_uid };
|
|
2379
|
+
const base = `/direct/${record.channel_id}/${record.offer_id}/fallback`;
|
|
2380
|
+
report("encrypting");
|
|
2381
|
+
const meta = createFallbackMeta(file);
|
|
2382
|
+
await ctx.client.api("POST", `${base}/start`, {
|
|
2383
|
+
meta_enc: seal(meta),
|
|
2384
|
+
plain_size: meta.size,
|
|
2385
|
+
cipher_size: meta.cipher_size,
|
|
2386
|
+
part_count: meta.part_count,
|
|
2387
|
+
...stamp
|
|
2388
|
+
});
|
|
2389
|
+
report("uploading");
|
|
2390
|
+
let parts;
|
|
2391
|
+
try {
|
|
2392
|
+
parts = await uploadFallback({
|
|
2393
|
+
file,
|
|
2394
|
+
offerId: record.offer_id,
|
|
2395
|
+
meta,
|
|
2396
|
+
put: deps.put ?? fetchPut,
|
|
2397
|
+
getUrls: async (from, count) => {
|
|
2398
|
+
const r = await ctx.client.api(
|
|
2399
|
+
"POST",
|
|
2400
|
+
`${base}/parts`,
|
|
2401
|
+
{ from, count, ...stamp }
|
|
2402
|
+
);
|
|
2403
|
+
return r.urls;
|
|
2404
|
+
}
|
|
2405
|
+
});
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
await ctx.client.api("POST", `${base}/abort`, stamp).catch(() => void 0);
|
|
2408
|
+
if (error instanceof ZasError) throw error;
|
|
2409
|
+
throw new ZasError("upload_failed", 0, error instanceof Error ? error.message : String(error));
|
|
2410
|
+
}
|
|
2411
|
+
report("finishing");
|
|
2412
|
+
await ctx.client.api("POST", `${base}/complete`, {
|
|
2413
|
+
parts: parts.map((part) => ({ part_number: part.partNumber, etag: part.etag })),
|
|
2414
|
+
...stamp
|
|
2415
|
+
});
|
|
2416
|
+
return {
|
|
2417
|
+
offer_id: record.offer_id,
|
|
2418
|
+
channel_id: record.channel_id,
|
|
2419
|
+
channel_name: record.channel_name,
|
|
2420
|
+
bytes: record.size,
|
|
2421
|
+
duration_ms: now() - startedAt,
|
|
2422
|
+
parts: parts.length
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// src/jobs.ts
|
|
2427
|
+
import { randomUUID } from "node:crypto";
|
|
2428
|
+
var DEFAULT_WAIT_MS = 6e4;
|
|
2429
|
+
var HISTORY = 50;
|
|
2430
|
+
var JobRunner = class {
|
|
2431
|
+
now;
|
|
2432
|
+
waitMs;
|
|
2433
|
+
/** Newest first, and trimmed. */
|
|
2434
|
+
jobs = [];
|
|
2435
|
+
/** Keyed on the job object, not its id, so a caller holding a job that has
|
|
2436
|
+
* already aged out of the list can still wait for it to finish. */
|
|
2437
|
+
settled = /* @__PURE__ */ new WeakMap();
|
|
2438
|
+
constructor(opts = {}) {
|
|
2439
|
+
this.now = opts.now ?? (() => Date.now());
|
|
2440
|
+
this.waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
|
|
2441
|
+
}
|
|
2442
|
+
start(kind, title, channel, work) {
|
|
2443
|
+
const job = {
|
|
2444
|
+
id: randomUUID(),
|
|
2445
|
+
kind,
|
|
2446
|
+
title,
|
|
2447
|
+
channel,
|
|
2448
|
+
started_at: this.now(),
|
|
2449
|
+
phase: null,
|
|
2450
|
+
status: "running"
|
|
2451
|
+
};
|
|
2452
|
+
this.jobs.unshift(job);
|
|
2453
|
+
this.jobs.length = Math.min(this.jobs.length, HISTORY);
|
|
2454
|
+
const report = (phase) => {
|
|
2455
|
+
if (job.status === "running") job.phase = phase;
|
|
2456
|
+
};
|
|
2457
|
+
this.settled.set(job, work(report).then(
|
|
2458
|
+
(result) => {
|
|
2459
|
+
job.status = "done";
|
|
2460
|
+
job.result = result;
|
|
2461
|
+
return job;
|
|
2462
|
+
},
|
|
2463
|
+
(error) => {
|
|
2464
|
+
job.status = "failed";
|
|
2465
|
+
job.error = error instanceof ZasError ? {
|
|
2466
|
+
code: error.code,
|
|
2467
|
+
status: error.status,
|
|
2468
|
+
sentence: humanSentence(error),
|
|
2469
|
+
message: error.message,
|
|
2470
|
+
...error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {},
|
|
2471
|
+
...error.serverCode !== void 0 ? { serverCode: error.serverCode } : {}
|
|
2472
|
+
} : { code: "upload_failed", status: 0, sentence: humanSentence(new ZasError("upload_failed", 0)) };
|
|
2473
|
+
return job;
|
|
2474
|
+
}
|
|
2475
|
+
));
|
|
2476
|
+
return job;
|
|
2477
|
+
}
|
|
2478
|
+
/** Resolves when the work settles, or when the wait runs out — the same job
|
|
2479
|
+
* object either way, so the caller reads `status` rather than guessing. */
|
|
2480
|
+
async wait(job) {
|
|
2481
|
+
const settled = this.settled.get(job);
|
|
2482
|
+
if (!settled) return job;
|
|
2483
|
+
let timer;
|
|
2484
|
+
const deadline = new Promise((resolve2) => {
|
|
2485
|
+
timer = setTimeout(() => resolve2(job), this.waitMs);
|
|
2486
|
+
});
|
|
2487
|
+
try {
|
|
2488
|
+
return await Promise.race([settled, deadline]);
|
|
2489
|
+
} finally {
|
|
2490
|
+
clearTimeout(timer);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
list() {
|
|
2494
|
+
return this.jobs.slice(0, HISTORY);
|
|
2495
|
+
}
|
|
2496
|
+
get(id) {
|
|
2497
|
+
return this.jobs.find((job) => job.id === id);
|
|
2498
|
+
}
|
|
2499
|
+
};
|
|
2500
|
+
|
|
2501
|
+
// src/read.ts
|
|
2502
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2503
|
+
import {
|
|
2504
|
+
closeSync,
|
|
2505
|
+
existsSync as existsSync2,
|
|
2506
|
+
mkdirSync as mkdirSync2,
|
|
2507
|
+
mkdtempSync,
|
|
2508
|
+
openSync,
|
|
2509
|
+
renameSync as renameSync2,
|
|
2510
|
+
rmdirSync,
|
|
2511
|
+
statSync,
|
|
2512
|
+
unlinkSync,
|
|
2513
|
+
writeSync
|
|
2514
|
+
} from "node:fs";
|
|
2515
|
+
import { tmpdir } from "node:os";
|
|
2516
|
+
import { dirname, extname as extname2, join as join2 } from "node:path";
|
|
2517
|
+
var DEFAULT_LIMIT = 20;
|
|
2518
|
+
var MAX_LIMIT = 50;
|
|
2519
|
+
var DOWNLOAD_PREFIX = "zas-agent-";
|
|
2520
|
+
var ID_SEGMENT = /^(?!__)[A-Za-z0-9_-]{1,128}$/;
|
|
2521
|
+
var MAX_CHUNKS = 8192;
|
|
2522
|
+
var MAX_DUPLICATES = 100;
|
|
2523
|
+
function stringOf(value) {
|
|
2524
|
+
return typeof value?.stringValue === "string" ? value.stringValue : void 0;
|
|
2525
|
+
}
|
|
2526
|
+
function timeOf(value) {
|
|
2527
|
+
const parsed = typeof value?.timestampValue === "string" ? Date.parse(value.timestampValue) : NaN;
|
|
2528
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2529
|
+
}
|
|
2530
|
+
function rowOf(doc) {
|
|
2531
|
+
const document = doc ?? {};
|
|
2532
|
+
const name = typeof document.name === "string" ? document.name : "";
|
|
2533
|
+
const fields = document.fields && typeof document.fields === "object" ? document.fields : {};
|
|
2534
|
+
return {
|
|
2535
|
+
id: name.slice(name.lastIndexOf("/") + 1),
|
|
2536
|
+
manifestEnc: stringOf(fields.manifest_enc),
|
|
2537
|
+
agent: stringOf(fields.agent),
|
|
2538
|
+
createdAt: timeOf(fields.created_at),
|
|
2539
|
+
expiresAt: timeOf(fields.expires_at),
|
|
2540
|
+
bar: fields.bar?.booleanValue === true
|
|
2541
|
+
};
|
|
2542
|
+
}
|
|
2543
|
+
function readable(row) {
|
|
2544
|
+
if (!row.id || row.bar || !row.manifestEnc) return false;
|
|
2545
|
+
return row.expiresAt === null || row.expiresAt > Date.now();
|
|
2546
|
+
}
|
|
2547
|
+
function openFor(channelKey, row) {
|
|
2548
|
+
try {
|
|
2549
|
+
return openManifest(channelKey, b64ToBytes(row.manifestEnc));
|
|
2550
|
+
} catch {
|
|
2551
|
+
return null;
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
function summaryOf(row, manifest) {
|
|
2555
|
+
const kind = manifest.kind === "text" ? "text" : "file";
|
|
2556
|
+
const name = typeof manifest.name === "string" ? manifest.name : "";
|
|
2557
|
+
return {
|
|
2558
|
+
id: row.id,
|
|
2559
|
+
kind,
|
|
2560
|
+
// Absence means "show the file name": the sender chose no title.
|
|
2561
|
+
title: manifest.title ?? name,
|
|
2562
|
+
name,
|
|
2563
|
+
mime: typeof manifest.mime === "string" ? manifest.mime : "application/octet-stream",
|
|
2564
|
+
size: typeof manifest.size === "number" ? manifest.size : 0,
|
|
2565
|
+
// The sealed time is the sender's own; the row's is the server's, and it
|
|
2566
|
+
// only answers for a manifest that carries none.
|
|
2567
|
+
created_at: typeof manifest.created_at === "string" && manifest.created_at ? manifest.created_at : row.createdAt !== null ? new Date(row.createdAt).toISOString() : "",
|
|
2568
|
+
by_agent: row.agent !== void 0,
|
|
2569
|
+
...kind === "text" ? { text: manifest.text ?? "" } : {}
|
|
2570
|
+
};
|
|
2571
|
+
}
|
|
2572
|
+
async function readGrant(ctx, channel) {
|
|
2573
|
+
const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
|
|
2574
|
+
if (!grant.read) throw new ZasError("read_forbidden", 403);
|
|
2575
|
+
return grant;
|
|
1817
2576
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
`${input.lang ?? ""}\0${input.secret ? "1" : "0"}\0${input.text}`
|
|
1824
|
-
));
|
|
1825
|
-
const key = await receiptKey(ctx, grant.channel_id, contentHash, name);
|
|
1826
|
-
const stored = receiptFor(ctx, key);
|
|
1827
|
-
if (stored) {
|
|
1828
|
-
return {
|
|
1829
|
-
link_id: stored.link_id,
|
|
1830
|
-
channel_id: grant.channel_id,
|
|
1831
|
-
channel_name: channelNameOf(ctx.identity, grant),
|
|
1832
|
-
bytes: stored.bytes,
|
|
1833
|
-
chunks: 0,
|
|
1834
|
-
deduplicated: 0,
|
|
1835
|
-
replayed: true
|
|
1836
|
-
};
|
|
2577
|
+
function nameFrom(channelKey, grant) {
|
|
2578
|
+
try {
|
|
2579
|
+
return decryptChannelName(channelKey, b64ToBytes(grant.name_enc));
|
|
2580
|
+
} catch {
|
|
2581
|
+
throw new ZasError("key_stale", 0);
|
|
1837
2582
|
}
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
);
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
at: Date.now()
|
|
2583
|
+
}
|
|
2584
|
+
function linksParent(identity, grant) {
|
|
2585
|
+
return `accounts/${identity.owner_uid}/channels/${grant.channel_id}`;
|
|
2586
|
+
}
|
|
2587
|
+
function linkPath(identity, grant, id) {
|
|
2588
|
+
return `projects/${identity.firestore_project}/databases/(default)/documents/${linksParent(identity, grant)}/links/${id}`;
|
|
2589
|
+
}
|
|
2590
|
+
async function queryLinks(ctx, grant, query) {
|
|
2591
|
+
try {
|
|
2592
|
+
return await ctx.client.firestoreRunQuery(linksParent(ctx.identity, grant), query);
|
|
2593
|
+
} catch (err) {
|
|
2594
|
+
if (err instanceof ZasError && err.status === 403) throw new ZasError("read_forbidden", 403);
|
|
2595
|
+
throw err;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
function clampLimit(limit) {
|
|
2599
|
+
if (limit === void 0 || !Number.isFinite(limit)) return DEFAULT_LIMIT;
|
|
2600
|
+
return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
|
|
2601
|
+
}
|
|
2602
|
+
async function listItems(ctx, channel, limit) {
|
|
2603
|
+
const grant = await readGrant(ctx, channel);
|
|
2604
|
+
const channelKey = channelKeyOf(ctx.identity, grant);
|
|
2605
|
+
const docs = await queryLinks(ctx, grant, {
|
|
2606
|
+
from: [{ collectionId: "links" }],
|
|
2607
|
+
orderBy: [{ field: { fieldPath: "created_at" }, direction: "DESCENDING" }],
|
|
2608
|
+
limit: clampLimit(limit)
|
|
1865
2609
|
});
|
|
2610
|
+
const items = [];
|
|
2611
|
+
for (const doc of docs) {
|
|
2612
|
+
const row = rowOf(doc);
|
|
2613
|
+
if (!readable(row)) continue;
|
|
2614
|
+
const manifest = openFor(channelKey, row);
|
|
2615
|
+
if (!manifest) continue;
|
|
2616
|
+
items.push(summaryOf(row, manifest));
|
|
2617
|
+
}
|
|
1866
2618
|
return {
|
|
1867
|
-
link_id: linkId,
|
|
1868
2619
|
channel_id: grant.channel_id,
|
|
1869
|
-
channel_name:
|
|
1870
|
-
|
|
1871
|
-
chunks: 0,
|
|
1872
|
-
deduplicated: 0,
|
|
1873
|
-
replayed: false
|
|
2620
|
+
channel_name: nameFrom(channelKey, grant),
|
|
2621
|
+
items
|
|
1874
2622
|
};
|
|
1875
2623
|
}
|
|
2624
|
+
function redeemFailure(status) {
|
|
2625
|
+
if (status === 429) return new ZasError("rate_limited", 429);
|
|
2626
|
+
if (status >= 500) return new ZasError("network", status);
|
|
2627
|
+
return new ZasError("invalid_cap", 403);
|
|
2628
|
+
}
|
|
2629
|
+
async function fetchChunk(ctx, chunk) {
|
|
2630
|
+
if (typeof chunk.cap !== "string" || chunk.cap === "") throw new ZasError("invalid_cap", 403);
|
|
2631
|
+
let headers = {};
|
|
2632
|
+
try {
|
|
2633
|
+
headers = { Authorization: `Bearer ${await ctx.client.idToken()}` };
|
|
2634
|
+
} catch {
|
|
2635
|
+
headers = {};
|
|
2636
|
+
}
|
|
2637
|
+
let url;
|
|
2638
|
+
try {
|
|
2639
|
+
const redeemed = await apiPublic(
|
|
2640
|
+
ctx.identity.api_base,
|
|
2641
|
+
"POST",
|
|
2642
|
+
"/v1/blobs/redeem",
|
|
2643
|
+
{ cap: chunk.cap },
|
|
2644
|
+
headers
|
|
2645
|
+
);
|
|
2646
|
+
url = typeof redeemed.url === "string" ? redeemed.url : "";
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
if (err instanceof ZasError) throw redeemFailure(err.status);
|
|
2649
|
+
throw err;
|
|
2650
|
+
}
|
|
2651
|
+
if (url === "") throw new ZasError("invalid_cap", 403);
|
|
2652
|
+
const res = await fetch(url);
|
|
2653
|
+
if (!res.ok) {
|
|
2654
|
+
await res.body?.cancel().catch(() => void 0);
|
|
2655
|
+
throw res.status >= 500 ? new ZasError("network", res.status) : new ZasError("invalid_cap", 403);
|
|
2656
|
+
}
|
|
2657
|
+
const ciphertext = new Uint8Array(await res.arrayBuffer());
|
|
2658
|
+
try {
|
|
2659
|
+
return decryptChunk(b64ToBytes(chunk.key), b64ToBytes(chunk.nonce), ciphertext);
|
|
2660
|
+
} catch {
|
|
2661
|
+
throw new ZasError("invalid_cap", 403);
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
function safeName(name, fallback) {
|
|
2665
|
+
const base = (typeof name === "string" ? name : "").split(/[\\/]/).pop() ?? "";
|
|
2666
|
+
return base.replace(/^\.+/, "").trim() || fallback;
|
|
2667
|
+
}
|
|
2668
|
+
function freeName(target) {
|
|
2669
|
+
if (!existsSync2(target)) return target;
|
|
2670
|
+
const ext = extname2(target);
|
|
2671
|
+
const stem = target.slice(0, target.length - ext.length);
|
|
2672
|
+
for (let n = 1; n <= MAX_DUPLICATES; n++) {
|
|
2673
|
+
const candidate = `${stem} (${n})${ext}`;
|
|
2674
|
+
if (!existsSync2(candidate)) return candidate;
|
|
2675
|
+
}
|
|
2676
|
+
throw new ZasError("write_failed", 0, target);
|
|
2677
|
+
}
|
|
2678
|
+
function destinationOf(dest, name, fallback) {
|
|
2679
|
+
const base = safeName(name, fallback);
|
|
2680
|
+
if (dest === void 0) {
|
|
2681
|
+
const created = mkdtempSync(join2(tmpdir(), DOWNLOAD_PREFIX));
|
|
2682
|
+
return { target: freeName(join2(created, base)), created };
|
|
2683
|
+
}
|
|
2684
|
+
const at = statSync(dest, { throwIfNoEntry: false })?.isDirectory() ? join2(dest, base) : dest;
|
|
2685
|
+
return { target: freeName(at) };
|
|
2686
|
+
}
|
|
2687
|
+
async function getItem(ctx, channel, id, dest) {
|
|
2688
|
+
const grant = await readGrant(ctx, channel);
|
|
2689
|
+
if (!ID_SEGMENT.test(id)) throw new ZasError("not_found", 404);
|
|
2690
|
+
const channelKey = channelKeyOf(ctx.identity, grant);
|
|
2691
|
+
const docs = await queryLinks(ctx, grant, {
|
|
2692
|
+
from: [{ collectionId: "links" }],
|
|
2693
|
+
where: {
|
|
2694
|
+
fieldFilter: {
|
|
2695
|
+
field: { fieldPath: "__name__" },
|
|
2696
|
+
op: "EQUAL",
|
|
2697
|
+
value: { referenceValue: linkPath(ctx.identity, grant, id) }
|
|
2698
|
+
}
|
|
2699
|
+
},
|
|
2700
|
+
limit: 1
|
|
2701
|
+
});
|
|
2702
|
+
const row = docs.length > 0 ? rowOf(docs[0]) : null;
|
|
2703
|
+
if (!row || !readable(row)) throw new ZasError("not_found", 404);
|
|
2704
|
+
const manifest = openFor(channelKey, row);
|
|
2705
|
+
if (!manifest) throw new ZasError("not_found", 404);
|
|
2706
|
+
if (manifest.kind === "text") {
|
|
2707
|
+
const text2 = manifest.text ?? "";
|
|
2708
|
+
return { text: text2, bytes: new TextEncoder().encode(text2).length };
|
|
2709
|
+
}
|
|
2710
|
+
if (manifest.chunks.length === 0) throw new ZasError("not_found", 404);
|
|
2711
|
+
if (manifest.chunks.length > MAX_CHUNKS) throw new ZasError("not_found", 404);
|
|
2712
|
+
let target;
|
|
2713
|
+
let created;
|
|
2714
|
+
let tmp;
|
|
2715
|
+
let fd;
|
|
2716
|
+
let written = 0;
|
|
2717
|
+
try {
|
|
2718
|
+
const chosen = destinationOf(dest, manifest.name, row.id);
|
|
2719
|
+
target = chosen.target;
|
|
2720
|
+
created = chosen.created;
|
|
2721
|
+
mkdirSync2(dirname(target), { recursive: true, mode: 448 });
|
|
2722
|
+
tmp = `${target}.${randomBytes2(6).toString("hex")}.tmp`;
|
|
2723
|
+
fd = openSync(tmp, "wx", 384);
|
|
2724
|
+
for (const chunk of manifest.chunks) {
|
|
2725
|
+
written += writeSync(fd, await fetchChunk(ctx, chunk));
|
|
2726
|
+
}
|
|
2727
|
+
const size = typeof manifest.size === "number" ? manifest.size : 0;
|
|
2728
|
+
if (size > 0 && written !== size) throw new ZasError("not_found", 404);
|
|
2729
|
+
closeSync(fd);
|
|
2730
|
+
fd = void 0;
|
|
2731
|
+
renameSync2(tmp, target);
|
|
2732
|
+
} catch (err) {
|
|
2733
|
+
if (err instanceof ZasError) throw err;
|
|
2734
|
+
const failure = err;
|
|
2735
|
+
if (typeof failure.syscall === "string" || typeof failure.errno === "number") {
|
|
2736
|
+
throw new ZasError("write_failed", 0, failure.message);
|
|
2737
|
+
}
|
|
2738
|
+
throw new ZasError("network", 0, String(err?.message ?? err));
|
|
2739
|
+
} finally {
|
|
2740
|
+
if (fd !== void 0) try {
|
|
2741
|
+
closeSync(fd);
|
|
2742
|
+
} catch {
|
|
2743
|
+
}
|
|
2744
|
+
if (tmp !== void 0 && existsSync2(tmp)) try {
|
|
2745
|
+
unlinkSync(tmp);
|
|
2746
|
+
} catch {
|
|
2747
|
+
}
|
|
2748
|
+
if (created !== void 0) try {
|
|
2749
|
+
rmdirSync(created);
|
|
2750
|
+
} catch {
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
return { path: target, bytes: written };
|
|
2754
|
+
}
|
|
1876
2755
|
|
|
1877
2756
|
// src/server.ts
|
|
1878
2757
|
function agentVersion() {
|
|
1879
|
-
return true ? "0.
|
|
2758
|
+
return true ? "0.4.0" : "0.0.0-dev";
|
|
1880
2759
|
}
|
|
1881
2760
|
var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
|
|
1882
2761
|
var PAIR_ANNOUNCE_MS = 15e3;
|
|
@@ -1886,7 +2765,7 @@ var delay = (ms) => new Promise((resolve2) => {
|
|
|
1886
2765
|
});
|
|
1887
2766
|
function rightsOf(grant) {
|
|
1888
2767
|
const rights = [];
|
|
1889
|
-
if (grant.send && grant.mode !== "view"
|
|
2768
|
+
if (grant.send && grant.mode !== "view") rights.push(grant.direct_mode ? "send (Directo)" : "send");
|
|
1890
2769
|
if (grant.read) rights.push("read");
|
|
1891
2770
|
return rights.length > 0 ? rights.join(" \xB7 ") : "no access";
|
|
1892
2771
|
}
|
|
@@ -2021,7 +2900,7 @@ function buildServer(profile, deps = {}) {
|
|
|
2021
2900
|
return text2(["pending", ...pairing.logs].join("\n"));
|
|
2022
2901
|
});
|
|
2023
2902
|
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,
|
|
2903
|
+
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
2904
|
inputSchema: {
|
|
2026
2905
|
path: z.string().describe("Absolute or relative path of the file to send."),
|
|
2027
2906
|
channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel."),
|
|
@@ -2041,6 +2920,58 @@ function buildServer(profile, deps = {}) {
|
|
|
2041
2920
|
return failed(e);
|
|
2042
2921
|
}
|
|
2043
2922
|
});
|
|
2923
|
+
const failedDirects = /* @__PURE__ */ new Map();
|
|
2924
|
+
const rememberFailed = (jobId, record) => {
|
|
2925
|
+
failedDirects.set(jobId, record);
|
|
2926
|
+
if (failedDirects.size > 50) failedDirects.delete(failedDirects.keys().next().value);
|
|
2927
|
+
};
|
|
2928
|
+
server.registerTool("zas_send_direct", {
|
|
2929
|
+
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,
|
|
2930
|
+
inputSchema: {
|
|
2931
|
+
path: z.string().describe("Absolute or relative path of the file to send."),
|
|
2932
|
+
channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel.")
|
|
2933
|
+
}
|
|
2934
|
+
}, async (input) => {
|
|
2935
|
+
try {
|
|
2936
|
+
const c = ctx();
|
|
2937
|
+
let job;
|
|
2938
|
+
job = runner.start(
|
|
2939
|
+
"direct",
|
|
2940
|
+
input.path,
|
|
2941
|
+
input.channel ?? "",
|
|
2942
|
+
(report) => sendDirect(c, input, report, {
|
|
2943
|
+
...deps.direct ?? {},
|
|
2944
|
+
onFailed: (record) => rememberFailed(job.id, record)
|
|
2945
|
+
})
|
|
2946
|
+
);
|
|
2947
|
+
return settled(await runner.wait(job));
|
|
2948
|
+
} catch (e) {
|
|
2949
|
+
return failed(e);
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
server.registerTool("zas_send_direct_fallback", {
|
|
2953
|
+
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,
|
|
2954
|
+
inputSchema: {
|
|
2955
|
+
job: z.string().describe("The job id zas_send_direct or zas_jobs reported for the Directo send that failed.")
|
|
2956
|
+
}
|
|
2957
|
+
}, async (input) => {
|
|
2958
|
+
try {
|
|
2959
|
+
const c = ctx();
|
|
2960
|
+
const record = failedDirects.get(input.job);
|
|
2961
|
+
if (!record) return failed(new ZasError("direct_not_failed", 0));
|
|
2962
|
+
const job = runner.start(
|
|
2963
|
+
"fallback",
|
|
2964
|
+
record.name,
|
|
2965
|
+
record.channel_name,
|
|
2966
|
+
(report) => sendDirectFallback(c, record, report, deps.direct)
|
|
2967
|
+
);
|
|
2968
|
+
const outcome = await runner.wait(job);
|
|
2969
|
+
if (outcome.status === "done") failedDirects.delete(input.job);
|
|
2970
|
+
return settled(outcome);
|
|
2971
|
+
} catch (e) {
|
|
2972
|
+
return failed(e);
|
|
2973
|
+
}
|
|
2974
|
+
});
|
|
2044
2975
|
server.registerTool("zas_send_note", {
|
|
2045
2976
|
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
2977
|
inputSchema: {
|
|
@@ -2092,7 +3023,7 @@ function buildServer(profile, deps = {}) {
|
|
|
2092
3023
|
}
|
|
2093
3024
|
});
|
|
2094
3025
|
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
|
|
3026
|
+
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
3027
|
}, async () => text2(runner.list()));
|
|
2097
3028
|
return server;
|
|
2098
3029
|
}
|