@marver-design/marver 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
4
4
 
5
+ ## 0.13.0 - 2026-09-01
6
+
7
+ ### Added
8
+
9
+ - **@-mentions in comments.** Type `@` in the composer to name someone already
10
+ visible in the canvas's comments - they get one mail for that comment and a
11
+ bell notification on the front door. Mentions ride the same identity-minimised
12
+ projection as authors: browsers only ever see opaque ids, and you can only
13
+ mention people you can already see. The agent side is real too: `marver
14
+ comments list --json` now carries each comment's mentions.
15
+ - **Reply mail.** A fresh reply mails the thread's other participants (the 10
16
+ most recent, at most once per person per thread per 6-hour window; history
17
+ imports never mail). Activity mail reads like a comment - who wrote it and a
18
+ snippet of what they said (capped, escaped; the identity service holds those
19
+ snippets for its delivery window - a disclosed tradeoff, documented in
20
+ docs/sharing.md) - and carries an unsubscribe link that mutes email without
21
+ touching the bell. Caveat: delivery needs Marver Sign In - a sovereign canvas
22
+ has no relay, and `share: { notify: false }` still declines everything.
23
+ - **Mentions light up in the canvas.** A mention of you pulses its comment pin
24
+ in accent blue until you open the thread, the marker stack opens on the
25
+ mentioning thread first, and a mention of YOU renders as the loudest chip -
26
+ including in your own view (they used to render plain exactly for the person
27
+ they were for). The @-picker got a solid background.
28
+ - **In-app notifications, with sound.** A fresh mention of you - or a reply in
29
+ a thread you are part of - raises a person-first pill while you are on the
30
+ canvas: the author's avatar, "name mentioned you", their message, and View,
31
+ with a soft two-tone ping (best-effort under browser autoplay rules; one
32
+ ping per batch). Same pill family the Live Jam agent uses, so they stack
33
+ together. History and first loads never announce.
34
+ - **Mail lands on the thread.** The activity mail's button ("Open the comment"
35
+ / "Open the thread") deep-links straight to the conversation
36
+ (`#/b/<board>?c=<thread>`); the relay refuses any other link shape, so mail
37
+ can only ever point a reader back into the canvas that sent it.
38
+
39
+ ### Fixed
40
+
41
+ - **Comment `profile` events now bind to the signed-in author.** They were
42
+ accepted with an arbitrary `author.email`, which let any commenter read the
43
+ opaque id of any guessed address off the projection.
44
+ - **Blank bodies and duplicate reply ids are refused.** Replay always discarded
45
+ them silently; the validator now agrees, so nothing downstream (like mail)
46
+ can fire for an event that was never going to render.
47
+
48
+ - **`marver share explain` now asks the canvas's own resolver.** It called
49
+ `resolveAccess` locally over the fetched roster, which drops the three things only the
50
+ server's `explain` route does - fills the owner role (a fresh owner was explained as an
51
+ ordinary ungranted person), explains a `@domain` through a synthetic member, and marks
52
+ the winning step. It now fetches `GET /__mv/api/share/explain?who=`, so the terminal and
53
+ the browser dialog can never disagree. `who` stays local (it lists only granted
54
+ non-owner principals, where a local resolve is identical).
55
+
5
56
  ## 0.12.0 - 2026-08-31
6
57
 
7
58
  ### Added
@@ -1,6 +1,6 @@
1
1
  import { i as ROUTE, n as NAME } from "./cli.mjs";
2
2
  import { l as detectHost, r as scanFrames, s as loadConfig } from "./manifest-CS6krOTe.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-Boy7I_JH.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DNc4Jpae.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
package/dist/cli.mjs CHANGED
@@ -46,7 +46,7 @@ cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "st
46
46
  });
47
47
  });
48
48
  for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot reload, comments, Live Jam)"], ["canvas", "Start the local canvas - same as dev"]]) cli.command(name, desc).option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
49
- const { dev } = await import("./dev-BXq7yE1p.mjs");
49
+ const { dev } = await import("./dev-DLwt3Brb.mjs");
50
50
  let port;
51
51
  if (opts.port !== void 0) {
52
52
  const n = Number(opts.port);
@@ -56,7 +56,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
56
56
  await dev(resolve(opts.root), port);
57
57
  });
58
58
  cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--embed-seeds", "Copy comment history INTO the web root (identifying - every event carries its author's email)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
59
- const { buildSite } = await import("./build-DmfxJc6L.mjs");
59
+ const { buildSite } = await import("./build-BxGrHFT2.mjs");
60
60
  try {
61
61
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
62
62
  await buildSite(resolve(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true);
@@ -66,7 +66,7 @@ cli.command("build", "Static export → design/.dist (what ships comes from desi
66
66
  }
67
67
  });
68
68
  cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default $PORT or 4199)").action(async (opts) => {
69
- const { serve } = await import("./serve-BeLLizNg.mjs");
69
+ const { serve } = await import("./serve-EjEqsiYa.mjs");
70
70
  let port;
71
71
  if (opts.port !== void 0) {
72
72
  const n = Number(opts.port);
@@ -75,7 +75,7 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
75
75
  serve(resolve(opts.root), port);
76
76
  });
77
77
  cli.command("comments <action> [value]", "Comment collaboration: connect <url> · sync · list · reply <thread> · resolve <thread> · invite <email> · revoke <email>").option("--root <dir>", "Host repo root", { default: "." }).option("--token <token>", "connect: the canvas's MARVER_CLI_TOKEN (default $MARVER_CLI_TOKEN) - the identity-mode path").option("--invite <token>", "connect: claim this invite instead of signing in").option("--canvas-password <password>", "connect: the canvas gate password (default $MARVER_PASSWORD or prompt)").option("--email <email>", "connect: account email (skips the prompt)").option("--password <password>", "connect: account password (skips the prompt - mind your shell history)").option("--name <name>", "connect --invite: display name for the new account").option("--open", "list: only unresolved threads").option("--json", "list: machine-readable output").option("--board <board>", "scope to one board").option("--body <text>", "reply: the reply text").option("--addressed-in <frame>", "resolve: the variant frame that answered the feedback").action(async (action, value, opts) => {
78
- const { commentsCommand } = await import("./comments-pNxjTrGN.mjs");
78
+ const { commentsCommand } = await import("./comments-DHB_8BRa.mjs");
79
79
  try {
80
80
  await commentsCommand(resolve(opts.root), action, value, opts);
81
81
  } catch (err) {
@@ -84,7 +84,7 @@ cli.command("comments <action> [value]", "Comment collaboration: connect <url>
84
84
  }
85
85
  });
86
86
  cli.command("share <action> [value]", "Sharing roster (owner): add <who> · remove <who> · block/unblock <email> · general <mode> · list · requests · explain <who> · who").option("--root <dir>", "Host repo root", { default: "." }).option("--role <role>", "add / requests --approve: view (default) or comment").option("--expires <iso>", "add: expiry timestamp, e.g. 2026-12-31T00:00:00Z").option("--approve <email>", "requests: approve this pending request (canvas-wide in v1)").option("--decline <email>", "requests: decline this pending request (silent to the asker)").option("--json", "list: machine-readable output").action(async (action, value, opts) => {
87
- const { shareCommand } = await import("./share-C64b1tdN.mjs");
87
+ const { shareCommand } = await import("./share-Gqo_Ygqw.mjs");
88
88
  try {
89
89
  await shareCommand(resolve(opts.root), action, value, opts);
90
90
  } catch (err) {
@@ -1,50 +1,138 @@
1
1
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
2
2
  import { A as reconfirmGrant, C as commentAllowed, D as operativeMode, E as loadShare, F as setBlocked, I as setGeneralMode, L as shareState, M as resolveAccess, N as resolveRequest, O as provisionVerdict, P as rosterEtag, S as ceilingsFromRights, T as loadRequests, _ as sessionUser, a as claimInvite, b as updateProfile, c as issueDeviceSession, d as opaqueId, f as ownerName, g as sessionIsOperator, h as revokeUser, j as removeGrant, k as putRequest, l as loadStore, m as publicUser, o as createInvite, s as inviteInfo, u as normEmail, v as signIn, w as entryAllowed, y as signOut, z as upsertGrant } from "./auth-DdUKeVKt.mjs";
3
- import { canvasIdentity, deriveThreads, loadSeen, markSeen, signCanvasJws, unreadCount, verifyCanvasJws } from "./summary-Bf9tiGrE.mjs";
3
+ import { canvasIdentity, deriveThreads, loadSeen, markSeen, signCanvasJws, unreadCount, verifyCanvasJws } from "./summary-C7CAypbB.mjs";
4
4
  import { t as secureSuffix } from "./secure-cookie-_K1Hsx8H.mjs";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { createHash, randomBytes } from "node:crypto";
8
8
  //#region src/server/notify.ts
9
9
  /**
10
- * The canvas's half of the notification relay (04-solution §2.3, §9.5).
10
+ * The canvas's half of the notification relay (04-solution §2.3, §9.5; v1.1
11
+ * additions in 07-v1.1 §A/§B).
11
12
  *
12
- * Three transactional moments, exactly: you were invited (a view/comment grant
13
- * landed on an address), your request was approved, someone asked for access
14
- * (to the owner - and that mail carries only "someone"). Delivery is the ID
15
- * service's relay; the canvas never talks to Resend. The action token is
16
- * signed by the canvas identity key - owner authorization is implicit in the
17
- * signature, since only the canvas server holds it.
13
+ * Transactional moments: you were invited, your request was approved, someone
14
+ * asked for access. Activity moments (v1.1): a thread you are in moved
15
+ * (`reply`), somebody named you (`mentioned`). Delivery is the ID service's
16
+ * relay; the canvas never talks to Resend. The action token is signed by the
17
+ * canvas identity key - owner authorization is implicit in the signature,
18
+ * since only the canvas server holds it.
18
19
  *
19
20
  * Fire-and-forget, always: a notification that failed must never be the reason
20
- * a grant did not land. Idempotency lives at the relay, keyed on
21
- * (origin, template, eventId) - the eventId is derived from the STATE
22
- * TRANSITION, so a re-invite at the same role sends nothing and a role change
23
- * sends one.
21
+ * a comment or grant did not land - delivery is best-effort at this boundary,
22
+ * durable only once the relay's outbox row exists. Idempotency lives at the
23
+ * relay, keyed on (origin, template, eventId) - the eventId is derived from
24
+ * the STATE TRANSITION, so a re-invite at the same role sends nothing, a
25
+ * mention mails once per person per comment ever, and replies throttle to one
26
+ * mail per recipient per thread per window.
27
+ *
28
+ * All relay requests ride ONE promise chain: however wide a reply fans out,
29
+ * this process holds at most one in-flight relay request (each bounded by its
30
+ * own 5s timeout) - a comment-authorized caller cannot burst sockets.
24
31
  */
25
32
  const transitionId = (...parts) => createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 32);
26
- function relayNotify(ctx, template, recipient, eventId) {
33
+ let chain = Promise.resolve();
34
+ const capped = (s, cap) => {
35
+ const t = s?.replace(/\s+/g, " ").trim();
36
+ return t ? t.length > cap ? `${t.slice(0, cap - 1)}…` : t : void 0;
37
+ };
38
+ function relayNotify(ctx, template, recipient, eventId, content) {
27
39
  if (!ctx.enabled || !ctx.issuer || !ctx.origin) return;
28
40
  if (recipient.startsWith("@") || !recipient.includes("@")) return;
29
- const now = Math.floor(Date.now() / 1e3);
30
- let token;
31
- try {
32
- token = signCanvasJws(ctx.dataDir, {
33
- origin: ctx.origin,
34
- template,
35
- recipient,
36
- eventId,
37
- iat: now,
38
- exp: now + 600
39
- }, "marver-relay+jwt");
40
- } catch {
41
- return;
41
+ const { dataDir, origin } = ctx;
42
+ const actor = capped(content?.actor, 60);
43
+ const snippet = capped(content?.snippet, 180);
44
+ const link = content?.link && content.link.length <= 160 ? content.link : void 0;
45
+ const url = `${ctx.issuer.replace(/\/+$/, "")}/relay/notify`;
46
+ chain = chain.then(() => {
47
+ const now = Math.floor(Date.now() / 1e3);
48
+ let token;
49
+ try {
50
+ token = signCanvasJws(dataDir, {
51
+ origin,
52
+ template,
53
+ recipient,
54
+ eventId,
55
+ iat: now,
56
+ exp: now + 600,
57
+ ...actor ? { actor } : {},
58
+ ...snippet ? { snippet } : {},
59
+ ...link ? { link } : {}
60
+ }, "marver-relay+jwt");
61
+ } catch {
62
+ return;
63
+ }
64
+ return fetch(url, {
65
+ method: "POST",
66
+ headers: { authorization: `Bearer ${token}` },
67
+ signal: AbortSignal.timeout(5e3)
68
+ }).then(() => void 0, () => void 0);
69
+ });
70
+ }
71
+ const FRESH_MS = 9e5;
72
+ const REPLY_WINDOW_MS = 216e5;
73
+ const MAX_REPLY_RECIPIENTS = 10;
74
+ /**
75
+ * Mail the people a fresh comment batch concerns. `accepted` is what
76
+ * appendEvents actually took (never the raw POST body); `log` is the board's
77
+ * full event list including them; `allowed` is the resolver's answer for THIS
78
+ * board (≥ view), injected so this stays pure of store plumbing. Recipients
79
+ * are canonical emails - by the time events land here the write boundary has
80
+ * already inverted the projection.
81
+ *
82
+ * Windows use SERVER time, never ev.ts - a client-datable timestamp must not
83
+ * pick its own throttle bucket. The board is in every key because comment ids
84
+ * are only board-local.
85
+ */
86
+ function notifyCommentActivity(ctx, board, accepted, log, allowed) {
87
+ if (!ctx.enabled || !ctx.issuer || !ctx.origin) return;
88
+ for (const [eventId, job] of activityJobs(board, accepted, log, allowed, Date.now())) relayNotify(ctx, job.template, job.recipient, eventId, {
89
+ actor: job.actor,
90
+ snippet: job.snippet,
91
+ link: job.link
92
+ });
93
+ }
94
+ /** The pure half: which (template, recipient, transition) a batch owes. Split
95
+ * out so the fan-out rules are testable without a relay or a signing key. */
96
+ function activityJobs(board, accepted, log, allowed, now) {
97
+ const jobs = /* @__PURE__ */ new Map();
98
+ const threadLink = (threadId) => threadId ? `/#/b/${encodeURIComponent(board)}?c=${encodeURIComponent(threadId)}` : void 0;
99
+ for (const ev of accepted) {
100
+ if (ev.type !== "create" && ev.type !== "reply" || Math.abs(now - ev.ts) > FRESH_MS) continue;
101
+ const author = ev.author?.email?.toLowerCase();
102
+ const mentioned = /* @__PURE__ */ new Set();
103
+ for (const m of ev.mentions ?? []) {
104
+ const email = m.email?.toLowerCase();
105
+ if (!email || email === author || !allowed(email)) continue;
106
+ mentioned.add(email);
107
+ jobs.set(transitionId("mention", email, board, ev.commentId ?? ev.id), {
108
+ template: "mentioned",
109
+ recipient: email,
110
+ actor: ev.author?.name,
111
+ snippet: ev.body,
112
+ link: threadLink(ev.type === "reply" ? ev.parentId : ev.commentId)
113
+ });
114
+ }
115
+ if (ev.type !== "reply" || !ev.parentId) continue;
116
+ const recent = [];
117
+ for (let i = log.length - 1; i >= 0 && recent.length < MAX_REPLY_RECIPIENTS; i--) {
118
+ const p = log[i];
119
+ if (p.id === ev.id) continue;
120
+ const email = (p.type === "reply" ? p.parentId === ev.parentId : p.type === "create" && p.commentId === ev.parentId) ? p.author?.email?.toLowerCase() : void 0;
121
+ if (email && email !== author && !mentioned.has(email) && !recent.includes(email)) recent.push(email);
122
+ }
123
+ const window = String(Math.floor(now / REPLY_WINDOW_MS));
124
+ for (const email of recent) {
125
+ if (!allowed(email)) continue;
126
+ jobs.set(transitionId("reply", email, board, ev.parentId, window), {
127
+ template: "reply",
128
+ recipient: email,
129
+ actor: ev.author?.name,
130
+ snippet: ev.body,
131
+ link: threadLink(ev.parentId)
132
+ });
133
+ }
42
134
  }
43
- fetch(`${ctx.issuer.replace(/\/+$/, "")}/relay/notify`, {
44
- method: "POST",
45
- headers: { authorization: `Bearer ${token}` },
46
- signal: AbortSignal.timeout(5e3)
47
- }).catch(() => {});
135
+ return jobs;
48
136
  }
49
137
  //#endregion
50
138
  //#region src/server/collab.ts
@@ -97,15 +185,41 @@ function validAvatar(s) {
97
185
  webp: (b) => b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP"
98
186
  }[m[1]]?.(head) ?? false;
99
187
  }
188
+ const EMAIL_SHAPE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
189
+ const OPAQUE_ID = /^[0-9a-f]{24}$/;
190
+ /** Mention shape rules (07-v1.1 §B). One generic refusal for every violation -
191
+ * no per-cause oracle. The browser form carries the opaque `id` and NEVER an
192
+ * email (a browser-supplied email is a membership probe); the operator form
193
+ * (CLI sync - canonical bytes) carries `email` and never an id. */
194
+ function badMentions(ev, operator) {
195
+ if (ev.mentions === void 0) return false;
196
+ if (ev.type !== "create" && ev.type !== "reply") return true;
197
+ if (!Array.isArray(ev.mentions) || ev.mentions.length > 8) return true;
198
+ const seen = /* @__PURE__ */ new Set();
199
+ for (const m of ev.mentions) {
200
+ if (!m || typeof m !== "object" || Object.keys(m).some((k) => k !== "id" && k !== "email" && k !== "label")) return true;
201
+ if (typeof m.label !== "string" || !m.label.trim() || m.label.length > 80 || /^@?marver\b/i.test(m.label.trim())) return true;
202
+ const who = operator ? m.email : m.id;
203
+ if (operator ? typeof m.email !== "string" || !EMAIL_SHAPE.test(m.email) || m.email.length > 254 || m.id !== void 0 : typeof m.id !== "string" || !OPAQUE_ID.test(m.id) || m.email !== void 0) return true;
204
+ const key = who.toLowerCase();
205
+ if (seen.has(key)) return true;
206
+ seen.add(key);
207
+ }
208
+ return false;
209
+ }
100
210
  /** Reject anything the log must not absorb. Returns an error string or null.
101
211
  * Events are append-only and synced by id, so acceptance is forever - validate hard. */
102
- function validateEvents(incoming, log, u, board) {
212
+ function validateEvents(incoming, log, u, board, opts = {}) {
103
213
  const now = Date.now();
104
214
  const creates = /* @__PURE__ */ new Map();
215
+ const comments = /* @__PURE__ */ new Set();
105
216
  const authors = /* @__PURE__ */ new Map();
106
217
  for (const ev of log) {
107
218
  if (ev.type === "create" && ev.commentId && !creates.has(ev.commentId)) creates.set(ev.commentId, ev);
108
- if ((ev.type === "create" || ev.type === "reply") && ev.commentId && ev.author?.email && !authors.has(ev.commentId)) authors.set(ev.commentId, ev.author.email.toLowerCase());
219
+ if ((ev.type === "create" || ev.type === "reply") && ev.commentId) {
220
+ comments.add(ev.commentId);
221
+ if (ev.author?.email && !authors.has(ev.commentId)) authors.set(ev.commentId, ev.author.email.toLowerCase());
222
+ }
109
223
  }
110
224
  const me = u.email.toLowerCase();
111
225
  for (const ev of incoming) {
@@ -117,7 +231,9 @@ function validateEvents(incoming, log, u, board) {
117
231
  if (typeof ev.ts !== "number" || ev.ts < 15778368e5 || ev.ts > now + 6e4) return "event timestamp out of bounds";
118
232
  if (ev.board !== void 0 && ev.board !== board) return "event board does not match the endpoint";
119
233
  if (ev.body !== void 0 && (typeof ev.body !== "string" || ev.body.length > MAX_BODY_TEXT)) return "body too long";
120
- if ((ev.type === "create" || ev.type === "reply" || ev.type === "react" || ev.type === "edit" || ev.type === "reanchor") && ev.author?.email?.toLowerCase() !== me) return "event author must be the signed-in account";
234
+ if ((ev.type === "create" || ev.type === "reply") && !ev.body?.trim()) return "a comment needs a body";
235
+ if (badMentions(ev, !!opts.operator)) return "invalid mentions";
236
+ if ((ev.type === "create" || ev.type === "reply" || ev.type === "react" || ev.type === "edit" || ev.type === "reanchor" || ev.type === "profile") && ev.author?.email?.toLowerCase() !== me) return "event author must be the signed-in account";
121
237
  if (typeof ev.commentId !== "string" || !ID_RE.test(ev.commentId)) {
122
238
  if (ev.type !== "profile") return "event needs a commentId";
123
239
  }
@@ -125,10 +241,13 @@ function validateEvents(incoming, log, u, board) {
125
241
  case "create":
126
242
  if (creates.has(ev.commentId)) return "a thread with that id already exists";
127
243
  creates.set(ev.commentId, ev);
244
+ comments.add(ev.commentId);
128
245
  if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
129
246
  break;
130
247
  case "reply":
131
248
  if (typeof ev.parentId !== "string" || !creates.has(ev.parentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.parentId)) return "reply parent does not exist";
249
+ if (comments.has(ev.commentId)) return "a comment with that id already exists";
250
+ comments.add(ev.commentId);
132
251
  if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
133
252
  break;
134
253
  case "edit": {
@@ -238,15 +357,67 @@ function collabHandler(dataDir, distDir) {
238
357
  return hit;
239
358
  };
240
359
  const project = (ev) => {
241
- if (!ev.author?.email) return ev;
242
- const { email, ...rest } = ev.author;
243
- return {
244
- ...ev,
245
- author: {
246
- ...rest,
247
- id: opaqueId(dataDir, email)
248
- }
360
+ let out = ev;
361
+ if (ev.author?.email) {
362
+ const { email, ...rest } = ev.author;
363
+ out = {
364
+ ...out,
365
+ author: {
366
+ ...rest,
367
+ id: opaqueId(dataDir, email)
368
+ }
369
+ };
370
+ }
371
+ if (ev.mentions !== void 0) {
372
+ const safe = Array.isArray(ev.mentions) ? ev.mentions.flatMap((m) => {
373
+ if (!m || typeof m !== "object") return [];
374
+ const label = typeof m.label === "string" ? m.label : "";
375
+ if (typeof m.email === "string") return [{
376
+ id: opaqueId(dataDir, m.email),
377
+ label
378
+ }];
379
+ if (typeof m.id === "string") return [{
380
+ id: m.id,
381
+ label
382
+ }];
383
+ return [];
384
+ }) : [];
385
+ out = {
386
+ ...out,
387
+ mentions: safe.length ? safe : void 0
388
+ };
389
+ }
390
+ return out;
391
+ };
392
+ /** Every opaque id the canvas can resolve back to an address: accounts, plus
393
+ * every author and mention across the boards' logs. Broader than any one
394
+ * composer can see, and safely so - ids are HMACs under the canvas secret,
395
+ * so a valid id for an unseen person cannot be manufactured to probe with. */
396
+ const mentionTargets = () => {
397
+ const map = /* @__PURE__ */ new Map();
398
+ const add = (email) => {
399
+ if (email) map.set(opaqueId(dataDir, email), normEmail(email));
249
400
  };
401
+ for (const usr of loadStore(dataDir).users) add(usr.email);
402
+ for (const st of threads.values()) for (const ev of st.events) {
403
+ add(ev.author?.email);
404
+ for (const mn of ev.mentions ?? []) add(mn.email);
405
+ }
406
+ return map;
407
+ };
408
+ /** May this address still read this board - the activity-mail eligibility
409
+ * filter, asked at enqueue time (delivery cannot re-check; 07-v1.1 §A). */
410
+ const mailAllowed = (board, email) => {
411
+ const store = shareState(dataDir);
412
+ if (!store) return true;
413
+ const owner = loadStore(dataDir).users.find((x) => x.role === "owner");
414
+ const userRole = owner && normEmail(owner.email) === normEmail(email) ? "owner" : "member";
415
+ return (resolveAccess({
416
+ email,
417
+ userRole,
418
+ store,
419
+ ceilings
420
+ }).boards[board] ?? "none") !== "none";
250
421
  };
251
422
  const rawTransport = (req) => {
252
423
  const tok = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
@@ -761,14 +932,32 @@ function collabHandler(dataDir, distDir) {
761
932
  const b = await readBody(req);
762
933
  const incoming = Array.isArray(b.events) ? b.events : [];
763
934
  if (incoming.length > 100) return json(res, 400, { error: "too many events in one push" }), true;
764
- const bad = validateEvents(incoming, readLog(commentsDir, m[1]), u, m[1]);
935
+ const operator = rawTransport(req);
936
+ const bad = validateEvents(incoming, readLog(commentsDir, m[1]), u, m[1], { operator });
765
937
  if (bad) return json(res, 400, { error: bad }), true;
938
+ if (!operator && incoming.some((ev) => ev.mentions?.length)) {
939
+ const targets = mentionTargets();
940
+ for (const ev of incoming) {
941
+ if (!ev.mentions?.length) continue;
942
+ const mapped = [];
943
+ for (const mn of ev.mentions) {
944
+ const email = targets.get(mn.id);
945
+ if (!email || email === normEmail(u.email)) return json(res, 400, { error: "invalid mentions" }), true;
946
+ mapped.push({
947
+ email,
948
+ label: mn.label
949
+ });
950
+ }
951
+ ev.mentions = mapped;
952
+ }
953
+ }
766
954
  const fresh = appendEvents(commentsDir, m[1], incoming);
767
955
  if (fresh.length) {
768
956
  const st = threads.get(m[1]);
769
957
  threads.set(m[1], deriveThreads([...st?.events ?? [], ...fresh]));
770
958
  }
771
959
  broadcast(m[1], fresh.map(project));
960
+ if (fresh.length) notifyCommentActivity(notifyCtx, m[1], fresh, threads.get(m[1])?.events ?? [], (email) => mailAllowed(m[1], email));
772
961
  return json(res, 200, { accepted: fresh.length }), true;
773
962
  }
774
963
  return false;
@@ -1,8 +1,8 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { n as replay } from "./events-C9k-ceOj.mjs";
2
+ import { n as replay } from "./events-B3LBn74P.mjs";
3
3
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.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";
4
+ import { a as syncOnce, i as loadCollab, n as connectClaim, r as connectToken, t as connect } from "./sync-BZaCWqK-.mjs";
5
+ import { n as localProfile } from "./profile-BjAPAJSb.mjs";
6
6
  import { join } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { randomUUID } from "node:crypto";
@@ -1,6 +1,6 @@
1
- import { n as replay } from "./events-C9k-ceOj.mjs";
1
+ import { n as replay } from "./events-B3LBn74P.mjs";
2
2
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
3
- import { n as localProfile } from "./profile-ChpTPd-X.mjs";
3
+ import { n as localProfile } from "./profile-BjAPAJSb.mjs";
4
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";
@@ -1,6 +1,6 @@
1
1
  import { n as NAME, r as PKG } from "./cli.mjs";
2
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";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DNc4Jpae.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("./plugin-Boy7I_JH.mjs").then((n) => n.i);
250
+ const { installedVersion } = await import("./plugin-DNc4Jpae.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-WYxDN9IT.mjs").then((n) => n.o);
258
+ const { loadCollab, syncOnce } = await import("./sync-BZaCWqK-.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-WjonKlPz.mjs");
288
+ const { startJam } = await import("./daemon-BChkzDqQ.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);
@@ -26,6 +26,7 @@ function replay(events) {
26
26
  resolved: false,
27
27
  agent: ev.agent,
28
28
  agentMeta: ev.agentMeta,
29
+ mentions: ev.mentions,
29
30
  replies: [],
30
31
  reactions: {}
31
32
  });
@@ -40,7 +41,8 @@ function replay(events) {
40
41
  body: ev.body,
41
42
  ts: ev.ts,
42
43
  agent: ev.agent,
43
- agentMeta: ev.agentMeta
44
+ agentMeta: ev.agentMeta,
45
+ mentions: ev.mentions
44
46
  });
45
47
  break;
46
48
  }
@@ -1,6 +1,6 @@
1
1
  import { i as avatarSourceFor, n as attachAvatar, p as provisionFromMarverId } from "./auth-DdUKeVKt.mjs";
2
2
  import { TransactionStore, browserBinding, verifyAssertion } from "./marver-id-CVCoaMGI.mjs";
3
- import { t as poweredByUrl } from "./serve-BeLLizNg.mjs";
3
+ import { t as poweredByUrl } from "./serve-EjEqsiYa.mjs";
4
4
  import { randomBytes } from "node:crypto";
5
5
  import { isIP } from "node:net";
6
6
  import { lookup } from "node:dns/promises";
@@ -422,7 +422,7 @@ function marverIdHandler(dir, issuer, canvasName, branding = true, ceilings = {}
422
422
  const { shareState, provisionVerdict } = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);
423
423
  const share = shareState(dir);
424
424
  if (!(share ? provisionVerdict(share, result.identity.email) === "blocked" : false)) {
425
- const { signCanvasJws } = await import("./summary-Bf9tiGrE.mjs");
425
+ const { signCanvasJws } = await import("./summary-C7CAypbB.mjs");
426
426
  const now = Math.floor(Date.now() / 1e3);
427
427
  request = signCanvasJws(dir, {
428
428
  aud: origin,
@@ -545,9 +545,12 @@ function finishPage(canvasName, host, switchUrl, branding = true) {
545
545
  p.lead { margin: 0; padding: 0 4px; font-size: 12.5px; line-height: 1.5;
546
546
  color: rgba(24,24,27,.66) }
547
547
  p.lead strong { color: #18181b; font-weight: 600 }
548
- a.cta { height: 40px; border-radius: 999px; background: #18181b; color: #fafafa;
549
- font: 600 13px -apple-system, system-ui, sans-serif; text-decoration: none;
550
- display: flex; align-items: center; justify-content: center }
548
+ /* .cta, not a.cta: the request-access <button> wears the same dark pill as the
549
+ switch-account <a> - scoping to the tag left the button naked (a flat native
550
+ strip that read as disabled) */
551
+ .cta { height: 40px; border-radius: 999px; background: #18181b; color: #fafafa;
552
+ font: 600 13px -apple-system, system-ui, sans-serif; text-decoration: none;
553
+ display: flex; align-items: center; justify-content: center }
551
554
  footer a { display: inline-flex; align-items: center; gap: 7px;
552
555
  font: 600 12.5px -apple-system, system-ui, sans-serif;
553
556
  color: rgba(24,24,27,.5); text-decoration: none }
@@ -1,6 +1,6 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
3
- import { n as localProfile, t as isConnected } from "./profile-ChpTPd-X.mjs";
3
+ import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
4
4
  import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-CS6krOTe.mjs";
5
5
  import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join, resolve, sep } from "node:path";
@@ -306,7 +306,7 @@ function apiMiddleware(root, opts = {}) {
306
306
  }
307
307
  const dir = join(root, "design", ".local");
308
308
  mkdirSync(dir, { recursive: true });
309
- const { validAvatar } = await import("./collab-BmaOjwP1.mjs");
309
+ const { validAvatar } = await import("./collab-mAiNZlDj.mjs");
310
310
  let cur = {};
311
311
  try {
312
312
  cur = JSON.parse(readFileSync(join(dir, "profile.json"), "utf8"));
@@ -397,7 +397,7 @@ function apiMiddleware(root, opts = {}) {
397
397
  }
398
398
  }
399
399
  if (path === "sync" && req.method === "POST") {
400
- const { loadCollab, syncOnce } = await import("./sync-WYxDN9IT.mjs").then((n) => n.o);
400
+ const { loadCollab, syncOnce } = await import("./sync-BZaCWqK-.mjs").then((n) => n.o);
401
401
  const collab = loadCollab(root);
402
402
  if (!collab) return json(res, 200, { connected: false });
403
403
  try {
@@ -426,7 +426,7 @@ function backgroundPush(root) {
426
426
  pushTimer = setTimeout(async () => {
427
427
  pushTimer = null;
428
428
  try {
429
- const { loadCollab, syncOnce } = await import("./sync-WYxDN9IT.mjs").then((n) => n.o);
429
+ const { loadCollab, syncOnce } = await import("./sync-BZaCWqK-.mjs").then((n) => n.o);
430
430
  const collab = loadCollab(root);
431
431
  if (collab) await syncOnce(root, collab);
432
432
  } catch {}
@@ -1,4 +1,4 @@
1
- import { i as loadCollab } from "./sync-WYxDN9IT.mjs";
1
+ import { i as loadCollab } from "./sync-BZaCWqK-.mjs";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  //#region src/server/profile.ts
@@ -74,7 +74,7 @@ async function serve(root, portFlag) {
74
74
  * accounts get in. Null when there is no roster to consult. */
75
75
  let guestDoorOpen = null;
76
76
  if (process.env.MARVER_DATA_DIR) {
77
- const { collabHandler } = await import("./collab-BmaOjwP1.mjs");
77
+ const { collabHandler } = await import("./collab-mAiNZlDj.mjs");
78
78
  const { appendEvents, readLog } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
79
79
  const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
80
80
  const dir = dataDir();
@@ -146,7 +146,7 @@ async function serve(root, portFlag) {
146
146
  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)`);
147
147
  process.exit(1);
148
148
  }
149
- const { marverIdHandler } = await import("./marver-id-gate-DOjnZakC.mjs");
149
+ const { marverIdHandler } = await import("./marver-id-gate-B2uraTHS.mjs");
150
150
  const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
151
151
  const canvasName = humanName(meta.name);
152
152
  const { ceilingsFromRights } = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);