@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +63 -1
  3. package/lib/artifacts/anchor.d.ts +33 -0
  4. package/lib/artifacts/anchor.js +93 -0
  5. package/lib/artifacts/comments.d.ts +38 -0
  6. package/lib/artifacts/comments.js +57 -0
  7. package/lib/artifacts/front-matter.d.ts +7 -0
  8. package/lib/artifacts/front-matter.js +19 -3
  9. package/lib/artifacts/index.d.ts +4 -1
  10. package/lib/artifacts/index.js +4 -1
  11. package/lib/artifacts/personal-store.d.ts +29 -0
  12. package/lib/artifacts/personal-store.js +100 -0
  13. package/lib/artifacts/render.js +60 -52
  14. package/lib/artifacts/state-store.d.ts +10 -0
  15. package/lib/artifacts/state-store.js +19 -0
  16. package/lib/artifacts/state.d.ts +7 -2
  17. package/lib/artifacts/state.js +7 -3
  18. package/lib/artifacts/store.d.ts +4 -0
  19. package/lib/artifacts/store.js +6 -0
  20. package/lib/artifacts/widgets.d.ts +4 -0
  21. package/lib/artifacts/widgets.js +70 -0
  22. package/lib/reader/artifact-reader.d.ts +5 -1
  23. package/lib/reader/artifact-reader.js +23 -17
  24. package/lib/reader/browser-reader.d.ts +8 -0
  25. package/lib/reader/browser-reader.js +171 -0
  26. package/lib/reader/comment-card.d.ts +18 -0
  27. package/lib/reader/comment-card.js +36 -0
  28. package/lib/reader/comment-layer.d.ts +17 -0
  29. package/lib/reader/comment-layer.js +384 -0
  30. package/lib/reader/comment-place.d.ts +45 -0
  31. package/lib/reader/comment-place.js +117 -0
  32. package/lib/reader/device-notes.d.ts +6 -0
  33. package/lib/reader/device-notes.js +35 -0
  34. package/lib/reader/index.d.ts +8 -0
  35. package/lib/reader/index.js +10 -0
  36. package/lib/reader/player-bar.d.ts +18 -0
  37. package/lib/reader/player-bar.js +16 -0
  38. package/lib/reader/speech-plan.d.ts +20 -0
  39. package/lib/reader/speech-plan.js +67 -0
  40. package/lib/routes/artifacts.d.ts +23 -0
  41. package/lib/routes/artifacts.js +26 -11
  42. package/lib/routes/personal-routes.d.ts +24 -0
  43. package/lib/routes/personal-routes.js +100 -0
  44. package/lib/routes/state-routes.d.ts +3 -0
  45. package/lib/routes/state-routes.js +71 -12
  46. 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
- slots: stateView(a.state, await ctx.state.entries(a.id), readerKey),
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 remove' }, 400);
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: typeof b.entry === 'string' ? b.entry : undefined });
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: typeof b.entry === 'string' ? b.entry : undefined });
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: b.value })
186
- : await ctx.state.append({ artifactId: id, slot, writer, value: b.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.9.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/artifact-reader.d.ts",
42
- "default": "./lib/reader/artifact-reader.js"
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",