@supersuit/artifacts 0.52.0 → 0.54.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,45 @@
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.54.0 (2026-10-25)
7
+
8
+ **A page no longer records under a reader left on the browser by another page.** posthog-js keeps
9
+ the last identity it was given on an origin, and `PageAnalytics` never cleared it, so a browser that
10
+ opened one per-reader link sent every password or public page it opened afterward under that
11
+ reader's recipient id and label, and a per-reader page's init-time `$pageview` went out under the
12
+ previous reader before `identify()` switched it.
13
+
14
+ - `PageAnalytics` calls `posthog.reset()` when the browser is identified as anyone other than the
15
+ page's own reader, before `register` (reset clears registered properties). An anonymous browser
16
+ is not reset, so its first visit still joins the reader it becomes; the device id survives.
17
+ - `capture_pageview` is now `false`; the page captures `$pageview` itself once the reader is settled.
18
+ - DETECTOR: in PostHog, a `password` or `public` `page_kind` event whose `distinct_id` starts with
19
+ `recipient:`, or whose person has a `name`, came from a host on an older version.
20
+ - REMEDY: update the host. Events already recorded keep the leftover identity; a dashboard should
21
+ name a reader only by an id of the page's own kind (a recipient id on `per-reader`, an email on
22
+ `gated`) and count everything else by device.
23
+ - Tests: `page-analytics-identity.test.tsx` mounts the component against a stand-in posthog-js
24
+ holding a leftover identity and checks the call order on every page kind.
25
+
26
+ ## 0.53.0 (2026-10-25)
27
+
28
+ **Every per-reader link can record who minted it, on whose authority, when, and the funnel step it
29
+ was sent for.** Superlinks: when an operator's agent sends a page, the link it makes names the
30
+ person it is for, and the founders asked that it also name who made it ("<person>'s Pluma, on
31
+ <person>'s explicit request, <time>"), so that a funnel move citing the link's opens has
32
+ provenance rather than a typed claim.
33
+
34
+ - `POST /recipients` `add` entries take `provenance: { actor, authority, at?, link?, funnel?, stage? }`.
35
+ `at` defaults to now; a missing actor or authority, a malformed time, a non-https link, a funnel
36
+ that is not a slug, or a stage without its funnel is refused (400) and nothing is added.
37
+ - `add`'s `added`, `GET /recipients` and `GET /views` carry `provenance` per reader (`null` when
38
+ none). Stored on the reader's row by both stores; `Provenance` is exported with the store types.
39
+ - DETECTOR: a sender that posts `provenance` and reads `added[i].provenance` back as missing or
40
+ `undefined` is talking to a host on 0.52.x or older, which drops the field.
41
+ - REMEDY: bump. Links made before this carry `provenance: null`; nothing is migrated.
42
+ - Tests: recipients-api.test.tsx (provenance kept and answered by add, the listing and views;
43
+ stamped now when no time is given; each malformed shape refused with 400).
44
+
6
45
  ## 0.52.0 (2026-10-25)
7
46
 
8
47
  **A render before the send is never counted as the reader opening the page.** A per-reader page
package/README.md CHANGED
@@ -1037,8 +1037,8 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
1037
1037
  | Route | Who | What |
1038
1038
  |---|---|---|
1039
1039
  | `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
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 |
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 |
1042
1042
  | `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
1043
1043
 
1044
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.
@@ -1070,6 +1070,20 @@ fact reclassifies what is already there. A link with no `sentAt` is read exactly
1070
1070
  cost, stated: a reader who opens the link within the seconds between delivery and the sender's
1071
1071
  mark is counted as before-send; senders mark as soon as delivery is confirmed.
1072
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
+
1073
1087
  ### Expiry
1074
1088
 
1075
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.
@@ -26,6 +26,6 @@ export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_
26
26
  export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
27
27
  export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
28
28
  export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
29
- 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';
30
30
  export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
31
31
  export { parseTranscriptFence, scanVoiceNotes, transcriptFenceFromWords, wordsFromWhisperJson, isAudioSrc, TRANSCRIPT_LANG, MAX_TRANSCRIPT_WORDS, type TranscriptParagraphs, type TimedWord, type VoiceNoteBlock } from './voice-note.js';
@@ -11,6 +11,19 @@ export type Recipient = {
11
11
  revokedAt?: string;
12
12
  closesAt?: string | null;
13
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;
14
27
  };
15
28
  export type RecipientsRecord = {
16
29
  recipients: Record<string, Recipient>;
@@ -43,6 +56,7 @@ export type RecipientSummary = {
43
56
  revoked: boolean;
44
57
  closesAt: string | null;
45
58
  sentAt: string | null;
59
+ provenance: Provenance | null;
46
60
  beforeSend: BeforeSend;
47
61
  opened: boolean;
48
62
  firstOpenAt: string | null;
@@ -63,6 +77,7 @@ type NewRecipient = {
63
77
  owner?: boolean;
64
78
  group?: boolean;
65
79
  closesAt?: string;
80
+ provenance?: Provenance;
66
81
  };
67
82
  export interface RecipientsStore {
68
83
  get(artifactId: string): Promise<RecipientsRecord | null>;
@@ -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) {
@@ -212,6 +212,7 @@ export function summarizeViews(rec, views, comments) {
212
212
  return {
213
213
  rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
214
214
  sentAt: r.sentAt ?? null,
215
+ provenance: r.provenance ?? null,
215
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,
216
217
  opened: mine.length > 0,
217
218
  firstOpenAt: mine[0]?.firstAt ?? null,
@@ -11,6 +11,12 @@
11
11
  // than a gated one: the replay still shows where the reader went, how far and how fast, without the
12
12
  // words. Autocapture is told not to collect element text for the same reason. There is no opt-out:
13
13
  // the options come from posthogInitOptions, which a test holds to RECORDING_MASK for every kind.
14
+ //
15
+ // posthog-js keeps the last identity it was given on this origin, and a page that names nobody does
16
+ // not clear it. So a browser that opened one reader's per-reader link reported every page it opened
17
+ // after under that reader's id and label, and a per-reader page's init-time view went out under the
18
+ // previous reader before identify switched it. The page now drops an identity it was not given
19
+ // before it records anything, and captures its own page view once it has settled who the reader is.
14
20
  import { useEffect } from 'react';
15
21
  import { currentSection } from '../artifacts/toc-spy.js';
16
22
  /** Sections read for less than this are passed over, not read. */
@@ -65,7 +71,8 @@ export function posthogInitOptions(plan) {
65
71
  return {
66
72
  api_host: plan.host,
67
73
  person_profiles: 'identified_only',
68
- capture_pageview: true,
74
+ // Captured by the page once it has settled who the reader is (see PageAnalytics).
75
+ capture_pageview: false,
69
76
  capture_pageleave: true,
70
77
  autocapture: true,
71
78
  // Autocapture records a clicked element's text; on these pages that text is the page.
@@ -85,11 +92,17 @@ export function PageAnalytics({ plan, rootId = 'artifact-narration-root' }) {
85
92
  if (cancelled)
86
93
  return;
87
94
  posthog.init(key, posthogInitOptions({ host }));
95
+ const who = identity ? JSON.parse(identity) : null;
96
+ // A reader left on this browser by another page is dropped first: reset also clears the
97
+ // registered properties, so it comes before register. An anonymous browser is left alone, so
98
+ // its first visit still joins the reader it becomes.
99
+ const left = posthog.get_property('$user_state') === 'identified' ? posthog.get_distinct_id() : null;
100
+ if (left && left !== who?.distinctId)
101
+ posthog.reset();
88
102
  posthog.register({ artifact_id: pageId, page_kind: kind, ...(tenant ? { tenant } : {}) });
89
- if (identity) {
90
- const who = JSON.parse(identity);
103
+ if (who)
91
104
  posthog.identify(who.distinctId, { ...(who.email ? { email: who.email } : {}), ...(who.name ? { name: who.name } : {}) });
92
- }
105
+ posthog.capture('$pageview');
93
106
  const capture = (e, p) => posthog.capture(e, p);
94
107
  const root = document.getElementById(rootId);
95
108
  const ids = root ? Array.from(root.querySelectorAll('h2[id], h3[id]'), (h) => h.id) : [];
@@ -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, sentAt: r.sentAt ?? 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))));
@@ -122,13 +165,14 @@ export function createRecipientsRoutes(given) {
122
165
  sentAt = new Date(atMs).toISOString();
123
166
  }
124
167
  if (adds.length) {
125
- const made = await ctx.recipients.add(id, adds.map((x) => ({
168
+ const made = await ctx.recipients.add(id, adds.map((x, i) => ({
126
169
  label: x.label,
127
170
  ...(typeof x.note === 'string' ? { note: x.note } : {}), ...(x.owner === true ? { owner: true } : {}), ...(x.group === true ? { group: true } : {}),
128
171
  // Never for the owner, whatever store the host brought: their link has no window.
129
172
  ...(typeof x.closes === 'string' && x.owner !== true ? { closesAt: new Date(x.closes).toISOString() } : {}),
173
+ ...(provenances[i] ? { provenance: provenances[i] } : {}),
130
174
  })));
131
- 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 }));
132
176
  }
133
177
  const revoked = b.revoke === '*' || Array.isArray(b.revoke)
134
178
  ? await ctx.recipients.revoke(id, b.revoke === '*' ? '*' : b.revoke.filter((x) => typeof x === 'string' && RID_RE.test(x)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.52.0",
3
+ "version": "0.54.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",