All docs / npm packages

@decentnetwork/dora

DHCP-style naming for AgentNet (Discover, Offer, Request, Acknowledge). Runs as a normal Carrier peer without admin rights.

Version
0.1.16 · npm
Platform
Node.js 20+ · no root
npm install -g @decentnetwork/dora

@decentnetwork/dora is the DHCP-style name and IP registry for the Decent Network virtual LAN. A dora server is an ordinary Carrier peer: it auto-accepts friend requests and answers register, lookup and list requests that arrive as Carrier text messages. Its roster maps each node's userid to a name and a 10.86.x.x address, stored in a YAML file. It runs as a normal user, with no TUN device and no root. The package ships a dora binary for running a registry, plus the server, client, store, allocator and wire-format helpers as a library.

When to use it

  • Run a private registry for your own @decentnetwork/lan nodes, so they get stable virtual IPs and names that do not collide with the public doras.
  • Embed the client (DoraClient) in your own Carrier app to register a name and resolve peers by name, userid or virtual IP, using whatever text transport you already have.
  • Embed the server (DoraServer) in a long-running Node process that already owns a @decentnetwork/peer Peer.

If you only want to join the public virtual LAN, you do not need this package directly: agentnet init already points at the public doras. If you only need messaging between peers, use @decentnetwork/peer and skip dora entirely.

Install

npm install @decentnetwork/dora@0.1.16

To embed the server, also install the Carrier SDK it takes a Peer from:

npm install @decentnetwork/peer@0.1.172

Quickstart

1. Run a registry with the CLI

--range-start and --range-end are required in 0.1.16. A registry is authoritative only for the band it declares, so pick a band that no other dora you federate with uses.

mkdir dora-demo && cd dora-demo
npm init -y
npm install @decentnetwork/dora@0.1.16
npx dora --data-dir ./data --range-start 10.86.2.10 --range-end 10.86.2.254 \
         --http-port 18790 --name docs-dora --verbose
# in another terminal: read-only status JSON
curl -s http://127.0.0.1:18790/api/status

Copy the address= value from the first line and share it. Clients friend that address once (agentnet dora enable --address <address>). The server accepts the request automatically.

Verified output (npm @decentnetwork/dora@0.1.16, 2026-11-06). The server took about 35 seconds to join the network and print registry ready:

registry identity: address=V58F5TvJAM7VEA8GyrtSgp87mA3WpbQnDWDRjSYqTKq6JbGTNeuP userid=DmkXCCAiXu36tdAnxjHQqPRgRB1yQct5WAtsFWLhvQKR
registry roster file: …/dora-demo/data/roster.yaml
registry pool start: 10.86.2.10 end: 10.86.2.254
[registry] dora server started — friend requests will auto-accept
registry ready — clients should configure registry.userid in decentlan's config.yaml
[registry] status UI on http://127.0.0.1:18790

$ curl -s http://127.0.0.1:18790/api/status      # trimmed
{
  "self": {
    "userid": "DmkXCCAiXu36tdAnxjHQqPRgRB1yQct5WAtsFWLhvQKR",
    "address": "V58F5TvJAM7VEA8GyrtSgp87mA3WpbQnDWDRjSYqTKq6JbGTNeuP",
    "name": "docs-dora",
    "segment": "10.86.2.10-10.86.2.254",
    "capacity": 245,
    "used": 0,
    …
  },
  "siblings": [],
  "records": [],
  "conflicts": [],
  …
}
^C
shutting down registry

Without the range flags the CLI exits with code 2 and prints the public federation bands, so you can pick a free one.

2. Use the client from code

DoraClient does not open a network connection itself. You supply sendText and onText adapters (in a real app, backed by Peer friend messages). This sample answers requests in-process with the exported DoraStore and IpAllocator, so it runs offline and shows the exact wire frames:

// quickstart.mjs
// Library quickstart: drive DoraClient against an in-process registry built
// from the exported DoraStore + IpAllocator + encode/decode wire helpers.
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
  DoraClient, DoraStore, IpAllocator, encode, decode, randomIpInSubnet, DORA_PREFIX,
} from "@decentnetwork/dora";

const dir = mkdtempSync(join(process.env.DORA_DEMO_DIR ?? tmpdir(), "dora-"));
const store = new DoraStore(join(dir, "roster.yaml"));
const allocator = new IpAllocator(store, { rangeStart: "10.86.2.10", rangeEnd: "10.86.2.254" });
console.log("segment:", allocator.segment(), "capacity:", allocator.capacity());

// A fake transport: the "registry" answers register/lookup/list in-process.
const REGISTRY = "registry-userid";
let deliver = () => {};
async function sendText(to, text) {
  const req = decode(text);
  let res;
  if (req.op === "register") {
    const record = { userid: req.userid, name: req.name, address: req.address,
      virtualIp: allocator.nextFree(), registeredAt: new Date().toISOString() };
    store.put(record);
    res = { op: "register-ok", record };
  } else if (req.op === "lookup") {
    const rec = req.by === "name" ? store.findByName(req.value)
      : req.by === "ip" ? store.findByIp(req.value) : store.get(req.value);
    res = rec ? { op: "lookup-ok", record: rec } : { op: "lookup-err", reason: "not found" };
  } else {
    res = { op: "list-ok", records: store.list(), total: store.list().length };
  }
  setTimeout(() => deliver(to, encode(res)), 10);
}
const client = new DoraClient({
  registryUserids: [REGISTRY],
  sendText,
  onText: (h) => { deliver = h; },
  timeoutMs: 2000,
});

const rec = await client.register({ userid: "node-a-userid", name: "node-a" });
console.log("registered:", rec.name, rec.virtualIp);
console.log("lookup by name:", (await client.lookup({ by: "name", value: "node-a" }))?.virtualIp);
console.log("roster size:", (await client.list()).length);
console.log("wire frame:", encode({ op: "lookup", by: "ip", value: rec.virtualIp }));
console.log("prefix:", DORA_PREFIX, "non-dora text decodes to:", decode("hello"));
console.log("fallback IP:", randomIpInSubnet("10.86.0.0/16"));

Verified output (npm @decentnetwork/dora@0.1.16, Node 24.3.0, 2026-11-06):

$ node quickstart.mjs
segment: 10.86.2.10-10.86.2.254 capacity: 245
registered: node-a 10.86.2.10
lookup by name: 10.86.2.10
roster size: 1
wire frame: DORA:{"op":"lookup","by":"ip","value":"10.86.2.10"}
prefix: DORA: non-dora text decodes to: null
fallback IP: 10.86.198.251

3. Embed the server

This is the same wiring as the dora CLI (dist/cli.js), minus the lock file, sibling replication and status page:

// server.mjs — embed a dora registry in your own Node process.
import { mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { Peer } from "@decentnetwork/peer";
import { DoraServer, DoraStore, IpAllocator } from "@decentnetwork/dora";

const dataDir = resolve(process.argv[2] ?? "./my-dora");
mkdirSync(dataDir, { recursive: true });

const peer = await Peer.create({
  keyFile: resolve(dataDir, "keypair.json"),
  compatibilityMode: "legacy",
  // Same public bootstrap + express nodes the dora CLI uses (dist/cli.js).
  bootstrapNodes: [
    { host: "13.58.208.50", port: 33445, pk: "89vny8MrKdDKs7Uta9RdVmspPjnRMdwMmaiEW27pZ7gh" },
    { host: "18.216.102.47", port: 33445, pk: "G5z8MqiNDFTadFUPfMdYsYtkUDbX5mNCMVHMZtsCnFeb" },
    { host: "54.193.141.205", port: 33445, pk: "7TfZWZNV8vnBxxWzJXuvKgX2QyKkLpg2oXx3LQ5tg8LW" },
  ],
  expressNodes: [
    { host: "lens.beagle.chat", port: 443, pk: "ECbs4GxwGzxGerNkmqDJFibEmevu8jAXqAZtikccvD95" },
  ],
});
await peer.start();
console.log(`dora address=${peer.address()} userid=${peer.userid()}`);
await peer.joinNetwork();
await peer.announceSelf(15000).catch((e) => console.warn("announce:", e.message));

const store = new DoraStore(resolve(dataDir, "roster.yaml"));
const allocator = new IpAllocator(store, { rangeStart: "10.86.2.10", rangeEnd: "10.86.2.254" });
const server = new DoraServer({ peer, store, allocator, verbose: true });
server.start();
console.log("serving segment", allocator.segment());

process.on("SIGINT", async () => { await peer.stop(); process.exit(0); });

Verified output (npm @decentnetwork/dora@0.1.16 + @decentnetwork/peer@0.1.172, 2026-11-06):

$ node server.mjs ./embedded-dora
dora address=ZGiTEA8PV35YHAhdUPNXoGMcY7wVba6XJ5B63zeow4PnDr5HPGMw userid=FgaTFrzoAjXwTFRnLzKdm2y422ShsXR7DP5FVKRHof2f
[registry] dora server started — friend requests will auto-accept
serving segment 10.86.2.10-10.86.2.254

API reference

All exports come from @decentnetwork/dora (dist/index.d.ts). Several are re-exported under new names: RegistryServer as DoraServer, RegistryClient as DoraClient, RegistryStore as DoraStore, and RegistryRecord as DoraRecord.

DoraServer

new DoraServer(opts: DoraServerOptions)
start(): void
availabilityLog(): AvailabilityLog | null
availabilitySummary(): string[]
DoraServerOptions fieldTypeNotes
peerPeer (from @decentnetwork/peer)The registry's Carrier identity. Start it before start().
storeDoraStoreRoster persistence
allocatorIpAllocatorIP band this server owns
verbose?booleanLog every served operation
siblings?Array<{ userid; address?; name? }>Sibling registries to replicate rosters from. Empty means replication is off.
availabilityFile?stringWhere to persist sibling reachability
syncIntervalMs?numberSibling pull interval (default 60 s)
replicaTtlMs?numberDrop replicas not re-asserted within this window (default 15 min)

DoraClient

new DoraClient(opts: DoraClientOptions)
ensureSubscribed(): void
register(opts: { userid: string; name: string; address?: string; requestedIp?: string; replace?: boolean }): Promise<DoraRecord>
lookup(opts: { by: "userid" | "name" | "ip"; value: string }): Promise<DoraRecord | null>
list(): Promise<DoraRecord[]>
DoraClientOptions fieldTypeNotes
registryUseridsstring[]Registries to try in order. The first one to answer wins.
sendText(toUserid, text) => Promise<void>Your transport, for example a Carrier friend message
onText(handler: (fromUserid, text) => void) => voidSubscribe the client to incoming text
timeoutMs?numberPer-registry wait (default 10 s)

list() pages through large rosters automatically. When every registry fails, the client throws AllRegistriesUnavailableError. Catch it and fall back to randomIpInSubnet().

DoraStore

new DoraStore(filePath: string)          // YAML roster; created on first write
get(userid): DoraRecord | null
findByName(name): DoraRecord | null
findByIp(ip): DoraRecord | null
list(): DoraRecord[]
listOwned(): DoraRecord[]                 // records without replicatedFrom
put(record: DoraRecord): void             // upsert + persist; caller checks collisions
remove(userid): boolean
touch(userid): void                       // update lastSeenAt
putReplicatedBatch(records, fromUserid, skip?): { merged: number; conflicts: IpConflict[] }
findIpConflicts(): IpConflict[]
pruneStaleReplicas(ttlMs: number, now?: number): number

IpAllocator

new IpAllocator(store: DoraStore, opts?: AllocatorOptions)   // { rangeStart?, rangeEnd? }
capacity(): number
segment(): string               // "<start>-<end>"
overlaps(segment: string): boolean
ownsIp(ip: string): boolean
acceptRequested(ip: string): boolean
nextFree(): string | null       // null when the band is exhausted

Wire format and helpers

const DORA_PREFIX = "DORA:";
encode(msg: DoraRequest | DoraResponse): string        // "DORA:" + JSON
decode(text: string): DoraRequest | DoraResponse | null // null for non-dora text
randomIpInSubnet(subnetCidr?: string, reservedFirstSlashTwentyFour?: boolean): string
class AllRegistriesUnavailableError extends Error
RequestResponse
RegisterRequest { op: "register", userid, name, address?, requestedIp?, replace? }RegisterOk { op: "register-ok", record } / RegisterErr { op: "register-err", reason, suggestion? }
LookupRequest { op: "lookup", by: "userid"|"name"|"ip", value }LookupOk { op: "lookup-ok", record } / LookupErr { op: "lookup-err", reason }
ListRequest { op: "list", offset? }ListOk { op: "list-ok", records, total?, seg? } / ListErr { op: "list-err", reason }

DoraRecord fields: userid, name, virtualIp, registeredAt, lastSeenAt?, address? (the full Carrier address, which peers need to send a friend request), replicatedFrom? and replicatedAt?.

Configuration

CLI flags

dora --range-start <ip> --range-end <ip> [options]
dora friend-add <userid> [name] [address] [--data-dir <dir>]
FlagDefaultEffect
--data-dir <path>~/.dora (or a legacy ~/.decent-registry if only that exists)Keypair, friend store, roster.yaml, dora.pid lock. The keypair here is the registry's public identity, so keep it stable.
--range-start <ip>, --range-end <ip>requiredThe IP band this registry owns
--udp-port <port>ephemeralA stable Carrier UDP port. Recommended for an always-on registry.
--verboseoffLog every operation
--peers <userid>[=<address>][#<name>],…noneSibling registries to replicate from
--sync-interval-ms <n>, --replica-ttl-ms <n>60000 / 900000Replication timing
--http-port <n>offServe a read-only status page (/) and JSON (/api/status)
--http-host <addr>127.0.0.1Bind address for the status page. The roster maps identities to addresses, so expose it deliberately.
--name <s>noneLabel shown on the status page

dora friend-add writes a userid straight into the data dir's friend store (keypair.json.friends.json) and exits. Use it to onboard a node whose friend request cannot reach the registry, then restart the server.

The CLI refuses to start if another dora process is using the same --data-dir. It checks both the process list and the dora.pid file.

Data files

roster.yaml (the server rewrites it on every change, so edit it only while dora is stopped):

records:
  - userid: node-a-userid
    name: node-a
    virtualIp: 10.86.2.10
    registeredAt: '2026-11-06T08:37:15.267Z'

Bootstrap and express nodes are fixed in dist/cli.js. There is no CLI flag for them. When embedding, pass your own list to Peer.create().

Platforms

  • Node.js >= 20 (engines). Verified with Node 24.3.0 on macOS (arm64).
  • No native modules, root or TUN. The CLI uses ps for duplicate-instance detection on Linux and macOS.
  • Needs outbound UDP/TCP to the Carrier bootstrap nodes. A fixed --udp-port helps clients reach a long-lived registry.
  • Runtime dependencies: @decentnetwork/peer (^0.1.101) and js-yaml.

Version, source and changelog