@marver-design/marver 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/dist/auth-B5yuwnOq.mjs +494 -0
  3. package/dist/{build-BkckEcZd.mjs → build-Cr_ZLMdq.mjs} +3 -2
  4. package/dist/cli.mjs +6 -6
  5. package/dist/{collab-s3k5byM1.mjs → collab-C-n-uQdm.mjs} +67 -11
  6. package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
  7. package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
  8. package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
  9. package/dist/{daemon-B_8BmmSJ.mjs → daemon-DSmaS453.mjs} +24 -6
  10. package/dist/{dev-Bm-R_-gs.mjs → dev-CLBjRs0J.mjs} +126 -12
  11. package/dist/{init-D2EQEbV6.mjs → init-BSclDg4I.mjs} +9 -29
  12. package/dist/{ledger-BgA7nQoH.mjs → ledger-Bu0BjqIe.mjs} +1 -12
  13. package/dist/managed-HwHVNI3h.mjs +94 -0
  14. package/dist/manifest-C2tzkNaC.mjs +233 -0
  15. package/dist/marver-id-B8-3WiHk.mjs +411 -0
  16. package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
  17. package/dist/{plugin-DhkR3NdW.mjs → plugin-vE7sxEeT.mjs} +18 -80
  18. package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
  19. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  20. package/dist/{serve-CZqPnj19.mjs → serve-CZ1KEQxd.mjs} +194 -42
  21. package/dist/{shot-BRgPaFCs.mjs → shot-Cyv3GN79.mjs} +140 -13
  22. package/dist/sync-DELGomPk.mjs +397 -0
  23. package/dist/update-DuWDj5nR.mjs +77 -0
  24. package/dist/utm-CxC3QN5X.mjs +20 -0
  25. package/package.json +1 -1
  26. package/src/client/content/img-lod.ts +6 -0
  27. package/src/client/frame-host/bridge.js +3 -1
  28. package/src/client/frame-host/main.tsx +4 -1
  29. package/src/client/shell/App.tsx +5 -0
  30. package/src/client/shell/canvas/FrameNode.tsx +45 -6
  31. package/src/client/shell/canvas/ready-watch.ts +28 -0
  32. package/src/client/shell/store.ts +27 -6
  33. package/src/client/shell/styles.css +8 -0
  34. package/src/shared/utm.ts +2 -2
  35. package/templates/instructions/configure.md +4 -0
  36. package/templates/instructions/jam.md +4 -0
  37. package/templates/instructions/publish.md +84 -15
  38. package/dist/auth-KQ9Aj-nB.mjs +0 -245
  39. package/dist/events-BMtBvvgU.mjs +0 -101
  40. package/dist/sync-BJKKmy1n.mjs +0 -150
@@ -0,0 +1,728 @@
1
+ import { i as avatarSourceFor, n as attachAvatar, u as provisionFromMarverId } from "./auth-B5yuwnOq.mjs";
2
+ import { TransactionStore, browserBinding, verifyAssertion } from "./marver-id-B8-3WiHk.mjs";
3
+ import { t as poweredByUrl } from "./utm-CxC3QN5X.mjs";
4
+ import { randomBytes } from "node:crypto";
5
+ import { isIP } from "node:net";
6
+ import { lookup } from "node:dns/promises";
7
+ //#region src/server/avatar.ts
8
+ /**
9
+ * Fetching somebody's picture, once, and keeping our own copy.
10
+ *
11
+ * A Marver ID assertion carries a `picture` URL - almost always Google's CDN.
12
+ * The canvas could just render that URL and be done, and it must not. Hotlinking
13
+ * would put a request to Google on every page view of a private canvas, which
14
+ * hands a third party a log of who looks at what, and leaves the avatar to
15
+ * vanish the day the URL rotates. So the picture is fetched ONCE, at sign-in,
16
+ * and stored as a data URI beside the account like any other avatar.
17
+ *
18
+ * That decision buys a problem: the canvas server now makes an outbound request
19
+ * to an address that arrived in a token. Everything below is about making that
20
+ * safe and, failing that, making it harmless - the whole thing is best-effort,
21
+ * and every failure path returns null so a sign-in never breaks over a picture.
22
+ */
23
+ /** Small enough to sit in a JSON store beside every account, without thinking. */
24
+ const MAX_BYTES = 65536;
25
+ const TIMEOUT_MS = 5e3;
26
+ /** What a browser will actually render, and nothing that can carry script. */
27
+ const ALLOWED = /* @__PURE__ */ new Set([
28
+ "image/png",
29
+ "image/jpeg",
30
+ "image/webp",
31
+ "image/gif"
32
+ ]);
33
+ /**
34
+ * Is this address one a self-hosted canvas can safely be asked to fetch?
35
+ *
36
+ * The canvas is somebody's own server, usually inside their own network, and it
37
+ * is being handed a URL. Without this check "fetch my picture" is a request to
38
+ * probe the metadata endpoint, or a database on the private subnet, or another
39
+ * service on the same box - the classic shape of SSRF, and worth more to an
40
+ * attacker on a self-hosted deployment than on a shared one.
41
+ *
42
+ * Stated honestly: this resolves the name and rejects private space, which does
43
+ * not close DNS rebinding - the name can resolve differently between this check
44
+ * and the connection Node actually makes. Pinning the address needs a custom
45
+ * agent. What bounds the residual risk is everything around it: the URL is https
46
+ * only, arrives inside a signature from a configured issuer, is never followed
47
+ * through a redirect, times out in five seconds, and the response is discarded
48
+ * unless it is a small image. The value of the remaining hole is one blind
49
+ * request; it is defence in depth rather than a wall.
50
+ */
51
+ /**
52
+ * Every hextet of an IPv6 address, or null if it is not one.
53
+ *
54
+ * Prefix matching on the printed form is what let the first version through:
55
+ * `fe80:` is a string test, and link-local is fe80::/10 - so fe90:: and febf::
56
+ * sailed past. Likewise `::ffff:` was only recognised in its dotted form, and
57
+ * `::ffff:a00:1` is the same 10.0.0.1 written in hex. Comparing numbers instead
58
+ * of text removes the whole class.
59
+ */
60
+ function v6Parts(addr) {
61
+ let s = addr.toLowerCase();
62
+ const zone = s.indexOf("%");
63
+ if (zone >= 0) s = s.slice(0, zone);
64
+ const tail4 = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
65
+ if (tail4) {
66
+ const b = [
67
+ Number(tail4[2]),
68
+ Number(tail4[3]),
69
+ Number(tail4[4]),
70
+ Number(tail4[5])
71
+ ];
72
+ if (b.some((n) => n > 255)) return null;
73
+ s = `${tail4[1]}${((b[0] << 8 | b[1]) >>> 0).toString(16)}:${((b[2] << 8 | b[3]) >>> 0).toString(16)}`;
74
+ }
75
+ const halves = s.split("::");
76
+ if (halves.length > 2) return null;
77
+ const head = halves[0] ? halves[0].split(":") : [];
78
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
79
+ const gap = halves.length === 2 ? 8 - head.length - tail.length : 0;
80
+ if (gap < 0 || halves.length === 1 && head.length !== 8) return null;
81
+ const parts = [
82
+ ...head,
83
+ ...Array(gap).fill("0"),
84
+ ...tail
85
+ ];
86
+ if (parts.length !== 8) return null;
87
+ const nums = parts.map((h) => /^[0-9a-f]{1,4}$/.test(h) ? parseInt(h, 16) : NaN);
88
+ return nums.some((n) => !Number.isInteger(n)) ? null : nums;
89
+ }
90
+ function isPublicAddress(addr) {
91
+ if (isIP(addr) === 6) {
92
+ const p = v6Parts(addr);
93
+ if (!p) return false;
94
+ if (p.slice(0, 5).every((x) => x === 0) && p[5] === 65535) return isPublicAddress(`${p[6] >> 8}.${p[6] & 255}.${p[7] >> 8}.${p[7] & 255}`);
95
+ /**
96
+ * Everything else must be global unicast, and then survive three exceptions.
97
+ *
98
+ * This is an ALLOWLIST, and the earlier blocklist is why. Naming the ranges
99
+ * to refuse means the default is "allowed", and IPv6 has far too much
100
+ * special-purpose space for that default to be safe: reserved blocks,
101
+ * documentation space, and several prefixes that carry an IPv4 address
102
+ * inside them and route to it. Every one I had not thought of was a hole,
103
+ * and the list of things I have not thought of is not something I can
104
+ * enumerate. Requiring 2000::/3 inverts it - the default becomes "refused",
105
+ * and the exceptions below are the small, auditable part.
106
+ *
107
+ * The three exceptions are all inside global unicast and none are reachable
108
+ * the way an avatar host is:
109
+ * 2001::/23 IETF protocol assignments - Teredo, benchmarking, ORCHID.
110
+ * Teredo in particular tunnels IPv4 inside the address.
111
+ * 2001:db8::/32 documentation. Never routed, and a favourite in examples.
112
+ * 2002::/16 6to4, which embeds an IPv4 address and routes to it.
113
+ *
114
+ * NAT64 (64:ff9b::/96) is refused simply by being outside 2000::/3, and
115
+ * that is the right outcome: it is a live route to whatever IPv4 address it
116
+ * carries, including the metadata endpoint. An IPv6-only canvas loses
117
+ * nothing real, because an avatar host that matters has a AAAA record.
118
+ */
119
+ if ((p[0] & 57344) !== 8192) return false;
120
+ if (p[0] === 8193 && p[1] < 512) return false;
121
+ if (p[0] === 8193 && p[1] === 3512) return false;
122
+ if (p[0] === 8194) return false;
123
+ if (p[0] >= 16383 && p[0] <= 16383) return false;
124
+ return true;
125
+ }
126
+ const q = addr.split(".").map(Number);
127
+ if (q.length !== 4 || q.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
128
+ const [a, b] = q;
129
+ if (a === 0 || a === 127) return false;
130
+ if (a === 10) return false;
131
+ if (a === 172 && b >= 16 && b <= 31) return false;
132
+ if (a === 192 && b === 168) return false;
133
+ if (a === 169 && b === 254) return false;
134
+ if (a === 100 && b >= 64 && b <= 127) return false;
135
+ if (a === 192 && b === 0) return false;
136
+ if (a === 198 && (b === 18 || b === 19)) return false;
137
+ if (a === 198 && b === 51 && q[2] === 100) return false;
138
+ if (a === 203 && b === 0 && q[2] === 113) return false;
139
+ if (a >= 224) return false;
140
+ return true;
141
+ }
142
+ /**
143
+ * Fetch a picture and return it as a data URI, or null.
144
+ *
145
+ * Null for every kind of failure, deliberately and without distinction: a
146
+ * picture is decoration, and there is no failure here worth turning into a
147
+ * refused sign-in or an error somebody has to read.
148
+ */
149
+ async function fetchAvatar(pictureUrl) {
150
+ let url;
151
+ try {
152
+ url = new URL(pictureUrl);
153
+ } catch {
154
+ return null;
155
+ }
156
+ if (url.protocol !== "https:") return null;
157
+ if (url.username || url.password) return null;
158
+ try {
159
+ const { address } = await lookup(url.hostname);
160
+ if (!isPublicAddress(address)) return null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ try {
165
+ return await avatarFromResponse(await fetch(url, {
166
+ redirect: "error",
167
+ signal: AbortSignal.timeout(TIMEOUT_MS),
168
+ headers: { accept: ALLOWED_ACCEPT }
169
+ }));
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+ const ALLOWED_ACCEPT = [...ALLOWED].join(", ");
175
+ /**
176
+ * Turn a response into a stored avatar, or refuse it.
177
+ *
178
+ * Separate from the fetch so the rules can be tested against real Responses
179
+ * without needing real TLS - these are the checks that decide what ends up in
180
+ * somebody's auth store, and they are worth more than a test that restates a
181
+ * list of MIME types back to itself.
182
+ *
183
+ * SVG is deliberately absent from the allowed set. Browsers render it, which
184
+ * makes it feel like an image, and it is a document that can carry script - an
185
+ * avatar is one of the few things a canvas shows without the author having
186
+ * written it, so it is exactly where that matters.
187
+ */
188
+ async function avatarFromResponse(res) {
189
+ if (!res.ok) return null;
190
+ const type = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
191
+ if (!ALLOWED.has(type)) return null;
192
+ const declared = Number(res.headers.get("content-length") ?? "0");
193
+ if (declared && declared > MAX_BYTES) return null;
194
+ const buf = await readCapped(res);
195
+ if (!buf || buf.byteLength === 0) return null;
196
+ return `data:${type};base64,${buf.toString("base64")}`;
197
+ }
198
+ /**
199
+ * Read a body, and stop reading the moment it is too big.
200
+ *
201
+ * The cap used to be applied to the result of arrayBuffer(), which is a check
202
+ * performed after the thing it was checking has already been allocated. A
203
+ * chunked response, or one whose Content-Length simply lies, could stream
204
+ * hundreds of megabytes into the canvas process inside the five second window,
205
+ * and a handful of concurrent sign-ins would be enough to end it. Counting as
206
+ * the chunks arrive and cancelling at the limit is the difference between a
207
+ * bound and a wish.
208
+ */
209
+ async function readCapped(res) {
210
+ const body = res.body;
211
+ if (!body || typeof body.getReader !== "function") {
212
+ const buf = Buffer.from(await res.arrayBuffer());
213
+ return buf.byteLength > MAX_BYTES ? null : buf;
214
+ }
215
+ const reader = body.getReader();
216
+ const chunks = [];
217
+ let size = 0;
218
+ try {
219
+ for (;;) {
220
+ const { done, value } = await reader.read();
221
+ if (done) break;
222
+ if (!value) continue;
223
+ size += value.byteLength;
224
+ if (size > MAX_BYTES) {
225
+ await reader.cancel().catch(() => {});
226
+ return null;
227
+ }
228
+ chunks.push(value);
229
+ }
230
+ } catch {
231
+ return null;
232
+ }
233
+ return Buffer.concat(chunks);
234
+ }
235
+ //#endregion
236
+ //#region src/server/marver-id-gate.ts
237
+ /**
238
+ * The three endpoints that bracket a Marver ID sign-in.
239
+ *
240
+ * GET /__mv/id/start mints a nonce bound to this browser, then sends the
241
+ * tab to the identity service. A plain redirect - no
242
+ * JavaScript is involved in leaving.
243
+ * GET /__mv/id/finish where the identity service sends the tab back, with
244
+ * the assertion in the URL FRAGMENT. Reads it, hands
245
+ * it to the callback, and shows what happened.
246
+ * POST /__mv/id/callback verifies the assertion server-side and, if the owner
247
+ * invited this address, issues a canvas session.
248
+ *
249
+ * This used to be a popup that handed the assertion back by postMessage. That
250
+ * design is dead on arrival for social sign-in: Google serves
251
+ * `Cross-Origin-Opener-Policy: same-origin`, which permanently severs the
252
+ * popup's `window.opener` - and it does not come back when the popup returns to
253
+ * our origin. Microsoft and Apple do the same. So the whole flow now happens in
254
+ * ONE tab, and nothing depends on two windows being able to talk. It also fixes
255
+ * popup blockers and mobile, where popups were never good.
256
+ *
257
+ * The assertion rides in the fragment on the way back, which is the one part of
258
+ * a URL a browser never sends to a server: it stays out of this canvas's access
259
+ * logs and out of any `Referer`.
260
+ *
261
+ * The important architectural choice is unchanged: a Marver ID sign-in ends with
262
+ * the SAME `mv_s` session cookie a password sign-in produces. Nothing downstream
263
+ * - the gate, the comment API, the event stream - learns that a new kind of
264
+ * login exists. One session concept, one place to revoke it.
265
+ *
266
+ * The assertion never touches the browser's storage. It is read from the
267
+ * fragment, POSTed straight here, and consumed. Authored frames run same-origin
268
+ * in this canvas, so anything left in localStorage would be readable by them.
269
+ */
270
+ /**
271
+ * Names the browser across the two requests. Not a session - just a handle.
272
+ *
273
+ * Over https it carries the `__Host-` prefix, which is not decoration. The
274
+ * handle is what binds a sign-in to the browser that started it, and without
275
+ * the prefix a sibling host on a shared parent domain can set one - "cookie
276
+ * tossing" - which means choosing the binding for somebody else's sign-in. The
277
+ * prefix makes a cookie host-only and unsettable by any other host, and the
278
+ * browser enforces it rather than us. Plain over http, where the prefix is
279
+ * invalid; there is no https there to have, and localhost has no siblings.
280
+ *
281
+ * What that leaves, stated rather than assumed. The prefix protects against
282
+ * every OTHER host, and against nothing on this one: cookies ignore ports, so a
283
+ * second service on the same hostname can still write the handle, and so can
284
+ * anyone who takes over the hostname itself. A browser that does not implement
285
+ * the prefix gets no protection at all. So the assumption a self-hosted canvas
286
+ * is making is exclusive control of its own hostname, and modern browsers -
287
+ * which is the same assumption its TLS certificate already makes.
288
+ */
289
+ const BROWSER_COOKIE = "mv_b";
290
+ const BROWSER_COOKIE_SECURE = "__Host-mv_b";
291
+ const browserCookieName = (secure) => secure ? BROWSER_COOKIE_SECURE : BROWSER_COOKIE;
292
+ const SESSION_COOKIE = "mv_s";
293
+ /** The double-submit half of the session pair. See where it is set. */
294
+ const CSRF_COOKIE = "mv_c";
295
+ const MONTH = 2592e3;
296
+ /** Matches TRANSACTION_TTL_MS - one deadline, expressed in both halves. */
297
+ const HANDLE_TTL_S = 900;
298
+ /**
299
+ * Where to put somebody back, after they sign in.
300
+ *
301
+ * A canvas link carries its board and thread in the fragment - `#/b/strategy`,
302
+ * `#/b/strategy?c=<thread>` - which no server ever receives. The gate's script
303
+ * reads it and hands it here on the query string, which means that by the time
304
+ * it arrives it is an ordinary attacker-reachable parameter: anyone can request
305
+ * /__mv/id/start?next=<anything>.
306
+ *
307
+ * It ends up in location.replace() on this canvas, so it is an open redirect if
308
+ * it is wrong. Only a hash route on this same canvas survives.
309
+ *
310
+ * The rejections worth naming:
311
+ * "#//evil.test" - a browser reads what follows a bare // as a host
312
+ * "javascript:.." - not a route at all
313
+ * anything with a control character, which is how a value gets smuggled past
314
+ * a check and into a header or a log
315
+ */
316
+ function safeHash(raw) {
317
+ if (!raw) return null;
318
+ const value = raw.trim();
319
+ if (!value.startsWith("#/")) return null;
320
+ if (value.startsWith("#//") || value.startsWith("#/\\")) return null;
321
+ if (/[\u0000-\u001f\u007f]/.test(value)) return null;
322
+ if (value.length > 512) return null;
323
+ if (/(^|\/)\.\.?(\/|$)/.test(value.slice(1))) return null;
324
+ if (!/^#\/[\w\-./~%!$&'()*+,;=:@?[\]]*$/.test(value)) return null;
325
+ return value;
326
+ }
327
+ function marverIdHandler(dir, issuer, canvasName, branding = true) {
328
+ const transactions = new TransactionStore();
329
+ const pinned = (process.env.MARVER_PUBLIC_ORIGIN ?? "").trim().replace(/\/$/, "");
330
+ let publicOrigin;
331
+ {
332
+ if (!pinned) throw new Error("MARVER_PUBLIC_ORIGIN is required when MARVER_ID_ISSUER is set.\n Set it to this canvas's exact public origin - scheme, host and port -\n e.g. https://canvas.example.com, or http://localhost:4173 in development.");
333
+ let u;
334
+ try {
335
+ u = new URL(pinned);
336
+ } catch {
337
+ throw new Error(`MARVER_PUBLIC_ORIGIN is not a URL: ${pinned}`);
338
+ }
339
+ const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
340
+ if (u.username || u.password) throw new Error(`MARVER_PUBLIC_ORIGIN must not carry credentials: ${pinned}`);
341
+ if (u.pathname !== "/" || u.search || u.hash) throw new Error(`MARVER_PUBLIC_ORIGIN must be a bare origin: ${pinned}`);
342
+ if (!(u.protocol === "https:" || u.protocol === "http:" && loopback)) throw new Error(`MARVER_PUBLIC_ORIGIN must be https, or http on loopback: ${pinned}`);
343
+ publicOrigin = u.origin;
344
+ }
345
+ return async function handle(req, res, url) {
346
+ const origin = publicOrigin;
347
+ const secure = origin.startsWith("https://");
348
+ if (req.method === "GET" && url.pathname === "/__mv/id/start") {
349
+ let browserId = readCookie(req, browserCookieName(secure));
350
+ const headers = [];
351
+ if (!browserId) browserId = randomBytes(24).toString("base64url");
352
+ headers.push(cookie(browserCookieName(secure), browserId, {
353
+ maxAge: HANDLE_TTL_S,
354
+ secure
355
+ }));
356
+ const next = safeHash(url.searchParams.get("next"));
357
+ const tx = transactions.mint(origin, browserBinding(browserId), next ?? void 0);
358
+ const authorize = `${issuer}/authorize?origin=${encodeURIComponent(origin)}&nonce=${encodeURIComponent(tx.nonce)}` + (canvasName ? `&name=${encodeURIComponent(canvasName)}` : "");
359
+ if (headers.length) res.setHeader("set-cookie", headers);
360
+ res.statusCode = 302;
361
+ res.setHeader("location", authorize);
362
+ res.setHeader("cache-control", "no-store");
363
+ res.end();
364
+ return true;
365
+ }
366
+ if (req.method === "GET" && url.pathname === "/__mv/id/finish") {
367
+ res.statusCode = 200;
368
+ res.setHeader("content-type", "text/html; charset=utf-8");
369
+ res.setHeader("cache-control", "no-store");
370
+ res.setHeader("referrer-policy", "no-referrer");
371
+ res.setHeader("content-security-policy", "frame-ancestors 'none'");
372
+ res.setHeader("x-frame-options", "DENY");
373
+ res.end(finishPage(canvasName, new URL(origin).host, `${issuer}/switch?origin=${encodeURIComponent(origin)}`, branding));
374
+ return true;
375
+ }
376
+ if (req.method === "POST" && url.pathname === "/__mv/id/callback") {
377
+ const { body, tooLarge } = await readBody(req, 16e3);
378
+ if (tooLarge) {
379
+ res.setHeader("connection", "close");
380
+ return json(res, 413, { error: "too large" });
381
+ }
382
+ let assertion = "";
383
+ try {
384
+ const parsed = JSON.parse(body);
385
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
386
+ const a = parsed.assertion;
387
+ if (typeof a === "string") assertion = a;
388
+ }
389
+ } catch {}
390
+ if (!assertion) return json(res, 400, { error: "bad request" });
391
+ const browserId = readCookie(req, browserCookieName(secure));
392
+ if (!browserId) {
393
+ console.warn("[marver-id] callback with no browser handle");
394
+ return json(res, 401, { error: "not signed in" });
395
+ }
396
+ let landing = null;
397
+ const result = await verifyAssertion({
398
+ token: assertion,
399
+ origin,
400
+ issuer,
401
+ store: transactions,
402
+ browser: browserBinding(browserId),
403
+ onConsumed: (tx) => {
404
+ landing = tx.next ?? null;
405
+ }
406
+ });
407
+ if (!result.ok) {
408
+ console.warn(`[marver-id] assertion rejected: ${result.reason}`);
409
+ return json(res, 401, { error: "not signed in" });
410
+ }
411
+ const session = provisionFromMarverId(dir, {
412
+ ...result.identity,
413
+ issuer
414
+ }, { ownerEmail: process.env.MARVER_OWNER_EMAIL });
415
+ if (!session) {
416
+ console.warn(`[marver-id] ${result.identity.email} is not on this canvas's list`);
417
+ return json(res, 403, {
418
+ error: "not invited",
419
+ email: result.identity.email
420
+ });
421
+ }
422
+ const picture = result.identity.picture;
423
+ const qualified = `${issuer}#${result.identity.subject}`;
424
+ if (picture) {
425
+ const before = avatarSourceFor(dir, qualified, result.identity.email, picture);
426
+ if (before.wanted) {
427
+ const avatar = await fetchAvatar(picture);
428
+ if (avatar) attachAvatar(dir, qualified, avatar, picture, before.source);
429
+ }
430
+ }
431
+ res.setHeader("set-cookie", [
432
+ cookie(SESSION_COOKIE, session.session, {
433
+ maxAge: MONTH,
434
+ secure
435
+ }),
436
+ `${CSRF_COOKIE}=${randomBytes(16).toString("base64url")}; Path=/; Max-Age=${MONTH}; SameSite=Lax${secure ? "; Secure" : ""}`,
437
+ cookie(browserCookieName(secure), "", {
438
+ maxAge: 0,
439
+ secure
440
+ })
441
+ ]);
442
+ return json(res, 200, {
443
+ ok: true,
444
+ next: landing
445
+ });
446
+ }
447
+ return json(res, 404, { error: "not found" });
448
+ };
449
+ }
450
+ /**
451
+ * The page the identity service sends the tab back to.
452
+ *
453
+ * Fixed bytes for everyone: the assertion is in the fragment, which the browser
454
+ * never sent here, so there is nothing to template and nothing to escape. The
455
+ * script reads it, wipes it out of the address bar before doing anything else -
456
+ * so it cannot be left behind in history or read off the screen - and hands it
457
+ * to the callback over a SAME-ORIGIN POST, which is what keeps the browser
458
+ * handle cookie flowing under SameSite=Lax.
459
+ *
460
+ * A refusal is shown HERE rather than bounced back to the gate, because this is
461
+ * the page somebody is looking at. Being told "that account was not invited" on
462
+ * the screen in front of you beats being returned to a form that silently
463
+ * refuses to move.
464
+ */
465
+ /**
466
+ * The mark and the escaper, defined here rather than imported from serve.ts.
467
+ *
468
+ * serve.ts imports this module, so reaching back for them would be a cycle. The
469
+ * paths are the same ones the gate draws - if one changes, both change, which
470
+ * is exactly the coupling the shared look depends on.
471
+ */
472
+ const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${size}" fill="currentColor" aria-hidden><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1"/><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z"/></svg>`;
473
+ const MARK = MARK_AT(18);
474
+ const MARK_LG = MARK_AT(24);
475
+ const ARROW = `<svg class="up" viewBox="0 0 256 256" width="11" height="11" fill="currentColor" aria-hidden><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"/></svg>`;
476
+ /** The canvas name is the operator's own, but it still lands in HTML. */
477
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
478
+ /**
479
+ * `share: { branding: false }` is documented as stripping every Marver mention,
480
+ * and this page was the exception nobody noticed - the last screen of the flow,
481
+ * still wearing the footer an operator had explicitly turned off. A setting that
482
+ * holds almost everywhere is a setting you cannot rely on.
483
+ */
484
+ function finishPage(canvasName, host, switchUrl, branding = true) {
485
+ const name = esc(canvasName || "this canvas");
486
+ const where = esc(host);
487
+ return `<!doctype html>
488
+ <html lang="en"><head><meta charset="utf-8" />
489
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
490
+ <meta name="robots" content="noindex" />
491
+ <title>Signing in - ${name}</title>
492
+ <link rel="icon" href="/__mv/favicon/favicon.ico" sizes="48x48" />
493
+ <style>
494
+ /* The gate's own tokens. This page is the last thing somebody sees before the
495
+ canvas opens - or the only thing, if they are refused - so it belongs to
496
+ the same surface rather than looking like an error from somewhere else. */
497
+ * { box-sizing: border-box }
498
+ body { margin: 0; min-height: 100vh; display: flex; flex-direction: column;
499
+ align-items: center; justify-content: center; gap: 18px;
500
+ background-color: #e7e9ef;
501
+ background-image: radial-gradient(#c9cbd5 1px, transparent 1px);
502
+ background-size: 20px 20px;
503
+ font: 500 14px -apple-system, system-ui, sans-serif; color: #18181b;
504
+ -webkit-font-smoothing: antialiased }
505
+ .card { width: 340px; padding: 24px; border-radius: 24px;
506
+ background: rgba(255,255,255,.64);
507
+ backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
508
+ box-shadow: 0 1px 2px rgba(24,24,27,.04), 0 12px 32px rgba(24,24,27,.07);
509
+ display: flex; flex-direction: column; gap: 14px }
510
+ header { display: flex; align-items: center; gap: 9px }
511
+ header svg { color: #0088ff; flex: none }
512
+ h1 { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -.01em }
513
+ /* Pulled up against the address below it: the state line introduces that
514
+ chip rather than floating between it and the canvas name. */
515
+ .state { margin: 0 0 -8px; padding: 0 4px; font-size: 13.5px; font-weight: 600 }
516
+ .chip { height: 40px; padding: 0 16px; border-radius: 999px;
517
+ background: rgba(24,24,27,.05); display: flex; align-items: center;
518
+ font: 500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
519
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
520
+ p.lead { margin: 0; padding: 0 4px; font-size: 12.5px; line-height: 1.5;
521
+ color: rgba(24,24,27,.66) }
522
+ p.lead strong { color: #18181b; font-weight: 600 }
523
+ a.cta { height: 40px; border-radius: 999px; background: #18181b; color: #fafafa;
524
+ font: 600 13px -apple-system, system-ui, sans-serif; text-decoration: none;
525
+ display: flex; align-items: center; justify-content: center }
526
+ footer a { display: inline-flex; align-items: center; gap: 7px;
527
+ font: 600 12.5px -apple-system, system-ui, sans-serif;
528
+ color: rgba(24,24,27,.5); text-decoration: none }
529
+ footer a > svg:first-of-type { color: #0088ff }
530
+ footer .md, footer .up { transition: color .15s, opacity .15s }
531
+ footer a:hover .md { color: #0088ff; text-decoration: underline; text-underline-offset: 3px }
532
+ footer .up { opacity: .45; margin-left: -3px }
533
+ footer a:hover .up { opacity: 1; color: #0088ff }
534
+ [hidden] { display: none !important }
535
+ /* Shown only if the wait becomes long enough to be worth explaining.
536
+ Painting the whole card first meant every successful sign-in flashed a
537
+ panel naming the canvas and its address on the way past - a screen nobody
538
+ needs, appearing and vanishing, which reads as a stutter rather than as
539
+ progress. The card is for when something has to be SAID; getting in says
540
+ itself by the canvas appearing. */
541
+ .wait { width: 34px; height: 34px; border-radius: 50%;
542
+ border: 3px solid rgba(24,24,27,.12); border-top-color: #0088ff;
543
+ animation: spin .7s linear infinite }
544
+ @keyframes spin { to { transform: rotate(360deg) } }
545
+ @media (prefers-reduced-motion: reduce) {
546
+ .wait { animation: none; border-top-color: rgba(24,24,27,.35) }
547
+ }
548
+ </style></head>
549
+ <body>
550
+ <div class="wait" id="wait" role="status" aria-label="Signing you in" hidden></div>
551
+ <div class="card" id="card" hidden>
552
+ <header>${MARK_LG}<h1 id="t">${name}</h1></header>
553
+ <p class="state" id="s"></p>
554
+ <div class="chip">${where}</div>
555
+ <p class="lead" id="m"></p>
556
+ <p class="lead" id="m2" hidden></p>
557
+ <a class="cta" id="back" href="/" data-switch="${esc(switchUrl)}" hidden>Use a different account</a>
558
+ </div>
559
+ ${branding ? `<footer id="mark" hidden><a href="${poweredByUrl(canvasName, "published-canvas", "sign-in")}" target="_blank" rel="noopener">${MARK} <span>Powered by <span class="md">Marver.design</span></span> ${ARROW}</a></footer>` : ""}
560
+ <script>
561
+ (function () {
562
+ var s = document.getElementById('s'), m = document.getElementById('m'), back = document.getElementById('back')
563
+ var m2 = document.getElementById('m2')
564
+ var wait = document.getElementById('wait'), card = document.getElementById('card')
565
+
566
+ /**
567
+ * A spinner, but only if the wait is long enough to need one.
568
+ *
569
+ * This page is usually one POST - fifty to two hundred milliseconds - and a
570
+ * spinner that appears and vanishes inside that window is worse than no
571
+ * spinner at all: it is a flicker, and a flicker reads as something going
572
+ * wrong. Held back, the fast path shows only the dotted ground, which is the
573
+ * same ground the canvas itself is drawn on - so it looks like the canvas
574
+ * loading rather than like a screen in between.
575
+ *
576
+ * Past a second and a half the wait is real, and silence stops being calm and
577
+ * starts being broken. That is when the spinner earns its place.
578
+ */
579
+ var slow = setTimeout(function () { wait.hidden = false }, 1500)
580
+
581
+ /**
582
+ * Stop waiting and show the card - only ever for something worth reading.
583
+ *
584
+ * The badge belongs to the CARD, not to the page. Left outside the hidden
585
+ * card it sat by itself in the middle of an empty ground on every successful
586
+ * sign-in - which is the same flash the card used to be, wearing a smaller
587
+ * hat. Nothing is on screen until there is something to say.
588
+ */
589
+ var mark = document.getElementById('mark')
590
+ function speak() {
591
+ clearTimeout(slow)
592
+ wait.hidden = true
593
+ card.hidden = false
594
+ if (mark) mark.hidden = false
595
+ }
596
+ /**
597
+ * State, what happened, and - separately - what to do about it.
598
+ *
599
+ * The advice used to run on from the diagnosis in one paragraph, so the
600
+ * sentence that tells somebody how to get unstuck was buried at the end of
601
+ * the sentence explaining why they are stuck. They are two different thoughts
602
+ * and the person only needs the second one.
603
+ */
604
+ function stop(state, msg, cta, advice) {
605
+ speak()
606
+ s.textContent = state; m.textContent = msg
607
+ if (advice) { m2.textContent = advice; m2.hidden = false }
608
+ if (cta) { back.textContent = cta; back.hidden = false }
609
+ }
610
+
611
+ /** Refused, and told which account did it. */
612
+ function refused(email) {
613
+ speak()
614
+ s.textContent = "You haven't been invited"
615
+ m.textContent = ''
616
+ m.appendChild(document.createTextNode('You are signed in as '))
617
+ var b = document.createElement('strong')
618
+ b.textContent = email
619
+ m.appendChild(b)
620
+ m.appendChild(document.createTextNode(', and that address is not on the invite list for this canvas.'))
621
+ m2.textContent = 'Ask whoever owns it to add you, or sign in with the address they invited.'
622
+ m2.hidden = false
623
+ back.textContent = 'Use a different account'
624
+ // Signing out happens at the identity service - this canvas cannot reach
625
+ // across origins to do it, and sending them back here would just hand them
626
+ // the same account and the same refusal.
627
+ back.href = back.getAttribute('data-switch') || '/'
628
+ back.hidden = false
629
+ }
630
+
631
+ // Take it, then erase it - before any await, so a slow network cannot leave
632
+ // the assertion sitting in the address bar.
633
+ var assertion = location.hash.replace(/^#/, '')
634
+ try { history.replaceState(null, '', location.pathname) } catch (e) {}
635
+
636
+ if (!assertion) return stop('Nothing to sign in with', 'That link is incomplete. Start again from the canvas.', 'Back to the canvas')
637
+
638
+ fetch('/__mv/id/callback', {
639
+ method: 'POST',
640
+ headers: { 'content-type': 'application/json' },
641
+ body: JSON.stringify({ assertion: assertion })
642
+ }).then(function (res) {
643
+ if (res.ok) {
644
+ // Back to the board or thread the link pointed at. The server decides
645
+ // where that is - it held the deep link the whole time - so a value in
646
+ // this page cannot send anybody somewhere else.
647
+ return res.json().then(function (body) {
648
+ var to = body && typeof body.next === 'string' && body.next.indexOf('#/') === 0 ? body.next : ''
649
+ location.replace('/' + to)
650
+ }, function () { location.replace('/') })
651
+ }
652
+ if (res.status === 403) {
653
+ return res.json().then(function (body) {
654
+ var who = body && typeof body.email === 'string' ? body.email : ''
655
+ // textContent, never innerHTML: the address is attested, but it is still
656
+ // a string arriving over the wire and this page will not be the place
657
+ // that learns the difference the hard way.
658
+ who ? refused(who) : stop("You haven't been invited",
659
+ 'That account is not on the invite list for this canvas. Ask whoever owns it to add your address.',
660
+ 'Use a different account')
661
+ }, function () {
662
+ stop("You haven't been invited",
663
+ 'That account is not on the invite list for this canvas.',
664
+ 'Use a different account',
665
+ 'Ask whoever owns it to add your address.')
666
+ })
667
+ }
668
+ stop('That sign-in did not work', 'Start again from the canvas, or try a different account.', 'Try again')
669
+ }).catch(function () {
670
+ stop('Could not reach the canvas', 'It may have stopped. Try again in a moment.', 'Try again')
671
+ })
672
+ })()
673
+ <\/script>
674
+ </body></html>`;
675
+ }
676
+ function json(res, status, body) {
677
+ res.statusCode = status;
678
+ res.setHeader("content-type", "application/json");
679
+ res.setHeader("cache-control", "no-store");
680
+ res.end(JSON.stringify(body));
681
+ return true;
682
+ }
683
+ function cookie(name, value, o) {
684
+ return `${name}=${value}; Path=/; Max-Age=${o.maxAge}; HttpOnly; SameSite=Lax${o.secure ? "; Secure" : ""}`;
685
+ }
686
+ function readCookie(req, name) {
687
+ return new RegExp(`(?:^|;\\s*)${name}=([\\w-]+)`).exec(String(req.headers.cookie ?? ""))?.[1] ?? "";
688
+ }
689
+ /**
690
+ * Read a bounded request body.
691
+ *
692
+ * Counts BYTES, not decoded characters, and signals an overflow rather than
693
+ * destroying the socket - the previous version killed the connection and then
694
+ * tried to write a response onto it, which cannot arrive.
695
+ */
696
+ function readBody(req, limit, timeoutMs = 1e4) {
697
+ return new Promise((resolve) => {
698
+ const chunks = [];
699
+ let size = 0;
700
+ let done = false;
701
+ const finish = (tooLarge) => {
702
+ if (done) return;
703
+ done = true;
704
+ clearTimeout(timer);
705
+ resolve({
706
+ body: tooLarge ? "" : Buffer.concat(chunks).toString("utf8"),
707
+ tooLarge
708
+ });
709
+ };
710
+ const timer = setTimeout(() => {
711
+ req.pause();
712
+ finish(true);
713
+ }, timeoutMs);
714
+ if (typeof timer.unref === "function") timer.unref();
715
+ req.on("data", (c) => {
716
+ size += c.length;
717
+ if (size > limit) {
718
+ req.pause();
719
+ return finish(true);
720
+ }
721
+ chunks.push(c);
722
+ });
723
+ req.on("end", () => finish(false));
724
+ req.on("error", () => finish(true));
725
+ });
726
+ }
727
+ //#endregion
728
+ export { marverIdHandler };