@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/auth.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
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, msgOf } 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
|
+
/** Throw now if the configured origins cannot be used. A function is called once. */
|
|
36
|
+
export function checkOrigins(source) {
|
|
37
|
+
if (!(source instanceof Set))
|
|
38
|
+
normalizeOrigins(typeof source === "function" ? source() : source);
|
|
39
|
+
}
|
|
40
|
+
/** The signing secret: the configured one, else a 32-byte one kept in `file`. */
|
|
41
|
+
export function loadSecret(configured, file) {
|
|
42
|
+
if (configured) {
|
|
43
|
+
if (configured.length < 16)
|
|
44
|
+
throw new TypeError("beagle-service: `sessionSecret` must be at least 16 characters");
|
|
45
|
+
return configured;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const s = readFileSync(file, "utf8").trim();
|
|
49
|
+
if (s)
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* first start */
|
|
54
|
+
}
|
|
55
|
+
const s = randomBytes(32).toString("hex");
|
|
56
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
57
|
+
writeFileSync(file, s, { mode: 0o600 });
|
|
58
|
+
try {
|
|
59
|
+
chmodSync(file, 0o600);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* best effort on filesystems without modes */
|
|
63
|
+
}
|
|
64
|
+
return s;
|
|
65
|
+
}
|
|
66
|
+
export function headerOf(req, name) {
|
|
67
|
+
const h = req?.headers;
|
|
68
|
+
if (!h)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (typeof h.get === "function")
|
|
71
|
+
return h.get(name) ?? undefined;
|
|
72
|
+
const v = h[name] ?? h[name.toLowerCase()];
|
|
73
|
+
return Array.isArray(v) ? v[0] : v;
|
|
74
|
+
}
|
|
75
|
+
const isSigHex = (v) => typeof v === "string" && /^[0-9a-fA-F]{128}$/.test(v);
|
|
76
|
+
export function createAuth(opts) {
|
|
77
|
+
const { state, users, secret } = opts;
|
|
78
|
+
const now = opts.now ?? Date.now;
|
|
79
|
+
const log = opts.log ?? (() => { });
|
|
80
|
+
const cfg = opts.session ?? {};
|
|
81
|
+
const ttlMs = (cfg.ttlDays ?? 30) * DAY_MS;
|
|
82
|
+
const cookieName = cfg.cookieName ?? "beagle_session";
|
|
83
|
+
const sameSite = cfg.sameSite ?? "Lax";
|
|
84
|
+
if (opts.serverId !== undefined && (typeof opts.serverId !== "string" || !opts.serverId || opts.serverId.length > 64)) {
|
|
85
|
+
throw new TypeError("beagle-service: `serverId` must be a string of 1 to 64 characters");
|
|
86
|
+
}
|
|
87
|
+
// Only the nonce MAC carries the server's name: a session is signed the
|
|
88
|
+
// same way on every server that has the secret.
|
|
89
|
+
const noncePurpose = opts.serverId ? `nonce@${opts.serverId}` : "nonce";
|
|
90
|
+
// ── which origins may ask ─────────────────────────────────────────────────
|
|
91
|
+
// A fixed set, or a function asked on each request. The function's answer
|
|
92
|
+
// is parsed again only when it differs from the last one; an answer that
|
|
93
|
+
// does not parse is logged and the last good list stays in force.
|
|
94
|
+
let fixed = new Set();
|
|
95
|
+
let source = null;
|
|
96
|
+
let sourceKey = null;
|
|
97
|
+
let complainedAt = 0;
|
|
98
|
+
function setOrigins(next) {
|
|
99
|
+
if (typeof next === "function") {
|
|
100
|
+
const list = next();
|
|
101
|
+
const set = normalizeOrigins(list); // throws: the caller learns it now
|
|
102
|
+
fixed = set;
|
|
103
|
+
source = next;
|
|
104
|
+
sourceKey = JSON.stringify(list);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
fixed = next instanceof Set ? new Set(next) : normalizeOrigins(next);
|
|
108
|
+
source = null;
|
|
109
|
+
sourceKey = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
setOrigins(opts.origins);
|
|
113
|
+
function current() {
|
|
114
|
+
if (!source)
|
|
115
|
+
return fixed;
|
|
116
|
+
try {
|
|
117
|
+
const list = source();
|
|
118
|
+
const key = JSON.stringify(list);
|
|
119
|
+
if (key !== sourceKey) {
|
|
120
|
+
fixed = normalizeOrigins(list);
|
|
121
|
+
sourceKey = key;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (Date.now() - complainedAt > 60_000) {
|
|
126
|
+
complainedAt = Date.now();
|
|
127
|
+
log(`beagle-service: the origins function gave a list that cannot be used (${msgOf(e)}); keeping ${[...fixed].join(", ")}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return fixed;
|
|
131
|
+
}
|
|
132
|
+
const mac = (purpose, body) => createHmac("sha256", secret).update(`${purpose}\n${body}`).digest("base64url");
|
|
133
|
+
const macOk = (purpose, body, got) => {
|
|
134
|
+
const want = Buffer.from(mac(purpose, body));
|
|
135
|
+
const have = Buffer.from(got);
|
|
136
|
+
return want.length === have.length && timingSafeEqual(want, have);
|
|
137
|
+
};
|
|
138
|
+
// ── nonces ────────────────────────────────────────────────────────────────
|
|
139
|
+
// A nonce carries its own expiry and our MAC, so issuing one stores nothing
|
|
140
|
+
// and any server process can recognise it. Using one stores it (until it
|
|
141
|
+
// would have expired anyway), which is what makes it single-use, also
|
|
142
|
+
// across a restart.
|
|
143
|
+
function issueNonce() {
|
|
144
|
+
const body = `${randomBytes(18).toString("base64url")}.${(now() + NONCE_TTL_MS).toString(36)}`;
|
|
145
|
+
return `${body}.${mac(noncePurpose, body).slice(0, 22)}`;
|
|
146
|
+
}
|
|
147
|
+
/** The nonce's id and expiry when it is ours (this server's, with
|
|
148
|
+
* `serverId`) and has not expired. Reads nothing, writes nothing. */
|
|
149
|
+
function readNonce(nonce) {
|
|
150
|
+
if (typeof nonce !== "string" || nonce.length > 128)
|
|
151
|
+
return null;
|
|
152
|
+
const parts = nonce.split(".");
|
|
153
|
+
if (parts.length !== 3)
|
|
154
|
+
return null;
|
|
155
|
+
const body = `${parts[0]}.${parts[1]}`;
|
|
156
|
+
const want = Buffer.from(mac(noncePurpose, body).slice(0, 22));
|
|
157
|
+
const have = Buffer.from(parts[2]);
|
|
158
|
+
if (want.length !== have.length || !timingSafeEqual(want, have))
|
|
159
|
+
return null;
|
|
160
|
+
const exp = parseInt(parts[1], 36);
|
|
161
|
+
if (!Number.isFinite(exp) || exp < now())
|
|
162
|
+
return null;
|
|
163
|
+
return { id: parts[0], exp };
|
|
164
|
+
}
|
|
165
|
+
// ── the two assertions ────────────────────────────────────────────────────
|
|
166
|
+
const fail = (status, error) => ({ ok: false, status, error });
|
|
167
|
+
const hintsOf = (b) => ({ address: b.address, name: b.name, avatar: b.avatar, punkId: b.punkId });
|
|
168
|
+
function doorOf(v) {
|
|
169
|
+
if (typeof v !== "string" || v.length > 200)
|
|
170
|
+
return null;
|
|
171
|
+
try {
|
|
172
|
+
const o = new URL(v).origin;
|
|
173
|
+
return o === "null" ? null : o;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function allowedOrigin(req) {
|
|
180
|
+
const origin = headerOf(req, "origin");
|
|
181
|
+
return origin && current().has(origin) ? origin : null;
|
|
182
|
+
}
|
|
183
|
+
async function verifySignIn(origin, body) {
|
|
184
|
+
if (!origin || !current().has(origin))
|
|
185
|
+
return fail(403, "origin-not-allowed");
|
|
186
|
+
if (!body || typeof body !== "object")
|
|
187
|
+
return fail(400, "bad-request");
|
|
188
|
+
const b = body;
|
|
189
|
+
const nonce = readNonce(b.nonce);
|
|
190
|
+
if (!nonce || (await state.has("nonce", nonce.id)))
|
|
191
|
+
return fail(401, "bad-nonce");
|
|
192
|
+
if (!isUserid(b.userid) || !isSigHex(b.sig) || !connectVerifySignIn(b.userid, origin, b.nonce, b.sig)) {
|
|
193
|
+
return fail(401, "bad-signature");
|
|
194
|
+
}
|
|
195
|
+
// Spent here, by the sign-in that verified, and only here: a request with
|
|
196
|
+
// no valid signature costs no write and adds nothing to the used list, so
|
|
197
|
+
// nobody without a key can grow it, or burn a nonce a visitor still holds.
|
|
198
|
+
// `addOnce` is the single-use guarantee: of two requests with the same
|
|
199
|
+
// valid assertion, one gets true.
|
|
200
|
+
if (!(await state.addOnce("nonce", nonce.id, nonce.exp)))
|
|
201
|
+
return fail(401, "bad-nonce");
|
|
202
|
+
return { ok: true, userid: b.userid, hints: hintsOf(b), door: doorOf(b.uiOrigin) };
|
|
203
|
+
}
|
|
204
|
+
async function verifyLaunch(origin, body) {
|
|
205
|
+
if (!origin || !current().has(origin))
|
|
206
|
+
return fail(403, "origin-not-allowed");
|
|
207
|
+
if (!body || typeof body !== "object")
|
|
208
|
+
return fail(400, "bad-request");
|
|
209
|
+
const b = body;
|
|
210
|
+
const ts = typeof b.ts === "number" ? b.ts : typeof b.ts === "string" && /^\d{1,16}$/.test(b.ts) ? Number(b.ts) : NaN;
|
|
211
|
+
// Both directions: a clock ahead of ours is as suspect as one behind.
|
|
212
|
+
if (!Number.isSafeInteger(ts) || Math.abs(now() - ts) > LAUNCH_MAX_AGE_MS)
|
|
213
|
+
return fail(401, "launch-expired");
|
|
214
|
+
if (!isUserid(b.userid) || !isSigHex(b.sig) || !connectVerifyLaunch(b.userid, origin, ts, b.sig)) {
|
|
215
|
+
return fail(401, "bad-signature");
|
|
216
|
+
}
|
|
217
|
+
// Last, so a forged repeat cannot burn an assertion a real visitor still
|
|
218
|
+
// holds. Keyed on what was signed (who, for which origin, at which
|
|
219
|
+
// instant), not on the signature's bytes: the same assertion written with
|
|
220
|
+
// other hex case, or any second signature over the same message, is a
|
|
221
|
+
// repeat too.
|
|
222
|
+
const fresh = await state.addOnce("launch", `${b.userid}|${origin}|${ts}`, now() + 2 * LAUNCH_MAX_AGE_MS);
|
|
223
|
+
if (!fresh)
|
|
224
|
+
return fail(401, "launch-replayed");
|
|
225
|
+
return { ok: true, userid: b.userid, hints: hintsOf(b), door: "launch" };
|
|
226
|
+
}
|
|
227
|
+
// ── sessions ──────────────────────────────────────────────────────────────
|
|
228
|
+
function mintSession(userid) {
|
|
229
|
+
const t = now();
|
|
230
|
+
const sid = randomBytes(12).toString("base64url");
|
|
231
|
+
const expiresAt = t + ttlMs;
|
|
232
|
+
const body = Buffer.from(JSON.stringify({ u: userid, i: sid, t, e: expiresAt })).toString("base64url");
|
|
233
|
+
return { token: `v1.${body}.${mac("session", body)}`, sid, expiresAt };
|
|
234
|
+
}
|
|
235
|
+
function readToken(token) {
|
|
236
|
+
if (typeof token !== "string" || token.length > 1024)
|
|
237
|
+
return null;
|
|
238
|
+
const parts = token.split(".");
|
|
239
|
+
if (parts.length !== 3 || parts[0] !== "v1" || !macOk("session", parts[1], parts[2]))
|
|
240
|
+
return null;
|
|
241
|
+
try {
|
|
242
|
+
const p = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
243
|
+
if (typeof p.u !== "string" || typeof p.i !== "string" || !Number.isFinite(p.t) || !Number.isFinite(p.e))
|
|
244
|
+
return null;
|
|
245
|
+
if (p.e < now())
|
|
246
|
+
return null;
|
|
247
|
+
return { userid: p.u, sid: p.i, issuedAt: p.t, expiresAt: p.e };
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function tokenFrom(req) {
|
|
254
|
+
const authz = headerOf(req, "authorization");
|
|
255
|
+
if (authz && /^bearer /i.test(authz))
|
|
256
|
+
return { token: authz.slice(7).trim(), via: "bearer" };
|
|
257
|
+
const cookies = headerOf(req, "cookie");
|
|
258
|
+
if (cookies) {
|
|
259
|
+
for (const part of cookies.split(";")) {
|
|
260
|
+
const i = part.indexOf("=");
|
|
261
|
+
if (i > 0 && part.slice(0, i).trim() === cookieName)
|
|
262
|
+
return { token: part.slice(i + 1).trim(), via: "cookie" };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const SAFE_METHOD = /^(GET|HEAD|OPTIONS)$/i;
|
|
268
|
+
async function authenticate(req, o = {}) {
|
|
269
|
+
const none = { ok: false, status: 401, error: "not-signed-in" };
|
|
270
|
+
try {
|
|
271
|
+
const found = tokenFrom(req);
|
|
272
|
+
if (!found)
|
|
273
|
+
return none;
|
|
274
|
+
const t = readToken(found.token);
|
|
275
|
+
if (!t)
|
|
276
|
+
return none;
|
|
277
|
+
if (await state.has("revoked", t.sid))
|
|
278
|
+
return none;
|
|
279
|
+
// A cookie is sent by the browser whoever wrote the page; the Origin
|
|
280
|
+
// header says who did. A bearer token is put there by the caller.
|
|
281
|
+
if (o.checkOrigin && found.via === "cookie") {
|
|
282
|
+
const method = req?.method;
|
|
283
|
+
if (!(typeof method === "string" && SAFE_METHOD.test(method)) && !allowedOrigin(req)) {
|
|
284
|
+
return { ok: false, status: 403, error: "origin-not-allowed" };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const touch = o.touch ?? cfg.touch ?? true;
|
|
288
|
+
const user = touch ? await users.touch(t.userid) : await users.get(t.userid);
|
|
289
|
+
if (!user)
|
|
290
|
+
return none;
|
|
291
|
+
if (user.status === "blocked")
|
|
292
|
+
return { ok: false, status: 403, error: "blocked" };
|
|
293
|
+
if (user.sessionsValidFrom && t.issuedAt < user.sessionsValidFrom)
|
|
294
|
+
return none;
|
|
295
|
+
const session = { ...t, via: found.via };
|
|
296
|
+
const registration = users.registration(user);
|
|
297
|
+
if (registration.refused && !o.allowUnregistered) {
|
|
298
|
+
return { ok: false, status: 403, error: "register-required", user, session, registration };
|
|
299
|
+
}
|
|
300
|
+
return { ok: true, user, session, registration };
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
// A store that cannot be read is not "nobody is signed in" and it is
|
|
304
|
+
// not an exception either: a caller that wraps this in try/catch and
|
|
305
|
+
// goes on would be deciding without knowing who asks.
|
|
306
|
+
log(`beagle-service: authenticate could not read the session: ${e?.stack ?? msgOf(e)}`);
|
|
307
|
+
return { ok: false, status: 500, error: "server-error" };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function revoke(token) {
|
|
311
|
+
const t = readToken(token);
|
|
312
|
+
if (t)
|
|
313
|
+
await state.addOnce("revoked", t.sid, t.expiresAt);
|
|
314
|
+
}
|
|
315
|
+
function cookie(token, origin) {
|
|
316
|
+
const parts = [`${cookieName}=${token ?? ""}`, `Path=${cfg.cookiePath ?? "/"}`, "HttpOnly", `SameSite=${sameSite}`];
|
|
317
|
+
parts.push(`Max-Age=${token ? Math.floor(ttlMs / 1000) : 0}`);
|
|
318
|
+
if (cfg.cookieDomain)
|
|
319
|
+
parts.push(`Domain=${cfg.cookieDomain}`);
|
|
320
|
+
// Secure always, except for a page the site itself listed under plain
|
|
321
|
+
// http (a localhost during development): a browser would drop it there.
|
|
322
|
+
if (sameSite === "None" || !origin?.startsWith("http://"))
|
|
323
|
+
parts.push("Secure");
|
|
324
|
+
return parts.join("; ");
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
issueNonce,
|
|
328
|
+
allowedOrigin,
|
|
329
|
+
origins: () => [...current()],
|
|
330
|
+
setOrigins,
|
|
331
|
+
verifySignIn,
|
|
332
|
+
verifyLaunch,
|
|
333
|
+
authenticate,
|
|
334
|
+
mintSession,
|
|
335
|
+
readToken,
|
|
336
|
+
tokenOf: (req) => tokenFrom(req)?.token ?? null,
|
|
337
|
+
revoke,
|
|
338
|
+
cookie,
|
|
339
|
+
};
|
|
340
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// beagle-service: the operator's command.
|
|
3
|
+
//
|
|
4
|
+
// npx beagle-service users list [--status visitor|registered|blocked] [--json]
|
|
5
|
+
// npx beagle-service users show <userid>
|
|
6
|
+
// npx beagle-service users block <userid> [why…]
|
|
7
|
+
// npx beagle-service users unblock <userid>
|
|
8
|
+
//
|
|
9
|
+
// It changes the same records the running site reads, through the library's
|
|
10
|
+
// own `users`, never by editing a file by hand. With the default file store a
|
|
11
|
+
// block takes effect at once, on sign-in and on every session that userid
|
|
12
|
+
// holds: the site reads the file again when it was replaced. No restart.
|
|
13
|
+
//
|
|
14
|
+
// Where the users are:
|
|
15
|
+
// --data <dir> the site's dataDir, for the default store (default ./data)
|
|
16
|
+
// --config <file> a module that exports the site's own `store` (and its
|
|
17
|
+
// `register` / `visitor` settings), or a ready `users`;
|
|
18
|
+
// default: beagle-service.config.{mjs,cjs,js} in this directory
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
import { jsonFileUserStore } from "./stores.js";
|
|
23
|
+
import { createUsers } from "./users.js";
|
|
24
|
+
const USAGE = `usage: beagle-service users list [--status visitor|registered|blocked] [--json]
|
|
25
|
+
beagle-service users show <userid>
|
|
26
|
+
beagle-service users block <userid> [why…]
|
|
27
|
+
beagle-service users unblock <userid>
|
|
28
|
+
|
|
29
|
+
options: --data <dir> the site's dataDir, for the default file store (default ./data)
|
|
30
|
+
--config <file> a module exporting { store, register?, visitor? } or { users }
|
|
31
|
+
(default: beagle-service.config.mjs / .cjs / .js here, if there is one)`;
|
|
32
|
+
class Usage extends Error {
|
|
33
|
+
}
|
|
34
|
+
function parse(argv) {
|
|
35
|
+
const words = [];
|
|
36
|
+
const flags = new Map();
|
|
37
|
+
const takesValue = new Set(["data", "config", "status"]);
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const a = argv[i];
|
|
40
|
+
if (!a.startsWith("--")) {
|
|
41
|
+
words.push(a);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const eq = a.indexOf("=");
|
|
45
|
+
const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
46
|
+
if (eq > 0)
|
|
47
|
+
flags.set(name, a.slice(eq + 1));
|
|
48
|
+
else if (takesValue.has(name)) {
|
|
49
|
+
const v = argv[++i];
|
|
50
|
+
if (v === undefined)
|
|
51
|
+
throw new Usage(`--${name} needs a value`);
|
|
52
|
+
flags.set(name, v);
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
flags.set(name, true);
|
|
56
|
+
}
|
|
57
|
+
return { words, flags };
|
|
58
|
+
}
|
|
59
|
+
async function usersFrom(flags) {
|
|
60
|
+
const named = flags.get("config");
|
|
61
|
+
let file = typeof named === "string" ? resolve(named) : null;
|
|
62
|
+
if (file && !existsSync(file))
|
|
63
|
+
throw new Error(`no such config file: ${file}`);
|
|
64
|
+
if (!file) {
|
|
65
|
+
for (const name of ["beagle-service.config.mjs", "beagle-service.config.cjs", "beagle-service.config.js"]) {
|
|
66
|
+
if (existsSync(resolve(name))) {
|
|
67
|
+
file = resolve(name);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let config = {};
|
|
73
|
+
if (file) {
|
|
74
|
+
const mod = await import(pathToFileURL(file).href);
|
|
75
|
+
config = mod.default ?? mod;
|
|
76
|
+
if (typeof config === "function")
|
|
77
|
+
config = await config();
|
|
78
|
+
if (!config || typeof config !== "object")
|
|
79
|
+
throw new Error(`${file} must export an object, or a function that returns one`);
|
|
80
|
+
if (config.users && typeof config.users.block === "function")
|
|
81
|
+
return config.users;
|
|
82
|
+
}
|
|
83
|
+
const data = flags.get("data");
|
|
84
|
+
const dataDir = typeof data === "string" ? data : (config.dataDir ?? "./data");
|
|
85
|
+
if (!config.store && !existsSync(join(dataDir, "users.json"))) {
|
|
86
|
+
throw new Error(`no users file at ${join(dataDir, "users.json")}; name the site's data directory with --data, or its own store with --config`);
|
|
87
|
+
}
|
|
88
|
+
return createUsers({
|
|
89
|
+
store: config.store ?? jsonFileUserStore(join(dataDir, "users.json")),
|
|
90
|
+
register: config.register,
|
|
91
|
+
visitor: config.visitor,
|
|
92
|
+
log: (m) => console.error(m),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const line = (u) => [
|
|
96
|
+
u.userid,
|
|
97
|
+
u.status.padEnd(10),
|
|
98
|
+
String(u.signIns || 0).padStart(5),
|
|
99
|
+
(u.lastSeen || "-").padEnd(24),
|
|
100
|
+
u.name || "",
|
|
101
|
+
u.blocked ? `[blocked ${u.blocked.at}: ${u.blocked.why}]` : "",
|
|
102
|
+
]
|
|
103
|
+
.join(" ")
|
|
104
|
+
.trimEnd();
|
|
105
|
+
async function main(argv) {
|
|
106
|
+
const { words, flags } = parse(argv);
|
|
107
|
+
if (flags.has("help") || words.length === 0) {
|
|
108
|
+
console.log(USAGE);
|
|
109
|
+
return words.length === 0 && !flags.has("help") ? 2 : 0;
|
|
110
|
+
}
|
|
111
|
+
const [group, cmd, userid, ...rest] = words;
|
|
112
|
+
if (group !== "users" || !cmd)
|
|
113
|
+
throw new Usage(`unknown command: ${words.join(" ")}`);
|
|
114
|
+
if (cmd === "list") {
|
|
115
|
+
const status = flags.get("status");
|
|
116
|
+
if (status !== undefined && status !== "visitor" && status !== "registered" && status !== "blocked") {
|
|
117
|
+
throw new Usage("--status is visitor, registered or blocked");
|
|
118
|
+
}
|
|
119
|
+
const all = await (await usersFrom(flags)).list(status ? { status: status } : undefined);
|
|
120
|
+
if (flags.has("json"))
|
|
121
|
+
console.log(JSON.stringify(all, null, 2));
|
|
122
|
+
else {
|
|
123
|
+
for (const u of all)
|
|
124
|
+
console.log(line(u));
|
|
125
|
+
console.log(`${all.length} user(s)`);
|
|
126
|
+
}
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (!userid || !["show", "block", "unblock"].includes(cmd))
|
|
130
|
+
throw new Usage(cmd === "show" || cmd === "block" || cmd === "unblock" ? `${cmd} needs a userid` : `unknown command: users ${cmd}`);
|
|
131
|
+
const users = await usersFrom(flags);
|
|
132
|
+
if (cmd === "show") {
|
|
133
|
+
const u = await users.get(userid);
|
|
134
|
+
if (!u) {
|
|
135
|
+
console.error("no such user");
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
console.log(JSON.stringify(u, null, 2));
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
if (cmd === "block") {
|
|
142
|
+
console.log(line(await users.block(userid, rest.join(" "))));
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
const u = await users.unblock(userid);
|
|
146
|
+
if (!u) {
|
|
147
|
+
console.error("no such user");
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
console.log(line(u));
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
// Exit by hand once the output is flushed: a site's own store may hold a
|
|
154
|
+
// connection open that would keep the process alive.
|
|
155
|
+
const exit = (code) => process.stdout.write("", () => process.stderr.write("", () => process.exit(code)));
|
|
156
|
+
main(process.argv.slice(2)).then(exit, (e) => {
|
|
157
|
+
if (e instanceof Usage) {
|
|
158
|
+
console.error(`${e.message}\n\n${USAGE}`);
|
|
159
|
+
return exit(2);
|
|
160
|
+
}
|
|
161
|
+
console.error(String(e?.message ?? e));
|
|
162
|
+
exit(1);
|
|
163
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.load = load;
|
|
4
|
+
exports.createBeagleService = createBeagleService;
|
|
5
|
+
exports.sharedBeagleService = sharedBeagleService;
|
|
6
|
+
let loading;
|
|
7
|
+
/** The whole library, as `import("@decentnetwork/beagle-service")` gives it:
|
|
8
|
+
* `const { createUsers, jsonFileStateStore } = await load();` */
|
|
9
|
+
function load() {
|
|
10
|
+
return (loading ??= import("./index.js"));
|
|
11
|
+
}
|
|
12
|
+
/** As the ES module's `createBeagleService`. */
|
|
13
|
+
function createBeagleService(config) {
|
|
14
|
+
return load().then((lib) => lib.createBeagleService(config));
|
|
15
|
+
}
|
|
16
|
+
const UNAVAILABLE = { ok: false, error: "server-error", message: "Sign-in is not available on this server." };
|
|
17
|
+
/** As the ES module's `sharedBeagleService`: returns at once, never throws,
|
|
18
|
+
* and is the same service for every caller in the process. */
|
|
19
|
+
function sharedBeagleService(config, opts) {
|
|
20
|
+
const real = () => load().then((lib) => lib.sharedBeagleService(config, opts));
|
|
21
|
+
const down = (res) => {
|
|
22
|
+
if (res.headersSent)
|
|
23
|
+
return;
|
|
24
|
+
res.statusCode = 500;
|
|
25
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
26
|
+
res.end(JSON.stringify(UNAVAILABLE));
|
|
27
|
+
};
|
|
28
|
+
const node = (pick) => async (req, res, next) => {
|
|
29
|
+
let svc;
|
|
30
|
+
try {
|
|
31
|
+
svc = await real();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return down(res);
|
|
35
|
+
}
|
|
36
|
+
return pick(svc)(req, res, next);
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
ready: () => real().then((svc) => svc.ready()),
|
|
40
|
+
authenticate: (req, o) => real().then((svc) => svc.authenticate(req, o), () => ({ ok: false, status: 500, error: "server-error" })),
|
|
41
|
+
handle: (req) => real().then((svc) => svc.handle(req), () => ({ status: 500, headers: { "content-type": "application/json; charset=utf-8" }, body: UNAVAILABLE })),
|
|
42
|
+
route: (name) => node((svc) => svc.route(name)),
|
|
43
|
+
nodeHandler: (o) => node((svc) => svc.nodeHandler(o)),
|
|
44
|
+
expressHandler: (o) => node((svc) => svc.expressHandler(o)),
|
|
45
|
+
nextApiHandler: (o) => node((svc) => svc.nextApiHandler(o)),
|
|
46
|
+
fetchHandler: (o) => (request) => real().then((svc) => svc.fetchHandler(o)(request)),
|
|
47
|
+
close: () => real().then((svc) => svc.close()),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type * as Library from "./index.js" with { "resolution-mode": "import" };
|
|
2
|
+
type Lib = typeof Library;
|
|
3
|
+
/** The whole library, as `import("@decentnetwork/beagle-service")` gives it:
|
|
4
|
+
* `const { createUsers, jsonFileStateStore } = await load();` */
|
|
5
|
+
export declare function load(): Promise<Lib>;
|
|
6
|
+
/** As the ES module's `createBeagleService`. */
|
|
7
|
+
export declare function createBeagleService(config: Library.BeagleServiceConfig): Promise<Library.BeagleService>;
|
|
8
|
+
/** As the ES module's `sharedBeagleService`: returns at once, never throws,
|
|
9
|
+
* and is the same service for every caller in the process. */
|
|
10
|
+
export declare function sharedBeagleService(config: Library.BeagleServiceConfig | (() => Library.BeagleServiceConfig), opts?: Library.SharedOptions): Library.SharedBeagleService;
|
|
11
|
+
export type * from "./types.js" with { "resolution-mode": "import" };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { BeagleService, BeagleServiceConfig, SharedBeagleService, SharedOptions } from "./types.js";
|
|
2
|
+
export declare function createBeagleService(config: BeagleServiceConfig): Promise<BeagleService>;
|
|
3
|
+
/** The service for this process: created on first use, and the same one for
|
|
4
|
+
* every caller with the same `key`, however many times the calling module is
|
|
5
|
+
* evaluated (Next.js bundles pages and API routes apart) and whichever of
|
|
6
|
+
* `import` and `require` loaded this package.
|
|
7
|
+
*
|
|
8
|
+
* Returns at once and never throws. A configuration that is wrong shows in
|
|
9
|
+
* `ready()`, which rejects; the routes then answer 500 and `authenticate`
|
|
10
|
+
* answers `ok: false`. A failed start is not kept: it is tried again, at most
|
|
11
|
+
* every `retryMs`.
|
|
12
|
+
*
|
|
13
|
+
* `config` may be a function, so that reading the environment happens at
|
|
14
|
+
* first use and not when the module is loaded. The first call for a key
|
|
15
|
+
* decides the configuration. */
|
|
16
|
+
export declare function sharedBeagleService(config: BeagleServiceConfig | (() => BeagleServiceConfig), opts?: SharedOptions): SharedBeagleService;
|
|
17
|
+
export { createAuth, normalizeOrigins, NONCE_TTL_MS, LAUNCH_MAX_AGE_MS } from "./auth.js";
|
|
18
|
+
export { createUsers, DEFAULT_PROFILE_GATEWAY } from "./users.js";
|
|
19
|
+
export { createAgent, loadServers, DEFAULT_SERVERS_URL } from "./agent.js";
|
|
20
|
+
export { Outbox, splitText } from "./outbox.js";
|
|
21
|
+
export { jsonFileUserStore, memoryUserStore, jsonFileStateStore, memoryStateStore, applyUserChange } from "./stores.js";
|
|
22
|
+
export { defaultPublicUser } from "./routes.js";
|
|
23
|
+
export * from "./types.js";
|