@decentnetwork/beagle-service 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +720 -2
- package/dist/agent.d.ts +18 -0
- package/dist/agent.js +448 -0
- package/dist/auth.d.ts +28 -0
- package/dist/auth.js +340 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +163 -0
- package/dist/commonjs.cjs +49 -0
- package/dist/commonjs.d.cts +11 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +191 -0
- package/dist/outbox.d.ts +63 -0
- package/dist/outbox.js +314 -0
- package/dist/routes.d.ts +29 -0
- package/dist/routes.js +454 -0
- package/dist/stores.d.ts +21 -0
- package/dist/stores.js +92 -0
- package/dist/types.d.ts +641 -0
- package/dist/types.js +1 -0
- package/dist/users.d.ts +16 -0
- package/dist/users.js +429 -0
- package/dist/util.d.ts +48 -0
- package/dist/util.js +208 -0
- package/examples/minimal-site/index.html +91 -0
- package/examples/minimal-site/server.mjs +64 -0
- package/examples/next-pages-cjs/README.md +30 -0
- package/examples/next-pages-cjs/instrumentation.js +16 -0
- package/examples/next-pages-cjs/lib/beagle.js +23 -0
- package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
- package/examples/next-pages-cjs/next.config.js +11 -0
- package/examples/next-pages-cjs/package.json +19 -0
- package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
- package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
- package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
- package/examples/next-pages-cjs/pages/api/notes.js +16 -0
- package/examples/next-pages-cjs/pages/index.js +52 -0
- package/examples/sign-in-from-script.mjs +37 -0
- package/package.json +67 -4
package/dist/agent.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Agent, AgentConfig, AgentServers, Users } from "./types.js";
|
|
2
|
+
import { type Logger } from "./util.js";
|
|
3
|
+
/** The public server list the Beagle apps read: bootstrap and Express nodes
|
|
4
|
+
* of the Carrier network. Any list in the same format can stand in for it. */
|
|
5
|
+
export declare const DEFAULT_SERVERS_URL = "https://beagle.chat/assets/bgservers.json";
|
|
6
|
+
export interface AgentOptions {
|
|
7
|
+
config: AgentConfig;
|
|
8
|
+
users: Users;
|
|
9
|
+
/** Directory for the agent's files. */
|
|
10
|
+
dir: string;
|
|
11
|
+
now?: () => number;
|
|
12
|
+
log?: Logger;
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
}
|
|
15
|
+
/** Bootstrap and Express nodes from a `bgservers.json`-format list. The last
|
|
16
|
+
* good answer is kept on disk, so a restart works while the URL is down. */
|
|
17
|
+
export declare function loadServers(source: AgentServers | string | undefined, cacheFile: string, doFetch: typeof fetch, log: Logger): Promise<AgentServers>;
|
|
18
|
+
export declare function createAgent(o: AgentOptions): Promise<Agent>;
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { Outbox } from "./outbox.js";
|
|
4
|
+
import { addressOf, atomicWriteJson, Hub, isUserid, msgOf, readJsonFile } from "./util.js";
|
|
5
|
+
/** The public server list the Beagle apps read: bootstrap and Express nodes
|
|
6
|
+
* of the Carrier network. Any list in the same format can stand in for it. */
|
|
7
|
+
export const DEFAULT_SERVERS_URL = "https://beagle.chat/assets/bgservers.json";
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
/** Bootstrap and Express nodes from a `bgservers.json`-format list. The last
|
|
10
|
+
* good answer is kept on disk, so a restart works while the URL is down. */
|
|
11
|
+
export async function loadServers(source, cacheFile, doFetch, log) {
|
|
12
|
+
if (source && typeof source === "object")
|
|
13
|
+
return source;
|
|
14
|
+
const url = source ?? DEFAULT_SERVERS_URL;
|
|
15
|
+
const node = (n) => ({ host: n.ipv4 ?? n.host, port: Number(n.port), pk: n.publicKey ?? n.pk });
|
|
16
|
+
const usable = (n) => typeof n.host === "string" && n.host.length > 0 && Number.isFinite(n.port);
|
|
17
|
+
try {
|
|
18
|
+
const r = await doFetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
19
|
+
if (!r.ok)
|
|
20
|
+
throw new Error(`HTTP ${r.status}`);
|
|
21
|
+
const list = await r.json();
|
|
22
|
+
const servers = {
|
|
23
|
+
bootstrapNodes: (Array.isArray(list?.bootstrapNodes) ? list.bootstrapNodes : []).map(node).filter(usable),
|
|
24
|
+
expressNodes: (Array.isArray(list?.expressNodes) ? list.expressNodes : []).map(node).filter(usable),
|
|
25
|
+
};
|
|
26
|
+
if (!servers.bootstrapNodes.length)
|
|
27
|
+
throw new Error("the list has no bootstrap nodes");
|
|
28
|
+
atomicWriteJson(cacheFile, servers);
|
|
29
|
+
return servers;
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
const cached = readJsonFile(cacheFile, null);
|
|
33
|
+
if (cached?.bootstrapNodes?.length) {
|
|
34
|
+
log(`beagle-service: could not load the server list from ${url} (${msgOf(e)}); using the copy from the last start`);
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`beagle-service: could not load the server list from ${url}: ${msgOf(e)}. Pass agent.servers = { bootstrapNodes, expressNodes } or a reachable URL.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function createAgent(o) {
|
|
41
|
+
const { config, users, dir } = o;
|
|
42
|
+
const now = o.now ?? Date.now;
|
|
43
|
+
const log = o.log ?? (() => { });
|
|
44
|
+
const hub = new Hub(log);
|
|
45
|
+
const policy = config.accept ?? "signed-in";
|
|
46
|
+
const befriendOn = config.befriendOn ?? "never";
|
|
47
|
+
const holdMs = (config.holdDays ?? 7) * DAY_MS;
|
|
48
|
+
const timing = config.timing ?? {};
|
|
49
|
+
const liveAckMs = timing.liveAckMs ?? 4_000;
|
|
50
|
+
const proofWindowMs = timing.proofWindowMs ?? 15_000;
|
|
51
|
+
const iso = (t) => new Date(t).toISOString();
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
const keyFile = config.keyFile ?? join(dir, "peer.save");
|
|
54
|
+
mkdirSync(dirname(keyFile), { recursive: true });
|
|
55
|
+
// ── the peer ──────────────────────────────────────────────────────────────
|
|
56
|
+
const servers = await loadServers(config.servers, join(dir, "servers.json"), o.fetch ?? fetch, log);
|
|
57
|
+
if (!servers.expressNodes?.length) {
|
|
58
|
+
log("beagle-service: no Express node is configured; a message to a friend who is offline will wait until they are back");
|
|
59
|
+
}
|
|
60
|
+
const peerOpts = {
|
|
61
|
+
keyFile,
|
|
62
|
+
bootstrapNodes: servers.bootstrapNodes,
|
|
63
|
+
expressNodes: servers.expressNodes ?? [],
|
|
64
|
+
...(config.nickname ? { nickname: config.nickname } : {}),
|
|
65
|
+
...(config.statusMessage ? { statusMessage: config.statusMessage } : {}),
|
|
66
|
+
platform: "agent",
|
|
67
|
+
...(config.punkId != null ? { punkId: config.punkId } : {}),
|
|
68
|
+
...(config.avatarUrl ? { profile: { avatarUrl: config.avatarUrl } } : {}),
|
|
69
|
+
...config.peerOptions,
|
|
70
|
+
};
|
|
71
|
+
const peer = config.createPeer ? await config.createPeer(peerOpts) : await (await import("@decentnetwork/peer")).Peer.create(peerOpts);
|
|
72
|
+
// ── state on disk ─────────────────────────────────────────────────────────
|
|
73
|
+
const pendingFile = join(dir, "pending.json");
|
|
74
|
+
const askedFile = join(dir, "asked.json");
|
|
75
|
+
const pending = new Map();
|
|
76
|
+
for (const e of readJsonFile(pendingFile, []))
|
|
77
|
+
if (e && typeof e.userid === "string")
|
|
78
|
+
pending.set(e.userid, e);
|
|
79
|
+
const asked = readJsonFile(askedFile, {});
|
|
80
|
+
/** Userids a friend request is being sent to right now. */
|
|
81
|
+
const asking = new Set();
|
|
82
|
+
const savePending = () => atomicWriteJson(pendingFile, [...pending.values()]);
|
|
83
|
+
const saveAsked = () => atomicWriteJson(askedFile, asked);
|
|
84
|
+
const friendOf = (userid) => peer.friends().find((f) => (f.userid ?? f.pubkey) === userid || f.pubkey === userid);
|
|
85
|
+
/** Settled both ways as far as this side can know: we accepted them, or a
|
|
86
|
+
* session came up after we asked. */
|
|
87
|
+
const isFriend = (userid) => {
|
|
88
|
+
const f = friendOf(userid);
|
|
89
|
+
return !!f && (f.acceptedAt != null || f.status === "online");
|
|
90
|
+
};
|
|
91
|
+
const outbox = new Outbox({
|
|
92
|
+
file: join(dir, "outbox.json"),
|
|
93
|
+
deadFile: join(dir, "dead-letter.jsonl"),
|
|
94
|
+
now,
|
|
95
|
+
log,
|
|
96
|
+
baseDelayMs: timing.baseDelayMs,
|
|
97
|
+
maxDelayMs: timing.maxDelayMs,
|
|
98
|
+
maxAgeMs: (config.maxQueueHours ?? 72) * 3_600_000,
|
|
99
|
+
onDelivered: (e) => void hub.emit("delivered", e),
|
|
100
|
+
onDead: (d) => void hub.emit("dead-letter", d),
|
|
101
|
+
transport: {
|
|
102
|
+
relation: (userid) => (isFriend(userid) ? "friend" : friendOf(userid) || asked[userid] || asking.has(userid) || pending.has(userid) ? "pending" : "none"),
|
|
103
|
+
proven: (userid) => {
|
|
104
|
+
if (friendOf(userid)?.status !== "online")
|
|
105
|
+
return false;
|
|
106
|
+
const s = peer.sessionStatus(userid);
|
|
107
|
+
return !!s?.established && typeof s.lastPingRecvMs === "number" && Date.now() - s.lastPingRecvMs < proofWindowMs;
|
|
108
|
+
},
|
|
109
|
+
sendLive: async (userid, text, deliveryId) => {
|
|
110
|
+
// A friend that reads the delivery envelope must confirm within the
|
|
111
|
+
// window; any other gets the SDK's own answer.
|
|
112
|
+
if ((friendOf(userid)?.protoVersion ?? 0) >= 2) {
|
|
113
|
+
await peer.sendTextUntilAck(userid, text, { deliveryId, timeoutMs: liveAckMs, retryIntervalMs: liveAckMs });
|
|
114
|
+
return "acked";
|
|
115
|
+
}
|
|
116
|
+
return (await peer.sendText(userid, text)).delivery;
|
|
117
|
+
},
|
|
118
|
+
sendExpress: async (userid, text, deliveryId) => {
|
|
119
|
+
await peer.sendTextViaExpress(userid, text, { deliveryId });
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
// ── the friend policy ─────────────────────────────────────────────────────
|
|
124
|
+
const publicRequest = ({ pubkey: _p, nospam: _n, ...rest }) => rest;
|
|
125
|
+
async function decide(entry, user) {
|
|
126
|
+
if (user?.status === "blocked")
|
|
127
|
+
return "reject";
|
|
128
|
+
if (policy === "all")
|
|
129
|
+
return "accept";
|
|
130
|
+
if (policy === "manual")
|
|
131
|
+
return "hold";
|
|
132
|
+
if (policy === "signed-in")
|
|
133
|
+
return user && user.firstSeen ? "accept" : "hold";
|
|
134
|
+
if (policy === "registered")
|
|
135
|
+
return user?.status === "registered" ? "accept" : "hold";
|
|
136
|
+
try {
|
|
137
|
+
const v = await policy(publicRequest(entry), user);
|
|
138
|
+
return v === true ? "accept" : v === "reject" ? "reject" : "hold";
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
log(`beagle-service: the accept policy threw for ${entry.userid.slice(0, 8)}: ${msgOf(e)}; the request is held`);
|
|
142
|
+
return "hold";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Userids being accepted right now: a sign-in and a registration can ask
|
|
146
|
+
* for the same held request to be looked at in the same moment. */
|
|
147
|
+
const accepting = new Set();
|
|
148
|
+
async function acceptEntry(entry) {
|
|
149
|
+
if (accepting.has(entry.userid))
|
|
150
|
+
return;
|
|
151
|
+
accepting.add(entry.userid);
|
|
152
|
+
try {
|
|
153
|
+
await peer.acceptFriendRequest(entry.pubkey, {
|
|
154
|
+
name: entry.name,
|
|
155
|
+
description: entry.description,
|
|
156
|
+
hello: entry.hello,
|
|
157
|
+
address: entry.address,
|
|
158
|
+
nospam: entry.nospam,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
accepting.delete(entry.userid);
|
|
163
|
+
}
|
|
164
|
+
if (pending.delete(entry.userid))
|
|
165
|
+
savePending();
|
|
166
|
+
// The request came from that key over the network: its address is at
|
|
167
|
+
// least as good as the unsigned hint from sign-in.
|
|
168
|
+
const address = addressOf(entry.userid, entry.address);
|
|
169
|
+
if (address) {
|
|
170
|
+
const user = await users.get(entry.userid);
|
|
171
|
+
if (user && !user.address)
|
|
172
|
+
await users.update(entry.userid, { address }).catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
void hub.emit("friend-accepted", { userid: entry.userid });
|
|
175
|
+
outbox.kick(entry.userid);
|
|
176
|
+
}
|
|
177
|
+
function refuseEntry(entry, why) {
|
|
178
|
+
try {
|
|
179
|
+
peer.rejectFriendRequest(entry.pubkey);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
log(`beagle-service: rejecting ${entry.userid.slice(0, 8)} failed: ${msgOf(e)}`);
|
|
183
|
+
}
|
|
184
|
+
if (pending.delete(entry.userid))
|
|
185
|
+
savePending();
|
|
186
|
+
void hub.emit("friend-refused", { userid: entry.userid, why });
|
|
187
|
+
}
|
|
188
|
+
async function onRequest(req) {
|
|
189
|
+
const userid = req.userid ?? req.pubkey;
|
|
190
|
+
const entry = {
|
|
191
|
+
userid,
|
|
192
|
+
pubkey: req.pubkey,
|
|
193
|
+
...(req.address ? { address: req.address } : {}),
|
|
194
|
+
...(req.nospam != null ? { nospam: req.nospam } : {}),
|
|
195
|
+
...(req.name ? { name: req.name } : {}),
|
|
196
|
+
...(req.description ? { description: req.description } : {}),
|
|
197
|
+
...(req.hello ? { hello: req.hello } : {}),
|
|
198
|
+
...(req.punkId != null ? { punkId: req.punkId } : {}),
|
|
199
|
+
...(req.avatarUrl ? { avatarUrl: req.avatarUrl } : {}),
|
|
200
|
+
arrivedAt: iso(now()),
|
|
201
|
+
};
|
|
202
|
+
const user = await users.get(userid);
|
|
203
|
+
const verdict = await decide(entry, user);
|
|
204
|
+
if (verdict === "accept")
|
|
205
|
+
return acceptEntry(entry);
|
|
206
|
+
if (verdict === "reject")
|
|
207
|
+
return refuseEntry(entry, user?.status === "blocked" ? "blocked" : "policy");
|
|
208
|
+
// Not allowed yet: keep it. It is looked at again when the sender signs
|
|
209
|
+
// in, registers or is unblocked, and at every start.
|
|
210
|
+
pending.set(userid, entry);
|
|
211
|
+
savePending();
|
|
212
|
+
void hub.emit("request-held", publicRequest(entry));
|
|
213
|
+
}
|
|
214
|
+
/** Look at a held request again; accept it when its sender qualifies now. */
|
|
215
|
+
async function recheck(userid) {
|
|
216
|
+
const entry = pending.get(userid);
|
|
217
|
+
if (!entry)
|
|
218
|
+
return false;
|
|
219
|
+
const user = await users.get(userid);
|
|
220
|
+
const verdict = await decide(entry, user);
|
|
221
|
+
if (verdict === "accept") {
|
|
222
|
+
await acceptEntry(entry);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
if (verdict === "reject")
|
|
226
|
+
refuseEntry(entry, user?.status === "blocked" ? "blocked" : "policy");
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
function sweepHeld() {
|
|
230
|
+
const t = now();
|
|
231
|
+
for (const entry of [...pending.values()]) {
|
|
232
|
+
if (t - Date.parse(entry.arrivedAt) > holdMs)
|
|
233
|
+
refuseEntry(entry, "expired");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// ── befriending from our side ─────────────────────────────────────────────
|
|
237
|
+
async function befriend(userid, opts = {}) {
|
|
238
|
+
const user = await users.get(userid);
|
|
239
|
+
if (!user)
|
|
240
|
+
return { ok: false, reason: "unknown-user" };
|
|
241
|
+
if (user.status === "blocked")
|
|
242
|
+
return { ok: false, reason: "blocked" };
|
|
243
|
+
if (isFriend(userid) && !opts.force)
|
|
244
|
+
return { ok: true, how: "already-friends" };
|
|
245
|
+
// They asked first: saying yes is the whole handshake.
|
|
246
|
+
const held = pending.get(userid);
|
|
247
|
+
if (held) {
|
|
248
|
+
await acceptEntry(held);
|
|
249
|
+
return { ok: true, how: "accepted" };
|
|
250
|
+
}
|
|
251
|
+
if (!user.address)
|
|
252
|
+
return { ok: false, reason: "no-address" };
|
|
253
|
+
if (asked[userid] && !opts.force)
|
|
254
|
+
return { ok: true, how: "already-asked" };
|
|
255
|
+
asking.add(userid);
|
|
256
|
+
try {
|
|
257
|
+
await peer.sendFriendRequest(user.address, opts.hello ?? config.hello ?? config.nickname ?? "", opts.force ? { force: true } : undefined);
|
|
258
|
+
asked[userid] = iso(now());
|
|
259
|
+
saveAsked();
|
|
260
|
+
return { ok: true, how: "requested" };
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
// Not marked as asked, so the next sign-in tries again.
|
|
264
|
+
log(`beagle-service: friend request to ${userid.slice(0, 8)} failed: ${msgOf(e)}`);
|
|
265
|
+
return { ok: false, reason: "send-failed", error: msgOf(e) };
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
asking.delete(userid);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
async function onQualify(user, what) {
|
|
272
|
+
const wants = befriendOn === what;
|
|
273
|
+
if (wants)
|
|
274
|
+
asking.add(user.userid);
|
|
275
|
+
try {
|
|
276
|
+
const accepted = await recheck(user.userid);
|
|
277
|
+
if (wants && !accepted)
|
|
278
|
+
await befriend(user.userid);
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
log(`beagle-service: friend handling for ${user.userid.slice(0, 8)} failed: ${msgOf(e)}`);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
if (wants)
|
|
285
|
+
asking.delete(user.userid);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function unfriend(userid) {
|
|
289
|
+
if (asked[userid]) {
|
|
290
|
+
delete asked[userid];
|
|
291
|
+
saveAsked();
|
|
292
|
+
}
|
|
293
|
+
if (!friendOf(userid))
|
|
294
|
+
return false;
|
|
295
|
+
const removed = peer.removeFriend(userid);
|
|
296
|
+
if (removed)
|
|
297
|
+
void hub.emit("friend-removed", { userid });
|
|
298
|
+
return removed;
|
|
299
|
+
}
|
|
300
|
+
function onBlocked(user) {
|
|
301
|
+
const held = pending.get(user.userid);
|
|
302
|
+
if (held)
|
|
303
|
+
refuseEntry(held, "blocked");
|
|
304
|
+
try {
|
|
305
|
+
unfriend(user.userid);
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
log(`beagle-service: removing blocked ${user.userid.slice(0, 8)} failed: ${msgOf(e)}`);
|
|
309
|
+
}
|
|
310
|
+
outbox.dropFor(user.userid, "blocked");
|
|
311
|
+
}
|
|
312
|
+
// ── messages ──────────────────────────────────────────────────────────────
|
|
313
|
+
const messageHandlers = new Set();
|
|
314
|
+
async function send(userid, text) {
|
|
315
|
+
if (typeof text !== "string" || text.length === 0)
|
|
316
|
+
throw new TypeError("beagle-service: agent.send needs a non-empty text");
|
|
317
|
+
const receipt = (items, state, why) => ({
|
|
318
|
+
id: items[0].id,
|
|
319
|
+
deliveryIds: items.map((i) => i.deliveryId),
|
|
320
|
+
state,
|
|
321
|
+
...(why ? { why } : {}),
|
|
322
|
+
});
|
|
323
|
+
if (!isUserid(userid))
|
|
324
|
+
return receipt(outbox.refuse(String(userid), text, "bad-address"), "dead", "bad-address");
|
|
325
|
+
const user = await users.get(userid);
|
|
326
|
+
if (user?.status === "blocked")
|
|
327
|
+
return receipt(outbox.refuse(userid, text, "blocked"), "dead", "blocked");
|
|
328
|
+
if (!friendOf(userid) && !asked[userid] && !asking.has(userid) && !pending.has(userid)) {
|
|
329
|
+
return receipt(outbox.refuse(userid, text, "not-a-friend"), "dead", "not-a-friend");
|
|
330
|
+
}
|
|
331
|
+
const items = outbox.enqueue(userid, text);
|
|
332
|
+
// On disk now. Delivery starts on the next turn of the event loop, so the
|
|
333
|
+
// caller's own `await` continues first.
|
|
334
|
+
setImmediate(() => void outbox.pump());
|
|
335
|
+
return receipt(items, "queued");
|
|
336
|
+
}
|
|
337
|
+
// Handlers are registered before start(), as the peer expects.
|
|
338
|
+
peer.onFriendRequest((req) => {
|
|
339
|
+
void onRequest(req).catch((e) => log(`beagle-service: friend request handling failed: ${msgOf(e)}`));
|
|
340
|
+
});
|
|
341
|
+
peer.onFriendConnection((ev) => {
|
|
342
|
+
if (ev.status === "connected")
|
|
343
|
+
outbox.kick(ev.pubkey);
|
|
344
|
+
});
|
|
345
|
+
peer.onText(async (msg) => {
|
|
346
|
+
// The SDK has acknowledged receipt already; a slow handler delays nothing.
|
|
347
|
+
const userid = msg.pubkey;
|
|
348
|
+
const user = await users.get(userid);
|
|
349
|
+
if (user?.status === "blocked")
|
|
350
|
+
return;
|
|
351
|
+
const message = {
|
|
352
|
+
userid,
|
|
353
|
+
user,
|
|
354
|
+
text: msg.text,
|
|
355
|
+
...(msg.deliveryId ? { deliveryId: msg.deliveryId } : {}),
|
|
356
|
+
...(msg.via ? { via: msg.via } : {}),
|
|
357
|
+
reply: (t) => send(userid, t),
|
|
358
|
+
};
|
|
359
|
+
for (const cb of [...messageHandlers]) {
|
|
360
|
+
try {
|
|
361
|
+
await cb(message);
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
log(`beagle-service: an onMessage handler threw: ${msgOf(e)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
const offs = [
|
|
369
|
+
users.on("sign-in", (user) => void onQualify(user, "sign-in")),
|
|
370
|
+
users.on("registered", (user) => void onQualify(user, "register")),
|
|
371
|
+
users.on("unblocked", (user) => void recheck(user.userid).catch(() => { })),
|
|
372
|
+
users.on("blocked", (user) => onBlocked(user)),
|
|
373
|
+
];
|
|
374
|
+
// ── start ─────────────────────────────────────────────────────────────────
|
|
375
|
+
await peer.start();
|
|
376
|
+
let stopped = false;
|
|
377
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms).unref?.());
|
|
378
|
+
const ready = (async () => {
|
|
379
|
+
for (let n = 0; !stopped; n++) {
|
|
380
|
+
try {
|
|
381
|
+
await peer.joinNetwork();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
catch (e) {
|
|
385
|
+
log(`beagle-service: joining the network failed (${msgOf(e)}); trying again`);
|
|
386
|
+
await sleep(Math.min(60_000, 5_000 * 2 ** Math.min(n, 4)));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
})();
|
|
390
|
+
// Requests held from an earlier run: their senders may qualify by now.
|
|
391
|
+
sweepHeld();
|
|
392
|
+
for (const userid of [...pending.keys()])
|
|
393
|
+
void recheck(userid).catch(() => { });
|
|
394
|
+
const pumpTimer = setInterval(() => void outbox.pump(), timing.pumpMs ?? 3_000);
|
|
395
|
+
const sweepTimer = setInterval(sweepHeld, 10 * 60_000);
|
|
396
|
+
pumpTimer.unref?.();
|
|
397
|
+
sweepTimer.unref?.();
|
|
398
|
+
setImmediate(() => void outbox.pump());
|
|
399
|
+
return {
|
|
400
|
+
userid: peer.userid(),
|
|
401
|
+
address: peer.address(),
|
|
402
|
+
nickname: config.nickname,
|
|
403
|
+
ready,
|
|
404
|
+
peer,
|
|
405
|
+
send,
|
|
406
|
+
onMessage: (cb) => {
|
|
407
|
+
messageHandlers.add(cb);
|
|
408
|
+
return () => messageHandlers.delete(cb);
|
|
409
|
+
},
|
|
410
|
+
befriend,
|
|
411
|
+
isFriend,
|
|
412
|
+
pending: () => [...pending.values()].map(publicRequest),
|
|
413
|
+
accept: async (userid) => {
|
|
414
|
+
const entry = pending.get(userid);
|
|
415
|
+
if (!entry)
|
|
416
|
+
return false;
|
|
417
|
+
const user = await users.get(userid);
|
|
418
|
+
if (user?.status === "blocked") {
|
|
419
|
+
refuseEntry(entry, "blocked");
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
await acceptEntry(entry);
|
|
423
|
+
return true;
|
|
424
|
+
},
|
|
425
|
+
reject: async (userid) => {
|
|
426
|
+
const entry = pending.get(userid);
|
|
427
|
+
if (!entry)
|
|
428
|
+
return false;
|
|
429
|
+
refuseEntry(entry, "manual");
|
|
430
|
+
return true;
|
|
431
|
+
},
|
|
432
|
+
unfriend,
|
|
433
|
+
outbox: () => outbox.list(),
|
|
434
|
+
deadLetters: () => outbox.deadLetters(),
|
|
435
|
+
on: (event, cb) => hub.on(event, cb),
|
|
436
|
+
stop: async () => {
|
|
437
|
+
if (stopped)
|
|
438
|
+
return;
|
|
439
|
+
stopped = true;
|
|
440
|
+
clearInterval(pumpTimer);
|
|
441
|
+
clearInterval(sweepTimer);
|
|
442
|
+
outbox.stop();
|
|
443
|
+
for (const off of offs)
|
|
444
|
+
off();
|
|
445
|
+
await peer.stop();
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Auth, HeadersCarrier, OriginsSource, SessionConfig, StateStore, Users } from "./types.js";
|
|
2
|
+
import { type Logger } from "./util.js";
|
|
3
|
+
/** How long a nonce lives, and how far a launch timestamp may be from now. */
|
|
4
|
+
export declare const NONCE_TTL_MS = 120000;
|
|
5
|
+
export declare const LAUNCH_MAX_AGE_MS = 120000;
|
|
6
|
+
/** Turn the configured list into exact origins. Throws on anything that is
|
|
7
|
+
* not `scheme://host[:port]`, so a typo is found at start, not as a sign-in
|
|
8
|
+
* that never verifies. */
|
|
9
|
+
export declare function normalizeOrigins(list: unknown): Set<string>;
|
|
10
|
+
/** Throw now if the configured origins cannot be used. A function is called once. */
|
|
11
|
+
export declare function checkOrigins(source: OriginsSource | Set<string>): void;
|
|
12
|
+
/** The signing secret: the configured one, else a 32-byte one kept in `file`. */
|
|
13
|
+
export declare function loadSecret(configured: string | undefined, file: string): string;
|
|
14
|
+
export declare function headerOf(req: HeadersCarrier | undefined, name: string): string | undefined;
|
|
15
|
+
export interface AuthOptions {
|
|
16
|
+
/** A list, a function that returns the list as it is now, or the set
|
|
17
|
+
* `normalizeOrigins` made. */
|
|
18
|
+
origins: OriginsSource | Set<string>;
|
|
19
|
+
state: StateStore;
|
|
20
|
+
users: Users;
|
|
21
|
+
secret: string;
|
|
22
|
+
/** Binds nonces to this server; see `BeagleServiceConfig.serverId`. */
|
|
23
|
+
serverId?: string;
|
|
24
|
+
session?: SessionConfig;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
log?: Logger;
|
|
27
|
+
}
|
|
28
|
+
export declare function createAuth(opts: AuthOptions): Auth;
|