@supersuit/artifacts 0.50.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,70 @@
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
+
6
70
  ## 0.50.0 (2026-10-19)
7
71
 
8
72
  **Product analytics, with video of each visit.** A host that passes `analytics: { posthogKey,
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.
@@ -1186,9 +1221,11 @@ Who is recorded, and as whom (`analyticsPlan`, `src/reader/analytics-plan.ts`):
1186
1221
 
1187
1222
  - Never the owner or an admin (signed in, previewing `?as=reader`, or on the owner's own per-reader
1188
1223
  link), never a link-preview fetcher, never a past version.
1189
- - A gated page identifies the signed-in reader by email and masks all text in its replays.
1224
+ - A gated page identifies the signed-in reader by email.
1190
1225
  - A per-reader link identifies as `recipient:<rid>` with the label the publisher gave it.
1191
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.
1192
1229
 
1193
1230
  ## Brand packs
1194
1231
 
@@ -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,
@@ -13,5 +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 } from './page-analytics.js';
16
+ export { PageAnalytics, trackSections, narrationEvent, MIN_SECTION_SECONDS, posthogInitOptions, RECORDING_MASK } from './page-analytics.js';
17
17
  export { analyticsPlan, DEFAULT_POSTHOG_HOST, type AnalyticsConfig, type AnalyticsPlan, type PageKind } from './analytics-plan.js';
@@ -14,5 +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 } from './page-analytics.js';
17
+ export { PageAnalytics, trackSections, narrationEvent, MIN_SECTION_SECONDS, posthogInitOptions, RECORDING_MASK } from './page-analytics.js';
18
18
  export { analyticsPlan, DEFAULT_POSTHOG_HOST } from './analytics-plan.js';
@@ -14,6 +14,27 @@ export declare function narrationEvent(type: string, audio: {
14
14
  duration: number;
15
15
  ended?: boolean;
16
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
+ };
17
38
  export declare function PageAnalytics({ plan, rootId }: {
18
39
  plan: AnalyticsPlan;
19
40
  rootId?: string;
@@ -6,9 +6,11 @@
6
6
  // The server decides whether this mounts at all and as whom (analytics-plan.ts). posthog-js is
7
7
  // imported only here, after mount, so a host with no key never downloads it.
8
8
  //
9
- // Text fields are masked in replays (PostHog's default). A gated page masks ALL text too, because
10
- // its body is confidential and a replay is a copy of it held by a third party: the replay still
11
- // shows where the reader went, how far and how fast, without the words.
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.
12
14
  import { useEffect } from 'react';
13
15
  import { currentSection } from '../artifacts/toc-spy.js';
14
16
  /** Sections read for less than this are passed over, not read. */
@@ -55,6 +57,24 @@ export function narrationEvent(type, audio) {
55
57
  return ['narration_complete', at];
56
58
  return null;
57
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
+ }
58
78
  export function PageAnalytics({ plan, rootId = 'artifact-narration-root' }) {
59
79
  const { key, host, pageId, kind, tenant } = plan;
60
80
  const identity = plan.identity ? JSON.stringify(plan.identity) : '';
@@ -64,16 +84,7 @@ export function PageAnalytics({ plan, rootId = 'artifact-narration-root' }) {
64
84
  void import('posthog-js').then(({ default: posthog }) => {
65
85
  if (cancelled)
66
86
  return;
67
- posthog.init(key, {
68
- api_host: host,
69
- person_profiles: 'identified_only',
70
- capture_pageview: true,
71
- capture_pageleave: true,
72
- autocapture: true,
73
- enable_heatmaps: true,
74
- disable_session_recording: false,
75
- session_recording: { maskAllInputs: true, ...(kind === 'gated' ? { maskTextSelector: '*' } : {}) },
76
- });
87
+ posthog.init(key, posthogInitOptions({ host }));
77
88
  posthog.register({ artifact_id: pageId, page_kind: kind, ...(tenant ? { tenant } : {}) });
78
89
  if (identity) {
79
90
  const who = JSON.parse(identity);
@@ -224,6 +224,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
224
224
  id: string;
225
225
  }>;
226
226
  }) => Promise<NextResponse<unknown>>;
227
+ HEALTH: (_req: NextRequest) => Promise<NextResponse<unknown>>;
227
228
  Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
228
229
  generateMetadata: ({ params }: Params) => Promise<Metadata>;
229
230
  VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
@@ -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.50.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",