@decentnetwork/beagle-connect
Sign in with Beagle from a website: identity, friend requests and app launches.
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
useridis 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.chatIn 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.
| Export | Signature | Returns / notes |
|---|---|---|
signIn | signIn({ 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". |
readLaunch | readLaunch({ 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. |
addFriend | addFriend({ address, name?, origins?, features?, existingWindow? }) | Resolves the popup's reply ({ ok: true, address, … }). address must be the full Carrier address, not a userid. |
pickUiOrigin | pickUiOrigin(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. |
verifySignIn | verifySignIn(userid, origin, nonce, sigHex) | boolean. Checks an XEdDSA signature over `decent-auth\n${origin}\n${nonce}` |
verifyLaunch | verifyLaunch(userid, origin, ts, sigHex) | boolean. Checks an XEdDSA signature over `decent-launch\n${origin}\n${ts}` |
DEFAULT_UI_ORIGINS | string[] | 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_MS | number | 120000 |
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: thewindow.openfeature string for the popup.maxAgeMs/strip: forreadLaunch.
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.openandpostMessage. Bundling needs the@decentnetwork/peeralias shown above (verified with Vite 5.4.10). - Node:
verifySignIn,verifyLaunch,DEFAULT_UI_ORIGINSandLAUNCH_MAX_AGE_MSwork in Node (verified with Node 24.3.0).signIn,addFriend,readLaunchandpickUiOriginneed a browser window. - Dependency:
@decentnetwork/peer(^0.1.141, which resolved to 0.1.172).
Version, source and changelog
- Verified version: 0.1.2 (
lateston npm, published 2026-08-28). - npm: https://www.npmjs.com/package/@decentnetwork/beagle-connect
- Source repository is not public yet. Browse the published code at https://www.npmjs.com/package/@decentnetwork/beagle-connect?activeTab=code
- License: MIT.
- Changelog: none is published in the package.