@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.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // src/index.ts
2
- import { randomUUID as randomUUID5 } from "crypto";
3
- import * as fs8 from "fs/promises";
2
+ import { randomUUID as randomUUID6 } from "crypto";
3
+ import * as fs9 from "fs/promises";
4
4
  import * as os from "os";
5
- import * as path7 from "path";
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 fs9 = await import("fs/promises");
1529
- const fd = await fs9.open(filePath, "r");
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 fs7 from "fs";
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: fs7.readFileSync(this.config.tls.cert),
3762
- key: fs7.readFileSync(this.config.tls.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
- path7.join(netDir, "chat_outbox.json"),
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
- path7.join(netDir, "result_outbox.json"),
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
- path7.join(netDir, "relay_notices.json"),
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 fs9 = await import("fs/promises");
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 fs9.mkdir(sessionDir, { recursive: true });
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 fs9.appendFile(transcriptPath, line, "utf-8");
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 = randomUUID5();
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 = randomUUID5();
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 = randomUUID5();
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 fs8.mkdir(netDir, { recursive: true });
6197
+ await fs9.mkdir(netDir, { recursive: true });
5900
6198
  const safeName = (name ?? "guest").slice(0, 64);
5901
6199
  if (pj.requireApproval) {
5902
- await fs8.appendFile(
5903
- path7.join(netDir, "pending_joins.jsonl"),
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 path7.join(this.getNetDir(), "pair_requests.json");
6230
+ return path8.join(this.getNetDir(), "pair_requests.json");
5933
6231
  }
5934
6232
  async loadPairRequests() {
5935
6233
  try {
5936
- const raw = JSON.parse(await fs8.readFile(this.pairRequestsPath(), "utf-8"));
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 fs8.writeFile(
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
- async acceptPair(ref) {
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 path7.resolve(this.projectRoot, path7.dirname(keyPath));
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,