@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/users.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { addressOf, Hub, isThenable, 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
|
+
/** What the store handed back, as a record this library can work with. A
|
|
54
|
+
* store may hold records that are older than the library, or that another
|
|
55
|
+
* program wrote: all that is asked of one is that it is an object. Missing
|
|
56
|
+
* fields get the value a new record would have; nothing is written. */
|
|
57
|
+
function normalise(raw, userid) {
|
|
58
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
59
|
+
return null;
|
|
60
|
+
const r = raw;
|
|
61
|
+
const u = { ...blank(typeof r.userid === "string" && r.userid ? r.userid : userid) };
|
|
62
|
+
for (const [k, v] of Object.entries(r))
|
|
63
|
+
if (v !== undefined && k !== "userid")
|
|
64
|
+
u[k] = v;
|
|
65
|
+
if (u.status !== "visitor" && u.status !== "registered" && u.status !== "blocked")
|
|
66
|
+
u.status = "visitor";
|
|
67
|
+
if (!u.profile || typeof u.profile !== "object" || Array.isArray(u.profile))
|
|
68
|
+
u.profile = {};
|
|
69
|
+
if (typeof u.visits !== "number" || !(u.visits >= 0))
|
|
70
|
+
u.visits = 0;
|
|
71
|
+
if (typeof u.signIns !== "number" || !(u.signIns >= 0))
|
|
72
|
+
u.signIns = 0;
|
|
73
|
+
if (typeof u.firstSeen !== "string" || Number.isNaN(Date.parse(u.firstSeen)))
|
|
74
|
+
u.firstSeen = null;
|
|
75
|
+
if (typeof u.lastSeen !== "string" || Number.isNaN(Date.parse(u.lastSeen)))
|
|
76
|
+
u.lastSeen = null;
|
|
77
|
+
return u;
|
|
78
|
+
}
|
|
79
|
+
/** The top-level fields in which `next` differs from `prev`. */
|
|
80
|
+
function changeOf(prev, next) {
|
|
81
|
+
const set = {};
|
|
82
|
+
const unset = [];
|
|
83
|
+
for (const [k, v] of Object.entries(next)) {
|
|
84
|
+
if (v === undefined)
|
|
85
|
+
continue;
|
|
86
|
+
if (!prev || JSON.stringify(prev[k]) !== JSON.stringify(v))
|
|
87
|
+
set[k] = v;
|
|
88
|
+
}
|
|
89
|
+
if (prev)
|
|
90
|
+
for (const k of Object.keys(prev))
|
|
91
|
+
if (next[k] === undefined && prev[k] !== undefined)
|
|
92
|
+
unset.push(k);
|
|
93
|
+
return { prev, set, unset };
|
|
94
|
+
}
|
|
95
|
+
/** Read one record, let `step` decide, write what it decided. One at a time
|
|
96
|
+
* per userid in this object; and with a store whose `get` and `put` are
|
|
97
|
+
* synchronous, with nothing at all between the read and the write. The
|
|
98
|
+
* store gets the changed fields beside the record, so it can leave alone
|
|
99
|
+
* what somebody else wrote. */
|
|
100
|
+
function mutate(userid, step) {
|
|
101
|
+
const apply = (raw) => {
|
|
102
|
+
const prev = normalise(raw, userid);
|
|
103
|
+
const { next, out } = step(prev);
|
|
104
|
+
if (!next)
|
|
105
|
+
return out;
|
|
106
|
+
const done = store.put(next, changeOf(prev, next));
|
|
107
|
+
return isThenable(done) ? done.then(() => out) : out;
|
|
108
|
+
};
|
|
109
|
+
return lock.run(userid, () => {
|
|
110
|
+
const got = store.get(userid);
|
|
111
|
+
return isThenable(got) ? got.then(apply) : apply(got);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const missingOf = (profile) => required.filter((f) => isEmpty(profile?.[f]));
|
|
115
|
+
/** visitor or registered, from what the profile holds today. */
|
|
116
|
+
const statusFor = (user) => (missingOf(user.profile).length === 0 ? "registered" : "visitor");
|
|
117
|
+
function registration(user) {
|
|
118
|
+
const missing = user.status === "registered" ? [] : missingOf(user.profile);
|
|
119
|
+
const state = { status: user.status, required, optional, missing, due: false, refused: false };
|
|
120
|
+
if (user.status !== "visitor")
|
|
121
|
+
return state;
|
|
122
|
+
const t = now();
|
|
123
|
+
let over = false;
|
|
124
|
+
if (visitor.maxDays !== undefined && user.firstSeen) {
|
|
125
|
+
const deadline = Date.parse(user.firstSeen) + visitor.maxDays * DAY_MS;
|
|
126
|
+
state.deadline = iso(deadline);
|
|
127
|
+
if (t > deadline)
|
|
128
|
+
over = true;
|
|
129
|
+
}
|
|
130
|
+
if (visitor.maxVisits !== undefined) {
|
|
131
|
+
state.visitsLeft = Math.max(0, visitor.maxVisits - (user.visits || 0));
|
|
132
|
+
if ((user.visits || 0) > visitor.maxVisits)
|
|
133
|
+
over = true;
|
|
134
|
+
}
|
|
135
|
+
state.due = over;
|
|
136
|
+
state.refused = over && visitor.then === "refuse";
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
const get = async (userid) => normalise(await store.get(userid), userid);
|
|
140
|
+
async function list(filter) {
|
|
141
|
+
const all = [];
|
|
142
|
+
for (const raw of await store.list()) {
|
|
143
|
+
const u = typeof raw?.userid === "string" ? normalise(raw, raw.userid) : null;
|
|
144
|
+
if (u)
|
|
145
|
+
all.push(u);
|
|
146
|
+
}
|
|
147
|
+
return filter?.status ? all.filter((u) => u.status === filter.status) : all;
|
|
148
|
+
}
|
|
149
|
+
function emitLater(event, ...args) {
|
|
150
|
+
// Never awaited by the caller: a listener (the agent accepting a held
|
|
151
|
+
// request, a site sending a welcome) must not hold up the HTTP answer,
|
|
152
|
+
// and must be free to call back into this module.
|
|
153
|
+
void hub.emit(event, ...args);
|
|
154
|
+
}
|
|
155
|
+
async function recordSignIn(userid, hints = {}, info = {}) {
|
|
156
|
+
if (!isUserid(userid))
|
|
157
|
+
throw new TypeError("beagle-service: not a userid");
|
|
158
|
+
const t = now();
|
|
159
|
+
const { user, events } = await mutate(userid, (prev) => {
|
|
160
|
+
const events = [];
|
|
161
|
+
if (prev?.status === "blocked")
|
|
162
|
+
return { out: { user: prev, events } };
|
|
163
|
+
const next = { ...blank(userid), ...prev };
|
|
164
|
+
const first = !prev || !prev.firstSeen;
|
|
165
|
+
// A blank or malformed hint never replaces a value already held: the
|
|
166
|
+
// address above all cannot be asked for again.
|
|
167
|
+
const address = addressOf(userid, hints.address);
|
|
168
|
+
if (address)
|
|
169
|
+
next.address = address;
|
|
170
|
+
const name = text(hints.name, 128);
|
|
171
|
+
if (name)
|
|
172
|
+
next.name = name;
|
|
173
|
+
const avatar = typeof hints.avatar === "string" && hints.avatar.length <= 4096 ? hints.avatar.trim() : "";
|
|
174
|
+
if (avatar)
|
|
175
|
+
next.avatar = avatar;
|
|
176
|
+
if (typeof hints.punkId === "number" && Number.isInteger(hints.punkId) && hints.punkId >= 0 && hints.punkId < 1_000_000) {
|
|
177
|
+
next.punkId = hints.punkId;
|
|
178
|
+
}
|
|
179
|
+
if (info.door)
|
|
180
|
+
next.door = info.door.slice(0, 200);
|
|
181
|
+
const origin = typeof info.origin === "string" && info.origin ? info.origin.slice(0, 200) : null;
|
|
182
|
+
if (origin)
|
|
183
|
+
next.origin = origin;
|
|
184
|
+
next.firstSeen ??= iso(t);
|
|
185
|
+
if (!prev?.lastSeen || t - Date.parse(prev.lastSeen) > gapMs)
|
|
186
|
+
next.visits = (next.visits || 0) + 1;
|
|
187
|
+
next.lastSeen = iso(t);
|
|
188
|
+
next.signIns = (next.signIns || 0) + 1;
|
|
189
|
+
const was = prev?.status;
|
|
190
|
+
next.status = statusFor(next);
|
|
191
|
+
if (next.status === "registered")
|
|
192
|
+
next.registeredAt ??= iso(t);
|
|
193
|
+
events.push(() => emitLater("sign-in", next, { first, door: next.door, origin }));
|
|
194
|
+
if (next.status === "registered" && was !== "registered")
|
|
195
|
+
events.push(() => emitLater("registered", next));
|
|
196
|
+
return { next, out: { user: next, events } };
|
|
197
|
+
});
|
|
198
|
+
if (events.length)
|
|
199
|
+
lastTouch.set(userid, t);
|
|
200
|
+
for (const fire of events)
|
|
201
|
+
fire();
|
|
202
|
+
if (opts.importProfile && user.status !== "blocked")
|
|
203
|
+
void importProfile(userid).catch(() => { });
|
|
204
|
+
return user;
|
|
205
|
+
}
|
|
206
|
+
async function touch(userid) {
|
|
207
|
+
const t = now();
|
|
208
|
+
if (t - (lastTouch.get(userid) ?? 0) < TOUCH_EVERY_MS)
|
|
209
|
+
return get(userid);
|
|
210
|
+
return mutate(userid, (prev) => {
|
|
211
|
+
if (!prev || prev.status === "blocked")
|
|
212
|
+
return { out: prev };
|
|
213
|
+
lastTouch.set(userid, t);
|
|
214
|
+
// Another process, or another copy of this object, may have noted it.
|
|
215
|
+
if (prev.lastSeen && Math.abs(t - Date.parse(prev.lastSeen)) < TOUCH_EVERY_MS)
|
|
216
|
+
return { out: prev };
|
|
217
|
+
const next = { ...prev, lastSeen: iso(t) };
|
|
218
|
+
if (!prev.lastSeen || t - Date.parse(prev.lastSeen) > gapMs)
|
|
219
|
+
next.visits = (prev.visits || 0) + 1;
|
|
220
|
+
return { next, out: next };
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
async function update(userid, patch) {
|
|
224
|
+
if (patch.status === "blocked")
|
|
225
|
+
throw new TypeError("beagle-service: use users.block(userid, why) to block");
|
|
226
|
+
const { user, fire } = await mutate(userid, (prev) => {
|
|
227
|
+
if (!prev)
|
|
228
|
+
throw new Error(`beagle-service: no user ${userid}`);
|
|
229
|
+
if (prev.status === "blocked" && patch.status)
|
|
230
|
+
throw new TypeError("beagle-service: use users.unblock(userid) to unblock");
|
|
231
|
+
const next = {
|
|
232
|
+
...prev,
|
|
233
|
+
...patch,
|
|
234
|
+
userid: prev.userid,
|
|
235
|
+
profile: patch.profile ? { ...prev.profile, ...patch.profile } : prev.profile,
|
|
236
|
+
};
|
|
237
|
+
const fire = next.status === "registered" && prev.status !== "registered";
|
|
238
|
+
if (fire)
|
|
239
|
+
next.registeredAt ??= iso(now());
|
|
240
|
+
return { next, out: { user: next, fire } };
|
|
241
|
+
});
|
|
242
|
+
if (fire)
|
|
243
|
+
emitLater("registered", user);
|
|
244
|
+
return user;
|
|
245
|
+
}
|
|
246
|
+
async function register(userid, submitted) {
|
|
247
|
+
const current = await get(userid);
|
|
248
|
+
if (!current)
|
|
249
|
+
return { ok: false, error: "not-found" };
|
|
250
|
+
if (current.status === "blocked")
|
|
251
|
+
return { ok: false, error: "blocked" };
|
|
252
|
+
const body = submitted && typeof submitted === "object" ? submitted : {};
|
|
253
|
+
const errors = {};
|
|
254
|
+
const accepted = {};
|
|
255
|
+
// Checked outside the per-user lock: `verify` is the site's code and may
|
|
256
|
+
// be slow or call back into users.
|
|
257
|
+
for (const field of [...required, ...optional]) {
|
|
258
|
+
if (!Object.prototype.hasOwnProperty.call(body, field))
|
|
259
|
+
continue;
|
|
260
|
+
let value = body[field];
|
|
261
|
+
if (typeof value === "string")
|
|
262
|
+
value = value.trim();
|
|
263
|
+
if (isEmpty(value))
|
|
264
|
+
continue; // a required one is reported as missing below
|
|
265
|
+
let size = 0;
|
|
266
|
+
try {
|
|
267
|
+
size = Buffer.byteLength(JSON.stringify(value));
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
size = Infinity;
|
|
271
|
+
}
|
|
272
|
+
if (size > MAX_FIELD_BYTES) {
|
|
273
|
+
errors[field] = "too-long";
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const bad = builtInCheck(field, value);
|
|
277
|
+
if (bad) {
|
|
278
|
+
errors[field] = bad;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (verify) {
|
|
282
|
+
let verdict;
|
|
283
|
+
try {
|
|
284
|
+
verdict = await verify(field, value, current, body);
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
log(`beagle-service: register.verify threw for "${field}": ${msgOf(e)}`);
|
|
288
|
+
verdict = "could-not-verify";
|
|
289
|
+
}
|
|
290
|
+
if (verdict === false) {
|
|
291
|
+
errors[field] = "not-accepted";
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (typeof verdict === "string") {
|
|
295
|
+
errors[field] = verdict;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (verdict && typeof verdict === "object")
|
|
299
|
+
value = verdict.value;
|
|
300
|
+
}
|
|
301
|
+
accepted[field] = value;
|
|
302
|
+
}
|
|
303
|
+
const { result, fire } = await mutate(userid, (prev) => {
|
|
304
|
+
const no = (result) => ({ out: { result, fire: false } });
|
|
305
|
+
if (!prev)
|
|
306
|
+
return no({ ok: false, error: "not-found" });
|
|
307
|
+
if (prev.status === "blocked")
|
|
308
|
+
return no({ ok: false, error: "blocked" });
|
|
309
|
+
const profile = { ...prev.profile, ...accepted };
|
|
310
|
+
for (const f of missingOf(profile))
|
|
311
|
+
errors[f] ??= "required";
|
|
312
|
+
if (Object.keys(errors).length)
|
|
313
|
+
return no({ ok: false, error: "invalid-fields", fields: errors });
|
|
314
|
+
const next = { ...prev, profile, status: "registered" };
|
|
315
|
+
next.registeredAt ??= iso(now());
|
|
316
|
+
return { next, out: { result: { ok: true, user: next }, fire: prev.status !== "registered" } };
|
|
317
|
+
});
|
|
318
|
+
if (fire && result.ok)
|
|
319
|
+
emitLater("registered", result.user);
|
|
320
|
+
return result;
|
|
321
|
+
}
|
|
322
|
+
async function block(userid, why = "") {
|
|
323
|
+
if (!isUserid(userid))
|
|
324
|
+
throw new TypeError("beagle-service: not a userid");
|
|
325
|
+
const { user, fire } = await mutate(userid, (found) => {
|
|
326
|
+
const prev = found ?? blank(userid);
|
|
327
|
+
if (prev.status === "blocked")
|
|
328
|
+
return { out: { user: prev, fire: false } };
|
|
329
|
+
const t = now();
|
|
330
|
+
const next = {
|
|
331
|
+
...prev,
|
|
332
|
+
status: "blocked",
|
|
333
|
+
blocked: { at: iso(t), why: String(why).slice(0, 1000), was: prev.status },
|
|
334
|
+
// Sessions issued up to now are dead for good, also after an unblock.
|
|
335
|
+
sessionsValidFrom: t + 1,
|
|
336
|
+
};
|
|
337
|
+
return { next, out: { user: next, fire: true } };
|
|
338
|
+
});
|
|
339
|
+
if (fire)
|
|
340
|
+
emitLater("blocked", user);
|
|
341
|
+
return user;
|
|
342
|
+
}
|
|
343
|
+
async function unblock(userid) {
|
|
344
|
+
const { user, fire } = await mutate(userid, (prev) => {
|
|
345
|
+
if (!prev || prev.status !== "blocked")
|
|
346
|
+
return { out: { user: prev, fire: false } };
|
|
347
|
+
const next = { ...prev, blocked: null, status: "visitor" };
|
|
348
|
+
next.status = prev.blocked?.was === "registered" || (prev.firstSeen && statusFor(next) === "registered") ? "registered" : "visitor";
|
|
349
|
+
return { next, out: { user: next, fire: true } };
|
|
350
|
+
});
|
|
351
|
+
if (fire && user)
|
|
352
|
+
emitLater("unblocked", user);
|
|
353
|
+
return user;
|
|
354
|
+
}
|
|
355
|
+
async function revokeSessions(userid) {
|
|
356
|
+
await mutate(userid, (prev) => (prev ? { next: { ...prev, sessionsValidFrom: now() + 1 }, out: undefined } : { out: undefined }));
|
|
357
|
+
}
|
|
358
|
+
// ── beagles.eth profile import ────────────────────────────────────────────
|
|
359
|
+
const gateway = (typeof opts.importProfile === "object" && opts.importProfile.gateway) || DEFAULT_PROFILE_GATEWAY;
|
|
360
|
+
const ttlMs = ((typeof opts.importProfile === "object" && opts.importProfile.ttlHours) || 24) * 3_600_000;
|
|
361
|
+
const noRecordUntil = new Map();
|
|
362
|
+
async function importProfile(userid) {
|
|
363
|
+
if (!isUserid(userid))
|
|
364
|
+
return null;
|
|
365
|
+
const t = now();
|
|
366
|
+
const user = await get(userid);
|
|
367
|
+
if (!user)
|
|
368
|
+
return null;
|
|
369
|
+
if (user.published?.fetchedAt && t - Date.parse(user.published.fetchedAt) < ttlMs)
|
|
370
|
+
return user.published;
|
|
371
|
+
if ((noRecordUntil.get(userid) ?? 0) > t)
|
|
372
|
+
return null;
|
|
373
|
+
const doFetch = opts.fetch ?? fetch;
|
|
374
|
+
let record;
|
|
375
|
+
try {
|
|
376
|
+
const r = await doFetch(`${gateway.replace(/\/+$/, "")}/getAddress/${userid}`, { signal: AbortSignal.timeout(5000) });
|
|
377
|
+
if (r.status === 404) {
|
|
378
|
+
noRecordUntil.set(userid, t + ttlMs);
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
if (!r.ok)
|
|
382
|
+
throw new Error(`HTTP ${r.status}`);
|
|
383
|
+
record = await r.json();
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
log(`beagle-service: profile import for ${userid.slice(0, 8)} failed: ${msgOf(e)}`);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
if (!record || typeof record !== "object" || typeof record.name !== "string") {
|
|
390
|
+
noRecordUntil.set(userid, t + ttlMs);
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
const texts = record.texts && typeof record.texts === "object" ? record.texts : {};
|
|
394
|
+
const published = { fetchedAt: iso(t) };
|
|
395
|
+
const put = (k, v, max) => {
|
|
396
|
+
const s = text(v, max);
|
|
397
|
+
if (s)
|
|
398
|
+
published[k] = s;
|
|
399
|
+
};
|
|
400
|
+
put("name", record.name, 255);
|
|
401
|
+
put("owner", record.owner, 128);
|
|
402
|
+
put("displayName", texts.displayName, 128);
|
|
403
|
+
put("description", texts.description, 1000);
|
|
404
|
+
put("avatar", texts.avatar, 2048);
|
|
405
|
+
put("url", texts.url, 2048);
|
|
406
|
+
const patch = { published };
|
|
407
|
+
// The record's address is a claim like the sign-in hint: used only when
|
|
408
|
+
// none is held, and only when it parses to this userid.
|
|
409
|
+
const address = addressOf(userid, texts.carrierAddress);
|
|
410
|
+
if (!user.address && address)
|
|
411
|
+
patch.address = address;
|
|
412
|
+
await update(userid, patch).catch(() => { });
|
|
413
|
+
return published;
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
get,
|
|
417
|
+
list,
|
|
418
|
+
update,
|
|
419
|
+
block,
|
|
420
|
+
unblock,
|
|
421
|
+
revokeSessions,
|
|
422
|
+
register,
|
|
423
|
+
registration,
|
|
424
|
+
recordSignIn,
|
|
425
|
+
touch,
|
|
426
|
+
importProfile,
|
|
427
|
+
on: (event, cb) => hub.on(event, cb),
|
|
428
|
+
};
|
|
429
|
+
}
|
package/dist/util.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
/** One JSON object in one file, shared by every store instance and every
|
|
9
|
+
* process that opens it (a server and the operator's command beside it, or a
|
|
10
|
+
* module a bundler evaluated twice). A read looks at the file's identity
|
|
11
|
+
* first and loads it again when another writer replaced it; a change is made
|
|
12
|
+
* under a lock file, on the copy that is on disk at that moment, and written
|
|
13
|
+
* with a rename. All of it is synchronous: no other code of this process runs
|
|
14
|
+
* between the read and the write. */
|
|
15
|
+
export declare class SharedJsonFile<T extends Record<string, any>> {
|
|
16
|
+
#private;
|
|
17
|
+
constructor(file: string);
|
|
18
|
+
/** The current content. Do not change it; use `update`. */
|
|
19
|
+
read(): T;
|
|
20
|
+
/** Change the content. `change` gets what is on disk now and says whether
|
|
21
|
+
* it has to be written. */
|
|
22
|
+
update<R>(change: (value: T) => {
|
|
23
|
+
write: boolean;
|
|
24
|
+
result: R;
|
|
25
|
+
}): R;
|
|
26
|
+
}
|
|
27
|
+
export declare const isThenable: <T>(v: T | PromiseLike<T>) => v is PromiseLike<T>;
|
|
28
|
+
/** A userid is the base58 of a 32-byte public key. */
|
|
29
|
+
export declare function isUserid(v: unknown): v is string;
|
|
30
|
+
/** The address when it is a well-formed Carrier address of `userid`, else null.
|
|
31
|
+
* An address is not signed at sign-in, so the only thing that can be checked
|
|
32
|
+
* is that it parses (key, nospam, checksum) and names the key that signed. */
|
|
33
|
+
export declare function addressOf(userid: string, address: unknown): string | null;
|
|
34
|
+
export declare const msgOf: (e: unknown) => string;
|
|
35
|
+
export type Logger = (message: string) => void;
|
|
36
|
+
/** A minimal typed event hub; a listener that throws never breaks the emitter. */
|
|
37
|
+
export declare class Hub<Events extends Record<string, unknown[]>> {
|
|
38
|
+
#private;
|
|
39
|
+
constructor(log?: Logger);
|
|
40
|
+
on<K extends keyof Events>(event: K, cb: (...args: Events[K]) => unknown): () => void;
|
|
41
|
+
/** Runs listeners in registration order and waits for each. */
|
|
42
|
+
emit<K extends keyof Events>(event: K, ...args: Events[K]): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/** Run jobs for the same key one after another. */
|
|
45
|
+
export declare class KeyedLock {
|
|
46
|
+
#private;
|
|
47
|
+
run<T>(key: string, job: () => T | PromiseLike<T>): Promise<T>;
|
|
48
|
+
}
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, 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
|
+
const LOCK_STALE_MS = 5_000;
|
|
43
|
+
const LOCK_WAIT_MS = 3_000;
|
|
44
|
+
const sleepCell = new Int32Array(new SharedArrayBuffer(4));
|
|
45
|
+
/** One JSON object in one file, shared by every store instance and every
|
|
46
|
+
* process that opens it (a server and the operator's command beside it, or a
|
|
47
|
+
* module a bundler evaluated twice). A read looks at the file's identity
|
|
48
|
+
* first and loads it again when another writer replaced it; a change is made
|
|
49
|
+
* under a lock file, on the copy that is on disk at that moment, and written
|
|
50
|
+
* with a rename. All of it is synchronous: no other code of this process runs
|
|
51
|
+
* between the read and the write. */
|
|
52
|
+
export class SharedJsonFile {
|
|
53
|
+
#file;
|
|
54
|
+
#value;
|
|
55
|
+
#seen = "";
|
|
56
|
+
constructor(file) {
|
|
57
|
+
this.#file = file;
|
|
58
|
+
this.#load();
|
|
59
|
+
}
|
|
60
|
+
#identity() {
|
|
61
|
+
try {
|
|
62
|
+
const s = statSync(this.#file);
|
|
63
|
+
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (e.code === "ENOENT")
|
|
67
|
+
return "";
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
#load() {
|
|
72
|
+
const seen = this.#identity();
|
|
73
|
+
// A null prototype: a key such as "__proto__" is a key like any other.
|
|
74
|
+
this.#value = Object.assign(Object.create(null), readJsonFile(this.#file, {}));
|
|
75
|
+
this.#seen = seen;
|
|
76
|
+
}
|
|
77
|
+
/** The current content. Do not change it; use `update`. */
|
|
78
|
+
read() {
|
|
79
|
+
if (this.#identity() !== this.#seen)
|
|
80
|
+
this.#load();
|
|
81
|
+
return this.#value;
|
|
82
|
+
}
|
|
83
|
+
/** Change the content. `change` gets what is on disk now and says whether
|
|
84
|
+
* it has to be written. */
|
|
85
|
+
update(change) {
|
|
86
|
+
mkdirSync(dirname(this.#file), { recursive: true });
|
|
87
|
+
const lock = `${this.#file}.lock`;
|
|
88
|
+
const waitUntil = Date.now() + LOCK_WAIT_MS;
|
|
89
|
+
for (;;) {
|
|
90
|
+
try {
|
|
91
|
+
closeSync(openSync(lock, "wx", 0o600));
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
if (e.code !== "EEXIST")
|
|
96
|
+
throw e;
|
|
97
|
+
let age = 0;
|
|
98
|
+
try {
|
|
99
|
+
age = Date.now() - statSync(lock).mtimeMs;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue; // released in between
|
|
103
|
+
}
|
|
104
|
+
// A writer holds the lock for a few milliseconds. One this old was
|
|
105
|
+
// left by a process that died while holding it.
|
|
106
|
+
if (age > LOCK_STALE_MS || Date.now() > waitUntil) {
|
|
107
|
+
try {
|
|
108
|
+
unlinkSync(lock);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* someone else removed it */
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
Atomics.wait(sleepCell, 0, 0, 5);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const value = this.read();
|
|
120
|
+
const { write, result } = change(value);
|
|
121
|
+
if (write) {
|
|
122
|
+
atomicWriteJson(this.#file, value);
|
|
123
|
+
this.#seen = this.#identity();
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// `change` may have altered the copy in memory before it threw.
|
|
129
|
+
this.#seen = "\0";
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
try {
|
|
134
|
+
unlinkSync(lock);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* already gone */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export const isThenable = (v) => !!v && typeof v.then === "function";
|
|
143
|
+
/** A userid is the base58 of a 32-byte public key. */
|
|
144
|
+
export function isUserid(v) {
|
|
145
|
+
if (typeof v !== "string" || v.length < 32 || v.length > 64)
|
|
146
|
+
return false;
|
|
147
|
+
try {
|
|
148
|
+
return base58ToBytes(v).length === 32;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** The address when it is a well-formed Carrier address of `userid`, else null.
|
|
155
|
+
* An address is not signed at sign-in, so the only thing that can be checked
|
|
156
|
+
* is that it parses (key, nospam, checksum) and names the key that signed. */
|
|
157
|
+
export function addressOf(userid, address) {
|
|
158
|
+
if (typeof address !== "string" || address.length < 40 || address.length > 80)
|
|
159
|
+
return null;
|
|
160
|
+
try {
|
|
161
|
+
return carrierIdFromAddress(address) === userid ? address : null;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export const msgOf = (e) => e?.message ?? String(e);
|
|
168
|
+
/** A minimal typed event hub; a listener that throws never breaks the emitter. */
|
|
169
|
+
export class Hub {
|
|
170
|
+
#listeners = new Map();
|
|
171
|
+
#log;
|
|
172
|
+
constructor(log = () => { }) {
|
|
173
|
+
this.#log = log;
|
|
174
|
+
}
|
|
175
|
+
on(event, cb) {
|
|
176
|
+
let set = this.#listeners.get(event);
|
|
177
|
+
if (!set)
|
|
178
|
+
this.#listeners.set(event, (set = new Set()));
|
|
179
|
+
set.add(cb);
|
|
180
|
+
return () => set.delete(cb);
|
|
181
|
+
}
|
|
182
|
+
/** Runs listeners in registration order and waits for each. */
|
|
183
|
+
async emit(event, ...args) {
|
|
184
|
+
for (const cb of [...(this.#listeners.get(event) ?? [])]) {
|
|
185
|
+
try {
|
|
186
|
+
await cb(...args);
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
this.#log(`beagle-service: a "${String(event)}" listener threw: ${msgOf(e)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Run jobs for the same key one after another. */
|
|
195
|
+
export class KeyedLock {
|
|
196
|
+
#tails = new Map();
|
|
197
|
+
run(key, job) {
|
|
198
|
+
const prev = this.#tails.get(key) ?? Promise.resolve();
|
|
199
|
+
const next = prev.then(job, job);
|
|
200
|
+
const tail = next.catch(() => { });
|
|
201
|
+
this.#tails.set(key, tail);
|
|
202
|
+
void tail.then(() => {
|
|
203
|
+
if (this.#tails.get(key) === tail)
|
|
204
|
+
this.#tails.delete(key);
|
|
205
|
+
});
|
|
206
|
+
return next;
|
|
207
|
+
}
|
|
208
|
+
}
|