@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.
- package/CHANGELOG.md +32 -0
- package/README.md +62 -0
- package/lib/artifacts/anchor.d.ts +33 -0
- package/lib/artifacts/anchor.js +93 -0
- package/lib/artifacts/comments.d.ts +38 -0
- package/lib/artifacts/comments.js +57 -0
- package/lib/artifacts/front-matter.d.ts +7 -0
- package/lib/artifacts/front-matter.js +19 -3
- package/lib/artifacts/index.d.ts +4 -1
- package/lib/artifacts/index.js +4 -1
- package/lib/artifacts/personal-store.d.ts +29 -0
- package/lib/artifacts/personal-store.js +100 -0
- package/lib/artifacts/render.js +60 -52
- package/lib/artifacts/state-store.d.ts +10 -0
- package/lib/artifacts/state-store.js +19 -0
- package/lib/artifacts/state.d.ts +7 -2
- package/lib/artifacts/state.js +7 -3
- package/lib/artifacts/store.d.ts +4 -0
- package/lib/artifacts/store.js +6 -0
- package/lib/artifacts/widgets.d.ts +4 -0
- package/lib/artifacts/widgets.js +70 -0
- package/lib/reader/comment-card.d.ts +18 -0
- package/lib/reader/comment-card.js +36 -0
- package/lib/reader/comment-layer.d.ts +17 -0
- package/lib/reader/comment-layer.js +384 -0
- package/lib/reader/comment-place.d.ts +45 -0
- package/lib/reader/comment-place.js +117 -0
- package/lib/reader/device-notes.d.ts +6 -0
- package/lib/reader/device-notes.js +35 -0
- package/lib/reader/index.d.ts +4 -0
- package/lib/reader/index.js +4 -0
- package/lib/routes/artifacts.d.ts +23 -0
- package/lib/routes/artifacts.js +23 -11
- package/lib/routes/personal-routes.d.ts +24 -0
- package/lib/routes/personal-routes.js +100 -0
- package/lib/routes/state-routes.d.ts +3 -0
- package/lib/routes/state-routes.js +71 -12
- 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;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
186
|
-
: await ctx.state.append({ artifactId: id, slot, writer, 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.
|
|
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",
|