@supersuit/artifacts 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,78 @@
3
3
  `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
4
  host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
5
 
6
+ ## 0.34.0
7
+
8
+ **Commenters hear when their comments are resolved, in one email per page per burst** (Gary,
9
+ 2026-10-12: "Can those notifications around resolved comments batch plz"). 0.33.0 let the owner
10
+ resolve threads, but the reader who asked never found out unless someone texted them.
11
+
12
+ - `createResolveDigests({ store, send, secret, siteUrl, brandName, ownerName?, ownerEmail?, admins?,
13
+ quietMs?, maxWaitMs? })` returns `onResolve` (the route hook), `flush({ now?, dryRun? })` (run it on
14
+ a schedule), `STOP` (the stop link's GET and POST) and `stopUrl`. The package composes and batches;
15
+ the host's `send` delivers.
16
+ - `onResolve` on `createArtifactRoutes` (and the state routes): told after every resolve and reopen
17
+ with the page, the whole thread, the resolution (null on reopen) and who did it (`actor.email` for
18
+ a signed-in owner or admin, `actor.publisher` for the key or a pass). Awaited; a throw is logged and
19
+ the resolve still lands.
20
+ - Who hears: each signed-in writer of a comment in the thread, about their own comments only; never
21
+ an anonymous reader, the person resolving, the owner or the admins. A burst closes after 10 quiet
22
+ minutes for that reader on that page (or 6 hours after its first resolution); a reopened thread
23
+ drops out. Only resolutions recorded through `onResolve` queue anything, so threads resolved before
24
+ a host wires it never produce an email.
25
+ - Sent once: claim and lease are transactions, and every retry of one digest carries the same
26
+ `idempotencyKey` for the provider. Every email has a signed stop link (per reader, per page) and
27
+ `List-Unsubscribe` headers.
28
+ - `createResolveDigestStore(db, base)` (Firestore, beside the state store: `<base>ResolveBursts`,
29
+ `<base>ResolveDigests`, `<base>ResolveStops`; no composite index) and
30
+ `createMemoryResolveDigestStore()`.
31
+ - DETECTOR: `createResolveDigests` is not exported, or `createArtifactRoutes` takes no `onResolve`:
32
+ 0.33.1 or earlier. Nothing changes for a host that does not wire it.
33
+ - REMEDY: to turn digests on, build `createResolveDigests` with your store, sender and a stop-link
34
+ secret, pass `onResolve: digests.onResolve`, add `app/api/resolve-digest/stop/route.ts` (GET and
35
+ POST = `digests.STOP`), and call `digests.flush()` from a scheduled job (every 5 minutes is plenty).
36
+ Otherwise update the dependency; nothing else.
37
+ - Tests: `artifacts/resolve-digest.test.ts` (who hears: writer only, replies by others, anonymous,
38
+ owner, admin and the resolver excluded; three threads make one digest; one per reader per page;
39
+ resolving again replaces; the quiet window and its restart; the 6-hour cap; a new burst after a
40
+ claim; reopen drops a thread and an emptied burst sends nothing; double and overlapping flushes send
41
+ once; a failed send retried with the same idempotency key; an expired lease after a crash; no
42
+ backfill; the stop link signed, refused when tampered, honoured for that page only, by GET and POST;
43
+ no secret queues nothing; dry run changes nothing; the email's words, links, escaping and no em
44
+ dash), `routes/resolve-digest.test.tsx` (the route hands the thread and the actor to `onResolve`,
45
+ reopen and the publish key too, a throwing hook never costs the resolve, end to end through RESOLVE
46
+ and flush, nothing for threads resolved before wiring), and `check-packed` (from the tarball: a
47
+ signed-in comment resolved with the key, nothing due inside the window, the dry run, one digest
48
+ sent once, its stop link).
49
+
50
+ ## 0.33.1
51
+
52
+ **The player bar: the speed button no longer reflows the bar, 2x is a speed, and the narrator
53
+ line is gone** (Gary, 2026-10-12: "The bottom bar speed dial shouldn't cause reflow when clicked.
54
+ 2x should be an option. And read by freedom should be removed."). Ships after 0.33.0, which was
55
+ already tagged when these were asked for.
56
+
57
+ - **No reflow.** The speed button sized itself to its label, so "1x" (30px) to "1.25x" (48px) made
58
+ it grow and the seek line beside it shrink by 18px on every tap. It is now a fixed 56px
59
+ (`w-14`), centred, never wrapping, with tabular figures, and a tap inserts nothing into the bar.
60
+ Measured in Chromium at 390 and 1280 wide with the bar rendered at every speed: 0.32.0's button
61
+ was 29.86, 47.56, 40.17 and 31.55px wide and the seek line moved with it; 0.33.1's bar, button
62
+ and seek line have identical boxes at every speed.
63
+ - **2x.** The button steps 1x, 1.25x, 1.5x, 2x and back to 1x (`RATES`, exported beside
64
+ `nextRate`).
65
+ - **No "Read aloud by Freedom".** The bar shows no narrator line. `PlayerBar`'s and
66
+ `ArtifactReader`'s `label` and `BrandPack.narratorLabel` are optional and ignored, so a host or
67
+ pack that still sets them compiles and shows nothing; `freedomDefault` no longer sets one. The
68
+ narration itself is unchanged.
69
+ - DETECTOR: on a narrated page, a line above the seek bar reading "Read aloud by ...", or the
70
+ speed button changing width as you tap it, or no 2x: 0.33.0 or earlier.
71
+ - REMEDY: update the dependency. A host may delete its own `narratorLabel` from its packs (it is
72
+ ignored either way). Nothing else.
73
+ - Tests: `reader/player-bar.test.tsx` (no narrator text even when a label is passed; the 1, 1.25,
74
+ 1.5, 2 cycle; GUARD: one class list and one node count across every speed, with `w-14`,
75
+ `whitespace-nowrap`, `tabular-nums` and no horizontal padding). jsdom has no layout, so the
76
+ measured proof is the Chromium check above, run against the built `lib` with the host's Tailwind.
77
+
6
78
  ## 0.33.0
7
79
 
8
80
  **A comment thread can be resolved** (Gary, 2026-10-12: "Are you going to resolve the comments at
package/README.md CHANGED
@@ -745,6 +745,48 @@ type Ctx = { params: Promise<{ id: string }> }
745
745
  export function POST(request: NextRequest, ctx: Ctx) { return artifacts.RESOLVE(request, ctx) }
746
746
  ```
747
747
 
748
+ ### Telling commenters their comments were resolved (`createResolveDigests`)
749
+
750
+ When threads are resolved, each signed-in reader whose comments were in them can get ONE email per
751
+ page per burst of resolving, listing every one of their comments resolved in it: the passage it was
752
+ pinned to (or "a box on the page"), their comment trimmed, the resolution note, a link to
753
+ `<page>#comment-<id>`, and one link to the page. Resolve nineteen threads in a sitting and a reader
754
+ gets one email, not nineteen.
755
+
756
+ - **Who hears:** the signed-in writer of each comment in a resolved thread, about the comments THEY
757
+ wrote (a reply by someone else is not theirs). Never an anonymous reader (no address), never the
758
+ person resolving, never `ownerEmail` or `admins` about their own comments.
759
+ - **The burst:** closes after `quietMs` (default 10 minutes) with no new resolution for that reader
760
+ on that page, or once its first resolution is `maxWaitMs` old (default 6 hours). A thread reopened
761
+ before then drops out; resolving one again replaces its line.
762
+ - **No backfill:** only resolutions that pass through `onResolve` queue anything, so threads resolved
763
+ before a host wires it never produce an email.
764
+ - **Sent once:** a due burst is claimed in one transaction (it becomes a digest record and the burst
765
+ is deleted), and a digest is leased in a transaction before it is sent, so overlapping runs cannot
766
+ both send it. The message carries `idempotencyKey` (the same on every retry of one digest): hand it
767
+ to your provider (Resend's `Idempotency-Key`), and a run that died between sending and recording
768
+ does not deliver twice when the lease expires. A failed send is retried with back-off, at most 5
769
+ times.
770
+ - **Stop:** every email carries a signed link (`<site>/api/resolve-digest/stop?t=...`, and the RFC 8058
771
+ `List-Unsubscribe` headers) that ends these emails for that reader on that page. Without `secret`
772
+ nothing is queued, since no email may go out without one.
773
+ - **Where it waits:** `createResolveDigestStore(db, base)` (Firestore: `<base>ResolveBursts`,
774
+ `<base>ResolveDigests`, `<base>ResolveStops`; single-field queries only, so no index to create), or
775
+ `createMemoryResolveDigestStore()` for tests.
776
+
777
+ ```ts
778
+ const digests = createResolveDigests({
779
+ store: createResolveDigestStore(db, 'artifact'),
780
+ send: async (m) => sendWithYourProvider(m), // { to, subject, text, html, headers, idempotencyKey }
781
+ secret: () => process.env.STOP_LINK_SECRET, // signs stop links
782
+ siteUrl: 'https://artifacts.example.com', brandName: 'Example Co', ownerName: 'Ann',
783
+ ownerEmail, admins,
784
+ })
785
+ createArtifactRoutes({ /* ... */, onResolve: digests.onResolve })
786
+ // app/api/resolve-digest/stop/route.ts: GET and POST = digests.STOP
787
+ // a scheduled job: await digests.flush() ({ dryRun: true } reports, sends nothing)
788
+ ```
789
+
748
790
  ### The comments feed
749
791
 
750
792
  So the publisher hears about a shared comment without opening every page, one route lists every
@@ -791,7 +833,7 @@ refuses the query and the error names a link that creates the index. A host with
791
833
  ## Brand packs
792
834
 
793
835
  A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
794
- the narrator label, an optional full-page `Wrapper` and `Mark`, and an optional `share` block
836
+ an optional full-page `Wrapper` and `Mark`, and an optional `share` block
795
837
  (a font loader and a backdrop) that draws the unfurl card. `freedomDefault` is the default look.
796
838
  Keep a pack carrying your own trademark in your own app, not in a pull request here.
797
839
 
@@ -16,6 +16,8 @@ export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChec
16
16
  export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, EMBED_KINDS, type SimulatorBlock, type EmbedBlock, type EmbedKind } from './simulator.js';
17
17
  export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
18
18
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
19
+ export { createResolveDigests, noticesFor, composeDigest, resolverName, itemsOf, emailKey, burstKey, stopToken, verifyStopToken, maskEmail, RESOLVE_DIGEST_QUIET_MS, RESOLVE_DIGEST_MAX_WAIT_MS, RESOLVE_DIGEST_LEASE_MS, RESOLVE_DIGEST_MAX_ATTEMPTS, RESOLVE_DIGEST_STOP_PATH, DIGEST_BODY_CHARS, DIGEST_QUOTE_CHARS, type ResolveEvent, type DigestItem, type Burst, type Digest, type DigestStatus, type ResolveDigestStore, type DigestMessage, type DigestSender, type ResolveDigestOptions, type ComposeOptions, type FlushReport } from './resolve-digest.js';
20
+ export { createResolveDigestStore, createMemoryResolveDigestStore } from './resolve-digest-store.js';
19
21
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
20
22
  export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER, type PersonalStore, type PersonalNote, type PersonalNoteValue } from './personal-store.js';
21
23
  export { deepgramTranscriber, openaiTranscriber, type Transcriber } from './transcribe.js';
@@ -16,6 +16,8 @@ export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChec
16
16
  export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, EMBED_KINDS } from './simulator.js';
17
17
  export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
18
18
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
19
+ export { createResolveDigests, noticesFor, composeDigest, resolverName, itemsOf, emailKey, burstKey, stopToken, verifyStopToken, maskEmail, RESOLVE_DIGEST_QUIET_MS, RESOLVE_DIGEST_MAX_WAIT_MS, RESOLVE_DIGEST_LEASE_MS, RESOLVE_DIGEST_MAX_ATTEMPTS, RESOLVE_DIGEST_STOP_PATH, DIGEST_BODY_CHARS, DIGEST_QUOTE_CHARS } from './resolve-digest.js';
20
+ export { createResolveDigestStore, createMemoryResolveDigestStore } from './resolve-digest-store.js';
19
21
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
20
22
  export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER } from './personal-store.js';
21
23
  export { deepgramTranscriber, openaiTranscriber } from './transcribe.js';
@@ -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
+ };
@@ -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
+ }
@@ -38,8 +38,9 @@ export type BrandPack = {
38
38
  };
39
39
  /** The small uppercase line above a title, e.g. "From Sam Rivera". */
40
40
  kicker: string;
41
- /** The label under the play button, by narrator voice id. */
42
- narratorLabel: (voice?: string) => string;
41
+ /** Unused since 0.33.1: the player bar names no narrator (it read "Read aloud by Freedom").
42
+ * Optional so a pack that still sets it compiles; nothing reads it. */
43
+ narratorLabel?: (voice?: string) => string;
43
44
  /** Optional full-page backdrop. Receives the page as children. */
44
45
  Wrapper?: ComponentType<{
45
46
  children: ReactNode;
package/lib/brand/pack.js CHANGED
@@ -11,7 +11,6 @@ export const freedomDefault = {
11
11
  body: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
12
12
  },
13
13
  kicker: 'A page from Freedom',
14
- narratorLabel: () => 'Read aloud by Freedom',
15
14
  // Pages unfurl as their title on the ground colour. A pack with a backdrop of its own
16
15
  // overrides this whole field.
17
16
  share: { font: newsreader },
@@ -18,12 +18,12 @@ export declare function litWordIndex(words: WordTiming[], now: number, started:
18
18
  /** Whether a key press is the reader scrolling the page themselves. Space scrolls only outside a
19
19
  * field that takes text. */
20
20
  export declare function isUserScrollKey(key: string, target: EventTarget | null): boolean;
21
- export declare function ArtifactReader({ src, words, rootId, label, accent, ground, }: {
21
+ export declare function ArtifactReader({ src, words, rootId, accent, ground, }: {
22
22
  src: string;
23
23
  words: WordTiming[];
24
24
  rootId: string;
25
- /** The line under the play button; the brand pack decides it from the voice. */
26
- label: string;
25
+ /** Ignored since 0.33.1: the player bar names no narrator. Kept so an older caller compiles. */
26
+ label?: string;
27
27
  accent: string;
28
28
  ground: string;
29
29
  }): import("react").JSX.Element;
@@ -146,8 +146,7 @@ export function isUserScrollKey(key, target) {
146
146
  }
147
147
  /** The Play from here bar's width, so it can be kept inside the screen. */
148
148
  const OFFER_WIDTH = 168;
149
- export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
150
- const narrator = label;
149
+ export function ArtifactReader({ src, words, rootId, accent, ground, }) {
151
150
  const audioRef = useRef(null);
152
151
  const mapRef = useRef([]);
153
152
  const litRef = useRef(null);
@@ -301,7 +300,7 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
301
300
  follow(true);
302
301
  }, onSeeking: () => {
303
302
  startedRef.current = true;
304
- }, onPause: () => { playingRef.current = false; setPlaying(false); }, onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), offer ? (_jsxs("div", { ref: offerRef, "data-nospeak": true, "data-play-offer": true, className: "absolute z-50 flex items-center rounded-full font-sans text-[13px] leading-none shadow-lg", style: { top: offer.top, left: offer.left, width: OFFER_WIDTH, whiteSpace: 'nowrap', background: accent, color: ground }, children: [_jsxs("button", { type: "button", "data-play-from-here": true, onClick: () => playFrom(offer.idx), className: "flex flex-1 items-center gap-1.5 rounded-l-full py-2 pl-3 pr-1 font-medium", children: [_jsx("svg", { "aria-hidden": "true", width: "10", height: "11", viewBox: "0 0 10 11", children: _jsx("path", { d: "M1 1l8 4.5L1 10z", fill: "currentColor" }) }), "Play from here"] }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOffer(null), className: "rounded-r-full py-2 pl-1 pr-3 text-base leading-none opacity-70", children: "\u00D7" })] })) : null, playing && !following ? (_jsx("button", { type: "button", "data-follow-voice": true, "data-nospeak": true, onClick: backToVoice, className: "fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium shadow-lg", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)', background: accent, color: ground }, children: "Back to the voice" })) : null, _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: narrator, accent: accent, ground: ground, value: t, max: dur, step: 0.1, onSeek: (v) => {
303
+ }, onPause: () => { playingRef.current = false; setPlaying(false); }, onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), offer ? (_jsxs("div", { ref: offerRef, "data-nospeak": true, "data-play-offer": true, className: "absolute z-50 flex items-center rounded-full font-sans text-[13px] leading-none shadow-lg", style: { top: offer.top, left: offer.left, width: OFFER_WIDTH, whiteSpace: 'nowrap', background: accent, color: ground }, children: [_jsxs("button", { type: "button", "data-play-from-here": true, onClick: () => playFrom(offer.idx), className: "flex flex-1 items-center gap-1.5 rounded-l-full py-2 pl-3 pr-1 font-medium", children: [_jsx("svg", { "aria-hidden": "true", width: "10", height: "11", viewBox: "0 0 10 11", children: _jsx("path", { d: "M1 1l8 4.5L1 10z", fill: "currentColor" }) }), "Play from here"] }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOffer(null), className: "rounded-r-full py-2 pl-1 pr-3 text-base leading-none opacity-70", children: "\u00D7" })] })) : null, playing && !following ? (_jsx("button", { type: "button", "data-follow-voice": true, "data-nospeak": true, onClick: backToVoice, className: "fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium shadow-lg", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)', background: accent, color: ground }, children: "Back to the voice" })) : null, _jsx(PlayerBar, { playing: playing, onToggle: toggle, accent: accent, ground: ground, value: t, max: dur, step: 0.1, onSeek: (v) => {
305
304
  if (audioRef.current)
306
305
  audioRef.current.currentTime = v;
307
306
  }, progress: `${fmt(t)} / ${fmt(dur)}`, rate: rate, onRate: () => {
@@ -1,8 +1,8 @@
1
- export declare function PlayerBar({ playing, onToggle, label, accent, ground, value, max, step, onSeek, progress, rate, onRate, }: {
1
+ export declare function PlayerBar({ playing, onToggle, accent, ground, value, max, step, onSeek, progress, rate, onRate, }: {
2
2
  playing: boolean;
3
3
  onToggle: () => void;
4
- /** The line under the play button. */
5
- label: string;
4
+ /** Ignored since 0.33.1: the bar names no narrator. Kept so an older caller still compiles. */
5
+ label?: string;
6
6
  accent: string;
7
7
  ground: string;
8
8
  value: number;
@@ -14,5 +14,6 @@ export declare function PlayerBar({ playing, onToggle, label, accent, ground, va
14
14
  rate: number;
15
15
  onRate: () => void;
16
16
  }): import("react").JSX.Element;
17
- /** The speeds the speed button steps through, in order, wrapping back to 1. */
17
+ /** The speeds the speed button steps through, in order, wrapping back to the first. */
18
+ export declare const RATES: readonly [1, 1.25, 1.5, 2];
18
19
  export declare function nextRate(rate: number): number;
@@ -3,14 +3,21 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  // The bar at the bottom of a page that can be listened to, shared by the recorded-narration
4
4
  // reader and the browser read-aloud, so a page looks the same whichever one is speaking. It is
5
5
  // pure chrome: each reader owns its engine and hands the bar what to show and what to call.
6
- export function PlayerBar({ playing, onToggle, label, accent, ground, value, max, step, onSeek, progress, rate, onRate, }) {
6
+ //
7
+ // No narrator line (0.33.1, Gary: "read by freedom should be removed"): the bar is the play
8
+ // button, the seek line, the progress and the speed. The speed button has a FIXED width wide enough
9
+ // for its longest label ("1.25x"), so stepping 1x -> 1.25x -> 2x never resizes it and never shifts
10
+ // the seek line beside it (Gary: "the bottom bar speed dial shouldn't cause reflow when clicked").
11
+ export function PlayerBar({ playing, onToggle, accent, ground, value, max, step, onSeek, progress, rate, onRate, }) {
7
12
  return (_jsxs(_Fragment, { children: [_jsx("style", { children: `
8
13
  .artifact-word { border-radius: 3px; transition: background-color 120ms; }
9
14
  .artifact-word:hover { background: var(--a-surface-strong, rgba(255,255,255,0.08)); cursor: pointer; }
10
15
  .artifact-word-lit { background: color-mix(in srgb, ${accent} 35%, transparent); color: var(--a-strong, #fff); }
11
- ` }), _jsx("div", { "data-artifact-player": true, className: "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--a-line)] backdrop-blur", style: { background: `color-mix(in srgb, ${ground} 90%, transparent)` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: onToggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: label }), _jsx("input", { type: "range", min: 0, max: max || 0, step: step, value: Math.min(value, max || 0), onChange: (e) => onSeek(Number(e.target.value)), "aria-label": "Seek", className: "mt-1 w-full", style: { accentColor: accent } })] }), _jsx("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-[color:var(--a-muted)]", children: progress }), _jsxs("button", { type: "button", onClick: onRate, className: "shrink-0 rounded border border-[color:var(--a-line-strong)] px-2 py-1 text-xs text-[color:var(--a-body)]", "aria-label": "Playback speed", children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
16
+ ` }), _jsx("div", { "data-artifact-player": true, className: "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--a-line)] backdrop-blur", style: { background: `color-mix(in srgb, ${ground} 90%, transparent)` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: onToggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx("input", { type: "range", min: 0, max: max || 0, step: step, value: Math.min(value, max || 0), onChange: (e) => onSeek(Number(e.target.value)), "aria-label": "Seek", className: "block w-full", style: { accentColor: accent } }) }), _jsx("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-[color:var(--a-muted)]", children: progress }), _jsxs("button", { type: "button", onClick: onRate, "data-artifact-rate": true, className: "w-14 shrink-0 whitespace-nowrap rounded border border-[color:var(--a-line-strong)] py-1 text-center text-xs tabular-nums text-[color:var(--a-body)]", "aria-label": `Playback speed, ${rate}x`, children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
12
17
  }
13
- /** The speeds the speed button steps through, in order, wrapping back to 1. */
18
+ /** The speeds the speed button steps through, in order, wrapping back to the first. */
19
+ export const RATES = [1, 1.25, 1.5, 2];
14
20
  export function nextRate(rate) {
15
- return rate >= 1.5 ? 1 : rate + 0.25;
21
+ const i = RATES.findIndex((r) => r > rate + 1e-9);
22
+ return i === -1 ? RATES[0] : RATES[i];
16
23
  }
@@ -8,6 +8,7 @@ import type { PageSettings, SettingsFact } from '../reader/settings-sheet.js';
8
8
  import { type Access, type AllowEntry } from '../artifacts/reader.js';
9
9
  import { type ReadersStore } from '../artifacts/readers-store.js';
10
10
  import { type ReaderEvent } from '../artifacts/read-alerts.js';
11
+ import type { ResolveEvent } from '../artifacts/resolve-digest.js';
11
12
  import type { PersonalStore } from '../artifacts/personal-store.js';
12
13
  import type { Transcriber } from '../artifacts/transcribe.js';
13
14
  export type ArtifactRoutesConfig = {
@@ -92,6 +93,11 @@ export type ArtifactRoutesConfig = {
92
93
  * event into the alerts worth sending). Awaited, and anything it throws is swallowed: a broken
93
94
  * sink never costs a reader their page or their record. Without it nothing extra is read. */
94
95
  onReaderEvent?: (event: ReaderEvent) => void | Promise<void>;
96
+ /** Told after a comment thread is resolved or reopened (0.34.0): the page, the whole thread, the
97
+ * resolution (null on reopen) and who did it. Pass `createResolveDigests(...).onResolve` to email
98
+ * each signed-in commenter one digest per burst of resolving. Awaited; anything it throws is
99
+ * logged and swallowed, so the resolve itself always lands. */
100
+ onResolve?: (event: ResolveEvent) => void | Promise<void>;
95
101
  /** The read-only lines under the controls in the page menu's Share settings sheet (0.31.0), for
96
102
  * a host that knows more than the package about how its pages are found: its robots.txt, who
97
103
  * its sign-in admits. Given, its rows REPLACE the package's own two (what a stranger with the
@@ -89,7 +89,7 @@ export function createArtifactRoutes(config) {
89
89
  const stateRoutes = createStateRoutes({
90
90
  store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey,
91
91
  publisherSecret: config.publisherSecret, publisherHost: config.publisherHost, pageUrl, signInOrigin, siteUrl,
92
- clientIp: config.clientIp, ownerEmail: config.ownerEmail, admins: config.admins, audioUrl,
92
+ clientIp: config.clientIp, ownerEmail: config.ownerEmail, admins: config.admins, audioUrl, onResolve: config.onResolve,
93
93
  });
94
94
  const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
95
95
  const voiceRoutes = createVoiceRoutes({
@@ -410,7 +410,7 @@ export function createArtifactRoutes(config) {
410
410
  return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
411
411
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
412
412
  checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
413
- })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
413
+ })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
414
414
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
415
415
  recorded at publish, or there is none. */] }));
416
416
  }
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
2
2
  import { type StateStore } from '../artifacts/state-store.js';
3
3
  import type { ArtifactStore } from '../artifacts/store.js';
4
4
  import type { ReadersStore } from '../artifacts/readers-store.js';
5
+ import type { ResolveEvent } from '../artifacts/resolve-digest.js';
5
6
  export declare const ANON_COOKIE = "artifact_anon";
6
7
  /** The most comments one feed answer carries. A caller that gets this many moves its cursor to the
7
8
  * last row's `at` and asks again. */
@@ -39,6 +40,10 @@ export type StateRoutesContext = {
39
40
  /** A signed URL to play a comment's recording. Called only for `comments/` paths, and only for
40
41
  * comments the asker is already being shown. Without it a recording has no play control. */
41
42
  audioUrl?: (id: string, path: string) => Promise<string>;
43
+ /** Told after a thread is resolved or reopened (0.34.0), with the whole thread and who did it.
44
+ * `createResolveDigests(...).onResolve` turns it into resolution emails. Awaited, and anything
45
+ * it throws is logged and swallowed: a broken mailer never costs the owner their resolve. */
46
+ onResolve?: (event: ResolveEvent) => void | Promise<void>;
42
47
  };
43
48
  type Params = {
44
49
  params: Promise<{
@@ -396,6 +396,7 @@ export function createStateRoutes(ctx) {
396
396
  async function RESOLVE(req, { params }) {
397
397
  const publisher = isPublisherAuthed(req, ctx);
398
398
  let by = 'the publisher';
399
+ let actorEmail;
399
400
  if (!publisher) {
400
401
  const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
401
402
  if (!reader)
@@ -409,6 +410,7 @@ export function createStateRoutes(ctx) {
409
410
  if (!/^application\/json\b/i.test(req.headers.get('content-type') ?? ''))
410
411
  return json({ error: 'send JSON' }, 415);
411
412
  by = reader.name?.trim().split(/\s+/)[0] || 'the owner';
413
+ actorEmail = reader.email;
412
414
  }
413
415
  const raw = await req.text();
414
416
  if (Buffer.byteLength(raw, 'utf8') > 4096)
@@ -461,6 +463,19 @@ export function createStateRoutes(ctx) {
461
463
  const r = await ctx.state.setResolved({ artifactId: id, slot: COMMENTS_SLOT, entryId: root.id, resolved: resolution });
462
464
  if (!r)
463
465
  return json({ error: 'no comment with that id on this page' }, 404);
466
+ if (ctx.onResolve) {
467
+ const thread = [r, ...[...all.values()].filter((e) => e.value.parent === root.id)
468
+ .sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))];
469
+ try {
470
+ await ctx.onResolve({
471
+ artifactId: id, title: a.title, url: ctx.pageUrl(id), thread: { id: root.id, entries: thread }, resolution,
472
+ actor: { publisher, ...(actorEmail ? { email: actorEmail } : {}) }, at: resolution?.at ?? new Date().toISOString(),
473
+ });
474
+ }
475
+ catch (err) {
476
+ console.error('onResolve failed', err);
477
+ }
478
+ }
464
479
  return json({ id, entry: root.id, resolved: resolution });
465
480
  }
466
481
  return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
5
5
  "license": "MIT",
6
6
  "type": "module",