@supersuit/artifacts 0.33.1 → 0.35.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.
@@ -0,0 +1,282 @@
1
+ // Resolution digests (0.34.0): when the owner, an admin or the publisher resolves comment threads,
2
+ // each signed-in reader whose comments were in them hears about it, in ONE email per page per burst
3
+ // of resolving, rather than one email per thread.
4
+ //
5
+ // resolve -> onResolve(event) queues a notice for every signed-in writer of a comment in the
6
+ // thread, except the person resolving, the page's owner and its
7
+ // admins; anonymous writers have no address and get nothing
8
+ // reopen -> onResolve(event) drops the thread from every burst still waiting on that page
9
+ // cron -> flush() a burst whose last resolution is QUIET_MS old (or whose first is
10
+ // MAX_WAIT_MS old) becomes one digest, sent once
11
+ //
12
+ // Only resolutions recorded through the route after a host wires `onResolve` queue anything: there
13
+ // is no backfill, so threads resolved before a host turned digests on never produce an email.
14
+ //
15
+ // Sent once, by construction. A due burst is CLAIMED in one transaction that turns it into a digest
16
+ // record and deletes the burst, so two overlapping runs cannot both claim it. A digest is then
17
+ // LEASED in a transaction before it is sent, so two runs cannot both send it. A run that dies after
18
+ // the send and before recording it leaves the lease to expire; the retry carries the same
19
+ // idempotency key (the digest id), which the sender passes to its provider (Resend keeps one for 24
20
+ // hours), so a retry does not deliver twice.
21
+ //
22
+ // Every digest carries a signed stop link that ends these emails for that reader on that page.
23
+ // The package knows nothing about where an email goes: the host hands it a `send` function.
24
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
25
+ /** A burst closes after this long with no new resolution for that reader on that page. */
26
+ export const RESOLVE_DIGEST_QUIET_MS = 10 * 60_000;
27
+ /** A burst that keeps growing is sent anyway once its first resolution is this old. */
28
+ export const RESOLVE_DIGEST_MAX_WAIT_MS = 6 * 3_600_000;
29
+ /** A digest leased for sending and not recorded as sent is retried after this long. */
30
+ export const RESOLVE_DIGEST_LEASE_MS = 15 * 60_000;
31
+ /** After this many attempts a digest is recorded as failed and never tried again. */
32
+ export const RESOLVE_DIGEST_MAX_ATTEMPTS = 5;
33
+ /** How much of a reader's comment, and of the passage it is pinned to, the email quotes. */
34
+ export const DIGEST_BODY_CHARS = 280;
35
+ export const DIGEST_QUOTE_CHARS = 160;
36
+ /** Where the stop link lands, under the host's origin. */
37
+ export const RESOLVE_DIGEST_STOP_PATH = '/api/resolve-digest/stop';
38
+ const norm = (e) => e.trim().toLowerCase();
39
+ /** A stable, non-reversible key for an address, used in document ids and stop links. */
40
+ export function emailKey(email) {
41
+ return createHash('sha256').update(norm(email)).digest('hex').slice(0, 24);
42
+ }
43
+ export const burstKey = (artifactId, email) => `${artifactId}__${emailKey(email)}`;
44
+ const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
45
+ const trim = (s, n) => { const t = oneLine(s); return t.length > n ? `${t.slice(0, n - 3).trimEnd()}...` : t; };
46
+ /** The notices one resolve event queues: per signed-in writer with an address, the comments in
47
+ * the thread that THEY wrote. Never the person resolving, the page's owner or its admins, never
48
+ * an anonymous writer, and never a comment somebody else wrote. A reopen queues nothing. Pure. */
49
+ export function noticesFor(event, o = {}) {
50
+ const r = event.resolution;
51
+ if (!r)
52
+ return [];
53
+ const skip = new Set([o.ownerEmail, ...(o.admins ?? []), event.actor.email].filter((e) => !!e && !!e.trim()).map(norm));
54
+ const root = event.thread.entries.find((e) => e.id === event.thread.id);
55
+ const anchor = root?.value?.anchor;
56
+ const quote = anchor?.kind === 'text' && anchor.quote ? trim(anchor.quote, DIGEST_QUOTE_CHARS) : null;
57
+ const by = new Map();
58
+ for (const e of event.thread.entries) {
59
+ const w = e.writer;
60
+ if (w.anonymous || !w.email || !w.email.trim())
61
+ continue;
62
+ const email = norm(w.email);
63
+ if (skip.has(email))
64
+ continue;
65
+ const v = e.value;
66
+ const body = v.body && v.body.trim() ? trim(v.body, DIGEST_BODY_CHARS) : 'a voice memo';
67
+ const item = { entryId: e.id, threadId: event.thread.id, quote, body, note: r.note ?? null, by: r.by, resolvedAt: r.at };
68
+ const n = by.get(email) ?? { email, name: w.name?.trim() || null, items: [] };
69
+ n.items.push(item);
70
+ by.set(email, n);
71
+ }
72
+ return [...by.values()];
73
+ }
74
+ /** A digest's items in the order the email lists them: by when each thread was resolved. */
75
+ export function itemsOf(threads) {
76
+ return Object.values(threads).flat().sort((x, y) => x.resolvedAt.localeCompare(y.resolvedAt));
77
+ }
78
+ // ---- Stop links
79
+ const stopSig = (secret, artifactId, key) => createHmac('sha256', secret).update(`resolve-digest-stop|${artifactId}|${key}`).digest('base64url').slice(0, 32);
80
+ /** `<artifactId>.<emailKey>.<signature>`: names the page and the reader (by key, not address). */
81
+ export function stopToken(secret, artifactId, email) {
82
+ const key = emailKey(email);
83
+ return `${artifactId}.${key}.${stopSig(secret, artifactId, key)}`;
84
+ }
85
+ export function verifyStopToken(secret, token) {
86
+ if (!secret || !token)
87
+ return null;
88
+ const m = /^([A-Za-z0-9]{1,32})\.([a-f0-9]{24})\.([A-Za-z0-9_-]{32})$/.exec(token.trim());
89
+ if (!m)
90
+ return null;
91
+ const want = Buffer.from(stopSig(secret, m[1], m[2]));
92
+ const got = Buffer.from(m[3]);
93
+ return got.length === want.length && timingSafeEqual(got, want) ? { artifactId: m[1], emailKey: m[2] } : null;
94
+ }
95
+ // ---- The email
96
+ const GENERIC_BY = new Set(['', 'the publisher', 'the owner']);
97
+ const join = (names) => names.length <= 1 ? names[0] ?? '' : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
98
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
99
+ /** Who resolved, as a person reads it: the names on the resolutions, with "the publisher" read as
100
+ * the page's owner (an agent resolving with the publish key is resolving on the owner's behalf). */
101
+ export function resolverName(items, fallback) {
102
+ const names = [];
103
+ for (const i of items) {
104
+ const n = GENERIC_BY.has(i.by.trim().toLowerCase()) ? fallback : i.by.trim();
105
+ if (!names.includes(n))
106
+ names.push(n);
107
+ }
108
+ return join(names) || fallback;
109
+ }
110
+ /** The digest as an email. Plain words, no em dashes, every comment linked to its place. */
111
+ export function composeDigest(d, o) {
112
+ const title = oneLine(d.title) || 'your page';
113
+ const who = resolverName(d.items, o.ownerName?.trim() || o.brandName);
114
+ const n = d.items.length;
115
+ const yours = n === 1 ? 'your comment' : `${n} of your comments`;
116
+ const subject = `${who} resolved ${yours} on ${title}`;
117
+ const first = d.name?.trim().split(/\s+/)[0];
118
+ const hello = first ? `Hi ${first},` : 'Hi,';
119
+ const lead = n === 1
120
+ ? `${who} resolved the comment you left on "${title}".`
121
+ : `${who} resolved ${n} of the comments you left on "${title}".`;
122
+ const link = (i) => `${d.url}#comment-${i.entryId}`;
123
+ const where = (i) => i.quote ? `On "${i.quote}"` : 'On a box on the page';
124
+ const host = (() => { try {
125
+ return new URL(d.url).host;
126
+ }
127
+ catch {
128
+ return '';
129
+ } })();
130
+ const why = 'You are getting this because you left these comments on the page while signed in.';
131
+ const text = [
132
+ hello, '', lead, '',
133
+ ...d.items.flatMap((i, k) => [
134
+ `${k + 1}. ${where(i)}`,
135
+ ` You wrote: "${i.body}"`,
136
+ ...(i.note ? [` What changed: ${i.note}`] : []),
137
+ ` See it: ${link(i)}`,
138
+ '',
139
+ ]),
140
+ `Read the page: ${d.url}`, '',
141
+ `${o.brandName}${host ? `, ${host}` : ''}`, '',
142
+ why,
143
+ `Stop these emails for this page: ${o.stopUrl}`,
144
+ ].join('\n');
145
+ const p = 'margin:0 0 14px';
146
+ const html = [
147
+ '<div style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;color:#1a1a1a;max-width:560px">',
148
+ `<p style="${p}">${esc(hello)}</p>`,
149
+ `<p style="${p}">${esc(lead)}</p>`,
150
+ ...d.items.map((i) => [
151
+ '<div style="margin:0 0 18px;padding:0 0 0 12px;border-left:3px solid #d0d0d0">',
152
+ i.quote ? `<p style="margin:0 0 6px;color:#555">On &ldquo;${esc(i.quote)}&rdquo;</p>` : '<p style="margin:0 0 6px;color:#555">On a box on the page</p>',
153
+ `<p style="margin:0 0 6px">You wrote: &ldquo;${esc(i.body)}&rdquo;</p>`,
154
+ i.note ? `<p style="margin:0 0 6px"><strong>What changed:</strong> ${esc(i.note)}</p>` : '',
155
+ `<p style="margin:0"><a href="${esc(link(i))}">See it on the page</a></p>`,
156
+ '</div>',
157
+ ].join('')),
158
+ `<p style="${p}"><a href="${esc(d.url)}">Read the page</a></p>`,
159
+ `<p style="${p};color:#555">${esc(o.brandName)}${host ? `, ${esc(host)}` : ''}</p>`,
160
+ `<p style="margin:0;font-size:13px;color:#777">${esc(why)} <a href="${esc(o.stopUrl)}" style="color:#777">Stop these emails for this page</a>.</p>`,
161
+ '</div>',
162
+ ].join('');
163
+ return { subject, text, html };
164
+ }
165
+ /** An address as a report shows it: enough to recognise, not enough to collect. */
166
+ export function maskEmail(email) {
167
+ const [user, domain] = norm(email).split('@');
168
+ return domain ? `${user.slice(0, 1)}***@${domain}` : '***';
169
+ }
170
+ export function createResolveDigests(o) {
171
+ const quietMs = o.quietMs ?? RESOLVE_DIGEST_QUIET_MS;
172
+ const maxWaitMs = o.maxWaitMs ?? RESOLVE_DIGEST_MAX_WAIT_MS;
173
+ const origin = o.siteUrl.replace(/\/+$/, '');
174
+ const stopPath = o.stopPath ?? RESOLVE_DIGEST_STOP_PATH;
175
+ function stopUrl(artifactId, email) {
176
+ const secret = o.secret();
177
+ return secret ? `${origin}${stopPath}?t=${stopToken(secret, artifactId, email)}` : null;
178
+ }
179
+ /** The resolve route's hook. Swallows nothing: the route catches. */
180
+ async function onResolve(event) {
181
+ if (!event.resolution) {
182
+ await o.store.dropThread(event.artifactId, event.thread.id);
183
+ return;
184
+ }
185
+ if (!o.secret())
186
+ return;
187
+ for (const n of noticesFor(event, { ownerEmail: o.ownerEmail, admins: o.admins })) {
188
+ if (await o.store.stopped(event.artifactId, emailKey(n.email)))
189
+ continue;
190
+ await o.store.add({ artifactId: event.artifactId, email: n.email, name: n.name, title: event.title, url: event.url, threadId: event.thread.id, items: n.items, at: event.at });
191
+ }
192
+ }
193
+ function message(d) {
194
+ const stop = stopUrl(d.artifactId, d.email);
195
+ if (!stop)
196
+ return null;
197
+ const m = composeDigest(d, { brandName: o.brandName, ownerName: o.ownerName, stopUrl: stop });
198
+ return {
199
+ to: d.email, ...m, idempotencyKey: `resolve-digest/${d.id}`,
200
+ headers: { 'List-Unsubscribe': `<${stop}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' },
201
+ };
202
+ }
203
+ /** Send every digest that is due. Run it on a schedule. `dryRun` changes nothing and sends
204
+ * nothing: it reports what a real run would send now. */
205
+ async function flush(opts = {}) {
206
+ const at = opts.now ?? new Date();
207
+ const now = at.toISOString();
208
+ const limit = opts.limit ?? 50;
209
+ const quietBefore = new Date(at.getTime() - quietMs).toISOString();
210
+ const maxBefore = new Date(at.getTime() - maxWaitMs).toISOString();
211
+ const report = { now, dryRun: !!opts.dryRun, due: 0, claimed: 0, sent: [], skipped: [], failed: [] };
212
+ const due = await o.store.due(quietBefore, maxBefore, limit);
213
+ report.due = due.length;
214
+ if (opts.dryRun) {
215
+ report.wouldSend = [];
216
+ for (const b of due) {
217
+ const items = itemsOf(b.threads);
218
+ const m = composeDigest({ ...b, items }, { brandName: o.brandName, ownerName: o.ownerName, stopUrl: '' });
219
+ report.wouldSend.push({ to: maskEmail(b.email), subject: m.subject, comments: items.length, artifactId: b.artifactId, stopped: await o.store.stopped(b.artifactId, emailKey(b.email)) });
220
+ }
221
+ return report;
222
+ }
223
+ if (!o.secret())
224
+ return report;
225
+ for (const b of due)
226
+ if (await o.store.claim({ key: b.key, burstId: b.burstId, quietBefore, maxBefore, now }))
227
+ report.claimed++;
228
+ for (const p of await o.store.pending(now, limit)) {
229
+ const d = await o.store.lease(p.id, now, new Date(at.getTime() + RESOLVE_DIGEST_LEASE_MS).toISOString());
230
+ if (!d)
231
+ continue;
232
+ if (d.attempts > RESOLVE_DIGEST_MAX_ATTEMPTS) {
233
+ await o.store.finish(d.id, { status: 'failed', at: now, reason: 'too many attempts' });
234
+ report.failed.push({ id: d.id, error: 'too many attempts' });
235
+ continue;
236
+ }
237
+ if (!d.items.length) {
238
+ await o.store.finish(d.id, { status: 'skipped', at: now, reason: 'empty' });
239
+ report.skipped.push({ id: d.id, reason: 'empty' });
240
+ continue;
241
+ }
242
+ if (await o.store.stopped(d.artifactId, emailKey(d.email))) {
243
+ await o.store.finish(d.id, { status: 'skipped', at: now, reason: 'stopped' });
244
+ report.skipped.push({ id: d.id, reason: 'stopped' });
245
+ continue;
246
+ }
247
+ const m = message(d);
248
+ if (!m) {
249
+ await o.store.release(d.id, now);
250
+ continue;
251
+ }
252
+ try {
253
+ const r = await o.send(m);
254
+ await o.store.finish(d.id, { status: 'sent', at: new Date().toISOString(), ...(r && r.id ? { providerId: r.id } : {}) });
255
+ report.sent.push({ id: d.id, to: maskEmail(d.email), subject: m.subject, comments: d.items.length });
256
+ }
257
+ catch (err) {
258
+ // Back off a little more each attempt: 5, 10, 20, 40 minutes.
259
+ const wait = 5 * 60_000 * 2 ** Math.max(0, d.attempts - 1);
260
+ await o.store.release(d.id, new Date(at.getTime() + wait).toISOString());
261
+ report.failed.push({ id: d.id, error: err?.message ?? 'send failed' });
262
+ }
263
+ }
264
+ return report;
265
+ }
266
+ const page = (title, body, status = 200) => new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${esc(title)}</title></head>` +
267
+ `<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;max-width:480px;margin:15vh auto;padding:0 20px;line-height:1.5;color:#1a1a1a"><h1 style="font-size:20px">${esc(title)}</h1><p>${body}</p></body></html>`, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
268
+ /** GET or POST <stopPath>?t=<token>: one click ends resolution emails for that reader on that
269
+ * page. POST answers the mail client's one-click unsubscribe (RFC 8058). */
270
+ async function STOP(req) {
271
+ const t = new URL(req.url).searchParams.get('t');
272
+ const v = verifyStopToken(o.secret(), t);
273
+ if (!v)
274
+ return page('That link does not work', 'It may have been copied only in part. Open it again from the email.', 400);
275
+ await o.store.stop(v.artifactId, v.emailKey, new Date().toISOString());
276
+ if (req.method === 'POST')
277
+ return new Response(null, { status: 204 });
278
+ const title = o.titleOf ? await o.titleOf(v.artifactId).catch(() => null) : null;
279
+ return page('Done', `You will get no more emails about resolved comments on ${title ? `&ldquo;${esc(title)}&rdquo;` : 'this page'}.`);
280
+ }
281
+ return { onResolve, flush, STOP, stopUrl, message };
282
+ }
@@ -81,6 +81,7 @@ export interface StateStore {
81
81
  export declare const readerKeyFor: {
82
82
  signedIn: (uid: string) => string;
83
83
  anonymous: (id: string) => string;
84
+ recipient: (rid: string) => string;
84
85
  };
85
86
  export declare function createStateStore(db: Firestore, base: string): StateStore;
86
87
  /** The same contract in memory: for tests, and for hosts' own tests. */
@@ -17,6 +17,7 @@ import { MAX_MANY_PER_READER } from './state.js';
17
17
  export const readerKeyFor = {
18
18
  signedIn: (uid) => `u:${uid}`,
19
19
  anonymous: (id) => `a:${id}`,
20
+ recipient: (rid) => `r:${rid}`,
20
21
  };
21
22
  const writerOf = (w) => ({
22
23
  ...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
@@ -22,6 +22,10 @@ export type ArtifactRecord = {
22
22
  narrationHash?: string;
23
23
  password?: string;
24
24
  access?: Access;
25
+ /** `per-reader`: opens only through a link made for one reader. */
26
+ links?: 'per-reader';
27
+ /** Body media are stored unlisted and served only to a valid reader. */
28
+ privateMedia?: true;
25
29
  state?: StateConfig;
26
30
  theme?: ThemeMode;
27
31
  /** The brand pack the page asked for by name; see ArtifactMeta.pack. */
@@ -193,6 +193,10 @@ async function saveArtifact(col, input) {
193
193
  const to = input.meta.to ? {} : { to: FieldValue.delete() };
194
194
  // How the page looks is content too: a line the file no longer carries goes back to the default.
195
195
  const look = {
196
+ // Who may open the page is a door, not content: like `access`, a file that leaves these lines
197
+ // out changes nothing, so a publisher that strips unknown keys can never reopen the page.
198
+ ...(input.meta.links ? { links: input.meta.links } : {}),
199
+ ...(input.meta.privateMedia ? { privateMedia: true } : {}),
196
200
  ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
197
201
  ...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
198
202
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
@@ -219,6 +223,8 @@ async function saveArtifact(col, input) {
219
223
  ...fields,
220
224
  ...(live.access ? { access: live.access } : {}),
221
225
  ...(live.password ? { password: live.password } : {}),
226
+ ...(input.meta.links ? { links: input.meta.links } : {}),
227
+ ...(input.meta.privateMedia ? { privateMedia: true } : {}),
222
228
  ...(live.state ? { state: live.state } : {}),
223
229
  ...(live.comments ? { comments: live.comments } : {}),
224
230
  ...(live.commentsVisible ? { commentsVisible: live.commentsVisible } : {}),
@@ -238,6 +244,9 @@ async function deleteArtifact(col, id) {
238
244
  const snap = await ref.get();
239
245
  if (!snap.exists)
240
246
  return false;
247
+ // Every prior body lives in the versions subcollection and outlives the page doc unless it goes too.
248
+ const past = await ref.collection('versions').get();
249
+ await Promise.all(past.docs.map((d) => ref.collection('versions').doc(d.id).delete()));
241
250
  await ref.delete();
242
251
  return true;
243
252
  }
@@ -1,5 +1,6 @@
1
1
  export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
2
2
  export { PlayerBar } from './player-bar.js';
3
+ export { QuietView } from './quiet-view.js';
3
4
  export { CommentLayer } from './comment-layer.js';
4
5
  export { PageMenu, roleLine, menuItems, type PageMenuProps, type PageRole } from './page-menu.js';
5
6
  export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY, type PageSettings, type SettingsFact, type SheetKind, type AccessLevel } from './settings-sheet.js';
@@ -2,6 +2,7 @@
2
2
  // own 'use client'; this barrel is plain re-exports so it can be imported from either side.
3
3
  export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
4
4
  export { PlayerBar } from './player-bar.js';
5
+ export { QuietView } from './quiet-view.js';
5
6
  export { CommentLayer } from './comment-layer.js';
6
7
  export { PageMenu, roleLine, menuItems } from './page-menu.js';
7
8
  export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY } from './settings-sheet.js';
@@ -0,0 +1,7 @@
1
+ export declare function QuietView({ id, rid, sig, token, endpoint }: {
2
+ id: string;
3
+ rid: string;
4
+ sig: string;
5
+ token: string;
6
+ endpoint?: string;
7
+ }): null;
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+ // The page's own word that a person looked at it. Silent: it renders nothing and never tells the
3
+ // reader (the publisher chose that). It waits until the page is actually on screen for two
4
+ // seconds, which a link preview fetcher never does because it never runs the page.
5
+ import { useEffect } from 'react';
6
+ const VIEW_AFTER_MS = 2000;
7
+ const BEAT_MS = 15_000;
8
+ const ALPHA = 'abcdefghijklmnopqrstuvwxyz0123456789';
9
+ const rand = (n) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => ALPHA[b % 36]).join('');
10
+ function deviceId() {
11
+ try {
12
+ let d = localStorage.getItem('artifact_device');
13
+ if (!d || !/^[a-z0-9]{16}$/.test(d)) {
14
+ d = rand(16);
15
+ localStorage.setItem('artifact_device', d);
16
+ }
17
+ return d;
18
+ }
19
+ catch {
20
+ return rand(16);
21
+ }
22
+ }
23
+ export function QuietView({ id, rid, sig, token, endpoint }) {
24
+ useEffect(() => {
25
+ const url = endpoint ?? `/api/artifacts/${id}/seen`;
26
+ const session = rand(16);
27
+ const device = deviceId();
28
+ const base = { rid, sig, session, device };
29
+ let beat = null;
30
+ let viewSent = false;
31
+ let wait = null;
32
+ let tick = null;
33
+ let since = 0;
34
+ const post = async (body) => {
35
+ try {
36
+ const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...base, ...body }), keepalive: true });
37
+ return r.ok ? (await r.json().catch(() => ({}))) : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ };
43
+ const visible = () => document.visibilityState === 'visible' && !document.prerendering;
44
+ const flush = (useBeacon) => {
45
+ if (!beat || !since)
46
+ return;
47
+ const seconds = Math.round((Date.now() - since) / 1000);
48
+ since = 0;
49
+ if (seconds <= 0)
50
+ return;
51
+ if (useBeacon && typeof navigator.sendBeacon === 'function') {
52
+ navigator.sendBeacon(url, new Blob([JSON.stringify({ ...base, event: 'beat', beat, seconds })], { type: 'application/json' }));
53
+ }
54
+ else
55
+ void post({ event: 'beat', beat, seconds });
56
+ };
57
+ const startBeats = () => {
58
+ since = Date.now();
59
+ tick = setInterval(() => { flush(false); since = Date.now(); }, BEAT_MS);
60
+ };
61
+ const arm = () => {
62
+ if (!visible())
63
+ return;
64
+ if (beat) {
65
+ if (!tick)
66
+ startBeats();
67
+ return;
68
+ }
69
+ // One render token, one view: once it is sent, a later show never sends another.
70
+ if (viewSent || wait)
71
+ return;
72
+ wait = setTimeout(async () => {
73
+ wait = null;
74
+ if (!visible())
75
+ return;
76
+ viewSent = true;
77
+ const r = await post({ event: 'view', token });
78
+ if (r?.beat && visible()) {
79
+ beat = r.beat;
80
+ startBeats();
81
+ }
82
+ else if (r?.beat)
83
+ beat = r.beat;
84
+ }, VIEW_AFTER_MS);
85
+ };
86
+ const disarm = () => {
87
+ if (wait) {
88
+ clearTimeout(wait);
89
+ wait = null;
90
+ }
91
+ if (tick) {
92
+ clearInterval(tick);
93
+ tick = null;
94
+ flush(true);
95
+ }
96
+ };
97
+ const onVisibility = () => (visible() ? arm() : disarm());
98
+ const onPlay = (e) => {
99
+ const el = e.target;
100
+ const media = el?.getAttribute?.('data-artifact-media');
101
+ if (media && beat)
102
+ void post({ event: 'play', beat, media });
103
+ };
104
+ document.addEventListener('visibilitychange', onVisibility);
105
+ document.addEventListener('prerenderingchange', onVisibility);
106
+ document.addEventListener('play', onPlay, true);
107
+ window.addEventListener('pagehide', disarm);
108
+ arm();
109
+ return () => {
110
+ document.removeEventListener('visibilitychange', onVisibility);
111
+ document.removeEventListener('prerenderingchange', onVisibility);
112
+ document.removeEventListener('play', onPlay, true);
113
+ window.removeEventListener('pagehide', disarm);
114
+ disarm();
115
+ };
116
+ }, [id, rid, sig, token, endpoint]);
117
+ return null;
118
+ }
@@ -4,10 +4,12 @@ import { type ArtifactRecord, type ArtifactStore, type VersionEntry } from '../a
4
4
  import type { StateStore } from '../artifacts/state-store.js';
5
5
  import { type ArtifactAssets } from '../artifacts/assets.js';
6
6
  import type { BrandPack } from '../brand/pack.js';
7
+ import type { RecipientsStore } from '../artifacts/recipients-store.js';
7
8
  import type { PageSettings, SettingsFact } from '../reader/settings-sheet.js';
8
9
  import { type Access, type AllowEntry } from '../artifacts/reader.js';
9
10
  import { type ReadersStore } from '../artifacts/readers-store.js';
10
11
  import { type ReaderEvent } from '../artifacts/read-alerts.js';
12
+ import type { ResolveEvent } from '../artifacts/resolve-digest.js';
11
13
  import type { PersonalStore } from '../artifacts/personal-store.js';
12
14
  import type { Transcriber } from '../artifacts/transcribe.js';
13
15
  export type ArtifactRoutesConfig = {
@@ -92,12 +94,22 @@ export type ArtifactRoutesConfig = {
92
94
  * event into the alerts worth sending). Awaited, and anything it throws is swallowed: a broken
93
95
  * sink never costs a reader their page or their record. Without it nothing extra is read. */
94
96
  onReaderEvent?: (event: ReaderEvent) => void | Promise<void>;
97
+ /** Told after a comment thread is resolved or reopened (0.34.0): the page, the whole thread, the
98
+ * resolution (null on reopen) and who did it. Pass `createResolveDigests(...).onResolve` to email
99
+ * each signed-in commenter one digest per burst of resolving. Awaited; anything it throws is
100
+ * logged and swallowed, so the resolve itself always lands. */
101
+ onResolve?: (event: ResolveEvent) => void | Promise<void>;
95
102
  /** The read-only lines under the controls in the page menu's Share settings sheet (0.31.0), for
96
103
  * a host that knows more than the package about how its pages are found: its robots.txt, who
97
104
  * its sign-in admits. Given, its rows REPLACE the package's own two (what a stranger with the
98
105
  * bare URL sees, and search engines). Called only for the owner and admins; a throw falls back
99
106
  * to the package's rows. */
100
107
  shareFacts?: (artifact: ArtifactRecord) => SettingsFact[] | Promise<SettingsFact[]>;
108
+ /** Who each per-reader page was sent to (recipients-store.ts). Without it, or without
109
+ * `recipientSecret`, a `links: per-reader` page is shut to everyone. */
110
+ recipients?: RecipientsStore;
111
+ /** Signs per-reader links and render tokens. At least 32 characters. Read at request time. */
112
+ recipientSecret?: () => string | undefined;
101
113
  };
102
114
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
103
115
  export declare const ARTIFACT_ID: RegExp;
@@ -109,6 +121,7 @@ type Params = {
109
121
  type PageProps = Params & {
110
122
  searchParams?: Promise<{
111
123
  key?: string | string[];
124
+ r?: string | string[];
112
125
  }>;
113
126
  };
114
127
  type VersionParams = {
@@ -120,11 +133,33 @@ type VersionParams = {
120
133
  type VersionProps = VersionParams & {
121
134
  searchParams?: Promise<{
122
135
  key?: string | string[];
136
+ r?: string | string[];
123
137
  }>;
124
138
  };
125
139
  export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
126
140
  dynamic: "force-dynamic";
127
141
  maxDuration: number;
142
+ SEEN: (req: NextRequest, { params }: {
143
+ params: Promise<{
144
+ id: string;
145
+ }>;
146
+ }) => Promise<NextResponse<unknown>>;
147
+ RECIPIENTS: (req: NextRequest, { params }: {
148
+ params: Promise<{
149
+ id: string;
150
+ }>;
151
+ }) => Promise<NextResponse<unknown>>;
152
+ VIEWS: (req: NextRequest, { params }: {
153
+ params: Promise<{
154
+ id: string;
155
+ }>;
156
+ }) => Promise<NextResponse<unknown>>;
157
+ sweepExpired: (now?: Date) => Promise<{
158
+ pages: number;
159
+ files: number;
160
+ skipped: string[];
161
+ failed: string[];
162
+ }>;
128
163
  PERSONAL_GET: (req: NextRequest, { params }: {
129
164
  params: Promise<{
130
165
  id: string;