@decentnetwork/lan 0.1.285 → 0.1.287

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.
@@ -6,10 +6,16 @@ export const DEFAULT_OFFLINE_CONFIG = {
6
6
  appName: "Beagle",
7
7
  socketUrl: "https://io.beagle.chat",
8
8
  socketUrls: ["https://io.beagle.chat"],
9
+ // Measured 2026-08-28: pushapi.beagle.chat returns 502 on every path (its
10
+ // vhost is down), www.callpass.cn is unreachable, and tokyo.fi.chat:3004 is
11
+ // the WRONG PORT — that service listens on 443. All three dead at once, so
12
+ // an offline call could not wake a phone, while the phone apps kept working
13
+ // because bgservers.json points them at tokyo.fi.chat on 443.
14
+ // Verified: POST https://tokyo.fi.chat/push-api/push-message with this
15
+ // client's exact query-string shape returns 200.
9
16
  pushEndpoints: [
10
- "https://pushapi.beagle.chat/push-api/push-message",
11
- "https://www.callpass.cn/push-api/push-message",
12
- "https://tokyo.fi.chat:3004/push-api/push-message"
17
+ "https://tokyo.fi.chat/push-api/push-message",
18
+ "https://pushapi.beagle.chat/push-api/push-message"
13
19
  ]
14
20
  };
15
21
  /**
@@ -60,6 +60,18 @@ export declare class PeerManager extends EventEmitter {
60
60
  * pubkey is a no-op at the SDK level.
61
61
  */
62
62
  acceptFriendRequest(pubkey: string): Promise<void>;
63
+ /**
64
+ * Decline an inbound request, at the SDK level and not only in our own
65
+ * pending list.
66
+ *
67
+ * Rejecting used to be described as "a no-op at the Carrier level — sender
68
+ * just sees no acceptance". It is not: the request leaves a friend record in
69
+ * the SDK, and the SDK treats a later request from someone already in that
70
+ * list as a reconnection signal and never surfaces it. So rejecting a person
71
+ * once meant they could never reach you again, with nothing on either side
72
+ * saying why.
73
+ */
74
+ rejectFriendRequest(pubkey: string): void;
63
75
  /**
64
76
  * Remove a friend (by userid/pubkey). Drops them from the Carrier friend
65
77
  * store (and persists the shrunk list). Returns true if a friend was
@@ -125,6 +125,23 @@ export class PeerManager extends EventEmitter {
125
125
  await this.peer.acceptFriendRequest(pubkey);
126
126
  this.logger.info(`Accepted friend request from ${pubkey}`);
127
127
  }
128
+ /**
129
+ * Decline an inbound request, at the SDK level and not only in our own
130
+ * pending list.
131
+ *
132
+ * Rejecting used to be described as "a no-op at the Carrier level — sender
133
+ * just sees no acceptance". It is not: the request leaves a friend record in
134
+ * the SDK, and the SDK treats a later request from someone already in that
135
+ * list as a reconnection signal and never surfaces it. So rejecting a person
136
+ * once meant they could never reach you again, with nothing on either side
137
+ * saying why.
138
+ */
139
+ rejectFriendRequest(pubkey) {
140
+ if (!this.peer)
141
+ return;
142
+ this.peer.rejectFriendRequest(pubkey);
143
+ this.logger.info(`Rejected friend request from ${pubkey}`);
144
+ }
128
145
  /**
129
146
  * Remove a friend (by userid/pubkey). Drops them from the Carrier friend
130
147
  * store (and persists the shrunk list). Returns true if a friend was
@@ -430,7 +430,17 @@ export class DaemonServer {
430
430
  const entry = this.pendingFriends?.remove(userid);
431
431
  if (!entry)
432
432
  throw new Error(`No pending friend-request for userid ${userid}`);
433
- // No-op at the Carrier level — sender just sees no acceptance.
433
+ // Tell the SDK as well. This used to be left as "a no-op at the
434
+ // Carrier level — sender just sees no acceptance", which was wrong:
435
+ // the request leaves a friend record behind, and a later request
436
+ // from anyone already in that list is swallowed as a reconnection
437
+ // signal. Reject someone once and they could never reach you again.
438
+ try {
439
+ this.peerManager?.rejectFriendRequest(entry.pubkey);
440
+ }
441
+ catch (e) {
442
+ this.logger.warn(`reject cleanup for ${userid.slice(0, 8)} failed: ${e.message}`);
443
+ }
434
444
  },
435
445
  chatSend: async (userid, text) => {
436
446
  if (!text)
@@ -141,8 +141,15 @@ export declare class DoraIntegration {
141
141
  * ipam.yaml — that file remains operator-managed. Dora is treated as
142
142
  * a live overlay that's refreshed on every restart.
143
143
  *
144
- * Conflict policy: dora wins. If ipam.yaml had a peer with a stale IP,
145
- * the dora record overwrites it (assignPeer dedupes by name + carrierId).
144
+ * Conflict policy: dora wins over ipam.yaml, but NOT over an address a real
145
+ * packet just proved. A peer that re-allocates shows up on the wire before
146
+ * dora's roster catches up, and letting the roster win there made the two
147
+ * writers fight: the router learned the live address from inbound traffic,
148
+ * the next refresh put the stale one back, and the mapping oscillated. From
149
+ * the outside that is a ping at ~60% loss — a few replies, then a run of
150
+ * timeouts, then replies again. Measured between mac-dev and air-wli
151
+ * 2026-08-30, with the flip visible in the log as alternating
152
+ * "IPAM updated from inbound" and "Assigned peer" lines for one peer.
146
153
  */
147
154
  private mergeRosterIntoIpam;
148
155
  /**
@@ -14,6 +14,10 @@
14
14
  import { DoraClient, AllRegistriesUnavailableError } from "@decentnetwork/dora";
15
15
  import { DEFAULT_DORAS } from "../config/loader.js";
16
16
  import { Logger } from "../utils/logger.js";
17
+ /** How long an address confirmed by an inbound packet outranks the dora
18
+ * roster. Long enough to cover a roster that lags a re-allocation, short
19
+ * enough that a peer which genuinely moved is picked up from dora again. */
20
+ const LEARNED_IP_TRUST_MS = 10 * 60_000;
17
21
  export class DoraIntegration {
18
22
  opts;
19
23
  client;
@@ -458,8 +462,15 @@ export class DoraIntegration {
458
462
  * ipam.yaml — that file remains operator-managed. Dora is treated as
459
463
  * a live overlay that's refreshed on every restart.
460
464
  *
461
- * Conflict policy: dora wins. If ipam.yaml had a peer with a stale IP,
462
- * the dora record overwrites it (assignPeer dedupes by name + carrierId).
465
+ * Conflict policy: dora wins over ipam.yaml, but NOT over an address a real
466
+ * packet just proved. A peer that re-allocates shows up on the wire before
467
+ * dora's roster catches up, and letting the roster win there made the two
468
+ * writers fight: the router learned the live address from inbound traffic,
469
+ * the next refresh put the stale one back, and the mapping oscillated. From
470
+ * the outside that is a ping at ~60% loss — a few replies, then a run of
471
+ * timeouts, then replies again. Measured between mac-dev and air-wli
472
+ * 2026-08-30, with the flip visible in the log as alternating
473
+ * "IPAM updated from inbound" and "Assigned peer" lines for one peer.
463
474
  */
464
475
  mergeRosterIntoIpam(roster, myUserid) {
465
476
  let added = 0;
@@ -471,6 +482,17 @@ export class DoraIntegration {
471
482
  if (!existing) {
472
483
  added += 1;
473
484
  }
485
+ else if (existing.virtualIp !== entry.virtualIp &&
486
+ existing.learnedAt !== undefined &&
487
+ Date.now() - existing.learnedAt < LEARNED_IP_TRUST_MS) {
488
+ // Traffic from this peer confirmed a different address, recently.
489
+ // Believe the wire and leave the entry alone; the roster will agree
490
+ // once dora catches up with the re-allocation.
491
+ this.logger.info(`roster says ${entry.name} is ${entry.virtualIp}, but traffic proved ` +
492
+ `${existing.virtualIp} — keeping the learned address`);
493
+ this.maybeFriend(entry);
494
+ continue;
495
+ }
474
496
  else if (existing.virtualIp !== entry.virtualIp ||
475
497
  existing.name !== entry.name) {
476
498
  updated += 1;
@@ -244,6 +244,9 @@ export class PacketRouter extends EventEmitter {
244
244
  carrierId: srcPubkey,
245
245
  virtualIp: srcIp,
246
246
  services: existing?.services ?? [],
247
+ // Stamped so a stale dora roster cannot overwrite what a real packet
248
+ // just proved. See IpamRecord.learnedAt.
249
+ learnedAt: Date.now(),
247
250
  });
248
251
  this.logger.info(existing
249
252
  ? `IPAM updated from inbound: ${name} ${existing.virtualIp} -> ${srcIp}`
package/dist/types.d.ts CHANGED
@@ -42,6 +42,14 @@ export interface IpamRecord {
42
42
  virtualIp: string;
43
43
  services: Service[];
44
44
  expiresAt?: number;
45
+ /** When this address was last confirmed by an inbound packet FROM this peer.
46
+ *
47
+ * A packet is proof of the address a peer is using right now; the dora
48
+ * roster is a cache that can lag a re-allocation. Without this the roster
49
+ * overwrote the learned address on every refresh and the router flipped
50
+ * between the two — visible as ~60% packet loss on a ping that otherwise
51
+ * worked. */
52
+ learnedAt?: number;
45
53
  }
46
54
  export interface IpamConfig {
47
55
  namespace: string;
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.285";
1
+ window.__DK_UI_VERSION="0.1.287";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.285",
3
+ "version": "0.1.287",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@decentnetwork/dora": "^0.1.14",
87
- "@decentnetwork/peer": "^0.1.141",
87
+ "@decentnetwork/peer": "0.1.154",
88
88
  "@decentnetwork/peer-webrtc": "^0.2.10",
89
89
  "ink": "^5.2.1",
90
90
  "js-yaml": "^4.1.0",