@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 +51 -0
- package/dist/{build-DmfxJc6L.mjs → build-BxGrHFT2.mjs} +1 -1
- package/dist/cli.mjs +5 -5
- package/dist/{collab-BmaOjwP1.mjs → collab-mAiNZlDj.mjs} +232 -43
- package/dist/{comments-pNxjTrGN.mjs → comments-DHB_8BRa.mjs} +3 -3
- package/dist/{daemon-WjonKlPz.mjs → daemon-BChkzDqQ.mjs} +2 -2
- package/dist/{dev-BXq7yE1p.mjs → dev-DLwt3Brb.mjs} +4 -4
- package/dist/{events-C9k-ceOj.mjs → events-B3LBn74P.mjs} +3 -1
- package/dist/{marver-id-gate-DOjnZakC.mjs → marver-id-gate-B2uraTHS.mjs} +8 -5
- package/dist/{plugin-Boy7I_JH.mjs → plugin-DNc4Jpae.mjs} +4 -4
- package/dist/{profile-ChpTPd-X.mjs → profile-BjAPAJSb.mjs} +1 -1
- package/dist/{serve-BeLLizNg.mjs → serve-EjEqsiYa.mjs} +2 -2
- package/dist/{share-C64b1tdN.mjs → share-Gqo_Ygqw.mjs} +14 -15
- package/dist/{summary-Bf9tiGrE.mjs → summary-C7CAypbB.mjs} +1 -1
- package/dist/{sync-WYxDN9IT.mjs → sync-BZaCWqK-.mjs} +1 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +19 -3
- package/src/client/shell/Comments.tsx +94 -17
- package/src/client/shell/comments-store.ts +59 -4
- package/src/client/shell/mentions.ts +108 -1
- package/src/client/shell/sound.ts +32 -0
- package/src/client/shell/store.ts +12 -4
- package/src/client/shell/styles.css +41 -0
- package/src/shared/events.ts +12 -3
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* sends
|
|
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
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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-
|
|
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-
|
|
5
|
-
import { n as localProfile } from "./profile-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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 {}
|
|
@@ -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-
|
|
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-
|
|
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);
|