@supersuit/artifacts 0.16.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,28 @@
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
+
6
28
  ## 0.16.0 (2026-09-29)
7
29
 
8
30
  **A brand pack can draw a page's structure, and one page can wear a pack of its own.** Four new
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`.
@@ -232,6 +233,29 @@ reimplement it. Keep passes short-lived (five minutes is what the tests assume).
232
233
  Without `signInOrigin`, a confidential page shows its door with no way through. It fails
233
234
  closed, never open.
234
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
+
235
259
  ## Reader answers (`state:`)
236
260
 
237
261
  A page can take answers from the people reading it: a vote, a reaction, a short response.
@@ -576,6 +600,7 @@ the page's pack.
576
600
  |---|---|
577
601
  | your publish key (named by you, read in `publishKey`) | Authorises publish, delete and access changes |
578
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 |
579
604
  | Firebase Admin credentials | The store and readers store |
580
605
 
581
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
+ }
@@ -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;
@@ -28,6 +28,13 @@ export type ArtifactRoutesConfig = {
28
28
  siteUrl: string;
29
29
  /** Read at request time, so a rotated key needs no rebuild. */
30
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;
31
38
  /** Share image when an artifact has no cover. Absolute or site-relative. */
32
39
  defaultShareImage?: string;
33
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';
@@ -69,8 +69,11 @@ export function createArtifactRoutes(config) {
69
69
  // Readers' recordings are played through short-lived signed URLs, issued only beside the comment
70
70
  // or note they belong to; a host whose assets cannot sign them shows no play control.
71
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);
72
74
  const stateRoutes = createStateRoutes({
73
- 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,
74
77
  clientIp: config.clientIp, ownerEmail: config.ownerEmail, audioUrl,
75
78
  });
76
79
  const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
@@ -355,7 +358,7 @@ export function createArtifactRoutes(config) {
355
358
  /** GET|POST /api/artifacts/<id>/access, publish key: the page's level and its list.
356
359
  * POST body: { access?: 'freedom'|'invite'|'public', add?: [{ email, name?, reason? }], remove?: [email] }. */
357
360
  async function ACCESS(request, { params }) {
358
- if (!isPublishAuthed(request, config.publishKey()))
361
+ if (!authed(request))
359
362
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
360
363
  if (!config.readers)
361
364
  return NextResponse.json({ error: 'this host keeps no reader record' }, { status: 501 });
@@ -390,7 +393,7 @@ export function createArtifactRoutes(config) {
390
393
  /** GET /api/artifacts/<id>/reads, publish key: who read it, for how long, how far, and every
391
394
  * attempt to take it away or to open it without being let in. */
392
395
  async function READS(request, { params }) {
393
- if (!isPublishAuthed(request, config.publishKey()))
396
+ if (!authed(request))
394
397
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
395
398
  if (!config.readers)
396
399
  return NextResponse.json({ error: 'this host keeps no reader record' }, { status: 501 });
@@ -402,7 +405,7 @@ export function createArtifactRoutes(config) {
402
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' } });
403
406
  }
404
407
  async function POST(request) {
405
- if (!isPublishAuthed(request, config.publishKey()))
408
+ if (!authed(request))
406
409
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
407
410
  const text = await request.text();
408
411
  const parsed = parseArtifactSource(text);
@@ -447,7 +450,7 @@ export function createArtifactRoutes(config) {
447
450
  }, { status: result.created ? 201 : 200 });
448
451
  }
449
452
  async function GET(request, { params }) {
450
- if (!isPublishAuthed(request, config.publishKey()))
453
+ if (!authed(request))
451
454
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
452
455
  const { id } = await params;
453
456
  const a = await store.get(id);
@@ -457,7 +460,7 @@ export function createArtifactRoutes(config) {
457
460
  }
458
461
  /** PUT /api/artifacts/<id>/assets/<name>: raw bytes in, public URL out. 16 MiB cap. */
459
462
  async function PUT_ASSET(request, { params }) {
460
- if (!isPublishAuthed(request, config.publishKey()))
463
+ if (!authed(request))
461
464
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
462
465
  if (!config.assets)
463
466
  return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
@@ -501,7 +504,7 @@ export function createArtifactRoutes(config) {
501
504
  const { id, name } = await params;
502
505
  return voiceRoutes.readerUpload(request, id, name, body);
503
506
  }
504
- if (!isPublishAuthed(request, config.publishKey()))
507
+ if (!authed(request))
505
508
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
506
509
  const assets = config.assets;
507
510
  if (!assets?.signUpload || !assets.finishUpload)
@@ -542,7 +545,7 @@ export function createArtifactRoutes(config) {
542
545
  /** GET|POST /api/artifacts/<id>/versions, publish key: the history, and a note written onto one
543
546
  * version after the fact. POST body: { version: number, note: string | null }. */
544
547
  async function VERSIONS(request, { params }) {
545
- if (!isPublishAuthed(request, config.publishKey()))
548
+ if (!authed(request))
546
549
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
547
550
  const { id } = await params;
548
551
  if (!store.history || !store.setNote)
@@ -561,7 +564,7 @@ export function createArtifactRoutes(config) {
561
564
  return NextResponse.json({ id, versions: await store.history(id) });
562
565
  }
563
566
  async function DELETE(request, { params }) {
564
- if (!isPublishAuthed(request, config.publishKey()))
567
+ if (!authed(request))
565
568
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
566
569
  const { id } = await params;
567
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.16.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",