@supersuit/artifacts 0.49.0 → 0.52.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,95 @@
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.52.0 (2026-10-25)
7
+
8
+ **A render before the send is never counted as the reader opening the page.** A per-reader page
9
+ reported its reader as having opened it (first open 20:32:34Z, 647 active seconds) a minute before
10
+ the text carrying his link was sent (20:33:35Z). The page had been rendered from his link on the
11
+ sender's side while the send waited on approval, in a real browser, so the unfurl filter could not
12
+ see it and the render token treated it as a reader.
13
+
14
+ - `POST /recipients` takes `sent: { rids: [rid] or '*', at?: ISO }` and answers `sent`, the rids
15
+ marked. Earliest mark wins; never the owner; a future time is refused (400).
16
+ - `GET /views` counts only sessions on devices that had not rendered the link before `sentAt`, and
17
+ reports the rest as `beforeSend: { sessions, devices, activeSeconds, firstAt }`; both lines and
18
+ `GET /recipients` carry `sentAt`. A reader with no `sentAt` reads exactly as before.
19
+ - `RecipientsStore.setSent` (optional; the memory and Firestore stores have it).
20
+ - DETECTOR: a reader whose `firstOpenAt` is earlier than the time the text carrying their link was
21
+ sent. Any host on 0.51.x has no way to tell such a render from the reader.
22
+ - REMEDY: bump, then have the sender mark each per-reader link sent once delivery is confirmed
23
+ (Supersuit's send-message does this from the next release). For a page already wrong, mark the
24
+ link with the true send time: the views reclassify, nothing is deleted.
25
+ - Tests: recipients-store.test.ts (setSent on both stores; summarizeViews leaves out a pre-send
26
+ session and every later session on its device, keeps the reader's own) and
27
+ recipients-api.test.tsx (the `sent` mark, its refusals, and the views it changes).
28
+
29
+ ## 0.51.0 (2026-10-23)
30
+
31
+ **Every page's session replay now masks all text and all inputs, not only a gated page's.** Since
32
+ 0.50.0 a public, password or per-reader page recorded its replays with every word readable, so a
33
+ page shared by key or by link sat in full in the analytics provider's copy. `posthog.init` now gets
34
+ `session_recording: { maskAllInputs: true, maskTextSelector: '*' }` on every kind (these client
35
+ options override the PostHog project's own masking setting), and `mask_all_text: true` so
36
+ autocapture stops collecting a clicked element's text. There is no opt-out. Replays still show where
37
+ the reader went, how far and how fast.
38
+
39
+ - `posthogInitOptions(plan)` and `RECORDING_MASK`, exported: the exact init options, pure.
40
+ - DETECTOR: open a replay of a public or per-reader page in PostHog; readable page text means the
41
+ host is on 0.50.x. Autocapture events carrying `$el_text` from page body are the same leak.
42
+ - REMEDY: bump. Replays recorded before the bump keep their text; delete them in PostHog (filter
43
+ recordings by the `artifact_id` / `page_kind` event properties, then delete), and consider
44
+ deleting old autocapture events' `$el_text` the same way.
45
+ - Tests: page-analytics-recording.test.tsx (mounts the component against a stand-in posthog-js and
46
+ holds every page kind's init options to the mask; red on 0.50.0 for public, password and
47
+ per-reader).
48
+
49
+ **A hub can no longer go live with a dead comments feed without being told.** The feed's Firestore
50
+ query needs a composite index (`<base>State`: `slot` ASC, `at` ASC) that 0.13.0 told hubs to create
51
+ by hand; a hub that never did answered every `GET /api/comments` with a bare 500 for five days.
52
+
53
+ - `firestoreIndexesFor(stateBases)`: every composite index the Firestore stores need, as the
54
+ contents of `firestore.indexes.json`, so a hub deploys it with `firebase deploy --only
55
+ firestore:indexes` and tests that its committed file still carries it.
56
+ - `COMMENTS_FEED` answers **503** `{ error: 'missing_firestore_index', index, create }` (create is
57
+ Firestore's link) instead of throwing. Any other error still throws.
58
+ - New route `HEALTH` (`GET /api/health`, no key): probes the feed query for one comment after now;
59
+ 200 `{ ok: true, checks }`, or 503 naming the missing index. Another failure reports
60
+ `query_failed` without its message.
61
+ - `missingIndexOf(err)` and `COMMENTS_FEED_INDEX`, exported for a host's own checks.
62
+ - DETECTOR: `curl <hub>/api/health` is 404 (no route), or 503 with `missing_firestore_index`.
63
+ - REMEDY: bump; add `app/api/health/route.ts` delegating GET to `HEALTH`; commit
64
+ `firestore.indexes.json` from `firestoreIndexesFor([...your state bases])` with `firebase.json`
65
+ pointing at it, add a test that the file contains it, and run `firebase deploy --only
66
+ firestore:indexes --project <project>` once.
67
+ - Tests: firestore-indexes.test.ts, and six in comments-feed.test.tsx (503 with the link, other
68
+ errors still throw, HEALTH red/green/other-failure/no-store).
69
+
70
+ ## 0.50.0 (2026-10-19)
71
+
72
+ **Product analytics, with video of each visit.** A host that passes `analytics: { posthogKey,
73
+ posthogHost?, tenant? }` to `createArtifactRoutes` gets PostHog on its pages: session replay (text
74
+ fields masked, PostHog's default), heatmaps, autocapture, and `$pageleave` with scroll depth, plus
75
+ two kinds of reading PostHog cannot see on its own: `section_viewed` (the heading's id and text and
76
+ the visible seconds spent in it, timed by the contents rail's own rule, now `currentSection` in
77
+ `toc-spy.tsx`) and `narration_play` / `narration_pause` / `narration_complete` (the narration
78
+ `<audio>` now carries `data-artifact-narration`). Every event carries `artifact_id`, `page_kind`
79
+ and `tenant`. The rules live in one pure function, `analyticsPlan` (`reader/analytics-plan.ts`):
80
+ no key, no script at all (posthog-js is imported only by `PageAnalytics`, after mount); never the
81
+ owner or an admin, signed in, previewing `?as=reader`, or on the owner's own per-reader link; never
82
+ a link-preview fetcher; never a past version. A reader is identified only where the page already
83
+ knows them: a gated page by the signed-in email, a per-reader link as `recipient:<rid>` with its
84
+ label. Public and password pages stay anonymous, even for a signed-in reader. A gated page also
85
+ masks all text in its replays, because its body is confidential and a replay is a copy of it.
86
+
87
+ - DETECTOR: a host that wants analytics has none; with no `analytics` config nothing changes and
88
+ no analytics code is downloaded.
89
+ - REMEDY: pass `analytics` per tenant (a public `phc_` project key). Turn on session replay and
90
+ heatmaps in the PostHog project's settings, which is where PostHog switches them on.
91
+ - Tests: analytics-plan.test.ts (no key, owner, bot, anonymous kinds, gated and per-reader
92
+ identity), page-analytics-mount.test.tsx (where it mounts on real routes, and as whom),
93
+ page-analytics.test.ts (section timing, narration events).
94
+
6
95
  ## 0.49.0 (2026-10-16)
7
96
 
8
97
  **The default look is Supersuit's, the Maison.** The product the default pack is named for became
package/README.md CHANGED
@@ -85,6 +85,7 @@ The rest, each exporting the named handler(s) from `artifacts`:
85
85
  | `app/api/reader/track/route.ts` | `POST` = `TRACK` |
86
86
  | `app/api/artifacts/[id]/resolve/route.ts` | `POST` = `RESOLVE` (see [resolving a thread](#resolving-a-thread)) |
87
87
  | `app/api/comments/route.ts` | `GET` = `COMMENTS_FEED` (see [the comments feed](#the-comments-feed)) |
88
+ | `app/api/health/route.ts` | `GET` = `HEALTH` (see [the comments feed](#the-comments-feed)) |
88
89
 
89
90
  Pages live at `/<id>` by default. Mounting inside a larger site, pass `pagePrefix: '/a/'` and put
90
91
  the page at `app/a/[id]/`. One deployment can serve many sites: build one set of routes per
@@ -990,8 +991,21 @@ export function GET(request: NextRequest) { return artifacts.COMMENTS_FEED(reque
990
991
 
991
992
  On Firestore (`createStateStore`) the query is one range scan over `<base>State` and needs a
992
993
  composite index: **`slot` ascending, `at` ascending**, collection scope. Without it Firestore
993
- refuses the query and the error names a link that creates the index. A host with its own
994
- `StateStore` implements `slotSince(slot, after, limit)`; without it the feed answers 501.
994
+ refuses the query and the feed answers **503** `{ error: 'missing_firestore_index', index, create }`,
995
+ `create` being Firestore's link that makes the index. A host with its own `StateStore` implements
996
+ `slotSince(slot, after, limit)`; without it the feed answers 501.
997
+
998
+ **Ship the index as a file, not a step to remember.** `firestoreIndexesFor(bases)` returns every
999
+ composite the stores need, for the bases you pass `createStateStore`, in the shape of
1000
+ `firestore.indexes.json`. Commit it, point `firebase.json` at it
1001
+ (`{ "firestore": { "indexes": "firestore.indexes.json" } }`), deploy it with
1002
+ `firebase deploy --only firestore:indexes --project <project>`, and keep a test asserting the
1003
+ committed file still contains `firestoreIndexesFor(...)`, so a release that needs a new index
1004
+ fails your tests rather than your readers.
1005
+
1006
+ **`HEALTH`** (`GET /api/health`, no key) runs the feed's query for one comment after now and
1007
+ answers 200 `{ ok: true, checks }`, or 503 naming the missing index. Call it after every deploy;
1008
+ a hub with no index went five days answering every feed request with a bare 500.
995
1009
 
996
1010
  ## Per-reader links
997
1011
 
@@ -1023,8 +1037,8 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
1023
1037
  | Route | Who | What |
1024
1038
  |---|---|---|
1025
1039
  | `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
1026
- | `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null } }`. `add` answers each new reader's link; `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window |
1027
- | `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments. Each comment is `{ id, parent, at, text }` (see Comment ids in /views). A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
1040
+ | `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null }, sent?: { rids: [rid] or '*', at?: ISO time } }`. `add` answers each new reader's link; `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window; `sent` records when links reached their readers (see A render before the send) |
1041
+ | `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments, `sentAt` and `beforeSend`. Each comment is `{ id, parent, at, text }` (see Comment ids in /views). A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
1028
1042
  | `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
1029
1043
 
1030
1044
  Config, both on `createArtifactRoutes`: `recipients` (a store from `createRecipientsStore(db, '<collection path>')`, or `memoryRecipientsStore()` in tests) and `recipientSecret`, a function returning the signing secret, read on every request. A secret shorter than 32 characters is treated as unset: per-reader pages stay shut and the routes answer 404 or 501. Signing with an empty secret throws.
@@ -1035,6 +1049,27 @@ The `Page` route must stay `force-dynamic` (the package's `dynamic` export says
1035
1049
 
1036
1050
  A view is the page rendered in a real browser, visible, for at least 2 seconds. The page then sends one signal carrying a single-use render token minted for that render; the token is spent on first use and expires after a day. Known unfurl fetchers (and any request with no user agent) are ignored, and so is a signal for a revoked or expired reader. The owner's own opens record zero. After the first signal the page sends a beat every few seconds while it stays visible (and a final one on hide, by `sendBeacon`), and a play signal when a clip starts. A signed-in owner or admin opens the page with no link and no tracking, so test a reader's link in a signed-out browser, never in the browser you are signed in to.
1037
1051
 
1052
+ ### A render before the send (0.52.0)
1053
+
1054
+ A view proves a browser rendered the link, not that the reader did. On 2026-10-25 a reader was
1055
+ reported as having opened a page a minute before the text carrying his link was sent: the link had
1056
+ been opened on the sender's side while the send waited on approval, in a real browser that ran the
1057
+ page, so no user-agent rule could have told it apart.
1058
+
1059
+ So the sender records the send: `POST /recipients` with `sent: { rids, at }` (`rids` a list or
1060
+ `'*'` for every reader but the owner; `at` an ISO time, default now on the host's clock, never in
1061
+ the future). The earliest mark stands, so a resend never hides opens between the two sends, and an
1062
+ earlier time corrects a mark written late. `sent` answers the reader ids it changed. A host with
1063
+ its own `RecipientsStore` needs `setSent`; without it the route answers 501 to a `sent` mark.
1064
+
1065
+ `GET /views` then counts, for that reader, only sessions on devices that had not rendered the link
1066
+ before `sentAt`: a session that began earlier was not the reader's, nor is any later session on the
1067
+ same device. Those are reported apart as `beforeSend: { sessions, devices, activeSeconds, firstAt }`
1068
+ (null when no send was recorded). Nothing is refused at record time, so marking a send after the
1069
+ fact reclassifies what is already there. A link with no `sentAt` is read exactly as before. The
1070
+ cost, stated: a reader who opens the link within the seconds between delivery and the sender's
1071
+ mark is counted as before-send; senders mark as soon as delivery is confirmed.
1072
+
1038
1073
  ### Expiry
1039
1074
 
1040
1075
  `expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. With a private bucket configured it deletes from both buckets. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
@@ -1174,6 +1209,24 @@ export const config = { matcher: ['/:id([a-z0-9]{8})'] }
1174
1209
 
1175
1210
  Any per-reader page served to a known unfurl fetcher (or a request with no user agent) carries its text only: no media, no signed URL, no reader's note, no cookie. `readHeader` on `createArtifactRoutes` reads the agent (default through `next/headers`). `DELETE /api/artifacts/<id>` removes the page's files from both buckets (`deletePage`), then its readers, their notes and views and every comment, before the record, and keeps the record (502) if the files could not go, so it can be run again.
1176
1211
 
1212
+ ## Product analytics (`analytics`)
1213
+
1214
+ Pass `analytics: { posthogKey: 'phc_...', posthogHost?: 'https://us.i.posthog.com', tenant?: 'name' }`
1215
+ to `createArtifactRoutes` and the host's pages record visits in PostHog: session replay, heatmaps,
1216
+ autocapture, scroll depth on leave, `section_viewed` (heading id, text, seconds) and
1217
+ `narration_play` / `narration_pause` / `narration_complete`. Without it no page loads any analytics
1218
+ code. Switch session replay and heatmaps on in the PostHog project as well.
1219
+
1220
+ Who is recorded, and as whom (`analyticsPlan`, `src/reader/analytics-plan.ts`):
1221
+
1222
+ - Never the owner or an admin (signed in, previewing `?as=reader`, or on the owner's own per-reader
1223
+ link), never a link-preview fetcher, never a past version.
1224
+ - A gated page identifies the signed-in reader by email.
1225
+ - A per-reader link identifies as `recipient:<rid>` with the label the publisher gave it.
1226
+ - A public or password page stays anonymous.
1227
+ - Every page, whatever its kind, records replays with all text and all inputs masked
1228
+ (`RECORDING_MASK`), and autocapture collects no element text. There is no opt-out.
1229
+
1177
1230
  ## Brand packs
1178
1231
 
1179
1232
  A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
@@ -0,0 +1,23 @@
1
+ export type FirestoreIndex = {
2
+ collectionGroup: string;
3
+ queryScope: 'COLLECTION';
4
+ fields: {
5
+ fieldPath: string;
6
+ order: 'ASCENDING' | 'DESCENDING';
7
+ }[];
8
+ };
9
+ export type FirestoreIndexesFile = {
10
+ indexes: FirestoreIndex[];
11
+ fieldOverrides: [];
12
+ };
13
+ /** The comments feed's composite in words, for error bodies and health reports. */
14
+ export declare const COMMENTS_FEED_INDEX = "<base>State (collection scope): slot ASC, at ASC";
15
+ /** Every composite index the package's Firestore stores need, for the state-store bases a host
16
+ * uses, in the shape of firestore.indexes.json. A Firestore index is keyed by the collection's id
17
+ * (the last path segment), so tenants sharing a base under different parents share one entry. */
18
+ export declare function firestoreIndexesFor(stateBases: string[]): FirestoreIndexesFile;
19
+ /** Firestore's missing-index refusal, with the console link it carries to create the index, or
20
+ * null for any other error. */
21
+ export declare function missingIndexOf(err: unknown): {
22
+ create: string | null;
23
+ } | null;
@@ -0,0 +1,40 @@
1
+ // The composite indexes the Firestore stores' queries need, and how to read the refusal Firestore
2
+ // throws when one is missing.
3
+ //
4
+ // Firestore serves equality-only queries by merging single-field indexes, so most of the stores'
5
+ // queries need nothing. The exception is an equality on one field plus a range or order on another:
6
+ // that needs a composite, and Firestore refuses the query (gRPC 9, FAILED_PRECONDITION) until it
7
+ // exists. Today there is one, the comments feed's `slotSince` on <base>State.
8
+ //
9
+ // A hub deploys these declaratively: commit `firestoreIndexesFor(<every base it passes to
10
+ // createStateStore>)` as firestore.indexes.json and run `firebase deploy --only firestore:indexes`.
11
+ // A hub that skipped this answered every GET /api/comments with a bare 500 for five days
12
+ // in October 2026, so the feed and HEALTH now name the index instead.
13
+ /** The comments feed's composite in words, for error bodies and health reports. */
14
+ export const COMMENTS_FEED_INDEX = '<base>State (collection scope): slot ASC, at ASC';
15
+ const collectionIdOf = (path) => path.split('/').filter(Boolean).pop() ?? path;
16
+ /** Every composite index the package's Firestore stores need, for the state-store bases a host
17
+ * uses, in the shape of firestore.indexes.json. A Firestore index is keyed by the collection's id
18
+ * (the last path segment), so tenants sharing a base under different parents share one entry. */
19
+ export function firestoreIndexesFor(stateBases) {
20
+ const groups = [...new Set(stateBases.map((b) => `${collectionIdOf(b)}State`))];
21
+ return {
22
+ indexes: groups.map((collectionGroup) => ({
23
+ collectionGroup, queryScope: 'COLLECTION',
24
+ fields: [{ fieldPath: 'slot', order: 'ASCENDING' }, { fieldPath: 'at', order: 'ASCENDING' }],
25
+ })),
26
+ fieldOverrides: [],
27
+ };
28
+ }
29
+ /** Firestore's missing-index refusal, with the console link it carries to create the index, or
30
+ * null for any other error. */
31
+ export function missingIndexOf(err) {
32
+ if (!err || typeof err !== 'object')
33
+ return null;
34
+ const message = String(err.message ?? '');
35
+ const code = err.code;
36
+ const precondition = code === 9 || code === 'failed-precondition' || /FAILED_PRECONDITION/.test(message);
37
+ if (!precondition || !/requires an index/i.test(message))
38
+ return null;
39
+ return { create: message.match(/https:\/\/\S+/)?.[0] ?? null };
40
+ }
@@ -11,6 +11,7 @@ export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type Flag
11
11
  export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, WORDS_PER_MINUTE, FINISHED_SCROLL, FINISHED_SHARE, QUIET_AFTER_DAYS, QUIET_WINDOW_DAYS, FRESH_HOURS, type ReadAlert, type ReadAlertKind, type ReadAlertInput, type ReaderEvent } from './read-alerts.js';
12
12
  export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT, type StateConfig, type SlotDef, type Shape, type Visibility, type Writers } from './state.js';
13
13
  export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
14
+ export { firestoreIndexesFor, missingIndexOf, COMMENTS_FEED_INDEX, type FirestoreIndex, type FirestoreIndexesFile } from './firestore-indexes.js';
14
15
  export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
15
16
  export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
16
17
  export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, fitFrom, FIT_MESSAGE, EMBED_KINDS, type SimulatorBlock, type EmbedBlock, type EmbedKind } from './simulator.js';
@@ -11,6 +11,7 @@ export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
11
11
  export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, WORDS_PER_MINUTE, FINISHED_SCROLL, FINISHED_SHARE, QUIET_AFTER_DAYS, QUIET_WINDOW_DAYS, FRESH_HOURS } from './read-alerts.js';
12
12
  export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT } from './state.js';
13
13
  export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
14
+ export { firestoreIndexesFor, missingIndexOf, COMMENTS_FEED_INDEX } from './firestore-indexes.js';
14
15
  export { stateView, responsesOf, responsesCsv } from './state-view.js';
15
16
  export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
16
17
  export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, fitFrom, FIT_MESSAGE, EMBED_KINDS } from './simulator.js';
@@ -1,4 +1,6 @@
1
1
  import type { Firestore } from 'firebase-admin/firestore';
2
+ /** `sentAt`: when the sender says the link reached the reader (the publish key marks it). A view
3
+ * that began before then was not the reader's, whoever rendered it. */
2
4
  export type Recipient = {
3
5
  rid: string;
4
6
  label: string;
@@ -8,6 +10,7 @@ export type Recipient = {
8
10
  addedAt: string;
9
11
  revokedAt?: string;
10
12
  closesAt?: string | null;
13
+ sentAt?: string;
11
14
  };
12
15
  export type RecipientsRecord = {
13
16
  recipients: Record<string, Recipient>;
@@ -25,12 +28,22 @@ export type ViewDoc = {
25
28
  activeSeconds: number;
26
29
  plays: Record<string, number>;
27
30
  };
31
+ /** What rendered the link before it was sent: never counted as the reader, shown so a publisher
32
+ * can see it happened (usually themselves, checking the page). Null when no send was recorded. */
33
+ export type BeforeSend = {
34
+ sessions: number;
35
+ devices: number;
36
+ activeSeconds: number;
37
+ firstAt: string | null;
38
+ } | null;
28
39
  export type RecipientSummary = {
29
40
  rid: string;
30
41
  label: string;
31
42
  group: boolean;
32
43
  revoked: boolean;
33
44
  closesAt: string | null;
45
+ sentAt: string | null;
46
+ beforeSend: BeforeSend;
34
47
  opened: boolean;
35
48
  firstOpenAt: string | null;
36
49
  cameBack: boolean;
@@ -59,6 +72,10 @@ export interface RecipientsStore {
59
72
  * with '*'. Never the owner's. Returns the rids changed. Optional so a host's own store keeps
60
73
  * working; without it the recipients route answers 501 to a `closes` change. */
61
74
  setCloses?(artifactId: string, rids: string[] | '*', closesAt: string | null): Promise<string[]>;
75
+ /** Mark the named readers' links (or every non-owner's, with '*') as sent at `sentAt`. The
76
+ * earliest mark stands: a resend never moves it later. Returns the rids changed. Optional so a
77
+ * host's own store keeps working; without it the recipients route answers 501 to a `sent` mark. */
78
+ setSent?(artifactId: string, rids: string[] | '*', sentAt: string): Promise<string[]>;
62
79
  setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
63
80
  useToken(key: string, expiresAt: number): Promise<boolean>;
64
81
  recordView(v: {
@@ -41,6 +41,20 @@ function closeIn(rec, rids, closesAt) {
41
41
  }
42
42
  return hit;
43
43
  }
44
+ function sentIn(rec, rids, sentAt) {
45
+ const hit = [];
46
+ for (const r of Object.values(rec.recipients)) {
47
+ if (r.owner || (rids !== '*' && !rids.includes(r.rid)))
48
+ continue;
49
+ // Earliest wins. A later resend cannot hide opens that happened after the first send, and an
50
+ // earlier time corrects a mark written late.
51
+ if (r.sentAt && Date.parse(r.sentAt) <= Date.parse(sentAt))
52
+ continue;
53
+ r.sentAt = sentAt;
54
+ hit.push(r.rid);
55
+ }
56
+ return hit;
57
+ }
44
58
  function addView(prev, v) {
45
59
  const doc = prev ? { ...prev, plays: { ...prev.plays } } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
46
60
  doc.activeSeconds += Math.max(0, Math.min(60, v.addSeconds));
@@ -67,6 +81,7 @@ export function memoryRecipientsStore() {
67
81
  },
68
82
  async revoke(id, rids) { const rec = recs.get(id); return rec ? revokeIn(rec, rids) : []; },
69
83
  async setCloses(id, rids, closesAt) { const rec = recs.get(id); return rec ? closeIn(rec, rids, closesAt) : []; },
84
+ async setSent(id, rids, sentAt) { const rec = recs.get(id); return rec ? sentIn(rec, rids, sentAt) : []; },
70
85
  async setExpiry(id, expireAt) {
71
86
  const rec = recs.get(id) ?? { recipients: {}, expireAt: null, keep: false };
72
87
  rec.expireAt = expireAt;
@@ -130,6 +145,16 @@ export function createRecipientsStore(db, base) {
130
145
  await recsCol().doc(id).set({ recipients: Object.fromEntries(hit.map((rid) => [rid, { closesAt }])) }, { merge: true });
131
146
  return hit;
132
147
  },
148
+ async setSent(id, rids, sentAt) {
149
+ const rec = await read(id);
150
+ if (!rec)
151
+ return [];
152
+ const hit = sentIn(rec, rids, sentAt);
153
+ if (!hit.length)
154
+ return hit;
155
+ await recsCol().doc(id).set({ recipients: Object.fromEntries(hit.map((rid) => [rid, { sentAt }])) }, { merge: true });
156
+ return hit;
157
+ },
133
158
  async setExpiry(id, expireAt) {
134
159
  await recsCol().doc(id).set({ expireAt, keep: expireAt === null }, { merge: true });
135
160
  },
@@ -169,10 +194,25 @@ export function createRecipientsStore(db, base) {
169
194
  /** One line per reader (never the owner), in the order they were added. */
170
195
  export function summarizeViews(rec, views, comments) {
171
196
  return Object.values(rec.recipients).filter((r) => !r.owner).sort((a, b) => a.addedAt.localeCompare(b.addedAt)).map((r) => {
172
- const mine = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
197
+ const all = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
198
+ // A RENDER BEFORE THE SEND IS NEVER THE READER (2026-10-25). The link was opened on the
199
+ // sender's side while the text waited on approval, and the reader was reported as having
200
+ // read a page that had not reached them. Nobody can have received a link before it was sent,
201
+ // so a session that began earlier is someone else's, and so is every session on that same
202
+ // device: the device had the link before the reader did. This decides by the send, not by
203
+ // guessing at user agents, so it holds for any renderer (a person checking the page, a
204
+ // preview that runs scripts) and costs a real reader nothing: their opens come after.
205
+ // Kept at read time, never by refusing to record, so a send marked late reclassifies.
206
+ const sentMs = r.sentAt ? Date.parse(r.sentAt) : NaN;
207
+ const pre = Number.isFinite(sentMs) ? all.filter((v) => Date.parse(v.firstAt) < sentMs) : [];
208
+ const preDevices = new Set(pre.map((v) => v.device));
209
+ const early = all.filter((v) => preDevices.has(v.device));
210
+ const mine = all.filter((v) => !preDevices.has(v.device));
173
211
  const firsts = mine.map((v) => Date.parse(v.firstAt));
174
212
  return {
175
213
  rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
214
+ sentAt: r.sentAt ?? null,
215
+ beforeSend: Number.isFinite(sentMs) ? { sessions: early.length, devices: preDevices.size, activeSeconds: early.reduce((n, v) => n + v.activeSeconds, 0), firstAt: early[0]?.firstAt ?? null } : null,
176
216
  opened: mine.length > 0,
177
217
  firstOpenAt: mine[0]?.firstAt ?? null,
178
218
  cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
@@ -1,3 +1,8 @@
1
+ /** How far below the top of the screen a heading may sit and still be the section being read. */
2
+ export declare const READING_LINE = 140;
3
+ /** The section being read: the last heading at or above the reading line, else the first. The
4
+ * page analytics times sections by the same rule, so a section's dwell matches the rail. */
5
+ export declare function currentSection(ids: string[], doc?: Document): string | undefined;
1
6
  export declare function TocSpy({ slugs }: {
2
7
  slugs: string[];
3
8
  }): null;
@@ -2,6 +2,19 @@
2
2
  // Marks the contents entry for the section being read. Renders nothing: it only sets
3
3
  // aria-current on the links the server drew, which is also what styles them.
4
4
  import { useEffect } from 'react';
5
+ /** How far below the top of the screen a heading may sit and still be the section being read. */
6
+ export const READING_LINE = 140;
7
+ /** The section being read: the last heading at or above the reading line, else the first. The
8
+ * page analytics times sections by the same rule, so a section's dwell matches the rail. */
9
+ export function currentSection(ids, doc = document) {
10
+ let current = ids[0];
11
+ for (const id of ids) {
12
+ const el = doc.getElementById(id);
13
+ if (el && el.getBoundingClientRect().top <= READING_LINE)
14
+ current = id;
15
+ }
16
+ return current;
17
+ }
5
18
  export function TocSpy({ slugs }) {
6
19
  const key = slugs.join('|');
7
20
  useEffect(() => {
@@ -9,12 +22,7 @@ export function TocSpy({ slugs }) {
9
22
  let frame = 0;
10
23
  const mark = () => {
11
24
  frame = 0;
12
- let current = ids[0];
13
- for (const id of ids) {
14
- const el = document.getElementById(id);
15
- if (el && el.getBoundingClientRect().top <= 140)
16
- current = id;
17
- }
25
+ const current = currentSection(ids);
18
26
  document.querySelectorAll('a[data-toc-slug]').forEach((a) => {
19
27
  if (a.dataset.tocSlug === current)
20
28
  a.setAttribute('aria-current', 'location');
@@ -0,0 +1,41 @@
1
+ export type AnalyticsConfig = {
2
+ /** A PostHog project API key (the public `phc_...` one, safe in a browser). */
3
+ posthogKey: string;
4
+ /** The ingestion host. Default PostHog's US cloud. */
5
+ posthogHost?: string;
6
+ /** Sent with every event as `tenant`, so one project can hold several hosts' pages. */
7
+ tenant?: string;
8
+ };
9
+ export type PageKind = 'public' | 'password' | 'gated' | 'per-reader';
10
+ export type AnalyticsPlan = {
11
+ key: string;
12
+ host: string;
13
+ pageId: string;
14
+ kind: PageKind;
15
+ tenant: string | null;
16
+ /** Who to identify the reader as, or null to keep them anonymous. */
17
+ identity: {
18
+ distinctId: string;
19
+ email?: string;
20
+ name?: string;
21
+ } | null;
22
+ };
23
+ export declare const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
24
+ export declare function analyticsPlan(input: {
25
+ config: AnalyticsConfig | null | undefined;
26
+ pageId: string;
27
+ kind: PageKind;
28
+ /** The owner or an admin is looking (signed in, previewing as a reader, or on the owner's own link). */
29
+ owner: boolean;
30
+ bot: boolean;
31
+ /** A gated page's signed-in reader. */
32
+ reader?: {
33
+ email: string;
34
+ name?: string | null;
35
+ } | null;
36
+ /** A per-reader link's recipient. */
37
+ recipient?: {
38
+ rid: string;
39
+ label: string;
40
+ } | null;
41
+ }): AnalyticsPlan | null;
@@ -0,0 +1,33 @@
1
+ // Whether a page view is recorded in the host's product analytics, and as whom. Pure, so the route
2
+ // decides on the server and the browser is handed either nothing or exactly what to send.
3
+ //
4
+ // THE RULES (0.50.0):
5
+ // - A host with no analytics key records nothing, and its pages load no analytics script at all.
6
+ // - The owner and the host's admins are never recorded, signed in or on their own per-reader link.
7
+ // - A link-preview fetcher is never handed the script.
8
+ // - A reader is identified only where the page already knows who they are: a gated page by the
9
+ // signed-in email, a per-reader link by its opaque recipient id and the label the publisher gave
10
+ // it (never a phone or an email, which the recipients store does not hold). A public or password
11
+ // page stays anonymous, even when its reader happens to be signed in.
12
+ export const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com';
13
+ export function analyticsPlan(input) {
14
+ const key = input.config?.posthogKey?.trim();
15
+ if (!key || input.owner || input.bot)
16
+ return null;
17
+ let identity = null;
18
+ if (input.kind === 'gated' && input.reader?.email) {
19
+ const email = input.reader.email.trim().toLowerCase();
20
+ identity = { distinctId: email, email, ...(input.reader.name ? { name: input.reader.name } : {}) };
21
+ }
22
+ else if (input.kind === 'per-reader' && input.recipient?.rid) {
23
+ identity = { distinctId: `recipient:${input.recipient.rid}`, name: input.recipient.label };
24
+ }
25
+ return {
26
+ key,
27
+ host: input.config.posthogHost?.trim() || DEFAULT_POSTHOG_HOST,
28
+ pageId: input.pageId,
29
+ kind: input.kind,
30
+ tenant: input.config.tenant ?? null,
31
+ identity,
32
+ };
33
+ }
@@ -275,7 +275,7 @@ export function ArtifactReader({ src, words, rootId, accent, ground, }) {
275
275
  else
276
276
  a.pause();
277
277
  };
278
- return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
278
+ return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, "data-artifact-narration": true, src: src, preload: "metadata", onPlay: () => {
279
279
  startedRef.current = true;
280
280
  playingRef.current = true;
281
281
  setPlaying(true);
@@ -13,3 +13,5 @@ export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom
13
13
  export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE, type VoiceHost, type VoiceMemo, type VoiceScope } from './voice-recorder.js';
14
14
  export { deviceAudio, isDeviceAudio, type DeviceAudio } from './device-audio.js';
15
15
  export { type SavedMemo } from './comment-card.js';
16
+ export { PageAnalytics, trackSections, narrationEvent, MIN_SECTION_SECONDS, posthogInitOptions, RECORDING_MASK } from './page-analytics.js';
17
+ export { analyticsPlan, DEFAULT_POSTHOG_HOST, type AnalyticsConfig, type AnalyticsPlan, type PageKind } from './analytics-plan.js';
@@ -14,3 +14,5 @@ export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDe
14
14
  export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
15
15
  export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE } from './voice-recorder.js';
16
16
  export { deviceAudio, isDeviceAudio } from './device-audio.js';
17
+ export { PageAnalytics, trackSections, narrationEvent, MIN_SECTION_SECONDS, posthogInitOptions, RECORDING_MASK } from './page-analytics.js';
18
+ export { analyticsPlan, DEFAULT_POSTHOG_HOST } from './analytics-plan.js';
@@ -0,0 +1,42 @@
1
+ import type { AnalyticsPlan } from './analytics-plan.js';
2
+ /** Sections read for less than this are passed over, not read. */
3
+ export declare const MIN_SECTION_SECONDS = 2;
4
+ type Capture = (event: string, props?: Record<string, unknown>) => void;
5
+ /** Times the section being read, by the contents rail's own rule, and reports each one as the
6
+ * reader leaves it. Only visible time counts. Returns a stop function that flushes the last one. */
7
+ export declare function trackSections(ids: string[], capture: Capture, doc?: Document, now?: () => number): {
8
+ check: () => void;
9
+ stop: () => void;
10
+ };
11
+ /** The narration's events: play, pause with where it stopped, complete. */
12
+ export declare function narrationEvent(type: string, audio: {
13
+ currentTime: number;
14
+ duration: number;
15
+ ended?: boolean;
16
+ }): [string, Record<string, unknown>] | null;
17
+ /** How every replay is masked: every text node and every input, on every page kind. The options set
18
+ * in `posthog.init` override the PostHog project's own masking setting, so this is the whole rule. */
19
+ export declare const RECORDING_MASK: Readonly<{
20
+ readonly maskAllInputs: true;
21
+ readonly maskTextSelector: "*";
22
+ }>;
23
+ /** The options the page hands `posthog.init`. Pure, so a test can hold it to the mask. */
24
+ export declare function posthogInitOptions(plan: Pick<AnalyticsPlan, 'host'>): {
25
+ api_host: string;
26
+ person_profiles: "identified_only";
27
+ capture_pageview: boolean;
28
+ capture_pageleave: boolean;
29
+ autocapture: boolean;
30
+ mask_all_text: boolean;
31
+ enable_heatmaps: boolean;
32
+ disable_session_recording: boolean;
33
+ session_recording: {
34
+ maskAllInputs: true;
35
+ maskTextSelector: "*";
36
+ };
37
+ };
38
+ export declare function PageAnalytics({ plan, rootId }: {
39
+ plan: AnalyticsPlan;
40
+ rootId?: string;
41
+ }): null;
42
+ export {};
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+ // The host's product analytics on a page: PostHog, with session replay, heatmaps, autocapture and
3
+ // scroll depth on leave, plus the reading this package knows about and PostHog cannot: which
4
+ // section was read and for how long, and the narration played, paused and finished.
5
+ //
6
+ // The server decides whether this mounts at all and as whom (analytics-plan.ts). posthog-js is
7
+ // imported only here, after mount, so a host with no key never downloads it.
8
+ //
9
+ // EVERY page's replay masks all text and all inputs, whatever its kind (since 0.51.0). A replay is a
10
+ // copy of the page held by a third party, and a page shared by key or by link is no less private
11
+ // than a gated one: the replay still shows where the reader went, how far and how fast, without the
12
+ // words. Autocapture is told not to collect element text for the same reason. There is no opt-out:
13
+ // the options come from posthogInitOptions, which a test holds to RECORDING_MASK for every kind.
14
+ import { useEffect } from 'react';
15
+ import { currentSection } from '../artifacts/toc-spy.js';
16
+ /** Sections read for less than this are passed over, not read. */
17
+ export const MIN_SECTION_SECONDS = 2;
18
+ /** Times the section being read, by the contents rail's own rule, and reports each one as the
19
+ * reader leaves it. Only visible time counts. Returns a stop function that flushes the last one. */
20
+ export function trackSections(ids, capture, doc = document, now = Date.now) {
21
+ let current;
22
+ let since = null;
23
+ const visible = () => doc.visibilityState === 'visible';
24
+ const flush = () => {
25
+ if (!current || since === null)
26
+ return;
27
+ const seconds = Math.round((now() - since) / 1000);
28
+ if (seconds >= MIN_SECTION_SECONDS) {
29
+ const heading = doc.getElementById(current)?.textContent?.trim() ?? '';
30
+ capture('section_viewed', { section_id: current, heading, seconds });
31
+ }
32
+ since = null;
33
+ };
34
+ const check = () => {
35
+ if (!visible()) {
36
+ flush();
37
+ return;
38
+ }
39
+ const next = currentSection(ids, doc);
40
+ if (next !== current) {
41
+ flush();
42
+ current = next;
43
+ }
44
+ if (since === null)
45
+ since = now();
46
+ };
47
+ return { check, stop: flush };
48
+ }
49
+ /** The narration's events: play, pause with where it stopped, complete. */
50
+ export function narrationEvent(type, audio) {
51
+ const at = { position_seconds: Math.round(audio.currentTime), duration_seconds: Number.isFinite(audio.duration) ? Math.round(audio.duration) : null };
52
+ if (type === 'play')
53
+ return ['narration_play', at];
54
+ if (type === 'pause' && !audio.ended)
55
+ return ['narration_pause', at];
56
+ if (type === 'ended')
57
+ return ['narration_complete', at];
58
+ return null;
59
+ }
60
+ /** How every replay is masked: every text node and every input, on every page kind. The options set
61
+ * in `posthog.init` override the PostHog project's own masking setting, so this is the whole rule. */
62
+ export const RECORDING_MASK = Object.freeze({ maskAllInputs: true, maskTextSelector: '*' });
63
+ /** The options the page hands `posthog.init`. Pure, so a test can hold it to the mask. */
64
+ export function posthogInitOptions(plan) {
65
+ return {
66
+ api_host: plan.host,
67
+ person_profiles: 'identified_only',
68
+ capture_pageview: true,
69
+ capture_pageleave: true,
70
+ autocapture: true,
71
+ // Autocapture records a clicked element's text; on these pages that text is the page.
72
+ mask_all_text: true,
73
+ enable_heatmaps: true,
74
+ disable_session_recording: false,
75
+ session_recording: { ...RECORDING_MASK },
76
+ };
77
+ }
78
+ export function PageAnalytics({ plan, rootId = 'artifact-narration-root' }) {
79
+ const { key, host, pageId, kind, tenant } = plan;
80
+ const identity = plan.identity ? JSON.stringify(plan.identity) : '';
81
+ useEffect(() => {
82
+ let cancelled = false;
83
+ let cleanup = () => { };
84
+ void import('posthog-js').then(({ default: posthog }) => {
85
+ if (cancelled)
86
+ return;
87
+ posthog.init(key, posthogInitOptions({ host }));
88
+ posthog.register({ artifact_id: pageId, page_kind: kind, ...(tenant ? { tenant } : {}) });
89
+ if (identity) {
90
+ const who = JSON.parse(identity);
91
+ posthog.identify(who.distinctId, { ...(who.email ? { email: who.email } : {}), ...(who.name ? { name: who.name } : {}) });
92
+ }
93
+ const capture = (e, p) => posthog.capture(e, p);
94
+ const root = document.getElementById(rootId);
95
+ const ids = root ? Array.from(root.querySelectorAll('h2[id], h3[id]'), (h) => h.id) : [];
96
+ const sections = ids.length ? trackSections(ids, capture) : null;
97
+ let frame = 0;
98
+ const onScroll = () => { if (sections && !frame)
99
+ frame = requestAnimationFrame(() => { frame = 0; sections.check(); }); };
100
+ const onVisibility = () => sections?.check();
101
+ const onHide = () => sections?.stop();
102
+ const onMedia = (e) => {
103
+ const el = e.target;
104
+ if (!el?.hasAttribute?.('data-artifact-narration'))
105
+ return;
106
+ const ev = narrationEvent(e.type, el);
107
+ if (ev)
108
+ capture(...ev);
109
+ };
110
+ sections?.check();
111
+ window.addEventListener('scroll', onScroll, { passive: true });
112
+ document.addEventListener('visibilitychange', onVisibility);
113
+ window.addEventListener('pagehide', onHide);
114
+ for (const t of ['play', 'pause', 'ended'])
115
+ document.addEventListener(t, onMedia, true);
116
+ cleanup = () => {
117
+ sections?.stop();
118
+ if (frame)
119
+ cancelAnimationFrame(frame);
120
+ window.removeEventListener('scroll', onScroll);
121
+ document.removeEventListener('visibilitychange', onVisibility);
122
+ window.removeEventListener('pagehide', onHide);
123
+ for (const t of ['play', 'pause', 'ended'])
124
+ document.removeEventListener(t, onMedia, true);
125
+ };
126
+ }).catch(() => { });
127
+ return () => { cancelled = true; cleanup(); };
128
+ }, [key, host, pageId, kind, tenant, identity, rootId]);
129
+ return null;
130
+ }
@@ -10,6 +10,7 @@ import { type Access, type AllowEntry } from '../artifacts/reader.js';
10
10
  import { type ReadersStore } from '../artifacts/readers-store.js';
11
11
  import { type ReaderEvent } from '../artifacts/read-alerts.js';
12
12
  import type { ResolveEvent } from '../artifacts/resolve-digest.js';
13
+ import { type AnalyticsConfig } from '../reader/analytics-plan.js';
13
14
  import type { PersonalStore } from '../artifacts/personal-store.js';
14
15
  import type { Transcriber } from '../artifacts/transcribe.js';
15
16
  export type ArtifactRoutesConfig = {
@@ -121,6 +122,12 @@ export type ArtifactRoutesConfig = {
121
122
  recipients?: RecipientsStore;
122
123
  /** Signs per-reader links and render tokens. At least 32 characters. Read at request time. */
123
124
  recipientSecret?: () => string | undefined;
125
+ /** Product analytics for this host's pages (0.50.0): PostHog with session replay, heatmaps,
126
+ * autocapture, scroll depth on leave, and section_viewed / narration_* events. Without a key no
127
+ * page loads any analytics script. Never records the owner or an admin, never a link-preview
128
+ * fetcher, never a past version; identifies a reader only on a gated page (their email) or a
129
+ * per-reader link (its recipient id and label). See reader/analytics-plan.ts. */
130
+ analytics?: AnalyticsConfig | null;
124
131
  };
125
132
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
126
133
  export declare const ARTIFACT_ID: RegExp;
@@ -217,6 +224,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
217
224
  id: string;
218
225
  }>;
219
226
  }) => Promise<NextResponse<unknown>>;
227
+ HEALTH: (_req: NextRequest) => Promise<NextResponse<unknown>>;
220
228
  Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
221
229
  generateMetadata: ({ params }: Params) => Promise<Metadata>;
222
230
  VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
@@ -36,6 +36,8 @@ import { ARTIFACT_ID_RE } from './ids.js';
36
36
  import { createStateRoutes } from './state-routes.js';
37
37
  import { createRecipientsRoutes } from './recipients-routes.js';
38
38
  import { QuietView } from '../reader/quiet-view.js';
39
+ import { PageAnalytics } from '../reader/page-analytics.js';
40
+ import { analyticsPlan } from '../reader/analytics-plan.js';
39
41
  import { createPersonalRoutes } from './personal-routes.js';
40
42
  import { createVoiceRoutes } from './voice-routes.js';
41
43
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
@@ -317,7 +319,7 @@ export function createArtifactRoutes(config) {
317
319
  if (!a)
318
320
  notFound();
319
321
  const { pack, brand } = lookOf(a);
320
- const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
322
+ const render = (top, reader, untracked = false) => (n === null ? Body(a, { top, reader, untracked }) : VersionBody(a, n, { top, reader }));
321
323
  if (a.access)
322
324
  return GatedPage(a, id, render, n);
323
325
  const broadcast = kindOf(a) === 'broadcast';
@@ -338,7 +340,7 @@ export function createArtifactRoutes(config) {
338
340
  if (isAdmin(me)) {
339
341
  const markdown = await media(a.markdown);
340
342
  const viewer = wantsReaderView(sp) ? null : me;
341
- const page = broadcast ? await BroadcastCard({ ...a, markdown }, { reader: viewer, bot }) : await Body({ ...a, markdown }, { top: true, reader: viewer });
343
+ const page = broadcast ? await BroadcastCard({ ...a, markdown }, { reader: viewer, bot, untracked: true }) : await Body({ ...a, markdown }, { top: true, reader: viewer, untracked: true });
342
344
  return _jsxs(BrandGround, { pack: pack, mode: a.theme, children: [viewer ? null : _jsx(ViewingAsReader, { back: pagePath(id) }), page] });
343
345
  }
344
346
  const secret = usableSecret(config.recipientSecret?.());
@@ -389,7 +391,7 @@ export function createArtifactRoutes(config) {
389
391
  // rendered for no one signed in, with a bar back to their own view. Anyone else asking gets the
390
392
  // page they already get.
391
393
  const asReader = isAdmin(reader) && wantsReaderView(sp);
392
- const page = await render(true, asReader ? null : reader);
394
+ const page = await render(true, asReader ? null : reader, asReader);
393
395
  if (n === null && !asReader)
394
396
  void store.bumpViews(id);
395
397
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, asReader ? _jsx(ViewingAsReader, { back: n === null ? pagePath(id) : `${pagePath(id)}/v/${n}` }) : null, page] }));
@@ -481,7 +483,7 @@ export function createArtifactRoutes(config) {
481
483
  * them, then this reader's own note. None of the document's furniture: no table of contents, no
482
484
  * version line, no read-along (a voice note is its own audio), and no page menu for a reader.
483
485
  * Comments only when the file turned them on; the quiet view beacon always, for a reader. */
484
- async function BroadcastCard(a, { reader, perReader, bot }) {
486
+ async function BroadcastCard(a, { reader, perReader, bot, untracked = false }) {
485
487
  const { brand } = lookOf(a);
486
488
  const mode = !bot && config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
487
489
  const admin = isAdmin(reader);
@@ -489,10 +491,10 @@ export function createArtifactRoutes(config) {
489
491
  const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
490
492
  return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
491
493
  // eslint-disable-next-line @next/next/no-img-element
492
- _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
494
+ _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked, bot })] }));
493
495
  }
494
496
  /** The page itself, shared by open and gated pages. */
495
- async function Body(a, { top, reader, perReader }) {
497
+ async function Body(a, { top, reader, perReader, untracked = false }) {
496
498
  const { pack, brand } = lookOf(a);
497
499
  let words = [];
498
500
  if (a.narration && a.timings) {
@@ -517,10 +519,27 @@ export function createArtifactRoutes(config) {
517
519
  return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, 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,
518
520
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
519
521
  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, embeds: embeds }) }));
520
- })() }), _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, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : 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,
522
+ })() }), _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, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked }), 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,
521
523
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
522
524
  recorded at publish, or there is none. */] }));
523
525
  }
526
+ /** The page's analytics, or nothing (analytics-plan.ts has the rules). `untracked` is the owner or
527
+ * an admin previewing as a reader, who arrives here with no reader at all. */
528
+ async function Analytics(a, { reader, perReader, untracked, bot }) {
529
+ if (!config.analytics?.posthogKey)
530
+ return null;
531
+ const ua = bot === undefined ? await (config.readHeader ?? defaultReadHeader)('user-agent').catch(() => undefined) : undefined;
532
+ const plan = analyticsPlan({
533
+ config: config.analytics,
534
+ pageId: a.id,
535
+ kind: perReader ? 'per-reader' : a.access ? 'gated' : a.password ? 'password' : 'public',
536
+ owner: untracked || isAdmin(reader) || !!perReader?.owner,
537
+ bot: bot ?? (ua !== undefined && isUnfurlAgent(ua)),
538
+ reader: a.access ? reader : null,
539
+ recipient: perReader ? { rid: perReader.rid, label: perReader.label } : null,
540
+ });
541
+ return plan ? _jsx(PageAnalytics, { plan: plan }) : null;
542
+ }
524
543
  const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
525
544
  /** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
526
545
  * reader. Swaps the five-minute pass for this host's grant and lands them on the page, with
@@ -65,7 +65,7 @@ export function createRecipientsRoutes(given) {
65
65
  const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
66
66
  return {
67
67
  id, expireAt: rec.expireAt, keep: rec.keep,
68
- recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
68
+ recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, sentAt: r.sentAt ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
69
69
  };
70
70
  }
71
71
  /** GET and POST /api/artifacts/<id>/recipients: who a per-reader page was sent to (publish key). */
@@ -105,6 +105,22 @@ export function createRecipientsRoutes(given) {
105
105
  if (!ctx.recipients.setCloses)
106
106
  return json({ error: 'this host cannot set a feedback window' }, 501);
107
107
  }
108
+ // `sent`: the sender's word that these links reached their readers, at `at` (default: now, on
109
+ // this host's clock). Views that began earlier are then not the reader's (summarizeViews). A
110
+ // time in the future is refused: it would hide a real reader's open.
111
+ const sent = b.sent;
112
+ let sentAt = null;
113
+ if (sent !== undefined) {
114
+ const ridsOk = !!sent && typeof sent === 'object' && (sent.rids === '*' || (Array.isArray(sent.rids) && sent.rids.every((x) => typeof x === 'string' && RID_RE.test(x))));
115
+ const atMs = sent && typeof sent === 'object' && sent.at !== undefined ? (typeof sent.at === 'string' && ISO.test(sent.at) ? Date.parse(sent.at) : NaN) : Date.now();
116
+ if (!ridsOk || !Number.isFinite(atMs))
117
+ return json({ error: 'sent is { rids: [rid] or "*", at: an ISO time (default now) }' }, 400);
118
+ if (atMs > Date.now() + 60_000)
119
+ return json({ error: 'sent.at must not be in the future' }, 400);
120
+ if (!ctx.recipients.setSent)
121
+ return json({ error: 'this host cannot record a send' }, 501);
122
+ sentAt = new Date(atMs).toISOString();
123
+ }
108
124
  if (adds.length) {
109
125
  const made = await ctx.recipients.add(id, adds.map((x) => ({
110
126
  label: x.label,
@@ -117,11 +133,12 @@ export function createRecipientsRoutes(given) {
117
133
  const revoked = b.revoke === '*' || Array.isArray(b.revoke)
118
134
  ? await ctx.recipients.revoke(id, b.revoke === '*' ? '*' : b.revoke.filter((x) => typeof x === 'string' && RID_RE.test(x)))
119
135
  : [];
136
+ const marked = sentAt ? await ctx.recipients.setSent(id, b.sent.rids, sentAt) : [];
120
137
  const windowed = win ? await ctx.recipients.setCloses(id, win.rids, win.at === null ? null : new Date(win.at).toISOString()) : [];
121
138
  if (b.expires === null || typeof b.expires === 'string') {
122
139
  await ctx.recipients.setExpiry(id, b.expires === null ? null : new Date(b.expires).toISOString());
123
140
  }
124
- return json({ ...(await listing(id)), added, revoked, windowed });
141
+ return json({ ...(await listing(id)), added, revoked, windowed, sent: marked });
125
142
  }
126
143
  /** GET /api/artifacts/<id>/views: one line per reader, with their comments (publish key). */
127
144
  async function VIEWS(req, { params }) {
@@ -62,5 +62,6 @@ export declare function createStateRoutes(ctx: StateRoutesContext): {
62
62
  COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
63
63
  RESOLVE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
64
64
  REPLY: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
65
+ HEALTH: (_req: NextRequest) => Promise<NextResponse<unknown>>;
65
66
  };
66
67
  export {};
@@ -30,6 +30,7 @@ import { COMMENTS_SLOT, FEEDBACK_CLOSED, MAX_COMMENT_CHARS, MAX_COMMENT_NAME_CHA
30
30
  import { MAX_QUOTE_CHARS } from '../artifacts/anchor.js';
31
31
  import { isCommentAudioPath } from '../artifacts/audio.js';
32
32
  import { readerKeyFor } from '../artifacts/state-store.js';
33
+ import { COMMENTS_FEED_INDEX, missingIndexOf } from '../artifacts/firestore-indexes.js';
33
34
  import { NOTES_SLOT, checkNoteValue, checklistAnswerIds, hasNotesWidget } from '../artifacts/widgets.js';
34
35
  import { CHECKLIST_SLOT, checkChecklistValue } from '../artifacts/checklist.js';
35
36
  import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
@@ -443,9 +444,20 @@ export function createStateRoutes(ctx) {
443
444
  if (only === null && !ctx.state.slotSince)
444
445
  return json({ error: 'this host cannot list comments by time' }, 501);
445
446
  const now = new Date().toISOString();
446
- const entries = only !== null
447
- ? (await ctx.state.entries(only)).filter((e) => e.slot === COMMENTS_SLOT && e.at > since).sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id)).slice(0, FEED_MAX)
448
- : await ctx.state.slotSince(COMMENTS_SLOT, since, FEED_MAX);
447
+ let entries;
448
+ try {
449
+ entries = only !== null
450
+ ? (await ctx.state.entries(only)).filter((e) => e.slot === COMMENTS_SLOT && e.at > since).sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id)).slice(0, FEED_MAX)
451
+ : await ctx.state.slotSince(COMMENTS_SLOT, since, FEED_MAX);
452
+ }
453
+ catch (err) {
454
+ // Firestore refuses the cross-page query until its composite index exists. Say which index,
455
+ // rather than a bare 500 the publisher's notifier cannot act on (October 2026).
456
+ const missing = missingIndexOf(err);
457
+ if (!missing)
458
+ throw err;
459
+ return json({ error: 'missing_firestore_index', index: COMMENTS_FEED_INDEX, create: missing.create }, 503);
460
+ }
449
461
  const pages = new Map();
450
462
  const pageOf = (id) => { if (!pages.has(id))
451
463
  pages.set(id, ctx.store.get(id)); return pages.get(id); };
@@ -639,5 +651,26 @@ export function createStateRoutes(ctx) {
639
651
  return json({ error: 'this thread takes no more replies' }, 409);
640
652
  return json({ id, entry: r.id, parent: root.id, existing: false });
641
653
  }
642
- return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE, REPLY };
654
+ /** GET /api/health: whether the queries this host's routes run will be served, today the
655
+ * comments feed's composite index. 200 with `ok: true`, or 503 naming what is missing. No key:
656
+ * it reveals nothing a public package does not already say, and a deploy check can call it. The
657
+ * probe asks for one comment after now, so it reads at most one document. */
658
+ async function HEALTH(_req) {
659
+ const checks = [];
660
+ if (ctx.state?.slotSince) {
661
+ try {
662
+ await ctx.state.slotSince(COMMENTS_SLOT, new Date().toISOString(), 1);
663
+ checks.push({ name: 'comments-feed-index', ok: true });
664
+ }
665
+ catch (err) {
666
+ const missing = missingIndexOf(err);
667
+ checks.push(missing
668
+ ? { name: 'comments-feed-index', ok: false, error: 'missing_firestore_index', index: COMMENTS_FEED_INDEX, create: missing.create }
669
+ : { name: 'comments-feed-index', ok: false, error: 'query_failed' });
670
+ }
671
+ }
672
+ const ok = checks.every((c) => c.ok === true);
673
+ return json({ ok, checks }, ok ? 200 : 503);
674
+ }
675
+ return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE, REPLY, HEALTH };
643
676
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.49.0",
3
+ "version": "0.52.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",
@@ -80,6 +80,7 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "gray-matter": "^4.0.3",
83
+ "posthog-js": "1.438.6",
83
84
  "react-markdown": "^9.0.3",
84
85
  "remark-gfm": "^4.0.1",
85
86
  "remark-parse": "^11.0.0",