做一个服务:注册、用户记录、钱包许可
基于 Beagle 身份做服务的完整服务端,代码可以直接贴:用 Beagle 登录来注册用户,把用户记录存在你自己的数据库,按用户在 Beagle 个人资料里绑定的钱包资产来授予访问。没有密码,你的网站上也不需要连接钱包。
2026 年 10 月你要做的是什么
三部分,按这个顺序。每部分在服务端都只有几十行,用户从不交给你密码、助记词或私钥。
| 部分 | 用户做什么 | 你的服务端做什么 |
|---|---|---|
| 1. 注册 | 点「用 Beagle 登录」,在 Beagle 里确认一次 | 发 nonce,验签,发会话 |
| 2. 用户记录 | 什么都不用做 | 按 Beagle userid 写入用户,记下登录和行为 |
| 3. 许可 | 在 Beagle 个人资料里绑定一次钱包 | 从公开记录里读出绑定的钱包,验证明,查资产 |
每个网站各做一个连接钱包,就是每个网站各跑一套钱包流程、各存一张绑定表、各让用户再签一次。用 Beagle 的话,用户只绑定一次:钱包签一段写明他 Beagle 身份的消息,这个证明放在他公开的 beagles.eth 记录里。任何服务都能读,谁都不用再问。
Beagle Meet(snoopy.fi.chat、goji.fi.chat)在生产上就是这么做的:登录、用户记录、按钱包给房间设门槛。下面的代码是给你的服务新写的,你不需要安装或运行 Meet。
npm install express pg @decentnetwork/beagle-connect @decentnetwork/beagle-ui
# beagle-connect brings @decentnetwork/peer (server-side signature check).
# beagle-ui is only used for its React-free subpath @decentnetwork/beagle-ui/wallet-proof.1. 用 Beagle 登录来注册
没有单独的注册。第一次验签通过就创建用户,之后每次登录都是更新。背景(几种入口、三种身份)见给你的应用接入 Beagle 登录。
浏览器端。 一个按钮。
import { signIn } from "@decentnetwork/beagle-connect";
document.querySelector("#signin").addEventListener("click", async () => {
// Open the window inside the click. After an await, popup blockers eat it.
const win = window.open("about:blank", "beagle-connect", "width=420,height=560");
// Single-use nonce from YOUR server. That is the whole replay defence.
const { nonce } = await fetch("/api/auth/nonce", { credentials: "include" }).then((r) => r.json());
// Finds the installed Beagle (127.0.0.1:8766) or falls back to app.beagle.chat.
const who = await signIn({ nonce, existingWindow: win });
// { userid, address, name, avatar, sig, nonce, uiOrigin }
const r = await fetch("/api/auth/beagle", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...who, origin: location.origin }),
});
const out = await r.json();
if (!r.ok) throw new Error(out.error);
render(out.user, out.permit); // permit: { ok, code?, message? }
});服务端。 nonce、验签、会话,一个文件。@decentnetwork/peer 里的 verifyDetached 是 XEdDSA:Beagle userid 是 X25519 公钥,所以 nacl.sign 这类 Ed25519 库永远验不过。
import express from "express";
import { randomBytes, createHmac, timingSafeEqual } from "node:crypto";
import { verifyDetached, base58ToBytes } from "@decentnetwork/peer";
const app = express();
app.use(express.json());
// Exact origins your pages are served from. Never app.beagle.chat:
// the user signs YOUR origin.
const ORIGINS = new Set(["https://example.com"]);
const SECRET = process.env.SESSION_SECRET; // 32+ random bytes, not in git
// ── nonce: single-use, two minutes ──────────────────────────────
const nonces = new Map(); // nonce -> expiry. Use Redis if you run more than one process.
app.get("/api/auth/nonce", (_req, res) => {
const now = Date.now();
for (const [k, exp] of nonces) if (exp < now) nonces.delete(k);
const nonce = randomBytes(18).toString("base64url");
nonces.set(nonce, now + 120_000);
res.json({ nonce });
});
function takeNonce(nonce) {
const exp = nonces.get(nonce);
nonces.delete(nonce);
return exp !== undefined && exp >= Date.now();
}
// ── the one primitive: XEdDSA over the Beagle key, NOT Ed25519 ──
function verifyBeagle(prefix, userid, origin, salt, sigHex) {
try {
const msg = new TextEncoder().encode(`${prefix}\n${origin}\n${salt}`);
return verifyDetached(base58ToBytes(userid), msg, Uint8Array.from(Buffer.from(sigHex, "hex")));
} catch {
return false;
}
}
// ── session: an HMAC'd cookie keyed by the Beagle userid ────────
const mac = (v) => createHmac("sha256", SECRET).update(v).digest("hex");
function setSession(res, userid) {
res.setHeader("Set-Cookie",
`sid=${encodeURIComponent(`${userid}.${mac(userid)}`)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000`);
}
function sessionUser(req) {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || "");
if (!m) return null;
const v = decodeURIComponent(m[1]);
const i = v.lastIndexOf(".");
const userid = v.slice(0, i);
const got = Buffer.from(v.slice(i + 1));
const want = Buffer.from(mac(userid));
return got.length === want.length && timingSafeEqual(got, want) ? userid : null;
}
// ── sign up / sign in: the same route ───────────────────────────
app.post("/api/auth/beagle", async (req, res) => {
const { userid, nonce, sig, origin } = req.body ?? {};
if (!userid || !nonce || !sig || !origin) return res.status(400).json({ error: "missing fields" });
if (!ORIGINS.has(origin)) return res.status(400).json({ error: "origin not allowed" });
if (!takeNonce(nonce)) return res.status(401).json({ error: "bad or expired nonce" });
if (!verifyBeagle("decent-auth", userid, origin, nonce, sig))
return res.status(401).json({ error: "signature did not verify" });
const denied = await deniedReason(userid);
if (denied) return res.status(403).json({ error: denied, code: "denied" });
const user = await rememberUser(req, userid, req.body, "popup");
setSession(res, userid);
res.json({ user, permit: await permitFor(userid) });
});从 Beagle 里打开。 如果你的服务在 Beagle 的「应用」页里,用户打开时已经登录了。页面加载时读 URL 片段,发到第二个路由。
import { readLaunch } from "@decentnetwork/beagle-connect";
// On page load. null = nothing in the fragment. Throws on a bad or stale one:
// show that, do not treat it as signed out.
const who = await readLaunch();
if (who) {
await fetch("/api/auth/beagle-launch", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(who),
});
}// Opened from Beagle's Apps tab: already signed in, never ask again.
// No nonce you issued, so YOU bound freshness and refuse a repeat.
const usedLaunch = new Map(); // sig -> expiry
app.post("/api/auth/beagle-launch", async (req, res) => {
const { userid, sig, ts } = req.body ?? {};
// The Origin HEADER, not the body: with no nonce to bind, a body origin
// would let an assertion minted for one of your sites be spent at another.
const origin = req.get("origin");
if (!userid || !sig || !Number.isFinite(Number(ts))) return res.status(400).json({ error: "missing fields" });
if (!ORIGINS.has(origin)) return res.status(400).json({ error: "origin not allowed" });
if (Math.abs(Date.now() - Number(ts)) > 120_000) return res.status(401).json({ error: "launch assertion expired" });
if (!verifyBeagle("decent-launch", userid, origin, ts, sig))
return res.status(401).json({ error: "signature did not verify" });
const now = Date.now();
for (const [k, exp] of usedLaunch) if (exp < now) usedLaunch.delete(k);
if (usedLaunch.has(sig)) return res.status(401).json({ error: "launch assertion already used" });
usedLaunch.set(sig, now + 240_000);
const user = await rememberUser(req, userid, req.body, "launch");
setSession(res, userid);
res.json({ user, permit: await permitFor(userid) });
});拿主机名而不是完整 origin(https://example.com)去验签。nonce 重复用,或在客户端生成。该验 decent-auth 的地方接受了 decent-launch。把弹窗返回的 name、avatar、address 当成证明:它们没有签名。
2. 用户记录存在你自己的后端
Beagle userid 就是账号主键。存他给你看的资料,和他在你这里做过的事。他的私钥留在 Beagle 里,你永远看不到。有了这份记录,你才能奖励用户、之后联系到他、以及拒绝服务。
-- The account key is the Beagle userid. No password column, no key column.
CREATE TABLE users (
userid TEXT PRIMARY KEY, -- Beagle userid (base58 X25519)
name TEXT, -- display only
avatar_url TEXT, -- display only
carrier_addr TEXT, -- where to reach them: message, add friend, reward
beagles_name TEXT, -- foo.beagles.eth, when they registered one
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Every sign-in: which door, from where. The trail you reward or refuse on.
CREATE TABLE auth_events (
id BIGSERIAL PRIMARY KEY,
at TIMESTAMPTZ NOT NULL DEFAULT now(),
userid TEXT NOT NULL,
door TEXT NOT NULL, -- popup | launch
origin TEXT,
ip TEXT,
user_agent TEXT
);
-- What they did in YOUR service. One row per action you care about.
CREATE TABLE activity (
id BIGSERIAL PRIMARY KEY,
at TIMESTAMPTZ NOT NULL DEFAULT now(),
userid TEXT NOT NULL,
kind TEXT NOT NULL, -- e.g. joined, viewed_signal, posted
detail JSONB
);
-- Refusing service is a row, written before you need it.
CREATE TABLE denials (
userid TEXT PRIMARY KEY,
reason TEXT NOT NULL,
by_userid TEXT,
at TIMESTAMPTZ NOT NULL DEFAULT now()
);用户有 beagles.eth 记录时,用它来填资料。它是签过名、公开发布的,不像弹窗里那些提示字段。
import pg from "pg";
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const GATEWAY = "https://ens-gateway.beaglechat.workers.dev";
// The person's public beagles.eth record, or null. Read-only, no key needed.
async function beaglesRecord(userid) {
try {
const r = await fetch(`${GATEWAY}/getAddress/${encodeURIComponent(userid)}`,
{ signal: AbortSignal.timeout(2500) });
if (!r.ok) return null;
const rec = await r.json();
// It must be THIS person's record: written by this key, or naming it as
// carrierUserId (names registered from the mobile wallet are owned by the wallet).
return rec && (rec.owner === userid || rec.texts?.carrierUserId === userid) ? rec : null;
} catch {
return null;
}
}
const clip = (v, n) => (typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null);
async function rememberUser(req, userid, body, door) {
const rec = await beaglesRecord(userid);
// The published record wins; the popup's name/avatar/address are unsigned hints.
const name = clip(rec?.texts?.displayName, 64) ?? clip(body.name, 64);
const avatar = clip(rec?.texts?.avatar, 512) ?? clip(body.avatar, 512);
const carrier = clip(rec?.texts?.carrierAddress, 64) ?? clip(body.address, 64);
const { rows } = await db.query(
`INSERT INTO users (userid, name, avatar_url, carrier_addr, beagles_name)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (userid) DO UPDATE SET
name = COALESCE(EXCLUDED.name, users.name),
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
carrier_addr = COALESCE(EXCLUDED.carrier_addr, users.carrier_addr),
beagles_name = COALESCE(EXCLUDED.beagles_name, users.beagles_name),
last_seen_at = now()
RETURNING *`,
[userid, name, avatar, carrier, rec?.name ?? null],
);
await db.query(
`INSERT INTO auth_events (userid, door, origin, ip, user_agent) VALUES ($1, $2, $3, $4, $5)`,
[userid, door, req.get("origin") ?? null, req.ip, clip(req.get("user-agent"), 256)],
);
return rows[0];
}
async function deniedReason(userid) {
const { rows } = await db.query(`SELECT reason FROM denials WHERE userid = $1`, [userid]);
return rows[0]?.reason ?? null;
}
const track = (userid, kind, detail = null) =>
db.query(`INSERT INTO activity (userid, kind, detail) VALUES ($1, $2, $3)`, [userid, kind, detail]);| 存 | 绝不存 |
|---|---|
| userid、名字、头像、Carrier 地址、beagles.eth 名字 | 密码:本来就没有 |
| 每次登录:入口、origin、IP、时间 | 私钥、助记词、钱包密钥 |
| 做过什么:加入、查看、购买、发帖 | 把弹窗的名字/头像当身份 |
| 拒绝记录:原因、谁决定的 |
留着 carrier_addr。有了它,你的服务或它的 agent 可以通过 Beagle 网络给这个人发消息、加好友、发奖励,而不持有他的任何东西。
3. 按钱包资产授予访问
用户在 Beagle 里绑定一次钱包:个人资料 > beagles.eth > 绑定 Solana / Ethereum。他的钱包(Phantom、MetaMask)签一段写明 Beagle userid、链、地址和时间的消息,Beagle 把地址和这个签名写进他公开的记录。你读它不需要任何密钥,也不用再弹窗。
GET https://ens-gateway.beaglechat.workers.dev/getAddress/<userid>
{
"name": "alice.beagles.eth",
"owner": "<userid>",
"addresses": { "501": "<solana address>" }, // 60 = ETH, 501 = SOL
"texts": {
"displayName": "Alice",
"avatar": "https://…",
"carrierAddress": "<52-char Carrier address>",
"carrierUserId": "<userid>",
"proof.sol": "{\"v\":1,\"msg\":\"…userid: <userid>\\nchain: sol\\naddress: …\\nissued: …\",\"sig\":\"…\"}"
}
}verifyRecordWallet(record, "sol") 离线验证这个签名,返回地址,或者告诉你为什么不行。用 Beagle 手机钱包注册的名字(记录本身归这个钱包所有)它也认。然后按你平常的办法查这个地址上的资产。
import { verifyRecordWallet } from "@decentnetwork/beagle-ui/wallet-proof";
const RPC = process.env.SOLANA_RPC || "https://api.mainnet-beta.solana.com";
const MINT = "YourTokenMint11111111111111111111111111111"; // your SPL token mint
const THRESHOLD = 1000; // whole tokens
// Sum of one SPL token across every token account the wallet owns.
async function splBalance(owner, mint) {
const r = await fetch(RPC, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(3000),
body: JSON.stringify({
jsonrpc: "2.0", id: 1, method: "getTokenAccountsByOwner",
params: [owner, { mint }, { encoding: "jsonParsed" }],
}),
});
const { result, error } = await r.json();
if (error) throw new Error(error.message);
return result.value.reduce((sum, a) => sum + (a.account.data.parsed.info.tokenAmount.uiAmount ?? 0), 0);
}
const yes = new Map(); // userid -> time of the last yes
// { ok: true } | { ok: false, code, message } | { ok: null, code: "unavailable", message }
async function permitFor(userid) {
const at = yes.get(userid);
if (at && Date.now() - at < 60_000) return { ok: true };
const rec = await beaglesRecord(userid);
const wallet = rec ? verifyRecordWallet(rec, "sol") : { ok: false };
if (!wallet.ok) {
return { ok: false, code: "no-wallet",
message: "Bind a Solana wallet in your Beagle profile (Profile > beagles.eth), then sign in again." };
}
let balance;
try {
balance = await splBalance(wallet.address, MINT);
} catch {
// The chain read failed. That is not a no: say so, and decide your policy.
return { ok: null, code: "unavailable", message: "Could not check your wallet right now. Try again in a minute." };
}
if (balance < THRESHOLD) {
return { ok: false, code: "below-threshold", message: `Holding ${THRESHOLD} tokens unlocks this.` };
}
yes.set(userid, Date.now()); // cache a yes briefly; never cache a no for long
return { ok: true };
}
// Put it in front of anything that needs the permit.
async function requirePermit(req, res, next) {
const userid = sessionUser(req);
if (!userid) return res.status(401).json({ error: "Sign in with Beagle first" });
if (await deniedReason(userid)) return res.status(403).json({ error: "denied", code: "denied" });
const p = await permitFor(userid);
if (p.ok !== true) return res.status(p.ok === null ? 503 : 403).json(p);
req.userid = userid;
next();
}
app.get("/api/signals", requirePermit, async (req, res) => {
await track(req.userid, "viewed_signals");
res.json(await latestSignals());
});以太坊是同一份记录、同一个调用,参数换成 "eth"。
// Same record, other chain. Proof: EIP-191 personal_sign by that address.
const w = verifyRecordWallet(rec, "eth"); // { ok, address: "0x…", via }
// ERC-20 balanceOf(address) through eth_call on your RPC:
const data = "0x70a08231" + w.address.slice(2).toLowerCase().padStart(64, "0");
// POST { jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: TOKEN, data }, "latest"] }
// → hex balance in the token's smallest unit; divide by 10 ** decimals.RPC 超时就返回「暂时查不了」(ok: null,HTTP 503),绝不说「你不符合条件」。故障期间放不放人是你的策略,要有意识地决定。「是」可以缓存一分钟左右;「否」别缓存太久,不然刚买入的人会白等。
签名证明的是这个 Beagle 身份控制这个地址。余额每个区块都在变,所以在授予访问的那一刻读,许可过期时再读一次。
上线前检查
多进程部署就放 Redis 或数据库。
协议加主机,和浏览器里的 location.origin 完全一致。
换掉它会让所有人重新登录,本来就该这样。
有 beagles.eth 记录就用记录;弹窗字段只做兜底。
denials 里一行就能挡住登录和所有许可。
没绑钱包就用一句话告诉用户去 Beagle 哪里绑。
返回 503 和重试提示,不是 403。