@supersuit/artifacts 0.33.1 → 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 +44 -0
- package/README.md +42 -0
- package/lib/artifacts/index.d.ts +2 -0
- package/lib/artifacts/index.js +2 -0
- package/lib/artifacts/resolve-digest-store.d.ts +8 -0
- package/lib/artifacts/resolve-digest-store.js +194 -0
- package/lib/artifacts/resolve-digest.d.ts +236 -0
- package/lib/artifacts/resolve-digest.js +282 -0
- package/lib/routes/artifacts.d.ts +6 -0
- package/lib/routes/artifacts.js +1 -1
- package/lib/routes/state-routes.d.ts +5 -0
- package/lib/routes/state-routes.js +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
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
|
+
|
|
6
50
|
## 0.33.1
|
|
7
51
|
|
|
8
52
|
**The player bar: the speed button no longer reflows the bar, 2x is a speed, and the narrator
|
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
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 “${esc(i.quote)}”</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: “${esc(i.body)}”</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 ? `“${esc(title)}”` : 'this page'}.`);
|
|
280
|
+
}
|
|
281
|
+
return { onResolve, flush, STOP, stopUrl, message };
|
|
282
|
+
}
|
|
@@ -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
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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({
|
|
@@ -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.
|
|
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",
|