@decentnetwork/beagle-service 0.0.0-stage → 0.1.0

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.
package/dist/auth.js ADDED
@@ -0,0 +1,257 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { verifyLaunch as connectVerifyLaunch, verifySignIn as connectVerifySignIn } from "@decentnetwork/beagle-connect";
5
+ import { isUserid } from "./util.js";
6
+ /** How long a nonce lives, and how far a launch timestamp may be from now. */
7
+ export const NONCE_TTL_MS = 120_000;
8
+ export const LAUNCH_MAX_AGE_MS = 120_000;
9
+ const DAY_MS = 86_400_000;
10
+ /** Turn the configured list into exact origins. Throws on anything that is
11
+ * not `scheme://host[:port]`, so a typo is found at start, not as a sign-in
12
+ * that never verifies. */
13
+ export function normalizeOrigins(list) {
14
+ if (!Array.isArray(list) || list.length === 0) {
15
+ throw new TypeError('beagle-service: `origins` must list the exact origins of your pages, e.g. ["https://your.app"]');
16
+ }
17
+ const out = new Set();
18
+ for (const raw of list) {
19
+ let u = null;
20
+ if (typeof raw === "string" && !raw.includes("*")) {
21
+ try {
22
+ u = new URL(raw);
23
+ }
24
+ catch {
25
+ u = null;
26
+ }
27
+ }
28
+ if (!u || u.origin === "null" || u.pathname !== "/" || u.search || u.hash || u.username || u.password) {
29
+ throw new TypeError(`beagle-service: origin ${JSON.stringify(raw)} is not an exact origin. Write scheme, host and port, e.g. "https://your.app" or "http://localhost:3000"; wildcards and paths are not accepted`);
30
+ }
31
+ out.add(u.origin);
32
+ }
33
+ return out;
34
+ }
35
+ /** The signing secret: the configured one, else a 32-byte one kept in `file`. */
36
+ export function loadSecret(configured, file) {
37
+ if (configured) {
38
+ if (configured.length < 16)
39
+ throw new TypeError("beagle-service: `sessionSecret` must be at least 16 characters");
40
+ return configured;
41
+ }
42
+ try {
43
+ const s = readFileSync(file, "utf8").trim();
44
+ if (s)
45
+ return s;
46
+ }
47
+ catch {
48
+ /* first start */
49
+ }
50
+ const s = randomBytes(32).toString("hex");
51
+ mkdirSync(dirname(file), { recursive: true });
52
+ writeFileSync(file, s, { mode: 0o600 });
53
+ try {
54
+ chmodSync(file, 0o600);
55
+ }
56
+ catch {
57
+ /* best effort on filesystems without modes */
58
+ }
59
+ return s;
60
+ }
61
+ export function headerOf(req, name) {
62
+ const h = req?.headers;
63
+ if (!h)
64
+ return undefined;
65
+ if (typeof h.get === "function")
66
+ return h.get(name) ?? undefined;
67
+ const v = h[name] ?? h[name.toLowerCase()];
68
+ return Array.isArray(v) ? v[0] : v;
69
+ }
70
+ const isSigHex = (v) => typeof v === "string" && /^[0-9a-fA-F]{128}$/.test(v);
71
+ export function createAuth(opts) {
72
+ const { origins, state, users, secret } = opts;
73
+ const now = opts.now ?? Date.now;
74
+ const cfg = opts.session ?? {};
75
+ const ttlMs = (cfg.ttlDays ?? 30) * DAY_MS;
76
+ const cookieName = cfg.cookieName ?? "beagle_session";
77
+ const sameSite = cfg.sameSite ?? "Lax";
78
+ const mac = (purpose, body) => createHmac("sha256", secret).update(`${purpose}\n${body}`).digest("base64url");
79
+ const macOk = (purpose, body, got) => {
80
+ const want = Buffer.from(mac(purpose, body));
81
+ const have = Buffer.from(got);
82
+ return want.length === have.length && timingSafeEqual(want, have);
83
+ };
84
+ // ── nonces ────────────────────────────────────────────────────────────────
85
+ // A nonce carries its own expiry and our MAC, so issuing one stores nothing
86
+ // and any server process can recognise it. Using one stores it (until it
87
+ // would have expired anyway), which is what makes it single-use, also
88
+ // across a restart.
89
+ function issueNonce() {
90
+ const body = `${randomBytes(18).toString("base64url")}.${(now() + NONCE_TTL_MS).toString(36)}`;
91
+ return `${body}.${mac("nonce", body).slice(0, 22)}`;
92
+ }
93
+ async function consumeNonce(nonce) {
94
+ if (typeof nonce !== "string" || nonce.length > 128)
95
+ return false;
96
+ const parts = nonce.split(".");
97
+ if (parts.length !== 3)
98
+ return false;
99
+ const body = `${parts[0]}.${parts[1]}`;
100
+ const want = Buffer.from(mac("nonce", body).slice(0, 22));
101
+ const have = Buffer.from(parts[2]);
102
+ if (want.length !== have.length || !timingSafeEqual(want, have))
103
+ return false;
104
+ const exp = parseInt(parts[1], 36);
105
+ if (!Number.isFinite(exp) || exp < now())
106
+ return false;
107
+ return await state.addOnce("nonce", parts[0], exp);
108
+ }
109
+ // ── the two assertions ────────────────────────────────────────────────────
110
+ const fail = (status, error) => ({ ok: false, status, error });
111
+ const hintsOf = (b) => ({ address: b.address, name: b.name, avatar: b.avatar, punkId: b.punkId });
112
+ function doorOf(v) {
113
+ if (typeof v !== "string" || v.length > 200)
114
+ return null;
115
+ try {
116
+ const o = new URL(v).origin;
117
+ return o === "null" ? null : o;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
123
+ function allowedOrigin(req) {
124
+ const origin = headerOf(req, "origin");
125
+ return origin && origins.has(origin) ? origin : null;
126
+ }
127
+ async function verifySignIn(origin, body) {
128
+ if (!origin || !origins.has(origin))
129
+ return fail(403, "origin-not-allowed");
130
+ if (!body || typeof body !== "object")
131
+ return fail(400, "bad-request");
132
+ const b = body;
133
+ // First, and whatever the signature turns out to be: a nonce that has been
134
+ // presented once is spent.
135
+ if (!(await consumeNonce(b.nonce)))
136
+ return fail(401, "bad-nonce");
137
+ if (!isUserid(b.userid) || !isSigHex(b.sig) || !connectVerifySignIn(b.userid, origin, b.nonce, b.sig)) {
138
+ return fail(401, "bad-signature");
139
+ }
140
+ return { ok: true, userid: b.userid, hints: hintsOf(b), door: doorOf(b.uiOrigin) };
141
+ }
142
+ async function verifyLaunch(origin, body) {
143
+ if (!origin || !origins.has(origin))
144
+ return fail(403, "origin-not-allowed");
145
+ if (!body || typeof body !== "object")
146
+ return fail(400, "bad-request");
147
+ const b = body;
148
+ const ts = typeof b.ts === "number" ? b.ts : typeof b.ts === "string" && /^\d{1,16}$/.test(b.ts) ? Number(b.ts) : NaN;
149
+ // Both directions: a clock ahead of ours is as suspect as one behind.
150
+ if (!Number.isSafeInteger(ts) || Math.abs(now() - ts) > LAUNCH_MAX_AGE_MS)
151
+ return fail(401, "launch-expired");
152
+ if (!isUserid(b.userid) || !isSigHex(b.sig) || !connectVerifyLaunch(b.userid, origin, ts, b.sig)) {
153
+ return fail(401, "bad-signature");
154
+ }
155
+ // Last, so a forged repeat cannot burn an assertion a real visitor still
156
+ // holds. Keyed on what was signed (who, for which origin, at which
157
+ // instant), not on the signature's bytes: the same assertion written with
158
+ // other hex case, or any second signature over the same message, is a
159
+ // repeat too.
160
+ const fresh = await state.addOnce("launch", `${b.userid}|${origin}|${ts}`, now() + 2 * LAUNCH_MAX_AGE_MS);
161
+ if (!fresh)
162
+ return fail(401, "launch-replayed");
163
+ return { ok: true, userid: b.userid, hints: hintsOf(b), door: "launch" };
164
+ }
165
+ // ── sessions ──────────────────────────────────────────────────────────────
166
+ function mintSession(userid) {
167
+ const t = now();
168
+ const sid = randomBytes(12).toString("base64url");
169
+ const expiresAt = t + ttlMs;
170
+ const body = Buffer.from(JSON.stringify({ u: userid, i: sid, t, e: expiresAt })).toString("base64url");
171
+ return { token: `v1.${body}.${mac("session", body)}`, sid, expiresAt };
172
+ }
173
+ function readToken(token) {
174
+ if (typeof token !== "string" || token.length > 1024)
175
+ return null;
176
+ const parts = token.split(".");
177
+ if (parts.length !== 3 || parts[0] !== "v1" || !macOk("session", parts[1], parts[2]))
178
+ return null;
179
+ try {
180
+ const p = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
181
+ if (typeof p.u !== "string" || typeof p.i !== "string" || !Number.isFinite(p.t) || !Number.isFinite(p.e))
182
+ return null;
183
+ if (p.e < now())
184
+ return null;
185
+ return { userid: p.u, sid: p.i, issuedAt: p.t, expiresAt: p.e };
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
191
+ function tokenFrom(req) {
192
+ const authz = headerOf(req, "authorization");
193
+ if (authz && /^bearer /i.test(authz))
194
+ return { token: authz.slice(7).trim(), via: "bearer" };
195
+ const cookies = headerOf(req, "cookie");
196
+ if (cookies) {
197
+ for (const part of cookies.split(";")) {
198
+ const i = part.indexOf("=");
199
+ if (i > 0 && part.slice(0, i).trim() === cookieName)
200
+ return { token: part.slice(i + 1).trim(), via: "cookie" };
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ async function authenticate(req, o = {}) {
206
+ const none = { ok: false, status: 401, error: "not-signed-in" };
207
+ const found = tokenFrom(req);
208
+ if (!found)
209
+ return none;
210
+ const t = readToken(found.token);
211
+ if (!t)
212
+ return none;
213
+ if (await state.has("revoked", t.sid))
214
+ return none;
215
+ const user = o.touch === false ? await users.get(t.userid) : await users.touch(t.userid);
216
+ if (!user)
217
+ return none;
218
+ if (user.status === "blocked")
219
+ return { ok: false, status: 403, error: "blocked" };
220
+ if (user.sessionsValidFrom && t.issuedAt < user.sessionsValidFrom)
221
+ return none;
222
+ const session = { ...t, via: found.via };
223
+ const registration = users.registration(user);
224
+ if (registration.refused && !o.allowUnregistered) {
225
+ return { ok: false, status: 403, error: "register-required", user, session, registration };
226
+ }
227
+ return { ok: true, user, session, registration };
228
+ }
229
+ async function revoke(token) {
230
+ const t = readToken(token);
231
+ if (t)
232
+ await state.addOnce("revoked", t.sid, t.expiresAt);
233
+ }
234
+ function cookie(token, origin) {
235
+ const parts = [`${cookieName}=${token ?? ""}`, `Path=${cfg.cookiePath ?? "/"}`, "HttpOnly", `SameSite=${sameSite}`];
236
+ parts.push(`Max-Age=${token ? Math.floor(ttlMs / 1000) : 0}`);
237
+ if (cfg.cookieDomain)
238
+ parts.push(`Domain=${cfg.cookieDomain}`);
239
+ // Secure always, except for a page the site itself listed under plain
240
+ // http (a localhost during development): a browser would drop it there.
241
+ if (sameSite === "None" || !origin?.startsWith("http://"))
242
+ parts.push("Secure");
243
+ return parts.join("; ");
244
+ }
245
+ return {
246
+ issueNonce,
247
+ allowedOrigin,
248
+ verifySignIn,
249
+ verifyLaunch,
250
+ authenticate,
251
+ mintSession,
252
+ readToken,
253
+ tokenOf: (req) => tokenFrom(req)?.token ?? null,
254
+ revoke,
255
+ cookie,
256
+ };
257
+ }
@@ -0,0 +1,9 @@
1
+ import type { BeagleService, BeagleServiceConfig } from "./types.js";
2
+ export declare function createBeagleService(config: BeagleServiceConfig): Promise<BeagleService>;
3
+ export { createAuth, normalizeOrigins, NONCE_TTL_MS, LAUNCH_MAX_AGE_MS } from "./auth.js";
4
+ export { createUsers, DEFAULT_PROFILE_GATEWAY } from "./users.js";
5
+ export { createAgent, loadServers, DEFAULT_SERVERS_URL } from "./agent.js";
6
+ export { Outbox, splitText } from "./outbox.js";
7
+ export { jsonFileUserStore, memoryUserStore, jsonFileStateStore, memoryStateStore } from "./stores.js";
8
+ export { defaultPublicUser } from "./routes.js";
9
+ export * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,69 @@
1
+ // @decentnetwork/beagle-service — the web backend for a site on Beagle identity.
2
+ //
3
+ // Three parts; a site may use the first alone:
4
+ //
5
+ // auth Sign in with Beagle on the server: nonce, exact origin, the two
6
+ // assertions, a launch replay store that survives a restart, sessions.
7
+ // users one record per userid: visitor, registered or blocked; the site's
8
+ // own registration fields; the visitor limit; the blacklist.
9
+ // agent the site's own Carrier identity: who may become a friend, requests
10
+ // held on disk, a persistent outbox, messages with the sender's record.
11
+ //
12
+ // Nothing here makes one site special. Every key is an equal identity; a site
13
+ // that uses this package is one Carrier service provider among any number.
14
+ import { join } from "node:path";
15
+ import { createAuth, loadSecret, normalizeOrigins } from "./auth.js";
16
+ import { createRoutes } from "./routes.js";
17
+ import { jsonFileStateStore, jsonFileUserStore } from "./stores.js";
18
+ import { createUsers } from "./users.js";
19
+ export async function createBeagleService(config) {
20
+ if (!config || typeof config !== "object")
21
+ throw new TypeError("beagle-service: createBeagleService needs a config object");
22
+ const origins = normalizeOrigins(config.origins);
23
+ const dataDir = config.dataDir ?? "./data";
24
+ const now = config.now ?? Date.now;
25
+ const log = config.log ?? ((m) => console.warn(m));
26
+ const store = config.store ?? jsonFileUserStore(join(dataDir, "users.json"));
27
+ const state = config.state ?? jsonFileStateStore(join(dataDir, "state.json"), now);
28
+ const secret = loadSecret(config.sessionSecret, join(dataDir, "session-secret"));
29
+ const users = createUsers({
30
+ store,
31
+ register: config.register,
32
+ visitor: config.visitor,
33
+ importProfile: config.importProfile,
34
+ now,
35
+ log,
36
+ fetch: config.fetch,
37
+ });
38
+ const auth = createAuth({ origins, state, users, secret, session: config.session, now });
39
+ let agent;
40
+ const routes = createRoutes({ auth, users, session: config.session, publicUser: config.publicUser, agent: () => agent, log });
41
+ if (config.agent) {
42
+ // Only when asked for: a site that needs sign-in alone starts no peer.
43
+ const { createAgent } = await import("./agent.js");
44
+ agent = await createAgent({ config: config.agent, users, dir: join(dataDir, "agent"), now, log, fetch: config.fetch });
45
+ }
46
+ return {
47
+ users,
48
+ auth,
49
+ agent,
50
+ handle: routes.handle,
51
+ handlers: routes.handlers,
52
+ nodeHandler: routes.nodeHandler,
53
+ expressHandler: routes.expressHandler,
54
+ nextApiHandler: routes.nextApiHandler,
55
+ fetchHandler: routes.fetchHandler,
56
+ close: async () => {
57
+ await agent?.stop();
58
+ },
59
+ };
60
+ }
61
+ export { createAuth, normalizeOrigins, NONCE_TTL_MS, LAUNCH_MAX_AGE_MS } from "./auth.js";
62
+ export { createUsers, DEFAULT_PROFILE_GATEWAY } from "./users.js";
63
+ export { createAgent, loadServers, DEFAULT_SERVERS_URL } from "./agent.js";
64
+ export { Outbox, splitText } from "./outbox.js";
65
+ export { jsonFileUserStore, memoryUserStore, jsonFileStateStore, memoryStateStore } from "./stores.js";
66
+ export { defaultPublicUser } from "./routes.js";
67
+ // `export *` rather than `export type *`: types.ts holds types only, and this
68
+ // form is read by TypeScript versions before 5.0 too.
69
+ export * from "./types.js";
@@ -0,0 +1,63 @@
1
+ import type { DeadLetter, DeadLetterReason, DeliveryEvent, OutboxItem } from "./types.js";
2
+ import { type Logger } from "./util.js";
3
+ /** Cut `text` into parts that each fit one message (about 700 bytes of
4
+ * UTF-8), at a line break or a space where one is near. The parts joined
5
+ * together are the text, unchanged. */
6
+ export declare function splitText(text: string): string[];
7
+ export interface OutboxTransport {
8
+ /** "friend": the friendship is settled. "pending": a request is out, or one
9
+ * of theirs is held. "none": there is no way a message could reach them. */
10
+ relation(userid: string): "friend" | "pending" | "none";
11
+ /** A live session that carried something from the friend a moment ago. */
12
+ proven(userid: string): boolean;
13
+ /** Live send with a short confirmation window; throws when nothing took it. */
14
+ sendLive(userid: string, text: string, deliveryId: string): Promise<"acked" | "accepted" | "offline">;
15
+ /** Post to an Express node whatever the session looks like; throws when it cannot. */
16
+ sendExpress(userid: string, text: string, deliveryId: string): Promise<void>;
17
+ }
18
+ export interface OutboxOptions {
19
+ file: string;
20
+ deadFile: string;
21
+ transport: OutboxTransport;
22
+ now?: () => number;
23
+ log?: Logger;
24
+ baseDelayMs?: number;
25
+ maxDelayMs?: number;
26
+ maxAgeMs?: number;
27
+ onDelivered?: (e: DeliveryEvent) => void;
28
+ onDead?: (d: DeadLetter) => void;
29
+ }
30
+ /**
31
+ * Messages on their way out, kept in one JSON file.
32
+ *
33
+ * - A message is written to disk before `enqueue` returns and removed only
34
+ * after a road took it, so a restart loses nothing.
35
+ * - Its `deliveryId` is fixed when it is queued. A retry, a second road or a
36
+ * send repeated after a crash carries the same id, and a friend that reads
37
+ * the delivery envelope shows it once.
38
+ * - One queue per recipient: a recipient's messages go in order, and an
39
+ * unreachable recipient never holds up another.
40
+ * - The road (the rule the agentnet daemon uses): a session that has just
41
+ * proven itself gets the message live, with a short wait for the
42
+ * confirmation; otherwise, or when no confirmation comes, it is posted to
43
+ * an Express node at once. Only when neither road takes it does it wait,
44
+ * with a delay that doubles up to `maxDelayMs`.
45
+ * - What can never arrive is moved to the dead-letter file with the reason.
46
+ */
47
+ export declare class Outbox {
48
+ #private;
49
+ constructor(o: OutboxOptions);
50
+ list(): OutboxItem[];
51
+ deadLetters(limit?: number): DeadLetter[];
52
+ /** Put a text on disk for `to`. Synchronous: when it returns, the message survives a crash. */
53
+ enqueue(to: string, text: string): OutboxItem[];
54
+ /** Record a message that cannot be sent at all, without queueing it. */
55
+ refuse(to: string, text: string, why: DeadLetterReason): OutboxItem[];
56
+ /** Dead-letter everything queued for `to`. */
57
+ dropFor(to: string, why: DeadLetterReason): number;
58
+ /** Something changed for `to` (accepted, connected): try now, not after the delay. */
59
+ kick(to: string): void;
60
+ stop(): void;
61
+ /** Try the head of every recipient's queue that is due. */
62
+ pump(): Promise<void>;
63
+ }