@marver-design/marver 0.11.1 → 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 +111 -0
- package/README.md +1 -1
- package/dist/auth-DdUKeVKt.mjs +1150 -0
- package/dist/{build-DI31MFTB.mjs → build-BxGrHFT2.mjs} +188 -33
- package/dist/cli.mjs +16 -7
- package/dist/collab-mAiNZlDj.mjs +970 -0
- package/dist/{comments-CRqaO2MH.mjs → comments-DHB_8BRa.mjs} +3 -2
- package/dist/{daemon-DSmaS453.mjs → daemon-BChkzDqQ.mjs} +3 -3
- package/dist/{dev-BWIL2ewj.mjs → dev-DLwt3Brb.mjs} +5 -5
- package/dist/events-B3LBn74P.mjs +103 -0
- package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
- package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
- package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
- package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-B2uraTHS.mjs} +100 -9
- package/dist/{plugin-DjBFxEQ7.mjs → plugin-DNc4Jpae.mjs} +82 -9
- package/dist/{profile-DcsJyppw.mjs → profile-BjAPAJSb.mjs} +1 -1
- package/dist/{serve-3APqaNSB.mjs → serve-EjEqsiYa.mjs} +70 -17
- package/dist/share-Gqo_Ygqw.mjs +158 -0
- package/dist/summary-C7CAypbB.mjs +211 -0
- package/dist/{sync-DELGomPk.mjs → sync-BZaCWqK-.mjs} +3 -102
- package/package.json +1 -1
- package/src/client/shell/App.tsx +62 -11
- package/src/client/shell/Comments.tsx +103 -22
- package/src/client/shell/LockedApp.tsx +113 -0
- package/src/client/shell/Play.tsx +97 -18
- package/src/client/shell/Toolbar.tsx +26 -2
- package/src/client/shell/canvas/Canvas.tsx +5 -15
- package/src/client/shell/canvas/ctl.ts +26 -0
- package/src/client/shell/comments-store.ts +96 -11
- package/src/client/shell/hash.ts +19 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/mentions.ts +108 -1
- package/src/client/shell/sound.ts +32 -0
- package/src/client/shell/store.ts +66 -7
- package/src/client/shell/styles.css +68 -0
- package/src/shared/events.ts +20 -6
- package/dist/auth-B5yuwnOq.mjs +0 -494
- package/dist/collab-CWaG3Q4w.mjs +0 -340
- package/dist/manifest-C2tzkNaC.mjs +0 -233
- package/dist/update-DuWDj5nR.mjs +0 -77
- package/dist/utm-CxC3QN5X.mjs +0 -20
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
|
|
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-C7CAypbB.mjs";
|
|
4
|
+
import { t as secureSuffix } from "./secure-cookie-_K1Hsx8H.mjs";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
//#region src/server/notify.ts
|
|
9
|
+
/**
|
|
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).
|
|
12
|
+
*
|
|
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.
|
|
19
|
+
*
|
|
20
|
+
* Fire-and-forget, always: a notification that failed must never be the reason
|
|
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.
|
|
31
|
+
*/
|
|
32
|
+
const transitionId = (...parts) => createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 32);
|
|
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) {
|
|
39
|
+
if (!ctx.enabled || !ctx.issuer || !ctx.origin) return;
|
|
40
|
+
if (recipient.startsWith("@") || !recipient.includes("@")) 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
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return jobs;
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/server/collab.ts
|
|
139
|
+
const MONTH = 2592e3;
|
|
140
|
+
const MAX_BODY = 262144;
|
|
141
|
+
/** The permission seam, per-person since the sharing release: `read` stays
|
|
142
|
+
* board-level (is it published - no read-privacy claim in v1), `admin` stays
|
|
143
|
+
* the owner role, and `comment` (which resolve/reopen ride, 01-sharing §7.4)
|
|
144
|
+
* asks the resolver - blocklist, grants, expiry, ceiling - freshly on every
|
|
145
|
+
* call, so a revocation or an expiry crossing bites on the next request.
|
|
146
|
+
* `dataDir` is what connects the seam to the roster; without a roster the
|
|
147
|
+
* legacy rule (any signed-in user on a comment board) is exactly what runs. */
|
|
148
|
+
function can(user, rights, board, action, dataDir) {
|
|
149
|
+
if (action === "read") return board in rights;
|
|
150
|
+
if (action === "admin") return user?.role === "owner";
|
|
151
|
+
if (!user || rights[board] !== "comment") return false;
|
|
152
|
+
if (!dataDir) return true;
|
|
153
|
+
return commentAllowed(dataDir, user, board, ceilingsFromRights(rights));
|
|
154
|
+
}
|
|
155
|
+
const EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
156
|
+
"create",
|
|
157
|
+
"reply",
|
|
158
|
+
"edit",
|
|
159
|
+
"resolve",
|
|
160
|
+
"reopen",
|
|
161
|
+
"react",
|
|
162
|
+
"profile",
|
|
163
|
+
"reanchor"
|
|
164
|
+
]);
|
|
165
|
+
const ID_RE = /^[\w-]{8,64}$/;
|
|
166
|
+
const MAX_BODY_TEXT = 1e4;
|
|
167
|
+
/** An avatar the server will store: a raster data-URI whose ACTUAL decoded bytes
|
|
168
|
+
* match the declared type. The declared MIME alone is a lie an attacker controls
|
|
169
|
+
* (base64 SVG labelled image/png); the magic bytes are the truth. Capped small -
|
|
170
|
+
* the client downscales to a 128px jpeg (~15KB), 64KB is generous headroom. */
|
|
171
|
+
function validAvatar(s) {
|
|
172
|
+
if (typeof s !== "string" || s.length > 65536) return false;
|
|
173
|
+
const m = /^data:image\/(jpeg|png|webp|gif);base64,([A-Za-z0-9+/]+={0,2})$/.exec(s);
|
|
174
|
+
if (!m) return false;
|
|
175
|
+
let head;
|
|
176
|
+
try {
|
|
177
|
+
head = Buffer.from(m[2].slice(0, 24), "base64");
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
jpeg: (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
|
|
183
|
+
png: (b) => b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71,
|
|
184
|
+
gif: (b) => b.toString("latin1", 0, 4) === "GIF8",
|
|
185
|
+
webp: (b) => b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP"
|
|
186
|
+
}[m[1]]?.(head) ?? false;
|
|
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
|
+
}
|
|
210
|
+
/** Reject anything the log must not absorb. Returns an error string or null.
|
|
211
|
+
* Events are append-only and synced by id, so acceptance is forever - validate hard. */
|
|
212
|
+
function validateEvents(incoming, log, u, board, opts = {}) {
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
const creates = /* @__PURE__ */ new Map();
|
|
215
|
+
const comments = /* @__PURE__ */ new Set();
|
|
216
|
+
const authors = /* @__PURE__ */ new Map();
|
|
217
|
+
for (const ev of log) {
|
|
218
|
+
if (ev.type === "create" && ev.commentId && !creates.has(ev.commentId)) creates.set(ev.commentId, ev);
|
|
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
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const me = u.email.toLowerCase();
|
|
225
|
+
for (const ev of incoming) {
|
|
226
|
+
if (!ev || typeof ev !== "object") return "malformed event";
|
|
227
|
+
if (typeof ev.id !== "string" || !ID_RE.test(ev.id)) return "event id must be an 8-64 char token";
|
|
228
|
+
if (!EVENT_TYPES.has(ev.type)) return `unknown event type "${ev.type}"`;
|
|
229
|
+
if (ev.agent !== void 0 || ev.agentMeta !== void 0) return "agent provenance is daemon-only";
|
|
230
|
+
if (ev.author && ev.author.id !== void 0) return "author id is server-assigned";
|
|
231
|
+
if (typeof ev.ts !== "number" || ev.ts < 15778368e5 || ev.ts > now + 6e4) return "event timestamp out of bounds";
|
|
232
|
+
if (ev.board !== void 0 && ev.board !== board) return "event board does not match the endpoint";
|
|
233
|
+
if (ev.body !== void 0 && (typeof ev.body !== "string" || ev.body.length > MAX_BODY_TEXT)) return "body too long";
|
|
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";
|
|
237
|
+
if (typeof ev.commentId !== "string" || !ID_RE.test(ev.commentId)) {
|
|
238
|
+
if (ev.type !== "profile") return "event needs a commentId";
|
|
239
|
+
}
|
|
240
|
+
switch (ev.type) {
|
|
241
|
+
case "create":
|
|
242
|
+
if (creates.has(ev.commentId)) return "a thread with that id already exists";
|
|
243
|
+
creates.set(ev.commentId, ev);
|
|
244
|
+
comments.add(ev.commentId);
|
|
245
|
+
if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
|
|
246
|
+
break;
|
|
247
|
+
case "reply":
|
|
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);
|
|
251
|
+
if (ev.author?.email) authors.set(ev.commentId, ev.author.email.toLowerCase());
|
|
252
|
+
break;
|
|
253
|
+
case "edit": {
|
|
254
|
+
const owner = authors.get(ev.commentId);
|
|
255
|
+
if (!owner) return "cannot edit a comment that does not exist";
|
|
256
|
+
if (owner !== me) return "only the author can edit a comment";
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case "resolve":
|
|
260
|
+
case "reopen":
|
|
261
|
+
if (!creates.has(ev.commentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.commentId)) return "cannot resolve a thread that does not exist";
|
|
262
|
+
break;
|
|
263
|
+
case "reanchor": {
|
|
264
|
+
if (!creates.has(ev.commentId) && !incoming.some((x) => x.type === "create" && x.commentId === ev.commentId)) return "cannot reanchor a thread that does not exist";
|
|
265
|
+
if (ev.anchor == null) return "reanchor needs a new anchor";
|
|
266
|
+
const owner = authors.get(ev.commentId);
|
|
267
|
+
if (owner && owner !== me) return "only the thread author can reanchor";
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
function collabHandler(dataDir, distDir) {
|
|
275
|
+
let rights = {};
|
|
276
|
+
let meta = {};
|
|
277
|
+
try {
|
|
278
|
+
meta = JSON.parse(readFileSync(join(distDir, "meta.json"), "utf8"));
|
|
279
|
+
rights = meta.rights ?? {};
|
|
280
|
+
} catch {}
|
|
281
|
+
const commentsDir = join(dataDir, "comments");
|
|
282
|
+
const idIssuer = (process.env.MARVER_ID_ISSUER ?? "").trim() || null;
|
|
283
|
+
const publicOrigin = (process.env.MARVER_PUBLIC_ORIGIN ?? "").trim() || null;
|
|
284
|
+
const appOrigin = (process.env.MARVER_APP_ORIGIN ?? "").trim() || "https://app.marver.design";
|
|
285
|
+
const frontDoorOn = !!idIssuer && !!publicOrigin && meta.frontDoor !== false;
|
|
286
|
+
const notifyCtx = {
|
|
287
|
+
dataDir,
|
|
288
|
+
issuer: idIssuer,
|
|
289
|
+
origin: publicOrigin,
|
|
290
|
+
enabled: meta.notify !== false
|
|
291
|
+
};
|
|
292
|
+
const threads = /* @__PURE__ */ new Map();
|
|
293
|
+
for (const b of Object.keys(rights)) threads.set(b, deriveThreads(readLog(commentsDir, b)));
|
|
294
|
+
const summaryJtis = /* @__PURE__ */ new Map();
|
|
295
|
+
const boardType = (b) => meta.boards?.[b]?.type ?? "mix";
|
|
296
|
+
const cors = (res) => {
|
|
297
|
+
res.setHeader("access-control-allow-origin", appOrigin);
|
|
298
|
+
res.setHeader("vary", "origin");
|
|
299
|
+
res.setHeader("access-control-allow-methods", "GET, OPTIONS");
|
|
300
|
+
res.setHeader("access-control-allow-headers", "authorization");
|
|
301
|
+
res.setHeader("access-control-max-age", "600");
|
|
302
|
+
};
|
|
303
|
+
const bootEpoch = randomBytes(4).toString("hex");
|
|
304
|
+
let seq = 0;
|
|
305
|
+
const ring = [];
|
|
306
|
+
const clients = /* @__PURE__ */ new Map();
|
|
307
|
+
const broadcast = (board, events) => {
|
|
308
|
+
for (const ev of events) {
|
|
309
|
+
const frame = `id: ${bootEpoch}-${++seq}\nevent: comment\ndata: ${JSON.stringify({
|
|
310
|
+
board,
|
|
311
|
+
ev
|
|
312
|
+
})}\n\n`;
|
|
313
|
+
ring.push({
|
|
314
|
+
seq,
|
|
315
|
+
data: frame
|
|
316
|
+
});
|
|
317
|
+
if (ring.length > 500) ring.shift();
|
|
318
|
+
for (const res of clients.keys()) res.write(frame);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
setInterval(() => {
|
|
322
|
+
for (const res of clients.keys()) res.write(": keepalive\n\n");
|
|
323
|
+
}, 24e4).unref();
|
|
324
|
+
const ceilings = ceilingsFromRights(rights);
|
|
325
|
+
const streamAllowed = (tok) => {
|
|
326
|
+
const store = shareState(dataDir);
|
|
327
|
+
if (!store) return true;
|
|
328
|
+
if (!tok) return store.general.mode !== "private";
|
|
329
|
+
const u = sessionUser(dataDir, tok);
|
|
330
|
+
return !!u && entryAllowed(dataDir, u, ceilings);
|
|
331
|
+
};
|
|
332
|
+
setInterval(() => {
|
|
333
|
+
for (const [res, c] of clients) {
|
|
334
|
+
if (streamAllowed(c.tok)) continue;
|
|
335
|
+
clients.delete(res);
|
|
336
|
+
try {
|
|
337
|
+
res.end();
|
|
338
|
+
} catch {}
|
|
339
|
+
}
|
|
340
|
+
}, 6e4).unref();
|
|
341
|
+
const attempts = /* @__PURE__ */ new Map();
|
|
342
|
+
const limited = (...keys) => {
|
|
343
|
+
const now = Date.now();
|
|
344
|
+
if (attempts.size > 1e4) {
|
|
345
|
+
for (const [k, a] of attempts) if (a.reset < now) attempts.delete(k);
|
|
346
|
+
}
|
|
347
|
+
if (attempts.size > 2e4) return true;
|
|
348
|
+
let hit = false;
|
|
349
|
+
for (const key of keys) {
|
|
350
|
+
const a = attempts.get(key);
|
|
351
|
+
if (!a || a.reset < now) attempts.set(key, {
|
|
352
|
+
n: 1,
|
|
353
|
+
reset: now + 6e4
|
|
354
|
+
});
|
|
355
|
+
else if (++a.n > 10) hit = true;
|
|
356
|
+
}
|
|
357
|
+
return hit;
|
|
358
|
+
};
|
|
359
|
+
const project = (ev) => {
|
|
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));
|
|
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";
|
|
421
|
+
};
|
|
422
|
+
const rawTransport = (req) => {
|
|
423
|
+
const tok = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
|
|
424
|
+
return tok ? sessionIsOperator(dataDir, tok) : false;
|
|
425
|
+
};
|
|
426
|
+
const cookie = (req, name) => new RegExp(`(?:^|;\\s*)${name}=([\\w-]+)`).exec(String(req.headers.cookie ?? ""))?.[1];
|
|
427
|
+
/** browser session cookie, or a bearer token (the dev proxy / agent CLI path -
|
|
428
|
+
* same session store, held server-side by the Vite process, never by a page) */
|
|
429
|
+
const sessionToken = (req) => /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? cookie(req, "mv_s") ?? null;
|
|
430
|
+
const currentUser = (req) => {
|
|
431
|
+
const tok = sessionToken(req);
|
|
432
|
+
return tok ? sessionUser(dataDir, tok) : null;
|
|
433
|
+
};
|
|
434
|
+
const json = (res, code, body) => {
|
|
435
|
+
res.statusCode = code;
|
|
436
|
+
res.setHeader("content-type", "application/json");
|
|
437
|
+
res.setHeader("cache-control", "no-store");
|
|
438
|
+
res.end(JSON.stringify(body));
|
|
439
|
+
};
|
|
440
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
441
|
+
if (!/^application\/json\b/.test(String(req.headers["content-type"] ?? ""))) return reject(/* @__PURE__ */ new Error("content-type must be application/json"));
|
|
442
|
+
let body = "";
|
|
443
|
+
let done = false;
|
|
444
|
+
const settle = (fn) => {
|
|
445
|
+
if (done) return;
|
|
446
|
+
done = true;
|
|
447
|
+
clearTimeout(timer);
|
|
448
|
+
fn();
|
|
449
|
+
};
|
|
450
|
+
const timer = setTimeout(() => settle(() => {
|
|
451
|
+
req.destroy();
|
|
452
|
+
reject(/* @__PURE__ */ new Error("body timed out"));
|
|
453
|
+
}), 1e4);
|
|
454
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
455
|
+
req.on("data", (c) => {
|
|
456
|
+
body += c;
|
|
457
|
+
if (body.length > MAX_BODY) settle(() => {
|
|
458
|
+
req.destroy();
|
|
459
|
+
reject(/* @__PURE__ */ new Error("body too large"));
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
req.on("end", () => settle(() => {
|
|
463
|
+
try {
|
|
464
|
+
resolve(body ? JSON.parse(body) : {});
|
|
465
|
+
} catch {
|
|
466
|
+
reject(/* @__PURE__ */ new Error("bad json"));
|
|
467
|
+
}
|
|
468
|
+
}));
|
|
469
|
+
req.on("error", () => settle(() => reject(/* @__PURE__ */ new Error("request failed"))));
|
|
470
|
+
});
|
|
471
|
+
const setSession = (req, res, token) => {
|
|
472
|
+
const secure = secureSuffix(req);
|
|
473
|
+
res.setHeader("set-cookie", [`mv_s=${token}; Path=/; Max-Age=${MONTH}; HttpOnly; SameSite=Lax${secure}`, `mv_c=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${MONTH}; SameSite=Lax${secure}`]);
|
|
474
|
+
};
|
|
475
|
+
const csrfOk = (req) => !!/^Bearer /.test(String(req.headers.authorization ?? "")) || !cookie(req, "mv_s") || !!cookie(req, "mv_c") && req.headers["x-mv-c"] === cookie(req, "mv_c");
|
|
476
|
+
/** Returns true when the request was handled. Mounted behind the gate by serve. */
|
|
477
|
+
return async (req, res, url) => {
|
|
478
|
+
if (!url.pathname.startsWith("/__mv/api/")) return false;
|
|
479
|
+
const path = url.pathname.slice(10);
|
|
480
|
+
const ip = process.env.MARVER_TRUSTED_PROXY ? String(req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "?").split(",")[0].trim() : String(req.socket.remoteAddress ?? "?");
|
|
481
|
+
try {
|
|
482
|
+
if (frontDoorOn && path === "summary" && req.method === "OPTIONS") {
|
|
483
|
+
cors(res);
|
|
484
|
+
res.statusCode = 204;
|
|
485
|
+
return res.end(), true;
|
|
486
|
+
}
|
|
487
|
+
if (frontDoorOn && path === "identity" && req.method === "GET") {
|
|
488
|
+
cors(res);
|
|
489
|
+
const id = canvasIdentity(dataDir);
|
|
490
|
+
return json(res, 200, {
|
|
491
|
+
kid: id.kid,
|
|
492
|
+
jwk: id.publicJwk
|
|
493
|
+
}), true;
|
|
494
|
+
}
|
|
495
|
+
if (frontDoorOn && path === "summary" && req.method === "GET") {
|
|
496
|
+
cors(res);
|
|
497
|
+
if (String(req.headers.cookie ?? "").trim()) return json(res, 401, { error: "not authorized" }), true;
|
|
498
|
+
if (limited(`sum:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
499
|
+
const tok = /^Bearer (\S+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? "";
|
|
500
|
+
const { verifyBearerJwt } = await import("./marver-id-CVCoaMGI.mjs");
|
|
501
|
+
const v = await verifyBearerJwt({
|
|
502
|
+
token: tok,
|
|
503
|
+
origin: publicOrigin,
|
|
504
|
+
issuer: idIssuer,
|
|
505
|
+
typ: "marver-summary+jwt",
|
|
506
|
+
azp: appOrigin,
|
|
507
|
+
maxAgeS: 120
|
|
508
|
+
});
|
|
509
|
+
if (!v.ok) return json(res, 401, { error: "not authorized" }), true;
|
|
510
|
+
const jti = typeof v.claims.jti === "string" ? v.claims.jti : "";
|
|
511
|
+
if (!jti) return json(res, 401, { error: "not authorized" }), true;
|
|
512
|
+
{
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
for (const [k, e] of summaryJtis) if (e < now) summaryJtis.delete(k);
|
|
515
|
+
if (summaryJtis.has(jti)) return json(res, 401, { error: "not authorized" }), true;
|
|
516
|
+
summaryJtis.set(jti, now + 15e4);
|
|
517
|
+
}
|
|
518
|
+
const share = shareState(dataDir);
|
|
519
|
+
const ceilings = ceilingsFromRights(rights);
|
|
520
|
+
const account = loadStore(dataDir).users.find((u) => normEmail(u.email) === normEmail(v.email));
|
|
521
|
+
const role = account?.role === "owner" ? "owner" : "member";
|
|
522
|
+
const resolution = share ? resolveAccess({
|
|
523
|
+
email: v.email,
|
|
524
|
+
userRole: account?.role,
|
|
525
|
+
store: share,
|
|
526
|
+
ceilings
|
|
527
|
+
}) : null;
|
|
528
|
+
if (!(share ? role === "owner" || resolution.entry : !!account)) return json(res, 404, { error: "not found" }), true;
|
|
529
|
+
const marks = loadSeen(dataDir)[normEmail(v.email)] ?? {};
|
|
530
|
+
let open = 0, unread = 0;
|
|
531
|
+
for (const [b, st] of threads) {
|
|
532
|
+
open += st.open.size;
|
|
533
|
+
unread += unreadCount(st, marks[b]);
|
|
534
|
+
}
|
|
535
|
+
const boardNames = Object.keys(rights);
|
|
536
|
+
const payload = {
|
|
537
|
+
v: 1,
|
|
538
|
+
name: meta.name ?? "Marver",
|
|
539
|
+
role: resolution?.role ?? (account ? "comment" : "view"),
|
|
540
|
+
type: boardType(boardNames[0] ?? ""),
|
|
541
|
+
boards: boardNames.map((b) => ({
|
|
542
|
+
name: b,
|
|
543
|
+
role: resolution?.boards[b] ?? (rights[b] === "comment" ? "comment" : "view"),
|
|
544
|
+
type: boardType(b)
|
|
545
|
+
})),
|
|
546
|
+
threads: {
|
|
547
|
+
open,
|
|
548
|
+
unread
|
|
549
|
+
},
|
|
550
|
+
owner: role === "owner",
|
|
551
|
+
...role === "owner" ? { people: loadStore(dataDir).users.length } : {},
|
|
552
|
+
kid: canvasIdentity(dataDir).kid
|
|
553
|
+
};
|
|
554
|
+
res.statusCode = 200;
|
|
555
|
+
res.setHeader("content-type", "application/jose");
|
|
556
|
+
res.setHeader("cache-control", "no-store");
|
|
557
|
+
return res.end(signCanvasJws(dataDir, payload)), true;
|
|
558
|
+
}
|
|
559
|
+
if (idIssuer && publicOrigin && path === "request-access" && req.method === "POST") {
|
|
560
|
+
if (limited(`req:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
561
|
+
const b = await readBody(req).catch(() => null);
|
|
562
|
+
const requestedRole = b?.requestedRole === "comment" ? "comment" : "view";
|
|
563
|
+
const note = typeof b?.note === "string" ? b.note.slice(0, 500) : void 0;
|
|
564
|
+
const share = shareState(dataDir);
|
|
565
|
+
const notBlocked = (email) => !share || provisionVerdict(share, email) !== "blocked";
|
|
566
|
+
const tok0 = /^Bearer (\S+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
|
|
567
|
+
const tok = tok0?.includes(".") ? tok0 : void 0;
|
|
568
|
+
const sessionCaller = tok || !b ? null : currentUser(req);
|
|
569
|
+
const tellOwner = (asker, put) => {
|
|
570
|
+
if (!put.fresh) return;
|
|
571
|
+
const owner = loadStore(dataDir).users.find((u) => u.role === "owner");
|
|
572
|
+
if (owner) relayNotify(notifyCtx, "access-requested", owner.email, transitionId("asked", asker, put.at));
|
|
573
|
+
};
|
|
574
|
+
if (b && tok) {
|
|
575
|
+
const v = verifyCanvasJws(dataDir, tok, "marver-reqaccess+jwt", publicOrigin);
|
|
576
|
+
if (v.ok && typeof v.claims.email === "string" && notBlocked(v.claims.email)) {
|
|
577
|
+
const put = putRequest(dataDir, {
|
|
578
|
+
email: v.claims.email,
|
|
579
|
+
name: typeof v.claims.name === "string" ? v.claims.name : void 0,
|
|
580
|
+
picture: typeof v.claims.picture === "string" ? v.claims.picture : void 0,
|
|
581
|
+
requestedRole,
|
|
582
|
+
target: typeof v.claims.target === "string" && v.claims.target ? v.claims.target.slice(0, 300) : void 0,
|
|
583
|
+
note
|
|
584
|
+
});
|
|
585
|
+
tellOwner(v.claims.email, put);
|
|
586
|
+
}
|
|
587
|
+
} else if (sessionCaller && csrfOk(req) && notBlocked(sessionCaller.email)) tellOwner(sessionCaller.email, putRequest(dataDir, {
|
|
588
|
+
email: sessionCaller.email,
|
|
589
|
+
name: sessionCaller.name,
|
|
590
|
+
requestedRole,
|
|
591
|
+
note
|
|
592
|
+
}));
|
|
593
|
+
return json(res, 202, { ok: true }), true;
|
|
594
|
+
}
|
|
595
|
+
if (path.startsWith("share/")) {
|
|
596
|
+
if (req.method === "OPTIONS") {
|
|
597
|
+
cors(res);
|
|
598
|
+
res.setHeader("access-control-allow-methods", "GET, PUT, POST, DELETE, OPTIONS");
|
|
599
|
+
res.setHeader("access-control-allow-headers", "authorization, content-type, x-mv-salt");
|
|
600
|
+
res.statusCode = 204;
|
|
601
|
+
return res.end(), true;
|
|
602
|
+
}
|
|
603
|
+
if (String(req.headers.cookie ?? "").trim()) {
|
|
604
|
+
cors(res);
|
|
605
|
+
return json(res, 403, { error: "owner only" }), true;
|
|
606
|
+
}
|
|
607
|
+
let bodyBuf;
|
|
608
|
+
try {
|
|
609
|
+
bodyBuf = req.method === "GET" ? Buffer.alloc(0) : await new Promise((resolve, reject) => {
|
|
610
|
+
const chunks = [];
|
|
611
|
+
let size = 0;
|
|
612
|
+
const onData = (c) => {
|
|
613
|
+
chunks.push(c);
|
|
614
|
+
size += c.length;
|
|
615
|
+
if (size > MAX_BODY) {
|
|
616
|
+
req.removeListener("data", onData);
|
|
617
|
+
req.pause();
|
|
618
|
+
reject(/* @__PURE__ */ new Error("body too large"));
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
req.on("data", onData);
|
|
622
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
623
|
+
req.on("error", () => reject(/* @__PURE__ */ new Error("request failed")));
|
|
624
|
+
});
|
|
625
|
+
} catch (err) {
|
|
626
|
+
cors(res);
|
|
627
|
+
res.setHeader("connection", "close");
|
|
628
|
+
return json(res, 422, { error: err.message }), true;
|
|
629
|
+
}
|
|
630
|
+
const body = bodyBuf.toString("utf8");
|
|
631
|
+
const tok = /^Bearer (\S+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? "";
|
|
632
|
+
const owner = loadStore(dataDir).users.find((u) => u.role === "owner");
|
|
633
|
+
let allowed = false;
|
|
634
|
+
const su = tok ? sessionUser(dataDir, tok) : null;
|
|
635
|
+
if (su && su.role === "owner") allowed = true;
|
|
636
|
+
if (!allowed && tok && idIssuer && publicOrigin) {
|
|
637
|
+
const { verifyBearerJwt } = await import("./marver-id-CVCoaMGI.mjs");
|
|
638
|
+
const v = await verifyBearerJwt({
|
|
639
|
+
token: tok,
|
|
640
|
+
origin: publicOrigin,
|
|
641
|
+
issuer: idIssuer,
|
|
642
|
+
typ: "marver-owner-api+jwt",
|
|
643
|
+
azp: appOrigin,
|
|
644
|
+
maxAgeS: 300
|
|
645
|
+
});
|
|
646
|
+
const subjectOk = owner?.idSubject ? owner.idSubject === `${idIssuer}#${v.ok ? v.sub : ""}` : true;
|
|
647
|
+
if (v.ok && owner && subjectOk && normEmail(v.email) === normEmail(owner.email)) {
|
|
648
|
+
if (req.method === "GET") allowed = true;
|
|
649
|
+
else {
|
|
650
|
+
const etag = rosterEtag(dataDir);
|
|
651
|
+
const claimedEtag = typeof v.claims.etag === "string" ? v.claims.etag : "";
|
|
652
|
+
if (claimedEtag && claimedEtag !== etag) {
|
|
653
|
+
cors(res);
|
|
654
|
+
return json(res, 409, { error: "roster changed - re-read and retry" }), true;
|
|
655
|
+
}
|
|
656
|
+
const salt = String(req.headers["x-mv-salt"] ?? "");
|
|
657
|
+
const bodyHash = createHash("sha256").update(bodyBuf).digest("hex");
|
|
658
|
+
const want = createHash("sha256").update(`${req.method}\n${url.pathname}\n${bodyHash}\n${etag}${salt ? `\n${salt}` : ""}`).digest("hex");
|
|
659
|
+
if (v.claims.digest === want) allowed = true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
cors(res);
|
|
664
|
+
if (!allowed) return json(res, 403, { error: "owner only" }), true;
|
|
665
|
+
const b = body ? (() => {
|
|
666
|
+
try {
|
|
667
|
+
return JSON.parse(body);
|
|
668
|
+
} catch {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
})() : {};
|
|
672
|
+
if (b === null) return json(res, 422, { error: "bad json" }), true;
|
|
673
|
+
const ceilings = ceilingsFromRights(rights);
|
|
674
|
+
const roster = () => {
|
|
675
|
+
const s = loadShare(dataDir);
|
|
676
|
+
const history = {};
|
|
677
|
+
const allAuthors = /* @__PURE__ */ new Set();
|
|
678
|
+
for (const [b, st] of threads) {
|
|
679
|
+
const authors = /* @__PURE__ */ new Set();
|
|
680
|
+
for (const e of st.events) if (e.author?.email) {
|
|
681
|
+
authors.add(e.author.email.toLowerCase());
|
|
682
|
+
allAuthors.add(e.author.email.toLowerCase());
|
|
683
|
+
}
|
|
684
|
+
if (authors.size) history[b] = authors.size;
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
general: s?.general ?? {
|
|
688
|
+
mode: "private",
|
|
689
|
+
role: "view"
|
|
690
|
+
},
|
|
691
|
+
blocked: s?.blocked ?? [],
|
|
692
|
+
grants: s?.grants ?? [],
|
|
693
|
+
requests: loadRequests(dataDir).map(({ exp, ...r }) => r),
|
|
694
|
+
etag: rosterEtag(dataDir),
|
|
695
|
+
ceilings,
|
|
696
|
+
history,
|
|
697
|
+
historyAuthors: allAuthors.size,
|
|
698
|
+
gate: idIssuer ? "identity" : process.env.MARVER_PASSWORD ? "password" : "open"
|
|
699
|
+
};
|
|
700
|
+
};
|
|
701
|
+
const withEtag = (r) => {
|
|
702
|
+
res.setHeader("etag", `"${r.etag}"`);
|
|
703
|
+
return r;
|
|
704
|
+
};
|
|
705
|
+
try {
|
|
706
|
+
const sub = path.slice(6);
|
|
707
|
+
if (sub === "roster" && req.method === "GET") return json(res, 200, withEtag(roster())), true;
|
|
708
|
+
if (sub === "explain" && req.method === "GET") {
|
|
709
|
+
const who = String(url.searchParams.get("who") ?? "");
|
|
710
|
+
if (!who) return json(res, 422, { error: "who is required" }), true;
|
|
711
|
+
const s = loadShare(dataDir);
|
|
712
|
+
if (!s) return json(res, 422, { error: "no roster yet" }), true;
|
|
713
|
+
const asEmail = who.startsWith("@") ? `probe-${randomBytes(8).toString("hex")}${who}` : who;
|
|
714
|
+
const account = loadStore(dataDir).users.find((u) => normEmail(u.email) === normEmail(asEmail));
|
|
715
|
+
const r = resolveAccess({
|
|
716
|
+
email: asEmail,
|
|
717
|
+
userRole: account?.role,
|
|
718
|
+
store: s,
|
|
719
|
+
ceilings
|
|
720
|
+
});
|
|
721
|
+
return json(res, 200, {
|
|
722
|
+
who,
|
|
723
|
+
entry: r.entry,
|
|
724
|
+
role: r.role,
|
|
725
|
+
boards: r.boards,
|
|
726
|
+
trace: r.trace
|
|
727
|
+
}), true;
|
|
728
|
+
}
|
|
729
|
+
if (sub === "grant" && req.method === "PUT") {
|
|
730
|
+
const g = upsertGrant(dataDir, ceilings, {
|
|
731
|
+
principal: String(b.principal ?? ""),
|
|
732
|
+
scope: b.scope === "canvas" ? "canvas" : String(b.scope ?? ""),
|
|
733
|
+
assigned: b.assigned === "comment" ? "comment" : "view",
|
|
734
|
+
expires: typeof b.expires === "string" ? b.expires : null,
|
|
735
|
+
by: owner?.email ?? "owner"
|
|
736
|
+
}, { identityMode: !!idIssuer });
|
|
737
|
+
if (g.changed) relayNotify(notifyCtx, "invited", g.principal, transitionId("invited", g.principal, g.assigned, g.at));
|
|
738
|
+
return json(res, 200, withEtag(roster())), true;
|
|
739
|
+
}
|
|
740
|
+
if (sub === "grant" && req.method === "DELETE") {
|
|
741
|
+
removeGrant(dataDir, String(b.principal ?? ""), b.scope === "canvas" ? "canvas" : String(b.scope ?? ""));
|
|
742
|
+
return json(res, 200, withEtag(roster())), true;
|
|
743
|
+
}
|
|
744
|
+
if (sub === "reconfirm" && req.method === "POST") {
|
|
745
|
+
reconfirmGrant(dataDir, ceilings, String(b.principal ?? ""), b.scope === "canvas" ? "canvas" : String(b.scope ?? ""), String(b.board ?? ""));
|
|
746
|
+
return json(res, 200, withEtag(roster())), true;
|
|
747
|
+
}
|
|
748
|
+
if (sub === "general" && req.method === "PUT") {
|
|
749
|
+
const mode = String(b.mode ?? "");
|
|
750
|
+
if (mode !== "private" && mode !== "password" && mode !== "public") return json(res, 422, { error: "mode must be private | password | public" }), true;
|
|
751
|
+
const operative = operativeMode(mode, {
|
|
752
|
+
password: !!process.env.MARVER_PASSWORD && !idIssuer,
|
|
753
|
+
issuer: !!idIssuer
|
|
754
|
+
});
|
|
755
|
+
setGeneralMode(dataDir, operative);
|
|
756
|
+
const r = withEtag(roster());
|
|
757
|
+
return json(res, 200, operative === mode ? r : {
|
|
758
|
+
...r,
|
|
759
|
+
clamped: {
|
|
760
|
+
asked: mode,
|
|
761
|
+
operative
|
|
762
|
+
}
|
|
763
|
+
}), true;
|
|
764
|
+
}
|
|
765
|
+
if (sub === "block" && (req.method === "PUT" || req.method === "DELETE")) {
|
|
766
|
+
const address = String(b.address ?? "");
|
|
767
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(address)) return json(res, 422, { error: "a valid address is required" }), true;
|
|
768
|
+
setBlocked(dataDir, address, req.method === "PUT");
|
|
769
|
+
return json(res, 200, withEtag(roster())), true;
|
|
770
|
+
}
|
|
771
|
+
const rm = /^request\/(.+)$/.exec(sub);
|
|
772
|
+
if (rm && req.method === "POST") {
|
|
773
|
+
const email = decodeURIComponent(rm[1]);
|
|
774
|
+
const approved = b.approve === true ? {
|
|
775
|
+
assigned: b.assigned === "comment" ? "comment" : "view",
|
|
776
|
+
by: owner?.email ?? "owner"
|
|
777
|
+
} : null;
|
|
778
|
+
if (!resolveRequest(dataDir, ceilings, email, approved)) return json(res, 422, { error: "no pending request for that address" }), true;
|
|
779
|
+
if (approved) relayNotify(notifyCtx, "request-approved", email, transitionId("approved", email, approved.assigned, (/* @__PURE__ */ new Date()).toISOString()));
|
|
780
|
+
return json(res, 200, withEtag(roster())), true;
|
|
781
|
+
}
|
|
782
|
+
} catch (err) {
|
|
783
|
+
return json(res, 422, { error: err.message }), true;
|
|
784
|
+
}
|
|
785
|
+
return json(res, 422, { error: "no such share route" }), true;
|
|
786
|
+
}
|
|
787
|
+
if (path === "seen" && req.method === "POST") {
|
|
788
|
+
if (!csrfOk(req)) return json(res, 403, { error: "missing or stale request token - reload the page" }), true;
|
|
789
|
+
const u = currentUser(req);
|
|
790
|
+
if (!u) return json(res, 401, { error: "sign in first" }), true;
|
|
791
|
+
const b = await readBody(req);
|
|
792
|
+
const board = String(b.board ?? "");
|
|
793
|
+
const st = threads.get(board);
|
|
794
|
+
if (!st) return json(res, 422, { error: "unknown board" }), true;
|
|
795
|
+
const latest = st.events[st.events.length - 1]?.id;
|
|
796
|
+
if (latest) markSeen(dataDir, normEmail(u.email), board, latest);
|
|
797
|
+
res.statusCode = 204;
|
|
798
|
+
return res.end(), true;
|
|
799
|
+
}
|
|
800
|
+
if (req.method === "GET") {
|
|
801
|
+
if (path === "me") {
|
|
802
|
+
const u = currentUser(req);
|
|
803
|
+
if (!u) return json(res, 401, { error: "signed out" }), true;
|
|
804
|
+
const share = shareState(dataDir);
|
|
805
|
+
const boards = share ? resolveAccess({
|
|
806
|
+
email: u.email,
|
|
807
|
+
userRole: u.role,
|
|
808
|
+
store: share,
|
|
809
|
+
ceilings: ceilingsFromRights(rights)
|
|
810
|
+
}).boards : Object.fromEntries(Object.entries(rights).map(([b, r]) => [b, r === "comment" ? "comment" : "view"]));
|
|
811
|
+
return json(res, 200, {
|
|
812
|
+
user: publicUser(u),
|
|
813
|
+
role: u.role,
|
|
814
|
+
id: opaqueId(dataDir, u.email),
|
|
815
|
+
boards
|
|
816
|
+
}), true;
|
|
817
|
+
}
|
|
818
|
+
if (path === "boards") {
|
|
819
|
+
const name = ownerName(dataDir);
|
|
820
|
+
return json(res, 200, {
|
|
821
|
+
rights,
|
|
822
|
+
stored: listBoards(commentsDir),
|
|
823
|
+
...name ? { owner: { name } } : {}
|
|
824
|
+
}), true;
|
|
825
|
+
}
|
|
826
|
+
const m = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
|
|
827
|
+
if (m) {
|
|
828
|
+
if (!can(currentUser(req), rights, m[1], "read")) return json(res, 404, { error: "no such board" }), true;
|
|
829
|
+
const events = readLog(commentsDir, m[1]);
|
|
830
|
+
return json(res, 200, { events: rawTransport(req) ? events : events.map(project) }), true;
|
|
831
|
+
}
|
|
832
|
+
if (path === "invite-info") {
|
|
833
|
+
if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
834
|
+
const info = inviteInfo(dataDir, String(url.searchParams.get("token") ?? ""));
|
|
835
|
+
return json(res, info ? 200 : 404, info ?? { error: "this invite link is invalid, expired, or already used" }), true;
|
|
836
|
+
}
|
|
837
|
+
if (path === "events") {
|
|
838
|
+
const tok = sessionToken(req);
|
|
839
|
+
if (!streamAllowed(tok)) return json(res, 401, { error: "not authorized" }), true;
|
|
840
|
+
res.statusCode = 200;
|
|
841
|
+
res.setHeader("content-type", "text/event-stream");
|
|
842
|
+
res.setHeader("cache-control", "no-store");
|
|
843
|
+
res.setHeader("x-accel-buffering", "no");
|
|
844
|
+
res.write(": connected\n\n");
|
|
845
|
+
const lastRaw = String(req.headers["last-event-id"] ?? "");
|
|
846
|
+
const lm = /^([0-9a-f]{8})-(\d+)$/.exec(lastRaw);
|
|
847
|
+
if (lastRaw) {
|
|
848
|
+
const last = lm && lm[1] === bootEpoch ? Number(lm[2]) : NaN;
|
|
849
|
+
if (!Number.isFinite(last)) res.write("event: resync\ndata: {}\n\n");
|
|
850
|
+
else {
|
|
851
|
+
const missed = ring.filter((r) => r.seq > last);
|
|
852
|
+
if (missed.length && missed[0].seq !== last + 1) res.write("event: resync\ndata: {}\n\n");
|
|
853
|
+
else for (const r of missed) res.write(r.data);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
clients.set(res, { tok });
|
|
857
|
+
req.on("close", () => clients.delete(res));
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
return false;
|
|
861
|
+
}
|
|
862
|
+
if (req.method !== "POST") return false;
|
|
863
|
+
if (!(path === "auth/claim" || path === "auth/signin") && !csrfOk(req)) return json(res, 403, { error: "missing or stale request token - reload the page" }), true;
|
|
864
|
+
if (path === "auth/claim") {
|
|
865
|
+
if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
866
|
+
const b = await readBody(req);
|
|
867
|
+
const { user, session } = claimInvite(dataDir, String(b.token ?? ""), {
|
|
868
|
+
password: String(b.password ?? ""),
|
|
869
|
+
name: String(b.name ?? ""),
|
|
870
|
+
avatar: validAvatar(b.avatar) ? b.avatar : void 0
|
|
871
|
+
}, ceilings);
|
|
872
|
+
setSession(req, res, session);
|
|
873
|
+
return json(res, 200, { user: publicUser(user) }), true;
|
|
874
|
+
}
|
|
875
|
+
if (path === "auth/signin") {
|
|
876
|
+
const b = await readBody(req);
|
|
877
|
+
const email = String(b.email ?? "").trim().toLowerCase();
|
|
878
|
+
if (limited(`ip:${ip}`, `em:${email}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
879
|
+
const hit = signIn(dataDir, email, String(b.password ?? ""), ceilings);
|
|
880
|
+
if (!hit) return json(res, 401, { error: "wrong email or password" }), true;
|
|
881
|
+
setSession(req, res, hit.session);
|
|
882
|
+
return json(res, 200, { user: publicUser(hit.user) }), true;
|
|
883
|
+
}
|
|
884
|
+
if (path === "auth/signout") {
|
|
885
|
+
const tok = cookie(req, "mv_s");
|
|
886
|
+
if (tok) signOut(dataDir, tok);
|
|
887
|
+
res.setHeader("set-cookie", ["mv_s=; Path=/; Max-Age=0; HttpOnly", "mv_c=; Path=/; Max-Age=0"]);
|
|
888
|
+
return json(res, 200, {}), true;
|
|
889
|
+
}
|
|
890
|
+
if (path === "profile") {
|
|
891
|
+
const u = currentUser(req);
|
|
892
|
+
if (!u) return json(res, 401, { error: "sign in first" }), true;
|
|
893
|
+
const b = await readBody(req);
|
|
894
|
+
const next = updateProfile(dataDir, u.email, {
|
|
895
|
+
name: typeof b.name === "string" ? b.name : void 0,
|
|
896
|
+
avatar: b.avatar === "" ? "" : validAvatar(b.avatar) ? b.avatar : void 0
|
|
897
|
+
});
|
|
898
|
+
return json(res, 200, { user: publicUser(next) }), true;
|
|
899
|
+
}
|
|
900
|
+
if (path === "cli-session") {
|
|
901
|
+
const bearer = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1] ?? "";
|
|
902
|
+
if (limited(`ip:${ip}`)) return json(res, 429, { error: "too many attempts - wait a minute" }), true;
|
|
903
|
+
const issued = issueDeviceSession(dataDir, bearer);
|
|
904
|
+
if (!issued) return json(res, 401, { error: "MARVER_CLI_TOKEN was refused, or nobody owns this canvas yet" }), true;
|
|
905
|
+
return json(res, 200, {
|
|
906
|
+
token: issued.token,
|
|
907
|
+
exp: issued.exp,
|
|
908
|
+
user: publicUser(issued.user)
|
|
909
|
+
}), true;
|
|
910
|
+
}
|
|
911
|
+
if (path === "invite") {
|
|
912
|
+
if (!can(currentUser(req), rights, "", "admin")) return json(res, 403, { error: "owner only" }), true;
|
|
913
|
+
const b = await readBody(req);
|
|
914
|
+
const email = String(b.email ?? "");
|
|
915
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return json(res, 400, { error: "a valid email is required" }), true;
|
|
916
|
+
const idMode = !!process.env.MARVER_ID_ISSUER;
|
|
917
|
+
return json(res, 200, {
|
|
918
|
+
...createInvite(dataDir, email),
|
|
919
|
+
idMode
|
|
920
|
+
}), true;
|
|
921
|
+
}
|
|
922
|
+
if (path === "revoke") {
|
|
923
|
+
if (!can(currentUser(req), rights, "", "admin")) return json(res, 403, { error: "owner only" }), true;
|
|
924
|
+
const b = await readBody(req);
|
|
925
|
+
revokeUser(dataDir, String(b.email ?? ""));
|
|
926
|
+
return json(res, 200, {}), true;
|
|
927
|
+
}
|
|
928
|
+
const m = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
|
|
929
|
+
if (m) {
|
|
930
|
+
const u = currentUser(req);
|
|
931
|
+
if (!can(u, rights, m[1], "comment", dataDir)) return json(res, u ? 403 : 401, { error: u ? `this board is read-only - ask ${ownerName(dataDir) ?? "the canvas owner"} for commenting access` : "sign in to comment" }), true;
|
|
932
|
+
const b = await readBody(req);
|
|
933
|
+
const incoming = Array.isArray(b.events) ? b.events : [];
|
|
934
|
+
if (incoming.length > 100) return json(res, 400, { error: "too many events in one push" }), true;
|
|
935
|
+
const operator = rawTransport(req);
|
|
936
|
+
const bad = validateEvents(incoming, readLog(commentsDir, m[1]), u, m[1], { operator });
|
|
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
|
+
}
|
|
954
|
+
const fresh = appendEvents(commentsDir, m[1], incoming);
|
|
955
|
+
if (fresh.length) {
|
|
956
|
+
const st = threads.get(m[1]);
|
|
957
|
+
threads.set(m[1], deriveThreads([...st?.events ?? [], ...fresh]));
|
|
958
|
+
}
|
|
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));
|
|
961
|
+
return json(res, 200, { accepted: fresh.length }), true;
|
|
962
|
+
}
|
|
963
|
+
return false;
|
|
964
|
+
} catch (err) {
|
|
965
|
+
return json(res, 400, { error: err.message }), true;
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
//#endregion
|
|
970
|
+
export { collabHandler, validAvatar };
|