@supersuit/artifacts 0.9.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 +51 -0
- package/README.md +63 -1
- 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/artifact-reader.d.ts +5 -1
- package/lib/reader/artifact-reader.js +23 -17
- package/lib/reader/browser-reader.d.ts +8 -0
- package/lib/reader/browser-reader.js +171 -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 +8 -0
- package/lib/reader/index.js +10 -0
- package/lib/reader/player-bar.d.ts +18 -0
- package/lib/reader/player-bar.js +16 -0
- package/lib/reader/speech-plan.d.ts +20 -0
- package/lib/reader/speech-plan.js +67 -0
- package/lib/routes/artifacts.d.ts +23 -0
- package/lib/routes/artifacts.js +26 -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 +3 -3
|
@@ -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",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"default": "./lib/brand/index.js"
|
|
39
39
|
},
|
|
40
40
|
"./reader": {
|
|
41
|
-
"types": "./lib/reader/
|
|
42
|
-
"default": "./lib/reader/
|
|
41
|
+
"types": "./lib/reader/index.d.ts",
|
|
42
|
+
"default": "./lib/reader/index.js"
|
|
43
43
|
},
|
|
44
44
|
"./gate": {
|
|
45
45
|
"types": "./lib/gate.d.ts",
|