@marver-design/marver 0.10.2 → 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.
@@ -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-C-n-uQdm.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
@@ -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-s3k5byM1.mjs");
72
- const { appendEvents, readLog } = await import("./comments-BZBKhKRO.mjs");
73
- const { dataDir } = await import("./comments-BZBKhKRO.mjs");
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-KQ9Aj-nB.mjs");
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 password = process.env.MARVER_PASSWORD ?? "";
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 (verifier) {
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
- 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);
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
- return !!tok && !!bearerCheck?.(tok);
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 = verifier ? "private, no-store" : file.startsWith(join(dist, "assets")) ? "public, max-age=31536000, immutable" : "no-store";
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)\n" : " gate: off - set MARVER_PASSWORD to require a password\n");
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
- function gate(res, meta, collabOn, error) {
220
- const name = meta.name ? meta.name[0].toUpperCase() + meta.name.slice(1) : "Marver";
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)