@openvole/volenet 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +127 -4
- package/dist/index.js +361 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { randomUUID as
|
|
3
|
-
import * as
|
|
2
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
3
|
+
import * as fs9 from "fs/promises";
|
|
4
4
|
import * as os from "os";
|
|
5
|
-
import * as
|
|
5
|
+
import * as path8 from "path";
|
|
6
6
|
|
|
7
7
|
// src/chat-outbox.ts
|
|
8
8
|
import * as crypto from "crypto";
|
|
@@ -1525,8 +1525,8 @@ var ChunkDecryptStream = class extends Transform {
|
|
|
1525
1525
|
};
|
|
1526
1526
|
async function scanFrameOffset(filePath, fromChunk, chunkBytes = CHUNK_BYTES_DEFAULT) {
|
|
1527
1527
|
if (fromChunk <= 0) return 0;
|
|
1528
|
-
const
|
|
1529
|
-
const fd = await
|
|
1528
|
+
const fs10 = await import("fs/promises");
|
|
1529
|
+
const fd = await fs10.open(filePath, "r");
|
|
1530
1530
|
try {
|
|
1531
1531
|
const len = Buffer.alloc(LEN_BYTES);
|
|
1532
1532
|
let offset = 0;
|
|
@@ -3202,6 +3202,95 @@ var ResultOutbox = class {
|
|
|
3202
3202
|
}
|
|
3203
3203
|
};
|
|
3204
3204
|
|
|
3205
|
+
// src/rooms.ts
|
|
3206
|
+
import * as crypto9 from "crypto";
|
|
3207
|
+
import * as fs7 from "fs/promises";
|
|
3208
|
+
import * as path7 from "path";
|
|
3209
|
+
var MAX_ROOM_MEMBERS = 64;
|
|
3210
|
+
var EMPTY_ROOM_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
3211
|
+
var RoomStore = class {
|
|
3212
|
+
constructor(file) {
|
|
3213
|
+
this.file = file;
|
|
3214
|
+
}
|
|
3215
|
+
file;
|
|
3216
|
+
rooms = /* @__PURE__ */ new Map();
|
|
3217
|
+
writing = Promise.resolve();
|
|
3218
|
+
async load(now = Date.now()) {
|
|
3219
|
+
try {
|
|
3220
|
+
const raw = JSON.parse(await fs7.readFile(this.file, "utf-8"));
|
|
3221
|
+
for (const r of Array.isArray(raw) ? raw : []) {
|
|
3222
|
+
if (r?.id && typeof r.name === "string" && Array.isArray(r.members)) {
|
|
3223
|
+
this.rooms.set(r.id, { ...r, lastOccupied: r.lastOccupied ?? now });
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
} catch {
|
|
3227
|
+
}
|
|
3228
|
+
await this.sweep(now);
|
|
3229
|
+
}
|
|
3230
|
+
get(id) {
|
|
3231
|
+
return this.rooms.get(id);
|
|
3232
|
+
}
|
|
3233
|
+
list() {
|
|
3234
|
+
return [...this.rooms.values()];
|
|
3235
|
+
}
|
|
3236
|
+
/** Rooms this member belongs to. */
|
|
3237
|
+
forMember(instanceId) {
|
|
3238
|
+
return this.list().filter((r) => r.members.includes(instanceId));
|
|
3239
|
+
}
|
|
3240
|
+
async create(name, creator, topic) {
|
|
3241
|
+
const room = {
|
|
3242
|
+
id: crypto9.randomUUID(),
|
|
3243
|
+
name: name.slice(0, 64) || "room",
|
|
3244
|
+
...topic ? { topic: topic.slice(0, 200) } : {},
|
|
3245
|
+
members: [creator],
|
|
3246
|
+
createdAt: Date.now(),
|
|
3247
|
+
lastOccupied: Date.now()
|
|
3248
|
+
};
|
|
3249
|
+
this.rooms.set(room.id, room);
|
|
3250
|
+
await this.persist();
|
|
3251
|
+
return room;
|
|
3252
|
+
}
|
|
3253
|
+
/** Add a member. The ceiling is the design's edge, so it is refused rather than stretched. */
|
|
3254
|
+
async join(id, instanceId) {
|
|
3255
|
+
const room = this.rooms.get(id);
|
|
3256
|
+
if (!room) return "no-such-room";
|
|
3257
|
+
if (room.members.includes(instanceId)) return room;
|
|
3258
|
+
if (room.members.length >= MAX_ROOM_MEMBERS) return "full";
|
|
3259
|
+
room.members.push(instanceId);
|
|
3260
|
+
room.lastOccupied = Date.now();
|
|
3261
|
+
await this.persist();
|
|
3262
|
+
return room;
|
|
3263
|
+
}
|
|
3264
|
+
async leave(id, instanceId) {
|
|
3265
|
+
const room = this.rooms.get(id);
|
|
3266
|
+
if (!room) return "no-such-room";
|
|
3267
|
+
if (!room.members.includes(instanceId)) return "not-a-member";
|
|
3268
|
+
room.members = room.members.filter((m) => m !== instanceId);
|
|
3269
|
+
if (room.members.length > 0) room.lastOccupied = Date.now();
|
|
3270
|
+
await this.persist();
|
|
3271
|
+
return room;
|
|
3272
|
+
}
|
|
3273
|
+
/** Drop rooms nobody has been in for the TTL. */
|
|
3274
|
+
async sweep(now = Date.now()) {
|
|
3275
|
+
const gone = this.list().filter(
|
|
3276
|
+
(r) => r.members.length === 0 && now - r.lastOccupied > EMPTY_ROOM_TTL_MS
|
|
3277
|
+
);
|
|
3278
|
+
if (gone.length === 0) return [];
|
|
3279
|
+
for (const r of gone) this.rooms.delete(r.id);
|
|
3280
|
+
await this.persist();
|
|
3281
|
+
return gone;
|
|
3282
|
+
}
|
|
3283
|
+
persist() {
|
|
3284
|
+
this.writing = this.writing.then(async () => {
|
|
3285
|
+
await fs7.mkdir(path7.dirname(this.file), { recursive: true });
|
|
3286
|
+
const tmp = `${this.file}.tmp`;
|
|
3287
|
+
await fs7.writeFile(tmp, JSON.stringify(this.list(), null, 2), "utf-8");
|
|
3288
|
+
await fs7.rename(tmp, this.file);
|
|
3289
|
+
});
|
|
3290
|
+
return this.writing;
|
|
3291
|
+
}
|
|
3292
|
+
};
|
|
3293
|
+
|
|
3205
3294
|
// src/sync.ts
|
|
3206
3295
|
var logger7 = createLogger("volenet-sync");
|
|
3207
3296
|
var VoleNetSync = class {
|
|
@@ -3458,7 +3547,7 @@ function isControlPlanePaw(pawName) {
|
|
|
3458
3547
|
}
|
|
3459
3548
|
|
|
3460
3549
|
// src/transport.ts
|
|
3461
|
-
import * as
|
|
3550
|
+
import * as fs8 from "fs";
|
|
3462
3551
|
import * as http from "http";
|
|
3463
3552
|
import * as https from "https";
|
|
3464
3553
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -3758,8 +3847,8 @@ var VoleNetTransport = class {
|
|
|
3758
3847
|
if (this.config.tls) {
|
|
3759
3848
|
this.server = https.createServer(
|
|
3760
3849
|
{
|
|
3761
|
-
cert:
|
|
3762
|
-
key:
|
|
3850
|
+
cert: fs8.readFileSync(this.config.tls.cert),
|
|
3851
|
+
key: fs8.readFileSync(this.config.tls.key)
|
|
3763
3852
|
},
|
|
3764
3853
|
requestHandler
|
|
3765
3854
|
);
|
|
@@ -3851,6 +3940,14 @@ var VoleNetTransport = class {
|
|
|
3851
3940
|
this.startPinging();
|
|
3852
3941
|
}
|
|
3853
3942
|
/** Bind the listening port, retrying briefly on EADDRINUSE (covers restart races). */
|
|
3943
|
+
/**
|
|
3944
|
+
* The port actually bound, which is not always the one configured — a host may pass 0 and let
|
|
3945
|
+
* the OS choose. Null before the server is listening.
|
|
3946
|
+
*/
|
|
3947
|
+
getPort() {
|
|
3948
|
+
const addr = this.server?.address();
|
|
3949
|
+
return addr && typeof addr === "object" ? addr.port : null;
|
|
3950
|
+
}
|
|
3854
3951
|
async listen() {
|
|
3855
3952
|
const maxAttempts = 5;
|
|
3856
3953
|
const retryDelayMs = 300;
|
|
@@ -4286,6 +4383,10 @@ var VoleNetManager = class {
|
|
|
4286
4383
|
relayNotices = null;
|
|
4287
4384
|
/** Brain answers whose asker had gone by the time they were ready — see result-outbox.ts. */
|
|
4288
4385
|
resultOutbox = null;
|
|
4386
|
+
/** Hub side: who is in which room (§7c). Only a relay hub keeps these. */
|
|
4387
|
+
roomStore = null;
|
|
4388
|
+
/** Member side: rooms this node is in, as the hub last described them. */
|
|
4389
|
+
rooms = /* @__PURE__ */ new Map();
|
|
4289
4390
|
/** Peers a flush is already running for, so a burst of pings does not send an answer twice. */
|
|
4290
4391
|
flushingResults = /* @__PURE__ */ new Set();
|
|
4291
4392
|
/** The polling timers waiting on delegated tasks, so stopping cancels them. */
|
|
@@ -4336,18 +4437,22 @@ var VoleNetManager = class {
|
|
|
4336
4437
|
}
|
|
4337
4438
|
const hoursToMs = (h, fallback) => typeof h === "number" && h > 0 ? h * 60 * 60 * 1e3 : fallback;
|
|
4338
4439
|
this.chatOutbox = new ChatOutbox(
|
|
4339
|
-
|
|
4440
|
+
path8.join(netDir, "chat_outbox.json"),
|
|
4340
4441
|
hoursToMs(this.config.relay?.outboxTtlHours, DEFAULT_OUTBOX_TTL_MS)
|
|
4341
4442
|
);
|
|
4342
4443
|
await this.chatOutbox.load().catch(() => void 0);
|
|
4343
4444
|
this.resultOutbox = new ResultOutbox(
|
|
4344
|
-
|
|
4445
|
+
path8.join(netDir, "result_outbox.json"),
|
|
4345
4446
|
hoursToMs(this.config.relay?.outboxTtlHours, DEFAULT_RESULT_TTL_MS)
|
|
4346
4447
|
);
|
|
4347
4448
|
await this.resultOutbox.load().catch(() => void 0);
|
|
4449
|
+
if (this.config.relay?.enabled) {
|
|
4450
|
+
this.roomStore = new RoomStore(path8.join(netDir, "rooms.json"));
|
|
4451
|
+
await this.roomStore.load().catch(() => void 0);
|
|
4452
|
+
}
|
|
4348
4453
|
if (this.config.relay?.enabled) {
|
|
4349
4454
|
this.relayNotices = new RelayNotices(
|
|
4350
|
-
|
|
4455
|
+
path8.join(netDir, "relay_notices.json"),
|
|
4351
4456
|
hoursToMs(this.config.relay?.noticeTtlHours, DEFAULT_NOTICE_TTL_MS)
|
|
4352
4457
|
);
|
|
4353
4458
|
await this.relayNotices.load().catch(() => void 0);
|
|
@@ -4803,6 +4908,49 @@ var VoleNetManager = class {
|
|
|
4803
4908
|
if (!payload?.from || !payload.box) return;
|
|
4804
4909
|
this.deliverSealed(payload.from, payload.box, messageBus, message.from);
|
|
4805
4910
|
});
|
|
4911
|
+
this.transport.onMessage((message) => {
|
|
4912
|
+
if (!message.type.startsWith("room:") || !this.roomStore || !this.keyPair) return;
|
|
4913
|
+
if (message.type === "room:info" || message.type === "room:members") return;
|
|
4914
|
+
if (message.type === "room:list:response" || message.type === "room:error") return;
|
|
4915
|
+
if (!this.discovery?.verifyMessageFrom(message)) return;
|
|
4916
|
+
void this.handleRoomCommand(message);
|
|
4917
|
+
});
|
|
4918
|
+
this.transport.onMessage((message) => {
|
|
4919
|
+
if (message.type !== "room:info" && message.type !== "room:members") return;
|
|
4920
|
+
if (!this.discovery?.verifyMessageFrom(message)) return;
|
|
4921
|
+
const info = message.payload;
|
|
4922
|
+
if (!info?.room || !Array.isArray(info.members)) return;
|
|
4923
|
+
const mine = this.keyPair?.instanceId;
|
|
4924
|
+
if (mine && !info.members.some((m) => m.instanceId === mine)) {
|
|
4925
|
+
this.rooms.delete(info.room);
|
|
4926
|
+
messageBus?.emit("volenet:room:members", {
|
|
4927
|
+
room: info.room,
|
|
4928
|
+
name: info.name,
|
|
4929
|
+
members: []
|
|
4930
|
+
});
|
|
4931
|
+
return;
|
|
4932
|
+
}
|
|
4933
|
+
this.rooms.set(info.room, info);
|
|
4934
|
+
const roster = this.hubRosters.get(message.from) ?? /* @__PURE__ */ new Map();
|
|
4935
|
+
for (const m of info.members) {
|
|
4936
|
+
if (!m?.instanceId || m.instanceId === mine) continue;
|
|
4937
|
+
if (roster.has(m.instanceId)) continue;
|
|
4938
|
+
roster.set(m.instanceId, {
|
|
4939
|
+
instanceId: m.instanceId,
|
|
4940
|
+
name: m.name,
|
|
4941
|
+
publicKey: m.publicKey,
|
|
4942
|
+
xPublicKey: m.xPublicKey,
|
|
4943
|
+
mlkemPublicKey: m.mlkemPublicKey,
|
|
4944
|
+
connected: false
|
|
4945
|
+
});
|
|
4946
|
+
}
|
|
4947
|
+
this.hubRosters.set(message.from, roster);
|
|
4948
|
+
messageBus?.emit("volenet:room:members", {
|
|
4949
|
+
room: info.room,
|
|
4950
|
+
name: info.name,
|
|
4951
|
+
members: info.members.map((m) => ({ instanceId: m.instanceId, name: m.name }))
|
|
4952
|
+
});
|
|
4953
|
+
});
|
|
4806
4954
|
this.transport.onMessage((message) => {
|
|
4807
4955
|
if (message.type !== "roster") return;
|
|
4808
4956
|
if (!this.discovery?.verifyMessageFrom(message)) return;
|
|
@@ -4916,7 +5064,7 @@ var VoleNetManager = class {
|
|
|
4916
5064
|
syncConfig
|
|
4917
5065
|
);
|
|
4918
5066
|
this.sync.setSessionWriteHandler(async (entry) => {
|
|
4919
|
-
const
|
|
5067
|
+
const fs10 = await import("fs/promises");
|
|
4920
5068
|
const pathMod = await import("path");
|
|
4921
5069
|
const sessionDir = pathMod.resolve(
|
|
4922
5070
|
this.projectRoot,
|
|
@@ -4925,12 +5073,12 @@ var VoleNetManager = class {
|
|
|
4925
5073
|
"paw-session",
|
|
4926
5074
|
entry.sessionId.replace(/[/\\]/g, "_")
|
|
4927
5075
|
);
|
|
4928
|
-
await
|
|
5076
|
+
await fs10.mkdir(sessionDir, { recursive: true });
|
|
4929
5077
|
const transcriptPath = pathMod.join(sessionDir, "transcript.md");
|
|
4930
5078
|
const timestamp = new Date(entry.timestamp).toTimeString().slice(0, 8);
|
|
4931
5079
|
const line = `[${timestamp}] ${entry.role}: ${entry.content.replace(/\n/g, " ").substring(0, 2e3)}
|
|
4932
5080
|
`;
|
|
4933
|
-
await
|
|
5081
|
+
await fs10.appendFile(transcriptPath, line, "utf-8");
|
|
4934
5082
|
logger9.info(
|
|
4935
5083
|
`Session sync received: ${entry.sessionId} \u2014 ${entry.role} from ${entry.instanceId.substring(0, 8)}`
|
|
4936
5084
|
);
|
|
@@ -5290,16 +5438,16 @@ var VoleNetManager = class {
|
|
|
5290
5438
|
* outbox and leaves when the member reappears in a roster. A hub too old to give a verdict
|
|
5291
5439
|
* is treated the way it always was — the write to the hub counts as the delivery.
|
|
5292
5440
|
*/
|
|
5293
|
-
async sendChatViaRelay(peerRef, text) {
|
|
5441
|
+
async sendChatViaRelay(peerRef, text, opts) {
|
|
5294
5442
|
const fromName = this.getInstanceName();
|
|
5295
5443
|
const sentAt = Date.now();
|
|
5296
|
-
const ref =
|
|
5444
|
+
const ref = randomUUID6();
|
|
5297
5445
|
const member = this.resolveRelayPeer(peerRef);
|
|
5298
5446
|
const verdict = this.awaitRelayVerdict(ref, member?.instanceId ?? peerRef);
|
|
5299
5447
|
const r = await this.sealToMemberViaRelay(
|
|
5300
5448
|
peerRef,
|
|
5301
5449
|
"chat:message",
|
|
5302
|
-
{ text, fromName, sentAt },
|
|
5450
|
+
{ text, fromName, sentAt, ...opts?.room ? { room: opts.room } : {} },
|
|
5303
5451
|
ref
|
|
5304
5452
|
);
|
|
5305
5453
|
if (!r.ok || !r.member || !r.inner) {
|
|
@@ -5391,7 +5539,7 @@ var VoleNetManager = class {
|
|
|
5391
5539
|
if (stillAway.has(entry.to)) continue;
|
|
5392
5540
|
const member = this.resolveRelayPeer(entry.to);
|
|
5393
5541
|
if (!member?.connected) continue;
|
|
5394
|
-
const ref =
|
|
5542
|
+
const ref = randomUUID6();
|
|
5395
5543
|
const verdict = this.awaitRelayVerdict(ref, entry.to);
|
|
5396
5544
|
const kind = entry.kind ?? "chat";
|
|
5397
5545
|
const fromName = this.getInstanceName();
|
|
@@ -5496,7 +5644,7 @@ var VoleNetManager = class {
|
|
|
5496
5644
|
*/
|
|
5497
5645
|
async sendConsentViaRelay(peerRef, kind, payload, note) {
|
|
5498
5646
|
const member = this.resolveRelayPeer(peerRef);
|
|
5499
|
-
const ref =
|
|
5647
|
+
const ref = randomUUID6();
|
|
5500
5648
|
const verdict = this.awaitRelayVerdict(ref, member?.instanceId ?? peerRef);
|
|
5501
5649
|
const r = await this.sealToMemberViaRelay(peerRef, `relay:${kind}`, payload, ref);
|
|
5502
5650
|
if (!r.ok || !r.member) {
|
|
@@ -5662,7 +5810,11 @@ var VoleNetManager = class {
|
|
|
5662
5810
|
text: payload.text,
|
|
5663
5811
|
messageId: inner.id,
|
|
5664
5812
|
timestamp: sentAt,
|
|
5665
|
-
relayed: true
|
|
5813
|
+
relayed: true,
|
|
5814
|
+
// Present when this was a room post, so a client files it under the room rather than
|
|
5815
|
+
// as a private conversation. A client that ignores it sees a direct message from
|
|
5816
|
+
// someone it has already consented to — degraded, never silently dropped.
|
|
5817
|
+
...payload.room ? { room: payload.room } : {}
|
|
5666
5818
|
});
|
|
5667
5819
|
}
|
|
5668
5820
|
/** True when a relay sender may reach me: '*' policy, an acceptFrom match, or a prior approval. */
|
|
@@ -5711,6 +5863,152 @@ var VoleNetManager = class {
|
|
|
5711
5863
|
return void 0;
|
|
5712
5864
|
}
|
|
5713
5865
|
/** Hub: push the current member directory to every connected member. */
|
|
5866
|
+
// ── Rooms: a member's side ───────────────────────────────────────────────────────────
|
|
5867
|
+
/** Rooms this node is in, as its hub last described them. */
|
|
5868
|
+
getRooms() {
|
|
5869
|
+
return [...this.rooms.values()];
|
|
5870
|
+
}
|
|
5871
|
+
/** Ask a hub to make a room, join one, leave one, invite to one, or list what it has. */
|
|
5872
|
+
async roomCommand(hubRef, type, payload) {
|
|
5873
|
+
if (!this.keyPair || !this.transport) return { ok: false, error: "VoleNet not started" };
|
|
5874
|
+
const hub = this.getInstances().find(
|
|
5875
|
+
(i) => i.id === hubRef || i.name === hubRef || i.id.startsWith(hubRef)
|
|
5876
|
+
);
|
|
5877
|
+
if (!hub) return { ok: false, error: `no hub found: "${hubRef}"` };
|
|
5878
|
+
const sent = await this.transport.sendToPeer(
|
|
5879
|
+
hub.id,
|
|
5880
|
+
createMessage(
|
|
5881
|
+
type,
|
|
5882
|
+
this.keyPair.instanceId,
|
|
5883
|
+
hub.id,
|
|
5884
|
+
payload,
|
|
5885
|
+
this.keyPair.privateKey,
|
|
5886
|
+
this.keyPair.pqPrivateKey
|
|
5887
|
+
)
|
|
5888
|
+
);
|
|
5889
|
+
return sent ? { ok: true } : { ok: false, error: "could not reach that hub" };
|
|
5890
|
+
}
|
|
5891
|
+
/**
|
|
5892
|
+
* Post to a room: one sealed copy per member.
|
|
5893
|
+
*
|
|
5894
|
+
* There is no room key, so this is the fan-out itself — the same `chat:message` sealed to each
|
|
5895
|
+
* member in turn (§7c). Everything §7 gives a private message therefore applies to each copy:
|
|
5896
|
+
* a member who is away has theirs held in this node's outbox and delivered when they return,
|
|
5897
|
+
* and consent still gates whether they accept anything from us at all.
|
|
5898
|
+
*/
|
|
5899
|
+
async postToRoom(roomId, text) {
|
|
5900
|
+
const room = this.rooms.get(roomId);
|
|
5901
|
+
if (!room) return { ok: false, sent: 0, held: 0, skipped: 0, error: "not in that room" };
|
|
5902
|
+
if (room.members.length > MAX_ROOM_MEMBERS) {
|
|
5903
|
+
return { ok: false, sent: 0, held: 0, skipped: 0, error: "room is over the member limit" };
|
|
5904
|
+
}
|
|
5905
|
+
let sent = 0;
|
|
5906
|
+
let held = 0;
|
|
5907
|
+
let skipped = 0;
|
|
5908
|
+
for (const m of room.members) {
|
|
5909
|
+
if (m.instanceId === this.keyPair?.instanceId) continue;
|
|
5910
|
+
const res = await this.sendChatViaRelay(m.instanceId, text, { room: roomId });
|
|
5911
|
+
if (!res.ok) skipped++;
|
|
5912
|
+
else if (res.queued) held++;
|
|
5913
|
+
else sent++;
|
|
5914
|
+
}
|
|
5915
|
+
return { ok: true, sent, held, skipped };
|
|
5916
|
+
}
|
|
5917
|
+
// ── Rooms: the hub's side ────────────────────────────────────────────────────────────
|
|
5918
|
+
/** Act on a member's room command and answer it. */
|
|
5919
|
+
async handleRoomCommand(message) {
|
|
5920
|
+
const store = this.roomStore;
|
|
5921
|
+
if (!store || !this.keyPair) return;
|
|
5922
|
+
const from = message.from;
|
|
5923
|
+
const p = message.payload ?? {};
|
|
5924
|
+
const fail = (reason, room) => this.sendToMember(from, "room:error", { ...room ? { room } : {}, reason });
|
|
5925
|
+
switch (message.type) {
|
|
5926
|
+
case "room:create": {
|
|
5927
|
+
const room = await store.create(p.name ?? "room", from, p.topic);
|
|
5928
|
+
this.sendToMember(from, "room:info", this.describeRoom(room));
|
|
5929
|
+
return;
|
|
5930
|
+
}
|
|
5931
|
+
case "room:list": {
|
|
5932
|
+
this.sendToMember(from, "room:list:response", {
|
|
5933
|
+
rooms: store.list().map((r) => ({
|
|
5934
|
+
room: r.id,
|
|
5935
|
+
name: r.name,
|
|
5936
|
+
topic: r.topic,
|
|
5937
|
+
members: r.members.length,
|
|
5938
|
+
joined: r.members.includes(from)
|
|
5939
|
+
}))
|
|
5940
|
+
});
|
|
5941
|
+
return;
|
|
5942
|
+
}
|
|
5943
|
+
case "room:join": {
|
|
5944
|
+
if (!p.room) return fail("no-such-room");
|
|
5945
|
+
const res = await store.join(p.room, from);
|
|
5946
|
+
if (typeof res === "string") return fail(res, p.room);
|
|
5947
|
+
this.sendToMember(from, "room:info", this.describeRoom(res));
|
|
5948
|
+
this.pushRoomMembers(res);
|
|
5949
|
+
return;
|
|
5950
|
+
}
|
|
5951
|
+
case "room:leave": {
|
|
5952
|
+
if (!p.room) return fail("no-such-room");
|
|
5953
|
+
const res = await store.leave(p.room, from);
|
|
5954
|
+
if (typeof res === "string") return fail(res, p.room);
|
|
5955
|
+
this.sendToMember(from, "room:info", this.describeRoom(res));
|
|
5956
|
+
this.pushRoomMembers(res);
|
|
5957
|
+
return;
|
|
5958
|
+
}
|
|
5959
|
+
case "room:invite": {
|
|
5960
|
+
if (!p.room || !p.member) return fail("no-such-room", p.room);
|
|
5961
|
+
const room = store.get(p.room);
|
|
5962
|
+
if (!room) return fail("no-such-room", p.room);
|
|
5963
|
+
if (!room.members.includes(from)) return fail("not-a-member", p.room);
|
|
5964
|
+
const res = await store.join(p.room, p.member);
|
|
5965
|
+
if (typeof res === "string") return fail(res, p.room);
|
|
5966
|
+
this.sendToMember(from, "room:info", this.describeRoom(res));
|
|
5967
|
+
this.sendToMember(p.member, "room:info", this.describeRoom(res));
|
|
5968
|
+
this.pushRoomMembers(res);
|
|
5969
|
+
return;
|
|
5970
|
+
}
|
|
5971
|
+
default:
|
|
5972
|
+
return;
|
|
5973
|
+
}
|
|
5974
|
+
}
|
|
5975
|
+
/** A room as its members need to see it: ids *and keys*, since a sender fans out itself. */
|
|
5976
|
+
describeRoom(room) {
|
|
5977
|
+
const known = new Map(this.discovery?.getInstances().map((i) => [i.id, i]) ?? []);
|
|
5978
|
+
const members = [];
|
|
5979
|
+
for (const id of room.members) {
|
|
5980
|
+
const i = known.get(id);
|
|
5981
|
+
if (!i) continue;
|
|
5982
|
+
members.push({
|
|
5983
|
+
instanceId: i.id,
|
|
5984
|
+
name: i.name,
|
|
5985
|
+
publicKey: i.publicKey,
|
|
5986
|
+
xPublicKey: i.xPublicKey,
|
|
5987
|
+
mlkemPublicKey: i.mlkemPublicKey
|
|
5988
|
+
});
|
|
5989
|
+
}
|
|
5990
|
+
return { room: room.id, name: room.name, topic: room.topic, members };
|
|
5991
|
+
}
|
|
5992
|
+
/** Tell everyone in a room who is in it now. */
|
|
5993
|
+
pushRoomMembers(room) {
|
|
5994
|
+
const info = this.describeRoom(room);
|
|
5995
|
+
for (const id of room.members) this.sendToMember(id, "room:members", info);
|
|
5996
|
+
}
|
|
5997
|
+
/** Signed, unsealed, straight to one member. Room control is hub business, not private. */
|
|
5998
|
+
sendToMember(to, type, payload) {
|
|
5999
|
+
if (!this.keyPair || !this.transport) return;
|
|
6000
|
+
void this.transport.sendToPeer(
|
|
6001
|
+
to,
|
|
6002
|
+
createMessage(
|
|
6003
|
+
type,
|
|
6004
|
+
this.keyPair.instanceId,
|
|
6005
|
+
to,
|
|
6006
|
+
payload,
|
|
6007
|
+
this.keyPair.privateKey,
|
|
6008
|
+
this.keyPair.pqPrivateKey
|
|
6009
|
+
)
|
|
6010
|
+
);
|
|
6011
|
+
}
|
|
5714
6012
|
broadcastRoster() {
|
|
5715
6013
|
if (!this.keyPair || !this.transport || !this.discovery) return;
|
|
5716
6014
|
const connected = new Set(
|
|
@@ -5896,11 +6194,11 @@ var VoleNetManager = class {
|
|
|
5896
6194
|
recent.push(now);
|
|
5897
6195
|
this.joinTimestamps.set(ip, recent);
|
|
5898
6196
|
const netDir = this.getNetDir();
|
|
5899
|
-
await
|
|
6197
|
+
await fs9.mkdir(netDir, { recursive: true });
|
|
5900
6198
|
const safeName = (name ?? "guest").slice(0, 64);
|
|
5901
6199
|
if (pj.requireApproval) {
|
|
5902
|
-
await
|
|
5903
|
-
|
|
6200
|
+
await fs9.appendFile(
|
|
6201
|
+
path8.join(netDir, "pending_joins.jsonl"),
|
|
5904
6202
|
`${JSON.stringify({ publicKey, name: safeName, ip, at: (/* @__PURE__ */ new Date()).toISOString() })}
|
|
5905
6203
|
`,
|
|
5906
6204
|
"utf-8"
|
|
@@ -5929,18 +6227,18 @@ var VoleNetManager = class {
|
|
|
5929
6227
|
}
|
|
5930
6228
|
// ── Consent-based pairing (vole net pair) ─────────────────────────────────
|
|
5931
6229
|
pairRequestsPath() {
|
|
5932
|
-
return
|
|
6230
|
+
return path8.join(this.getNetDir(), "pair_requests.json");
|
|
5933
6231
|
}
|
|
5934
6232
|
async loadPairRequests() {
|
|
5935
6233
|
try {
|
|
5936
|
-
const raw = JSON.parse(await
|
|
6234
|
+
const raw = JSON.parse(await fs9.readFile(this.pairRequestsPath(), "utf-8"));
|
|
5937
6235
|
for (const r of raw) if (r?.id && r.publicKey) this.pairRequests.set(r.id, r);
|
|
5938
6236
|
} catch {
|
|
5939
6237
|
}
|
|
5940
6238
|
}
|
|
5941
6239
|
async persistPairRequests() {
|
|
5942
6240
|
try {
|
|
5943
|
-
await
|
|
6241
|
+
await fs9.writeFile(
|
|
5944
6242
|
this.pairRequestsPath(),
|
|
5945
6243
|
JSON.stringify([...this.pairRequests.values()], null, 2)
|
|
5946
6244
|
);
|
|
@@ -5950,7 +6248,7 @@ var VoleNetManager = class {
|
|
|
5950
6248
|
}
|
|
5951
6249
|
/** Handle POST /volenet/pair — queue the introduction; trust NOTHING until acceptPair. */
|
|
5952
6250
|
async handlePairRequest(body, ip, bus) {
|
|
5953
|
-
const { publicKey, name, note, endpoint } = body ?? {};
|
|
6251
|
+
const { publicKey, name, note, endpoint, wants } = body ?? {};
|
|
5954
6252
|
if (!publicKey || !parsePublicKey(publicKey)) {
|
|
5955
6253
|
return { status: 400, json: { error: "invalid public key" } };
|
|
5956
6254
|
}
|
|
@@ -5979,6 +6277,9 @@ var VoleNetManager = class {
|
|
|
5979
6277
|
publicKey,
|
|
5980
6278
|
endpoint: typeof endpoint === "string" ? endpoint.slice(0, 200) : void 0,
|
|
5981
6279
|
note: typeof note === "string" ? note.slice(0, 200) : void 0,
|
|
6280
|
+
// Only what this node understands: an unknown ask is dropped rather than stored, so a
|
|
6281
|
+
// request cannot smuggle a permission past an operator who never saw it named.
|
|
6282
|
+
wants: Array.isArray(wants) ? wants.filter((w) => w === "brain").slice(0, 4) : void 0,
|
|
5982
6283
|
ts: now
|
|
5983
6284
|
});
|
|
5984
6285
|
await this.persistPairRequests();
|
|
@@ -6001,7 +6302,15 @@ var VoleNetManager = class {
|
|
|
6001
6302
|
return [...this.pairRequests.values()].map(({ publicKey: _pk, ...rest }) => rest);
|
|
6002
6303
|
}
|
|
6003
6304
|
/** Operator consent: trust the requester's pinned key, live-reload, dial back if possible. */
|
|
6004
|
-
|
|
6305
|
+
/**
|
|
6306
|
+
* Accept a pair request, and optionally grant what it asked for in the same act.
|
|
6307
|
+
*
|
|
6308
|
+
* Trusting a key and saying what that key may do were two steps in two places — the second a
|
|
6309
|
+
* hand-edited config file and a restart — which is why a paired peer so often sat there unable
|
|
6310
|
+
* to do the thing it was paired for. A grant writes a `net.peers` entry naming the peer's
|
|
6311
|
+
* identity, live, so it applies without a restart, and asks the host to remember it.
|
|
6312
|
+
*/
|
|
6313
|
+
async acceptPair(ref, grant) {
|
|
6005
6314
|
const req = [...this.pairRequests.values()].find(
|
|
6006
6315
|
(r) => r.id === ref || r.name === ref || r.id.startsWith(ref)
|
|
6007
6316
|
);
|
|
@@ -6013,10 +6322,27 @@ var VoleNetManager = class {
|
|
|
6013
6322
|
if (req.endpoint) {
|
|
6014
6323
|
await this.addPeerEntry(req.endpoint);
|
|
6015
6324
|
}
|
|
6325
|
+
if (grant && (grant.trust || grant.allowBrain)) {
|
|
6326
|
+
const entry = {
|
|
6327
|
+
id: req.id,
|
|
6328
|
+
name: req.name,
|
|
6329
|
+
trust: grant.trust ?? "read",
|
|
6330
|
+
...grant.allowBrain ? { allowBrain: true } : {}
|
|
6331
|
+
};
|
|
6332
|
+
this.config.peers = this.config.peers ?? [];
|
|
6333
|
+
const at = this.config.peers.findIndex((p) => p.id === req.id);
|
|
6334
|
+
if (at >= 0) this.config.peers[at] = { ...this.config.peers[at], ...entry };
|
|
6335
|
+
else this.config.peers.push(entry);
|
|
6336
|
+
try {
|
|
6337
|
+
await this.config.persistPeerEntry?.(entry);
|
|
6338
|
+
} catch (err) {
|
|
6339
|
+
logger9.warn(`Could not persist peer entry: ${err instanceof Error ? err.message : err}`);
|
|
6340
|
+
}
|
|
6341
|
+
}
|
|
6016
6342
|
logger9.info(
|
|
6017
6343
|
`Pair accepted: "${req.name}" (${req.id.substring(0, 8)}) is now trusted${req.endpoint ? ` and saved as a peer (${req.endpoint})` : " (no endpoint advertised \u2014 it must connect to us)"}`
|
|
6018
6344
|
);
|
|
6019
|
-
return { ok: true, name: req.name };
|
|
6345
|
+
return { ok: true, name: req.name, ...grant ? { granted: grant } : {} };
|
|
6020
6346
|
}
|
|
6021
6347
|
async denyPair(ref) {
|
|
6022
6348
|
const req = [...this.pairRequests.values()].find(
|
|
@@ -6090,7 +6416,7 @@ var VoleNetManager = class {
|
|
|
6090
6416
|
* fingerprint client-side), persist + dial the peer, and file the pair request for
|
|
6091
6417
|
* the other operator. Fully live — no restart needed on this side.
|
|
6092
6418
|
*/
|
|
6093
|
-
async initiatePair(url, publicKey, note) {
|
|
6419
|
+
async initiatePair(url, publicKey, note, wants) {
|
|
6094
6420
|
if (!this.keyPair) return { ok: false, error: "VoleNet not started" };
|
|
6095
6421
|
const base = url.replace(/\/$/, "");
|
|
6096
6422
|
if (!parsePublicKey(publicKey)) return { ok: false, error: "invalid public key" };
|
|
@@ -6105,6 +6431,7 @@ var VoleNetManager = class {
|
|
|
6105
6431
|
publicKey: this.keyPair.publicKeyString,
|
|
6106
6432
|
name: this.config.instanceName ?? "vole",
|
|
6107
6433
|
note,
|
|
6434
|
+
...wants?.length ? { wants } : {},
|
|
6108
6435
|
endpoint: this.transport ? buildAdvertisedEndpoint({
|
|
6109
6436
|
publicUrl: this.config.publicUrl ?? process.env.VOLE_NET_PUBLIC_URL,
|
|
6110
6437
|
tls: !!this.config.tls,
|
|
@@ -6265,7 +6592,7 @@ var VoleNetManager = class {
|
|
|
6265
6592
|
}
|
|
6266
6593
|
getNetDir() {
|
|
6267
6594
|
const keyPath = this.config.keyPath ?? ".openvole/net/vole_key";
|
|
6268
|
-
return
|
|
6595
|
+
return path8.resolve(this.projectRoot, path8.dirname(keyPath));
|
|
6269
6596
|
}
|
|
6270
6597
|
getHostname() {
|
|
6271
6598
|
const override = this.config.hostname ?? process.env.VOLE_NET_HOSTNAME;
|
|
@@ -6326,10 +6653,13 @@ export {
|
|
|
6326
6653
|
DEFAULT_NOTICE_TTL_MS,
|
|
6327
6654
|
DEFAULT_OUTBOX_TTL_MS,
|
|
6328
6655
|
DEFAULT_RESULT_TTL_MS,
|
|
6656
|
+
EMPTY_ROOM_TTL_MS,
|
|
6329
6657
|
MAX_RESULTS_PER_PEER,
|
|
6658
|
+
MAX_ROOM_MEMBERS,
|
|
6330
6659
|
RelayNotices,
|
|
6331
6660
|
RemoteTaskManager,
|
|
6332
6661
|
ResultOutbox,
|
|
6662
|
+
RoomStore,
|
|
6333
6663
|
VoleNetDiscovery,
|
|
6334
6664
|
VoleNetLeader,
|
|
6335
6665
|
VoleNetManager,
|