@decentnetwork/beagle 0.1.58 → 0.1.59

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.
@@ -0,0 +1,20 @@
1
+ export interface RosterPeer {
2
+ userid: string;
3
+ address: string | null;
4
+ name: string | null;
5
+ descr: string | null;
6
+ punk: number | null;
7
+ online: boolean;
8
+ lastSeen: number;
9
+ }
10
+ /** Who else is on this bridge. Null when the bridge speaks the older protocol
11
+ * and never issues a ticket — an absence, not an error. */
12
+ export declare function bridgeRoster(wsUrl: string, keyFile: string, profile: {
13
+ name?: string;
14
+ descr?: string;
15
+ address?: string;
16
+ }): Promise<{
17
+ bridge: string;
18
+ online: number;
19
+ peers: RosterPeer[];
20
+ } | null>;
@@ -0,0 +1,105 @@
1
+ // Reading a bridge's roster without using the bridge.
2
+ //
3
+ // A browser peer gets the roster because it already holds a bridge ticket: the
4
+ // ticket is issued during the WebSocket handshake it has to do anyway to reach
5
+ // a relay. A desktop peer connects to relays directly and never handshakes
6
+ // with a bridge, so it has no ticket and the venue is invisible to it.
7
+ //
8
+ // It does not have to stay that way. The handshake proves one thing — that we
9
+ // hold the secret key for the userid we claim — and that proof is XEdDSA over
10
+ // a nonce, which this process can produce from its own keyfile. So we open a
11
+ // WebSocket purely to complete the handshake, take the ticket, and close it.
12
+ // No relay traffic ever crosses that socket; the bridge is a directory here,
13
+ // not a transport.
14
+ //
15
+ // The alternative is the directory peer (add it as a friend, message it), and
16
+ // that one is better for a client with no public bridge at all. This exists so
17
+ // the desktop and the browser can render the SAME roster from the same shape,
18
+ // which is what lets discover.jsx be one file instead of two.
19
+ import { readFileSync } from "node:fs";
20
+ import WebSocket from "ws";
21
+ import { signDetached } from "@decentnetwork/peer";
22
+ const HANDSHAKE_MS = 12_000;
23
+ /** Tickets outlive a single call; re-handshaking per poll would be absurd. */
24
+ const tickets = new Map();
25
+ const TICKET_TTL_MS = 60 * 60_000;
26
+ const hex = (u8) => Buffer.from(u8).toString("hex");
27
+ function loadKeys(keyFile) {
28
+ const raw = JSON.parse(readFileSync(keyFile, "utf-8"));
29
+ return { pk: raw.publicKey, sk: new Uint8Array(Buffer.from(raw.secretKey, "hex")) };
30
+ }
31
+ /** ws(s)://host/relay-ws → https://host */
32
+ function httpBase(wsUrl) {
33
+ const u = new URL(wsUrl);
34
+ return `${u.protocol === "wss:" ? "https:" : "http:"}//${u.host}`;
35
+ }
36
+ /** Complete the handshake and return a ticket. Opens a socket to an
37
+ * allowlisted relay because the bridge needs a target to accept the
38
+ * connection at all — but nothing is ever sent over it. */
39
+ function fetchTicket(wsUrl, keyFile, profile) {
40
+ return new Promise((resolve, reject) => {
41
+ const { pk, sk } = loadKeys(keyFile);
42
+ const ws = new WebSocket(`${wsUrl}?host=127.0.0.1&port=33445`);
43
+ const done = (err, ticket) => {
44
+ clearTimeout(timer);
45
+ try {
46
+ ws.close();
47
+ }
48
+ catch { /* already closing */ }
49
+ err ? reject(err) : resolve(ticket);
50
+ };
51
+ const timer = setTimeout(() => done(new Error("bridge handshake timed out")), HANDSHAKE_MS);
52
+ ws.on("open", () => ws.send(JSON.stringify({
53
+ t: "hello", ver: 1, tox: pk,
54
+ address: profile.address || undefined,
55
+ name: profile.name || undefined,
56
+ descr: profile.descr || undefined,
57
+ })));
58
+ ws.on("message", (data, isBinary) => {
59
+ if (isBinary)
60
+ return; // relay bytes: not ours
61
+ let msg;
62
+ try {
63
+ msg = JSON.parse(data.toString());
64
+ }
65
+ catch {
66
+ return;
67
+ }
68
+ if (msg.t === "challenge") {
69
+ const message = new TextEncoder().encode(`decent-bridge\n${msg.origin}\n${msg.n}`);
70
+ ws.send(JSON.stringify({ t: "proof", sig: hex(signDetached(sk, message)) }));
71
+ return;
72
+ }
73
+ if (msg.t === "ticket" && msg.ticket)
74
+ done(null, msg.ticket);
75
+ });
76
+ ws.on("error", (e) => done(e));
77
+ ws.on("close", () => done(new Error("bridge closed before issuing a ticket")));
78
+ });
79
+ }
80
+ /** Who else is on this bridge. Null when the bridge speaks the older protocol
81
+ * and never issues a ticket — an absence, not an error. */
82
+ export async function bridgeRoster(wsUrl, keyFile, profile) {
83
+ const base = httpBase(wsUrl);
84
+ let cached = tickets.get(wsUrl);
85
+ if (cached && Date.now() - cached.at > TICKET_TTL_MS)
86
+ cached = undefined;
87
+ for (let attempt = 0; attempt < 2; attempt++) {
88
+ if (!cached) {
89
+ const ticket = await fetchTicket(wsUrl, keyFile, profile);
90
+ cached = { ticket, at: Date.now() };
91
+ tickets.set(wsUrl, cached);
92
+ }
93
+ const res = await fetch(`${base}/roster`, { headers: { authorization: `Bearer ${cached.ticket}` } });
94
+ if (res.status === 401) { // stale ticket: re-handshake once
95
+ tickets.delete(wsUrl);
96
+ cached = undefined;
97
+ continue;
98
+ }
99
+ if (!res.ok)
100
+ throw new Error(`bridge roster ${res.status}`);
101
+ const d = (await res.json());
102
+ return { bridge: d.bridge || base, online: d.online ?? 0, peers: d.peers ?? [] };
103
+ }
104
+ return null;
105
+ }
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ function parseArgs(argv) {
27
27
  " --host <addr> bind address (default 127.0.0.1)",
28
28
  " --config-dir <p> identity/config dir (default ~/.agentnet)",
29
29
  " --dora-dir <p> dora roster dir, if this machine runs a dora",
30
+ " --bridge <wss> bridge whose roster to show (default: bridge.beagle.chat)",
30
31
  " --backend <k> force 'daemon' or 'embedded' (default: auto)",
31
32
  " --wait-daemon <s> with --backend daemon, wait this long for it (default 60)",
32
33
  " -h, --help this text",
@@ -40,6 +41,7 @@ function parseArgs(argv) {
40
41
  host: get("--host") ?? "127.0.0.1",
41
42
  configDir: get("--config-dir") ?? defaultConfigDir(),
42
43
  doraDir: get("--dora-dir"),
44
+ bridge: get("--bridge"),
43
45
  backend: get("--backend"),
44
46
  waitDaemon: get("--wait-daemon"),
45
47
  };
@@ -157,6 +159,10 @@ async function main() {
157
159
  keyFile: resolve(decentlanCarrierDir(args.configDir), "keypair.json"),
158
160
  bootstrapNodes,
159
161
  },
162
+ // The venue this node shows in Here. A default rather than a required flag:
163
+ // the roster is only useful if it is populated, and everyone starts on the
164
+ // same one until they have a reason not to.
165
+ bridgeWs: args.bridge || "wss://bridge.beagle.chat/relay-ws",
160
166
  meExtra: {
161
167
  // The panel reads "lan <x> · peer <y>". Report the real decentlan version
162
168
  // powering this backend, not beagle's own — conflating them made the