@supersuit/artifacts 0.1.0 → 0.3.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.
@@ -1,12 +1,15 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
3
  import type { ArtifactStore } from '../artifacts/store.js';
4
+ import type { StateStore } from '../artifacts/state-store.js';
4
5
  import { type ArtifactAssets } from '../artifacts/assets.js';
5
6
  import type { BrandPack } from '../brand/pack.js';
6
7
  import { type Access } from '../artifacts/reader.js';
7
8
  import { type ReadersStore } from '../artifacts/readers-store.js';
8
9
  export type ArtifactRoutesConfig = {
9
10
  store: ArtifactStore;
11
+ /** Where readers' answers live. Without it every state route answers 501. */
12
+ state?: StateStore;
10
13
  /** Where uploaded files go. Optional; without it PUT_ASSET answers 501. */
11
14
  assets?: ArtifactAssets;
12
15
  brand: BrandPack;
@@ -31,6 +34,10 @@ export type ArtifactRoutesConfig = {
31
34
  signInOrigin?: string;
32
35
  /** Who the banner says grants access, e.g. "Example Co". Default the brand name. */
33
36
  owner?: string;
37
+ /** The address the state routes' rate limit counts by. Default reads the first hop of
38
+ * `x-forwarded-for`, which is correct on Vercel (it overwrites XFF with the real client IP)
39
+ * and wrong behind any other proxy that appends rather than replaces; pass this there. */
40
+ clientIp?: (req: NextRequest) => string;
34
41
  };
35
42
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
36
43
  export declare const ARTIFACT_ID: RegExp;
@@ -45,11 +52,29 @@ type PageProps = Params & {
45
52
  }>;
46
53
  };
47
54
  export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
55
+ dynamic: "force-dynamic";
56
+ maxDuration: number;
57
+ STATE_GET: (req: NextRequest, { params }: {
58
+ params: Promise<{
59
+ id: string;
60
+ }>;
61
+ }) => Promise<NextResponse<unknown> | undefined>;
62
+ STATE_POST: (req: NextRequest, { params }: {
63
+ params: Promise<{
64
+ id: string;
65
+ }>;
66
+ }) => Promise<NextResponse<unknown> | undefined>;
67
+ RESPONSES: (req: NextRequest, { params }: {
68
+ params: Promise<{
69
+ id: string;
70
+ }>;
71
+ }) => Promise<NextResponse<unknown>>;
48
72
  Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
49
73
  generateMetadata: ({ params }: Params) => Promise<Metadata>;
50
74
  POST: (request: NextRequest) => Promise<NextResponse<{
51
75
  error: string;
52
76
  }> | NextResponse<{
77
+ warning?: string | undefined;
53
78
  id: string;
54
79
  url: string;
55
80
  version: number;
@@ -109,7 +134,5 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
109
134
  access: Access | null;
110
135
  views: number;
111
136
  }>>;
112
- dynamic: "force-dynamic";
113
- maxDuration: number;
114
137
  };
115
138
  export {};
@@ -8,6 +8,7 @@ import { narrationText } from '../artifacts/narration.js';
8
8
  import { ArtifactMarkdown } from '../artifacts/render.js';
9
9
  import { ArtifactDoor } from '../artifacts/door.js';
10
10
  import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
11
+ import { shapeChanges } from '../artifacts/state.js';
11
12
  import { ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
12
13
  import { BrandGround } from '../brand/wrapper.js';
13
14
  import { renderShareCard } from '../brand/share-card.js';
@@ -16,8 +17,10 @@ import { ReaderWatch } from '../reader/reader-watch.js';
16
17
  import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
17
18
  import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
18
19
  import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
20
+ import { ARTIFACT_ID_RE } from './ids.js';
21
+ import { createStateRoutes } from './state-routes.js';
19
22
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
20
- export const ARTIFACT_ID = /^[abcdefghjkmnpqrstuvwxyz23456789]{8}$/;
23
+ export const ARTIFACT_ID = ARTIFACT_ID_RE;
21
24
  async function defaultReadCookie(name) {
22
25
  const { cookies } = await import('next/headers');
23
26
  return (await cookies()).get(name)?.value;
@@ -34,6 +37,10 @@ export function createArtifactRoutes(config) {
34
37
  const readerSecret = () => config.readerSecret?.();
35
38
  const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
36
39
  const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
40
+ const stateRoutes = createStateRoutes({
41
+ store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey, pageUrl, signInOrigin, siteUrl,
42
+ clientIp: config.clientIp,
43
+ });
37
44
  async function generateMetadata({ params }) {
38
45
  const { id } = await params;
39
46
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
@@ -78,9 +85,18 @@ export function createArtifactRoutes(config) {
78
85
  const open = isUnlocked({ id, password: a.password, key, cookie });
79
86
  // Opened by the key in the URL: remember it in a cookie holding the hash, never the
80
87
  // password, scoped to this page, so a refresh or a shared device does not ask again.
81
- const remember = open && a.password && key !== undefined && cookie !== keyHash(id, a.password)
82
- ? `document.cookie=${JSON.stringify(`${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${pagePath(id)}; Max-Age=31536000; SameSite=Lax; Secure`)}`
83
- : null;
88
+ // The same cookie again on the page's state API: a cookie scoped to the page path is never
89
+ // sent to /api/artifacts/<id>/state, so without it a password page could not take answers.
90
+ const unlockLine = (path) => `${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${path}; Max-Age=31536000; SameSite=Lax; Secure`;
91
+ // The API copy is set on EVERY open of a page with state:, because the page cannot see it (a
92
+ // cookie scoped to /api/... never reaches the page path): a reader who unlocked the page
93
+ // before its API cookie existed would otherwise be refused every answer until they reopened
94
+ // the ?key= link.
95
+ const paths = !open || !a.password ? [] : [
96
+ ...(key !== undefined && cookie !== keyHash(id, a.password) ? [pagePath(id)] : []),
97
+ ...(a.state ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
98
+ ];
99
+ const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
84
100
  if (!open) {
85
101
  return (_jsxs(BrandGround, { pack: brand, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }));
86
102
  }
@@ -97,7 +113,7 @@ export function createArtifactRoutes(config) {
97
113
  }
98
114
  }
99
115
  const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
100
- return (_jsxs(BrandGround, { pack: brand, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown }) })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null] }));
116
+ return (_jsxs(BrandGround, { pack: brand, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined }) })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null] }));
101
117
  }
102
118
  /** A page with `access:`. The body is rendered only after the reader is known and allowed;
103
119
  * everyone else gets the title, the summary, and a door. */
@@ -136,7 +152,7 @@ export function createArtifactRoutes(config) {
136
152
  }
137
153
  }
138
154
  const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
139
- return (_jsxs(_Fragment, { children: [_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("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown }) })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null] }));
155
+ return (_jsxs(_Fragment, { children: [_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("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined }) })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null] }));
140
156
  }
141
157
  const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
142
158
  /** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
@@ -281,11 +297,38 @@ export function createArtifactRoutes(config) {
281
297
  if (!parsed.ok)
282
298
  return NextResponse.json({ error: parsed.error }, { status: 400 });
283
299
  const id = request.nextUrl.searchParams.get('id') ?? parsed.meta.id ?? undefined;
300
+ if (id && parsed.meta.state) {
301
+ const existing = await store.get(id);
302
+ const changed = shapeChanges(existing?.state, parsed.meta.state);
303
+ if (changed.length)
304
+ return NextResponse.json({ error: changed.join('; ') }, { status: 400 });
305
+ // The declared shape can be dodged by republishing once with `state` omitted (which clears
306
+ // it) and then again with the slot's shape flipped: `existing.state` reads as undefined at
307
+ // that final publish, so the check above sees nothing to compare against. The ANSWERS
308
+ // never went anywhere, so the truth is in what is actually stored, not in the file.
309
+ if (config.state) {
310
+ const shapeOf = new Map();
311
+ for (const e of await config.state.entries(id))
312
+ if (!shapeOf.has(e.slot))
313
+ shapeOf.set(e.slot, e.shape);
314
+ const dodged = [];
315
+ for (const [name, def] of Object.entries(parsed.meta.state.slots)) {
316
+ const was = shapeOf.get(name);
317
+ if (was && was !== def.shape)
318
+ dodged.push(`slot "${name}" changed shape from ${was} to ${def.shape}; rename the slot instead`);
319
+ }
320
+ if (dodged.length)
321
+ return NextResponse.json({ error: dodged.join('; ') }, { status: 400 });
322
+ }
323
+ }
284
324
  const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body });
285
325
  if ('notFound' in result)
286
326
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
287
327
  revalidatePath(pagePath(result.id));
288
- return NextResponse.json({ id: result.id, url: pageUrl(result.id), version: result.version }, { status: result.created ? 201 : 200 });
328
+ return NextResponse.json({
329
+ id: result.id, url: pageUrl(result.id), version: result.version,
330
+ ...(parsed.meta.state && !config.state ? { warning: 'this host keeps no answers; state: is stored but inert' } : {}),
331
+ }, { status: result.created ? 201 : 200 });
289
332
  }
290
333
  async function GET(request, { params }) {
291
334
  if (!isPublishAuthed(request, config.publishKey()))
@@ -341,5 +384,5 @@ export function createArtifactRoutes(config) {
341
384
  revalidatePath(pagePath(id));
342
385
  return NextResponse.json({ deleted: true });
343
386
  }
344
- return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, dynamic: 'force-dynamic', maxDuration: 30 };
387
+ return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
345
388
  }
@@ -0,0 +1 @@
1
+ export declare const ARTIFACT_ID_RE: RegExp;
@@ -0,0 +1,3 @@
1
+ // Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store.
2
+ // No 0/o, 1/l/i: an id read aloud or retyped from a screenshot must survive it.
3
+ export const ARTIFACT_ID_RE = /^[abcdefghjkmnpqrstuvwxyz23456789]{8}$/;
@@ -0,0 +1,35 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { type StateStore } from '../artifacts/state-store.js';
3
+ import type { ArtifactStore } from '../artifacts/store.js';
4
+ import type { ReadersStore } from '../artifacts/readers-store.js';
5
+ export declare const ANON_COOKIE = "artifact_anon";
6
+ /** The id an anonymous writer's rate counter is stored under. An HMAC over the site and the IP,
7
+ * keyed by the reader secret when the host has one (so the stored value cannot be reversed by
8
+ * hashing the IPv4 space), else by the site URL. The site is in the message either way, so two
9
+ * hosts sharing a secret still count one visitor under unrelated ids. */
10
+ export declare function rateKey(ip: string, siteUrl: string, secret?: string): string;
11
+ export type StateRoutesContext = {
12
+ store: ArtifactStore;
13
+ state?: StateStore;
14
+ readers?: ReadersStore;
15
+ readerSecret: () => string | undefined;
16
+ publishKey: () => string | undefined;
17
+ pageUrl: (id: string) => string;
18
+ signInOrigin?: string;
19
+ siteUrl: string;
20
+ /** The address the rate limit counts by. Default reads the first hop of `x-forwarded-for`,
21
+ * which is correct on Vercel (it overwrites XFF with the real client IP) and wrong behind any
22
+ * other proxy that appends rather than replaces; a host behind one of those passes its own. */
23
+ clientIp?: (req: NextRequest) => string;
24
+ };
25
+ type Params = {
26
+ params: Promise<{
27
+ id: string;
28
+ }>;
29
+ };
30
+ export declare function createStateRoutes(ctx: StateRoutesContext): {
31
+ STATE_GET: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
32
+ STATE_POST: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
33
+ RESPONSES: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
34
+ };
35
+ export {};
@@ -0,0 +1,216 @@
1
+ // The state routes: what readers put into a page, and what the publisher gets back.
2
+ //
3
+ // GET /api/artifacts/<id>/state the reader's own answers + what the page lets them see
4
+ // POST /api/artifacts/<id>/state { slot, op: set|append|remove, value?, entry? }
5
+ // GET /api/artifacts/<id>/responses publish key; every answer with who (?format=csv)
6
+ // DELETE /api/artifacts/<id>/responses?reader=<key> publish key; one reader's answers
7
+ //
8
+ // Who is writing: the grant a gated page already uses, or on a page with `writers: anyone`, an
9
+ // anonymous id in an HttpOnly cookie. When both are present the anonymous answers move to the
10
+ // signed-in reader, once, and the cookie is cleared.
11
+ import { createHmac, randomBytes } from 'node:crypto';
12
+ import { NextResponse } from 'next/server';
13
+ import { isPublishAuthed } from '../artifacts/auth.js';
14
+ import { ARTIFACT_ID_RE } from './ids.js';
15
+ import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
16
+ import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
17
+ import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters } from '../artifacts/state.js';
18
+ import { readerKeyFor } from '../artifacts/state-store.js';
19
+ import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
20
+ import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
21
+ export const ANON_COOKIE = 'artifact_anon';
22
+ const ANON_ID = /^[A-Za-z0-9]{24}$/;
23
+ const MAX_BODY = 16 * 1024;
24
+ /** The id an anonymous writer's rate counter is stored under. An HMAC over the site and the IP,
25
+ * keyed by the reader secret when the host has one (so the stored value cannot be reversed by
26
+ * hashing the IPv4 space), else by the site URL. The site is in the message either way, so two
27
+ * hosts sharing a secret still count one visitor under unrelated ids. */
28
+ export function rateKey(ip, siteUrl, secret) {
29
+ return createHmac('sha256', secret || siteUrl).update(`${siteUrl}|${ip}`).digest('hex').slice(0, 16);
30
+ }
31
+ export function createStateRoutes(ctx) {
32
+ const anonCookie = (v, maxAge) => `${ANON_COOKIE}=${v}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
33
+ const json = (body, status = 200) => NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } });
34
+ const newAnonId = () => { let s = ''; while (s.length < 24)
35
+ s += randomBytes(24).toString('base64').replace(/[^A-Za-z0-9]/g, ''); return s.slice(0, 24); };
36
+ const defaultClientIp = (req) => (req.headers.get('x-forwarded-for') ?? '').split(',')[0].trim();
37
+ const ipHash = (req) => rateKey((ctx.clientIp ?? defaultClientIp)(req), ctx.siteUrl, ctx.readerSecret());
38
+ const siteOrigin = new URL(ctx.siteUrl).origin;
39
+ /** Resolve the page and who is asking, or the refusal. Shared by GET and POST. */
40
+ async function open(req, id) {
41
+ if (!ctx.state)
42
+ return { error: json({ error: 'this host keeps no answers' }, 501) };
43
+ const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
44
+ if (!a)
45
+ return { error: json({ error: `no artifact with id ${id}` }, 404) };
46
+ if (!a.state)
47
+ return { error: json({ error: 'this page takes no answers' }, 404) };
48
+ if (a.password && !isUnlocked({ id, password: a.password, cookie: req.cookies.get(unlockCookieName(id))?.value }))
49
+ return { error: json({ error: 'this page is locked' }, 403) };
50
+ const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
51
+ const signIn = ctx.signInOrigin ? signInUrl(ctx.signInOrigin, ctx.pageUrl(id)) : null;
52
+ if (a.access) {
53
+ if (!ctx.readers)
54
+ return { error: json({ error: 'this page is closed' }, 403) };
55
+ if (!reader)
56
+ return { error: json({ error: 'sign in to answer', signIn }, 401) };
57
+ if (!decide(a.access, reader, await ctx.readers.allowList(id)).open)
58
+ return { error: json({ error: 'this page is not open to you' }, 403) };
59
+ // The page shows its body only after the agreement, so its answers wait for it too.
60
+ if (!(await ctx.readers.acknowledged(id, reader.email)))
61
+ return { error: json({ error: 'accept the agreement first' }, 403) };
62
+ }
63
+ const rawAnon = req.cookies.get(ANON_COOKIE)?.value;
64
+ const anonId = rawAnon && ANON_ID.test(rawAnon) ? rawAnon : null;
65
+ return { a, reader, anonId, signIn };
66
+ }
67
+ const writerFor = (r) => ({ key: readerKeyFor.signedIn(r.uid), uid: r.uid, email: r.email, name: r.name, anonymous: false });
68
+ async function viewBody(a, reader, readerKey) {
69
+ const allow = reader && ctx.readers && a.access ? await ctx.readers.allowList(a.id) : [];
70
+ return {
71
+ reader: reader ? { firstName: firstName(reader, allow) } : null,
72
+ canWrite: !!reader || effectiveWriters(a.state, a.access) === 'anyone',
73
+ slots: stateView(a.state, await ctx.state.entries(a.id), readerKey),
74
+ };
75
+ }
76
+ // A GET that writes, on purpose: when a signed-in reader still carries an anonymous cookie, the
77
+ // answers move here, because this is the first request that sees both. It is safe as a GET:
78
+ // moveReader is idempotent (a second run finds nothing under the cleared key), and the grant
79
+ // cookie is SameSite=Lax, so a cross-site page can trigger it only by top-level navigation,
80
+ // which moves the reader's own answers onto the reader's own account and nothing else.
81
+ async function STATE_GET(req, { params }) {
82
+ const { id } = await params;
83
+ const o = await open(req, id);
84
+ if ('error' in o)
85
+ return o.error;
86
+ const { a, reader, anonId, signIn } = o;
87
+ let clearAnon = false;
88
+ if (reader && anonId) {
89
+ await ctx.state.moveReader(readerKeyFor.anonymous(anonId), writerFor(reader));
90
+ clearAnon = true;
91
+ }
92
+ const key = reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
93
+ const res = json({ ...(await viewBody(a, reader, key)), ...(reader ? {} : { signIn }) });
94
+ if (clearAnon)
95
+ res.headers.append('set-cookie', anonCookie('', 0));
96
+ return res;
97
+ }
98
+ async function STATE_POST(req, { params }) {
99
+ const { id } = await params;
100
+ // A cross-site form or fetch carries the reader's cookies (anonymous or Lax grant on a
101
+ // top-level POST); a browser always names its Origin on a POST, so a foreign one is refused.
102
+ const origin = req.headers.get('origin');
103
+ if (origin !== null && origin !== siteOrigin)
104
+ return json({ error: 'wrong origin' }, 403);
105
+ // Refuse on the declared length before reading a byte: a client naming an oversize body
106
+ // does not get the server to buffer it first.
107
+ const declaredLength = Number(req.headers.get('content-length') ?? '');
108
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY)
109
+ return json({ error: 'request over 16 KB' }, 413);
110
+ const raw = await req.text();
111
+ // Measured in bytes, not JS string length: a string of multi-byte characters can sit under
112
+ // the code-unit count and over the byte cap the limit is actually about.
113
+ if (Buffer.byteLength(raw, 'utf8') > MAX_BODY)
114
+ return json({ error: 'request over 16 KB' }, 413);
115
+ let b;
116
+ try {
117
+ const parsed = JSON.parse(raw);
118
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
119
+ return json({ error: 'body must be JSON' }, 400);
120
+ b = parsed;
121
+ }
122
+ catch {
123
+ return json({ error: 'body must be JSON' }, 400);
124
+ }
125
+ const o = await open(req, id);
126
+ if ('error' in o)
127
+ return o.error;
128
+ const { a, reader, signIn } = o;
129
+ let { anonId } = o;
130
+ const slot = typeof b.slot === 'string' ? b.slot : '';
131
+ // Object.hasOwn, never a bracket read: `slots['__proto__']` or `slots['constructor']`
132
+ // resolves through the prototype chain to a real (truthy) object that names no slot.
133
+ const def = Object.hasOwn(a.state.slots, slot) ? a.state.slots[slot] : undefined;
134
+ if (!def)
135
+ return json({ error: `no slot named ${slot} on this page` }, 400);
136
+ const op = b.op;
137
+ if (op !== 'set' && op !== 'append' && op !== 'remove')
138
+ return json({ error: 'op must be set, append or remove' }, 400);
139
+ if (op === 'set' && def.shape !== 'one')
140
+ return json({ error: `slot ${slot} takes append` }, 400);
141
+ if (op === 'append' && def.shape !== 'many')
142
+ return json({ error: `slot ${slot} takes set` }, 400);
143
+ let setCookie = null;
144
+ let writer;
145
+ if (reader)
146
+ writer = writerFor(reader);
147
+ else {
148
+ if (effectiveWriters(a.state, a.access) !== 'anyone')
149
+ return json({ error: 'sign in to answer', signIn }, 401);
150
+ // A remove adds nothing, so it is never counted and never mints a cookie: with no cookie
151
+ // there is nothing of theirs to remove, and the answer is simply the page as it stands.
152
+ if (op === 'remove') {
153
+ if (!anonId)
154
+ return json(await viewBody(a, null, null));
155
+ const key = readerKeyFor.anonymous(anonId);
156
+ await ctx.state.remove({ artifactId: id, slot, readerKey: key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
157
+ return json(await viewBody(a, null, key));
158
+ }
159
+ const n = await ctx.state.countAnonWrite(id, ipHash(req), Math.floor(Date.now() / 60000));
160
+ if (n > ANON_WRITES_PER_MINUTE)
161
+ return json({ error: 'too many answers from here; try again in a minute' }, 429);
162
+ if (!anonId) {
163
+ anonId = newAnonId();
164
+ setCookie = anonCookie(anonId, 31536000);
165
+ }
166
+ writer = { key: readerKeyFor.anonymous(anonId), name: null, anonymous: true };
167
+ }
168
+ if (op === 'remove') {
169
+ await ctx.state.remove({ artifactId: id, slot, readerKey: writer.key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
170
+ }
171
+ else {
172
+ const bad = checkValue(b.value) ?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null);
173
+ if (bad)
174
+ return json({ error: bad }, 400);
175
+ // The page-wide cap per slot. Counted before the write and not atomically with it, so a
176
+ // burst can overshoot by the writes in flight; it bounds the slot, it is not a quota.
177
+ if ((await ctx.state.countSlot(id, slot)) >= MAX_ENTRIES_PER_SLOT) {
178
+ const replacing = op === 'set' && (ctx.state.hasOne
179
+ ? await ctx.state.hasOne(id, slot, writer.key)
180
+ : (await ctx.state.entries(id)).some((e) => e.slot === slot && e.readerKey === writer.key));
181
+ if (!replacing)
182
+ return json({ error: 'this page is not taking more answers here' }, 409);
183
+ }
184
+ const r = op === 'set'
185
+ ? await ctx.state.set({ artifactId: id, slot, writer, value: b.value })
186
+ : await ctx.state.append({ artifactId: id, slot, writer, value: b.value });
187
+ if ('full' in r)
188
+ return json({ error: 'you have left the most answers this page takes here' }, 409);
189
+ }
190
+ const res = json(await viewBody(a, reader, writer.key));
191
+ if (setCookie)
192
+ res.headers.append('set-cookie', setCookie);
193
+ return res;
194
+ }
195
+ async function RESPONSES(req, { params }) {
196
+ if (!isPublishAuthed(req, ctx.publishKey()))
197
+ return json({ error: 'Unauthorized' }, 401);
198
+ if (!ctx.state)
199
+ return json({ error: 'this host keeps no answers' }, 501);
200
+ const { id } = await params;
201
+ const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
202
+ if (!a)
203
+ return json({ error: `no artifact with id ${id}` }, 404);
204
+ if (req.method === 'DELETE') {
205
+ const key = req.nextUrl.searchParams.get('reader') ?? '';
206
+ if (!/^[ua]:[A-Za-z0-9_-]{1,128}$/.test(key))
207
+ return json({ error: 'reader must be a reader key like u:<uid> or a:<id>' }, 400);
208
+ return json({ removed: await ctx.state.removeReader(id, key) });
209
+ }
210
+ const rows = responsesOf(await ctx.state.entries(id));
211
+ if (req.nextUrl.searchParams.get('format') === 'csv')
212
+ return new NextResponse(responsesCsv(rows), { headers: { 'content-type': 'text/csv; charset=utf-8', 'cache-control': 'no-store' } });
213
+ return json({ id, title: a.title, responses: rows });
214
+ }
215
+ return { STATE_GET, STATE_POST, RESPONSES };
216
+ }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare function NotesProvider({ artifactId, headings, accent, children }: {
3
+ artifactId: string;
4
+ headings: {
5
+ slug: string;
6
+ text: string;
7
+ }[];
8
+ accent?: string;
9
+ children: ReactNode;
10
+ }): import("react").JSX.Element;
11
+ /** The small control inside a heading. */
12
+ export declare function NoteToggle({ slug }: {
13
+ slug: string;
14
+ }): import("react").JSX.Element | null;
15
+ /** After each heading: its notes, and the box when its control is open. */
16
+ export declare function HeadingNotes({ slug, text }: {
17
+ slug: string;
18
+ text: string;
19
+ }): import("react").JSX.Element | null;
20
+ /** Where the ```notes fence sits: a line saying notes are on, and the notes whose heading is gone. */
21
+ export declare function NotesEarlier(): import("react").JSX.Element | null;
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The notes widget in the reader's browser. One provider per page reads the page's state once
4
+ // (GET /api/artifacts/<id>/state), places every note under the heading it was left under, and
5
+ // sets aside the ones whose heading is gone. A small "note" control sits in each heading; the
6
+ // panel after the heading lists its notes and, when open, takes a new one.
7
+ //
8
+ // Everything drawn here carries data-nospeak: narration reads the prose, never the notes, and
9
+ // the read-along highlighter skips the same nodes. Notes are shown as text, never as markup.
10
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
11
+ import { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from '../artifacts/notes-place.js';
12
+ const NotesContext = createContext(null);
13
+ const isNote = (v) => !!v && typeof v === 'object' && typeof v.slug === 'string' &&
14
+ typeof v.heading === 'string' && typeof v.note === 'string';
15
+ function notesFrom(body) {
16
+ const slot = body?.slots?.[NOTES_SLOT];
17
+ if (!slot)
18
+ return [];
19
+ const rows = Array.isArray(slot.shared) ? slot.shared : Array.isArray(slot.mine) ? slot.mine.map((e) => ({ ...e, mine: true })) : [];
20
+ const out = [];
21
+ for (const r of rows) {
22
+ if (!isNote(r.value))
23
+ continue;
24
+ out.push({
25
+ id: String(r.id ?? ''), slug: r.value.slug, heading: r.value.heading, note: r.value.note,
26
+ name: r.mine ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', at: String(r.at ?? ''), mine: r.mine === true,
27
+ });
28
+ }
29
+ return out;
30
+ }
31
+ export function NotesProvider({ artifactId, headings, accent, children }) {
32
+ const endpoint = `/api/artifacts/${artifactId}/state`;
33
+ const [notes, setNotes] = useState([]);
34
+ const [off, setOff] = useState(false);
35
+ const [canWrite, setCanWrite] = useState(false);
36
+ const [signIn, setSignIn] = useState(null);
37
+ const [open, setOpen] = useState(null);
38
+ const [error, setError] = useState(null);
39
+ const [busy, setBusy] = useState(false);
40
+ const apply = useCallback((status, body) => {
41
+ if (status === 200) {
42
+ setNotes(notesFrom(body));
43
+ setCanWrite(body.canWrite === true);
44
+ setSignIn(typeof body.signIn === 'string' ? body.signIn : null);
45
+ setError(null);
46
+ return true;
47
+ }
48
+ if (status === 401) {
49
+ setCanWrite(false);
50
+ setSignIn(typeof body.signIn === 'string' ? body.signIn : null);
51
+ return false;
52
+ }
53
+ // No state store, no such page, or no slot: the page takes no notes here, so draw nothing.
54
+ if (status === 404 || status === 501) {
55
+ setOff(true);
56
+ return false;
57
+ }
58
+ setError(typeof body.error === 'string' ? body.error : 'that did not save; try again');
59
+ return false;
60
+ }, []);
61
+ const call = useCallback(async (init) => {
62
+ try {
63
+ const r = await fetch(endpoint, { credentials: 'same-origin', cache: 'no-store', ...init });
64
+ const body = (await r.json().catch(() => ({})));
65
+ return apply(r.status, body);
66
+ }
67
+ catch {
68
+ setError('that did not reach the page; try again');
69
+ return false;
70
+ }
71
+ }, [endpoint, apply]);
72
+ useEffect(() => { void call(); }, [call]);
73
+ const post = useCallback(async (slug, heading, note) => {
74
+ setBusy(true);
75
+ const done = await call({
76
+ method: 'POST', headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ slot: NOTES_SLOT, op: 'append', value: { slug, heading, note } }),
78
+ });
79
+ setBusy(false);
80
+ if (done)
81
+ setOpen(null);
82
+ return done;
83
+ }, [call]);
84
+ const remove = useCallback(async (id) => {
85
+ await call({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: NOTES_SLOT, op: 'remove', entry: id }) });
86
+ }, [call]);
87
+ const placed = useMemo(() => placeNotes(headings, notes), [headings, notes]);
88
+ const value = { off, accent: accent ?? 'currentColor', ...placed, canWrite, signIn, open, setOpen, error, busy, post, remove };
89
+ return _jsx(NotesContext.Provider, { value: value, children: children });
90
+ }
91
+ /** The small control inside a heading. */
92
+ export function NoteToggle({ slug }) {
93
+ const c = useContext(NotesContext);
94
+ if (!c || c.off)
95
+ return null;
96
+ const n = c.bySlug[slug]?.length ?? 0;
97
+ return (_jsx("button", { type: "button", "data-nospeak": true, "data-note-toggle": slug, "aria-expanded": c.open === slug, onClick: () => c.setOpen(c.open === slug ? null : slug), className: "ml-3 inline-block rounded-full border px-2 py-0.5 align-middle font-sans text-[11px] font-medium uppercase tracking-[0.15em] opacity-70 transition-opacity hover:opacity-100", style: { borderColor: c.accent, color: c.accent }, children: n ? `note · ${n}` : 'note' }));
98
+ }
99
+ function NoteItem({ n, c, under }) {
100
+ return (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: c.accent }, children: [_jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-zinc-100", children: n.note }), _jsxs("span", { className: "block text-xs opacity-60", children: [n.name, under ? _jsxs(_Fragment, { children: [" \u00B7 under \u201C", n.heading, "\u201D"] }) : null, n.mine && n.id ? (_jsx("button", { type: "button", "data-note-remove": n.id, onClick: () => void c.remove(n.id), className: "ml-2 underline opacity-80 hover:opacity-100", children: "remove" })) : null] })] }));
101
+ }
102
+ function NoteForm({ slug, heading, c }) {
103
+ const [text, setText] = useState('');
104
+ const submit = async (e) => {
105
+ e.preventDefault();
106
+ const note = text.trim();
107
+ if (!note)
108
+ return;
109
+ if (await c.post(slug, heading, note))
110
+ setText('');
111
+ };
112
+ if (!c.canWrite) {
113
+ return c.signIn ? (_jsx("p", { className: "my-2 text-sm", children: _jsx("a", { href: c.signIn, className: "underline", style: { color: c.accent }, children: "Sign in to leave a note" }) })) : (_jsx("p", { className: "my-2 text-sm opacity-70", children: "Notes here are open to signed-in readers." }));
114
+ }
115
+ return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-white/15 bg-white/[0.04] p-3 text-[15px] text-zinc-100" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: '#111' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
116
+ }
117
+ /** After each heading: its notes, and the box when its control is open. */
118
+ export function HeadingNotes({ slug, text }) {
119
+ const c = useContext(NotesContext);
120
+ if (!c || c.off)
121
+ return null;
122
+ const list = c.bySlug[slug] ?? [];
123
+ const isOpen = c.open === slug;
124
+ if (!list.length && !isOpen)
125
+ return null;
126
+ return (_jsxs("div", { "data-nospeak": true, "data-heading-notes": slug, className: "my-3", children: [list.length ? _jsx("ul", { className: "m-0 grid list-none gap-2 p-0", children: list.map((n) => _jsx(NoteItem, { n: n, c: c }, n.id)) }) : null, isOpen ? _jsx(NoteForm, { slug: slug, heading: text, c: c }) : null] }));
127
+ }
128
+ /** Where the ```notes fence sits: a line saying notes are on, and the notes whose heading is gone. */
129
+ export function NotesEarlier() {
130
+ const c = useContext(NotesContext);
131
+ if (!c || c.off)
132
+ return null;
133
+ return (_jsxs("section", { "data-nospeak": true, "data-artifact-notes": true, className: "my-8 text-sm", children: [_jsx("p", { className: "opacity-60", children: "Leave a note beside any heading." }), c.earlier.length ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "mt-4 text-[11px] uppercase tracking-[0.2em]", style: { color: c.accent }, children: "Notes on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 p-0", children: c.earlier.map((n) => _jsx(NoteItem, { n: n, c: c, under: true }, n.id)) })] })) : null] }));
134
+ }