@marver-design/marver 0.10.2 → 0.11.1

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 (30) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +16 -3
  3. package/dist/auth-B5yuwnOq.mjs +494 -0
  4. package/dist/{build-zUf_kwKp.mjs → build-DI31MFTB.mjs} +3 -2
  5. package/dist/cli.mjs +6 -6
  6. package/dist/{collab-s3k5byM1.mjs → collab-CWaG3Q4w.mjs} +43 -11
  7. package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
  8. package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
  9. package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
  10. package/dist/{daemon-BpKBJZee.mjs → daemon-DSmaS453.mjs} +23 -5
  11. package/dist/{dev-D2PG6igI.mjs → dev-BWIL2ewj.mjs} +117 -13
  12. package/dist/{init-DwV0_d9G.mjs → init-BSclDg4I.mjs} +2 -1
  13. package/dist/manifest-C2tzkNaC.mjs +233 -0
  14. package/dist/marver-id-B8-3WiHk.mjs +411 -0
  15. package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
  16. package/dist/{plugin-C1sDewZ3.mjs → plugin-DjBFxEQ7.mjs} +12 -84
  17. package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
  18. package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
  19. package/dist/{serve-CZqPnj19.mjs → serve-3APqaNSB.mjs} +196 -43
  20. package/dist/sync-DELGomPk.mjs +397 -0
  21. package/dist/update-DuWDj5nR.mjs +77 -0
  22. package/dist/utm-CxC3QN5X.mjs +20 -0
  23. package/package.json +1 -1
  24. package/src/shared/utm.ts +2 -2
  25. package/templates/instructions/configure.md +4 -0
  26. package/templates/instructions/publish.md +99 -16
  27. package/dist/auth-KQ9Aj-nB.mjs +0 -245
  28. package/dist/events-BMtBvvgU.mjs +0 -101
  29. package/dist/sync-BJKKmy1n.mjs +0 -150
  30. package/dist/{shot-D7I0MCLu.mjs → shot-Cyv3GN79.mjs} +1 -1
@@ -1,10 +1,9 @@
1
- import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
1
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
3
- import { n as localProfile, t as isConnected } from "./profile-BkiWglVE.mjs";
4
- import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DIsp3ldB.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";
5
5
  import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
6
- import { basename, dirname, join, resolve, sep } from "node:path";
7
- import { fileURLToPath } from "node:url";
6
+ import { basename, join, resolve, sep } from "node:path";
8
7
  import { randomBytes } from "node:crypto";
9
8
  //#region src/server/api.ts
10
9
  const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
@@ -266,7 +265,7 @@ function apiMiddleware(root, opts = {}) {
266
265
  const frameId = url.searchParams.get("frame") ?? "";
267
266
  const theme = url.searchParams.get("theme") ?? "light";
268
267
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
269
- const { shootFrame } = await import("./shot-D7I0MCLu.mjs");
268
+ const { shootFrame } = await import("./shot-Cyv3GN79.mjs");
270
269
  const r = await shootFrame({
271
270
  root,
272
271
  viewports: opts.viewports ?? {},
@@ -306,7 +305,7 @@ function apiMiddleware(root, opts = {}) {
306
305
  }
307
306
  const dir = join(root, "design", ".local");
308
307
  mkdirSync(dir, { recursive: true });
309
- const { validAvatar } = await import("./collab-s3k5byM1.mjs");
308
+ const { validAvatar } = await import("./collab-CWaG3Q4w.mjs");
310
309
  let cur = {};
311
310
  try {
312
311
  cur = JSON.parse(readFileSync(join(dir, "profile.json"), "utf8"));
@@ -353,7 +352,7 @@ function apiMiddleware(root, opts = {}) {
353
352
  }
354
353
  }
355
354
  if (path === "boards.rights" && req.method === "GET") {
356
- const { listBoards } = await import("./comments-BZBKhKRO.mjs");
355
+ const { listBoards } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
357
356
  return json(res, 200, {
358
357
  local: true,
359
358
  stored: listBoards(join(root, "design", "comments"))
@@ -361,7 +360,7 @@ function apiMiddleware(root, opts = {}) {
361
360
  }
362
361
  const cm = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
363
362
  if (cm) {
364
- const { appendEvents, readLog } = await import("./comments-BZBKhKRO.mjs");
363
+ const { appendEvents, readLog } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
365
364
  const dir = join(root, "design", "comments");
366
365
  if (req.method === "GET") return json(res, 200, { events: readLog(dir, cm[1]) });
367
366
  if (req.method === "POST") {
@@ -397,7 +396,7 @@ function apiMiddleware(root, opts = {}) {
397
396
  }
398
397
  }
399
398
  if (path === "sync" && req.method === "POST") {
400
- const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
399
+ const { loadCollab, syncOnce } = await import("./sync-DELGomPk.mjs").then((n) => n.o);
401
400
  const collab = loadCollab(root);
402
401
  if (!collab) return json(res, 200, { connected: false });
403
402
  try {
@@ -426,7 +425,7 @@ function backgroundPush(root) {
426
425
  pushTimer = setTimeout(async () => {
427
426
  pushTimer = null;
428
427
  try {
429
- const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
428
+ const { loadCollab, syncOnce } = await import("./sync-DELGomPk.mjs").then((n) => n.o);
430
429
  const collab = loadCollab(root);
431
430
  if (collab) await syncOnce(root, collab);
432
431
  } catch {}
@@ -484,77 +483,6 @@ function routesMiddleware(server, clientDir) {
484
483
  };
485
484
  }
486
485
  //#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
558
486
  //#region src/server/plugin.ts
559
487
  const VIRTUAL_THEME = "virtual:sh-theme";
560
488
  const VIRTUAL_CONFIG = "virtual:sh-config";
@@ -798,7 +726,7 @@ function marverPlugin(ctx) {
798
726
  });
799
727
  return;
800
728
  }
801
- const { shootFrame } = await import("./shot-D7I0MCLu.mjs");
729
+ const { shootFrame } = await import("./shot-Cyv3GN79.mjs");
802
730
  write(await shootFrame({
803
731
  root,
804
732
  viewports: config.viewports,
@@ -878,4 +806,4 @@ async function tailwind3Css(root) {
878
806
  return null;
879
807
  }
880
808
  //#endregion
881
- export { update_exports as i, tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
809
+ export { tailwind3Css as n, tailwind4Plugin as r, marverPlugin as t };
@@ -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 in design/.local/:
8
- * - profile.json {name, email?, avatar?} - set from the composer's avatar popover
9
- * - collab.json {email, name} - the connect account, written by `comments connect`
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(readJson(join(root, "design", ".local", "collab.json")).email);
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 = readJson(join(dir, "collab.json"));
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,26 @@
1
+ //#region src/server/secure-cookie.ts
2
+ /**
3
+ * Is this deployment https, for the purpose of setting `Secure` on a cookie?
4
+ *
5
+ * The obvious answer - read `X-Forwarded-Proto` - is wrong in exactly the case
6
+ * that matters, and it is the same trap that made `MARVER_PUBLIC_ORIGIN`
7
+ * mandatory for identity mode: nginx's own documented `proxy_pass
8
+ * http://localhost:PORT` sets no `X-Forwarded-*` at all. A canvas served over
9
+ * https behind that config sees no header, decides "not secure", and issues a
10
+ * thirty-day session cookie the browser will happily send over plain http.
11
+ *
12
+ * So the pinned origin wins whenever it is set. It is a deliberate statement by
13
+ * whoever deployed the canvas, rather than a guess about a proxy that may not be
14
+ * speaking. The header remains the fallback for a canvas with no pinned origin,
15
+ * where a guess is all there is - and http on loopback is the ordinary
16
+ * development case, where `Secure` would break the cookie entirely.
17
+ */
18
+ function isSecureDeployment(req) {
19
+ const pinned = (process.env.MARVER_PUBLIC_ORIGIN ?? "").trim();
20
+ if (pinned) return pinned.toLowerCase().startsWith("https://");
21
+ return req.headers["x-forwarded-proto"] === "https";
22
+ }
23
+ /** `; Secure`, or nothing - the suffix a Set-Cookie line wants. */
24
+ const secureSuffix = (req) => isSecureDeployment(req) ? "; Secure" : "";
25
+ //#endregion
26
+ export { secureSuffix as t };
@@ -1,27 +1,10 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
+ import { t as secureSuffix } from "./secure-cookie-_K1Hsx8H.mjs";
3
+ import { t as poweredByUrl } from "./utm-CxC3QN5X.mjs";
2
4
  import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
5
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
6
  import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
7
  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
8
  //#region src/server/serve.ts
26
9
  /**
27
10
  * `marver serve` - a zero-dependency static server for design/.dist with an optional
@@ -66,11 +49,12 @@ async function serve(root, portFlag) {
66
49
  } catch {}
67
50
  let collab = null;
68
51
  let bearerCheck = null;
52
+ let operatorCheck = null;
69
53
  let sessionCheck = null;
70
54
  if (process.env.MARVER_DATA_DIR) {
71
- const { collabHandler } = await import("./collab-s3k5byM1.mjs");
72
- const { appendEvents, readLog } = await import("./comments-BZBKhKRO.mjs");
73
- const { dataDir } = await import("./comments-BZBKhKRO.mjs");
55
+ const { collabHandler } = await import("./collab-CWaG3Q4w.mjs");
56
+ const { appendEvents, readLog } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
57
+ const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
74
58
  const dir = dataDir();
75
59
  const seedDir = join(dist, "design", "comments");
76
60
  if (existsSync(seedDir)) for (const f of readdirSync(seedDir)) {
@@ -79,13 +63,14 @@ async function serve(root, portFlag) {
79
63
  appendEvents(join(dir, "comments"), board, readLog(seedDir, board));
80
64
  }
81
65
  collab = collabHandler(dir, dist);
82
- const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-KQ9Aj-nB.mjs");
66
+ const { loadStore, createInvite, normEmail, operatorUser, sessionUser } = await import("./auth-B5yuwnOq.mjs").then((n) => n.r);
83
67
  bearerCheck = (token) => sessionUser(dir, token);
68
+ operatorCheck = (token, req, url) => req.method === "POST" && url.pathname === "/__mv/api/cli-session" && !!operatorUser(dir, token);
84
69
  sessionCheck = (req) => {
85
70
  const tok = /(?:^|;\s*)mv_s=([\w-]+)/.exec(String(req.headers.cookie ?? ""))?.[1];
86
71
  return tok ? sessionUser(dir, tok) : null;
87
72
  };
88
- const owner = process.env.MARVER_OWNER_EMAIL;
73
+ const owner = process.env.MARVER_ID_ISSUER ? "" : process.env.MARVER_OWNER_EMAIL;
89
74
  if (owner) {
90
75
  const store = loadStore(dir);
91
76
  if (!store.users.length && !store.invites.some((i) => i.emailNorm === normEmail(owner))) {
@@ -96,7 +81,42 @@ async function serve(root, portFlag) {
96
81
  }
97
82
  }
98
83
  }
99
- const password = process.env.MARVER_PASSWORD ?? "";
84
+ const cliToken = process.env.MARVER_CLI_TOKEN ?? "";
85
+ if (cliToken) {
86
+ const { cliTokenProblem } = await import("./auth-B5yuwnOq.mjs").then((n) => n.r);
87
+ if (!process.env.MARVER_DATA_DIR) {
88
+ console.error(`[${NAME}] MARVER_CLI_TOKEN needs MARVER_DATA_DIR - there are no accounts for it to act as otherwise.`);
89
+ process.exit(1);
90
+ }
91
+ const problem = cliTokenProblem(cliToken);
92
+ if (problem) {
93
+ 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)`);
94
+ process.exit(1);
95
+ }
96
+ }
97
+ const rawIssuer = (process.env.MARVER_ID_ISSUER ?? "").trim();
98
+ const { normalizeIssuer } = await import("./marver-id-B8-3WiHk.mjs");
99
+ const idIssuer = normalizeIssuer(rawIssuer);
100
+ if (rawIssuer && !idIssuer) {
101
+ 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.`);
102
+ process.exit(1);
103
+ }
104
+ let idHandler = null;
105
+ if (idIssuer) {
106
+ if (!process.env.MARVER_DATA_DIR) {
107
+ console.error(`[${NAME}] MARVER_ID_ISSUER needs MARVER_DATA_DIR - identity accounts need somewhere to live.`);
108
+ process.exit(1);
109
+ }
110
+ if (!process.env.MARVER_PUBLIC_ORIGIN) {
111
+ 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
+ process.exit(1);
113
+ }
114
+ const { marverIdHandler } = await import("./marver-id-gate-BDzW6ahN.mjs");
115
+ const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
116
+ const canvasName = humanName(meta.name);
117
+ idHandler = marverIdHandler(dataDir(), idIssuer, canvasName, meta.branding);
118
+ }
119
+ const password = idIssuer ? "" : process.env.MARVER_PASSWORD ?? "";
100
120
  const verifier = password ? scryptSync(password, "marver-gate", 32) : null;
101
121
  const cookieKey = randomBytes(32);
102
122
  const sign = (exp) => createHmac("sha256", cookieKey).update(`v1.${exp}`).digest("hex");
@@ -108,10 +128,11 @@ async function serve(root, portFlag) {
108
128
  const want = Buffer.from(sign(exp)), got = Buffer.from(m[2]);
109
129
  return want.length === got.length && timingSafeEqual(want, got);
110
130
  };
131
+ const gated = Boolean(verifier || idIssuer);
111
132
  const server = createServer((req, res) => {
112
133
  const url = new URL(req.url ?? "/", "http://x");
113
- if (verifier) {
114
- if (req.method === "POST" && url.pathname === "/__mv/auth") {
134
+ if (gated) {
135
+ if (verifier && req.method === "POST" && url.pathname === "/__mv/auth") {
115
136
  let body = "";
116
137
  req.on("data", (c) => {
117
138
  body += c;
@@ -122,31 +143,39 @@ async function serve(root, portFlag) {
122
143
  const given = form.get("password") ?? "";
123
144
  if (timingSafeEqual(scryptSync(given, "marver-gate", 32), verifier)) {
124
145
  const exp = Math.floor(Date.now() / 1e3) + MONTH;
125
- const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
146
+ const secure = secureSuffix(req);
126
147
  res.setHeader("set-cookie", `${COOKIE}=${exp}.${sign(exp)}; Path=/; Max-Age=${MONTH}; HttpOnly; SameSite=Lax${secure}`);
127
148
  const next = form.get("next") ?? "";
128
149
  res.statusCode = 303;
129
150
  res.setHeader("location", /^#\/[\w\/?&=%.,~-]*$/.test(next) ? `/${next}` : "/");
130
151
  return res.end();
131
152
  }
132
- return gate(res, meta, !!collab, "Wrong canvas password - try again");
153
+ return gate(res, meta, !!collab, "Wrong canvas password - try again", !!idIssuer);
133
154
  });
134
155
  return;
135
156
  }
136
- let decoded = url.pathname;
137
- try {
138
- decoded = decodeURIComponent(url.pathname);
139
- } catch {}
140
- const cosmetic = /^\/__mv\/favicon\/[\w-]+(?:\.[\w-]+)+$/.test(decoded) || /^\/__mv\/logo\.(?:svg|png)$/.test(decoded);
157
+ const cosmetic = isCosmetic(url.pathname);
141
158
  if (!authed(req) && !cosmetic && !sessionCheck?.(req)) {
142
159
  const bearerOk = collab && url.pathname.startsWith("/__mv/api/") && (() => {
143
160
  const tok = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
144
- return !!tok && !!bearerCheck?.(tok);
161
+ if (!tok) return false;
162
+ return !!bearerCheck?.(tok) || !!operatorCheck?.(tok, req, url);
145
163
  })();
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);
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");
165
+ if (!bearerOk && !preGate) return gate(res, meta, !!collab, void 0, !!idIssuer);
148
166
  }
149
167
  }
168
+ if (idHandler && url.pathname.startsWith("/__mv/id/")) {
169
+ idHandler(req, res, url).catch((err) => {
170
+ console.error(`[${NAME}] marver-id handler failed:`, err?.message ?? err);
171
+ if (!res.headersSent) {
172
+ res.statusCode = 500;
173
+ res.setHeader("content-type", "application/json");
174
+ res.end("{\"error\":\"internal\"}");
175
+ }
176
+ });
177
+ return;
178
+ }
150
179
  if (url.pathname.startsWith("/__mv/api/")) {
151
180
  if (!collab) {
152
181
  res.statusCode = 404;
@@ -179,13 +208,21 @@ async function serve(root, portFlag) {
179
208
  if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("outside dist");
180
209
  file = real;
181
210
  } catch {
211
+ if (gated && isCosmetic(url.pathname)) {
212
+ res.statusCode = 404;
213
+ return res.end("not found");
214
+ }
182
215
  file = join(dist, "index.html");
183
216
  }
217
+ if (gated && isCosmetic(url.pathname) && !file.startsWith(join(dist, "__mv")) && relative(realDist, file) === "index.html") {
218
+ res.statusCode = 404;
219
+ return res.end("not found");
220
+ }
184
221
  if (!extname(file)) file = join(dist, "index.html");
185
222
  try {
186
223
  const content = readFileSync(file);
187
224
  res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
188
- const cache = verifier ? "private, no-store" : file.startsWith(join(dist, "assets")) ? "public, max-age=31536000, immutable" : "no-store";
225
+ const cache = gated ? "private, no-store" : file.startsWith(join(dist, "assets")) ? "public, max-age=31536000, immutable" : "no-store";
189
226
  res.setHeader("cache-control", cache);
190
227
  res.end(content);
191
228
  } catch {
@@ -203,10 +240,30 @@ async function serve(root, portFlag) {
203
240
  });
204
241
  server.listen(port, () => {
205
242
  console.log(`\n ${NAME} serving design/.dist → http://localhost:${port}/`);
206
- console.log(verifier ? " gate: ON (MARVER_PASSWORD set)\n" : " gate: off - set MARVER_PASSWORD to require a password\n");
243
+ 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");
244
+ if (cliToken) console.log(` CLI: MARVER_CLI_TOKEN accepted (acts as the canvas owner)\n`);
245
+ else if (idIssuer) console.log(` CLI: set MARVER_CLI_TOKEN to invite or sync from the repo\n`);
246
+ else console.log("");
207
247
  });
208
248
  return server;
209
249
  }
250
+ /**
251
+ * Paths the gate lets through unauthenticated: the favicons and logo the gate
252
+ * page itself wears. They carry no design data.
253
+ *
254
+ * Matched against the DECODED path, because the static handler decodes too - a
255
+ * raw-encoded `/__mv/favicon/%2f..%2f..%2findex.html` must not slip through as
256
+ * "cosmetic". And because these paths skip the gate, a request for one that does
257
+ * NOT exist must 404 rather than falling back to the shell; that fallback was a
258
+ * complete bundle disclosure on any gated canvas.
259
+ */
260
+ function isCosmetic(pathname) {
261
+ let decoded = pathname;
262
+ try {
263
+ decoded = decodeURIComponent(pathname);
264
+ } catch {}
265
+ return /^\/__mv\/favicon\/[\w-]+(?:\.[\w-]+)+$/.test(decoded) || /^\/__mv\/logo\.(?:svg|png)$/.test(decoded);
266
+ }
210
267
  /** The Marver logo mark (ParallelogramDuo, same as the shell's sidebar). */
211
268
  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
269
  const MARK = MARK_AT(17);
@@ -216,14 +273,81 @@ const MARK_LG = MARK_AT(24);
216
273
  * guests pay the canvas password, members sign in with their OWN password, and an
217
274
  * invite link (#/i/<token>) opens straight into the claim. The member and claim
218
275
  * states exist only on collaboration canvases - a static canvas keeps one field. */
219
- function gate(res, meta, collabOn, error) {
220
- const name = meta.name ? meta.name[0].toUpperCase() + meta.name.slice(1) : "Marver";
276
+ /**
277
+ * Marver ID needs no client script at all, and that is the point.
278
+ *
279
+ * Continue is a plain GET form to /__mv/id/start, which redirects the tab to the
280
+ * identity service; the tab returns to /__mv/id/finish, which completes the
281
+ * sign-in. Nothing depends on JavaScript to leave, so no popup blocker,
282
+ * extension or CSP can strand somebody on the gate.
283
+ *
284
+ * What this replaced was a popup, a postMessage listener, and a poll on the
285
+ * popup's closed flag. It could not work for social sign-in: Google serves
286
+ * Cross-Origin-Opener-Policy: same-origin, which permanently severs the opener
287
+ * relationship, so the assertion had nowhere to go and the button sat on
288
+ * "Opening..." for ever while the console filled with COOP warnings. Found by
289
+ * signing in with a real Google account, which no test had done.
290
+ */
291
+ /**
292
+ * A canvas name a person would recognise.
293
+ *
294
+ * meta.name comes from the host package.json, so it arrives in package shape -
295
+ * "marver-strategy", "acme_q3_review". Capitalising the first letter alone left
296
+ * "Marver-strategy" on the sign-in card, which reads as a slug rather than as
297
+ * the name of the thing somebody is opening.
298
+ *
299
+ * Only separators are touched. Words already carrying their own capitals keep
300
+ * them - "myApp-billing" stays "myApp Billing" rather than being flattened to
301
+ * "Myapp Billing".
302
+ */
303
+ function humanName(raw) {
304
+ if (!raw) return void 0;
305
+ const words = raw.split(/[-_.\s]+/).filter(Boolean);
306
+ if (!words.length) return void 0;
307
+ return words.map((w) => /[A-Z]/.test(w) ? w : w[0].toUpperCase() + w.slice(1)).join(" ");
308
+ }
309
+ /**
310
+ * "A terminal is asking to sign in as you."
311
+ *
312
+ * The browser half of the device flow, and the only place authority enters it.
313
+ * Deliberately server-rendered rather than a shell route: it has to work on a
314
+ * canvas whose bundle the visitor may not be allowed to load yet, and it is a
315
+ * security prompt - the fewer moving parts between the code and the person
316
+ * reading it, the better.
317
+ *
318
+ * The code is shown, large, and the copy asks the reader to CHECK it against
319
+ * their terminal. That comparison is the whole defence. Somebody who can be
320
+ * talked into approving a code they did not generate has handed over a session,
321
+ * so the page never approves anything by merely being opened - the click is
322
+ * required, and it is refused outright unless a real session is behind it.
323
+ */
324
+ /**
325
+ * Refuse to be framed.
326
+ *
327
+ * Every HTML page this server serves is either a credential prompt or the
328
+ * private canvas itself, and neither has any business inside somebody else's
329
+ * frame. The approval page made the cost concrete: an attacker starts a device
330
+ * flow of their own, frames the approval URL under an unrelated button, and
331
+ * polls their device code after the victim clicks - collecting the victim's
332
+ * real session. SameSite=Lax does not help, because a framed page on the same
333
+ * site still carries its cookies.
334
+ *
335
+ * Both headers, because the CSP directive is the modern one and X-Frame-Options
336
+ * is what older clients actually enforce.
337
+ */
338
+ function denyFraming(res) {
339
+ res.setHeader("content-security-policy", "frame-ancestors 'none'");
340
+ res.setHeader("x-frame-options", "DENY");
341
+ }
342
+ function gate(res, meta, collabOn, error, idOn = false) {
343
+ const name = humanName(meta.name) ?? "Marver";
221
344
  const appMark = meta.logo ? `<img src="${esc(meta.logo)}" alt="" width="24" height="24" />` : MARK_LG;
222
345
  const title = meta.branding ? `${name} | Marver - Visualize your software` : name;
223
346
  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
347
  res.statusCode = 200;
225
348
  res.setHeader("content-type", "text/html; charset=utf-8");
226
349
  res.setHeader("cache-control", "no-store");
350
+ denyFraming(res);
227
351
  res.end(`<!doctype html>
228
352
  <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
229
353
  <title>${esc(title)}</title>
@@ -315,6 +439,18 @@ ${meta.branding ? "<meta property=\"og:site_name\" content=\"Marver\" />" : ""}
315
439
  <div class="card">
316
440
 
317
441
  <section id="guest" class="on">
442
+ ${idOn ? `
443
+ <div style="display:flex;flex-direction:column;gap:14px" id="id-card">
444
+ <header>${appMark}<h1>${esc(name)}</h1></header>
445
+ <p class="lead">This canvas is private. Taking you to Marver to sign in...</p>
446
+ <div class="err" id="id-err">${error ? esc(error) : ""}</div>
447
+ <div class="ctawrap">
448
+ <form method="get" action="/__mv/id/start" style="width:100%">
449
+ <input type="hidden" name="next" id="id-next" />
450
+ <button class="cta" id="id-go" type="submit">Continue</button>
451
+ </form>
452
+ </div>
453
+ </div>` : `
318
454
  <form method="post" action="/__mv/auth" style="display:flex;flex-direction:column;gap:14px">
319
455
  <header>${appMark}<h1>${esc(name)}</h1></header>
320
456
  <p class="lead">You're one step from the canvas. This space is private - enter the canvas password to step inside.</p>
@@ -326,7 +462,7 @@ ${meta.branding ? "<meta property=\"og:site_name\" content=\"Marver\" />" : ""}
326
462
  <span class="tip">Enter the canvas password first</span>
327
463
  </div>
328
464
  ${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>
465
+ </form>`}
330
466
  </section>
331
467
  ${collabOn ? `
332
468
  <section id="member">
@@ -370,10 +506,27 @@ ${collabOn ? `
370
506
  const $ = (id) => document.getElementById(id)
371
507
  const next = document.querySelector('[name=next]')
372
508
  if (next) next.value = location.hash
509
+
510
+ // Marver ID: carry the deep link, then go. The card is a fallback for
511
+ // somebody without JavaScript, not a step - it is replaced immediately.
512
+ //
513
+ // The hash is why this page exists at all. A canvas link keeps its board and
514
+ // thread in the fragment, which no server ever receives, so a plain redirect
515
+ // from the server would drop every shared link. Reading it here is the only
516
+ // chance anybody gets.
517
+ const idNext = $('id-next')
518
+ if (idNext) {
519
+ idNext.value = location.hash
520
+ const to = '/__mv/id/start' + (location.hash ? '?next=' + encodeURIComponent(location.hash) : '')
521
+ // replace, not assign: Back should go where they came from, not bounce
522
+ // them forward into the redirect again.
523
+ location.replace(to)
524
+ }
525
+
373
526
  // the guest CTA follows its field (server-side form, client-side gating)
374
527
  const gpass = document.querySelector('#guest input[type=password]')
375
528
  const gbtn = document.querySelector('#guest button.cta')
376
- gpass.addEventListener('input', () => { gbtn.disabled = !gpass.value })
529
+ if (gpass && gbtn) gpass.addEventListener('input', () => { gbtn.disabled = !gpass.value })
377
530
  ${collabOn ? `
378
531
  const show = (id) => {
379
532
  for (const s of document.querySelectorAll('section')) s.classList.toggle('on', s.id === id)