@supersuit/artifacts 0.35.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,76 @@
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
+
46
+ ## 0.36.0
47
+
48
+ **Private media moves to its own private bucket, and a host without one refuses it.** 0.35.0 kept
49
+ `media: private` files under `<prefix>/<id>/private/` in the same bucket as public assets and
50
+ never called `makePublic` on them. That is no protection on a bucket that carries a bucket-wide
51
+ public read grant: every "private" object was readable by anyone holding its URL.
52
+
53
+ - `createArtifactAssets(bucket, prefix, { privateBucket })`. With `privateBucket`, `putPrivate`,
54
+ `signPrivateUpload`, `finishPrivateUpload` and `privateUrl` use it (same `<prefix>/<id>/private/`
55
+ keys); `put`, `signUpload`, `finishUpload`, covers and narration stay on `bucket`. `deletePage`
56
+ clears the page from both. Passing the public bucket again as the private one throws.
57
+ - Without `privateBucket` the four private methods are not defined, so the host fails closed:
58
+ `POST /api/artifacts` refuses a page with `media: private` (or a republish of a page that already
59
+ keeps private media) with 501 and saves nothing, and `?private=1` uploads answer 501, all with
60
+ the same words naming `privateBucket`.
61
+ - `PRIVATE_READ_TTL_MS` (exported, `60 * 60 * 1000`): the life of a signed V4 read URL. One is
62
+ minted per private file on every render that passed the per-reader gate, and none for a revoked
63
+ or expired link.
64
+ - DETECTOR: your host passes `media: private` pages and calls `createArtifactAssets(bucket, prefix)`
65
+ with two arguments; or `gcloud storage buckets get-iam-policy` on your bucket lists `allUsers`
66
+ and you have objects under `*/private/`.
67
+ - REMEDY: create a separate bucket (uniform access, public access prevention enforced, object
68
+ access for the host's service account only), pass it as `privateBucket`, give it CORS for `PUT`
69
+ from your origin if browsers upload to it directly, and republish any page whose private media
70
+ sits in the public bucket (then delete those old objects). A host that publishes no private
71
+ media needs nothing; until it adds a private bucket, `media: private` is refused.
72
+ - TESTS: `src/artifacts/assets.test.ts` (private media), `src/routes/private-media.test.tsx`
73
+ (publishing media: private), `src/routes/per-reader.test.tsx` (mints a fresh signed URL on every
74
+ passing render), `src/artifacts/index.test.ts`.
75
+
6
76
  ## 0.35.0
7
77
 
8
78
  **Per-reader links, private media, video with sound, quiet views, and expiry.** A page can now go
package/README.md CHANGED
@@ -22,7 +22,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
22
22
  | `createArtifactRoutes(config)` | Every route handler: the page, its metadata, the publish API, the share card, the reader sign-in exchange, access control |
23
23
  | `createArtifactStore(db, path)` | Firestore store for pages and their version history, under any collection path |
24
24
  | `createReadersStore(db, path)` | The record for confidential pages: allowlist, agreements, reading sessions, flags |
25
- | `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
25
+ | `createArtifactAssets(bucket, prefix, { privateBucket? })` | Uploaded images for a page, in a Cloud Storage bucket; private media in a separate private bucket |
26
26
  | `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
27
27
  | `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
28
28
  | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
@@ -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
 
@@ -849,6 +849,14 @@ media: private
849
849
 
850
850
  `links: per-reader` shuts the page to everyone who does not arrive through a valid, unrevoked, unexpired link. It cannot be combined with `password`, `access` or `comments_visible: readers` (readers never see each other's comments). `media: private` keeps body media under the page's private prefix; `.mp4` and `.m4a` (and `.mp3`) are supported, and `sound:` at the start of a video's alt makes it a clip with controls and sound. Both lines are sticky: republishing a file that leaves them out changes nothing.
851
851
 
852
+ ### Private media lives in its own bucket
853
+
854
+ `media: private` needs a private store: `createArtifactAssets(bucket, prefix, { privateBucket })`. Private files go to `privateBucket` under `<prefix>/<id>/private/`; covers, images and narration stay on the public `bucket`. Without `privateBucket` the private methods are not defined, and the host refuses private media with 501 at every door: publishing a page with `media: private` (or republishing a page that already keeps it), and `?private=1` uploads. Nothing is saved. This is on purpose: a public bucket often carries a bucket-wide public read grant, so an object written there is readable by anyone who has its URL, whatever the object's own settings say.
855
+
856
+ The private bucket must be a different bucket from the public one (passing the same bucket twice throws), with uniform bucket-level access, public access prevention enforced, and object access granted only to the host's own service account. The page signs a V4 read URL per private file on every render that passed the per-reader gate, valid `PRIVATE_READ_TTL_MS` (one hour); a revoked or expired link renders nothing and mints nothing. A reader revoked after a render can keep loading a URL already handed to them for at most that hour. Expiry's `deletePage` clears the page from both buckets.
857
+
858
+ If the publisher uploads straight to the private bucket from a browser (`?private=1` on `UPLOAD`), give that bucket a CORS rule allowing `PUT` from your origin with the `content-type` and `cache-control` headers; a reader's browser playing a signed URL needs `GET` (and `HEAD`) from your origin only if your player fetches it with CORS.
859
+
852
860
  Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `VIEWS`, `SEEN`, and `sweepExpired`.
853
861
 
854
862
  | Route | Who | What |
@@ -868,7 +876,61 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
868
876
 
869
877
  ### Expiry
870
878
 
871
- `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. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
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
+
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.
872
934
 
873
935
  ## Brand packs
874
936
 
@@ -35,15 +35,27 @@ export interface ArtifactAssets {
35
35
  }>;
36
36
  /** After a signed private upload: `private:<stored name>`, or null if nothing arrived. */
37
37
  finishPrivateUpload?(id: string, name: string, digest: string): Promise<string | null>;
38
- /** A signed read URL for a stored private name, valid one hour. */
38
+ /** A V4 signed read URL for a stored private name, valid PRIVATE_READ_TTL_MS (one hour). */
39
39
  privateUrl?(id: string, stored: string): Promise<string>;
40
- /** Delete every object under `<prefix>/<id>/`; returns how many. */
40
+ /** Delete every object under `<prefix>/<id>/`, in the public bucket and the private one; returns how many. */
41
41
  deletePage?(id: string): Promise<number>;
42
42
  }
43
43
  /** A reference to a private file as it appears in a page: `private:<stored name>`. */
44
44
  export declare const PRIVATE_REF: RegExp;
45
45
  /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
46
46
  export declare const ASSET_DIGEST: RegExp;
47
+ /** How long a signed read URL for private media stays valid: one hour. A reader revoked after a
48
+ * render can keep loading a URL already handed to them for at most this long. */
49
+ export declare const PRIVATE_READ_TTL_MS: number;
50
+ export interface ArtifactAssetsOptions {
51
+ /**
52
+ * The bucket private media lives in. It must be a different bucket from the public one, with
53
+ * uniform access, public access prevention enforced, and object access granted only to the
54
+ * host's own service account. Without it the private methods are not defined at all, so a
55
+ * `media: private` page is refused instead of being stored where anyone could read it by URL.
56
+ */
57
+ privateBucket?: Bucket;
58
+ }
47
59
  /** Names are one path segment: letters, digits, dot, dash, underscore. */
48
60
  export declare const ASSET_NAME: RegExp;
49
61
  export declare const ASSET_TYPES: Record<string, string>;
@@ -67,4 +79,4 @@ export declare function contentTypeFor(name: string): string | undefined;
67
79
  export declare function hashedName(name: string, bytes: Buffer): string;
68
80
  /** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
69
81
  export declare function storedName(name: string, digest: string): string;
70
- export declare function createArtifactAssets(bucket: Bucket, prefix: string): ArtifactAssets;
82
+ export declare function createArtifactAssets(bucket: Bucket, prefix: string, options?: ArtifactAssetsOptions): ArtifactAssets;
@@ -10,6 +10,9 @@ export const PRIVATE_REF = /^private:([A-Za-z0-9][A-Za-z0-9._-]{0,140})$/;
10
10
  /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
11
11
  export const ASSET_DIGEST = /^[0-9a-f]{8}$/;
12
12
  const CACHE_CONTROL = 'public, max-age=31536000, immutable';
13
+ /** How long a signed read URL for private media stays valid: one hour. A reader revoked after a
14
+ * render can keep loading a URL already handed to them for at most this long. */
15
+ export const PRIVATE_READ_TTL_MS = 60 * 60 * 1000;
13
16
  /** Names are one path segment: letters, digits, dot, dash, underscore. */
14
17
  export const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
15
18
  export const ASSET_TYPES = {
@@ -52,9 +55,13 @@ export function storedName(name, digest) {
52
55
  // A name with no extension is legal here, so append rather than assume there is a dot.
53
56
  return dot <= 0 ? `${name}.${digest}` : `${name.slice(0, dot)}.${digest}${name.slice(dot)}`;
54
57
  }
55
- export function createArtifactAssets(bucket, prefix) {
58
+ export function createArtifactAssets(bucket, prefix, options = {}) {
56
59
  const clean = prefix.replace(/^\/+|\/+$/g, '');
57
- return {
60
+ const priv = options.privateBucket;
61
+ // The same bucket twice would put "private" media back where a public read grant reaches it.
62
+ if (priv && priv.name === bucket.name)
63
+ throw new Error('the private bucket must be a different bucket from the public one');
64
+ const assets = {
58
65
  async put(id, name, bytes, contentType) {
59
66
  const key = `${clean}/${id}/${hashedName(name, bytes)}`;
60
67
  const file = bucket.file(key);
@@ -135,36 +142,46 @@ export function createArtifactAssets(bucket, prefix) {
135
142
  const [url] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
136
143
  return url;
137
144
  },
145
+ async deletePage(id) {
146
+ // An empty or path-shaped id would make the prefix wider than one page.
147
+ if (!ARTIFACT_ID_RE.test(id))
148
+ throw new Error('not an artifact id');
149
+ let n = 0;
150
+ for (const b of priv ? [bucket, priv] : [bucket]) {
151
+ const [files] = await b.getFiles({ prefix: `${clean}/${id}/` });
152
+ for (const f of files)
153
+ await f.delete();
154
+ n += files.length;
155
+ }
156
+ return n;
157
+ },
158
+ };
159
+ if (!priv)
160
+ return assets;
161
+ const privateFile = (id, stored) => priv.file(`${clean}/${id}/private/${stored}`);
162
+ return {
163
+ ...assets,
138
164
  async putPrivate(id, name, bytes, contentType) {
139
165
  const stored = hashedName(name, bytes);
140
- await bucket.file(`${clean}/${id}/private/${stored}`).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
166
+ await privateFile(id, stored).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
141
167
  return `private:${stored}`;
142
168
  },
143
169
  async signPrivateUpload(id, name, digest, contentType) {
144
170
  const stored = storedName(name, digest);
145
171
  const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600' };
146
- const [uploadUrl] = await bucket.file(`${clean}/${id}/private/${stored}`).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType, extensionHeaders: { 'cache-control': headers['cache-control'] } });
172
+ const [uploadUrl] = await privateFile(id, stored).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType, extensionHeaders: { 'cache-control': headers['cache-control'] } });
147
173
  return { uploadUrl, headers, url: `private:${stored}` };
148
174
  },
149
175
  async finishPrivateUpload(id, name, digest) {
150
176
  const stored = storedName(name, digest);
151
- const [there] = await bucket.file(`${clean}/${id}/private/${stored}`).exists();
177
+ const [there] = await privateFile(id, stored).exists();
152
178
  return there ? `private:${stored}` : null;
153
179
  },
154
180
  async privateUrl(id, stored) {
155
181
  if (!PRIVATE_REF.test(`private:${stored}`))
156
182
  throw new Error('not a private file name');
157
- const [url] = await bucket.file(`${clean}/${id}/private/${stored}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
183
+ const [url] = await privateFile(id, stored).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + PRIVATE_READ_TTL_MS });
158
184
  return url;
159
185
  },
160
- async deletePage(id) {
161
- // An empty or path-shaped id would make the prefix wider than one page.
162
- if (!ARTIFACT_ID_RE.test(id))
163
- throw new Error('not an artifact id');
164
- const [files] = await bucket.getFiles({ prefix: `${clean}/${id}/` });
165
- for (const f of files)
166
- await f.delete();
167
- return files.length;
168
- },
169
186
  };
170
187
  }
@@ -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,10 +1,10 @@
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
- export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
7
+ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS, type ArtifactAssets, type ArtifactAssetsOptions } from './assets.js';
8
8
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
9
9
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
10
10
  export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc, type ReaderSummary } from './readers-store.js';
@@ -1,10 +1,10 @@
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
- export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
7
+ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS } from './assets.js';
8
8
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
9
9
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
10
10
  export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.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,10 +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;
52
+ /** What a host with no private store answers to private media, in words that say what to set up. */
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';
44
54
  async function defaultReadCookie(name) {
45
55
  const { cookies } = await import('next/headers');
46
56
  return (await cookies()).get(name)?.value;
47
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
+ }
48
64
  export function createArtifactRoutes(config) {
49
65
  const { store, siteUrl } = config;
50
66
  // The pack as configured draws the share card; the page paints with its theme variables, so a
@@ -280,30 +296,40 @@ export function createArtifactRoutes(config) {
280
296
  const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
281
297
  if (a.access)
282
298
  return GatedPage(a, id, render, n);
283
- if (a.links === 'per-reader') {
299
+ const broadcast = kindOf(a) === 'broadcast';
300
+ if (a.links === 'per-reader' || broadcast) {
284
301
  if (n !== null)
285
302
  notFound(); // a past version of a per-reader page is never served
286
303
  const sp = (await searchParams) ?? {};
287
304
  const param = Array.isArray(sp.r) ? sp.r[0] : sp.r;
288
305
  const readCookie = config.readCookie ?? defaultReadCookie;
289
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);
290
312
  // A signed-in owner or admin gets through as the owner, with no link and no tracking.
291
313
  const me = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
292
314
  if (isAdmin(me)) {
293
- const markdown = a.privateMedia ? await withPrivateUrls(a.markdown, id, privateUrl) : a.markdown;
294
- 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 });
295
318
  }
296
319
  const secret = usableSecret(config.recipientSecret?.());
297
320
  const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
298
321
  if (g.state === 'shut') {
299
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 }) }));
300
323
  }
301
- const token = mintRenderToken(secret, id, g.rid);
302
- 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
303
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(';')
304
327
  : null;
305
- const markdown = a.privateMedia ? await withPrivateUrls(a.markdown, id, privateUrl) : a.markdown;
306
- 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 });
307
333
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, body] }));
308
334
  }
309
335
  // A password shuts the body, never the title: the header stays so the reader knows which
@@ -418,6 +444,21 @@ export function createArtifactRoutes(config) {
418
444
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
419
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 }) })] }));
420
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
+ }
421
462
  /** The page itself, shared by open and gated pages. */
422
463
  async function Body(a, { top, reader, perReader }) {
423
464
  const { pack, brand } = lookOf(a);
@@ -697,14 +738,34 @@ export function createArtifactRoutes(config) {
697
738
  const [sessions, flags, acks] = await Promise.all([config.readers.sessions(id), config.readers.flags(id), config.readers.acks(id)]);
698
739
  return NextResponse.json({ id, title: a.title, access: a.access ?? null, views: a.views, ...summarize(sessions, flags), acks: acks.map((k) => ({ email: k.email, name: k.name, at: k.at })) }, { headers: { 'cache-control': 'no-store' } });
699
740
  }
741
+ /** Whether this host can store and serve private media: both halves come from a private bucket. */
742
+ const keepsPrivateMedia = () => Boolean(config.assets?.putPrivate && config.assets.privateUrl);
700
743
  async function POST(request) {
701
744
  if (!authed(request))
702
745
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
703
746
  const text = await request.text();
704
- const parsed = parseArtifactSource(text);
747
+ let parsed = parseArtifactSource(text);
705
748
  if (!parsed.ok)
706
749
  return NextResponse.json({ error: parsed.error }, { status: 400 });
707
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
+ }
764
+ // Fail closed: private media needs a private store. Without one the media would have to sit in
765
+ // the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
766
+ // `media:` is sticky, so a stored page that already keeps private media counts too.
767
+ if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await store.get(id))?.privateMedia)))
768
+ return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
708
769
  if (id && parsed.meta.state) {
709
770
  const existing = await store.get(id);
710
771
  const changed = shapeChanges(existing?.state, parsed.meta.state);
@@ -736,9 +797,24 @@ export function createArtifactRoutes(config) {
736
797
  const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
737
798
  if ('notFound' in result)
738
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
+ }
739
814
  revalidatePath(pagePath(result.id));
740
815
  return NextResponse.json({
741
816
  id: result.id, url: pageUrl(result.id), version: result.version,
817
+ ...(expireAt !== undefined ? { expireAt } : {}),
742
818
  ...(parsed.meta.state && !config.state ? { warning: 'this host keeps no answers; state: is stored but inert' } : {}),
743
819
  }, { status: result.created ? 201 : 200 });
744
820
  }
@@ -759,7 +835,7 @@ export function createArtifactRoutes(config) {
759
835
  return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
760
836
  const priv = request.nextUrl.searchParams.get('private') === '1';
761
837
  if (priv && !config.assets.putPrivate)
762
- return NextResponse.json({ error: 'this host keeps no private media' }, { status: 501 });
838
+ return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
763
839
  const { id, name } = await params;
764
840
  if (!ARTIFACT_ID.test(id))
765
841
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
@@ -806,7 +882,7 @@ export function createArtifactRoutes(config) {
806
882
  const priv = request.nextUrl.searchParams.get('private') === '1';
807
883
  if (priv) {
808
884
  if (!assets?.signPrivateUpload || !assets.finishPrivateUpload)
809
- return NextResponse.json({ error: 'this host keeps no private media' }, { status: 501 });
885
+ return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
810
886
  }
811
887
  else if (!assets?.signUpload || !assets.finishUpload)
812
888
  return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
@@ -865,15 +941,97 @@ export function createArtifactRoutes(config) {
865
941
  revalidatePath(pagePath(id));
866
942
  return NextResponse.json({ id, versions: await store.history(id) });
867
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. */
868
948
  async function DELETE(request, { params }) {
869
949
  if (!authed(request))
870
950
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
871
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
+ }
872
970
  const ok = await store.delete(id);
873
971
  if (!ok)
874
972
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
875
973
  revalidatePath(pagePath(id));
876
- 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' } });
877
1035
  }
878
- 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 };
879
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.35.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",