@openvole/volenet 1.0.0 → 1.1.1

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 CHANGED
@@ -8,19 +8,29 @@ npm install @openvole/volenet
8
8
  ```
9
9
 
10
10
  ```ts
11
- import { VoleNetManager, createEventBus, generateKeyPair } from '@openvole/volenet'
11
+ import { VoleNetManager, createEventBus } from '@openvole/volenet'
12
12
 
13
13
  const bus = createEventBus()
14
- bus.on('volenet:chat', (m) => console.log(`${m.fromName}: ${m.text}`))
14
+ // `room` is present when this arrived as a room post rather than a private message.
15
+ bus.on('volenet:chat', (m) => console.log(`${m.room ?? 'dm'} ${m.fromName}: ${m.text}`))
15
16
 
16
17
  const net = new VoleNetManager(
17
18
  { enabled: true, instanceName: 'my-node', port: 9700, keyPath: './data/net/vole_key' },
18
19
  './data',
19
20
  )
20
21
  await net.start(undefined, bus)
22
+
21
23
  await net.sendChat('some-peer', 'hello')
24
+
25
+ // A room, through a hub that reads none of it (§7c).
26
+ await net.roomCommand('my-hub', 'room:create', { name: 'valley' })
27
+ const [room] = net.getRooms()
28
+ await net.postToRoom(room.room, 'morning') // → { sent, held, skipped }
22
29
  ```
23
30
 
31
+ An identity is generated on first start if `keyPath` has none. `generateKeyPair`, `trustPeer` and
32
+ the rest are exported for anything that manages keys itself.
33
+
24
34
  ## What it gives you
25
35
 
26
36
  - **Identity that is a keypair, not an account.** Hybrid Ed25519 + ML-DSA-65 signatures, so a
@@ -32,6 +42,10 @@ await net.sendChat('some-peer', 'hello')
32
42
  - **Blind relay hubs.** A hub forwards ciphertext for peers that cannot dial each other and keeps
33
43
  none of it. An undelivered message waits in the *sender's* outbox; the hub keeps a notice — who
34
44
  tried, how often, when — and nothing more.
45
+ - **Rooms.** Several members in one conversation, each post sealed once per member — so there is no
46
+ room key, and removing somebody stops them reading by construction rather than by a rotation
47
+ anybody has to remember. Capped at 64 members, which is where sealing per member stops making
48
+ sense; it refuses there rather than getting quietly slow.
35
49
  - **Consent.** Nothing is trusted because it connected. A peer is paired directly, or consented to
36
50
  through a hub, before it can say anything to you.
37
51
  - **Intermittent peers as a supported shape.** Senders hold what they could not deliver and flush
@@ -46,6 +60,7 @@ Three things a host may provide, and none of them are required:
46
60
  | an `EventSink` to `start()` | every event this node raises | it runs silently; `createEventBus()` is there if you have no bus |
47
61
  | a `ToolProvider` to `start()` | tools shareable with trusted peers | no tool sharing |
48
62
  | `persistPeer` in the config | peers learned at runtime survive a restart | they are live-only |
63
+ | `persistPeerEntry` in the config | a peer named by identity survives too — the only way to record one with no address | live-only |
49
64
 
50
65
  `setLoggerFactory()` redirects the library's logging into your own logger. Left alone it writes to
51
66
  `VOLE_LOG_FILE` when that is set, and is silent otherwise.
@@ -71,5 +86,5 @@ are referenced from the Rust client's source.
71
86
  pnpm -C src/volenet test
72
87
  ```
73
88
 
74
- 204 tests, including end-to-end pairing, relay with consent, held messages, file transfer, and
75
- byte-level interop with the Rust client.
89
+ 213 tests, including end-to-end pairing, relay with consent, held messages, rooms across a
90
+ three-node mesh, file transfer, and byte-level interop with the Rust client.
package/dist/index.d.ts CHANGED
@@ -76,7 +76,7 @@ declare class RelayNotices {
76
76
  * The local node's post-quantum (ML-DSA) signing key, set once at VoleNet start.
77
77
  * Module-level because there is exactly one signing identity per process — this lets
78
78
  */
79
- type VoleNetMessageType = 'ping' | 'pong' | 'discover' | 'discover:response' | 'auth:challenge' | 'auth:response' | 'auth:result' | 'task:delegate' | 'task:result' | 'task:status' | 'memory:sync' | 'memory:search' | 'memory:results' | 'session:sync' | 'tool:list' | 'tool:list:response' | 'tool:call' | 'tool:result' | 'leader:heartbeat' | 'leader:claim' | 'leader:ack' | 'chat:message' | 'sealed' | 'sealed:direct' | 'relay:deliver' | 'relay:error' | 'relay:ack' | 'relay:pending' | 'roster' | 'relay:connect-request' | 'relay:connect-accept' | 'relay:connect-deny' | 'file:offer' | 'file:accept' | 'file:reject' | 'file:relay-ready' | 'file:done' | 'file:error' | 'file:cancel' | 'relay:blob:create' | 'relay:blob:grant' | 'relay:blob:deny' | 'relay:blob:fetch' | 'relay:blob:done';
79
+ type VoleNetMessageType = 'ping' | 'pong' | 'discover' | 'discover:response' | 'auth:challenge' | 'auth:response' | 'auth:result' | 'task:delegate' | 'task:result' | 'task:status' | 'memory:sync' | 'memory:search' | 'memory:results' | 'session:sync' | 'tool:list' | 'tool:list:response' | 'tool:call' | 'tool:result' | 'leader:heartbeat' | 'leader:claim' | 'leader:ack' | 'chat:message' | 'sealed' | 'sealed:direct' | 'relay:deliver' | 'room:create' | 'room:join' | 'room:leave' | 'room:invite' | 'room:list' | 'room:list:response' | 'room:info' | 'room:members' | 'room:error' | 'relay:error' | 'relay:ack' | 'relay:pending' | 'roster' | 'relay:connect-request' | 'relay:connect-accept' | 'relay:connect-deny' | 'file:offer' | 'file:accept' | 'file:reject' | 'file:relay-ready' | 'file:done' | 'file:error' | 'file:cancel' | 'relay:blob:create' | 'relay:blob:grant' | 'relay:blob:deny' | 'relay:blob:fetch' | 'relay:blob:done';
80
80
  interface VoleNetMessage {
81
81
  version: number;
82
82
  id: string;
@@ -249,6 +249,11 @@ declare class VoleNetTransport {
249
249
  */
250
250
  start(): Promise<void>;
251
251
  /** Bind the listening port, retrying briefly on EADDRINUSE (covers restart races). */
252
+ /**
253
+ * The port actually bound, which is not always the one configured — a host may pass 0 and let
254
+ * the OS choose. Null before the server is listening.
255
+ */
256
+ getPort(): number | null;
252
257
  private listen;
253
258
  /**
254
259
  * Stop the transport.
@@ -703,6 +708,55 @@ declare class RemoteTaskManager {
703
708
  dispose(): void;
704
709
  }
705
710
 
711
+ /** The size past which sealing per member stops being sensible. Enforced by the hub at join. */
712
+ declare const MAX_ROOM_MEMBERS = 64;
713
+ /** How long a room with nobody in it is kept before the hub forgets it. */
714
+ declare const EMPTY_ROOM_TTL_MS: number;
715
+ interface RoomRecord {
716
+ id: string;
717
+ name: string;
718
+ topic?: string;
719
+ /** Instance ids. Keys are looked up from the hub's own directory when a room is described. */
720
+ members: string[];
721
+ createdAt: number;
722
+ /** When the room last had a member, so an abandoned one can be forgotten. */
723
+ lastOccupied: number;
724
+ }
725
+ /** A room as a member sees it: enough to fan out a post without asking anything further. */
726
+ interface RoomMember {
727
+ instanceId: string;
728
+ name: string;
729
+ publicKey: string;
730
+ xPublicKey?: string;
731
+ mlkemPublicKey?: string;
732
+ }
733
+ interface RoomInfo {
734
+ room: string;
735
+ name: string;
736
+ topic?: string;
737
+ members: RoomMember[];
738
+ }
739
+ type RoomError = 'full' | 'not-a-member' | 'no-such-room' | 'refused';
740
+ /** The hub's side: who is in what. Persisted, so a restart does not dissolve every room. */
741
+ declare class RoomStore {
742
+ private readonly file;
743
+ private rooms;
744
+ private writing;
745
+ constructor(file: string);
746
+ load(now?: number): Promise<void>;
747
+ get(id: string): RoomRecord | undefined;
748
+ list(): RoomRecord[];
749
+ /** Rooms this member belongs to. */
750
+ forMember(instanceId: string): RoomRecord[];
751
+ create(name: string, creator: string, topic?: string): Promise<RoomRecord>;
752
+ /** Add a member. The ceiling is the design's edge, so it is refused rather than stretched. */
753
+ join(id: string, instanceId: string): Promise<RoomRecord | RoomError>;
754
+ leave(id: string, instanceId: string): Promise<RoomRecord | RoomError>;
755
+ /** Drop rooms nobody has been in for the TTL. */
756
+ sweep(now?: number): Promise<RoomRecord[]>;
757
+ private persist;
758
+ }
759
+
706
760
  /**
707
761
  * VoleNet Sync — memory and session synchronization across peers.
708
762
  *
@@ -1048,6 +1102,17 @@ interface VoleNetConfig {
1048
1102
  * live-only, which is the right default for a library.
1049
1103
  */
1050
1104
  persistPeer?: (url: string) => Promise<void>;
1105
+ /**
1106
+ * Remember a peer entry that has no url — one named by identity, as an address-less peer must
1107
+ * be. Separate from `persistPeer` rather than widening it, so a host written against the
1108
+ * earlier callback keeps working.
1109
+ */
1110
+ persistPeerEntry?: (entry: {
1111
+ id?: string;
1112
+ name?: string;
1113
+ trust?: string;
1114
+ allowBrain?: boolean;
1115
+ }) => Promise<void>;
1051
1116
  peers?: Array<{
1052
1117
  /**
1053
1118
  * Where to reach this peer. Also how the entry is matched to a connected peer, by
@@ -1202,6 +1267,20 @@ interface VoleNetConfig {
1202
1267
  */
1203
1268
  files?: VoleNetFilesConfig;
1204
1269
  }
1270
+ /**
1271
+ * What a peer asks for when it introduces itself, beyond being trusted at all.
1272
+ *
1273
+ * Trust and permission are separate — the keystore says who may connect, `net.peers` says what
1274
+ * they may then do — and that split used to mean the operator accepted a pair request and then,
1275
+ * separately, hand-edited a config file to make the peer useful. A requester can now say what it
1276
+ * is for, so accepting is one decision made with the reason in front of you.
1277
+ */
1278
+ type PairWant = 'brain';
1279
+ /** What the operator grants when accepting. Absent means trust only, as before. */
1280
+ interface PairGrant {
1281
+ trust?: 'full' | 'tool' | 'read';
1282
+ allowBrain?: boolean;
1283
+ }
1205
1284
  /** A hub-vouched mesh member, learned from a relay hub's roster broadcast. */
1206
1285
  interface RosterMember {
1207
1286
  instanceId: string;
@@ -1260,6 +1339,10 @@ declare class VoleNetManager {
1260
1339
  private relayNotices;
1261
1340
  /** Brain answers whose asker had gone by the time they were ready — see result-outbox.ts. */
1262
1341
  private resultOutbox;
1342
+ /** Hub side: who is in which room (§7c). Only a relay hub keeps these. */
1343
+ private roomStore;
1344
+ /** Member side: rooms this node is in, as the hub last described them. */
1345
+ private rooms;
1263
1346
  /** Peers a flush is already running for, so a burst of pings does not send an answer twice. */
1264
1347
  private flushingResults;
1265
1348
  /** The polling timers waiting on delegated tasks, so stopping cancels them. */
@@ -1448,6 +1531,36 @@ declare class VoleNetManager {
1448
1531
  private rosterName;
1449
1532
  private findRosterMember;
1450
1533
  /** Hub: push the current member directory to every connected member. */
1534
+ /** Rooms this node is in, as its hub last described them. */
1535
+ getRooms(): RoomInfo[];
1536
+ /** Ask a hub to make a room, join one, leave one, invite to one, or list what it has. */
1537
+ roomCommand(hubRef: string, type: 'room:create' | 'room:join' | 'room:leave' | 'room:invite' | 'room:list', payload: Record<string, unknown>): Promise<{
1538
+ ok: boolean;
1539
+ error?: string;
1540
+ }>;
1541
+ /**
1542
+ * Post to a room: one sealed copy per member.
1543
+ *
1544
+ * There is no room key, so this is the fan-out itself — the same `chat:message` sealed to each
1545
+ * member in turn (§7c). Everything §7 gives a private message therefore applies to each copy:
1546
+ * a member who is away has theirs held in this node's outbox and delivered when they return,
1547
+ * and consent still gates whether they accept anything from us at all.
1548
+ */
1549
+ postToRoom(roomId: string, text: string): Promise<{
1550
+ ok: boolean;
1551
+ sent: number;
1552
+ held: number;
1553
+ skipped: number;
1554
+ error?: string;
1555
+ }>;
1556
+ /** Act on a member's room command and answer it. */
1557
+ private handleRoomCommand;
1558
+ /** A room as its members need to see it: ids *and keys*, since a sender fans out itself. */
1559
+ private describeRoom;
1560
+ /** Tell everyone in a room who is in it now. */
1561
+ private pushRoomMembers;
1562
+ /** Signed, unsealed, straight to one member. Room control is hub business, not private. */
1563
+ private sendToMember;
1451
1564
  private broadcastRoster;
1452
1565
  /** Hand a member that just reconnected everything the hub noted for it while it was away. */
1453
1566
  private sendRelayNotices;
@@ -1537,12 +1650,22 @@ declare class VoleNetManager {
1537
1650
  name: string;
1538
1651
  endpoint?: string;
1539
1652
  note?: string;
1653
+ wants?: PairWant[];
1540
1654
  ts: number;
1541
1655
  }>;
1542
1656
  /** Operator consent: trust the requester's pinned key, live-reload, dial back if possible. */
1543
- acceptPair(ref: string): Promise<{
1657
+ /**
1658
+ * Accept a pair request, and optionally grant what it asked for in the same act.
1659
+ *
1660
+ * Trusting a key and saying what that key may do were two steps in two places — the second a
1661
+ * hand-edited config file and a restart — which is why a paired peer so often sat there unable
1662
+ * to do the thing it was paired for. A grant writes a `net.peers` entry naming the peer's
1663
+ * identity, live, so it applies without a restart, and asks the host to remember it.
1664
+ */
1665
+ acceptPair(ref: string, grant?: PairGrant): Promise<{
1544
1666
  ok: boolean;
1545
1667
  name?: string;
1668
+ granted?: PairGrant;
1546
1669
  error?: string;
1547
1670
  }>;
1548
1671
  denyPair(ref: string): Promise<{
@@ -1574,7 +1697,7 @@ declare class VoleNetManager {
1574
1697
  * fingerprint client-side), persist + dial the peer, and file the pair request for
1575
1698
  * the other operator. Fully live — no restart needed on this side.
1576
1699
  */
1577
- initiatePair(url: string, publicKey: string, note?: string): Promise<{
1700
+ initiatePair(url: string, publicKey: string, note?: string, wants?: PairWant[]): Promise<{
1578
1701
  ok: boolean;
1579
1702
  pending?: boolean;
1580
1703
  alreadyTrusted?: boolean;
@@ -1670,4 +1793,4 @@ declare function upsertPeerUrl(peers: PeerEntry[], url: string): {
1670
1793
  replaced?: string;
1671
1794
  };
1672
1795
 
1673
- export { CONTROL_PLANE_PAWS, type ChatEntry, ChatOutbox, DEFAULT_NOTICE_TTL_MS, DEFAULT_OUTBOX_TTL_MS, DEFAULT_RESULT_TTL_MS, type EventBus, type EventSink, type LeaderState, type Logger, type LoggerFactory, MAX_RESULTS_PER_PEER, type MemorySearchRequest, type MemorySearchResult, type MemorySyncEntry, type OutboxEntry, type OutboxKind, type PendingResult, type RelayNotice, RelayNotices, RemoteTaskManager, type RemoteTaskRequest, type RemoteTaskResult, type RemoteToolCallRequest, type RemoteToolCallResult, type RemoteToolInfo, ResultOutbox, type RosterMember, type SealedBox, type SessionSyncEntry, type SharedToolDefinition, type SharedToolEntry, type ToolProvider, type VoleKeyPair, type VoleNetConfig, VoleNetDiscovery, type VoleNetInstance, VoleNetLeader, VoleNetManager, type VoleNetMessage, type VoleNetMessageType, VoleNetSync, VoleNetTransport, addRelayAccept, buildAdvertisedEndpoint, closeLogger, createEventBus, createLogger, createMessage, findEndpointDrift, generateKeyPair, isControlPlanePaw, isSharedTool, loadAuthorizedVoles, loadKeyPair, loadRelayAccepts, parsePublicKey, peerPrefix, removeRelayAccept, revokePeer, seal, setLoggerFactory, trustPeer, unseal, upsertPeerUrl, verifyMessage, withVerifiedCaller };
1796
+ export { CONTROL_PLANE_PAWS, type ChatEntry, ChatOutbox, DEFAULT_NOTICE_TTL_MS, DEFAULT_OUTBOX_TTL_MS, DEFAULT_RESULT_TTL_MS, EMPTY_ROOM_TTL_MS, type EventBus, type EventSink, type LeaderState, type Logger, type LoggerFactory, MAX_RESULTS_PER_PEER, MAX_ROOM_MEMBERS, type MemorySearchRequest, type MemorySearchResult, type MemorySyncEntry, type OutboxEntry, type OutboxKind, type PairGrant, type PairWant, type PendingResult, type RelayNotice, RelayNotices, RemoteTaskManager, type RemoteTaskRequest, type RemoteTaskResult, type RemoteToolCallRequest, type RemoteToolCallResult, type RemoteToolInfo, ResultOutbox, type RoomError, type RoomInfo, type RoomMember, type RoomRecord, RoomStore, type RosterMember, type SealedBox, type SessionSyncEntry, type SharedToolDefinition, type SharedToolEntry, type ToolProvider, type VoleKeyPair, type VoleNetConfig, VoleNetDiscovery, type VoleNetInstance, VoleNetLeader, VoleNetManager, type VoleNetMessage, type VoleNetMessageType, VoleNetSync, VoleNetTransport, addRelayAccept, buildAdvertisedEndpoint, closeLogger, createEventBus, createLogger, createMessage, findEndpointDrift, generateKeyPair, isControlPlanePaw, isSharedTool, loadAuthorizedVoles, loadKeyPair, loadRelayAccepts, parsePublicKey, peerPrefix, removeRelayAccept, revokePeer, seal, setLoggerFactory, trustPeer, unseal, upsertPeerUrl, verifyMessage, withVerifiedCaller };