All docs / npm packages

@decentnetwork/beagle-connect

Sign in with Beagle from a website: identity, friend requests and app launches.

Version
0.1.2 · npm
Platform
browser
Source
Source repository not public yet
npm install @decentnetwork/beagle-connect

@decentnetwork/beagle-connect adds "Sign in with Beagle" to a website. It finds the visitor's Beagle (an installed client on localhost:8766, or the web client at app.beagle.chat), opens its /connect popup, and returns an assertion signed by the visitor's own Carrier key. There are no passwords, no OAuth and no third-party server. The package does three things: signIn() proves who the visitor is, readLaunch() accepts a visitor who opened your site from Beagle's Apps tab, and addFriend() asks their Beagle to send a friend request. verifySignIn() and verifyLaunch() also run in Node, for checking on your server.

When to use it

  • Your site wants an identity without accounts: the Beagle userid is a public key, and the proof is an XEdDSA signature over your origin plus a nonce.
  • Your site is listed in Beagle's Apps tab and should not ask the visitor to sign in again.
  • Your site introduces two people (after a meeting, say) and wants their Beagle clients to become friends.

It does not carry messages. For chat and data between peers, use @decentnetwork/peer. The popup it opens is served by @decentnetwork/beagle (installed) or by app.beagle.chat (web).

Install

npm install @decentnetwork/beagle-connect@0.1.2 @decentnetwork/peer@0.1.172

@decentnetwork/peer is already a dependency of beagle-connect. Install it directly too, so that your server can import signDetached/verifyDetached and the bundler alias below can resolve it.

Bundling for the browser

beagle-connect imports verifyDetached and base58ToBytes from the root of @decentnetwork/peer. That root also loads Node-only transports (dgram, fs, net, node:crypto). A plain vite build therefore fails in 0.1.2:

error during build:
node_modules/@decentnetwork/peer/dist/utils/bytes.js (31:9): "randomBytes" is not exported by "__vite-browser-external", imported by "node_modules/@decentnetwork/peer/dist/utils/bytes.js".

Point the bundler at the two browser-safe modules instead. They depend only on curve25519-js and tweetnacl:

// src/peer-browser.js
// beagle-connect needs only these two functions from @decentnetwork/peer.
// Importing the package root pulls in Node-only transports (dgram, fs, net),
// so point the bundler at the two browser-safe modules instead.
export { verifyDetached } from "@decentnetwork/peer/dist/crypto/sign.js";
export { base58ToBytes } from "@decentnetwork/peer/dist/utils/base58.js";
// vite.config.js
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";

export default defineConfig({
  resolve: {
    alias: [
      {
        find: /^@decentnetwork\/peer$/,
        replacement: fileURLToPath(new URL("./src/peer-browser.js", import.meta.url)),
      },
    ],
  },
});

Quickstart

Browser: a sign-in button

<!-- index.html -->
<!doctype html>
<html>
  <head><meta charset="utf-8" /><title>Sign in with Beagle</title></head>
  <body>
    <button id="signin">Sign in with Beagle</button>
    <pre id="out"></pre>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
// src/main.js
import { signIn, readLaunch, pickUiOrigin } from "@decentnetwork/beagle-connect";

const out = document.getElementById("out");
const show = (x) => (out.textContent += JSON.stringify(x, null, 2) + "\n");

// Arrived from Beagle's Apps tab? (null when there is no #beagle= fragment)
readLaunch().then((who) => who && show({ launch: who })).catch((e) => show({ launchError: e.message }));

// Which Beagle would answer right now?
pickUiOrigin().then((origin) => show({ beagleAt: origin }));

document.getElementById("signin").addEventListener("click", async () => {
  // In production the nonce comes from your server and is single-use.
  const nonce = crypto.randomUUID();
  try {
    const who = await signIn({ nonce }); // opens the popup synchronously, inside the click
    show({ userid: who.userid, address: who.address, name: who.name, uiOrigin: who.uiOrigin });
    // POST `who` to your server and verify it there before minting a session.
  } catch (e) {
    show({ error: e.message });
  }
});

Call signIn() and addFriend() directly from a click handler. Browsers block a popup opened after an await.

Server: verify the assertion

Never trust the browser's check alone. On your server, verify the signature against your own origin and a nonce you issued (and then retire). This Node script plays the Beagle side too, so it runs on its own:

// verify.mjs
// Server-side check of a "Sign in with Beagle" assertion, in plain Node.
// We play the Beagle client here (sign with an X25519 key) so the example is
// self-contained; in production `userid` and `sig` come from signIn().
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { signDetached, bytesToBase58 } from "@decentnetwork/peer";
import { verifySignIn, verifyLaunch, LAUNCH_MAX_AGE_MS, DEFAULT_UI_ORIGINS } from "@decentnetwork/beagle-connect";

const { publicKey, privateKey } = generateKeyPairSync("x25519");
const pub = Buffer.from(publicKey.export({ format: "jwk" }).x, "base64url");
const sec = Buffer.from(privateKey.export({ format: "jwk" }).d, "base64url");
const userid = bytesToBase58(new Uint8Array(pub));            // a Carrier userid IS the public key
const toHex = (b) => Buffer.from(b).toString("hex");

const MY_ORIGIN = "https://your.app";
const nonce = randomUUID();                                     // issued by your server, single use
const sig = toHex(signDetached(new Uint8Array(sec),
  new TextEncoder().encode(`decent-auth\n${MY_ORIGIN}\n${nonce}`)));

console.log("signIn verifies:          ", verifySignIn(userid, MY_ORIGIN, nonce, sig));
console.log("other origin rejected:    ", verifySignIn(userid, "https://evil.example", nonce, sig));
console.log("replayed as launch:       ", verifyLaunch(userid, MY_ORIGIN, nonce, sig));

const ts = Date.now();
const launchSig = toHex(signDetached(new Uint8Array(sec),
  new TextEncoder().encode(`decent-launch\n${MY_ORIGIN}\n${ts}`)));
console.log("launch verifies:          ", verifyLaunch(userid, MY_ORIGIN, ts, launchSig));
console.log("launch max age (ms):      ", LAUNCH_MAX_AGE_MS);
console.log("origins probed:           ", DEFAULT_UI_ORIGINS.join(", "));

Verified output (npm @decentnetwork/beagle-connect@0.1.2 + @decentnetwork/peer@0.1.172, Node 24.3.0, Vite 5.4.10, 2026-11-06):

$ npx vite build
vite v5.4.10 building for production...
transforming...
✓ 15 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                 0.28 kB │ gzip:  0.20 kB
dist/assets/index-BO6rXdEv.js  57.60 kB │ gzip: 18.86 kB
✓ built in 212ms

$ node verify.mjs
signIn verifies:           true
other origin rejected:     false
replayed as launch:        false
launch verifies:           true
launch max age (ms):       120000
origins probed:            http://127.0.0.1:8766, http://localhost:8766, https://localhost:8766, https://127.0.0.1:8766, http://localhost:8767, http://localhost:8768, http://localhost:8765, https://app.beagle.chat

In the browser, a test build of the same code ran with origins: ["http://localhost:18766", "https://app.beagle.chat"] against a local beagle 0.1.131. pickUiOrigin returned http://localhost:18766. The full signIn popup round trip was not exercised, because the test browser blocked popups. signIn rejected with Beagle could not open — allow pop-ups for this site., which is the documented error.

API reference

The package ships plain ES modules (src/index.js) with no TypeScript declarations. The signatures below come from the source.

ExportSignatureReturns / notes
signInsignIn({ nonce, origins?, features?, existingWindow? })Promise<{ userid, address, name, avatar, sig, nonce, uiOrigin }>. Throws on deny ("Cancelled"), timeout ("Beagle did not answer.", after 120 s), a blocked popup, or a bad signature. features defaults to "width=420,height=560".
readLaunchreadLaunch({ maxAgeMs?, strip? })Promise<{ v, userid, address, name, punkId, ts, sig, assertion } | null>. Reads #beagle=<base64url>, checks age (default 120 s, either direction) and signature, and removes the fragment from the address bar unless strip: false. Throws if a fragment is present but invalid.
addFriendaddFriend({ address, name?, origins?, features?, existingWindow? })Resolves the popup's reply ({ ok: true, address, … }). address must be the full Carrier address, not a userid.
pickUiOriginpickUiOrigin(origins = DEFAULT_UI_ORIGINS)Promise<string>. Probes every candidate except the last one in parallel (GET /api/state, then a no-cors /connect). Returns the earliest one that answers, or the last entry as the fallback.
verifySignInverifySignIn(userid, origin, nonce, sigHex)boolean. Checks an XEdDSA signature over `decent-auth\n${origin}\n${nonce}`
verifyLaunchverifyLaunch(userid, origin, ts, sigHex)boolean. Checks an XEdDSA signature over `decent-launch\n${origin}\n${ts}`
DEFAULT_UI_ORIGINSstring[]The 8 origins listed in the output above: local ports 8766/8767/8768/8765 over http and https, then https://app.beagle.chat
LAUNCH_MAX_AGE_MSnumber120000

existingWindow lets you pass a window you already opened in your own click handler.

What is signed: the prefix, your origin and the nonce or timestamp. What is not signed: name, avatar, punkId and address. Treat those as display hints, never as identity. Pin origin to your own origin on the server, never to a value taken from the payload. For readLaunch assertions, your server must also remember every accepted sig for at least the two-minute window and refuse repeats.

Configuration

There are no config files or environment variables. Per call you can set:

  • origins: which Beagle UIs to try. The last entry is the fallback and is never probed.
  • features: the window.open feature string for the popup.
  • maxAgeMs / strip: for readLaunch.

An https page that reaches for http://localhost is subject to the browser's Private Network Access rules. pickUiOrigin treats an opaque (no-cors) answer as evidence that a local Beagle exists.

Platforms

  • Browser: modern browsers with ES modules, fetch, AbortController, crypto.randomUUID, window.open and postMessage. Bundling needs the @decentnetwork/peer alias shown above (verified with Vite 5.4.10).
  • Node: verifySignIn, verifyLaunch, DEFAULT_UI_ORIGINS and LAUNCH_MAX_AGE_MS work in Node (verified with Node 24.3.0). signIn, addFriend, readLaunch and pickUiOrigin need a browser window.
  • Dependency: @decentnetwork/peer (^0.1.141, which resolved to 0.1.172).

Version, source and changelog