@marver-design/marver 0.7.0 → 0.8.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 (54) hide show
  1. package/CHANGELOG.md +141 -3
  2. package/README.md +72 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
  7. package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
  8. package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
  9. package/dist/daemon-DkyNOwIt.mjs +878 -0
  10. package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
  11. package/dist/events-BMtBvvgU.mjs +101 -0
  12. package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
  13. package/dist/ledger-CbzTJrV2.mjs +64 -0
  14. package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
  15. package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
  16. package/dist/profile-BkiWglVE.mjs +39 -0
  17. package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
  18. package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
  19. package/dist/work-CLrmY-vQ.mjs +97 -0
  20. package/dist/work-lzC-lPY0.mjs +76 -0
  21. package/package.json +16 -1
  22. package/src/client/const.ts +1 -1
  23. package/src/client/content/diagram.tsx +1 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +1 -1
  26. package/src/client/content/palette.ts +2 -2
  27. package/src/client/frame-host/bridge.js +1 -1
  28. package/src/client/frame-host/inspect.js +16 -3
  29. package/src/client/frame-host/serialize.ts +2 -2
  30. package/src/client/shell/App.tsx +165 -26
  31. package/src/client/shell/Comments.tsx +434 -78
  32. package/src/client/shell/Play.tsx +12 -8
  33. package/src/client/shell/Toolbar.tsx +14 -2
  34. package/src/client/shell/canvas/Canvas.tsx +1 -1
  35. package/src/client/shell/canvas/FrameNode.tsx +47 -15
  36. package/src/client/shell/canvas/snapshots.ts +3 -3
  37. package/src/client/shell/comments-store.ts +147 -33
  38. package/src/client/shell/hash.ts +2 -2
  39. package/src/client/shell/icons.tsx +5 -1
  40. package/src/client/shell/keys.ts +39 -0
  41. package/src/client/shell/mentions.ts +18 -0
  42. package/src/client/shell/perf.ts +1 -1
  43. package/src/client/shell/store.ts +127 -39
  44. package/src/client/shell/styles.css +303 -30
  45. package/src/client/shell/tidy.ts +5 -5
  46. package/src/client/stage/main.tsx +2 -2
  47. package/src/shared/events.ts +23 -6
  48. package/src/shared/utm.ts +22 -0
  49. package/templates/AGENTS-embedded.md +49 -1
  50. package/templates/AGENTS-studio.md +49 -1
  51. package/templates/instructions/configure.md +15 -0
  52. package/templates/instructions/jam.md +85 -0
  53. package/templates/instructions/publish.md +1 -1
  54. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -1,6 +1,6 @@
1
1
  import { n as NAME, r as PKG } from "./cli.mjs";
2
- import { o as loadConfig, s as detectHost } from "./manifest-C8FODq2S.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-wMY9lNf3.mjs";
2
+ import { o as loadConfig, s as detectHost } from "./manifest-B4zcDGBf.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BdQEeTLg.mjs";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
@@ -141,7 +141,7 @@ async function dev(root, portFlag) {
141
141
  const port = typeof addr === "object" && addr ? addr.port : picked.port;
142
142
  if (picked.fellBack) console.log(`\n port ${desiredPort} is in use - serving "${projectName}" on ${port} instead`);
143
143
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
144
- const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
144
+ const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
145
145
  if (loadCollab(root)) console.log(` comments: syncing with the published canvas (design/.local/collab.json)\n`);
146
146
  let syncing = false;
147
147
  const tick = async () => {
@@ -156,6 +156,31 @@ async function dev(root, portFlag) {
156
156
  };
157
157
  tick();
158
158
  setInterval(tick, 3e4).unref();
159
+ const { workActivity, writeDevInfo, removeDevInfo } = await import("./work-CLrmY-vQ.mjs");
160
+ const unsubscribe = workActivity.onChange((frames) => server.ws.send("sh:jam-activity", { frames }));
161
+ const sweep = setInterval(() => workActivity.sweep(), 15e3);
162
+ sweep.unref?.();
163
+ writeDevInfo(root, port);
164
+ {
165
+ const close = server.close.bind(server);
166
+ server.close = (async () => {
167
+ unsubscribe();
168
+ clearInterval(sweep);
169
+ removeDevInfo(root);
170
+ return close();
171
+ });
172
+ }
173
+ if (config.jam?.agent) {
174
+ const { startJam } = await import("./daemon-DkyNOwIt.mjs");
175
+ const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
176
+ if (jam) {
177
+ const close = server.close.bind(server);
178
+ server.close = (async () => {
179
+ jam.stop();
180
+ return close();
181
+ });
182
+ }
183
+ }
159
184
  return server;
160
185
  }
161
186
  //#endregion
@@ -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?.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,5 +1,5 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { a as DEFAULTS, c as readJson, i as writeManifest, r as scanFrames, s as detectHost } from "./manifest-C8FODq2S.mjs";
2
+ import { a as DEFAULTS, c as readJson, i as writeManifest, r as scanFrames, s as detectHost } from "./manifest-B4zcDGBf.mjs";
3
3
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -93,6 +93,30 @@ function init(root, opts) {
93
93
  writeManaged("AGENTS.md", readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_GUIDANCE}}", uiGuidance(host, noApp(host))).replace(/\{\{NEXT_NOTES\}\}\n?/, host.router === "next" ? NEXT_NOTES : ""));
94
94
  const agentsNow = readFileSync(join(design, "AGENTS.md"), "utf8");
95
95
  if (!agentsNow.startsWith(MANAGED_PREFIX) && !agentsNow.startsWith(LEGACY_PREFIX) && agentsNow.includes("# Design canvas - agent contract") && !agentsNow.includes("## The method (binding)")) console.warn(` note: design/AGENTS.md predates managed regeneration - if you never edited it, delete it and re-run init to get the current contract (incl. the design/instructions routing).`);
96
+ const claudeMd = join(root, "CLAUDE.md");
97
+ const importLine = "@design/AGENTS.md";
98
+ if (!existsSync(claudeMd)) {
99
+ writeFileSync(claudeMd, `# ${NAME}\n\nThis repo designs with ${NAME}. The agent contract for the canvas lives in design/AGENTS.md:\n\n${importLine}\n`);
100
+ created.push("CLAUDE.md");
101
+ } else {
102
+ const cur = readFileSync(claudeMd, "utf8");
103
+ if (!cur.includes(importLine)) {
104
+ writeFileSync(claudeMd, cur.replace(/\n*$/, "") + `\n\n${importLine}\n`);
105
+ created.push("CLAUDE.md (updated)");
106
+ }
107
+ }
108
+ const rootAgentsMd = join(root, "AGENTS.md");
109
+ const pointerLine = `Before any design or canvas work, read design/AGENTS.md and follow it - it is binding.`;
110
+ if (!existsSync(rootAgentsMd)) {
111
+ writeFileSync(rootAgentsMd, `# ${NAME}\n\nThis repo designs with ${NAME}. ${pointerLine}\n`);
112
+ created.push("AGENTS.md");
113
+ } else {
114
+ const cur = readFileSync(rootAgentsMd, "utf8");
115
+ if (!cur.includes("design/AGENTS.md")) {
116
+ writeFileSync(rootAgentsMd, cur.replace(/\n*$/, "") + `\n\n${pointerLine}\n`);
117
+ created.push("AGENTS.md (updated)");
118
+ }
119
+ }
96
120
  const instrRoot = join(templates, "instructions");
97
121
  for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
98
122
  for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) writeManaged(`instructions/${e.name}/${f}`, readFileSync(join(instrRoot, e.name, f), "utf8"));
@@ -140,7 +164,8 @@ function init(root, opts) {
140
164
  writeFileSync(join(design, "boards", ".gitkeep"), "");
141
165
  created.push("design/boards/");
142
166
  }
143
- if (opts.demo && !existsSync(join(design, "scenes", "demo"))) {
167
+ const sceneDirs = existsSync(join(design, "scenes")) ? readdirSync(join(design, "scenes"), { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_")).length : 0;
168
+ if (opts.demo && sceneDirs === 0 && !existsSync(join(design, "scenes", "demo"))) {
144
169
  cpSync(join(templates, "demo"), join(design, "scenes", "demo"), { recursive: true });
145
170
  created.push("design/scenes/demo/ (3 frames)");
146
171
  }
@@ -162,7 +187,7 @@ function init(root, opts) {
162
187
  console.log(`\n commit design/ - only .local/ is ignored`);
163
188
  console.log(` uninstall = delete design/, remove the ${NAME} dependency${host.tsconfigSweepsDesign ? ", revert the \"design\" line in tsconfig exclude" : ""}`);
164
189
  if (!noApp(host) && !existsSync(join(design, "DESIGN.md"))) console.log(`\n note: design/DESIGN.md (the brand doc) does not exist yet - have your agent create it from the app's tokens (instructions/brand.md, Path A) to reach the idle state.`);
165
- console.log(`\n next: npx ${NAME} dev (canvas on http://localhost:${DEFAULTS.port} by default)\n`);
190
+ console.log(`\n next: npx ${NAME} dev (or: npx ${NAME} canvas - same thing; canvas on http://localhost:${DEFAULTS.port} by default)\n`);
166
191
  if (!noApp(host)) console.log(` then, to your agent: "Read design/AGENTS.md. This is our first session - follow design/instructions/welcome.md."\n`);
167
192
  }
168
193
  const MANAGED_PREFIX = "<!-- marver:managed ";
@@ -0,0 +1,64 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ //#region \0rolldown/runtime.js
4
+ var __defProp = Object.defineProperty;
5
+ var __exportAll = (all, no_symbols) => {
6
+ let target = {};
7
+ for (var name in all) __defProp(target, name, {
8
+ get: all[name],
9
+ enumerable: true
10
+ });
11
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
12
+ return target;
13
+ };
14
+ //#endregion
15
+ //#region src/server/jam/ledger.ts
16
+ /**
17
+ * The device-bound authorization ledger - the whole trust boundary.
18
+ *
19
+ * When the dev POST accepts an owner-gated write, it records that event's id here. The
20
+ * daemon's owner-trigger check is `has(root, id)`, never a synced field: sync copies
21
+ * `origin` byte-for-byte, so a remote comment can spoof `origin:'local'` (proven RCE),
22
+ * but it can never appear in a file that is written only on THIS machine by the gated
23
+ * POST and never synced. Synced-in events are never in the ledger, so they never trigger.
24
+ *
25
+ * One `<board>\t<id>` per line, append-only, gitignored, never synced (design/.local/ is
26
+ * watch-ignored and sync-excluded). Agent-written events are never recorded (they are
27
+ * daemon-authored, not owner input, so they cannot self-authorize a next job).
28
+ *
29
+ * The key is (board, id), NOT id alone: event ids are client UUIDs that sync copies verbatim,
30
+ * so a remote collaborator could reuse an owner's ledgered id in a NEW malicious event. Binding
31
+ * to the board it was gate-written on defeats that - the forged copy lands on some board the
32
+ * ledger never authorized for that id, so it never triggers.
33
+ */
34
+ var ledger_exports = /* @__PURE__ */ __exportAll({
35
+ has: () => has,
36
+ record: () => record
37
+ });
38
+ const ledgerFile = (root) => join(root, "design", ".local", "jam-ledger");
39
+ const line = (board, id) => `${board}\t${id}`;
40
+ /** Was this (board, id) authorized on this device by the gated dev POST? */
41
+ function has(root, board, id) {
42
+ if (!board || !id) return false;
43
+ const file = ledgerFile(root);
44
+ if (!existsSync(file)) return false;
45
+ const want = line(board, id);
46
+ for (const l of readFileSync(file, "utf8").split("\n")) if (l === want) return true;
47
+ return false;
48
+ }
49
+ /** Authorize a (board, id). fsync'd (a 200-acked, ledgered write must survive a crash) and
50
+ * 0600 (owner-only). Idempotent enough: a duplicate line is harmless, `has` matches either. */
51
+ function record(root, board, id) {
52
+ if (!board || !id) return;
53
+ const file = ledgerFile(root);
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ const fd = openSync(file, "a", 384);
56
+ try {
57
+ writeSync(fd, line(board, id) + "\n");
58
+ fsyncSync(fd);
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ //#endregion
64
+ export { ledger_exports as n, has as t };
@@ -169,7 +169,8 @@ async function loadConfig(root) {
169
169
  branding: user.share?.branding !== false,
170
170
  name: typeof user.share?.name === "string" ? user.share.name : void 0,
171
171
  logo: typeof user.share?.logo === "string" ? user.share.logo : void 0
172
- }
172
+ },
173
+ jam: validJam(user.jam)
173
174
  };
174
175
  } catch (err) {
175
176
  console.error(`[marver] design/config.ts failed to load, using defaults:\n ${err.message}`);
@@ -183,6 +184,20 @@ function validPort(n) {
183
184
  function validZoom(n) {
184
185
  return typeof n === "number" && Number.isFinite(n) && n >= .1 && n <= 10 ? n : null;
185
186
  }
187
+ /** Normalize the jam block. An unknown/missing `agent` means Live Jam stays OFF (returns undefined),
188
+ * so a stray `jam: {}` never silently arms the daemon. Other fields fall back to lean defaults. */
189
+ function validJam(v) {
190
+ if (!v || typeof v !== "object") return void 0;
191
+ const j = v;
192
+ if (j.agent !== "claude" && j.agent !== "codex") return void 0;
193
+ const concurrency = typeof j.concurrency === "number" && Number.isInteger(j.concurrency) && j.concurrency >= 1 && j.concurrency <= 16 ? j.concurrency : 3;
194
+ return {
195
+ agent: j.agent,
196
+ concurrency,
197
+ subagents: j.subagents !== false,
198
+ proactive: j.proactive === true
199
+ };
200
+ }
186
201
  function validViewports(v) {
187
202
  if (!v || typeof v !== "object") return null;
188
203
  const out = {};
@@ -199,7 +214,7 @@ function validViewports(v) {
199
214
  //#region src/server/manifest.ts
200
215
  const FRAME_EXT = /\.(tsx|jsx|html)$/;
201
216
  const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
202
- /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted (spec §6). */
217
+ /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
203
218
  function extractMeta(src) {
204
219
  const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
205
220
  if (!m) return {};
@@ -223,7 +238,7 @@ function extractMeta(src) {
223
238
  if (intent) out.intent = intent;
224
239
  return out;
225
240
  }
226
- /** Content-frame detection (SPEC-026): LEXICAL by convention - the frame file
241
+ /** Content-frame detection: LEXICAL by convention - the frame file
227
242
  * itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
228
243
  * string in a UI frame can never misbadge it; barrels/re-exports are not
229
244
  * detected - meta.intent is the taught path and always works). Returns the
@@ -343,7 +358,7 @@ function scanFrames(root) {
343
358
  })).sort((a, b) => a.name.localeCompare(b.name))
344
359
  };
345
360
  }
346
- /** Variant groups (SPEC-023 §1). A group = 2+ frames in one DIRECTORY whose basenames
361
+ /** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
347
362
  * are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
348
363
  * directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
349
364
  * Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
@@ -1,5 +1,6 @@
1
1
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
2
- import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-C8FODq2S.mjs";
2
+ import { n as localProfile, t as isConnected } from "./profile-BkiWglVE.mjs";
3
+ import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-B4zcDGBf.mjs";
3
4
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
4
5
  import { basename, dirname, join, resolve, sep } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -7,6 +8,28 @@ import { randomBytes } from "node:crypto";
7
8
  //#region src/server/api.ts
8
9
  const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
9
10
  const BODY_LIMIT = 1e6;
11
+ const CSRF_MAX_AGE = 2592e3;
12
+ /** Read one cookie value off the request. */
13
+ function cookie(req, name) {
14
+ const m = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(String(req.headers.cookie ?? ""));
15
+ return m ? m[1] : "";
16
+ }
17
+ /** The owner gate. The dev server is 127.0.0.1-only, so the live threat is a
18
+ * cross-origin drive-by page firing a POST at localhost. Two defenses, mirroring the published
19
+ * side (collab.ts:206): a double-submit cookie (mv_c is JS-readable same-origin, so a cross-origin
20
+ * page cannot read it to echo x-mv-c), plus an Origin allowlist. Only a POST that passes this is
21
+ * eligible to be ledgered, so a forged @marver can never authorize the local agent. */
22
+ function ownerGated(req) {
23
+ const c = cookie(req, "mv_c");
24
+ if (!c || req.headers["x-mv-c"] !== c) return false;
25
+ const origin = req.headers.origin;
26
+ if (origin) try {
27
+ if (new URL(String(origin)).host !== String(req.headers.host ?? "")) return false;
28
+ } catch {
29
+ return false;
30
+ }
31
+ return true;
32
+ }
10
33
  function json(res, status, body) {
11
34
  res.statusCode = status;
12
35
  res.setHeader("content-type", "application/json");
@@ -74,6 +97,7 @@ function apiMiddleware(root) {
74
97
  const url = new URL(req.url ?? "/", "http://x");
75
98
  if (!url.pathname.startsWith(`/__mv/api/`)) return next();
76
99
  const path = url.pathname.slice(`${ROUTE}/api/`.length);
100
+ if (req.method === "GET" && !cookie(req, "mv_c")) res.setHeader("set-cookie", `mv_c=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${CSRF_MAX_AGE}; SameSite=Lax`);
77
101
  try {
78
102
  if (path === "boards" && req.method === "GET") {
79
103
  mkdirSync(boardsDir, { recursive: true });
@@ -146,9 +170,11 @@ function apiMiddleware(root) {
146
170
  if (path === "me" && req.method === "GET") return json(res, 200, {
147
171
  user: localProfile(root),
148
172
  role: "owner",
149
- local: true
173
+ local: true,
174
+ connected: isConnected(root)
150
175
  });
151
176
  if (path === "profile" && req.method === "POST") {
177
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
152
178
  const raw = await readBody(req);
153
179
  if (raw == null) return json(res, 400, { error: "body too large" });
154
180
  let b;
@@ -159,17 +185,54 @@ function apiMiddleware(root) {
159
185
  }
160
186
  const dir = join(root, "design", ".local");
161
187
  mkdirSync(dir, { recursive: true });
188
+ const { validAvatar } = await import("./collab-s3k5byM1.mjs");
189
+ let cur = {};
190
+ try {
191
+ cur = JSON.parse(readFileSync(join(dir, "profile.json"), "utf8"));
192
+ } catch {}
162
193
  const prof = {
163
- ...localProfile(root),
194
+ ...cur,
164
195
  ...typeof b.name === "string" && b.name.trim() ? { name: b.name.trim() } : {},
165
196
  ...typeof b.email === "string" ? { email: b.email.trim() } : {},
166
- ...typeof b.avatar === "string" ? { avatar: b.avatar || void 0 } : {}
197
+ ...b.avatar === "" ? { avatar: void 0 } : validAvatar(b.avatar) ? { avatar: b.avatar } : {}
167
198
  };
168
199
  atomicWrite(join(dir, "profile.json"), JSON.stringify(prof, null, 2) + "\n");
169
- return json(res, 200, { user: prof });
200
+ return json(res, 200, { user: localProfile(root) });
201
+ }
202
+ if (path === "work") {
203
+ const { workActivity, readDevInfo, WORK_TTL_DEFAULT, WORK_TTL_MAX } = await import("./work-CLrmY-vQ.mjs");
204
+ const tokenOk = () => {
205
+ const t = readDevInfo(root)?.token;
206
+ return !!t && req.headers["x-mv-work"] === t;
207
+ };
208
+ if (req.method === "GET") {
209
+ if (!ownerGated(req) && !tokenOk()) return json(res, 403, { error: "forbidden" });
210
+ return json(res, 200, { frames: workActivity.active() });
211
+ }
212
+ if (req.method === "POST") {
213
+ if (!ownerGated(req) && !tokenOk()) return json(res, 403, { error: "forbidden" });
214
+ const raw = await readBody(req);
215
+ if (raw == null) return json(res, 400, { error: "body too large" });
216
+ let b;
217
+ try {
218
+ b = JSON.parse(raw);
219
+ } catch {
220
+ return json(res, 400, { error: "malformed JSON" });
221
+ }
222
+ const frames = Array.isArray(b.frames) ? b.frames.filter((f) => typeof f === "string" && f) : [];
223
+ const on = b.on !== false;
224
+ if (!on && b.all === true) {
225
+ workActivity.clearAll("cli");
226
+ return json(res, 200, { frames: workActivity.active() });
227
+ }
228
+ if (!frames.length) return json(res, 400, { error: "frames required" });
229
+ const ttl = Math.min(Math.max(Number(b.ttlMs) || WORK_TTL_DEFAULT, 1e4), WORK_TTL_MAX);
230
+ for (const f of frames) on ? workActivity.mark(f, ttl, "cli") : workActivity.clear(f, "cli");
231
+ return json(res, 200, { frames: workActivity.active() });
232
+ }
170
233
  }
171
234
  if (path === "boards.rights" && req.method === "GET") {
172
- const { listBoards } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
235
+ const { listBoards } = await import("./comments-BZBKhKRO.mjs");
173
236
  return json(res, 200, {
174
237
  local: true,
175
238
  stored: listBoards(join(root, "design", "comments"))
@@ -177,10 +240,11 @@ function apiMiddleware(root) {
177
240
  }
178
241
  const cm = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
179
242
  if (cm) {
180
- const { appendEvents, readLog } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
243
+ const { appendEvents, readLog } = await import("./comments-BZBKhKRO.mjs");
181
244
  const dir = join(root, "design", "comments");
182
245
  if (req.method === "GET") return json(res, 200, { events: readLog(dir, cm[1]) });
183
246
  if (req.method === "POST") {
247
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
184
248
  const raw = await readBody(req);
185
249
  if (raw == null) return json(res, 400, { error: "body too large" });
186
250
  let b;
@@ -191,23 +255,28 @@ function apiMiddleware(root) {
191
255
  }
192
256
  const incoming = Array.isArray(b.events) ? b.events : [];
193
257
  const me = localProfile(root);
194
- const stamped = incoming.map((ev) => ({
195
- ...ev,
196
- board: ev.board ?? cm[1],
197
- author: ev.author ?? ([
198
- "create",
199
- "reply",
200
- "react",
201
- "edit"
202
- ].includes(ev.type) ? me : void 0)
203
- }));
258
+ const stamped = incoming.map((ev) => {
259
+ const { agent: _a, agentMeta: _am, origin: _o, ...clean } = ev;
260
+ return {
261
+ ...clean,
262
+ board: clean.board ?? cm[1],
263
+ author: clean.author ?? ([
264
+ "create",
265
+ "reply",
266
+ "react",
267
+ "edit"
268
+ ].includes(clean.type) ? me : void 0)
269
+ };
270
+ });
204
271
  const fresh = appendEvents(dir, cm[1], stamped);
272
+ const { record } = await import("./ledger-CbzTJrV2.mjs").then((n) => n.n);
273
+ for (const ev of fresh) if (ev.type === "create" || ev.type === "reply") record(root, cm[1], ev.id);
205
274
  backgroundPush(root);
206
275
  return json(res, 200, { accepted: fresh.length });
207
276
  }
208
277
  }
209
278
  if (path === "sync" && req.method === "POST") {
210
- const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
279
+ const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
211
280
  const collab = loadCollab(root);
212
281
  if (!collab) return json(res, 200, { connected: false });
213
282
  try {
@@ -228,27 +297,6 @@ function apiMiddleware(root) {
228
297
  }
229
298
  };
230
299
  }
231
- function localProfile(root) {
232
- try {
233
- const c = JSON.parse(readFileSync(join(root, "design", ".local", "collab.json"), "utf8"));
234
- if (typeof c?.email === "string" && c.email) return {
235
- email: c.email,
236
- name: c.name ?? "Designer"
237
- };
238
- } catch {}
239
- try {
240
- const p = JSON.parse(readFileSync(join(root, "design", ".local", "profile.json"), "utf8"));
241
- if (typeof p?.name === "string") return {
242
- email: p.email ?? "",
243
- name: p.name,
244
- avatar: p.avatar
245
- };
246
- } catch {}
247
- return {
248
- email: "",
249
- name: "Designer"
250
- };
251
- }
252
300
  let pushTimer = null;
253
301
  /** Debounced fire-and-forget push after a local write; failures are silent - the
254
302
  * 30s sync loop is the reliability layer, this is just latency. */
@@ -257,7 +305,7 @@ function backgroundPush(root) {
257
305
  pushTimer = setTimeout(async () => {
258
306
  pushTimer = null;
259
307
  try {
260
- const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
308
+ const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
261
309
  const collab = loadCollab(root);
262
310
  if (collab) await syncOnce(root, collab);
263
311
  } catch {}
@@ -438,7 +486,7 @@ function marverPlugin(ctx) {
438
486
  zoomSpeed: config.zoomSpeed,
439
487
  noTheme: themeFile() == null,
440
488
  setup: setupPending,
441
- projectName: basename(root)
489
+ projectName: config.share.name || basename(root)
442
490
  })}`;
443
491
  }
444
492
  if (id === "\0virtual:sh-data") return "export default null";
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ //#region src/server/profile.ts
4
+ /**
5
+ * The dev identity - ONE resolver for every server-side consumer (api, jam daemon, CLI).
6
+ *
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`
10
+ *
11
+ * The connect account wins name + email (published serves validate author == session, so
12
+ * events born here must carry an identity the remote will accept). The avatar always comes
13
+ * from profile.json - connect doesn't carry one, and the local photo is still YOUR photo.
14
+ */
15
+ const readJson = (path) => {
16
+ try {
17
+ const v = JSON.parse(readFileSync(path, "utf8"));
18
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
19
+ } catch {
20
+ return {};
21
+ }
22
+ };
23
+ const str = (v) => typeof v === "string" ? v.trim() : "";
24
+ /** True when a connect account provides the identity - name/email are then read-only in dev. */
25
+ function isConnected(root) {
26
+ return !!str(readJson(join(root, "design", ".local", "collab.json")).email);
27
+ }
28
+ function localProfile(root) {
29
+ const dir = join(root, "design", ".local");
30
+ const prof = readJson(join(dir, "profile.json"));
31
+ const collab = readJson(join(dir, "collab.json"));
32
+ return {
33
+ name: str(collab.name) || str(prof.name) || "You",
34
+ email: str(collab.email) || str(prof.email),
35
+ avatar: str(prof.avatar) || void 0
36
+ };
37
+ }
38
+ //#endregion
39
+ export { localProfile as n, isConnected as t };
@@ -3,10 +3,29 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
3
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
5
  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
6
25
  //#region src/server/serve.ts
7
26
  /**
8
27
  * `marver serve` - a zero-dependency static server for design/.dist with an optional
9
- * password gate (SPEC-M2 §4b). No MARVER_PASSWORD → plain static serving. With it, the
28
+ * password gate. No MARVER_PASSWORD → plain static serving. With it, the
10
29
  * bundle is never sent pre-auth: every unauthenticated request gets the gate page, auth
11
30
  * is a POST compare + an HMAC-signed 30-day cookie keyed off the password itself (all
12
31
  * instances agree, nothing stored server-side).
@@ -49,9 +68,9 @@ async function serve(root, portFlag) {
49
68
  let bearerCheck = null;
50
69
  let sessionCheck = null;
51
70
  if (process.env.MARVER_DATA_DIR) {
52
- const { collabHandler } = await import("./collab-CXy8gqoz.mjs");
53
- const { appendEvents, readLog } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
54
- const { dataDir } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
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
74
  const dir = dataDir();
56
75
  const seedDir = join(dist, "design", "comments");
57
76
  if (existsSync(seedDir)) for (const f of readdirSync(seedDir)) {
@@ -60,7 +79,7 @@ async function serve(root, portFlag) {
60
79
  appendEvents(join(dir, "comments"), board, readLog(seedDir, board));
61
80
  }
62
81
  collab = collabHandler(dir, dist);
63
- const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-B36fMCM3.mjs");
82
+ const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-KQ9Aj-nB.mjs");
64
83
  bearerCheck = (token) => sessionUser(dir, token);
65
84
  sessionCheck = (req) => {
66
85
  const tok = /(?:^|;\s*)mv_s=([\w-]+)/.exec(String(req.headers.cookie ?? ""))?.[1];
@@ -128,7 +147,12 @@ async function serve(root, portFlag) {
128
147
  if (!bearerOk && !preGate) return gate(res, meta, !!collab);
129
148
  }
130
149
  }
131
- if (collab && url.pathname.startsWith("/__mv/api/")) {
150
+ if (url.pathname.startsWith("/__mv/api/")) {
151
+ if (!collab) {
152
+ res.statusCode = 404;
153
+ res.setHeader("content-type", "application/json");
154
+ return res.end("{\"error\":\"collaboration is not enabled on this canvas\"}");
155
+ }
132
156
  collab(req, res, url).then((handled) => {
133
157
  if (!handled) {
134
158
  res.statusCode = 404;
@@ -188,7 +212,7 @@ const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${
188
212
  const MARK = MARK_AT(17);
189
213
  const MARK_LG = MARK_AT(24);
190
214
  /** The gate: the canvas's own antechamber - light ground, dot grid, glass card in the
191
- * shell's exact token language. Three doors, one credential each (SPEC-M3 gate v2):
215
+ * shell's exact token language. Three doors, one credential each:
192
216
  * guests pay the canvas password, members sign in with their OWN password, and an
193
217
  * invite link (#/i/<token>) opens straight into the claim. The member and claim
194
218
  * states exist only on collaboration canvases - a static canvas keeps one field. */
@@ -423,7 +447,7 @@ ${collabOn ? `
423
447
  ` : ""}
424
448
  })()
425
449
  <\/script>
426
- ${meta.branding ? `<footer><a href="https://marver.design" target="_blank" rel="noopener">${MARK} <span>Powered by <span class="md">Marver.design</span></span> <svg class="up" viewBox="0 0 256 256" width="11" height="11" fill="currentColor" aria-hidden><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"/></svg></a></footer>` : ""}
450
+ ${meta.branding ? `<footer><a href="${poweredByUrl(meta.name, "published-canvas", "gate")}" target="_blank" rel="noopener">${MARK} <span>Powered by <span class="md">Marver.design</span></span> <svg class="up" viewBox="0 0 256 256" width="11" height="11" fill="currentColor" aria-hidden><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"/></svg></a></footer>` : ""}
427
451
  </main></body></html>`);
428
452
  }
429
453
  const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);