@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.
Files changed (34) hide show
  1. package/README.md +289 -28
  2. package/dist/auth.d.ts +10 -2
  3. package/dist/auth.js +119 -36
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +163 -0
  6. package/dist/commonjs.cjs +49 -0
  7. package/dist/commonjs.d.cts +11 -0
  8. package/dist/index.d.ts +16 -2
  9. package/dist/index.js +127 -5
  10. package/dist/routes.d.ts +9 -1
  11. package/dist/routes.js +157 -24
  12. package/dist/stores.d.ts +12 -4
  13. package/dist/stores.js +60 -37
  14. package/dist/types.d.ts +157 -12
  15. package/dist/users.js +108 -58
  16. package/dist/util.d.ts +22 -2
  17. package/dist/util.js +103 -2
  18. package/examples/next-pages-cjs/README.md +30 -0
  19. package/examples/next-pages-cjs/instrumentation.js +16 -0
  20. package/examples/next-pages-cjs/lib/beagle.js +23 -0
  21. package/examples/next-pages-cjs/lib/peer-for-browser.mjs +2 -0
  22. package/examples/next-pages-cjs/next.config.js +11 -0
  23. package/examples/next-pages-cjs/package.json +19 -0
  24. package/examples/next-pages-cjs/pages/api/auth/[...nextauth].js +17 -0
  25. package/examples/next-pages-cjs/pages/api/auth/launch.js +3 -0
  26. package/examples/next-pages-cjs/pages/api/auth/logout.js +3 -0
  27. package/examples/next-pages-cjs/pages/api/auth/me.js +3 -0
  28. package/examples/next-pages-cjs/pages/api/auth/nonce.js +3 -0
  29. package/examples/next-pages-cjs/pages/api/auth/register.js +3 -0
  30. package/examples/next-pages-cjs/pages/api/auth/sign-in.js +3 -0
  31. package/examples/next-pages-cjs/pages/api/notes.js +16 -0
  32. package/examples/next-pages-cjs/pages/index.js +52 -0
  33. package/examples/sign-in-from-script.mjs +37 -0
  34. 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. `put` receives the whole record and must store all of it,
51
- * including fields it does not recognise. */
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
- /** Check a `decent-auth` assertion: origin, nonce (consumed either way), signature. */
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
- authenticate(req: HeadersCarrier, opts?: {
243
- allowUnregistered?: boolean;
244
- touch?: boolean;
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
- origins: string[];
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)) ?? null;
139
+ const get = async (userid) => normalise(await store.get(userid), userid);
79
140
  async function list(filter) {
80
- const all = await store.list();
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 events = [];
93
- const user = await lock.run(userid, async () => {
94
- const prev = await get(userid);
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
- await store.put(next);
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 lock.run(userid, async () => {
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
- await store.put(next);
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 events = [];
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
- if (next.status === "registered" && prev.status !== "registered") {
237
+ const fire = next.status === "registered" && prev.status !== "registered";
238
+ if (fire)
171
239
  next.registeredAt ??= iso(now());
172
- events.push(() => emitLater("registered", next));
173
- }
174
- await store.put(next);
175
- return next;
240
+ return { next, out: { user: next, fire } };
176
241
  });
177
- for (const fire of events)
178
- fire();
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 events = [];
239
- const result = await lock.run(userid, async () => {
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
- await store.put(next);
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
- for (const fire of events)
258
- fire();
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
- let fire = false;
265
- const user = await lock.run(userid, async () => {
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
- await store.put(next);
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
- let fire = false;
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
- await store.put(next);
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 lock.run(userid, async () => {
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 async jobs for the same key one after another. */
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: () => Promise<T>): Promise<T>;
47
+ run<T>(key: string, job: () => T | PromiseLike<T>): Promise<T>;
28
48
  }