@ours.network/cli 0.5.0 → 1.0.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/README.md +13 -13
- package/dist/boot-35BOHNSZ.js +9 -0
- package/dist/{chunk-6W3RHW3C.js → chunk-6K2JBKHI.js} +5 -6
- package/dist/{chunk-G2GHHOYX.js → chunk-DRQEV4SN.js} +2 -2
- package/dist/chunk-FXKFSNKS.js +166 -0
- package/dist/chunk-MHVYJY3H.js +2530 -0
- package/dist/{chunk-4IFFMMQO.js → chunk-QZIL3IT7.js} +3 -3
- package/dist/{chunk-YSHMCQ2B.js → chunk-SDNTPCKW.js} +3 -3
- package/dist/chunk-TNNBVUKK.js +241 -0
- package/dist/{chunk-3RLAHOPS.js → chunk-UZQWSOUB.js} +15 -1
- package/dist/{chunk-VGWQD7CJ.js → chunk-ZFE47KH3.js} +1 -2
- package/dist/commands.d.ts +3 -3
- package/dist/commands.js +1 -1
- package/dist/config-command.js +2 -2
- package/dist/connection.js +1 -1
- package/dist/daemon-UUG6P6GK.js +250 -0
- package/dist/help.js +2 -2
- package/dist/lifecycle.js +2 -2
- package/dist/main.js +11 -13
- package/dist/server-TDJBEWTU.js +2077 -0
- package/dist/service-instance.d.ts +3 -0
- package/dist/service-instance.js +7 -1
- package/dist/service.d.ts +30 -0
- package/dist/service.js +10 -4
- package/package.json +4 -2
- package/dist/chunk-227DBTDK.js +0 -132
|
@@ -0,0 +1,2077 @@
|
|
|
1
|
+
import {
|
|
2
|
+
API_VISIBILITY,
|
|
3
|
+
BROKER_URL,
|
|
4
|
+
CONFIG,
|
|
5
|
+
FILE_SELECTION_CAP,
|
|
6
|
+
GC_INTERVAL_MS,
|
|
7
|
+
PORT,
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
STATE_DIR,
|
|
10
|
+
VERSION,
|
|
11
|
+
appendNotifyLog,
|
|
12
|
+
bindSession,
|
|
13
|
+
bootWrapper,
|
|
14
|
+
buildMessagesPayload,
|
|
15
|
+
capabilityReconcileSweep,
|
|
16
|
+
clearNotifyHook,
|
|
17
|
+
closeTemporaryIdentity,
|
|
18
|
+
clusterSweep,
|
|
19
|
+
contactRestoreSweep,
|
|
20
|
+
decodeWireBin,
|
|
21
|
+
delegateRole,
|
|
22
|
+
deleteIdentityCompletely,
|
|
23
|
+
describeIdentity,
|
|
24
|
+
e2eRecoverySweep,
|
|
25
|
+
encodeWireBin,
|
|
26
|
+
envelopeDispatch,
|
|
27
|
+
establishRoot,
|
|
28
|
+
exportAdBlob,
|
|
29
|
+
findIdentityFile,
|
|
30
|
+
hashLeaseToken,
|
|
31
|
+
identities,
|
|
32
|
+
isSelectableWireId,
|
|
33
|
+
leaseByToken,
|
|
34
|
+
leases,
|
|
35
|
+
log,
|
|
36
|
+
mimeFromExt,
|
|
37
|
+
mutatingTx,
|
|
38
|
+
outboundRemovalInFlight,
|
|
39
|
+
persistBindings,
|
|
40
|
+
pidAlive,
|
|
41
|
+
provisionIdentity,
|
|
42
|
+
publishToBook,
|
|
43
|
+
readBook,
|
|
44
|
+
readE2eWireIds,
|
|
45
|
+
readonlyTx,
|
|
46
|
+
refreshUnread,
|
|
47
|
+
registrar,
|
|
48
|
+
renderContactRoots,
|
|
49
|
+
renderContacts,
|
|
50
|
+
renderDegraded,
|
|
51
|
+
renderFileMetadata,
|
|
52
|
+
renderImportRenames,
|
|
53
|
+
renderInbox,
|
|
54
|
+
renderPending,
|
|
55
|
+
requireAuth,
|
|
56
|
+
reservedNames,
|
|
57
|
+
resolveBound,
|
|
58
|
+
rootName,
|
|
59
|
+
saveState,
|
|
60
|
+
scheduleCapabilityReconcile,
|
|
61
|
+
serveNotifications,
|
|
62
|
+
sessionHeaders,
|
|
63
|
+
setNotifyHook,
|
|
64
|
+
startupProgress,
|
|
65
|
+
sweepStaleTempIdentities,
|
|
66
|
+
tombstones,
|
|
67
|
+
unpublishFromBook,
|
|
68
|
+
unreadSummary,
|
|
69
|
+
validateName,
|
|
70
|
+
withScope,
|
|
71
|
+
withScopeAsync,
|
|
72
|
+
writeIncomingFiles,
|
|
73
|
+
writeTempMetaFile
|
|
74
|
+
} from "./chunk-MHVYJY3H.js";
|
|
75
|
+
import {
|
|
76
|
+
buildIdentityFile,
|
|
77
|
+
writeIdentityFile
|
|
78
|
+
} from "./chunk-FXKFSNKS.js";
|
|
79
|
+
|
|
80
|
+
// ../../src/http/server.ts
|
|
81
|
+
import { createServer as createHttpServer } from "node:http";
|
|
82
|
+
import { randomUUID } from "node:crypto";
|
|
83
|
+
import * as fs2 from "node:fs";
|
|
84
|
+
|
|
85
|
+
// ../../src/protocol.ts
|
|
86
|
+
var OURS_COMPAT_VERSION = 1;
|
|
87
|
+
|
|
88
|
+
// ../../src/gc.ts
|
|
89
|
+
var gcTimer = null;
|
|
90
|
+
var gcRunning = false;
|
|
91
|
+
function startGcTimer() {
|
|
92
|
+
if (gcTimer) return;
|
|
93
|
+
gcTimer = setInterval(() => {
|
|
94
|
+
if (gcRunning) return;
|
|
95
|
+
gcRunning = true;
|
|
96
|
+
void (async () => {
|
|
97
|
+
try {
|
|
98
|
+
for (const id of identities.values()) {
|
|
99
|
+
try {
|
|
100
|
+
await withScopeAsync(async (lt) => {
|
|
101
|
+
await mutatingTx(id, "::actor::gc", {}, lt);
|
|
102
|
+
});
|
|
103
|
+
} catch (e) {
|
|
104
|
+
log(`gc(${id.name}) failed:`, String(e));
|
|
105
|
+
}
|
|
106
|
+
await contactRestoreSweep(id);
|
|
107
|
+
await capabilityReconcileSweep(id);
|
|
108
|
+
await e2eRecoverySweep(id);
|
|
109
|
+
}
|
|
110
|
+
} finally {
|
|
111
|
+
gcRunning = false;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
}, GC_INTERVAL_MS);
|
|
115
|
+
gcTimer.unref?.();
|
|
116
|
+
log(`message GC timer armed (every ${GC_INTERVAL_MS}ms)`);
|
|
117
|
+
}
|
|
118
|
+
function stopGcTimer() {
|
|
119
|
+
if (gcTimer) {
|
|
120
|
+
clearInterval(gcTimer);
|
|
121
|
+
gcTimer = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ../../src/sse-keepalive.ts
|
|
126
|
+
var DEFAULT_SSE_KEEPALIVE_MS = 25e3;
|
|
127
|
+
var KEEPALIVE_FRAME = ": ka\n\n";
|
|
128
|
+
function sseKeepaliveMs(env = process.env) {
|
|
129
|
+
const raw = (env.OURS_SSE_KEEPALIVE_MS ?? "").trim();
|
|
130
|
+
if (raw === "") return DEFAULT_SSE_KEEPALIVE_MS;
|
|
131
|
+
const n = Number(raw);
|
|
132
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_SSE_KEEPALIVE_MS;
|
|
133
|
+
}
|
|
134
|
+
function armSseKeepalive(res, intervalMs = sseKeepaliveMs()) {
|
|
135
|
+
if (!(intervalMs > 0)) return;
|
|
136
|
+
let isSse = false;
|
|
137
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
138
|
+
res.writeHead = (...args) => {
|
|
139
|
+
const hdrs = typeof args[1] === "object" && args[1] !== null ? args[1] : args[2];
|
|
140
|
+
if (hdrs && !Array.isArray(hdrs)) {
|
|
141
|
+
for (const [k, v] of Object.entries(hdrs)) {
|
|
142
|
+
if (k.toLowerCase() === "content-type" && String(v).includes("text/event-stream")) isSse = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return origWriteHead(...args);
|
|
146
|
+
};
|
|
147
|
+
const timer = setInterval(() => {
|
|
148
|
+
if (!isSse || res.writableEnded || res.destroyed) return;
|
|
149
|
+
try {
|
|
150
|
+
res.write(KEEPALIVE_FRAME);
|
|
151
|
+
} catch {
|
|
152
|
+
}
|
|
153
|
+
}, intervalMs);
|
|
154
|
+
timer.unref?.();
|
|
155
|
+
const stop = () => clearInterval(timer);
|
|
156
|
+
res.once("close", stop);
|
|
157
|
+
res.once("finish", stop);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ../../src/uploads.ts
|
|
161
|
+
import * as fs from "node:fs";
|
|
162
|
+
import { createHash } from "node:crypto";
|
|
163
|
+
import { basename, join } from "node:path";
|
|
164
|
+
var uploadsDirFor = (id) => join(id.dir, "uploads");
|
|
165
|
+
var UPLOAD_TTL_MS = Number(process.env.OURS_UPLOAD_TTL_MS) > 0 ? Number(process.env.OURS_UPLOAD_TTL_MS) : 60 * 60 * 1e3;
|
|
166
|
+
var UPLOAD_ID_RE = /^[0-9a-f]{32}$/;
|
|
167
|
+
var newUploadId = () => createHash("sha256").update(`${process.pid}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 32);
|
|
168
|
+
var isUploadId = (s) => UPLOAD_ID_RE.test(s);
|
|
169
|
+
var bytesPath = (id, uploadId) => join(uploadsDirFor(id), uploadId);
|
|
170
|
+
var metaPath = (id, uploadId) => join(uploadsDirFor(id), `${uploadId}.json`);
|
|
171
|
+
function sanitizeUploadFilename(raw) {
|
|
172
|
+
const flat = raw.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\\/g, "/");
|
|
173
|
+
const base = basename(flat);
|
|
174
|
+
const clean = base === "" || base === "." || base === ".." ? "" : base;
|
|
175
|
+
return clean.slice(0, 255);
|
|
176
|
+
}
|
|
177
|
+
var UploadRefused = class extends Error {
|
|
178
|
+
constructor(status, message) {
|
|
179
|
+
super(message);
|
|
180
|
+
this.status = status;
|
|
181
|
+
this.name = "UploadRefused";
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
async function stageUpload(id, body, meta) {
|
|
185
|
+
const filename = sanitizeUploadFilename(meta.filename);
|
|
186
|
+
if (filename === "") throw new UploadRefused(400, "x-ours-filename is required and must be a plain file name");
|
|
187
|
+
if (meta.declaredBytes === null) throw new UploadRefused(411, "Content-Length is required");
|
|
188
|
+
if (meta.declaredBytes >= meta.cap) {
|
|
189
|
+
throw new UploadRefused(
|
|
190
|
+
400,
|
|
191
|
+
`upload is ${meta.declaredBytes} bytes, at or over the transport's ${meta.cap}-byte envelope budget \u2014 it could not be sent even with no filename or MIME, so it is refused before it is stored`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const uploadId = newUploadId();
|
|
195
|
+
fs.mkdirSync(uploadsDirFor(id), { recursive: true, mode: 448 });
|
|
196
|
+
const dest = bytesPath(id, uploadId);
|
|
197
|
+
const hash = createHash("sha256");
|
|
198
|
+
let size = 0;
|
|
199
|
+
const out = fs.createWriteStream(dest, { mode: 384 });
|
|
200
|
+
try {
|
|
201
|
+
await new Promise((resolve2, reject) => {
|
|
202
|
+
const fail = (err) => {
|
|
203
|
+
body.unpipe?.(out);
|
|
204
|
+
out.destroy();
|
|
205
|
+
reject(err);
|
|
206
|
+
};
|
|
207
|
+
body.on("data", (chunk) => {
|
|
208
|
+
size += chunk.length;
|
|
209
|
+
if (size > meta.declaredBytes) {
|
|
210
|
+
fail(new UploadRefused(400, "request body is longer than its Content-Length"));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
hash.update(chunk);
|
|
214
|
+
});
|
|
215
|
+
body.on("error", fail);
|
|
216
|
+
out.on("error", fail);
|
|
217
|
+
out.on("finish", () => resolve2());
|
|
218
|
+
body.pipe(out);
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
try {
|
|
222
|
+
fs.rmSync(dest, { force: true });
|
|
223
|
+
} catch {
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
const staged = {
|
|
228
|
+
upload_id: uploadId,
|
|
229
|
+
filename,
|
|
230
|
+
mime: meta.mime,
|
|
231
|
+
size,
|
|
232
|
+
sha256: hash.digest("hex"),
|
|
233
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
234
|
+
};
|
|
235
|
+
fs.writeFileSync(metaPath(id, uploadId), JSON.stringify(staged), { mode: 384 });
|
|
236
|
+
return staged;
|
|
237
|
+
}
|
|
238
|
+
function consumeStagedUpload(id, uploadId) {
|
|
239
|
+
if (!isUploadId(uploadId)) return null;
|
|
240
|
+
const bytes = bytesPath(id, uploadId);
|
|
241
|
+
let buf;
|
|
242
|
+
let meta;
|
|
243
|
+
try {
|
|
244
|
+
buf = fs.readFileSync(bytes);
|
|
245
|
+
meta = JSON.parse(fs.readFileSync(metaPath(id, uploadId), "utf8"));
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
} finally {
|
|
249
|
+
try {
|
|
250
|
+
fs.rmSync(bytes, { force: true });
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
fs.rmSync(metaPath(id, uploadId), { force: true });
|
|
255
|
+
} catch {
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { meta, buf };
|
|
259
|
+
}
|
|
260
|
+
function sweepStaleUploads() {
|
|
261
|
+
let dropped = 0;
|
|
262
|
+
const cutoff = Date.now() - UPLOAD_TTL_MS;
|
|
263
|
+
for (const id of identities.values()) {
|
|
264
|
+
const dir = uploadsDirFor(id);
|
|
265
|
+
let entries;
|
|
266
|
+
try {
|
|
267
|
+
entries = fs.readdirSync(dir);
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const name of entries) {
|
|
272
|
+
const p = join(dir, name);
|
|
273
|
+
try {
|
|
274
|
+
if (fs.statSync(p).mtimeMs >= cutoff) continue;
|
|
275
|
+
fs.rmSync(p, { force: true });
|
|
276
|
+
dropped++;
|
|
277
|
+
} catch {
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (dropped > 0) log(`[${id.name}] swept ${dropped} stale staged upload file(s)`);
|
|
281
|
+
}
|
|
282
|
+
return dropped;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ../../src/messaging/file-limit.ts
|
|
286
|
+
import { object_to_adapt_value } from "@adapt-toolkit/sdk/wrapper";
|
|
287
|
+
var WIRE_ID_PLACEHOLDER = "w".repeat(64);
|
|
288
|
+
var WIRE_VERSION = 9;
|
|
289
|
+
var PINNED_FILE_ENVELOPE_CAP = 2097152;
|
|
290
|
+
function resolveFileEnvelopeCap() {
|
|
291
|
+
return PINNED_FILE_ENVELOPE_CAP;
|
|
292
|
+
}
|
|
293
|
+
var byteLen = (s) => Buffer.byteLength(s, "utf8");
|
|
294
|
+
function envelopeSkipMargin(i) {
|
|
295
|
+
const replyBytes = i.replyTo ? byteLen(i.replyTo.wire_id) + 32 : 0;
|
|
296
|
+
return 2048 + 2 * (byteLen(i.filename) + byteLen(i.mime) + replyBytes);
|
|
297
|
+
}
|
|
298
|
+
function needsEnvelopeMeasurement(i) {
|
|
299
|
+
return i.bytes + envelopeSkipMargin(i) > i.cap;
|
|
300
|
+
}
|
|
301
|
+
function measureFileEnvelope(i) {
|
|
302
|
+
const data = i.packet.NewBinaryFromBuffer(i.payload).Attach(i.lifetime);
|
|
303
|
+
const envelope = object_to_adapt_value({
|
|
304
|
+
filename: i.filename,
|
|
305
|
+
mime: i.mime,
|
|
306
|
+
data,
|
|
307
|
+
wire_id: WIRE_ID_PLACEHOLDER,
|
|
308
|
+
...i.replyTo ? { reply_to: i.replyTo } : {},
|
|
309
|
+
pv: WIRE_VERSION
|
|
310
|
+
});
|
|
311
|
+
envelope.Attach(i.lifetime);
|
|
312
|
+
return envelope.Serialize().length;
|
|
313
|
+
}
|
|
314
|
+
var MAX_SOLVE_ROUNDS = 8;
|
|
315
|
+
function maxFilePayloadBytes(i) {
|
|
316
|
+
const measure = (n2) => measureFileEnvelope({ ...i, payload: i.payload.subarray(0, n2) });
|
|
317
|
+
let n = i.payload.length;
|
|
318
|
+
for (let round = 0; round < MAX_SOLVE_ROUNDS; round++) {
|
|
319
|
+
const s = measure(n);
|
|
320
|
+
if (s <= i.cap) break;
|
|
321
|
+
const next = n - (s - i.cap);
|
|
322
|
+
if (next <= 0) return 0;
|
|
323
|
+
n = next;
|
|
324
|
+
}
|
|
325
|
+
if (measure(n) > i.cap) return 0;
|
|
326
|
+
while (n < i.payload.length && measure(n + 1) <= i.cap) n++;
|
|
327
|
+
return n;
|
|
328
|
+
}
|
|
329
|
+
function checkFileEnvelope(i) {
|
|
330
|
+
const cap = i.cap ?? resolveFileEnvelopeCap();
|
|
331
|
+
const bytes = i.payload.length;
|
|
332
|
+
if (!needsEnvelopeMeasurement({
|
|
333
|
+
bytes,
|
|
334
|
+
filename: i.filename,
|
|
335
|
+
mime: i.mime,
|
|
336
|
+
replyTo: i.replyTo,
|
|
337
|
+
cap
|
|
338
|
+
})) {
|
|
339
|
+
return { ok: true };
|
|
340
|
+
}
|
|
341
|
+
const envelopeBytes = measureFileEnvelope(i);
|
|
342
|
+
if (envelopeBytes <= cap) return { ok: true };
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
bytes,
|
|
346
|
+
cap,
|
|
347
|
+
envelopeBytes,
|
|
348
|
+
maxPayloadBytes: maxFilePayloadBytes({ ...i, cap })
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ../../src/errors.ts
|
|
353
|
+
var OursError = class extends Error {
|
|
354
|
+
// `details` is PURELY ADDITIVE and optional: every existing construction and
|
|
355
|
+
// every `e.code` check keeps working untouched. It exists because a refusal a
|
|
356
|
+
// caller is expected to ACT on (trim N bytes, shorten the filename) cannot be
|
|
357
|
+
// delivered as prose alone — see errFileTooLarge.
|
|
358
|
+
constructor(code, message, details) {
|
|
359
|
+
super(message);
|
|
360
|
+
this.code = code;
|
|
361
|
+
this.details = details;
|
|
362
|
+
this.name = "OursError";
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
var errNameTaken = (tool, name) => new OursError("NAME_TAKEN", `${tool} failed: an identity named "${name}" already exists.`);
|
|
366
|
+
var errNoLeaseHeaders = () => new OursError(
|
|
367
|
+
"NO_LEASE_HEADERS",
|
|
368
|
+
"create_temporary_identity failed: this client is not connected through the ours connector (no lease token / client pid headers), so there is no session lease to own the identity. Launch ours via the connector (`ours-mcp proxy`)."
|
|
369
|
+
);
|
|
370
|
+
var errRandomNameExhausted = () => new OursError(
|
|
371
|
+
"RANDOM_NAME_EXHAUSTED",
|
|
372
|
+
"create_temporary_identity failed: could not pick a free random name after 5 attempts \u2014 retry."
|
|
373
|
+
);
|
|
374
|
+
var errRootExists = (rootName2) => new OursError(
|
|
375
|
+
"ROOT_EXISTS",
|
|
376
|
+
`create_root_identity failed: a root identity already exists ("${rootName2}") \u2014 one root per host. Nothing to do.`
|
|
377
|
+
);
|
|
378
|
+
var errNotBoundNoName = () => new OursError("NOT_BOUND_NO_NAME", "close_temporary_identity failed: no identity is bound to this session and no name was given.");
|
|
379
|
+
var errNotTemporary = (name) => new OursError(
|
|
380
|
+
"NOT_TEMPORARY",
|
|
381
|
+
`close_temporary_identity failed: "${name}" is a permanent identity. Use remove_identity if you really mean to delete it.`
|
|
382
|
+
);
|
|
383
|
+
var errTempOwnedElsewhereClose = (name, pid) => new OursError(
|
|
384
|
+
"TEMP_OWNED_ELSEWHERE",
|
|
385
|
+
`close_temporary_identity failed: "${name}" is owned by another LIVE session (owner pid ${pid}) \u2014 one session cannot delete another's temporary identity.`
|
|
386
|
+
);
|
|
387
|
+
var errTempClosedWithError = (name, deleteError, remote) => new OursError("DELETE_PARTIAL", `Temporary identity "${name}" closed with an error: ${deleteError}. ${remote}`);
|
|
388
|
+
var errPathNotAbsolute = (path) => new OursError("PATH_NOT_ABSOLUTE", `define_local_identity_file failed: path must be absolute (got "${path}").`);
|
|
389
|
+
var errNoSuchIdentity = (name) => new OursError("NO_SUCH_IDENTITY", `choose_identity failed: no identity named "${name}". Create it with create_identity.`);
|
|
390
|
+
var errNoLeaseToken = () => new OursError(
|
|
391
|
+
"NO_LEASE_TOKEN",
|
|
392
|
+
"choose_identity failed: this client is not connected through the ours connector (no lease token header). Launch ours via the connector (`ours-mcp proxy`)."
|
|
393
|
+
);
|
|
394
|
+
var errTempClosingChoose = (name) => new OursError("TEMP_CLOSING", `choose_identity failed: temporary identity "${name}" is closing \u2014 its state is being deleted.`);
|
|
395
|
+
var errTempOwnedLive = (name, pid) => new OursError(
|
|
396
|
+
"TEMP_OWNED_ELSEWHERE",
|
|
397
|
+
`choose_identity failed: "${name}" is a TEMPORARY identity owned by another live session (owner pid ${pid}). Ownership is exclusive and cannot be overridden \u2014 not even with force. Create your own with create_temporary_identity.`
|
|
398
|
+
);
|
|
399
|
+
var errTempStale = (name, pid) => new OursError(
|
|
400
|
+
"TEMP_STALE",
|
|
401
|
+
`choose_identity failed: temporary identity "${name}" is STALE \u2014 its owning session (pid ${pid}) is gone and it is pending automatic cleanup. It cannot be adopted by another session; use close_temporary_identity to reclaim (clean it up) now.`
|
|
402
|
+
);
|
|
403
|
+
var errBoundElsewhere = (name) => new OursError(
|
|
404
|
+
"BOUND_ELSEWHERE",
|
|
405
|
+
`choose_identity declined: "${name}" is currently bound to another live session. Do not retry with force=true on your own \u2014 tell the user it is in use elsewhere and ask whether to forcibly rebind it here; only retry with force=true after they explicitly confirm.`
|
|
406
|
+
);
|
|
407
|
+
var errTempOwnedElsewhereRemove = (name, pid) => new OursError(
|
|
408
|
+
"TEMP_OWNED_ELSEWHERE",
|
|
409
|
+
`remove_identity failed: "${name}" is a TEMPORARY identity owned by another LIVE session (owner pid ${pid}) \u2014 one session cannot delete another's temporary identity.`
|
|
410
|
+
);
|
|
411
|
+
var errIdentityClosedWithError = (name, deleteError, remote) => new OursError("DELETE_PARTIAL", `Temporary identity "${name}" closed with an error: ${deleteError}.${remote}`);
|
|
412
|
+
var errRootHasRoles = (name, roles) => new OursError(
|
|
413
|
+
"ROOT_HAS_ROLES",
|
|
414
|
+
`remove_identity failed: "${name}" is the root identity and still has ${roles.length} role(s): ${roles.join(", ")}. Remove the roles first.`
|
|
415
|
+
);
|
|
416
|
+
var errIdentityPartiallyRemoved = (name, fail) => new OursError("DELETE_PARTIAL", `Identity "${name}" removed from memory, but ${fail}`);
|
|
417
|
+
var errPublicInviteNamed = () => new OursError(
|
|
418
|
+
"PUBLIC_INVITE_NAMED",
|
|
419
|
+
"generate_invite failed: a public invite cannot pre-assign a contact name \u2014 every redeemer would be registered under it. Omit `name` for a public invite."
|
|
420
|
+
);
|
|
421
|
+
var errNoPolicyArgs = () => new OursError("NO_POLICY_ARGS", "set_local_book_policy: pass expose and/or auto_accept.");
|
|
422
|
+
var errSendFileArgs = () => new OursError("SEND_ARGS", "send_file: provide exactly one of `path` or `data_base64`.");
|
|
423
|
+
var errSendFileFilenameRequired = () => new OursError("SEND_ARGS", "send_file: `filename` is required with `data_base64`.");
|
|
424
|
+
var errFileUnreadable = (detail) => new OursError("FILE_UNREADABLE", `send_file: cannot read file: ${detail}`);
|
|
425
|
+
var errSendFileUploadArgs = () => new OursError("SEND_ARGS", "send_file: provide exactly one of `path`, `data_base64` or `upload_id`.");
|
|
426
|
+
var errUploadNotFound = (uploadId) => new OursError(
|
|
427
|
+
"FILE_UNREADABLE",
|
|
428
|
+
`send_file: no staged upload "${uploadId}" for the bound identity. Upload the bytes with POST /files/upload first. A staged upload is consumed by the first send that names it \u2014 including a send that failed \u2014 so a retry needs a fresh upload.`
|
|
429
|
+
);
|
|
430
|
+
var errFileTooLarge = (i) => new OursError(
|
|
431
|
+
"FILE_TOO_LARGE",
|
|
432
|
+
`send_file: "${i.filename}" is ${i.bytes} bytes. This send's envelope would serialize to ${i.envelopeBytes} bytes, over the transport's ${i.limit}-byte budget \u2014 the largest payload it can carry with this filename, MIME and reply reference is ${i.maxPayloadBytes} bytes. Nothing was sent.`,
|
|
433
|
+
i
|
|
434
|
+
);
|
|
435
|
+
var errInvalidSelection = (cap) => new OursError("INVALID_SELECTION", `get_files failed: wire_ids must contain 1-${cap} items.`);
|
|
436
|
+
var errMalformedId = () => new OursError("MALFORMED_ID", "get_files failed: every selected wire_id must be exactly 64 hexadecimal characters.");
|
|
437
|
+
var errDuplicateId = () => new OursError("DUPLICATE_ID", "get_files failed: wire_ids must not contain duplicates.");
|
|
438
|
+
var errUnknownOrStaleId = () => new OursError(
|
|
439
|
+
"UNKNOWN_OR_STALE_ID",
|
|
440
|
+
"get_files failed: one or more selected wire_ids is unknown, stale, or no longer unread; no files were retrieved."
|
|
441
|
+
);
|
|
442
|
+
var errTool = (tool, detail, code = "TX_FAILED") => new OursError(code, `${tool} failed: ${detail}`);
|
|
443
|
+
|
|
444
|
+
// ../../src/api/identity.ts
|
|
445
|
+
import { randomBytes } from "node:crypto";
|
|
446
|
+
import { isAbsolute } from "node:path";
|
|
447
|
+
function describeOrNull(id) {
|
|
448
|
+
try {
|
|
449
|
+
return describeIdentity(id);
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function identityInfo(id) {
|
|
455
|
+
const d = describeOrNull(id);
|
|
456
|
+
return {
|
|
457
|
+
name: id.name,
|
|
458
|
+
cid: id.cid,
|
|
459
|
+
bio: d?.bio ?? "",
|
|
460
|
+
persona: d?.persona ?? "",
|
|
461
|
+
roleId: d?.roleId ?? "",
|
|
462
|
+
rootCid: d?.rootCid ?? "",
|
|
463
|
+
rootName: d?.rootName ?? ""
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
async function setBio(id, bio) {
|
|
467
|
+
if (!bio) return;
|
|
468
|
+
await withScopeAsync(async (lt) => {
|
|
469
|
+
await mutatingTx(id, "::a2a_messaging::set_my_bio", { bio }, lt);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
function requireBound(ctx) {
|
|
473
|
+
const b = resolveBound(ctx.sessionId());
|
|
474
|
+
if (!("error" in b)) return b.id;
|
|
475
|
+
throw new OursError(b.code, b.error);
|
|
476
|
+
}
|
|
477
|
+
async function createIdentity(ctx, a) {
|
|
478
|
+
const { name, bio, exposeLocal, localAutoAccept } = a;
|
|
479
|
+
const bad = validateName(name);
|
|
480
|
+
if (bad) throw errTool("create_identity", bad, "NAME_INVALID");
|
|
481
|
+
if (identities.has(name)) throw errNameTaken("create_identity", name);
|
|
482
|
+
try {
|
|
483
|
+
const id = await provisionIdentity(name, { exposeLocal, localAutoAccept });
|
|
484
|
+
await setBio(id, bio);
|
|
485
|
+
let hierarchy;
|
|
486
|
+
let underRoot = null;
|
|
487
|
+
let adopted = [];
|
|
488
|
+
let failed = [];
|
|
489
|
+
const root = rootName ? identities.get(rootName) : void 0;
|
|
490
|
+
if (root) {
|
|
491
|
+
await delegateRole(root, id);
|
|
492
|
+
hierarchy = "role";
|
|
493
|
+
underRoot = root.name;
|
|
494
|
+
if (envelopeDispatch()) void clusterSweep(root);
|
|
495
|
+
} else {
|
|
496
|
+
hierarchy = "root";
|
|
497
|
+
const res = await establishRoot(id);
|
|
498
|
+
adopted = res.adopted;
|
|
499
|
+
failed = res.failed;
|
|
500
|
+
}
|
|
501
|
+
bindSession(ctx.sessionId(), name);
|
|
502
|
+
return { info: identityInfo(id), hierarchy, underRoot, adopted, failed, exposedLocal: exposeLocal, localAutoAccept };
|
|
503
|
+
} catch (err) {
|
|
504
|
+
throw errTool("create_identity", String(err), "PROVISION_FAILED");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function createTemporaryIdentity(ctx, a) {
|
|
508
|
+
const { bio, exposeLocal, localAutoAccept } = a;
|
|
509
|
+
const token = ctx.leaseToken();
|
|
510
|
+
const pid = ctx.clientPid();
|
|
511
|
+
if (!token || !pid) throw errNoLeaseHeaders();
|
|
512
|
+
let chosen = a.name;
|
|
513
|
+
if (chosen !== void 0) {
|
|
514
|
+
const bad = validateName(chosen);
|
|
515
|
+
if (bad) throw errTool("create_temporary_identity", bad, "NAME_INVALID");
|
|
516
|
+
if (identities.has(chosen) || reservedNames.has(chosen)) {
|
|
517
|
+
throw errNameTaken("create_temporary_identity", chosen);
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
for (let i = 0; i < 5 && chosen === void 0; i++) {
|
|
521
|
+
const cand = `tmp-${randomBytes(5).toString("hex")}`;
|
|
522
|
+
if (!identities.has(cand) && !reservedNames.has(cand)) chosen = cand;
|
|
523
|
+
}
|
|
524
|
+
if (chosen === void 0) throw errRandomNameExhausted();
|
|
525
|
+
}
|
|
526
|
+
const name = chosen;
|
|
527
|
+
try {
|
|
528
|
+
const id = await provisionIdentity(name, {
|
|
529
|
+
exposeLocal,
|
|
530
|
+
localAutoAccept,
|
|
531
|
+
temp: { tokenHash: hashLeaseToken(token), pid }
|
|
532
|
+
});
|
|
533
|
+
await setBio(id, bio);
|
|
534
|
+
bindSession(ctx.sessionId(), name);
|
|
535
|
+
return { info: identityInfo(id), ownerPid: pid, exposedLocal: exposeLocal, localAutoAccept };
|
|
536
|
+
} catch (err) {
|
|
537
|
+
throw errTool("create_temporary_identity", String(err), "PROVISION_FAILED");
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async function closeTemporaryIdentityOp(ctx, a) {
|
|
541
|
+
const token = ctx.leaseToken();
|
|
542
|
+
let id;
|
|
543
|
+
if (a.name !== void 0) {
|
|
544
|
+
id = identities.get(a.name);
|
|
545
|
+
if (!id) return { name: a.name, result: null };
|
|
546
|
+
} else {
|
|
547
|
+
const lease = token ? leaseByToken(token) : void 0;
|
|
548
|
+
id = lease ? identities.get(lease.identity) : void 0;
|
|
549
|
+
if (!id) throw errNotBoundNoName();
|
|
550
|
+
}
|
|
551
|
+
if (!id.temp) throw errNotTemporary(id.name);
|
|
552
|
+
const owner = id.temp.owner;
|
|
553
|
+
const isOwner = token !== void 0 && owner.tokenHash === hashLeaseToken(token);
|
|
554
|
+
if (!isOwner && !id.temp.closing && pidAlive(owner.pid)) {
|
|
555
|
+
throw errTempOwnedElsewhereClose(id.name, owner.pid);
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
const res = await closeTemporaryIdentity(
|
|
559
|
+
id,
|
|
560
|
+
isOwner ? "explicit close by owner" : "stale reclaim via close_temporary_identity"
|
|
561
|
+
);
|
|
562
|
+
if (res.deleteError) {
|
|
563
|
+
const remote = res.attempted === 0 ? "It had no contacts, so no remove-me notices were needed." : `Remove-me notices: ${res.notified}/${res.attempted} queued, ${res.failed} not sent (best effort \u2014 delivery and remote contact deletion are not guaranteed).`;
|
|
564
|
+
throw errTempClosedWithError(id.name, res.deleteError, remote);
|
|
565
|
+
}
|
|
566
|
+
return { name: id.name, result: res };
|
|
567
|
+
} catch (err) {
|
|
568
|
+
if (err instanceof OursError) throw err;
|
|
569
|
+
throw errTool("close_temporary_identity", String(err), "CLOSE_FAILED");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function createRootIdentity(ctx, a) {
|
|
573
|
+
const { name, bio, exposeLocal, localAutoAccept, skipIfRootExists } = a;
|
|
574
|
+
const bad = validateName(name);
|
|
575
|
+
if (bad) throw errTool("create_root_identity", bad, "NAME_INVALID");
|
|
576
|
+
const existingRootName = rootName;
|
|
577
|
+
if (skipIfRootExists && existingRootName && identities.has(existingRootName)) {
|
|
578
|
+
throw errRootExists(existingRootName);
|
|
579
|
+
}
|
|
580
|
+
if (identities.has(name)) throw errNameTaken("create_root_identity", name);
|
|
581
|
+
try {
|
|
582
|
+
const id = await provisionIdentity(name, { exposeLocal, localAutoAccept });
|
|
583
|
+
await setBio(id, bio);
|
|
584
|
+
const existingRoot = rootName ? identities.get(rootName) : void 0;
|
|
585
|
+
if (existingRoot && existingRoot.name !== name) {
|
|
586
|
+
await delegateRole(existingRoot, id);
|
|
587
|
+
bindSession(ctx.sessionId(), name);
|
|
588
|
+
return {
|
|
589
|
+
// existingRoot.name is the in-memory root name (index.ts:3806).
|
|
590
|
+
info: identityInfo(id),
|
|
591
|
+
hierarchy: "role",
|
|
592
|
+
underRoot: existingRoot.name,
|
|
593
|
+
adopted: [],
|
|
594
|
+
failed: [],
|
|
595
|
+
exposedLocal: exposeLocal,
|
|
596
|
+
localAutoAccept
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const { adopted, failed } = await establishRoot(id);
|
|
600
|
+
bindSession(ctx.sessionId(), name);
|
|
601
|
+
return {
|
|
602
|
+
info: identityInfo(id),
|
|
603
|
+
hierarchy: "root",
|
|
604
|
+
underRoot: null,
|
|
605
|
+
adopted,
|
|
606
|
+
failed,
|
|
607
|
+
exposedLocal: exposeLocal,
|
|
608
|
+
localAutoAccept
|
|
609
|
+
};
|
|
610
|
+
} catch (err) {
|
|
611
|
+
throw errTool("create_root_identity", String(err), "PROVISION_FAILED");
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
async function defineLocalIdentityFile(a) {
|
|
615
|
+
if (!isAbsolute(a.path)) throw errPathNotAbsolute(a.path);
|
|
616
|
+
const opts = { name: a.name, force: a.force, exposeLocal: a.exposeLocal, localAutoAccept: a.localAutoAccept };
|
|
617
|
+
try {
|
|
618
|
+
const written = writeIdentityFile(a.path, opts, a.overwrite);
|
|
619
|
+
return { written, json: buildIdentityFile(opts) };
|
|
620
|
+
} catch (err) {
|
|
621
|
+
throw errTool("define_local_identity_file", String(err), "WRITE_FAILED");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function chooseIdentity(ctx, a) {
|
|
625
|
+
const { name, force } = a;
|
|
626
|
+
if (!identities.has(name)) throw errNoSuchIdentity(name);
|
|
627
|
+
const sid = ctx.sessionId();
|
|
628
|
+
const token = ctx.leaseToken();
|
|
629
|
+
if (!token) throw errNoLeaseToken();
|
|
630
|
+
const target = identities.get(name);
|
|
631
|
+
if (target.temp) {
|
|
632
|
+
if (target.temp.closing) throw errTempClosingChoose(name);
|
|
633
|
+
const owner = target.temp.owner;
|
|
634
|
+
if (owner.tokenHash !== hashLeaseToken(token)) {
|
|
635
|
+
if (pidAlive(owner.pid)) throw errTempOwnedLive(name, owner.pid);
|
|
636
|
+
throw errTempStale(name, owner.pid);
|
|
637
|
+
}
|
|
638
|
+
const hdrPid = ctx.clientPid();
|
|
639
|
+
if (hdrPid && hdrPid !== owner.pid) {
|
|
640
|
+
owner.pid = hdrPid;
|
|
641
|
+
try {
|
|
642
|
+
writeTempMetaFile(target.dir, target.temp);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
log(`[${name}] temp marker refresh failed:`, String(err));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const existing = leases.get(name);
|
|
649
|
+
if (existing && existing.token !== token) {
|
|
650
|
+
if (!pidAlive(existing.pid)) {
|
|
651
|
+
log(`auto-reclaiming "${name}" from dead client pid ${existing.pid}`);
|
|
652
|
+
leases.delete(name);
|
|
653
|
+
} else if (!force) {
|
|
654
|
+
throw errBoundElsewhere(name);
|
|
655
|
+
} else {
|
|
656
|
+
tombstones.add(existing.token);
|
|
657
|
+
leases.delete(name);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const prev = [...leases.values()].find((l) => l.token === token && l.identity !== name)?.identity;
|
|
661
|
+
const switchedFrom = prev && identities.has(prev) ? prev : null;
|
|
662
|
+
bindSession(sid, name);
|
|
663
|
+
return { name, cid: identities.get(name).cid, switchedFrom };
|
|
664
|
+
}
|
|
665
|
+
async function listIdentities(ctx) {
|
|
666
|
+
if (identities.size === 0) return [];
|
|
667
|
+
const myToken = ctx.leaseToken();
|
|
668
|
+
const sessionOf = (name) => {
|
|
669
|
+
const lease = leases.get(name);
|
|
670
|
+
if (!lease) return null;
|
|
671
|
+
if (lease.token === myToken) return "mine";
|
|
672
|
+
if (!pidAlive(lease.pid)) return null;
|
|
673
|
+
return "other-live";
|
|
674
|
+
};
|
|
675
|
+
const tempOf = (id) => {
|
|
676
|
+
if (!id.temp) return null;
|
|
677
|
+
const ownerPid = id.temp.owner.pid;
|
|
678
|
+
if (id.temp.closing) return { state: "closing", ownerPid };
|
|
679
|
+
if (myToken !== void 0 && id.temp.owner.tokenHash === hashLeaseToken(myToken)) {
|
|
680
|
+
return { state: "mine", ownerPid };
|
|
681
|
+
}
|
|
682
|
+
return pidAlive(ownerPid) ? { state: "other-live", ownerPid } : { state: "stale", ownerPid };
|
|
683
|
+
};
|
|
684
|
+
const row = (id, kind) => ({
|
|
685
|
+
name: id.name,
|
|
686
|
+
cid: id.cid,
|
|
687
|
+
kind,
|
|
688
|
+
temp: tempOf(id),
|
|
689
|
+
session: sessionOf(id.name)
|
|
690
|
+
});
|
|
691
|
+
const root = rootName ? identities.get(rootName) : void 0;
|
|
692
|
+
const rows = [];
|
|
693
|
+
if (root) {
|
|
694
|
+
rows.push({ ...row(root, "root"), temp: null });
|
|
695
|
+
for (const id of identities.values()) {
|
|
696
|
+
if (id.name === root.name) continue;
|
|
697
|
+
if (describeIdentity(id).roleId !== "") rows.push(row(id, "role"));
|
|
698
|
+
}
|
|
699
|
+
for (const id of identities.values()) {
|
|
700
|
+
if (id.name === root.name || describeIdentity(id).roleId !== "") continue;
|
|
701
|
+
rows.push(row(id, "flat"));
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
for (const id of identities.values()) rows.push(row(id, "flat"));
|
|
705
|
+
}
|
|
706
|
+
return rows;
|
|
707
|
+
}
|
|
708
|
+
async function currentIdentity(ctx) {
|
|
709
|
+
const id = requireBound(ctx);
|
|
710
|
+
const d = describeOrNull(id);
|
|
711
|
+
return {
|
|
712
|
+
name: id.name,
|
|
713
|
+
cid: id.cid,
|
|
714
|
+
bio: d?.bio ?? "",
|
|
715
|
+
persona: d?.persona ?? "",
|
|
716
|
+
roleId: d?.roleId ?? "",
|
|
717
|
+
rootCid: d?.rootCid ?? "",
|
|
718
|
+
rootName: d?.rootName ?? "",
|
|
719
|
+
temporary: id.temp !== void 0,
|
|
720
|
+
isRoot: id.name === rootName,
|
|
721
|
+
described: d !== null
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
async function removeIdentity(ctx, a) {
|
|
725
|
+
const { name } = a;
|
|
726
|
+
const id = identities.get(name);
|
|
727
|
+
if (!id) throw errTool("remove_identity", `no identity named "${name}".`, "NO_SUCH_IDENTITY");
|
|
728
|
+
if (id.temp) {
|
|
729
|
+
const token = ctx.leaseToken();
|
|
730
|
+
const owner = id.temp.owner;
|
|
731
|
+
if ((token === void 0 || owner.tokenHash !== hashLeaseToken(token)) && !id.temp.closing && pidAlive(owner.pid)) {
|
|
732
|
+
throw errTempOwnedElsewhereRemove(name, owner.pid);
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
const res = await closeTemporaryIdentity(id, "remove_identity");
|
|
736
|
+
if (res.deleteError) {
|
|
737
|
+
const remote = res.attempted === 0 ? "" : ` Remove-me notices: ${res.notified}/${res.attempted} queued, ${res.failed} not sent (best effort).`;
|
|
738
|
+
throw errIdentityClosedWithError(name, res.deleteError, remote);
|
|
739
|
+
}
|
|
740
|
+
return { name, kind: "temporary", close: res };
|
|
741
|
+
} catch (err) {
|
|
742
|
+
if (err instanceof OursError) throw err;
|
|
743
|
+
throw errTool("remove_identity", String(err), "CLOSE_FAILED");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (name === rootName) {
|
|
747
|
+
const roles = [...identities.values()].filter(
|
|
748
|
+
(i) => i.name !== name && describeIdentity(i).rootCid === id.cid
|
|
749
|
+
);
|
|
750
|
+
if (roles.length > 0) throw errRootHasRoles(name, roles.map((r) => r.name));
|
|
751
|
+
}
|
|
752
|
+
const fail = deleteIdentityCompletely(id);
|
|
753
|
+
if (fail) throw errIdentityPartiallyRemoved(name, fail);
|
|
754
|
+
return { name, kind: "permanent" };
|
|
755
|
+
}
|
|
756
|
+
async function releaseLease(ctx) {
|
|
757
|
+
const token = ctx.leaseToken();
|
|
758
|
+
if (!token) throw errNoLeaseToken();
|
|
759
|
+
const released = [];
|
|
760
|
+
for (const [n, l] of [...leases]) {
|
|
761
|
+
if (l.token !== token) continue;
|
|
762
|
+
leases.delete(n);
|
|
763
|
+
released.push(n);
|
|
764
|
+
const rid = identities.get(n);
|
|
765
|
+
if (rid?.temp && rid.temp.owner.tokenHash === hashLeaseToken(token) && !rid.temp.closing) {
|
|
766
|
+
void closeTemporaryIdentity(rid, "owning session released its lease").catch(
|
|
767
|
+
(err) => log(`[${n}] close on lease release failed:`, String(err))
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
tombstones.delete(token);
|
|
772
|
+
persistBindings();
|
|
773
|
+
log(`lease released by token \u2026${token.slice(-6)}`);
|
|
774
|
+
return { released };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ../../src/api/contacts.ts
|
|
778
|
+
async function generateInvite(ctx, a = {}) {
|
|
779
|
+
const id = requireBound(ctx);
|
|
780
|
+
const { name, mode } = a;
|
|
781
|
+
if (mode === "public" && name) throw errPublicInviteNamed();
|
|
782
|
+
try {
|
|
783
|
+
return await withScopeAsync(async (lt) => {
|
|
784
|
+
const targ = {};
|
|
785
|
+
if (name) targ.name = name;
|
|
786
|
+
if (mode) targ.mode = mode;
|
|
787
|
+
const data = await mutatingTx(id, "::a2a_messaging::generate_invite", targ, lt);
|
|
788
|
+
return {
|
|
789
|
+
blob: encodeWireBin(Buffer.from(data.Reduce("invite").GetBinary())),
|
|
790
|
+
inviteId: data.Reduce("invite_id").Visualize(),
|
|
791
|
+
// The baseline never types this field — it only asks
|
|
792
|
+
// `res.mode === 'public'` (index.ts:4147) and treats everything else as
|
|
793
|
+
// one_time. So make the same judgement rather than ASSERTING the packet's
|
|
794
|
+
// Visualize() into the union: a cast would be a promise the packet does
|
|
795
|
+
// not make, and a third mode added packet-side would type as a lie.
|
|
796
|
+
mode: data.Reduce("mode").Visualize() === "public" ? "public" : "one_time"
|
|
797
|
+
};
|
|
798
|
+
});
|
|
799
|
+
} catch (e) {
|
|
800
|
+
throw errTool("generate_invite", String(e));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async function listInvites(ctx) {
|
|
804
|
+
const id = requireBound(ctx);
|
|
805
|
+
try {
|
|
806
|
+
return withScope((lt) => {
|
|
807
|
+
const v = readonlyTx(id, "::a2a_messaging::list_invites", lt);
|
|
808
|
+
const out = [];
|
|
809
|
+
if (v.IsNil()) return out;
|
|
810
|
+
for (const key of v.GetKeys()) {
|
|
811
|
+
const rec = v.Reduce(key);
|
|
812
|
+
if (rec.IsNil()) continue;
|
|
813
|
+
const assigned = rec.Reduce("assigned").Visualize();
|
|
814
|
+
const created = rec.Reduce("created").Visualize();
|
|
815
|
+
out.push({
|
|
816
|
+
invite_id: typeof key === "string" ? key : key.Visualize(),
|
|
817
|
+
mode: rec.Reduce("mode").Visualize(),
|
|
818
|
+
assigned: assigned === "%%NIL" ? "" : assigned,
|
|
819
|
+
created: created === "%%NIL" ? "" : created
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return out;
|
|
823
|
+
});
|
|
824
|
+
} catch (e) {
|
|
825
|
+
throw errTool("list_invites", String(e));
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async function revokeInvite(ctx, a) {
|
|
829
|
+
const id = requireBound(ctx);
|
|
830
|
+
const { invite_id } = a;
|
|
831
|
+
try {
|
|
832
|
+
return await withScopeAsync(async (lt) => {
|
|
833
|
+
const data = await mutatingTx(id, "::a2a_messaging::revoke_invite", { invite_id }, lt);
|
|
834
|
+
const wp = data.Reduce("was_public");
|
|
835
|
+
return {
|
|
836
|
+
revoked: data.Reduce("revoked").GetBoolean(),
|
|
837
|
+
wasPublic: wp.IsNil() ? false : wp.GetBoolean()
|
|
838
|
+
};
|
|
839
|
+
});
|
|
840
|
+
} catch (e) {
|
|
841
|
+
throw errTool("revoke_invite", String(e));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async function addContact(ctx, a) {
|
|
845
|
+
const id = requireBound(ctx);
|
|
846
|
+
const { invite, name } = a;
|
|
847
|
+
let buf;
|
|
848
|
+
try {
|
|
849
|
+
buf = decodeWireBin(invite);
|
|
850
|
+
if (buf.length === 0) throw new Error("the invite blob is empty");
|
|
851
|
+
} catch (e) {
|
|
852
|
+
throw errTool(
|
|
853
|
+
"add_contact",
|
|
854
|
+
e instanceof Error ? e.message : "the invite blob is not valid.",
|
|
855
|
+
"INVITE_MALFORMED"
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
return await withScopeAsync(async (lt) => {
|
|
860
|
+
const blobValue = id.pw.packet.NewBinaryFromBuffer(buf).Attach(lt);
|
|
861
|
+
const targ = { invite: blobValue };
|
|
862
|
+
if (name) targ.name = name;
|
|
863
|
+
const data = await mutatingTx(id, "::a2a_messaging::add_contact", targ, lt);
|
|
864
|
+
const cid = data.Reduce("container_id").Visualize();
|
|
865
|
+
const pending = data.Reduce("pending").Visualize();
|
|
866
|
+
const inviterName = data.Reduce("inviter_name").Visualize();
|
|
867
|
+
const nil = (s) => !s || s === "%%NIL";
|
|
868
|
+
const display = !nil(pending) ? pending : !nil(inviterName) ? inviterName : cid;
|
|
869
|
+
scheduleCapabilityReconcile(id);
|
|
870
|
+
return { display, cid };
|
|
871
|
+
});
|
|
872
|
+
} catch (e) {
|
|
873
|
+
throw errTool("add_contact", String(e));
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async function listContacts(ctx) {
|
|
877
|
+
const id = requireBound(ctx);
|
|
878
|
+
try {
|
|
879
|
+
return withScope((lt) => ({
|
|
880
|
+
contacts: renderContacts(readonlyTx(id, "::a2a_messaging::list_contacts", lt)),
|
|
881
|
+
pending: renderPending(readonlyTx(id, "::actor::list_pending_introductions", lt)),
|
|
882
|
+
roots: renderContactRoots(readonlyTx(id, "::a2a_messaging::list_contact_roots", lt)),
|
|
883
|
+
degraded: renderDegraded(readonlyTx(id, "::a2a_messaging::list_degraded_contacts", lt)),
|
|
884
|
+
renames: renderImportRenames(readonlyTx(id, "::a2a_messaging::list_import_renames", lt))
|
|
885
|
+
}));
|
|
886
|
+
} catch (e) {
|
|
887
|
+
throw errTool("list_contacts", String(e));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
async function listLocalContactBook(ctx) {
|
|
891
|
+
const entries = Object.values(readBook());
|
|
892
|
+
const myToken = ctx.leaseToken();
|
|
893
|
+
const mine = myToken ? leaseByToken(myToken)?.identity : void 0;
|
|
894
|
+
return entries.map((e) => ({
|
|
895
|
+
name: e.name,
|
|
896
|
+
container_id: e.container_id,
|
|
897
|
+
published_at: e.published_at,
|
|
898
|
+
isMine: e.name === mine
|
|
899
|
+
}));
|
|
900
|
+
}
|
|
901
|
+
async function setLocalBookPolicy(ctx, a) {
|
|
902
|
+
const id = requireBound(ctx);
|
|
903
|
+
const { expose, auto_accept } = a;
|
|
904
|
+
if (expose === void 0 && auto_accept === void 0) throw errNoPolicyArgs();
|
|
905
|
+
const done = [];
|
|
906
|
+
try {
|
|
907
|
+
if (auto_accept !== void 0) {
|
|
908
|
+
await withScopeAsync(async (lt) => {
|
|
909
|
+
await mutatingTx(id, "::actor::set_local_policy", { auto_accept }, lt);
|
|
910
|
+
});
|
|
911
|
+
done.push(`auto_accept=${auto_accept}`);
|
|
912
|
+
}
|
|
913
|
+
if (expose === true) {
|
|
914
|
+
await publishToBook(id);
|
|
915
|
+
done.push("published in the local contact book");
|
|
916
|
+
} else if (expose === false) {
|
|
917
|
+
unpublishFromBook(id);
|
|
918
|
+
done.push("removed from the local contact book");
|
|
919
|
+
}
|
|
920
|
+
return { identity: id.name, changes: done };
|
|
921
|
+
} catch (e) {
|
|
922
|
+
throw errTool("set_local_book_policy", String(e));
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async function removeContact(ctx, a) {
|
|
926
|
+
const id = requireBound(ctx);
|
|
927
|
+
const { contact } = a;
|
|
928
|
+
try {
|
|
929
|
+
outboundRemovalInFlight.add(id.name);
|
|
930
|
+
return await withScopeAsync(async (lt) => {
|
|
931
|
+
const data = await mutatingTx(id, "::a2a_messaging::remove_contact", { contact }, lt);
|
|
932
|
+
const name = data.Reduce("removed").Visualize();
|
|
933
|
+
const cid = data.Reduce("container_id").Visualize();
|
|
934
|
+
const notifiedV = data.Reduce("notified");
|
|
935
|
+
const notified = notifiedV.IsNil() ? void 0 : notifiedV.GetBoolean();
|
|
936
|
+
return { name, cid, notified };
|
|
937
|
+
});
|
|
938
|
+
} catch (e) {
|
|
939
|
+
throw errTool("remove_contact", String(e));
|
|
940
|
+
} finally {
|
|
941
|
+
outboundRemovalInFlight.delete(id.name);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
async function renameContact(ctx, a) {
|
|
945
|
+
const id = requireBound(ctx);
|
|
946
|
+
const { contact, name } = a;
|
|
947
|
+
try {
|
|
948
|
+
return await withScopeAsync(async (lt) => {
|
|
949
|
+
const data = await mutatingTx(id, "::a2a_messaging::rename_contact", { contact, name }, lt);
|
|
950
|
+
return {
|
|
951
|
+
from: data.Reduce("renamed").Visualize(),
|
|
952
|
+
cid: data.Reduce("container_id").Visualize()
|
|
953
|
+
};
|
|
954
|
+
});
|
|
955
|
+
} catch (e) {
|
|
956
|
+
throw errTool("rename_contact", String(e));
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
async function respondToIntroduction(ctx, a) {
|
|
960
|
+
const id = requireBound(ctx);
|
|
961
|
+
const { contact, action } = a;
|
|
962
|
+
try {
|
|
963
|
+
if (action === "approve") {
|
|
964
|
+
const res = await withScopeAsync(async (lt) => {
|
|
965
|
+
const data = await mutatingTx(id, "::actor::approve_introduction", { contact }, lt);
|
|
966
|
+
return {
|
|
967
|
+
action: "approve",
|
|
968
|
+
name: data.Reduce("approved").Visualize(),
|
|
969
|
+
cid: data.Reduce("container_id").Visualize(),
|
|
970
|
+
flushed: data.Reduce("flushed").Visualize()
|
|
971
|
+
};
|
|
972
|
+
});
|
|
973
|
+
refreshUnread(id);
|
|
974
|
+
scheduleCapabilityReconcile(id);
|
|
975
|
+
return res;
|
|
976
|
+
}
|
|
977
|
+
return await withScopeAsync(async (lt) => {
|
|
978
|
+
const data = await mutatingTx(id, "::actor::reject_introduction", { contact }, lt);
|
|
979
|
+
return {
|
|
980
|
+
action: "reject",
|
|
981
|
+
name: data.Reduce("rejected").Visualize(),
|
|
982
|
+
dropped: data.Reduce("dropped_messages").Visualize()
|
|
983
|
+
};
|
|
984
|
+
});
|
|
985
|
+
} catch (e) {
|
|
986
|
+
throw errTool("respond_to_introduction", String(e));
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ../../src/api/messaging.ts
|
|
991
|
+
import { readFile } from "node:fs/promises";
|
|
992
|
+
import { basename as basename2 } from "node:path";
|
|
993
|
+
|
|
994
|
+
// ../../src/messaging/send.ts
|
|
995
|
+
function findSibling(id, contact) {
|
|
996
|
+
if (!rootName || !identities.has(rootName)) return null;
|
|
997
|
+
const target = identities.get(contact) ?? [...identities.values()].find((i) => i.cid === contact);
|
|
998
|
+
if (!target || target.name === id.name) return null;
|
|
999
|
+
const member = (i) => i.name === rootName || describeIdentity(i).roleId !== "";
|
|
1000
|
+
return member(id) && member(target) ? target : null;
|
|
1001
|
+
}
|
|
1002
|
+
async function sendViaSibling(id, target, text) {
|
|
1003
|
+
const targetAd = exportAdBlob(target);
|
|
1004
|
+
await withScopeAsync(async (lt) => {
|
|
1005
|
+
await mutatingTx(id, "::actor::connect_sibling", {
|
|
1006
|
+
name: target.name,
|
|
1007
|
+
target_ad: id.pw.packet.NewBinaryFromBuffer(targetAd).Attach(lt),
|
|
1008
|
+
text
|
|
1009
|
+
}, lt);
|
|
1010
|
+
});
|
|
1011
|
+
scheduleCapabilityReconcile(id);
|
|
1012
|
+
return `"${target.name}" was not a contact yet \u2014 connected as an intra-root sibling (delegation-cert auto-accept) and delivered the message.`;
|
|
1013
|
+
}
|
|
1014
|
+
async function sendViaLocalBook(id, contact, text) {
|
|
1015
|
+
if (!registrar) {
|
|
1016
|
+
throw new Error(`"${contact}" is not a contact, and the local contact book is unavailable.`);
|
|
1017
|
+
}
|
|
1018
|
+
const entries = readBook();
|
|
1019
|
+
const entry = entries[contact] ?? Object.values(entries).find((e) => e.container_id === contact);
|
|
1020
|
+
if (!entry) {
|
|
1021
|
+
throw new Error(
|
|
1022
|
+
`"${contact}" is not a contact and has no local contact-book entry. Use generate_invite/add_contact for remote peers, or list_local_contact_book to see local ones.`
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
if (entry.container_id === id.cid) {
|
|
1026
|
+
throw new Error("that contact-book entry is this identity itself.");
|
|
1027
|
+
}
|
|
1028
|
+
const targetAd = Buffer.from(entry.address_document, "base64url");
|
|
1029
|
+
const entrySig = Buffer.from(entry.registrar_sig, "base64url");
|
|
1030
|
+
const joinerAd = exportAdBlob(id);
|
|
1031
|
+
await withScopeAsync(async (lt) => {
|
|
1032
|
+
const minted = await mutatingTx(registrar, "::actor::mint_introduction", {
|
|
1033
|
+
joiner_ad: registrar.pw.packet.NewBinaryFromBuffer(joinerAd).Attach(lt),
|
|
1034
|
+
target_ad: registrar.pw.packet.NewBinaryFromBuffer(targetAd).Attach(lt)
|
|
1035
|
+
}, lt);
|
|
1036
|
+
const introBlob = Buffer.from(minted.Reduce("intro").GetBinary());
|
|
1037
|
+
await mutatingTx(id, "::actor::connect_local", {
|
|
1038
|
+
name: entry.name,
|
|
1039
|
+
target_ad: id.pw.packet.NewBinaryFromBuffer(targetAd).Attach(lt),
|
|
1040
|
+
intro: id.pw.packet.NewBinaryFromBuffer(introBlob).Attach(lt),
|
|
1041
|
+
entry_sig: id.pw.packet.NewBinaryFromBuffer(entrySig).Attach(lt),
|
|
1042
|
+
text
|
|
1043
|
+
}, lt);
|
|
1044
|
+
});
|
|
1045
|
+
scheduleCapabilityReconcile(id);
|
|
1046
|
+
return `"${entry.name}" was not a contact yet \u2014 connected via the local contact book and sent the message with the introduction. If "${entry.name}" requires approval for local introductions, delivery completes once they approve.`;
|
|
1047
|
+
}
|
|
1048
|
+
var parseSendVerdict = (sent) => {
|
|
1049
|
+
const has = (f) => !sent.Reduce(f).IsNil();
|
|
1050
|
+
const wireId = sent.Reduce("wire_id").Visualize();
|
|
1051
|
+
const cid = has("sent_to") ? sent.Reduce("sent_to").Visualize() : "";
|
|
1052
|
+
if (has("downgrade_refused")) return { kind: "refused", wireId, cid };
|
|
1053
|
+
if (has("migrating")) return { kind: "migrating", wireId, cid, queued: has("queued") ? Number(sent.Reduce("queued").Visualize()) : 0 };
|
|
1054
|
+
if (has("route")) {
|
|
1055
|
+
const notRetained = has("retained") && !sent.Reduce("retained").GetBoolean();
|
|
1056
|
+
return { kind: "e2e", wireId, cid, notRetained };
|
|
1057
|
+
}
|
|
1058
|
+
if (has("deferred")) return { kind: "deferred", wireId, queued: has("queued") ? Number(sent.Reduce("queued").Visualize()) : 0 };
|
|
1059
|
+
return { kind: "sent", wireId };
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
// ../../src/api/messaging.ts
|
|
1063
|
+
function replyRef(wireId, sentence) {
|
|
1064
|
+
if (!wireId) return void 0;
|
|
1065
|
+
return sentence !== void 0 ? { wire_id: wireId, sentence } : { wire_id: wireId };
|
|
1066
|
+
}
|
|
1067
|
+
async function sendMessage(ctx, a) {
|
|
1068
|
+
const id = requireBound(ctx);
|
|
1069
|
+
const { contact, text } = a;
|
|
1070
|
+
const reply_to = replyRef(a.reply_to_wire_id, a.reply_to_sentence);
|
|
1071
|
+
try {
|
|
1072
|
+
const v = await withScopeAsync(async (lt) => parseSendVerdict(await mutatingTx(id, "::a2a_messaging::send_message", {
|
|
1073
|
+
contact,
|
|
1074
|
+
text,
|
|
1075
|
+
...reply_to ? { reply_to } : {}
|
|
1076
|
+
}, lt)));
|
|
1077
|
+
switch (v.kind) {
|
|
1078
|
+
case "refused":
|
|
1079
|
+
log(`[e2e-route] refused cid=${v.cid} wire_id=${v.wireId} (downgrade_refused)`);
|
|
1080
|
+
break;
|
|
1081
|
+
case "migrating":
|
|
1082
|
+
log(`[migration] queued cid=${v.cid} wire_id=${v.wireId} (migrating, ${v.queued} queued)`);
|
|
1083
|
+
break;
|
|
1084
|
+
case "e2e":
|
|
1085
|
+
log(`[e2e-route] cid=${v.cid} wire_id=${v.wireId} verdict=e2e (core delivered over migrated session)${v.notRetained ? " NOT RETAINED (oversized)" : ""}`);
|
|
1086
|
+
if (v.notRetained) {
|
|
1087
|
+
appendNotifyLog(id, { event: "send_not_retained", wire_id: v.wireId, kind: "message" });
|
|
1088
|
+
}
|
|
1089
|
+
break;
|
|
1090
|
+
default:
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
return v;
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
if (!/Unknown contact/.test(String(e))) {
|
|
1096
|
+
throw errTool("send_message", String(e));
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
const sibling = findSibling(id, contact);
|
|
1100
|
+
const sent = sibling ? await sendViaSibling(id, sibling, text) : await sendViaLocalBook(id, contact, text);
|
|
1101
|
+
return { kind: "introduced", text: sent };
|
|
1102
|
+
} catch (e2) {
|
|
1103
|
+
throw errTool("send_message", String(e2), "FALLBACK_FAILED");
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
async function sendFile(ctx, a) {
|
|
1108
|
+
const id = requireBound(ctx);
|
|
1109
|
+
const { contact, path, data_base64, upload_id, filename, mime } = a;
|
|
1110
|
+
if ((path ? 1 : 0) + (data_base64 ? 1 : 0) + (upload_id ? 1 : 0) !== 1) {
|
|
1111
|
+
throw upload_id ? errSendFileUploadArgs() : errSendFileArgs();
|
|
1112
|
+
}
|
|
1113
|
+
let buf;
|
|
1114
|
+
let fname;
|
|
1115
|
+
let staged = null;
|
|
1116
|
+
try {
|
|
1117
|
+
if (path) {
|
|
1118
|
+
buf = await readFile(path);
|
|
1119
|
+
fname = filename ?? basename2(path);
|
|
1120
|
+
} else if (upload_id) {
|
|
1121
|
+
const got = consumeStagedUpload(id, upload_id);
|
|
1122
|
+
if (!got) throw errUploadNotFound(upload_id);
|
|
1123
|
+
staged = got.meta;
|
|
1124
|
+
buf = got.buf;
|
|
1125
|
+
fname = filename ?? got.meta.filename;
|
|
1126
|
+
} else {
|
|
1127
|
+
if (!filename) throw errSendFileFilenameRequired();
|
|
1128
|
+
buf = Buffer.from(data_base64, "base64");
|
|
1129
|
+
fname = filename;
|
|
1130
|
+
}
|
|
1131
|
+
} catch (e) {
|
|
1132
|
+
if (e instanceof OursError) throw e;
|
|
1133
|
+
throw errFileUnreadable(String(e));
|
|
1134
|
+
}
|
|
1135
|
+
const mt = mime ?? (path ? mimeFromExt(path) : staged?.mime || "");
|
|
1136
|
+
const reply_to = replyRef(a.reply_to_wire_id, a.reply_to_sentence);
|
|
1137
|
+
const cap = resolveFileEnvelopeCap();
|
|
1138
|
+
let preflight;
|
|
1139
|
+
try {
|
|
1140
|
+
preflight = withScope((lt) => checkFileEnvelope({
|
|
1141
|
+
// ⚠ THE BOUND IDENTITY'S OWN PACKET, for the same reason as the send below.
|
|
1142
|
+
//
|
|
1143
|
+
// Dereferenced LAZILY, and that is load-bearing: checkFileEnvelope's fast
|
|
1144
|
+
// path never builds a binary, so an ordinary small file must not touch
|
|
1145
|
+
// `id.pw` at all. Reading it eagerly here would make every send_file
|
|
1146
|
+
// require a live packet before the size question is even asked, which
|
|
1147
|
+
// breaks Appendix A row 54 (`send_file failed: `) — that row reaches the
|
|
1148
|
+
// outer catch through mutatingTx's fail-closed gate on a packet-less
|
|
1149
|
+
// identity, and would instead die on a TypeError in here.
|
|
1150
|
+
packet: { NewBinaryFromBuffer: (b) => id.pw.packet.NewBinaryFromBuffer(b) },
|
|
1151
|
+
lifetime: lt,
|
|
1152
|
+
filename: fname,
|
|
1153
|
+
mime: mt,
|
|
1154
|
+
payload: buf,
|
|
1155
|
+
replyTo: reply_to,
|
|
1156
|
+
cap
|
|
1157
|
+
}));
|
|
1158
|
+
} catch (e) {
|
|
1159
|
+
if (e instanceof OursError) throw e;
|
|
1160
|
+
throw errTool("send_file", String(e));
|
|
1161
|
+
}
|
|
1162
|
+
if (!preflight.ok) {
|
|
1163
|
+
throw errFileTooLarge({
|
|
1164
|
+
filename: fname,
|
|
1165
|
+
bytes: preflight.bytes,
|
|
1166
|
+
limit: preflight.cap,
|
|
1167
|
+
envelopeBytes: preflight.envelopeBytes,
|
|
1168
|
+
maxPayloadBytes: preflight.maxPayloadBytes
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
try {
|
|
1172
|
+
const v = await withScopeAsync(async (lt) => {
|
|
1173
|
+
const binValue = id.pw.packet.NewBinaryFromBuffer(buf).Attach(lt);
|
|
1174
|
+
return parseSendVerdict(await mutatingTx(id, "::a2a_messaging::send_file", {
|
|
1175
|
+
contact,
|
|
1176
|
+
filename: fname,
|
|
1177
|
+
mime: mt,
|
|
1178
|
+
data: binValue,
|
|
1179
|
+
...reply_to ? { reply_to } : {}
|
|
1180
|
+
}, lt));
|
|
1181
|
+
});
|
|
1182
|
+
switch (v.kind) {
|
|
1183
|
+
case "refused":
|
|
1184
|
+
log(`[e2e-route] refused cid=${v.cid} wire_id=${v.wireId} (downgrade_refused, file)`);
|
|
1185
|
+
break;
|
|
1186
|
+
case "migrating":
|
|
1187
|
+
log(`[migration] file-defer cid=${v.cid} wire_id=${v.wireId} (migrating, not queued)`);
|
|
1188
|
+
break;
|
|
1189
|
+
case "e2e":
|
|
1190
|
+
log(`[e2e-route] cid=${v.cid} wire_id=${v.wireId} verdict=e2e file (core delivered over migrated session)${v.notRetained ? " NOT RETAINED (oversized)" : ""}`);
|
|
1191
|
+
if (v.notRetained) {
|
|
1192
|
+
appendNotifyLog(id, { event: "send_not_retained", wire_id: v.wireId, kind: "file" });
|
|
1193
|
+
}
|
|
1194
|
+
break;
|
|
1195
|
+
default:
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
return { ...v, filename: fname, bytes: buf.length, mime: mt };
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
throw errTool("send_file", String(e));
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
async function listIncomingMessages(ctx) {
|
|
1204
|
+
const id = requireBound(ctx);
|
|
1205
|
+
try {
|
|
1206
|
+
return withScope((lt) => renderInbox(readonlyTx(id, "::actor::list_incoming_messages", lt)));
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
throw errTool("list_incoming_messages", String(e));
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
async function getMessages(ctx) {
|
|
1212
|
+
const id = requireBound(ctx);
|
|
1213
|
+
try {
|
|
1214
|
+
const fresh = await withScopeAsync(async (lt) => {
|
|
1215
|
+
const data = await mutatingTx(id, "::actor::get_messages", {}, lt);
|
|
1216
|
+
return renderInbox(data.Reduce("messages"));
|
|
1217
|
+
});
|
|
1218
|
+
refreshUnread(id);
|
|
1219
|
+
const e2eWireIds = readE2eWireIds(id);
|
|
1220
|
+
return buildMessagesPayload(fresh, e2eWireIds);
|
|
1221
|
+
} catch (e) {
|
|
1222
|
+
throw errTool("get_messages", String(e));
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
async function deferMessages(ctx, a) {
|
|
1226
|
+
const id = requireBound(ctx);
|
|
1227
|
+
const { msg_ids } = a;
|
|
1228
|
+
try {
|
|
1229
|
+
const n = await withScopeAsync(async (lt) => {
|
|
1230
|
+
const data = await mutatingTx(id, "::actor::defer_messages", { msg_ids }, lt);
|
|
1231
|
+
return data.Reduce("deferred").Visualize();
|
|
1232
|
+
});
|
|
1233
|
+
refreshUnread(id);
|
|
1234
|
+
return { deferred: n };
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
throw errTool("defer_messages", String(e));
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// ../../src/api/files.ts
|
|
1241
|
+
async function listIncomingFiles(ctx) {
|
|
1242
|
+
const id = requireBound(ctx);
|
|
1243
|
+
try {
|
|
1244
|
+
return withScope((lt) => renderFileMetadata(readonlyTx(id, "::actor::list_incoming_files", lt)));
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
throw errTool("list_incoming_files", String(e));
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
async function getFiles(ctx, a = {}) {
|
|
1250
|
+
const id = requireBound(ctx);
|
|
1251
|
+
const { wire_ids } = a;
|
|
1252
|
+
if (wire_ids !== void 0) {
|
|
1253
|
+
if (wire_ids.length < 1 || wire_ids.length > FILE_SELECTION_CAP) {
|
|
1254
|
+
throw errInvalidSelection(FILE_SELECTION_CAP);
|
|
1255
|
+
}
|
|
1256
|
+
if (wire_ids.some((wire) => !isSelectableWireId(wire))) throw errMalformedId();
|
|
1257
|
+
if (new Set(wire_ids).size !== wire_ids.length) throw errDuplicateId();
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
const out = await withScopeAsync(async (lt) => {
|
|
1261
|
+
const data = await mutatingTx(id, "::actor::get_files", wire_ids === void 0 ? {} : { wire_ids }, lt);
|
|
1262
|
+
return await writeIncomingFiles(id, data.Reduce("files"));
|
|
1263
|
+
});
|
|
1264
|
+
refreshUnread(id);
|
|
1265
|
+
return {
|
|
1266
|
+
files: out.files,
|
|
1267
|
+
// The finished summary travels; ours-mcp cannot re-derive it. See GetFilesResult.
|
|
1268
|
+
text: out.text,
|
|
1269
|
+
mode: wire_ids === void 0 ? "all_unread" : "selected",
|
|
1270
|
+
// null — NOT [] — for the bulk form. See GetFilesResult.
|
|
1271
|
+
requested: wire_ids ?? null
|
|
1272
|
+
};
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
const message = String(e);
|
|
1275
|
+
if (wire_ids !== void 0 && /unknown, stale, or no longer unread/i.test(message)) {
|
|
1276
|
+
throw errUnknownOrStaleId();
|
|
1277
|
+
}
|
|
1278
|
+
throw errTool("get_files", message);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
function saveFileFallbackNotice(ctx, a) {
|
|
1282
|
+
const id = requireBound(ctx);
|
|
1283
|
+
return { existsOnDisk: findIdentityFile(id, a.wire_id) !== null };
|
|
1284
|
+
}
|
|
1285
|
+
async function deferFiles(ctx, a) {
|
|
1286
|
+
const id = requireBound(ctx);
|
|
1287
|
+
const { file_ids } = a;
|
|
1288
|
+
try {
|
|
1289
|
+
const n = await withScopeAsync(async (lt) => {
|
|
1290
|
+
const data = await mutatingTx(id, "::actor::defer_files", { file_ids }, lt);
|
|
1291
|
+
return data.Reduce("deferred").Visualize();
|
|
1292
|
+
});
|
|
1293
|
+
refreshUnread(id);
|
|
1294
|
+
return { deferred: n };
|
|
1295
|
+
} catch (e) {
|
|
1296
|
+
throw errTool("defer_files", String(e));
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// ../../src/api/profile.ts
|
|
1301
|
+
async function setBio2(ctx, a) {
|
|
1302
|
+
const id = requireBound(ctx);
|
|
1303
|
+
const { bio } = a;
|
|
1304
|
+
try {
|
|
1305
|
+
await withScopeAsync(async (lt) => {
|
|
1306
|
+
await mutatingTx(id, "::a2a_messaging::set_my_bio", { bio }, lt);
|
|
1307
|
+
});
|
|
1308
|
+
let refreshed = 0;
|
|
1309
|
+
if (id.name === rootName) {
|
|
1310
|
+
for (const other of identities.values()) {
|
|
1311
|
+
if (other.name === id.name) continue;
|
|
1312
|
+
if (describeIdentity(other).rootCid !== id.cid) continue;
|
|
1313
|
+
try {
|
|
1314
|
+
await delegateRole(id, other);
|
|
1315
|
+
refreshed += 1;
|
|
1316
|
+
} catch (e) {
|
|
1317
|
+
log(`failed to refresh root profile in role "${other.name}":`, String(e));
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return { identity: id.name, rolesRefreshed: refreshed };
|
|
1322
|
+
} catch (e) {
|
|
1323
|
+
throw errTool("set_bio", String(e));
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
async function advertiseMigrate(ctx) {
|
|
1327
|
+
const id = requireBound(ctx);
|
|
1328
|
+
try {
|
|
1329
|
+
return await withScopeAsync(async (lt) => {
|
|
1330
|
+
const r = await mutatingTx(id, "::a2a_messaging::advertise_migrate", {}, lt);
|
|
1331
|
+
return {
|
|
1332
|
+
wasAdvertising: r.Reduce("was_advertising").Visualize() === "true",
|
|
1333
|
+
advertising: r.Reduce("advertising").Visualize() === "true",
|
|
1334
|
+
offers: parseInt(r.Reduce("offers_initiated").Visualize(), 10) || 0
|
|
1335
|
+
};
|
|
1336
|
+
});
|
|
1337
|
+
} catch (e) {
|
|
1338
|
+
throw errTool("advertise_migrate", String(e));
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
async function setPersona(ctx, a) {
|
|
1342
|
+
const id = requireBound(ctx);
|
|
1343
|
+
const { persona } = a;
|
|
1344
|
+
try {
|
|
1345
|
+
await withScopeAsync(async (lt) => {
|
|
1346
|
+
await mutatingTx(id, "::a2a_messaging::set_my_persona", { persona }, lt);
|
|
1347
|
+
});
|
|
1348
|
+
return { identity: id.name };
|
|
1349
|
+
} catch (e) {
|
|
1350
|
+
throw errTool("set_persona", String(e));
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async function readvertiseOnUpgrade(ctx) {
|
|
1354
|
+
const id = requireBound(ctx);
|
|
1355
|
+
try {
|
|
1356
|
+
const n = await withScopeAsync(async (lt) => {
|
|
1357
|
+
const data = await mutatingTx(id, "::a2a_messaging::readvertise_on_upgrade", {}, lt);
|
|
1358
|
+
return data.Reduce("readvertised").Visualize();
|
|
1359
|
+
});
|
|
1360
|
+
return { readvertised: n };
|
|
1361
|
+
} catch (e) {
|
|
1362
|
+
throw errTool("readvertise_on_upgrade", String(e));
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// ../../src/api/conversations.ts
|
|
1367
|
+
function renderConversation(av) {
|
|
1368
|
+
const out = [];
|
|
1369
|
+
const msgs = av.Reduce("messages");
|
|
1370
|
+
if (msgs.IsNil()) return out;
|
|
1371
|
+
for (let i = 0; ; i++) {
|
|
1372
|
+
const m = msgs.Reduce(i);
|
|
1373
|
+
if (m.IsNil()) break;
|
|
1374
|
+
const wire = m.Reduce("wire_id");
|
|
1375
|
+
const receipt = m.Reduce("receipt");
|
|
1376
|
+
out.push({
|
|
1377
|
+
dir: m.Reduce("dir").Visualize() === "out" ? "out" : "in",
|
|
1378
|
+
text: m.Reduce("text").Visualize(),
|
|
1379
|
+
date: m.Reduce("date").Visualize(),
|
|
1380
|
+
// GetBoolean, never Visualize: a MUFL boolean visualizes as '%%TRUE',
|
|
1381
|
+
// never 'TRUE' — the G4 trap from wire-handlers, one layer up.
|
|
1382
|
+
read: m.Reduce("read").GetBoolean(),
|
|
1383
|
+
wire_id: wire.IsNil() ? "" : wire.Visualize(),
|
|
1384
|
+
receipt: receipt.IsNil() ? null : receipt.Visualize()
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
return out;
|
|
1388
|
+
}
|
|
1389
|
+
async function getConversation(ctx, a) {
|
|
1390
|
+
const id = requireBound(ctx);
|
|
1391
|
+
try {
|
|
1392
|
+
const messages = withScope(
|
|
1393
|
+
(lt) => renderConversation(readonlyTx(id, "::actor::get_conversation", lt, { contact: a.contact }))
|
|
1394
|
+
);
|
|
1395
|
+
return { contact: a.contact, messages };
|
|
1396
|
+
} catch (e) {
|
|
1397
|
+
throw errTool("get_conversation", String(e));
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
async function getReceipts(ctx, a) {
|
|
1401
|
+
const id = requireBound(ctx);
|
|
1402
|
+
try {
|
|
1403
|
+
const receipts = withScope((lt) => {
|
|
1404
|
+
const av = readonlyTx(id, "::actor::get_receipts", lt, { contact: a.contact }).Reduce("receipts");
|
|
1405
|
+
const out = {};
|
|
1406
|
+
if (av.IsNil()) return out;
|
|
1407
|
+
for (const key of av.GetKeys()) {
|
|
1408
|
+
const wid = key.Visualize();
|
|
1409
|
+
out[wid] = av.Reduce(wid).Visualize();
|
|
1410
|
+
}
|
|
1411
|
+
return out;
|
|
1412
|
+
});
|
|
1413
|
+
return { contact: a.contact, receipts };
|
|
1414
|
+
} catch (e) {
|
|
1415
|
+
throw errTool("get_receipts", String(e));
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
async function markRead(ctx, a) {
|
|
1419
|
+
const id = requireBound(ctx);
|
|
1420
|
+
try {
|
|
1421
|
+
const marked = await withScopeAsync(async (lt) => {
|
|
1422
|
+
const data = await mutatingTx(id, "::actor::mark_read", { contact: a.contact }, lt);
|
|
1423
|
+
return Number(data.Reduce("marked").Visualize());
|
|
1424
|
+
});
|
|
1425
|
+
return { contact: a.contact, marked };
|
|
1426
|
+
} catch (e) {
|
|
1427
|
+
throw errTool("mark_read", String(e));
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
async function setConversationPolicy(ctx, a) {
|
|
1431
|
+
const id = requireBound(ctx);
|
|
1432
|
+
try {
|
|
1433
|
+
return await withScopeAsync(async (lt) => {
|
|
1434
|
+
const data = await mutatingTx(id, "::actor::set_local_policy", { keep_history: a.keep_history }, lt);
|
|
1435
|
+
return {
|
|
1436
|
+
keepHistory: data.Reduce("keep_history").GetBoolean(),
|
|
1437
|
+
autoAccept: data.Reduce("auto_accept").GetBoolean()
|
|
1438
|
+
};
|
|
1439
|
+
});
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
throw errTool("set_local_policy", String(e));
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// ../../src/http/api-routes.ts
|
|
1446
|
+
var API_PREFIX = "/api/v1/";
|
|
1447
|
+
var op = (fn) => (ctx, body) => fn(ctx, body);
|
|
1448
|
+
var op0 = (fn) => (ctx) => fn(ctx);
|
|
1449
|
+
var opNoCtx = (fn) => (_ctx, body) => fn(body);
|
|
1450
|
+
var API_OPERATIONS = {
|
|
1451
|
+
// ----- identity (Task 10) --------------------------------------------------
|
|
1452
|
+
createIdentity: op(createIdentity),
|
|
1453
|
+
createTemporaryIdentity: op(createTemporaryIdentity),
|
|
1454
|
+
closeTemporaryIdentityOp: op(closeTemporaryIdentityOp),
|
|
1455
|
+
createRootIdentity: op(createRootIdentity),
|
|
1456
|
+
defineLocalIdentityFile: opNoCtx(defineLocalIdentityFile),
|
|
1457
|
+
chooseIdentity: op(chooseIdentity),
|
|
1458
|
+
listIdentities: op0(listIdentities),
|
|
1459
|
+
currentIdentity: op0(currentIdentity),
|
|
1460
|
+
removeIdentity: op(removeIdentity),
|
|
1461
|
+
releaseLease: op0(releaseLease),
|
|
1462
|
+
// ----- contacts, invites, introductions, local book (Task 11) --------------
|
|
1463
|
+
generateInvite: op(generateInvite),
|
|
1464
|
+
listInvites: op0(listInvites),
|
|
1465
|
+
revokeInvite: op(revokeInvite),
|
|
1466
|
+
addContact: op(addContact),
|
|
1467
|
+
listContacts: op0(listContacts),
|
|
1468
|
+
listLocalContactBook: op0(listLocalContactBook),
|
|
1469
|
+
setLocalBookPolicy: op(setLocalBookPolicy),
|
|
1470
|
+
removeContact: op(removeContact),
|
|
1471
|
+
renameContact: op(renameContact),
|
|
1472
|
+
respondToIntroduction: op(respondToIntroduction),
|
|
1473
|
+
// ----- profile (Task 12) ---------------------------------------------------
|
|
1474
|
+
setBio: op(setBio2),
|
|
1475
|
+
advertiseMigrate: op0(advertiseMigrate),
|
|
1476
|
+
readvertiseOnUpgrade: op0(readvertiseOnUpgrade),
|
|
1477
|
+
setPersona: op(setPersona),
|
|
1478
|
+
// ----- messaging (Task 12) -------------------------------------------------
|
|
1479
|
+
sendMessage: op(sendMessage),
|
|
1480
|
+
sendFile: op(sendFile),
|
|
1481
|
+
listIncomingMessages: op0(listIncomingMessages),
|
|
1482
|
+
getMessages: op0(getMessages),
|
|
1483
|
+
deferMessages: op(deferMessages),
|
|
1484
|
+
deferFiles: op(deferFiles),
|
|
1485
|
+
// The messenger conversation surface. getConversation and getReceipts read
|
|
1486
|
+
// without consuming or emitting; markRead is the human read event.
|
|
1487
|
+
getConversation: op(getConversation),
|
|
1488
|
+
getReceipts: op(getReceipts),
|
|
1489
|
+
markRead: op(markRead),
|
|
1490
|
+
setConversationPolicy: op(setConversationPolicy),
|
|
1491
|
+
// ----- files (Task 12) -----------------------------------------------------
|
|
1492
|
+
listIncomingFiles: op0(listIncomingFiles),
|
|
1493
|
+
getFiles: op(getFiles),
|
|
1494
|
+
saveFileFallbackNotice: op(saveFileFallbackNotice)
|
|
1495
|
+
};
|
|
1496
|
+
var apiSessionRefs = /* @__PURE__ */ new Map();
|
|
1497
|
+
var headerValue = (req, name) => {
|
|
1498
|
+
const v = req.headers[name];
|
|
1499
|
+
return typeof v === "string" && v !== "" ? v : void 0;
|
|
1500
|
+
};
|
|
1501
|
+
function enterApiSession(req) {
|
|
1502
|
+
const token = headerValue(req, "x-ours-lease-token");
|
|
1503
|
+
const pidRaw = headerValue(req, "x-ours-client-pid");
|
|
1504
|
+
const parsed = pidRaw ? parseInt(pidRaw, 10) : NaN;
|
|
1505
|
+
const pid = Number.isInteger(parsed) ? parsed : void 0;
|
|
1506
|
+
const sid = token ? `api:${hashLeaseToken(token)}` : "api:anonymous";
|
|
1507
|
+
sessionHeaders.set(sid, { token, pid });
|
|
1508
|
+
apiSessionRefs.set(sid, (apiSessionRefs.get(sid) ?? 0) + 1);
|
|
1509
|
+
return {
|
|
1510
|
+
// Lazy, like the `/mcp` context: an operation that rebinds mid-call must see
|
|
1511
|
+
// the map as it is now, not a copy taken at request entry.
|
|
1512
|
+
ctx: {
|
|
1513
|
+
sessionId: () => sid,
|
|
1514
|
+
leaseToken: () => sessionHeaders.get(sid)?.token,
|
|
1515
|
+
clientPid: () => sessionHeaders.get(sid)?.pid
|
|
1516
|
+
},
|
|
1517
|
+
release: () => {
|
|
1518
|
+
const n = (apiSessionRefs.get(sid) ?? 1) - 1;
|
|
1519
|
+
if (n > 0) {
|
|
1520
|
+
apiSessionRefs.set(sid, n);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
apiSessionRefs.delete(sid);
|
|
1524
|
+
sessionHeaders.delete(sid);
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
var sendJson = (res, status, value) => {
|
|
1529
|
+
if (res.writableEnded) return;
|
|
1530
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1531
|
+
res.end(JSON.stringify(value ?? null));
|
|
1532
|
+
};
|
|
1533
|
+
async function serveApiOperation(req, res, pathname, readBody2) {
|
|
1534
|
+
const name = pathname.slice(API_PREFIX.length);
|
|
1535
|
+
if (!requireAuth(req, res)) return;
|
|
1536
|
+
if (req.method !== "POST") {
|
|
1537
|
+
sendJson(res, 405, { error: "method not allowed: the typed API is POST-only" });
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const handler = Object.prototype.hasOwnProperty.call(API_OPERATIONS, name) ? API_OPERATIONS[name] : void 0;
|
|
1541
|
+
if (!handler) {
|
|
1542
|
+
sendJson(res, 404, { error: `unknown operation "${name}"` });
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
let body;
|
|
1546
|
+
try {
|
|
1547
|
+
body = await readBody2(req);
|
|
1548
|
+
} catch {
|
|
1549
|
+
sendJson(res, 400, { error: "the request body must be JSON" });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
1553
|
+
sendJson(res, 400, { error: "the request body must be a JSON object" });
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
const session = enterApiSession(req);
|
|
1557
|
+
try {
|
|
1558
|
+
sendJson(res, 200, await handler(session.ctx, body));
|
|
1559
|
+
} catch (err) {
|
|
1560
|
+
if (err instanceof OursError) {
|
|
1561
|
+
sendJson(res, 400, { error: { code: err.code, message: err.message } });
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
log("api handler error:", name, String(err));
|
|
1565
|
+
sendJson(res, 500, { error: `internal error in ${name}: ${String(err)}` });
|
|
1566
|
+
} finally {
|
|
1567
|
+
session.release();
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// ../../src/runtime/sdk-signal-ownership.ts
|
|
1572
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1573
|
+
import { dirname, resolve } from "node:path";
|
|
1574
|
+
import { fileURLToPath } from "node:url";
|
|
1575
|
+
var SIGNALS = ["SIGINT", "SIGTERM"];
|
|
1576
|
+
var PINNED_SDK_VERSION = "0.10.12";
|
|
1577
|
+
var PINNED_HANDLER_NAME = "bound onSignal";
|
|
1578
|
+
var BOUND_FUNCTION_SOURCE = "function () { [native code] }";
|
|
1579
|
+
var neutralized = false;
|
|
1580
|
+
var lastAudit = null;
|
|
1581
|
+
var installedSdkVersion = () => {
|
|
1582
|
+
const wrapperEntry = fileURLToPath(import.meta.resolve("@adapt-toolkit/sdk/wrapper"));
|
|
1583
|
+
const packagePath = resolve(dirname(wrapperEntry), "..", "..", "package.json");
|
|
1584
|
+
const parsed = JSON.parse(readFileSync2(packagePath, "utf8"));
|
|
1585
|
+
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
1586
|
+
};
|
|
1587
|
+
var sameReferences = (left, right) => left.length === right.length && left.every((listener, index) => listener === right[index]);
|
|
1588
|
+
var isPinnedSdkExitHandler = (listener) => listener.name === PINNED_HANDLER_NAME && listener.length === 0 && !Object.prototype.hasOwnProperty.call(listener, "prototype") && Function.prototype.toString.call(listener) === BOUND_FUNCTION_SOURCE;
|
|
1589
|
+
function neutralizePinnedSdkSignalHandlers() {
|
|
1590
|
+
if (neutralized) return;
|
|
1591
|
+
const sdkVersion = installedSdkVersion();
|
|
1592
|
+
const exitBefore = process.listeners("exit");
|
|
1593
|
+
const owned = /* @__PURE__ */ new Map();
|
|
1594
|
+
const invalid = (detail) => {
|
|
1595
|
+
lastAudit = {
|
|
1596
|
+
sdkVersion,
|
|
1597
|
+
outcome: "ambiguous",
|
|
1598
|
+
exitListenerCount: exitBefore.length,
|
|
1599
|
+
exitListenerReferencesPreserved: sameReferences(exitBefore, process.listeners("exit"))
|
|
1600
|
+
};
|
|
1601
|
+
throw new Error(
|
|
1602
|
+
`Cannot safely neutralize @adapt-toolkit/sdk ${sdkVersion} signal ownership: ${detail}. No process signal listeners were changed.`
|
|
1603
|
+
);
|
|
1604
|
+
};
|
|
1605
|
+
if (sdkVersion !== PINNED_SDK_VERSION) {
|
|
1606
|
+
invalid(`this compatibility bridge is pinned to ${PINNED_SDK_VERSION}`);
|
|
1607
|
+
}
|
|
1608
|
+
for (const signal of SIGNALS) {
|
|
1609
|
+
const matches = process.listeners(signal).filter(isPinnedSdkExitHandler);
|
|
1610
|
+
if (matches.length !== 1) {
|
|
1611
|
+
invalid(`${signal} expected exactly one ${PINNED_HANDLER_NAME} listener, found ${matches.length}`);
|
|
1612
|
+
}
|
|
1613
|
+
owned.set(signal, matches[0]);
|
|
1614
|
+
}
|
|
1615
|
+
if (owned.get("SIGINT") === owned.get("SIGTERM")) {
|
|
1616
|
+
invalid("the SIGINT/SIGTERM pair unexpectedly shares one function reference");
|
|
1617
|
+
}
|
|
1618
|
+
for (const [signal, listener] of owned) process.off(signal, listener);
|
|
1619
|
+
const exitAfter = process.listeners("exit");
|
|
1620
|
+
const exitListenerReferencesPreserved = sameReferences(exitBefore, exitAfter);
|
|
1621
|
+
if (!exitListenerReferencesPreserved) {
|
|
1622
|
+
throw new Error("ADAPT signal neutralization unexpectedly changed process exit listeners");
|
|
1623
|
+
}
|
|
1624
|
+
lastAudit = {
|
|
1625
|
+
sdkVersion,
|
|
1626
|
+
outcome: "neutralized",
|
|
1627
|
+
exitListenerCount: exitAfter.length,
|
|
1628
|
+
exitListenerReferencesPreserved
|
|
1629
|
+
};
|
|
1630
|
+
neutralized = true;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// ../../src/http/server.ts
|
|
1634
|
+
neutralizePinnedSdkSignalHandlers();
|
|
1635
|
+
function looksLikeInitializeRequest(body) {
|
|
1636
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
1637
|
+
const m = body;
|
|
1638
|
+
return m.jsonrpc === "2.0" && m.method === "initialize";
|
|
1639
|
+
}
|
|
1640
|
+
var LOAD_TIME_CONFIG_FIELDS = ["brokerUrl", "port", "stateDir", "gcIntervalMs", "apiVisibility"];
|
|
1641
|
+
function readBody(req) {
|
|
1642
|
+
return new Promise((resolve2, reject) => {
|
|
1643
|
+
let data = "";
|
|
1644
|
+
req.on("data", (chunk) => data += chunk);
|
|
1645
|
+
req.on("end", () => {
|
|
1646
|
+
try {
|
|
1647
|
+
resolve2(JSON.parse(data));
|
|
1648
|
+
} catch (e) {
|
|
1649
|
+
reject(e);
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
req.on("error", reject);
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
async function startDaemon(opts = {}) {
|
|
1656
|
+
try {
|
|
1657
|
+
return await startHttpDaemon(opts);
|
|
1658
|
+
} catch (err) {
|
|
1659
|
+
startupProgress?.failed();
|
|
1660
|
+
log(`fatal startup error: ${err?.stack ?? err}`);
|
|
1661
|
+
throw err;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
async function startHttpDaemon(opts) {
|
|
1665
|
+
const handleSignals = opts.handleSignals !== false;
|
|
1666
|
+
if (process.env.OURS_TEST_STARTUP_FAIL === "1") {
|
|
1667
|
+
throw new Error("forced startup failure (OURS_TEST_STARTUP_FAIL)");
|
|
1668
|
+
}
|
|
1669
|
+
if (opts.config) {
|
|
1670
|
+
const drift = LOAD_TIME_CONFIG_FIELDS.filter((k) => opts.config[k] !== CONFIG[k]);
|
|
1671
|
+
if (drift.length > 0) {
|
|
1672
|
+
throw new Error(
|
|
1673
|
+
`startDaemon(config) disagrees with the config resolved at module load (${drift.join(", ")}). The engine reads its config when src/runtime/env.ts is imported, so a later override cannot move the state dir, port or broker \u2014 set OURS_* env or config.json before importing the SDK.`
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
const mcp = opts.mcp;
|
|
1678
|
+
const isInitialize = mcp?.isInitializeRequest ?? looksLikeInitializeRequest;
|
|
1679
|
+
let installedHook = null;
|
|
1680
|
+
if (typeof opts.onIdentityNotify === "function") {
|
|
1681
|
+
const sink = opts.onIdentityNotify;
|
|
1682
|
+
installedHook = (identityName, summary) => {
|
|
1683
|
+
sink(identityName, summary, leases.get(identityName)?.sid ?? null);
|
|
1684
|
+
};
|
|
1685
|
+
setNotifyHook(installedHook);
|
|
1686
|
+
}
|
|
1687
|
+
log("booting wrapper\u2026");
|
|
1688
|
+
try {
|
|
1689
|
+
await bootWrapper();
|
|
1690
|
+
startGcTimer();
|
|
1691
|
+
} catch (err) {
|
|
1692
|
+
if (installedHook) clearNotifyHook(installedHook);
|
|
1693
|
+
stopGcTimer();
|
|
1694
|
+
throw err;
|
|
1695
|
+
}
|
|
1696
|
+
log(`wrapper ready (identities=${identities.size}), starting HTTP server\u2026`);
|
|
1697
|
+
startupProgress?.update("server");
|
|
1698
|
+
const transports = {};
|
|
1699
|
+
const serversBySession = /* @__PURE__ */ new Map();
|
|
1700
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
1701
|
+
const STUCK_MS = 3e4;
|
|
1702
|
+
const enterInflight = (sid) => {
|
|
1703
|
+
const e = inflight.get(sid);
|
|
1704
|
+
if (e) e.n++;
|
|
1705
|
+
else inflight.set(sid, { n: 1, since: Date.now() });
|
|
1706
|
+
};
|
|
1707
|
+
const leaveInflight = (sid) => {
|
|
1708
|
+
const e = inflight.get(sid);
|
|
1709
|
+
if (e && --e.n <= 0) inflight.delete(sid);
|
|
1710
|
+
};
|
|
1711
|
+
const reapDeadSessions = () => {
|
|
1712
|
+
let reaped = 0;
|
|
1713
|
+
for (const sid of [...serversBySession.keys()]) {
|
|
1714
|
+
if (sid === "stdio") continue;
|
|
1715
|
+
const pid = sessionHeaders.get(sid)?.pid;
|
|
1716
|
+
if (pid === void 0 || pid <= 1 || pidAlive(pid)) continue;
|
|
1717
|
+
const inf = inflight.get(sid);
|
|
1718
|
+
if (inf && inf.n > 0 && Date.now() - inf.since < STUCK_MS) continue;
|
|
1719
|
+
const srv = serversBySession.get(sid);
|
|
1720
|
+
serversBySession.delete(sid);
|
|
1721
|
+
delete transports[sid];
|
|
1722
|
+
sessionHeaders.delete(sid);
|
|
1723
|
+
inflight.delete(sid);
|
|
1724
|
+
void srv?.close().catch(() => {
|
|
1725
|
+
});
|
|
1726
|
+
mcp?.onSessionClosed?.(sid);
|
|
1727
|
+
reaped++;
|
|
1728
|
+
log(`session ${sid.slice(0, 8)}\u2026 reaped (client pid ${pid} dead)`);
|
|
1729
|
+
}
|
|
1730
|
+
return reaped;
|
|
1731
|
+
};
|
|
1732
|
+
const sessionReaper = setInterval(() => {
|
|
1733
|
+
reapDeadSessions();
|
|
1734
|
+
sweepStaleTempIdentities();
|
|
1735
|
+
sweepStaleUploads();
|
|
1736
|
+
}, 6e4);
|
|
1737
|
+
sessionReaper.unref?.();
|
|
1738
|
+
const REQ_META_MAX = 1e3;
|
|
1739
|
+
const REQ_META_TTL_MS = 10 * 6e4;
|
|
1740
|
+
const requestMeta = /* @__PURE__ */ new Map();
|
|
1741
|
+
const noteRequestMeta = (sid, body) => {
|
|
1742
|
+
const list = Array.isArray(body) ? body : [body];
|
|
1743
|
+
for (const m of list) {
|
|
1744
|
+
const msg = m;
|
|
1745
|
+
if (!msg || typeof msg !== "object") continue;
|
|
1746
|
+
if (msg.id === void 0 || msg.id === null || typeof msg.method !== "string") continue;
|
|
1747
|
+
requestMeta.set(`${sid}:${String(msg.id)}`, { method: msg.method, at: Date.now() });
|
|
1748
|
+
}
|
|
1749
|
+
if (requestMeta.size > REQ_META_MAX) {
|
|
1750
|
+
const cutoff = Date.now() - REQ_META_TTL_MS;
|
|
1751
|
+
for (const [k, v] of requestMeta) {
|
|
1752
|
+
if (v.at < cutoff) requestMeta.delete(k);
|
|
1753
|
+
if (requestMeta.size <= REQ_META_MAX) break;
|
|
1754
|
+
}
|
|
1755
|
+
while (requestMeta.size > REQ_META_MAX) {
|
|
1756
|
+
const oldest = requestMeta.keys().next();
|
|
1757
|
+
if (oldest.done) break;
|
|
1758
|
+
requestMeta.delete(oldest.value);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
const reportUndeliverable = (sid, err) => {
|
|
1763
|
+
try {
|
|
1764
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
1765
|
+
const m = /No connection established for request ID:\s*(\S+)/.exec(text);
|
|
1766
|
+
if (!m) return;
|
|
1767
|
+
const rid = m[1];
|
|
1768
|
+
const meta = requestMeta.get(`${sid}:${rid}`);
|
|
1769
|
+
requestMeta.delete(`${sid}:${rid}`);
|
|
1770
|
+
log(
|
|
1771
|
+
`UNDELIVERABLE RESPONSE session=${sid.slice(0, 8)}\u2026 request_id=${rid} method=${meta?.method ?? "unknown"} age_ms=${meta ? Date.now() - meta.at : "unknown"} at=${(/* @__PURE__ */ new Date()).toISOString()} \u2014 the answer was produced but its stream was already gone`
|
|
1772
|
+
);
|
|
1773
|
+
} catch {
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
1777
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
1778
|
+
if (req.method === "GET" && url.pathname === "/state-dir") {
|
|
1779
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1780
|
+
res.end(JSON.stringify({ stateDir: STATE_DIR, version: VERSION, compat: OURS_COMPAT_VERSION }));
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (req.method === "GET" && (url.pathname === "/version" || url.pathname === "/info")) {
|
|
1784
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1785
|
+
res.end(
|
|
1786
|
+
JSON.stringify({
|
|
1787
|
+
name: "ours",
|
|
1788
|
+
version: VERSION,
|
|
1789
|
+
compat: OURS_COMPAT_VERSION,
|
|
1790
|
+
protocol: PROTOCOL_VERSION,
|
|
1791
|
+
pid: process.pid,
|
|
1792
|
+
stateDir: STATE_DIR
|
|
1793
|
+
})
|
|
1794
|
+
);
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
if (req.method === "GET" && url.pathname === "/identities") {
|
|
1798
|
+
if (!requireAuth(req, res)) return;
|
|
1799
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1800
|
+
res.end(JSON.stringify({
|
|
1801
|
+
identities: [...identities.values()].map((i) => ({
|
|
1802
|
+
name: i.name,
|
|
1803
|
+
...i.temp ? { temporary: true, stale: !pidAlive(i.temp.owner.pid) } : {}
|
|
1804
|
+
}))
|
|
1805
|
+
}));
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
if (req.method === "GET" && url.pathname === "/unread") {
|
|
1809
|
+
if (!requireAuth(req, res)) return;
|
|
1810
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1811
|
+
res.end(JSON.stringify(unreadSummary()));
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
{
|
|
1815
|
+
const m = /^\/identities\/([^/]+)\/notifications$/.exec(url.pathname);
|
|
1816
|
+
if (req.method === "GET" && m) {
|
|
1817
|
+
if (!requireAuth(req, res)) return;
|
|
1818
|
+
const name = decodeURIComponent(m[1]);
|
|
1819
|
+
if (validateName(name) !== null) {
|
|
1820
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1821
|
+
res.end(JSON.stringify({ error: "invalid identity name" }));
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
await serveNotifications(req, res, name, url.searchParams.get("since"), url.searchParams.get("kinds"));
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
if (req.method === "POST" && url.pathname === "/files/upload") {
|
|
1829
|
+
if (!requireAuth(req, res)) return;
|
|
1830
|
+
const leaseTok = req.headers["x-ours-lease-token"];
|
|
1831
|
+
const lease = typeof leaseTok === "string" && leaseTok ? leaseByToken(leaseTok) : void 0;
|
|
1832
|
+
const id = lease ? identities.get(lease.identity) : void 0;
|
|
1833
|
+
if (!id) {
|
|
1834
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1835
|
+
res.end(JSON.stringify({ error: "no identity bound to this session" }));
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
const lenRaw = req.headers["content-length"];
|
|
1839
|
+
const len = typeof lenRaw === "string" ? Number(lenRaw) : NaN;
|
|
1840
|
+
const hdrName = req.headers["x-ours-filename"];
|
|
1841
|
+
try {
|
|
1842
|
+
const staged = await stageUpload(id, req, {
|
|
1843
|
+
filename: typeof hdrName === "string" ? decodeURIComponent(hdrName) : "",
|
|
1844
|
+
mime: typeof req.headers["content-type"] === "string" ? req.headers["content-type"] : "",
|
|
1845
|
+
declaredBytes: Number.isInteger(len) && len >= 0 ? len : null,
|
|
1846
|
+
cap: resolveFileEnvelopeCap()
|
|
1847
|
+
});
|
|
1848
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1849
|
+
res.end(JSON.stringify(staged));
|
|
1850
|
+
} catch (e) {
|
|
1851
|
+
const status = e instanceof UploadRefused ? e.status : 500;
|
|
1852
|
+
if (!res.headersSent) {
|
|
1853
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1854
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
{
|
|
1860
|
+
const m = /^\/files\/([^/]+)$/.exec(url.pathname);
|
|
1861
|
+
if (req.method === "GET" && m) {
|
|
1862
|
+
if (!requireAuth(req, res)) return;
|
|
1863
|
+
const wireId = decodeURIComponent(m[1]);
|
|
1864
|
+
const leaseTok = req.headers["x-ours-lease-token"];
|
|
1865
|
+
const lease = typeof leaseTok === "string" && leaseTok ? leaseByToken(leaseTok) : void 0;
|
|
1866
|
+
const id = lease ? identities.get(lease.identity) : void 0;
|
|
1867
|
+
if (!id) {
|
|
1868
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1869
|
+
res.end(JSON.stringify({ error: "no identity bound to this session" }));
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
const filePath = findIdentityFile(id, wireId);
|
|
1873
|
+
if (!filePath) {
|
|
1874
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1875
|
+
res.end(JSON.stringify({ error: "no such file for the bound identity (run get_files first, or it belongs to another identity)" }));
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
try {
|
|
1879
|
+
const stat = fs2.statSync(filePath);
|
|
1880
|
+
res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": String(stat.size) });
|
|
1881
|
+
const stream = fs2.createReadStream(filePath);
|
|
1882
|
+
stream.on("error", () => {
|
|
1883
|
+
try {
|
|
1884
|
+
res.destroy();
|
|
1885
|
+
} catch {
|
|
1886
|
+
}
|
|
1887
|
+
});
|
|
1888
|
+
stream.pipe(res);
|
|
1889
|
+
} catch (e) {
|
|
1890
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1891
|
+
res.end(JSON.stringify({ error: `failed to read file: ${String(e)}` }));
|
|
1892
|
+
}
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
if (url.pathname.startsWith(API_PREFIX)) {
|
|
1897
|
+
await serveApiOperation(req, res, url.pathname, readBody);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (url.pathname !== "/mcp" || !mcp) {
|
|
1901
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
1902
|
+
res.end("Not found");
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
if (!requireAuth(req, res)) return;
|
|
1906
|
+
try {
|
|
1907
|
+
if (req.method === "POST") {
|
|
1908
|
+
const body = await readBody(req);
|
|
1909
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1910
|
+
const hdrToken = req.headers["x-ours-lease-token"];
|
|
1911
|
+
const hdrPidRaw = req.headers["x-ours-client-pid"];
|
|
1912
|
+
const hdrPid = hdrPidRaw ? parseInt(hdrPidRaw, 10) : void 0;
|
|
1913
|
+
const headers = { token: hdrToken, pid: Number.isInteger(hdrPid) ? hdrPid : void 0 };
|
|
1914
|
+
if (sessionId && transports[sessionId]) {
|
|
1915
|
+
sessionHeaders.set(sessionId, headers);
|
|
1916
|
+
noteRequestMeta(sessionId, body);
|
|
1917
|
+
enterInflight(sessionId);
|
|
1918
|
+
armSseKeepalive(res);
|
|
1919
|
+
try {
|
|
1920
|
+
await transports[sessionId].handleRequest(req, res, body);
|
|
1921
|
+
} finally {
|
|
1922
|
+
leaveInflight(sessionId);
|
|
1923
|
+
}
|
|
1924
|
+
} else if (!sessionId && isInitialize(body)) {
|
|
1925
|
+
reapDeadSessions();
|
|
1926
|
+
const transport = mcp.createTransport({
|
|
1927
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1928
|
+
onsessioninitialized: (sid) => {
|
|
1929
|
+
transports[sid] = transport;
|
|
1930
|
+
sessionHeaders.set(sid, headers);
|
|
1931
|
+
serversBySession.set(sid, server);
|
|
1932
|
+
mcp.onSessionInitialized?.(sid, server);
|
|
1933
|
+
log(`session ${sid.slice(0, 8)}\u2026 initialized`);
|
|
1934
|
+
}
|
|
1935
|
+
});
|
|
1936
|
+
const ctx = {
|
|
1937
|
+
sessionId: () => transport.sessionId ?? "pending",
|
|
1938
|
+
leaseToken: () => sessionHeaders.get(transport.sessionId ?? "pending")?.token,
|
|
1939
|
+
clientPid: () => sessionHeaders.get(transport.sessionId ?? "pending")?.pid
|
|
1940
|
+
};
|
|
1941
|
+
const server = mcp.createServer(ctx);
|
|
1942
|
+
server.server.onerror = (err) => reportUndeliverable(transport.sessionId ?? "pending", err);
|
|
1943
|
+
transport.onclose = () => {
|
|
1944
|
+
const sid = transport.sessionId;
|
|
1945
|
+
if (sid) {
|
|
1946
|
+
delete transports[sid];
|
|
1947
|
+
serversBySession.delete(sid);
|
|
1948
|
+
sessionHeaders.delete(sid);
|
|
1949
|
+
mcp.onSessionClosed?.(sid);
|
|
1950
|
+
log(`session ${sid.slice(0, 8)}\u2026 closed (lease kept)`);
|
|
1951
|
+
}
|
|
1952
|
+
};
|
|
1953
|
+
await server.connect(transport);
|
|
1954
|
+
armSseKeepalive(res);
|
|
1955
|
+
await transport.handleRequest(req, res, body);
|
|
1956
|
+
} else {
|
|
1957
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1958
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32e3, message: "Bad Request: No valid session ID" }, id: null }));
|
|
1959
|
+
}
|
|
1960
|
+
} else if (req.method === "GET") {
|
|
1961
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1962
|
+
if (!sessionId || !transports[sessionId]) {
|
|
1963
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1964
|
+
res.end("Invalid or missing session ID");
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
enterInflight(sessionId);
|
|
1968
|
+
armSseKeepalive(res);
|
|
1969
|
+
try {
|
|
1970
|
+
await transports[sessionId].handleRequest(req, res);
|
|
1971
|
+
} finally {
|
|
1972
|
+
leaveInflight(sessionId);
|
|
1973
|
+
}
|
|
1974
|
+
} else if (req.method === "DELETE") {
|
|
1975
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1976
|
+
if (!sessionId || !transports[sessionId]) {
|
|
1977
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1978
|
+
res.end("Invalid or missing session ID");
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
const token = sessionHeaders.get(sessionId)?.token ?? req.headers["x-ours-lease-token"];
|
|
1982
|
+
if (token) {
|
|
1983
|
+
for (const [n, l] of [...leases]) {
|
|
1984
|
+
if (l.token !== token) continue;
|
|
1985
|
+
leases.delete(n);
|
|
1986
|
+
const rid = identities.get(n);
|
|
1987
|
+
if (rid?.temp && rid.temp.owner.tokenHash === hashLeaseToken(token) && !rid.temp.closing) {
|
|
1988
|
+
void closeTemporaryIdentity(rid, "owning session released its lease").catch(
|
|
1989
|
+
(err) => log(`[${n}] close on lease release failed:`, String(err))
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
tombstones.delete(token);
|
|
1994
|
+
persistBindings();
|
|
1995
|
+
log(`lease released by token \u2026${token.slice(-6)}`);
|
|
1996
|
+
}
|
|
1997
|
+
await transports[sessionId].handleRequest(req, res);
|
|
1998
|
+
} else {
|
|
1999
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
2000
|
+
res.end("Method not allowed");
|
|
2001
|
+
}
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
log("HTTP handler error:", String(err));
|
|
2004
|
+
if (!res.headersSent) {
|
|
2005
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2006
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }));
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
2010
|
+
httpServer.requestTimeout = 0;
|
|
2011
|
+
let torn = false;
|
|
2012
|
+
const teardown = async () => {
|
|
2013
|
+
if (torn) return;
|
|
2014
|
+
torn = true;
|
|
2015
|
+
clearInterval(sessionReaper);
|
|
2016
|
+
stopGcTimer();
|
|
2017
|
+
if (installedHook) clearNotifyHook(installedHook);
|
|
2018
|
+
for (const sid of Object.keys(transports)) {
|
|
2019
|
+
try {
|
|
2020
|
+
await transports[sid].close();
|
|
2021
|
+
} catch {
|
|
2022
|
+
}
|
|
2023
|
+
delete transports[sid];
|
|
2024
|
+
}
|
|
2025
|
+
serversBySession.clear();
|
|
2026
|
+
for (const id of identities.values()) {
|
|
2027
|
+
try {
|
|
2028
|
+
saveState(id);
|
|
2029
|
+
} catch (err) {
|
|
2030
|
+
log(`[${id.name}] shutdown save failed:`, String(err));
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
httpServer.closeAllConnections?.();
|
|
2034
|
+
};
|
|
2035
|
+
try {
|
|
2036
|
+
await new Promise((resolve2, reject) => {
|
|
2037
|
+
const onError = (err) => reject(err);
|
|
2038
|
+
httpServer.once("error", onError);
|
|
2039
|
+
httpServer.listen(PORT, "127.0.0.1", () => {
|
|
2040
|
+
httpServer.off("error", onError);
|
|
2041
|
+
startupProgress?.ready();
|
|
2042
|
+
log(`MCP server v${opts.version ?? VERSION} ready (transport=http, port=${PORT}, visibility=${API_VISIBILITY}, identities=${identities.size}, state=${STATE_DIR}, broker=${BROKER_URL})`);
|
|
2043
|
+
resolve2();
|
|
2044
|
+
});
|
|
2045
|
+
});
|
|
2046
|
+
} catch (err) {
|
|
2047
|
+
await teardown();
|
|
2048
|
+
throw err;
|
|
2049
|
+
}
|
|
2050
|
+
const shutdown = async () => {
|
|
2051
|
+
log("shutting down\u2026");
|
|
2052
|
+
await teardown();
|
|
2053
|
+
httpServer.close();
|
|
2054
|
+
await opts.onShutdown?.();
|
|
2055
|
+
process.exit(0);
|
|
2056
|
+
};
|
|
2057
|
+
if (handleSignals) {
|
|
2058
|
+
process.on("SIGINT", shutdown);
|
|
2059
|
+
process.on("SIGTERM", shutdown);
|
|
2060
|
+
}
|
|
2061
|
+
const address = httpServer.address();
|
|
2062
|
+
const port = typeof address === "object" && address !== null ? address.port : PORT;
|
|
2063
|
+
return {
|
|
2064
|
+
port,
|
|
2065
|
+
close: async () => {
|
|
2066
|
+
if (handleSignals) {
|
|
2067
|
+
process.off("SIGINT", shutdown);
|
|
2068
|
+
process.off("SIGTERM", shutdown);
|
|
2069
|
+
}
|
|
2070
|
+
await teardown();
|
|
2071
|
+
await new Promise((resolve2) => httpServer.close(() => resolve2()));
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
export {
|
|
2076
|
+
startDaemon
|
|
2077
|
+
};
|