@supersuit/artifacts 0.15.0 → 0.17.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,48 @@
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.17.0 (2026-09-29)
7
+
8
+ **A host can accept a signed, hour-long publisher pass wherever it accepts the publish key.** A
9
+ shared key cannot be self-served and cannot be revoked for one person without rotating it for
10
+ everyone. New routes config `publisherSecret` and `publisherHost`: when both are set, every
11
+ publish-key route (publish, read, delete, versions, access, reads, assets, upload, responses, the
12
+ comments feed) also accepts `Authorization: Bearer p1.<payload>.<sig>`, a pass naming one person
13
+ and this host, signed with the reader pass's HMAC scheme under its own domain
14
+ (`artifact-publisher`). New exports `mintPublisherPass`, `verifyPublisherPass`,
15
+ `PUBLISHER_PASS_TTL_SECONDS` (3600), `isPublisherAuthed`. `isPublishAuthed` is unchanged.
16
+
17
+ - DETECTOR: none needed. A host that sets neither field refuses every pass and takes its key
18
+ exactly as on 0.16.0.
19
+ - REMEDY: nothing, unless you want admins to publish without the key: set both fields, and have
20
+ your sign-in side mint passes for the people it vouches for.
21
+ - Tests: the pass vector pinned byte for byte; a valid pass for the right host gets exactly what
22
+ the key gets on every publish-key handler; wrong host, expired, bad signature, a reader pass
23
+ (`a1`) and a grant (`g1`) are refused on every handler; a `p1` pass never verifies as a reader
24
+ pass or grant; a host missing either field refuses a pass and still takes its key. Broken on
25
+ purpose twice and failed: the host check removed (11 failures), and the comments feed left on
26
+ the bare key check (its pass case failed).
27
+
28
+ ## 0.16.0 (2026-09-29)
29
+
30
+ **A brand pack can draw a page's structure, and one page can wear a pack of its own.** Four new
31
+ optional `BrandPack` fields: `Cover` (the title block), `Article` (what holds the cover and body),
32
+ `Section` (each top-level section, with its index, count and the outline, for a break before it
33
+ or a wrapper around it) and `figure(src, alt)` (a component in place of an image or embed). New
34
+ routes config `packs` (by name) and `packFor(artifact)`, and front matter `pack: <name>`.
35
+
36
+ - DETECTOR: none needed. A pack that uses none of the new fields renders exactly as on 0.15.0,
37
+ pinned by a golden rendered from 0.15.0 itself (`src/routes/__golden__`).
38
+ - REMEDY: nothing, unless you want a page drawn differently: set the fields on a pack of your
39
+ own and register it with `packs` or choose it with `packFor`.
40
+ - Tests: the golden (default and wrapped packs, current page and a past version); each extension
41
+ point; a claimed image replacing its paragraph and an unclaimed one untouched; `pack:` by name,
42
+ an unknown name, `packFor` ahead of front matter, other pages untouched, the share card following
43
+ the page's pack; `pack:` parse and refusals and the store round trip; and the read-along: a page
44
+ rendered through all four has exactly the narrated words. Each was broken on purpose once and
45
+ failed (figure left speakable, sections not split, cover and article ignored, `packFor` and
46
+ `pack:` ignored, `pack` not cleared on republish).
47
+
6
48
  ## 0.15.0 (2026-09-29)
7
49
 
8
50
  **A reader signed in with the wrong Google account can change it.** Before, "Sign out" only
package/README.md CHANGED
@@ -28,6 +28,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
28
28
  | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
29
29
  | `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
30
30
  | `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
31
+ | `mintPublisherPass`, `verifyPublisherPass` | The publisher pass: an hour-long stand-in for the publish key, for one person on one host (see below) |
31
32
  | `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
32
33
 
33
34
  Subpath imports: `@supersuit/artifacts/artifacts`, `/routes`, `/brand`, `/reader`, `/gate`.
@@ -157,6 +158,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
157
158
  | `password` | Shuts the body behind a door (below) |
158
159
  | `access` | `freedom`, `invite` or `public`: a confidential page (below) |
159
160
  | `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
161
+ | `pack` | A brand pack the host registered by name (`packs` in the routes config). An unknown name renders in the host's default pack; a republish without the line goes back to it |
160
162
  | `toc` | The table of contents. Absent, it appears once the page has four `##` sections; `false` never; `true` from one |
161
163
  | `definitions` | Terms defined inline (below). A republish without it removes them |
162
164
  | `change` | One line saying what this version changed (see Version history) |
@@ -231,6 +233,29 @@ reimplement it. Keep passes short-lived (five minutes is what the tests assume).
231
233
  Without `signInOrigin`, a confidential page shows its door with no way through. It fails
232
234
  closed, never open.
233
235
 
236
+ ### The publisher pass (publishing without the shared key)
237
+
238
+ A shared publish key cannot be handed to one person and taken back from one person. A host can
239
+ also accept a **publisher pass** wherever it accepts the key: a signed, hour-long statement that
240
+ a named person may publish on that host, minted by an authority that knows who may.
241
+
242
+ ```ts
243
+ createArtifactRoutes({
244
+ // ...
245
+ publisherSecret: () => process.env.ARTIFACT_PASS_SECRET, // shared with the minting side
246
+ publisherHost: 'artifacts.example.com', // the host a pass must name
247
+ })
248
+ ```
249
+
250
+ Both fields are optional; without either, passes are refused and only the key works. The pass is
251
+ `p1.<payload>.<sig>`: `payload` is the base64url of
252
+ `{"u": uid, "e": email, "n": name|null, "h": host, "x": expiry-unix-seconds}`, and `sig` is the
253
+ first 32 hex characters of `HMAC-SHA256(secret, "artifact-publisher:p1.<payload>")`. It is sent as
254
+ `Authorization: Bearer <pass>`. A pass for another host is refused even under the same secret, and
255
+ a reader pass or grant never verifies as a publisher pass (nor the reverse), because the domain
256
+ and version differ. `mintPublisherPass(secret, { uid, email, name, host }, exp)` builds one;
257
+ `PUBLISHER_PASS_TTL_SECONDS` is 3600.
258
+
234
259
  ## Reader answers (`state:`)
235
260
 
236
261
  A page can take answers from the people reading it: a vote, a reaction, a short response.
@@ -546,12 +571,36 @@ motion. `colors` are `r,g,b` triplets (default `GLOW_PASTELS`); `veil` is 0 to 1
546
571
  const pack: BrandPack = { ...freedomDefault, mode: 'light', backdrop: { kind: 'bubbles' } }
547
572
  ```
548
573
 
574
+ **Drawing more than colours.** Four optional fields let a pack draw a page's structure. A pack
575
+ that sets none of them renders every page byte for byte as before (a test holds that).
576
+
577
+ - `Cover` draws the title block instead of the centred header. It is handed `title`, `subtitle`
578
+ and `summary` to render (they are spoken, in that order), the shell's `kicker` and `meta`
579
+ (version line, signed-in line) to place, and the page's `markdown` to quote from.
580
+ - `Article` holds the cover, the inline contents and the body, instead of the centred column.
581
+ - `Section` receives each top-level section (split at the shallowest heading level the page
582
+ uses): its `index`, `count`, the whole `outline`, the heading's `text` and `slug`, the default
583
+ `heading`, or its `headingContent` and `headingAttrs` (id and comment anchor) for a pack that
584
+ draws the heading itself, and the section's body as `children`.
585
+ - `figure(src, alt)` returns a component to draw an image or embed in its place, or undefined to
586
+ keep the default. An image alone in its paragraph replaces the paragraph.
587
+
588
+ Everything a pack adds beyond the text it is handed must sit inside `data-nospeak`, which the
589
+ read-along skips; the shell puts figures there itself. A test renders a page through all four
590
+ and checks the words on the page are exactly the words narrated.
591
+
592
+ **One page in a different pack.** Register packs by name with `packs: { name: pack }` and a page
593
+ picks one with `pack: name` in its front matter. `packFor(artifact)` lets the host choose instead,
594
+ ahead of the front matter, for a page whose file cannot carry the line. The share card follows
595
+ the page's pack.
596
+
549
597
  ## Environment
550
598
 
551
599
  | Variable | Used for |
552
600
  |---|---|
553
601
  | your publish key (named by you, read in `publishKey`) | Authorises publish, delete and access changes |
554
602
  | `ARTIFACT_PASS_SECRET` (named by you, read in `readerSecret`) | Verifies reader passes and signs grants; shared with your sign-in side |
603
+ | a publisher-pass secret (named by you, read in `publisherSecret`; may be the same one) | Verifies publisher passes; shared with whatever mints them |
555
604
  | Firebase Admin credentials | The store and readers store |
556
605
 
557
606
  ## Contributing
@@ -1 +1,13 @@
1
+ /** The Bearer is exactly this instance's publish key. */
1
2
  export declare function isPublishAuthed(request: Request, key: string | undefined): boolean;
3
+ export type PublishAuth = {
4
+ /** The tenant's shared publish key. Read at request time. */
5
+ publishKey: () => string | undefined;
6
+ /** Verifies publisher passes. Absent, or returning nothing, means passes are refused. */
7
+ publisherSecret?: () => string | undefined;
8
+ /** The hostname a pass must name (`h`), e.g. artifacts.example.com. Absent means passes are refused. */
9
+ publisherHost?: string;
10
+ };
11
+ /** Every publish-key route's check: the tenant key, OR a valid publisher pass for this host
12
+ * when the host opted in. A host that configures neither pass field behaves exactly as before. */
13
+ export declare function isPublisherAuthed(request: Request, auth: PublishAuth, now?: number): boolean;
@@ -1,8 +1,22 @@
1
- // One publish key per instance. The site gate lets the artifacts API through precisely
2
- // because this header is the whole authorization.
1
+ // Who may publish. The site gate lets the artifacts API through precisely because the
2
+ // Authorization header is the whole authorization.
3
+ import { verifyPublisherPass } from './publisher.js';
4
+ /** The Bearer is exactly this instance's publish key. */
3
5
  export function isPublishAuthed(request, key) {
4
6
  if (!key)
5
7
  return false;
6
8
  const h = request.headers.get('authorization') ?? '';
7
9
  return h === `Bearer ${key}`;
8
10
  }
11
+ /** Every publish-key route's check: the tenant key, OR a valid publisher pass for this host
12
+ * when the host opted in. A host that configures neither pass field behaves exactly as before. */
13
+ export function isPublisherAuthed(request, auth, now = Date.now()) {
14
+ if (isPublishAuthed(request, auth.publishKey()))
15
+ return true;
16
+ if (!auth.publisherHost || !auth.publisherSecret)
17
+ return false;
18
+ const h = request.headers.get('authorization') ?? '';
19
+ if (!h.startsWith('Bearer '))
20
+ return false;
21
+ return verifyPublisherPass(auth.publisherSecret(), h.slice(7).trim(), auth.publisherHost, now) !== null;
22
+ }
@@ -36,6 +36,10 @@ export type ArtifactMeta = {
36
36
  state?: StateConfig;
37
37
  /** Light, dark, or the reader's device, overriding the brand pack's mode for this page. */
38
38
  theme?: ThemeMode;
39
+ /** The brand pack this page renders in, by the name the host registered it under (`packs` in
40
+ * the routes config). A name the host does not know renders in its default pack. Content, like
41
+ * `theme:`: a republish without the line goes back to the default. */
42
+ pack?: string;
39
43
  /** The table of contents: absent draws it once the page has four sections, false never. */
40
44
  toc?: boolean;
41
45
  /** Terms this page defines inline: the first occurrence of each is underlined and opens its
@@ -51,6 +55,8 @@ export type ArtifactMeta = {
51
55
  /** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
52
56
  commentsVisible?: CommentsVisible;
53
57
  };
58
+ /** A pack name, as a host registers one. */
59
+ export declare const PACK_NAME: RegExp;
54
60
  /** The longest `to:` a page may carry: a name or two, never a sentence. */
55
61
  export declare const MAX_TO_CHARS = 60;
56
62
  export declare function parseArtifactSource(text: string): {
@@ -7,9 +7,11 @@ import { 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
+ /** A pack name, as a host registers one. */
11
+ export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
10
12
  /** The longest `to:` a page may carry: a name or two, never a sentence. */
11
13
  export const MAX_TO_CHARS = 60;
12
- const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions', 'change', 'comments', 'comments_visible']);
14
+ 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']);
13
15
  export function parseArtifactSource(text) {
14
16
  const { data, content } = matter(text);
15
17
  const d = data;
@@ -57,6 +59,11 @@ export function parseArtifactSource(text) {
57
59
  return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
58
60
  meta.theme = d.theme;
59
61
  }
62
+ if (d.pack !== undefined && d.pack !== null) {
63
+ if (typeof d.pack !== 'string' || !PACK_NAME.test(d.pack))
64
+ return { ok: false, error: 'pack must be a pack name: lowercase letters, digits and dashes, up to 40 characters' };
65
+ meta.pack = d.pack;
66
+ }
60
67
  if (d.toc !== undefined) {
61
68
  if (typeof d.toc !== 'boolean')
62
69
  return { ok: false, error: 'toc must be true or false' };
@@ -2,7 +2,8 @@ export { parseArtifactSource, type ArtifactMeta } from './front-matter.js';
2
2
  export { ArtifactMarkdown } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
5
- export { isPublishAuthed } from './auth.js';
5
+ export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
6
+ export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS, type Publisher } from './publisher.js';
6
7
  export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
7
8
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
8
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';
@@ -2,7 +2,8 @@ export { parseArtifactSource } from './front-matter.js';
2
2
  export { ArtifactMarkdown } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
5
- export { isPublishAuthed } from './auth.js';
5
+ export { isPublishAuthed, isPublisherAuthed } from './auth.js';
6
+ export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS } from './publisher.js';
6
7
  export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
7
8
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
8
9
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
@@ -0,0 +1,15 @@
1
+ export declare const PUBLISHER_DOMAIN = "artifact-publisher";
2
+ export declare const PUBLISHER_VERSION = "p1";
3
+ export declare const PUBLISHER_PASS_TTL_SECONDS = 3600;
4
+ /** Who a publisher pass names, and the one host it is good for. */
5
+ export type Publisher = {
6
+ uid: string;
7
+ email: string;
8
+ name: string | null;
9
+ host: string;
10
+ };
11
+ export declare function mintPublisherPass(secret: string, p: Publisher, exp: number): string;
12
+ /** The publisher a pass names, if it is correctly signed, unexpired, and for `host` (a hostname
13
+ * like `artifacts.example.com`, no scheme). Anything else is null: a pass for another host is
14
+ * refused even under the same secret, because two hosts can share one. */
15
+ export declare function verifyPublisherPass(secret: string | undefined, pass: string | null | undefined, host: string | undefined, now?: number): Publisher | null;
@@ -0,0 +1,48 @@
1
+ // The publisher pass: a short-lived, signed statement that a named person may do what the
2
+ // publish key does, on ONE host.
3
+ //
4
+ // A shared publish key cannot be self-served and cannot be revoked for one person without
5
+ // rotating it for everyone. A host that opts in (routes config `publisherSecret` +
6
+ // `publisherHost`) also accepts this pass as the Bearer: a sign-in authority that knows who the
7
+ // host's admins are mints it for one of them, and it expires within an hour, so revoking the
8
+ // admin stops new passes and an outstanding one dies on its own.
9
+ //
10
+ // THE SHAPE IS A CONTRACT WITH THE AUTHORITY THAT MINTS IT, byte for byte: `p1.<payload>.<sig>`,
11
+ // payload the base64url of `{ u, e, n, h, x }`, sig the first 32 hex of
12
+ // HMAC-SHA256(secret, `artifact-publisher:p1.<payload>`). Same scheme as the reader pass, with its
13
+ // own domain and version, so a reader pass (a1) or grant (g1) never verifies here and a
14
+ // publisher pass never verifies as either (publisher.test.ts pins both directions and the vector).
15
+ import { openToken, signToken } from './reader.js';
16
+ export const PUBLISHER_DOMAIN = 'artifact-publisher';
17
+ export const PUBLISHER_VERSION = 'p1';
18
+ export const PUBLISHER_PASS_TTL_SECONDS = 3600;
19
+ const normHost = (h) => h.trim().toLowerCase();
20
+ export function mintPublisherPass(secret, p, exp) {
21
+ return signToken(secret, PUBLISHER_DOMAIN, PUBLISHER_VERSION, {
22
+ u: p.uid,
23
+ e: p.email.trim().toLowerCase(),
24
+ n: p.name,
25
+ h: normHost(p.host),
26
+ x: exp,
27
+ });
28
+ }
29
+ /** The publisher a pass names, if it is correctly signed, unexpired, and for `host` (a hostname
30
+ * like `artifacts.example.com`, no scheme). Anything else is null: a pass for another host is
31
+ * refused even under the same secret, because two hosts can share one. */
32
+ export function verifyPublisherPass(secret, pass, host, now = Date.now()) {
33
+ if (!host)
34
+ return null;
35
+ const d = openToken(secret, PUBLISHER_DOMAIN, PUBLISHER_VERSION, pass, now);
36
+ if (!d)
37
+ return null;
38
+ if (typeof d.u !== 'string' || !d.u || typeof d.e !== 'string' || !d.e.includes('@') || typeof d.h !== 'string')
39
+ return null;
40
+ if (normHost(d.h) !== normHost(host))
41
+ return null;
42
+ return {
43
+ uid: d.u,
44
+ email: d.e.trim().toLowerCase(),
45
+ name: typeof d.n === 'string' && d.n.trim() ? d.n.trim() : null,
46
+ host: normHost(d.h),
47
+ };
48
+ }
@@ -12,6 +12,13 @@ export declare const PASS_TTL_SECONDS = 300;
12
12
  * changes need no wait: the list is read on every request. */
13
13
  export declare const GRANT_TTL_SECONDS: number;
14
14
  export declare const GRANT_COOKIE = "artifact_reader";
15
+ /** Sign a small JSON payload as `<version>.<b64url(json)>.<sig>` under one domain. The domain is
16
+ * in the MAC and the version is the token's first segment, so a token minted for one purpose
17
+ * (reader pass, grant, publisher pass) never verifies as another. */
18
+ export declare function signToken(secret: string, domain: string, version: string, data: Record<string, unknown>): string;
19
+ /** The payload of a correctly signed, unexpired token of this domain and version, else null.
20
+ * `x` (unix seconds) is required and checked; every other field is the caller's to validate. */
21
+ export declare function openToken(secret: string | undefined, domain: string, version: string, token: string | null | undefined, now: number): Record<string, unknown> | null;
15
22
  export declare function mintPass(secret: string, reader: Reader, exp: number): string;
16
23
  export declare function verifyPass(secret: string | undefined, pass: string | null | undefined, now?: number): Reader | null;
17
24
  export declare function mintGrant(secret: string, reader: Reader, now?: number): string;
@@ -29,13 +29,16 @@ function safeEqual(a, b) {
29
29
  return false;
30
30
  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
31
31
  }
32
- function encode(secret, domain, version, reader, exp) {
33
- // Short keys: this rides in a URL and a cookie.
34
- const payload = b64url(JSON.stringify({ u: reader.uid, e: reader.email, n: reader.name, m: reader.member, x: exp }));
35
- const body = `${version}.${payload}`;
32
+ /** Sign a small JSON payload as `<version>.<b64url(json)>.<sig>` under one domain. The domain is
33
+ * in the MAC and the version is the token's first segment, so a token minted for one purpose
34
+ * (reader pass, grant, publisher pass) never verifies as another. */
35
+ export function signToken(secret, domain, version, data) {
36
+ const body = `${version}.${b64url(JSON.stringify(data))}`;
36
37
  return `${body}.${sig(secret, domain, body)}`;
37
38
  }
38
- function decode(secret, domain, version, token, now) {
39
+ /** The payload of a correctly signed, unexpired token of this domain and version, else null.
40
+ * `x` (unix seconds) is required and checked; every other field is the caller's to validate. */
41
+ export function openToken(secret, domain, version, token, now) {
39
42
  if (!secret || typeof token !== 'string')
40
43
  return null;
41
44
  const parts = token.split('.');
@@ -51,7 +54,20 @@ function decode(secret, domain, version, token, now) {
51
54
  catch {
52
55
  return null;
53
56
  }
54
- if (typeof d.x !== 'number' || d.x * 1000 < now)
57
+ if (!d || typeof d !== 'object' || Array.isArray(d))
58
+ return null;
59
+ const o = d;
60
+ if (typeof o.x !== 'number' || o.x * 1000 < now)
61
+ return null;
62
+ return o;
63
+ }
64
+ function encode(secret, domain, version, reader, exp) {
65
+ // Short keys: this rides in a URL and a cookie.
66
+ return signToken(secret, domain, version, { u: reader.uid, e: reader.email, n: reader.name, m: reader.member, x: exp });
67
+ }
68
+ function decode(secret, domain, version, token, now) {
69
+ const d = openToken(secret, domain, version, token, now);
70
+ if (!d)
55
71
  return null;
56
72
  if (typeof d.u !== 'string' || !d.u || typeof d.e !== 'string' || !d.e.includes('@'))
57
73
  return null;
@@ -1,9 +1,21 @@
1
+ import { type ComponentType } from 'react';
2
+ import type { FigureProps, SectionOutline, SectionProps } from '../brand/pack.js';
3
+ import { type Heading } from './widgets.js';
1
4
  import { type Definition } from './definitions.js';
2
5
  export declare function isReachableHref(href: unknown): href is string;
6
+ /** A pack's figure resolver (BrandPack.figure). */
7
+ export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps> | undefined;
8
+ /** The page's top-level sections: every heading among the body's top-level blocks at the
9
+ * shallowest depth any of them uses. Headings inside lists or quotes never start a section. */
10
+ export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
3
11
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
4
12
  * page carries a ```notes block. */
5
- export declare function ArtifactMarkdown({ markdown, notes, definitions, }: {
13
+ export declare function ArtifactMarkdown({ markdown, notes, definitions, Section, figure, }: {
6
14
  markdown: string;
15
+ /** The pack's section component (BrandPack.Section): each top-level section is drawn through it. */
16
+ Section?: ComponentType<SectionProps>;
17
+ /** The pack's figure resolver (BrandPack.figure). */
18
+ figure?: FigureFor;
7
19
  notes?: {
8
20
  artifactId: string;
9
21
  accent?: string;
@@ -1,6 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Markdown to the house look, for /a/<id>. Server-safe: no hooks, no client
3
+ // directive. Raw HTML is escaped by react-markdown's default; never add
4
+ // rehype-raw here, this renders strangers' links on a public route.
5
+ // Two extensions past GFM, both small on purpose:
6
+ // ```links one "url | note" per line, rendered as link cards
7
+ // > [!note] / > [!warning] a callout instead of a blockquote
8
+ import { Children, isValidElement } from 'react';
2
9
  import ReactMarkdown from 'react-markdown';
3
10
  import remarkGfm from 'remark-gfm';
11
+ import remarkParse from 'remark-parse';
12
+ import { unified } from 'unified';
4
13
  import { blocksOf, hasNotesWidget, headingsOf } from './widgets.js';
5
14
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
6
15
  import { VideoAutoplay } from '../reader/video-autoplay.js';
@@ -70,18 +79,41 @@ function InlineAudio({ src, label }) {
70
79
  const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
71
80
  return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
72
81
  }
73
- function baseComponents(block) {
82
+ /** The one image a paragraph holds and nothing else (whitespace aside), or null. */
83
+ function soleImage(node) {
84
+ const kids = (node?.children ?? []).filter((c) => !(c.type === 'text' && !c.value?.trim()));
85
+ if (kids.length !== 1 || kids[0].type !== 'element' || kids[0].tagName !== 'img')
86
+ return null;
87
+ const src = kids[0].properties?.src;
88
+ return typeof src === 'string' ? { src, alt: String(kids[0].properties?.alt ?? '') } : null;
89
+ }
90
+ function baseComponents(block, figure) {
74
91
  return {
75
- p: ({ node, children }) => _jsx("p", { ...block(node), className: "my-4 leading-relaxed", children: children }),
92
+ p: ({ node, children }) => {
93
+ // An image alone in its paragraph that the pack draws itself replaces the paragraph, so the
94
+ // pack's figure may hold block content (a <p> cannot) and still carry the comment anchor.
95
+ const img = figure ? soleImage(node) : null;
96
+ const F = img ? figure(img.src, img.alt) : undefined;
97
+ if (img && F) {
98
+ return (_jsx("div", { ...block(node), "data-nospeak": true, "data-artifact-figure": true, children: _jsx(F, { src: img.src, alt: img.alt }) }));
99
+ }
100
+ return _jsx("p", { ...block(node), className: "my-4 leading-relaxed", children: children });
101
+ },
76
102
  ul: ({ node, children }) => _jsx("ul", { ...block(node), className: "my-4 list-disc space-y-1 pl-6", children: children }),
77
103
  ol: ({ node, children }) => _jsx("ol", { ...block(node), className: "my-4 list-decimal space-y-1 pl-6", children: children }),
78
104
  hr: () => _jsx("hr", { className: "my-10 border-[color:var(--a-line)]" }),
79
105
  table: ({ node, children }) => (_jsx("div", { ...block(node), className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
80
106
  th: ({ children }) => (_jsx("th", { className: "border-b border-[color:var(--a-line-strong)] px-3 py-2 text-left font-semibold text-[color:var(--a-strong)]", children: children })),
81
107
  td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
82
- img: ({ src, alt }) => typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : typeof src === 'string' && AUDIO.test(src) ? (_jsx(InlineAudio, { src: src, label: alt ?? '' })) : (
83
- // eslint-disable-next-line @next/next/no-img-element
84
- _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
108
+ img: ({ src, alt }) => {
109
+ const F = typeof src === 'string' ? figure?.(src, alt ?? '') : undefined;
110
+ if (F) {
111
+ return (_jsx("span", { "data-nospeak": true, "data-artifact-figure": true, className: "block", children: _jsx(F, { src: src, alt: alt ?? '' }) }));
112
+ }
113
+ return typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : typeof src === 'string' && AUDIO.test(src) ? (_jsx(InlineAudio, { src: src, label: alt ?? '' })) : (
114
+ // eslint-disable-next-line @next/next/no-img-element
115
+ _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" }));
116
+ },
85
117
  a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
86
118
  code: ({ className, children }) => {
87
119
  const lang = /language-(\w+)/.exec(className ?? '')?.[1];
@@ -127,20 +159,20 @@ const HEADING_CLASS = {
127
159
  };
128
160
  /** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
129
161
  * so a contents link and a note both land on it. */
130
- function pageComponents(headings, block) {
162
+ function pageComponents(headings, block, figure) {
131
163
  const byLine = new Map(headings.map((h) => [h.line, h]));
132
164
  const heading = (depth) => function Heading({ node, children }) {
133
165
  const Tag = `h${depth}`;
134
166
  const h = byLine.get(node?.position?.start.line ?? -1);
135
167
  return _jsx(Tag, { id: h?.slug, ...block(node), className: HEADING_CLASS[depth], children: children });
136
168
  };
137
- return { ...baseComponents(block), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
169
+ return { ...baseComponents(block, figure), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
138
170
  }
139
171
  /** Components for a page with a notes block: every heading gets its slug as an id, a note
140
172
  * control, and its notes after it. The heading is found by its source line, so the slug is the
141
173
  * one headingsOf derived, the same one a note stores. */
142
- function notesComponents(headings, block) {
143
- const components = baseComponents(block);
174
+ function notesComponents(headings, block, figure) {
175
+ const components = baseComponents(block, figure);
144
176
  const byLine = new Map(headings.map((h) => [h.line, h]));
145
177
  const heading = (depth) => function NotedHeading({ node, children }) {
146
178
  const Tag = `h${depth}`;
@@ -156,6 +188,55 @@ function notesComponents(headings, block) {
156
188
  code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
157
189
  };
158
190
  }
191
+ /** The page's top-level sections: every heading among the body's top-level blocks at the
192
+ * shallowest depth any of them uses. Headings inside lists or quotes never start a section. */
193
+ export function sectionOutline(markdown, headings = headingsOf(markdown)) {
194
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(markdown);
195
+ const tops = tree.children.filter((n) => n.type === 'heading');
196
+ if (!tops.length)
197
+ return [];
198
+ const depth = Math.min(...tops.map((h) => h.depth));
199
+ const byLine = new Map(headings.map((h) => [h.line, h]));
200
+ return tops
201
+ .filter((h) => h.depth === depth)
202
+ .map((h, index) => {
203
+ const known = byLine.get(h.position?.start.line ?? -1);
204
+ return { index, depth, text: known?.text ?? '', slug: known?.slug ?? '' };
205
+ });
206
+ }
207
+ /** Groups the root's blocks into one node per top-level section (a heading at `depth` and what
208
+ * follows it up to the next), so the renderer can hand each to the pack's Section. The blocks
209
+ * keep their positions, so comment anchors and heading slugs are exactly what they were. */
210
+ function remarkSections(depth) {
211
+ return () => (tree) => {
212
+ const out = [];
213
+ let body = null;
214
+ let i = 0;
215
+ for (const n of tree.children) {
216
+ if (n.type === 'heading' && n.depth === depth) {
217
+ body = { children: [] };
218
+ const b = { type: 'artifactSectionBody', data: { hName: 'div', hProperties: { dataArtifactSectionBody: '' } }, children: body.children };
219
+ out.push({ type: 'artifactSection', data: { hName: 'section', hProperties: { dataArtifactSection: String(i++) } }, children: [n, b] });
220
+ }
221
+ else if (body)
222
+ body.children.push(n);
223
+ else
224
+ out.push(n);
225
+ }
226
+ tree.children = out;
227
+ };
228
+ }
229
+ function sectionComponent(Section, outline, block) {
230
+ return function ArtifactSection({ node, children }) {
231
+ const i = Number(node?.properties?.dataArtifactSection);
232
+ const o = outline[i];
233
+ // Not one of ours (a raw <section> never reaches here: raw HTML is escaped).
234
+ if (!o)
235
+ return _jsx("section", { children: children });
236
+ const [heading, body] = Children.toArray(children).filter(isValidElement);
237
+ return (_jsx(Section, { index: i, count: outline.length, outline: outline, text: o.text, slug: o.slug, heading: heading, headingContent: heading?.props.children, headingAttrs: { id: o.slug, ...block(heading?.props.node) }, children: body?.props.children }));
238
+ };
239
+ }
159
240
  // The term is text with a dotted underline; the description copy is visually hidden and out of
160
241
  // flow (absolute), so it takes no room in the line. Neither ever changes size: the definition
161
242
  // that opens is drawn by DefinitionLayer on document.body.
@@ -170,7 +251,7 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
170
251
  'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
171
252
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
172
253
  * page carries a ```notes block. */
173
- export function ArtifactMarkdown({ markdown, notes, definitions, }) {
254
+ export function ArtifactMarkdown({ markdown, notes, definitions, Section, figure, }) {
174
255
  const on = !!notes && hasNotesWidget(markdown);
175
256
  const headings = headingsOf(markdown);
176
257
  const blocks = blocksOf(markdown);
@@ -179,7 +260,12 @@ export function ArtifactMarkdown({ markdown, notes, definitions, }) {
179
260
  return id ? { 'data-block': id } : {};
180
261
  };
181
262
  const defined = definitions?.length ? definitions : null;
182
- const body = (_jsx(ReactMarkdown, { remarkPlugins: defined ? [remarkGfm, remarkDefinitions(defined)] : [remarkGfm], components: on ? notesComponents(headings, block) : pageComponents(headings, block), children: markdown }));
263
+ const outline = Section ? sectionOutline(markdown, headings) : [];
264
+ const plugins = [remarkGfm, ...(defined ? [remarkDefinitions(defined)] : []), ...(outline.length ? [remarkSections(outline[0].depth)] : [])];
265
+ const components = on ? notesComponents(headings, block, figure) : pageComponents(headings, block, figure);
266
+ if (outline.length)
267
+ components.section = sectionComponent(Section, outline, block);
268
+ const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, children: markdown }));
183
269
  return (
184
270
  // break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
185
271
  _jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
@@ -23,6 +23,8 @@ export type ArtifactRecord = {
23
23
  access?: Access;
24
24
  state?: StateConfig;
25
25
  theme?: ThemeMode;
26
+ /** The brand pack the page asked for by name; see ArtifactMeta.pack. */
27
+ pack?: string;
26
28
  toc?: boolean;
27
29
  definitions?: Definition[];
28
30
  /** Who may leave shared comments; absent is off. Personal notes need nothing. */
@@ -134,6 +134,7 @@ async function saveArtifact(col, input) {
134
134
  ...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
135
135
  ...(input.meta.state ? { state: input.meta.state } : {}),
136
136
  ...(input.meta.theme ? { theme: input.meta.theme } : {}),
137
+ ...(input.meta.pack ? { pack: input.meta.pack } : {}),
137
138
  ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
138
139
  ...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
139
140
  ...(input.meta.comments ? { comments: input.meta.comments } : {}),
@@ -181,6 +182,7 @@ async function saveArtifact(col, input) {
181
182
  // How the page looks is content too: a line the file no longer carries goes back to the default.
182
183
  const look = {
183
184
  ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
185
+ ...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
184
186
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
185
187
  ...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
186
188
  // Comments are not an access control, so a line the file no longer carries goes back to
@@ -1,4 +1,4 @@
1
- export { freedomDefault, type BrandPack, type ShareCardAssets, type Palette, type Backdrop } from './pack.js';
1
+ export { freedomDefault, type BrandPack, type ShareCardAssets, type Palette, type Backdrop, type CoverProps, type ArticleProps, type SectionProps, type SectionOutline, type FigureProps } from './pack.js';
2
2
  export { BrandGround, BrandMark } from './wrapper.js';
3
3
  export { renderShareCard } from './share-card.js';
4
4
  export { THEME_MODES, type ThemeMode, themeCss, themedPack, resolveMode } from './theme.js';
@@ -52,6 +52,76 @@ export type BrandPack = {
52
52
  * previews as the page rather than as the brand. Absent, the instance's static default
53
53
  * share image is used. Both loaders run once per instance. */
54
54
  share?: ShareCardAssets;
55
+ /** Optional: draws the page's title block instead of the default centred header. Receives the
56
+ * title, subtitle and summary to render (they are SPOKEN, in that order, so render all three),
57
+ * plus the shell's unspoken kicker and version line to place. Anything else it draws must be
58
+ * inside `data-nospeak`, or the read-along highlight loses its place. */
59
+ Cover?: ComponentType<CoverProps>;
60
+ /** Optional: the element that holds the title block and the body, instead of the default
61
+ * centred column. Receives the cover (the pack's Cover or the default header), the inline
62
+ * table of contents when the page shows one, and the body. */
63
+ Article?: ComponentType<ArticleProps>;
64
+ /** Optional: each top-level section of the body (split at the shallowest heading level the page
65
+ * uses) is rendered through this, so a pack can draw a break before it or wrap it. Content
66
+ * before the first such heading is left as it is. Decoration must be `data-nospeak`. */
67
+ Section?: ComponentType<SectionProps>;
68
+ /** Optional: replaces an image (or `.mp4`/`.mp3` embed) with a component, by its src. Return
69
+ * undefined to keep the default. The shell wraps the result in `data-nospeak`, and an image
70
+ * alone in its paragraph replaces the paragraph, so the component may draw block content. */
71
+ figure?: (src: string, alt: string) => ComponentType<FigureProps> | undefined;
72
+ };
73
+ export type CoverProps = {
74
+ title: string;
75
+ subtitle?: string;
76
+ summary: string;
77
+ /** Who the page is for, when it says. Already inside `kicker`. */
78
+ to?: string;
79
+ /** The body, for a cover that quotes from it (anything quoted must be `data-nospeak`). */
80
+ markdown: string;
81
+ /** The shell's kicker line (the pack's kicker, then who it is for), unspoken. */
82
+ kicker: ReactNode;
83
+ /** The version line and, for a signed-in reader, who they are signed in as. Unspoken. */
84
+ meta: ReactNode;
85
+ version: number;
86
+ updatedAt: string;
87
+ createdAt: string;
88
+ };
89
+ export type ArticleProps = {
90
+ cover: ReactNode;
91
+ toc: ReactNode;
92
+ children: ReactNode;
93
+ };
94
+ /** One top-level heading of the page, in order. */
95
+ export type SectionOutline = {
96
+ index: number;
97
+ depth: number;
98
+ text: string;
99
+ slug: string;
100
+ };
101
+ export type SectionProps = {
102
+ /** This section's place among the page's top-level sections, from 0, and how many there are. */
103
+ index: number;
104
+ count: number;
105
+ /** Every top-level section, so a pack can tell what came before and what comes next. */
106
+ outline: SectionOutline[];
107
+ /** The heading's plain text and slug (its id, which the table of contents links to). */
108
+ text: string;
109
+ slug: string;
110
+ /** The heading as the shell draws it by default. */
111
+ heading: ReactNode;
112
+ /** The heading's inner content and the attributes it carries (its id and its comment anchor),
113
+ * for a pack that draws the heading element itself. Put the attributes on whatever holds it. */
114
+ headingContent: ReactNode;
115
+ headingAttrs: {
116
+ id?: string;
117
+ 'data-block'?: string;
118
+ };
119
+ /** Everything under the heading, up to the next top-level heading. */
120
+ children: ReactNode;
121
+ };
122
+ export type FigureProps = {
123
+ src: string;
124
+ alt: string;
55
125
  };
56
126
  export type ShareCardAssets = {
57
127
  /** The display font, as bytes the renderer can embed. Satori needs a real file: a CSS
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
- import { type ArtifactStore, type VersionEntry } from '../artifacts/store.js';
3
+ import { type ArtifactRecord, type ArtifactStore, type VersionEntry } from '../artifacts/store.js';
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';
@@ -18,10 +18,23 @@ export type ArtifactRoutesConfig = {
18
18
  /** Where uploaded files go. Optional; without it PUT_ASSET answers 501. */
19
19
  assets?: ArtifactAssets;
20
20
  brand: BrandPack;
21
+ /** Other packs a page may choose by name with `pack: <name>` in its front matter. A name this
22
+ * host does not know falls back to `brand`, so a page never fails to render over its look. */
23
+ packs?: Record<string, BrandPack>;
24
+ /** Choose a page's pack on the host, ahead of its front matter: for a page whose file cannot
25
+ * carry the line, or a look the host decides. Undefined means no opinion. */
26
+ packFor?: (artifact: ArtifactRecord) => BrandPack | undefined;
21
27
  /** The host that serves a 200, e.g. https://artifacts.example.com. No trailing slash. */
22
28
  siteUrl: string;
23
29
  /** Read at request time, so a rotated key needs no rebuild. */
24
30
  publishKey: () => string | undefined;
31
+ /** Verifies publisher passes (`p1`, see artifacts/publisher.ts): a signed, hour-long stand-in
32
+ * for the publish key, minted for one person by an authority that knows who may publish here.
33
+ * Read at request time. Without it, or without `publisherHost`, passes are refused. */
34
+ publisherSecret?: () => string | undefined;
35
+ /** The hostname a publisher pass must name, e.g. artifacts.example.com. Set it only on a host
36
+ * that opts in to passes. */
37
+ publisherHost?: string;
25
38
  /** Share image when an artifact has no cover. Absolute or site-relative. */
26
39
  defaultShareImage?: string;
27
40
  /** Where a page lives under the origin. '/' on a dedicated host (artifacts.<name>/<id>);
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { revalidatePath } from 'next/cache';
3
3
  import { notFound, redirect } from 'next/navigation';
4
4
  import { NextResponse } from 'next/server';
5
- import { isPublishAuthed } from '../artifacts/auth.js';
5
+ import { isPublisherAuthed } from '../artifacts/auth.js';
6
6
  import { parseArtifactSource } from '../artifacts/front-matter.js';
7
7
  import { narrationText } from '../artifacts/narration.js';
8
8
  import { ArtifactMarkdown } from '../artifacts/render.js';
@@ -43,6 +43,15 @@ export function createArtifactRoutes(config) {
43
43
  // page follows its light, dark or system mode (brand/theme.ts).
44
44
  const pack = config.brand;
45
45
  const brand = themedPack(pack);
46
+ /** The pack one page renders in: the host's choice, then the page's `pack:`, then the default. */
47
+ const looks = new Map();
48
+ function lookOf(a) {
49
+ const p = config.packFor?.(a) ?? (a.pack ? config.packs?.[a.pack] : undefined) ?? pack;
50
+ let l = looks.get(p);
51
+ if (!l)
52
+ looks.set(p, (l = { pack: p, brand: themedPack(p) }));
53
+ return l;
54
+ }
46
55
  const prefix = config.pagePrefix ?? '/';
47
56
  const pageUrl = (id) => `${siteUrl}${prefix}${id}`;
48
57
  const pagePath = (id) => `${prefix}${id}`;
@@ -60,8 +69,11 @@ export function createArtifactRoutes(config) {
60
69
  // Readers' recordings are played through short-lived signed URLs, issued only beside the comment
61
70
  // or note they belong to; a host whose assets cannot sign them shows no play control.
62
71
  const audioUrl = config.assets?.audioUrl ? (id, path) => config.assets.audioUrl(id, path) : undefined;
72
+ const publishAuth = { publishKey: config.publishKey, publisherSecret: config.publisherSecret, publisherHost: config.publisherHost };
73
+ const authed = (request) => isPublisherAuthed(request, publishAuth);
63
74
  const stateRoutes = createStateRoutes({
64
- store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey, pageUrl, signInOrigin, siteUrl,
75
+ store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey,
76
+ publisherSecret: config.publisherSecret, publisherHost: config.publisherHost, pageUrl, signInOrigin, siteUrl,
65
77
  clientIp: config.clientIp, ownerEmail: config.ownerEmail, audioUrl,
66
78
  });
67
79
  const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
@@ -71,7 +83,7 @@ export function createArtifactRoutes(config) {
71
83
  });
72
84
  /** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
73
85
  * whole paragraph is data-nospeak, and narrationText has no `to` to read. */
74
- function Kicker({ to }) {
86
+ function Kicker({ to, brand }) {
75
87
  return (_jsxs("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: [brand.kicker, to ? _jsx("span", { "data-nospeak": true, "data-to": true, children: ` · For ${to}` }) : null] }));
76
88
  }
77
89
  async function generateMetadata({ params }) {
@@ -80,6 +92,7 @@ export function createArtifactRoutes(config) {
80
92
  if (!a)
81
93
  return { title: 'Not found', robots: { index: false, follow: false } };
82
94
  const url = pageUrl(a.id);
95
+ const { brand } = lookOf(a);
83
96
  const image = absolute(a.cover) ?? (brand.share ? shareCardUrl(a.id, a.updatedAt) : absolute(config.defaultShareImage));
84
97
  // Who it is for leads the unfurl, so a thread shows it before the reader opens anything.
85
98
  const title = a.to ? `For ${a.to}: ${a.title}` : a.title;
@@ -129,6 +142,7 @@ export function createArtifactRoutes(config) {
129
142
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
130
143
  if (!a)
131
144
  notFound();
145
+ const { pack, brand } = lookOf(a);
132
146
  const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top }));
133
147
  if (a.access)
134
148
  return GatedPage(a, id, render, n);
@@ -155,7 +169,7 @@ export function createArtifactRoutes(config) {
155
169
  ];
156
170
  const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
157
171
  if (!open) {
158
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to }), _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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
172
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", 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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
159
173
  }
160
174
  // Who is reading an open page, when they happen to be signed in: their comments follow them.
161
175
  // Only a host that signs readers in can know one; the read never fails the page.
@@ -168,13 +182,14 @@ export function createArtifactRoutes(config) {
168
182
  /** A page with `access:`. The body is rendered only after the reader is known and allowed;
169
183
  * everyone else gets the title, the summary, and a door. */
170
184
  async function GatedPage(a, id, render, n) {
185
+ const { pack, brand } = lookOf(a);
171
186
  const here = n === null ? pageUrl(id) : `${pageUrl(id)}/v/${n}`;
172
187
  const readCookie = config.readCookie ?? defaultReadCookie;
173
188
  // Only a host that signs readers in can know one; the read never fails the page.
174
189
  const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
175
190
  const allow = config.readers && reader ? await config.readers.allowList(id) : [];
176
191
  const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
177
- const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to }), _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 })] }));
192
+ const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", 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 })] }));
178
193
  if (!d.open) {
179
194
  if (d.why === 'not-allowed' && config.readers)
180
195
  void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
@@ -203,8 +218,16 @@ export function createArtifactRoutes(config) {
203
218
  function VersionLine({ a, n, at, history }) {
204
219
  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] }));
205
220
  }
221
+ /** The title block and the body, in the pack's Article when it has one. Without one this is
222
+ * exactly the markup every page had before packs could frame it. */
223
+ function Framed({ pack, cover, toc, children }) {
224
+ if (pack.Article)
225
+ return _jsx(pack.Article, { cover: cover, toc: toc, children: children });
226
+ return (_jsxs(_Fragment, { children: [cover, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc, children] })] }));
227
+ }
206
228
  /** A past version: its own body and title, read-only (no narration, no answers), under a banner. */
207
229
  async function VersionBody(a, n, { top }) {
230
+ const { pack, brand } = lookOf(a);
208
231
  const v = store.version ? await store.version(a.id, n) : null;
209
232
  if (!v)
210
233
  notFound();
@@ -215,10 +238,11 @@ export function createArtifactRoutes(config) {
215
238
  const title = v.title ?? a.title;
216
239
  const summary = v.summary ?? a.summary;
217
240
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
218
- 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" })] }) }), _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 }), _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 })] }), _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions })] })] }));
241
+ 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 }) })] }));
219
242
  }
220
243
  /** The page itself, shared by open and gated pages. */
221
244
  async function Body(a, { top, reader }) {
245
+ const { pack, brand } = lookOf(a);
222
246
  let words = [];
223
247
  if (a.narration && a.timings) {
224
248
  try {
@@ -233,7 +257,12 @@ export function createArtifactRoutes(config) {
233
257
  const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
234
258
  // Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
235
259
  const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
236
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_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 }), _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 }), _jsx(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, 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] }), 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, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), _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 })) : (
260
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
261
+ const n = a.version ?? (a.versions?.length ?? 0) + 1;
262
+ 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] }));
263
+ 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] }));
264
+ 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 }) }));
265
+ })() }), _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 })) : (
237
266
  // No recorded narration, or its timings would not load: the browser reads the page.
238
267
  _jsx(BrowserReader, { rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground }))] }));
239
268
  }
@@ -329,7 +358,7 @@ export function createArtifactRoutes(config) {
329
358
  /** GET|POST /api/artifacts/<id>/access, publish key: the page's level and its list.
330
359
  * POST body: { access?: 'freedom'|'invite'|'public', add?: [{ email, name?, reason? }], remove?: [email] }. */
331
360
  async function ACCESS(request, { params }) {
332
- if (!isPublishAuthed(request, config.publishKey()))
361
+ if (!authed(request))
333
362
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
334
363
  if (!config.readers)
335
364
  return NextResponse.json({ error: 'this host keeps no reader record' }, { status: 501 });
@@ -364,7 +393,7 @@ export function createArtifactRoutes(config) {
364
393
  /** GET /api/artifacts/<id>/reads, publish key: who read it, for how long, how far, and every
365
394
  * attempt to take it away or to open it without being let in. */
366
395
  async function READS(request, { params }) {
367
- if (!isPublishAuthed(request, config.publishKey()))
396
+ if (!authed(request))
368
397
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
369
398
  if (!config.readers)
370
399
  return NextResponse.json({ error: 'this host keeps no reader record' }, { status: 501 });
@@ -376,7 +405,7 @@ export function createArtifactRoutes(config) {
376
405
  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' } });
377
406
  }
378
407
  async function POST(request) {
379
- if (!isPublishAuthed(request, config.publishKey()))
408
+ if (!authed(request))
380
409
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
381
410
  const text = await request.text();
382
411
  const parsed = parseArtifactSource(text);
@@ -421,7 +450,7 @@ export function createArtifactRoutes(config) {
421
450
  }, { status: result.created ? 201 : 200 });
422
451
  }
423
452
  async function GET(request, { params }) {
424
- if (!isPublishAuthed(request, config.publishKey()))
453
+ if (!authed(request))
425
454
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
426
455
  const { id } = await params;
427
456
  const a = await store.get(id);
@@ -431,7 +460,7 @@ export function createArtifactRoutes(config) {
431
460
  }
432
461
  /** PUT /api/artifacts/<id>/assets/<name>: raw bytes in, public URL out. 16 MiB cap. */
433
462
  async function PUT_ASSET(request, { params }) {
434
- if (!isPublishAuthed(request, config.publishKey()))
463
+ if (!authed(request))
435
464
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
436
465
  if (!config.assets)
437
466
  return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
@@ -475,7 +504,7 @@ export function createArtifactRoutes(config) {
475
504
  const { id, name } = await params;
476
505
  return voiceRoutes.readerUpload(request, id, name, body);
477
506
  }
478
- if (!isPublishAuthed(request, config.publishKey()))
507
+ if (!authed(request))
479
508
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
480
509
  const assets = config.assets;
481
510
  if (!assets?.signUpload || !assets.finishUpload)
@@ -505,17 +534,18 @@ export function createArtifactRoutes(config) {
505
534
  * tenant on the static default never serves a half-styled card. */
506
535
  async function SHARE_IMAGE(_request, { params }) {
507
536
  const { id } = await params;
508
- if (!brand.share)
509
- return new NextResponse('no share card for this host', { status: 404 });
510
537
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
538
+ const look = a ? lookOf(a) : { pack, brand };
539
+ if (!look.brand.share)
540
+ return new NextResponse('no share card for this host', { status: 404 });
511
541
  if (!a)
512
542
  return new NextResponse(`no artifact with id ${id}`, { status: 404 });
513
- return renderShareCard(pack, a.title, { to: a.to });
543
+ return renderShareCard(look.pack, a.title, { to: a.to });
514
544
  }
515
545
  /** GET|POST /api/artifacts/<id>/versions, publish key: the history, and a note written onto one
516
546
  * version after the fact. POST body: { version: number, note: string | null }. */
517
547
  async function VERSIONS(request, { params }) {
518
- if (!isPublishAuthed(request, config.publishKey()))
548
+ if (!authed(request))
519
549
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
520
550
  const { id } = await params;
521
551
  if (!store.history || !store.setNote)
@@ -534,7 +564,7 @@ export function createArtifactRoutes(config) {
534
564
  return NextResponse.json({ id, versions: await store.history(id) });
535
565
  }
536
566
  async function DELETE(request, { params }) {
537
- if (!isPublishAuthed(request, config.publishKey()))
567
+ if (!authed(request))
538
568
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
539
569
  const { id } = await params;
540
570
  const ok = await store.delete(id);
@@ -20,6 +20,9 @@ export type StateRoutesContext = {
20
20
  readers?: ReadersStore;
21
21
  readerSecret: () => string | undefined;
22
22
  publishKey: () => string | undefined;
23
+ /** Publisher passes, as in ArtifactRoutesConfig. Both absent means passes are refused. */
24
+ publisherSecret?: () => string | undefined;
25
+ publisherHost?: string;
23
26
  pageUrl: (id: string) => string;
24
27
  signInOrigin?: string;
25
28
  siteUrl: string;
@@ -11,7 +11,7 @@
11
11
  // signed-in reader, once, and the cookie is cleared.
12
12
  import { createHmac, randomBytes } from 'node:crypto';
13
13
  import { NextResponse } from 'next/server';
14
- import { isPublishAuthed } from '../artifacts/auth.js';
14
+ import { isPublisherAuthed } from '../artifacts/auth.js';
15
15
  import { ARTIFACT_ID_RE } from './ids.js';
16
16
  import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
17
17
  import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
@@ -290,7 +290,7 @@ export function createStateRoutes(ctx) {
290
290
  return res;
291
291
  }
292
292
  async function RESPONSES(req, { params }) {
293
- if (!isPublishAuthed(req, ctx.publishKey()))
293
+ if (!isPublisherAuthed(req, ctx))
294
294
  return json({ error: 'Unauthorized' }, 401);
295
295
  if (!ctx.state)
296
296
  return json({ error: 'this host keeps no answers' }, 501);
@@ -316,7 +316,7 @@ export function createStateRoutes(ctx) {
316
316
  * page. It reads the `comments` slot of the state core only; personal notes live in a store no
317
317
  * publisher route reads, so they cannot appear here. The publisher's own notifier polls it. */
318
318
  async function COMMENTS_FEED(req) {
319
- if (!isPublishAuthed(req, ctx.publishKey()))
319
+ if (!isPublisherAuthed(req, ctx))
320
320
  return json({ error: 'Unauthorized' }, 401);
321
321
  const since = feedSince(req.nextUrl.searchParams.get('since'));
322
322
  if (!since)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.15.0",
3
+ "version": "0.17.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",