@decentnetwork/beagle-service 0.1.0 → 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/README.md +289 -28
- package/dist/auth.d.ts +10 -2
- package/dist/auth.js +119 -36
- 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 +16 -2
- package/dist/index.js +127 -5
- package/dist/routes.d.ts +9 -1
- package/dist/routes.js +157 -24
- package/dist/stores.d.ts +12 -4
- package/dist/stores.js +60 -37
- package/dist/types.d.ts +157 -12
- package/dist/users.js +108 -58
- package/dist/util.d.ts +22 -2
- package/dist/util.js +103 -2
- 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 +19 -7
package/dist/types.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface UserRecord {
|
|
|
14
14
|
punkId: number | null;
|
|
15
15
|
/** Which Beagle answered: the popup's origin, or "launch" for an Apps-tab arrival. */
|
|
16
16
|
door: string | null;
|
|
17
|
+
/** The origin of the site's page the last sign-in was signed for. */
|
|
18
|
+
origin?: string | null;
|
|
17
19
|
status: UserStatus;
|
|
18
20
|
/** The site's registration fields (`register.required` / `register.optional`). */
|
|
19
21
|
profile: Record<string, unknown>;
|
|
@@ -46,12 +48,34 @@ export interface PublishedProfile {
|
|
|
46
48
|
owner?: string;
|
|
47
49
|
fetchedAt: string;
|
|
48
50
|
}
|
|
51
|
+
/** What one write changed, as the second argument of `UserStore.put`. */
|
|
52
|
+
export interface UserChange {
|
|
53
|
+
/** The record as it was read for this write; null when there was none. */
|
|
54
|
+
prev: UserRecord | null;
|
|
55
|
+
/** Top-level fields that were added or got a new value, with that value. */
|
|
56
|
+
set: Record<string, unknown>;
|
|
57
|
+
/** Top-level fields that were removed. */
|
|
58
|
+
unset: string[];
|
|
59
|
+
}
|
|
49
60
|
/** Where users are kept. Implement these three calls for Postgres, sqlite or
|
|
50
|
-
* anything else.
|
|
51
|
-
*
|
|
61
|
+
* anything else.
|
|
62
|
+
*
|
|
63
|
+
* `get` may return any object that names the userid; fields this library
|
|
64
|
+
* needs and does not find are filled in when it is read (a record that
|
|
65
|
+
* predates the library is fine).
|
|
66
|
+
*
|
|
67
|
+
* `put` receives the whole record, including fields the store does not
|
|
68
|
+
* recognise, and as a second argument what this write changed. A store that
|
|
69
|
+
* other code writes to as well should store only `change.set` and remove
|
|
70
|
+
* `change.unset`: then a field somebody else wrote since the read is not
|
|
71
|
+
* written back from an old copy. A store that ignores the second argument
|
|
72
|
+
* and stores the whole record (an adapter written for 0.1.0) keeps working.
|
|
73
|
+
*
|
|
74
|
+
* When `get` and `put` are synchronous, the library calls them in the same
|
|
75
|
+
* turn of the event loop with nothing in between. */
|
|
52
76
|
export interface UserStore {
|
|
53
77
|
get(userid: string): Promise<UserRecord | null> | UserRecord | null;
|
|
54
|
-
put(user: UserRecord): Promise<void> | void;
|
|
78
|
+
put(user: UserRecord, change?: UserChange): Promise<void> | void;
|
|
55
79
|
list(): Promise<UserRecord[]> | UserRecord[];
|
|
56
80
|
}
|
|
57
81
|
/** Small durable sets with expiry: used nonces, used launch assertions and
|
|
@@ -124,6 +148,7 @@ export interface UsersEvents {
|
|
|
124
148
|
"sign-in": [user: UserRecord, info: {
|
|
125
149
|
first: boolean;
|
|
126
150
|
door: string | null;
|
|
151
|
+
origin: string | null;
|
|
127
152
|
}];
|
|
128
153
|
/** A visitor became a registered user. */
|
|
129
154
|
registered: [user: UserRecord];
|
|
@@ -160,6 +185,7 @@ export interface Users {
|
|
|
160
185
|
punkId?: unknown;
|
|
161
186
|
}, info?: {
|
|
162
187
|
door?: string | null;
|
|
188
|
+
origin?: string | null;
|
|
163
189
|
}): Promise<UserRecord>;
|
|
164
190
|
/** Note activity: `lastSeen`, and `visits` after a gap. Written at most
|
|
165
191
|
* once a minute per user. */
|
|
@@ -181,7 +207,56 @@ export interface SessionConfig {
|
|
|
181
207
|
cookieDomain?: string;
|
|
182
208
|
/** Default "/". */
|
|
183
209
|
cookiePath?: string;
|
|
210
|
+
/** `authenticate()` notes the user's activity (`lastSeen`, `visits`), which
|
|
211
|
+
* is one write to the users store per user per minute at most. false: it
|
|
212
|
+
* only reads. One call can say otherwise with `authenticate(req, { touch })`.
|
|
213
|
+
* Default true. */
|
|
214
|
+
touch?: boolean;
|
|
215
|
+
}
|
|
216
|
+
/** A limit on sign-in and launch attempts, counted in memory per process. */
|
|
217
|
+
export interface RateLimitConfig {
|
|
218
|
+
/** Attempts one client may make per window. Default 30. */
|
|
219
|
+
perClient?: number;
|
|
220
|
+
/** Attempts all clients together may make per window. Default 600. */
|
|
221
|
+
total?: number;
|
|
222
|
+
/** Default 60. */
|
|
223
|
+
windowSeconds?: number;
|
|
224
|
+
/** Read the client's address from `X-Forwarded-For`: true for one proxy in
|
|
225
|
+
* front of the server, or how many. Default false: the connection's own
|
|
226
|
+
* address, and a connection from this machine (a local proxy) counts
|
|
227
|
+
* against `total` only. */
|
|
228
|
+
trustProxy?: boolean | number;
|
|
229
|
+
/** Name the client yourself; null counts against `total` only. */
|
|
230
|
+
key?: (request: ServiceRequest) => string | null | undefined;
|
|
231
|
+
}
|
|
232
|
+
/** What `onSignIn` is told beside the user. */
|
|
233
|
+
export interface SignInInfo {
|
|
234
|
+
/** The origin of the site's page the assertion was signed for. */
|
|
235
|
+
origin: string;
|
|
236
|
+
/** Which Beagle answered: the popup's origin, or "launch". */
|
|
237
|
+
door: string | null;
|
|
238
|
+
/** Which route verified it. */
|
|
239
|
+
route: "sign-in" | "launch";
|
|
240
|
+
/** The first sign-in of this userid. */
|
|
241
|
+
first: boolean;
|
|
242
|
+
/** The request as the route got it (`raw` is the framework's own object,
|
|
243
|
+
* when an adapter made the request). */
|
|
244
|
+
request: ServiceRequest;
|
|
184
245
|
}
|
|
246
|
+
/** Refuse a sign-in from `onSignIn`: no session is issued. */
|
|
247
|
+
export interface SignInRefusal {
|
|
248
|
+
/** true, or the `error` code the page gets. Default "sign-in-refused". */
|
|
249
|
+
refuse: true | string;
|
|
250
|
+
/** Default 403. */
|
|
251
|
+
status?: number;
|
|
252
|
+
message?: string;
|
|
253
|
+
}
|
|
254
|
+
/** Runs after the assertion is verified and the user record is written, and
|
|
255
|
+
* before the session is issued and the answer goes out. Return an object and
|
|
256
|
+
* its fields are added to the sign-in answer (the library's own fields win);
|
|
257
|
+
* return `{ refuse }` to refuse. It is awaited; if it throws, the sign-in
|
|
258
|
+
* answers 500 and no session is issued. */
|
|
259
|
+
export type SignInHook = (user: UserRecord, info: SignInInfo) => void | Record<string, unknown> | SignInRefusal | Promise<void | Record<string, unknown> | SignInRefusal>;
|
|
185
260
|
export interface SessionInfo {
|
|
186
261
|
userid: string;
|
|
187
262
|
/** Session id; logout revokes this. */
|
|
@@ -191,7 +266,7 @@ export interface SessionInfo {
|
|
|
191
266
|
/** How the token arrived. */
|
|
192
267
|
via: "cookie" | "bearer";
|
|
193
268
|
}
|
|
194
|
-
export type ErrorCode = "origin-not-allowed" | "bad-request" | "bad-nonce" | "bad-signature" | "launch-expired" | "launch-replayed" | "blocked" | "register-required" | "not-signed-in" | "invalid-fields" | "not-found" | "method-not-allowed" | "too-large" | "server-error";
|
|
269
|
+
export type ErrorCode = "origin-not-allowed" | "bad-request" | "bad-nonce" | "bad-signature" | "launch-expired" | "launch-replayed" | "blocked" | "register-required" | "sign-in-refused" | "rate-limited" | "not-signed-in" | "invalid-fields" | "not-found" | "method-not-allowed" | "too-large" | "server-error";
|
|
195
270
|
export type VerifyResult = {
|
|
196
271
|
ok: true;
|
|
197
272
|
userid: string;
|
|
@@ -226,23 +301,45 @@ export interface HeadersCarrier {
|
|
|
226
301
|
headers: Record<string, string | string[] | undefined> | {
|
|
227
302
|
get(name: string): string | null;
|
|
228
303
|
};
|
|
304
|
+
method?: string;
|
|
305
|
+
}
|
|
306
|
+
/** The exact origins of the site's pages: a list, or a function that returns
|
|
307
|
+
* the list as it is now (called on each request; keep it cheap). */
|
|
308
|
+
export type OriginsSource = string[] | (() => string[]);
|
|
309
|
+
export interface AuthenticateOptions {
|
|
310
|
+
/** Let a visitor past the limit through (the register route does). */
|
|
311
|
+
allowUnregistered?: boolean;
|
|
312
|
+
/** Note the user's activity; see `session.touch`. */
|
|
313
|
+
touch?: boolean;
|
|
314
|
+
/** For the site's own state-changing routes: a session that arrived in a
|
|
315
|
+
* cookie is accepted only when the request's `Origin` header is on the
|
|
316
|
+
* list (403 `origin-not-allowed` otherwise). A bearer token needs no
|
|
317
|
+
* Origin. When the request object has a `method` and it is GET, HEAD or
|
|
318
|
+
* OPTIONS, nothing is checked, so the option can be passed on every call. */
|
|
319
|
+
checkOrigin?: boolean;
|
|
229
320
|
}
|
|
230
321
|
export interface Auth {
|
|
231
322
|
/** A single-use nonce that lives 120 s. */
|
|
232
323
|
issueNonce(): string;
|
|
233
324
|
/** The request's `Origin` header when it is on the exact list, else null. */
|
|
234
325
|
allowedOrigin(req: HeadersCarrier): string | null;
|
|
235
|
-
/**
|
|
326
|
+
/** The exact origins accepted now. */
|
|
327
|
+
origins(): string[];
|
|
328
|
+
/** Replace the list while running. Throws on a malformed entry and keeps
|
|
329
|
+
* the list it had. Used nonces, launch assertions and sessions are not
|
|
330
|
+
* touched. */
|
|
331
|
+
setOrigins(origins: OriginsSource): void;
|
|
332
|
+
/** Check a `decent-auth` assertion: origin, nonce, signature. The nonce is
|
|
333
|
+
* spent by the sign-in that verifies; one that fails writes nothing. */
|
|
236
334
|
verifySignIn(origin: string | null | undefined, body: unknown): Promise<VerifyResult>;
|
|
237
335
|
/** Check a `decent-launch` assertion: origin, ±120 s, signature, then replay (consumed last, kept on disk). */
|
|
238
336
|
verifyLaunch(origin: string | null | undefined, body: unknown): Promise<VerifyResult>;
|
|
239
337
|
/** Who is making this request, for the site's own routes. Refuses a blocked
|
|
240
338
|
* user (403 `blocked`) and a visitor past the limit under `then: "refuse"`
|
|
241
|
-
* (403 `register-required`) unless `allowUnregistered` is set.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}): Promise<AuthResult>;
|
|
339
|
+
* (403 `register-required`) unless `allowUnregistered` is set. Never
|
|
340
|
+
* rejects: no session, a bad one, or a store that cannot be read all answer
|
|
341
|
+
* `ok: false` (the last with status 500 `server-error`). */
|
|
342
|
+
authenticate(req: HeadersCarrier, opts?: AuthenticateOptions): Promise<AuthResult>;
|
|
246
343
|
/** A session token for `userid`. */
|
|
247
344
|
mintSession(userid: string): {
|
|
248
345
|
token: string;
|
|
@@ -271,6 +368,10 @@ export interface ServiceRequest {
|
|
|
271
368
|
headers: Record<string, string | string[] | undefined>;
|
|
272
369
|
/** The parsed JSON body of a POST. */
|
|
273
370
|
body?: unknown;
|
|
371
|
+
/** The client's address, for the rate limit. The adapters fill it in. */
|
|
372
|
+
ip?: string | null;
|
|
373
|
+
/** The framework's own request object, when an adapter made this one. */
|
|
374
|
+
raw?: unknown;
|
|
274
375
|
}
|
|
275
376
|
export interface ServiceResponse {
|
|
276
377
|
status: number;
|
|
@@ -287,6 +388,7 @@ export interface Handlers {
|
|
|
287
388
|
logout: RouteHandler;
|
|
288
389
|
register: RouteHandler;
|
|
289
390
|
}
|
|
391
|
+
export type RouteName = keyof Handlers;
|
|
290
392
|
export interface AdapterOptions {
|
|
291
393
|
/** Where the routes are mounted. Default "/api/auth". */
|
|
292
394
|
prefix?: string;
|
|
@@ -449,8 +551,10 @@ export interface Agent {
|
|
|
449
551
|
export interface BeagleServiceConfig {
|
|
450
552
|
/** Exact origins the site's pages are served from, e.g. "https://your.app"
|
|
451
553
|
* (scheme, host, port). No suffix matching. `http://localhost:3000` only
|
|
452
|
-
* when it is listed.
|
|
453
|
-
|
|
554
|
+
* when it is listed. A function is called on each request, for a list that
|
|
555
|
+
* changes while the server runs; `auth.setOrigins()` does the same from
|
|
556
|
+
* outside. A malformed entry throws when the service is created. */
|
|
557
|
+
origins: OriginsSource;
|
|
454
558
|
/** Where the library keeps its files. Default "./data". */
|
|
455
559
|
dataDir?: string;
|
|
456
560
|
/** Users backend. Default: `<dataDir>/users.json`. */
|
|
@@ -459,7 +563,15 @@ export interface BeagleServiceConfig {
|
|
|
459
563
|
state?: StateStore;
|
|
460
564
|
/** Signs sessions and nonces. Default: generated into `<dataDir>/session-secret`. */
|
|
461
565
|
sessionSecret?: string;
|
|
566
|
+
/** A name for this server among several that have one `sessionSecret` and
|
|
567
|
+
* cannot share a `state` store. A nonce is then accepted only by the server
|
|
568
|
+
* that issued it; sessions stay valid on all of them. */
|
|
569
|
+
serverId?: string;
|
|
462
570
|
session?: SessionConfig;
|
|
571
|
+
/** Limit on sign-in and launch attempts. false turns it off. */
|
|
572
|
+
rateLimit?: RateLimitConfig | false;
|
|
573
|
+
/** The site's part of a sign-in, before the answer goes out. */
|
|
574
|
+
onSignIn?: SignInHook;
|
|
463
575
|
register?: RegisterConfig;
|
|
464
576
|
visitor?: VisitorConfig;
|
|
465
577
|
/** Import the userid's beagles.eth record after sign-in. true, or the gateway and cache time. */
|
|
@@ -489,8 +601,41 @@ export interface BeagleService {
|
|
|
489
601
|
expressHandler(opts?: AdapterOptions): NodeHandler;
|
|
490
602
|
/** For `pages/api/auth/[...beagle].js` in Next.js: `export default svc.nextApiHandler()`. */
|
|
491
603
|
nextApiHandler(opts?: AdapterOptions): NodeHandler;
|
|
604
|
+
/** One route as a `(req, res)` handler, whatever path it is mounted on: for
|
|
605
|
+
* a site that gives each route its own file (beside NextAuth's catch-all,
|
|
606
|
+
* or under names of its own). */
|
|
607
|
+
route(name: RouteName): NodeHandler;
|
|
492
608
|
/** For Web `Request`/`Response` hosts, such as Next.js App Router route handlers. */
|
|
493
609
|
fetchHandler(opts?: AdapterOptions): (request: Request) => Promise<Response>;
|
|
494
610
|
/** Stop the agent and timers. */
|
|
495
611
|
close(): Promise<void>;
|
|
496
612
|
}
|
|
613
|
+
/** A service that is created on first use and kept for the life of the
|
|
614
|
+
* process, whoever asks for it and however often the module that asks is
|
|
615
|
+
* evaluated. Nothing here throws at the caller for a service that cannot
|
|
616
|
+
* start, except `ready()`. */
|
|
617
|
+
export interface SharedBeagleService {
|
|
618
|
+
/** The service. Rejects when it cannot be created (a malformed `origins`
|
|
619
|
+
* entry, a users file that does not parse); the failure is not kept, the
|
|
620
|
+
* next call tries again. Await it at start to find a bad configuration
|
|
621
|
+
* before the first request. */
|
|
622
|
+
ready(): Promise<BeagleService>;
|
|
623
|
+
/** `auth.authenticate` of the service. Never rejects: when the service
|
|
624
|
+
* cannot start it answers `ok: false`, status 500. */
|
|
625
|
+
authenticate(req: HeadersCarrier, opts?: AuthenticateOptions): Promise<AuthResult>;
|
|
626
|
+
/** As on the service; they answer 500 `server-error` while it cannot start. */
|
|
627
|
+
handle: RouteHandler;
|
|
628
|
+
route(name: RouteName): NodeHandler;
|
|
629
|
+
nodeHandler(opts?: AdapterOptions): NodeHandler;
|
|
630
|
+
expressHandler(opts?: AdapterOptions): NodeHandler;
|
|
631
|
+
nextApiHandler(opts?: AdapterOptions): NodeHandler;
|
|
632
|
+
fetchHandler(opts?: AdapterOptions): (request: Request) => Promise<Response>;
|
|
633
|
+
/** Close the service if it was created, and forget it. */
|
|
634
|
+
close(): Promise<void>;
|
|
635
|
+
}
|
|
636
|
+
export interface SharedOptions {
|
|
637
|
+
/** A process may hold several services under different keys. Default "default". */
|
|
638
|
+
key?: string;
|
|
639
|
+
/** After a failed start, wait this long before trying again. Default 5000. */
|
|
640
|
+
retryMs?: number;
|
|
641
|
+
}
|
package/dist/users.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addressOf, Hub, isUserid, KeyedLock, msgOf } from "./util.js";
|
|
1
|
+
import { addressOf, Hub, isThenable, isUserid, KeyedLock, msgOf } from "./util.js";
|
|
2
2
|
export const DEFAULT_PROFILE_GATEWAY = "https://ens-gateway.beaglechat.workers.dev";
|
|
3
3
|
const DAY_MS = 86_400_000;
|
|
4
4
|
const TOUCH_EVERY_MS = 60_000;
|
|
@@ -50,6 +50,67 @@ export function createUsers(opts) {
|
|
|
50
50
|
visits: 0,
|
|
51
51
|
signIns: 0,
|
|
52
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
|
+
}
|
|
53
114
|
const missingOf = (profile) => required.filter((f) => isEmpty(profile?.[f]));
|
|
54
115
|
/** visitor or registered, from what the profile holds today. */
|
|
55
116
|
const statusFor = (user) => (missingOf(user.profile).length === 0 ? "registered" : "visitor");
|
|
@@ -75,9 +136,14 @@ export function createUsers(opts) {
|
|
|
75
136
|
state.refused = over && visitor.then === "refuse";
|
|
76
137
|
return state;
|
|
77
138
|
}
|
|
78
|
-
const get = async (userid) => (await store.get(userid))
|
|
139
|
+
const get = async (userid) => normalise(await store.get(userid), userid);
|
|
79
140
|
async function list(filter) {
|
|
80
|
-
const all =
|
|
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
|
+
}
|
|
81
147
|
return filter?.status ? all.filter((u) => u.status === filter.status) : all;
|
|
82
148
|
}
|
|
83
149
|
function emitLater(event, ...args) {
|
|
@@ -89,12 +155,11 @@ export function createUsers(opts) {
|
|
|
89
155
|
async function recordSignIn(userid, hints = {}, info = {}) {
|
|
90
156
|
if (!isUserid(userid))
|
|
91
157
|
throw new TypeError("beagle-service: not a userid");
|
|
92
|
-
const
|
|
93
|
-
const user = await
|
|
94
|
-
const
|
|
158
|
+
const t = now();
|
|
159
|
+
const { user, events } = await mutate(userid, (prev) => {
|
|
160
|
+
const events = [];
|
|
95
161
|
if (prev?.status === "blocked")
|
|
96
|
-
return prev;
|
|
97
|
-
const t = now();
|
|
162
|
+
return { out: { user: prev, events } };
|
|
98
163
|
const next = { ...blank(userid), ...prev };
|
|
99
164
|
const first = !prev || !prev.firstSeen;
|
|
100
165
|
// A blank or malformed hint never replaces a value already held: the
|
|
@@ -113,6 +178,9 @@ export function createUsers(opts) {
|
|
|
113
178
|
}
|
|
114
179
|
if (info.door)
|
|
115
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;
|
|
116
184
|
next.firstSeen ??= iso(t);
|
|
117
185
|
if (!prev?.lastSeen || t - Date.parse(prev.lastSeen) > gapMs)
|
|
118
186
|
next.visits = (next.visits || 0) + 1;
|
|
@@ -122,13 +190,13 @@ export function createUsers(opts) {
|
|
|
122
190
|
next.status = statusFor(next);
|
|
123
191
|
if (next.status === "registered")
|
|
124
192
|
next.registeredAt ??= iso(t);
|
|
125
|
-
|
|
126
|
-
lastTouch.set(userid, t);
|
|
127
|
-
events.push(() => emitLater("sign-in", next, { first, door: next.door }));
|
|
193
|
+
events.push(() => emitLater("sign-in", next, { first, door: next.door, origin }));
|
|
128
194
|
if (next.status === "registered" && was !== "registered")
|
|
129
195
|
events.push(() => emitLater("registered", next));
|
|
130
|
-
return next;
|
|
196
|
+
return { next, out: { user: next, events } };
|
|
131
197
|
});
|
|
198
|
+
if (events.length)
|
|
199
|
+
lastTouch.set(userid, t);
|
|
132
200
|
for (const fire of events)
|
|
133
201
|
fire();
|
|
134
202
|
if (opts.importProfile && user.status !== "blocked")
|
|
@@ -139,24 +207,23 @@ export function createUsers(opts) {
|
|
|
139
207
|
const t = now();
|
|
140
208
|
if (t - (lastTouch.get(userid) ?? 0) < TOUCH_EVERY_MS)
|
|
141
209
|
return get(userid);
|
|
142
|
-
return
|
|
143
|
-
const prev = await get(userid);
|
|
210
|
+
return mutate(userid, (prev) => {
|
|
144
211
|
if (!prev || prev.status === "blocked")
|
|
145
|
-
return prev;
|
|
212
|
+
return { out: prev };
|
|
146
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 };
|
|
147
217
|
const next = { ...prev, lastSeen: iso(t) };
|
|
148
218
|
if (!prev.lastSeen || t - Date.parse(prev.lastSeen) > gapMs)
|
|
149
219
|
next.visits = (prev.visits || 0) + 1;
|
|
150
|
-
|
|
151
|
-
return next;
|
|
220
|
+
return { next, out: next };
|
|
152
221
|
});
|
|
153
222
|
}
|
|
154
223
|
async function update(userid, patch) {
|
|
155
224
|
if (patch.status === "blocked")
|
|
156
225
|
throw new TypeError("beagle-service: use users.block(userid, why) to block");
|
|
157
|
-
const
|
|
158
|
-
const user = await lock.run(userid, async () => {
|
|
159
|
-
const prev = await get(userid);
|
|
226
|
+
const { user, fire } = await mutate(userid, (prev) => {
|
|
160
227
|
if (!prev)
|
|
161
228
|
throw new Error(`beagle-service: no user ${userid}`);
|
|
162
229
|
if (prev.status === "blocked" && patch.status)
|
|
@@ -167,15 +234,13 @@ export function createUsers(opts) {
|
|
|
167
234
|
userid: prev.userid,
|
|
168
235
|
profile: patch.profile ? { ...prev.profile, ...patch.profile } : prev.profile,
|
|
169
236
|
};
|
|
170
|
-
|
|
237
|
+
const fire = next.status === "registered" && prev.status !== "registered";
|
|
238
|
+
if (fire)
|
|
171
239
|
next.registeredAt ??= iso(now());
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
await store.put(next);
|
|
175
|
-
return next;
|
|
240
|
+
return { next, out: { user: next, fire } };
|
|
176
241
|
});
|
|
177
|
-
|
|
178
|
-
|
|
242
|
+
if (fire)
|
|
243
|
+
emitLater("registered", user);
|
|
179
244
|
return user;
|
|
180
245
|
}
|
|
181
246
|
async function register(userid, submitted) {
|
|
@@ -235,37 +300,32 @@ export function createUsers(opts) {
|
|
|
235
300
|
}
|
|
236
301
|
accepted[field] = value;
|
|
237
302
|
}
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
const prev = await get(userid);
|
|
303
|
+
const { result, fire } = await mutate(userid, (prev) => {
|
|
304
|
+
const no = (result) => ({ out: { result, fire: false } });
|
|
241
305
|
if (!prev)
|
|
242
|
-
return { ok: false, error: "not-found" };
|
|
306
|
+
return no({ ok: false, error: "not-found" });
|
|
243
307
|
if (prev.status === "blocked")
|
|
244
|
-
return { ok: false, error: "blocked" };
|
|
308
|
+
return no({ ok: false, error: "blocked" });
|
|
245
309
|
const profile = { ...prev.profile, ...accepted };
|
|
246
310
|
for (const f of missingOf(profile))
|
|
247
311
|
errors[f] ??= "required";
|
|
248
312
|
if (Object.keys(errors).length)
|
|
249
|
-
return { ok: false, error: "invalid-fields", fields: errors };
|
|
313
|
+
return no({ ok: false, error: "invalid-fields", fields: errors });
|
|
250
314
|
const next = { ...prev, profile, status: "registered" };
|
|
251
315
|
next.registeredAt ??= iso(now());
|
|
252
|
-
|
|
253
|
-
if (prev.status !== "registered")
|
|
254
|
-
events.push(() => emitLater("registered", next));
|
|
255
|
-
return { ok: true, user: next };
|
|
316
|
+
return { next, out: { result: { ok: true, user: next }, fire: prev.status !== "registered" } };
|
|
256
317
|
});
|
|
257
|
-
|
|
258
|
-
|
|
318
|
+
if (fire && result.ok)
|
|
319
|
+
emitLater("registered", result.user);
|
|
259
320
|
return result;
|
|
260
321
|
}
|
|
261
322
|
async function block(userid, why = "") {
|
|
262
323
|
if (!isUserid(userid))
|
|
263
324
|
throw new TypeError("beagle-service: not a userid");
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const prev = (await get(userid)) ?? blank(userid);
|
|
325
|
+
const { user, fire } = await mutate(userid, (found) => {
|
|
326
|
+
const prev = found ?? blank(userid);
|
|
267
327
|
if (prev.status === "blocked")
|
|
268
|
-
return prev;
|
|
328
|
+
return { out: { user: prev, fire: false } };
|
|
269
329
|
const t = now();
|
|
270
330
|
const next = {
|
|
271
331
|
...prev,
|
|
@@ -274,36 +334,26 @@ export function createUsers(opts) {
|
|
|
274
334
|
// Sessions issued up to now are dead for good, also after an unblock.
|
|
275
335
|
sessionsValidFrom: t + 1,
|
|
276
336
|
};
|
|
277
|
-
|
|
278
|
-
fire = true;
|
|
279
|
-
return next;
|
|
337
|
+
return { next, out: { user: next, fire: true } };
|
|
280
338
|
});
|
|
281
339
|
if (fire)
|
|
282
340
|
emitLater("blocked", user);
|
|
283
341
|
return user;
|
|
284
342
|
}
|
|
285
343
|
async function unblock(userid) {
|
|
286
|
-
|
|
287
|
-
const user = await lock.run(userid, async () => {
|
|
288
|
-
const prev = await get(userid);
|
|
344
|
+
const { user, fire } = await mutate(userid, (prev) => {
|
|
289
345
|
if (!prev || prev.status !== "blocked")
|
|
290
|
-
return prev;
|
|
346
|
+
return { out: { user: prev, fire: false } };
|
|
291
347
|
const next = { ...prev, blocked: null, status: "visitor" };
|
|
292
348
|
next.status = prev.blocked?.was === "registered" || (prev.firstSeen && statusFor(next) === "registered") ? "registered" : "visitor";
|
|
293
|
-
|
|
294
|
-
fire = true;
|
|
295
|
-
return next;
|
|
349
|
+
return { next, out: { user: next, fire: true } };
|
|
296
350
|
});
|
|
297
351
|
if (fire && user)
|
|
298
352
|
emitLater("unblocked", user);
|
|
299
353
|
return user;
|
|
300
354
|
}
|
|
301
355
|
async function revokeSessions(userid) {
|
|
302
|
-
await
|
|
303
|
-
const prev = await get(userid);
|
|
304
|
-
if (prev)
|
|
305
|
-
await store.put({ ...prev, sessionsValidFrom: now() + 1 });
|
|
306
|
-
});
|
|
356
|
+
await mutate(userid, (prev) => (prev ? { next: { ...prev, sessionsValidFrom: now() + 1 }, out: undefined } : { out: undefined }));
|
|
307
357
|
}
|
|
308
358
|
// ── beagles.eth profile import ────────────────────────────────────────────
|
|
309
359
|
const gateway = (typeof opts.importProfile === "object" && opts.importProfile.gateway) || DEFAULT_PROFILE_GATEWAY;
|
package/dist/util.d.ts
CHANGED
|
@@ -5,6 +5,26 @@ export declare function atomicWriteJson(file: string, value: unknown, mode?: num
|
|
|
5
5
|
* does not parse throws, because treating it as empty would let the next
|
|
6
6
|
* write replace real data with nothing. */
|
|
7
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>;
|
|
8
28
|
/** A userid is the base58 of a 32-byte public key. */
|
|
9
29
|
export declare function isUserid(v: unknown): v is string;
|
|
10
30
|
/** The address when it is a well-formed Carrier address of `userid`, else null.
|
|
@@ -21,8 +41,8 @@ export declare class Hub<Events extends Record<string, unknown[]>> {
|
|
|
21
41
|
/** Runs listeners in registration order and waits for each. */
|
|
22
42
|
emit<K extends keyof Events>(event: K, ...args: Events[K]): Promise<void>;
|
|
23
43
|
}
|
|
24
|
-
/** Run
|
|
44
|
+
/** Run jobs for the same key one after another. */
|
|
25
45
|
export declare class KeyedLock {
|
|
26
46
|
#private;
|
|
27
|
-
run<T>(key: string, job: () =>
|
|
47
|
+
run<T>(key: string, job: () => T | PromiseLike<T>): Promise<T>;
|
|
28
48
|
}
|