@supersuit/artifacts 0.39.0 → 0.40.1
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 +80 -0
- package/README.md +88 -5
- package/lib/artifacts/comments.d.ts +9 -0
- package/lib/artifacts/comments.js +6 -0
- package/lib/artifacts/front-matter.d.ts +3 -0
- package/lib/artifacts/front-matter.js +8 -1
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/index.js +1 -1
- package/lib/artifacts/recipients-store.d.ts +15 -6
- package/lib/artifacts/recipients-store.js +29 -4
- package/lib/artifacts/recipients.d.ts +9 -0
- package/lib/artifacts/recipients.js +16 -0
- package/lib/artifacts/state-store.d.ts +5 -0
- package/lib/artifacts/state-store.js +5 -0
- package/lib/artifacts/store.d.ts +1 -0
- package/lib/artifacts/store.js +2 -0
- package/lib/reader/comment-card.d.ts +10 -2
- package/lib/reader/comment-card.js +15 -9
- package/lib/reader/comment-layer.d.ts +7 -1
- package/lib/reader/comment-layer.js +74 -15
- package/lib/reader/feedback-window.d.ts +13 -0
- package/lib/reader/feedback-window.js +39 -0
- package/lib/routes/artifacts.d.ts +5 -0
- package/lib/routes/artifacts.js +5 -4
- package/lib/routes/per-reader.d.ts +2 -0
- package/lib/routes/per-reader.js +1 -1
- package/lib/routes/recipients-routes.js +22 -4
- package/lib/routes/state-routes.d.ts +1 -0
- package/lib/routes/state-routes.js +132 -11
- package/package.json +1 -1
|
@@ -12,16 +12,21 @@
|
|
|
12
12
|
// Who is writing: the grant a gated page already uses, or on a page with `writers: anyone`, an
|
|
13
13
|
// anonymous id in an HttpOnly cookie. When both are present the anonymous answers move to the
|
|
14
14
|
// signed-in reader, once, and the cookie is cleared.
|
|
15
|
+
//
|
|
16
|
+
// A `links: per-reader` page is written through the link: one link is one reader (`r:<rid>`). The
|
|
17
|
+
// exception is a GROUP link on a page with `comment_name: required` (0.40.0), where each browser is
|
|
18
|
+
// its own reader: the same anonymous cookie, and a key this server derives from the link and it.
|
|
15
19
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
16
20
|
import { NextResponse } from 'next/server';
|
|
17
21
|
import { isPublisherAuthed } from '../artifacts/auth.js';
|
|
18
22
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
19
23
|
import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
|
|
20
24
|
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
21
|
-
import { ridCookieName, usableSecret } from '../artifacts/recipients.js';
|
|
25
|
+
import { browserIdFor, ridCookieName, ridOfReaderKey, usableSecret } from '../artifacts/recipients.js';
|
|
22
26
|
import { perReaderGate } from './per-reader.js';
|
|
23
27
|
import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
|
|
24
|
-
import { COMMENTS_SLOT, MAX_COMMENT_NAME_CHARS, cleanResolveNote, resolutionOf, validateComment } from '../artifacts/comments.js';
|
|
28
|
+
import { COMMENTS_SLOT, FEEDBACK_CLOSED, MAX_COMMENT_CHARS, MAX_COMMENT_NAME_CHARS, NAME_NEEDED, cleanResolveNote, resolutionOf, validateComment } from '../artifacts/comments.js';
|
|
29
|
+
import { MAX_QUOTE_CHARS } from '../artifacts/anchor.js';
|
|
25
30
|
import { isCommentAudioPath } from '../artifacts/audio.js';
|
|
26
31
|
import { readerKeyFor } from '../artifacts/state-store.js';
|
|
27
32
|
import { NOTES_SLOT, checkNoteValue, checklistAnswerIds, hasNotesWidget } from '../artifacts/widgets.js';
|
|
@@ -90,20 +95,30 @@ export function createStateRoutes(ctx) {
|
|
|
90
95
|
let recipient = null;
|
|
91
96
|
// The owner and the page's admins open it without a link: their comment layer is signed-in, never a reader's.
|
|
92
97
|
if (a.links === 'per-reader' && !isOwner(reader)) {
|
|
93
|
-
const
|
|
98
|
+
const secret = usableSecret(ctx.recipientSecret?.());
|
|
99
|
+
const g = await perReaderGate({ id, param: undefined, cookie: req.cookies.get(ridCookieName(id))?.value, secret, recipients: ctx.recipients });
|
|
94
100
|
if (g.state === 'shut')
|
|
95
101
|
return { error: json({ error: 'this page is locked' }, 403) };
|
|
96
|
-
|
|
102
|
+
// The gate opened, so the secret is usable: it is what the browser id below is derived with.
|
|
103
|
+
recipient = { rid: g.rid, label: g.label, group: g.group, closesAt: g.closesAt, perBrowser: g.group && a.commentName === 'required', secret: secret };
|
|
97
104
|
}
|
|
98
105
|
const rawAnon = req.cookies.get(ANON_COOKIE)?.value;
|
|
99
106
|
const anonId = rawAnon && ANON_ID.test(rawAnon) ? rawAnon : null;
|
|
100
107
|
return { a, reader, anonId, signIn, recipient };
|
|
101
108
|
}
|
|
109
|
+
/** The key a reader who came through a link writes and reads under. One link is one reader,
|
|
110
|
+
* except a group link on a page with `comment_name: required`, where each browser is its own:
|
|
111
|
+
* the link's verified rid and an id derived from the anonymous cookie this server set. Null for
|
|
112
|
+
* such a browser before its first write, when it has no cookie and nothing is its own. The
|
|
113
|
+
* client never names the key and cannot work out another browser's. */
|
|
114
|
+
const recipientKey = (id, r, anonId) => !r.perBrowser ? readerKeyFor.recipient(r.rid) : anonId ? readerKeyFor.groupBrowser(r.rid, browserIdFor(r.secret, id, r.rid, anonId)) : null;
|
|
102
115
|
const ownerEmail = ctx.ownerEmail?.trim().toLowerCase();
|
|
103
116
|
const admins = new Set((ctx.admins ?? []).map((e) => e.trim().toLowerCase()));
|
|
104
117
|
// The owner and the page's admins read every comment, with names.
|
|
105
118
|
const isOwner = (r) => !!r && ((!!ownerEmail && r.email.trim().toLowerCase() === ownerEmail) || admins.has(r.email.trim().toLowerCase()));
|
|
106
119
|
const declared = (v) => { const by = v?.by; return typeof by === 'string' && by.trim() ? by.trim() : null; };
|
|
120
|
+
/** A window that has passed, or that cannot be read as a time, is closed: never open forever. */
|
|
121
|
+
const windowClosed = (r) => !!r?.closesAt && !(Date.parse(r.closesAt) > Date.now());
|
|
107
122
|
/** Comments are on for this page: the slot exists because `comments:` put it there. */
|
|
108
123
|
const commentsOn = (a) => !!a.comments && a.comments !== 'off' && Object.hasOwn(a.state.slots, COMMENTS_SLOT);
|
|
109
124
|
const writerFor = (r) => ({ key: readerKeyFor.signedIn(r.uid), uid: r.uid, email: r.email, name: r.name, anonymous: false });
|
|
@@ -138,6 +153,17 @@ export function createStateRoutes(ctx) {
|
|
|
138
153
|
.sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
|
|
139
154
|
.map((e) => ({ id: e.id, name: e.writer.name?.trim() || declared(e.value) || 'a reader', value: e.value, at: e.at, mine: e.readerKey === readerKey }));
|
|
140
155
|
}
|
|
156
|
+
// The one thing of someone else's a per-reader reader is shown: the publisher's reply to a
|
|
157
|
+
// comment that reader wrote. Never a reply to another reader's thread.
|
|
158
|
+
if (canWriteAs && commentsOn(a) && slots[COMMENTS_SLOT]) {
|
|
159
|
+
const own = new Set(entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey === readerKey).map((e) => e.id));
|
|
160
|
+
const replies = entries
|
|
161
|
+
.filter((e) => e.slot === COMMENTS_SLOT && e.value.publisher === true && own.has(e.value.parent ?? ''))
|
|
162
|
+
.sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
|
|
163
|
+
.map((e) => ({ id: e.id, name: e.writer.name?.trim() || 'the publisher', value: e.value, at: e.at, mine: false }));
|
|
164
|
+
if (replies.length)
|
|
165
|
+
slots[COMMENTS_SLOT].shared = replies;
|
|
166
|
+
}
|
|
141
167
|
if (commentsOn(a) && slots[COMMENTS_SLOT]) {
|
|
142
168
|
const v = slots[COMMENTS_SLOT];
|
|
143
169
|
await withAudioUrls(a.id, Array.isArray(v.mine) ? v.mine : []);
|
|
@@ -176,8 +202,9 @@ export function createStateRoutes(ctx) {
|
|
|
176
202
|
await ctx.state.moveReader(readerKeyFor.anonymous(anonId), writerFor(reader));
|
|
177
203
|
clearAnon = true;
|
|
178
204
|
}
|
|
179
|
-
const key = recipient ?
|
|
180
|
-
const
|
|
205
|
+
const key = recipient ? recipientKey(id, recipient, anonId) : reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
|
|
206
|
+
const feedback = recipient?.closesAt ? { feedback: { closesAt: recipient.closesAt, closed: windowClosed(recipient) } } : {};
|
|
207
|
+
const res = json({ ...(await viewBody(a, reader, key, !!recipient)), ...feedback, ...(reader || recipient ? {} : { signIn }) });
|
|
181
208
|
if (clearAnon)
|
|
182
209
|
res.headers.append('set-cookie', anonCookie('', 0));
|
|
183
210
|
return res;
|
|
@@ -233,10 +260,27 @@ export function createStateRoutes(ctx) {
|
|
|
233
260
|
if (op === 'replace' && !ctx.state.replace)
|
|
234
261
|
return json({ error: 'this host cannot edit an answer' }, 501);
|
|
235
262
|
const isComments = slot === COMMENTS_SLOT && commentsOn(a);
|
|
263
|
+
// A reader whose feedback window has closed can still read and still delete their own; a new
|
|
264
|
+
// comment or an edit is refused in words, so what they typed is not lost silently.
|
|
265
|
+
if (isComments && recipient && op !== 'remove' && windowClosed(recipient))
|
|
266
|
+
return json({ error: FEEDBACK_CLOSED, closed: true }, 403);
|
|
236
267
|
let setCookie = null;
|
|
237
268
|
let writer;
|
|
238
|
-
if (recipient)
|
|
239
|
-
|
|
269
|
+
if (recipient) {
|
|
270
|
+
let key = recipientKey(id, recipient, anonId);
|
|
271
|
+
if (!key) {
|
|
272
|
+
// A browser on a group link that has written nothing: there is nothing of its own to delete
|
|
273
|
+
// or edit, and a first note is when it is handed its id.
|
|
274
|
+
if (op === 'remove')
|
|
275
|
+
return isComments && entryId ? json({ error: 'no comment of yours with that id' }, 404) : json(await viewBody(a, reader, null, true));
|
|
276
|
+
if (op === 'replace')
|
|
277
|
+
return json({ error: 'no answer of yours with that id' }, 404);
|
|
278
|
+
anonId = newAnonId();
|
|
279
|
+
setCookie = anonCookie(anonId, 31536000);
|
|
280
|
+
key = recipientKey(id, recipient, anonId);
|
|
281
|
+
}
|
|
282
|
+
writer = { key, name: recipient.label, anonymous: true };
|
|
283
|
+
}
|
|
240
284
|
else if (reader)
|
|
241
285
|
writer = writerFor(reader);
|
|
242
286
|
else {
|
|
@@ -284,6 +328,15 @@ export function createStateRoutes(ctx) {
|
|
|
284
328
|
const c = validateComment(b.value, (cid) => seen.get(cid) ?? null);
|
|
285
329
|
if (!c.ok)
|
|
286
330
|
return json({ error: c.error }, 400);
|
|
331
|
+
// `comment_name: required`: an open link is shared by several people, so each comment carries
|
|
332
|
+
// the name its writer typed; a named link already says who, so a typed name is dropped rather
|
|
333
|
+
// than trusted over the label. A page without the line is untouched.
|
|
334
|
+
if (a.commentName === 'required' && recipient) {
|
|
335
|
+
if (recipient.group && !c.value.by)
|
|
336
|
+
return json({ error: NAME_NEEDED, needsName: true }, 400);
|
|
337
|
+
if (!recipient.group)
|
|
338
|
+
delete c.value.by;
|
|
339
|
+
}
|
|
287
340
|
// A shared comment's recording is one uploaded as a comment, never a path elsewhere under the
|
|
288
341
|
// page: naming a personal recording here would get it signed for the publisher.
|
|
289
342
|
if (c.value.audio !== undefined && !isCommentAudioPath(c.value.audio))
|
|
@@ -355,7 +408,8 @@ export function createStateRoutes(ctx) {
|
|
|
355
408
|
*
|
|
356
409
|
* Each row carries `resolved`: its THREAD's resolution (a reply carries its parent's), or null.
|
|
357
410
|
* `&open=1` leaves out every row of a resolved thread, except a reply written after the thread
|
|
358
|
-
* was resolved, which is news about it.
|
|
411
|
+
* was resolved, which is news about it. A reply the publisher wrote (`publisher: true`) is the
|
|
412
|
+
* exception: it answers a thread and never reopens one. `cursor` is the `at` of the last comment READ, filtered
|
|
359
413
|
* or not, and `more` says a full page was read; a caller paging with `open=1` follows `cursor`,
|
|
360
414
|
* since the last row it was given can sit well before it. */
|
|
361
415
|
async function COMMENTS_FEED(req) {
|
|
@@ -393,12 +447,16 @@ export function createStateRoutes(ctx) {
|
|
|
393
447
|
continue;
|
|
394
448
|
const v = e.value;
|
|
395
449
|
const resolved = resolutionOf(v.parent ? (await commentsOf(e.artifactId)).get(v.parent)?.value : v);
|
|
396
|
-
|
|
450
|
+
// A publisher's reply is the answer to a thread, never news about it: it does not reopen one.
|
|
451
|
+
if (openOnly && resolved && !(v.parent && v.publisher !== true && e.at > resolved.at))
|
|
397
452
|
continue;
|
|
398
453
|
const audio = isCommentAudioPath(v.audio) && ctx.audioUrl ? await ctx.audioUrl(e.artifactId, v.audio) : null;
|
|
399
454
|
comments.push({
|
|
400
455
|
artifactId: e.artifactId, title: a.title, entryId: e.id, parent: v.parent ?? null,
|
|
401
456
|
name: e.writer.anonymous ? null : e.writer.name?.trim() || 'a signed-in reader',
|
|
457
|
+
// Which per-reader link it came through (0.40.0): the rid and the label the publisher gave it.
|
|
458
|
+
reader: ridOfReaderKey(e.readerKey) ? { rid: ridOfReaderKey(e.readerKey), label: e.writer.name ?? null } : null,
|
|
459
|
+
by: declared(v), publisher: v.publisher === true,
|
|
402
460
|
quote: v.anchor?.kind === 'text' ? v.anchor.quote : null, region: v.anchor?.kind === 'region',
|
|
403
461
|
body: v.body, transcript: v.transcript ?? null, audioUrl: audio, at: e.at, resolved,
|
|
404
462
|
link: `${ctx.pageUrl(e.artifactId)}#comment-${e.id}`,
|
|
@@ -500,5 +558,68 @@ export function createStateRoutes(ctx) {
|
|
|
500
558
|
}
|
|
501
559
|
return json({ id, entry: root.id, resolved: resolution });
|
|
502
560
|
}
|
|
503
|
-
|
|
561
|
+
/** POST /api/artifacts/<id>/reply: the publisher's one reply in a comment thread (0.40.0).
|
|
562
|
+
* Body { entry, body, by?, quote? }. Publish key or publisher pass only. `entry` may name a reply:
|
|
563
|
+
* the thread it belongs to is answered. A thread already answered is left as it is and the first
|
|
564
|
+
* reply is named back (`existing: true`). That is one reply per thread when calls do not overlap:
|
|
565
|
+
* the check is a read and then an append, not one atomic step, so an overlapping
|
|
566
|
+
* retry can post a second. */
|
|
567
|
+
async function REPLY(req, { params }) {
|
|
568
|
+
if (!isPublisherAuthed(req, ctx))
|
|
569
|
+
return json({ error: 'Unauthorized' }, 401);
|
|
570
|
+
const raw = await req.text();
|
|
571
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_BODY)
|
|
572
|
+
return json({ error: 'request over 16 KB' }, 413);
|
|
573
|
+
let b;
|
|
574
|
+
try {
|
|
575
|
+
const parsed = JSON.parse(raw);
|
|
576
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
577
|
+
return json({ error: 'body must be a JSON object' }, 400);
|
|
578
|
+
b = parsed;
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return json({ error: 'body must be JSON' }, 400);
|
|
582
|
+
}
|
|
583
|
+
const unknownKeys = Object.keys(b).filter((k) => !['entry', 'body', 'by', 'quote'].includes(k));
|
|
584
|
+
if (unknownKeys.length)
|
|
585
|
+
return json({ error: `unknown field: ${unknownKeys.join(', ')}; the body is { entry, body, by?, quote? }` }, 400);
|
|
586
|
+
if (typeof b.entry !== 'string' || !ENTRY_ID.test(b.entry))
|
|
587
|
+
return json({ error: 'entry is the id of the comment to reply to' }, 400);
|
|
588
|
+
const body = typeof b.body === 'string' ? b.body.trim() : '';
|
|
589
|
+
if (!body || body.length > MAX_COMMENT_CHARS)
|
|
590
|
+
return json({ error: `body is 1 to ${MAX_COMMENT_CHARS} characters of text` }, 400);
|
|
591
|
+
let by = 'the publisher';
|
|
592
|
+
if (b.by !== undefined) {
|
|
593
|
+
if (typeof b.by !== 'string' || /[\r\n]/.test(b.by) || !b.by.trim() || b.by.trim().length > MAX_COMMENT_NAME_CHARS)
|
|
594
|
+
return json({ error: `by is one line of at most ${MAX_COMMENT_NAME_CHARS} characters` }, 400);
|
|
595
|
+
by = b.by.trim();
|
|
596
|
+
}
|
|
597
|
+
if (b.quote !== undefined && (typeof b.quote !== 'string' || !b.quote.trim() || b.quote.length > MAX_QUOTE_CHARS))
|
|
598
|
+
return json({ error: `quote is 1 to ${MAX_QUOTE_CHARS} characters of the page's text` }, 400);
|
|
599
|
+
if (!ctx.state)
|
|
600
|
+
return json({ error: 'this host keeps no answers' }, 501);
|
|
601
|
+
const { id } = await params;
|
|
602
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
603
|
+
if (!a)
|
|
604
|
+
return json({ error: `no artifact with id ${id}` }, 404);
|
|
605
|
+
const all = new Map((await ctx.state.entries(id)).filter((e) => e.slot === COMMENTS_SLOT).map((e) => [e.id, e]));
|
|
606
|
+
const asked = all.get(b.entry);
|
|
607
|
+
if (!asked)
|
|
608
|
+
return json({ error: 'no comment with that id on this page' }, 404);
|
|
609
|
+
const parent = asked.value.parent;
|
|
610
|
+
const root = parent ? all.get(parent) : asked;
|
|
611
|
+
if (!root)
|
|
612
|
+
return json({ error: 'the comment this reply answers is gone' }, 404);
|
|
613
|
+
const key = readerKeyFor.publisher(root.id);
|
|
614
|
+
const had = [...all.values()].find((e) => e.readerKey === key);
|
|
615
|
+
if (had)
|
|
616
|
+
return json({ id, entry: had.id, parent: root.id, existing: true });
|
|
617
|
+
const anchor = typeof b.quote === 'string' ? { kind: 'text', quote: b.quote, prefix: '', suffix: '' } : root.value.anchor;
|
|
618
|
+
const value = { anchor, body, version: a.version ?? 1, parent: root.id, by, publisher: true };
|
|
619
|
+
const r = await ctx.state.append({ artifactId: id, slot: COMMENTS_SLOT, writer: { key, name: by, anonymous: false }, value });
|
|
620
|
+
if ('full' in r)
|
|
621
|
+
return json({ error: 'this thread takes no more replies' }, 409);
|
|
622
|
+
return json({ id, entry: r.id, parent: root.id, existing: false });
|
|
623
|
+
}
|
|
624
|
+
return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE, REPLY };
|
|
504
625
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.1",
|
|
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",
|