@supersuit/artifacts 0.33.1 → 0.35.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.
@@ -18,6 +18,8 @@ import { isPublisherAuthed } from '../artifacts/auth.js';
18
18
  import { ARTIFACT_ID_RE } from './ids.js';
19
19
  import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
20
20
  import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
21
+ import { ridCookieName, usableSecret } from '../artifacts/recipients.js';
22
+ import { perReaderGate } from './per-reader.js';
21
23
  import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
22
24
  import { COMMENTS_SLOT, MAX_COMMENT_NAME_CHARS, cleanResolveNote, resolutionOf, validateComment } from '../artifacts/comments.js';
23
25
  import { isCommentAudioPath } from '../artifacts/audio.js';
@@ -83,9 +85,19 @@ export function createStateRoutes(ctx) {
83
85
  if (!(await ctx.readers.acknowledged(id, reader.email)))
84
86
  return { error: json({ error: 'accept the agreement first' }, 403) };
85
87
  }
88
+ // A per-reader page is answered through the link it was sent with: the rid cookie names the
89
+ // reader, and what they write is theirs under that key. A revoked or expired rid is shut.
90
+ let recipient = null;
91
+ // The owner and the page's admins open it without a link: their comment layer is signed-in, never a reader's.
92
+ 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 });
94
+ if (g.state === 'shut')
95
+ return { error: json({ error: 'this page is locked' }, 403) };
96
+ recipient = { rid: g.rid, label: g.label };
97
+ }
86
98
  const rawAnon = req.cookies.get(ANON_COOKIE)?.value;
87
99
  const anonId = rawAnon && ANON_ID.test(rawAnon) ? rawAnon : null;
88
- return { a, reader, anonId, signIn };
100
+ return { a, reader, anonId, signIn, recipient };
89
101
  }
90
102
  const ownerEmail = ctx.ownerEmail?.trim().toLowerCase();
91
103
  const admins = new Set((ctx.admins ?? []).map((e) => e.trim().toLowerCase()));
@@ -107,11 +119,19 @@ export function createStateRoutes(ctx) {
107
119
  }
108
120
  return rows;
109
121
  }
110
- async function viewBody(a, reader, readerKey) {
122
+ async function viewBody(a, reader, readerKey, canWriteAs = false) {
111
123
  const allow = reader && ctx.readers && a.access ? await ctx.readers.allowList(a.id) : [];
112
124
  const entries = await ctx.state.entries(a.id);
113
125
  const slots = stateView(a.state, entries, readerKey);
114
- const owner = isOwner(reader);
126
+ // A reader who came through their own link sees only their own answers: whatever the page's
127
+ // settings say, nothing of another reader's is shared with them.
128
+ if (canWriteAs)
129
+ for (const v of Object.values(slots)) {
130
+ v.visibility = 'private';
131
+ delete v.shared;
132
+ delete v.tally;
133
+ }
134
+ const owner = !canWriteAs && isOwner(reader);
115
135
  // The owner reads every comment with its writer's full name, whatever the page shows readers.
116
136
  if (owner && commentsOn(a)) {
117
137
  slots[COMMENTS_SLOT].shared = entries.filter((e) => e.slot === COMMENTS_SLOT)
@@ -125,15 +145,15 @@ export function createStateRoutes(ctx) {
125
145
  }
126
146
  return {
127
147
  reader: reader ? { firstName: firstName(reader, allow) } : null,
128
- canWrite: !!reader || effectiveWriters(a.state, a.access) === 'anyone',
148
+ canWrite: !!reader || canWriteAs || effectiveWriters(a.state, a.access) === 'anyone',
129
149
  ...(owner ? { owner: true } : {}),
130
150
  slots,
131
151
  };
132
152
  }
133
153
  /** The comments a writer can see, by id: every one on a readers-visible page, else their own,
134
154
  * and every one for the owner. A reply may name only a comment its writer was shown. */
135
- async function visibleComments(a, writerKey, reader) {
136
- const all = slotVisibility(a.state, COMMENTS_SLOT) === 'shared' || isOwner(reader);
155
+ async function visibleComments(a, writerKey, reader, perReader = false) {
156
+ const all = !perReader && (slotVisibility(a.state, COMMENTS_SLOT) === 'shared' || isOwner(reader));
137
157
  const byId = new Map();
138
158
  for (const e of await ctx.state.entries(a.id))
139
159
  if (e.slot === COMMENTS_SLOT && (all || e.readerKey === writerKey))
@@ -150,14 +170,14 @@ export function createStateRoutes(ctx) {
150
170
  const o = await open(req, id);
151
171
  if ('error' in o)
152
172
  return o.error;
153
- const { a, reader, anonId, signIn } = o;
173
+ const { a, reader, anonId, signIn, recipient } = o;
154
174
  let clearAnon = false;
155
- if (reader && anonId) {
175
+ if (reader && anonId && !recipient) {
156
176
  await ctx.state.moveReader(readerKeyFor.anonymous(anonId), writerFor(reader));
157
177
  clearAnon = true;
158
178
  }
159
- const key = reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
160
- const res = json({ ...(await viewBody(a, reader, key)), ...(reader ? {} : { signIn }) });
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 }) });
161
181
  if (clearAnon)
162
182
  res.headers.append('set-cookie', anonCookie('', 0));
163
183
  return res;
@@ -192,7 +212,7 @@ export function createStateRoutes(ctx) {
192
212
  const o = await open(req, id);
193
213
  if ('error' in o)
194
214
  return o.error;
195
- const { a, reader, signIn } = o;
215
+ const { a, reader, signIn, recipient } = o;
196
216
  let { anonId } = o;
197
217
  const slot = typeof b.slot === 'string' ? b.slot : '';
198
218
  // Object.hasOwn, never a bracket read: `slots['__proto__']` or `slots['constructor']`
@@ -215,7 +235,9 @@ export function createStateRoutes(ctx) {
215
235
  const isComments = slot === COMMENTS_SLOT && commentsOn(a);
216
236
  let setCookie = null;
217
237
  let writer;
218
- if (reader)
238
+ if (recipient)
239
+ writer = { key: readerKeyFor.recipient(recipient.rid), name: recipient.label, anonymous: true };
240
+ else if (reader)
219
241
  writer = writerFor(reader);
220
242
  else {
221
243
  if (effectiveWriters(a.state, a.access, slot) !== 'anyone')
@@ -258,7 +280,7 @@ export function createStateRoutes(ctx) {
258
280
  return json({ error: bad }, 400);
259
281
  let value = b.value;
260
282
  if (isComments) {
261
- const seen = await visibleComments(a, writer.key, reader);
283
+ const seen = await visibleComments(a, writer.key, reader, !!recipient);
262
284
  const c = validateComment(b.value, (cid) => seen.get(cid) ?? null);
263
285
  if (!c.ok)
264
286
  return json({ error: c.error }, 400);
@@ -279,7 +301,7 @@ export function createStateRoutes(ctx) {
279
301
  const r = await ctx.state.replace({ artifactId: id, slot, readerKey: writer.key, entryId: entryId, value });
280
302
  if (!r)
281
303
  return json({ error: 'no answer of yours with that id' }, 404);
282
- const res = json(await viewBody(a, reader, writer.key));
304
+ const res = json(await viewBody(a, reader, writer.key, !!recipient));
283
305
  if (setCookie)
284
306
  res.headers.append('set-cookie', setCookie);
285
307
  return res;
@@ -299,7 +321,7 @@ export function createStateRoutes(ctx) {
299
321
  if ('full' in r)
300
322
  return json({ error: 'you have left the most answers this page takes here' }, 409);
301
323
  }
302
- const res = json(await viewBody(a, reader, writer.key));
324
+ const res = json(await viewBody(a, reader, writer.key, !!recipient));
303
325
  if (setCookie)
304
326
  res.headers.append('set-cookie', setCookie);
305
327
  return res;
@@ -396,6 +418,7 @@ export function createStateRoutes(ctx) {
396
418
  async function RESOLVE(req, { params }) {
397
419
  const publisher = isPublisherAuthed(req, ctx);
398
420
  let by = 'the publisher';
421
+ let actorEmail;
399
422
  if (!publisher) {
400
423
  const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
401
424
  if (!reader)
@@ -409,6 +432,7 @@ export function createStateRoutes(ctx) {
409
432
  if (!/^application\/json\b/i.test(req.headers.get('content-type') ?? ''))
410
433
  return json({ error: 'send JSON' }, 415);
411
434
  by = reader.name?.trim().split(/\s+/)[0] || 'the owner';
435
+ actorEmail = reader.email;
412
436
  }
413
437
  const raw = await req.text();
414
438
  if (Buffer.byteLength(raw, 'utf8') > 4096)
@@ -461,6 +485,19 @@ export function createStateRoutes(ctx) {
461
485
  const r = await ctx.state.setResolved({ artifactId: id, slot: COMMENTS_SLOT, entryId: root.id, resolved: resolution });
462
486
  if (!r)
463
487
  return json({ error: 'no comment with that id on this page' }, 404);
488
+ if (ctx.onResolve) {
489
+ const thread = [r, ...[...all.values()].filter((e) => e.value.parent === root.id)
490
+ .sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))];
491
+ try {
492
+ await ctx.onResolve({
493
+ artifactId: id, title: a.title, url: ctx.pageUrl(id), thread: { id: root.id, entries: thread }, resolution,
494
+ actor: { publisher, ...(actorEmail ? { email: actorEmail } : {}) }, at: resolution?.at ?? new Date().toISOString(),
495
+ });
496
+ }
497
+ catch (err) {
498
+ console.error('onResolve failed', err);
499
+ }
500
+ }
464
501
  return json({ id, entry: root.id, resolved: resolution });
465
502
  }
466
503
  return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.33.1",
3
+ "version": "0.35.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",