@marver-design/marver 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +1 -1
- package/dist/auth-DdUKeVKt.mjs +1150 -0
- package/dist/{build-DI31MFTB.mjs → build-DmfxJc6L.mjs} +188 -33
- package/dist/cli.mjs +16 -7
- package/dist/collab-BmaOjwP1.mjs +781 -0
- package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
- package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
- package/dist/{dev-BWIL2ewj.mjs → dev-BXq7yE1p.mjs} +5 -5
- package/dist/events-C9k-ceOj.mjs +101 -0
- package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
- package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
- package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
- package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-DOjnZakC.mjs} +94 -6
- package/dist/{plugin-DjBFxEQ7.mjs → plugin-Boy7I_JH.mjs} +82 -9
- package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
- package/dist/{serve-3APqaNSB.mjs → serve-BeLLizNg.mjs} +70 -17
- package/dist/share-C64b1tdN.mjs +159 -0
- package/dist/summary-Bf9tiGrE.mjs +211 -0
- package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
- package/package.json +1 -1
- package/src/client/shell/App.tsx +43 -8
- package/src/client/shell/Comments.tsx +9 -5
- package/src/client/shell/LockedApp.tsx +113 -0
- package/src/client/shell/Play.tsx +97 -18
- package/src/client/shell/Toolbar.tsx +26 -2
- package/src/client/shell/canvas/Canvas.tsx +5 -15
- package/src/client/shell/canvas/ctl.ts +26 -0
- package/src/client/shell/comments-store.ts +39 -9
- package/src/client/shell/hash.ts +19 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +54 -3
- package/src/client/shell/styles.css +27 -0
- package/src/shared/events.ts +8 -3
- package/dist/auth-B5yuwnOq.mjs +0 -494
- package/dist/collab-CWaG3Q4w.mjs +0 -340
- package/dist/manifest-C2tzkNaC.mjs +0 -233
- package/dist/update-DuWDj5nR.mjs +0 -77
- package/dist/utm-CxC3QN5X.mjs +0 -20
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { i as avatarSourceFor, n as attachAvatar,
|
|
2
|
-
import { TransactionStore, browserBinding, verifyAssertion } from "./marver-id-
|
|
3
|
-
import { t as poweredByUrl } from "./
|
|
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-BeLLizNg.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
|
-
}, {
|
|
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-Bf9tiGrE.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;
|
|
@@ -538,6 +563,24 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
|
|
|
538
563
|
needs, appearing and vanishing, which reads as a stutter rather than as
|
|
539
564
|
progress. The card is for when something has to be SAID; getting in says
|
|
540
565
|
itself by the canvas appearing. */
|
|
566
|
+
/* the request-access ask (01-sharing §7.6): the gate's own language - chips,
|
|
567
|
+
rounded field, the same CTA - below a hairline. */
|
|
568
|
+
.rdiv { border: 0; height: 1px; background: rgba(24,24,27,.09); margin: 2px 2px 6px }
|
|
569
|
+
.rrow { display: flex; gap: 8px; padding: 0 2px }
|
|
570
|
+
.rchip { flex: 1; cursor: pointer }
|
|
571
|
+
.rchip input { position: absolute; opacity: 0; pointer-events: none }
|
|
572
|
+
.rchip span { display: flex; align-items: center; justify-content: center; height: 34px;
|
|
573
|
+
border-radius: 999px; border: 1px solid rgba(24,24,27,.14); background: #fff;
|
|
574
|
+
font: 500 12.5px -apple-system, system-ui, sans-serif; transition: border-color .15s, box-shadow .15s }
|
|
575
|
+
.rchip input:checked + span { border-color: #0088ff; box-shadow: 0 0 0 3px rgba(0,136,255,.14); color: #0088ff }
|
|
576
|
+
#req { display: flex; flex-direction: column; gap: 12px }
|
|
577
|
+
#req[hidden] { display: none }
|
|
578
|
+
#req textarea { padding: 10px 16px; border-radius: 16px; border: 1px solid rgba(24,24,27,.14);
|
|
579
|
+
background: #fff; color: #18181b; font: 500 13px -apple-system, system-ui, sans-serif;
|
|
580
|
+
outline: none; resize: none; width: 100% }
|
|
581
|
+
#req textarea:focus { border-color: #0088ff; box-shadow: 0 0 0 3px rgba(0,136,255,.18) }
|
|
582
|
+
#req button.cta { border: 0; width: 100%; cursor: pointer }
|
|
583
|
+
#req button.cta:disabled { background: rgba(24,24,27,.35); cursor: default }
|
|
541
584
|
.wait { width: 34px; height: 34px; border-radius: 50%;
|
|
542
585
|
border: 3px solid rgba(24,24,27,.12); border-top-color: #0088ff;
|
|
543
586
|
animation: spin .7s linear infinite }
|
|
@@ -555,6 +598,16 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
|
|
|
555
598
|
<p class="lead" id="m"></p>
|
|
556
599
|
<p class="lead" id="m2" hidden></p>
|
|
557
600
|
<a class="cta" id="back" href="/" data-switch="${esc(switchUrl)}" hidden>Use a different account</a>
|
|
601
|
+
<div id="req" hidden>
|
|
602
|
+
<hr class="rdiv" />
|
|
603
|
+
<p class="lead">Or ask for access - the owner decides who gets in.</p>
|
|
604
|
+
<div class="rrow">
|
|
605
|
+
<label class="rchip"><input type="radio" name="rrole" value="view" checked /><span>View</span></label>
|
|
606
|
+
<label class="rchip"><input type="radio" name="rrole" value="comment" /><span>View + comment</span></label>
|
|
607
|
+
</div>
|
|
608
|
+
<textarea id="rnote" maxlength="500" rows="2" placeholder="Add a note (optional)"></textarea>
|
|
609
|
+
<button class="cta" id="rgo" type="button">Request access</button>
|
|
610
|
+
</div>
|
|
558
611
|
</div>
|
|
559
612
|
${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
613
|
<script>
|
|
@@ -628,6 +681,40 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
|
|
|
628
681
|
back.hidden = false
|
|
629
682
|
}
|
|
630
683
|
|
|
684
|
+
/** The door a refusal offers: role, note, one send. The response is the same
|
|
685
|
+
* whatever the server recognised (no probe oracle) - "sent" is all it says. */
|
|
686
|
+
function offerRequest(token, target) {
|
|
687
|
+
var req = document.getElementById('req'), go = document.getElementById('rgo')
|
|
688
|
+
req.hidden = false
|
|
689
|
+
// name what the link pointed at - context the owner sees with the ask
|
|
690
|
+
if (target) {
|
|
691
|
+
var lead = req.querySelector('p.lead')
|
|
692
|
+
if (lead) {
|
|
693
|
+
lead.textContent = 'Or ask for access - the owner decides. Your request will name what this link pointed at ('
|
|
694
|
+
var b = document.createElement('code')
|
|
695
|
+
b.textContent = target
|
|
696
|
+
b.style.font = '500 11.5px ui-monospace, monospace'
|
|
697
|
+
lead.appendChild(b)
|
|
698
|
+
lead.appendChild(document.createTextNode(').'))
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
go.addEventListener('click', function () {
|
|
702
|
+
go.disabled = true
|
|
703
|
+
var role = (document.querySelector('input[name="rrole"]:checked') || {}).value === 'comment' ? 'comment' : 'view'
|
|
704
|
+
var note = (document.getElementById('rnote') || {}).value || ''
|
|
705
|
+
fetch('/__mv/api/request-access', {
|
|
706
|
+
method: 'POST',
|
|
707
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token },
|
|
708
|
+
body: JSON.stringify({ requestedRole: role, note: note.slice(0, 500) })
|
|
709
|
+
}).then(function () { sent() }, function () { sent() })
|
|
710
|
+
function sent() {
|
|
711
|
+
go.textContent = 'Request sent'
|
|
712
|
+
var lead = req.querySelector('p.lead')
|
|
713
|
+
if (lead) lead.textContent = 'The owner will review it. If they approve, signing in again will let you straight in.'
|
|
714
|
+
}
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
|
|
631
718
|
// Take it, then erase it - before any await, so a slow network cannot leave
|
|
632
719
|
// the assertion sitting in the address bar.
|
|
633
720
|
var assertion = location.hash.replace(/^#/, '')
|
|
@@ -658,6 +745,7 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
|
|
|
658
745
|
who ? refused(who) : stop("You haven't been invited",
|
|
659
746
|
'That account is not on the invite list for this canvas. Ask whoever owns it to add your address.',
|
|
660
747
|
'Use a different account')
|
|
748
|
+
if (who && body && typeof body.request === 'string') offerRequest(body.request, typeof body.target === 'string' ? body.target : '')
|
|
661
749
|
}, function () {
|
|
662
750
|
stop("You haven't been invited",
|
|
663
751
|
'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-
|
|
3
|
-
import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-
|
|
4
|
-
import { n as installedVersion, t as checkUpdate } from "./update-DuWDj5nR.mjs";
|
|
3
|
+
import { n as localProfile, t as isConnected } from "./profile-ChpTPd-X.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-
|
|
309
|
+
const { validAvatar } = await import("./collab-BmaOjwP1.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-
|
|
400
|
+
const { loadCollab, syncOnce } = await import("./sync-WYxDN9IT.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-
|
|
429
|
+
const { loadCollab, syncOnce } = await import("./sync-WYxDN9IT.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,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-
|
|
77
|
+
const { collabHandler } = await import("./collab-BmaOjwP1.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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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-
|
|
67
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
149
|
+
const { marverIdHandler } = await import("./marver-id-gate-DOjnZakC.mjs");
|
|
115
150
|
const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
|
|
116
151
|
const canvasName = humanName(meta.name);
|
|
117
|
-
|
|
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,159 @@
|
|
|
1
|
+
import { n as NAME } from "./cli.mjs";
|
|
2
|
+
import { M as resolveAccess } from "./auth-DdUKeVKt.mjs";
|
|
3
|
+
import { i as loadCollab } from "./sync-WYxDN9IT.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` and `who` run the SAME pure function the
|
|
13
|
+
* server enforces with, on the same roster bytes, so the terminal and the
|
|
14
|
+
* dialog can never disagree (acceptance 9).
|
|
15
|
+
*
|
|
16
|
+
* add <who> grant view (default) or --role comment; --expires <iso>
|
|
17
|
+
* remove <who> remove the grant
|
|
18
|
+
* block <email> / unblock <email>
|
|
19
|
+
* general <mode> private · password · public
|
|
20
|
+
* list the roster - general access, grants, blocklist
|
|
21
|
+
* requests pending access requests; --approve <email> [--role r] · --decline <email>
|
|
22
|
+
* explain <who> the resolver's trace for one principal
|
|
23
|
+
* who the whole matrix
|
|
24
|
+
*/
|
|
25
|
+
async function api(root, method, path, body) {
|
|
26
|
+
const collab = loadCollab(root);
|
|
27
|
+
if (!collab) throw new Error(`no canvas connected - run \`npx ${NAME} comments connect <url>\` first`);
|
|
28
|
+
const res = await fetch(`${collab.url.replace(/\/+$/, "")}/__mv/api/${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: {
|
|
31
|
+
authorization: `Bearer ${collab.token}`,
|
|
32
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
33
|
+
},
|
|
34
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
35
|
+
});
|
|
36
|
+
if (res.status === 403) throw new Error("the canvas refused - sharing is owner-only, and the connect credential must be the owner's");
|
|
37
|
+
if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `canvas answered ${res.status}`);
|
|
38
|
+
return res.json();
|
|
39
|
+
}
|
|
40
|
+
/** The published ceilings, from the boards route - the same rights serve enforces with. */
|
|
41
|
+
async function fetchCeilings(root) {
|
|
42
|
+
const { rights } = await api(root, "GET", "boards");
|
|
43
|
+
return Object.fromEntries(Object.entries(rights).map(([b, r]) => [b, r === "comment" ? "comment" : "view"]));
|
|
44
|
+
}
|
|
45
|
+
const asStore = (roster) => ({
|
|
46
|
+
version: 1,
|
|
47
|
+
general: roster.general,
|
|
48
|
+
blocked: roster.blocked,
|
|
49
|
+
grants: roster.grants
|
|
50
|
+
});
|
|
51
|
+
/** The trace, rendered - what `explain` prints and what the dialog shows. */
|
|
52
|
+
function renderExplain(who, store, ceilings) {
|
|
53
|
+
const r = resolveAccess({
|
|
54
|
+
email: who.startsWith("@") ? null : who,
|
|
55
|
+
store,
|
|
56
|
+
ceilings
|
|
57
|
+
});
|
|
58
|
+
const lines = [];
|
|
59
|
+
for (const step of r.trace) lines.push(` ${step.where.padEnd(14)} ${step.role.padEnd(9)} ${step.why}`);
|
|
60
|
+
const boards = Object.entries(r.boards);
|
|
61
|
+
const readable = boards.filter(([, role]) => role !== "none").length;
|
|
62
|
+
const writable = boards.filter(([, role]) => role === "comment").length;
|
|
63
|
+
for (const [b, role] of boards) lines.push(` board ${b.padEnd(14)} ${role}`);
|
|
64
|
+
lines.push(` → ${readable} of ${boards.length} board${boards.length === 1 ? "" : "s"}, may comment on ${writable}`);
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
async function shareCommand(root, action, value, opts) {
|
|
68
|
+
switch (action) {
|
|
69
|
+
case "add": {
|
|
70
|
+
if (!value) throw new Error("add needs a principal: an email, or @domain");
|
|
71
|
+
const role = opts.role === "comment" ? "comment" : "view";
|
|
72
|
+
const r = await api(root, "PUT", "share/grant", {
|
|
73
|
+
principal: value,
|
|
74
|
+
scope: "canvas",
|
|
75
|
+
assigned: role,
|
|
76
|
+
expires: opts.expires ?? null
|
|
77
|
+
});
|
|
78
|
+
console.log(` granted ${value} → ${role}${opts.expires ? ` until ${opts.expires}` : ""} (${r.grants.length} grant${r.grants.length === 1 ? "" : "s"} total)`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
case "remove":
|
|
82
|
+
if (!value) throw new Error("remove needs the principal to remove");
|
|
83
|
+
await api(root, "DELETE", "share/grant", {
|
|
84
|
+
principal: value,
|
|
85
|
+
scope: "canvas"
|
|
86
|
+
});
|
|
87
|
+
console.log(` removed ${value}`);
|
|
88
|
+
return;
|
|
89
|
+
case "block":
|
|
90
|
+
case "unblock":
|
|
91
|
+
if (!value) throw new Error(`${action} needs an email address`);
|
|
92
|
+
await api(root, action === "block" ? "PUT" : "DELETE", "share/block", { address: value });
|
|
93
|
+
console.log(action === "block" ? ` blocked ${value} - refused everywhere, ahead of every grant.\n Blocking only bites while general access is Private.` : ` unblocked ${value}`);
|
|
94
|
+
return;
|
|
95
|
+
case "general": {
|
|
96
|
+
if (value !== "private" && value !== "password" && value !== "public") throw new Error("general needs a mode: private · password · public");
|
|
97
|
+
const r = await api(root, "PUT", "share/general", { mode: value });
|
|
98
|
+
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)`);
|
|
99
|
+
else console.log(` general access → ${value}${value === "public" ? " (anyone with the URL reads - Public is called Public)" : ""}`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
case "list": {
|
|
103
|
+
const r = await api(root, "GET", "share/roster");
|
|
104
|
+
if (opts.json) return console.log(JSON.stringify(r, null, 2));
|
|
105
|
+
console.log(`\n general access: ${r.general.mode}`);
|
|
106
|
+
if (r.blocked.length) console.log(` blocked: ${r.blocked.join(", ")}`);
|
|
107
|
+
if (!r.grants.length) console.log(" no grants - only the owner (and general access, if open) gets in");
|
|
108
|
+
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}`);
|
|
109
|
+
if (r.requests.length) console.log(`\n ${r.requests.length} pending request${r.requests.length === 1 ? "" : "s"} - \`${NAME} share requests\``);
|
|
110
|
+
console.log("");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
case "requests": {
|
|
114
|
+
if (opts.approve || opts.decline) {
|
|
115
|
+
const email = opts.approve ?? opts.decline;
|
|
116
|
+
await api(root, "POST", `share/request/${encodeURIComponent(email)}`, {
|
|
117
|
+
approve: !!opts.approve,
|
|
118
|
+
...opts.approve ? { assigned: opts.role === "comment" ? "comment" : "view" } : {}
|
|
119
|
+
});
|
|
120
|
+
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)`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const r = await api(root, "GET", "share/roster");
|
|
124
|
+
if (!r.requests.length) return console.log(" no pending requests");
|
|
125
|
+
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}"` : ""}`);
|
|
126
|
+
console.log(`\n approve: \`${NAME} share requests --approve <email> [--role comment]\` · decline: \`--decline <email>\``);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
case "explain": {
|
|
130
|
+
if (!value) throw new Error("explain needs a principal to explain");
|
|
131
|
+
const [roster, ceilings] = await Promise.all([api(root, "GET", "share/roster"), fetchCeilings(root)]);
|
|
132
|
+
console.log(`\n${renderExplain(value, asStore(roster), ceilings)}\n`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
case "who": {
|
|
136
|
+
const [roster, ceilings] = await Promise.all([api(root, "GET", "share/roster"), fetchCeilings(root)]);
|
|
137
|
+
const store = asStore(roster);
|
|
138
|
+
const boards = Object.keys(ceilings);
|
|
139
|
+
const principals = [...new Set(store.grants.map((g) => g.principal))];
|
|
140
|
+
console.log(`\n ${"".padEnd(28)} ${boards.map((b) => b.padEnd(16)).join("")}`);
|
|
141
|
+
for (const p of principals) {
|
|
142
|
+
const r = resolveAccess({
|
|
143
|
+
email: p.startsWith("@") ? null : p,
|
|
144
|
+
store,
|
|
145
|
+
ceilings
|
|
146
|
+
});
|
|
147
|
+
const cells = p.startsWith("@") ? boards.map(() => "(per member)") : boards.map((b) => r.boards[b]);
|
|
148
|
+
console.log(` ${p.padEnd(28)} ${cells.map((c) => String(c).padEnd(16)).join("")}`);
|
|
149
|
+
}
|
|
150
|
+
for (const b of store.blocked) console.log(` blocked ${String(b).padEnd(20)} ${boards.map(() => "none".padEnd(16)).join("")}`);
|
|
151
|
+
if (store.general.mode !== "private") console.log(` ${"anyone (general access)".padEnd(28)} ${boards.map((b) => "view".padEnd(16)).join("")}`);
|
|
152
|
+
console.log("");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
default: throw new Error(`unknown action "${action}" - use add · remove · block · unblock · general · list · requests · explain · who`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
export { shareCommand };
|