@benmore/bm 0.0.0-stage → 2.7.309

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 (4) hide show
  1. package/README.md +19 -2
  2. package/index.d.ts +392 -0
  3. package/index.js +2203 -0
  4. package/package.json +12 -4
package/index.js ADDED
@@ -0,0 +1,2203 @@
1
+ // bm - Benmore frontend SDK. Auto-served at /_internal/bm.js, resolved
2
+ // in TSX/JS via the `import 'bm'` bare specifier (HTML pages set up an
3
+ // import map pointing to this file).
4
+ //
5
+ // Surface intentionally narrow. Does ONE thing: wraps the backend so
6
+ // the agent doesn't reinvent fetch/CSRF/SSE/WS on every app.
7
+ // Rendering / DOM / state are NOT in scope - bring your own.
8
+ //
9
+ // Hard cap: <5KB gzipped. Anything that creeps past goes into an opt-in
10
+ // /_internal/bm-<thing>.js side-module instead.
11
+ //
12
+ // =================================================================
13
+ // 1. fetch wrapper - auto CSRF, auto JSON, uniform { ok, status, data }
14
+ // =================================================================
15
+
16
+ const CSRF_META = () => document.querySelector('meta[name="csrf-token"]')?.content || '';
17
+
18
+ async function request(method, path, body, options) {
19
+ const headers = new Headers({ Accept: 'application/json' });
20
+ if (options?.headers) {
21
+ new Headers(options.headers).forEach((value, name) => headers.set(name, value));
22
+ }
23
+ const init = { method, credentials: 'same-origin', headers };
24
+ if (options?.signal !== undefined) init.signal = options.signal;
25
+ if (body !== undefined && body !== null) {
26
+ headers.set('Content-Type', 'application/json');
27
+ init.body = JSON.stringify(body);
28
+ }
29
+ if (method !== 'GET' && method !== 'HEAD') {
30
+ headers.set('X-CSRF-Token', CSRF_META());
31
+ }
32
+ const res = await fetch(path, init);
33
+ if (res.status === 204) {
34
+ observeSessionResponse(method, path, true, null);
35
+ return { ok: true, status: 204, data: null };
36
+ }
37
+ const text = await res.text();
38
+ const ct = (res.headers.get('content-type') || '').toLowerCase();
39
+ let data = null;
40
+ if (text) {
41
+ if (ct.includes('application/json')) {
42
+ try { data = JSON.parse(text); } catch { data = { error: text }; }
43
+ } else {
44
+ data = { error: text };
45
+ }
46
+ }
47
+ observeSessionResponse(method, path, res.ok, data);
48
+ return { ok: res.ok, status: res.status, data };
49
+ }
50
+
51
+ // Convenience: unwrap or throw. Most agent code wants this shape:
52
+ // const message = await bm.api.post('/api/messages', {...});
53
+ async function unwrap(res) {
54
+ if (!res.ok) {
55
+ const msg = res.data?.error || `HTTP ${res.status}`;
56
+ const err = new Error(msg);
57
+ err.status = res.status;
58
+ err.body = res.data;
59
+ throw err;
60
+ }
61
+ return res.data;
62
+ }
63
+
64
+ export const api = {
65
+ // Raw forms - return { ok, status, data }, never throw on HTTP errors.
66
+ raw: {
67
+ get: (p, o) => request('GET', p, undefined, o),
68
+ post: (p, b, o) => request('POST', p, b, o),
69
+ patch: (p, b, o) => request('PATCH', p, b, o),
70
+ delete: (p, o) => request('DELETE', p, undefined, o),
71
+ },
72
+ // Convenience forms - throw on !ok, return data directly.
73
+ get: async (p, o) => unwrap(await request('GET', p, undefined, o)),
74
+ post: async (p, b, o) => unwrap(await request('POST', p, b, o)),
75
+ patch: async (p, b, o) => unwrap(await request('PATCH', p, b, o)),
76
+ delete: async (p, o) => unwrap(await request('DELETE', p, undefined, o)),
77
+
78
+ /**
79
+ * Optimistic mutation + reconcile. Applies the change to local
80
+ * state IMMEDIATELY (so the UI is instant), races a network
81
+ * PATCH/POST/DELETE, and reconciles when the server responds.
82
+ *
83
+ * const handle = bm.api.optimistic({
84
+ * apply: () => state.markRead(channelId), // local mutation
85
+ * revert: (snapshot) => state.restore(snapshot), // on failure
86
+ * snapshot: () => state.snapshotChannel(channelId), // for revert
87
+ * request: () => bm.api.patch('/api/memberships/' + id, { last_read_at: new Date().toISOString() }),
88
+ * });
89
+ * // optional: await handle for the server response (or .catch for errors)
90
+ *
91
+ * Returns a thenable that resolves with the server response (or
92
+ * rejects with the error after the local state has been reverted).
93
+ *
94
+ * The "newer-wins" guard an earlier app build built for last_read_at maps
95
+ * directly onto `snapshot()` / `revert()` here - keep a snapshot of
96
+ * the field before the optimistic apply, restore it on rejection
97
+ * BUT only if no NEWER optimistic apply happened in between (track
98
+ * a generation counter at the caller side or via the snapshot).
99
+ */
100
+ optimistic({ apply, revert, snapshot, request }) {
101
+ if (typeof apply !== 'function' || typeof request !== 'function') {
102
+ throw new Error('bm.api.optimistic({ apply, request, ... }) requires apply + request functions');
103
+ }
104
+ let snap;
105
+ try {
106
+ if (typeof snapshot === 'function') snap = snapshot();
107
+ apply();
108
+ } catch (err) {
109
+ // apply() itself blew up - bail before sending the request.
110
+ return Promise.reject(err);
111
+ }
112
+ return Promise.resolve()
113
+ .then(() => request())
114
+ .catch(err => {
115
+ if (typeof revert === 'function') {
116
+ try { revert(snap); } catch (rerr) {
117
+ console.warn('[bm.api.optimistic] revert threw:', rerr);
118
+ }
119
+ }
120
+ throw err;
121
+ });
122
+ },
123
+ };
124
+
125
+ // =================================================================
126
+ // 2. Auth - current user, sign out
127
+ // =================================================================
128
+
129
+ let _meCache = undefined; // distinguish "never fetched" (undefined) from "fetched, no session" (null)
130
+ let _authRevision = 0;
131
+ const _sessionListeners = new Set();
132
+
133
+ function resetSessionState(profile = undefined) {
134
+ _authRevision++;
135
+ _meCache = profile;
136
+ resetSessionQueries();
137
+ const bus = _liveConns.get('__bus__');
138
+ if (bus) bus.reconnect(profile !== null);
139
+ _sessionListeners.forEach(reset => reset(profile !== null));
140
+ }
141
+
142
+ // These native endpoints change the cookie session or its active tenant. A
143
+ // successful password/passkey proof that still needs OTP/MFA is not a session.
144
+ // Custom auth routes can explicitly finish with auth.refreshMe().
145
+ function observeSessionResponse(method, path, ok, data) {
146
+ if (!ok || typeof path !== 'string') return;
147
+ const route = path.split(/[?#]/, 1)[0];
148
+ if (route === '/api/_auth/act_as' && (method === 'POST' || method === 'DELETE')) {
149
+ resetSessionState();
150
+ return;
151
+ }
152
+ if (method !== 'POST') return;
153
+ if (route === '/logout') resetSessionState(null);
154
+ else if (['/login', '/signup', '/api/_auth/passkeys/login'].includes(route)) {
155
+ if (data?.user?.id != null) resetSessionState();
156
+ } else if (route === '/api/_auth/mfa/verify') {
157
+ if (data?.token && data?.user_id != null) resetSessionState();
158
+ } else if (['/api/_switch_group', '/api/_groups/create', '/api/_auth/impersonate', '/api/_auth/end-impersonation', '/verify-mfa', '/verify-otp'].includes(route)) {
159
+ if (!data?.otp_required && !data?.mfa_required && !data?.mfa_setup_required) resetSessionState();
160
+ }
161
+ }
162
+
163
+ async function me() {
164
+ if (_meCache !== undefined) return _meCache;
165
+ const revision = _authRevision;
166
+ const res = await request('GET', '/api/_auth/profile');
167
+ if (revision !== _authRevision) return me();
168
+ _meCache = res.ok ? res.data : null;
169
+ return _meCache;
170
+ }
171
+
172
+ async function signOut() {
173
+ // Framework's logout endpoint is registered at app.Paths.Logout (default /logout).
174
+ await unwrap(await request('POST', '/logout'));
175
+ }
176
+
177
+ // Force-refresh the profile cache (useful right after signup).
178
+ async function refreshMe() {
179
+ resetSessionState();
180
+ return me();
181
+ }
182
+
183
+ // signIn - POST /login. `identifier` is the email/phone/username.
184
+ // Server side accepts both form-encoded and JSON bodies (v2.7.26+).
185
+ // Returns the user object on success; throws { status, body } on failure.
186
+ async function signIn(identifier, password) {
187
+ const res = await request('POST', '/login', { email: identifier, password });
188
+ if (!res.ok) {
189
+ const err = new Error(res.data?.error || 'Invalid credentials');
190
+ err.status = res.status;
191
+ err.body = res.data;
192
+ throw err;
193
+ }
194
+ return res.data?.user || null;
195
+ }
196
+
197
+ // signUp - POST /signup. Pass any extra signup_fields (configured in
198
+ // app.yaml's `auth.signup_fields`) as additional keys on the object:
199
+ // await bm.auth.signUp({ email, password, first_name: 'Ada' })
200
+ async function signUp(fields) {
201
+ const res = await request('POST', '/signup', fields);
202
+ if (!res.ok) {
203
+ const err = new Error(res.data?.error || 'Sign-up failed');
204
+ err.status = res.status;
205
+ err.body = res.data;
206
+ throw err;
207
+ }
208
+ return res.data?.user || null;
209
+ }
210
+
211
+ // =================================================================
212
+ // Passkeys - sign in with Face ID, Touch ID, Windows Hello or a phone.
213
+ // =================================================================
214
+ // await bm.auth.passkey.register('MacBook', { password }) // signed in: add a passkey
215
+ // await bm.auth.passkey.signIn() // signed out: sign in
216
+ // await bm.auth.passkey.list(); await bm.auth.passkey.remove(id)
217
+ // bm.auth.passkey.supported() is false where the browser has no WebAuthn.
218
+
219
+ function _b64uToBuf(s) {
220
+ const pad = '='.repeat((4 - (s.length % 4)) % 4);
221
+ const bin = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
222
+ const out = new Uint8Array(bin.length);
223
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
224
+ return out.buffer;
225
+ }
226
+
227
+ function _bufToB64u(buf) {
228
+ const bytes = new Uint8Array(buf);
229
+ let bin = '';
230
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
231
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
232
+ }
233
+
234
+ async function _passkeyCall(method, path, body, fallback) {
235
+ const res = await request(method, path, body);
236
+ if (!res.ok) {
237
+ const err = new Error(res.data?.error || fallback);
238
+ err.status = res.status;
239
+ err.body = res.data;
240
+ throw err;
241
+ }
242
+ return res.data;
243
+ }
244
+
245
+ const passkey = {
246
+ supported() {
247
+ return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials;
248
+ },
249
+ async register(name, proof) {
250
+ // proof: { password } or { mfa_code } - adding a passkey re-confirms the account.
251
+ // Optional within 10 minutes of signing in on this browser (e.g. with Google).
252
+ const o = await _passkeyCall('POST', '/api/_auth/passkeys/register/options', proof || {}, 'Could not start passkey setup');
253
+ const cred = await navigator.credentials.create({ publicKey: {
254
+ ...o,
255
+ challenge: _b64uToBuf(o.challenge),
256
+ user: { ...o.user, id: _b64uToBuf(o.user.id) },
257
+ excludeCredentials: (o.excludeCredentials || []).map((c) => ({ ...c, id: _b64uToBuf(c.id) })),
258
+ } });
259
+ const r = cred.response;
260
+ return (await _passkeyCall('POST', '/api/_auth/passkeys/register', {
261
+ id: cred.id, rawId: _bufToB64u(cred.rawId), type: cred.type, name: name || '',
262
+ response: {
263
+ clientDataJSON: _bufToB64u(r.clientDataJSON),
264
+ attestationObject: _bufToB64u(r.attestationObject),
265
+ },
266
+ }, 'Could not save the passkey')).passkey;
267
+ },
268
+ async signIn() {
269
+ const o = await _passkeyCall('POST', '/api/_auth/passkeys/login/options', {}, 'Could not start passkey sign-in');
270
+ const cred = await navigator.credentials.get({ publicKey: {
271
+ ...o,
272
+ challenge: _b64uToBuf(o.challenge),
273
+ allowCredentials: (o.allowCredentials || []).map((c) => ({ ...c, id: _b64uToBuf(c.id) })),
274
+ } });
275
+ const r = cred.response;
276
+ const data = await _passkeyCall('POST', '/api/_auth/passkeys/login', {
277
+ id: cred.id, rawId: _bufToB64u(cred.rawId), type: cred.type,
278
+ response: {
279
+ clientDataJSON: _bufToB64u(r.clientDataJSON),
280
+ authenticatorData: _bufToB64u(r.authenticatorData),
281
+ signature: _bufToB64u(r.signature),
282
+ userHandle: r.userHandle ? _bufToB64u(r.userHandle) : '',
283
+ },
284
+ }, 'Passkey sign-in failed');
285
+ return data.user || null;
286
+ },
287
+ async list() {
288
+ return (await _passkeyCall('GET', '/api/_auth/passkeys', undefined, 'Could not list passkeys')).passkeys || [];
289
+ },
290
+ async remove(id) {
291
+ await _passkeyCall('DELETE', '/api/_auth/passkeys/' + _seg(id), undefined, 'Could not remove the passkey');
292
+ },
293
+ };
294
+
295
+ export const auth = { me, signOut, refreshMe, signIn, signUp, passkey };
296
+
297
+ // =================================================================
298
+ // 3. Users - public user-lookup (v2.7.11+, default-private)
299
+ // =================================================================
300
+
301
+ export const users = {
302
+ async list(opts = {}) {
303
+ const qs = new URLSearchParams();
304
+ if (opts.limit) qs.set('limit', String(opts.limit));
305
+ if (opts.offset) qs.set('offset', String(opts.offset));
306
+ const path = '/api/_auth/users' + (qs.toString() ? '?' + qs : '');
307
+ const r = await api.get(path);
308
+ return r.users || [];
309
+ },
310
+ async get(id) {
311
+ return api.get(`/api/_auth/users/${_rowRef(id)}`);
312
+ },
313
+ };
314
+
315
+ // =================================================================
316
+ // 4. Tables - CRUD for any model in schema.prisma
317
+ // Usage: bm.table('messages').list() / .get(id) / .create({...})
318
+ // .update(id, {...}) / .delete(id)
319
+ //
320
+ // INTERCEPTORS (v2.7.65+): register transforms that mutate every
321
+ // read/write opts before the request fires. Use case: multi-tenant
322
+ // apps that need to inject `where[group_id]=N` on every query.
323
+ //
324
+ // bm.table.before('read', opts => ({
325
+ // ...opts,
326
+ // where: { ...opts.where, group_id: currentGroupId() }
327
+ // }));
328
+ //
329
+ // Hooks: 'read' fires for list/get/count; 'write' fires for
330
+ // create/update/delete. The hook receives (opts, ctx) where
331
+ // ctx = { table, op } so an interceptor can scope by table.
332
+ // Multiple hooks run in registration order, output of one feeds
333
+ // the next. Pre-2.7.65, multi-tenant apps had to wrap api.get in
334
+ // their own apiList() helper and bypass bm.table entirely; this
335
+ // hook lets them stay on the typed client.
336
+ // =================================================================
337
+
338
+ const _tableHooks = { read: [], write: [] };
339
+ const _runHooks = (kind, opts, ctx) => {
340
+ let cur = opts || {};
341
+ for (const fn of _tableHooks[kind]) {
342
+ try { cur = fn(cur, ctx) ?? cur; }
343
+ catch (e) { console.warn('bm.table interceptor threw:', e); }
344
+ }
345
+ return cur;
346
+ };
347
+
348
+ // A row id goes into the path as written (apps pass `id + '?include=...'`),
349
+ // so an id taken from a URL param such as `../_auth/tokens/7` aimed the
350
+ // CSRF-bearing PATCH/DELETE/POST at another endpoint once the browser
351
+ // resolved the dot segments. Refuse dot segments and backslashes, judged
352
+ // after removing tab, LF and CR the way the URL parser does (`.\t.` is `..`).
353
+ function _rowRef(id) {
354
+ const s = String(id);
355
+ const path = s.split(/[?#]/, 1)[0].replace(/[\t\n\r]/g, '');
356
+ if (path.includes('\\') || /(^|\/)(\.|%2e){1,2}(\/|$)/i.test(path)) {
357
+ throw new Error('bm.table: invalid row id ' + JSON.stringify(s));
358
+ }
359
+ return s;
360
+ }
361
+
362
+ // One encoded path segment. encodeURIComponent leaves '.' alone, so an id of
363
+ // '..' still removed the segment before it (DELETE /api/docs/../permissions/x
364
+ // is DELETE /api/permissions/x).
365
+ function _seg(v) {
366
+ const e = encodeURIComponent(String(v));
367
+ if (e === '.' || e === '..') throw new Error('bm: invalid path segment ' + JSON.stringify(String(v)));
368
+ return e;
369
+ }
370
+
371
+ export function table(name) {
372
+ if (!name || typeof name !== 'string') {
373
+ throw new Error('bm.table(name) requires a string table name');
374
+ }
375
+ // Trim leading /api/ if the agent pasted a path; trim trailing slash.
376
+ const t = name.replace(/^\/api\//, '').replace(/\/+$/, '');
377
+ const base = `/api/${t}`;
378
+ return {
379
+ name: t,
380
+ async list(opts = {}) {
381
+ opts = _runHooks('read', opts, { table: t, op: 'list' });
382
+ const qs = new URLSearchParams();
383
+ if (opts.limit) qs.set('limit', String(opts.limit));
384
+ if (opts.offset) qs.set('offset', String(opts.offset));
385
+ if (opts.cursor != null) qs.set('cursor', String(opts.cursor));
386
+ if (opts.q) qs.set('q', String(opts.q));
387
+ if (opts.sort) qs.set('sort', String(opts.sort));
388
+ if (opts.order) qs.set('order', String(opts.order));
389
+ if (opts.where) {
390
+ for (const [k, v] of Object.entries(opts.where)) qs.set(k, String(v));
391
+ }
392
+ const path = base + (qs.toString() ? '?' + qs : '');
393
+ const r = await api.get(path);
394
+ // Offset lists are bare arrays; cursor/page lists use { data: [...] }.
395
+ // Retain { rows: [...] } compatibility for existing table adapters.
396
+ return Array.isArray(r) ? r : (r.rows || r.data || []);
397
+ },
398
+ get(id) {
399
+ _runHooks('read', { id }, { table: t, op: 'get' });
400
+ return api.get(`${base}/${_rowRef(id)}`);
401
+ },
402
+ create(body) {
403
+ const opts = _runHooks('write', { body }, { table: t, op: 'create' });
404
+ return api.post(base, opts.body ?? body);
405
+ },
406
+ update(id, body) {
407
+ const opts = _runHooks('write', { id, body }, { table: t, op: 'update' });
408
+ return api.patch(`${base}/${_rowRef(opts.id ?? id)}`, opts.body ?? body);
409
+ },
410
+ delete(id) {
411
+ _runHooks('write', { id }, { table: t, op: 'delete' });
412
+ return api.delete(`${base}/${_rowRef(id)}`);
413
+ },
414
+ // bm.table('x').count({ where, q }) - TRUE row count, no LIMIT cap.
415
+ //
416
+ // Auto-CRUD list defaults to a 50-row page cap (a real app
417
+ // session: "50 records showing but actual count is 693" surfaced
418
+ // three times before the agent hand-rolled /api/reports/stats). This
419
+ // hits /api/<table>?count=true which short-circuits the row fetch
420
+ // server-side and returns just { count: N }. WHERE filters are
421
+ // honored (`where`, `q`); LIMIT / page caps are not.
422
+ async count(opts = {}) {
423
+ opts = _runHooks('read', opts, { table: t, op: 'count' });
424
+ const qs = new URLSearchParams({ count: '1' });
425
+ if (opts.q) qs.set('q', String(opts.q));
426
+ if (opts.where) {
427
+ for (const [k, v] of Object.entries(opts.where)) qs.set(k, String(v));
428
+ }
429
+ const r = await api.get(`${base}?${qs}`);
430
+ return (r && typeof r.count === 'number') ? r.count : 0;
431
+ },
432
+ // Feature methods - TS gates them at compile time per TableFeatures.
433
+ // At runtime they always exist; the server returns 4xx when the
434
+ // table doesn't actually support the operation.
435
+ restore: (id) => api.post(`${base}/${_rowRef(id)}/restore`),
436
+ versions: (id) => api.get(`${base}/${_rowRef(id)}/versions`),
437
+ revertTo: (id, version) => api.post(`${base}/${_rowRef(id)}/revert/${_rowRef(version)}`),
438
+ };
439
+ }
440
+
441
+ // Interceptor surface - attached to the table function itself so the
442
+ // call site reads `bm.table.before('read', fn)`. Two hook points:
443
+ //
444
+ // 'read' fires for list / get / count - receives (opts, ctx)
445
+ // 'write' fires for create / update / delete - receives (opts, ctx)
446
+ //
447
+ // Hooks run in registration order; each hook's return value feeds the
448
+ // next. Returning undefined keeps the previous opts (so `() => {}` is
449
+ // a no-op, not a wipe). Throwing logs a warning + continues - never
450
+ // aborts the request, never silently drops data.
451
+ table.before = function (kind, fn) {
452
+ if (kind !== 'read' && kind !== 'write') {
453
+ throw new Error("bm.table.before(kind, fn): kind must be 'read' or 'write'");
454
+ }
455
+ if (typeof fn !== 'function') {
456
+ throw new Error('bm.table.before(kind, fn): fn must be a function');
457
+ }
458
+ _tableHooks[kind].push(fn);
459
+ // Return an unsubscribe so a feature module can undo its own
460
+ // interceptor on teardown (test cleanup, dynamic provider swap).
461
+ return () => {
462
+ const i = _tableHooks[kind].indexOf(fn);
463
+ if (i >= 0) _tableHooks[kind].splice(i, 1);
464
+ };
465
+ };
466
+ table.clearHooks = function () {
467
+ _tableHooks.read.length = 0;
468
+ _tableHooks.write.length = 0;
469
+ };
470
+
471
+ // =================================================================
472
+ // 5. Live - SSE invalidation, NAMED event-aware
473
+ //
474
+ // The framework emits `event: change\ndata: {...}` - a NAMED event.
475
+ // EventSource.onmessage only catches DEFAULT events, so wiring onmessage
476
+ // alone silently drops every framework broadcast. bm.live() wires BOTH
477
+ // listeners so this trap is impossible.
478
+ //
479
+ // Usage:
480
+ // const stop = bm.live('messages', ev => render(...));
481
+ // stop(); // unsubscribe + close connection
482
+ // const stop2 = bm.live('*', ev => log(ev)); // every table
483
+ // =================================================================
484
+
485
+ const _liveConns = new Map(); // share one EventSource per page
486
+
487
+ function liveBus() {
488
+ if (_liveConns.has('__bus__')) {
489
+ const bus = _liveConns.get('__bus__');
490
+ bus.ensure();
491
+ return bus;
492
+ }
493
+ const subs = new Set();
494
+ let es = null;
495
+ const reconnect = (connect = true) => {
496
+ if (es) es.close();
497
+ es = null;
498
+ if (!connect) return;
499
+ const current = es = new EventSource('/sse/events');
500
+ const fire = (raw) => {
501
+ if (es !== current) return; // queued event from a previous session
502
+ let data;
503
+ try { data = JSON.parse(raw); } catch { return; }
504
+ subs.forEach(fn => { try { fn(data); } catch (e) { console.error(e); } });
505
+ };
506
+ current.addEventListener('change', e => fire(e.data));
507
+ current.onmessage = e => fire(e.data);
508
+ current.onerror = () => { /* EventSource auto-reconnects */ };
509
+ };
510
+ const bus = { subs, reconnect, ensure: () => { if (!es) reconnect(); }, close: () => reconnect(false) };
511
+ reconnect();
512
+ _liveConns.set('__bus__', bus);
513
+ return bus;
514
+ }
515
+
516
+ export function live(tableOrStar, cb) {
517
+ if (typeof cb !== 'function') {
518
+ throw new Error('bm.live(table, callback) requires a function');
519
+ }
520
+ const bus = liveBus();
521
+ const handler = (data) => {
522
+ // table === '' means "generic refresh hint for an owner-scoped table
523
+ // you can't see the row of." `*` subscribers always get it.
524
+ if (tableOrStar === '*' ||
525
+ data.table === tableOrStar ||
526
+ data.action === 'refresh') {
527
+ cb(data);
528
+ }
529
+ };
530
+ bus.subs.add(handler);
531
+ return () => bus.subs.delete(handler);
532
+ }
533
+
534
+ // bm.live.scoped(table, fetchAndRender, opts) - `live()` with the
535
+ // production-grade plumbing baked in:
536
+ //
537
+ // - **Serialized callbacks**: one fetchAndRender runs at a time per
538
+ // subscription. Events during a pending callback queue one trailing
539
+ // refresh, so an older callback cannot overwrite a newer callback's
540
+ // render. The returned promise must cover all fetch and render work.
541
+ //
542
+ // - **Trailing-edge debounce**: bursts of events coalesce into a
543
+ // single fetch fired `debounce` ms after the LAST event. Avoids
544
+ // N round-trips for N events; the framework's SSE fan-out is
545
+ // intentionally chatty (every CRUD mutation emits) so debouncing
546
+ // downstream is the right pattern.
547
+ //
548
+ // `fetchAndRender` is called with no args; it should read whatever
549
+ // data it needs (auto-CRUD list, custom flow, whatever) and update
550
+ // the DOM. Its returned promise is awaited; other values are ignored.
551
+ // Errors are caught + logged. Detached work and separate subscriptions
552
+ // are not serialized. A callback that never settles blocks its queue.
553
+ //
554
+ // Returns an unsubscribe function. Calling it stops the SSE
555
+ // subscription AND cancels pending/queued fetches. An already-running
556
+ // callback cannot be cancelled; callers must guard its teardown effects.
557
+ //
558
+ // const unsub = bm.live.scoped('messages', refreshChat, { debounce: 60 });
559
+ // // ... later
560
+ // unsub();
561
+ live.scoped = function liveScoped(tableOrStar, fetchAndRender, opts) {
562
+ if (typeof fetchAndRender !== 'function') {
563
+ throw new Error('bm.live.scoped(table, fetchAndRender, opts) requires a function');
564
+ }
565
+ const debounceMs = (opts && typeof opts.debounce === 'number') ? opts.debounce : 60;
566
+ let running = false, queued = false, stopped = false;
567
+ let pendingTimer = null;
568
+
569
+ const runFetch = async () => {
570
+ pendingTimer = null;
571
+ if (stopped) return;
572
+ if (running) { queued = true; return; }
573
+ running = true;
574
+ try {
575
+ const result = fetchAndRender();
576
+ if (result && typeof result.then === 'function') {
577
+ await result;
578
+ }
579
+ } catch (err) {
580
+ console.warn('[bm.live.scoped] fetchAndRender threw:', err);
581
+ } finally {
582
+ running = false;
583
+ if (queued && !stopped) {
584
+ queued = false;
585
+ schedule();
586
+ }
587
+ }
588
+ };
589
+
590
+ const schedule = () => {
591
+ if (stopped) return;
592
+ if (pendingTimer != null) clearTimeout(pendingTimer);
593
+ pendingTimer = setTimeout(runFetch, debounceMs);
594
+ };
595
+
596
+ // Prime through the debounce window so the caller doesn't need to
597
+ // double-call: `bm.live.scoped('x', refresh); refresh();`. Just
598
+ // pass the renderer and we'll prime the view on mount.
599
+ schedule();
600
+
601
+ const unsub = live(tableOrStar, () => schedule());
602
+
603
+ // Visibility hook: when the tab returns to foreground, fire a
604
+ // fresh fetch - many SSE events get dropped by browsers that
605
+ // sleep the page while it's backgrounded. An earlier app build built
606
+ // this manually; bake in.
607
+ let visibilityHandler = null;
608
+ if (typeof document !== 'undefined') {
609
+ visibilityHandler = () => {
610
+ if (!document.hidden) schedule();
611
+ };
612
+ document.addEventListener('visibilitychange', visibilityHandler);
613
+ }
614
+
615
+ return () => {
616
+ stopped = true;
617
+ queued = false;
618
+ unsub();
619
+ if (pendingTimer != null) {
620
+ clearTimeout(pendingTimer);
621
+ pendingTimer = null;
622
+ }
623
+ if (visibilityHandler && typeof document !== 'undefined') {
624
+ document.removeEventListener('visibilitychange', visibilityHandler);
625
+ }
626
+ };
627
+ };
628
+
629
+ // =================================================================
630
+ // 6. Rooms - WebSocket room broadcast (chat, presence, WebRTC signaling)
631
+ //
632
+ // As of v2.7.10 broadcasts deliver cross-user in same-app scenarios.
633
+ // Multi-tenant isolation (groups: config) still applies.
634
+ //
635
+ // Usage:
636
+ // const room = bm.room('lobby');
637
+ // room.on('hello', (payload, from) => greet(from));
638
+ // room.onAny((payload, from) => log(payload));
639
+ // room.send({ kind: 'hello' });
640
+ // room.leave();
641
+ // =================================================================
642
+
643
+ export function room(name) {
644
+ if (!name || typeof name !== 'string') {
645
+ throw new Error('bm.room(name) requires a string room name');
646
+ }
647
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
648
+ const ws = new WebSocket(`${proto}://${location.host}/ws`);
649
+ const namedHandlers = new Map(); // kind -> Set<fn>
650
+ const anyHandlers = new Set();
651
+ let queue = [];
652
+ let open = false;
653
+
654
+ ws.onopen = () => {
655
+ open = true;
656
+ ws.send(JSON.stringify({ type: 'join', room: name }));
657
+ queue.forEach(p => ws.send(JSON.stringify({ type: 'broadcast', room: name, payload: p })));
658
+ queue = null;
659
+ };
660
+ ws.onmessage = (ev) => {
661
+ let msg;
662
+ try { msg = JSON.parse(ev.data); } catch { return; }
663
+ if (msg.type !== 'message' && msg.type !== 'presence') return;
664
+ const payload = msg.payload || {};
665
+ const from = msg.from;
666
+ const kind = payload.kind || msg.type; // 'presence' has no kind
667
+ const set = namedHandlers.get(kind);
668
+ if (set) set.forEach(fn => { try { fn(payload, from); } catch (e) { console.error(e); } });
669
+ anyHandlers.forEach(fn => { try { fn(payload, from, kind); } catch (e) { console.error(e); } });
670
+ };
671
+
672
+ return {
673
+ name,
674
+ on(kind, fn) {
675
+ if (!namedHandlers.has(kind)) namedHandlers.set(kind, new Set());
676
+ namedHandlers.get(kind).add(fn);
677
+ return this;
678
+ },
679
+ onAny(fn) { anyHandlers.add(fn); return this; },
680
+ off(kind, fn) {
681
+ namedHandlers.get(kind)?.delete(fn);
682
+ return this;
683
+ },
684
+ send(payload) {
685
+ if (open) {
686
+ ws.send(JSON.stringify({ type: 'broadcast', room: name, payload }));
687
+ } else {
688
+ queue.push(payload);
689
+ }
690
+ },
691
+ leave() {
692
+ if (open) ws.send(JSON.stringify({ type: 'leave', room: name }));
693
+ ws.close();
694
+ },
695
+ raw: ws,
696
+ };
697
+ }
698
+
699
+ // =================================================================
700
+ // 7. Flows - per-flow methods generated from flows.yaml
701
+ //
702
+ // At runtime there's no per-flow client - the agent calls
703
+ // bm.flows.<flowName>(params, body), which routes to the URL by
704
+ // looking up a runtime registry written by the type generator at
705
+ // /_internal/flows.json. We Proxy() the lookup so unknown names
706
+ // throw a clear runtime error to match the TS error.
707
+ // =================================================================
708
+
709
+ let _flowsRegistry = null;
710
+ async function loadFlowsRegistry() {
711
+ if (_flowsRegistry !== null) return _flowsRegistry;
712
+ try {
713
+ const r = await fetch('/_internal/flows.json', { credentials: 'same-origin', cache: 'no-store' });
714
+ if (r.ok) _flowsRegistry = await r.json();
715
+ else _flowsRegistry = {};
716
+ } catch { _flowsRegistry = {}; }
717
+ return _flowsRegistry;
718
+ }
719
+
720
+ // Registry keys are camelCase (company_setup / company-setup → companySetup).
721
+ // Accept snake_case and kebab-case aliases too - agents naturally write
722
+ // bm.flows.company_setup when the file is flows/company-setup.yaml, and
723
+ // making the obvious spelling work beats making them learn the mapping.
724
+ function camelizeFlowName(name) {
725
+ return name.replace(/[-_]+([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
726
+ }
727
+
728
+ export const flows = new Proxy({}, {
729
+ get(_target, flowName) {
730
+ if (typeof flowName !== 'string') return undefined;
731
+ return async (params = {}, body) => {
732
+ let reg = await loadFlowsRegistry();
733
+ let entry = reg[flowName] || reg[camelizeFlowName(flowName)];
734
+ if (!entry) {
735
+ // The registry lists only flows THIS caller may call, so one loaded
736
+ // before sign-in (or before a role grant) can be missing the flow.
737
+ // Refetch once before reporting it as undeclared.
738
+ _flowsRegistry = null;
739
+ reg = await loadFlowsRegistry();
740
+ entry = reg[flowName] || reg[camelizeFlowName(flowName)];
741
+ }
742
+ if (!entry) {
743
+ const available = Object.keys(reg).sort();
744
+ const listing = available.length
745
+ ? `Available flows: ${available.map(n => `bm.flows.${n}()`).join(', ')}.`
746
+ : 'No flows are registered (flows.yaml / flows/*.yaml define none with on.request triggers).';
747
+ throw new Error(
748
+ `bm.flows.${flowName} is not declared in flows.yaml. ${listing} ` +
749
+ `Note: a single-job file flows/company-setup.yaml registers as bm.flows.companySetup ` +
750
+ `(file name, camelCased) - snake/kebab spellings of the same name also resolve.`
751
+ );
752
+ }
753
+ // Substitute :path params, each one encoded segment (never '.'/'..').
754
+ let path = entry.path;
755
+ for (const [k, v] of Object.entries(params)) {
756
+ if (path.includes(`:${k}`)) path = path.replace(`:${k}`, _seg(v));
757
+ }
758
+ const method = (entry.method || 'POST').toUpperCase();
759
+ const res = await request(method, path, body);
760
+ if (!res.ok) {
761
+ const err = new Error(res.data?.error || `flow ${flowName} failed (${res.status})`);
762
+ err.status = res.status; err.body = res.data;
763
+ throw err;
764
+ }
765
+ // Async flows return {job_id, status_url} - wrap into a Job handle.
766
+ if (res.data && res.data.job_id && res.data.status_url) {
767
+ return makeJob(res.data.job_id, res.data.status_url);
768
+ }
769
+ return res.data;
770
+ };
771
+ }
772
+ });
773
+
774
+ // =================================================================
775
+ // 8. Workflows - typed state-machine client
776
+ // bm.workflow('orders', 7).transitionTo('shipped')
777
+ // bm.workflow('orders', 7).available()
778
+ // bm.workflow('orders', 7).current()
779
+ // =================================================================
780
+
781
+ export function workflow(table, id) {
782
+ if (!table || id === undefined || id === null) {
783
+ throw new Error('bm.workflow(table, id) requires both args');
784
+ }
785
+ const base = `/api/${_seg(table)}/${_seg(id)}`;
786
+ return {
787
+ table,
788
+ id,
789
+ // Server expects { to: <newState> } (workflows.go handleTransition).
790
+ transitionTo: (state) => api.post(`${base}/transition`, { to: state }),
791
+ available: () => api.get(`${base}/transitions`),
792
+ current: async () => {
793
+ const row = await api.get(base);
794
+ return row.state ?? row.status;
795
+ },
796
+ };
797
+ }
798
+
799
+ // =================================================================
800
+ // 9. Aggregates - materialized SQL values
801
+ // bm.aggregate('total_revenue') → scalar
802
+ // bm.aggregates.all() → { name: value, ... }
803
+ // =================================================================
804
+
805
+ // The framework exposes /api/_aggregates as a single list endpoint -
806
+ // no per-name route. We fetch the whole list (cheap, ~N rows) and
807
+ // pick out the one the caller asked for. The list is cached on the
808
+ // server side so this is one round-trip then a hash lookup.
809
+ export async function aggregate(name) {
810
+ const r = await api.get('/api/_aggregates');
811
+ const list = (r && (r.aggregates || r)) || [];
812
+ if (Array.isArray(list)) {
813
+ const hit = list.find(a => a && a.name === name);
814
+ return hit ? (hit.value ?? null) : null;
815
+ }
816
+ // Map shape: { name: value }
817
+ return (r && r[name] !== undefined) ? r[name] : null;
818
+ }
819
+
820
+ export const aggregates = {
821
+ get: aggregate,
822
+ async all() {
823
+ const r = await api.get('/api/_aggregates');
824
+ const list = (r && (r.aggregates || r)) || [];
825
+ if (!Array.isArray(list)) return r;
826
+ return Object.fromEntries(list.map(a => [a.name, a.value]));
827
+ },
828
+ };
829
+
830
+ // =================================================================
831
+ // 10. Jobs - async-flow status + wait helper
832
+ // =================================================================
833
+
834
+ function makeJob(jobId, statusUrl) {
835
+ const statusPath = statusUrl || `/api/_jobs/${_seg(jobId)}/status`;
836
+ return {
837
+ job_id: jobId,
838
+ status_url: statusUrl,
839
+ status: () => api.get(statusPath),
840
+ wait: (opts = {}) => waitForJob(jobId, { ...opts, statusUrl }),
841
+ };
842
+ }
843
+
844
+ async function waitForJob(jobId, { intervalMs = 1000, timeoutMs = 5 * 60 * 1000, statusUrl = '' } = {}) {
845
+ const statusPath = statusUrl || `/api/_jobs/${_seg(jobId)}/status`;
846
+ const deadline = Date.now() + timeoutMs;
847
+ while (Date.now() < deadline) {
848
+ const status = await api.get(statusPath);
849
+ if (status.status === 'completed') return status.result;
850
+ if (status.status === 'failed') {
851
+ const err = new Error(status.error || `job ${jobId} failed`);
852
+ err.status = status; throw err;
853
+ }
854
+ await new Promise(r => setTimeout(r, intervalMs));
855
+ }
856
+ throw new Error(`job ${jobId} timed out after ${timeoutMs}ms`);
857
+ }
858
+
859
+ export const jobs = {
860
+ // statusUrl is the tokenized URL returned by an async flow's submit
861
+ // response. Without it, the bare /api/_jobs/{id}/status path 404s for any
862
+ // non-admin caller (the status endpoint requires the capability token), so
863
+ // always pass through the server-issued status_url when polling outside an
864
+ // admin session.
865
+ status: (jobId, statusUrl) =>
866
+ api.get(statusUrl || `/api/_jobs/${_seg(jobId)}/status`),
867
+ wait: waitForJob,
868
+ };
869
+
870
+ // =================================================================
871
+ // 11. Notifications - in-app inbox
872
+ // =================================================================
873
+
874
+ export const notifications = {
875
+ async list(opts = {}) {
876
+ const qs = new URLSearchParams();
877
+ if (opts.limit) qs.set('limit', String(opts.limit));
878
+ if (opts.offset) qs.set('offset', String(opts.offset));
879
+ if (opts.unread) qs.set('unread', '1');
880
+ const path = '/api/_notifications' + (qs.toString() ? '?' + qs : '');
881
+ const r = await api.get(path);
882
+ return Array.isArray(r) ? r : (r.notifications || []);
883
+ },
884
+ markRead: (id) => api.patch(`/api/_notifications/${_seg(id)}/read`),
885
+ markAllRead: () => api.post('/api/_notifications/read-all'),
886
+ delete: (id) => api.delete(`/api/_notifications/${_seg(id)}`),
887
+ // Events carry no rows. Drain an ID cursor (including rows already marked
888
+ // read by another tab), coalescing insert events into one paginated fetch.
889
+ onNew(cb) {
890
+ let active = true, current;
891
+ const isCurrent = state => active && current === state && state.revision === _authRevision;
892
+ const initialize = state => api.get('/api/_notifications?limit=1')
893
+ .then((result) => {
894
+ if (isCurrent(state) && Number.isSafeInteger(result.latest_id) && result.latest_id >= 0) {
895
+ state.lastSeen = result.latest_id;
896
+ }
897
+ })
898
+ .catch(() => {});
899
+ // Each session owns its baseline, cursor and pending drain. A delayed old
900
+ // response must neither publish private rows nor stall the next account.
901
+ const reset = (enabled = true) => {
902
+ const state = current = { revision: _authRevision, enabled, lastSeen: null, pending: false, running: false };
903
+ state.ready = enabled ? initialize(state) : Promise.resolve();
904
+ };
905
+ _sessionListeners.add(reset);
906
+ reset();
907
+ const drain = async state => {
908
+ if (state.running || !isCurrent(state)) return;
909
+ state.running = true;
910
+ try {
911
+ await state.ready;
912
+ if (!isCurrent(state)) return;
913
+ if (state.lastSeen === null) {
914
+ state.ready = initialize(state);
915
+ await state.ready;
916
+ if (!isCurrent(state) || state.lastSeen === null) return;
917
+ }
918
+ while (isCurrent(state) && state.pending) {
919
+ state.pending = false;
920
+ while (isCurrent(state)) {
921
+ const result = await api.get(`/api/_notifications?limit=100&after_id=${state.lastSeen}`);
922
+ if (!isCurrent(state)) return;
923
+ const rows = result.notifications || [];
924
+ let advanced = false;
925
+ for (const n of rows) {
926
+ if (!isCurrent(state)) return;
927
+ if (!Number.isSafeInteger(n.id) || n.id <= state.lastSeen) continue;
928
+ state.lastSeen = n.id; advanced = true;
929
+ try { cb(n); } catch (e) { console.error(e); }
930
+ }
931
+ if (rows.length < 100 || !advanced) break;
932
+ }
933
+ }
934
+ } catch (_) { /* Retry from the last delivered ID on the next event. */ }
935
+ finally { state.running = false; }
936
+ };
937
+ const stop = live('_benmore_notifications', (ev) => {
938
+ if (ev.action !== 'insert' || !current.enabled) return;
939
+ current.pending = true;
940
+ void drain(current);
941
+ });
942
+ return () => { active = false; _sessionListeners.delete(reset); stop(); };
943
+ },
944
+ };
945
+
946
+ // =================================================================
947
+ // 12. Upload - multipart file upload
948
+ // =================================================================
949
+
950
+ // The framework's upload handler accepts CSRF in EITHER the header
951
+ // OR a `_csrf` form field. Sending both makes the call work whether
952
+ // the deployed framework version honors header-CSRF for multipart
953
+ // (newer) or only form-CSRF (older).
954
+ export async function upload(file, opts = {}) {
955
+ const csrf = CSRF_META();
956
+ const fd = new FormData();
957
+ fd.append('file', file, opts.filename || (file.name || 'upload'));
958
+ if (opts.kind) fd.append('kind', opts.kind);
959
+ if (csrf) fd.append('_csrf', csrf);
960
+ const res = await fetch('/api/_upload', {
961
+ method: 'POST',
962
+ credentials: 'same-origin',
963
+ headers: { 'X-CSRF-Token': csrf },
964
+ body: fd,
965
+ });
966
+ if (!res.ok) {
967
+ const text = await res.text();
968
+ throw new Error(`upload failed (${res.status}): ${text}`);
969
+ }
970
+ return res.json();
971
+ }
972
+
973
+ // =================================================================
974
+ // 13. Audit log (admin endpoint)
975
+ // =================================================================
976
+
977
+ export const audit = {
978
+ async list(filter = {}) {
979
+ const qs = new URLSearchParams();
980
+ for (const [k, v] of Object.entries(filter)) {
981
+ if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
982
+ }
983
+ const path = '/api/_audit' + (qs.toString() ? '?' + qs : '');
984
+ const r = await api.get(path);
985
+ return Array.isArray(r) ? r : (r.entries || []);
986
+ },
987
+ };
988
+
989
+ // =================================================================
990
+ // 14. Permissions - per-row ACLs
991
+ // =================================================================
992
+
993
+ // Permission enum matches the framework's permissionLevel map:
994
+ // 'view' | 'edit' | 'delete' | 'admin' (see permissions.go).
995
+ // Body shape: { email, permission, expires_at? }.
996
+ export const permissions = {
997
+ share: (table, id, email, permission = 'view', expires_at) =>
998
+ api.post(`/api/${_seg(table)}/${_seg(id)}/share`,
999
+ { email, permission, ...(expires_at ? { expires_at } : {}) }),
1000
+ revoke: (table, id, email) =>
1001
+ api.delete(`/api/${_seg(table)}/${_seg(id)}/permissions/${_seg(email)}`),
1002
+ list: (table, id) =>
1003
+ api.get(`/api/${_seg(table)}/${_seg(id)}/permissions`),
1004
+ };
1005
+
1006
+ // =================================================================
1007
+ // 15. Signed URLs - time-limited file access
1008
+ // =================================================================
1009
+
1010
+ // The framework exposes GET /api/_signed_url?path=...&expires_in=...
1011
+ // (signed_urls.go). Older docs spelled this `/api/_signed-url` (POST);
1012
+ // stay aligned with the live shape.
1013
+ // signedUrl(path, opts) - mint a time-limited URL for a file.
1014
+ // opts can be a number (ttlSeconds, back-compat) OR
1015
+ // { ttlSeconds?, record?: { table, id } }.
1016
+ // PRIVATE files on the platform media CDN REQUIRE `record` - the row that
1017
+ // owns the file. The server authorizes the signature against THAT record's
1018
+ // access policy (roles, perms, scopes, group/owner/ACL), not just a logged-in
1019
+ // session, then signs. Public / local files don't need it.
1020
+ // await bm.signedUrl(doc.file_url, { record: { table: 'documents', id: doc.id } })
1021
+ // Default lifetime: 5 minutes, except audio/video, which get the server's
1022
+ // playback lifetime (4h): a <video>/<audio> keeps range-requesting the URL
1023
+ // for as long as it plays, so a 5-minute link stalls a long video.
1024
+ const SIGNED_MEDIA_RE = /\.(mp4|m4v|mov|webm|ogv|mkv|3gp|mp3|m4a|aac|wav|flac|ogg|oga|opus|weba)([?#]|$)/i;
1025
+ export async function signedUrl(path, opts) {
1026
+ let ttlSeconds, record = null;
1027
+ if (typeof opts === 'number') ttlSeconds = opts;
1028
+ else if (opts && typeof opts === 'object') { ttlSeconds = opts.ttlSeconds ?? opts.ttl; record = opts.record || null; }
1029
+ if (ttlSeconds == null && !SIGNED_MEDIA_RE.test(String(path))) ttlSeconds = 300;
1030
+ const qs = new URLSearchParams({ path: String(path) });
1031
+ if (ttlSeconds != null) qs.set('expires_in', String(ttlSeconds));
1032
+ if (record && record.table && record.id != null) {
1033
+ qs.set('record_table', String(record.table));
1034
+ qs.set('record_id', String(record.id));
1035
+ }
1036
+ const r = await api.get('/api/_signed_url?' + qs.toString());
1037
+ // Prefer the ROOT-RELATIVE path: it inherits the page's scheme/host, so
1038
+ // it works on https (the absolute `url` is built http:// behind CF/TLS
1039
+ // termination and would be blocked as mixed-content + CSP img-src 'self').
1040
+ return r.path || r.url || r;
1041
+ }
1042
+
1043
+ // =================================================================
1044
+ // 16. I18n - typed translation helper
1045
+ //
1046
+ // At runtime, the framework injects the active language's dictionary
1047
+ // as window.__bm_i18n.strings via the per-page bootstrap. bm.t reads
1048
+ // from there and falls back to the raw key when missing.
1049
+ // =================================================================
1050
+
1051
+ export function t(key, vars) {
1052
+ const dict = (typeof window !== 'undefined' && window.__bm_i18n?.strings) || {};
1053
+ const out = Object.prototype.hasOwnProperty.call(dict, key) ? (dict[key] ?? key) : key;
1054
+ if (!vars) return out;
1055
+ // Match the original placeholders once. Variable names and inserted values
1056
+ // are literal data: no regex metacharacters, replacement-dollar semantics,
1057
+ // inherited keys, or recursive expansion of newly inserted placeholders.
1058
+ return out.replace(/\{\{\s*([^{}]*?)\s*\}\}/g, (match, name) =>
1059
+ Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match);
1060
+ }
1061
+
1062
+ // =================================================================
1063
+ // 17. MFA - TOTP enrollment + verification
1064
+ // =================================================================
1065
+
1066
+ // =================================================================
1067
+ // 13b. Broadcast - SFU-backed 1:N livestream (v2.7.29+)
1068
+ //
1069
+ // Peer-mesh WebRTC chokes at ~5 viewers because the publisher uploads
1070
+ // N copies of their media (one per viewer). bm.broadcast routes media
1071
+ // through Cloudflare Realtime SFU - publisher uploads ONCE, the SFU
1072
+ // fans out. Scales to hundreds of viewers; sub-second latency.
1073
+ //
1074
+ // Usage:
1075
+ //
1076
+ // // Publisher (the streamer):
1077
+ // const stream = await navigator.mediaDevices.getUserMedia({video:true, audio:true});
1078
+ // const broadcast = await bm.broadcast.publish('my-stream-slug', stream);
1079
+ // // … broadcast.stop() when done
1080
+ //
1081
+ // // Subscriber (every viewer):
1082
+ // const subscription = await bm.broadcast.subscribe('my-stream-slug');
1083
+ // videoElement.srcObject = subscription.stream;
1084
+ // // … subscription.stop() when leaving
1085
+ //
1086
+ // // Stats (anyone, for "N watching" pills):
1087
+ // const { live, viewers } = await bm.broadcast.stats('my-stream-slug');
1088
+ //
1089
+ // Falls back gracefully when the platform operator hasn't configured
1090
+ // an SFU app - bm.broadcast.publish() throws { status: 503,
1091
+ // reason: 'SFU not configured' } and apps can recover to peer-mesh.
1092
+ // =================================================================
1093
+
1094
+ async function broadcastIceServers() {
1095
+ // Re-use the same TURN/STUN resolver - SFU sessions still need ICE.
1096
+ try { return await webrtc.iceServers(); }
1097
+ catch { return [{ urls: ['stun:stun.l.google.com:19302'] }]; }
1098
+ }
1099
+
1100
+ // waitForICEGathering blocks until the PC has finished gathering ICE
1101
+ // candidates (or a timeout fires). Without this the SDP we send to
1102
+ // CF's SFU has no ICE candidates → CF can't establish the UDP
1103
+ // connection → SDP exchange succeeds but media never flows.
1104
+ // 2s timeout: ICE gathering on a healthy network completes in <500ms;
1105
+ // the timeout is a backstop in case `iceGatheringState === 'complete'`
1106
+ // never fires (rare browser bug - Firefox <90 with certain TURN setups).
1107
+ function waitForICEGathering(pc) {
1108
+ if (pc.iceGatheringState === 'complete') return Promise.resolve();
1109
+ return new Promise(resolve => {
1110
+ let done = false;
1111
+ const finish = () => { if (done) return; done = true; resolve(); };
1112
+ pc.addEventListener('icegatheringstatechange', () => {
1113
+ if (pc.iceGatheringState === 'complete') finish();
1114
+ });
1115
+ setTimeout(finish, 2000);
1116
+ });
1117
+ }
1118
+
1119
+ async function broadcastSDPExchange(path, slug, sdpOffer) {
1120
+ const res = await request('POST', path, { slug, sdp: sdpOffer });
1121
+ if (!res.ok) {
1122
+ const err = new Error(res.data?.error || 'broadcast: SDP exchange failed');
1123
+ err.status = res.status;
1124
+ err.body = res.data;
1125
+ throw err;
1126
+ }
1127
+ return res.data; // { sdp, session_id, slug }
1128
+ }
1129
+
1130
+ // subscribeWithPolicy is the workhorse behind bm.broadcast.subscribe.
1131
+ // First call uses iceTransportPolicy: 'all' (default) - tries direct
1132
+ // path, then reflexive, then relay. If ICE state hits 'failed' or
1133
+ // stalls in 'checking' for >10s (typical cellular symptom - UDP packets
1134
+ // silently dropped by carrier NAT), we tear down and retry with
1135
+ // iceTransportPolicy: 'relay'. That forces every candidate to go through
1136
+ // TURN, which CF can ALWAYS reach because TURN gives the subscriber a
1137
+ // stable, externally-reachable allocation address.
1138
+ //
1139
+ // Why subscribers need this more than publishers: publishers send a
1140
+ // constant stream of RTP packets to CF, keeping the NAT mapping fresh.
1141
+ // Subscribers send only sparse RTCP feedback, so address-restricted
1142
+ // cone NATs (common on cellular) often refuse CF's RTP responses after
1143
+ // the first few seconds. TURN solves this by holding a stable allocation.
1144
+ async function subscribeWithPolicy(slug, policy, attempt = 1) {
1145
+ const ice = await broadcastIceServers();
1146
+ const pc = new RTCPeerConnection({ iceServers: ice, iceTransportPolicy: policy });
1147
+
1148
+ pc.addTransceiver('video', { direction: 'recvonly' });
1149
+ pc.addTransceiver('audio', { direction: 'recvonly' });
1150
+
1151
+ const remoteStream = new MediaStream();
1152
+ pc.ontrack = (ev) => {
1153
+ if (ev.streams && ev.streams[0]) {
1154
+ ev.streams[0].getTracks().forEach(t => remoteStream.addTrack(t));
1155
+ } else {
1156
+ remoteStream.addTrack(ev.track);
1157
+ }
1158
+ };
1159
+
1160
+ // ICE connection-state probe. If we hit 'failed' OR stay stuck in
1161
+ // 'checking' / 'new' for too long, retry once with relay-only. Most
1162
+ // cellular failures show up as 'checking' that never advances -
1163
+ // UDP packets just get dropped at the carrier.
1164
+ let resolved = false;
1165
+ const failurePromise = new Promise((_, reject) => {
1166
+ const watchdog = setTimeout(() => {
1167
+ if (!resolved) reject(new Error(`subscribe ICE timeout (state=${pc.iceConnectionState}, policy=${policy})`));
1168
+ }, 10000);
1169
+ pc.addEventListener('iceconnectionstatechange', () => {
1170
+ const s = pc.iceConnectionState;
1171
+ if (s === 'connected' || s === 'completed') {
1172
+ resolved = true;
1173
+ clearTimeout(watchdog);
1174
+ } else if (s === 'failed' || s === 'disconnected') {
1175
+ clearTimeout(watchdog);
1176
+ reject(new Error(`subscribe ICE ${s} (policy=${policy})`));
1177
+ }
1178
+ });
1179
+ });
1180
+
1181
+ const offer = await pc.createOffer();
1182
+ await pc.setLocalDescription(offer);
1183
+ await waitForICEGathering(pc);
1184
+
1185
+ const sdpExchange = await broadcastSDPExchange('/api/_broadcast/subscribe', slug, pc.localDescription.sdp);
1186
+ await pc.setRemoteDescription({ type: 'answer', sdp: sdpExchange.sdp });
1187
+
1188
+ // Race ICE-success against the failure/timeout watchdog. If the
1189
+ // browser can't establish a path within 10s, retry over TURN-relay
1190
+ // ONLY. Don't retry forever - second failure means the network is
1191
+ // genuinely unreachable and we surface the error.
1192
+ try {
1193
+ await Promise.race([
1194
+ failurePromise,
1195
+ new Promise(resolve => {
1196
+ if (pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') {
1197
+ resolved = true;
1198
+ resolve();
1199
+ } else {
1200
+ const check = () => {
1201
+ const s = pc.iceConnectionState;
1202
+ if (s === 'connected' || s === 'completed') {
1203
+ resolved = true;
1204
+ pc.removeEventListener('iceconnectionstatechange', check);
1205
+ resolve();
1206
+ }
1207
+ };
1208
+ pc.addEventListener('iceconnectionstatechange', check);
1209
+ }
1210
+ }),
1211
+ ]);
1212
+ } catch (e) {
1213
+ try { pc.close(); } catch {}
1214
+ if (attempt === 1 && policy !== 'relay') {
1215
+ console.warn('[bm.broadcast] direct/STUN subscribe failed, retrying via TURN relay:', e?.message || e);
1216
+ return subscribeWithPolicy(slug, 'relay', 2);
1217
+ }
1218
+ throw e;
1219
+ }
1220
+
1221
+ let stopped = false;
1222
+ return {
1223
+ slug,
1224
+ sessionId: sdpExchange.session_id,
1225
+ pc,
1226
+ stream: remoteStream,
1227
+ policy,
1228
+ stop() {
1229
+ if (stopped) return;
1230
+ stopped = true;
1231
+ try { pc.close(); } catch {}
1232
+ },
1233
+ };
1234
+ }
1235
+
1236
+ export const broadcast = {
1237
+ /**
1238
+ * Publish a MediaStream to the SFU under `slug`. Returns a handle
1239
+ * with .stop() to tear down. Throws when SFU isn't configured -
1240
+ * caller can catch and fall back to peer-mesh.
1241
+ */
1242
+ async publish(slug, mediaStream) {
1243
+ const ice = await broadcastIceServers();
1244
+ const pc = new RTCPeerConnection({ iceServers: ice });
1245
+
1246
+ // Add every track from the supplied stream. CF infers the
1247
+ // direction (recv/send) from the SDP, so just sendonly tracks
1248
+ // for a publisher.
1249
+ for (const track of mediaStream.getTracks()) {
1250
+ pc.addTransceiver(track, { direction: 'sendonly', streams: [mediaStream] });
1251
+ }
1252
+
1253
+ const offer = await pc.createOffer();
1254
+ await pc.setLocalDescription(offer);
1255
+ // CRITICAL: wait for ICE gathering to finish so pc.localDescription
1256
+ // has the candidate lines baked in. Without this, CF receives an
1257
+ // offer with no candidates → no UDP path → media never flows even
1258
+ // though SDP exchange succeeds. Classic WebRTC race; symptom is
1259
+ // "black screen but everything looks fine in the logs".
1260
+ await waitForICEGathering(pc);
1261
+
1262
+ const { sdp, session_id } = await broadcastSDPExchange('/api/_broadcast/publish', slug, pc.localDescription.sdp);
1263
+ await pc.setRemoteDescription({ type: 'answer', sdp });
1264
+
1265
+ let stopped = false;
1266
+ // Keep the slug claimed while live: a publisher that stops
1267
+ // heartbeating is treated as crashed and its slug can be taken over.
1268
+ const heartbeat = setInterval(() => {
1269
+ if (!stopped) request('POST', '/api/_broadcast/heartbeat', { slug }).catch(() => {});
1270
+ }, 60000);
1271
+ return {
1272
+ slug,
1273
+ sessionId: session_id,
1274
+ pc,
1275
+ async stop() {
1276
+ if (stopped) return;
1277
+ stopped = true;
1278
+ clearInterval(heartbeat);
1279
+ try { pc.close(); } catch {}
1280
+ try { await request('POST', '/api/_broadcast/stop', { slug }); } catch {}
1281
+ },
1282
+ };
1283
+ },
1284
+
1285
+ /**
1286
+ * Subscribe to a published slug. Returns { stream, pc, stop() }.
1287
+ * `stream` is a MediaStream you can wire into <video>.srcObject.
1288
+ *
1289
+ * Options:
1290
+ * waitForPublisher: ms to keep retrying when no publisher is live
1291
+ * yet (404 / 503). Defaults to 30000. Set to 0
1292
+ * to fail fast on the first call (legacy behavior).
1293
+ * Multiple apps had to hand-roll
1294
+ * this loop because the SDK threw on the publisher
1295
+ * race ("A subscribes to B before B's publish
1296
+ * finishes ICE gathering" - both viewer-side and
1297
+ * huddle-second-joiner cases).
1298
+ * signal: AbortSignal to cancel the retry loop early.
1299
+ */
1300
+ async subscribe(slug, opts = {}) {
1301
+ const waitMs = opts.waitForPublisher === undefined ? 30000 : opts.waitForPublisher;
1302
+ const signal = opts.signal;
1303
+ const deadline = Date.now() + waitMs;
1304
+ let attempt = 0;
1305
+ let lastErr;
1306
+ while (true) {
1307
+ if (signal && signal.aborted) {
1308
+ throw new Error('bm.broadcast.subscribe aborted');
1309
+ }
1310
+ try {
1311
+ return await subscribeWithPolicy(slug, 'all');
1312
+ } catch (e) {
1313
+ lastErr = e;
1314
+ // Only retry on "no publisher yet" shapes: 404 (slug not in
1315
+ // _benmore_broadcasts), 503 ({reason: 'publisher not live'}),
1316
+ // or a 503-like 4xx where the server's stats endpoint would
1317
+ // report live:false. ICE failures, network drops, auth 401s,
1318
+ // CSP rejections - all propagate immediately.
1319
+ const status = e?.status;
1320
+ const transient = status === 404 || status === 503;
1321
+ if (!transient || Date.now() >= deadline) throw e;
1322
+ // Backoff: 500ms, 1s, 2s, 4s, capped at 4s. Total wait inside
1323
+ // waitMs naturally.
1324
+ const delay = Math.min(500 * Math.pow(2, attempt), 4000);
1325
+ attempt++;
1326
+ await new Promise(r => setTimeout(r, delay));
1327
+ }
1328
+ }
1329
+ /* eslint-disable-next-line no-unreachable */
1330
+ throw lastErr;
1331
+ },
1332
+
1333
+ /**
1334
+ * Returns { live: bool, viewers: number, started_at?: string }.
1335
+ * Safe to poll; cheap (one indexed lookup).
1336
+ */
1337
+ async stats(slug) {
1338
+ const res = await request('GET', `/api/_broadcast/stats/${_seg(slug)}`);
1339
+ return res.data || { live: false, viewers: 0 };
1340
+ },
1341
+ };
1342
+
1343
+ // =================================================================
1344
+ // 14. WebRTC - ICE servers (STUN + optional TURN)
1345
+ //
1346
+ // Agents building peer-to-peer apps (livestream, voice, screen-share)
1347
+ // should NEVER hand-roll ICE config. STUN alone fails for ~30-50% of
1348
+ // real-world connections (symmetric NAT on cellular, double-NAT homes).
1349
+ // `bm.webrtc.iceServers()` returns whatever the framework has been
1350
+ // configured to mint - STUN by default, plus TURN when the operator
1351
+ // sets TURN_PROVIDER + creds via `benmore env set` on the deployed app.
1352
+ //
1353
+ // Cached in-memory per-tab for 30min (TURN creds typically last 1h
1354
+ // server-side; we refresh before they expire).
1355
+ // =================================================================
1356
+
1357
+ let _iceCache = null;
1358
+ let _iceCacheUntil = 0;
1359
+
1360
+ export const webrtc = {
1361
+ /**
1362
+ * Returns the configured ICE servers array, suitable to pass directly
1363
+ * to `new RTCPeerConnection({ iceServers: ... })`.
1364
+ *
1365
+ * Always includes STUN. Includes TURN when the operator configured
1366
+ * one (TURN_PROVIDER + TURN_CF_APP_ID/TURN_CF_APP_TOKEN for Cloudflare,
1367
+ * or TURN_URL/TURN_USERNAME/TURN_CREDENTIAL for a static provider).
1368
+ */
1369
+ async iceServers() {
1370
+ if (_iceCache && Date.now() < _iceCacheUntil) return _iceCache;
1371
+ const res = await request('GET', '/api/_webrtc/ice');
1372
+ const list = res.data?.iceServers || [{ urls: ['stun:stun.l.google.com:19302'] }];
1373
+ _iceCache = list;
1374
+ _iceCacheUntil = Date.now() + 30 * 60 * 1000;
1375
+ return list;
1376
+ },
1377
+ };
1378
+
1379
+ export const mfa = {
1380
+ // Signed-in enrollment re-proves the current password; the login-issued
1381
+ // setup token path (require_mfa_for_roles) needs none.
1382
+ enroll: (password) => api.post('/api/_auth/mfa/setup', password ? { password } : {}),
1383
+ verify: (code) => api.post('/api/_auth/mfa/verify', { code }),
1384
+ disable: (password) => api.post('/api/_auth/mfa/disable', { password }),
1385
+ };
1386
+
1387
+ // =================================================================
1388
+ // 18. Soft-delete restore + versioning helpers - patched onto the
1389
+ // table() return value when the table has the feature. Pure
1390
+ // runtime: when the developer hits .restore on a table that
1391
+ // doesn't have deleted_at, the server returns a clear error.
1392
+ // The TS types stop the call at compile time.
1393
+ // =================================================================
1394
+
1395
+ // (Already inside table() factory above for runtime - the type
1396
+ // declaration in bm.d.ts gates compile-time access.)
1397
+
1398
+ // =================================================================
1399
+ // Default export
1400
+ // =================================================================
1401
+
1402
+ // =================================================================
1403
+ // bm.html - XSS-safe tagged template literal (v2.7.66+)
1404
+ //
1405
+ // Every interpolated value gets HTML-escaped automatically. Stop
1406
+ // writing manual escapeHtml() calls; stop forgetting them. Use this
1407
+ // for ALL innerHTML / template-literal HTML construction.
1408
+ //
1409
+ // el.innerHTML = bm.html`<li>${user.name}</li>`; // safe
1410
+ // el.innerHTML = bm.html`<a href="${url}">${title}</a>`; // both escaped
1411
+ //
1412
+ // Opt-out per-value via bm.raw(html) when the value is ALREADY safe
1413
+ // HTML (component output, sanitized markdown, framework-rendered icons):
1414
+ //
1415
+ // el.innerHTML = bm.html`<div>${bm.raw(iconHtml)}${user.name}</div>`;
1416
+ //
1417
+ // Arrays of strings are joined automatically, so a render loop reads:
1418
+ //
1419
+ // el.innerHTML = bm.html`<ul>${rows.map(r => bm.html`<li>${r.name}</li>`)}</ul>`;
1420
+ //
1421
+ // Notes:
1422
+ // - Returns a plain string - drop-in replacement for normal template
1423
+ // literals; nothing else changes about the calling code.
1424
+ // - Escapes the 5 XSS-relevant characters: & < > " '
1425
+ // - Nested bm.html`…` values are NOT double-escaped (they're tagged
1426
+ // as raw via a hidden symbol so the outer template knows they're
1427
+ // already safe). Same idea as React/lit's html`` tag.
1428
+ // - null / undefined render as empty string (matches lit / Preact).
1429
+ // =================================================================
1430
+
1431
+ const _bmHtmlSafe = Symbol('bm.html.safe');
1432
+
1433
+ function _escForHtml(s) {
1434
+ const str = String(s);
1435
+ // Single-pass replace via charCodeAt is faster than chained .replace
1436
+ // for typical short strings; the difference matters in render loops
1437
+ // that hit thousands of cells per second.
1438
+ let out = '';
1439
+ for (let i = 0; i < str.length; i++) {
1440
+ const c = str.charCodeAt(i);
1441
+ if (c === 38) out += '&amp;'; // &
1442
+ else if (c === 60) out += '&lt;'; // <
1443
+ else if (c === 62) out += '&gt;'; // >
1444
+ else if (c === 34) out += '&quot;'; // "
1445
+ else if (c === 39) out += '&#39;'; // '
1446
+ else out += str[i];
1447
+ }
1448
+ return out;
1449
+ }
1450
+
1451
+ function _interpolate(v) {
1452
+ if (v == null) return '';
1453
+ // Already-safe HTML (from bm.raw / nested bm.html / Array of either).
1454
+ if (typeof v === 'object' && v[_bmHtmlSafe] === true) return v.value;
1455
+ if (Array.isArray(v)) {
1456
+ let out = '';
1457
+ for (const item of v) out += _interpolate(item);
1458
+ return out;
1459
+ }
1460
+ return _escForHtml(v);
1461
+ }
1462
+
1463
+ // A value that starts a URL attribute can't be made safe by escaping:
1464
+ // href="javascript:..." still runs. Same rule as server-rendered pages
1465
+ // (template_context.go dangerousURL): javascript:, vbscript:, livescript:
1466
+ // and data: other than a raster image become #blocked-unsafe-url, with the
1467
+ // scheme read as the URL parser reads it (leading control characters and any
1468
+ // tab or newline ignored). bm.raw(url) opts out.
1469
+ const _urlAttrOpen = /\s(?:href|src|action|formaction|xlink:href|poster|data|cite|background)\s*=\s*["']?$/i;
1470
+ const _rasterDataURL = /^data:\s*image\/(png|gif|jpe?g|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)[;,]/i;
1471
+ function _scriptURL(v) {
1472
+ const u = String(v).replace(/^[\x00-\x20]+/, '').replace(/[\t\n\r]/g, '');
1473
+ const m = /^([^:/?#]+):/.exec(u);
1474
+ if (!m) return false;
1475
+ const scheme = m[1].toLowerCase();
1476
+ return scheme === 'javascript' || scheme === 'vbscript' || scheme === 'livescript' ||
1477
+ (scheme === 'data' && !_rasterDataURL.test(u));
1478
+ }
1479
+
1480
+ export function html(strings, ...values) {
1481
+ let out = strings[0];
1482
+ for (let i = 0; i < values.length; i++) {
1483
+ let v = values[i];
1484
+ if (v != null && typeof v !== 'object' && _urlAttrOpen.test(strings[i]) && _scriptURL(v)) v = '#blocked-unsafe-url';
1485
+ out += _interpolate(v) + strings[i + 1];
1486
+ }
1487
+ // Tag the result so it can be nested into another bm.html`...`
1488
+ // without being escaped a second time.
1489
+ return { [_bmHtmlSafe]: true, value: out, toString() { return out; } };
1490
+ }
1491
+
1492
+ /** Mark a string as already-safe HTML - escapes the surrounding
1493
+ * bm.html`...` interpolation but trusts THIS value verbatim. Use for
1494
+ * component output, sanitized markdown, framework icon helpers. */
1495
+ export function raw(htmlString) {
1496
+ return { [_bmHtmlSafe]: true, value: String(htmlString ?? '') };
1497
+ }
1498
+
1499
+ // markdown - async loader for /_internal/markdown.js. Imported lazily
1500
+ // on first call so apps that don't render markdown don't pay the
1501
+ // download cost. The renderer escapes HTML before parsing, allowlists
1502
+ // http/https/mailto link schemes, and supports **bold** / *italic* /
1503
+ // `code` / ```fenced``` / [links](url) / lists / blockquotes. Chat,
1504
+ // notes, comments - anywhere user-provided text becomes display HTML.
1505
+ //
1506
+ // const html = await bm.markdown(messageText);
1507
+ // messageEl.innerHTML = html;
1508
+ // markdown renders via the server-side benmark engine
1509
+ // (POST /_internal/markdown): robust CommonMark + GFM + syntax highlighting,
1510
+ // mermaid diagrams, and media embeds - one renderer shared with every other
1511
+ // framework surface. Results are cached per text. On network failure we fall
1512
+ // back to the tiny offline /_internal/markdown.js subset (and, failing that,
1513
+ // HTML-escaped plaintext), so a message NEVER renders as raw or unsanitized
1514
+ // HTML. Pass { inline: true } for the single-line chat subset.
1515
+ const _mdCache = new Map();
1516
+ let _mdFallback = null;
1517
+ async function _mdFallbackFn(text, inline) {
1518
+ if (_mdFallback == null) {
1519
+ try { const m = await import('/_internal/markdown.js'); _mdFallback = m.markdown || m.default; }
1520
+ catch (_) {
1521
+ _mdFallback = (t) => String(t == null ? '' : t).replace(
1522
+ /[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
1523
+ }
1524
+ }
1525
+ return _mdFallback(text, inline);
1526
+ }
1527
+ async function markdown(text, opts) {
1528
+ if (text == null) return '';
1529
+ const inline = !!(opts && opts.inline);
1530
+ const key = (inline ? '1' : '0') + String(text);
1531
+ if (_mdCache.has(key)) return _mdCache.get(key);
1532
+ try {
1533
+ const res = await fetch('/_internal/markdown', {
1534
+ method: 'POST',
1535
+ headers: { 'Content-Type': 'application/json' },
1536
+ body: JSON.stringify({ text: String(text), inline }),
1537
+ });
1538
+ if (!res.ok) throw new Error('markdown ' + res.status);
1539
+ const html = await res.text();
1540
+ if (_mdCache.size > 500) _mdCache.clear();
1541
+ _mdCache.set(key, html);
1542
+ return html;
1543
+ } catch (_) {
1544
+ return _mdFallbackFn(text, inline);
1545
+ }
1546
+ }
1547
+ // markdownBatch renders many strings in ONE round-trip (POST
1548
+ // /_internal/markdown/batch) - use it to render a whole message/notes list at
1549
+ // once. items is an array of strings or { text, inline } objects; returns an
1550
+ // array of HTML strings aligned by index. Results are added to the cache.
1551
+ async function markdownBatch(items) {
1552
+ const norm = (items || []).map((it) =>
1553
+ typeof it === 'string' ? { text: it, inline: false }
1554
+ : { text: String((it && it.text) || ''), inline: !!(it && it.inline) });
1555
+ if (!norm.length) return [];
1556
+ try {
1557
+ const res = await fetch('/_internal/markdown/batch', {
1558
+ method: 'POST',
1559
+ headers: { 'Content-Type': 'application/json' },
1560
+ body: JSON.stringify({ items: norm }),
1561
+ });
1562
+ if (!res.ok) throw new Error('markdown batch ' + res.status);
1563
+ const out = (await res.json()).html || [];
1564
+ norm.forEach((n, i) => { if (out[i] != null) _mdCache.set((n.inline ? '1' : '0') + n.text, out[i]); });
1565
+ return out;
1566
+ } catch (_) {
1567
+ return Promise.all(norm.map((n) => markdown(n.text, { inline: n.inline })));
1568
+ }
1569
+ }
1570
+
1571
+ // =================================================================
1572
+ // presence - "who is live in <slug> right now"
1573
+ //
1574
+ // Wraps the three-layer pattern every chat / huddle / live app
1575
+ // hand-rolled before v2.7.35:
1576
+ // - 20s heartbeat PATCH so the server knows the tab is alive
1577
+ // - pagehide + sendBeacon to mark "I've left" on tab close
1578
+ // - server cron that sweeps expired rows (presence.go)
1579
+ //
1580
+ // Usage:
1581
+ // const p = bm.presence('huddle:42'); // joins, starts heartbeat
1582
+ // p.members(); // → [{user_id, joined_at,...}]
1583
+ // p.leave(); // explicit; pagehide auto-fires
1584
+ //
1585
+ // Stays subscribed to `_benmore_presence` SSE events so any change
1586
+ // (anyone joining / leaving / sweep expiry) triggers an updated count.
1587
+ // =================================================================
1588
+
1589
+ function presence(slug) {
1590
+ if (typeof slug !== 'string' || !slug) {
1591
+ throw new Error('bm.presence(slug) requires a non-empty string');
1592
+ }
1593
+ let left = false;
1594
+ let heartbeatTimer = null;
1595
+ let onChangeUnsub = null;
1596
+
1597
+ const post = (action) => api.post('/api/_presence/heartbeat', { slug, action }).catch(() => {});
1598
+ const sendBeaconLeave = () => {
1599
+ // Beacon survives tab close - fetch with keepalive is the right
1600
+ // primitive but sendBeacon is the only one with universal support.
1601
+ if (left) return;
1602
+ left = true;
1603
+ try {
1604
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
1605
+ const blob = new Blob(
1606
+ [JSON.stringify({ slug, action: 'leave', _csrf: csrf })],
1607
+ { type: 'application/json' }
1608
+ );
1609
+ navigator.sendBeacon?.('/api/_presence/heartbeat', blob);
1610
+ } catch {}
1611
+ };
1612
+
1613
+ // Initial join + start the 18s heartbeat (slightly under the 20s
1614
+ // the server advertises so we're never close to the boundary).
1615
+ post('join');
1616
+ heartbeatTimer = setInterval(() => { if (!left) post('heartbeat'); }, 18000);
1617
+
1618
+ // pagehide is more reliable than beforeunload across browsers.
1619
+ // Both fire when the tab is closed; pagehide also fires on bfcache.
1620
+ if (typeof window !== 'undefined') {
1621
+ window.addEventListener('pagehide', sendBeaconLeave);
1622
+ window.addEventListener('beforeunload', sendBeaconLeave);
1623
+ }
1624
+
1625
+ return {
1626
+ slug,
1627
+ async members() {
1628
+ const res = await api.get('/api/_presence?slug=' + encodeURIComponent(slug));
1629
+ return Array.isArray(res?.members) ? res.members : [];
1630
+ },
1631
+ async count() {
1632
+ const res = await api.get('/api/_presence?slug=' + encodeURIComponent(slug));
1633
+ return typeof res?.count === 'number' ? res.count : 0;
1634
+ },
1635
+ // Subscribe to SSE invalidations on the presence table; callback
1636
+ // fires after any join/leave/sweep so the caller can refresh.
1637
+ onChange(fn) {
1638
+ if (typeof fn !== 'function') return () => {};
1639
+ onChangeUnsub = live('_benmore_presence', () => fn());
1640
+ return onChangeUnsub;
1641
+ },
1642
+ leave() {
1643
+ if (left) return;
1644
+ left = true;
1645
+ clearInterval(heartbeatTimer);
1646
+ heartbeatTimer = null;
1647
+ if (onChangeUnsub) { onChangeUnsub(); onChangeUnsub = null; }
1648
+ post('leave');
1649
+ },
1650
+ };
1651
+ }
1652
+
1653
+ // =================================================================
1654
+ // cache - self-busting sessionStorage / localStorage helper
1655
+ //
1656
+ // One app.s pre-paint pattern (a sessionStorage cache key) went
1657
+ // stale when v=6 → v=7 because the cache version was hardcoded and
1658
+ // never tied to the actual bundle's mtime. Result: agents shipped a
1659
+ // new sidebar, browsers painted the OLD cached one, debugging cost
1660
+ // hours.
1661
+ //
1662
+ // bm.cache.namespaced(name, version) returns a 4-method client whose
1663
+ // storage key includes the version. On version change, the old key
1664
+ // reads as miss (it's there but invisible) and a subsequent write
1665
+ // lands at the new key - the old entry GCs naturally when storage
1666
+ // pressure hits, or you can call .purgeOld() to evict eagerly.
1667
+ //
1668
+ // Usage:
1669
+ // const shell = bm.cache.namespaced('shell', staticAssetVersion);
1670
+ // shell.get(); // → cached value or undefined
1671
+ // shell.set(html); // store
1672
+ // shell.clear(); // current-version only
1673
+ // shell.purgeOld(); // drop every prior version of this name
1674
+ // =================================================================
1675
+
1676
+ function makeCacheNamespace(storage, name, version) {
1677
+ if (!storage || typeof name !== 'string' || !name) return null;
1678
+ const ver = String(version || 'v0');
1679
+ const key = `bm:${name}:${ver}`;
1680
+ const prefix = `bm:${name}:`;
1681
+ return {
1682
+ name, version: ver,
1683
+ get() {
1684
+ try { return storage.getItem(key) ?? undefined; } catch { return undefined; }
1685
+ },
1686
+ set(value) {
1687
+ try { storage.setItem(key, String(value)); } catch {}
1688
+ },
1689
+ clear() {
1690
+ try { storage.removeItem(key); } catch {}
1691
+ },
1692
+ purgeOld() {
1693
+ try {
1694
+ for (let i = storage.length - 1; i >= 0; i--) {
1695
+ const k = storage.key(i);
1696
+ if (k && k.startsWith(prefix) && k !== key) storage.removeItem(k);
1697
+ }
1698
+ } catch {}
1699
+ },
1700
+ };
1701
+ }
1702
+
1703
+ const cache = {
1704
+ /** Session-scoped self-busting cache. Survives the tab but not the
1705
+ * browser process. The right choice for "pre-paint the shell" and
1706
+ * other this-session ephemera. */
1707
+ namespaced(name, version) {
1708
+ if (typeof sessionStorage === 'undefined') return null;
1709
+ return makeCacheNamespace(sessionStorage, name, version);
1710
+ },
1711
+ /** Persistent self-busting cache. Survives reloads + browser
1712
+ * restarts. Use for "remember the user's last filter" - anything
1713
+ * useful across sessions but invalidatable per-deploy. */
1714
+ persistent(name, version) {
1715
+ if (typeof localStorage === 'undefined') return null;
1716
+ return makeCacheNamespace(localStorage, name, version);
1717
+ },
1718
+ };
1719
+
1720
+ // =================================================================
1721
+ // store - small app-state primitive (Zustand-shaped, no dependency)
1722
+ // =================================================================
1723
+
1724
+ function shallowEqualObject(a, b) {
1725
+ if (a === b) return true;
1726
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
1727
+ const ak = Object.keys(a), bk = Object.keys(b);
1728
+ if (ak.length !== bk.length) return false;
1729
+ for (const k of ak) if (!Object.is(a[k], b[k])) return false;
1730
+ return true;
1731
+ }
1732
+
1733
+ function resolveStoreCache(persist) {
1734
+ if (!persist || !persist.name) return null;
1735
+ const kind = persist.storage === 'persistent' ? 'persistent' : 'namespaced';
1736
+ return cache[kind](persist.name, persist.version || 'v0');
1737
+ }
1738
+
1739
+ export function createStore(initialState = {}, opts = {}) {
1740
+ const base = (initialState && typeof initialState === 'object') ? initialState : {};
1741
+ const persist = opts.persist || opts.cache;
1742
+ const storage = resolveStoreCache(persist);
1743
+ let state = base;
1744
+ if (storage) {
1745
+ try {
1746
+ const raw = storage.get();
1747
+ if (raw) {
1748
+ // Persisted blobs live in localStorage/sessionStorage and are therefore
1749
+ // attacker-controllable on the client. We only accept a plain object and
1750
+ // merge it over base; this is not a trust boundary (the server remains
1751
+ // authoritative), but it prevents a malformed/non-object blob from
1752
+ // replacing state wholesale or injecting a non-object root.
1753
+ const parsed = JSON.parse(raw);
1754
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1755
+ state = { ...base, ...parsed };
1756
+ }
1757
+ }
1758
+ } catch {}
1759
+ }
1760
+ const initial = { ...state };
1761
+ const subs = new Set();
1762
+
1763
+ // Each subscriber's listener receives (nextSlice, oldSlice). The previous
1764
+ // value passed to listeners is the subscriber's own previously-observed slice
1765
+ // (which, for the default whole-state selector, IS the prior whole state), so
1766
+ // notify() needs no separate `prev` argument.
1767
+ const notify = () => {
1768
+ subs.forEach((sub) => {
1769
+ let nextSlice;
1770
+ try { nextSlice = sub.selector(state); } catch (e) { console.warn('[bm.store] selector threw:', e); return; }
1771
+ if (!sub.equal(nextSlice, sub.slice)) {
1772
+ const oldSlice = sub.slice;
1773
+ sub.slice = nextSlice;
1774
+ try { sub.listener(nextSlice, oldSlice); } catch (e) { console.warn('[bm.store] listener threw:', e); }
1775
+ }
1776
+ });
1777
+ };
1778
+
1779
+ const persistState = () => {
1780
+ if (!storage) return;
1781
+ try { storage.set(JSON.stringify(state)); } catch {}
1782
+ };
1783
+
1784
+ return {
1785
+ get: () => state,
1786
+ set(patch) {
1787
+ let next = (typeof patch === 'function') ? patch(state) : patch;
1788
+ if (next == null) return state;
1789
+ // State is contractually a Record<string, any>; object patches merge
1790
+ // shallowly. A patch (including a function result) that is an array or
1791
+ // other non-object would replace the whole state and break that contract,
1792
+ // so we reject it with a warning instead of silently clobbering state.
1793
+ if (typeof next !== 'object' || Array.isArray(next)) {
1794
+ console.warn('[bm.store] set() ignored a non-object patch; state must be a plain object');
1795
+ return state;
1796
+ }
1797
+ next = { ...state, ...next };
1798
+ if (Object.is(next, state) || shallowEqualObject(next, state)) return state;
1799
+ state = next;
1800
+ persistState();
1801
+ notify();
1802
+ return state;
1803
+ },
1804
+ subscribe(selector, listener, equal = Object.is) {
1805
+ if (typeof listener !== 'function') {
1806
+ listener = selector;
1807
+ selector = (s) => s;
1808
+ }
1809
+ if (typeof selector !== 'function' || typeof listener !== 'function') {
1810
+ throw new Error('bm.createStore().subscribe(selector, listener) requires a listener');
1811
+ }
1812
+ const sub = { selector, listener, equal, slice: selector(state) };
1813
+ subs.add(sub);
1814
+ return () => subs.delete(sub);
1815
+ },
1816
+ reset(nextState) {
1817
+ state = nextState && typeof nextState === 'object' ? { ...nextState } : { ...initial };
1818
+ persistState();
1819
+ notify();
1820
+ return state;
1821
+ },
1822
+ };
1823
+ }
1824
+
1825
+ // =================================================================
1826
+ // query - key-based fetch/mutation cache for app data
1827
+ // =================================================================
1828
+
1829
+ function stableStringify(value) {
1830
+ if (value == null || typeof value !== 'object') return JSON.stringify(value);
1831
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
1832
+ const keys = Object.keys(value).sort();
1833
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
1834
+ }
1835
+
1836
+ function queryKey(key) {
1837
+ return typeof key === 'string' ? key : stableStringify(key);
1838
+ }
1839
+
1840
+ const _queryCache = new Map();
1841
+ const _queryTableKeys = new Map();
1842
+ let _queryLiveStop = null;
1843
+ // Live-invalidation tuning. The '*' SSE subscription fires for every CRUD event
1844
+ // on every table the app has read, including the client's own writes, so we
1845
+ // coalesce invalidations per-table over a short window to avoid a refetch
1846
+ // storm under chatty fan-out. Refetch-on-live is opt-out via _queryLiveConfig.
1847
+ const _queryLiveConfig = { refetch: true, debounceMs: 50 };
1848
+ const _queryLiveTimers = new Map();
1849
+
1850
+ function queryEntry(key) {
1851
+ const k = queryKey(key);
1852
+ if (!_queryCache.has(k)) {
1853
+ _queryCache.set(k, { key: k, rawKey: key, data: undefined, error: null, updatedAt: 0, promise: null, revision: 0, rollbackOwner: null, fetcher: null, staleTime: 0, subs: new Set() });
1854
+ }
1855
+ return _queryCache.get(k);
1856
+ }
1857
+
1858
+ function notifyQuery(e) {
1859
+ e.subs.forEach((fn) => { try { fn({ data: e.data, error: e.error, updatedAt: e.updatedAt, pending: !!e.promise }); } catch (err) { console.warn('[bm.query] subscriber threw:', err); } });
1860
+ }
1861
+
1862
+ // A fetcher may not support cancellation. Let its caller settle normally, but
1863
+ // revoke its right to publish data/errors or clear a newer request's pending state.
1864
+ function supersedeQueryFetch(e) {
1865
+ e.revision++;
1866
+ e.promise = null;
1867
+ }
1868
+
1869
+ function resetSessionQueries() {
1870
+ if (_queryLiveStop) _queryLiveStop();
1871
+ _queryTableKeys.clear();
1872
+ const entries = [..._queryCache.values()];
1873
+ // Clear every entry before notifying: a subscriber may read another key.
1874
+ // Retain entries/subscriptions so old promises cannot find a fresh entry and
1875
+ // existing views can render the empty state before requesting their new data.
1876
+ for (const e of entries) {
1877
+ supersedeQueryFetch(e);
1878
+ e.rollbackOwner = null;
1879
+ e.data = undefined;
1880
+ e.error = null;
1881
+ e.updatedAt = 0;
1882
+ e.fetcher = null;
1883
+ e.staleTime = 0;
1884
+ }
1885
+ for (const e of entries) notifyQuery(e);
1886
+ }
1887
+
1888
+ function setQueryValue(e, data, owner = null) {
1889
+ supersedeQueryFetch(e);
1890
+ e.rollbackOwner = owner;
1891
+ if (owner) owner.revision = e.revision;
1892
+ e.data = data;
1893
+ e.error = null;
1894
+ e.updatedAt = Date.now();
1895
+ notifyQuery(e);
1896
+ return data;
1897
+ }
1898
+
1899
+ function rememberTableKey(tableName, key) {
1900
+ if (!tableName) return;
1901
+ const k = queryKey(key);
1902
+ if (!_queryTableKeys.has(tableName)) _queryTableKeys.set(tableName, new Set());
1903
+ _queryTableKeys.get(tableName).add(k);
1904
+ }
1905
+
1906
+ function ensureQueryLiveInvalidation() {
1907
+ if (_queryLiveStop || typeof live !== 'function' || typeof EventSource === 'undefined') return;
1908
+ const stop = live('*', (ev) => {
1909
+ if (!ev || (!ev.table && ev.action !== 'refresh')) return;
1910
+ // Private/member-of events may hide the table name. That refresh hint
1911
+ // covers every registered live query, but not unrelated custom caches.
1912
+ const table = ev.table || '';
1913
+ if (_queryLiveTimers.has('') || _queryLiveTimers.has(table)) return;
1914
+ // A generic refresh subsumes already queued table-specific refreshes.
1915
+ if (!table) {
1916
+ for (const timer of _queryLiveTimers.values()) clearTimeout(timer);
1917
+ _queryLiveTimers.clear();
1918
+ }
1919
+ const t = setTimeout(() => {
1920
+ _queryLiveTimers.delete(table);
1921
+ const opts = { refetch: _queryLiveConfig.refetch };
1922
+ if (table) query.invalidateTable(table, opts);
1923
+ else {
1924
+ const keys = new Set([..._queryTableKeys.values()].flatMap(keys => [...keys]));
1925
+ query.invalidate((_raw, stable) => keys.has(stable), opts);
1926
+ }
1927
+ }, _queryLiveConfig.debounceMs);
1928
+ if (typeof t.unref === 'function') t.unref();
1929
+ _queryLiveTimers.set(table, t);
1930
+ });
1931
+ // Expose a teardown so callers/tests can stop the page-lifetime subscription.
1932
+ _queryLiveStop = () => {
1933
+ try { if (typeof stop === 'function') stop(); } catch {}
1934
+ for (const t of _queryLiveTimers.values()) clearTimeout(t);
1935
+ _queryLiveTimers.clear();
1936
+ _queryLiveStop = null;
1937
+ };
1938
+ }
1939
+
1940
+ export const query = {
1941
+ key: queryKey,
1942
+ stableStringify,
1943
+ get(key) {
1944
+ return queryEntry(key).data;
1945
+ },
1946
+ set(key, data) {
1947
+ return setQueryValue(queryEntry(key), data);
1948
+ },
1949
+ subscribe(key, fn) {
1950
+ if (typeof fn !== 'function') throw new Error('bm.query.subscribe(key, fn) requires a function');
1951
+ const e = queryEntry(key);
1952
+ e.subs.add(fn);
1953
+ fn({ data: e.data, error: e.error, updatedAt: e.updatedAt, pending: !!e.promise });
1954
+ return () => e.subs.delete(fn);
1955
+ },
1956
+ async fetch(key, fetcher, opts = {}) {
1957
+ if (typeof fetcher !== 'function') throw new Error('bm.query.fetch(key, fetcher) requires a fetcher');
1958
+ const e = queryEntry(key);
1959
+ const staleTime = Number(opts.staleTime || opts.staleMs || 0);
1960
+ if (!opts.force && e.data !== undefined && staleTime > 0 && Date.now() - e.updatedAt < staleTime) return e.data;
1961
+ // If a fetch is already in flight, return it without clobbering e.fetcher/
1962
+ // e.staleTime: a concurrent caller with a different fetcher must not have
1963
+ // its fetcher attached to data it didn't produce, or a later refetch(key)
1964
+ // would run the wrong fetcher.
1965
+ if (e.promise) return e.promise;
1966
+ e.fetcher = fetcher;
1967
+ e.staleTime = staleTime;
1968
+ const revision = e.revision;
1969
+ const pending = Promise.resolve()
1970
+ .then(fetcher)
1971
+ .then((data) => {
1972
+ if (e.revision === revision) {
1973
+ e.revision++;
1974
+ e.rollbackOwner = null;
1975
+ e.data = data;
1976
+ e.error = null;
1977
+ e.updatedAt = Date.now();
1978
+ }
1979
+ return data;
1980
+ })
1981
+ .catch((err) => {
1982
+ if (e.revision === revision) {
1983
+ e.revision++;
1984
+ e.error = err;
1985
+ }
1986
+ throw err;
1987
+ })
1988
+ .finally(() => {
1989
+ if (e.promise === pending) {
1990
+ e.promise = null;
1991
+ notifyQuery(e);
1992
+ }
1993
+ });
1994
+ e.promise = pending;
1995
+ notifyQuery(e);
1996
+ return pending;
1997
+ },
1998
+ async refetch(key) {
1999
+ const e = queryEntry(key);
2000
+ if (!e.fetcher) return e.data;
2001
+ return query.fetch(e.rawKey, e.fetcher, { force: true, staleTime: e.staleTime });
2002
+ },
2003
+ invalidate(match, opts = {}) {
2004
+ const keys = [];
2005
+ // For a non-function match we support exact-key matching plus structural
2006
+ // array-prefix matching: ['user'] matches ['user', 1] but NOT ['users'].
2007
+ // We deliberately avoid raw string startsWith on the stable-stringified key
2008
+ // because that produced false positives (e.g. {a:1} matching {a:1,b:2}, or
2009
+ // ['user'] matching ['username']). Array-prefix is predictable for the
2010
+ // common ['table', name, spec] / ['query', spec] key shapes used here.
2011
+ const matchExact = typeof match !== 'function' ? queryKey(match) : null;
2012
+ const matchPrefix = (typeof match !== 'function' && Array.isArray(match)) ? match : null;
2013
+ const isArrayPrefix = (prefix, raw) => {
2014
+ if (!Array.isArray(raw) || raw.length < prefix.length) return false;
2015
+ for (let i = 0; i < prefix.length; i++) {
2016
+ if (stableStringify(prefix[i]) !== stableStringify(raw[i])) return false;
2017
+ }
2018
+ return true;
2019
+ };
2020
+ const predicate = typeof match === 'function'
2021
+ ? match
2022
+ : (raw, stable) => stable === matchExact || (matchPrefix !== null && isArrayPrefix(matchPrefix, raw));
2023
+ for (const [stable, e] of _queryCache.entries()) {
2024
+ if (!predicate(e.rawKey, stable)) continue;
2025
+ supersedeQueryFetch(e);
2026
+ e.rollbackOwner = null;
2027
+ e.updatedAt = 0;
2028
+ keys.push(stable);
2029
+ notifyQuery(e);
2030
+ if (opts.refetch && e.fetcher) query.refetch(e.rawKey).catch(() => {});
2031
+ }
2032
+ return keys;
2033
+ },
2034
+ tableKey(tableName, spec = {}) {
2035
+ return ['table', tableName, spec];
2036
+ },
2037
+ async read(spec, opts = {}) {
2038
+ if (!spec || !spec.table) throw new Error('bm.query.read({ table, ... }) requires table');
2039
+ const key = opts.key || ['query', spec];
2040
+ if (opts.live !== false) {
2041
+ rememberTableKey(spec.table, key);
2042
+ ensureQueryLiveInvalidation();
2043
+ }
2044
+ return query.fetch(key, async () => {
2045
+ const r = await api.post('/api/_query', spec);
2046
+ return Array.isArray(r) ? r : (r.rows || []);
2047
+ }, opts);
2048
+ },
2049
+ table(tableName, opts = {}) {
2050
+ // Separate client control options (which never affect the server result)
2051
+ // from the server-affecting query spec. Only the spec may influence the
2052
+ // cache key, otherwise two identical reads that differ only in staleTime/
2053
+ // force/live would fragment into distinct cache entries and defeat dedupe.
2054
+ const { staleTime, staleMs, force, live, key, ...serverOpts } = opts;
2055
+ const spec = { ...serverOpts, table: tableName };
2056
+ // Route through read() with the same ['query', spec] key shape so that a
2057
+ // bm.query.table('posts') and bm.query.read({ table: 'posts' }) targeting
2058
+ // identical rows collapse onto one shared cache entry. Forward every client
2059
+ // control option (staleTime/staleMs/force/live) so none are silently dropped.
2060
+ return query.read(spec, { key: key, staleTime, staleMs, force, live });
2061
+ },
2062
+ invalidateTable(tableName, opts = {}) {
2063
+ const keys = _queryTableKeys.get(tableName);
2064
+ if (!keys) return [];
2065
+ return query.invalidate((_raw, stable) => keys.has(stable), opts);
2066
+ },
2067
+ async mutate(opts = {}) {
2068
+ const authRevision = _authRevision;
2069
+ if (typeof opts.request !== 'function') throw new Error('bm.query.mutate({ request }) requires request');
2070
+ const keys = Array.isArray(opts.keys) ? opts.keys : (opts.key ? [opts.key] : []);
2071
+ // Snapshot the full prior entry (data + updatedAt + error), not just data,
2072
+ // so a failed mutation can be restored exactly without stamping a fresh
2073
+ // updatedAt or clearing a pre-existing error the way set() would.
2074
+ const snapshots = [...new Map(keys.map(key => [queryKey(key), key])).values()].map((key) => {
2075
+ const e = queryEntry(key);
2076
+ return { key, data: e.data, updatedAt: e.updatedAt, error: e.error,
2077
+ revision: e.revision, previous: e.rollbackOwner, state: 'pending', applied: false };
2078
+ });
2079
+ try {
2080
+ if (typeof opts.apply === 'function') {
2081
+ for (const snap of snapshots) {
2082
+ const data = opts.apply(snap.data, snap.key);
2083
+ snap.applied = true;
2084
+ setQueryValue(queryEntry(snap.key), data, snap);
2085
+ }
2086
+ }
2087
+ const result = await opts.request();
2088
+ if (authRevision !== _authRevision) return result;
2089
+ for (const snap of snapshots) {
2090
+ snap.state = 'succeeded';
2091
+ snap.previous = null;
2092
+ const e = queryEntry(snap.key);
2093
+ if (e.rollbackOwner === snap) e.rollbackOwner = null;
2094
+ }
2095
+ if (opts.invalidate) query.invalidate(opts.invalidate, { refetch: opts.refetch !== false });
2096
+ return result;
2097
+ } catch (err) {
2098
+ if (authRevision !== _authRevision) throw err;
2099
+ for (const snap of snapshots) snap.state = 'failed';
2100
+ for (const snap of snapshots) {
2101
+ const e = queryEntry(snap.key);
2102
+ // A later set, fetch, invalidation or edit owns its state. Neither a
2103
+ // default nor an application rollback may overwrite that newer work.
2104
+ if (snap.applied ? e.rollbackOwner !== snap : e.revision !== snap.revision) continue;
2105
+ // A newer failed edit may have snapshotted another pending edit. Skip
2106
+ // ancestors that also failed; restore a still-pending ancestor's
2107
+ // ownership so it can unwind if its own request subsequently fails.
2108
+ let prior = snap;
2109
+ while (prior.previous && prior.previous.state === 'failed') prior = prior.previous;
2110
+ const owner = prior.previous?.state === 'pending' ? prior.previous : null;
2111
+ if (typeof opts.rollback === 'function') {
2112
+ const data = opts.rollback(prior.data, err, snap.key);
2113
+ // The callback itself may publish a newer cache value.
2114
+ if (snap.applied ? e.rollbackOwner !== snap : e.revision !== snap.revision) continue;
2115
+ // A read begun after the optimistic edit can still supply its
2116
+ // authoritative result. Rolling back data must not cancel that read.
2117
+ if (!e.promise) supersedeQueryFetch(e);
2118
+ e.rollbackOwner = owner;
2119
+ if (owner) owner.revision = e.revision;
2120
+ e.data = data;
2121
+ e.updatedAt = Date.now();
2122
+ notifyQuery(e);
2123
+ } else if (snap.applied) {
2124
+ // Preserve the original timestamp and error, including stale state.
2125
+ if (!e.promise) supersedeQueryFetch(e);
2126
+ e.rollbackOwner = owner;
2127
+ if (owner) owner.revision = e.revision;
2128
+ e.data = prior.data;
2129
+ e.updatedAt = prior.updatedAt;
2130
+ if (e.error === null) e.error = prior.error;
2131
+ notifyQuery(e);
2132
+ }
2133
+ }
2134
+ throw err;
2135
+ }
2136
+ },
2137
+ };
2138
+
2139
+ const bm = {
2140
+ api, auth, users, table, live, room,
2141
+ flows, workflow, aggregate, aggregates, jobs,
2142
+ notifications, upload, audit, permissions, signedUrl,
2143
+ t, mfa, webrtc, broadcast, markdown, markdownBatch, presence, cache,
2144
+ createStore, query, html, raw,
2145
+ };
2146
+ export default bm;
2147
+
2148
+ // Optional global for plain-JS pages without an import map.
2149
+ if (typeof window !== 'undefined') window.bm = bm;
2150
+
2151
+ // =================================================================
2152
+ // Client error capture → POST /api/_errors → _benmore_client_errors.
2153
+ // Powers the get_client_errors MCP tool + Sentry/Slack forwarding.
2154
+ // Installs window.onerror + unhandledrejection (the two ways JS
2155
+ // surfaces an uncaught failure - the 404-from-a-fetch case is an
2156
+ // unhandled rejection, so BOTH are required). Throttled + deduped so
2157
+ // an error loop can't spam the endpoint.
2158
+ // =================================================================
2159
+ if (typeof window !== 'undefined') {
2160
+ let _errSent = 0, _errLast = '';
2161
+ const _ERR_CAP = 25;
2162
+ const _postErr = (p) => {
2163
+ if (_errSent >= _ERR_CAP) return;
2164
+ const key = (p.message || '') + '|' + (p.source || '') + '|' + (p.line || 0);
2165
+ if (key === _errLast) return; // collapse immediate duplicates
2166
+ _errLast = key; _errSent++;
2167
+ try {
2168
+ fetch('/api/_errors', {
2169
+ method: 'POST',
2170
+ headers: { 'Content-Type': 'application/json' },
2171
+ body: JSON.stringify(p),
2172
+ keepalive: true,
2173
+ credentials: 'same-origin',
2174
+ }).catch(() => {}); // never let reporting throw (would loop)
2175
+ } catch (_) {}
2176
+ };
2177
+ window.addEventListener('error', (e) => {
2178
+ // Resource-load errors (img/script 404s) have no .message - skip them.
2179
+ if (!e || !e.message) return;
2180
+ _postErr({
2181
+ kind: 'error',
2182
+ message: String(e.message).slice(0, 2000),
2183
+ stack: e.error && e.error.stack ? String(e.error.stack).slice(0, 8000) : '',
2184
+ source: e.filename || '',
2185
+ line: e.lineno || 0,
2186
+ col: e.colno || 0,
2187
+ url: location.href,
2188
+ });
2189
+ });
2190
+ window.addEventListener('unhandledrejection', (e) => {
2191
+ const r = e && e.reason;
2192
+ const msg = r && r.message ? r.message : (typeof r === 'string' ? r : 'Unhandled promise rejection');
2193
+ _postErr({
2194
+ kind: 'unhandledrejection',
2195
+ message: String(msg).slice(0, 2000),
2196
+ stack: r && r.stack ? String(r.stack).slice(0, 8000) : '',
2197
+ source: '',
2198
+ line: 0,
2199
+ col: 0,
2200
+ url: location.href,
2201
+ });
2202
+ });
2203
+ }