@supersuit/artifacts 0.39.0 → 0.41.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.
@@ -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 g = await perReaderGate({ id, param: undefined, cookie: req.cookies.get(ridCookieName(id))?.value, secret: usableSecret(ctx.recipientSecret?.()), recipients: ctx.recipients });
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
- recipient = { rid: g.rid, label: g.label };
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 ? readerKeyFor.recipient(recipient.rid) : reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
180
- const res = json({ ...(await viewBody(a, reader, key, !!recipient)), ...(reader || recipient ? {} : { signIn }) });
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
- writer = { key: readerKeyFor.recipient(recipient.rid), name: recipient.label, anonymous: true };
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. `cursor` is the `at` of the last comment READ, filtered
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
- if (openOnly && resolved && !(v.parent && e.at > resolved.at))
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
- return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE };
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.39.0",
3
+ "version": "0.41.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",