@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/users.js ADDED
@@ -0,0 +1,379 @@
1
+ import { addressOf, Hub, isUserid, KeyedLock, msgOf } from "./util.js";
2
+ export const DEFAULT_PROFILE_GATEWAY = "https://ens-gateway.beaglechat.workers.dev";
3
+ const DAY_MS = 86_400_000;
4
+ const TOUCH_EVERY_MS = 60_000;
5
+ const MAX_FIELD_BYTES = 2000;
6
+ const text = (v, max) => {
7
+ if (typeof v !== "string")
8
+ return null;
9
+ const s = v.trim();
10
+ return s && s.length <= max ? s : s ? s.slice(0, max) : null;
11
+ };
12
+ const isEmpty = (v) => v === undefined || v === null || (typeof v === "string" && v.trim() === "");
13
+ /** Loose shape checks for the two field names nearly every site uses. A site
14
+ * that needs more (a confirmation code, a country rule) adds `register.verify`. */
15
+ function builtInCheck(field, value) {
16
+ if (field === "email") {
17
+ if (typeof value !== "string" || value.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
18
+ return "not-an-email";
19
+ }
20
+ if (field === "phone") {
21
+ if (typeof value !== "string" || !/^\+?[0-9][0-9 ().-]{4,30}$/.test(value))
22
+ return "not-a-phone-number";
23
+ }
24
+ return null;
25
+ }
26
+ export function createUsers(opts) {
27
+ const store = opts.store;
28
+ const now = opts.now ?? Date.now;
29
+ const log = opts.log ?? (() => { });
30
+ const required = [...new Set(opts.register?.required ?? [])];
31
+ const optional = [...new Set(opts.register?.optional ?? [])].filter((f) => !required.includes(f));
32
+ const verify = opts.register?.verify;
33
+ const visitor = opts.visitor ?? {};
34
+ const gapMs = (visitor.visitGapMinutes ?? 30) * 60_000;
35
+ const hub = new Hub(log);
36
+ const lock = new KeyedLock();
37
+ const lastTouch = new Map();
38
+ const iso = (t) => new Date(t).toISOString();
39
+ const blank = (userid) => ({
40
+ userid,
41
+ address: null,
42
+ name: null,
43
+ avatar: null,
44
+ punkId: null,
45
+ door: null,
46
+ status: "visitor",
47
+ profile: {},
48
+ firstSeen: null,
49
+ lastSeen: null,
50
+ visits: 0,
51
+ signIns: 0,
52
+ });
53
+ const missingOf = (profile) => required.filter((f) => isEmpty(profile?.[f]));
54
+ /** visitor or registered, from what the profile holds today. */
55
+ const statusFor = (user) => (missingOf(user.profile).length === 0 ? "registered" : "visitor");
56
+ function registration(user) {
57
+ const missing = user.status === "registered" ? [] : missingOf(user.profile);
58
+ const state = { status: user.status, required, optional, missing, due: false, refused: false };
59
+ if (user.status !== "visitor")
60
+ return state;
61
+ const t = now();
62
+ let over = false;
63
+ if (visitor.maxDays !== undefined && user.firstSeen) {
64
+ const deadline = Date.parse(user.firstSeen) + visitor.maxDays * DAY_MS;
65
+ state.deadline = iso(deadline);
66
+ if (t > deadline)
67
+ over = true;
68
+ }
69
+ if (visitor.maxVisits !== undefined) {
70
+ state.visitsLeft = Math.max(0, visitor.maxVisits - (user.visits || 0));
71
+ if ((user.visits || 0) > visitor.maxVisits)
72
+ over = true;
73
+ }
74
+ state.due = over;
75
+ state.refused = over && visitor.then === "refuse";
76
+ return state;
77
+ }
78
+ const get = async (userid) => (await store.get(userid)) ?? null;
79
+ async function list(filter) {
80
+ const all = await store.list();
81
+ return filter?.status ? all.filter((u) => u.status === filter.status) : all;
82
+ }
83
+ function emitLater(event, ...args) {
84
+ // Never awaited by the caller: a listener (the agent accepting a held
85
+ // request, a site sending a welcome) must not hold up the HTTP answer,
86
+ // and must be free to call back into this module.
87
+ void hub.emit(event, ...args);
88
+ }
89
+ async function recordSignIn(userid, hints = {}, info = {}) {
90
+ if (!isUserid(userid))
91
+ throw new TypeError("beagle-service: not a userid");
92
+ const events = [];
93
+ const user = await lock.run(userid, async () => {
94
+ const prev = await get(userid);
95
+ if (prev?.status === "blocked")
96
+ return prev;
97
+ const t = now();
98
+ const next = { ...blank(userid), ...prev };
99
+ const first = !prev || !prev.firstSeen;
100
+ // A blank or malformed hint never replaces a value already held: the
101
+ // address above all cannot be asked for again.
102
+ const address = addressOf(userid, hints.address);
103
+ if (address)
104
+ next.address = address;
105
+ const name = text(hints.name, 128);
106
+ if (name)
107
+ next.name = name;
108
+ const avatar = typeof hints.avatar === "string" && hints.avatar.length <= 4096 ? hints.avatar.trim() : "";
109
+ if (avatar)
110
+ next.avatar = avatar;
111
+ if (typeof hints.punkId === "number" && Number.isInteger(hints.punkId) && hints.punkId >= 0 && hints.punkId < 1_000_000) {
112
+ next.punkId = hints.punkId;
113
+ }
114
+ if (info.door)
115
+ next.door = info.door.slice(0, 200);
116
+ next.firstSeen ??= iso(t);
117
+ if (!prev?.lastSeen || t - Date.parse(prev.lastSeen) > gapMs)
118
+ next.visits = (next.visits || 0) + 1;
119
+ next.lastSeen = iso(t);
120
+ next.signIns = (next.signIns || 0) + 1;
121
+ const was = prev?.status;
122
+ next.status = statusFor(next);
123
+ if (next.status === "registered")
124
+ next.registeredAt ??= iso(t);
125
+ await store.put(next);
126
+ lastTouch.set(userid, t);
127
+ events.push(() => emitLater("sign-in", next, { first, door: next.door }));
128
+ if (next.status === "registered" && was !== "registered")
129
+ events.push(() => emitLater("registered", next));
130
+ return next;
131
+ });
132
+ for (const fire of events)
133
+ fire();
134
+ if (opts.importProfile && user.status !== "blocked")
135
+ void importProfile(userid).catch(() => { });
136
+ return user;
137
+ }
138
+ async function touch(userid) {
139
+ const t = now();
140
+ if (t - (lastTouch.get(userid) ?? 0) < TOUCH_EVERY_MS)
141
+ return get(userid);
142
+ return lock.run(userid, async () => {
143
+ const prev = await get(userid);
144
+ if (!prev || prev.status === "blocked")
145
+ return prev;
146
+ lastTouch.set(userid, t);
147
+ const next = { ...prev, lastSeen: iso(t) };
148
+ if (!prev.lastSeen || t - Date.parse(prev.lastSeen) > gapMs)
149
+ next.visits = (prev.visits || 0) + 1;
150
+ await store.put(next);
151
+ return next;
152
+ });
153
+ }
154
+ async function update(userid, patch) {
155
+ if (patch.status === "blocked")
156
+ throw new TypeError("beagle-service: use users.block(userid, why) to block");
157
+ const events = [];
158
+ const user = await lock.run(userid, async () => {
159
+ const prev = await get(userid);
160
+ if (!prev)
161
+ throw new Error(`beagle-service: no user ${userid}`);
162
+ if (prev.status === "blocked" && patch.status)
163
+ throw new TypeError("beagle-service: use users.unblock(userid) to unblock");
164
+ const next = {
165
+ ...prev,
166
+ ...patch,
167
+ userid: prev.userid,
168
+ profile: patch.profile ? { ...prev.profile, ...patch.profile } : prev.profile,
169
+ };
170
+ if (next.status === "registered" && prev.status !== "registered") {
171
+ next.registeredAt ??= iso(now());
172
+ events.push(() => emitLater("registered", next));
173
+ }
174
+ await store.put(next);
175
+ return next;
176
+ });
177
+ for (const fire of events)
178
+ fire();
179
+ return user;
180
+ }
181
+ async function register(userid, submitted) {
182
+ const current = await get(userid);
183
+ if (!current)
184
+ return { ok: false, error: "not-found" };
185
+ if (current.status === "blocked")
186
+ return { ok: false, error: "blocked" };
187
+ const body = submitted && typeof submitted === "object" ? submitted : {};
188
+ const errors = {};
189
+ const accepted = {};
190
+ // Checked outside the per-user lock: `verify` is the site's code and may
191
+ // be slow or call back into users.
192
+ for (const field of [...required, ...optional]) {
193
+ if (!Object.prototype.hasOwnProperty.call(body, field))
194
+ continue;
195
+ let value = body[field];
196
+ if (typeof value === "string")
197
+ value = value.trim();
198
+ if (isEmpty(value))
199
+ continue; // a required one is reported as missing below
200
+ let size = 0;
201
+ try {
202
+ size = Buffer.byteLength(JSON.stringify(value));
203
+ }
204
+ catch {
205
+ size = Infinity;
206
+ }
207
+ if (size > MAX_FIELD_BYTES) {
208
+ errors[field] = "too-long";
209
+ continue;
210
+ }
211
+ const bad = builtInCheck(field, value);
212
+ if (bad) {
213
+ errors[field] = bad;
214
+ continue;
215
+ }
216
+ if (verify) {
217
+ let verdict;
218
+ try {
219
+ verdict = await verify(field, value, current, body);
220
+ }
221
+ catch (e) {
222
+ log(`beagle-service: register.verify threw for "${field}": ${msgOf(e)}`);
223
+ verdict = "could-not-verify";
224
+ }
225
+ if (verdict === false) {
226
+ errors[field] = "not-accepted";
227
+ continue;
228
+ }
229
+ if (typeof verdict === "string") {
230
+ errors[field] = verdict;
231
+ continue;
232
+ }
233
+ if (verdict && typeof verdict === "object")
234
+ value = verdict.value;
235
+ }
236
+ accepted[field] = value;
237
+ }
238
+ const events = [];
239
+ const result = await lock.run(userid, async () => {
240
+ const prev = await get(userid);
241
+ if (!prev)
242
+ return { ok: false, error: "not-found" };
243
+ if (prev.status === "blocked")
244
+ return { ok: false, error: "blocked" };
245
+ const profile = { ...prev.profile, ...accepted };
246
+ for (const f of missingOf(profile))
247
+ errors[f] ??= "required";
248
+ if (Object.keys(errors).length)
249
+ return { ok: false, error: "invalid-fields", fields: errors };
250
+ const next = { ...prev, profile, status: "registered" };
251
+ next.registeredAt ??= iso(now());
252
+ await store.put(next);
253
+ if (prev.status !== "registered")
254
+ events.push(() => emitLater("registered", next));
255
+ return { ok: true, user: next };
256
+ });
257
+ for (const fire of events)
258
+ fire();
259
+ return result;
260
+ }
261
+ async function block(userid, why = "") {
262
+ if (!isUserid(userid))
263
+ throw new TypeError("beagle-service: not a userid");
264
+ let fire = false;
265
+ const user = await lock.run(userid, async () => {
266
+ const prev = (await get(userid)) ?? blank(userid);
267
+ if (prev.status === "blocked")
268
+ return prev;
269
+ const t = now();
270
+ const next = {
271
+ ...prev,
272
+ status: "blocked",
273
+ blocked: { at: iso(t), why: String(why).slice(0, 1000), was: prev.status },
274
+ // Sessions issued up to now are dead for good, also after an unblock.
275
+ sessionsValidFrom: t + 1,
276
+ };
277
+ await store.put(next);
278
+ fire = true;
279
+ return next;
280
+ });
281
+ if (fire)
282
+ emitLater("blocked", user);
283
+ return user;
284
+ }
285
+ async function unblock(userid) {
286
+ let fire = false;
287
+ const user = await lock.run(userid, async () => {
288
+ const prev = await get(userid);
289
+ if (!prev || prev.status !== "blocked")
290
+ return prev;
291
+ const next = { ...prev, blocked: null, status: "visitor" };
292
+ next.status = prev.blocked?.was === "registered" || (prev.firstSeen && statusFor(next) === "registered") ? "registered" : "visitor";
293
+ await store.put(next);
294
+ fire = true;
295
+ return next;
296
+ });
297
+ if (fire && user)
298
+ emitLater("unblocked", user);
299
+ return user;
300
+ }
301
+ async function revokeSessions(userid) {
302
+ await lock.run(userid, async () => {
303
+ const prev = await get(userid);
304
+ if (prev)
305
+ await store.put({ ...prev, sessionsValidFrom: now() + 1 });
306
+ });
307
+ }
308
+ // ── beagles.eth profile import ────────────────────────────────────────────
309
+ const gateway = (typeof opts.importProfile === "object" && opts.importProfile.gateway) || DEFAULT_PROFILE_GATEWAY;
310
+ const ttlMs = ((typeof opts.importProfile === "object" && opts.importProfile.ttlHours) || 24) * 3_600_000;
311
+ const noRecordUntil = new Map();
312
+ async function importProfile(userid) {
313
+ if (!isUserid(userid))
314
+ return null;
315
+ const t = now();
316
+ const user = await get(userid);
317
+ if (!user)
318
+ return null;
319
+ if (user.published?.fetchedAt && t - Date.parse(user.published.fetchedAt) < ttlMs)
320
+ return user.published;
321
+ if ((noRecordUntil.get(userid) ?? 0) > t)
322
+ return null;
323
+ const doFetch = opts.fetch ?? fetch;
324
+ let record;
325
+ try {
326
+ const r = await doFetch(`${gateway.replace(/\/+$/, "")}/getAddress/${userid}`, { signal: AbortSignal.timeout(5000) });
327
+ if (r.status === 404) {
328
+ noRecordUntil.set(userid, t + ttlMs);
329
+ return null;
330
+ }
331
+ if (!r.ok)
332
+ throw new Error(`HTTP ${r.status}`);
333
+ record = await r.json();
334
+ }
335
+ catch (e) {
336
+ log(`beagle-service: profile import for ${userid.slice(0, 8)} failed: ${msgOf(e)}`);
337
+ return null;
338
+ }
339
+ if (!record || typeof record !== "object" || typeof record.name !== "string") {
340
+ noRecordUntil.set(userid, t + ttlMs);
341
+ return null;
342
+ }
343
+ const texts = record.texts && typeof record.texts === "object" ? record.texts : {};
344
+ const published = { fetchedAt: iso(t) };
345
+ const put = (k, v, max) => {
346
+ const s = text(v, max);
347
+ if (s)
348
+ published[k] = s;
349
+ };
350
+ put("name", record.name, 255);
351
+ put("owner", record.owner, 128);
352
+ put("displayName", texts.displayName, 128);
353
+ put("description", texts.description, 1000);
354
+ put("avatar", texts.avatar, 2048);
355
+ put("url", texts.url, 2048);
356
+ const patch = { published };
357
+ // The record's address is a claim like the sign-in hint: used only when
358
+ // none is held, and only when it parses to this userid.
359
+ const address = addressOf(userid, texts.carrierAddress);
360
+ if (!user.address && address)
361
+ patch.address = address;
362
+ await update(userid, patch).catch(() => { });
363
+ return published;
364
+ }
365
+ return {
366
+ get,
367
+ list,
368
+ update,
369
+ block,
370
+ unblock,
371
+ revokeSessions,
372
+ register,
373
+ registration,
374
+ recordSignIn,
375
+ touch,
376
+ importProfile,
377
+ on: (event, cb) => hub.on(event, cb),
378
+ };
379
+ }
package/dist/util.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /** Write JSON so a crash leaves either the old file or the new one, never half
2
+ * of one: write a sibling temp file, fsync it, rename it over the target. */
3
+ export declare function atomicWriteJson(file: string, value: unknown, mode?: number): void;
4
+ /** Read a JSON file. A missing file is the fallback; a file that is there but
5
+ * does not parse throws, because treating it as empty would let the next
6
+ * write replace real data with nothing. */
7
+ export declare function readJsonFile<T>(file: string, fallback: T): T;
8
+ /** A userid is the base58 of a 32-byte public key. */
9
+ export declare function isUserid(v: unknown): v is string;
10
+ /** The address when it is a well-formed Carrier address of `userid`, else null.
11
+ * An address is not signed at sign-in, so the only thing that can be checked
12
+ * is that it parses (key, nospam, checksum) and names the key that signed. */
13
+ export declare function addressOf(userid: string, address: unknown): string | null;
14
+ export declare const msgOf: (e: unknown) => string;
15
+ export type Logger = (message: string) => void;
16
+ /** A minimal typed event hub; a listener that throws never breaks the emitter. */
17
+ export declare class Hub<Events extends Record<string, unknown[]>> {
18
+ #private;
19
+ constructor(log?: Logger);
20
+ on<K extends keyof Events>(event: K, cb: (...args: Events[K]) => unknown): () => void;
21
+ /** Runs listeners in registration order and waits for each. */
22
+ emit<K extends keyof Events>(event: K, ...args: Events[K]): Promise<void>;
23
+ }
24
+ /** Run async jobs for the same key one after another. */
25
+ export declare class KeyedLock {
26
+ #private;
27
+ run<T>(key: string, job: () => Promise<T>): Promise<T>;
28
+ }
package/dist/util.js ADDED
@@ -0,0 +1,107 @@
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { base58ToBytes, carrierIdFromAddress } from "@decentnetwork/peer";
5
+ /** Write JSON so a crash leaves either the old file or the new one, never half
6
+ * of one: write a sibling temp file, fsync it, rename it over the target. */
7
+ export function atomicWriteJson(file, value, mode = 0o600) {
8
+ mkdirSync(dirname(file), { recursive: true });
9
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
10
+ const fd = openSync(tmp, "w", mode);
11
+ try {
12
+ writeSync(fd, JSON.stringify(value, null, 2));
13
+ fsyncSync(fd);
14
+ }
15
+ finally {
16
+ closeSync(fd);
17
+ }
18
+ renameSync(tmp, file);
19
+ }
20
+ /** Read a JSON file. A missing file is the fallback; a file that is there but
21
+ * does not parse throws, because treating it as empty would let the next
22
+ * write replace real data with nothing. */
23
+ export function readJsonFile(file, fallback) {
24
+ let raw;
25
+ try {
26
+ raw = readFileSync(file, "utf8");
27
+ }
28
+ catch (e) {
29
+ if (e.code === "ENOENT")
30
+ return fallback;
31
+ throw e;
32
+ }
33
+ if (raw.trim() === "")
34
+ return fallback;
35
+ try {
36
+ return JSON.parse(raw);
37
+ }
38
+ catch (e) {
39
+ throw new Error(`beagle-service: ${file} is not valid JSON (${e.message}); fix or move it before starting`);
40
+ }
41
+ }
42
+ /** A userid is the base58 of a 32-byte public key. */
43
+ export function isUserid(v) {
44
+ if (typeof v !== "string" || v.length < 32 || v.length > 64)
45
+ return false;
46
+ try {
47
+ return base58ToBytes(v).length === 32;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ /** The address when it is a well-formed Carrier address of `userid`, else null.
54
+ * An address is not signed at sign-in, so the only thing that can be checked
55
+ * is that it parses (key, nospam, checksum) and names the key that signed. */
56
+ export function addressOf(userid, address) {
57
+ if (typeof address !== "string" || address.length < 40 || address.length > 80)
58
+ return null;
59
+ try {
60
+ return carrierIdFromAddress(address) === userid ? address : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ export const msgOf = (e) => e?.message ?? String(e);
67
+ /** A minimal typed event hub; a listener that throws never breaks the emitter. */
68
+ export class Hub {
69
+ #listeners = new Map();
70
+ #log;
71
+ constructor(log = () => { }) {
72
+ this.#log = log;
73
+ }
74
+ on(event, cb) {
75
+ let set = this.#listeners.get(event);
76
+ if (!set)
77
+ this.#listeners.set(event, (set = new Set()));
78
+ set.add(cb);
79
+ return () => set.delete(cb);
80
+ }
81
+ /** Runs listeners in registration order and waits for each. */
82
+ async emit(event, ...args) {
83
+ for (const cb of [...(this.#listeners.get(event) ?? [])]) {
84
+ try {
85
+ await cb(...args);
86
+ }
87
+ catch (e) {
88
+ this.#log(`beagle-service: a "${String(event)}" listener threw: ${msgOf(e)}`);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ /** Run async jobs for the same key one after another. */
94
+ export class KeyedLock {
95
+ #tails = new Map();
96
+ run(key, job) {
97
+ const prev = this.#tails.get(key) ?? Promise.resolve();
98
+ const next = prev.then(job, job);
99
+ const tail = next.catch(() => { });
100
+ this.#tails.set(key, tail);
101
+ void tail.then(() => {
102
+ if (this.#tails.get(key) === tail)
103
+ this.#tails.delete(key);
104
+ });
105
+ return next;
106
+ }
107
+ }
@@ -0,0 +1,91 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <meta charset="utf-8">
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <title>Minimal site</title>
6
+ <style>
7
+ body { font: 16px/1.5 system-ui, sans-serif; max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
8
+ button, input { font: inherit; padding: .5rem .8rem; margin: .2rem .2rem .2rem 0; }
9
+ code { word-break: break-all; }
10
+ #error { color: #b00020; }
11
+ </style>
12
+ <h1>Minimal site</h1>
13
+ <div id="out">Loading…</div>
14
+ <p id="error"></p>
15
+
16
+ <script type="module">
17
+ import { signIn, readLaunch, addFriend } from "/beagle-connect.js";
18
+
19
+ // Two doors, one origin each: an https page cannot probe for an installed
20
+ // Beagle, so the visitor says which one they use. A Beagle on another local
21
+ // port: open this page with ?beagle=http://127.0.0.1:8767
22
+ const local = new URLSearchParams(location.search).get("beagle");
23
+ const DOORS = {
24
+ installed: /^http:\/\/(127\.0\.0\.1|localhost):\d+$/.test(local ?? "") ? local : "http://127.0.0.1:8766",
25
+ web: "https://app.beagle.chat",
26
+ };
27
+ const out = document.querySelector("#out");
28
+ const fail = (e) => (document.querySelector("#error").textContent = e?.message ?? String(e));
29
+ const api = async (path, body) => {
30
+ const r = await fetch(`/api/auth/${path}`, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : {});
31
+ return { status: r.status, ...(await r.json()) };
32
+ };
33
+ const el = (tag, props = {}, ...kids) => {
34
+ const n = Object.assign(document.createElement(tag), props);
35
+ n.append(...kids);
36
+ return n;
37
+ };
38
+
39
+ async function signInThrough(door) {
40
+ // Open the window inside the click: one opened after an await has lost the user gesture.
41
+ const win = window.open("about:blank", "beagle-connect", "width=420,height=560");
42
+ if (!win) return fail(new Error("Allow pop-ups for this site, then click again."));
43
+ try {
44
+ const { nonce } = await api("nonce");
45
+ const who = await signIn({ nonce, origins: [DOORS[door]], existingWindow: win });
46
+ render(await api("sign-in", who)); // { userid, address, name, avatar, sig, nonce, uiOrigin }
47
+ } catch (e) {
48
+ try { win.close(); } catch {}
49
+ fail(e);
50
+ }
51
+ }
52
+
53
+ function render(me) {
54
+ document.querySelector("#error").textContent = "";
55
+ out.replaceChildren();
56
+ if (!me.user) {
57
+ if (me.error && me.error !== "not-signed-in") fail(new Error(me.message));
58
+ out.append(
59
+ el("button", { textContent: "Sign in with Beagle (installed)", onclick: () => signInThrough("installed") }),
60
+ el("button", { textContent: "Sign in with Beagle in the browser", onclick: () => signInThrough("web") }),
61
+ );
62
+ return;
63
+ }
64
+ const { user, registration, agent } = me;
65
+ out.append(el("p", {}, `Signed in as ${user.name ?? "someone"} (${user.status}) `, el("code", { textContent: user.userid })));
66
+ if (registration.missing.length || registration.optional.length) {
67
+ const form = el("form", {}, el("p", { textContent: registration.due ? "Please register to go on:" : registration.missing.length ? "Register:" : "Your details:" }));
68
+ for (const f of [...registration.required, ...registration.optional]) {
69
+ form.append(el("input", { name: f, placeholder: f + (registration.required.includes(f) ? " (required)" : ""), value: user.profile[f] ?? "" }));
70
+ }
71
+ form.append(el("button", { textContent: "Save" }));
72
+ form.onsubmit = async (ev) => {
73
+ ev.preventDefault();
74
+ const r = await api("register", { profile: Object.fromEntries(new FormData(form)) });
75
+ r.ok ? render(r) : fail(new Error(Object.entries(r.fields ?? {}).map(([k, v]) => `${k}: ${v}`).join(", ") || r.message));
76
+ };
77
+ out.append(form);
78
+ }
79
+ if (agent) out.append(el("button", { textContent: `Add ${agent.name ?? "our agent"} on Beagle`, onclick: () => addFriend({ address: agent.address, name: agent.name }).catch(fail) }));
80
+ out.append(el("button", { textContent: "Sign out", onclick: async () => { await api("logout", {}); render({}); } }));
81
+ fetch("/api/hello").then((r) => r.json()).then((j) => out.append(el("p", { textContent: j.message ?? `The site's own route says: ${j.error}` })));
82
+ }
83
+
84
+ // Arrived from Beagle's Apps tab? Then the visitor is signed in already.
85
+ try {
86
+ const launch = await readLaunch();
87
+ if (launch) await api("launch", launch);
88
+ } catch (e) { fail(e); }
89
+ render(await api("me"));
90
+ </script>
91
+ </html>
@@ -0,0 +1,64 @@
1
+ // A whole site on Beagle identity in one file.
2
+ // npm install @decentnetwork/beagle-service @decentnetwork/beagle-connect esbuild
3
+ // node server.mjs → http://localhost:3000
4
+ import http from "node:http";
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { build } from "esbuild";
8
+ import { createBeagleService } from "@decentnetwork/beagle-service";
9
+
10
+ const PORT = Number(process.env.PORT ?? 3000);
11
+ const ORIGIN = process.env.ORIGIN ?? `http://localhost:${PORT}`; // exactly what the browser's address bar shows
12
+
13
+ const svc = await createBeagleService({
14
+ origins: [ORIGIN],
15
+ dataDir: process.env.DATA_DIR ?? fileURLToPath(new URL("./data", import.meta.url)),
16
+ register: { required: ["email"], optional: ["location"] }, // [] = every visitor is a user at once
17
+ visitor: { maxDays: 14, then: "ask" }, // "refuse" = 403 until they register
18
+ // The site's own Carrier identity. Leave it out if the site only needs sign-in.
19
+ agent: process.env.AGENT ? { nickname: "Minimal Site", accept: "signed-in", befriendOn: "register" } : undefined,
20
+ });
21
+
22
+ svc.agent?.onMessage(({ user, text, reply }) => reply(user ? `Hello ${user.name ?? "there"}, you said: ${text}` : "Sign in on the site first."));
23
+ svc.users.on("registered", (user) => console.log(`registered: ${user.userid} ${JSON.stringify(user.profile)}`));
24
+
25
+ // beagle-connect for the browser. It needs two functions of @decentnetwork/peer
26
+ // (the signature check), so bundle those two modules instead of the whole peer.
27
+ const peerForBrowser = {
28
+ name: "peer-for-browser",
29
+ setup(b) {
30
+ b.onResolve({ filter: /^@decentnetwork\/peer$/ }, () => ({ path: "peer", namespace: "shim" }));
31
+ b.onLoad({ filter: /.*/, namespace: "shim" }, () => ({
32
+ resolveDir: fileURLToPath(new URL(".", import.meta.url)),
33
+ contents: `export { verifyDetached } from "@decentnetwork/peer/dist/crypto/sign.js";
34
+ export { base58ToBytes } from "@decentnetwork/peer/dist/utils/base58.js";`,
35
+ }));
36
+ },
37
+ };
38
+ const client = (await build({
39
+ entryPoints: [fileURLToPath(import.meta.resolve("@decentnetwork/beagle-connect"))],
40
+ bundle: true, format: "esm", platform: "browser", write: false, plugins: [peerForBrowser],
41
+ })).outputFiles[0].text;
42
+
43
+ const page = readFileSync(new URL("./index.html", import.meta.url));
44
+ const auth = svc.nodeHandler({ prefix: "/api/auth" }); // nonce, sign-in, launch, me, logout, register
45
+ const send = (res, status, type, body) => { res.writeHead(status, { "content-type": type, "cache-control": "no-store" }); res.end(body); };
46
+
47
+ http.createServer(async (req, res) => {
48
+ const path = new URL(req.url, ORIGIN).pathname;
49
+ if (path.startsWith("/api/auth/")) return auth(req, res);
50
+ if (path === "/api/hello") {
51
+ // One of the site's own routes: who is asking?
52
+ const who = await svc.auth.authenticate(req);
53
+ if (!who.ok) return send(res, who.status, "application/json", JSON.stringify({ error: who.error }));
54
+ return send(res, 200, "application/json", JSON.stringify({ message: `Hello ${who.user.name ?? who.user.userid}, you are ${who.user.status}.` }));
55
+ }
56
+ if (path === "/beagle-connect.js") return send(res, 200, "text/javascript; charset=utf-8", client);
57
+ if (path === "/") return send(res, 200, "text/html; charset=utf-8", page);
58
+ send(res, 404, "text/plain", "not found");
59
+ }).listen(PORT, () => {
60
+ console.log(`minimal-site: ${ORIGIN}`);
61
+ if (svc.agent) console.log(`agent address (add it in Beagle): ${svc.agent.address}`);
62
+ });
63
+
64
+ // To refuse someone: await svc.users.block(userid, "spam") (sign-in answers 403, sessions end, the agent drops them)