@marver-design/marver 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +1 -1
  3. package/dist/auth-DdUKeVKt.mjs +1150 -0
  4. package/dist/{build-DI31MFTB.mjs → build-BxGrHFT2.mjs} +188 -33
  5. package/dist/cli.mjs +16 -7
  6. package/dist/collab-mAiNZlDj.mjs +970 -0
  7. package/dist/{comments-CRqaO2MH.mjs → comments-DHB_8BRa.mjs} +3 -2
  8. package/dist/{daemon-DSmaS453.mjs → daemon-BChkzDqQ.mjs} +3 -3
  9. package/dist/{dev-BWIL2ewj.mjs → dev-DLwt3Brb.mjs} +5 -5
  10. package/dist/events-B3LBn74P.mjs +103 -0
  11. package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
  12. package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
  13. package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
  14. package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-B2uraTHS.mjs} +100 -9
  15. package/dist/{plugin-DjBFxEQ7.mjs → plugin-DNc4Jpae.mjs} +82 -9
  16. package/dist/{profile-DcsJyppw.mjs → profile-BjAPAJSb.mjs} +1 -1
  17. package/dist/{serve-3APqaNSB.mjs → serve-EjEqsiYa.mjs} +70 -17
  18. package/dist/share-Gqo_Ygqw.mjs +158 -0
  19. package/dist/summary-C7CAypbB.mjs +211 -0
  20. package/dist/{sync-DELGomPk.mjs → sync-BZaCWqK-.mjs} +3 -102
  21. package/package.json +1 -1
  22. package/src/client/shell/App.tsx +62 -11
  23. package/src/client/shell/Comments.tsx +103 -22
  24. package/src/client/shell/LockedApp.tsx +113 -0
  25. package/src/client/shell/Play.tsx +97 -18
  26. package/src/client/shell/Toolbar.tsx +26 -2
  27. package/src/client/shell/canvas/Canvas.tsx +5 -15
  28. package/src/client/shell/canvas/ctl.ts +26 -0
  29. package/src/client/shell/comments-store.ts +96 -11
  30. package/src/client/shell/hash.ts +19 -0
  31. package/src/client/shell/icons.tsx +1 -0
  32. package/src/client/shell/mentions.ts +108 -1
  33. package/src/client/shell/sound.ts +32 -0
  34. package/src/client/shell/store.ts +66 -7
  35. package/src/client/shell/styles.css +68 -0
  36. package/src/shared/events.ts +20 -6
  37. package/dist/auth-B5yuwnOq.mjs +0 -494
  38. package/dist/collab-CWaG3Q4w.mjs +0 -340
  39. package/dist/manifest-C2tzkNaC.mjs +0 -233
  40. package/dist/update-DuWDj5nR.mjs +0 -77
  41. package/dist/utm-CxC3QN5X.mjs +0 -20
@@ -0,0 +1,1150 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
+ //#region src/server/share.ts
6
+ /**
7
+ * The sharing roster and the ONE resolver (01-sharing §4-5, 04-solution §2.2).
8
+ *
9
+ * `share.json` lives beside `auth.json` on the volume and is the only grant
10
+ * store. `publish.json` (via meta.json `rights`) stays the per-board CEILING;
11
+ * this file holds who was granted what and until when. The resolver is a pure
12
+ * function - blocklist first, then the highest matching grant, then the ceiling
13
+ * clamps - and it is consulted at every door: gate admission, identity
14
+ * provisioning, password sign-in, comment writes, request-access eligibility
15
+ * and SSE re-authorization. One function, so no door can drift.
16
+ *
17
+ * The non-promotion invariant is carried by two mechanics and nothing else:
18
+ * reads always use `min(current ceiling, grant.boardRole[b])`, and every boot
19
+ * re-clamps `boardRole[b] = min(previous ?? assigned, ceiling[b])` - ceilings
20
+ * pull entries down, never up. A later ceiling rise leaves the entry low, which
21
+ * is exactly the condition the owner's re-confirm action raises. No version
22
+ * hashes, no reconcile ceremony.
23
+ *
24
+ * No share.json = pre-migration 0.11 behaviour, exactly. The file is created
25
+ * once at serve boot from the migration matrix (01-sharing §10); until then
26
+ * every caller falls back to the legacy rules it always had. A present but
27
+ * corrupt or malformed file fails CLOSED - a policy typo must deny, never
28
+ * quietly grant the ceiling.
29
+ */
30
+ var share_exports = /* @__PURE__ */ __exportAll({
31
+ ceilingsFromRights: () => ceilingsFromRights,
32
+ commentAllowed: () => commentAllowed,
33
+ ensureShare: () => ensureShare,
34
+ entryAllowed: () => entryAllowed,
35
+ grantFromInviteRedemption: () => grantFromInviteRedemption,
36
+ loadRequests: () => loadRequests,
37
+ loadShare: () => loadShare,
38
+ operativeMode: () => operativeMode,
39
+ provisionVerdict: () => provisionVerdict,
40
+ putRequest: () => putRequest,
41
+ reclampShare: () => reclampShare,
42
+ reconfirmGrant: () => reconfirmGrant,
43
+ removeGrant: () => removeGrant,
44
+ removePrincipalGrants: () => removePrincipalGrants,
45
+ renamePrincipalGrants: () => renamePrincipalGrants,
46
+ resolveAccess: () => resolveAccess,
47
+ resolveRequest: () => resolveRequest,
48
+ roleMax: () => roleMax,
49
+ roleMin: () => roleMin,
50
+ rosterEtag: () => rosterEtag,
51
+ saveShare: () => saveShare,
52
+ setBlocked: () => setBlocked,
53
+ setGeneralMode: () => setGeneralMode,
54
+ shareFile: () => shareFile,
55
+ shareState: () => shareState,
56
+ upsertGrant: () => upsertGrant
57
+ });
58
+ const RANK = {
59
+ none: 0,
60
+ view: 1,
61
+ comment: 2
62
+ };
63
+ const roleMin = (a, b) => RANK[a] <= RANK[b] ? a : b;
64
+ const roleMax = (a, b) => RANK[a] >= RANK[b] ? a : b;
65
+ const shareFile = (dir) => join(dir, "share.json");
66
+ /** meta.json rights → ceilings. `read` publishes at view; `comment` at comment. */
67
+ const ceilingsFromRights = (rights) => Object.fromEntries(Object.entries(rights).map(([b, r]) => [b, r === "comment" ? "comment" : "view"]));
68
+ const ROLES = /* @__PURE__ */ new Set([
69
+ "none",
70
+ "view",
71
+ "comment"
72
+ ]);
73
+ const MODES = /* @__PURE__ */ new Set([
74
+ "private",
75
+ "password",
76
+ "public"
77
+ ]);
78
+ /** Fail CLOSED on anything malformed: an unknown role string must never rank
79
+ * above a real one, and a misspelt mode must never open the anonymous door. */
80
+ function validateStore(p) {
81
+ const bad = (why) => {
82
+ throw new Error(`share store is malformed (${why}) - refusing to load it. Fix or delete it deliberately.`);
83
+ };
84
+ if (p?.version !== 1) bad("version must be 1");
85
+ if (!MODES.has(p?.general?.mode)) bad(`general.mode "${p?.general?.mode}"`);
86
+ if (p?.general?.role !== "view") bad("general.role must be \"view\"");
87
+ if (!Array.isArray(p?.blocked) || p.blocked.some((b) => typeof b !== "string")) bad("blocked must be addresses");
88
+ if (!Array.isArray(p?.grants)) bad("grants must be an array");
89
+ for (const g of p.grants) {
90
+ if (typeof g?.principal !== "string" || !g.principal) bad("grant principal");
91
+ if (g?.scope !== "canvas" && !(typeof g?.scope === "string" && g.scope.startsWith("board:"))) bad(`grant scope "${g?.scope}"`);
92
+ if (g?.assigned !== "view" && g?.assigned !== "comment") bad(`grant assigned "${g?.assigned}"`);
93
+ if (typeof g?.boardRole !== "object" || g.boardRole === null) bad("grant boardRole");
94
+ for (const r of Object.values(g.boardRole)) if (!ROLES.has(r)) bad(`boardRole value "${r}"`);
95
+ if (g?.expires !== null && typeof g?.expires !== "string") bad("grant expires");
96
+ }
97
+ }
98
+ /** Null when no share.json exists (pre-migration) - callers keep legacy rules.
99
+ * A present-but-unreadable/corrupt/malformed file fails CLOSED. */
100
+ function loadShare(dir) {
101
+ let raw;
102
+ try {
103
+ raw = readFileSync(shareFile(dir), "utf8");
104
+ } catch (err) {
105
+ if (err.code === "ENOENT") return null;
106
+ throw new Error(`share store unreadable (${err.message}) - refusing to treat it as absent`);
107
+ }
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ } catch {
112
+ throw new Error("share store is corrupt JSON - refusing to treat it as absent. Restore it or delete it deliberately.");
113
+ }
114
+ validateStore(parsed);
115
+ return parsed;
116
+ }
117
+ /** Atomic rewrite; expired grants garbage-collect on every write, the same sweep
118
+ * invites and sessions already ride. 0600 - the roster is a list of addresses.
119
+ * The request-path cache is refreshed HERE, in-process: the supported setup is
120
+ * single-instance, so the writer and every reader share this module, and an
121
+ * mtime tie on a coarse filesystem can never serve a pre-revoke roster. */
122
+ function saveShare(dir, store) {
123
+ const now = Date.now();
124
+ store.grants = store.grants.filter((g) => !g.expires || Date.parse(g.expires) > now);
125
+ const file = shareFile(dir);
126
+ mkdirSync(dirname(file), { recursive: true });
127
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
128
+ const fd = openSync(tmp, "wx", 384);
129
+ try {
130
+ writeSync(fd, JSON.stringify(store, null, 2));
131
+ fsyncSync(fd);
132
+ } finally {
133
+ closeSync(fd);
134
+ }
135
+ renameSync(tmp, file);
136
+ cache.set(dir, {
137
+ mtime: statSync(file).mtimeMs,
138
+ store
139
+ });
140
+ }
141
+ const cache = /* @__PURE__ */ new Map();
142
+ function shareState(dir) {
143
+ const file = shareFile(dir);
144
+ let mtime = -1;
145
+ try {
146
+ mtime = statSync(file).mtimeMs;
147
+ } catch {}
148
+ const hit = cache.get(dir);
149
+ if (hit && hit.mtime === mtime) return hit.store;
150
+ const store = mtime < 0 ? null : loadShare(dir);
151
+ cache.set(dir, {
152
+ mtime,
153
+ store
154
+ });
155
+ return store;
156
+ }
157
+ /** Materialise boardRole entries for a grant, clamped: canvas scope covers every
158
+ * published board, a board scope exactly its board. Existing entries are kept
159
+ * (the ratchet) unless the ceiling pulls them down. */
160
+ function materialise(g, ceilings) {
161
+ const boards = g.scope === "canvas" ? Object.keys(ceilings) : [g.scope.slice(6)];
162
+ for (const b of boards) {
163
+ if (!(b in ceilings)) continue;
164
+ g.boardRole[b] = roleMin(g.boardRole[b] ?? g.assigned, ceilings[b]);
165
+ }
166
+ }
167
+ /** The boot re-clamp (01-sharing §3.6): atomically, before serving. Ceilings
168
+ * pull entries down never up; boards new to this build get entries at
169
+ * min(assigned, ceiling). Entries for boards no longer published are kept -
170
+ * the ratchet must survive a board leaving and returning. */
171
+ function reclampShare(dir, ceilings) {
172
+ withLock(dir, () => {
173
+ const store = loadShare(dir);
174
+ if (!store) return;
175
+ for (const g of store.grants) {
176
+ materialise(g, ceilings);
177
+ for (const b of Object.keys(g.boardRole)) if (b in ceilings) g.boardRole[b] = roleMin(g.boardRole[b], ceilings[b]);
178
+ }
179
+ saveShare(dir, store);
180
+ }, ".share.lock");
181
+ }
182
+ /**
183
+ * First-boot migration (01-sharing §10): every live 0.11 canvas keeps its exact
184
+ * behaviour. The generated roster is a normal file the owner can read; creating
185
+ * it is logged by the caller. Existing accounts each get a canvas-scoped
186
+ * `comment` grant (clamped per board) because that is precisely what they could
187
+ * do yesterday; general access mirrors the gate the environment configures.
188
+ */
189
+ function ensureShare(dir, mode, users, ceilings) {
190
+ return withLock(dir, () => {
191
+ if (existsSync(shareFile(dir))) return { created: false };
192
+ const at = (/* @__PURE__ */ new Date()).toISOString();
193
+ saveShare(dir, {
194
+ version: 1,
195
+ general: {
196
+ mode,
197
+ role: "view"
198
+ },
199
+ blocked: [],
200
+ grants: users.map((u) => {
201
+ const g = {
202
+ principal: normEmail(u.email),
203
+ scope: "canvas",
204
+ assigned: "comment",
205
+ boardRole: {},
206
+ expires: null,
207
+ by: "migration",
208
+ at
209
+ };
210
+ materialise(g, ceilings);
211
+ return g;
212
+ })
213
+ });
214
+ return { created: true };
215
+ }, ".share.lock");
216
+ }
217
+ /** Upsert a grant (idempotent by principal+scope) and materialise its entries.
218
+ * Re-granting REPLACES: new assigned, fresh ratchet - the owner just said so.
219
+ *
220
+ * v1 accepts `scope: "canvas"` ONLY (04-solution §9.4): before read privacy
221
+ * exists, a board-scoped grant would open the whole bundle while reading as
222
+ * "just this board" - the schema stays v2-ready, the door refuses. Domain
223
+ * principals need the identity gate: a canvas that cannot verify addresses
224
+ * cannot verify domains (01-sharing §4.4), and they ship only together with
225
+ * the blocklist, which is why creation demands `identityMode`. */
226
+ function upsertGrant(dir, ceilings, input, opts = {}) {
227
+ if (input.scope !== "canvas") throw new Error("v1 accepts canvas-scoped grants only - board scopes arrive with read privacy (v2)");
228
+ if (input.principal.startsWith("@") && !opts.identityMode) throw new Error("domain grants need the identity gate - a password canvas cannot verify who holds an address");
229
+ return withLock(dir, () => {
230
+ const store = loadShare(dir);
231
+ if (!store) throw new Error("no share store - the canvas has not booted under v2 yet");
232
+ const principal = input.principal.startsWith("@") ? input.principal.toLowerCase().trim() : normEmail(input.principal);
233
+ const now = Date.now();
234
+ const prior = store.grants.find((g) => g.principal === principal && g.scope === input.scope && live(g, now));
235
+ const changed = !prior || prior.assigned !== input.assigned;
236
+ store.grants = store.grants.filter((g) => !(g.principal === principal && g.scope === input.scope));
237
+ const g = {
238
+ principal,
239
+ scope: input.scope,
240
+ assigned: input.assigned,
241
+ boardRole: !changed && prior ? { ...prior.boardRole } : {},
242
+ expires: input.expires ?? null,
243
+ by: input.by,
244
+ at: (/* @__PURE__ */ new Date()).toISOString()
245
+ };
246
+ materialise(g, ceilings);
247
+ store.grants.push(g);
248
+ saveShare(dir, store);
249
+ return {
250
+ ...g,
251
+ changed
252
+ };
253
+ }, ".share.lock");
254
+ }
255
+ /** Remove every grant held by an exact principal. Ridden by account revocation:
256
+ * `revokeUser` alone would leave the grant behind, and on an identity canvas
257
+ * the next sign-in would quietly re-provision the account it just removed. */
258
+ function removePrincipalGrants(dir, email) {
259
+ withLock(dir, () => {
260
+ const store = loadShare(dir);
261
+ if (!store) return;
262
+ const norm = normEmail(email);
263
+ const before = store.grants.length;
264
+ store.grants = store.grants.filter((g) => g.principal !== norm);
265
+ if (store.grants.length !== before) saveShare(dir, store);
266
+ }, ".share.lock");
267
+ }
268
+ /** Follow a verified rename: exact grants move to the address the same subject
269
+ * now holds - the owner granted the person, and the address is a label on
270
+ * them (docs promise: renames follow the subject). Domain grants never move;
271
+ * they simply re-evaluate against the new address. A grant already existing
272
+ * for the new address wins (it is the newer statement of intent). */
273
+ function renamePrincipalGrants(dir, fromEmail, toEmail) {
274
+ withLock(dir, () => {
275
+ const store = loadShare(dir);
276
+ if (!store) return;
277
+ const from = normEmail(fromEmail), to = normEmail(toEmail);
278
+ let changed = false;
279
+ for (const g of store.grants) {
280
+ if (g.principal !== from) continue;
281
+ if (store.grants.some((o) => o !== g && o.principal === to && o.scope === g.scope)) continue;
282
+ g.principal = to;
283
+ changed = true;
284
+ }
285
+ if (changed) {
286
+ store.grants = store.grants.filter((g) => g.principal !== from);
287
+ saveShare(dir, store);
288
+ }
289
+ }, ".share.lock");
290
+ }
291
+ const domainOf = (email) => normEmail(email).split("@")[1] ?? "";
292
+ const grantMatches = (g, email) => g.principal.startsWith("@") ? g.principal.slice(1) === domainOf(email) : g.principal === normEmail(email);
293
+ const live = (g, now) => !g.expires || Date.parse(g.expires) > now;
294
+ /**
295
+ * The pure resolver. Blocklist first (the only deny), then grants additive
296
+ * (highest wins, read through the boardRole ratchet with the read-time ceiling
297
+ * min), general access contributes `view` when the mode is not Private, and the
298
+ * owner/operator ROLE precedes principal matching exactly as `admin` does today
299
+ * (clamped by the ceiling like everything else - an unpublished board is empty
300
+ * even for the owner).
301
+ *
302
+ * `email: null` is an anonymous caller - someone past a password gate or on a
303
+ * public canvas. They have no principal to match and cannot be blocked (there
304
+ * is no identity to block), which is 01-sharing §3.4 stated as code.
305
+ *
306
+ * `exactOnly` drops domain grants from consideration: the password sign-in
307
+ * door uses it, because domain membership is only meaningful when an identity
308
+ * service verified the address (01-sharing §4.4).
309
+ */
310
+ function resolveAccess(input) {
311
+ const { store, ceilings } = input;
312
+ const email = input.email ? normEmail(input.email) : null;
313
+ const now = Date.now();
314
+ const trace = [];
315
+ const boards = {};
316
+ if (email && store.blocked.some((b) => normEmail(b) === email)) {
317
+ for (const b of Object.keys(ceilings)) boards[b] = "none";
318
+ trace.push({
319
+ where: "blocklist",
320
+ role: "none",
321
+ why: `${email} is blocked - refused everywhere, ahead of every grant`,
322
+ win: true
323
+ });
324
+ return {
325
+ entry: false,
326
+ boards,
327
+ role: "none",
328
+ trace
329
+ };
330
+ }
331
+ trace.push({
332
+ where: "blocklist",
333
+ role: "none",
334
+ why: email ? "not blocked" : "anonymous - no identity to block",
335
+ win: false
336
+ });
337
+ const matching = email ? store.grants.filter((g) => live(g, now) && grantMatches(g, email) && !(input.exactOnly && g.principal.startsWith("@"))) : [];
338
+ const anonView = store.general.mode !== "private" ? "view" : "none";
339
+ let top = "none";
340
+ for (const b of Object.keys(ceilings)) {
341
+ let r = "none";
342
+ if (input.userRole === "owner") r = roleMin("comment", ceilings[b]);
343
+ for (const g of matching) r = roleMax(r, roleMin(g.boardRole[b] ?? "none", ceilings[b]));
344
+ r = roleMax(r, roleMin(anonView, ceilings[b]));
345
+ boards[b] = r;
346
+ top = roleMax(top, r);
347
+ }
348
+ if (input.userRole === "owner") trace.push({
349
+ where: "role",
350
+ role: "comment",
351
+ why: "canvas owner - precedes principal matching",
352
+ win: true
353
+ });
354
+ if (matching.length) {
355
+ const best = matching.map((g) => `${g.principal} ${g.assigned}${g.expires ? ` until ${g.expires.slice(0, 10)}` : ""}`).join(", ");
356
+ trace.push({
357
+ where: "grants",
358
+ role: top,
359
+ why: `highest of: ${best}`,
360
+ win: input.userRole !== "owner"
361
+ });
362
+ } else if (email && input.userRole !== "owner") trace.push({
363
+ where: "grants",
364
+ role: "none",
365
+ why: "no matching grant",
366
+ win: false
367
+ });
368
+ if (anonView !== "none") trace.push({
369
+ where: "general",
370
+ role: "view",
371
+ why: `general access is ${store.general.mode} - anyone admitted reads`,
372
+ win: top === "view" && !matching.length && input.userRole !== "owner"
373
+ });
374
+ trace.push({
375
+ where: "ceiling",
376
+ role: top,
377
+ why: "publish.json clamps per board - reads use min(ceiling, boardRole)",
378
+ win: false
379
+ });
380
+ return {
381
+ entry: RANK[top] >= RANK.view,
382
+ boards,
383
+ role: top,
384
+ trace
385
+ };
386
+ }
387
+ /** What the gate consults, request-path cheap: is this identified person still
388
+ * let in at all? Owner always is (someone must administer). Pre-migration
389
+ * (no store) keeps legacy behaviour: any valid session is gate passage. */
390
+ function entryAllowed(dir, user, ceilings) {
391
+ const store = shareState(dir);
392
+ if (!store) return true;
393
+ if (user.role === "owner") return true;
394
+ return resolveAccess({
395
+ email: user.email,
396
+ userRole: user.role,
397
+ store,
398
+ ceilings
399
+ }).entry;
400
+ }
401
+ /** The comment seam's new internals: may this person write on this board.
402
+ * Pre-migration keeps today's rule (any signed-in user on a comment board). */
403
+ function commentAllowed(dir, user, board, ceilings) {
404
+ const store = shareState(dir);
405
+ if (!store) return ceilings[board] === "comment";
406
+ if (ceilings[board] !== "comment") return false;
407
+ return resolveAccess({
408
+ email: user.email,
409
+ userRole: user.role,
410
+ store,
411
+ ceilings
412
+ }).boards[board] === "comment";
413
+ }
414
+ /**
415
+ * The provisioning doors' question: may this address be admitted at all.
416
+ * `blocked` beats everything. With ceilings in hand (every real server has
417
+ * them) the answer is the ONE resolver's entry test; without them (bare test
418
+ * harnesses only) it falls back to the materialised entries, which are
419
+ * ceiling-clamped already. `aliases` lets a verified rename count the grants
420
+ * the subject held under its previous address.
421
+ */
422
+ function provisionVerdict(store, email, opts = {}) {
423
+ const addresses = [email, ...opts.aliases ?? []].map(normEmail);
424
+ for (const a of addresses) if (store.blocked.some((b) => normEmail(b) === a)) return "blocked";
425
+ const now = Date.now();
426
+ for (const a of addresses) if (opts.ceilings) {
427
+ if (resolveAccess({
428
+ email: a,
429
+ store: {
430
+ ...store,
431
+ general: {
432
+ mode: "private",
433
+ role: "view"
434
+ }
435
+ },
436
+ ceilings: opts.ceilings,
437
+ exactOnly: opts.exactOnly
438
+ }).entry) return "granted";
439
+ } else if (store.grants.some((g) => live(g, now) && grantMatches(g, a) && !(opts.exactOnly && g.principal.startsWith("@")) && Object.values(g.boardRole).some((r) => RANK[r] >= RANK.view))) return "granted";
440
+ return "none";
441
+ }
442
+ /** An unexpired v1 invite, redeemed after migration, materialises the same
443
+ * canvas-scoped comment grant an existing account received (01-sharing §10) -
444
+ * because that is exactly what claiming it would have produced under v1.
445
+ * A no-op before migration (no store) and on an address already granted. */
446
+ function grantFromInviteRedemption(dir, email, ceilings = {}) {
447
+ const store = shareState(dir);
448
+ if (!store) return;
449
+ const principal = normEmail(email);
450
+ if (store.grants.some((g) => g.principal === principal && g.scope === "canvas")) return;
451
+ upsertGrant(dir, ceilings, {
452
+ principal,
453
+ scope: "canvas",
454
+ assigned: "comment",
455
+ by: "invite"
456
+ });
457
+ }
458
+ /** The operative general mode: what the environment can actually enforce clamps
459
+ * what the roster asks for. No gate can only be Public; a password gate cannot
460
+ * be Public (the password would be theater); an identity gate is Private in v1. */
461
+ function operativeMode(stored, env) {
462
+ if (env.issuer) return "private";
463
+ if (env.password) return stored === "private" ? "private" : "password";
464
+ return "public";
465
+ }
466
+ /** The strong ETag mutations are conditioned on: a hash of the exact stored
467
+ * roster bytes - share.json AND the pending requests, because approving a
468
+ * request is a mutation the roster read included, and a replay after any
469
+ * intervening change (a re-ask, a decline) must fail its precondition. */
470
+ function rosterEtag(dir) {
471
+ let raw = "";
472
+ try {
473
+ raw = readFileSync(shareFile(dir), "utf8");
474
+ } catch {}
475
+ let reqRaw = "";
476
+ try {
477
+ reqRaw = readFileSync(join(dir, "requests.json"), "utf8");
478
+ } catch {}
479
+ return createHash("sha256").update(raw).update("\n").update(reqRaw).digest("hex").slice(0, 32);
480
+ }
481
+ function removeGrant(dir, principal, scope) {
482
+ withLock(dir, () => {
483
+ const store = loadShare(dir);
484
+ if (!store) return;
485
+ const norm = principal.startsWith("@") ? principal.toLowerCase().trim() : normEmail(principal);
486
+ const before = store.grants.length;
487
+ store.grants = store.grants.filter((g) => !(g.principal === norm && g.scope === scope));
488
+ if (store.grants.length !== before) saveShare(dir, store);
489
+ }, ".share.lock");
490
+ }
491
+ /** The re-confirm badge's verb: raises ONE board's ratchet entry back to
492
+ * min(assigned, ceiling) - the only thing that ever raises one (01-sharing §3.6). */
493
+ function reconfirmGrant(dir, ceilings, principal, scope, board) {
494
+ withLock(dir, () => {
495
+ const store = loadShare(dir);
496
+ if (!store) throw new Error("no share store");
497
+ const norm = principal.startsWith("@") ? principal.toLowerCase().trim() : normEmail(principal);
498
+ const g = store.grants.find((x) => x.principal === norm && x.scope === scope);
499
+ if (!g) throw new Error("no such grant");
500
+ if (!(board in ceilings)) throw new Error("no such board");
501
+ g.boardRole[board] = roleMin(g.assigned, ceilings[board]);
502
+ saveShare(dir, store);
503
+ }, ".share.lock");
504
+ }
505
+ function setGeneralMode(dir, mode) {
506
+ withLock(dir, () => {
507
+ const store = loadShare(dir);
508
+ if (!store) throw new Error("no share store");
509
+ store.general = {
510
+ mode,
511
+ role: "view"
512
+ };
513
+ saveShare(dir, store);
514
+ }, ".share.lock");
515
+ }
516
+ function setBlocked(dir, address, blocked) {
517
+ withLock(dir, () => {
518
+ const store = loadShare(dir);
519
+ if (!store) throw new Error("no share store");
520
+ const norm = normEmail(address);
521
+ const has = store.blocked.some((b) => normEmail(b) === norm);
522
+ if (blocked && !has) store.blocked.push(norm);
523
+ else if (!blocked && has) store.blocked = store.blocked.filter((b) => normEmail(b) !== norm);
524
+ else return;
525
+ saveShare(dir, store);
526
+ }, ".share.lock");
527
+ }
528
+ const requestsFile = (dir) => join(dir, "requests.json");
529
+ function loadRequests(dir) {
530
+ try {
531
+ const p = JSON.parse(readFileSync(requestsFile(dir), "utf8"));
532
+ const now = Date.now();
533
+ return Array.isArray(p) ? p.filter((r) => typeof r?.email === "string" && r.exp > now) : [];
534
+ } catch {
535
+ return [];
536
+ }
537
+ }
538
+ function saveRequests(dir, rows) {
539
+ const file = requestsFile(dir);
540
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
541
+ const fd = openSync(tmp, "wx", 384);
542
+ try {
543
+ writeSync(fd, JSON.stringify(rows));
544
+ fsyncSync(fd);
545
+ } finally {
546
+ closeSync(fd);
547
+ }
548
+ renameSync(tmp, file);
549
+ }
550
+ function putRequest(dir, req) {
551
+ return withLock(dir, () => {
552
+ const existing = loadRequests(dir);
553
+ const fresh = !existing.some((r) => normEmail(r.email) === normEmail(req.email));
554
+ const at = (/* @__PURE__ */ new Date()).toISOString();
555
+ const rows = existing.filter((r) => normEmail(r.email) !== normEmail(req.email));
556
+ rows.push({
557
+ ...req,
558
+ email: normEmail(req.email),
559
+ at,
560
+ exp: Date.now() + 2592e6
561
+ });
562
+ saveRequests(dir, rows);
563
+ return {
564
+ fresh,
565
+ at
566
+ };
567
+ }, ".share.lock");
568
+ }
569
+ /** Approving adds the grant (canvas-wide in v1 - the dialog says so) and
570
+ * resolves the row; declining just resolves it, silently to the asker. */
571
+ function resolveRequest(dir, ceilings, email, approve) {
572
+ const row = loadRequests(dir).find((r) => normEmail(r.email) === normEmail(email));
573
+ if (!row) return false;
574
+ if (approve) upsertGrant(dir, ceilings, {
575
+ principal: row.email,
576
+ scope: "canvas",
577
+ assigned: approve.assigned,
578
+ by: approve.by
579
+ });
580
+ withLock(dir, () => {
581
+ saveRequests(dir, loadRequests(dir).filter((r) => normEmail(r.email) !== normEmail(email)));
582
+ }, ".share.lock");
583
+ return true;
584
+ }
585
+ //#endregion
586
+ //#region src/server/auth.ts
587
+ /**
588
+ * Accounts, invites, sessions - the minimal credible implementation,
589
+ * extending the gate's own idiom. No framework: per-user salted scrypt verifiers,
590
+ * opaque session tokens stored hashed, single-use invite links as the identity
591
+ * bootstrap (no email infrastructure anywhere).
592
+ *
593
+ * Password = read, account = comment: everything here concerns accounts only; the
594
+ * shared gate password (serve.ts) remains the outer READ boundary.
595
+ *
596
+ * State lives in MARVER_DATA_DIR as two small JSON files rewritten atomically -
597
+ * users change rarely; the event-log treatment is reserved for comments.
598
+ */
599
+ var auth_exports = /* @__PURE__ */ __exportAll({
600
+ CLI_TOKEN_CHARS: () => CLI_TOKEN_CHARS,
601
+ MIN_CLI_TOKEN: () => 32,
602
+ attachAvatar: () => attachAvatar,
603
+ avatarSourceFor: () => avatarSourceFor,
604
+ canvasSecret: () => canvasSecret,
605
+ claimInvite: () => claimInvite,
606
+ cliTokenProblem: () => cliTokenProblem,
607
+ createInvite: () => createInvite,
608
+ inviteInfo: () => inviteInfo,
609
+ issueDeviceSession: () => issueDeviceSession,
610
+ loadStore: () => loadStore,
611
+ normEmail: () => normEmail,
612
+ opaqueId: () => opaqueId,
613
+ operatorUser: () => operatorUser,
614
+ ownerName: () => ownerName,
615
+ provisionFromMarverId: () => provisionFromMarverId,
616
+ publicUser: () => publicUser,
617
+ revokeUser: () => revokeUser,
618
+ sessionIsOperator: () => sessionIsOperator,
619
+ sessionUser: () => sessionUser,
620
+ signIn: () => signIn,
621
+ signOut: () => signOut,
622
+ updateProfile: () => updateProfile,
623
+ withLock: () => withLock
624
+ });
625
+ const SCRYPT = {
626
+ N: 2 ** 15,
627
+ r: 8,
628
+ p: 1,
629
+ keylen: 32,
630
+ maxmem: 67108864
631
+ };
632
+ const INVITE_TTL = 6048e5;
633
+ const SESSION_TTL = 2592e6;
634
+ const normEmail = (e) => e.trim().toLowerCase();
635
+ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
636
+ const token = () => randomBytes(32).toString("base64url");
637
+ const storeFile = (dir) => join(dir, "auth.json");
638
+ /** Only a MISSING file is an empty store. A present-but-unreadable/corrupt auth.json
639
+ * must fail CLOSED - treating it as empty would let the owner bootstrap re-run and
640
+ * a later save overwrite every account. */
641
+ function loadStore(dir) {
642
+ let raw;
643
+ try {
644
+ raw = readFileSync(storeFile(dir), "utf8");
645
+ } catch (err) {
646
+ if (err.code === "ENOENT") return {
647
+ users: [],
648
+ invites: [],
649
+ sessions: []
650
+ };
651
+ throw new Error(`auth store unreadable (${err.message}) - refusing to treat it as empty`);
652
+ }
653
+ let parsed;
654
+ try {
655
+ parsed = JSON.parse(raw);
656
+ } catch {
657
+ throw new Error("auth store is corrupt JSON - refusing to treat it as empty. Restore it or delete it deliberately.");
658
+ }
659
+ if (!Array.isArray(parsed?.users) || !Array.isArray(parsed?.invites) || !Array.isArray(parsed?.sessions)) throw new Error("auth store has an unexpected shape - refusing to load it");
660
+ return parsed;
661
+ }
662
+ /** Atomic rewrite (tmp + rename) - a crash mid-write must never lose every account.
663
+ * 0600 throughout: the store holds emails and password verifiers. */
664
+ function saveStore(dir, store) {
665
+ const file = storeFile(dir);
666
+ mkdirSync(dirname(file), { recursive: true });
667
+ const now = Date.now();
668
+ store.invites = store.invites.filter((i) => i.exp > now);
669
+ store.sessions = store.sessions.filter((s) => s.exp > now);
670
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
671
+ const fd = openSync(tmp, "wx", 384);
672
+ try {
673
+ writeSync(fd, JSON.stringify(store, null, 2));
674
+ fsyncSync(fd);
675
+ } finally {
676
+ closeSync(fd);
677
+ }
678
+ renameSync(tmp, file);
679
+ }
680
+ /** Cross-process mutex over auth.json's read-modify-write. Within one Node process
681
+ * the store mutations are already synchronous and atomic; this covers deploy overlap
682
+ * and any accidental multi-instance run (the supported setup is single-instance) -
683
+ * without it, a sign-in that loaded a pre-revoke snapshot could rename it back over a
684
+ * successful revoke. A crashed holder's lock is stolen after 10s; waiting past 5s
685
+ * fails loudly rather than hanging. */
686
+ function withLock(dir, fn, lockName = ".auth.lock") {
687
+ mkdirSync(dir, { recursive: true });
688
+ const lock = join(dir, lockName);
689
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
690
+ const deadline = Date.now() + 5e3;
691
+ for (;;) try {
692
+ closeSync(openSync(lock, "wx"));
693
+ break;
694
+ } catch (e) {
695
+ if (e.code !== "EEXIST") throw e;
696
+ try {
697
+ if (Date.now() - statSync(lock).mtimeMs > 1e4) {
698
+ unlinkSync(lock);
699
+ continue;
700
+ }
701
+ } catch {
702
+ continue;
703
+ }
704
+ if (Date.now() > deadline) throw new Error("auth store is busy - please retry");
705
+ nap(25);
706
+ }
707
+ try {
708
+ return fn();
709
+ } finally {
710
+ try {
711
+ unlinkSync(lock);
712
+ } catch {}
713
+ }
714
+ }
715
+ const findUser = (store, email) => store.users.find((u) => normEmail(u.email) === normEmail(email));
716
+ /** Mint a single-use invite for an email (this IS the allowlist entry - inviting an
717
+ * address authorizes it). Re-inviting an email replaces its pending invite. The raw
718
+ * token is returned exactly once; only its hash is stored. */
719
+ /** The owner's display name - public-safe (never the email). Null before claim. */
720
+ function ownerName(dir) {
721
+ return loadStore(dir).users.find((u) => u.role === "owner")?.name?.trim() || null;
722
+ }
723
+ /** Peek at a live invite: the claim screens show WHO the invite is for. The raw
724
+ * token is the proof - holding it means the owner sent it to you. */
725
+ function inviteInfo(dir, rawToken) {
726
+ const hash = sha256(rawToken);
727
+ const invite = loadStore(dir).invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
728
+ return invite ? { email: invite.emailNorm } : null;
729
+ }
730
+ function createInvite(dir, email) {
731
+ return withLock(dir, () => {
732
+ const store = loadStore(dir);
733
+ if (findUser(store, email)) throw new Error(`${normEmail(email)} already has an account`);
734
+ const raw = token();
735
+ const exp = Date.now() + INVITE_TTL;
736
+ store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
737
+ store.invites.push({
738
+ emailNorm: normEmail(email),
739
+ tokenHash: sha256(raw),
740
+ exp
741
+ });
742
+ saveStore(dir, store);
743
+ return {
744
+ token: raw,
745
+ exp
746
+ };
747
+ });
748
+ }
749
+ /** Claim an invite: burns it, creates the account, opens the first session. */
750
+ function claimInvite(dir, rawToken, profile, ceilings = {}) {
751
+ if (profile.password.length < 8) throw new Error("password must be at least 8 characters");
752
+ if (!profile.name.trim()) throw new Error("a display name is required");
753
+ return withLock(dir, () => {
754
+ const store = loadStore(dir);
755
+ const hash = sha256(rawToken);
756
+ const invite = store.invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
757
+ if (!invite) throw new Error("this invite link is invalid, expired, or already used");
758
+ if (findUser(store, invite.emailNorm)) {
759
+ store.invites = store.invites.filter((i) => i !== invite);
760
+ saveStore(dir, store);
761
+ throw new Error("an account already exists for this address - sign in instead");
762
+ }
763
+ store.invites = store.invites.filter((i) => i !== invite);
764
+ const salt = randomBytes(16).toString("hex");
765
+ const user = {
766
+ email: invite.emailNorm,
767
+ name: profile.name.trim(),
768
+ avatar: profile.avatar,
769
+ role: store.users.length ? "member" : "owner",
770
+ salt,
771
+ hash: scryptSync(profile.password, Buffer.from(salt, "hex"), SCRYPT.keylen, SCRYPT).toString("hex"),
772
+ params: SCRYPT,
773
+ createdAt: Date.now()
774
+ };
775
+ store.users.push(user);
776
+ grantFromInviteRedemption(dir, invite.emailNorm, ceilings);
777
+ const session = pushSession(store, user);
778
+ saveStore(dir, store);
779
+ return {
780
+ user,
781
+ session
782
+ };
783
+ });
784
+ }
785
+ /** Password sign-in. One generic failure - never reveal whether the email exists. */
786
+ function signIn(dir, email, password, ceilings) {
787
+ return withLock(dir, () => {
788
+ const store = loadStore(dir);
789
+ const found = findUser(store, email);
790
+ const user = found && found.auth !== "marver-id" && found.salt && found.hash ? found : null;
791
+ const salt = user ? Buffer.from(user.salt, "hex") : randomBytes(16);
792
+ const params = user?.params ?? SCRYPT;
793
+ const got = scryptSync(password, salt, params.keylen, params);
794
+ const want = user ? Buffer.from(user.hash, "hex") : randomBytes(SCRYPT.keylen);
795
+ if (!user || got.length !== want.length || !timingSafeEqual(got, want)) return null;
796
+ const share = shareState(dir);
797
+ if (share && user.role !== "owner") {
798
+ if (provisionVerdict(share, user.email, {
799
+ ceilings,
800
+ exactOnly: true
801
+ }) !== "granted") return null;
802
+ }
803
+ const session = pushSession(store, user);
804
+ saveStore(dir, store);
805
+ return {
806
+ user,
807
+ session
808
+ };
809
+ });
810
+ }
811
+ /**
812
+ * Turn a verified Marver ID identity into a local session.
813
+ *
814
+ * The identity service has already proved who this person is; this function
815
+ * decides whether they may in - and that decision is LOCAL, which is the whole
816
+ * shape of L1a. The identity service knows nothing about who is allowed where.
817
+ *
818
+ * `allowed` is the owner's allowlist. An email that is not on it gets no account
819
+ * and no session: being able to prove you are someone is not the same as being
820
+ * invited. The first allowed account to arrive owns the canvas, matching the
821
+ * invite flow's rule.
822
+ *
823
+ * No password is fabricated. A marver-id account carries no salt or hash at all,
824
+ * so there is no second, weaker door into it.
825
+ */
826
+ function provisionFromMarverId(dir, identity, opts = {}) {
827
+ const emailNorm = normEmail(identity.email);
828
+ if (!emailNorm) return null;
829
+ return withLock(dir, () => {
830
+ const store = loadStore(dir);
831
+ const qualified = `${identity.issuer}#${identity.subject}`;
832
+ const bound = store.users.find((u) => u.idSubject === qualified);
833
+ const existing = bound ?? findUser(store, emailNorm);
834
+ let vacated = null;
835
+ if (bound && normEmail(bound.email) !== emailNorm) {
836
+ const clash = findUser(store, emailNorm);
837
+ if (clash && clash !== bound) return null;
838
+ vacated = normEmail(bound.email);
839
+ bound.email = emailNorm;
840
+ store.sessions = store.sessions.filter((s) => s.emailNorm !== vacated);
841
+ }
842
+ const invite = store.invites.find((i) => i.emailNorm === emailNorm && i.exp > Date.now());
843
+ const ownerNorm = opts.ownerEmail ? normEmail(opts.ownerEmail) : "";
844
+ const reservedForOwner = !!ownerNorm && !store.users.length && ownerNorm !== emailNorm;
845
+ const isBootstrapOwner = !!ownerNorm && !store.users.length && ownerNorm === emailNorm;
846
+ if (reservedForOwner) return null;
847
+ const share = shareState(dir);
848
+ if (share) {
849
+ const verdict = provisionVerdict(share, emailNorm, {
850
+ ceilings: opts.ceilings,
851
+ aliases: vacated ? [vacated] : void 0
852
+ });
853
+ if (verdict === "blocked") return null;
854
+ if (!(verdict === "granted" || existing?.role === "owner" || !!invite || isBootstrapOwner)) return null;
855
+ } else if (!existing && !invite && !isBootstrapOwner) return null;
856
+ if (invite) store.invites = store.invites.filter((i) => i !== invite);
857
+ let user = existing;
858
+ if (user) {
859
+ if (!user.idSubject) user.idSubject = qualified;
860
+ else if (user.idSubject === identity.subject) user.idSubject = qualified;
861
+ else if (user.idSubject !== qualified) return null;
862
+ } else {
863
+ user = {
864
+ email: emailNorm,
865
+ name: identity.name || emailNorm.split("@")[0] || emailNorm,
866
+ role: store.users.length ? "member" : "owner",
867
+ auth: "marver-id",
868
+ idSubject: `${identity.issuer}#${identity.subject}`,
869
+ createdAt: Date.now()
870
+ };
871
+ store.users.push(user);
872
+ }
873
+ if (identity.name && (!user.name || user.name === emailNorm.split("@")[0])) user.name = identity.name;
874
+ if (invite) grantFromInviteRedemption(dir, emailNorm, opts.ceilings);
875
+ if (vacated) renamePrincipalGrants(dir, vacated, emailNorm);
876
+ const session = pushSession(store, user);
877
+ saveStore(dir, store);
878
+ return {
879
+ user,
880
+ session
881
+ };
882
+ });
883
+ }
884
+ /**
885
+ * Attach a picture the identity service supplied.
886
+ *
887
+ * Separate from provisioning because the fetch happens after admission and
888
+ * outside the lock, so by the time there are bytes the account already exists.
889
+ *
890
+ * `avatarSource` is what makes this safe to repeat: its presence means "this
891
+ * picture came from the identity service", and only such a picture may be
892
+ * replaced. An avatar with no source was chosen HERE, by the person, and the
893
+ * identity service does not get to overwrite it - which is the same rule the
894
+ * name follows.
895
+ *
896
+ * Replacing a rotated one is the point. Refusing to, as an earlier version did,
897
+ * meant the stored source never caught up with the assertion, so every single
898
+ * sign-in fetched the new picture and then threw it away.
899
+ */
900
+ function attachAvatar(dir, subjectQualified, avatar, source, expected) {
901
+ withLock(dir, () => {
902
+ const store = loadStore(dir);
903
+ const user = store.users.find((u) => u.idSubject === subjectQualified);
904
+ if (!user) return;
905
+ if (user.avatar && !user.avatarSource) return;
906
+ if (user.avatarSource !== expected) return;
907
+ user.avatar = avatar;
908
+ user.avatarSource = source;
909
+ saveStore(dir, store);
910
+ });
911
+ }
912
+ /**
913
+ * Would a picture from the identity service actually be used?
914
+ *
915
+ * Read-only and outside the lock, so the gate can decide whether a network
916
+ * fetch is worth making before it commits to one. Worst case it is wrong and we
917
+ * fetch a picture that then gets discarded - which costs one request, versus
918
+ * fetching an avatar on every single sign-in forever.
919
+ */
920
+ function avatarSourceFor(dir, subjectQualified, email, pictureUrl) {
921
+ const store = loadStore(dir);
922
+ const user = store.users.find((u) => u.idSubject === subjectQualified) ?? findUser(store, normEmail(email));
923
+ if (!user) return {
924
+ wanted: true,
925
+ source: void 0
926
+ };
927
+ if (!user.avatar) return {
928
+ wanted: true,
929
+ source: user.avatarSource
930
+ };
931
+ if (!user.avatarSource) return {
932
+ wanted: false,
933
+ source: void 0
934
+ };
935
+ return {
936
+ wanted: user.avatarSource !== pictureUrl,
937
+ source: user.avatarSource
938
+ };
939
+ }
940
+ function pushSession(store, user) {
941
+ const raw = token();
942
+ store.sessions.push({
943
+ tokenHash: sha256(raw),
944
+ emailNorm: normEmail(user.email),
945
+ exp: Date.now() + SESSION_TTL
946
+ });
947
+ return raw;
948
+ }
949
+ /**
950
+ * The canvas secret - the HMAC key behind opaque author ids (01-sharing §7.5).
951
+ *
952
+ * Generated once onto the volume, 0600, cached per process. The id it derives
953
+ * is stable within a canvas and meaningless across canvases; a bare hash of an
954
+ * email is guessable (the address space is small), which is why this is keyed.
955
+ */
956
+ const secretCache = /* @__PURE__ */ new Map();
957
+ function canvasSecret(dir) {
958
+ const hit = secretCache.get(dir);
959
+ if (hit) return hit;
960
+ const file = join(dir, "canvas.secret");
961
+ const read = () => {
962
+ try {
963
+ const key = Buffer.from(readFileSync(file, "utf8").trim(), "hex");
964
+ return key.length === 32 ? key : null;
965
+ } catch {
966
+ return null;
967
+ }
968
+ };
969
+ const key = read() ?? withLock(dir, () => {
970
+ const again = read();
971
+ if (again) return again;
972
+ const fresh = randomBytes(32);
973
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
974
+ const fd = openSync(tmp, "wx", 384);
975
+ try {
976
+ writeSync(fd, fresh.toString("hex"));
977
+ fsyncSync(fd);
978
+ } finally {
979
+ closeSync(fd);
980
+ }
981
+ renameSync(tmp, file);
982
+ return fresh;
983
+ });
984
+ secretCache.set(dir, key);
985
+ return key;
986
+ }
987
+ /** The opaque per-canvas author id: HMAC(canvas secret, lowercased email). */
988
+ function opaqueId(dir, email) {
989
+ return createHmac("sha256", canvasSecret(dir)).update(normEmail(email)).digest("hex").slice(0, 24);
990
+ }
991
+ /** Was this session minted by the operator credential? The CLI sync path keeps
992
+ * the RAW author profile (the agent needs real addresses and the operator
993
+ * already owns the canvas); every other caller gets the projection. */
994
+ function sessionIsOperator(dir, rawToken) {
995
+ const store = loadStore(dir);
996
+ const hash = sha256(rawToken);
997
+ const s = store.sessions.find((s) => s.tokenHash === hash && s.exp > Date.now());
998
+ return !!s?.via && s.via === operatorFingerprint();
999
+ }
1000
+ function sessionUser(dir, rawToken) {
1001
+ const store = loadStore(dir);
1002
+ const hash = sha256(rawToken);
1003
+ const s = store.sessions.find((s) => s.tokenHash === hash && s.exp > Date.now());
1004
+ if (!s) return null;
1005
+ if (s.via && s.via !== operatorFingerprint()) return null;
1006
+ return store.users.find((u) => normEmail(u.email) === s.emailNorm) ?? null;
1007
+ }
1008
+ /** The alphabet a bearer token can actually travel in: both the gate and the API
1009
+ * parse `Authorization` with `[\w-]+`, so a secret containing anything else is
1010
+ * accepted at boot and then silently unusable. Checked HERE as well as at boot so
1011
+ * the two can never drift apart. */
1012
+ const CLI_TOKEN_CHARS = /^[\w-]+$/;
1013
+ /**
1014
+ * The operator's own credential, read from the deployment environment.
1015
+ *
1016
+ * `comments connect` authenticates with a password, and an identity account has
1017
+ * none by design - so on an identity-gated canvas the whole CLI surface (invite,
1018
+ * revoke, and the comment sync the agent loop runs on) had no reachable
1019
+ * credential. This is the door, and where it lives is the entire point.
1020
+ *
1021
+ * The obvious alternative - a page that mints a token for whoever is signed in -
1022
+ * is the device flow this project already built and pulled (2d0850c). Authored
1023
+ * frames run same-origin in a canvas: frame JavaScript reads `mv_c`, every
1024
+ * request it makes carries the viewer's session, so any browser-reachable way to
1025
+ * mint a durable token is a way for a frame to mint one silently and carry it
1026
+ * off. There is no header that separates a frame from its own origin.
1027
+ *
1028
+ * An environment variable is on the other side of that line. It is never sent to
1029
+ * a page, no frame can read it, and reaching it already means reaching the
1030
+ * deployment - at which point the canvas was never the weakest thing in the room.
1031
+ * The cost is honest: it is a static secret that rotates by redeploying, and it
1032
+ * acts as the owner, so it is an operator credential rather than a person's.
1033
+ */
1034
+ function operatorUser(dir, presented) {
1035
+ return operatorMatch(presented) ? ownerOf(dir) : null;
1036
+ }
1037
+ /** The owner's account, or null on a canvas nobody has claimed yet. Chosen by
1038
+ * stored ROLE, never by array order or by whatever MARVER_OWNER_EMAIL currently
1039
+ * says - that variable only ever nominated a bootstrap account, and honouring it
1040
+ * afterwards would let a changed environment repoint this at a different person. */
1041
+ const ownerOf = (dir) => loadStore(dir).users.find((u) => u.role === "owner") ?? null;
1042
+ /**
1043
+ * Why a configured `MARVER_CLI_TOKEN` is unusable, or null when it is fine.
1044
+ *
1045
+ * One function so that boot and the matcher can never disagree. They did: boot
1046
+ * trimmed the value before checking it while `comments connect` sent the shell's
1047
+ * value as-is, so `" abcd... "` started a canvas that then refused the operator's
1048
+ * own token with no explanation anywhere. Surrounding whitespace is now a refusal
1049
+ * rather than something quietly repaired on one side of the wire.
1050
+ */
1051
+ function cliTokenProblem(raw) {
1052
+ if (!raw) return null;
1053
+ if (raw !== raw.trim()) return "MARVER_CLI_TOKEN has whitespace around it - quote it, or drop the quotes that put it there";
1054
+ if (raw.length < 32) return `MARVER_CLI_TOKEN is too short to be a bearer credential (${raw.length} chars, needs 32)`;
1055
+ if (!CLI_TOKEN_CHARS.test(raw)) return "MARVER_CLI_TOKEN contains characters that cannot travel in an Authorization header";
1056
+ return null;
1057
+ }
1058
+ /** The configured secret, or '' when there is none worth honouring. Never trimmed
1059
+ * into shape: a value that needed trimming was refused at boot. */
1060
+ const operatorSecret = () => {
1061
+ const raw = process.env.MARVER_CLI_TOKEN ?? "";
1062
+ return raw && !cliTokenProblem(raw) ? raw : "";
1063
+ };
1064
+ /** A fingerprint of the current secret, so a session can record WHICH one minted
1065
+ * it. The hash, never the value: this is written to auth.json, and a store that
1066
+ * quietly contains the operator's credential is the thing being avoided. */
1067
+ const operatorFingerprint = () => {
1068
+ const secret = operatorSecret();
1069
+ return secret ? sha256(`cli-generation:${secret}`) : "";
1070
+ };
1071
+ function operatorMatch(presented) {
1072
+ const secret = operatorSecret();
1073
+ if (!secret || !presented) return false;
1074
+ return timingSafeEqual(Buffer.from(sha256(presented), "hex"), Buffer.from(sha256(secret), "hex"));
1075
+ }
1076
+ /**
1077
+ * Trade the operator's secret for an ordinary session, once, from the terminal.
1078
+ *
1079
+ * The secret itself must not become the thing a repo carries. `connect` persists
1080
+ * whatever it is given, and that file sits on a developer's disk for as long as
1081
+ * the project lasts - a non-expiring master key is the wrong shape for it. What comes back here expires, dies with
1082
+ * `comments revoke`, and can be replaced without touching the deployment.
1083
+ *
1084
+ * Only the operator secret opens this. Deliberately NOT any signed-in session:
1085
+ * authored frames run same-origin and ride the viewer's cookies, so a route that
1086
+ * minted sessions for whoever was signed in would be the pulled device flow
1087
+ * (2d0850c) with a different name. A frame cannot present this Bearer, because
1088
+ * the value it needs was never in the browser.
1089
+ */
1090
+ function issueDeviceSession(dir, presented) {
1091
+ if (!operatorMatch(presented)) return null;
1092
+ return withLock(dir, () => {
1093
+ const store = loadStore(dir);
1094
+ const user = store.users.find((u) => u.role === "owner");
1095
+ if (!user) return null;
1096
+ const token = pushSession(store, user);
1097
+ store.sessions[store.sessions.length - 1].via = operatorFingerprint();
1098
+ saveStore(dir, store);
1099
+ return {
1100
+ token,
1101
+ exp: Date.now() + SESSION_TTL,
1102
+ user
1103
+ };
1104
+ });
1105
+ }
1106
+ function signOut(dir, rawToken) {
1107
+ withLock(dir, () => {
1108
+ const store = loadStore(dir);
1109
+ const hash = sha256(rawToken);
1110
+ store.sessions = store.sessions.filter((s) => s.tokenHash !== hash);
1111
+ saveStore(dir, store);
1112
+ });
1113
+ }
1114
+ /** Update name/avatar on an existing account. */
1115
+ function updateProfile(dir, email, patch) {
1116
+ return withLock(dir, () => {
1117
+ const store = loadStore(dir);
1118
+ const user = findUser(store, email);
1119
+ if (!user) throw new Error("no such account");
1120
+ if (patch.name?.trim()) user.name = patch.name.trim();
1121
+ if (patch.avatar !== void 0) {
1122
+ user.avatar = patch.avatar || void 0;
1123
+ user.avatarSource = void 0;
1124
+ }
1125
+ saveStore(dir, store);
1126
+ return user;
1127
+ });
1128
+ }
1129
+ /** Remove an account and all its sessions (owner action). The LAST owner cannot be
1130
+ * removed - a store with members but no owner has no one left to administer it,
1131
+ * and bootstrap will not re-run while any user exists. */
1132
+ function revokeUser(dir, email) {
1133
+ withLock(dir, () => {
1134
+ const store = loadStore(dir);
1135
+ if (store.users.find((u) => normEmail(u.email) === normEmail(email))?.role === "owner" && !store.users.some((u) => u.role === "owner" && normEmail(u.email) !== normEmail(email))) throw new Error("cannot remove the last owner - the canvas would have no administrator left");
1136
+ store.users = store.users.filter((u) => normEmail(u.email) !== normEmail(email));
1137
+ store.sessions = store.sessions.filter((s) => s.emailNorm !== normEmail(email));
1138
+ store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
1139
+ saveStore(dir, store);
1140
+ });
1141
+ removePrincipalGrants(dir, email);
1142
+ }
1143
+ /** The public shape of a user - what other viewers (and events) may see. */
1144
+ const publicUser = (u) => ({
1145
+ email: u.email,
1146
+ name: u.name,
1147
+ avatar: u.avatar
1148
+ });
1149
+ //#endregion
1150
+ export { reconfirmGrant as A, commentAllowed as C, operativeMode as D, loadShare as E, setBlocked as F, setGeneralMode as I, shareState as L, resolveAccess as M, resolveRequest as N, provisionVerdict as O, rosterEtag as P, share_exports as R, ceilingsFromRights as S, loadRequests as T, sessionUser as _, claimInvite as a, updateProfile as b, issueDeviceSession as c, opaqueId as d, ownerName as f, sessionIsOperator as g, revokeUser as h, avatarSourceFor as i, removeGrant as j, putRequest as k, loadStore as l, publicUser as m, attachAvatar as n, createInvite as o, provisionFromMarverId as p, auth_exports as r, inviteInfo as s, CLI_TOKEN_CHARS as t, normEmail as u, signIn as v, entryAllowed as w, withLock as x, signOut as y, upsertGrant as z };