@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
@@ -1,6 +1,6 @@
1
- import { i as avatarSourceFor, n as attachAvatar, u as provisionFromMarverId } from "./auth-B5yuwnOq.mjs";
2
- import { TransactionStore, browserBinding, verifyAssertion } from "./marver-id-B8-3WiHk.mjs";
3
- import { t as poweredByUrl } from "./utm-CxC3QN5X.mjs";
1
+ import { i as avatarSourceFor, n as attachAvatar, p as provisionFromMarverId } from "./auth-DdUKeVKt.mjs";
2
+ import { TransactionStore, browserBinding, verifyAssertion } from "./marver-id-CVCoaMGI.mjs";
3
+ import { t as poweredByUrl } from "./serve-EjEqsiYa.mjs";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { isIP } from "node:net";
6
6
  import { lookup } from "node:dns/promises";
@@ -324,7 +324,7 @@ function safeHash(raw) {
324
324
  if (!/^#\/[\w\-./~%!$&'()*+,;=:@?[\]]*$/.test(value)) return null;
325
325
  return value;
326
326
  }
327
- function marverIdHandler(dir, issuer, canvasName, branding = true) {
327
+ function marverIdHandler(dir, issuer, canvasName, branding = true, ceilings = {}) {
328
328
  const transactions = new TransactionStore();
329
329
  const pinned = (process.env.MARVER_PUBLIC_ORIGIN ?? "").trim().replace(/\/$/, "");
330
330
  let publicOrigin;
@@ -411,12 +411,37 @@ function marverIdHandler(dir, issuer, canvasName, branding = true) {
411
411
  const session = provisionFromMarverId(dir, {
412
412
  ...result.identity,
413
413
  issuer
414
- }, { ownerEmail: process.env.MARVER_OWNER_EMAIL });
414
+ }, {
415
+ ownerEmail: process.env.MARVER_OWNER_EMAIL,
416
+ ceilings
417
+ });
415
418
  if (!session) {
416
419
  console.warn(`[marver-id] ${result.identity.email} is not on this canvas's list`);
420
+ let request;
421
+ try {
422
+ const { shareState, provisionVerdict } = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);
423
+ const share = shareState(dir);
424
+ if (!(share ? provisionVerdict(share, result.identity.email) === "blocked" : false)) {
425
+ const { signCanvasJws } = await import("./summary-C7CAypbB.mjs");
426
+ const now = Math.floor(Date.now() / 1e3);
427
+ request = signCanvasJws(dir, {
428
+ aud: origin,
429
+ sub: result.identity.subject,
430
+ email: result.identity.email,
431
+ ...result.identity.name ? { name: result.identity.name } : {},
432
+ ...result.identity.picture ? { picture: result.identity.picture } : {},
433
+ target: landing ?? "",
434
+ iat: now,
435
+ exp: now + 900,
436
+ jti: randomBytes(12).toString("hex")
437
+ }, "marver-reqaccess+jwt");
438
+ }
439
+ } catch {}
417
440
  return json(res, 403, {
418
441
  error: "not invited",
419
- email: result.identity.email
442
+ email: result.identity.email,
443
+ ...request ? { request } : {},
444
+ ...request && landing ? { target: landing } : {}
420
445
  });
421
446
  }
422
447
  const picture = result.identity.picture;
@@ -520,9 +545,12 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
520
545
  p.lead { margin: 0; padding: 0 4px; font-size: 12.5px; line-height: 1.5;
521
546
  color: rgba(24,24,27,.66) }
522
547
  p.lead strong { color: #18181b; font-weight: 600 }
523
- a.cta { height: 40px; border-radius: 999px; background: #18181b; color: #fafafa;
524
- font: 600 13px -apple-system, system-ui, sans-serif; text-decoration: none;
525
- display: flex; align-items: center; justify-content: center }
548
+ /* .cta, not a.cta: the request-access <button> wears the same dark pill as the
549
+ switch-account <a> - scoping to the tag left the button naked (a flat native
550
+ strip that read as disabled) */
551
+ .cta { height: 40px; border-radius: 999px; background: #18181b; color: #fafafa;
552
+ font: 600 13px -apple-system, system-ui, sans-serif; text-decoration: none;
553
+ display: flex; align-items: center; justify-content: center }
526
554
  footer a { display: inline-flex; align-items: center; gap: 7px;
527
555
  font: 600 12.5px -apple-system, system-ui, sans-serif;
528
556
  color: rgba(24,24,27,.5); text-decoration: none }
@@ -538,6 +566,24 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
538
566
  needs, appearing and vanishing, which reads as a stutter rather than as
539
567
  progress. The card is for when something has to be SAID; getting in says
540
568
  itself by the canvas appearing. */
569
+ /* the request-access ask (01-sharing §7.6): the gate's own language - chips,
570
+ rounded field, the same CTA - below a hairline. */
571
+ .rdiv { border: 0; height: 1px; background: rgba(24,24,27,.09); margin: 2px 2px 6px }
572
+ .rrow { display: flex; gap: 8px; padding: 0 2px }
573
+ .rchip { flex: 1; cursor: pointer }
574
+ .rchip input { position: absolute; opacity: 0; pointer-events: none }
575
+ .rchip span { display: flex; align-items: center; justify-content: center; height: 34px;
576
+ border-radius: 999px; border: 1px solid rgba(24,24,27,.14); background: #fff;
577
+ font: 500 12.5px -apple-system, system-ui, sans-serif; transition: border-color .15s, box-shadow .15s }
578
+ .rchip input:checked + span { border-color: #0088ff; box-shadow: 0 0 0 3px rgba(0,136,255,.14); color: #0088ff }
579
+ #req { display: flex; flex-direction: column; gap: 12px }
580
+ #req[hidden] { display: none }
581
+ #req textarea { padding: 10px 16px; border-radius: 16px; border: 1px solid rgba(24,24,27,.14);
582
+ background: #fff; color: #18181b; font: 500 13px -apple-system, system-ui, sans-serif;
583
+ outline: none; resize: none; width: 100% }
584
+ #req textarea:focus { border-color: #0088ff; box-shadow: 0 0 0 3px rgba(0,136,255,.18) }
585
+ #req button.cta { border: 0; width: 100%; cursor: pointer }
586
+ #req button.cta:disabled { background: rgba(24,24,27,.35); cursor: default }
541
587
  .wait { width: 34px; height: 34px; border-radius: 50%;
542
588
  border: 3px solid rgba(24,24,27,.12); border-top-color: #0088ff;
543
589
  animation: spin .7s linear infinite }
@@ -555,6 +601,16 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
555
601
  <p class="lead" id="m"></p>
556
602
  <p class="lead" id="m2" hidden></p>
557
603
  <a class="cta" id="back" href="/" data-switch="${esc(switchUrl)}" hidden>Use a different account</a>
604
+ <div id="req" hidden>
605
+ <hr class="rdiv" />
606
+ <p class="lead">Or ask for access - the owner decides who gets in.</p>
607
+ <div class="rrow">
608
+ <label class="rchip"><input type="radio" name="rrole" value="view" checked /><span>View</span></label>
609
+ <label class="rchip"><input type="radio" name="rrole" value="comment" /><span>View + comment</span></label>
610
+ </div>
611
+ <textarea id="rnote" maxlength="500" rows="2" placeholder="Add a note (optional)"></textarea>
612
+ <button class="cta" id="rgo" type="button">Request access</button>
613
+ </div>
558
614
  </div>
559
615
  ${branding ? `<footer id="mark" hidden><a href="${poweredByUrl(canvasName, "published-canvas", "sign-in")}" target="_blank" rel="noopener">${MARK} <span>Powered by <span class="md">Marver.design</span></span> ${ARROW}</a></footer>` : ""}
560
616
  <script>
@@ -628,6 +684,40 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
628
684
  back.hidden = false
629
685
  }
630
686
 
687
+ /** The door a refusal offers: role, note, one send. The response is the same
688
+ * whatever the server recognised (no probe oracle) - "sent" is all it says. */
689
+ function offerRequest(token, target) {
690
+ var req = document.getElementById('req'), go = document.getElementById('rgo')
691
+ req.hidden = false
692
+ // name what the link pointed at - context the owner sees with the ask
693
+ if (target) {
694
+ var lead = req.querySelector('p.lead')
695
+ if (lead) {
696
+ lead.textContent = 'Or ask for access - the owner decides. Your request will name what this link pointed at ('
697
+ var b = document.createElement('code')
698
+ b.textContent = target
699
+ b.style.font = '500 11.5px ui-monospace, monospace'
700
+ lead.appendChild(b)
701
+ lead.appendChild(document.createTextNode(').'))
702
+ }
703
+ }
704
+ go.addEventListener('click', function () {
705
+ go.disabled = true
706
+ var role = (document.querySelector('input[name="rrole"]:checked') || {}).value === 'comment' ? 'comment' : 'view'
707
+ var note = (document.getElementById('rnote') || {}).value || ''
708
+ fetch('/__mv/api/request-access', {
709
+ method: 'POST',
710
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token },
711
+ body: JSON.stringify({ requestedRole: role, note: note.slice(0, 500) })
712
+ }).then(function () { sent() }, function () { sent() })
713
+ function sent() {
714
+ go.textContent = 'Request sent'
715
+ var lead = req.querySelector('p.lead')
716
+ if (lead) lead.textContent = 'The owner will review it. If they approve, signing in again will let you straight in.'
717
+ }
718
+ })
719
+ }
720
+
631
721
  // Take it, then erase it - before any await, so a slow network cannot leave
632
722
  // the assertion sitting in the address bar.
633
723
  var assertion = location.hash.replace(/^#/, '')
@@ -658,6 +748,7 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
658
748
  who ? refused(who) : stop("You haven't been invited",
659
749
  'That account is not on the invite list for this canvas. Ask whoever owns it to add your address.',
660
750
  'Use a different account')
751
+ if (who && body && typeof body.request === 'string') offerRequest(body.request, typeof body.target === 'string' ? body.target : '')
661
752
  }, function () {
662
753
  stop("You haven't been invited",
663
754
  'That account is not on the invite list for this canvas.',
@@ -1,9 +1,10 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
1
2
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
2
- import { n as localProfile, t as isConnected } from "./profile-DcsJyppw.mjs";
3
- import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-C2tzkNaC.mjs";
4
- import { n as installedVersion, t as checkUpdate } from "./update-DuWDj5nR.mjs";
3
+ import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
4
+ import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-CS6krOTe.mjs";
5
5
  import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
6
- import { basename, join, resolve, sep } from "node:path";
6
+ import { basename, dirname, join, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { randomBytes } from "node:crypto";
8
9
  //#region src/server/api.ts
9
10
  const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
@@ -305,7 +306,7 @@ function apiMiddleware(root, opts = {}) {
305
306
  }
306
307
  const dir = join(root, "design", ".local");
307
308
  mkdirSync(dir, { recursive: true });
308
- const { validAvatar } = await import("./collab-CWaG3Q4w.mjs");
309
+ const { validAvatar } = await import("./collab-mAiNZlDj.mjs");
309
310
  let cur = {};
310
311
  try {
311
312
  cur = JSON.parse(readFileSync(join(dir, "profile.json"), "utf8"));
@@ -396,7 +397,7 @@ function apiMiddleware(root, opts = {}) {
396
397
  }
397
398
  }
398
399
  if (path === "sync" && req.method === "POST") {
399
- const { loadCollab, syncOnce } = await import("./sync-DELGomPk.mjs").then((n) => n.o);
400
+ const { loadCollab, syncOnce } = await import("./sync-BZaCWqK-.mjs").then((n) => n.o);
400
401
  const collab = loadCollab(root);
401
402
  if (!collab) return json(res, 200, { connected: false });
402
403
  try {
@@ -425,7 +426,7 @@ function backgroundPush(root) {
425
426
  pushTimer = setTimeout(async () => {
426
427
  pushTimer = null;
427
428
  try {
428
- const { loadCollab, syncOnce } = await import("./sync-DELGomPk.mjs").then((n) => n.o);
429
+ const { loadCollab, syncOnce } = await import("./sync-BZaCWqK-.mjs").then((n) => n.o);
429
430
  const collab = loadCollab(root);
430
431
  if (collab) await syncOnce(root, collab);
431
432
  } catch {}
@@ -483,6 +484,77 @@ function routesMiddleware(server, clientDir) {
483
484
  };
484
485
  }
485
486
  //#endregion
487
+ //#region src/server/update.ts
488
+ /**
489
+ * Update discovery - dev only, and deliberately boring about privacy: one anonymous
490
+ * registry metadata GET per day (the same request `npm view` makes), cached in
491
+ * design/.local/, nothing sent beyond the request itself. Offline, slow, or
492
+ * firewalled registries degrade to silence. MARVER_NO_UPDATE_CHECK=1 disables it.
493
+ * Published bundles never check anything - viewers are not the owner.
494
+ */
495
+ var update_exports = /* @__PURE__ */ __exportAll({
496
+ checkUpdate: () => checkUpdate,
497
+ installedVersion: () => installedVersion
498
+ });
499
+ const TTL = 864e5;
500
+ /** Installed version: walk up from this module to the package's own package.json
501
+ * (one level from dist/, two from src/server/ - the walk covers both). */
502
+ function installedVersion() {
503
+ let dir = dirname(fileURLToPath(import.meta.url));
504
+ for (let i = 0; i < 4; i++) {
505
+ try {
506
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
507
+ if (pkg.name === "@marver-design/marver") return pkg.version;
508
+ } catch {}
509
+ dir = dirname(dir);
510
+ }
511
+ return null;
512
+ }
513
+ /** Strictly-newer numeric compare; anything non-numeric (prerelease tags) compares false. */
514
+ const newer = (a, b) => {
515
+ const pa = a.split(".").map((n) => parseInt(n, 10));
516
+ const pb = b.split(".").map((n) => parseInt(n, 10));
517
+ for (let i = 0; i < 3; i++) {
518
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
519
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
520
+ if (x !== y) return x > y;
521
+ }
522
+ return false;
523
+ };
524
+ /** The latest published version when strictly newer than installed, else null. Never throws. */
525
+ async function checkUpdate(root) {
526
+ if (process.env.MARVER_NO_UPDATE_CHECK) return null;
527
+ const current = installedVersion();
528
+ if (!current) return null;
529
+ const latest = await latestVersion(root);
530
+ return latest && newer(latest, current) ? latest : null;
531
+ }
532
+ async function latestVersion(root) {
533
+ const cacheFile = join(root, "design", ".local", "update-check.json");
534
+ const wellFormed = (v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v);
535
+ try {
536
+ const c = JSON.parse(readFileSync(cacheFile, "utf8"));
537
+ const age = Date.now() - c.checkedAt;
538
+ if (typeof c.checkedAt === "number" && age >= 0 && age < TTL) return wellFormed(c.latest) ? c.latest : null;
539
+ } catch {}
540
+ let latest = null;
541
+ try {
542
+ const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, { signal: AbortSignal.timeout(3e3) });
543
+ if (res.ok) {
544
+ const v = (await res.json())?.version;
545
+ if (wellFormed(v)) latest = v;
546
+ }
547
+ } catch {}
548
+ try {
549
+ mkdirSync(join(root, "design", ".local"), { recursive: true });
550
+ writeFileSync(cacheFile, JSON.stringify({
551
+ checkedAt: Date.now(),
552
+ latest
553
+ }) + "\n");
554
+ } catch {}
555
+ return latest;
556
+ }
557
+ //#endregion
486
558
  //#region src/server/plugin.ts
487
559
  const VIRTUAL_THEME = "virtual:sh-theme";
488
560
  const VIRTUAL_CONFIG = "virtual:sh-config";
@@ -539,7 +611,8 @@ function marverPlugin(ctx) {
539
611
  zoomSpeed: config.zoomSpeed,
540
612
  noTheme: themeFile() == null,
541
613
  setup: setupPending,
542
- projectName: config.share.name || basename(root)
614
+ projectName: config.share.name || basename(root),
615
+ branding: config.share.branding
543
616
  })}`;
544
617
  }
545
618
  if (id === "\0virtual:sh-data") return "export default null";
@@ -806,4 +879,4 @@ async function tailwind3Css(root) {
806
879
  return null;
807
880
  }
808
881
  //#endregion
809
- export { tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
882
+ export { update_exports as i, tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
@@ -1,4 +1,4 @@
1
- import { i as loadCollab } from "./sync-DELGomPk.mjs";
1
+ import { i as loadCollab } from "./sync-BZaCWqK-.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  //#region src/server/profile.ts
@@ -1,10 +1,28 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
2
  import { t as secureSuffix } from "./secure-cookie-_K1Hsx8H.mjs";
3
- import { t as poweredByUrl } from "./utm-CxC3QN5X.mjs";
4
3
  import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
5
4
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
6
5
  import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
7
6
  import { createServer } from "node:http";
7
+ //#region src/shared/utm.ts
8
+ /** Automatic attribution for the powered-by links: every canvas tags its marver.design
9
+ * link so site analytics can see WHICH canvas sent a visitor - zero user setup.
10
+ * Conventional UTM semantics (stable groupings first, the variable last):
11
+ * utm_source = the surface class ('published-canvas' | 'dev-canvas')
12
+ * utm_medium = the link unit ('powered-by')
13
+ * utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
14
+ * utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page)
15
+ */
16
+ function poweredByUrl(canvasName, source, content) {
17
+ const slug = (canvasName ?? "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
18
+ return `https://marver.design/?${new URLSearchParams({
19
+ utm_source: source,
20
+ utm_medium: "powered-by",
21
+ ...slug ? { utm_campaign: slug } : {},
22
+ utm_content: content
23
+ }).toString()}`;
24
+ }
25
+ //#endregion
8
26
  //#region src/server/serve.ts
9
27
  /**
10
28
  * `marver serve` - a zero-dependency static server for design/.dist with an optional
@@ -51,24 +69,41 @@ async function serve(root, portFlag) {
51
69
  let bearerCheck = null;
52
70
  let operatorCheck = null;
53
71
  let sessionCheck = null;
72
+ /** Whether the anonymous door (canvas password / guest cookie) is open at all:
73
+ * general access can be set to Private on a password canvas, and then only
74
+ * accounts get in. Null when there is no roster to consult. */
75
+ let guestDoorOpen = null;
54
76
  if (process.env.MARVER_DATA_DIR) {
55
- const { collabHandler } = await import("./collab-CWaG3Q4w.mjs");
77
+ const { collabHandler } = await import("./collab-mAiNZlDj.mjs");
56
78
  const { appendEvents, readLog } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
57
79
  const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
58
80
  const dir = dataDir();
59
- const seedDir = join(dist, "design", "comments");
60
- if (existsSync(seedDir)) for (const f of readdirSync(seedDir)) {
61
- if (!f.endsWith(".jsonl")) continue;
62
- const board = f.slice(0, -6);
63
- appendEvents(join(dir, "comments"), board, readLog(seedDir, board));
81
+ for (const seedDir of [join(root, "design", ".dist-seeds"), join(dist, "design", "comments")]) {
82
+ if (!existsSync(seedDir)) continue;
83
+ for (const f of readdirSync(seedDir)) {
84
+ if (!f.endsWith(".jsonl")) continue;
85
+ const board = f.slice(0, -6);
86
+ appendEvents(join(dir, "comments"), board, readLog(seedDir, board));
87
+ }
64
88
  }
65
89
  collab = collabHandler(dir, dist);
66
- const { loadStore, createInvite, normEmail, operatorUser, sessionUser } = await import("./auth-B5yuwnOq.mjs").then((n) => n.r);
67
- bearerCheck = (token) => sessionUser(dir, token);
90
+ const { loadStore, createInvite, normEmail, operatorUser, sessionUser } = await import("./auth-DdUKeVKt.mjs").then((n) => n.r);
91
+ const share = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);
92
+ const ceilings = share.ceilingsFromRights(meta.rights ?? {});
93
+ const admitted = (u) => !!u && share.entryAllowed(dir, u, ceilings);
94
+ guestDoorOpen = () => {
95
+ const s = share.shareState(dir);
96
+ return !s || s.general.mode !== "private";
97
+ };
98
+ bearerCheck = (token) => {
99
+ const u = sessionUser(dir, token);
100
+ return admitted(u) ? u : null;
101
+ };
68
102
  operatorCheck = (token, req, url) => req.method === "POST" && url.pathname === "/__mv/api/cli-session" && !!operatorUser(dir, token);
69
103
  sessionCheck = (req) => {
70
104
  const tok = /(?:^|;\s*)mv_s=([\w-]+)/.exec(String(req.headers.cookie ?? ""))?.[1];
71
- return tok ? sessionUser(dir, tok) : null;
105
+ const u = tok ? sessionUser(dir, tok) : null;
106
+ return admitted(u) ? u : null;
72
107
  };
73
108
  const owner = process.env.MARVER_ID_ISSUER ? "" : process.env.MARVER_OWNER_EMAIL;
74
109
  if (owner) {
@@ -83,7 +118,7 @@ async function serve(root, portFlag) {
83
118
  }
84
119
  const cliToken = process.env.MARVER_CLI_TOKEN ?? "";
85
120
  if (cliToken) {
86
- const { cliTokenProblem } = await import("./auth-B5yuwnOq.mjs").then((n) => n.r);
121
+ const { cliTokenProblem } = await import("./auth-DdUKeVKt.mjs").then((n) => n.r);
87
122
  if (!process.env.MARVER_DATA_DIR) {
88
123
  console.error(`[${NAME}] MARVER_CLI_TOKEN needs MARVER_DATA_DIR - there are no accounts for it to act as otherwise.`);
89
124
  process.exit(1);
@@ -95,7 +130,7 @@ async function serve(root, portFlag) {
95
130
  }
96
131
  }
97
132
  const rawIssuer = (process.env.MARVER_ID_ISSUER ?? "").trim();
98
- const { normalizeIssuer } = await import("./marver-id-B8-3WiHk.mjs");
133
+ const { normalizeIssuer } = await import("./marver-id-CVCoaMGI.mjs");
99
134
  const idIssuer = normalizeIssuer(rawIssuer);
100
135
  if (rawIssuer && !idIssuer) {
101
136
  console.error(`[${NAME}] MARVER_ID_ISSUER is not a usable issuer: ${rawIssuer}\n It must be a bare https origin - https://id.marver.design - with no path, query or credentials.\n http:// is accepted only for localhost, while developing against a local identity service.`);
@@ -111,11 +146,13 @@ async function serve(root, portFlag) {
111
146
  console.error(`[${NAME}] MARVER_ID_ISSUER is set but MARVER_PUBLIC_ORIGIN is not.\n Every assertion is bound to this canvas's exact origin, and it cannot be\n inferred from request headers - a proxy can make any request look local.\n Set it to the origin people actually reach this canvas on:\n MARVER_PUBLIC_ORIGIN=https://canvas.example.com\n MARVER_PUBLIC_ORIGIN=http://localhost:${portFlag ?? (Number(process.env.PORT) || 4199)} (development)`);
112
147
  process.exit(1);
113
148
  }
114
- const { marverIdHandler } = await import("./marver-id-gate-BDzW6ahN.mjs");
149
+ const { marverIdHandler } = await import("./marver-id-gate-B2uraTHS.mjs");
115
150
  const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
116
151
  const canvasName = humanName(meta.name);
117
- idHandler = marverIdHandler(dataDir(), idIssuer, canvasName, meta.branding);
152
+ const { ceilingsFromRights } = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);
153
+ idHandler = marverIdHandler(dataDir(), idIssuer, canvasName, meta.branding, ceilingsFromRights(meta.rights ?? {}));
118
154
  }
155
+ if (idIssuer && process.env.MARVER_PASSWORD) console.log(` note: MARVER_ID_ISSUER and MARVER_PASSWORD are both set - the identity gate wins, the password is IGNORED`);
119
156
  const password = idIssuer ? "" : process.env.MARVER_PASSWORD ?? "";
120
157
  const verifier = password ? scryptSync(password, "marver-gate", 32) : null;
121
158
  const cookieKey = randomBytes(32);
@@ -129,10 +166,22 @@ async function serve(root, portFlag) {
129
166
  return want.length === got.length && timingSafeEqual(want, got);
130
167
  };
131
168
  const gated = Boolean(verifier || idIssuer);
169
+ if (collab) {
170
+ const { ensureShare, reclampShare, ceilingsFromRights } = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);
171
+ const { loadStore } = await import("./auth-DdUKeVKt.mjs").then((n) => n.r);
172
+ const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
173
+ const dir = dataDir();
174
+ const ceilings = ceilingsFromRights(meta.rights ?? {});
175
+ const mode = idIssuer ? "private" : verifier ? "password" : "public";
176
+ const { created } = ensureShare(dir, mode, loadStore(dir).users, ceilings);
177
+ if (created) console.log(` share: roster migrated → ${join(dir, "share.json")} (general: ${mode}, ${loadStore(dir).users.length} account grant${loadStore(dir).users.length === 1 ? "" : "s"})`);
178
+ reclampShare(dir, ceilings);
179
+ }
132
180
  const server = createServer((req, res) => {
133
181
  const url = new URL(req.url ?? "/", "http://x");
134
182
  if (gated) {
135
183
  if (verifier && req.method === "POST" && url.pathname === "/__mv/auth") {
184
+ if (guestDoorOpen && !guestDoorOpen()) return gate(res, meta, !!collab, "This canvas is members-only - sign in with your account instead", !!idIssuer);
136
185
  let body = "";
137
186
  req.on("data", (c) => {
138
187
  body += c;
@@ -155,13 +204,13 @@ async function serve(root, portFlag) {
155
204
  return;
156
205
  }
157
206
  const cosmetic = isCosmetic(url.pathname);
158
- if (!authed(req) && !cosmetic && !sessionCheck?.(req)) {
207
+ if (!(authed(req) && (guestDoorOpen?.() ?? true)) && !cosmetic && !sessionCheck?.(req)) {
159
208
  const bearerOk = collab && url.pathname.startsWith("/__mv/api/") && (() => {
160
209
  const tok = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
161
210
  if (!tok) return false;
162
211
  return !!bearerCheck?.(tok) || !!operatorCheck?.(tok, req, url);
163
212
  })();
164
- const preGate = collab && !idIssuer && (req.method === "POST" && (url.pathname === "/__mv/api/auth/signin" || url.pathname === "/__mv/api/auth/claim") || req.method === "GET" && url.pathname === "/__mv/api/invite-info") || !!idIssuer && (req.method === "GET" && url.pathname === "/__mv/id/start" || req.method === "GET" && url.pathname === "/__mv/id/finish" || req.method === "POST" && url.pathname === "/__mv/id/callback");
213
+ const preGate = collab && !idIssuer && (req.method === "POST" && (url.pathname === "/__mv/api/auth/signin" || url.pathname === "/__mv/api/auth/claim") || req.method === "GET" && url.pathname === "/__mv/api/invite-info") || !!idIssuer && (req.method === "GET" && url.pathname === "/__mv/id/start" || req.method === "GET" && url.pathname === "/__mv/id/finish" || req.method === "POST" && url.pathname === "/__mv/id/callback" || req.method === "GET" && url.pathname === "/__mv/api/summary" || req.method === "OPTIONS" && url.pathname === "/__mv/api/summary" || req.method === "GET" && url.pathname === "/__mv/api/identity" || req.method === "POST" && url.pathname === "/__mv/api/request-access" || url.pathname.startsWith("/__mv/api/share/"));
165
214
  if (!bearerOk && !preGate) return gate(res, meta, !!collab, void 0, !!idIssuer);
166
215
  }
167
216
  }
@@ -200,6 +249,10 @@ async function serve(root, portFlag) {
200
249
  res.statusCode = 400;
201
250
  return res.end("bad request");
202
251
  }
252
+ if (path.startsWith("/design/comments/")) {
253
+ res.statusCode = 404;
254
+ return res.end("not found");
255
+ }
203
256
  if (path.endsWith("/")) path += "index.html";
204
257
  let file = resolve(dist, path.slice(1));
205
258
  try {
@@ -605,4 +658,4 @@ ${collabOn ? `
605
658
  }
606
659
  const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
607
660
  //#endregion
608
- export { serve };
661
+ export { serve, poweredByUrl as t };
@@ -0,0 +1,158 @@
1
+ import { n as NAME } from "./cli.mjs";
2
+ import { M as resolveAccess } from "./auth-DdUKeVKt.mjs";
3
+ import { i as loadCollab } from "./sync-BZaCWqK-.mjs";
4
+ //#region src/cli/share.ts
5
+ /**
6
+ * `marver share <action>` - the roster from the terminal (01-sharing §9).
7
+ *
8
+ * The sovereign administration path: on a canvas with no identity issuer this
9
+ * IS the whole interface, and on every canvas it calls the same owner routes
10
+ * the app does, over the device credential `comments connect` stored. The
11
+ * resolver ships with an explainer because a permission system nobody can
12
+ * predict is not safe - `explain` asks the canvas's OWN `share/explain` route,
13
+ * so its trace is the resolver the doors enforce with (owner role filled,
14
+ * `@domain` explained through a synthetic member, the winning step marked) and
15
+ * cannot drift from the dialog's Who-sees-what (acceptance 9). `who` runs that
16
+ * same pure function locally over the fetched roster for the granted principals
17
+ * it lists - none of them the owner, so no server-only role handling applies.
18
+ *
19
+ * add <who> grant view (default) or --role comment; --expires <iso>
20
+ * remove <who> remove the grant
21
+ * block <email> / unblock <email>
22
+ * general <mode> private · password · public
23
+ * list the roster - general access, grants, blocklist
24
+ * requests pending access requests; --approve <email> [--role r] · --decline <email>
25
+ * explain <who> the resolver's trace for one principal
26
+ * who the whole matrix
27
+ */
28
+ async function api(root, method, path, body) {
29
+ const collab = loadCollab(root);
30
+ if (!collab) throw new Error(`no canvas connected - run \`npx ${NAME} comments connect <url>\` first`);
31
+ const res = await fetch(`${collab.url.replace(/\/+$/, "")}/__mv/api/${path}`, {
32
+ method,
33
+ headers: {
34
+ authorization: `Bearer ${collab.token}`,
35
+ ...body === void 0 ? {} : { "content-type": "application/json" }
36
+ },
37
+ body: body === void 0 ? void 0 : JSON.stringify(body)
38
+ });
39
+ if (res.status === 403) throw new Error("the canvas refused - sharing is owner-only, and the connect credential must be the owner's");
40
+ if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `canvas answered ${res.status}`);
41
+ return res.json();
42
+ }
43
+ /** The published ceilings, from the boards route - the same rights serve enforces with. */
44
+ async function fetchCeilings(root) {
45
+ const { rights } = await api(root, "GET", "boards");
46
+ return Object.fromEntries(Object.entries(rights).map(([b, r]) => [b, r === "comment" ? "comment" : "view"]));
47
+ }
48
+ const asStore = (roster) => ({
49
+ version: 1,
50
+ general: roster.general,
51
+ blocked: roster.blocked,
52
+ grants: roster.grants
53
+ });
54
+ /** The resolver's trace as the server returned it - the winning step marked with
55
+ * an arrow, the same verdict the dialog's Who-sees-what draws. */
56
+ function renderExplain(r) {
57
+ const lines = [];
58
+ for (const step of r.trace) lines.push(` ${step.win ? "→" : " "} ${step.where.padEnd(14)} ${step.role.padEnd(9)} ${step.why}`);
59
+ const boards = Object.entries(r.boards);
60
+ const readable = boards.filter(([, role]) => role !== "none").length;
61
+ const writable = boards.filter(([, role]) => role === "comment").length;
62
+ for (const [b, role] of boards) lines.push(` board ${b.padEnd(14)} ${role}`);
63
+ lines.push(` → ${readable} of ${boards.length} board${boards.length === 1 ? "" : "s"}, may comment on ${writable}`);
64
+ return lines.join("\n");
65
+ }
66
+ async function shareCommand(root, action, value, opts) {
67
+ switch (action) {
68
+ case "add": {
69
+ if (!value) throw new Error("add needs a principal: an email, or @domain");
70
+ const role = opts.role === "comment" ? "comment" : "view";
71
+ const r = await api(root, "PUT", "share/grant", {
72
+ principal: value,
73
+ scope: "canvas",
74
+ assigned: role,
75
+ expires: opts.expires ?? null
76
+ });
77
+ console.log(` granted ${value} → ${role}${opts.expires ? ` until ${opts.expires}` : ""} (${r.grants.length} grant${r.grants.length === 1 ? "" : "s"} total)`);
78
+ return;
79
+ }
80
+ case "remove":
81
+ if (!value) throw new Error("remove needs the principal to remove");
82
+ await api(root, "DELETE", "share/grant", {
83
+ principal: value,
84
+ scope: "canvas"
85
+ });
86
+ console.log(` removed ${value}`);
87
+ return;
88
+ case "block":
89
+ case "unblock":
90
+ if (!value) throw new Error(`${action} needs an email address`);
91
+ await api(root, action === "block" ? "PUT" : "DELETE", "share/block", { address: value });
92
+ console.log(action === "block" ? ` blocked ${value} - refused everywhere, ahead of every grant.\n Blocking only bites while general access is Private.` : ` unblocked ${value}`);
93
+ return;
94
+ case "general": {
95
+ if (value !== "private" && value !== "password" && value !== "public") throw new Error("general needs a mode: private · password · public");
96
+ const r = await api(root, "PUT", "share/general", { mode: value });
97
+ if (r.clamped) console.log(` general access → ${r.clamped.operative} (you asked for ${r.clamped.asked}, but the gate this canvas runs cannot enforce it - the roster never claims a state the server does not hold)`);
98
+ else console.log(` general access → ${value}${value === "public" ? " (anyone with the URL reads - Public is called Public)" : ""}`);
99
+ return;
100
+ }
101
+ case "list": {
102
+ const r = await api(root, "GET", "share/roster");
103
+ if (opts.json) return console.log(JSON.stringify(r, null, 2));
104
+ console.log(`\n general access: ${r.general.mode}`);
105
+ if (r.blocked.length) console.log(` blocked: ${r.blocked.join(", ")}`);
106
+ if (!r.grants.length) console.log(" no grants - only the owner (and general access, if open) gets in");
107
+ for (const g of r.grants) console.log(` ${g.principal.padEnd(28)} ${g.assigned.padEnd(8)}${g.expires ? ` until ${g.expires.slice(0, 10)}` : ""} by ${g.by}`);
108
+ if (r.requests.length) console.log(`\n ${r.requests.length} pending request${r.requests.length === 1 ? "" : "s"} - \`${NAME} share requests\``);
109
+ console.log("");
110
+ return;
111
+ }
112
+ case "requests": {
113
+ if (opts.approve || opts.decline) {
114
+ const email = opts.approve ?? opts.decline;
115
+ await api(root, "POST", `share/request/${encodeURIComponent(email)}`, {
116
+ approve: !!opts.approve,
117
+ ...opts.approve ? { assigned: opts.role === "comment" ? "comment" : "view" } : {}
118
+ });
119
+ console.log(opts.approve ? ` approved ${email} → ${opts.role === "comment" ? "comment" : "view"} - CANVAS-WIDE (v1 approval covers every published board)` : ` declined ${email} (silently - no rejection reaches them)`);
120
+ return;
121
+ }
122
+ const r = await api(root, "GET", "share/roster");
123
+ if (!r.requests.length) return console.log(" no pending requests");
124
+ for (const q of r.requests) console.log(` ${q.email.padEnd(28)} asks ${q.requestedRole.padEnd(8)}${q.target ? ` for ${q.target}` : ""}${q.note ? ` "${q.note}"` : ""}`);
125
+ console.log(`\n approve: \`${NAME} share requests --approve <email> [--role comment]\` · decline: \`--decline <email>\``);
126
+ return;
127
+ }
128
+ case "explain": {
129
+ if (!value) throw new Error("explain needs a principal to explain");
130
+ const r = await api(root, "GET", `share/explain?who=${encodeURIComponent(value)}`);
131
+ console.log(`\n${renderExplain(r)}\n`);
132
+ return;
133
+ }
134
+ case "who": {
135
+ const [roster, ceilings] = await Promise.all([api(root, "GET", "share/roster"), fetchCeilings(root)]);
136
+ const store = asStore(roster);
137
+ const boards = Object.keys(ceilings);
138
+ const principals = [...new Set(store.grants.map((g) => g.principal))];
139
+ console.log(`\n ${"".padEnd(28)} ${boards.map((b) => b.padEnd(16)).join("")}`);
140
+ for (const p of principals) {
141
+ const r = resolveAccess({
142
+ email: p.startsWith("@") ? null : p,
143
+ store,
144
+ ceilings
145
+ });
146
+ const cells = p.startsWith("@") ? boards.map(() => "(per member)") : boards.map((b) => r.boards[b]);
147
+ console.log(` ${p.padEnd(28)} ${cells.map((c) => String(c).padEnd(16)).join("")}`);
148
+ }
149
+ for (const b of store.blocked) console.log(` blocked ${String(b).padEnd(20)} ${boards.map(() => "none".padEnd(16)).join("")}`);
150
+ if (store.general.mode !== "private") console.log(` ${"anyone (general access)".padEnd(28)} ${boards.map((b) => "view".padEnd(16)).join("")}`);
151
+ console.log("");
152
+ return;
153
+ }
154
+ default: throw new Error(`unknown action "${action}" - use add · remove · block · unblock · general · list · requests · explain · who`);
155
+ }
156
+ }
157
+ //#endregion
158
+ export { shareCommand };