@supersuit/artifacts 0.36.0 → 0.37.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,46 @@
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.37.0
7
+
8
+ **A broadcast is its own page kind.** `template: broadcast` in front matter makes a page one
9
+ message sent to several people, each through their own link, instead of a document with extra
10
+ lines. Documents are unchanged: an ordinary page renders byte for byte as before (the golden render
11
+ test is untouched) and lists as it always would have.
12
+
13
+ - The message card: the sender's words, then the media in the order written (a video with sound
14
+ and controls, photos, a voice note's player, link cards), then the reader's own note. No table of
15
+ contents, no version line, no read-along player, no page menu for a reader.
16
+ - The kind's defaults: per-reader links implied; private media implied wherever the body has a
17
+ stored private file or a local one (a remote URL alone stores nothing); the kind is sticky like
18
+ `links:` (a republish leaving `template:` out stays a broadcast, `template: document` is refused
19
+ with 409); `access`, `password` and `comments_visible: readers` refused in words; comments off unless
20
+ the file turns them on; no narration player (a voice note is its own audio). A body pointing at a
21
+ local file without `media: private` is refused, because that line is what makes the publisher
22
+ upload it privately.
23
+ - Expiry: `POST /api/artifacts` gives a broadcast with no date one 90 days out (`BROADCAST_TTL_MS`)
24
+ and answers it as `expireAt`. A date already set stands; Keep holds.
25
+ - `GET /api/artifacts?kind=broadcast|document` (`LIST`, publish key or the owner or an admin signed
26
+ in): the owner's two lists. A broadcast's line carries `readers` (live links) and `revoked`; a
27
+ recipients store that fails fails the list. `ArtifactStore.list(kind)` (optional; the Firestore store has it),
28
+ `kindOf`, `listOfKind`, `ARTIFACT_KINDS`.
29
+ - An unfurl fetcher never gets a signed media URL. DETECTOR: a per-reader page fetched with a
30
+ preview agent's user agent rendered its private media's signed URLs into the HTML, which a
31
+ preview service may cache. REMEDY: none in the host; the page now renders its text only to a
32
+ known fetcher or a request with no agent (`readHeader`, default through `next/headers`).
33
+ - `DELETE /api/artifacts/<id>` removes the page's files from both buckets (`deletePage`), then its
34
+ readers, their notes and views and every comment on it, before the record. DETECTOR: a deleted
35
+ page's media stayed in the bucket, and a kept broadcast's per-person notes and comments outlived
36
+ it. REMEDY: none; a failure to remove either answers 502 and keeps the record so the delete can be run again. An id that is not a
37
+ page id is refused before the bucket is touched.
38
+ - `shutPageResponse(request)`: a revoked or expired per-reader link answers 410 Gone with the same
39
+ quiet words. DETECTOR: a revoked link answered 200. REMEDY: call it from your `proxy.ts` (README,
40
+ Broadcasts: skip requests with no link or rid cookie before loading anything, and catch every
41
+ failure); without it the page still says the same words with a 200.
42
+ - Documents: the one output change is the bot rule above, on per-reader documents only.
43
+ - TESTS: `src/routes/broadcast.test.tsx`, `src/artifacts/store.test.ts` (listing one kind),
44
+ `src/artifacts/index.test.ts`, `src/artifacts/front-matter.test.ts`.
45
+
6
46
  ## 0.36.0
7
47
 
8
48
  **Private media moves to its own private bucket, and a host without one refuses it.** 0.35.0 kept
package/README.md CHANGED
@@ -254,7 +254,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
254
254
  | `links` | `per-reader`: the page opens only through a link made for one reader (see Per-reader links). Sticky: a republish whose file leaves the line out changes nothing |
255
255
  | `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
256
256
  | `change` | One line saying what this version changed (see Version history) |
257
- | `template` | `document`, the only one so far |
257
+ | `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
258
258
 
259
259
  An unknown key is refused, so a typo fails loudly instead of being ignored.
260
260
 
@@ -878,6 +878,60 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
878
878
 
879
879
  `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.
880
880
 
881
+ ## Broadcasts (`template: broadcast`)
882
+
883
+ A broadcast is its own page kind, not a document with extra flags. It is one message sent to several people, each through their own link, and it renders as one card: the sender's words at the top, then the media in the order the words put it (a video with sound and controls, photos, a voice note's player, link cards), then that person's own note. None of the document's furniture: no table of contents, no version line, no read-along, and no page menu for a reader.
884
+
885
+ ```markdown
886
+ ---
887
+ title: At the lake
888
+ summary: We finally made it up there.
889
+ template: broadcast
890
+ links: per-reader
891
+ media: private
892
+ voice: none
893
+ ---
894
+ We finally made it up there.
895
+
896
+ ![sound: The lake at dusk](lake.mp4)
897
+ ```
898
+
899
+ What the kind decides, so the file does not have to:
900
+
901
+ - **Per-reader links, always.** `links: per-reader` is implied. The kind is sticky like `links:`: a republish that leaves `template:` out stays a broadcast, and one that says `document` is refused (409). `access`, `password` and `comments_visible: readers` are refused in words.
902
+ - **Private media wherever there is media.** `media: private` is implied as soon as the body carries a stored private file or a local one (a remote URL alone stores nothing, so it does not count). A body that points at a local file (one the publisher will upload) must still say `media: private`, because that line is what makes the publisher upload it privately; without it the page is refused before anything is uploaded.
903
+ - **Expires 90 days after it is published** (`BROADCAST_TTL_MS`), set by `POST /api/artifacts` when the page has no date yet, and answered as `expireAt` in its response. A date set on the recipients route stands, a republish never moves it, and Keep (`expires: null`) holds.
904
+ - **Comments off** unless the file turns them on. **No narration player**, ever: a voice note is its own audio (`![Voice note](note.m4a)` draws a player). Write `voice: none` so the publisher does not record one.
905
+ - **Silent view tracking**: the quiet view beacon, as on every per-reader page.
906
+
907
+ Routes:
908
+
909
+ | Route | Who | What |
910
+ |---|---|---|
911
+ | `GET /api/artifacts?kind=broadcast` | publish key, or the owner or an admin signed in | Every broadcast, newest first: id, title, summary, url, dates, `expireAt`, `keep`, `readers` (people whose link still works) and `revoked`. `?kind=document` (the default) lists documents, never a broadcast |
912
+ | `shutPageResponse(request)` | your proxy | A per-reader page whose link was revoked or whose date passed answers **410 Gone** with the quiet words the page shows. Null for everything else, which the proxy passes on. Reads nothing unless the request carries a reader's link or its cookie |
913
+
914
+ ```ts
915
+ // proxy.ts
916
+ import { NextResponse, type NextRequest } from 'next/server'
917
+ export async function proxy(request: NextRequest) {
918
+ // Only a request carrying a reader's link or its cookie can be shut; skip the rest cheaply.
919
+ const hasLink = request.nextUrl.searchParams.has('r') || request.cookies.getAll().some((c) => c.name.startsWith('artifact_r_'))
920
+ if (!hasLink) return NextResponse.next()
921
+ try {
922
+ const { routes } = await import('./routes') // your createArtifactRoutes(...)
923
+ const shut = await routes.shutPageResponse(request)
924
+ if (shut) return shut
925
+ } catch {
926
+ // A store outage never costs a reader the page: the page applies the same gate itself.
927
+ }
928
+ return NextResponse.next()
929
+ }
930
+ export const config = { matcher: ['/:id([a-z0-9]{8})'] }
931
+ ```
932
+
933
+ Any per-reader page served to a known unfurl fetcher (or a request with no user agent) carries its text only: no media, no signed URL, no reader's note, no cookie. `readHeader` on `createArtifactRoutes` reads the agent (default through `next/headers`). `DELETE /api/artifacts/<id>` removes the page's files from both buckets (`deletePage`), then its readers, their notes and views and every comment, before the record, and keeps the record (502) if the files could not go, so it can be run again.
934
+
881
935
  ## Brand packs
882
936
 
883
937
  A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
@@ -6,7 +6,9 @@ import { type Definition } from './definitions.js';
6
6
  export type ArtifactMeta = {
7
7
  title: string;
8
8
  summary: string;
9
- template: 'document';
9
+ /** The page kind. `document` (the default) is the read-along reader; `broadcast` is one message
10
+ * card sent to several people, each through their own link (0.37.0). */
11
+ template: ArtifactKind;
10
12
  /** A line under the title. Title, subtitle and summary are three separate fields; the summary
11
13
  * is the teaser, and the unfurl carries the title and the summary. */
12
14
  subtitle?: string;
@@ -59,11 +61,21 @@ export type ArtifactMeta = {
59
61
  /** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
60
62
  commentsVisible?: CommentsVisible;
61
63
  };
64
+ /** What a page is. A broadcast is its own kind, not a document with extra flags: it renders as a
65
+ * message card, opens only through per-reader links, keeps its media private, has comments off
66
+ * and no narration by default, and expires 90 days after publish unless kept. */
67
+ export type ArtifactKind = 'document' | 'broadcast';
68
+ export declare const ARTIFACT_KINDS: readonly ArtifactKind[];
62
69
  /** A pack name, as a host registers one. */
63
70
  export declare const PACK_NAME: RegExp;
64
71
  /** The longest `to:` a page may carry: a name or two, never a sentence. */
65
72
  export declare const MAX_TO_CHARS = 60;
66
- export declare function parseArtifactSource(text: string): {
73
+ export declare function parseArtifactSource(text: string,
74
+ /** The kind a file that leaves `template:` out is read as: the stored page's kind on a
75
+ * republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
76
+ opts?: {
77
+ defaultKind?: ArtifactKind;
78
+ }): {
67
79
  ok: true;
68
80
  meta: ArtifactMeta;
69
81
  body: string;
@@ -7,12 +7,23 @@ import { mergeChecklistState, mergeWidgetState, scanWidgets } from './widgets.js
7
7
  import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
8
8
  import { THEME_MODES } from '../brand/theme.js';
9
9
  import { parseDefinitions } from './definitions.js';
10
+ export const ARTIFACT_KINDS = ['document', 'broadcast'];
11
+ /** Any media in a body: an image, a video or an audio clip, written as markdown image syntax. */
12
+ const MEDIA_IN_BODY = /!\[[^\]]*\]\(\s*<?([^)\s>]+)/g;
13
+ /** A reference the publisher uploads before the page is finished: neither a URL, a site path nor
14
+ * an already-stored private file. */
15
+ const LOCAL_REF = /^(?![a-z][a-z0-9+.-]*:|\/|#)/i;
16
+ /** A file already stored in the page's private store. */
17
+ const PRIVATE_REF_IN_BODY = /^private:/;
10
18
  /** A pack name, as a host registers one. */
11
19
  export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
12
20
  /** The longest `to:` a page may carry: a name or two, never a sentence. */
13
21
  export const MAX_TO_CHARS = 60;
14
22
  const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media']);
15
- export function parseArtifactSource(text) {
23
+ export function parseArtifactSource(text,
24
+ /** The kind a file that leaves `template:` out is read as: the stored page's kind on a
25
+ * republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
26
+ opts = {}) {
16
27
  const { data, content } = matter(text);
17
28
  const d = data;
18
29
  for (const k of Object.keys(d)) {
@@ -24,14 +35,24 @@ export function parseArtifactSource(text) {
24
35
  if (typeof v !== 'string' || !v.trim())
25
36
  return { ok: false, error: `front matter is missing ${k}` };
26
37
  }
27
- const template = d.template ?? 'document';
28
- if (template !== 'document')
29
- return { ok: false, error: 'template must be document' };
38
+ const template = d.template ?? opts.defaultKind ?? 'document';
39
+ if (!ARTIFACT_KINDS.includes(template))
40
+ return { ok: false, error: 'template must be document or broadcast' };
30
41
  const meta = {
31
42
  title: String(d.title).trim(),
32
43
  summary: String(d.summary).trim(),
33
- template: 'document',
44
+ template: template,
34
45
  };
46
+ // A broadcast opens only through each person's own link: a door or a shared thread of comments
47
+ // would make it something else, so those lines are refused in words rather than dropped.
48
+ if (meta.template === 'broadcast') {
49
+ if (d.access !== undefined && d.access !== null)
50
+ return { ok: false, error: 'a broadcast opens only through each person\'s own link: drop access' };
51
+ if (d.password !== undefined && d.password !== null)
52
+ return { ok: false, error: 'a broadcast opens only through each person\'s own link: drop password' };
53
+ if (d.comments_visible === 'readers')
54
+ return { ok: false, error: 'a broadcast keeps each person\'s comments to the owner: drop comments_visible: readers' };
55
+ }
35
56
  for (const k of ['subtitle', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password']) {
36
57
  const v = d[k];
37
58
  if (typeof v === 'string' && v)
@@ -106,6 +127,18 @@ export function parseArtifactSource(text) {
106
127
  return { ok: false, error: 'comments_visible must be owner or readers' };
107
128
  meta.commentsVisible = d.comments_visible;
108
129
  }
130
+ if (meta.template === 'broadcast') {
131
+ // Per-reader links are what a broadcast is, whatever the file says or leaves out.
132
+ meta.links = 'per-reader';
133
+ const refs = [...content.matchAll(MEDIA_IN_BODY)].map((m) => m[1]).filter((r) => LOCAL_REF.test(r) || PRIVATE_REF_IN_BODY.test(r));
134
+ // The publisher uploads a local file privately only when the file itself says `media: private`,
135
+ // so a broadcast that would send one up in public is refused before anything is uploaded.
136
+ if (!meta.privateMedia && refs.some((r) => LOCAL_REF.test(r)))
137
+ return { ok: false, error: 'a broadcast keeps its media private: add media: private so the publisher uploads it privately' };
138
+ // A remote URL alone stores nothing here, so only a stored private file or a local one counts.
139
+ if (refs.length)
140
+ meta.privateMedia = true;
141
+ }
109
142
  // Only the per-reader page resolves private media, so on any other page it would ship broken.
110
143
  if (meta.privateMedia && meta.links !== 'per-reader')
111
144
  return { ok: false, error: 'media: private needs links: per-reader' };
@@ -1,7 +1,7 @@
1
- export { parseArtifactSource, type ArtifactMeta } from './front-matter.js';
1
+ export { parseArtifactSource, ARTIFACT_KINDS, type ArtifactMeta, type ArtifactKind } from './front-matter.js';
2
2
  export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
- export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
4
+ export { createArtifactStore, newArtifactId, cleanNote, kindOf, listOfKind, MAX_CHANGE_NOTE_CHARS, type ArtifactListItem, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
5
5
  export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
6
6
  export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS, type Publisher } from './publisher.js';
7
7
  export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS, type ArtifactAssets, type ArtifactAssetsOptions } from './assets.js';
@@ -1,7 +1,7 @@
1
- export { parseArtifactSource } from './front-matter.js';
1
+ export { parseArtifactSource, ARTIFACT_KINDS } from './front-matter.js';
2
2
  export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
- export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
4
+ export { createArtifactStore, newArtifactId, cleanNote, kindOf, listOfKind, MAX_CHANGE_NOTE_CHARS } from './store.js';
5
5
  export { isPublishAuthed, isPublisherAuthed } from './auth.js';
6
6
  export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS } from './publisher.js';
7
7
  export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS } from './assets.js';
@@ -1,5 +1,5 @@
1
1
  import { type Firestore } from 'firebase-admin/firestore';
2
- import type { ArtifactMeta } from './front-matter.js';
2
+ import type { ArtifactKind, ArtifactMeta } from './front-matter.js';
3
3
  import type { Access } from './reader.js';
4
4
  import type { StateConfig } from './state.js';
5
5
  import type { ThemeMode } from '../brand/theme.js';
@@ -10,7 +10,8 @@ export type ArtifactRecord = {
10
10
  id: string;
11
11
  title: string;
12
12
  summary: string;
13
- template: 'document';
13
+ /** The page kind; see ArtifactMeta.template. Pages saved before kinds existed carry `document`. */
14
+ template: ArtifactKind;
14
15
  subtitle?: string;
15
16
  /** Who the page is for; see ArtifactMeta.to. */
16
17
  to?: string;
@@ -74,6 +75,25 @@ export type VersionRecord = VersionEntry & {
74
75
  export declare const MAX_CHANGE_NOTE_CHARS = 280;
75
76
  export declare function cleanNote(note: unknown): string | undefined;
76
77
  export declare function newArtifactId(): string;
78
+ /** One line of the owner's list of pages: no body, no settings. */
79
+ export type ArtifactListItem = {
80
+ id: string;
81
+ title: string;
82
+ summary: string;
83
+ template: ArtifactKind;
84
+ createdAt: string;
85
+ updatedAt: string;
86
+ views: number;
87
+ };
88
+ /** The kind a stored page is: anything not saved as a broadcast is a document, so pages saved
89
+ * before kinds existed list where they always would have. */
90
+ export declare const kindOf: (a: {
91
+ template?: string;
92
+ }) => ArtifactKind;
93
+ /** The owner's list of one kind, newest first: the same rule every store applies. */
94
+ export declare function listOfKind(pages: Iterable<Partial<ArtifactRecord> & {
95
+ id: string;
96
+ }>, kind: ArtifactKind): ArtifactListItem[];
77
97
  export type SaveResult = {
78
98
  id: string;
79
99
  version: number;
@@ -107,6 +127,9 @@ export interface ArtifactStore {
107
127
  version?(id: string, n: number): Promise<VersionRecord | null>;
108
128
  /** Write (or, with null, clear) the change note on one version after the fact. */
109
129
  setNote?(id: string, n: number, note: string | null): Promise<boolean>;
130
+ /** Every page of one kind, newest first, without bodies (0.37.0): the owner's documents and the
131
+ * owner's broadcasts are two lists. */
132
+ list?(kind: ArtifactKind): Promise<ArtifactListItem[]>;
110
133
  }
111
134
  /** The Firestore-backed store. The instance hands in its own Firestore, so the shell
112
135
  * never knows which project it is writing to; a hosted tenant passes a namespaced one. */
@@ -26,6 +26,16 @@ export function newArtifactId() {
26
26
  s += ALPHABET[randomInt(ALPHABET.length)];
27
27
  return s;
28
28
  }
29
+ /** The kind a stored page is: anything not saved as a broadcast is a document, so pages saved
30
+ * before kinds existed list where they always would have. */
31
+ export const kindOf = (a) => (a.template === 'broadcast' ? 'broadcast' : 'document');
32
+ /** The owner's list of one kind, newest first: the same rule every store applies. */
33
+ export function listOfKind(pages, kind) {
34
+ return [...pages]
35
+ .filter((p) => kindOf(p) === kind)
36
+ .map((p) => ({ id: p.id, title: p.title ?? '', summary: p.summary ?? '', template: kind, createdAt: p.createdAt ?? '', updatedAt: p.updatedAt ?? '', views: p.views ?? 0 }))
37
+ .sort((x, y) => y.updatedAt.localeCompare(x.updatedAt) || x.id.localeCompare(y.id));
38
+ }
29
39
  /** The Firestore-backed store. The instance hands in its own Firestore, so the shell
30
40
  * never knows which project it is writing to; a hosted tenant passes a namespaced one. */
31
41
  export function createArtifactStore(db, collection = 'artifacts') {
@@ -59,6 +69,11 @@ export function createArtifactStore(db, collection = 'artifacts') {
59
69
  history: (id) => historyOf(col, id),
60
70
  version: (id, n) => versionOf(col, id, n),
61
71
  setNote: (id, n, note) => setNote(col, id, n, note),
72
+ list: async (kind) => {
73
+ // `select` keeps every body out of the listing; a tenant's pages number in the hundreds.
74
+ const snap = await col().select('title', 'summary', 'template', 'createdAt', 'updatedAt', 'views').get();
75
+ return listOfKind(snap.docs.map((d) => ({ ...d.data(), id: d.id })), kind);
76
+ },
62
77
  };
63
78
  }
64
79
  const vid = (n) => String(n).padStart(6, '0');
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from './artifacts/index.js';
2
2
  export * from './brand/index.js';
3
3
  export * from './gate.js';
4
- export { createArtifactRoutes, type ArtifactRoutesConfig } from './routes/artifacts.js';
4
+ export { createArtifactRoutes, BROADCAST_TTL_MS, type ArtifactRoutesConfig } from './routes/artifacts.js';
5
5
  export { ANON_COOKIE } from './routes/state-routes.js';
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from './artifacts/index.js';
2
2
  export * from './brand/index.js';
3
3
  export * from './gate.js';
4
- export { createArtifactRoutes } from './routes/artifacts.js';
4
+ export { createArtifactRoutes, BROADCAST_TTL_MS } from './routes/artifacts.js';
5
5
  export { ANON_COOKIE } from './routes/state-routes.js';
@@ -53,6 +53,9 @@ export type ArtifactRoutesConfig = {
53
53
  pagePrefix?: string;
54
54
  /** Read one request cookie by name. Default reads through next/headers; tests inject one. */
55
55
  readCookie?: (name: string) => Promise<string | undefined>;
56
+ /** Read one request header by name (the per-reader page reads `user-agent`, so an unfurl fetcher
57
+ * is never handed a signed media URL). Default reads through next/headers; tests inject one. */
58
+ readHeader?: (name: string) => Promise<string | undefined>;
56
59
  /** The record for gated pages (`access:` in front matter). Without it a gated page stays shut
57
60
  * to everyone, never open: failing closed is the only safe default for a confidential page. */
58
61
  readers?: ReadersStore;
@@ -136,6 +139,9 @@ type VersionProps = VersionParams & {
136
139
  r?: string | string[];
137
140
  }>;
138
141
  };
142
+ /** How long a broadcast lives when nobody gave it a date: 90 days from its first publish, unless
143
+ * the owner taps Keep (`expires: null` on the recipients route). */
144
+ export declare const BROADCAST_TTL_MS: number;
139
145
  export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
140
146
  dynamic: "force-dynamic";
141
147
  maxDuration: number;
@@ -160,6 +166,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
160
166
  skipped: string[];
161
167
  failed: string[];
162
168
  }>;
169
+ forgetReaders: (id: string) => Promise<void>;
163
170
  PERSONAL_GET: (req: NextRequest, { params }: {
164
171
  params: Promise<{
165
172
  id: string;
@@ -210,6 +217,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
210
217
  error: string;
211
218
  }> | NextResponse<{
212
219
  warning?: string | undefined;
220
+ expireAt?: string | null | undefined;
213
221
  id: string;
214
222
  url: string;
215
223
  version: number;
@@ -224,7 +232,34 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
224
232
  error: string;
225
233
  }> | NextResponse<{
226
234
  deleted: boolean;
235
+ files: number;
236
+ }>>;
237
+ LIST: (request: NextRequest) => Promise<NextResponse<{
238
+ error: string;
239
+ }> | NextResponse<{
240
+ kind: string;
241
+ pages: ({
242
+ views: number;
243
+ id: string;
244
+ title: string;
245
+ summary: string;
246
+ url: string;
247
+ createdAt: string;
248
+ updatedAt: string;
249
+ } | {
250
+ expireAt: string | null;
251
+ keep: boolean;
252
+ readers: number;
253
+ revoked: number;
254
+ id: string;
255
+ title: string;
256
+ summary: string;
257
+ url: string;
258
+ createdAt: string;
259
+ updatedAt: string;
260
+ })[];
227
261
  }>>;
262
+ shutPageResponse: (request: NextRequest) => Promise<Response | null>;
228
263
  PUT_ASSET: (request: NextRequest, { params }: {
229
264
  params: Promise<{
230
265
  id: string;
@@ -3,13 +3,13 @@ import { revalidatePath } from 'next/cache';
3
3
  import { notFound, redirect } from 'next/navigation';
4
4
  import { NextResponse } from 'next/server';
5
5
  import { isPublisherAuthed } from '../artifacts/auth.js';
6
- import { parseArtifactSource } from '../artifacts/front-matter.js';
6
+ import { ARTIFACT_KINDS, parseArtifactSource } from '../artifacts/front-matter.js';
7
7
  import { narrationText } from '../artifacts/narration.js';
8
8
  import { ArtifactMarkdown } from '../artifacts/render.js';
9
9
  import { CHECKLIST_SLOT } from '../artifacts/checklist.js';
10
10
  import { ArtifactDoor } from '../artifacts/door.js';
11
11
  import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
12
- import { cleanNote } from '../artifacts/store.js';
12
+ import { cleanNote, kindOf } from '../artifacts/store.js';
13
13
  import { effectiveWriters, shapeChanges } from '../artifacts/state.js';
14
14
  import { COMMENTS_SLOT } from '../artifacts/comments.js';
15
15
  import { ASSET_DIGEST, ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
@@ -22,7 +22,7 @@ import { CommentLayer } from '../reader/comment-layer.js';
22
22
  import { ReaderWatch } from '../reader/reader-watch.js';
23
23
  import { UpdatedTime } from '../reader/updated-time.js';
24
24
  import { perReaderGate, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
25
- import { ridCookieName, mintRenderToken, usableSecret, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
25
+ import { ridCookieName, mintRenderToken, usableSecret, isUnfurlAgent, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
26
26
  import { PageMenu } from '../reader/page-menu.js';
27
27
  import { COMMENTS_MODES } from '../artifacts/comments.js';
28
28
  import { newPageKey } from '../artifacts/settings.js';
@@ -41,12 +41,26 @@ import { createVoiceRoutes } from './voice-routes.js';
41
41
  export const ARTIFACT_ID = ARTIFACT_ID_RE;
42
42
  /** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
43
43
  const VERSION_N = /^[1-9]\d{0,5}$/;
44
+ /** How long a broadcast lives when nobody gave it a date: 90 days from its first publish, unless
45
+ * the owner taps Keep (`expires: null` on the recipients route). */
46
+ export const BROADCAST_TTL_MS = 90 * 24 * 60 * 60 * 1000;
47
+ /** Media in a body, as markdown image syntax: what an unfurl fetcher's copy of a page leaves out. */
48
+ const MEDIA_MD = /!\[[^\]]*\]\([^)]*\)/g;
49
+ const withoutMedia = (md) => md.replace(MEDIA_MD, '');
50
+ /** A colour from a pack, only if it is plainly a colour: it is written into a raw HTML answer. */
51
+ const SAFE_COLOUR = /^(#[0-9a-f]{3,8}|[a-z]+|rgba?\([0-9.,\s%]+\))$/i;
44
52
  /** What a host with no private store answers to private media, in words that say what to set up. */
45
53
  const NO_PRIVATE_STORE = 'this host keeps no private store, so media: private is refused and nothing was published. Give createArtifactAssets a privateBucket (its own bucket, never the public one) to keep private media';
46
54
  async function defaultReadCookie(name) {
47
55
  const { cookies } = await import('next/headers');
48
56
  return (await cookies()).get(name)?.value;
49
57
  }
58
+ /** A header the request did not send reads as '' (so an agent that sends none is treated as a
59
+ * fetcher); a header that cannot be read at all, outside a request, reads as undefined. */
60
+ async function defaultReadHeader(name) {
61
+ const { headers } = await import('next/headers');
62
+ return (await headers()).get(name) ?? '';
63
+ }
50
64
  export function createArtifactRoutes(config) {
51
65
  const { store, siteUrl } = config;
52
66
  // The pack as configured draws the share card; the page paints with its theme variables, so a
@@ -282,30 +296,40 @@ export function createArtifactRoutes(config) {
282
296
  const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
283
297
  if (a.access)
284
298
  return GatedPage(a, id, render, n);
285
- if (a.links === 'per-reader') {
299
+ const broadcast = kindOf(a) === 'broadcast';
300
+ if (a.links === 'per-reader' || broadcast) {
286
301
  if (n !== null)
287
302
  notFound(); // a past version of a per-reader page is never served
288
303
  const sp = (await searchParams) ?? {};
289
304
  const param = Array.isArray(sp.r) ? sp.r[0] : sp.r;
290
305
  const readCookie = config.readCookie ?? defaultReadCookie;
291
306
  const privateUrl = config.assets?.privateUrl?.bind(config.assets);
307
+ // An unfurl fetcher (or anything sending no agent at all) gets the card's text and never a
308
+ // signed media URL: a preview service may cache what it fetched long after a link is revoked.
309
+ const ua = await (config.readHeader ?? defaultReadHeader)('user-agent').catch(() => undefined);
310
+ const bot = ua !== undefined && isUnfurlAgent(ua);
311
+ const media = async (md) => (bot ? withoutMedia(md) : a.privateMedia ? await withPrivateUrls(md, id, privateUrl) : md);
292
312
  // A signed-in owner or admin gets through as the owner, with no link and no tracking.
293
313
  const me = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
294
314
  if (isAdmin(me)) {
295
- const markdown = a.privateMedia ? await withPrivateUrls(a.markdown, id, privateUrl) : a.markdown;
296
- return _jsx(BrandGround, { pack: pack, mode: a.theme, children: await Body({ ...a, markdown }, { top: true, reader: me }) });
315
+ const markdown = await media(a.markdown);
316
+ const page = broadcast ? await BroadcastCard({ ...a, markdown }, { reader: me, bot }) : await Body({ ...a, markdown }, { top: true, reader: me });
317
+ return _jsx(BrandGround, { pack: pack, mode: a.theme, children: page });
297
318
  }
298
319
  const secret = usableSecret(config.recipientSecret?.());
299
320
  const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
300
321
  if (g.state === 'shut') {
301
322
  return (_jsx(BrandGround, { pack: pack, mode: a.theme, children: _jsx("p", { "data-per-reader-shut": true, className: "mx-auto max-w-xl px-6 pt-32 text-center text-lg opacity-80", children: g.why === 'no-link' ? NO_LINK : UNAVAILABLE }) }));
302
323
  }
303
- const token = mintRenderToken(secret, id, g.rid);
304
- const remember = g.fromLink
324
+ // A fetcher is not the reader: it sets no cookie, carries no render token and sees no note.
325
+ const remember = g.fromLink && !bot
305
326
  ? [pagePath(id), `/api/artifacts/${id}`].map((p) => `document.cookie=${JSON.stringify(`${ridCookieName(id)}=${g.rid}.${g.sig}; Path=${p}; Max-Age=${RID_COOKIE_MAX_AGE}; SameSite=Lax; Secure`)}`).join(';')
306
327
  : null;
307
- const markdown = a.privateMedia ? await withPrivateUrls(a.markdown, id, privateUrl) : a.markdown;
308
- const body = await Body({ ...a, markdown }, { top: true, reader: null, perReader: { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, token } });
328
+ const perReader = bot ? undefined : { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, token: mintRenderToken(secret, id, g.rid) };
329
+ const markdown = await media(a.markdown);
330
+ const body = broadcast
331
+ ? await BroadcastCard({ ...a, markdown }, { reader: null, perReader, bot })
332
+ : await Body({ ...a, markdown }, { top: true, reader: null, perReader });
309
333
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, body] }));
310
334
  }
311
335
  // A password shuts the body, never the title: the header stays so the reader knows which
@@ -420,6 +444,21 @@ export function createArtifactRoutes(config) {
420
444
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
421
445
  return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { n: n, at: v.at })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
422
446
  }
447
+ /** A broadcast: one message card. The sender's words at the top, then the media (a video with
448
+ * sound and controls, photos, a voice note's player, link cards) in the order the words put
449
+ * them, then this reader's own note. None of the document's furniture: no table of contents, no
450
+ * version line, no read-along (a voice note is its own audio), and no page menu for a reader.
451
+ * Comments only when the file turned them on; the quiet view beacon always, for a reader. */
452
+ async function BroadcastCard(a, { reader, perReader, bot }) {
453
+ const { brand } = lookOf(a);
454
+ const mode = !bot && config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
455
+ const admin = isAdmin(reader);
456
+ // A photo sent with no other media can ride as the cover; with media in the body it would repeat it.
457
+ const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
458
+ return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
459
+ // eslint-disable-next-line @next/next/no-img-element
460
+ _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
461
+ }
423
462
  /** The page itself, shared by open and gated pages. */
424
463
  async function Body(a, { top, reader, perReader }) {
425
464
  const { pack, brand } = lookOf(a);
@@ -705,10 +744,23 @@ export function createArtifactRoutes(config) {
705
744
  if (!authed(request))
706
745
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
707
746
  const text = await request.text();
708
- const parsed = parseArtifactSource(text);
747
+ let parsed = parseArtifactSource(text);
709
748
  if (!parsed.ok)
710
749
  return NextResponse.json({ error: parsed.error }, { status: 400 });
711
750
  const id = request.nextUrl.searchParams.get('id') ?? parsed.meta.id ?? undefined;
751
+ // The kind is sticky, like `links:`: a republish of a broadcast that leaves `template:` out is
752
+ // still a broadcast, and one that says `document` is refused rather than quietly changed.
753
+ if (id && parsed.meta.template === 'document') {
754
+ const stored = await store.get(id);
755
+ if (stored && kindOf(stored) === 'broadcast') {
756
+ const again = parseArtifactSource(text, { defaultKind: 'broadcast' });
757
+ if (!again.ok)
758
+ return NextResponse.json({ error: again.error }, { status: 400 });
759
+ if (again.meta.template !== 'broadcast')
760
+ return NextResponse.json({ error: 'this page is a broadcast and stays one: drop template: document, or publish the document as a new page' }, { status: 409 });
761
+ parsed = again;
762
+ }
763
+ }
712
764
  // Fail closed: private media needs a private store. Without one the media would have to sit in
713
765
  // the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
714
766
  // `media:` is sticky, so a stored page that already keeps private media counts too.
@@ -745,9 +797,24 @@ export function createArtifactRoutes(config) {
745
797
  const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
746
798
  if ('notFound' in result)
747
799
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
800
+ // A broadcast with no date gets one: 90 days from now. A date already set (by the sender, or
801
+ // by an earlier publish) stands, and Keep (expireAt null, keep true) is never undone.
802
+ let expireAt;
803
+ if (parsed.meta.template === 'broadcast' && config.recipients) {
804
+ const rec = await config.recipients.get(result.id);
805
+ if (rec?.keep)
806
+ expireAt = null;
807
+ else if (rec?.expireAt)
808
+ expireAt = rec.expireAt;
809
+ else {
810
+ expireAt = new Date(Date.now() + BROADCAST_TTL_MS).toISOString();
811
+ await config.recipients.setExpiry(result.id, expireAt);
812
+ }
813
+ }
748
814
  revalidatePath(pagePath(result.id));
749
815
  return NextResponse.json({
750
816
  id: result.id, url: pageUrl(result.id), version: result.version,
817
+ ...(expireAt !== undefined ? { expireAt } : {}),
751
818
  ...(parsed.meta.state && !config.state ? { warning: 'this host keeps no answers; state: is stored but inert' } : {}),
752
819
  }, { status: result.created ? 201 : 200 });
753
820
  }
@@ -874,15 +941,97 @@ export function createArtifactRoutes(config) {
874
941
  revalidatePath(pagePath(id));
875
942
  return NextResponse.json({ id, versions: await store.history(id) });
876
943
  }
944
+ /** DELETE /api/artifacts/<id>, publish key: the page and every file stored under it, public and
945
+ * private. The files go first, so a bucket that fails leaves the record to delete again rather
946
+ * than media nobody can find. The id is checked before anything is touched: a path-shaped id
947
+ * would widen the bucket prefix past one page. */
877
948
  async function DELETE(request, { params }) {
878
949
  if (!authed(request))
879
950
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
880
951
  const { id } = await params;
952
+ if (!ARTIFACT_ID.test(id) || !(await store.get(id)))
953
+ return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
954
+ let files = 0;
955
+ if (config.assets?.deletePage) {
956
+ try {
957
+ files = await config.assets.deletePage(id);
958
+ }
959
+ catch (e) {
960
+ return NextResponse.json({ error: `the page's files could not be removed, so the page was kept; delete it again: ${e instanceof Error ? e.message : String(e)}` }, { status: 502 });
961
+ }
962
+ }
963
+ // Nothing of the page outlives it: readers, their notes and views, and every comment, kept or not.
964
+ try {
965
+ await recipientsRoutes.forgetReaders(id);
966
+ }
967
+ catch (e) {
968
+ return NextResponse.json({ error: `the page's readers could not be forgotten, so the page was kept; delete it again: ${e instanceof Error ? e.message : String(e)}` }, { status: 502 });
969
+ }
881
970
  const ok = await store.delete(id);
882
971
  if (!ok)
883
972
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
884
973
  revalidatePath(pagePath(id));
885
- return NextResponse.json({ deleted: true });
974
+ return NextResponse.json({ deleted: true, files });
975
+ }
976
+ /** GET /api/artifacts?kind=document|broadcast: the owner's list of one kind, newest first, with
977
+ * no bodies. Documents and broadcasts are separate lists; `kind` defaults to document, so a
978
+ * documents list never carries a broadcast. A broadcast's line adds its expiry, whether it is
979
+ * kept, how many people still hold a live link (`readers`) and how many were revoked. The publish key, or the owner or an admin signed in. */
980
+ async function LIST(request) {
981
+ const no = (error, status) => NextResponse.json({ error }, { status, headers: { 'cache-control': 'no-store' } });
982
+ const me = verifyGrant(readerSecret(), request.cookies.get(GRANT_COOKIE)?.value);
983
+ if (!authed(request) && !isAdmin(me))
984
+ return no('Unauthorized', 401);
985
+ const kind = request.nextUrl.searchParams.get('kind') ?? 'document';
986
+ if (!ARTIFACT_KINDS.includes(kind))
987
+ return no(`kind must be one of: ${ARTIFACT_KINDS.join(', ')}`, 400);
988
+ if (!store.list)
989
+ return no('this store cannot list its pages', 501);
990
+ const items = await store.list(kind);
991
+ const pages = await Promise.all(items.map(async (p) => {
992
+ const line = { id: p.id, title: p.title, summary: p.summary, url: pageUrl(p.id), createdAt: p.createdAt, updatedAt: p.updatedAt };
993
+ if (kind !== 'broadcast')
994
+ return { ...line, views: p.views };
995
+ // A store that fails fails the list: a blank line would read as "kept, sent to nobody".
996
+ const rec = config.recipients ? await config.recipients.get(p.id) : null;
997
+ const people = rec ? Object.values(rec.recipients).filter((r) => !r.owner) : [];
998
+ return { ...line, expireAt: rec?.expireAt ?? null, keep: rec?.keep ?? false, readers: people.filter((r) => !r.revokedAt).length, revoked: people.filter((r) => r.revokedAt).length };
999
+ }));
1000
+ return NextResponse.json({ kind, pages }, { headers: { 'cache-control': 'no-store' } });
1001
+ }
1002
+ /** For a host's proxy, ahead of the page: a per-reader page whose link was revoked or whose date
1003
+ * passed answers 410 Gone with the same quiet words the page shows, so a crawler or a preview
1004
+ * service drops it. Null for everything else (no link at all, a valid link, a document, the
1005
+ * owner signed in, any other path), which the proxy passes on untouched. Reads nothing unless
1006
+ * the request carries a reader's link or its cookie. */
1007
+ async function shutPageResponse(request) {
1008
+ const path = request.nextUrl.pathname;
1009
+ if (!path.startsWith(prefix))
1010
+ return null;
1011
+ const id = path.slice(prefix.length);
1012
+ if (!ARTIFACT_ID.test(id))
1013
+ return null;
1014
+ const param = request.nextUrl.searchParams.get('r') ?? undefined;
1015
+ const cookie = request.cookies.get(ridCookieName(id))?.value;
1016
+ if (!param && !cookie)
1017
+ return null;
1018
+ const secret = usableSecret(config.recipientSecret?.());
1019
+ if (!secret || !config.recipients)
1020
+ return null;
1021
+ if (isAdmin(verifyGrant(readerSecret(), request.cookies.get(GRANT_COOKIE)?.value)))
1022
+ return null;
1023
+ const a = await store.get(id);
1024
+ if (!a || a.access || !(a.links === 'per-reader' || kindOf(a) === 'broadcast'))
1025
+ return null;
1026
+ const g = await perReaderGate({ id, param, cookie, secret, recipients: config.recipients });
1027
+ if (g.state !== 'shut' || (g.why !== 'revoked' && g.why !== 'expired'))
1028
+ return null;
1029
+ const look = lookOf(a).pack;
1030
+ const colour = (c, fallback) => (SAFE_COLOUR.test(c) ? c : fallback);
1031
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow"><title>${UNAVAILABLE}</title></head>`
1032
+ + `<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:${colour(look.ground, '#0e0f13')};color:${colour(look.ink, '#ece7dc')};font-family:system-ui,sans-serif">`
1033
+ + `<p data-per-reader-shut style="max-width:36rem;padding:0 1.5rem;text-align:center;font-size:1.125rem;opacity:.8">${UNAVAILABLE}</p></body></html>`;
1034
+ return new Response(html, { status: 410, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex, nofollow' } });
886
1035
  }
887
- return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, ...recipientsRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
1036
+ return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, LIST, shutPageResponse, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, ...recipientsRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
888
1037
  }
@@ -30,5 +30,6 @@ export declare function createRecipientsRoutes(given: RecipientsRoutesCtx): {
30
30
  skipped: string[];
31
31
  failed: string[];
32
32
  }>;
33
+ forgetReaders: (id: string) => Promise<void>;
33
34
  };
34
35
  export {};
@@ -121,6 +121,19 @@ export function createRecipientsRoutes(given) {
121
121
  const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, at: e.at, text: String(e.value?.body ?? '') }));
122
122
  return json({ id, expireAt: rec.expireAt, keep: rec.keep, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments) });
123
123
  }
124
+ /** Reader text goes with the page: every comment and answer on it, whoever wrote it. */
125
+ async function clearReaderText(id) {
126
+ if (ctx.state)
127
+ for (const key of new Set((await ctx.state.entries(id)).map((e) => e.readerKey)))
128
+ await ctx.state.removeReader(id, key);
129
+ }
130
+ /** Everything a page holds about its readers: their comments and answers, then who it was sent
131
+ * to, each person's note and their views. DELETE calls it before removing the page record, so a
132
+ * failure leaves the record and the delete can be run again. */
133
+ async function forgetReaders(id) {
134
+ await clearReaderText(id);
135
+ await ctx.recipients?.forget(id);
136
+ }
124
137
  /** The daily sweep: deletes only pages whose recipients record carries an expireAt that is a
125
138
  * well-formed time in the past and is not kept. An unparseable expireAt is never deleted (the
126
139
  * gate already shuts the page); it is reported in `skipped`. A page that fails midway stays
@@ -162,10 +175,7 @@ export function createRecipientsRoutes(given) {
162
175
  log(`sweepExpired: ${id} was kept during the sweep`);
163
176
  continue;
164
177
  }
165
- // Reader text expires with the page: every comment and answer, whoever wrote it.
166
- if (ctx.state)
167
- for (const key of new Set((await ctx.state.entries(id)).map((e) => e.readerKey)))
168
- await ctx.state.removeReader(id, key);
178
+ await clearReaderText(id);
169
179
  await ctx.store.delete(id);
170
180
  await ctx.recipients.forget(id);
171
181
  out.pages++;
@@ -178,5 +188,5 @@ export function createRecipientsRoutes(given) {
178
188
  }
179
189
  return out;
180
190
  }
181
- return { SEEN, RECIPIENTS, VIEWS, sweepExpired };
191
+ return { SEEN, RECIPIENTS, VIEWS, sweepExpired, forgetReaders };
182
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.36.0",
3
+ "version": "0.37.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",