@marver-design/marver 0.10.1 → 0.11.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 +176 -0
- package/dist/auth-B5yuwnOq.mjs +494 -0
- package/dist/{build-BkckEcZd.mjs → build-Cr_ZLMdq.mjs} +3 -2
- package/dist/cli.mjs +6 -6
- package/dist/{collab-s3k5byM1.mjs → collab-C-n-uQdm.mjs} +67 -11
- package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
- package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
- package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
- package/dist/{daemon-B_8BmmSJ.mjs → daemon-DSmaS453.mjs} +24 -6
- package/dist/{dev-Bm-R_-gs.mjs → dev-CLBjRs0J.mjs} +126 -12
- package/dist/{init-D2EQEbV6.mjs → init-BSclDg4I.mjs} +9 -29
- package/dist/{ledger-BgA7nQoH.mjs → ledger-Bu0BjqIe.mjs} +1 -12
- package/dist/managed-HwHVNI3h.mjs +94 -0
- package/dist/manifest-C2tzkNaC.mjs +233 -0
- package/dist/marver-id-B8-3WiHk.mjs +411 -0
- package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
- package/dist/{plugin-DhkR3NdW.mjs → plugin-vE7sxEeT.mjs} +18 -80
- package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/{serve-CZqPnj19.mjs → serve-CZ1KEQxd.mjs} +194 -42
- package/dist/{shot-BRgPaFCs.mjs → shot-Cyv3GN79.mjs} +140 -13
- package/dist/sync-DELGomPk.mjs +397 -0
- package/dist/update-DuWDj5nR.mjs +77 -0
- package/dist/utm-CxC3QN5X.mjs +20 -0
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +6 -0
- package/src/client/frame-host/bridge.js +3 -1
- package/src/client/frame-host/main.tsx +4 -1
- package/src/client/shell/App.tsx +5 -0
- package/src/client/shell/canvas/FrameNode.tsx +45 -6
- package/src/client/shell/canvas/ready-watch.ts +28 -0
- package/src/client/shell/store.ts +27 -6
- package/src/client/shell/styles.css +8 -0
- package/src/shared/utm.ts +2 -2
- package/templates/instructions/configure.md +4 -0
- package/templates/instructions/jam.md +4 -0
- package/templates/instructions/publish.md +84 -15
- package/dist/auth-KQ9Aj-nB.mjs +0 -245
- package/dist/events-BMtBvvgU.mjs +0 -101
- package/dist/sync-BJKKmy1n.mjs +0 -150
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
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-
|
|
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";
|
|
4
5
|
import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
5
|
-
import { basename,
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
7
7
|
import { randomBytes } from "node:crypto";
|
|
8
8
|
//#region src/server/api.ts
|
|
9
9
|
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -265,7 +265,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
265
265
|
const frameId = url.searchParams.get("frame") ?? "";
|
|
266
266
|
const theme = url.searchParams.get("theme") ?? "light";
|
|
267
267
|
const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
|
|
268
|
-
const { shootFrame } = await import("./shot-
|
|
268
|
+
const { shootFrame } = await import("./shot-Cyv3GN79.mjs");
|
|
269
269
|
const r = await shootFrame({
|
|
270
270
|
root,
|
|
271
271
|
viewports: opts.viewports ?? {},
|
|
@@ -279,7 +279,12 @@ function apiMiddleware(root, opts = {}) {
|
|
|
279
279
|
frame: frameId,
|
|
280
280
|
theme,
|
|
281
281
|
width: r.width,
|
|
282
|
-
height: r.height
|
|
282
|
+
height: r.height,
|
|
283
|
+
scale: r.scale,
|
|
284
|
+
...r.truncated ? {
|
|
285
|
+
truncated: true,
|
|
286
|
+
note: r.note
|
|
287
|
+
} : {}
|
|
283
288
|
});
|
|
284
289
|
}
|
|
285
290
|
if (path === "me" && req.method === "GET") return json(res, 200, {
|
|
@@ -300,7 +305,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
300
305
|
}
|
|
301
306
|
const dir = join(root, "design", ".local");
|
|
302
307
|
mkdirSync(dir, { recursive: true });
|
|
303
|
-
const { validAvatar } = await import("./collab-
|
|
308
|
+
const { validAvatar } = await import("./collab-C-n-uQdm.mjs");
|
|
304
309
|
let cur = {};
|
|
305
310
|
try {
|
|
306
311
|
cur = JSON.parse(readFileSync(join(dir, "profile.json"), "utf8"));
|
|
@@ -347,7 +352,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
347
352
|
}
|
|
348
353
|
}
|
|
349
354
|
if (path === "boards.rights" && req.method === "GET") {
|
|
350
|
-
const { listBoards } = await import("./comments-
|
|
355
|
+
const { listBoards } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
|
|
351
356
|
return json(res, 200, {
|
|
352
357
|
local: true,
|
|
353
358
|
stored: listBoards(join(root, "design", "comments"))
|
|
@@ -355,7 +360,7 @@ function apiMiddleware(root, opts = {}) {
|
|
|
355
360
|
}
|
|
356
361
|
const cm = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
|
|
357
362
|
if (cm) {
|
|
358
|
-
const { appendEvents, readLog } = await import("./comments-
|
|
363
|
+
const { appendEvents, readLog } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
|
|
359
364
|
const dir = join(root, "design", "comments");
|
|
360
365
|
if (req.method === "GET") return json(res, 200, { events: readLog(dir, cm[1]) });
|
|
361
366
|
if (req.method === "POST") {
|
|
@@ -384,14 +389,14 @@ function apiMiddleware(root, opts = {}) {
|
|
|
384
389
|
};
|
|
385
390
|
});
|
|
386
391
|
const fresh = appendEvents(dir, cm[1], stamped);
|
|
387
|
-
const { record } = await import("./ledger-
|
|
392
|
+
const { record } = await import("./ledger-Bu0BjqIe.mjs").then((n) => n.n);
|
|
388
393
|
for (const ev of fresh) if (ev.type === "create" || ev.type === "reply") record(root, cm[1], ev.id);
|
|
389
394
|
backgroundPush(root);
|
|
390
395
|
return json(res, 200, { accepted: fresh.length });
|
|
391
396
|
}
|
|
392
397
|
}
|
|
393
398
|
if (path === "sync" && req.method === "POST") {
|
|
394
|
-
const { loadCollab, syncOnce } = await import("./sync-
|
|
399
|
+
const { loadCollab, syncOnce } = await import("./sync-DELGomPk.mjs").then((n) => n.o);
|
|
395
400
|
const collab = loadCollab(root);
|
|
396
401
|
if (!collab) return json(res, 200, { connected: false });
|
|
397
402
|
try {
|
|
@@ -420,7 +425,7 @@ function backgroundPush(root) {
|
|
|
420
425
|
pushTimer = setTimeout(async () => {
|
|
421
426
|
pushTimer = null;
|
|
422
427
|
try {
|
|
423
|
-
const { loadCollab, syncOnce } = await import("./sync-
|
|
428
|
+
const { loadCollab, syncOnce } = await import("./sync-DELGomPk.mjs").then((n) => n.o);
|
|
424
429
|
const collab = loadCollab(root);
|
|
425
430
|
if (collab) await syncOnce(root, collab);
|
|
426
431
|
} catch {}
|
|
@@ -478,73 +483,6 @@ function routesMiddleware(server, clientDir) {
|
|
|
478
483
|
};
|
|
479
484
|
}
|
|
480
485
|
//#endregion
|
|
481
|
-
//#region src/server/update.ts
|
|
482
|
-
/**
|
|
483
|
-
* Update discovery - dev only, and deliberately boring about privacy: one anonymous
|
|
484
|
-
* registry metadata GET per day (the same request `npm view` makes), cached in
|
|
485
|
-
* design/.local/, nothing sent beyond the request itself. Offline, slow, or
|
|
486
|
-
* firewalled registries degrade to silence. MARVER_NO_UPDATE_CHECK=1 disables it.
|
|
487
|
-
* Published bundles never check anything - viewers are not the owner.
|
|
488
|
-
*/
|
|
489
|
-
const TTL = 864e5;
|
|
490
|
-
/** Installed version: walk up from this module to the package's own package.json
|
|
491
|
-
* (one level from dist/, two from src/server/ - the walk covers both). */
|
|
492
|
-
function installedVersion() {
|
|
493
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
494
|
-
for (let i = 0; i < 4; i++) {
|
|
495
|
-
try {
|
|
496
|
-
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
497
|
-
if (pkg.name === "@marver-design/marver") return pkg.version;
|
|
498
|
-
} catch {}
|
|
499
|
-
dir = dirname(dir);
|
|
500
|
-
}
|
|
501
|
-
return null;
|
|
502
|
-
}
|
|
503
|
-
/** Strictly-newer numeric compare; anything non-numeric (prerelease tags) compares false. */
|
|
504
|
-
const newer = (a, b) => {
|
|
505
|
-
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
506
|
-
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
507
|
-
for (let i = 0; i < 3; i++) {
|
|
508
|
-
const x = pa[i] ?? 0, y = pb[i] ?? 0;
|
|
509
|
-
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
|
510
|
-
if (x !== y) return x > y;
|
|
511
|
-
}
|
|
512
|
-
return false;
|
|
513
|
-
};
|
|
514
|
-
/** The latest published version when strictly newer than installed, else null. Never throws. */
|
|
515
|
-
async function checkUpdate(root) {
|
|
516
|
-
if (process.env.MARVER_NO_UPDATE_CHECK) return null;
|
|
517
|
-
const current = installedVersion();
|
|
518
|
-
if (!current) return null;
|
|
519
|
-
const latest = await latestVersion(root);
|
|
520
|
-
return latest && newer(latest, current) ? latest : null;
|
|
521
|
-
}
|
|
522
|
-
async function latestVersion(root) {
|
|
523
|
-
const cacheFile = join(root, "design", ".local", "update-check.json");
|
|
524
|
-
const wellFormed = (v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v);
|
|
525
|
-
try {
|
|
526
|
-
const c = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
527
|
-
const age = Date.now() - c.checkedAt;
|
|
528
|
-
if (typeof c.checkedAt === "number" && age >= 0 && age < TTL) return wellFormed(c.latest) ? c.latest : null;
|
|
529
|
-
} catch {}
|
|
530
|
-
let latest = null;
|
|
531
|
-
try {
|
|
532
|
-
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, { signal: AbortSignal.timeout(3e3) });
|
|
533
|
-
if (res.ok) {
|
|
534
|
-
const v = (await res.json())?.version;
|
|
535
|
-
if (wellFormed(v)) latest = v;
|
|
536
|
-
}
|
|
537
|
-
} catch {}
|
|
538
|
-
try {
|
|
539
|
-
mkdirSync(join(root, "design", ".local"), { recursive: true });
|
|
540
|
-
writeFileSync(cacheFile, JSON.stringify({
|
|
541
|
-
checkedAt: Date.now(),
|
|
542
|
-
latest
|
|
543
|
-
}) + "\n");
|
|
544
|
-
} catch {}
|
|
545
|
-
return latest;
|
|
546
|
-
}
|
|
547
|
-
//#endregion
|
|
548
486
|
//#region src/server/plugin.ts
|
|
549
487
|
const VIRTUAL_THEME = "virtual:sh-theme";
|
|
550
488
|
const VIRTUAL_CONFIG = "virtual:sh-config";
|
|
@@ -788,7 +726,7 @@ function marverPlugin(ctx) {
|
|
|
788
726
|
});
|
|
789
727
|
return;
|
|
790
728
|
}
|
|
791
|
-
const { shootFrame } = await import("./shot-
|
|
729
|
+
const { shootFrame } = await import("./shot-Cyv3GN79.mjs");
|
|
792
730
|
write(await shootFrame({
|
|
793
731
|
root,
|
|
794
732
|
viewports: config.viewports,
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { i as loadCollab } from "./sync-DELGomPk.mjs";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
//#region src/server/profile.ts
|
|
4
5
|
/**
|
|
5
6
|
* The dev identity - ONE resolver for every server-side consumer (api, jam daemon, CLI).
|
|
6
7
|
*
|
|
7
|
-
* Two sources merge
|
|
8
|
-
* - profile.json {name, email?, avatar?} - set from the composer's
|
|
9
|
-
*
|
|
8
|
+
* Two sources merge:
|
|
9
|
+
* - design/.local/profile.json {name, email?, avatar?} - set from the composer's
|
|
10
|
+
* avatar popover
|
|
11
|
+
* - the connect account {email, name}, written by `comments connect` into
|
|
12
|
+
* ~/.marver/canvases/ - outside the repo, because `marver dev` serves the repo
|
|
10
13
|
*
|
|
11
14
|
* The connect account wins name + email (published serves validate author == session, so
|
|
12
15
|
* events born here must carry an identity the remote will accept). The avatar always comes
|
|
@@ -23,16 +26,16 @@ const readJson = (path) => {
|
|
|
23
26
|
const str = (v) => typeof v === "string" ? v.trim() : "";
|
|
24
27
|
/** True when a connect account provides the identity - name/email are then read-only in dev. */
|
|
25
28
|
function isConnected(root) {
|
|
26
|
-
return !!str(
|
|
29
|
+
return !!str(loadCollab(root)?.email);
|
|
27
30
|
}
|
|
28
31
|
function localProfile(root) {
|
|
29
32
|
const dir = join(root, "design", ".local");
|
|
30
33
|
const prof = readJson(join(dir, "profile.json"));
|
|
31
|
-
const collab =
|
|
34
|
+
const collab = loadCollab(root) ?? {};
|
|
32
35
|
return {
|
|
33
36
|
name: str(collab.name) || str(prof.name) || "You",
|
|
34
37
|
email: str(collab.email) || str(prof.email),
|
|
35
|
-
avatar: str(prof.avatar) || void 0
|
|
38
|
+
avatar: str(collab.avatar) || str(prof.avatar) || void 0
|
|
36
39
|
};
|
|
37
40
|
}
|
|
38
41
|
//#endregion
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { __exportAll as t };
|
|
@@ -1,27 +1,9 @@
|
|
|
1
1
|
import { n as NAME } from "./cli.mjs";
|
|
2
|
+
import { t as poweredByUrl } from "./utm-CxC3QN5X.mjs";
|
|
2
3
|
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
4
|
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
5
|
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
5
6
|
import { createServer } from "node:http";
|
|
6
|
-
//#region src/shared/utm.ts
|
|
7
|
-
/** Automatic attribution for the powered-by links: every canvas tags its marver.design
|
|
8
|
-
* link so site analytics can see WHICH canvas sent a visitor - zero user setup.
|
|
9
|
-
* Conventional UTM semantics (stable groupings first, the variable last):
|
|
10
|
-
* utm_source = the surface class ('published-canvas' | 'dev-canvas')
|
|
11
|
-
* utm_medium = the link unit ('powered-by')
|
|
12
|
-
* utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
|
|
13
|
-
* utm_content = the placement ('gate' badge | 'shell' wordmark)
|
|
14
|
-
*/
|
|
15
|
-
function poweredByUrl(canvasName, source, content) {
|
|
16
|
-
const slug = (canvasName ?? "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
17
|
-
return `https://marver.design/?${new URLSearchParams({
|
|
18
|
-
utm_source: source,
|
|
19
|
-
utm_medium: "powered-by",
|
|
20
|
-
...slug ? { utm_campaign: slug } : {},
|
|
21
|
-
utm_content: content
|
|
22
|
-
}).toString()}`;
|
|
23
|
-
}
|
|
24
|
-
//#endregion
|
|
25
7
|
//#region src/server/serve.ts
|
|
26
8
|
/**
|
|
27
9
|
* `marver serve` - a zero-dependency static server for design/.dist with an optional
|
|
@@ -66,11 +48,12 @@ async function serve(root, portFlag) {
|
|
|
66
48
|
} catch {}
|
|
67
49
|
let collab = null;
|
|
68
50
|
let bearerCheck = null;
|
|
51
|
+
let operatorCheck = null;
|
|
69
52
|
let sessionCheck = null;
|
|
70
53
|
if (process.env.MARVER_DATA_DIR) {
|
|
71
|
-
const { collabHandler } = await import("./collab-
|
|
72
|
-
const { appendEvents, readLog } = await import("./comments-
|
|
73
|
-
const { dataDir } = await import("./comments-
|
|
54
|
+
const { collabHandler } = await import("./collab-C-n-uQdm.mjs");
|
|
55
|
+
const { appendEvents, readLog } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
|
|
56
|
+
const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
|
|
74
57
|
const dir = dataDir();
|
|
75
58
|
const seedDir = join(dist, "design", "comments");
|
|
76
59
|
if (existsSync(seedDir)) for (const f of readdirSync(seedDir)) {
|
|
@@ -79,13 +62,14 @@ async function serve(root, portFlag) {
|
|
|
79
62
|
appendEvents(join(dir, "comments"), board, readLog(seedDir, board));
|
|
80
63
|
}
|
|
81
64
|
collab = collabHandler(dir, dist);
|
|
82
|
-
const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-
|
|
65
|
+
const { loadStore, createInvite, normEmail, operatorUser, sessionUser } = await import("./auth-B5yuwnOq.mjs").then((n) => n.r);
|
|
83
66
|
bearerCheck = (token) => sessionUser(dir, token);
|
|
67
|
+
operatorCheck = (token, req, url) => req.method === "POST" && url.pathname === "/__mv/api/cli-session" && !!operatorUser(dir, token);
|
|
84
68
|
sessionCheck = (req) => {
|
|
85
69
|
const tok = /(?:^|;\s*)mv_s=([\w-]+)/.exec(String(req.headers.cookie ?? ""))?.[1];
|
|
86
70
|
return tok ? sessionUser(dir, tok) : null;
|
|
87
71
|
};
|
|
88
|
-
const owner = process.env.MARVER_OWNER_EMAIL;
|
|
72
|
+
const owner = process.env.MARVER_ID_ISSUER ? "" : process.env.MARVER_OWNER_EMAIL;
|
|
89
73
|
if (owner) {
|
|
90
74
|
const store = loadStore(dir);
|
|
91
75
|
if (!store.users.length && !store.invites.some((i) => i.emailNorm === normEmail(owner))) {
|
|
@@ -96,7 +80,42 @@ async function serve(root, portFlag) {
|
|
|
96
80
|
}
|
|
97
81
|
}
|
|
98
82
|
}
|
|
99
|
-
const
|
|
83
|
+
const cliToken = process.env.MARVER_CLI_TOKEN ?? "";
|
|
84
|
+
if (cliToken) {
|
|
85
|
+
const { cliTokenProblem } = await import("./auth-B5yuwnOq.mjs").then((n) => n.r);
|
|
86
|
+
if (!process.env.MARVER_DATA_DIR) {
|
|
87
|
+
console.error(`[${NAME}] MARVER_CLI_TOKEN needs MARVER_DATA_DIR - there are no accounts for it to act as otherwise.`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const problem = cliTokenProblem(cliToken);
|
|
91
|
+
if (problem) {
|
|
92
|
+
console.error(`[${NAME}] ${problem}.\n Nothing rate-limits this credential and nothing slows a guess down, so its entropy is the\n whole defence. Generate one rather than choosing one, in hex rather than base64:\n MARVER_CLI_TOKEN=$(openssl rand -hex 24)`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const rawIssuer = (process.env.MARVER_ID_ISSUER ?? "").trim();
|
|
97
|
+
const { normalizeIssuer } = await import("./marver-id-B8-3WiHk.mjs");
|
|
98
|
+
const idIssuer = normalizeIssuer(rawIssuer);
|
|
99
|
+
if (rawIssuer && !idIssuer) {
|
|
100
|
+
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.`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
let idHandler = null;
|
|
104
|
+
if (idIssuer) {
|
|
105
|
+
if (!process.env.MARVER_DATA_DIR) {
|
|
106
|
+
console.error(`[${NAME}] MARVER_ID_ISSUER needs MARVER_DATA_DIR - identity accounts need somewhere to live.`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
if (!process.env.MARVER_PUBLIC_ORIGIN) {
|
|
110
|
+
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)`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const { marverIdHandler } = await import("./marver-id-gate-BDzW6ahN.mjs");
|
|
114
|
+
const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
|
|
115
|
+
const canvasName = humanName(meta.name);
|
|
116
|
+
idHandler = marverIdHandler(dataDir(), idIssuer, canvasName, meta.branding);
|
|
117
|
+
}
|
|
118
|
+
const password = idIssuer ? "" : process.env.MARVER_PASSWORD ?? "";
|
|
100
119
|
const verifier = password ? scryptSync(password, "marver-gate", 32) : null;
|
|
101
120
|
const cookieKey = randomBytes(32);
|
|
102
121
|
const sign = (exp) => createHmac("sha256", cookieKey).update(`v1.${exp}`).digest("hex");
|
|
@@ -108,10 +127,11 @@ async function serve(root, portFlag) {
|
|
|
108
127
|
const want = Buffer.from(sign(exp)), got = Buffer.from(m[2]);
|
|
109
128
|
return want.length === got.length && timingSafeEqual(want, got);
|
|
110
129
|
};
|
|
130
|
+
const gated = Boolean(verifier || idIssuer);
|
|
111
131
|
const server = createServer((req, res) => {
|
|
112
132
|
const url = new URL(req.url ?? "/", "http://x");
|
|
113
|
-
if (
|
|
114
|
-
if (req.method === "POST" && url.pathname === "/__mv/auth") {
|
|
133
|
+
if (gated) {
|
|
134
|
+
if (verifier && req.method === "POST" && url.pathname === "/__mv/auth") {
|
|
115
135
|
let body = "";
|
|
116
136
|
req.on("data", (c) => {
|
|
117
137
|
body += c;
|
|
@@ -129,24 +149,32 @@ async function serve(root, portFlag) {
|
|
|
129
149
|
res.setHeader("location", /^#\/[\w\/?&=%.,~-]*$/.test(next) ? `/${next}` : "/");
|
|
130
150
|
return res.end();
|
|
131
151
|
}
|
|
132
|
-
return gate(res, meta, !!collab, "Wrong canvas password - try again");
|
|
152
|
+
return gate(res, meta, !!collab, "Wrong canvas password - try again", !!idIssuer);
|
|
133
153
|
});
|
|
134
154
|
return;
|
|
135
155
|
}
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
decoded = decodeURIComponent(url.pathname);
|
|
139
|
-
} catch {}
|
|
140
|
-
const cosmetic = /^\/__mv\/favicon\/[\w-]+(?:\.[\w-]+)+$/.test(decoded) || /^\/__mv\/logo\.(?:svg|png)$/.test(decoded);
|
|
156
|
+
const cosmetic = isCosmetic(url.pathname);
|
|
141
157
|
if (!authed(req) && !cosmetic && !sessionCheck?.(req)) {
|
|
142
158
|
const bearerOk = collab && url.pathname.startsWith("/__mv/api/") && (() => {
|
|
143
159
|
const tok = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
|
|
144
|
-
|
|
160
|
+
if (!tok) return false;
|
|
161
|
+
return !!bearerCheck?.(tok) || !!operatorCheck?.(tok, req, url);
|
|
145
162
|
})();
|
|
146
|
-
const preGate = collab && (req.method === "POST" && (url.pathname === "/__mv/api/auth/signin" || url.pathname === "/__mv/api/auth/claim") || req.method === "GET" && url.pathname === "/__mv/api/invite-info");
|
|
147
|
-
if (!bearerOk && !preGate) return gate(res, meta, !!collab);
|
|
163
|
+
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");
|
|
164
|
+
if (!bearerOk && !preGate) return gate(res, meta, !!collab, void 0, !!idIssuer);
|
|
148
165
|
}
|
|
149
166
|
}
|
|
167
|
+
if (idHandler && url.pathname.startsWith("/__mv/id/")) {
|
|
168
|
+
idHandler(req, res, url).catch((err) => {
|
|
169
|
+
console.error(`[${NAME}] marver-id handler failed:`, err?.message ?? err);
|
|
170
|
+
if (!res.headersSent) {
|
|
171
|
+
res.statusCode = 500;
|
|
172
|
+
res.setHeader("content-type", "application/json");
|
|
173
|
+
res.end("{\"error\":\"internal\"}");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
150
178
|
if (url.pathname.startsWith("/__mv/api/")) {
|
|
151
179
|
if (!collab) {
|
|
152
180
|
res.statusCode = 404;
|
|
@@ -179,13 +207,21 @@ async function serve(root, portFlag) {
|
|
|
179
207
|
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("outside dist");
|
|
180
208
|
file = real;
|
|
181
209
|
} catch {
|
|
210
|
+
if (gated && isCosmetic(url.pathname)) {
|
|
211
|
+
res.statusCode = 404;
|
|
212
|
+
return res.end("not found");
|
|
213
|
+
}
|
|
182
214
|
file = join(dist, "index.html");
|
|
183
215
|
}
|
|
216
|
+
if (gated && isCosmetic(url.pathname) && !file.startsWith(join(dist, "__mv")) && relative(realDist, file) === "index.html") {
|
|
217
|
+
res.statusCode = 404;
|
|
218
|
+
return res.end("not found");
|
|
219
|
+
}
|
|
184
220
|
if (!extname(file)) file = join(dist, "index.html");
|
|
185
221
|
try {
|
|
186
222
|
const content = readFileSync(file);
|
|
187
223
|
res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
|
|
188
|
-
const cache =
|
|
224
|
+
const cache = gated ? "private, no-store" : file.startsWith(join(dist, "assets")) ? "public, max-age=31536000, immutable" : "no-store";
|
|
189
225
|
res.setHeader("cache-control", cache);
|
|
190
226
|
res.end(content);
|
|
191
227
|
} catch {
|
|
@@ -203,10 +239,30 @@ async function serve(root, portFlag) {
|
|
|
203
239
|
});
|
|
204
240
|
server.listen(port, () => {
|
|
205
241
|
console.log(`\n ${NAME} serving design/.dist → http://localhost:${port}/`);
|
|
206
|
-
console.log(verifier ? " gate: ON (MARVER_PASSWORD set)
|
|
242
|
+
console.log(idIssuer ? ` gate: ON - Marver ID (${idIssuer})` : verifier ? " gate: ON (MARVER_PASSWORD set)" : " gate: off - set MARVER_PASSWORD or MARVER_ID_ISSUER to require sign-in");
|
|
243
|
+
if (cliToken) console.log(` CLI: MARVER_CLI_TOKEN accepted (acts as the canvas owner)\n`);
|
|
244
|
+
else if (idIssuer) console.log(` CLI: set MARVER_CLI_TOKEN to invite or sync from the repo\n`);
|
|
245
|
+
else console.log("");
|
|
207
246
|
});
|
|
208
247
|
return server;
|
|
209
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Paths the gate lets through unauthenticated: the favicons and logo the gate
|
|
251
|
+
* page itself wears. They carry no design data.
|
|
252
|
+
*
|
|
253
|
+
* Matched against the DECODED path, because the static handler decodes too - a
|
|
254
|
+
* raw-encoded `/__mv/favicon/%2f..%2f..%2findex.html` must not slip through as
|
|
255
|
+
* "cosmetic". And because these paths skip the gate, a request for one that does
|
|
256
|
+
* NOT exist must 404 rather than falling back to the shell; that fallback was a
|
|
257
|
+
* complete bundle disclosure on any gated canvas.
|
|
258
|
+
*/
|
|
259
|
+
function isCosmetic(pathname) {
|
|
260
|
+
let decoded = pathname;
|
|
261
|
+
try {
|
|
262
|
+
decoded = decodeURIComponent(pathname);
|
|
263
|
+
} catch {}
|
|
264
|
+
return /^\/__mv\/favicon\/[\w-]+(?:\.[\w-]+)+$/.test(decoded) || /^\/__mv\/logo\.(?:svg|png)$/.test(decoded);
|
|
265
|
+
}
|
|
210
266
|
/** The Marver logo mark (ParallelogramDuo, same as the shell's sidebar). */
|
|
211
267
|
const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${size}" fill="currentColor" aria-hidden><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1"/><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z"/></svg>`;
|
|
212
268
|
const MARK = MARK_AT(17);
|
|
@@ -216,14 +272,81 @@ const MARK_LG = MARK_AT(24);
|
|
|
216
272
|
* guests pay the canvas password, members sign in with their OWN password, and an
|
|
217
273
|
* invite link (#/i/<token>) opens straight into the claim. The member and claim
|
|
218
274
|
* states exist only on collaboration canvases - a static canvas keeps one field. */
|
|
219
|
-
|
|
220
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Marver ID needs no client script at all, and that is the point.
|
|
277
|
+
*
|
|
278
|
+
* Continue is a plain GET form to /__mv/id/start, which redirects the tab to the
|
|
279
|
+
* identity service; the tab returns to /__mv/id/finish, which completes the
|
|
280
|
+
* sign-in. Nothing depends on JavaScript to leave, so no popup blocker,
|
|
281
|
+
* extension or CSP can strand somebody on the gate.
|
|
282
|
+
*
|
|
283
|
+
* What this replaced was a popup, a postMessage listener, and a poll on the
|
|
284
|
+
* popup's closed flag. It could not work for social sign-in: Google serves
|
|
285
|
+
* Cross-Origin-Opener-Policy: same-origin, which permanently severs the opener
|
|
286
|
+
* relationship, so the assertion had nowhere to go and the button sat on
|
|
287
|
+
* "Opening..." for ever while the console filled with COOP warnings. Found by
|
|
288
|
+
* signing in with a real Google account, which no test had done.
|
|
289
|
+
*/
|
|
290
|
+
/**
|
|
291
|
+
* A canvas name a person would recognise.
|
|
292
|
+
*
|
|
293
|
+
* meta.name comes from the host package.json, so it arrives in package shape -
|
|
294
|
+
* "marver-strategy", "acme_q3_review". Capitalising the first letter alone left
|
|
295
|
+
* "Marver-strategy" on the sign-in card, which reads as a slug rather than as
|
|
296
|
+
* the name of the thing somebody is opening.
|
|
297
|
+
*
|
|
298
|
+
* Only separators are touched. Words already carrying their own capitals keep
|
|
299
|
+
* them - "myApp-billing" stays "myApp Billing" rather than being flattened to
|
|
300
|
+
* "Myapp Billing".
|
|
301
|
+
*/
|
|
302
|
+
function humanName(raw) {
|
|
303
|
+
if (!raw) return void 0;
|
|
304
|
+
const words = raw.split(/[-_.\s]+/).filter(Boolean);
|
|
305
|
+
if (!words.length) return void 0;
|
|
306
|
+
return words.map((w) => /[A-Z]/.test(w) ? w : w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* "A terminal is asking to sign in as you."
|
|
310
|
+
*
|
|
311
|
+
* The browser half of the device flow, and the only place authority enters it.
|
|
312
|
+
* Deliberately server-rendered rather than a shell route: it has to work on a
|
|
313
|
+
* canvas whose bundle the visitor may not be allowed to load yet, and it is a
|
|
314
|
+
* security prompt - the fewer moving parts between the code and the person
|
|
315
|
+
* reading it, the better.
|
|
316
|
+
*
|
|
317
|
+
* The code is shown, large, and the copy asks the reader to CHECK it against
|
|
318
|
+
* their terminal. That comparison is the whole defence. Somebody who can be
|
|
319
|
+
* talked into approving a code they did not generate has handed over a session,
|
|
320
|
+
* so the page never approves anything by merely being opened - the click is
|
|
321
|
+
* required, and it is refused outright unless a real session is behind it.
|
|
322
|
+
*/
|
|
323
|
+
/**
|
|
324
|
+
* Refuse to be framed.
|
|
325
|
+
*
|
|
326
|
+
* Every HTML page this server serves is either a credential prompt or the
|
|
327
|
+
* private canvas itself, and neither has any business inside somebody else's
|
|
328
|
+
* frame. The approval page made the cost concrete: an attacker starts a device
|
|
329
|
+
* flow of their own, frames the approval URL under an unrelated button, and
|
|
330
|
+
* polls their device code after the victim clicks - collecting the victim's
|
|
331
|
+
* real session. SameSite=Lax does not help, because a framed page on the same
|
|
332
|
+
* site still carries its cookies.
|
|
333
|
+
*
|
|
334
|
+
* Both headers, because the CSP directive is the modern one and X-Frame-Options
|
|
335
|
+
* is what older clients actually enforce.
|
|
336
|
+
*/
|
|
337
|
+
function denyFraming(res) {
|
|
338
|
+
res.setHeader("content-security-policy", "frame-ancestors 'none'");
|
|
339
|
+
res.setHeader("x-frame-options", "DENY");
|
|
340
|
+
}
|
|
341
|
+
function gate(res, meta, collabOn, error, idOn = false) {
|
|
342
|
+
const name = humanName(meta.name) ?? "Marver";
|
|
221
343
|
const appMark = meta.logo ? `<img src="${esc(meta.logo)}" alt="" width="24" height="24" />` : MARK_LG;
|
|
222
344
|
const title = meta.branding ? `${name} | Marver - Visualize your software` : name;
|
|
223
345
|
const desc = meta.branding ? `${name}, shared as a live Marver canvas - real screens and prototypes, built from the codebase. Marver is the agent-native design canvas.` : `${name} - a private design canvas.`;
|
|
224
346
|
res.statusCode = 200;
|
|
225
347
|
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
226
348
|
res.setHeader("cache-control", "no-store");
|
|
349
|
+
denyFraming(res);
|
|
227
350
|
res.end(`<!doctype html>
|
|
228
351
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
229
352
|
<title>${esc(title)}</title>
|
|
@@ -315,6 +438,18 @@ ${meta.branding ? "<meta property=\"og:site_name\" content=\"Marver\" />" : ""}
|
|
|
315
438
|
<div class="card">
|
|
316
439
|
|
|
317
440
|
<section id="guest" class="on">
|
|
441
|
+
${idOn ? `
|
|
442
|
+
<div style="display:flex;flex-direction:column;gap:14px" id="id-card">
|
|
443
|
+
<header>${appMark}<h1>${esc(name)}</h1></header>
|
|
444
|
+
<p class="lead">This canvas is private. Taking you to Marver to sign in...</p>
|
|
445
|
+
<div class="err" id="id-err">${error ? esc(error) : ""}</div>
|
|
446
|
+
<div class="ctawrap">
|
|
447
|
+
<form method="get" action="/__mv/id/start" style="width:100%">
|
|
448
|
+
<input type="hidden" name="next" id="id-next" />
|
|
449
|
+
<button class="cta" id="id-go" type="submit">Continue</button>
|
|
450
|
+
</form>
|
|
451
|
+
</div>
|
|
452
|
+
</div>` : `
|
|
318
453
|
<form method="post" action="/__mv/auth" style="display:flex;flex-direction:column;gap:14px">
|
|
319
454
|
<header>${appMark}<h1>${esc(name)}</h1></header>
|
|
320
455
|
<p class="lead">You're one step from the canvas. This space is private - enter the canvas password to step inside.</p>
|
|
@@ -326,7 +461,7 @@ ${meta.branding ? "<meta property=\"og:site_name\" content=\"Marver\" />" : ""}
|
|
|
326
461
|
<span class="tip">Enter the canvas password first</span>
|
|
327
462
|
</div>
|
|
328
463
|
${collabOn ? "<div class=\"swap\"><span style=\"font:500 12px -apple-system,system-ui,sans-serif;color:rgba(24,24,27,.45)\">Member? <a data-go=\"member\">Sign in instead</a></span></div>" : ""}
|
|
329
|
-
</form
|
|
464
|
+
</form>`}
|
|
330
465
|
</section>
|
|
331
466
|
${collabOn ? `
|
|
332
467
|
<section id="member">
|
|
@@ -370,10 +505,27 @@ ${collabOn ? `
|
|
|
370
505
|
const $ = (id) => document.getElementById(id)
|
|
371
506
|
const next = document.querySelector('[name=next]')
|
|
372
507
|
if (next) next.value = location.hash
|
|
508
|
+
|
|
509
|
+
// Marver ID: carry the deep link, then go. The card is a fallback for
|
|
510
|
+
// somebody without JavaScript, not a step - it is replaced immediately.
|
|
511
|
+
//
|
|
512
|
+
// The hash is why this page exists at all. A canvas link keeps its board and
|
|
513
|
+
// thread in the fragment, which no server ever receives, so a plain redirect
|
|
514
|
+
// from the server would drop every shared link. Reading it here is the only
|
|
515
|
+
// chance anybody gets.
|
|
516
|
+
const idNext = $('id-next')
|
|
517
|
+
if (idNext) {
|
|
518
|
+
idNext.value = location.hash
|
|
519
|
+
const to = '/__mv/id/start' + (location.hash ? '?next=' + encodeURIComponent(location.hash) : '')
|
|
520
|
+
// replace, not assign: Back should go where they came from, not bounce
|
|
521
|
+
// them forward into the redirect again.
|
|
522
|
+
location.replace(to)
|
|
523
|
+
}
|
|
524
|
+
|
|
373
525
|
// the guest CTA follows its field (server-side form, client-side gating)
|
|
374
526
|
const gpass = document.querySelector('#guest input[type=password]')
|
|
375
527
|
const gbtn = document.querySelector('#guest button.cta')
|
|
376
|
-
gpass.addEventListener('input', () => { gbtn.disabled = !gpass.value })
|
|
528
|
+
if (gpass && gbtn) gpass.addEventListener('input', () => { gbtn.disabled = !gpass.value })
|
|
377
529
|
${collabOn ? `
|
|
378
530
|
const show = (id) => {
|
|
379
531
|
for (const s of document.querySelectorAll('section')) s.classList.toggle('on', s.id === id)
|