@supersuit/artifacts 0.34.0 → 0.35.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.
@@ -17,6 +17,7 @@ import { MAX_MANY_PER_READER } from './state.js';
17
17
  export const readerKeyFor = {
18
18
  signedIn: (uid) => `u:${uid}`,
19
19
  anonymous: (id) => `a:${id}`,
20
+ recipient: (rid) => `r:${rid}`,
20
21
  };
21
22
  const writerOf = (w) => ({
22
23
  ...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
@@ -22,6 +22,10 @@ export type ArtifactRecord = {
22
22
  narrationHash?: string;
23
23
  password?: string;
24
24
  access?: Access;
25
+ /** `per-reader`: opens only through a link made for one reader. */
26
+ links?: 'per-reader';
27
+ /** Body media are stored unlisted and served only to a valid reader. */
28
+ privateMedia?: true;
25
29
  state?: StateConfig;
26
30
  theme?: ThemeMode;
27
31
  /** The brand pack the page asked for by name; see ArtifactMeta.pack. */
@@ -193,6 +193,10 @@ async function saveArtifact(col, input) {
193
193
  const to = input.meta.to ? {} : { to: FieldValue.delete() };
194
194
  // How the page looks is content too: a line the file no longer carries goes back to the default.
195
195
  const look = {
196
+ // Who may open the page is a door, not content: like `access`, a file that leaves these lines
197
+ // out changes nothing, so a publisher that strips unknown keys can never reopen the page.
198
+ ...(input.meta.links ? { links: input.meta.links } : {}),
199
+ ...(input.meta.privateMedia ? { privateMedia: true } : {}),
196
200
  ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
197
201
  ...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
198
202
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
@@ -219,6 +223,8 @@ async function saveArtifact(col, input) {
219
223
  ...fields,
220
224
  ...(live.access ? { access: live.access } : {}),
221
225
  ...(live.password ? { password: live.password } : {}),
226
+ ...(input.meta.links ? { links: input.meta.links } : {}),
227
+ ...(input.meta.privateMedia ? { privateMedia: true } : {}),
222
228
  ...(live.state ? { state: live.state } : {}),
223
229
  ...(live.comments ? { comments: live.comments } : {}),
224
230
  ...(live.commentsVisible ? { commentsVisible: live.commentsVisible } : {}),
@@ -238,6 +244,9 @@ async function deleteArtifact(col, id) {
238
244
  const snap = await ref.get();
239
245
  if (!snap.exists)
240
246
  return false;
247
+ // Every prior body lives in the versions subcollection and outlives the page doc unless it goes too.
248
+ const past = await ref.collection('versions').get();
249
+ await Promise.all(past.docs.map((d) => ref.collection('versions').doc(d.id).delete()));
241
250
  await ref.delete();
242
251
  return true;
243
252
  }
@@ -1,5 +1,6 @@
1
1
  export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
2
2
  export { PlayerBar } from './player-bar.js';
3
+ export { QuietView } from './quiet-view.js';
3
4
  export { CommentLayer } from './comment-layer.js';
4
5
  export { PageMenu, roleLine, menuItems, type PageMenuProps, type PageRole } from './page-menu.js';
5
6
  export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY, type PageSettings, type SettingsFact, type SheetKind, type AccessLevel } from './settings-sheet.js';
@@ -2,6 +2,7 @@
2
2
  // own 'use client'; this barrel is plain re-exports so it can be imported from either side.
3
3
  export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
4
4
  export { PlayerBar } from './player-bar.js';
5
+ export { QuietView } from './quiet-view.js';
5
6
  export { CommentLayer } from './comment-layer.js';
6
7
  export { PageMenu, roleLine, menuItems } from './page-menu.js';
7
8
  export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY } from './settings-sheet.js';
@@ -0,0 +1,7 @@
1
+ export declare function QuietView({ id, rid, sig, token, endpoint }: {
2
+ id: string;
3
+ rid: string;
4
+ sig: string;
5
+ token: string;
6
+ endpoint?: string;
7
+ }): null;
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+ // The page's own word that a person looked at it. Silent: it renders nothing and never tells the
3
+ // reader (the publisher chose that). It waits until the page is actually on screen for two
4
+ // seconds, which a link preview fetcher never does because it never runs the page.
5
+ import { useEffect } from 'react';
6
+ const VIEW_AFTER_MS = 2000;
7
+ const BEAT_MS = 15_000;
8
+ const ALPHA = 'abcdefghijklmnopqrstuvwxyz0123456789';
9
+ const rand = (n) => Array.from(crypto.getRandomValues(new Uint8Array(n)), (b) => ALPHA[b % 36]).join('');
10
+ function deviceId() {
11
+ try {
12
+ let d = localStorage.getItem('artifact_device');
13
+ if (!d || !/^[a-z0-9]{16}$/.test(d)) {
14
+ d = rand(16);
15
+ localStorage.setItem('artifact_device', d);
16
+ }
17
+ return d;
18
+ }
19
+ catch {
20
+ return rand(16);
21
+ }
22
+ }
23
+ export function QuietView({ id, rid, sig, token, endpoint }) {
24
+ useEffect(() => {
25
+ const url = endpoint ?? `/api/artifacts/${id}/seen`;
26
+ const session = rand(16);
27
+ const device = deviceId();
28
+ const base = { rid, sig, session, device };
29
+ let beat = null;
30
+ let viewSent = false;
31
+ let wait = null;
32
+ let tick = null;
33
+ let since = 0;
34
+ const post = async (body) => {
35
+ try {
36
+ const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...base, ...body }), keepalive: true });
37
+ return r.ok ? (await r.json().catch(() => ({}))) : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ };
43
+ const visible = () => document.visibilityState === 'visible' && !document.prerendering;
44
+ const flush = (useBeacon) => {
45
+ if (!beat || !since)
46
+ return;
47
+ const seconds = Math.round((Date.now() - since) / 1000);
48
+ since = 0;
49
+ if (seconds <= 0)
50
+ return;
51
+ if (useBeacon && typeof navigator.sendBeacon === 'function') {
52
+ navigator.sendBeacon(url, new Blob([JSON.stringify({ ...base, event: 'beat', beat, seconds })], { type: 'application/json' }));
53
+ }
54
+ else
55
+ void post({ event: 'beat', beat, seconds });
56
+ };
57
+ const startBeats = () => {
58
+ since = Date.now();
59
+ tick = setInterval(() => { flush(false); since = Date.now(); }, BEAT_MS);
60
+ };
61
+ const arm = () => {
62
+ if (!visible())
63
+ return;
64
+ if (beat) {
65
+ if (!tick)
66
+ startBeats();
67
+ return;
68
+ }
69
+ // One render token, one view: once it is sent, a later show never sends another.
70
+ if (viewSent || wait)
71
+ return;
72
+ wait = setTimeout(async () => {
73
+ wait = null;
74
+ if (!visible())
75
+ return;
76
+ viewSent = true;
77
+ const r = await post({ event: 'view', token });
78
+ if (r?.beat && visible()) {
79
+ beat = r.beat;
80
+ startBeats();
81
+ }
82
+ else if (r?.beat)
83
+ beat = r.beat;
84
+ }, VIEW_AFTER_MS);
85
+ };
86
+ const disarm = () => {
87
+ if (wait) {
88
+ clearTimeout(wait);
89
+ wait = null;
90
+ }
91
+ if (tick) {
92
+ clearInterval(tick);
93
+ tick = null;
94
+ flush(true);
95
+ }
96
+ };
97
+ const onVisibility = () => (visible() ? arm() : disarm());
98
+ const onPlay = (e) => {
99
+ const el = e.target;
100
+ const media = el?.getAttribute?.('data-artifact-media');
101
+ if (media && beat)
102
+ void post({ event: 'play', beat, media });
103
+ };
104
+ document.addEventListener('visibilitychange', onVisibility);
105
+ document.addEventListener('prerenderingchange', onVisibility);
106
+ document.addEventListener('play', onPlay, true);
107
+ window.addEventListener('pagehide', disarm);
108
+ arm();
109
+ return () => {
110
+ document.removeEventListener('visibilitychange', onVisibility);
111
+ document.removeEventListener('prerenderingchange', onVisibility);
112
+ document.removeEventListener('play', onPlay, true);
113
+ window.removeEventListener('pagehide', disarm);
114
+ disarm();
115
+ };
116
+ }, [id, rid, sig, token, endpoint]);
117
+ return null;
118
+ }
@@ -4,6 +4,7 @@ import { type ArtifactRecord, type ArtifactStore, type VersionEntry } from '../a
4
4
  import type { StateStore } from '../artifacts/state-store.js';
5
5
  import { type ArtifactAssets } from '../artifacts/assets.js';
6
6
  import type { BrandPack } from '../brand/pack.js';
7
+ import type { RecipientsStore } from '../artifacts/recipients-store.js';
7
8
  import type { PageSettings, SettingsFact } from '../reader/settings-sheet.js';
8
9
  import { type Access, type AllowEntry } from '../artifacts/reader.js';
9
10
  import { type ReadersStore } from '../artifacts/readers-store.js';
@@ -104,6 +105,11 @@ export type ArtifactRoutesConfig = {
104
105
  * bare URL sees, and search engines). Called only for the owner and admins; a throw falls back
105
106
  * to the package's rows. */
106
107
  shareFacts?: (artifact: ArtifactRecord) => SettingsFact[] | Promise<SettingsFact[]>;
108
+ /** Who each per-reader page was sent to (recipients-store.ts). Without it, or without
109
+ * `recipientSecret`, a `links: per-reader` page is shut to everyone. */
110
+ recipients?: RecipientsStore;
111
+ /** Signs per-reader links and render tokens. At least 32 characters. Read at request time. */
112
+ recipientSecret?: () => string | undefined;
107
113
  };
108
114
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
109
115
  export declare const ARTIFACT_ID: RegExp;
@@ -115,6 +121,7 @@ type Params = {
115
121
  type PageProps = Params & {
116
122
  searchParams?: Promise<{
117
123
  key?: string | string[];
124
+ r?: string | string[];
118
125
  }>;
119
126
  };
120
127
  type VersionParams = {
@@ -126,11 +133,33 @@ type VersionParams = {
126
133
  type VersionProps = VersionParams & {
127
134
  searchParams?: Promise<{
128
135
  key?: string | string[];
136
+ r?: string | string[];
129
137
  }>;
130
138
  };
131
139
  export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
132
140
  dynamic: "force-dynamic";
133
141
  maxDuration: number;
142
+ SEEN: (req: NextRequest, { params }: {
143
+ params: Promise<{
144
+ id: string;
145
+ }>;
146
+ }) => Promise<NextResponse<unknown>>;
147
+ RECIPIENTS: (req: NextRequest, { params }: {
148
+ params: Promise<{
149
+ id: string;
150
+ }>;
151
+ }) => Promise<NextResponse<unknown>>;
152
+ VIEWS: (req: NextRequest, { params }: {
153
+ params: Promise<{
154
+ id: string;
155
+ }>;
156
+ }) => Promise<NextResponse<unknown>>;
157
+ sweepExpired: (now?: Date) => Promise<{
158
+ pages: number;
159
+ files: number;
160
+ skipped: string[];
161
+ failed: string[];
162
+ }>;
134
163
  PERSONAL_GET: (req: NextRequest, { params }: {
135
164
  params: Promise<{
136
165
  id: string;
@@ -21,6 +21,8 @@ import { ArtifactReader } from '../reader/artifact-reader.js';
21
21
  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
+ import { perReaderGate, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
25
+ import { ridCookieName, mintRenderToken, usableSecret, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
24
26
  import { PageMenu } from '../reader/page-menu.js';
25
27
  import { COMMENTS_MODES } from '../artifacts/comments.js';
26
28
  import { newPageKey } from '../artifacts/settings.js';
@@ -31,6 +33,8 @@ import { readSecondsFor, wordCount } from '../artifacts/read-alerts.js';
31
33
  import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
32
34
  import { ARTIFACT_ID_RE } from './ids.js';
33
35
  import { createStateRoutes } from './state-routes.js';
36
+ import { createRecipientsRoutes } from './recipients-routes.js';
37
+ import { QuietView } from '../reader/quiet-view.js';
34
38
  import { createPersonalRoutes } from './personal-routes.js';
35
39
  import { createVoiceRoutes } from './voice-routes.js';
36
40
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
@@ -87,10 +91,14 @@ export function createArtifactRoutes(config) {
87
91
  const publishAuth = { publishKey: config.publishKey, publisherSecret: config.publisherSecret, publisherHost: config.publisherHost };
88
92
  const authed = (request) => isPublisherAuthed(request, publishAuth);
89
93
  const stateRoutes = createStateRoutes({
90
- store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey,
94
+ store, state: config.state, readers: config.readers, readerSecret, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
91
95
  publisherSecret: config.publisherSecret, publisherHost: config.publisherHost, pageUrl, signInOrigin, siteUrl,
92
96
  clientIp: config.clientIp, ownerEmail: config.ownerEmail, admins: config.admins, audioUrl, onResolve: config.onResolve,
93
97
  });
98
+ const recipientsRoutes = createRecipientsRoutes({
99
+ store, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
100
+ state: config.state, assets: config.assets, pageUrl, authed,
101
+ });
94
102
  const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
95
103
  const voiceRoutes = createVoiceRoutes({
96
104
  store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
@@ -272,6 +280,32 @@ export function createArtifactRoutes(config) {
272
280
  const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
273
281
  if (a.access)
274
282
  return GatedPage(a, id, render, n);
283
+ if (a.links === 'per-reader') {
284
+ if (n !== null)
285
+ notFound(); // a past version of a per-reader page is never served
286
+ const sp = (await searchParams) ?? {};
287
+ const param = Array.isArray(sp.r) ? sp.r[0] : sp.r;
288
+ const readCookie = config.readCookie ?? defaultReadCookie;
289
+ const privateUrl = config.assets?.privateUrl?.bind(config.assets);
290
+ // A signed-in owner or admin gets through as the owner, with no link and no tracking.
291
+ const me = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
292
+ 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 }) });
295
+ }
296
+ const secret = usableSecret(config.recipientSecret?.());
297
+ const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
298
+ if (g.state === 'shut') {
299
+ 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
+ }
301
+ const token = mintRenderToken(secret, id, g.rid);
302
+ const remember = g.fromLink
303
+ ? [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
+ : 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 } });
307
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, body] }));
308
+ }
275
309
  // A password shuts the body, never the title: the header stays so the reader knows which
276
310
  // page they were sent, and the unfurl (generateMetadata) keeps reading as the page.
277
311
  const sp = (await searchParams) ?? {};
@@ -385,7 +419,7 @@ export function createArtifactRoutes(config) {
385
419
  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 }) })] }));
386
420
  }
387
421
  /** The page itself, shared by open and gated pages. */
388
- async function Body(a, { top, reader }) {
422
+ async function Body(a, { top, reader, perReader }) {
389
423
  const { pack, brand } = lookOf(a);
390
424
  let words = [];
391
425
  if (a.narration && a.timings) {
@@ -407,10 +441,10 @@ export function createArtifactRoutes(config) {
407
441
  const n = a.version ?? (a.versions?.length ?? 0) + 1;
408
442
  const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
409
443
  const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.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: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
410
- return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
444
+ return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
411
445
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
412
446
  checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
413
- })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
447
+ })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
414
448
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
415
449
  recorded at publish, or there is none. */] }));
416
450
  }
@@ -523,6 +557,8 @@ export function createArtifactRoutes(config) {
523
557
  if (request.method === 'GET')
524
558
  return NextResponse.json({ id, access: a.access ?? null, readers: await config.readers.allowList(id) });
525
559
  const b = (await request.json().catch(() => null));
560
+ if (a.links === 'per-reader' && b?.access !== undefined)
561
+ return NextResponse.json({ error: 'a per-reader page opens only from its own links, so its access and password cannot be changed' }, { status: 409 });
526
562
  let access = a.access ?? null;
527
563
  if (b?.access !== undefined) {
528
564
  if (b.access !== 'public' && !ACCESS_LEVELS.includes(b.access))
@@ -587,6 +623,8 @@ export function createArtifactRoutes(config) {
587
623
  }
588
624
  if (!b || typeof b !== 'object' || Array.isArray(b))
589
625
  return no('the body is an object', 400);
626
+ if (a.links === 'per-reader' && (b.access !== undefined || b.password !== undefined))
627
+ return no('a per-reader page opens only from its own links, so its access and password cannot be changed', 409);
590
628
  const KEYS = ['access', 'password', 'comments', 'commentsVisible', 'add', 'remove'];
591
629
  const unknown = Object.keys(b).filter((k) => !KEYS.includes(k));
592
630
  if (unknown.length)
@@ -719,6 +757,9 @@ export function createArtifactRoutes(config) {
719
757
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
720
758
  if (!config.assets)
721
759
  return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
760
+ const priv = request.nextUrl.searchParams.get('private') === '1';
761
+ if (priv && !config.assets.putPrivate)
762
+ return NextResponse.json({ error: 'this host keeps no private media' }, { status: 501 });
722
763
  const { id, name } = await params;
723
764
  if (!ARTIFACT_ID.test(id))
724
765
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
@@ -734,7 +775,7 @@ export function createArtifactRoutes(config) {
734
775
  return NextResponse.json({ error: 'empty body' }, { status: 400 });
735
776
  if (bytes.length > 16 * 1024 * 1024)
736
777
  return NextResponse.json({ error: 'asset over 16 MiB' }, { status: 413 });
737
- const url = await config.assets.put(id, name, bytes, type);
778
+ const url = priv ? await config.assets.putPrivate(id, name, bytes, type) : await config.assets.put(id, name, bytes, type);
738
779
  return NextResponse.json({ id, name, url }, { status: 201 });
739
780
  }
740
781
  /**
@@ -762,7 +803,12 @@ export function createArtifactRoutes(config) {
762
803
  if (!authed(request))
763
804
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
764
805
  const assets = config.assets;
765
- if (!assets?.signUpload || !assets.finishUpload)
806
+ const priv = request.nextUrl.searchParams.get('private') === '1';
807
+ if (priv) {
808
+ if (!assets?.signPrivateUpload || !assets.finishPrivateUpload)
809
+ return NextResponse.json({ error: 'this host keeps no private media' }, { status: 501 });
810
+ }
811
+ else if (!assets?.signUpload || !assets.finishUpload)
766
812
  return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
767
813
  const { id, name } = await params;
768
814
  if (!ARTIFACT_ID.test(id))
@@ -778,12 +824,13 @@ export function createArtifactRoutes(config) {
778
824
  if (!ASSET_DIGEST.test(digest))
779
825
  return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
780
826
  if (body.done === true) {
781
- const url = await assets.finishUpload(id, name, digest);
827
+ const url = priv ? await assets.finishPrivateUpload(id, name, digest) : await assets.finishUpload(id, name, digest);
782
828
  if (!url)
783
829
  return NextResponse.json({ error: 'nothing was uploaded to that url' }, { status: 409 });
784
830
  return NextResponse.json({ id, name, url }, { status: 201 });
785
831
  }
786
- return NextResponse.json({ id, name, ...(await assets.signUpload(id, name, digest, type)) }, { status: 201 });
832
+ const signed = priv ? await assets.signPrivateUpload(id, name, digest, type) : await assets.signUpload(id, name, digest, type);
833
+ return NextResponse.json({ id, name, ...signed }, { status: 201 });
787
834
  }
788
835
  /** GET /<id>/share.png: the page's title card. 404 when the pack draws none, so a
789
836
  * tenant on the static default never serves a half-styled card. */
@@ -828,5 +875,5 @@ export function createArtifactRoutes(config) {
828
875
  revalidatePath(pagePath(id));
829
876
  return NextResponse.json({ deleted: true });
830
877
  }
831
- 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, dynamic: 'force-dynamic', maxDuration: 30 };
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 };
832
879
  }
@@ -0,0 +1,25 @@
1
+ import type { RecipientsStore } from '../artifacts/recipients-store.js';
2
+ export type PerReaderOpen = {
3
+ state: 'open';
4
+ rid: string;
5
+ sig: string;
6
+ label: string;
7
+ note: string | null;
8
+ owner: boolean;
9
+ fromLink: boolean;
10
+ };
11
+ export type PerReaderShut = {
12
+ state: 'shut';
13
+ why: 'no-link' | 'revoked' | 'expired' | 'closed';
14
+ };
15
+ export declare const UNAVAILABLE = "This page is no longer available.";
16
+ export declare const NO_LINK = "This page opens from the link it was sent with.";
17
+ export declare function perReaderGate(o: {
18
+ id: string;
19
+ param: string | undefined;
20
+ cookie: string | undefined;
21
+ secret: string | undefined;
22
+ recipients: RecipientsStore | undefined;
23
+ now?: number;
24
+ }): Promise<PerReaderOpen | PerReaderShut>;
25
+ export declare function withPrivateUrls(markdown: string, id: string, privateUrl: ((id: string, stored: string) => Promise<string>) | undefined): Promise<string>;
@@ -0,0 +1,38 @@
1
+ // Who is reading a `links: per-reader` page, decided before anything of the page is rendered.
2
+ // Every refusal is the same quiet page; a revoked or expired reader learns only that it is gone.
3
+ import { parseRidParam, verifyRid } from '../artifacts/recipients.js';
4
+ import { PRIVATE_REF } from '../artifacts/assets.js';
5
+ export const UNAVAILABLE = 'This page is no longer available.';
6
+ export const NO_LINK = 'This page opens from the link it was sent with.';
7
+ export async function perReaderGate(o) {
8
+ // An unset or empty secret verifies nothing: fail closed before any signature is compared.
9
+ if (!o.secret || !o.recipients)
10
+ return { state: 'shut', why: 'closed' };
11
+ const fromParam = parseRidParam(o.param);
12
+ const fromCookie = parseRidParam(o.cookie);
13
+ const pick = fromParam && verifyRid(o.secret, o.id, fromParam.rid, fromParam.sig) ? { ...fromParam, fromLink: true }
14
+ : fromCookie && verifyRid(o.secret, o.id, fromCookie.rid, fromCookie.sig) ? { ...fromCookie, fromLink: false } : null;
15
+ if (!pick)
16
+ return { state: 'shut', why: 'no-link' };
17
+ const rec = await o.recipients.get(o.id);
18
+ const r = rec?.recipients[pick.rid];
19
+ if (!rec || !r)
20
+ return { state: 'shut', why: 'no-link' };
21
+ if (r.revokedAt)
22
+ return { state: 'shut', why: 'revoked' };
23
+ // A malformed expireAt parses to NaN: treat it as expired, never as no expiry.
24
+ if (rec.expireAt && !(Date.parse(rec.expireAt) > (o.now ?? Date.now())))
25
+ return { state: 'shut', why: 'expired' };
26
+ return { state: 'open', rid: pick.rid, sig: pick.sig, label: r.label, note: r.note ?? null, owner: r.owner === true, fromLink: pick.fromLink };
27
+ }
28
+ const REF_IN_MD = /\]\((private:[A-Za-z0-9][A-Za-z0-9._-]{0,140})\)/g;
29
+ export async function withPrivateUrls(markdown, id, privateUrl) {
30
+ const refs = [...new Set([...markdown.matchAll(REF_IN_MD)].map((m) => m[1]))];
31
+ let out = markdown;
32
+ for (const ref of refs) {
33
+ const stored = PRIVATE_REF.exec(ref)?.[1];
34
+ const url = stored && privateUrl ? await privateUrl(id, stored) : '#';
35
+ out = out.split(`](${ref})`).join(`](${url})`);
36
+ }
37
+ return out;
38
+ }
@@ -0,0 +1,34 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+ import { type RecipientsStore } from '../artifacts/recipients-store.js';
3
+ import type { ArtifactStore } from '../artifacts/store.js';
4
+ import type { StateStore } from '../artifacts/state-store.js';
5
+ import type { ArtifactAssets } from '../artifacts/assets.js';
6
+ type Params = {
7
+ params: Promise<{
8
+ id: string;
9
+ }>;
10
+ };
11
+ export type RecipientsRoutesCtx = {
12
+ store: ArtifactStore;
13
+ recipients?: RecipientsStore;
14
+ recipientSecret?: () => string | undefined;
15
+ publishKey: () => string | undefined;
16
+ state?: StateStore;
17
+ assets?: ArtifactAssets;
18
+ pageUrl: (id: string) => string;
19
+ authed: (r: Request) => boolean;
20
+ /** Where the sweep reports a page it skipped or could not finish. Defaults to console.warn. */
21
+ log?: (message: string) => void;
22
+ };
23
+ export declare function createRecipientsRoutes(given: RecipientsRoutesCtx): {
24
+ SEEN: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
25
+ RECIPIENTS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
26
+ VIEWS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
27
+ sweepExpired: (now?: Date) => Promise<{
28
+ pages: number;
29
+ files: number;
30
+ skipped: string[];
31
+ failed: string[];
32
+ }>;
33
+ };
34
+ export {};