@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,87 @@
1
+ // src/artifacts/recipients.ts
2
+ // A page published with `links: per-reader` opens only through a link made for one reader. The
3
+ // link names an opaque rid and a signature over the page and the rid, so a rid cannot be guessed
4
+ // or carried to another page. A view is counted only from a signal the rendered page sends, and
5
+ // that signal carries a token minted for this one render: a fetch that never runs the page (every
6
+ // messaging app's preview fetcher) can never produce one.
7
+ import { createHmac, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
8
+ export const RID_RE = /^[a-z0-9]{12}$/;
9
+ const SIG_RE = /^[0-9a-f]{16}$/;
10
+ export const RID_COOKIE_MAX_AGE = 7_776_000;
11
+ export const VIEW_AFTER_MS = 2000;
12
+ export const TOKEN_MAX_AGE_MS = 86_400_000;
13
+ const ALPHA = 'abcdefghijklmnopqrstuvwxyz0123456789';
14
+ /** A signing secret shorter than this is treated as unset by the routes: the page shuts. */
15
+ export const RECIPIENT_SECRET_MIN = 32;
16
+ export function usableSecret(secret) {
17
+ return typeof secret === 'string' && secret.length >= RECIPIENT_SECRET_MIN ? secret : undefined;
18
+ }
19
+ const mac = (secret, text) => {
20
+ // An empty key still produces a valid MAC that anyone can compute; never sign with one.
21
+ if (!secret)
22
+ throw new Error('a signing secret is required');
23
+ return createHmac('sha256', secret).update(text).digest('hex').slice(0, 16);
24
+ };
25
+ const same = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
26
+ export function newRid() {
27
+ const bytes = randomBytes(12);
28
+ let s = '';
29
+ for (const b of bytes)
30
+ s += ALPHA[b % ALPHA.length];
31
+ return s;
32
+ }
33
+ export function ridSig(secret, id, rid) {
34
+ return mac(secret, `${id}\n${rid}`);
35
+ }
36
+ export function ridLink(pageUrl, id, rid, secret) {
37
+ return `${pageUrl}?r=${rid}.${ridSig(secret, id, rid)}`;
38
+ }
39
+ export function parseRidParam(v) {
40
+ if (typeof v !== 'string')
41
+ return null;
42
+ const [rid, sig, extra] = v.split('.');
43
+ if (extra !== undefined || !RID_RE.test(rid ?? '') || !SIG_RE.test(sig ?? ''))
44
+ return null;
45
+ return { rid, sig };
46
+ }
47
+ export function verifyRid(secret, id, rid, sig) {
48
+ if (!secret || !RID_RE.test(rid) || !SIG_RE.test(sig))
49
+ return false;
50
+ return same(ridSig(secret, id, rid), sig);
51
+ }
52
+ export function ridCookieName(id) {
53
+ return `artifact_r_${id}`;
54
+ }
55
+ /** `<time base36>.<nonce 16 hex>.<mac 16 hex>`, minted once per server render. */
56
+ export function mintRenderToken(secret, id, rid, now = Date.now()) {
57
+ const ts = now.toString(36);
58
+ const nonce = randomBytes(8).toString('hex');
59
+ return `${ts}.${nonce}.${mac(secret, `${id}\n${rid}\n${ts}\n${nonce}`)}`;
60
+ }
61
+ export function verifyRenderToken(secret, id, rid, token, now = Date.now()) {
62
+ const parts = String(token ?? '').split('.');
63
+ if (parts.length !== 3)
64
+ return { ok: false, why: 'malformed' };
65
+ if (!secret)
66
+ return { ok: false, why: 'no secret' };
67
+ const [ts, nonce, m] = parts;
68
+ if (!/^[0-9a-z]{1,12}$/.test(ts) || !SIG_RE.test(nonce) || !SIG_RE.test(m))
69
+ return { ok: false, why: 'malformed' };
70
+ if (!same(mac(secret, `${id}\n${rid}\n${ts}\n${nonce}`), m))
71
+ return { ok: false, why: 'not minted here' };
72
+ const at = parseInt(ts, 36);
73
+ if (!Number.isFinite(at) || now - at > TOKEN_MAX_AGE_MS || at - now > 60_000)
74
+ return { ok: false, why: 'expired' };
75
+ return { ok: true, key: createHash('sha256').update(token).digest('hex').slice(0, 32), expiresAt: at + TOKEN_MAX_AGE_MS };
76
+ }
77
+ export function beatKey(secret, id, rid, session) {
78
+ return mac(secret, `beat\n${id}\n${rid}\n${session}`);
79
+ }
80
+ /** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
81
+ * `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
82
+ export const UNFURL_AGENTS = /facebookexternalhit|facebot|twitterbot|slackbot|whatsapp|linkedinbot|discordbot|telegrambot|skypeuripreview|redditbot|applebot|googlebot|bingbot|embedly|iframely|pinterest|bitlybot|vkshare|mastodon|preview/i;
83
+ export function isUnfurlAgent(ua) {
84
+ if (!ua || !ua.trim())
85
+ return true;
86
+ return UNFURL_AGENTS.test(ua);
87
+ }
@@ -77,7 +77,18 @@ const VIDEO = /\.mp4(?:[?#].*)?$/i;
77
77
  /** `![caption](x.mp3)` is a clip the reader plays: an annotated conversation, a voice note, a
78
78
  * quote heard rather than read. Native controls, and `preload="none"` so a page of clips fetches
79
79
  * nothing until one is pressed. The alt is its caption, shown above the player. */
80
- const AUDIO = /\.mp3(?:[?#].*)?$/i;
80
+ const AUDIO = /\.(mp3|m4a)(?:[?#].*)?$/i;
81
+ /** The stored file's name, for play tracking: the last path segment before any query. */
82
+ const mediaName = (src) => {
83
+ const last = src.split(/[?#]/)[0].split('/').pop() ?? '';
84
+ try {
85
+ return decodeURIComponent(last);
86
+ }
87
+ catch {
88
+ return last;
89
+ }
90
+ };
91
+ const SOUND = /^sound:\s*/i;
81
92
  const VIDEO_IN_MD = /\]\([^)\s]+\.mp4(?:[?#][^)]*)?\)/i;
82
93
  // A fenced block, which `links` and `notes` fences are too; mounting for those costs one
83
94
  // no-op effect and is cheaper than parsing the markdown twice to find out.
@@ -88,11 +99,15 @@ const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').repla
88
99
  // iPhone from going full screen. No `autoplay`: VideoAutoplay starts each one as it nears the
89
100
  // screen, so a page of videos does not download them all on open.
90
101
  function InlineVideo({ src, label }) {
91
- const html = `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-video muted loop playsinline preload="metadata" disablepictureinpicture class="block w-full"></video>`;
102
+ const name = escapeAttr(mediaName(src));
103
+ // `sound:` in the alt is a moment to watch: controls and sound, started by the reader.
104
+ const html = SOUND.test(label)
105
+ ? `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label.replace(SOUND, ''))}" data-artifact-media="${name}" controls playsinline preload="metadata" class="block w-full"></video>`
106
+ : `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-video data-artifact-media="${name}" muted loop playsinline preload="metadata" disablepictureinpicture class="block w-full"></video>`;
92
107
  return _jsx("span", { className: "my-6 block overflow-hidden rounded-lg border border-[color:var(--a-line)]", dangerouslySetInnerHTML: { __html: html } });
93
108
  }
94
109
  function InlineAudio({ src, label }) {
95
- const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
110
+ const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio data-artifact-media="${escapeAttr(mediaName(src))}" controls preload="none" class="block w-full"></audio>`;
96
111
  return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
97
112
  }
98
113
  /** An embed block (```simulator, ```messages): the player in a frame when the host names one, its
@@ -0,0 +1,8 @@
1
+ import type { Firestore } from 'firebase-admin/firestore';
2
+ import { type Burst, type Digest, type ResolveDigestStore } from './resolve-digest.js';
3
+ export declare function createResolveDigestStore(db: Firestore, base: string): ResolveDigestStore;
4
+ /** The same contract in memory: for tests, and for hosts' own tests. */
5
+ export declare function createMemoryResolveDigestStore(): ResolveDigestStore & {
6
+ digests(): Digest[];
7
+ bursts(): Burst[];
8
+ };
@@ -0,0 +1,194 @@
1
+ // Where resolution digests wait and are recorded (0.34.0). Beside the readers' state, per tenant:
2
+ //
3
+ // <base>ResolveBursts/<page>__<emailKey> one reader's open burst on one page; deleted when claimed
4
+ // <base>ResolveDigests/<burstId> a claimed burst, frozen: pending, then sent, skipped or failed
5
+ // <base>ResolveStops/<page>__<emailKey> the reader followed the stop link for that page
6
+ //
7
+ // Items are stored as a JSON string (`json`), as the state store does, so a quote or a body can
8
+ // never trip Firestore's rules on nested values. Every query is single-field (Firestore indexes
9
+ // those itself), so no composite index is needed.
10
+ //
11
+ // The Firestore implementation has no emulator test here; it is proven on the live host after each
12
+ // release that changes it. The memory implementation carries the contract tests.
13
+ import { randomUUID } from 'node:crypto';
14
+ import { burstKey, itemsOf } from './resolve-digest.js';
15
+ const toBurst = (key, d) => { const { json, ...rest } = d; return { key, ...rest, threads: JSON.parse(json) }; };
16
+ const toDigest = (id, d) => { const { json, ...rest } = d; return { id, ...rest, items: JSON.parse(json) }; };
17
+ const isDue = (b, quietBefore, maxBefore) => b.lastAt <= quietBefore || b.firstAt <= maxBefore;
18
+ const digestOf = (b, now) => ({
19
+ id: b.burstId, artifactId: b.artifactId, email: b.email, name: b.name, title: b.title, url: b.url,
20
+ items: itemsOf(b.threads), status: 'pending', createdAt: now, leaseUntil: now, attempts: 0,
21
+ });
22
+ const stopId = (artifactId, key) => `${artifactId}__${key}`;
23
+ export function createResolveDigestStore(db, base) {
24
+ const bursts = () => db.collection(`${base}ResolveBursts`);
25
+ const digests = () => db.collection(`${base}ResolveDigests`);
26
+ const stops = () => db.collection(`${base}ResolveStops`);
27
+ return {
28
+ async add({ artifactId, email, name, title, url, threadId, items, at }) {
29
+ const ref = bursts().doc(burstKey(artifactId, email));
30
+ await db.runTransaction(async (tx) => {
31
+ const snap = await tx.get(ref);
32
+ const cur = snap.exists ? toBurst(ref.id, snap.data()) : null;
33
+ const threads = { ...(cur?.threads ?? {}), [threadId]: items };
34
+ const next = {
35
+ burstId: cur?.burstId ?? randomUUID(), artifactId, email, name: name ?? cur?.name ?? null, title, url,
36
+ firstAt: cur?.firstAt ?? at, lastAt: cur && cur.lastAt > at ? cur.lastAt : at, json: JSON.stringify(threads),
37
+ };
38
+ tx.set(ref, next);
39
+ });
40
+ },
41
+ async dropThread(artifactId, threadId) {
42
+ const docs = (await bursts().where('artifactId', '==', artifactId).get()).docs;
43
+ let changed = 0;
44
+ for (const d of docs) {
45
+ await db.runTransaction(async (tx) => {
46
+ const snap = await tx.get(d.ref);
47
+ if (!snap.exists)
48
+ return;
49
+ const b = toBurst(d.id, snap.data());
50
+ if (!(threadId in b.threads))
51
+ return;
52
+ delete b.threads[threadId];
53
+ changed++;
54
+ if (!Object.keys(b.threads).length)
55
+ tx.delete(d.ref);
56
+ else
57
+ tx.update(d.ref, { json: JSON.stringify(b.threads) });
58
+ });
59
+ }
60
+ return changed;
61
+ },
62
+ async due(quietBefore, maxBefore, limit) {
63
+ const [quiet, old] = await Promise.all([
64
+ bursts().where('lastAt', '<=', quietBefore).limit(limit).get(),
65
+ bursts().where('firstAt', '<=', maxBefore).limit(limit).get(),
66
+ ]);
67
+ const out = new Map();
68
+ for (const d of [...quiet.docs, ...old.docs])
69
+ out.set(d.id, toBurst(d.id, d.data()));
70
+ return [...out.values()].slice(0, limit);
71
+ },
72
+ async claim({ key, burstId, quietBefore, maxBefore, now }) {
73
+ const ref = bursts().doc(key);
74
+ return db.runTransaction(async (tx) => {
75
+ const snap = await tx.get(ref);
76
+ if (!snap.exists)
77
+ return null;
78
+ const b = toBurst(key, snap.data());
79
+ if (b.burstId !== burstId || !isDue(b, quietBefore, maxBefore))
80
+ return null;
81
+ const dref = digests().doc(b.burstId);
82
+ if ((await tx.get(dref)).exists) {
83
+ tx.delete(ref);
84
+ return null;
85
+ }
86
+ const d = digestOf(b, now);
87
+ const { id: _id, items, ...rest } = d;
88
+ tx.create(dref, { ...rest, json: JSON.stringify(items) });
89
+ tx.delete(ref);
90
+ return d;
91
+ });
92
+ },
93
+ async pending(now, limit) {
94
+ // Equality only, so no composite index; the lease filter runs here. Pending is small.
95
+ const docs = (await digests().where('status', '==', 'pending').limit(limit * 4).get()).docs;
96
+ return docs.map((d) => toDigest(d.id, d.data()))
97
+ .filter((d) => d.leaseUntil <= now)
98
+ .sort((x, y) => x.createdAt.localeCompare(y.createdAt))
99
+ .slice(0, limit);
100
+ },
101
+ async lease(id, now, until) {
102
+ const ref = digests().doc(id);
103
+ return db.runTransaction(async (tx) => {
104
+ const snap = await tx.get(ref);
105
+ if (!snap.exists)
106
+ return null;
107
+ const d = toDigest(id, snap.data());
108
+ if (d.status !== 'pending' || d.leaseUntil > now)
109
+ return null;
110
+ tx.update(ref, { leaseUntil: until, attempts: d.attempts + 1 });
111
+ return { ...d, leaseUntil: until, attempts: d.attempts + 1 };
112
+ });
113
+ },
114
+ async finish(id, { status, at, reason, providerId }) {
115
+ await digests().doc(id).update({
116
+ status, ...(status === 'sent' ? { sentAt: at } : {}), ...(reason ? { reason } : {}), ...(providerId ? { providerId } : {}),
117
+ });
118
+ },
119
+ async release(id, retryAt) {
120
+ await digests().doc(id).update({ leaseUntil: retryAt });
121
+ },
122
+ async stop(artifactId, key, at) {
123
+ await stops().doc(stopId(artifactId, key)).set({ artifactId, emailKey: key, at });
124
+ },
125
+ stopped: async (artifactId, key) => (await stops().doc(stopId(artifactId, key)).get()).exists,
126
+ };
127
+ }
128
+ /** The same contract in memory: for tests, and for hosts' own tests. */
129
+ export function createMemoryResolveDigestStore() {
130
+ const bursts = new Map();
131
+ const digests = new Map();
132
+ const stopSet = new Set();
133
+ return {
134
+ async add({ artifactId, email, name, title, url, threadId, items, at }) {
135
+ const key = burstKey(artifactId, email);
136
+ const cur = bursts.get(key);
137
+ bursts.set(key, {
138
+ key, burstId: cur?.burstId ?? randomUUID(), artifactId, email, name: name ?? cur?.name ?? null, title, url,
139
+ firstAt: cur?.firstAt ?? at, lastAt: cur && cur.lastAt > at ? cur.lastAt : at,
140
+ threads: { ...(cur?.threads ?? {}), [threadId]: structuredClone(items) },
141
+ });
142
+ },
143
+ async dropThread(artifactId, threadId) {
144
+ let changed = 0;
145
+ for (const b of [...bursts.values()].filter((b) => b.artifactId === artifactId && threadId in b.threads)) {
146
+ delete b.threads[threadId];
147
+ changed++;
148
+ if (!Object.keys(b.threads).length)
149
+ bursts.delete(b.key);
150
+ }
151
+ return changed;
152
+ },
153
+ due: async (quietBefore, maxBefore, limit) => [...bursts.values()].filter((b) => isDue(b, quietBefore, maxBefore)).slice(0, limit).map((b) => structuredClone(b)),
154
+ async claim({ key, burstId, quietBefore, maxBefore, now }) {
155
+ const b = bursts.get(key);
156
+ if (!b || b.burstId !== burstId || !isDue(b, quietBefore, maxBefore))
157
+ return null;
158
+ bursts.delete(key);
159
+ if (digests.has(b.burstId))
160
+ return null;
161
+ const d = digestOf(b, now);
162
+ digests.set(d.id, d);
163
+ return structuredClone(d);
164
+ },
165
+ pending: async (now, limit) => [...digests.values()].filter((d) => d.status === 'pending' && d.leaseUntil <= now)
166
+ .sort((x, y) => x.createdAt.localeCompare(y.createdAt)).slice(0, limit).map((d) => structuredClone(d)),
167
+ async lease(id, now, until) {
168
+ const d = digests.get(id);
169
+ if (!d || d.status !== 'pending' || d.leaseUntil > now)
170
+ return null;
171
+ d.leaseUntil = until;
172
+ d.attempts++;
173
+ return structuredClone(d);
174
+ },
175
+ async finish(id, { status, at, reason, providerId }) {
176
+ const d = digests.get(id);
177
+ if (!d)
178
+ return;
179
+ d.status = status;
180
+ if (status === 'sent')
181
+ d.sentAt = at;
182
+ if (reason)
183
+ d.reason = reason;
184
+ if (providerId)
185
+ d.providerId = providerId;
186
+ },
187
+ async release(id, retryAt) { const d = digests.get(id); if (d)
188
+ d.leaseUntil = retryAt; },
189
+ async stop(artifactId, key) { stopSet.add(stopId(artifactId, key)); },
190
+ stopped: async (artifactId, key) => stopSet.has(stopId(artifactId, key)),
191
+ digests: () => [...digests.values()].map((d) => structuredClone(d)),
192
+ bursts: () => [...bursts.values()].map((b) => structuredClone(b)),
193
+ };
194
+ }
@@ -0,0 +1,236 @@
1
+ import type { CommentResolution } from './comments.js';
2
+ import type { StateEntry } from './state-store.js';
3
+ /** A burst closes after this long with no new resolution for that reader on that page. */
4
+ export declare const RESOLVE_DIGEST_QUIET_MS: number;
5
+ /** A burst that keeps growing is sent anyway once its first resolution is this old. */
6
+ export declare const RESOLVE_DIGEST_MAX_WAIT_MS: number;
7
+ /** A digest leased for sending and not recorded as sent is retried after this long. */
8
+ export declare const RESOLVE_DIGEST_LEASE_MS: number;
9
+ /** After this many attempts a digest is recorded as failed and never tried again. */
10
+ export declare const RESOLVE_DIGEST_MAX_ATTEMPTS = 5;
11
+ /** How much of a reader's comment, and of the passage it is pinned to, the email quotes. */
12
+ export declare const DIGEST_BODY_CHARS = 280;
13
+ export declare const DIGEST_QUOTE_CHARS = 160;
14
+ /** Where the stop link lands, under the host's origin. */
15
+ export declare const RESOLVE_DIGEST_STOP_PATH = "/api/resolve-digest/stop";
16
+ /** What the resolve route tells a host after a thread is resolved or reopened. */
17
+ export type ResolveEvent = {
18
+ artifactId: string;
19
+ title: string;
20
+ /** The page's address, e.g. https://artifacts.example.com/abc23456. */
21
+ url: string;
22
+ /** The thread: its top comment's id and every comment in it, top comment first, oldest first. */
23
+ thread: {
24
+ id: string;
25
+ entries: StateEntry[];
26
+ };
27
+ /** The resolution just recorded, or null when the thread was reopened. */
28
+ resolution: CommentResolution | null;
29
+ /** Who did it: a signed-in owner or admin (their email), or the publish key or a publisher pass. */
30
+ actor: {
31
+ email?: string;
32
+ publisher: boolean;
33
+ };
34
+ at: string;
35
+ };
36
+ /** One of a reader's comments in a resolved thread, as the email lists it. */
37
+ export type DigestItem = {
38
+ entryId: string;
39
+ threadId: string;
40
+ /** The passage the thread is pinned to, trimmed; null for a box drawn over the page. */
41
+ quote: string | null;
42
+ /** The reader's comment, trimmed. A voice memo with no text says so. */
43
+ body: string;
44
+ note: string | null;
45
+ by: string;
46
+ resolvedAt: string;
47
+ };
48
+ /** A reader's open burst on one page: the threads resolved so far, waiting for the quiet window. */
49
+ export type Burst = {
50
+ key: string;
51
+ burstId: string;
52
+ artifactId: string;
53
+ email: string;
54
+ name: string | null;
55
+ title: string;
56
+ url: string;
57
+ firstAt: string;
58
+ lastAt: string;
59
+ threads: Record<string, DigestItem[]>;
60
+ };
61
+ export type DigestStatus = 'pending' | 'sent' | 'skipped' | 'failed';
62
+ /** A claimed burst, frozen: what one email says. Its id is the burst's id, and the idempotency key. */
63
+ export type Digest = {
64
+ id: string;
65
+ artifactId: string;
66
+ email: string;
67
+ name: string | null;
68
+ title: string;
69
+ url: string;
70
+ items: DigestItem[];
71
+ status: DigestStatus;
72
+ createdAt: string;
73
+ leaseUntil: string;
74
+ attempts: number;
75
+ sentAt?: string;
76
+ reason?: string;
77
+ providerId?: string;
78
+ };
79
+ export interface ResolveDigestStore {
80
+ /** Put this thread's items into the reader's open burst on this page (replacing the thread's
81
+ * earlier items, if it was resolved again), opening a burst when none is waiting. */
82
+ add(input: {
83
+ artifactId: string;
84
+ email: string;
85
+ name: string | null;
86
+ title: string;
87
+ url: string;
88
+ threadId: string;
89
+ items: DigestItem[];
90
+ at: string;
91
+ }): Promise<void>;
92
+ /** Take this thread out of every burst still waiting on this page (it was reopened). A burst
93
+ * left with no thread is deleted. Returns how many bursts changed. */
94
+ dropThread(artifactId: string, threadId: string): Promise<number>;
95
+ /** Bursts whose last resolution is at or before `quietBefore`, or whose first is at or before
96
+ * `maxBefore`. */
97
+ due(quietBefore: string, maxBefore: string, limit: number): Promise<Burst[]>;
98
+ /** Atomically turn one due burst into a pending digest and delete the burst. Null when the
99
+ * burst is gone, was replaced, or is no longer due (a resolution landed since `due`). */
100
+ claim(input: {
101
+ key: string;
102
+ burstId: string;
103
+ quietBefore: string;
104
+ maxBefore: string;
105
+ now: string;
106
+ }): Promise<Digest | null>;
107
+ /** Pending digests whose lease has run out (or was never taken), oldest first. */
108
+ pending(now: string, limit: number): Promise<Digest[]>;
109
+ /** Atomically take the lease on one pending digest whose lease has run out: sets `leaseUntil`
110
+ * and counts an attempt. Null when it is not pending or another run holds it. */
111
+ lease(id: string, now: string, until: string): Promise<Digest | null>;
112
+ /** Record how a leased digest ended. */
113
+ finish(id: string, outcome: {
114
+ status: Exclude<DigestStatus, 'pending'>;
115
+ at: string;
116
+ reason?: string;
117
+ providerId?: string;
118
+ }): Promise<void>;
119
+ /** A send failed: leave it pending, retried once `retryAt` passes. */
120
+ release(id: string, retryAt: string): Promise<void>;
121
+ /** The reader followed the stop link for this page. Keyed by `emailKey`, so no address is stored. */
122
+ stop(artifactId: string, emailKey: string, at: string): Promise<void>;
123
+ stopped(artifactId: string, emailKey: string): Promise<boolean>;
124
+ }
125
+ /** One email, ready for a provider. `idempotencyKey` is the same on every retry of one digest. */
126
+ export type DigestMessage = {
127
+ to: string;
128
+ subject: string;
129
+ text: string;
130
+ html: string;
131
+ /** List-Unsubscribe and List-Unsubscribe-Post (RFC 8058), pointing at the stop link. */
132
+ headers: Record<string, string>;
133
+ idempotencyKey: string;
134
+ };
135
+ /** The host's email provider. Throw to have the digest retried on the next run. */
136
+ export type DigestSender = (m: DigestMessage) => Promise<{
137
+ id?: string;
138
+ } | void>;
139
+ /** A stable, non-reversible key for an address, used in document ids and stop links. */
140
+ export declare function emailKey(email: string): string;
141
+ export declare const burstKey: (artifactId: string, email: string) => string;
142
+ /** The notices one resolve event queues: per signed-in writer with an address, the comments in
143
+ * the thread that THEY wrote. Never the person resolving, the page's owner or its admins, never
144
+ * an anonymous writer, and never a comment somebody else wrote. A reopen queues nothing. Pure. */
145
+ export declare function noticesFor(event: ResolveEvent, o?: {
146
+ ownerEmail?: string;
147
+ admins?: readonly string[];
148
+ }): {
149
+ email: string;
150
+ name: string | null;
151
+ items: DigestItem[];
152
+ }[];
153
+ /** A digest's items in the order the email lists them: by when each thread was resolved. */
154
+ export declare function itemsOf(threads: Record<string, DigestItem[]>): DigestItem[];
155
+ /** `<artifactId>.<emailKey>.<signature>`: names the page and the reader (by key, not address). */
156
+ export declare function stopToken(secret: string, artifactId: string, email: string): string;
157
+ export declare function verifyStopToken(secret: string | undefined, token: string | null | undefined): {
158
+ artifactId: string;
159
+ emailKey: string;
160
+ } | null;
161
+ /** Who resolved, as a person reads it: the names on the resolutions, with "the publisher" read as
162
+ * the page's owner (an agent resolving with the publish key is resolving on the owner's behalf). */
163
+ export declare function resolverName(items: DigestItem[], fallback: string): string;
164
+ export type ComposeOptions = {
165
+ /** Whose pages these are, as the email signs off: "Example Co". */
166
+ brandName: string;
167
+ /** Who "the publisher" is when an agent resolved: the owner's first name. Default `brandName`. */
168
+ ownerName?: string;
169
+ stopUrl: string;
170
+ };
171
+ /** The digest as an email. Plain words, no em dashes, every comment linked to its place. */
172
+ export declare function composeDigest(d: Pick<Digest, 'name' | 'title' | 'url' | 'items'>, o: ComposeOptions): {
173
+ subject: string;
174
+ text: string;
175
+ html: string;
176
+ };
177
+ /** An address as a report shows it: enough to recognise, not enough to collect. */
178
+ export declare function maskEmail(email: string): string;
179
+ export type ResolveDigestOptions = {
180
+ store: ResolveDigestStore;
181
+ send: DigestSender;
182
+ /** Signs stop links. Read at call time. Without it nothing is queued and nothing is sent, since
183
+ * every digest must carry a working stop link. */
184
+ secret: () => string | undefined;
185
+ /** The host's origin, e.g. https://artifacts.example.com. */
186
+ siteUrl: string;
187
+ brandName: string;
188
+ ownerName?: string;
189
+ /** Never notified about their own comments, like the person resolving. */
190
+ ownerEmail?: string;
191
+ admins?: readonly string[];
192
+ quietMs?: number;
193
+ maxWaitMs?: number;
194
+ /** The page's title for the stop page. Optional. */
195
+ titleOf?: (artifactId: string) => Promise<string | null>;
196
+ stopPath?: string;
197
+ };
198
+ export type FlushReport = {
199
+ now: string;
200
+ dryRun: boolean;
201
+ due: number;
202
+ claimed: number;
203
+ sent: {
204
+ id: string;
205
+ to: string;
206
+ subject: string;
207
+ comments: number;
208
+ }[];
209
+ skipped: {
210
+ id: string;
211
+ reason: string;
212
+ }[];
213
+ failed: {
214
+ id: string;
215
+ error: string;
216
+ }[];
217
+ /** dryRun only: what a real run would send now. */
218
+ wouldSend?: {
219
+ to: string;
220
+ subject: string;
221
+ comments: number;
222
+ artifactId: string;
223
+ stopped: boolean;
224
+ }[];
225
+ };
226
+ export declare function createResolveDigests(o: ResolveDigestOptions): {
227
+ onResolve: (event: ResolveEvent) => Promise<void>;
228
+ flush: (opts?: {
229
+ now?: Date;
230
+ dryRun?: boolean;
231
+ limit?: number;
232
+ }) => Promise<FlushReport>;
233
+ STOP: (req: Request) => Promise<Response>;
234
+ stopUrl: (artifactId: string, email: string) => string | null;
235
+ message: (d: Digest) => DigestMessage | null;
236
+ };