@decentnetwork/beagle 0.1.60 → 0.1.63

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.
@@ -18,6 +18,9 @@ export interface EmbeddedHostOptions {
18
18
  }[];
19
19
  nickname?: string;
20
20
  statusMessage?: string;
21
+ /** Our CryptoPunks id, advertised to friends in the userinfo `gender`
22
+ * field — the only avatar path that does not need a server. */
23
+ punkId?: number;
21
24
  autoAcceptFriends: boolean;
22
25
  onProfileChange?: (info: {
23
26
  name?: string;
@@ -138,6 +138,9 @@ export class EmbeddedHost {
138
138
  expressNodes: this.#opts.expressNodes,
139
139
  nickname: this.#opts.nickname,
140
140
  statusMessage: this.#opts.statusMessage,
141
+ // Advertised from startup, not only after a profile edit — otherwise a
142
+ // restart silently stops telling friends what our avatar is.
143
+ punkId: this.#opts.punkId,
141
144
  fileResumeDir: resolve(this.#opts.configDir, "file-resume"),
142
145
  });
143
146
  this.#wire();
@@ -402,7 +405,9 @@ export class EmbeddedHost {
402
405
  const id = this.#node.identity();
403
406
  return {
404
407
  identity: id,
405
- node: { name: this.#opts.nickname ?? "", backend: "embedded", autoAccept: this.#autoAccept },
408
+ // punkId so the UI can report what is actually being advertised,
409
+ // which is not necessarily what beagles.eth says.
410
+ node: { name: this.#opts.nickname ?? "", backend: "embedded", autoAccept: this.#autoAccept, punkId: this.#opts.punkId ?? null },
406
411
  // No TUN in this backend — say so explicitly rather than omitting
407
412
  // the key, so the UI can distinguish "no LAN" from "unknown".
408
413
  tun: null,
@@ -448,6 +453,10 @@ export class EmbeddedHost {
448
453
  f.status === "online" ||
449
454
  this.#messages.hasInbound(f.carrierId),
450
455
  lastSeen: f.acceptedAt,
456
+ // The avatar they advertised over Carrier (userinfo `gender`).
457
+ // Peer-to-peer, so it works for a friend who never registered a
458
+ // beagles.eth name — until now that meant an identicon.
459
+ punkId: f.punkId ?? null,
451
460
  pinned: meta?.pinned ?? false,
452
461
  lastMessage: lastMsg ? { dir: lastMsg.dir, text: lastMsg.text, ts: lastMsg.ts } : undefined,
453
462
  unread: this.#messages.unreadCount(f.carrierId, meta?.lastReadTs ?? 0),
@@ -511,12 +520,21 @@ export class EmbeddedHost {
511
520
  this.#meta.setAlias(uid, req.alias);
512
521
  return {};
513
522
  case "set-profile": {
514
- const info = { name: req.name, description: req.description };
523
+ const info = {
524
+ name: req.name,
525
+ description: req.description,
526
+ // null clears, undefined leaves alone. setUserInfo re-sends the
527
+ // profile to every established friend, so a new avatar lands now
528
+ // rather than on their next reconnect.
529
+ punkId: req.punkId === undefined ? undefined : (req.punkId === null ? null : Number(req.punkId)),
530
+ };
515
531
  this.#node.setUserInfo(info);
516
532
  if (info.name)
517
533
  this.#opts.nickname = info.name;
518
534
  if (info.description !== undefined)
519
535
  this.#opts.statusMessage = info.description;
536
+ if (info.punkId !== undefined)
537
+ this.#opts.punkId = info.punkId ?? undefined;
520
538
  this.#opts.onProfileChange?.(info);
521
539
  return {};
522
540
  }
package/dist/server.js CHANGED
@@ -273,7 +273,14 @@ function installMode() {
273
273
  return "npx";
274
274
  if (BEAGLE_ROOT.includes(join("node_modules", "@decentnetwork", "beagle")))
275
275
  return "global";
276
- return "dev";
276
+ // "dev" has to be PROVEN, not assumed. It used to be the fallthrough for
277
+ // any layout this function did not recognise, so a user with a perfectly
278
+ // ordinary install was told to run `git pull` — against a private repo they
279
+ // cannot clone, with no other option offered. A dead end presented as
280
+ // instructions.
281
+ if (existsSync(join(BEAGLE_ROOT, ".git")))
282
+ return "dev";
283
+ return "unknown";
277
284
  }
278
285
  async function npmLatest(pkg) {
279
286
  const ctl = new AbortController();
@@ -550,10 +557,15 @@ const CONNECT_PAGE = `<!doctype html>
550
557
  <body>
551
558
  <div class="card">
552
559
  <div class="brand"><span class="d"></span>Beagle</div>
553
- <h1>Sign in</h1>
554
- <p class="sub"><span id="origin" class="origin">…</span> wants to sign you in with your Beagle identity.</p>
560
+ <h1 id="headline">Sign in</h1>
561
+ <p class="sub"><span id="origin" class="origin">…</span><span id="leadTail"> wants to sign you in with your Beagle identity.</span></p>
562
+ <div id="targetRow" class="id" hidden>
563
+ <div class="lbl">Friend request to</div>
564
+ <div id="targetName" class="who" hidden></div>
565
+ <div id="targetAddr" class="userid"></div>
566
+ </div>
555
567
  <div class="id">
556
- <div class="lbl">Your identity</div>
568
+ <div id="idLabel" class="lbl">Your identity</div>
557
569
  <div id="who" class="who" hidden></div>
558
570
  <div id="userid" class="userid">loading…</div>
559
571
  </div>
@@ -562,21 +574,49 @@ const CONNECT_PAGE = `<!doctype html>
562
574
  <button id="deny" class="btn ghost">Deny</button>
563
575
  <button id="approve" class="btn solid" disabled>Approve</button>
564
576
  </div>
565
- <p class="note">Approving sends the site a signature bound to that site, proving you control this identity. Your private key never leaves this device.</p>
577
+ <p id="note" class="note">Approving sends the site a signature bound to that site, proving you control this identity. Your private key never leaves this device.</p>
566
578
  </div>
567
579
  <script>
568
580
  (function(){
569
581
  var qs=new URLSearchParams(location.search);
570
582
  var origin=qs.get("origin")||"", nonce=qs.get("nonce")||"";
583
+ // "signin" (default) or "add". One hands the site a signature; the other
584
+ // asks a stranger to be your friend. Both need consent, and only the second
585
+ // changes anything — so they are separate actions, never one grant.
586
+ var action=qs.get("action")==="add"?"add":"signin";
587
+ var target=(qs.get("address")||"").trim(), targetName=(qs.get("name")||"").trim();
571
588
  var oEl=document.getElementById("origin"), errEl=document.getElementById("err");
572
589
  var approve=document.getElementById("approve"), deny=document.getElementById("deny");
573
590
  oEl.textContent=origin||"(unknown site)";
574
591
  function fail(m){errEl.textContent=m;errEl.hidden=false;}
575
- function reply(data){ if(window.opener&&validOrigin){ window.opener.postMessage(Object.assign({type:"decent-auth",nonce:nonce},data),origin);} }
592
+ function reply(data){
593
+ if(!window.opener||!validOrigin) return;
594
+ // A friend request is NOT an auth assertion and must never be mistaken for
595
+ // one by a listener written before this existed — hence its own type.
596
+ var base=action==="add"?{type:"beagle-action",nonce:nonce,action:"add"}:{type:"decent-auth",nonce:nonce};
597
+ window.opener.postMessage(Object.assign(base,data),origin);
598
+ }
576
599
  var validOrigin=false;
577
600
  try{var u=new URL(origin); validOrigin=(u.protocol==="http:"||u.protocol==="https:")&&u.origin===origin;}catch(e){}
578
601
  if(!validOrigin) fail("This sign-in request has an invalid origin and was blocked.");
579
- if(nonce.length===0||nonce.length>512){ validOrigin=false; fail("This sign-in request is missing a valid nonce."); }
602
+ if(nonce.length===0||nonce.length>512){ validOrigin=false; fail("This request is missing a valid nonce."); }
603
+ if(action==="add"&&!(target.length>=50&&target.length<=60&&/^[1-9A-HJ-NP-Za-km-z]+$/.test(target))){
604
+ validOrigin=false; fail("That does not look like a Beagle address, so nothing was sent.");
605
+ }
606
+ if(action==="add"){
607
+ document.title="Add a friend on Beagle";
608
+ document.getElementById("headline").textContent="Add a friend";
609
+ // Set the TAIL, not the whole paragraph: rewriting it would remove the
610
+ // #origin span, and the site name would then render as "null".
611
+ document.getElementById("leadTail").textContent=" wants to send a friend request from your Beagle identity.";
612
+ document.getElementById("targetRow").hidden=false;
613
+ var tn=document.getElementById("targetName");
614
+ tn.textContent=targetName||""; tn.hidden=!targetName;
615
+ document.getElementById("targetAddr").textContent=target;
616
+ document.getElementById("idLabel").textContent="Sent as";
617
+ approve.textContent="Send request";
618
+ document.getElementById("note").textContent="The other person sees your name and has to accept. Nothing is shared with the site — it only asked; your Beagle sent it.";
619
+ }
580
620
  fetch("/api/state").then(function(r){return r.json();}).then(function(s){
581
621
  var uid=(s.me&&s.me.userid)||"", nm=(s.me&&s.me.name)||"";
582
622
  document.getElementById("userid").textContent=uid||"(no identity)";
@@ -586,12 +626,24 @@ const CONNECT_PAGE = `<!doctype html>
586
626
  }).catch(function(){ fail("Could not reach the local agentnet daemon."); });
587
627
  deny.onclick=function(){ reply({error:"denied"}); setTimeout(function(){window.close();},60); };
588
628
  approve.onclick=function(){
589
- approve.disabled=true; approve.textContent="Signing…";
629
+ approve.disabled=true;
630
+ if(action==="add"){
631
+ approve.textContent="Sending…";
632
+ fetch("/api/add",{method:"POST",headers:{"content-type":"application/json"},
633
+ body:JSON.stringify({address:target,hello:targetName?("Hi — we met on "+new URL(origin).host+"."):""})})
634
+ .then(function(r){return r.json();})
635
+ .then(function(j){ if(j.ok===false) throw new Error(j.error||"could not send");
636
+ reply({ok:true,address:target}); setTimeout(function(){window.close();},60); })
637
+ .catch(function(e){ approve.disabled=false; approve.textContent="Send request";
638
+ fail("Could not send the request: "+e.message); });
639
+ return;
640
+ }
641
+ approve.textContent="Signing…";
590
642
  fetch("/api/connect-approve",{method:"POST",headers:{"content-type":"application/json"},
591
643
  body:JSON.stringify({origin:origin,nonce:nonce})})
592
644
  .then(function(r){return r.json();})
593
645
  .then(function(j){ if(!j.ok) throw new Error(j.error||"sign failed");
594
- reply({userid:j.userid,sig:j.sig,name:j.name,avatar:j.avatar}); setTimeout(function(){window.close();},60); })
646
+ reply({userid:j.userid,sig:j.sig,name:j.name,avatar:j.avatar,punkId:j.punkId}); setTimeout(function(){window.close();},60); })
595
647
  .catch(function(e){ approve.disabled=false; approve.textContent="Approve"; fail("Signing failed: "+e.message); });
596
648
  };
597
649
  })();
@@ -615,6 +667,10 @@ const fmtTime = (ts) => {
615
667
  return `${d.getDate()} ${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getMonth()]}`;
616
668
  };
617
669
  const viaFromTransport = (t) => t === "udp" || t === "both" ? "direct" : t === "tcp-relay" ? "relay" : null;
670
+ // What we last told the daemon to advertise. Module-scoped on purpose: the
671
+ // value is per-process, and re-sending it on every /api/desktop poll would
672
+ // re-push the profile to every friend several times a second.
673
+ let lastAdvertisedPunk;
618
674
  export function startBeagleServer(opts) {
619
675
  const host = opts.listenHost ?? "127.0.0.1";
620
676
  const port = opts.listenPort ?? 8765;
@@ -640,6 +696,32 @@ export function startBeagleServer(opts) {
640
696
  const handler = async (req, res) => {
641
697
  try {
642
698
  const url = (req.url || "/").split("?")[0];
699
+ // Let Sign-in-with-Beagle pages (meet, billing, …) detect this daemon
700
+ // from a public https origin. Chrome Local Network Access blocks the
701
+ // probe unless we answer the private-network preflight.
702
+ const detect = url === "/api/state" || url === "/api/ping" || url === "/connect";
703
+ if (detect) {
704
+ const origin = String(req.headers.origin || "");
705
+ if (origin) {
706
+ res.setHeader("Access-Control-Allow-Origin", origin);
707
+ res.setHeader("Vary", "Origin");
708
+ }
709
+ else {
710
+ res.setHeader("Access-Control-Allow-Origin", "*");
711
+ }
712
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
713
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
714
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
715
+ if (req.method === "OPTIONS") {
716
+ res.writeHead(204);
717
+ res.end();
718
+ return;
719
+ }
720
+ }
721
+ if (req.method === "GET" && url === "/api/ping") {
722
+ sendJson(res, 200, { ok: true });
723
+ return;
724
+ }
643
725
  // Desktop UI bundle (the design). Falls back to the classic page when
644
726
  // the bundle hasn't been built (dist/ui/desktop missing).
645
727
  const desktopIndex = join(DESKTOP_DIR, "index.html");
@@ -694,7 +776,7 @@ export function startBeagleServer(opts) {
694
776
  // LOCALHOST-ONLY: the popup always runs in the local user's browser
695
777
  // (localhost), so binding the UI to a LAN IP must never let a remote
696
778
  // host request signatures with this identity.
697
- if (url === "/connect" || url === "/api/connect-approve") {
779
+ if (url === "/connect" || url === "/api/connect-approve" || url === "/api/launch-token") {
698
780
  if (!isLocalRequest(req)) {
699
781
  res.writeHead(403, { "content-type": "text/plain" });
700
782
  res.end("Sign in with Beagle is available only on this machine (localhost).");
@@ -734,14 +816,17 @@ export function startBeagleServer(opts) {
734
816
  // neither is part of what the signature proves.
735
817
  let name = "";
736
818
  let avatar = null;
819
+ let punkId = null;
820
+ const diagId = await opts.call({ op: "diag" });
737
821
  try {
738
- const diag = await opts.call({ op: "diag" });
822
+ const diag = diagId;
739
823
  name = ((diag.ok ? diag.data : {})?.node?.name) ?? "";
740
824
  const pub = (await ensPublicByUserid(1200)).get(userid);
741
825
  if (pub) {
742
826
  if (!name)
743
827
  name = pub.displayName ?? "";
744
828
  avatar = pub.avatarUrl ?? null;
829
+ punkId = pub.punkId;
745
830
  if (!avatar && pub.punkId != null) {
746
831
  const punk = await punksFetch(`/api/punks/${pub.punkId}`).catch(() => null);
747
832
  avatar = punk?.image ?? null;
@@ -749,7 +834,47 @@ export function startBeagleServer(opts) {
749
834
  }
750
835
  }
751
836
  catch { /* identity is what matters; the trimmings are best-effort */ }
752
- sendJson(res, 200, { ok: true, userid, sig: r.data?.sig ?? "", name, avatar });
837
+ // The address, not just the userid: a friend request needs the full
838
+ // address (key + nospam + checksum), so without it a site that signed
839
+ // someone in still cannot offer to introduce them to anyone.
840
+ const address = ((diagId.ok ? diagId.data : {})?.identity?.address) ?? "";
841
+ sendJson(res, 200, { ok: true, userid, address, sig: r.data?.sig ?? "", name, avatar, punkId });
842
+ return;
843
+ }
844
+ // Mint the "launch an app as me" assertion the Apps tab puts in an app's
845
+ // URL fragment, so someone opening an app from their own client is not
846
+ // asked to sign in to it. LOCALHOST-ONLY like /connect: it is a
847
+ // signature with this identity.
848
+ if (req.method === "POST" && url === "/api/launch-token") {
849
+ const body = await readBody(req);
850
+ const target = validateOrigin(body.origin);
851
+ if (!target) {
852
+ sendJson(res, 400, { ok: false, error: "invalid origin" });
853
+ return;
854
+ }
855
+ const ts = Date.now();
856
+ // A DIFFERENT prefix from decent-auth on purpose: an assertion minted
857
+ // here must never be replayable as a "a site asked me to sign in"
858
+ // proof, nor the reverse.
859
+ const signed = await opts.call({ op: "sign", text: `decent-launch\n${target}\n${ts}` });
860
+ if (!signed.ok) {
861
+ sendJson(res, 502, { ok: false, error: signed.error || "sign failed" });
862
+ return;
863
+ }
864
+ const diag = await opts.call({ op: "diag" });
865
+ const d = (diag.ok ? diag.data : {}) ?? {};
866
+ const identity = d.identity ?? {};
867
+ const node = d.node ?? {};
868
+ sendJson(res, 200, {
869
+ ok: true,
870
+ v: 1,
871
+ userid: signed.data?.userid ?? identity.userid ?? "",
872
+ address: identity.address ?? "",
873
+ name: node.name ?? "",
874
+ punkId: null,
875
+ ts,
876
+ sig: signed.data?.sig ?? "",
877
+ });
753
878
  return;
754
879
  }
755
880
  // ── beagles.eth name registration ──
@@ -1515,6 +1640,10 @@ export function startBeagleServer(opts) {
1515
1640
  sendJson(res, 400, { ok: false, error: "running from a source checkout — update with: git pull && npm run build" });
1516
1641
  return;
1517
1642
  }
1643
+ // Layout we do not recognise: try the thing that works for almost
1644
+ // everyone rather than refusing. If it genuinely cannot install, the
1645
+ // npm error says so — which is still more use than a git command
1646
+ // against a repo the user has no access to.
1518
1647
  if (mode === "npx") {
1519
1648
  // npx with @latest re-resolves on every launch: restarting IS the update.
1520
1649
  sendJson(res, 200, { ok: true, npx: true });
@@ -1592,6 +1721,20 @@ export function startBeagleServer(opts) {
1592
1721
  ]);
1593
1722
  const d = (diag.ok ? diag.data : {}) ?? {};
1594
1723
  const identity = d.identity ?? {};
1724
+ // A desktop install has no locally-picked punk: its avatar is whatever
1725
+ // it registered on beagles.eth. Advertise THAT over Carrier, so an iOS
1726
+ // friend sees a picture instead of nothing. Sent only when the value
1727
+ // CHANGES — set-profile re-pushes the profile to every established
1728
+ // friend, and doing that on every poll would be a packet storm.
1729
+ const myPunk = ensPub.get(identity.userid ?? "")?.punkId ?? null;
1730
+ // Only ever PUSH a real punk, never a null. A null here means "nothing
1731
+ // registered on beagles.eth", which is not the same as "no avatar" —
1732
+ // pushing it wiped a punk set directly through /api/set-profile, on
1733
+ // the very next poll. Registration seeds the avatar; it does not own it.
1734
+ if (myPunk != null && myPunk !== lastAdvertisedPunk) {
1735
+ lastAdvertisedPunk = myPunk;
1736
+ void opts.call({ op: "set-profile", punkId: myPunk }).catch(() => undefined);
1737
+ }
1595
1738
  const tun = d.tun ?? {};
1596
1739
  const node = d.node ?? {};
1597
1740
  const diagFriends = d.friends ?? [];
@@ -1647,7 +1790,7 @@ export function startBeagleServer(opts) {
1647
1790
  // toggle rather than showing a state it cannot know.
1648
1791
  autoAccept: typeof node.autoAccept === "boolean" ? node.autoAccept : null,
1649
1792
  avatarUrl: ensPub.get(identity.userid ?? "")?.avatarUrl ?? null,
1650
- punkId: ensPub.get(identity.userid ?? "")?.punkId ?? null,
1793
+ punkId: myPunk ?? node.punkId ?? null,
1651
1794
  // The UI compares this against its baked-in __DK_UI_VERSION and
1652
1795
  // reloads itself when they differ — an open tab otherwise runs a
1653
1796
  // stale bundle forever after a beagle update (multi-tab call fixes
@@ -1688,7 +1831,11 @@ export function startBeagleServer(opts) {
1688
1831
  lastTs: lm?.ts ?? 0,
1689
1832
  wire: "163",
1690
1833
  avatarUrl: ensPub.get(uid)?.avatarUrl ?? null,
1691
- punkId: ensPub.get(uid)?.punkId ?? null,
1834
+ // What they advertised over Carrier wins over the directory: it is
1835
+ // what they are using on their device right now, it needs no
1836
+ // gateway, and it is the only avatar a friend who never registered
1837
+ // a beagles.eth name will ever have.
1838
+ punkId: f.punkId ?? ensPub.get(uid)?.punkId ?? null,
1692
1839
  };
1693
1840
  });
1694
1841
  const pend = pending.ok ? (pending.data?.pending ?? []) : [];
@@ -1921,8 +2068,10 @@ export function startBeagleServer(opts) {
1921
2068
  return;
1922
2069
  }
1923
2070
  if (req.method === "POST" && url === "/api/set-profile") {
1924
- const { name, description } = await readBody(req);
1925
- const r = await opts.call({ op: "set-profile", name, description });
2071
+ // punkId passes through so an avatar can be set without a beagles.eth
2072
+ // registration the daemon advertises it over Carrier either way.
2073
+ const { name, description, punkId } = await readBody(req);
2074
+ const r = await opts.call({ op: "set-profile", name, description, punkId });
1926
2075
  sendJson(res, r.ok ? 200 : 400, r);
1927
2076
  return;
1928
2077
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.60",
3
+ "version": "0.1.63",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,13 +27,13 @@
27
27
  "dependencies": {
28
28
  "@decentnetwork/chat-components": "^0.1.3",
29
29
  "@decentnetwork/lan": "^0.1.283",
30
- "@decentnetwork/peer": "^0.1.141",
30
+ "@decentnetwork/peer": "0.1.143",
31
31
  "@decentnetwork/peer-webrtc": "^0.2.16",
32
32
  "js-yaml": "^4.1.0",
33
33
  "yargs": "^17.7.2"
34
34
  },
35
35
  "devDependencies": {
36
- "@decentnetwork/beagle-ui": "0.1.1",
36
+ "@decentnetwork/beagle-ui": "0.2.0",
37
37
  "@types/js-yaml": "^4.0.9",
38
38
  "@types/node": "^20.11.0",
39
39
  "@types/yargs": "^17.0.32",