@marver-design/marver 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1 -1
  3. package/dist/auth-DdUKeVKt.mjs +1150 -0
  4. package/dist/{build-DI31MFTB.mjs → build-DmfxJc6L.mjs} +188 -33
  5. package/dist/cli.mjs +16 -7
  6. package/dist/collab-BmaOjwP1.mjs +781 -0
  7. package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
  8. package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
  9. package/dist/{dev-BWIL2ewj.mjs → dev-BXq7yE1p.mjs} +5 -5
  10. package/dist/events-C9k-ceOj.mjs +101 -0
  11. package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
  12. package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
  13. package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
  14. package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-DOjnZakC.mjs} +94 -6
  15. package/dist/{plugin-DjBFxEQ7.mjs → plugin-Boy7I_JH.mjs} +82 -9
  16. package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
  17. package/dist/{serve-3APqaNSB.mjs → serve-BeLLizNg.mjs} +70 -17
  18. package/dist/share-C64b1tdN.mjs +159 -0
  19. package/dist/summary-Bf9tiGrE.mjs +211 -0
  20. package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
  21. package/package.json +1 -1
  22. package/src/client/shell/App.tsx +43 -8
  23. package/src/client/shell/Comments.tsx +9 -5
  24. package/src/client/shell/LockedApp.tsx +113 -0
  25. package/src/client/shell/Play.tsx +97 -18
  26. package/src/client/shell/Toolbar.tsx +26 -2
  27. package/src/client/shell/canvas/Canvas.tsx +5 -15
  28. package/src/client/shell/canvas/ctl.ts +26 -0
  29. package/src/client/shell/comments-store.ts +39 -9
  30. package/src/client/shell/hash.ts +19 -0
  31. package/src/client/shell/icons.tsx +1 -0
  32. package/src/client/shell/store.ts +54 -3
  33. package/src/client/shell/styles.css +27 -0
  34. package/src/shared/events.ts +8 -3
  35. package/dist/auth-B5yuwnOq.mjs +0 -494
  36. package/dist/collab-CWaG3Q4w.mjs +0 -340
  37. package/dist/manifest-C2tzkNaC.mjs +0 -233
  38. package/dist/update-DuWDj5nR.mjs +0 -77
  39. package/dist/utm-CxC3QN5X.mjs +0 -20
@@ -1,7 +1,8 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { a as syncOnce, i as loadCollab, n as connectClaim, r as connectToken, s as replay, t as connect } from "./sync-DELGomPk.mjs";
2
+ import { n as replay } from "./events-C9k-ceOj.mjs";
3
3
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
4
- import { n as localProfile } from "./profile-DcsJyppw.mjs";
4
+ import { a as syncOnce, i as loadCollab, n as connectClaim, r as connectToken, t as connect } from "./sync-WYxDN9IT.mjs";
5
+ import { n as localProfile } from "./profile-ChpTPd-X.mjs";
5
6
  import { join } from "node:path";
6
7
  import { createInterface } from "node:readline";
7
8
  import { randomUUID } from "node:crypto";
@@ -1,7 +1,7 @@
1
- import { s as replay } from "./sync-DELGomPk.mjs";
1
+ import { n as replay } from "./events-C9k-ceOj.mjs";
2
2
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
3
- import { n as localProfile } from "./profile-DcsJyppw.mjs";
4
- import { i as toFrameId } from "./manifest-C2tzkNaC.mjs";
3
+ import { n as localProfile } from "./profile-ChpTPd-X.mjs";
4
+ import { i as toFrameId } from "./manifest-CS6krOTe.mjs";
5
5
  import { workActivity } from "./work-CLrmY-vQ.mjs";
6
6
  import { r as deviceId, t as has } from "./ledger-Bu0BjqIe.mjs";
7
7
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
@@ -1,6 +1,6 @@
1
1
  import { n as NAME, r as PKG } from "./cli.mjs";
2
- import { i as detectHost, n as loadConfig } from "./config-t9coJ-Pq.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DjBFxEQ7.mjs";
2
+ import { l as detectHost, s as loadConfig } from "./manifest-CS6krOTe.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-Boy7I_JH.mjs";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -247,7 +247,7 @@ async function dev(root, portFlag) {
247
247
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
248
248
  try {
249
249
  const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
250
- const { installedVersion } = await import("./update-DuWDj5nR.mjs").then((n) => n.r);
250
+ const { installedVersion } = await import("./plugin-Boy7I_JH.mjs").then((n) => n.i);
251
251
  const stale = staleManagedInstructions(root);
252
252
  if (stale.length) {
253
253
  const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
@@ -255,7 +255,7 @@ async function dev(root, portFlag) {
255
255
  console.log(` instructions predate ${v ? NAME + " " + v : "the installed version"} (${shown}) - run: npx ${NAME} init\n`);
256
256
  }
257
257
  } catch {}
258
- const { loadCollab, syncOnce } = await import("./sync-DELGomPk.mjs").then((n) => n.o);
258
+ const { loadCollab, syncOnce } = await import("./sync-WYxDN9IT.mjs").then((n) => n.o);
259
259
  if (loadCollab(root)) console.log(` comments: syncing with the published canvas\n`);
260
260
  let syncing = false;
261
261
  const tick = async () => {
@@ -285,7 +285,7 @@ async function dev(root, portFlag) {
285
285
  });
286
286
  }
287
287
  if (config.jam) {
288
- const { startJam } = await import("./daemon-DSmaS453.mjs");
288
+ const { startJam } = await import("./daemon-WjonKlPz.mjs");
289
289
  const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
290
290
  if (jam) {
291
291
  const close = server.close.bind(server);
@@ -0,0 +1,101 @@
1
+ //#region src/shared/events.ts
2
+ /** Events the other side lacks, by id - the sync payload in either direction. */
3
+ function diffEvents(mine, theirIds) {
4
+ const have = new Set(theirIds);
5
+ return mine.filter((e) => !have.has(e.id));
6
+ }
7
+ /** Replay a board's events into current thread state. Deterministic: replay order is
8
+ * (ts, id) so two stores holding the same event SET always derive the same state.
9
+ * TWO passes - creates first, then everything else - so a reply whose author's clock
10
+ * ran ahead of the creator's still lands instead of being dropped forever. */
11
+ function replay(events) {
12
+ const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
13
+ const threads = /* @__PURE__ */ new Map();
14
+ for (const ev of ordered) {
15
+ if (ev.type !== "create") continue;
16
+ if (!ev.commentId || threads.has(ev.commentId)) continue;
17
+ threads.set(ev.commentId, {
18
+ id: ev.commentId,
19
+ board: ev.board,
20
+ nodeKey: ev.nodeKey,
21
+ frame: ev.frame,
22
+ anchor: ev.anchor,
23
+ author: ev.author,
24
+ body: ev.body,
25
+ ts: ev.ts,
26
+ resolved: false,
27
+ agent: ev.agent,
28
+ agentMeta: ev.agentMeta,
29
+ replies: [],
30
+ reactions: {}
31
+ });
32
+ }
33
+ for (const ev of ordered) switch (ev.type) {
34
+ case "reply": {
35
+ const t = ev.parentId ? threads.get(ev.parentId) : void 0;
36
+ if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
37
+ t.replies.push({
38
+ id: ev.commentId,
39
+ author: ev.author,
40
+ body: ev.body,
41
+ ts: ev.ts,
42
+ agent: ev.agent,
43
+ agentMeta: ev.agentMeta
44
+ });
45
+ break;
46
+ }
47
+ case "edit": {
48
+ if (!ev.commentId || ev.body === void 0) break;
49
+ const t = threads.get(ev.commentId);
50
+ if (t) {
51
+ t.body = ev.body;
52
+ break;
53
+ }
54
+ for (const th of threads.values()) {
55
+ const r = th.replies.find((r) => r.id === ev.commentId);
56
+ if (r) {
57
+ r.body = ev.body;
58
+ break;
59
+ }
60
+ }
61
+ break;
62
+ }
63
+ case "resolve": {
64
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
65
+ if (t) {
66
+ t.resolved = true;
67
+ if (ev.addressedIn) t.addressedIn = ev.addressedIn;
68
+ }
69
+ break;
70
+ }
71
+ case "reopen": {
72
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
73
+ if (t) {
74
+ t.resolved = false;
75
+ t.addressedIn = void 0;
76
+ }
77
+ break;
78
+ }
79
+ case "reanchor": {
80
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
81
+ if (t && ev.anchor != null) t.anchor = ev.anchor;
82
+ break;
83
+ }
84
+ case "react": {
85
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
86
+ const who = ev.author?.id ?? ev.author?.email;
87
+ if (!t || !who || !ev.emoji) break;
88
+ const users = t.reactions[ev.emoji] ??= [];
89
+ const at = users.indexOf(who);
90
+ if (at === -1) users.push(who);
91
+ else {
92
+ users.splice(at, 1);
93
+ if (!users.length) delete t.reactions[ev.emoji];
94
+ }
95
+ break;
96
+ }
97
+ }
98
+ return [...threads.values()];
99
+ }
100
+ //#endregion
101
+ export { replay as n, diffEvents as t };
@@ -1,6 +1,5 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { a as readJson, i as detectHost, r as detectAgent, t as DEFAULTS } from "./config-t9coJ-Pq.mjs";
3
- import { a as writeManifest, r as scanFrames } from "./manifest-C2tzkNaC.mjs";
2
+ import { a as writeManifest, c as detectAgent, l as detectHost, o as DEFAULTS, r as scanFrames, u as readJson } from "./manifest-CS6krOTe.mjs";
4
3
  import { MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir } from "./managed-HwHVNI3h.mjs";
5
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
5
  import { dirname, join, relative } from "node:path";
@@ -144,7 +143,7 @@ function init(root, opts) {
144
143
  refresh("providers.tsx", [providersTemplate(null, null), providersTemplate(null, host.toaster, host.routerPkg)], providersTemplate(host.router, host.toaster, host.routerPkg));
145
144
  refresh("tsconfig.json", [STANDALONE_TSCONFIG], tsconfigNow());
146
145
  }
147
- write(".gitignore", ".local/\n.dist/\n");
146
+ write(".gitignore", ".local/\n.dist/\n.dist-seeds/\n");
148
147
  write(".gitattributes", "comments/*.jsonl merge=union\n");
149
148
  write("scenes/_layout.tsx", readFileSync(join(templates, "root-layout.tsx"), "utf8"));
150
149
  if (!existsSync(join(design, "boards"))) {
@@ -1,6 +1,8 @@
1
- import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
2
- import { delimiter, isAbsolute, join } from "node:path";
1
+ import { r as PKG, t as CONTENT_WIDTH } from "./cli.mjs";
2
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { delimiter, isAbsolute, join, relative, sep } from "node:path";
3
4
  import { pathToFileURL } from "node:url";
5
+ import { createHash } from "node:crypto";
4
6
  //#region src/server/detect.ts
5
7
  function readJson(file) {
6
8
  try {
@@ -262,7 +264,9 @@ async function loadConfig(root) {
262
264
  share: {
263
265
  branding: user.share?.branding !== false,
264
266
  name: typeof user.share?.name === "string" ? user.share.name : void 0,
265
- logo: typeof user.share?.logo === "string" ? user.share.logo : void 0
267
+ logo: typeof user.share?.logo === "string" ? user.share.logo : void 0,
268
+ frontDoor: user.share?.frontDoor !== false && user.share?.front_door !== false,
269
+ notify: user.share?.notify !== false
266
270
  },
267
271
  ...jamFields(user.jam)
268
272
  };
@@ -366,4 +370,232 @@ function validViewports(v) {
366
370
  return Object.keys(out).length ? out : null;
367
371
  }
368
372
  //#endregion
369
- export { readJson as a, detectHost as i, loadConfig as n, detectAgent as r, DEFAULTS as t };
373
+ //#region src/server/manifest.ts
374
+ const FRAME_EXT = /\.(tsx|jsx|html)$/;
375
+ const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
376
+ /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
377
+ function extractMeta(src) {
378
+ const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
379
+ if (!m) return {};
380
+ const body = m[1];
381
+ const pick = (key) => {
382
+ const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
383
+ return r ? r[2] : void 0;
384
+ };
385
+ const out = {};
386
+ const title = pick("title");
387
+ if (title) out.title = title;
388
+ const viewport = pick("viewport");
389
+ if (viewport) out.viewport = viewport;
390
+ const theme = pick("theme");
391
+ if (theme) out.theme = theme;
392
+ const of = pick("of");
393
+ if (of) out.of = of;
394
+ const variant = pick("variant");
395
+ if (variant) out.variant = variant;
396
+ const intent = pick("intent");
397
+ if (intent) out.intent = intent;
398
+ return out;
399
+ }
400
+ /** Content-frame detection: LEXICAL by convention - the frame file
401
+ * itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
402
+ * string in a UI frame can never misbadge it; barrels/re-exports are not
403
+ * detected - meta.intent is the taught path and always works). Returns the
404
+ * inferred intent + natural width, or null for UI frames. */
405
+ const CONTENT_IMPORT = new RegExp(`from\\s+['"]${PKG}/content['"]`);
406
+ const WIDE_DOC = /<Doc\b[^>]*\blayout\s*=\s*["']wide["']/;
407
+ const contentWidthOf = (src) => WIDE_DOC.test(src) ? CONTENT_WIDTH.wide : CONTENT_WIDTH.document;
408
+ function contentScan(src) {
409
+ if (!CONTENT_IMPORT.test(src)) return null;
410
+ const count = (re) => (src.match(re) ?? []).length;
411
+ const diagrams = count(/<Diagram[\s>/]/g);
412
+ const imgs = count(/<Img[\s>/]/g);
413
+ const mds = count(/<Md[\s>/]/g);
414
+ return {
415
+ intent: diagrams > 0 ? "diagram" : imgs > mds ? "moodboard" : "spec",
416
+ width: contentWidthOf(src)
417
+ };
418
+ }
419
+ /** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
420
+ function toFrameId(designRelPath) {
421
+ const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
422
+ return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
423
+ }
424
+ /**
425
+ * A7: which frames does a changed `design/**` file affect? Manifest + directory conventions,
426
+ * NOT a module-graph walk (deterministic, cheap, may over-reload but never misses a
427
+ * conventionally-affected frame). Returns:
428
+ * null -> uncontrolled: leave to default Vite HMR (src/** deps, theme.css, config, assets)
429
+ * [] -> controlled but affects no current frame (e.g. a layout in an empty dir)
430
+ * [ids] -> controlled: the shell drives a rev-stamped reload of these frames
431
+ */
432
+ function affectedFrameIds(absFile, root, manifest) {
433
+ const design = join(root, "design");
434
+ if (absFile !== design && !absFile.startsWith(design + sep)) return null;
435
+ const rel = relative(design, absFile).split(sep).join("/");
436
+ const name = rel.split("/").pop() ?? rel;
437
+ const designRel = `design/${rel}`;
438
+ const tsxFrames = manifest.frames.filter((f) => f.kind === "tsx");
439
+ if (name === "theme.css") return null;
440
+ if (rel === "providers.tsx" || rel === "providers.jsx") return tsxFrames.map((f) => f.id);
441
+ if (/(^|\/)(_layout\.(tsx|jsx)|_fixtures\.(ts|tsx|js|jsx|json))$/.test(rel)) {
442
+ const prefix = `design/${rel.slice(0, rel.length - name.length)}`;
443
+ return tsxFrames.filter((f) => f.file.startsWith(prefix)).map((f) => f.id);
444
+ }
445
+ const direct = manifest.frames.find((f) => f.file === designRel);
446
+ if (direct) return [direct.id];
447
+ return null;
448
+ }
449
+ function walk(dir, out = []) {
450
+ if (!existsSync(dir)) return out;
451
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
452
+ if (e.name.startsWith(".")) continue;
453
+ const p = join(dir, e.name);
454
+ if (e.isDirectory()) walk(p, out);
455
+ else out.push(p);
456
+ }
457
+ return out;
458
+ }
459
+ function scanFrames(root) {
460
+ const design = join(root, "design");
461
+ const frames = [];
462
+ for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
463
+ const name = abs.split(sep).pop();
464
+ if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
465
+ const rel = relative(design, abs).split(sep).join("/");
466
+ const id = toFrameId(rel);
467
+ const scene = id.includes("/") ? id.split("/")[0] : "";
468
+ if (base === "scenes" && RESERVED_SCENES.has(scene)) {
469
+ console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
470
+ continue;
471
+ }
472
+ const kind = name.endsWith(".html") ? "html" : "tsx";
473
+ const entry = {
474
+ id,
475
+ file: `design/${rel}`,
476
+ kind,
477
+ scene
478
+ };
479
+ if (kind === "tsx") {
480
+ const src = readFileSync(abs, "utf8");
481
+ const meta = extractMeta(src);
482
+ if (meta.title) entry.title = meta.title;
483
+ if (meta.viewport) entry.viewport = meta.viewport;
484
+ if (meta.theme) entry.theme = meta.theme;
485
+ if (meta.of) entry.variantGroup = meta.of;
486
+ if (meta.variant) entry.variant = meta.variant;
487
+ const content = contentScan(src);
488
+ if (content || meta.intent) {
489
+ entry.intent = meta.intent ?? content.intent;
490
+ entry.contentWidth = content?.width ?? contentWidthOf(src);
491
+ }
492
+ }
493
+ frames.push(entry);
494
+ }
495
+ const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
496
+ frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
497
+ const seen = /* @__PURE__ */ new Map();
498
+ const deduped = frames.filter((f) => {
499
+ const prev = seen.get(f.id);
500
+ if (prev) {
501
+ console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
502
+ return false;
503
+ }
504
+ seen.set(f.id, f.file);
505
+ return true;
506
+ });
507
+ frames.length = 0;
508
+ frames.push(...deduped);
509
+ inferVariantGroups(frames);
510
+ const sceneCounts = /* @__PURE__ */ new Map();
511
+ for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
512
+ return {
513
+ frames,
514
+ scenes: [...sceneCounts.entries()].map(([name, n]) => ({
515
+ name,
516
+ frames: n
517
+ })).sort((a, b) => a.name.localeCompare(b.name))
518
+ };
519
+ }
520
+ /** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
521
+ * are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
522
+ * directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
523
+ * Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
524
+ * States (empty.tsx, error.tsx) never letter-prefix, so they never misgroup.
525
+ * Mutates entries in place: only frames whose group materializes keep the fields. */
526
+ function inferVariantGroups(frames) {
527
+ const dirOf = (id) => {
528
+ const i = id.lastIndexOf("/");
529
+ return i >= 0 ? id.slice(0, i) : "";
530
+ };
531
+ const candidates = /* @__PURE__ */ new Map();
532
+ for (const f of frames) {
533
+ if (f.kind !== "tsx") {
534
+ delete f.variantGroup;
535
+ delete f.variant;
536
+ continue;
537
+ }
538
+ let group = f.variantGroup;
539
+ let key = f.variant;
540
+ if (!group) {
541
+ const base = f.id.slice(f.id.lastIndexOf("/") + 1);
542
+ const m = /^([a-z])-.+$/.exec(base);
543
+ if (!m) continue;
544
+ group = dirOf(f.id);
545
+ if (!group) continue;
546
+ key = key ?? m[1];
547
+ } else if (!key) {
548
+ const m = /^([a-z])-.+$/.exec(f.id.slice(f.id.lastIndexOf("/") + 1));
549
+ if (!m) {
550
+ console.warn(`[marver] ${f.file} declares of:"${group}" but no variant key (add meta.variant or a letter prefix) - not grouped.`);
551
+ delete f.variantGroup;
552
+ delete f.variant;
553
+ continue;
554
+ }
555
+ key = m[1];
556
+ }
557
+ f.variantGroup = group;
558
+ f.variant = key;
559
+ candidates.set(group, [...candidates.get(group) ?? [], f]);
560
+ }
561
+ for (const [group, members] of candidates) {
562
+ if (new Set(members.map((m) => dirOf(m.id))).size > 1) {
563
+ console.warn(`[marver] group "${group}" spans directories - variants must be siblings; not grouped.`);
564
+ for (const m of members) {
565
+ delete m.variantGroup;
566
+ delete m.variant;
567
+ }
568
+ continue;
569
+ }
570
+ const seen = /* @__PURE__ */ new Set();
571
+ const kept = [];
572
+ for (const m of members.sort((a, b) => (a.variant ?? "").localeCompare(b.variant ?? ""))) {
573
+ if (seen.has(m.variant)) {
574
+ console.warn(`[marver] duplicate variant "${m.variant}" in group "${group}" (${m.file}) - not grouped.`);
575
+ delete m.variantGroup;
576
+ delete m.variant;
577
+ continue;
578
+ }
579
+ seen.add(m.variant);
580
+ kept.push(m);
581
+ }
582
+ if (kept.length < 2) for (const m of kept) {
583
+ delete m.variantGroup;
584
+ delete m.variant;
585
+ }
586
+ }
587
+ for (const f of frames) if (f.variant && !f.variantGroup) delete f.variant;
588
+ }
589
+ /** Write design/manifest.json only when content changed. Returns the manifest either way. */
590
+ function writeManifest(root, manifest) {
591
+ const file = join(root, "design", "manifest.json");
592
+ const next = JSON.stringify(manifest, null, 2) + "\n";
593
+ const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
594
+ if (hash(prev) === hash(next)) return false;
595
+ mkdirSync(join(root, "design"), { recursive: true });
596
+ writeFileSync(file, next);
597
+ return true;
598
+ }
599
+ const hash = (s) => createHash("sha256").update(s).digest("hex");
600
+ //#endregion
601
+ export { writeManifest as a, detectAgent as c, toFrameId as i, detectHost as l, hash as n, DEFAULTS as o, scanFrames as r, loadConfig as s, affectedFrameIds as t, readJson as u };
@@ -365,6 +365,111 @@ async function verifySignature(parts, kid, issuer) {
365
365
  }, signature);
366
366
  }
367
367
  /**
368
+ * Verify a short-lived bearer JWT the identity service minted for a NON-gate
369
+ * purpose (the front door's summary token, 04-solution §9.1). Same trust root
370
+ * and JWKS as the assertion path, but a distinct `typ` - so neither token can
371
+ * ever be replayed at the other door - and no nonce: the token is not a
372
+ * sign-in, it is a capability that expires in seconds.
373
+ */
374
+ async function verifyBearerJwt(opts) {
375
+ const { token, origin, issuer, typ } = opts;
376
+ if (!token || token.length > MAX_TOKEN_BYTES) return {
377
+ ok: false,
378
+ reason: "token size"
379
+ };
380
+ const parts = token.split(".");
381
+ if (parts.length !== 3) return {
382
+ ok: false,
383
+ reason: "not a jws"
384
+ };
385
+ let header;
386
+ let claims;
387
+ try {
388
+ const h = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
389
+ const c = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
390
+ if (!isPlainObject(h) || !isPlainObject(c)) return {
391
+ ok: false,
392
+ reason: "malformed json"
393
+ };
394
+ header = h;
395
+ claims = c;
396
+ } catch {
397
+ return {
398
+ ok: false,
399
+ reason: "malformed json"
400
+ };
401
+ }
402
+ if (header.alg !== "ES256") return {
403
+ ok: false,
404
+ reason: "alg"
405
+ };
406
+ if (header.typ !== typ) return {
407
+ ok: false,
408
+ reason: "typ"
409
+ };
410
+ if ("jku" in header || "x5u" in header || "jwk" in header) return {
411
+ ok: false,
412
+ reason: "header injection"
413
+ };
414
+ const kid = typeof header.kid === "string" ? header.kid : null;
415
+ if (!kid || kid.length > 128) return {
416
+ ok: false,
417
+ reason: "kid"
418
+ };
419
+ if (claims.iss !== issuer) return {
420
+ ok: false,
421
+ reason: "iss"
422
+ };
423
+ if (claims.aud !== origin) return {
424
+ ok: false,
425
+ reason: "aud"
426
+ };
427
+ if (opts.azp && claims.azp !== opts.azp) return {
428
+ ok: false,
429
+ reason: "azp"
430
+ };
431
+ const sub = typeof claims.sub === "string" ? claims.sub : "";
432
+ const email = typeof claims.email === "string" ? claims.email : "";
433
+ if (!sub || !email) return {
434
+ ok: false,
435
+ reason: "missing claims"
436
+ };
437
+ const now = Math.floor(Date.now() / 1e3);
438
+ const exp = typeof claims.exp === "number" ? claims.exp : 0;
439
+ const iat = typeof claims.iat === "number" ? claims.iat : 0;
440
+ if (!exp || exp + CLOCK_TOLERANCE_S < now) return {
441
+ ok: false,
442
+ reason: "expired"
443
+ };
444
+ if (!iat || iat - CLOCK_TOLERANCE_S > now) return {
445
+ ok: false,
446
+ reason: "issued in the future"
447
+ };
448
+ if (opts.maxAgeS && exp - iat > opts.maxAgeS) return {
449
+ ok: false,
450
+ reason: "lifetime"
451
+ };
452
+ let verified = false;
453
+ try {
454
+ verified = await verifySignature(parts, kid, issuer);
455
+ } catch {
456
+ return {
457
+ ok: false,
458
+ reason: "key fetch failed"
459
+ };
460
+ }
461
+ if (!verified) return {
462
+ ok: false,
463
+ reason: "signature"
464
+ };
465
+ return {
466
+ ok: true,
467
+ sub,
468
+ email,
469
+ claims
470
+ };
471
+ }
472
+ /**
368
473
  * The optional display claims, taken only if they are the right shape.
369
474
  *
370
475
  * Deliberately forgiving: a malformed name or picture drops that ONE field and
@@ -408,4 +513,4 @@ function browserBinding(cookieValue) {
408
513
  return createHash("sha256").update(cookieValue).digest("base64url");
409
514
  }
410
515
  //#endregion
411
- export { TransactionStore, browserBinding, normalizeIssuer, verifyAssertion };
516
+ export { TransactionStore, browserBinding, normalizeIssuer, verifyAssertion, verifyBearerJwt };