@supersuit/artifacts 0.10.0 → 0.11.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +62 -0
  3. package/lib/artifacts/anchor.d.ts +33 -0
  4. package/lib/artifacts/anchor.js +93 -0
  5. package/lib/artifacts/comments.d.ts +38 -0
  6. package/lib/artifacts/comments.js +57 -0
  7. package/lib/artifacts/front-matter.d.ts +7 -0
  8. package/lib/artifacts/front-matter.js +19 -3
  9. package/lib/artifacts/index.d.ts +4 -1
  10. package/lib/artifacts/index.js +4 -1
  11. package/lib/artifacts/personal-store.d.ts +29 -0
  12. package/lib/artifacts/personal-store.js +100 -0
  13. package/lib/artifacts/render.js +60 -52
  14. package/lib/artifacts/state-store.d.ts +10 -0
  15. package/lib/artifacts/state-store.js +19 -0
  16. package/lib/artifacts/state.d.ts +7 -2
  17. package/lib/artifacts/state.js +7 -3
  18. package/lib/artifacts/store.d.ts +4 -0
  19. package/lib/artifacts/store.js +6 -0
  20. package/lib/artifacts/widgets.d.ts +4 -0
  21. package/lib/artifacts/widgets.js +70 -0
  22. package/lib/reader/comment-card.d.ts +18 -0
  23. package/lib/reader/comment-card.js +36 -0
  24. package/lib/reader/comment-layer.d.ts +17 -0
  25. package/lib/reader/comment-layer.js +384 -0
  26. package/lib/reader/comment-place.d.ts +45 -0
  27. package/lib/reader/comment-place.js +117 -0
  28. package/lib/reader/device-notes.d.ts +6 -0
  29. package/lib/reader/device-notes.js +35 -0
  30. package/lib/reader/index.d.ts +4 -0
  31. package/lib/reader/index.js +4 -0
  32. package/lib/routes/artifacts.d.ts +23 -0
  33. package/lib/routes/artifacts.js +23 -11
  34. package/lib/routes/personal-routes.d.ts +24 -0
  35. package/lib/routes/personal-routes.js +100 -0
  36. package/lib/routes/state-routes.d.ts +3 -0
  37. package/lib/routes/state-routes.js +71 -12
  38. package/package.json +1 -1
@@ -6,10 +6,14 @@ import { type ArtifactAssets } from '../artifacts/assets.js';
6
6
  import type { BrandPack } from '../brand/pack.js';
7
7
  import { type Access } from '../artifacts/reader.js';
8
8
  import { type ReadersStore } from '../artifacts/readers-store.js';
9
+ import type { PersonalStore } from '../artifacts/personal-store.js';
9
10
  export type ArtifactRoutesConfig = {
10
11
  store: ArtifactStore;
11
12
  /** Where readers' answers live. Without it every state route answers 501. */
12
13
  state?: StateStore;
14
+ /** Where signed-in readers' personal notes live. Read ONLY by the reader's own /personal
15
+ * handlers, never by any publisher route. Without it every reader's notes stay on their device. */
16
+ personal?: PersonalStore;
13
17
  /** Where uploaded files go. Optional; without it PUT_ASSET answers 501. */
14
18
  assets?: ArtifactAssets;
15
19
  brand: BrandPack;
@@ -41,6 +45,10 @@ export type ArtifactRoutesConfig = {
41
45
  * `x-forwarded-for`, which is correct on Vercel (it overwrites XFF with the real client IP)
42
46
  * and wrong behind any other proxy that appends rather than replaces; pass this there. */
43
47
  clientIp?: (req: NextRequest) => string;
48
+ /** The page owner's sign-in email. Signed in as them, a page shows every shared comment with
49
+ * its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
50
+ * the publisher reads comments through /responses. */
51
+ ownerEmail?: string;
44
52
  };
45
53
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
46
54
  export declare const ARTIFACT_ID: RegExp;
@@ -68,6 +76,21 @@ type VersionProps = VersionParams & {
68
76
  export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
69
77
  dynamic: "force-dynamic";
70
78
  maxDuration: number;
79
+ PERSONAL_GET: (req: NextRequest, { params }: {
80
+ params: Promise<{
81
+ id: string;
82
+ }>;
83
+ }) => Promise<NextResponse<unknown> | undefined>;
84
+ PERSONAL_POST: (req: NextRequest, { params }: {
85
+ params: Promise<{
86
+ id: string;
87
+ }>;
88
+ }) => Promise<NextResponse<unknown> | undefined>;
89
+ PERSONAL_DELETE: (req: NextRequest, { params }: {
90
+ params: Promise<{
91
+ id: string;
92
+ }>;
93
+ }) => Promise<NextResponse<unknown> | undefined>;
71
94
  STATE_GET: (req: NextRequest, { params }: {
72
95
  params: Promise<{
73
96
  id: string;
@@ -9,7 +9,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
11
  import { cleanNote } from '../artifacts/store.js';
12
- import { shapeChanges } from '../artifacts/state.js';
12
+ import { effectiveWriters, shapeChanges } from '../artifacts/state.js';
13
+ import { COMMENTS_SLOT } from '../artifacts/comments.js';
13
14
  import { ASSET_DIGEST, ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
14
15
  import { BrandGround } from '../brand/wrapper.js';
15
16
  import { themedPack } from '../brand/theme.js';
@@ -17,6 +18,7 @@ import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
17
18
  import { renderShareCard } from '../brand/share-card.js';
18
19
  import { ArtifactReader } from '../reader/artifact-reader.js';
19
20
  import { BrowserReader } from '../reader/browser-reader.js';
21
+ import { CommentLayer } from '../reader/comment-layer.js';
20
22
  import { ReaderWatch } from '../reader/reader-watch.js';
21
23
  import { UpdatedTime } from '../reader/updated-time.js';
22
24
  import { VersionHistory } from '../reader/version-history.js';
@@ -25,6 +27,7 @@ import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
25
27
  import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
26
28
  import { ARTIFACT_ID_RE } from './ids.js';
27
29
  import { createStateRoutes } from './state-routes.js';
30
+ import { createPersonalRoutes } from './personal-routes.js';
28
31
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
29
32
  export const ARTIFACT_ID = ARTIFACT_ID_RE;
30
33
  /** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
@@ -46,13 +49,15 @@ export function createArtifactRoutes(config) {
46
49
  // The version in the URL is what makes a re-publish show up: every unfurler caches by URL.
47
50
  const signInOrigin = config.signInOrigin;
48
51
  const owner = config.owner ?? brand.name;
52
+ const ownerEmail = config.ownerEmail?.trim().toLowerCase();
49
53
  const readerSecret = () => config.readerSecret?.();
50
54
  const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
51
55
  const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
52
56
  const stateRoutes = createStateRoutes({
53
57
  store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey, pageUrl, signInOrigin, siteUrl,
54
- clientIp: config.clientIp,
58
+ clientIp: config.clientIp, ownerEmail: config.ownerEmail,
55
59
  });
60
+ const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl });
56
61
  async function generateMetadata({ params }) {
57
62
  const { id } = await params;
58
63
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
@@ -106,7 +111,7 @@ export function createArtifactRoutes(config) {
106
111
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
107
112
  if (!a)
108
113
  notFound();
109
- const render = (top) => (n === null ? Body(a, { top }) : VersionBody(a, n, { top }));
114
+ const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top }));
110
115
  if (a.access)
111
116
  return GatedPage(a, id, render, n);
112
117
  // A password shuts the body, never the title: the header stays so the reader knows which
@@ -121,19 +126,23 @@ export function createArtifactRoutes(config) {
121
126
  // The same cookie again on the page's state API: a cookie scoped to the page path is never
122
127
  // sent to /api/artifacts/<id>/state, so without it a password page could not take answers.
123
128
  const unlockLine = (path) => `${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${path}; Max-Age=31536000; SameSite=Lax; Secure`;
124
- // The API copy is set on EVERY open of a page with state:, because the page cannot see it (a
129
+ // The API copy is set on EVERY open of a page with state: (or on a host keeping personal notes,
130
+ // which every page takes), because the page cannot see it (a
125
131
  // cookie scoped to /api/... never reaches the page path): a reader who unlocked the page
126
132
  // before its API cookie existed would otherwise be refused every answer until they reopened
127
133
  // the ?key= link.
128
134
  const paths = !open || !a.password ? [] : [
129
135
  ...(key !== undefined && cookie !== keyHash(id, a.password) ? [pagePath(id)] : []),
130
- ...(a.state ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
136
+ ...(a.state || config.personal ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
131
137
  ];
132
138
  const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
133
139
  if (!open) {
134
140
  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("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 })] }));
135
141
  }
136
- const page = await render(true);
142
+ // Who is reading an open page, when they happen to be signed in: their comments follow them.
143
+ // Only a host that signs readers in can know one; the read never fails the page.
144
+ const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
145
+ const page = await render(true, reader);
137
146
  if (n === null)
138
147
  void store.bumpViews(id);
139
148
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, page] }));
@@ -143,7 +152,8 @@ export function createArtifactRoutes(config) {
143
152
  async function GatedPage(a, id, render, n) {
144
153
  const here = n === null ? pageUrl(id) : `${pageUrl(id)}/v/${n}`;
145
154
  const readCookie = config.readCookie ?? defaultReadCookie;
146
- const reader = verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE));
155
+ // Only a host that signs readers in can know one; the read never fails the page.
156
+ const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
147
157
  const allow = config.readers && reader ? await config.readers.allowList(id) : [];
148
158
  const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
149
159
  const header = (_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 })] }));
@@ -159,7 +169,7 @@ export function createArtifactRoutes(config) {
159
169
  if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
160
170
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: signOutUrl(id) })] }));
161
171
  }
162
- const page = await render(false);
172
+ const page = await render(false, r);
163
173
  if (n === null)
164
174
  void store.bumpViews(id);
165
175
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), page, _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
@@ -190,7 +200,7 @@ export function createArtifactRoutes(config) {
190
200
  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("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: 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 })] })] }));
191
201
  }
192
202
  /** The page itself, shared by open and gated pages. */
193
- async function Body(a, { top }) {
203
+ async function Body(a, { top, reader }) {
194
204
  let words = [];
195
205
  if (a.narration && a.timings) {
196
206
  try {
@@ -203,7 +213,9 @@ export function createArtifactRoutes(config) {
203
213
  }
204
214
  }
205
215
  const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
206
- 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("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(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), 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 })] })] }), 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 })) : (
216
+ // Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
217
+ const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
218
+ 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("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(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), 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 })) : (
207
219
  // No recorded narration, or its timings would not load: the browser reads the page.
208
220
  _jsx(BrowserReader, { rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground }))] }));
209
221
  }
@@ -496,5 +508,5 @@ export function createArtifactRoutes(config) {
496
508
  revalidatePath(pagePath(id));
497
509
  return NextResponse.json({ deleted: true });
498
510
  }
499
- return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
511
+ return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
500
512
  }
@@ -0,0 +1,24 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { type PersonalStore } from '../artifacts/personal-store.js';
3
+ import type { ArtifactStore } from '../artifacts/store.js';
4
+ import type { ReadersStore } from '../artifacts/readers-store.js';
5
+ export type PersonalRoutesContext = {
6
+ store: ArtifactStore;
7
+ personal?: PersonalStore;
8
+ readers?: ReadersStore;
9
+ readerSecret: () => string | undefined;
10
+ pageUrl: (id: string) => string;
11
+ signInOrigin?: string;
12
+ siteUrl: string;
13
+ };
14
+ type Params = {
15
+ params: Promise<{
16
+ id: string;
17
+ }>;
18
+ };
19
+ export declare function createPersonalRoutes(ctx: PersonalRoutesContext): {
20
+ PERSONAL_GET: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
21
+ PERSONAL_POST: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
22
+ PERSONAL_DELETE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
23
+ };
24
+ export {};
@@ -0,0 +1,100 @@
1
+ // The reader's own notes on a page, and nobody else's.
2
+ //
3
+ // GET /api/artifacts/<id>/personal the signed-in reader's notes on this page
4
+ // POST /api/artifacts/<id>/personal { value } adds one; { entry, value } edits one
5
+ // DELETE /api/artifacts/<id>/personal?entry=<id> deletes one
6
+ //
7
+ // Signed-in readers only, by the grant cookie. A reader who is not signed in gets 401 with
8
+ // `{ device: true }` and keeps the note in their browser instead, where no server (this host's
9
+ // owner included) can read it. There is deliberately NO publisher variant of any of these: the
10
+ // publish key opens nothing here, and personal-privacy.test.ts holds every handler to that.
11
+ import { NextResponse } from 'next/server';
12
+ import { ARTIFACT_ID_RE } from './ids.js';
13
+ import { GRANT_COOKIE, decide, signInUrl, verifyGrant } from '../artifacts/reader.js';
14
+ import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
15
+ import { validatePersonal } from '../artifacts/personal-store.js';
16
+ const MAX_BODY = 16 * 1024;
17
+ export function createPersonalRoutes(ctx) {
18
+ const json = (body, status = 200) => NextResponse.json(body, { status, headers: { 'cache-control': 'no-store' } });
19
+ const siteOrigin = new URL(ctx.siteUrl).origin;
20
+ /** The page, the signed-in reader, and every door the page itself has; or the refusal. */
21
+ async function open(req, id) {
22
+ const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
23
+ if (!a)
24
+ return { error: json({ error: `no artifact with id ${id}` }, 404) };
25
+ if (a.password && !isUnlocked({ id, password: a.password, cookie: req.cookies.get(unlockCookieName(id))?.value }))
26
+ return { error: json({ error: 'this page is locked' }, 403) };
27
+ const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
28
+ const signIn = ctx.signInOrigin ? signInUrl(ctx.signInOrigin, ctx.pageUrl(id)) : null;
29
+ if (a.access) {
30
+ if (!ctx.readers)
31
+ return { error: json({ error: 'this page is closed' }, 403) };
32
+ if (!reader)
33
+ return { error: json({ error: 'sign in to read this page', signIn }, 401) };
34
+ if (!decide(a.access, reader, await ctx.readers.allowList(id)).open)
35
+ return { error: json({ error: 'this page is not open to you' }, 403) };
36
+ if (!(await ctx.readers.acknowledged(id, reader.email)))
37
+ return { error: json({ error: 'accept the agreement first' }, 403) };
38
+ }
39
+ // The client reads `device: true` as "keep this note in the browser".
40
+ if (!ctx.personal)
41
+ return { error: json({ error: 'this host keeps no personal notes', device: true }, 501) };
42
+ if (!reader)
43
+ return { error: json({ error: 'sign in to keep notes everywhere', device: true, signIn }, 401) };
44
+ return { id, uid: reader.uid, personal: ctx.personal };
45
+ }
46
+ const list = async (o) => json({ notes: await o.personal.list(o.id, o.uid) });
47
+ // A write carries the reader's cookies; a browser always names its Origin on one, so a foreign one is refused.
48
+ const foreign = (req) => { const origin = req.headers.get('origin'); return origin !== null && origin !== siteOrigin; };
49
+ async function PERSONAL_GET(req, { params }) {
50
+ const o = await open(req, (await params).id);
51
+ return 'error' in o ? o.error : list(o);
52
+ }
53
+ async function PERSONAL_POST(req, { params }) {
54
+ if (foreign(req))
55
+ return json({ error: 'wrong origin' }, 403);
56
+ const declared = Number(req.headers.get('content-length') ?? '');
57
+ if (Number.isFinite(declared) && declared > MAX_BODY)
58
+ return json({ error: 'request over 16 KB' }, 413);
59
+ const raw = await req.text();
60
+ if (Buffer.byteLength(raw, 'utf8') > MAX_BODY)
61
+ return json({ error: 'request over 16 KB' }, 413);
62
+ let b;
63
+ try {
64
+ const parsed = JSON.parse(raw);
65
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
66
+ return json({ error: 'body must be JSON' }, 400);
67
+ b = parsed;
68
+ }
69
+ catch {
70
+ return json({ error: 'body must be JSON' }, 400);
71
+ }
72
+ const o = await open(req, (await params).id);
73
+ if ('error' in o)
74
+ return o.error;
75
+ const v = validatePersonal(b.value);
76
+ if (!v.ok)
77
+ return json({ error: v.error }, 400);
78
+ if (b.entry !== undefined) {
79
+ if (typeof b.entry !== 'string' || !(await o.personal.replace(o.id, o.uid, b.entry, v.value)))
80
+ return json({ error: 'no note of yours with that id' }, 404);
81
+ return list(o);
82
+ }
83
+ const r = await o.personal.add(o.id, o.uid, v.value);
84
+ if ('full' in r)
85
+ return json({ error: 'you have left the most notes this page keeps' }, 409);
86
+ return list(o);
87
+ }
88
+ async function PERSONAL_DELETE(req, { params }) {
89
+ if (foreign(req))
90
+ return json({ error: 'wrong origin' }, 403);
91
+ const o = await open(req, (await params).id);
92
+ if ('error' in o)
93
+ return o.error;
94
+ const entry = req.nextUrl.searchParams.get('entry') ?? '';
95
+ if (!(await o.personal.remove(o.id, o.uid, entry)))
96
+ return json({ error: 'no note of yours with that id' }, 404);
97
+ return list(o);
98
+ }
99
+ return { PERSONAL_GET, PERSONAL_POST, PERSONAL_DELETE };
100
+ }
@@ -21,6 +21,9 @@ export type StateRoutesContext = {
21
21
  * which is correct on Vercel (it overwrites XFF with the real client IP) and wrong behind any
22
22
  * other proxy that appends rather than replaces; a host behind one of those passes its own. */
23
23
  clientIp?: (req: NextRequest) => string;
24
+ /** The page owner's sign-in email. Signed in as them, a page's comments show every entry with
25
+ * full names, whatever `comments_visible:` says. */
26
+ ownerEmail?: string;
24
27
  };
25
28
  type Params = {
26
29
  params: Promise<{
@@ -1,7 +1,7 @@
1
1
  // The state routes: what readers put into a page, and what the publisher gets back.
2
2
  //
3
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? }
4
+ // POST /api/artifacts/<id>/state { slot, op: set|append|remove|replace, value?, entry? }
5
5
  // GET /api/artifacts/<id>/responses publish key; every answer with who (?format=csv)
6
6
  // DELETE /api/artifacts/<id>/responses?reader=<key> publish key; one reader's answers
7
7
  //
@@ -14,7 +14,8 @@ import { isPublishAuthed } from '../artifacts/auth.js';
14
14
  import { ARTIFACT_ID_RE } from './ids.js';
15
15
  import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
16
16
  import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
17
- import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters } from '../artifacts/state.js';
17
+ import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
18
+ import { COMMENTS_SLOT, validateComment } from '../artifacts/comments.js';
18
19
  import { readerKeyFor } from '../artifacts/state-store.js';
19
20
  import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
20
21
  import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
@@ -64,15 +65,39 @@ export function createStateRoutes(ctx) {
64
65
  const anonId = rawAnon && ANON_ID.test(rawAnon) ? rawAnon : null;
65
66
  return { a, reader, anonId, signIn };
66
67
  }
68
+ const ownerEmail = ctx.ownerEmail?.trim().toLowerCase();
69
+ const isOwner = (r) => !!r && !!ownerEmail && r.email === ownerEmail;
70
+ /** Comments are on for this page: the slot exists because `comments:` put it there. */
71
+ const commentsOn = (a) => !!a.comments && a.comments !== 'off' && Object.hasOwn(a.state.slots, COMMENTS_SLOT);
67
72
  const writerFor = (r) => ({ key: readerKeyFor.signedIn(r.uid), uid: r.uid, email: r.email, name: r.name, anonymous: false });
68
73
  async function viewBody(a, reader, readerKey) {
69
74
  const allow = reader && ctx.readers && a.access ? await ctx.readers.allowList(a.id) : [];
75
+ const entries = await ctx.state.entries(a.id);
76
+ const slots = stateView(a.state, entries, readerKey);
77
+ const owner = isOwner(reader);
78
+ // The owner reads every comment with its writer's full name, whatever the page shows readers.
79
+ if (owner && commentsOn(a)) {
80
+ slots[COMMENTS_SLOT].shared = entries.filter((e) => e.slot === COMMENTS_SLOT)
81
+ .sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
82
+ .map((e) => ({ id: e.id, name: e.writer.name?.trim() || 'a reader', value: e.value, at: e.at, mine: e.readerKey === readerKey }));
83
+ }
70
84
  return {
71
85
  reader: reader ? { firstName: firstName(reader, allow) } : null,
72
86
  canWrite: !!reader || effectiveWriters(a.state, a.access) === 'anyone',
73
- slots: stateView(a.state, await ctx.state.entries(a.id), readerKey),
87
+ ...(owner ? { owner: true } : {}),
88
+ slots,
74
89
  };
75
90
  }
91
+ /** The comments a writer can see, by id: every one on a readers-visible page, else their own,
92
+ * and every one for the owner. A reply may name only a comment its writer was shown. */
93
+ async function visibleComments(a, writerKey, reader) {
94
+ const all = slotVisibility(a.state, COMMENTS_SLOT) === 'shared' || isOwner(reader);
95
+ const byId = new Map();
96
+ for (const e of await ctx.state.entries(a.id))
97
+ if (e.slot === COMMENTS_SLOT && (all || e.readerKey === writerKey))
98
+ byId.set(e.id, e.value);
99
+ return byId;
100
+ }
76
101
  // A GET that writes, on purpose: when a signed-in reader still carries an anonymous cookie, the
77
102
  // answers move here, because this is the first request that sees both. It is safe as a GET:
78
103
  // moveReader is idempotent (a second run finds nothing under the cleared key), and the grant
@@ -134,28 +159,40 @@ export function createStateRoutes(ctx) {
134
159
  if (!def)
135
160
  return json({ error: `no slot named ${slot} on this page` }, 400);
136
161
  const op = b.op;
137
- if (op !== 'set' && op !== 'append' && op !== 'remove')
138
- return json({ error: 'op must be set, append or remove' }, 400);
162
+ if (op !== 'set' && op !== 'append' && op !== 'remove' && op !== 'replace')
163
+ return json({ error: 'op must be set, append, remove or replace' }, 400);
139
164
  if (op === 'set' && def.shape !== 'one')
140
165
  return json({ error: `slot ${slot} takes append` }, 400);
141
- if (op === 'append' && def.shape !== 'many')
166
+ if ((op === 'append' || op === 'replace') && def.shape !== 'many')
142
167
  return json({ error: `slot ${slot} takes set` }, 400);
168
+ const entryId = typeof b.entry === 'string' ? b.entry : undefined;
169
+ if (op === 'replace' && !entryId)
170
+ return json({ error: 'replace names the entry it edits' }, 400);
171
+ if (op === 'replace' && !ctx.state.replace)
172
+ return json({ error: 'this host cannot edit an answer' }, 501);
173
+ const isComments = slot === COMMENTS_SLOT && commentsOn(a);
143
174
  let setCookie = null;
144
175
  let writer;
145
176
  if (reader)
146
177
  writer = writerFor(reader);
147
178
  else {
148
- if (effectiveWriters(a.state, a.access) !== 'anyone')
179
+ if (effectiveWriters(a.state, a.access, slot) !== 'anyone')
149
180
  return json({ error: 'sign in to answer', signIn }, 401);
150
181
  // A remove adds nothing, so it is never counted and never mints a cookie: with no cookie
151
182
  // there is nothing of theirs to remove, and the answer is simply the page as it stands.
183
+ // Comments say so instead: deleting a comment that is not yours is a refusal, not a no-op.
152
184
  if (op === 'remove') {
153
185
  if (!anonId)
154
- return json(await viewBody(a, null, null));
186
+ return isComments && entryId ? json({ error: 'no comment of yours with that id' }, 404) : json(await viewBody(a, null, null));
155
187
  const key = readerKeyFor.anonymous(anonId);
156
- await ctx.state.remove({ artifactId: id, slot, readerKey: key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
188
+ const n = await ctx.state.remove({ artifactId: id, slot, readerKey: key, entryId });
189
+ if (isComments && entryId && !n)
190
+ return json({ error: 'no comment of yours with that id' }, 404);
157
191
  return json(await viewBody(a, null, key));
158
192
  }
193
+ // An edit with no cookie has nothing of theirs to edit.
194
+ if (op === 'replace' && !anonId)
195
+ return json({ error: 'no answer of yours with that id' }, 404);
159
196
  const n = await ctx.state.countAnonWrite(id, ipHash(req), Math.floor(Date.now() / 60000));
160
197
  if (n > ANON_WRITES_PER_MINUTE)
161
198
  return json({ error: 'too many answers from here; try again in a minute' }, 429);
@@ -166,12 +203,34 @@ export function createStateRoutes(ctx) {
166
203
  writer = { key: readerKeyFor.anonymous(anonId), name: null, anonymous: true };
167
204
  }
168
205
  if (op === 'remove') {
169
- await ctx.state.remove({ artifactId: id, slot, readerKey: writer.key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
206
+ const n = await ctx.state.remove({ artifactId: id, slot, readerKey: writer.key, entryId });
207
+ if (isComments && entryId && !n)
208
+ return json({ error: 'no comment of yours with that id' }, 404);
170
209
  }
171
210
  else {
172
211
  const bad = checkValue(b.value) ?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null);
173
212
  if (bad)
174
213
  return json({ error: bad }, 400);
214
+ let value = b.value;
215
+ if (isComments) {
216
+ const seen = await visibleComments(a, writer.key, reader);
217
+ const c = validateComment(b.value, (cid) => seen.get(cid) ?? null);
218
+ if (!c.ok)
219
+ return json({ error: c.error }, 400);
220
+ // An edit keeps the comment where it is in its thread: a reply stays a reply to the same one.
221
+ if (op === 'replace' && seen.has(entryId) && (seen.get(entryId).parent ?? null) !== (c.value.parent ?? null))
222
+ return json({ error: 'an edit cannot move a comment into or out of a thread' }, 400);
223
+ value = c.value;
224
+ }
225
+ if (op === 'replace') {
226
+ const r = await ctx.state.replace({ artifactId: id, slot, readerKey: writer.key, entryId: entryId, value });
227
+ if (!r)
228
+ return json({ error: 'no answer of yours with that id' }, 404);
229
+ const res = json(await viewBody(a, reader, writer.key));
230
+ if (setCookie)
231
+ res.headers.append('set-cookie', setCookie);
232
+ return res;
233
+ }
175
234
  // The page-wide cap per slot. Counted before the write and not atomically with it, so a
176
235
  // burst can overshoot by the writes in flight; it bounds the slot, it is not a quota.
177
236
  if ((await ctx.state.countSlot(id, slot)) >= MAX_ENTRIES_PER_SLOT) {
@@ -182,8 +241,8 @@ export function createStateRoutes(ctx) {
182
241
  return json({ error: 'this page is not taking more answers here' }, 409);
183
242
  }
184
243
  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 });
244
+ ? await ctx.state.set({ artifactId: id, slot, writer, value })
245
+ : await ctx.state.append({ artifactId: id, slot, writer, value });
187
246
  if ('full' in r)
188
247
  return json({ error: 'you have left the most answers this page takes here' }, 409);
189
248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",