zas-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +286 -0
- package/dist/cli.js +1931 -0
- package/package.json +61 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1931 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __cr } from "node:module"; const require = __cr(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
|
|
9
|
+
// src/shared/agent.ts
|
|
10
|
+
import { p256 } from "@noble/curves/nist.js";
|
|
11
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2";
|
|
12
|
+
|
|
13
|
+
// src/shared/hash.ts
|
|
14
|
+
import { blake3 } from "hash-wasm";
|
|
15
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
16
|
+
import { sha512 } from "@noble/hashes/sha2";
|
|
17
|
+
import { hmac } from "@noble/hashes/hmac";
|
|
18
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
19
|
+
async function blake3Bytes(data) {
|
|
20
|
+
const hex = await blake3(data, 256);
|
|
21
|
+
return hexToBytes(hex);
|
|
22
|
+
}
|
|
23
|
+
async function blake3Hex(data) {
|
|
24
|
+
return blake3(data, 256);
|
|
25
|
+
}
|
|
26
|
+
function hkdf512(ikm, info, length) {
|
|
27
|
+
return hkdf(sha512, ikm, void 0, new TextEncoder().encode(info), length);
|
|
28
|
+
}
|
|
29
|
+
function hmacSha256(key, data) {
|
|
30
|
+
return hmac(sha256, key, data);
|
|
31
|
+
}
|
|
32
|
+
function hexToBytes(hex) {
|
|
33
|
+
const out = new Uint8Array(hex.length / 2);
|
|
34
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function bytesToHex(bytes) {
|
|
38
|
+
let s = "";
|
|
39
|
+
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
function bytesToB64(bytes) {
|
|
43
|
+
let bin = "";
|
|
44
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
45
|
+
const b64 = typeof btoa !== "undefined" ? btoa(bin) : Buffer.from(bytes).toString("base64");
|
|
46
|
+
return b64;
|
|
47
|
+
}
|
|
48
|
+
function b64ToBytes(b64) {
|
|
49
|
+
if (typeof atob !== "undefined") {
|
|
50
|
+
const bin = atob(b64);
|
|
51
|
+
const out = new Uint8Array(bin.length);
|
|
52
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
56
|
+
}
|
|
57
|
+
function concatBytes(...arrays) {
|
|
58
|
+
const total = arrays.reduce((n, a) => n + a.length, 0);
|
|
59
|
+
const out = new Uint8Array(total);
|
|
60
|
+
let off = 0;
|
|
61
|
+
for (const a of arrays) {
|
|
62
|
+
out.set(a, off);
|
|
63
|
+
off += a.length;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/shared/agent.ts
|
|
69
|
+
var AGENT_KINDS = ["claude_code", "codex", "other"];
|
|
70
|
+
function isAgentKind(value) {
|
|
71
|
+
return typeof value === "string" && AGENT_KINDS.includes(value);
|
|
72
|
+
}
|
|
73
|
+
var AGENT_PAIRING_TTL_MS = 10 * 60 * 1e3;
|
|
74
|
+
var AGENT_CHALLENGE_TTL_MS = 60 * 1e3;
|
|
75
|
+
var AGENT_TOKEN_TTL_SEC = 3600;
|
|
76
|
+
var AGENT_LAST_SEEN_BUCKET_MS = 5 * 60 * 1e3;
|
|
77
|
+
var AGENT_HOST_MAX = 60;
|
|
78
|
+
var AGENT_CHALLENGE_DOMAIN = "ZAS-AGENT-CHALLENGE-V1";
|
|
79
|
+
var text = new TextEncoder();
|
|
80
|
+
function field(bytes) {
|
|
81
|
+
const length = new Uint8Array(4);
|
|
82
|
+
new DataView(length.buffer).setUint32(0, bytes.length, false);
|
|
83
|
+
return concatBytes(length, bytes);
|
|
84
|
+
}
|
|
85
|
+
function agentChallengeBytes(agentUid, challengeId, nonce) {
|
|
86
|
+
return sha2562(concatBytes(
|
|
87
|
+
field(text.encode(AGENT_CHALLENGE_DOMAIN)),
|
|
88
|
+
field(text.encode(agentUid)),
|
|
89
|
+
field(text.encode(challengeId)),
|
|
90
|
+
field(nonce)
|
|
91
|
+
));
|
|
92
|
+
}
|
|
93
|
+
function signAgentChallenge(privateKey, agentUid, challengeId, nonce) {
|
|
94
|
+
return p256.sign(agentChallengeBytes(agentUid, challengeId, nonce), privateKey, { lowS: true }).toCompactRawBytes();
|
|
95
|
+
}
|
|
96
|
+
function agentFingerprintShort(fingerprint) {
|
|
97
|
+
return fingerprint.slice(0, 16).match(/.{4}/g).join(" ");
|
|
98
|
+
}
|
|
99
|
+
function agentSendIdempotencyKey(channelId, contentHashHex, title) {
|
|
100
|
+
return bytesToHex(sha2562(text.encode(`${channelId}\0${contentHashHex}\0${title}`))).slice(0, 32);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/errors.ts
|
|
104
|
+
var RENAMES = {
|
|
105
|
+
storage_limit: "quota_exceeded",
|
|
106
|
+
file_too_large: "file_too_big",
|
|
107
|
+
read_only: "send_forbidden",
|
|
108
|
+
unknown_channel: "grant_missing",
|
|
109
|
+
no_account: "grant_missing"
|
|
110
|
+
};
|
|
111
|
+
var ZasError = class extends Error {
|
|
112
|
+
constructor(code, status, message, retryAfterMs, serverCode) {
|
|
113
|
+
super(message ?? code);
|
|
114
|
+
this.code = code;
|
|
115
|
+
this.status = status;
|
|
116
|
+
this.retryAfterMs = retryAfterMs;
|
|
117
|
+
this.serverCode = serverCode;
|
|
118
|
+
this.name = "ZasError";
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var SIGN_IN_CODES = /* @__PURE__ */ new Set([
|
|
122
|
+
"bad_signature",
|
|
123
|
+
"rate_limited",
|
|
124
|
+
"feature_disabled",
|
|
125
|
+
"missing_token",
|
|
126
|
+
"agent_revoked",
|
|
127
|
+
"agent_forbidden"
|
|
128
|
+
]);
|
|
129
|
+
function errorFromResponse(status, body) {
|
|
130
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
131
|
+
const raw = typeof fields.error === "string" ? fields.error : `http_${status}`;
|
|
132
|
+
const retryAfterMs = typeof fields.retry_after_ms === "number" && Number.isFinite(fields.retry_after_ms) ? fields.retry_after_ms : void 0;
|
|
133
|
+
const renamed = RENAMES[raw] ?? raw;
|
|
134
|
+
const known = Object.prototype.hasOwnProperty.call(SENTENCES, renamed) || SIGN_IN_CODES.has(renamed);
|
|
135
|
+
const code = known ? renamed : status < 500 ? "upload_failed" : "network";
|
|
136
|
+
return new ZasError(code, status, raw, retryAfterMs, raw);
|
|
137
|
+
}
|
|
138
|
+
var SENTENCES = {
|
|
139
|
+
identity_corrupt: { es: "El archivo de identidad del agente est\xE1 da\xF1ado: {path}. Restauralo o borralo y volv\xE9 a emparejar.", en: "The agent identity file is damaged: {path}. Restore it, or delete it and pair again." },
|
|
140
|
+
not_paired: { es: "Este agente todav\xEDa no est\xE1 emparejado. Corr\xE9 \xABnpx -y zas-agent pair\xBB en la terminal.", en: "This agent is not paired yet. Run \u201Cnpx -y zas-agent pair\u201D in the terminal." },
|
|
141
|
+
internal: { es: "Algo fall\xF3 dentro del agente. Prob\xE1 de nuevo.", en: "Something failed inside the agent. Try again." },
|
|
142
|
+
agent_forbidden: { es: "Este agente no puede hacer eso; solo el due\xF1o de la cuenta.", en: "This agent cannot do that; only the account owner can." },
|
|
143
|
+
agent_revoked: { es: "El due\xF1o revoc\xF3 este agente. Volv\xE9 a emparejar con \xABzas-agent pair\xBB.", en: "The owner revoked this agent. Pair again with \u201Czas-agent pair\u201D." },
|
|
144
|
+
grant_missing: { es: "Este agente no tiene acceso a ese canal. El due\xF1o lo agrega desde Ajustes \u2192 Agentes.", en: "This agent has no access to that channel. The owner adds it from Settings \u2192 Agents." },
|
|
145
|
+
not_found: { es: "Ese \xEDtem no est\xE1 en el canal.", en: "That item is not in the channel." },
|
|
146
|
+
invalid_cap: { es: "Ese archivo ya no est\xE1 disponible.", en: "That file is no longer available." },
|
|
147
|
+
write_failed: { es: "No se pudo guardar el archivo en el destino.", en: "The file could not be saved to the destination." },
|
|
148
|
+
send_forbidden: { es: "Este agente no puede enviar a ese canal.", en: "This agent cannot send to that channel." },
|
|
149
|
+
read_forbidden: { es: "Este agente no puede leer ese canal.", en: "This agent cannot read that channel." },
|
|
150
|
+
direct_mode: { es: "Ese canal est\xE1 en modo Directo. Us\xE1 zas_send_direct.", en: "That channel is in Directo mode. Use zas_send_direct." },
|
|
151
|
+
not_direct_mode: { es: "Ese canal no est\xE1 en modo Directo. Us\xE1 zas_send_file.", en: "That channel is not in Directo mode. Use zas_send_file." },
|
|
152
|
+
key_stale: { es: "La clave del canal cambi\xF3. El due\xF1o la renueva al abrir Zas.", en: "The channel key changed. The owner refreshes it by opening Zas." },
|
|
153
|
+
quota_exceeded: { es: "La cuenta lleg\xF3 a su l\xEDmite de almacenamiento.", en: "The account reached its storage limit." },
|
|
154
|
+
rate_limited: { es: "Demasiados env\xEDos seguidos. Esper\xE1 un momento.", en: "Too many sends in a row. Wait a moment." },
|
|
155
|
+
file_too_big: { es: "El archivo supera el m\xE1ximo del plan.", en: "The file is over the plan limit." },
|
|
156
|
+
duplicate: { es: "Ese \xEDtem ya est\xE1 en el canal.", en: "That item is already in the channel." },
|
|
157
|
+
pairing_expired: { es: "El emparejamiento venci\xF3. Corr\xE9 \xABzas-agent pair\xBB de nuevo.", en: "The pairing expired. Run \u201Czas-agent pair\u201D again." },
|
|
158
|
+
pairing_cancelled: { es: "El due\xF1o cancel\xF3 el emparejamiento.", en: "The owner cancelled the pairing." },
|
|
159
|
+
feature_disabled: { es: "Los agentes todav\xEDa no est\xE1n habilitados para esta cuenta.", en: "Agents are not enabled for this account yet." },
|
|
160
|
+
upload_failed: { es: "No se pudo subir el archivo. Prob\xE1 de nuevo.", en: "The upload failed. Try again." },
|
|
161
|
+
oprf_failed: { es: "Zas no respondi\xF3 bien al preparar el archivo. Prob\xE1 de nuevo.", en: "Zas did not answer correctly while preparing the file. Try again." },
|
|
162
|
+
network: { es: "No hay conexi\xF3n con Zas. Prob\xE1 de nuevo en un momento.", en: "Zas cannot be reached. Try again in a moment." },
|
|
163
|
+
sign_in_failed: { es: "Zas no acept\xF3 la sesi\xF3n de este agente. Prob\xE1 de nuevo o volv\xE9 a emparejar.", en: "Zas did not accept this agent session. Try again, or pair again." },
|
|
164
|
+
bad_signature: { es: "Zas rechaz\xF3 la firma de este agente. Emparejalo de nuevo.", en: "Zas rejected this agent's signature. Pair it again." },
|
|
165
|
+
missing_token: { es: "Falta el token de sesi\xF3n. Emparej\xE1 el agente de nuevo.", en: "The session token is missing. Pair the agent again." }
|
|
166
|
+
};
|
|
167
|
+
function humanSentence(error, locale = "es") {
|
|
168
|
+
const sentence = SENTENCES[error.code]?.[locale];
|
|
169
|
+
if (sentence) return sentence.replace("{path}", () => error.message);
|
|
170
|
+
return locale === "es" ? `Zas respondi\xF3 ${error.code} (${error.status}).` : `Zas answered ${error.code} (${error.status}).`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/identity.ts
|
|
174
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
175
|
+
import { homedir } from "node:os";
|
|
176
|
+
import { join, resolve } from "node:path";
|
|
177
|
+
import { x25519 } from "@noble/curves/ed25519";
|
|
178
|
+
import { p256 as p2562 } from "@noble/curves/nist.js";
|
|
179
|
+
var IDENTITY_FILE = "identity.json";
|
|
180
|
+
var PENDING_FILE = "pending.json";
|
|
181
|
+
var GRANTS_FILE = "grants.json";
|
|
182
|
+
var FINGERPRINTS_FILE = "fingerprints.json";
|
|
183
|
+
function agentHome() {
|
|
184
|
+
return process.env.ZAS_AGENT_HOME || join(homedir(), ".zas", "agent");
|
|
185
|
+
}
|
|
186
|
+
var PROFILE_RE = /^(?!\.)[A-Za-z0-9._-]{1,64}$/;
|
|
187
|
+
function profileDir(profile) {
|
|
188
|
+
if (!PROFILE_RE.test(profile)) throw new ZasError("internal", 0, `Perfil inv\xE1lido: ${profile}`);
|
|
189
|
+
return join(agentHome(), profile);
|
|
190
|
+
}
|
|
191
|
+
function ensureDir(dir) {
|
|
192
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
193
|
+
if (process.platform !== "win32") chmodSync(dir, 448);
|
|
194
|
+
}
|
|
195
|
+
function writePrivate(dir, name, value) {
|
|
196
|
+
ensureDir(dir);
|
|
197
|
+
const target = join(dir, name);
|
|
198
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
199
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
|
|
200
|
+
`, { mode: 384 });
|
|
201
|
+
if (process.platform !== "win32") chmodSync(tmp, 384);
|
|
202
|
+
renameSync(tmp, target);
|
|
203
|
+
}
|
|
204
|
+
function readText(path) {
|
|
205
|
+
try {
|
|
206
|
+
return readFileSync(path, "utf8");
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (err.code === "ENOENT") return null;
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function readCache(dir, name) {
|
|
213
|
+
const target = join(dir, name);
|
|
214
|
+
if (!existsSync(target)) return null;
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(readFileSync(target, "utf8"));
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function readSecret(dir, name) {
|
|
222
|
+
const target = resolve(join(dir, name));
|
|
223
|
+
const text2 = readText(target);
|
|
224
|
+
if (text2 === null) return null;
|
|
225
|
+
let parsed = null;
|
|
226
|
+
try {
|
|
227
|
+
parsed = JSON.parse(text2);
|
|
228
|
+
} catch {
|
|
229
|
+
parsed = null;
|
|
230
|
+
}
|
|
231
|
+
if (!parsed || typeof parsed !== "object") throw new ZasError("identity_corrupt", 0, target);
|
|
232
|
+
return parsed;
|
|
233
|
+
}
|
|
234
|
+
function newKeyMaterial() {
|
|
235
|
+
const x = x25519.utils.randomSecretKey();
|
|
236
|
+
const p = p2562.utils.randomSecretKey();
|
|
237
|
+
return {
|
|
238
|
+
x25519_private: bytesToB64(x),
|
|
239
|
+
x25519_public: bytesToB64(x25519.getPublicKey(x)),
|
|
240
|
+
p256_private: bytesToB64(p),
|
|
241
|
+
// Uncompressed: 65 bytes is what the server stores and verifies against.
|
|
242
|
+
p256_public: bytesToB64(p2562.getPublicKey(p, false))
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function loadIdentity(profile) {
|
|
246
|
+
return readSecret(profileDir(profile), IDENTITY_FILE);
|
|
247
|
+
}
|
|
248
|
+
function saveIdentity(profile, identity) {
|
|
249
|
+
writePrivate(profileDir(profile), IDENTITY_FILE, identity);
|
|
250
|
+
}
|
|
251
|
+
function savePending(profile, pending) {
|
|
252
|
+
writePrivate(profileDir(profile), PENDING_FILE, pending);
|
|
253
|
+
}
|
|
254
|
+
function clearPending(profile) {
|
|
255
|
+
rmSync(join(profileDir(profile), PENDING_FILE), { force: true });
|
|
256
|
+
}
|
|
257
|
+
function loadGrants(profile) {
|
|
258
|
+
return readCache(profileDir(profile), GRANTS_FILE);
|
|
259
|
+
}
|
|
260
|
+
function saveGrants(profile, cache) {
|
|
261
|
+
writePrivate(profileDir(profile), GRANTS_FILE, cache);
|
|
262
|
+
}
|
|
263
|
+
var isPlainObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
264
|
+
function loadFingerprints(profile) {
|
|
265
|
+
const cached = readCache(profileDir(profile), FINGERPRINTS_FILE);
|
|
266
|
+
if (!isPlainObject(cached) || !isPlainObject(cached.entries)) return { entries: {} };
|
|
267
|
+
return { entries: cached.entries };
|
|
268
|
+
}
|
|
269
|
+
function saveFingerprints(profile, cache) {
|
|
270
|
+
writePrivate(profileDir(profile), FINGERPRINTS_FILE, cache);
|
|
271
|
+
}
|
|
272
|
+
function defaultEndpoints() {
|
|
273
|
+
return {
|
|
274
|
+
api_base: process.env.ZAS_API_BASE || "https://zas.red/api",
|
|
275
|
+
token_base: process.env.ZAS_TOKEN_BASE || "https://zas.red/anon-token",
|
|
276
|
+
oprf_base: process.env.ZAS_OPRF_BASE || "https://zas.red/oprf",
|
|
277
|
+
firestore_project: process.env.ZAS_FIREBASE_PROJECT || "zas-me"
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/pair.ts
|
|
282
|
+
import { hostname } from "node:os";
|
|
283
|
+
|
|
284
|
+
// src/client.ts
|
|
285
|
+
var REFRESH_MARGIN_MS = 5 * 60 * 1e3;
|
|
286
|
+
async function readBody(res) {
|
|
287
|
+
const text2 = await res.text();
|
|
288
|
+
if (!text2) return {};
|
|
289
|
+
try {
|
|
290
|
+
return JSON.parse(text2);
|
|
291
|
+
} catch {
|
|
292
|
+
return {};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function jsonInit(method, body, headers) {
|
|
296
|
+
return {
|
|
297
|
+
method,
|
|
298
|
+
headers: { ...body === void 0 ? {} : { "Content-Type": "application/json" }, ...headers },
|
|
299
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
async function apiPublic(base, method, path, body, headers = {}, fetchImpl) {
|
|
303
|
+
const call = fetchImpl ?? ((input, init) => fetch(input, init));
|
|
304
|
+
const res = await call(`${base}${path}`, jsonInit(method, body, headers));
|
|
305
|
+
const parsed = res.status === 204 ? void 0 : await readBody(res);
|
|
306
|
+
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
307
|
+
return parsed;
|
|
308
|
+
}
|
|
309
|
+
var ZasClient = class _ZasClient {
|
|
310
|
+
constructor(identity, opts = {}) {
|
|
311
|
+
this.identity = identity;
|
|
312
|
+
this.call = opts.fetch ?? ((input, init) => fetch(input, init));
|
|
313
|
+
this.now = opts.now ?? (() => Date.now());
|
|
314
|
+
}
|
|
315
|
+
call;
|
|
316
|
+
now;
|
|
317
|
+
/** No refresh token: the agent re-signs in from its P-256 key, which it has
|
|
318
|
+
* on disk anyway, so keeping a long-lived credential in memory would buy
|
|
319
|
+
* nothing and widen what a crash dump holds. */
|
|
320
|
+
session = null;
|
|
321
|
+
/** Concurrent callers wait on the one sign-in instead of racing three. */
|
|
322
|
+
pendingSignIn = null;
|
|
323
|
+
async signIn() {
|
|
324
|
+
const { agent_uid, owner_uid, token_base, p256_private } = this.identity;
|
|
325
|
+
const chal = await apiPublic(
|
|
326
|
+
token_base,
|
|
327
|
+
"POST",
|
|
328
|
+
"/v1/agents/challenge",
|
|
329
|
+
{ agent_uid, owner_uid },
|
|
330
|
+
{},
|
|
331
|
+
this.call
|
|
332
|
+
);
|
|
333
|
+
const signature = bytesToB64(
|
|
334
|
+
signAgentChallenge(b64ToBytes(p256_private), agent_uid, chal.challenge_id, b64ToBytes(chal.nonce))
|
|
335
|
+
);
|
|
336
|
+
const tok = await apiPublic(
|
|
337
|
+
token_base,
|
|
338
|
+
"POST",
|
|
339
|
+
"/v1/agents/token",
|
|
340
|
+
{ agent_uid, owner_uid, challenge_id: chal.challenge_id, signature },
|
|
341
|
+
{},
|
|
342
|
+
this.call
|
|
343
|
+
);
|
|
344
|
+
const res = await this.call(`${_ZasClient.identityToolkitBase()}/accounts:signInWithCustomToken?key=${_ZasClient.apiKey()}`, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { "Content-Type": "application/json" },
|
|
347
|
+
body: JSON.stringify({ token: tok.token, returnSecureToken: true })
|
|
348
|
+
});
|
|
349
|
+
if (!res.ok) throw new ZasError("sign_in_failed", res.status);
|
|
350
|
+
const data = await res.json();
|
|
351
|
+
const seconds = Number(data.expiresIn);
|
|
352
|
+
const ttl = Number.isFinite(seconds) && seconds > 0 ? seconds : AGENT_TOKEN_TTL_SEC;
|
|
353
|
+
this.session = { idToken: data.idToken, expiresAt: this.now() + ttl * 1e3 };
|
|
354
|
+
}
|
|
355
|
+
async idToken() {
|
|
356
|
+
if (!this.session || this.session.expiresAt - this.now() <= REFRESH_MARGIN_MS) {
|
|
357
|
+
await this.signInOnce();
|
|
358
|
+
}
|
|
359
|
+
return this.session.idToken;
|
|
360
|
+
}
|
|
361
|
+
async signInOnce() {
|
|
362
|
+
if (!this.pendingSignIn) {
|
|
363
|
+
this.pendingSignIn = this.signIn().finally(() => {
|
|
364
|
+
this.pendingSignIn = null;
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
await this.pendingSignIn;
|
|
368
|
+
}
|
|
369
|
+
async api(method, path, body, headers = {}) {
|
|
370
|
+
const res = await this.authedRetrying(`${this.identity.api_base}/v1${path}`, method, body, headers);
|
|
371
|
+
const parsed = res.status === 204 ? void 0 : await readBody(res);
|
|
372
|
+
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
373
|
+
return parsed;
|
|
374
|
+
}
|
|
375
|
+
async authed(url, method, body, headers) {
|
|
376
|
+
const token = await this.idToken();
|
|
377
|
+
return this.call(url, jsonInit(method, body, { Authorization: `Bearer ${token}`, ...headers }));
|
|
378
|
+
}
|
|
379
|
+
/** One authenticated call, with one fresh sign-in if the token is rejected.
|
|
380
|
+
* A 401 on a token the clock said was young means a revoked session or a
|
|
381
|
+
* server that restarted; both are fixed by signing in again, and neither is
|
|
382
|
+
* fixed by doing it twice. The API and the OPRF service both call through
|
|
383
|
+
* here, so a token that dies mid-send costs one retry, not the send. */
|
|
384
|
+
async authedRetrying(url, method, body, headers) {
|
|
385
|
+
const res = await this.authed(url, method, body, headers);
|
|
386
|
+
if (res.status !== 401) return res;
|
|
387
|
+
await res.body?.cancel().catch(() => {
|
|
388
|
+
});
|
|
389
|
+
this.session = null;
|
|
390
|
+
return this.authed(url, method, body, headers);
|
|
391
|
+
}
|
|
392
|
+
async oprfEvaluate(blinded) {
|
|
393
|
+
const res = await this.authedRetrying(`${this.identity.oprf_base}/evaluate`, "POST", { blinded }, {});
|
|
394
|
+
const parsed = await readBody(res);
|
|
395
|
+
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
396
|
+
return parsed.evaluated ?? [];
|
|
397
|
+
}
|
|
398
|
+
/** Firestore REST runQuery. `parentPath` is relative to the documents root
|
|
399
|
+
* ('' for the root itself); the rows without a `document` are the read-time
|
|
400
|
+
* metadata Firestore interleaves, and they are dropped. */
|
|
401
|
+
async firestoreRunQuery(parentPath, query) {
|
|
402
|
+
const base = _ZasClient.firestoreBase(this.identity.firestore_project);
|
|
403
|
+
const url = `${base}${parentPath ? `/${parentPath}` : ""}:runQuery`;
|
|
404
|
+
const res = await this.authed(url, "POST", { structuredQuery: query }, {});
|
|
405
|
+
const parsed = await readBody(res);
|
|
406
|
+
if (!res.ok) throw errorFromResponse(res.status, parsed);
|
|
407
|
+
if (!Array.isArray(parsed)) return [];
|
|
408
|
+
return parsed.filter((row) => !!row && typeof row === "object" && "document" in row).map((row) => row.document);
|
|
409
|
+
}
|
|
410
|
+
/** The web app's public config value. It identifies the project, it is not a secret. */
|
|
411
|
+
static apiKey() {
|
|
412
|
+
return process.env.ZAS_FIREBASE_API_KEY || "AIzaSyAiZbAPrxH7EKaJftJoGcEVEL0h6rAVcvE";
|
|
413
|
+
}
|
|
414
|
+
static identityToolkitBase() {
|
|
415
|
+
const host = process.env.FIREBASE_AUTH_EMULATOR_HOST;
|
|
416
|
+
return host ? `http://${host}/identitytoolkit.googleapis.com/v1` : "https://identitytoolkit.googleapis.com/v1";
|
|
417
|
+
}
|
|
418
|
+
static firestoreBase(project) {
|
|
419
|
+
const host = process.env.FIRESTORE_EMULATOR_HOST;
|
|
420
|
+
const root = host ? `http://${host}` : "https://firestore.googleapis.com";
|
|
421
|
+
return `${root}/v1/projects/${project}/databases/(default)/documents`;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// src/snippets.ts
|
|
426
|
+
function packageName() {
|
|
427
|
+
return true ? "zas-agent" : "zas-agent";
|
|
428
|
+
}
|
|
429
|
+
function claudeSnippet(profile) {
|
|
430
|
+
return `claude mcp add zas -- npx -y ${packageName()} --profile ${profile}`;
|
|
431
|
+
}
|
|
432
|
+
function codexSnippet(profile) {
|
|
433
|
+
return `codex mcp add zas -- npx -y ${packageName()} --profile ${profile}`;
|
|
434
|
+
}
|
|
435
|
+
function kindForProfile(profile) {
|
|
436
|
+
if (profile === "claude-code") return "claude_code";
|
|
437
|
+
if (profile === "codex") return "codex";
|
|
438
|
+
return "other";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/pair.ts
|
|
442
|
+
var POLL_INTERVAL_MS = 2e3;
|
|
443
|
+
function pairUrl(webBase, pairingId) {
|
|
444
|
+
return `${webBase}/agents/pair?p=${pairingId}`;
|
|
445
|
+
}
|
|
446
|
+
function formatCode(code) {
|
|
447
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
448
|
+
}
|
|
449
|
+
var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
450
|
+
async function runPair(opts) {
|
|
451
|
+
const sleep2 = opts.sleep ?? defaultSleep;
|
|
452
|
+
const now = opts.now ?? (() => Date.now());
|
|
453
|
+
const existing = loadIdentity(opts.profile);
|
|
454
|
+
if (existing) {
|
|
455
|
+
opts.log(
|
|
456
|
+
`Este perfil ya est\xE1 emparejado como \xAB${existing.name}\xBB. Se va a crear un agente nuevo; revoc\xE1 el anterior desde Ajustes \u2192 Agentes.`
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
const host = (opts.host ?? hostname()).slice(0, AGENT_HOST_MAX);
|
|
460
|
+
const keys = newKeyMaterial();
|
|
461
|
+
const endpoints = { ...defaultEndpoints(), api_base: opts.apiBase };
|
|
462
|
+
const created = await apiPublic(
|
|
463
|
+
opts.apiBase,
|
|
464
|
+
"POST",
|
|
465
|
+
"/v1/agents/pairings",
|
|
466
|
+
{ kind: opts.kind, host, x25519_public: keys.x25519_public, p256_public: keys.p256_public },
|
|
467
|
+
{},
|
|
468
|
+
opts.fetch
|
|
469
|
+
);
|
|
470
|
+
const pending = {
|
|
471
|
+
version: 1,
|
|
472
|
+
profile: opts.profile,
|
|
473
|
+
pairing_id: created.pairing_id,
|
|
474
|
+
poll_secret: created.poll_secret,
|
|
475
|
+
code: created.code,
|
|
476
|
+
fingerprint: created.fingerprint,
|
|
477
|
+
expires_at: created.expires_at,
|
|
478
|
+
kind: opts.kind,
|
|
479
|
+
host,
|
|
480
|
+
...keys,
|
|
481
|
+
...endpoints,
|
|
482
|
+
created_at: now()
|
|
483
|
+
};
|
|
484
|
+
savePending(opts.profile, pending);
|
|
485
|
+
const minutesLeft = Math.max(0, Math.round((created.expires_at - now()) / 6e4));
|
|
486
|
+
opts.log(
|
|
487
|
+
[
|
|
488
|
+
"Abr\xED esta p\xE1gina con tu cuenta de Zas:",
|
|
489
|
+
` ${pairUrl(opts.webBase, created.pairing_id)}`,
|
|
490
|
+
`C\xF3digo: ${formatCode(created.code)}`,
|
|
491
|
+
`Huella: ${agentFingerprintShort(created.fingerprint)}`,
|
|
492
|
+
`Esperando la aprobaci\xF3n\u2026 (vence en ${minutesLeft} minutos)`
|
|
493
|
+
].join("\n")
|
|
494
|
+
);
|
|
495
|
+
let approved = null;
|
|
496
|
+
for (; ; ) {
|
|
497
|
+
let answer;
|
|
498
|
+
try {
|
|
499
|
+
answer = await apiPublic(
|
|
500
|
+
opts.apiBase,
|
|
501
|
+
"POST",
|
|
502
|
+
`/v1/agents/pairings/${created.pairing_id}/poll`,
|
|
503
|
+
void 0,
|
|
504
|
+
{ "X-Zas-Poll-Secret": created.poll_secret },
|
|
505
|
+
opts.fetch
|
|
506
|
+
);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
if (!(err instanceof ZasError) || err.code !== "rate_limited") throw err;
|
|
509
|
+
if (now() >= created.expires_at) throw new ZasError("pairing_expired", 410);
|
|
510
|
+
await sleep2(err.retryAfterMs ?? 1e4);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (answer.status === "approved") {
|
|
514
|
+
approved = { agent_uid: answer.agent_uid, owner_uid: answer.owner_uid };
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
if (answer.status === "expired" || answer.status === "cancelled") {
|
|
518
|
+
clearPending(opts.profile);
|
|
519
|
+
throw new ZasError(`pairing_${answer.status}`, 410);
|
|
520
|
+
}
|
|
521
|
+
await sleep2(POLL_INTERVAL_MS);
|
|
522
|
+
}
|
|
523
|
+
const baseIdentity = {
|
|
524
|
+
version: 1,
|
|
525
|
+
agent_uid: approved.agent_uid,
|
|
526
|
+
owner_uid: approved.owner_uid,
|
|
527
|
+
name: "",
|
|
528
|
+
kind: opts.kind,
|
|
529
|
+
host,
|
|
530
|
+
...keys,
|
|
531
|
+
...endpoints
|
|
532
|
+
};
|
|
533
|
+
const client = new ZasClient(baseIdentity, { fetch: opts.fetch, now });
|
|
534
|
+
await client.signIn();
|
|
535
|
+
const me = await client.api("GET", "/agents/me");
|
|
536
|
+
const identity = { ...baseIdentity, name: me.name };
|
|
537
|
+
saveIdentity(opts.profile, identity);
|
|
538
|
+
clearPending(opts.profile);
|
|
539
|
+
const claudeLines = (lead) => [lead, ` ${claudeSnippet(opts.profile)}`];
|
|
540
|
+
const codexLines = (lead) => [lead, ` ${codexSnippet(opts.profile)}`];
|
|
541
|
+
const install = opts.kind === "claude_code" ? claudeLines("Agregalo a Claude Code:") : opts.kind === "codex" ? codexLines("Agregalo a Codex:") : [...claudeLines("Agregalo a Claude Code:"), ...codexLines("O a Codex:")];
|
|
542
|
+
opts.log(
|
|
543
|
+
[
|
|
544
|
+
`Listo: el agente \xAB${identity.name}\xBB qued\xF3 emparejado con tu cuenta.`,
|
|
545
|
+
...install
|
|
546
|
+
].join("\n")
|
|
547
|
+
);
|
|
548
|
+
return identity;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/server.ts
|
|
552
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
553
|
+
import { z } from "zod";
|
|
554
|
+
|
|
555
|
+
// src/shared/manifest.ts
|
|
556
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
|
557
|
+
|
|
558
|
+
// src/shared/constants.ts
|
|
559
|
+
var CHUNKER_VERSION = "chunker_v1";
|
|
560
|
+
var CHUNK_MIN = 1 * 1024 * 1024;
|
|
561
|
+
var CHUNK_AVG_BITS = 22;
|
|
562
|
+
var CHUNK_MAX = 8 * 1024 * 1024;
|
|
563
|
+
var STORAGE_V2_PART_BYTES = 16 * 1024 * 1024;
|
|
564
|
+
var GEAR_SEED = "zas-gear-v1:";
|
|
565
|
+
var HKDF_INFO_CHUNK_KEY = "zas-chunk-key-v1";
|
|
566
|
+
var HKDF_INFO_CHUNK_NONCE = "zas-chunk-nonce-v1";
|
|
567
|
+
var OPRF_CONTEXT = "OPRFV1-\0-ristretto255-SHA512";
|
|
568
|
+
var MANIFEST_VERSION = 1;
|
|
569
|
+
var ANON_EMPTY_TTL_MS = 30 * 60 * 1e3;
|
|
570
|
+
var FREE_RELAY_MAX_BYTES = 50 * 1024 * 1024;
|
|
571
|
+
var SAFE_STORED_FILE_LIMITS = {
|
|
572
|
+
anon: 30 * 1024 * 1024,
|
|
573
|
+
free: FREE_RELAY_MAX_BYTES,
|
|
574
|
+
pro: 200 * 1024 * 1024,
|
|
575
|
+
max: 200 * 1024 * 1024
|
|
576
|
+
};
|
|
577
|
+
var STORED_FILE_LIMITS_V2 = {
|
|
578
|
+
anon: 30 * 1024 * 1024,
|
|
579
|
+
free: FREE_RELAY_MAX_BYTES,
|
|
580
|
+
pro: 5 * 1024 * 1024 * 1024,
|
|
581
|
+
max: 20 * 1024 * 1024 * 1024
|
|
582
|
+
};
|
|
583
|
+
var STORAGE_LIMITS = {
|
|
584
|
+
anon: 30 * 1024 * 1024,
|
|
585
|
+
free: 100 * 1024 * 1024,
|
|
586
|
+
pro: null,
|
|
587
|
+
max: null
|
|
588
|
+
};
|
|
589
|
+
var THUMBNAIL_MAX_EDGE = 640;
|
|
590
|
+
var THUMBNAIL_MAX_BYTES = 14e4;
|
|
591
|
+
|
|
592
|
+
// src/shared/manifest.ts
|
|
593
|
+
function newManifest(partial) {
|
|
594
|
+
return { v: MANIFEST_VERSION, chunker: CHUNKER_VERSION, ...partial };
|
|
595
|
+
}
|
|
596
|
+
var ENVELOPE_MAGIC = 90;
|
|
597
|
+
var KEY_VERSION_LEGACY = 1;
|
|
598
|
+
function envelopeVersion(sealed) {
|
|
599
|
+
if (sealed.length > 26 && sealed[0] === ENVELOPE_MAGIC && sealed[1] >= 1) return sealed[1];
|
|
600
|
+
return KEY_VERSION_LEGACY;
|
|
601
|
+
}
|
|
602
|
+
function sealBytes(key, version, plain) {
|
|
603
|
+
if (version < 1 || version > 255) throw new Error("key version out of range");
|
|
604
|
+
const nonce = new Uint8Array(24);
|
|
605
|
+
crypto.getRandomValues(nonce);
|
|
606
|
+
const ct = xchacha20poly1305(key, nonce).encrypt(plain);
|
|
607
|
+
return concatBytes(new Uint8Array([ENVELOPE_MAGIC, version]), nonce, ct);
|
|
608
|
+
}
|
|
609
|
+
function openSealed(key, sealed) {
|
|
610
|
+
const versioned = envelopeVersion(sealed) !== KEY_VERSION_LEGACY || sealed[0] === ENVELOPE_MAGIC;
|
|
611
|
+
if (versioned && sealed.length > 26) {
|
|
612
|
+
try {
|
|
613
|
+
return xchacha20poly1305(key, sealed.slice(2, 26)).decrypt(sealed.slice(26));
|
|
614
|
+
} catch {
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return xchacha20poly1305(key, sealed.slice(0, 24)).decrypt(sealed.slice(24));
|
|
618
|
+
}
|
|
619
|
+
function sealManifest(channelKey, manifest, version = KEY_VERSION_LEGACY) {
|
|
620
|
+
return sealBytes(channelKey, version, new TextEncoder().encode(JSON.stringify(manifest)));
|
|
621
|
+
}
|
|
622
|
+
function openManifest(channelKey, sealed) {
|
|
623
|
+
const m = JSON.parse(new TextDecoder().decode(openSealed(channelKey, sealed)));
|
|
624
|
+
if (!Array.isArray(m.chunks)) m.chunks = [];
|
|
625
|
+
return m;
|
|
626
|
+
}
|
|
627
|
+
function decryptChannelName(channelKey, sealed) {
|
|
628
|
+
return new TextDecoder().decode(openSealed(channelKey, sealed));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/shared/sharedchannel.ts
|
|
632
|
+
import { xchacha20poly1305 as xchacha20poly13052 } from "@noble/ciphers/chacha";
|
|
633
|
+
import { x25519 as x255192 } from "@noble/curves/ed25519";
|
|
634
|
+
var ASSIGNMENT_MAGIC = new Uint8Array([90, 67, 1]);
|
|
635
|
+
var ASSIGNMENT_HEADER_BYTES = 3 + 1 + 32 + 24;
|
|
636
|
+
function assignmentEnvelope(envelope) {
|
|
637
|
+
return envelope.length >= ASSIGNMENT_HEADER_BYTES + 16 && envelope[0] === ASSIGNMENT_MAGIC[0] && envelope[1] === ASSIGNMENT_MAGIC[1] && envelope[2] === ASSIGNMENT_MAGIC[2];
|
|
638
|
+
}
|
|
639
|
+
function openChannelAssignment(privateKey, envelope) {
|
|
640
|
+
if (!assignmentEnvelope(envelope)) throw new Error("bad envelope");
|
|
641
|
+
const ephemeralPublic = envelope.slice(4, 36);
|
|
642
|
+
const nonce = envelope.slice(36, 60);
|
|
643
|
+
const shared = x255192.getSharedSecret(privateKey, ephemeralPublic);
|
|
644
|
+
const key = hkdf512(shared, "ZAS-CHANNEL-ASSIGNMENT-ENVELOPE-V1", 32);
|
|
645
|
+
const plain = xchacha20poly13052(key, nonce).decrypt(envelope.slice(60));
|
|
646
|
+
shared.fill(0);
|
|
647
|
+
key.fill(0);
|
|
648
|
+
if (plain.length !== 32) throw new Error("bad channel key");
|
|
649
|
+
return plain;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/grants.ts
|
|
653
|
+
var GRANTS_MAX_AGE_MS = 6e4;
|
|
654
|
+
async function refreshGrants(client, profile) {
|
|
655
|
+
const me = await client.api("GET", "/agents/me");
|
|
656
|
+
const grants = Array.isArray(me.grants) ? me.grants : [];
|
|
657
|
+
saveGrants(profile, { fetched_at: Date.now(), agent_uid: client.identity.agent_uid, grants });
|
|
658
|
+
return grants;
|
|
659
|
+
}
|
|
660
|
+
async function grantsFor(client, profile, maxAgeMs = GRANTS_MAX_AGE_MS) {
|
|
661
|
+
const cached = loadGrants(profile);
|
|
662
|
+
const fresh = cached && cached.agent_uid === client.identity.agent_uid && Array.isArray(cached.grants) && Date.now() - cached.fetched_at < maxAgeMs;
|
|
663
|
+
return fresh ? cached.grants : refreshGrants(client, profile);
|
|
664
|
+
}
|
|
665
|
+
function channelKeyOf(identity, grant) {
|
|
666
|
+
try {
|
|
667
|
+
return openChannelAssignment(b64ToBytes(identity.x25519_private), b64ToBytes(grant.wrapped_key));
|
|
668
|
+
} catch {
|
|
669
|
+
throw new ZasError("key_stale", 0);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function channelNameOf(identity, grant) {
|
|
673
|
+
try {
|
|
674
|
+
return decryptChannelName(channelKeyOf(identity, grant), b64ToBytes(grant.name_enc));
|
|
675
|
+
} catch (err) {
|
|
676
|
+
if (err instanceof ZasError) throw err;
|
|
677
|
+
throw new ZasError("key_stale", 0);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
var fold = (value) => value.trim().toLowerCase();
|
|
681
|
+
function resolveChannel(identity, grants, channel) {
|
|
682
|
+
if (channel === void 0 || fold(channel) === "") {
|
|
683
|
+
if (grants.length === 1) return grants[0];
|
|
684
|
+
throw new ZasError("grant_missing", 0);
|
|
685
|
+
}
|
|
686
|
+
const wanted = channel.trim();
|
|
687
|
+
const byId = grants.find((g) => g.channel_id === wanted);
|
|
688
|
+
if (byId) return byId;
|
|
689
|
+
const folded = fold(wanted);
|
|
690
|
+
const byName = grants.filter((g) => {
|
|
691
|
+
try {
|
|
692
|
+
return fold(channelNameOf(identity, g)) === folded;
|
|
693
|
+
} catch {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
if (byName.length === 1) return byName[0];
|
|
698
|
+
throw new ZasError("grant_missing", 0);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/jobs.ts
|
|
702
|
+
import { randomUUID } from "node:crypto";
|
|
703
|
+
var DEFAULT_WAIT_MS = 6e4;
|
|
704
|
+
var HISTORY = 50;
|
|
705
|
+
var JobRunner = class {
|
|
706
|
+
now;
|
|
707
|
+
waitMs;
|
|
708
|
+
/** Newest first, and trimmed. */
|
|
709
|
+
jobs = [];
|
|
710
|
+
/** Keyed on the job object, not its id, so a caller holding a job that has
|
|
711
|
+
* already aged out of the list can still wait for it to finish. */
|
|
712
|
+
settled = /* @__PURE__ */ new WeakMap();
|
|
713
|
+
constructor(opts = {}) {
|
|
714
|
+
this.now = opts.now ?? (() => Date.now());
|
|
715
|
+
this.waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
|
|
716
|
+
}
|
|
717
|
+
start(kind, title, channel, work) {
|
|
718
|
+
const job = {
|
|
719
|
+
id: randomUUID(),
|
|
720
|
+
kind,
|
|
721
|
+
title,
|
|
722
|
+
channel,
|
|
723
|
+
started_at: this.now(),
|
|
724
|
+
phase: null,
|
|
725
|
+
status: "running"
|
|
726
|
+
};
|
|
727
|
+
this.jobs.unshift(job);
|
|
728
|
+
this.jobs.length = Math.min(this.jobs.length, HISTORY);
|
|
729
|
+
const report = (phase) => {
|
|
730
|
+
if (job.status === "running") job.phase = phase;
|
|
731
|
+
};
|
|
732
|
+
this.settled.set(job, work(report).then(
|
|
733
|
+
(result) => {
|
|
734
|
+
job.status = "done";
|
|
735
|
+
job.result = result;
|
|
736
|
+
return job;
|
|
737
|
+
},
|
|
738
|
+
(error) => {
|
|
739
|
+
job.status = "failed";
|
|
740
|
+
job.error = error instanceof ZasError ? {
|
|
741
|
+
code: error.code,
|
|
742
|
+
status: error.status,
|
|
743
|
+
sentence: humanSentence(error),
|
|
744
|
+
message: error.message,
|
|
745
|
+
...error.retryAfterMs !== void 0 ? { retryAfterMs: error.retryAfterMs } : {},
|
|
746
|
+
...error.serverCode !== void 0 ? { serverCode: error.serverCode } : {}
|
|
747
|
+
} : { code: "upload_failed", status: 0, sentence: humanSentence(new ZasError("upload_failed", 0)) };
|
|
748
|
+
return job;
|
|
749
|
+
}
|
|
750
|
+
));
|
|
751
|
+
return job;
|
|
752
|
+
}
|
|
753
|
+
/** Resolves when the work settles, or when the wait runs out — the same job
|
|
754
|
+
* object either way, so the caller reads `status` rather than guessing. */
|
|
755
|
+
async wait(job) {
|
|
756
|
+
const settled = this.settled.get(job);
|
|
757
|
+
if (!settled) return job;
|
|
758
|
+
let timer;
|
|
759
|
+
const deadline = new Promise((resolve2) => {
|
|
760
|
+
timer = setTimeout(() => resolve2(job), this.waitMs);
|
|
761
|
+
});
|
|
762
|
+
try {
|
|
763
|
+
return await Promise.race([settled, deadline]);
|
|
764
|
+
} finally {
|
|
765
|
+
clearTimeout(timer);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
list() {
|
|
769
|
+
return this.jobs.slice(0, HISTORY);
|
|
770
|
+
}
|
|
771
|
+
get(id) {
|
|
772
|
+
return this.jobs.find((job) => job.id === id);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
// src/read.ts
|
|
777
|
+
import { randomBytes } from "node:crypto";
|
|
778
|
+
import {
|
|
779
|
+
closeSync,
|
|
780
|
+
existsSync as existsSync2,
|
|
781
|
+
mkdirSync as mkdirSync2,
|
|
782
|
+
mkdtempSync,
|
|
783
|
+
openSync,
|
|
784
|
+
renameSync as renameSync2,
|
|
785
|
+
rmdirSync,
|
|
786
|
+
statSync,
|
|
787
|
+
unlinkSync,
|
|
788
|
+
writeSync
|
|
789
|
+
} from "node:fs";
|
|
790
|
+
import { tmpdir } from "node:os";
|
|
791
|
+
import { dirname, extname, join as join2 } from "node:path";
|
|
792
|
+
|
|
793
|
+
// src/shared/mle.ts
|
|
794
|
+
import { xchacha20poly1305 as xchacha20poly13053 } from "@noble/ciphers/chacha";
|
|
795
|
+
function chunkKeyFromF(f) {
|
|
796
|
+
return {
|
|
797
|
+
key: hkdf512(f, HKDF_INFO_CHUNK_KEY, 32),
|
|
798
|
+
nonce: hkdf512(f, HKDF_INFO_CHUNK_NONCE, 24)
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
async function encryptChunk(f, plaintext) {
|
|
802
|
+
const { key, nonce } = chunkKeyFromF(f);
|
|
803
|
+
const ciphertext = xchacha20poly13053(key, nonce).encrypt(plaintext);
|
|
804
|
+
const blobId = await blake3Hex(ciphertext);
|
|
805
|
+
return { ciphertext, blobId, key, nonce };
|
|
806
|
+
}
|
|
807
|
+
function decryptChunk(key, nonce, ciphertext) {
|
|
808
|
+
return xchacha20poly13053(key, nonce).decrypt(ciphertext);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/read.ts
|
|
812
|
+
var DEFAULT_LIMIT = 20;
|
|
813
|
+
var MAX_LIMIT = 50;
|
|
814
|
+
var DOWNLOAD_PREFIX = "zas-agent-";
|
|
815
|
+
var ID_SEGMENT = /^(?!__)[A-Za-z0-9_-]{1,128}$/;
|
|
816
|
+
var MAX_CHUNKS = 8192;
|
|
817
|
+
var MAX_DUPLICATES = 100;
|
|
818
|
+
function stringOf(value) {
|
|
819
|
+
return typeof value?.stringValue === "string" ? value.stringValue : void 0;
|
|
820
|
+
}
|
|
821
|
+
function timeOf(value) {
|
|
822
|
+
const parsed = typeof value?.timestampValue === "string" ? Date.parse(value.timestampValue) : NaN;
|
|
823
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
824
|
+
}
|
|
825
|
+
function rowOf(doc) {
|
|
826
|
+
const document = doc ?? {};
|
|
827
|
+
const name = typeof document.name === "string" ? document.name : "";
|
|
828
|
+
const fields = document.fields && typeof document.fields === "object" ? document.fields : {};
|
|
829
|
+
return {
|
|
830
|
+
id: name.slice(name.lastIndexOf("/") + 1),
|
|
831
|
+
manifestEnc: stringOf(fields.manifest_enc),
|
|
832
|
+
agent: stringOf(fields.agent),
|
|
833
|
+
createdAt: timeOf(fields.created_at),
|
|
834
|
+
expiresAt: timeOf(fields.expires_at),
|
|
835
|
+
bar: fields.bar?.booleanValue === true
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
function readable(row) {
|
|
839
|
+
if (!row.id || row.bar || !row.manifestEnc) return false;
|
|
840
|
+
return row.expiresAt === null || row.expiresAt > Date.now();
|
|
841
|
+
}
|
|
842
|
+
function openFor(channelKey, row) {
|
|
843
|
+
try {
|
|
844
|
+
return openManifest(channelKey, b64ToBytes(row.manifestEnc));
|
|
845
|
+
} catch {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function summaryOf(row, manifest) {
|
|
850
|
+
const kind = manifest.kind === "text" ? "text" : "file";
|
|
851
|
+
const name = typeof manifest.name === "string" ? manifest.name : "";
|
|
852
|
+
return {
|
|
853
|
+
id: row.id,
|
|
854
|
+
kind,
|
|
855
|
+
// Absence means "show the file name": the sender chose no title.
|
|
856
|
+
title: manifest.title ?? name,
|
|
857
|
+
name,
|
|
858
|
+
mime: typeof manifest.mime === "string" ? manifest.mime : "application/octet-stream",
|
|
859
|
+
size: typeof manifest.size === "number" ? manifest.size : 0,
|
|
860
|
+
// The sealed time is the sender's own; the row's is the server's, and it
|
|
861
|
+
// only answers for a manifest that carries none.
|
|
862
|
+
created_at: typeof manifest.created_at === "string" && manifest.created_at ? manifest.created_at : row.createdAt !== null ? new Date(row.createdAt).toISOString() : "",
|
|
863
|
+
by_agent: row.agent !== void 0,
|
|
864
|
+
...kind === "text" ? { text: manifest.text ?? "" } : {}
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
async function readGrant(ctx, channel) {
|
|
868
|
+
const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
|
|
869
|
+
if (!grant.read) throw new ZasError("read_forbidden", 403);
|
|
870
|
+
return grant;
|
|
871
|
+
}
|
|
872
|
+
function nameFrom(channelKey, grant) {
|
|
873
|
+
try {
|
|
874
|
+
return decryptChannelName(channelKey, b64ToBytes(grant.name_enc));
|
|
875
|
+
} catch {
|
|
876
|
+
throw new ZasError("key_stale", 0);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
function linksParent(identity, grant) {
|
|
880
|
+
return `accounts/${identity.owner_uid}/channels/${grant.channel_id}`;
|
|
881
|
+
}
|
|
882
|
+
function linkPath(identity, grant, id) {
|
|
883
|
+
return `projects/${identity.firestore_project}/databases/(default)/documents/${linksParent(identity, grant)}/links/${id}`;
|
|
884
|
+
}
|
|
885
|
+
async function queryLinks(ctx, grant, query) {
|
|
886
|
+
try {
|
|
887
|
+
return await ctx.client.firestoreRunQuery(linksParent(ctx.identity, grant), query);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
if (err instanceof ZasError && err.status === 403) throw new ZasError("read_forbidden", 403);
|
|
890
|
+
throw err;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
function clampLimit(limit) {
|
|
894
|
+
if (limit === void 0 || !Number.isFinite(limit)) return DEFAULT_LIMIT;
|
|
895
|
+
return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit)));
|
|
896
|
+
}
|
|
897
|
+
async function listItems(ctx, channel, limit) {
|
|
898
|
+
const grant = await readGrant(ctx, channel);
|
|
899
|
+
const channelKey = channelKeyOf(ctx.identity, grant);
|
|
900
|
+
const docs = await queryLinks(ctx, grant, {
|
|
901
|
+
from: [{ collectionId: "links" }],
|
|
902
|
+
orderBy: [{ field: { fieldPath: "created_at" }, direction: "DESCENDING" }],
|
|
903
|
+
limit: clampLimit(limit)
|
|
904
|
+
});
|
|
905
|
+
const items = [];
|
|
906
|
+
for (const doc of docs) {
|
|
907
|
+
const row = rowOf(doc);
|
|
908
|
+
if (!readable(row)) continue;
|
|
909
|
+
const manifest = openFor(channelKey, row);
|
|
910
|
+
if (!manifest) continue;
|
|
911
|
+
items.push(summaryOf(row, manifest));
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
channel_id: grant.channel_id,
|
|
915
|
+
channel_name: nameFrom(channelKey, grant),
|
|
916
|
+
items
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
function redeemFailure(status) {
|
|
920
|
+
if (status === 429) return new ZasError("rate_limited", 429);
|
|
921
|
+
if (status >= 500) return new ZasError("network", status);
|
|
922
|
+
return new ZasError("invalid_cap", 403);
|
|
923
|
+
}
|
|
924
|
+
async function fetchChunk(ctx, chunk) {
|
|
925
|
+
if (typeof chunk.cap !== "string" || chunk.cap === "") throw new ZasError("invalid_cap", 403);
|
|
926
|
+
let headers = {};
|
|
927
|
+
try {
|
|
928
|
+
headers = { Authorization: `Bearer ${await ctx.client.idToken()}` };
|
|
929
|
+
} catch {
|
|
930
|
+
headers = {};
|
|
931
|
+
}
|
|
932
|
+
let url;
|
|
933
|
+
try {
|
|
934
|
+
const redeemed = await apiPublic(
|
|
935
|
+
ctx.identity.api_base,
|
|
936
|
+
"POST",
|
|
937
|
+
"/v1/blobs/redeem",
|
|
938
|
+
{ cap: chunk.cap },
|
|
939
|
+
headers
|
|
940
|
+
);
|
|
941
|
+
url = typeof redeemed.url === "string" ? redeemed.url : "";
|
|
942
|
+
} catch (err) {
|
|
943
|
+
if (err instanceof ZasError) throw redeemFailure(err.status);
|
|
944
|
+
throw err;
|
|
945
|
+
}
|
|
946
|
+
if (url === "") throw new ZasError("invalid_cap", 403);
|
|
947
|
+
const res = await fetch(url);
|
|
948
|
+
if (!res.ok) {
|
|
949
|
+
await res.body?.cancel().catch(() => void 0);
|
|
950
|
+
throw res.status >= 500 ? new ZasError("network", res.status) : new ZasError("invalid_cap", 403);
|
|
951
|
+
}
|
|
952
|
+
const ciphertext = new Uint8Array(await res.arrayBuffer());
|
|
953
|
+
try {
|
|
954
|
+
return decryptChunk(b64ToBytes(chunk.key), b64ToBytes(chunk.nonce), ciphertext);
|
|
955
|
+
} catch {
|
|
956
|
+
throw new ZasError("invalid_cap", 403);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function safeName(name, fallback) {
|
|
960
|
+
const base = (typeof name === "string" ? name : "").split(/[\\/]/).pop() ?? "";
|
|
961
|
+
return base.replace(/^\.+/, "").trim() || fallback;
|
|
962
|
+
}
|
|
963
|
+
function freeName(target) {
|
|
964
|
+
if (!existsSync2(target)) return target;
|
|
965
|
+
const ext = extname(target);
|
|
966
|
+
const stem = target.slice(0, target.length - ext.length);
|
|
967
|
+
for (let n = 1; n <= MAX_DUPLICATES; n++) {
|
|
968
|
+
const candidate = `${stem} (${n})${ext}`;
|
|
969
|
+
if (!existsSync2(candidate)) return candidate;
|
|
970
|
+
}
|
|
971
|
+
throw new ZasError("write_failed", 0, target);
|
|
972
|
+
}
|
|
973
|
+
function destinationOf(dest, name, fallback) {
|
|
974
|
+
const base = safeName(name, fallback);
|
|
975
|
+
if (dest === void 0) {
|
|
976
|
+
const created = mkdtempSync(join2(tmpdir(), DOWNLOAD_PREFIX));
|
|
977
|
+
return { target: freeName(join2(created, base)), created };
|
|
978
|
+
}
|
|
979
|
+
const at = statSync(dest, { throwIfNoEntry: false })?.isDirectory() ? join2(dest, base) : dest;
|
|
980
|
+
return { target: freeName(at) };
|
|
981
|
+
}
|
|
982
|
+
async function getItem(ctx, channel, id, dest) {
|
|
983
|
+
const grant = await readGrant(ctx, channel);
|
|
984
|
+
if (!ID_SEGMENT.test(id)) throw new ZasError("not_found", 404);
|
|
985
|
+
const channelKey = channelKeyOf(ctx.identity, grant);
|
|
986
|
+
const docs = await queryLinks(ctx, grant, {
|
|
987
|
+
from: [{ collectionId: "links" }],
|
|
988
|
+
where: {
|
|
989
|
+
fieldFilter: {
|
|
990
|
+
field: { fieldPath: "__name__" },
|
|
991
|
+
op: "EQUAL",
|
|
992
|
+
value: { referenceValue: linkPath(ctx.identity, grant, id) }
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
limit: 1
|
|
996
|
+
});
|
|
997
|
+
const row = docs.length > 0 ? rowOf(docs[0]) : null;
|
|
998
|
+
if (!row || !readable(row)) throw new ZasError("not_found", 404);
|
|
999
|
+
const manifest = openFor(channelKey, row);
|
|
1000
|
+
if (!manifest) throw new ZasError("not_found", 404);
|
|
1001
|
+
if (manifest.kind === "text") {
|
|
1002
|
+
const text2 = manifest.text ?? "";
|
|
1003
|
+
return { text: text2, bytes: new TextEncoder().encode(text2).length };
|
|
1004
|
+
}
|
|
1005
|
+
if (manifest.chunks.length === 0) throw new ZasError("not_found", 404);
|
|
1006
|
+
if (manifest.chunks.length > MAX_CHUNKS) throw new ZasError("not_found", 404);
|
|
1007
|
+
let target;
|
|
1008
|
+
let created;
|
|
1009
|
+
let tmp;
|
|
1010
|
+
let fd;
|
|
1011
|
+
let written = 0;
|
|
1012
|
+
try {
|
|
1013
|
+
const chosen = destinationOf(dest, manifest.name, row.id);
|
|
1014
|
+
target = chosen.target;
|
|
1015
|
+
created = chosen.created;
|
|
1016
|
+
mkdirSync2(dirname(target), { recursive: true, mode: 448 });
|
|
1017
|
+
tmp = `${target}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1018
|
+
fd = openSync(tmp, "wx", 384);
|
|
1019
|
+
for (const chunk of manifest.chunks) {
|
|
1020
|
+
written += writeSync(fd, await fetchChunk(ctx, chunk));
|
|
1021
|
+
}
|
|
1022
|
+
const size = typeof manifest.size === "number" ? manifest.size : 0;
|
|
1023
|
+
if (size > 0 && written !== size) throw new ZasError("not_found", 404);
|
|
1024
|
+
closeSync(fd);
|
|
1025
|
+
fd = void 0;
|
|
1026
|
+
renameSync2(tmp, target);
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
if (err instanceof ZasError) throw err;
|
|
1029
|
+
const failure = err;
|
|
1030
|
+
if (typeof failure.syscall === "string" || typeof failure.errno === "number") {
|
|
1031
|
+
throw new ZasError("write_failed", 0, failure.message);
|
|
1032
|
+
}
|
|
1033
|
+
throw new ZasError("network", 0, String(err?.message ?? err));
|
|
1034
|
+
} finally {
|
|
1035
|
+
if (fd !== void 0) try {
|
|
1036
|
+
closeSync(fd);
|
|
1037
|
+
} catch {
|
|
1038
|
+
}
|
|
1039
|
+
if (tmp !== void 0 && existsSync2(tmp)) try {
|
|
1040
|
+
unlinkSync(tmp);
|
|
1041
|
+
} catch {
|
|
1042
|
+
}
|
|
1043
|
+
if (created !== void 0) try {
|
|
1044
|
+
rmdirSync(created);
|
|
1045
|
+
} catch {
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return { path: target, bytes: written };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// src/send.ts
|
|
1052
|
+
import { promises as fsp } from "node:fs";
|
|
1053
|
+
import { basename, extname as extname2 } from "node:path";
|
|
1054
|
+
|
|
1055
|
+
// src/shared/chunker.ts
|
|
1056
|
+
import { blake3 as blake32 } from "hash-wasm";
|
|
1057
|
+
var gearPromise = null;
|
|
1058
|
+
function gearTable() {
|
|
1059
|
+
if (!gearPromise) {
|
|
1060
|
+
gearPromise = (async () => {
|
|
1061
|
+
const table = new Uint32Array(256);
|
|
1062
|
+
for (let i = 0; i < 256; i++) {
|
|
1063
|
+
const hex = await blake32(new TextEncoder().encode(GEAR_SEED + i), 256);
|
|
1064
|
+
table[i] = parseInt(hex.slice(0, 8), 16) >>> 0;
|
|
1065
|
+
}
|
|
1066
|
+
return table;
|
|
1067
|
+
})();
|
|
1068
|
+
}
|
|
1069
|
+
return gearPromise;
|
|
1070
|
+
}
|
|
1071
|
+
var AVG = 1 << CHUNK_AVG_BITS;
|
|
1072
|
+
var MASK_S = (1 << CHUNK_AVG_BITS + 2) - 1;
|
|
1073
|
+
var MASK_L = (1 << CHUNK_AVG_BITS - 2) - 1;
|
|
1074
|
+
function cutPoint(buf, gear, eof) {
|
|
1075
|
+
const len = Math.min(buf.length, CHUNK_MAX);
|
|
1076
|
+
if (buf.length < CHUNK_MAX && !eof) {
|
|
1077
|
+
if (buf.length <= CHUNK_MIN) return null;
|
|
1078
|
+
}
|
|
1079
|
+
if (eof && len <= CHUNK_MIN) return len > 0 ? len : null;
|
|
1080
|
+
let hash = 0;
|
|
1081
|
+
const normal = Math.min(AVG, len);
|
|
1082
|
+
let i = CHUNK_MIN;
|
|
1083
|
+
for (; i < normal; i++) {
|
|
1084
|
+
hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
|
|
1085
|
+
if ((hash & MASK_S) === 0) return i + 1;
|
|
1086
|
+
}
|
|
1087
|
+
for (; i < len; i++) {
|
|
1088
|
+
hash = (hash << 1 >>> 0) + gear[buf[i]] >>> 0;
|
|
1089
|
+
if ((hash & MASK_L) === 0) return i + 1;
|
|
1090
|
+
}
|
|
1091
|
+
if (len === CHUNK_MAX) return CHUNK_MAX;
|
|
1092
|
+
if (eof) return len > 0 ? len : null;
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
async function* chunkStream(source) {
|
|
1096
|
+
const gear = await gearTable();
|
|
1097
|
+
let pending = [];
|
|
1098
|
+
let pendingLen = 0;
|
|
1099
|
+
const compact = () => {
|
|
1100
|
+
if (pending.length === 1) return pending[0];
|
|
1101
|
+
const merged = new Uint8Array(pendingLen);
|
|
1102
|
+
let off = 0;
|
|
1103
|
+
for (const p of pending) {
|
|
1104
|
+
merged.set(p, off);
|
|
1105
|
+
off += p.length;
|
|
1106
|
+
}
|
|
1107
|
+
pending = [merged];
|
|
1108
|
+
return merged;
|
|
1109
|
+
};
|
|
1110
|
+
const drain = function* (eof) {
|
|
1111
|
+
while (pendingLen > 0) {
|
|
1112
|
+
const buf = compact();
|
|
1113
|
+
const cut = cutPoint(buf, gear, eof);
|
|
1114
|
+
if (cut === null) return;
|
|
1115
|
+
yield buf.slice(0, cut);
|
|
1116
|
+
pending = cut < buf.length ? [buf.slice(cut)] : [];
|
|
1117
|
+
pendingLen = buf.length - cut;
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
for await (const piece of source) {
|
|
1121
|
+
if (piece.length === 0) continue;
|
|
1122
|
+
pending.push(piece);
|
|
1123
|
+
pendingLen += piece.length;
|
|
1124
|
+
if (pendingLen >= CHUNK_MAX) yield* drain(false);
|
|
1125
|
+
}
|
|
1126
|
+
yield* drain(true);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// src/shared/oprf.ts
|
|
1130
|
+
import { RistrettoPoint } from "@noble/curves/ed25519";
|
|
1131
|
+
import { sha512 as sha5122 } from "@noble/hashes/sha2";
|
|
1132
|
+
import { invert, mod } from "@noble/curves/abstract/modular";
|
|
1133
|
+
var ORDER = 2n ** 252n + 27742317777372353535851937790883648493n;
|
|
1134
|
+
var te = new TextEncoder();
|
|
1135
|
+
function i2osp(value, length) {
|
|
1136
|
+
const out = new Uint8Array(length);
|
|
1137
|
+
for (let i = length - 1; i >= 0; i--) {
|
|
1138
|
+
out[i] = value & 255;
|
|
1139
|
+
value >>>= 8;
|
|
1140
|
+
}
|
|
1141
|
+
return out;
|
|
1142
|
+
}
|
|
1143
|
+
function expandMessageXmd(msg, dst, lenInBytes) {
|
|
1144
|
+
const bInBytes = 64;
|
|
1145
|
+
const rInBytes = 128;
|
|
1146
|
+
const ell = Math.ceil(lenInBytes / bInBytes);
|
|
1147
|
+
if (ell > 255) throw new Error("expand_message_xmd: ell too large");
|
|
1148
|
+
const dstPrime = concatBytes(dst, i2osp(dst.length, 1));
|
|
1149
|
+
const zPad = new Uint8Array(rInBytes);
|
|
1150
|
+
const lIbStr = i2osp(lenInBytes, 2);
|
|
1151
|
+
const msgPrime = concatBytes(zPad, msg, lIbStr, i2osp(0, 1), dstPrime);
|
|
1152
|
+
const b0 = sha5122(msgPrime);
|
|
1153
|
+
const b = [];
|
|
1154
|
+
b[0] = sha5122(concatBytes(b0, i2osp(1, 1), dstPrime));
|
|
1155
|
+
for (let i = 2; i <= ell; i++) {
|
|
1156
|
+
const xored = new Uint8Array(bInBytes);
|
|
1157
|
+
for (let j = 0; j < bInBytes; j++) xored[j] = b0[j] ^ b[i - 2][j];
|
|
1158
|
+
b[i - 1] = sha5122(concatBytes(xored, i2osp(i, 1), dstPrime));
|
|
1159
|
+
}
|
|
1160
|
+
return concatBytes(...b).slice(0, lenInBytes);
|
|
1161
|
+
}
|
|
1162
|
+
function bytesToBigIntBE(bytes) {
|
|
1163
|
+
let v = 0n;
|
|
1164
|
+
for (const byte of bytes) v = v << 8n | BigInt(byte);
|
|
1165
|
+
return v;
|
|
1166
|
+
}
|
|
1167
|
+
function hashToGroup(input) {
|
|
1168
|
+
const dst = te.encode("HashToGroup-" + OPRF_CONTEXT);
|
|
1169
|
+
const uniform = expandMessageXmd(input, dst, 64);
|
|
1170
|
+
return RistrettoPoint.hashToCurve(uniform);
|
|
1171
|
+
}
|
|
1172
|
+
function randomScalar() {
|
|
1173
|
+
const bytes = new Uint8Array(64);
|
|
1174
|
+
crypto.getRandomValues(bytes);
|
|
1175
|
+
const s = mod(bytesToBigIntBE(bytes), ORDER);
|
|
1176
|
+
return s === 0n ? 1n : s;
|
|
1177
|
+
}
|
|
1178
|
+
function oprfBlind(input) {
|
|
1179
|
+
const blind = randomScalar();
|
|
1180
|
+
const P = hashToGroup(input);
|
|
1181
|
+
return { blind, blindedElement: P.multiply(blind).toRawBytes() };
|
|
1182
|
+
}
|
|
1183
|
+
function oprfFinalize(input, blind, evaluatedElement) {
|
|
1184
|
+
const E = RistrettoPoint.fromHex(evaluatedElement);
|
|
1185
|
+
const N = E.multiply(invert(blind, ORDER));
|
|
1186
|
+
const unblinded = N.toRawBytes();
|
|
1187
|
+
const hashInput = concatBytes(
|
|
1188
|
+
i2osp(input.length, 2),
|
|
1189
|
+
input,
|
|
1190
|
+
i2osp(unblinded.length, 2),
|
|
1191
|
+
unblinded,
|
|
1192
|
+
te.encode("Finalize")
|
|
1193
|
+
);
|
|
1194
|
+
return sha5122(hashInput);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// src/thumbnail.ts
|
|
1198
|
+
import { Jimp } from "jimp";
|
|
1199
|
+
var PREFIX = "data:image/jpeg;base64,";
|
|
1200
|
+
var READABLE = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/bmp", "image/gif", "image/tiff"]);
|
|
1201
|
+
var QUALITY_STEPS = [85, 70, 55, 40];
|
|
1202
|
+
var MAX_INPUT_BYTES = 80 * 1024 * 1024;
|
|
1203
|
+
function thumbnailable(mime) {
|
|
1204
|
+
return READABLE.has(mime.split(";")[0].trim().toLowerCase());
|
|
1205
|
+
}
|
|
1206
|
+
async function thumbnailFor(bytes, mime) {
|
|
1207
|
+
if (!thumbnailable(mime)) return void 0;
|
|
1208
|
+
if (bytes.length > MAX_INPUT_BYTES) return void 0;
|
|
1209
|
+
try {
|
|
1210
|
+
const image = await Jimp.read(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
1211
|
+
if (Math.max(image.width, image.height) > THUMBNAIL_MAX_EDGE) {
|
|
1212
|
+
image.scaleToFit({ w: THUMBNAIL_MAX_EDGE, h: THUMBNAIL_MAX_EDGE });
|
|
1213
|
+
}
|
|
1214
|
+
for (const quality of QUALITY_STEPS) {
|
|
1215
|
+
const jpeg = await image.getBuffer("image/jpeg", { quality });
|
|
1216
|
+
const uri = PREFIX + Buffer.from(jpeg).toString("base64");
|
|
1217
|
+
if (uri.length <= THUMBNAIL_MAX_BYTES) return uri;
|
|
1218
|
+
}
|
|
1219
|
+
return void 0;
|
|
1220
|
+
} catch {
|
|
1221
|
+
return void 0;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// src/send.ts
|
|
1226
|
+
var MAX_FILE_BYTES = 5 * 1024 * 1024 * 1024;
|
|
1227
|
+
var REPLAY_WINDOW_MS = 10 * 60 * 1e3;
|
|
1228
|
+
var UPLOAD_CONCURRENCY = 4;
|
|
1229
|
+
var OPRF_PROBE_BATCH = 200;
|
|
1230
|
+
var MAX_REFUSALS_PER_SEND = 8;
|
|
1231
|
+
var NOTE_NAME_MAX = 40;
|
|
1232
|
+
function batches(items, size) {
|
|
1233
|
+
const out = [];
|
|
1234
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
1235
|
+
return out;
|
|
1236
|
+
}
|
|
1237
|
+
function sliceSize(ctx) {
|
|
1238
|
+
return Math.max(1, Math.floor(ctx.batch ?? OPRF_PROBE_BATCH));
|
|
1239
|
+
}
|
|
1240
|
+
var MIME = new Map(Object.entries({
|
|
1241
|
+
jpg: "image/jpeg",
|
|
1242
|
+
jpeg: "image/jpeg",
|
|
1243
|
+
png: "image/png",
|
|
1244
|
+
gif: "image/gif",
|
|
1245
|
+
webp: "image/webp",
|
|
1246
|
+
bmp: "image/bmp",
|
|
1247
|
+
tif: "image/tiff",
|
|
1248
|
+
tiff: "image/tiff",
|
|
1249
|
+
svg: "image/svg+xml",
|
|
1250
|
+
pdf: "application/pdf",
|
|
1251
|
+
txt: "text/plain",
|
|
1252
|
+
md: "text/markdown",
|
|
1253
|
+
csv: "text/csv",
|
|
1254
|
+
json: "application/json",
|
|
1255
|
+
html: "text/html",
|
|
1256
|
+
zip: "application/zip",
|
|
1257
|
+
gz: "application/gzip",
|
|
1258
|
+
tar: "application/x-tar",
|
|
1259
|
+
mp4: "video/mp4",
|
|
1260
|
+
mov: "video/quicktime",
|
|
1261
|
+
mp3: "audio/mpeg",
|
|
1262
|
+
wav: "audio/wav",
|
|
1263
|
+
doc: "application/msword",
|
|
1264
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1265
|
+
xls: "application/vnd.ms-excel",
|
|
1266
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
1267
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
1268
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
1269
|
+
ts: "text/typescript",
|
|
1270
|
+
js: "text/javascript",
|
|
1271
|
+
py: "text/x-python",
|
|
1272
|
+
go: "text/x-go",
|
|
1273
|
+
rs: "text/x-rust",
|
|
1274
|
+
java: "text/x-java",
|
|
1275
|
+
c: "text/x-c",
|
|
1276
|
+
h: "text/x-c",
|
|
1277
|
+
cpp: "text/x-c++",
|
|
1278
|
+
sh: "application/x-sh",
|
|
1279
|
+
yml: "application/yaml",
|
|
1280
|
+
yaml: "application/yaml",
|
|
1281
|
+
toml: "application/toml",
|
|
1282
|
+
xml: "application/xml"
|
|
1283
|
+
}));
|
|
1284
|
+
function mimeFor(path) {
|
|
1285
|
+
return MIME.get(extname2(path).slice(1).toLowerCase()) ?? "application/octet-stream";
|
|
1286
|
+
}
|
|
1287
|
+
async function mapLimit(items, limit, work) {
|
|
1288
|
+
const out = new Array(items.length);
|
|
1289
|
+
let next = 0;
|
|
1290
|
+
const runner = async () => {
|
|
1291
|
+
for (; ; ) {
|
|
1292
|
+
const index = next++;
|
|
1293
|
+
if (index >= items.length) return;
|
|
1294
|
+
out[index] = await work(items[index], index);
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runner));
|
|
1298
|
+
return out;
|
|
1299
|
+
}
|
|
1300
|
+
async function grantFor(ctx, channel) {
|
|
1301
|
+
const grant = resolveChannel(ctx.identity, await grantsFor(ctx.client, ctx.profile), channel);
|
|
1302
|
+
if (!grant.send || grant.mode === "view") throw new ZasError("send_forbidden", 403);
|
|
1303
|
+
if (grant.direct_mode) throw new ZasError("direct_mode", 409);
|
|
1304
|
+
return grant;
|
|
1305
|
+
}
|
|
1306
|
+
async function receiptKey(ctx, channelId, contentHash, title) {
|
|
1307
|
+
const raw = `${ctx.identity.agent_uid}\0${channelId}\0${contentHash}\0${title}`;
|
|
1308
|
+
return blake3Hex(new TextEncoder().encode(raw));
|
|
1309
|
+
}
|
|
1310
|
+
function receiptFor(ctx, key) {
|
|
1311
|
+
const entries = loadFingerprints(ctx.profile).entries;
|
|
1312
|
+
if (!Object.prototype.hasOwnProperty.call(entries, key)) return void 0;
|
|
1313
|
+
const entry = entries[key];
|
|
1314
|
+
if (!entry || typeof entry.at !== "number" || Date.now() - entry.at >= REPLAY_WINDOW_MS) return void 0;
|
|
1315
|
+
return entry;
|
|
1316
|
+
}
|
|
1317
|
+
function remember(ctx, key, receipt) {
|
|
1318
|
+
const cutoff = Date.now() - REPLAY_WINDOW_MS;
|
|
1319
|
+
const entries = {};
|
|
1320
|
+
for (const [k, v] of Object.entries(loadFingerprints(ctx.profile).entries)) {
|
|
1321
|
+
if (v && typeof v.at === "number" && v.at >= cutoff) entries[k] = v;
|
|
1322
|
+
}
|
|
1323
|
+
entries[key] = receipt;
|
|
1324
|
+
saveFingerprints(ctx.profile, { entries });
|
|
1325
|
+
}
|
|
1326
|
+
function sleep(ms) {
|
|
1327
|
+
return new Promise((resolve2) => {
|
|
1328
|
+
setTimeout(resolve2, ms);
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
var CHUNK_PUT_RETRY_DELAY_MS = 1e3;
|
|
1332
|
+
async function putWithRetry(url, body) {
|
|
1333
|
+
for (let attempt = 0; ; attempt++) {
|
|
1334
|
+
let res;
|
|
1335
|
+
try {
|
|
1336
|
+
res = await fetch(url, { method: "PUT", body });
|
|
1337
|
+
} catch {
|
|
1338
|
+
if (attempt > 0) throw new ZasError("upload_failed", 0);
|
|
1339
|
+
await sleep(CHUNK_PUT_RETRY_DELAY_MS);
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
if (res.ok || res.status < 500 || attempt > 0) return res;
|
|
1343
|
+
await res.body?.cancel().catch(() => void 0);
|
|
1344
|
+
await sleep(CHUNK_PUT_RETRY_DELAY_MS);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
var COMMIT_ATTEMPTS = 4;
|
|
1348
|
+
var COMMIT_RETRY_BASE_MS = 250;
|
|
1349
|
+
async function commitChunk(ctx, blobId, uploadId) {
|
|
1350
|
+
for (let attempt = 0; attempt < COMMIT_ATTEMPTS; attempt++) {
|
|
1351
|
+
try {
|
|
1352
|
+
const { cap } = await ctx.client.api(
|
|
1353
|
+
"POST",
|
|
1354
|
+
`/blobs/${blobId}/commit`,
|
|
1355
|
+
{ upload_id: uploadId }
|
|
1356
|
+
);
|
|
1357
|
+
return cap;
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
if (!(err instanceof ZasError) || err.serverCode !== "commit_pending") throw err;
|
|
1360
|
+
if (attempt === COMMIT_ATTEMPTS - 1) break;
|
|
1361
|
+
await sleep(COMMIT_RETRY_BASE_MS * 2 ** attempt);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
throw new ZasError("upload_failed", 409);
|
|
1365
|
+
}
|
|
1366
|
+
async function uploadChunk(ctx, enc) {
|
|
1367
|
+
const reserved = await ctx.client.api(
|
|
1368
|
+
"POST",
|
|
1369
|
+
`/blobs/${enc.blobId}/upload-url`,
|
|
1370
|
+
{ size: enc.ciphertext.length }
|
|
1371
|
+
);
|
|
1372
|
+
if (typeof reserved.url !== "string" || typeof reserved.upload_id !== "string") {
|
|
1373
|
+
throw new ZasError("upload_failed", 0);
|
|
1374
|
+
}
|
|
1375
|
+
const put = await putWithRetry(reserved.url, enc.ciphertext);
|
|
1376
|
+
if (!put.ok) {
|
|
1377
|
+
await put.body?.cancel().catch(() => void 0);
|
|
1378
|
+
throw new ZasError("upload_failed", put.status);
|
|
1379
|
+
}
|
|
1380
|
+
return commitChunk(ctx, enc.blobId, reserved.upload_id);
|
|
1381
|
+
}
|
|
1382
|
+
async function proveChunk(ctx, enc, challenge) {
|
|
1383
|
+
const samples = challenge.offsets.map((o) => enc.ciphertext.slice(o, o + challenge.sample_len));
|
|
1384
|
+
try {
|
|
1385
|
+
const { cap } = await ctx.client.api("POST", `/blobs/${enc.blobId}/prove`, {
|
|
1386
|
+
challenge_id: challenge.challenge_id,
|
|
1387
|
+
mac: bytesToHex(hmacSha256(b64ToBytes(challenge.nonce), concatBytes(...samples)))
|
|
1388
|
+
});
|
|
1389
|
+
return cap;
|
|
1390
|
+
} catch (err) {
|
|
1391
|
+
if (!(err instanceof ZasError) || err.serverCode !== "proof_failed") throw err;
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
async function placeSlice(ctx, slice, into, budget) {
|
|
1396
|
+
const probe = await ctx.client.api("POST", "/blobs/probe", {
|
|
1397
|
+
ids: slice.map((e) => e.blobId)
|
|
1398
|
+
});
|
|
1399
|
+
const results = probe.results ?? {};
|
|
1400
|
+
const challenges = probe.challenges ?? {};
|
|
1401
|
+
for (const enc of slice) {
|
|
1402
|
+
if (results[enc.blobId] === "blocked") throw new ZasError("upload_failed", 451);
|
|
1403
|
+
}
|
|
1404
|
+
const provable = [];
|
|
1405
|
+
const upload = [];
|
|
1406
|
+
for (const enc of slice) {
|
|
1407
|
+
if (results[enc.blobId] === "prove" && challenges[enc.blobId]) provable.push(enc);
|
|
1408
|
+
else upload.push(enc);
|
|
1409
|
+
}
|
|
1410
|
+
let refused = budget.refusals >= MAX_REFUSALS_PER_SEND;
|
|
1411
|
+
const prove = async (enc) => {
|
|
1412
|
+
if (refused) {
|
|
1413
|
+
upload.push(enc);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const cap = await proveChunk(ctx, enc, challenges[enc.blobId]);
|
|
1417
|
+
if (cap === null) {
|
|
1418
|
+
refused = true;
|
|
1419
|
+
budget.refusals += 1;
|
|
1420
|
+
upload.push(enc);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
into.set(enc.blobId, { cap, proven: true });
|
|
1424
|
+
};
|
|
1425
|
+
if (provable.length > 0) await prove(provable[0]);
|
|
1426
|
+
await mapLimit(provable.slice(1), UPLOAD_CONCURRENCY, prove);
|
|
1427
|
+
await mapLimit(upload, UPLOAD_CONCURRENCY, async (enc) => {
|
|
1428
|
+
into.set(enc.blobId, { cap: await uploadChunk(ctx, enc), proven: false });
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
async function placeChunks(ctx, encs) {
|
|
1432
|
+
if (encs.length === 0) return [];
|
|
1433
|
+
const distinct = [];
|
|
1434
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1435
|
+
for (const enc of encs) {
|
|
1436
|
+
if (seen.has(enc.blobId)) continue;
|
|
1437
|
+
seen.add(enc.blobId);
|
|
1438
|
+
distinct.push(enc);
|
|
1439
|
+
}
|
|
1440
|
+
const placements = /* @__PURE__ */ new Map();
|
|
1441
|
+
const budget = { refusals: 0 };
|
|
1442
|
+
for (const slice of batches(distinct, sliceSize(ctx))) await placeSlice(ctx, slice, placements, budget);
|
|
1443
|
+
return encs.map((enc) => {
|
|
1444
|
+
const placement = placements.get(enc.blobId);
|
|
1445
|
+
if (!placement) throw new ZasError("upload_failed", 0);
|
|
1446
|
+
return {
|
|
1447
|
+
entry: {
|
|
1448
|
+
blob_id: enc.blobId,
|
|
1449
|
+
key: bytesToB64(enc.key),
|
|
1450
|
+
nonce: bytesToB64(enc.nonce),
|
|
1451
|
+
size: enc.ciphertext.length,
|
|
1452
|
+
cap: placement.cap
|
|
1453
|
+
},
|
|
1454
|
+
proven: placement.proven
|
|
1455
|
+
};
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
async function postLink(ctx, grant, manifest, placed, idempotencyKey) {
|
|
1459
|
+
const channelKey = channelKeyOf(ctx.identity, grant);
|
|
1460
|
+
const sealed = sealManifest(channelKey, manifest, grant.key_version);
|
|
1461
|
+
const created = await ctx.client.api("POST", "/links", {
|
|
1462
|
+
channel_id: grant.channel_id,
|
|
1463
|
+
manifest_enc: bytesToB64(sealed),
|
|
1464
|
+
caps: placed.map((p) => p.entry.cap).filter((cap) => typeof cap === "string"),
|
|
1465
|
+
// Every chunk now arrives with a cap, proven or uploaded, so there is
|
|
1466
|
+
// nothing left for the server to verify here. The field stays because the
|
|
1467
|
+
// server reads it, and an absent array is not the same as an empty one.
|
|
1468
|
+
proofs: [],
|
|
1469
|
+
idempotency_key: idempotencyKey
|
|
1470
|
+
});
|
|
1471
|
+
const bound = created.caps ?? {};
|
|
1472
|
+
let patched = false;
|
|
1473
|
+
for (const p of placed) {
|
|
1474
|
+
const cap = bound[p.entry.blob_id];
|
|
1475
|
+
if (cap) {
|
|
1476
|
+
p.entry.cap = cap;
|
|
1477
|
+
patched = true;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (patched) {
|
|
1481
|
+
await ctx.client.api("PATCH", `/links/${grant.channel_id}/${created.link_id}`, {
|
|
1482
|
+
manifest_enc: bytesToB64(sealManifest(channelKey, manifest, grant.key_version))
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
return created.link_id;
|
|
1486
|
+
}
|
|
1487
|
+
async function sendFile(ctx, input, onPhase) {
|
|
1488
|
+
const stat = await fsp.stat(input.path).catch(() => {
|
|
1489
|
+
throw new ZasError("upload_failed", 400);
|
|
1490
|
+
});
|
|
1491
|
+
if (!stat.isFile()) throw new ZasError("upload_failed", 400);
|
|
1492
|
+
if (stat.size > MAX_FILE_BYTES) throw new ZasError("file_too_big", 413);
|
|
1493
|
+
const grant = await grantFor(ctx, input.channel);
|
|
1494
|
+
const name = basename(input.path);
|
|
1495
|
+
const title = input.title ?? name;
|
|
1496
|
+
const file = await fsp.readFile(input.path);
|
|
1497
|
+
if (file.byteLength > MAX_FILE_BYTES) throw new ZasError("file_too_big", 413);
|
|
1498
|
+
const bytes = new Uint8Array(file.buffer, file.byteOffset, file.byteLength);
|
|
1499
|
+
const contentHash = await blake3Hex(bytes);
|
|
1500
|
+
const key = await receiptKey(ctx, grant.channel_id, contentHash, title);
|
|
1501
|
+
const stored = receiptFor(ctx, key);
|
|
1502
|
+
if (stored) {
|
|
1503
|
+
return {
|
|
1504
|
+
link_id: stored.link_id,
|
|
1505
|
+
channel_id: grant.channel_id,
|
|
1506
|
+
channel_name: channelNameOf(ctx.identity, grant),
|
|
1507
|
+
bytes: stored.bytes,
|
|
1508
|
+
chunks: stored.chunks,
|
|
1509
|
+
deduplicated: stored.deduplicated,
|
|
1510
|
+
replayed: true
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
onPhase?.("hashing");
|
|
1514
|
+
const plains = [];
|
|
1515
|
+
for await (const piece of chunkStream([bytes])) plains.push(piece);
|
|
1516
|
+
const hashes = await Promise.all(plains.map((p) => blake3Bytes(p)));
|
|
1517
|
+
const blinds = hashes.map((h) => oprfBlind(h));
|
|
1518
|
+
const evaluated = [];
|
|
1519
|
+
for (const slice of batches(blinds.map((b) => bytesToB64(b.blindedElement)), sliceSize(ctx))) {
|
|
1520
|
+
const answers = await ctx.client.oprfEvaluate(slice);
|
|
1521
|
+
if (answers.length !== slice.length) throw new ZasError("oprf_failed", 0);
|
|
1522
|
+
for (const one of answers) evaluated.push(one);
|
|
1523
|
+
}
|
|
1524
|
+
onPhase?.("encrypting");
|
|
1525
|
+
const encs = await Promise.all(plains.map(
|
|
1526
|
+
(plain, i) => encryptChunk(oprfFinalize(hashes[i], blinds[i].blind, b64ToBytes(evaluated[i])), plain)
|
|
1527
|
+
));
|
|
1528
|
+
onPhase?.("uploading");
|
|
1529
|
+
const placed = await placeChunks(ctx, encs);
|
|
1530
|
+
onPhase?.("finishing");
|
|
1531
|
+
const mime = mimeFor(input.path);
|
|
1532
|
+
const thumb = await thumbnailFor(bytes, mime);
|
|
1533
|
+
const manifest = newManifest({
|
|
1534
|
+
kind: "file",
|
|
1535
|
+
name,
|
|
1536
|
+
// Only when the caller chose one: absence means "show the file name", and
|
|
1537
|
+
// writing the file name into `title` would make a rename look deliberate.
|
|
1538
|
+
...input.title !== void 0 ? { title: input.title } : {},
|
|
1539
|
+
mime,
|
|
1540
|
+
size: bytes.length,
|
|
1541
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1542
|
+
...thumb ? { thumb_data: thumb } : {},
|
|
1543
|
+
chunks: placed.map((p) => p.entry)
|
|
1544
|
+
});
|
|
1545
|
+
const linkId = await postLink(
|
|
1546
|
+
ctx,
|
|
1547
|
+
grant,
|
|
1548
|
+
manifest,
|
|
1549
|
+
placed,
|
|
1550
|
+
agentSendIdempotencyKey(grant.channel_id, contentHash, title)
|
|
1551
|
+
);
|
|
1552
|
+
const deduplicated = placed.filter((p) => p.proven).length;
|
|
1553
|
+
remember(ctx, key, {
|
|
1554
|
+
link_id: linkId,
|
|
1555
|
+
bytes: bytes.length,
|
|
1556
|
+
chunks: placed.length,
|
|
1557
|
+
deduplicated,
|
|
1558
|
+
at: Date.now()
|
|
1559
|
+
});
|
|
1560
|
+
return {
|
|
1561
|
+
link_id: linkId,
|
|
1562
|
+
channel_id: grant.channel_id,
|
|
1563
|
+
channel_name: channelNameOf(ctx.identity, grant),
|
|
1564
|
+
bytes: bytes.length,
|
|
1565
|
+
chunks: placed.length,
|
|
1566
|
+
deduplicated,
|
|
1567
|
+
replayed: false
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
function noteName(text2) {
|
|
1571
|
+
return text2.split("\n", 1)[0].trim().slice(0, NOTE_NAME_MAX) || "nota";
|
|
1572
|
+
}
|
|
1573
|
+
async function sendNote(ctx, input) {
|
|
1574
|
+
const grant = await grantFor(ctx, input.channel);
|
|
1575
|
+
const name = input.title ?? noteName(input.text);
|
|
1576
|
+
const encoded = new TextEncoder().encode(input.text);
|
|
1577
|
+
const contentHash = await blake3Hex(new TextEncoder().encode(
|
|
1578
|
+
`${input.lang ?? ""}\0${input.secret ? "1" : "0"}\0${input.text}`
|
|
1579
|
+
));
|
|
1580
|
+
const key = await receiptKey(ctx, grant.channel_id, contentHash, name);
|
|
1581
|
+
const stored = receiptFor(ctx, key);
|
|
1582
|
+
if (stored) {
|
|
1583
|
+
return {
|
|
1584
|
+
link_id: stored.link_id,
|
|
1585
|
+
channel_id: grant.channel_id,
|
|
1586
|
+
channel_name: channelNameOf(ctx.identity, grant),
|
|
1587
|
+
bytes: stored.bytes,
|
|
1588
|
+
chunks: 0,
|
|
1589
|
+
deduplicated: 0,
|
|
1590
|
+
replayed: true
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
const manifest = newManifest({
|
|
1594
|
+
kind: "text",
|
|
1595
|
+
name,
|
|
1596
|
+
...input.title !== void 0 ? { title: input.title } : {},
|
|
1597
|
+
mime: "text/plain",
|
|
1598
|
+
size: encoded.length,
|
|
1599
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1600
|
+
text: input.text,
|
|
1601
|
+
...input.lang ? { code: { lang: input.lang, auto: false } } : {},
|
|
1602
|
+
// Only ever true or absent, exactly as the note cover is defined: absent is
|
|
1603
|
+
// "the sender did not classify", which no receiver may read as safe.
|
|
1604
|
+
...input.secret ? { sensitive: true } : {},
|
|
1605
|
+
chunks: []
|
|
1606
|
+
});
|
|
1607
|
+
const linkId = await postLink(
|
|
1608
|
+
ctx,
|
|
1609
|
+
grant,
|
|
1610
|
+
manifest,
|
|
1611
|
+
[],
|
|
1612
|
+
agentSendIdempotencyKey(grant.channel_id, contentHash, name)
|
|
1613
|
+
);
|
|
1614
|
+
remember(ctx, key, {
|
|
1615
|
+
link_id: linkId,
|
|
1616
|
+
bytes: encoded.length,
|
|
1617
|
+
chunks: 0,
|
|
1618
|
+
deduplicated: 0,
|
|
1619
|
+
at: Date.now()
|
|
1620
|
+
});
|
|
1621
|
+
return {
|
|
1622
|
+
link_id: linkId,
|
|
1623
|
+
channel_id: grant.channel_id,
|
|
1624
|
+
channel_name: channelNameOf(ctx.identity, grant),
|
|
1625
|
+
bytes: encoded.length,
|
|
1626
|
+
chunks: 0,
|
|
1627
|
+
deduplicated: 0,
|
|
1628
|
+
replayed: false
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// src/server.ts
|
|
1633
|
+
function agentVersion() {
|
|
1634
|
+
return true ? "0.1.0" : "0.0.0-dev";
|
|
1635
|
+
}
|
|
1636
|
+
var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
|
|
1637
|
+
var PAIR_ANNOUNCE_MS = 15e3;
|
|
1638
|
+
var NOTE_LABEL_MAX = 40;
|
|
1639
|
+
var delay = (ms) => new Promise((resolve2) => {
|
|
1640
|
+
setTimeout(resolve2, ms);
|
|
1641
|
+
});
|
|
1642
|
+
function rightsOf(grant) {
|
|
1643
|
+
const rights = [];
|
|
1644
|
+
if (grant.send && grant.mode !== "view" && !grant.direct_mode) rights.push("env\xEDa");
|
|
1645
|
+
if (grant.read) rights.push("lee");
|
|
1646
|
+
return rights.length > 0 ? rights.join(" \xB7 ") : "sin permisos";
|
|
1647
|
+
}
|
|
1648
|
+
function buildServer(profile, deps = {}) {
|
|
1649
|
+
const server = new McpServer({ name: "zas", version: agentVersion() });
|
|
1650
|
+
const runner = deps.runner ?? new JobRunner();
|
|
1651
|
+
let client = null;
|
|
1652
|
+
const ctx = () => {
|
|
1653
|
+
const identity = deps.identity ?? loadIdentity(profile);
|
|
1654
|
+
if (!identity) throw new ZasError("not_paired", 0);
|
|
1655
|
+
if (deps.client) return { identity, client: deps.client, profile };
|
|
1656
|
+
if (!client || client.identity.agent_uid !== identity.agent_uid) client = new ZasClient(identity);
|
|
1657
|
+
return { identity, client, profile };
|
|
1658
|
+
};
|
|
1659
|
+
const text2 = (value) => ({
|
|
1660
|
+
content: [{
|
|
1661
|
+
type: "text",
|
|
1662
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
1663
|
+
}]
|
|
1664
|
+
});
|
|
1665
|
+
const failed = (e) => {
|
|
1666
|
+
const err = e instanceof ZasError ? e : new ZasError("internal", 0, String(e));
|
|
1667
|
+
return { isError: true, ...text2(`${err.code}: ${humanSentence(err, "es")} / ${humanSentence(err, "en")}`) };
|
|
1668
|
+
};
|
|
1669
|
+
const settled = (job) => {
|
|
1670
|
+
if (job.status === "done") return text2(job.result);
|
|
1671
|
+
if (job.status === "failed") {
|
|
1672
|
+
const e = job.error;
|
|
1673
|
+
return failed(e ? new ZasError(e.code, e.status, e.message, e.retryAfterMs, e.serverCode) : new ZasError("internal", 0));
|
|
1674
|
+
}
|
|
1675
|
+
return text2({ job_id: job.id, status: "running", phase: job.phase });
|
|
1676
|
+
};
|
|
1677
|
+
server.registerTool("zas_status", {
|
|
1678
|
+
description: "Say whether this machine is paired with a Zas account, and list the owner's channels this agent may send to or read from." + AGENT_CUE
|
|
1679
|
+
}, async () => {
|
|
1680
|
+
try {
|
|
1681
|
+
if (!(deps.identity ?? loadIdentity(profile))) {
|
|
1682
|
+
return text2(humanSentence(new ZasError("not_paired", 0), "es"));
|
|
1683
|
+
}
|
|
1684
|
+
const c = ctx();
|
|
1685
|
+
const grants = await grantsFor(c.client, profile);
|
|
1686
|
+
const lines = grants.map((grant) => {
|
|
1687
|
+
let name = grant.channel_id;
|
|
1688
|
+
try {
|
|
1689
|
+
name = channelNameOf(c.identity, grant);
|
|
1690
|
+
} catch {
|
|
1691
|
+
}
|
|
1692
|
+
return ` ${name} \xB7 ${rightsOf(grant)}`;
|
|
1693
|
+
});
|
|
1694
|
+
return text2([
|
|
1695
|
+
`Emparejado como \xAB${c.identity.name}\xBB (${c.identity.kind}) con la cuenta ${c.identity.owner_uid}.`,
|
|
1696
|
+
grants.length > 0 ? "Canales:" : "Sin canales: el due\xF1o todav\xEDa no le dio acceso a ninguno.",
|
|
1697
|
+
...lines,
|
|
1698
|
+
`${packageName()} ${agentVersion()} \xB7 perfil ${profile}`
|
|
1699
|
+
].join("\n"));
|
|
1700
|
+
} catch (e) {
|
|
1701
|
+
return failed(e);
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
let pairing = null;
|
|
1705
|
+
server.registerTool("zas_pair", {
|
|
1706
|
+
description: "Pair this machine with a Zas account. The first call returns a URL and a code for the owner to approve; a later call says whether they did." + AGENT_CUE
|
|
1707
|
+
}, async () => {
|
|
1708
|
+
if (!pairing) {
|
|
1709
|
+
const state = { logs: [], status: "running" };
|
|
1710
|
+
pairing = state;
|
|
1711
|
+
(deps.runPair ?? runPair)({
|
|
1712
|
+
profile,
|
|
1713
|
+
kind: kindForProfile(profile),
|
|
1714
|
+
webBase: process.env.ZAS_WEB_BASE || "https://zas.red",
|
|
1715
|
+
// Read now, not at import: an MCP server is started by a client that
|
|
1716
|
+
// may have set the environment after this module was loaded.
|
|
1717
|
+
apiBase: defaultEndpoints().api_base,
|
|
1718
|
+
log: (line) => {
|
|
1719
|
+
state.logs.push(line);
|
|
1720
|
+
}
|
|
1721
|
+
}).then(
|
|
1722
|
+
(identity) => {
|
|
1723
|
+
state.status = "paired";
|
|
1724
|
+
state.identity = identity;
|
|
1725
|
+
},
|
|
1726
|
+
(error) => {
|
|
1727
|
+
state.status = "failed";
|
|
1728
|
+
state.error = error;
|
|
1729
|
+
}
|
|
1730
|
+
);
|
|
1731
|
+
const until = Date.now() + (deps.announceMs ?? PAIR_ANNOUNCE_MS);
|
|
1732
|
+
while (state.status === "running" && !state.logs.some(hasPairUrl) && Date.now() < until) {
|
|
1733
|
+
await delay(50);
|
|
1734
|
+
}
|
|
1735
|
+
if (state.status === "failed") {
|
|
1736
|
+
pairing = null;
|
|
1737
|
+
return failed(state.error);
|
|
1738
|
+
}
|
|
1739
|
+
return text2(state.logs.join("\n") || "Abriendo el emparejamiento\u2026");
|
|
1740
|
+
}
|
|
1741
|
+
if (pairing.status === "paired") return text2(`paired as ${pairing.identity?.name ?? ""}`.trim());
|
|
1742
|
+
if (pairing.status === "failed") {
|
|
1743
|
+
const error = pairing.error;
|
|
1744
|
+
pairing = null;
|
|
1745
|
+
return failed(error);
|
|
1746
|
+
}
|
|
1747
|
+
return text2(["pending", ...pairing.logs].join("\n"));
|
|
1748
|
+
});
|
|
1749
|
+
server.registerTool("zas_send_file", {
|
|
1750
|
+
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,
|
|
1751
|
+
inputSchema: {
|
|
1752
|
+
path: z.string().describe("Absolute or relative path of the file to send."),
|
|
1753
|
+
channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel."),
|
|
1754
|
+
title: z.string().optional().describe("Label for the item. Defaults to the file name.")
|
|
1755
|
+
}
|
|
1756
|
+
}, async (input) => {
|
|
1757
|
+
try {
|
|
1758
|
+
const c = ctx();
|
|
1759
|
+
const job = runner.start(
|
|
1760
|
+
"file",
|
|
1761
|
+
input.title ?? input.path,
|
|
1762
|
+
input.channel ?? "",
|
|
1763
|
+
(report) => sendFile(c, input, report)
|
|
1764
|
+
);
|
|
1765
|
+
return settled(await runner.wait(job));
|
|
1766
|
+
} catch (e) {
|
|
1767
|
+
return failed(e);
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
server.registerTool("zas_send_note", {
|
|
1771
|
+
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,
|
|
1772
|
+
inputSchema: {
|
|
1773
|
+
text: z.string().describe("The body of the note."),
|
|
1774
|
+
channel: z.string().optional().describe("Channel name or id. Optional when the agent holds exactly one channel."),
|
|
1775
|
+
title: z.string().optional().describe("Label for the item. Defaults to the first line."),
|
|
1776
|
+
lang: z.string().optional().describe('Language of the snippet, for highlighting (for example "ts", "py").'),
|
|
1777
|
+
secret: z.boolean().optional().describe("Hide the body behind a cover until the reader opens it.")
|
|
1778
|
+
}
|
|
1779
|
+
}, async (input) => {
|
|
1780
|
+
try {
|
|
1781
|
+
const c = ctx();
|
|
1782
|
+
const job = runner.start(
|
|
1783
|
+
"note",
|
|
1784
|
+
input.title ?? input.text.split("\n", 1)[0].slice(0, NOTE_LABEL_MAX),
|
|
1785
|
+
input.channel ?? "",
|
|
1786
|
+
() => sendNote(c, input)
|
|
1787
|
+
);
|
|
1788
|
+
return settled(await runner.wait(job));
|
|
1789
|
+
} catch (e) {
|
|
1790
|
+
return failed(e);
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
server.registerTool("zas_list_items", {
|
|
1794
|
+
description: "List the most recent items in one of the owner's Zas channels. Needs a grant that includes reading." + AGENT_CUE,
|
|
1795
|
+
inputSchema: {
|
|
1796
|
+
channel: z.string().describe("Channel name or id."),
|
|
1797
|
+
limit: z.number().int().min(1).max(50).optional().describe("How many items, 1 to 50. Defaults to 20.")
|
|
1798
|
+
}
|
|
1799
|
+
}, async (input) => {
|
|
1800
|
+
try {
|
|
1801
|
+
return text2(await listItems(ctx(), input.channel, input.limit));
|
|
1802
|
+
} catch (e) {
|
|
1803
|
+
return failed(e);
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
server.registerTool("zas_get_item", {
|
|
1807
|
+
description: "Fetch one item from a Zas channel. A note comes back as text; a file is written to disk. Returns the path written; it can differ from `dest` when a file with that name already exists. Writes a new file under `dest` (or the system temp directory); it never overwrites an existing file." + AGENT_CUE,
|
|
1808
|
+
inputSchema: {
|
|
1809
|
+
channel: z.string().describe("Channel name or id."),
|
|
1810
|
+
id: z.string().describe("Item id, as `zas_list_items` reports it."),
|
|
1811
|
+
dest: z.string().optional().describe('Where to write a file. A directory means "inside it". Defaults to a fresh temporary directory.')
|
|
1812
|
+
}
|
|
1813
|
+
}, async (input) => {
|
|
1814
|
+
try {
|
|
1815
|
+
return text2(await getItem(ctx(), input.channel, input.id, input.dest));
|
|
1816
|
+
} catch (e) {
|
|
1817
|
+
return failed(e);
|
|
1818
|
+
}
|
|
1819
|
+
});
|
|
1820
|
+
server.registerTool("zas_jobs", {
|
|
1821
|
+
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
|
|
1822
|
+
}, async () => text2(runner.list()));
|
|
1823
|
+
return server;
|
|
1824
|
+
}
|
|
1825
|
+
var hasPairUrl = (line) => line.includes("/agents/pair?p=");
|
|
1826
|
+
|
|
1827
|
+
// src/cli.ts
|
|
1828
|
+
var DEFAULT_PROFILE = "claude-code";
|
|
1829
|
+
function parseArgs(argv) {
|
|
1830
|
+
const parsed = { command: "serve", profile: DEFAULT_PROFILE };
|
|
1831
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1832
|
+
const arg = argv[i];
|
|
1833
|
+
const eq = arg.indexOf("=");
|
|
1834
|
+
const name = arg.startsWith("--") && eq > 0 ? arg.slice(0, eq) : arg;
|
|
1835
|
+
const inline = arg.startsWith("--") && eq > 0 ? arg.slice(eq + 1) : void 0;
|
|
1836
|
+
const take = () => inline !== void 0 ? inline : argv[++i] ?? "";
|
|
1837
|
+
if (name === "pair") parsed.command = "pair";
|
|
1838
|
+
else if (name === "--version" || name === "-v") parsed.command = "version";
|
|
1839
|
+
else if (name === "--help" || name === "-h") parsed.command = "help";
|
|
1840
|
+
else if (name === "--profile") {
|
|
1841
|
+
parsed.profile = take() || DEFAULT_PROFILE;
|
|
1842
|
+
if (!PROFILE_RE.test(parsed.profile)) {
|
|
1843
|
+
return { ...parsed, command: "invalid", message: `Perfil inv\xE1lido: ${parsed.profile}` };
|
|
1844
|
+
}
|
|
1845
|
+
} else if (name === "--kind") {
|
|
1846
|
+
const kind = take();
|
|
1847
|
+
if (!isAgentKind(kind)) return { ...parsed, command: "help", unknown: `--kind ${kind}` };
|
|
1848
|
+
parsed.kind = kind;
|
|
1849
|
+
} else if (name === "--host") parsed.host = take() || void 0;
|
|
1850
|
+
else return { ...parsed, command: "help", unknown: arg };
|
|
1851
|
+
}
|
|
1852
|
+
return parsed;
|
|
1853
|
+
}
|
|
1854
|
+
var USAGE = [
|
|
1855
|
+
"zas-agent \u2014 send files and notes from a coding agent into your Zas channels.",
|
|
1856
|
+
"",
|
|
1857
|
+
" zas-agent [--profile <name>] serve the MCP tools over stdio",
|
|
1858
|
+
" zas-agent pair [--profile <name>] pair this machine with a Zas account",
|
|
1859
|
+
" [--kind claude_code|codex|other] [--host <name>]",
|
|
1860
|
+
" zas-agent --version"
|
|
1861
|
+
].join("\n");
|
|
1862
|
+
async function main(argv, log = (l) => console.error(l)) {
|
|
1863
|
+
const args = parseArgs(argv);
|
|
1864
|
+
if (args.command === "version") {
|
|
1865
|
+
console.log(agentVersion());
|
|
1866
|
+
return 0;
|
|
1867
|
+
}
|
|
1868
|
+
if (args.command === "invalid") {
|
|
1869
|
+
log(args.message ?? "");
|
|
1870
|
+
return 2;
|
|
1871
|
+
}
|
|
1872
|
+
if (args.command === "help") {
|
|
1873
|
+
if (args.unknown) log(`No entiendo \xAB${args.unknown}\xBB.`);
|
|
1874
|
+
log(USAGE);
|
|
1875
|
+
return args.unknown ? 2 : 0;
|
|
1876
|
+
}
|
|
1877
|
+
if (args.command === "pair") {
|
|
1878
|
+
try {
|
|
1879
|
+
await runPair({
|
|
1880
|
+
profile: args.profile,
|
|
1881
|
+
kind: args.kind ?? kindForProfile(args.profile),
|
|
1882
|
+
...args.host !== void 0 ? { host: args.host } : {},
|
|
1883
|
+
webBase: process.env.ZAS_WEB_BASE || "https://zas.red",
|
|
1884
|
+
apiBase: defaultEndpoints().api_base,
|
|
1885
|
+
log
|
|
1886
|
+
});
|
|
1887
|
+
} catch (e) {
|
|
1888
|
+
const err = e instanceof ZasError ? e : new ZasError("internal", 0, String(e));
|
|
1889
|
+
log(humanSentence(err, "es"));
|
|
1890
|
+
return 1;
|
|
1891
|
+
}
|
|
1892
|
+
return 0;
|
|
1893
|
+
}
|
|
1894
|
+
await buildServer(args.profile).connect(new StdioServerTransport());
|
|
1895
|
+
return 0;
|
|
1896
|
+
}
|
|
1897
|
+
var real = (path) => {
|
|
1898
|
+
try {
|
|
1899
|
+
return realpathSync(path);
|
|
1900
|
+
} catch {
|
|
1901
|
+
return "";
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
function isInvokedDirectly(argv1, selfUrl) {
|
|
1905
|
+
if (argv1 === void 0 || argv1 === "") return false;
|
|
1906
|
+
const entry = real(argv1);
|
|
1907
|
+
if (entry === "") return false;
|
|
1908
|
+
let self = "";
|
|
1909
|
+
try {
|
|
1910
|
+
self = real(fileURLToPath(selfUrl));
|
|
1911
|
+
} catch {
|
|
1912
|
+
return false;
|
|
1913
|
+
}
|
|
1914
|
+
return self !== "" && self === entry;
|
|
1915
|
+
}
|
|
1916
|
+
if (isInvokedDirectly(process.argv[1], import.meta.url)) {
|
|
1917
|
+
main(process.argv.slice(2)).then(
|
|
1918
|
+
(code) => {
|
|
1919
|
+
if (code !== 0) process.exitCode = code;
|
|
1920
|
+
},
|
|
1921
|
+
(error) => {
|
|
1922
|
+
console.error(String(error?.stack ?? error));
|
|
1923
|
+
process.exitCode = 1;
|
|
1924
|
+
}
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
export {
|
|
1928
|
+
isInvokedDirectly,
|
|
1929
|
+
main,
|
|
1930
|
+
parseArgs
|
|
1931
|
+
};
|