@supersuit/artifacts 0.42.0 → 0.43.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 CHANGED
@@ -3,6 +3,50 @@
3
3
  `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
4
  host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
5
 
6
+ ## 0.43.0
7
+
8
+ **A comment's id per reader in `/views`, the comments feed for one page, and a limit on new browsers
9
+ through a group link.** A consumer that keeps a local copy of a page's comments could not tell exactly
10
+ which ones it lacked: `/views` listed each reader's comments as `{ at, text }` with no id, and
11
+ `GET /api/comments` is the whole host's feed. Everything here is additive; a caller that ignores the
12
+ new fields and the new parameter sees 0.42.0.
13
+
14
+ - `GET /api/artifacts/<id>/views`: each comment gains `id` (the entry id the feed calls `entryId`) and
15
+ `parent` (the entry id it replies to, or null). Under `comment_name: required` every browser on a
16
+ group link is still listed under the link, each with its own id. Publisher replies stay out.
17
+ - `GET /api/comments?artifact=<page id>`: only that page's rows, read from that page's entries rather
18
+ than a scan of the host. `since`, `open`, `cursor` and `more` work over that page's comments. A value
19
+ that is not a page id is a 400; a well-formed id with no page is an empty feed. Without the parameter
20
+ the feed is unchanged.
21
+ - On a `comment_name: required` page, the first stored note of each new browser through one group
22
+ link (a reader key with no entry on the page yet, with or without an `artifact_anon` cookie) is
23
+ counted against the anonymous write limit, keyed by the link, and answers
24
+ `429 { error: "too many new readers through this link; try again in a minute" }` past 30 a minute.
25
+ Counted after validation, so a refused request spends nothing. A browser's later notes, a named link
26
+ and a page without the line are not counted.
27
+ - `summarizeViews` (a public export) takes comments with `id` and `parent` OPTIONAL: a caller passing
28
+ `{ readerKey, at, text }` compiles and runs as before, and gets `id: null, parent: null` on each item.
29
+ - README: the `id` and `parent` shapes, the `artifact` parameter, and the note that removing
30
+ `comment_name: required` later turns a group link back into one shared reader and hides every
31
+ per-browser note from its writer.
32
+
33
+ DETECTOR: a consumer that reads `comments[]` from `/views` and rebuilds each item field by field, or
34
+ that rejects unknown keys, meets two new ones: `rg -n "\.comments" <your consumer>`. A consumer that
35
+ paged the host-wide feed to find one page's comments can ask for that page alone. A group link handed
36
+ to a crowd that arrives all at once (more than 30 new browsers writing inside a minute) now sees 429 for
37
+ the rest of that minute. A caller already sending a stray `artifact=` parameter on the feed, ignored
38
+ until now, gets a filtered feed, or a 400 if it is not a page id.
39
+
40
+ REMEDY: read `id` and `parent` where present and tolerate them where not; add `&artifact=<id>` to a
41
+ per-page poll. Nothing is needed for the limit: the same people write again the next minute.
42
+
43
+ Tests: `src/routes/comment-ids.test.tsx` (ids equal the feed's `entryId`; a reply's `parent`; two
44
+ browsers on one group link under the link with distinct ids; no publisher reply; `artifact=` returns
45
+ one page's rows and composes with `since` and `open=1`; paging 300 comments of one page among 450 in
46
+ pages of 200 never returns another page's row; the unfiltered feed unchanged; a malformed id is 400
47
+ and an unknown page is empty; the limit trips for cookieless writes through one link, not for a
48
+ returning browser, a named link or a page without the line). Every test builds its own stores.
49
+
6
50
  ## 0.42.0
7
51
 
8
52
  **One page can say who sees its version number and past versions: `history: everyone | admins` in
package/README.md CHANGED
@@ -851,6 +851,19 @@ Authorization: Bearer <publish key>
851
851
  quote, region, body, transcript, audioUrl, at, resolved, link } ] }
852
852
  ```
853
853
 
854
+ **One page's feed (0.43.0).** `&artifact=<page id>` returns only that page's rows. `since`, `open`,
855
+ `cursor` and `more` behave as above, over that page's comments alone, so a caller paging one page
856
+ reaches its end without reading another page's rows. The feed is read from that page's own entries,
857
+ not from a scan of the host. A value that is not a page id is a `400` ("artifact must be a page id,
858
+ like abcd2345"); a well-formed id with no page, or no comments, answers an empty feed
859
+ (`{ now, comments: [], cursor: null, more: false }`), as an empty host does. Without the parameter
860
+ the feed is what it was.
861
+
862
+ ```
863
+ GET /api/comments?since=2026-09-28T12:00:00Z&artifact=abcd2345&open=1
864
+ Authorization: Bearer <publish key>
865
+ ```
866
+
854
867
  Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
855
868
  a 400). `name` is null for an anonymous reader; `reader` is `{ rid, label }` for a comment left through a per-reader link, else null; `by` is the name its writer typed, else null; `publisher` is true for a reply written through the reply route; `quote` is the text the comment is pinned to, or
856
869
  null for a box (`region: true`); `parent` is the comment a reply answers; `audioUrl` is a signed
@@ -911,7 +924,7 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
911
924
  |---|---|---|
912
925
  | `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
913
926
  | `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null } }`. `add` answers each new reader's link; `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window |
914
- | `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments. A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
927
+ | `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments. Each comment is `{ id, parent, at, text }` (see Comment ids in /views). A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
915
928
  | `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
916
929
 
917
930
  Config, both on `createArtifactRoutes`: `recipients` (a store from `createRecipientsStore(db, '<collection path>')`, or `memoryRecipientsStore()` in tests) and `recipientSecret`, a function returning the signing secret, read on every request. A secret shorter than 32 characters is treated as unset: per-reader pages stay shut and the routes answer 404 or 501. Signing with an empty secret throws.
@@ -978,6 +991,35 @@ the one shared reader (`r:<rid>`) and are shown to no browser afterwards. A name
978
991
  reader either way. The feedback window, the feed's `reader: { rid, label }` and the `/views` line
979
992
  stay per link: every browser on a group link shares them.
980
993
 
994
+ ### Comment ids in `/views` (0.43.0)
995
+
996
+ Each comment in a reader's `comments` array carries the entry id the comments feed calls
997
+ `entryId`, and the id of the comment it replies to:
998
+
999
+ ```json
1000
+ { "rid": "abcdefghijkl", "label": "Anyone with the link", "group": true, "comments": [
1001
+ { "id": "kq3vN8xTz2LwPd5mRa", "parent": null, "at": "2026-10-11T10:01:00.000Z", "text": "Which river?" },
1002
+ { "id": "Jm7cHy4bQe9sUf1xWk", "parent": "kq3vN8xTz2LwPd5mRa", "at": "2026-10-11T10:04:00.000Z", "text": "The north one." } ] }
1003
+ ```
1004
+
1005
+ `at` and `text` are as before. Under `comment_name: required` the notes of every browser on a
1006
+ group link are listed under that link's row, each with its own `id`. The publisher's replies are not
1007
+ listed. A caller that keeps a local copy of a page's comments compares these ids with
1008
+ `GET /api/comments?artifact=<id>` to see exactly which ones it lacks.
1009
+
1010
+ **Limit on new browsers.** On a page with `comment_name: required`, the first stored note of each new
1011
+ browser through one group link is counted against that link, per minute, on the anonymous write limit
1012
+ (30). A browser is new when its reader key has no entry on the page yet, whether or not it arrived with
1013
+ an `artifact_anon` cookie. Past the limit the state route answers
1014
+ `429 { error: "too many new readers through this link; try again in a minute" }`. Only a stored note is
1015
+ counted: a request refused for a missing name, a closed window or a malformed body spends nothing. A
1016
+ browser's later notes, a named link and a page without the line are never counted. "Note" here means
1017
+ any stored entry in any slot, and a browser that removes its only entry and writes again is new again.
1018
+
1019
+ **Removing the line later.** Taking `comment_name: required` off a page turns its group link back into
1020
+ one shared reader, and every note left per browser stops being shown to its writer: the notes stay with
1021
+ the publisher, under their per-browser keys.
1022
+
981
1023
  ## Broadcasts (`template: broadcast`)
982
1024
 
983
1025
  A broadcast is its own page kind, not a document with extra flags. It is one message sent to several people, each through their own link, and it renders as one card: the sender's words at the top, then the media in the order the words put it (a video with sound and controls, photos, a voice note's player, link cards), then that person's own note. None of the document's furniture: no table of contents, no version line, no read-along, and no page menu for a reader.
@@ -38,6 +38,8 @@ export type RecipientSummary = {
38
38
  plays: number;
39
39
  devices: number;
40
40
  comments: {
41
+ id: string | null;
42
+ parent: string | null;
41
43
  at: string;
42
44
  text: string;
43
45
  }[];
@@ -78,6 +80,8 @@ export declare function createRecipientsStore(db: Firestore, base: string): Reci
78
80
  /** One line per reader (never the owner), in the order they were added. */
79
81
  export declare function summarizeViews(rec: RecipientsRecord, views: ViewDoc[], comments: {
80
82
  readerKey: string;
83
+ id?: string | null;
84
+ parent?: string | null;
81
85
  at: string;
82
86
  text: string;
83
87
  }[]): RecipientSummary[];
@@ -179,7 +179,7 @@ export function summarizeViews(rec, views, comments) {
179
179
  activeSeconds: mine.reduce((n, v) => n + v.activeSeconds, 0),
180
180
  plays: mine.reduce((n, v) => n + Object.values(v.plays).reduce((a, b) => a + b, 0), 0),
181
181
  devices: new Set(mine.map((v) => v.device)).size,
182
- comments: comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid).map((c) => ({ at: c.at, text: c.text })),
182
+ comments: comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid).map((c) => ({ id: c.id ?? null, parent: c.parent ?? null, at: c.at, text: c.text })),
183
183
  };
184
184
  });
185
185
  }
@@ -136,7 +136,7 @@ export function createRecipientsRoutes(given) {
136
136
  // A page nobody has been added to yet has no readers, which is an answer and not a missing page.
137
137
  const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
138
138
  const entries = ctx.state ? await ctx.state.entries(id) : [];
139
- const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, at: e.at, text: String(e.value?.body ?? '') }));
139
+ const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, id: e.id, parent: typeof e.value?.parent === 'string' ? e.value.parent : null, at: e.at, text: String(e.value?.body ?? '') }));
140
140
  return json({ id, expireAt: rec.expireAt, keep: rec.keep, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments) });
141
141
  }
142
142
  /** Reader text goes with the page: every comment and answer on it, whoever wrote it. */
@@ -5,7 +5,8 @@
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
  // GET /api/comments?since=<ISO> publish key; every shared comment on any page after a time
8
- // (&open=1: leave out threads that are resolved)
8
+ // (&open=1: leave out threads that are resolved;
9
+ // &artifact=<id>: only that page's comments)
9
10
  // POST /api/artifacts/<id>/resolve publish key, or the owner or an admin signed in; resolve or
10
11
  // reopen one comment thread { entry, resolved?, note?, by? }
11
12
  //
@@ -368,6 +369,16 @@ export function createStateRoutes(ctx) {
368
369
  if (!replacing)
369
370
  return json({ error: 'this page is not taking more answers here' }, 409);
370
371
  }
372
+ // A group link under `comment_name: required` makes each browser its own reader, so one link
373
+ // could start any number of them. The FIRST STORED write of a reader key the page has not seen
374
+ // is counted against the link, whether it came with a cookie or not (any 24 characters pass as
375
+ // one), and only here, after validation, so a refused request spends nothing. A key that
376
+ // already has an entry is never counted again.
377
+ if (recipient?.perBrowser && !(await ctx.state.entries(id)).some((e) => e.readerKey === writer.key)) {
378
+ const n = await ctx.state.countAnonWrite(id, `gl.${recipient.rid}`, Math.floor(Date.now() / 60000));
379
+ if (n > ANON_WRITES_PER_MINUTE)
380
+ return json({ error: 'too many new readers through this link; try again in a minute' }, 429);
381
+ }
371
382
  const r = op === 'set'
372
383
  ? await ctx.state.set({ artifactId: id, slot, writer, value })
373
384
  : await ctx.state.append({ artifactId: id, slot, writer, value });
@@ -424,10 +435,17 @@ export function createStateRoutes(ctx) {
424
435
  const openOnly = openParam === '1' || openParam === 'true';
425
436
  if (!ctx.state)
426
437
  return json({ error: 'this host keeps no answers' }, 501);
427
- if (!ctx.state.slotSince)
438
+ // One page's feed (0.43.0): read that page's entries alone, so a caller paging a single page
439
+ // never reads, or is handed, another page's comments.
440
+ const only = req.nextUrl.searchParams.get('artifact');
441
+ if (only !== null && !ARTIFACT_ID_RE.test(only))
442
+ return json({ error: 'artifact must be a page id, like abcd2345' }, 400);
443
+ if (only === null && !ctx.state.slotSince)
428
444
  return json({ error: 'this host cannot list comments by time' }, 501);
429
445
  const now = new Date().toISOString();
430
- const entries = await ctx.state.slotSince(COMMENTS_SLOT, since, FEED_MAX);
446
+ const entries = only !== null
447
+ ? (await ctx.state.entries(only)).filter((e) => e.slot === COMMENTS_SLOT && e.at > since).sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id)).slice(0, FEED_MAX)
448
+ : await ctx.state.slotSince(COMMENTS_SLOT, since, FEED_MAX);
431
449
  const pages = new Map();
432
450
  const pageOf = (id) => { if (!pages.has(id))
433
451
  pages.set(id, ctx.store.get(id)); return pages.get(id); };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.42.0",
3
+ "version": "0.43.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",