@supersuit/artifacts 0.20.0 → 0.21.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,24 @@
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.21.0 (2026-10-08)
7
+
8
+ **A page's past versions are for its admins.** Every reader who could open a page could also open
9
+ its History and read every earlier version at `/<id>/v/<n>`, so a passage edited out of a page
10
+ (a name taken out, a figure corrected) stayed readable one click away.
11
+
12
+ - New config `admins` (sign-in emails) and `history: 'admins' | 'everyone'`. The default,
13
+ `'admins'`, shows the History control and serves `/<id>/v/<n>` only to a signed-in reader whose
14
+ email is `ownerEmail` or in `admins`, compared without case. To everyone else the control is
15
+ absent and a past version is not found. "Version N · Updated <minute>" still shows to everyone.
16
+ - DETECTOR: after the bump, a reader who is not an admin no longer sees "History" under a page's
17
+ summary. A host that configures neither `ownerEmail` nor `admins` hides history from everyone.
18
+ - REMEDY: pass `admins` (and `ownerEmail`) for the people who should keep seeing history, or
19
+ `history: 'everyone'` to keep the 0.20.0 behaviour.
20
+ - Tests: signed out, a signed-in non-admin, the owner in another case and a listed admin, a past
21
+ version 404ing for non-admins, nobody configured, and `'everyone'` restoring the old page. The
22
+ earlier version and golden tests pin `history: 'everyone'`, so they still prove 0.20.0's page.
23
+
6
24
  ## 0.20.0 (2026-10-07)
7
25
 
8
26
  **A host can hear when a confidential page is read.** The reader record answered `GET /reads`
package/README.md CHANGED
@@ -123,6 +123,17 @@ phone, a side panel on a desk), so opening it moves no text. Each past version i
123
123
  the same password or confidential door as the page itself. A version that does not exist is a
124
124
  404; on a shut page the door comes first, so it never says which versions exist.
125
125
 
126
+ **Past versions are for admins by default (0.21.0).** An earlier draft can hold what the current
127
+ one was edited to remove, so only the page's admins see the History control or open
128
+ `/<id>/v/<n>`: the reader signed in as `ownerEmail` or any address in `admins` (case does not
129
+ matter). Everyone else still sees "Version N · Updated <minute>", and a past version is a 404 to
130
+ them. With neither configured, nobody does. `history: 'everyone'` in the config restores the old
131
+ behaviour, where every reader who can open the page reads its history.
132
+
133
+ ```ts
134
+ createArtifactRoutes({ ..., ownerEmail: 'owner@example.com', admins: ['editor@example.com'] })
135
+ ```
136
+
126
137
  **Do not write a "Version history" section in the markdown.** Say what changed when you publish:
127
138
 
128
139
  - `POST /api/artifacts?id=<id>&note=<one line>`, or a `change:` line in the front matter. The query
@@ -64,6 +64,15 @@ export type ArtifactRoutesConfig = {
64
64
  * its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
65
65
  * the publisher reads comments through /responses. */
66
66
  ownerEmail?: string;
67
+ /** Sign-in emails, besides `ownerEmail`, of the people who administer this host's pages. They
68
+ * see a page's version history; with `history: 'admins'` (the default) nobody else does. */
69
+ admins?: readonly string[];
70
+ /** Who sees a page's past versions: the History control under the summary and every
71
+ * `/<id>/v/<n>`. `'admins'` (default): only `ownerEmail` and `admins`, signed in; to everyone
72
+ * else the History control is absent and a past version is not found, because an earlier draft
73
+ * can hold what the current one was edited to remove. `'everyone'`: every reader who can open
74
+ * the page, which was the only behaviour before 0.21.0. */
75
+ history?: 'admins' | 'everyone';
67
76
  /** Turns a reader's voice memo into text on the server, so the service's key never reaches a
68
77
  * browser: `deepgramTranscriber(key)` or `openaiTranscriber(key)` from `./artifacts`, or your
69
78
  * own. Without it the transcribe route answers 404 and readers get the browser's live
@@ -60,6 +60,9 @@ export function createArtifactRoutes(config) {
60
60
  const signInOrigin = config.signInOrigin;
61
61
  const owner = config.owner ?? brand.name;
62
62
  const ownerEmail = config.ownerEmail?.trim().toLowerCase();
63
+ const admins = new Set([ownerEmail, ...(config.admins ?? []).map((e) => e.trim().toLowerCase())].filter((e) => !!e));
64
+ /** Whether this reader may see a page's past versions (config `history`). */
65
+ const seesHistory = (reader) => config.history === 'everyone' || (!!reader && admins.has(reader.email.trim().toLowerCase()));
63
66
  const readerSecret = () => config.readerSecret?.();
64
67
  const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
65
68
  // Signing out alone cannot change the account: the authority still holds the Google session
@@ -195,7 +198,7 @@ export function createArtifactRoutes(config) {
195
198
  if (!a)
196
199
  notFound();
197
200
  const { pack, brand } = lookOf(a);
198
- const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top }));
201
+ const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
199
202
  if (a.access)
200
203
  return GatedPage(a, id, render, n);
201
204
  // A password shuts the body, never the title: the header stays so the reader knows which
@@ -273,8 +276,8 @@ export function createArtifactRoutes(config) {
273
276
  return store.history(id).catch(() => null);
274
277
  }
275
278
  /** "Version N · Updated <minute> · History": the line under the summary. */
276
- function VersionLine({ a, n, at, history }) {
277
- return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
279
+ function VersionLine({ a, n, at, history, reader }) {
280
+ return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 && seesHistory(reader) ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
278
281
  }
279
282
  /** The title block and the body, in the pack's Article when it has one. Without one this is
280
283
  * exactly the markup every page had before packs could frame it. */
@@ -284,8 +287,11 @@ export function createArtifactRoutes(config) {
284
287
  return (_jsxs(_Fragment, { children: [cover, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc, children] })] }));
285
288
  }
286
289
  /** A past version: its own body and title, read-only (no narration, no answers), under a banner. */
287
- async function VersionBody(a, n, { top }) {
290
+ async function VersionBody(a, n, { top, reader }) {
288
291
  const { pack, brand } = lookOf(a);
292
+ // Past versions are for admins unless the host says otherwise: to anyone else none exists.
293
+ if (!seesHistory(reader))
294
+ notFound();
289
295
  const v = store.version ? await store.version(a.id, n) : null;
290
296
  if (!v)
291
297
  notFound();
@@ -296,7 +302,7 @@ export function createArtifactRoutes(config) {
296
302
  const title = v.title ?? a.title;
297
303
  const summary = v.summary ?? a.summary;
298
304
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
299
- return (_jsxs(_Fragment, { children: [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, { a: a, n: n, at: v.at, history: history }), 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, { a: a, n: n, at: v.at, history: history })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, Section: pack.Section, figure: pack.figure }) })] }));
305
+ return (_jsxs(_Fragment, { children: [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, { a: a, n: n, at: v.at, history: history, reader: reader }), 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, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, Section: pack.Section, figure: pack.figure }) })] }));
300
306
  }
301
307
  /** The page itself, shared by open and gated pages. */
302
308
  async function Body(a, { top, reader }) {
@@ -317,7 +323,7 @@ export function createArtifactRoutes(config) {
317
323
  const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
318
324
  return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
319
325
  const n = a.version ?? (a.versions?.length ?? 0) + 1;
320
- const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id) }), 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] }));
326
+ const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id), reader: reader }), 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] }));
321
327
  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] }));
322
328
  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, Section: pack.Section, figure: pack.figure }) }));
323
329
  })() }), _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: !!reader && !!ownerEmail && reader.email === ownerEmail, 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", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",