@supersuit/artifacts 0.50.0 → 0.53.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 +83 -0
- package/README.md +56 -5
- package/lib/artifacts/firestore-indexes.d.ts +23 -0
- package/lib/artifacts/firestore-indexes.js +40 -0
- package/lib/artifacts/index.d.ts +2 -1
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/recipients-store.d.ts +32 -0
- package/lib/artifacts/recipients-store.js +43 -2
- package/lib/reader/index.d.ts +1 -1
- package/lib/reader/index.js +1 -1
- package/lib/reader/page-analytics.d.ts +21 -0
- package/lib/reader/page-analytics.js +24 -13
- package/lib/routes/artifacts.d.ts +1 -0
- package/lib/routes/recipients-routes.d.ts +10 -1
- package/lib/routes/recipients-routes.js +65 -4
- package/lib/routes/state-routes.d.ts +1 -0
- package/lib/routes/state-routes.js +37 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,89 @@
|
|
|
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.53.0 (2026-10-25)
|
|
7
|
+
|
|
8
|
+
**Every per-reader link can record who minted it, on whose authority, when, and the funnel step it
|
|
9
|
+
was sent for.** Superlinks: when an operator's agent sends a page, the link it makes names the
|
|
10
|
+
person it is for, and the founders asked that it also name who made it ("<person>'s Pluma, on
|
|
11
|
+
<person>'s explicit request, <time>"), so that a funnel move citing the link's opens has
|
|
12
|
+
provenance rather than a typed claim.
|
|
13
|
+
|
|
14
|
+
- `POST /recipients` `add` entries take `provenance: { actor, authority, at?, link?, funnel?, stage? }`.
|
|
15
|
+
`at` defaults to now; a missing actor or authority, a malformed time, a non-https link, a funnel
|
|
16
|
+
that is not a slug, or a stage without its funnel is refused (400) and nothing is added.
|
|
17
|
+
- `add`'s `added`, `GET /recipients` and `GET /views` carry `provenance` per reader (`null` when
|
|
18
|
+
none). Stored on the reader's row by both stores; `Provenance` is exported with the store types.
|
|
19
|
+
- DETECTOR: a sender that posts `provenance` and reads `added[i].provenance` back as missing or
|
|
20
|
+
`undefined` is talking to a host on 0.52.x or older, which drops the field.
|
|
21
|
+
- REMEDY: bump. Links made before this carry `provenance: null`; nothing is migrated.
|
|
22
|
+
- Tests: recipients-api.test.tsx (provenance kept and answered by add, the listing and views;
|
|
23
|
+
stamped now when no time is given; each malformed shape refused with 400).
|
|
24
|
+
|
|
25
|
+
## 0.52.0 (2026-10-25)
|
|
26
|
+
|
|
27
|
+
**A render before the send is never counted as the reader opening the page.** A per-reader page
|
|
28
|
+
reported its reader as having opened it (first open 20:32:34Z, 647 active seconds) a minute before
|
|
29
|
+
the text carrying his link was sent (20:33:35Z). The page had been rendered from his link on the
|
|
30
|
+
sender's side while the send waited on approval, in a real browser, so the unfurl filter could not
|
|
31
|
+
see it and the render token treated it as a reader.
|
|
32
|
+
|
|
33
|
+
- `POST /recipients` takes `sent: { rids: [rid] or '*', at?: ISO }` and answers `sent`, the rids
|
|
34
|
+
marked. Earliest mark wins; never the owner; a future time is refused (400).
|
|
35
|
+
- `GET /views` counts only sessions on devices that had not rendered the link before `sentAt`, and
|
|
36
|
+
reports the rest as `beforeSend: { sessions, devices, activeSeconds, firstAt }`; both lines and
|
|
37
|
+
`GET /recipients` carry `sentAt`. A reader with no `sentAt` reads exactly as before.
|
|
38
|
+
- `RecipientsStore.setSent` (optional; the memory and Firestore stores have it).
|
|
39
|
+
- DETECTOR: a reader whose `firstOpenAt` is earlier than the time the text carrying their link was
|
|
40
|
+
sent. Any host on 0.51.x has no way to tell such a render from the reader.
|
|
41
|
+
- REMEDY: bump, then have the sender mark each per-reader link sent once delivery is confirmed
|
|
42
|
+
(Supersuit's send-message does this from the next release). For a page already wrong, mark the
|
|
43
|
+
link with the true send time: the views reclassify, nothing is deleted.
|
|
44
|
+
- Tests: recipients-store.test.ts (setSent on both stores; summarizeViews leaves out a pre-send
|
|
45
|
+
session and every later session on its device, keeps the reader's own) and
|
|
46
|
+
recipients-api.test.tsx (the `sent` mark, its refusals, and the views it changes).
|
|
47
|
+
|
|
48
|
+
## 0.51.0 (2026-10-23)
|
|
49
|
+
|
|
50
|
+
**Every page's session replay now masks all text and all inputs, not only a gated page's.** Since
|
|
51
|
+
0.50.0 a public, password or per-reader page recorded its replays with every word readable, so a
|
|
52
|
+
page shared by key or by link sat in full in the analytics provider's copy. `posthog.init` now gets
|
|
53
|
+
`session_recording: { maskAllInputs: true, maskTextSelector: '*' }` on every kind (these client
|
|
54
|
+
options override the PostHog project's own masking setting), and `mask_all_text: true` so
|
|
55
|
+
autocapture stops collecting a clicked element's text. There is no opt-out. Replays still show where
|
|
56
|
+
the reader went, how far and how fast.
|
|
57
|
+
|
|
58
|
+
- `posthogInitOptions(plan)` and `RECORDING_MASK`, exported: the exact init options, pure.
|
|
59
|
+
- DETECTOR: open a replay of a public or per-reader page in PostHog; readable page text means the
|
|
60
|
+
host is on 0.50.x. Autocapture events carrying `$el_text` from page body are the same leak.
|
|
61
|
+
- REMEDY: bump. Replays recorded before the bump keep their text; delete them in PostHog (filter
|
|
62
|
+
recordings by the `artifact_id` / `page_kind` event properties, then delete), and consider
|
|
63
|
+
deleting old autocapture events' `$el_text` the same way.
|
|
64
|
+
- Tests: page-analytics-recording.test.tsx (mounts the component against a stand-in posthog-js and
|
|
65
|
+
holds every page kind's init options to the mask; red on 0.50.0 for public, password and
|
|
66
|
+
per-reader).
|
|
67
|
+
|
|
68
|
+
**A hub can no longer go live with a dead comments feed without being told.** The feed's Firestore
|
|
69
|
+
query needs a composite index (`<base>State`: `slot` ASC, `at` ASC) that 0.13.0 told hubs to create
|
|
70
|
+
by hand; a hub that never did answered every `GET /api/comments` with a bare 500 for five days.
|
|
71
|
+
|
|
72
|
+
- `firestoreIndexesFor(stateBases)`: every composite index the Firestore stores need, as the
|
|
73
|
+
contents of `firestore.indexes.json`, so a hub deploys it with `firebase deploy --only
|
|
74
|
+
firestore:indexes` and tests that its committed file still carries it.
|
|
75
|
+
- `COMMENTS_FEED` answers **503** `{ error: 'missing_firestore_index', index, create }` (create is
|
|
76
|
+
Firestore's link) instead of throwing. Any other error still throws.
|
|
77
|
+
- New route `HEALTH` (`GET /api/health`, no key): probes the feed query for one comment after now;
|
|
78
|
+
200 `{ ok: true, checks }`, or 503 naming the missing index. Another failure reports
|
|
79
|
+
`query_failed` without its message.
|
|
80
|
+
- `missingIndexOf(err)` and `COMMENTS_FEED_INDEX`, exported for a host's own checks.
|
|
81
|
+
- DETECTOR: `curl <hub>/api/health` is 404 (no route), or 503 with `missing_firestore_index`.
|
|
82
|
+
- REMEDY: bump; add `app/api/health/route.ts` delegating GET to `HEALTH`; commit
|
|
83
|
+
`firestore.indexes.json` from `firestoreIndexesFor([...your state bases])` with `firebase.json`
|
|
84
|
+
pointing at it, add a test that the file contains it, and run `firebase deploy --only
|
|
85
|
+
firestore:indexes --project <project>` once.
|
|
86
|
+
- Tests: firestore-indexes.test.ts, and six in comments-feed.test.tsx (503 with the link, other
|
|
87
|
+
errors still throw, HEALTH red/green/other-failure/no-store).
|
|
88
|
+
|
|
6
89
|
## 0.50.0 (2026-10-19)
|
|
7
90
|
|
|
8
91
|
**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
|
|
994
|
-
`
|
|
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
|
|
1040
|
+
| `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes?, provenance? }], 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 and its `provenance` (see Who minted a 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`, `beforeSend` and `provenance`. 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,41 @@ 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
|
+
|
|
1073
|
+
### Who minted a link (0.53.0)
|
|
1074
|
+
|
|
1075
|
+
Every link can say who made it, on whose authority, and when: `add: [{ label, provenance: { actor,
|
|
1076
|
+
authority, at?, link?, funnel?, stage? } }]`. `actor` is a person acting by hand or "<person>'s
|
|
1077
|
+
Pluma"; `authority` names what they acted under ("approved by <person> on the send card", "on
|
|
1078
|
+
<person>'s explicit request", "under the recurrent <name>"), with `link` to the session or message
|
|
1079
|
+
where one exists (https only); `at` defaults to now. `funnel` (a slug) and `stage` (an id, only with
|
|
1080
|
+
its funnel) name the funnel step the link was sent for, so a funnel move that cites the link's opens
|
|
1081
|
+
has provenance. A provenance with no actor or no authority, or a malformed time, link, funnel or
|
|
1082
|
+
stage, is refused (400) and nothing is added. It is kept on the reader's row and answered back by
|
|
1083
|
+
`add`, `GET /recipients` and `GET /views` (`null` for a link made without one). A host on 0.52.x
|
|
1084
|
+
ignores the field, so a sender that needs it reads it back from `added` and treats `null` as an old
|
|
1085
|
+
host.
|
|
1086
|
+
|
|
1038
1087
|
### Expiry
|
|
1039
1088
|
|
|
1040
1089
|
`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 +1235,11 @@ Who is recorded, and as whom (`analyticsPlan`, `src/reader/analytics-plan.ts`):
|
|
|
1186
1235
|
|
|
1187
1236
|
- Never the owner or an admin (signed in, previewing `?as=reader`, or on the owner's own per-reader
|
|
1188
1237
|
link), never a link-preview fetcher, never a past version.
|
|
1189
|
-
- A gated page identifies the signed-in reader by email
|
|
1238
|
+
- A gated page identifies the signed-in reader by email.
|
|
1190
1239
|
- A per-reader link identifies as `recipient:<rid>` with the label the publisher gave it.
|
|
1191
1240
|
- A public or password page stays anonymous.
|
|
1241
|
+
- Every page, whatever its kind, records replays with all text and all inputs masked
|
|
1242
|
+
(`RECORDING_MASK`), and autocapture collects no element text. There is no opt-out.
|
|
1192
1243
|
|
|
1193
1244
|
## Brand packs
|
|
1194
1245
|
|
|
@@ -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
|
+
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -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';
|
|
@@ -25,6 +26,6 @@ export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_
|
|
|
25
26
|
export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
|
|
26
27
|
export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
|
|
27
28
|
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
|
|
28
|
-
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
|
|
29
|
+
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary, type Provenance } from './recipients-store.js';
|
|
29
30
|
export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
|
|
30
31
|
export { parseTranscriptFence, scanVoiceNotes, transcriptFenceFromWords, wordsFromWhisperJson, isAudioSrc, TRANSCRIPT_LANG, MAX_TRANSCRIPT_WORDS, type TranscriptParagraphs, type TimedWord, type VoiceNoteBlock } from './voice-note.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -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,20 @@ export type Recipient = {
|
|
|
8
10
|
addedAt: string;
|
|
9
11
|
revokedAt?: string;
|
|
10
12
|
closesAt?: string | null;
|
|
13
|
+
sentAt?: string;
|
|
14
|
+
provenance?: Provenance;
|
|
15
|
+
};
|
|
16
|
+
/** Who minted a link, on whose authority, and when (a superlink, 0.53.0): `actor` is a person by
|
|
17
|
+
* hand or "<person>'s Pluma"; `authority` is the request, recurrent or approval it acted under, with
|
|
18
|
+
* `link` to the session or message where one exists; `funnel` and `stage` name the funnel step the
|
|
19
|
+
* link was sent to move someone from, so a funnel move citing its opens has provenance. */
|
|
20
|
+
export type Provenance = {
|
|
21
|
+
actor: string;
|
|
22
|
+
authority: string;
|
|
23
|
+
at: string;
|
|
24
|
+
link?: string;
|
|
25
|
+
funnel?: string;
|
|
26
|
+
stage?: string;
|
|
11
27
|
};
|
|
12
28
|
export type RecipientsRecord = {
|
|
13
29
|
recipients: Record<string, Recipient>;
|
|
@@ -25,12 +41,23 @@ export type ViewDoc = {
|
|
|
25
41
|
activeSeconds: number;
|
|
26
42
|
plays: Record<string, number>;
|
|
27
43
|
};
|
|
44
|
+
/** What rendered the link before it was sent: never counted as the reader, shown so a publisher
|
|
45
|
+
* can see it happened (usually themselves, checking the page). Null when no send was recorded. */
|
|
46
|
+
export type BeforeSend = {
|
|
47
|
+
sessions: number;
|
|
48
|
+
devices: number;
|
|
49
|
+
activeSeconds: number;
|
|
50
|
+
firstAt: string | null;
|
|
51
|
+
} | null;
|
|
28
52
|
export type RecipientSummary = {
|
|
29
53
|
rid: string;
|
|
30
54
|
label: string;
|
|
31
55
|
group: boolean;
|
|
32
56
|
revoked: boolean;
|
|
33
57
|
closesAt: string | null;
|
|
58
|
+
sentAt: string | null;
|
|
59
|
+
provenance: Provenance | null;
|
|
60
|
+
beforeSend: BeforeSend;
|
|
34
61
|
opened: boolean;
|
|
35
62
|
firstOpenAt: string | null;
|
|
36
63
|
cameBack: boolean;
|
|
@@ -50,6 +77,7 @@ type NewRecipient = {
|
|
|
50
77
|
owner?: boolean;
|
|
51
78
|
group?: boolean;
|
|
52
79
|
closesAt?: string;
|
|
80
|
+
provenance?: Provenance;
|
|
53
81
|
};
|
|
54
82
|
export interface RecipientsStore {
|
|
55
83
|
get(artifactId: string): Promise<RecipientsRecord | null>;
|
|
@@ -59,6 +87,10 @@ export interface RecipientsStore {
|
|
|
59
87
|
* with '*'. Never the owner's. Returns the rids changed. Optional so a host's own store keeps
|
|
60
88
|
* working; without it the recipients route answers 501 to a `closes` change. */
|
|
61
89
|
setCloses?(artifactId: string, rids: string[] | '*', closesAt: string | null): Promise<string[]>;
|
|
90
|
+
/** Mark the named readers' links (or every non-owner's, with '*') as sent at `sentAt`. The
|
|
91
|
+
* earliest mark stands: a resend never moves it later. Returns the rids changed. Optional so a
|
|
92
|
+
* host's own store keeps working; without it the recipients route answers 501 to a `sent` mark. */
|
|
93
|
+
setSent?(artifactId: string, rids: string[] | '*', sentAt: string): Promise<string[]>;
|
|
62
94
|
setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
|
|
63
95
|
useToken(key: string, expiresAt: number): Promise<boolean>;
|
|
64
96
|
recordView(v: {
|
|
@@ -15,7 +15,7 @@ function makeRecipients(input, taken) {
|
|
|
15
15
|
const note = typeof i.note === 'string' && i.note.trim() ? i.note.trim().slice(0, NOTE_MAX) : undefined;
|
|
16
16
|
// The owner never carries a window: setCloses skips them and the page hides the closed state
|
|
17
17
|
// from them, so one stored here would offer Share on their link and then refuse it.
|
|
18
|
-
return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}), ...(typeof i.closesAt === 'string' && !i.owner ? { closesAt: i.closesAt } : {}) };
|
|
18
|
+
return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}), ...(typeof i.closesAt === 'string' && !i.owner ? { closesAt: i.closesAt } : {}), ...(i.provenance ? { provenance: { ...i.provenance } } : {}) };
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
function revokeIn(rec, rids) {
|
|
@@ -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,26 @@ 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
|
|
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
|
+
provenance: r.provenance ?? null,
|
|
216
|
+
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
217
|
opened: mine.length > 0,
|
|
177
218
|
firstOpenAt: mine[0]?.firstAt ?? null,
|
|
178
219
|
cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/reader/index.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// shows where the reader went, how far and how fast, without the
|
|
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>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import { type RecipientsStore } from '../artifacts/recipients-store.js';
|
|
2
|
+
import { type Provenance, type RecipientsStore } from '../artifacts/recipients-store.js';
|
|
3
3
|
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
4
|
import type { StateStore } from '../artifacts/state-store.js';
|
|
5
5
|
import type { ArtifactAssets } from '../artifacts/assets.js';
|
|
@@ -20,6 +20,15 @@ export type RecipientsRoutesCtx = {
|
|
|
20
20
|
/** Where the sweep reports a page it skipped or could not finish. Defaults to console.warn. */
|
|
21
21
|
log?: (message: string) => void;
|
|
22
22
|
};
|
|
23
|
+
/** A reader's provenance as sent, checked: who minted the link, on whose authority, when (default
|
|
24
|
+
* now), and the funnel step it serves. -> the clean record, or the reason it is refused. */
|
|
25
|
+
export declare function readProvenance(v: unknown, now?: Date): {
|
|
26
|
+
ok: true;
|
|
27
|
+
value: Provenance;
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
why: string;
|
|
31
|
+
};
|
|
23
32
|
export declare function createRecipientsRoutes(given: RecipientsRoutesCtx): {
|
|
24
33
|
SEEN: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
25
34
|
RECIPIENTS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
@@ -8,6 +8,38 @@ import { ARTIFACT_ID_RE } from './ids.js';
|
|
|
8
8
|
const SESSION = /^[a-z0-9]{1,32}$/;
|
|
9
9
|
const MEDIA = /^[A-Za-z0-9][A-Za-z0-9._-]{0,140}$/;
|
|
10
10
|
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
11
|
+
const SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
12
|
+
/** A reader's provenance as sent, checked: who minted the link, on whose authority, when (default
|
|
13
|
+
* now), and the funnel step it serves. -> the clean record, or the reason it is refused. */
|
|
14
|
+
export function readProvenance(v, now = new Date()) {
|
|
15
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
16
|
+
return { ok: false, why: 'provenance is { actor, authority, at?, link?, funnel?, stage? }' };
|
|
17
|
+
const p = v;
|
|
18
|
+
const text = (x, max) => (typeof x === 'string' && x.trim() ? x.trim().slice(0, max) : '');
|
|
19
|
+
const actor = text(p.actor, 120), authority = text(p.authority, 300);
|
|
20
|
+
if (!actor || !authority)
|
|
21
|
+
return { ok: false, why: 'provenance needs who minted the link (actor) and on whose authority (authority)' };
|
|
22
|
+
if (p.at !== undefined && (typeof p.at !== 'string' || !ISO.test(p.at)))
|
|
23
|
+
return { ok: false, why: 'provenance.at must be an ISO time' };
|
|
24
|
+
let link;
|
|
25
|
+
if (p.link !== undefined) {
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(String(p.link));
|
|
28
|
+
if (u.protocol !== 'https:')
|
|
29
|
+
throw new Error('not https');
|
|
30
|
+
link = u.toString();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { ok: false, why: 'provenance.link must be an https address' };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (p.funnel !== undefined && (typeof p.funnel !== 'string' || !SLUG.test(p.funnel)))
|
|
37
|
+
return { ok: false, why: 'provenance.funnel must be a funnel slug' };
|
|
38
|
+
if (p.stage !== undefined && (typeof p.stage !== 'string' || !SLUG.test(p.stage) || p.funnel === undefined))
|
|
39
|
+
return { ok: false, why: 'provenance.stage must be a stage id, with its funnel' };
|
|
40
|
+
const at = typeof p.at === 'string' ? new Date(p.at).toISOString() : now.toISOString();
|
|
41
|
+
return { ok: true, value: { actor, authority, at, ...(link ? { link } : {}), ...(p.funnel ? { funnel: p.funnel } : {}), ...(p.stage ? { stage: p.stage } : {}) } };
|
|
42
|
+
}
|
|
11
43
|
const none = () => new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
|
|
12
44
|
const json = (b, status = 200) => NextResponse.json(b, { status, headers: { 'cache-control': 'no-store' } });
|
|
13
45
|
export function createRecipientsRoutes(given) {
|
|
@@ -65,7 +97,7 @@ export function createRecipientsRoutes(given) {
|
|
|
65
97
|
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
66
98
|
return {
|
|
67
99
|
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) })),
|
|
100
|
+
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, provenance: r.provenance ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
|
|
69
101
|
};
|
|
70
102
|
}
|
|
71
103
|
/** GET and POST /api/artifacts/<id>/recipients: who a per-reader page was sent to (publish key). */
|
|
@@ -96,6 +128,17 @@ export function createRecipientsRoutes(given) {
|
|
|
96
128
|
const adds = Array.isArray(b.add) ? b.add : [];
|
|
97
129
|
if (adds.some((x) => x.closes !== undefined && (typeof x.closes !== 'string' || Number.isNaN(Date.parse(x.closes)))))
|
|
98
130
|
return json({ error: 'closes must be an ISO time' }, 400);
|
|
131
|
+
const provenances = [];
|
|
132
|
+
for (const x of adds) {
|
|
133
|
+
if (x.provenance === undefined) {
|
|
134
|
+
provenances.push(undefined);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const p = readProvenance(x.provenance);
|
|
138
|
+
if (!p.ok)
|
|
139
|
+
return json({ error: p.why }, 400);
|
|
140
|
+
provenances.push(p.value);
|
|
141
|
+
}
|
|
99
142
|
const win = b.closes;
|
|
100
143
|
if (win !== undefined) {
|
|
101
144
|
const ridsOk = !!win && (win.rids === '*' || (Array.isArray(win.rids) && win.rids.every((x) => typeof x === 'string' && RID_RE.test(x))));
|
|
@@ -105,23 +148,41 @@ export function createRecipientsRoutes(given) {
|
|
|
105
148
|
if (!ctx.recipients.setCloses)
|
|
106
149
|
return json({ error: 'this host cannot set a feedback window' }, 501);
|
|
107
150
|
}
|
|
151
|
+
// `sent`: the sender's word that these links reached their readers, at `at` (default: now, on
|
|
152
|
+
// this host's clock). Views that began earlier are then not the reader's (summarizeViews). A
|
|
153
|
+
// time in the future is refused: it would hide a real reader's open.
|
|
154
|
+
const sent = b.sent;
|
|
155
|
+
let sentAt = null;
|
|
156
|
+
if (sent !== undefined) {
|
|
157
|
+
const ridsOk = !!sent && typeof sent === 'object' && (sent.rids === '*' || (Array.isArray(sent.rids) && sent.rids.every((x) => typeof x === 'string' && RID_RE.test(x))));
|
|
158
|
+
const atMs = sent && typeof sent === 'object' && sent.at !== undefined ? (typeof sent.at === 'string' && ISO.test(sent.at) ? Date.parse(sent.at) : NaN) : Date.now();
|
|
159
|
+
if (!ridsOk || !Number.isFinite(atMs))
|
|
160
|
+
return json({ error: 'sent is { rids: [rid] or "*", at: an ISO time (default now) }' }, 400);
|
|
161
|
+
if (atMs > Date.now() + 60_000)
|
|
162
|
+
return json({ error: 'sent.at must not be in the future' }, 400);
|
|
163
|
+
if (!ctx.recipients.setSent)
|
|
164
|
+
return json({ error: 'this host cannot record a send' }, 501);
|
|
165
|
+
sentAt = new Date(atMs).toISOString();
|
|
166
|
+
}
|
|
108
167
|
if (adds.length) {
|
|
109
|
-
const made = await ctx.recipients.add(id, adds.map((x) => ({
|
|
168
|
+
const made = await ctx.recipients.add(id, adds.map((x, i) => ({
|
|
110
169
|
label: x.label,
|
|
111
170
|
...(typeof x.note === 'string' ? { note: x.note } : {}), ...(x.owner === true ? { owner: true } : {}), ...(x.group === true ? { group: true } : {}),
|
|
112
171
|
// Never for the owner, whatever store the host brought: their link has no window.
|
|
113
172
|
...(typeof x.closes === 'string' && x.owner !== true ? { closesAt: new Date(x.closes).toISOString() } : {}),
|
|
173
|
+
...(provenances[i] ? { provenance: provenances[i] } : {}),
|
|
114
174
|
})));
|
|
115
|
-
added = made.map((r) => ({ rid: r.rid, label: r.label, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) }));
|
|
175
|
+
added = made.map((r) => ({ rid: r.rid, label: r.label, url: ridLink(ctx.pageUrl(id), id, r.rid, secret), provenance: r.provenance ?? null }));
|
|
116
176
|
}
|
|
117
177
|
const revoked = b.revoke === '*' || Array.isArray(b.revoke)
|
|
118
178
|
? await ctx.recipients.revoke(id, b.revoke === '*' ? '*' : b.revoke.filter((x) => typeof x === 'string' && RID_RE.test(x)))
|
|
119
179
|
: [];
|
|
180
|
+
const marked = sentAt ? await ctx.recipients.setSent(id, b.sent.rids, sentAt) : [];
|
|
120
181
|
const windowed = win ? await ctx.recipients.setCloses(id, win.rids, win.at === null ? null : new Date(win.at).toISOString()) : [];
|
|
121
182
|
if (b.expires === null || typeof b.expires === 'string') {
|
|
122
183
|
await ctx.recipients.setExpiry(id, b.expires === null ? null : new Date(b.expires).toISOString());
|
|
123
184
|
}
|
|
124
|
-
return json({ ...(await listing(id)), added, revoked, windowed });
|
|
185
|
+
return json({ ...(await listing(id)), added, revoked, windowed, sent: marked });
|
|
125
186
|
}
|
|
126
187
|
/** GET /api/artifacts/<id>/views: one line per reader, with their comments (publish key). */
|
|
127
188
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.53.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",
|