@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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,117 @@
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.41.0
7
+
8
+ **A reader who may not see a page's history is no longer told its version number.** The line under
9
+ the summary read "Version N · Updated <minute>" to everyone. A page sent to someone reads as a
10
+ message, and "Version 9" on a message tells its reader about eight drafts they were never meant to
11
+ think about. The number now goes with the history: whoever `history` lets see past versions
12
+ (the owner and `admins` by default, every reader under `history: 'everyone'`) sees
13
+ "Version N · Updated <minute>", and everyone else sees "Updated <minute>". There is no new config
14
+ key; the existing `history` setting is the toggle, and its default is off for readers.
15
+
16
+ - `CoverProps` gains `showVersion: boolean`, true exactly when this reader may see the number. The
17
+ `meta` node a Cover places already follows it. `version` is still handed over unchanged, so a
18
+ Cover that draws the number itself keeps drawing it until it reads `showVersion`.
19
+ - A past version's page (`/<id>/v/<n>`) is unchanged: only a reader who may see the history reaches
20
+ it, so it still names its version.
21
+
22
+ DETECTOR: a host whose pack has a `Cover` that renders `version`: `rg -n "version" <your packs>`.
23
+ Such a Cover still shows the number to every reader after this release, because the shell cannot
24
+ take a prop away from a pack. A host that relied on every reader seeing the version line and does
25
+ not set `history: 'everyone'` will find the number gone for signed-out readers.
26
+
27
+ REMEDY: in a Cover that draws the number, draw it only when `showVersion` is true. To show every
28
+ reader the number (and the history with it), set `history: 'everyone'`.
29
+
30
+ Tests: `src/routes/page-menu.test.tsx` (a visitor, a reader and a stranger get "Updated" with no
31
+ version number; an admin and `history: 'everyone'` get "Version 2 · Updated"),
32
+ `src/routes/extension-points.test.tsx` (a Cover is handed `showVersion` false for a reader and true
33
+ under `history: 'everyone'`), `src/routes/versions.test.tsx` and `src/routes/broadcast.test.tsx`
34
+ (the two tests that pinned the old line now pin the new one). All three new tests failed against
35
+ 0.40.1.
36
+
37
+ ## 0.40.1
38
+
39
+ **0.40.0 was tagged and never published: this is the first release carrying it.** The publish
40
+ workflow runs the tests before it publishes, and one test failed there, so nothing reached npm
41
+ under 0.40.0. Everything in the 0.40.0 entry below ships in this version, unchanged.
42
+
43
+ - The failing test was the comment card's, and the defect was in the tests, not the card: a name
44
+ one test saved was still remembered by the next wherever the runtime has a working
45
+ `localStorage` (Node 24, which the workflow uses) and gone where it does not (Node 25), so
46
+ "asks for a first name before sending" passed on one and failed on the other. Every card test
47
+ now starts with its own empty storage.
48
+
49
+ DETECTOR: `npm view @supersuit/artifacts versions --json` lists no `0.40.0`. A host pinned to
50
+ exactly `0.40.0` cannot install.
51
+
52
+ REMEDY: take `0.40.1`. There is no code difference from what 0.40.0 described.
53
+
54
+ Tests: `src/reader/comment-card.test.tsx` gives each test a fresh storage; the old file fails three
55
+ tests when storage persists between tests, and the new one passes either way.
56
+
57
+ ## 0.40.0
58
+
59
+ **A per-reader page can be a feedback round: a window per reader, a name on every note where the
60
+ page asks for one, a reply the commenter can see.** Nothing changes for a page that uses none of it.
61
+
62
+ - A reader carries `closesAt`. The recipients route sets it (`add[].closes`), moves or clears it
63
+ (`closes: { rids, at }`), and reports it (`closesAt` in the listing and in `/views`). Past it the
64
+ state route refuses a new shared comment or an edit with `403 { closed: true }`; the page and the
65
+ reader's own comments stay readable, and deleting their own still works. `RecipientsStore.setCloses`
66
+ is optional, so a host's own store keeps working (the route answers 501 to a `closes` change
67
+ without it).
68
+ - The page draws it: whose draft, the publisher's note to that reader, and a live countdown, in
69
+ place of the `For <label>: <note>` line. A reader with no window sees what they saw before.
70
+ - `comment_name: required` (front matter, per-reader pages only): a group reader's shared comment
71
+ needs a typed first name (`400 { needsName: true }` without one); a reader on a named link is
72
+ never asked, in the card or the reply form, and a typed name is dropped.
73
+ - Under `comment_name: required`, and only there, **each browser on a group link is its own
74
+ reader**. Until now a group link was one reader (`r:<rid>`): everyone holding it saw every note
75
+ left through it as their own, could edit and delete them, and saw the publisher's reply to any of
76
+ them. With the line, a browser's notes are stored under `r:<rid>.<24 hex>`, which the server
77
+ derives (an HMAC keyed by the recipient secret) from the page, the link's verified rid and the
78
+ anonymous-reader cookie (`artifact_anon`) it sets with that browser's first note. A client cannot
79
+ name a key or work out another browser's, and the stored key is not the cookie. A browser that
80
+ clears its cookies is a new reader. A named link is unchanged, and so is a group link on a page
81
+ without the line (one shared reader, as in 0.39.0). The window, the feed's `reader.rid` and the
82
+ `/views` comments stay per link.
83
+ - `POST /api/artifacts/<id>/reply` (publish key): the publisher's one reply in a thread, optionally
84
+ pinned to the passage that changed. On a per-reader page only the thread's own writer is shown it
85
+ (under `comment_name: required`, only the browser that wrote it). It never reopens a resolved
86
+ thread under `open=1`.
87
+ - A `template: broadcast` page with comments on hands its comment layer the window and the name
88
+ rule, as a document does: a group reader is asked for a first name, and a closed window stops
89
+ offering Share. A broadcast draws no banner.
90
+ - An owner added with `closes` gets no window (the owner never has one).
91
+ - Feed rows gain `reader: { rid, label } | null`, `by` and `publisher`. No field is removed.
92
+
93
+ DETECTOR: on a page with at least one reader,
94
+ `curl -s -H "Authorization: Bearer $KEY" "$HOST/api/artifacts/<id>/recipients" | grep -c closesAt`
95
+ answers 0 on a host older than this (a page with no readers answers 0 on any version). For the
96
+ group link: a host is affected if it has sent one group link to people who should not read each
97
+ other's notes. `GET /api/artifacts/<id>/responses` shows it: rows in the `comments` slot whose
98
+ `reader` is the bare `r:<rid>` of a link `GET /recipients` lists with `group: true` were written as
99
+ the one shared reader.
100
+
101
+ REMEDY: take 0.40.0 and add the reply route file (README, "Replying as the publisher"). A host with
102
+ its own `RecipientsStore` adds `setCloses` to use feedback windows. A publisher that strips unknown
103
+ front matter keys lets `comment_name` through. To give the people on a group link their own notes,
104
+ republish the page with `comment_name: required`; the host needs nothing else (the cookie is the
105
+ one anonymous readers already get). Notes left through that link before the republish stay with
106
+ the publisher and are shown to no browser afterwards. Anything that matched a reader key to a link
107
+ with `key === 'r:' + rid` now reads the rid with `ridOfReaderKey(key)` instead, or it misses the
108
+ notes of a browser on a group link.
109
+
110
+ Tests: `recipients-store.test.ts`, `recipients-api.test.tsx`, `recipients.test.ts`,
111
+ `front-matter.test.ts`, `store.test.ts`, `comments.test.tsx` (closed window, names, reply
112
+ visibility, two browsers on one group link with and without the line), `comments-feed.test.tsx`,
113
+ `feedback-window.test.tsx`, `comment-card.test.tsx`, `comment-layer.test.tsx`,
114
+ `per-reader.test.tsx`, `broadcast-feedback.test.tsx`; `unchanged-default.test.tsx` and
115
+ `broadcast.test.tsx` unedited.
116
+
6
117
  ## 0.39.0
7
118
 
8
119
  **A session demo's frame fits its player, and is wider than the reading column on a wide screen.**
package/README.md CHANGED
@@ -194,7 +194,8 @@ before 0.31.0 has no `declared` and takes its file on its next publish, as every
194
194
 
195
195
  ## Version history
196
196
 
197
- Every page names its version under the summary, "Version 4 · Updated <minute>", and the page
197
+ Every page says when it was last updated under the summary, and to its admins that line also names
198
+ the version: "Version 4 · Updated <minute>". The page
198
199
  menu's **Version history** opens a panel listing every version newest first: its number, the minute it went live in
199
200
  the reader's own time zone, and its change note. The panel is drawn over the page (a sheet on a
200
201
  phone, a side panel on a desk), so opening it moves no text. Each past version is readable at
@@ -205,9 +206,15 @@ the same password or confidential door as the page itself. A version that does n
205
206
  **Past versions are for admins by default (0.21.0).** An earlier draft can hold what the current
206
207
  one was edited to remove, so only the page's admins see Version history in the page menu or open
207
208
  `/<id>/v/<n>`: the reader signed in as `ownerEmail` or any address in `admins` (case does not
208
- matter). Everyone else still sees "Version N · Updated <minute>", and a past version is a 404 to
209
- them. With neither configured, nobody does. `history: 'everyone'` in the config restores the old
210
- behaviour, where every reader who can open the page reads its history.
209
+ matter). A past version is a 404 to everyone else. With neither configured, nobody does.
210
+ `history: 'everyone'` in the config restores the old behaviour, where every reader who can open
211
+ the page reads its history.
212
+
213
+ **The version number goes with the history (0.41.0).** A page sent to someone reads as a message,
214
+ and "Version 9" on a message tells its reader about eight drafts. So whoever may not see past
215
+ versions sees only "Updated <minute>" under the summary; admins, and every reader under
216
+ `history: 'everyone'`, see "Version N · Updated <minute>". A pack's `Cover` is handed
217
+ `showVersion` alongside `version`, and draws the number itself only when it is true.
211
218
 
212
219
  ```ts
213
220
  createArtifactRoutes({ ..., ownerEmail: 'owner@example.com', admins: ['editor@example.com'] })
@@ -253,6 +260,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
253
260
  | `definitions` | Terms defined inline (below). A republish without it removes them |
254
261
  | `links` | `per-reader`: the page opens only through a link made for one reader (see Per-reader links). Sticky: a republish whose file leaves the line out changes nothing |
255
262
  | `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
263
+ | `comment_name` | `required`: on a per-reader page, a comment through a group link needs a typed first name, and each browser on a group link is its own reader (see Per-reader links). Needs `links: per-reader` |
256
264
  | `change` | One line saying what this version changed (see Version history) |
257
265
  | `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
258
266
 
@@ -748,6 +756,36 @@ type Ctx = { params: Promise<{ id: string }> }
748
756
  export function POST(request: NextRequest, ctx: Ctx) { return artifacts.RESOLVE(request, ctx) }
749
757
  ```
750
758
 
759
+
760
+ ### Replying as the publisher (0.40.0)
761
+
762
+ POST /api/artifacts/<id>/reply
763
+ Authorization: Bearer <publish key or publisher pass>
764
+ Content-Type: application/json
765
+
766
+ { "entry": "<entryId>", "body": "Named the river in the second paragraph.", "by": "Pat", "quote": "the river Ash" }
767
+
768
+ 200 { id, entry, parent, existing }
769
+
770
+ One reply per thread when calls do not overlap. `entry` may name a reply: the thread it belongs to
771
+ is answered. A second call for a thread already answered changes nothing and names the first reply
772
+ back with `existing: true`, so a caller that retries does not post twice. The check is a read and
773
+ then an append, so two overlapping calls can both post. `by` defaults to "the publisher". `quote`
774
+ (text on the page, at most 500 characters) pins the reply to the passage that changed, and the
775
+ thread then offers "Show what changed"; without it the reply sits where its thread does. On a
776
+ per-reader page the reply is shown to the reader whose comment started the thread and to no other
777
+ reader. It is allowed after that reader's window has closed. On a page that is not per-reader
778
+ and whose comments are owner-only (`comments_visible: owner`), `REPLY` still answers 200 and stores
779
+ the reply, but the commenter is not shown it: only the owner, the admins and the feed are. The feed lists it with
780
+ `publisher: true`, and a publisher reply never reopens a resolved thread under `open=1`. 404 for a
781
+ page or comment that is not there, 501 with no `state`.
782
+
783
+ // app/api/artifacts/[id]/reply/route.ts
784
+ import type { NextRequest } from 'next/server'
785
+ import { artifacts } from '@/lib/artifacts'
786
+ type Ctx = { params: Promise<{ id: string }> }
787
+ export function POST(request: NextRequest, ctx: Ctx) { return artifacts.REPLY(request, ctx) }
788
+
751
789
  ### Telling commenters their comments were resolved (`createResolveDigests`)
752
790
 
753
791
  When threads are resolved, each signed-in reader whose comments were in them can get ONE email per
@@ -800,12 +838,12 @@ appear (the privacy test covers this handler like every other).
800
838
  GET /api/comments?since=2026-09-28T12:00:00Z
801
839
  Authorization: Bearer <publish key>
802
840
 
803
- 200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name, quote, region,
804
- body, transcript, audioUrl, at, resolved, link } ] }
841
+ 200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name, reader, by, publisher,
842
+ quote, region, body, transcript, audioUrl, at, resolved, link } ] }
805
843
  ```
806
844
 
807
845
  Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
808
- a 400). `name` is null for an anonymous reader; `quote` is the text the comment is pinned to, or
846
+ 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
809
847
  null for a box (`region: true`); `parent` is the comment a reply answers; `audioUrl` is a signed
810
848
  URL to the recording, when there is one and the host can sign it. `link` is the page with
811
849
  `#comment-<entryId>`: opening it scrolls to the pin and opens its thread (a reply opens its
@@ -863,7 +901,7 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
863
901
  | Route | Who | What |
864
902
  |---|---|---|
865
903
  | `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
866
- | `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group? }], revoke?: [rid] or '*', expires?: ISO time or null }`. `add` answers each new reader's link; `expires: null` is Keep |
904
+ | `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 |
867
905
  | `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 |
868
906
  | `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
869
907
 
@@ -879,6 +917,58 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
879
917
 
880
918
  `expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. With a private bucket configured it deletes from both buckets. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
881
919
 
920
+ ### A feedback window (0.40.0)
921
+
922
+ A reader may carry `closesAt`, the time their feedback window closes. It is per reader, set when
923
+ the reader is added (`add[].closes`) or afterwards (`closes: { rids, at }`, where `rids` is a list
924
+ of reader ids or `'*'` for every reader but the owner, and `at: null` clears the window). The owner
925
+ never has one: `closes` on an `add` entry with `owner: true` is dropped. It is
926
+ separate from the page's `expires`: a reader past their window still opens the page and reads
927
+ their own comments. A time in the past closes the window now, and a window that cannot be read as
928
+ a time counts as closed. `closes` answers `windowed`, the reader ids it changed, beside `added` and
929
+ `revoked`. A host with its own `RecipientsStore` needs `setCloses` for this; without it the route
930
+ answers 501 to a `closes` change.
931
+
932
+ Once the window is closed, a new shared comment or an edit is refused with
933
+ `403 { error: "Feedback on this draft is closed. Your note was not sent.", closed: true }`.
934
+ Deleting their own comment (`remove`) still works. `GET /state` answers
935
+ `feedback: { closesAt, closed }` for a reader who has a window, and `GET /recipients` and
936
+ `GET /views` carry each reader's `closesAt` (null when there is none).
937
+
938
+ The page says so. A reader with a window sees, above the title, "A draft from <owner>, for
939
+ <label>" (a group link names nobody), the note the publisher wrote for them, and how long is left,
940
+ counted in days from two days up, then hours from two hours, then minutes, and live while the page
941
+ stays open. Past it the line reads "Feedback is closed. You can still read the draft and your own
942
+ notes." A reader with no window sees the `For <label>: <note>` line as before. In a thread, a
943
+ comment of theirs that nobody has answered shows "Waiting for a reply." while the window exists.
944
+
945
+ ### A name on every note: `comment_name: required` (0.40.0)
946
+
947
+ A per-reader page that says `comment_name: required` in its front matter puts a name on every
948
+ shared comment. A `group` reader (one link shared by several people) must type a first name:
949
+ without one the state route answers `400 { error: "Add your first name, so your note has a name on
950
+ it.", needsName: true }`, and the comment card, the reply form and a signed-in reader's card all
951
+ ask before they send. A reader on a named link is never asked, and a name sent for them is
952
+ dropped: the label is who they are. The line needs `links: per-reader` (a page without it is
953
+ refused), and a republish that leaves it out removes it. A page without the line takes names
954
+ exactly as before.
955
+
956
+ **Who a group link is.** Without `comment_name: required`, a group link is one shared reader:
957
+ everyone who opens it sees every note left through it as their own, can edit or delete any of
958
+ them, and sees the publisher's reply to any of them. Do not send one to people who should not read
959
+ each other's notes. With the line, each browser on a group link is its own reader: it sees, edits
960
+ and deletes only the notes written from that browser, and is shown only the publisher's replies to
961
+ those. The browser is known by the anonymous-reader cookie (`artifact_anon`, HttpOnly, a year), set
962
+ with its first note, and its notes are stored under a key the server derives from the link and that
963
+ cookie (`r:<rid>.<24 hex>`), never under the cookie itself; `ridOfReaderKey(key)` reads the
964
+ link's rid out of either shape. A browser that clears its cookies, or a
965
+ second device, is a new reader: the notes it left stay with the publisher and are no longer shown
966
+ to it. So does a reader who signs in on another page of the same host, since signing in clears the
967
+ anonymous cookie. Notes left through a group link before the page gained the line were written as
968
+ the one shared reader (`r:<rid>`) and are shown to no browser afterwards. A named link is one
969
+ reader either way. The feedback window, the feed's `reader: { rid, label }` and the `/views` line
970
+ stay per link: every browser on a group link shares them.
971
+
882
972
  ## Broadcasts (`template: broadcast`)
883
973
 
884
974
  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.
@@ -902,7 +992,7 @@ What the kind decides, so the file does not have to:
902
992
  - **Per-reader links, always.** `links: per-reader` is implied. The kind is sticky like `links:`: a republish that leaves `template:` out stays a broadcast, and one that says `document` is refused (409). `access`, `password` and `comments_visible: readers` are refused in words.
903
993
  - **Private media wherever there is media.** `media: private` is implied as soon as the body carries a stored private file or a local one (a remote URL alone stores nothing, so it does not count). A body that points at a local file (one the publisher will upload) must still say `media: private`, because that line is what makes the publisher upload it privately; without it the page is refused before anything is uploaded.
904
994
  - **Expires 90 days after it is published** (`BROADCAST_TTL_MS`), set by `POST /api/artifacts` when the page has no date yet, and answered as `expireAt` in its response. A date set on the recipients route stands, a republish never moves it, and Keep (`expires: null`) holds.
905
- - **Comments off** unless the file turns them on. **No narration player**, ever: a voice note is its own audio (`![Voice note](note.m4a)` draws a player). Write `voice: none` so the publisher does not record one.
995
+ - **Comments off** unless the file turns them on. With them on, a reader's feedback window and `comment_name: required` work as on a document: past the window the card stops offering Share, and a group reader is asked for a first name. A broadcast draws no banner, though: no "A draft from" line, no countdown and no "Feedback is closed" line above the card. **No narration player**, ever: a voice note is its own audio (`![Voice note](note.m4a)` draws a player). Write `voice: none` so the publisher does not record one.
906
996
  - **Silent view tracking**: the quiet view beacon, as on every per-reader page.
907
997
 
908
998
  Routes:
@@ -16,6 +16,12 @@ export declare function mergeCommentsState(state: StateConfig | undefined, comme
16
16
  };
17
17
  export declare const MAX_COMMENT_CHARS = 4000;
18
18
  export declare const MAX_COMMENT_NAME_CHARS = 80;
19
+ /** What a reader is told when their feedback window has closed (0.40.0). The card shows it under the
20
+ * words they typed, which stay in the box. */
21
+ export declare const FEEDBACK_CLOSED = "Feedback on this draft is closed. Your note was not sent.";
22
+ /** What a reader on an open (group) link is told when a comment arrives with no name, on a page
23
+ * that says `comment_name: required` (0.40.0). */
24
+ export declare const NAME_NEEDED = "Add your first name, so your note has a name on it.";
19
25
  export declare const COMMENT_TRANSCRIPTS: readonly ["browser", "host", "typed"];
20
26
  /** One shared comment or personal note. `parent` makes it a reply to a top-level comment (one
21
27
  * level only). `audio` and `transcript` are filled by voice memos; a memo may have an empty body. */
@@ -28,6 +34,9 @@ export type CommentValue = {
28
34
  transcript?: (typeof COMMENT_TRANSCRIPTS)[number];
29
35
  /** The name a reader typed so the owner can credit them. A signed-in reader's account name wins. */
30
36
  by?: string;
37
+ /** On a reply only: the publisher wrote it, through the reply route with the publish key (0.40.0).
38
+ * Never set by a reader: `validateComment` refuses the key. */
39
+ publisher?: true;
31
40
  /** On a thread's top-level comment only: the owner, an admin or the publisher marked the thread
32
41
  * done (0.33.0). Written by the resolve route, never by a reader: `validateComment` refuses the
33
42
  * key, and an edit keeps whatever the comment already carried. */
@@ -15,6 +15,12 @@ export function mergeCommentsState(state, comments, visible) {
15
15
  }
16
16
  export const MAX_COMMENT_CHARS = 4000;
17
17
  export const MAX_COMMENT_NAME_CHARS = 80;
18
+ /** What a reader is told when their feedback window has closed (0.40.0). The card shows it under the
19
+ * words they typed, which stay in the box. */
20
+ export const FEEDBACK_CLOSED = 'Feedback on this draft is closed. Your note was not sent.';
21
+ /** What a reader on an open (group) link is told when a comment arrives with no name, on a page
22
+ * that says `comment_name: required` (0.40.0). */
23
+ export const NAME_NEEDED = 'Add your first name, so your note has a name on it.';
18
24
  export const COMMENT_TRANSCRIPTS = ['browser', 'host', 'typed'];
19
25
  export const MAX_RESOLVE_NOTE_CHARS = 280;
20
26
  /** The resolve route's check on a note: absent, null or blank means no note; otherwise one line of
@@ -60,6 +60,9 @@ export type ArtifactMeta = {
60
60
  comments?: CommentsMode;
61
61
  /** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
62
62
  commentsVisible?: CommentsVisible;
63
+ /** `comment_name: required` (0.40.0): on a per-reader page, a comment through a group link must
64
+ * carry a typed first name, and a named link is never asked for one. Opt-in; content, like `toc:`. */
65
+ commentName?: 'required';
63
66
  };
64
67
  /** What a page is. A broadcast is its own kind, not a document with extra flags: it renders as a
65
68
  * message card, opens only through per-reader links, keeps its media private, has comments off
@@ -19,7 +19,7 @@ const PRIVATE_REF_IN_BODY = /^private:/;
19
19
  export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
20
20
  /** The longest `to:` a page may carry: a name or two, never a sentence. */
21
21
  export const MAX_TO_CHARS = 60;
22
- const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media']);
22
+ const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media', 'comment_name']);
23
23
  export function parseArtifactSource(text,
24
24
  /** The kind a file that leaves `template:` out is read as: the stored page's kind on a
25
25
  * republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
@@ -87,6 +87,11 @@ opts = {}) {
87
87
  return { ok: false, error: 'media must be private' };
88
88
  meta.privateMedia = true;
89
89
  }
90
+ if (d.comment_name !== undefined && d.comment_name !== null) {
91
+ if (d.comment_name !== 'required')
92
+ return { ok: false, error: 'comment_name must be required' };
93
+ meta.commentName = 'required';
94
+ }
90
95
  if (d.theme !== undefined) {
91
96
  if (!THEME_MODES.includes(d.theme))
92
97
  return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
@@ -145,6 +150,8 @@ opts = {}) {
145
150
  // People on one broadcast never see each other's comments, so a per-reader page cannot share them.
146
151
  if (meta.links === 'per-reader' && meta.commentsVisible === 'readers')
147
152
  return { ok: false, error: 'a per-reader page keeps each reader\'s comments to the owner: drop comments_visible: readers' };
153
+ if (meta.commentName && meta.links !== 'per-reader')
154
+ return { ok: false, error: 'comment_name: required needs links: per-reader' };
148
155
  // Widgets declare their own slots, so a notes block (and a checklist with send) is merged into state: here, before the
149
156
  // page is stored, and the state API and the republish shape checks see it like any slot.
150
157
  // Errors name the line in the FILE: the body's lines are counted after the front matter's.
@@ -24,6 +24,6 @@ export { deepgramTranscriber, openaiTranscriber, type Transcriber } from './tran
24
24
  export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
25
25
  export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
26
26
  export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
27
- export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
27
+ export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
28
28
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
29
29
  export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
@@ -24,6 +24,6 @@ export { deepgramTranscriber, openaiTranscriber } from './transcribe.js';
24
24
  export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
25
25
  export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey } from './settings.js';
26
26
  // Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
27
- export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
27
+ export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
28
28
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
29
29
  export { migrateMemosToPrivate, MEMO_KEY } from './migrate-memos.js';
@@ -7,6 +7,7 @@ export type Recipient = {
7
7
  group?: true;
8
8
  addedAt: string;
9
9
  revokedAt?: string;
10
+ closesAt?: string | null;
10
11
  };
11
12
  export type RecipientsRecord = {
12
13
  recipients: Record<string, Recipient>;
@@ -29,6 +30,7 @@ export type RecipientSummary = {
29
30
  label: string;
30
31
  group: boolean;
31
32
  revoked: boolean;
33
+ closesAt: string | null;
32
34
  opened: boolean;
33
35
  firstOpenAt: string | null;
34
36
  cameBack: boolean;
@@ -40,15 +42,21 @@ export type RecipientSummary = {
40
42
  text: string;
41
43
  }[];
42
44
  };
45
+ type NewRecipient = {
46
+ label: string;
47
+ note?: string;
48
+ owner?: boolean;
49
+ group?: boolean;
50
+ closesAt?: string;
51
+ };
43
52
  export interface RecipientsStore {
44
53
  get(artifactId: string): Promise<RecipientsRecord | null>;
45
- add(artifactId: string, input: {
46
- label: string;
47
- note?: string;
48
- owner?: boolean;
49
- group?: boolean;
50
- }[]): Promise<Recipient[]>;
54
+ add(artifactId: string, input: NewRecipient[]): Promise<Recipient[]>;
51
55
  revoke(artifactId: string, rids: string[] | '*'): Promise<string[]>;
56
+ /** Set (an ISO time) or clear (null) the feedback window of the named readers, or of every reader
57
+ * with '*'. Never the owner's. Returns the rids changed. Optional so a host's own store keeps
58
+ * working; without it the recipients route answers 501 to a `closes` change. */
59
+ setCloses?(artifactId: string, rids: string[] | '*', closesAt: string | null): Promise<string[]>;
52
60
  setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
53
61
  useToken(key: string, expiresAt: number): Promise<boolean>;
54
62
  recordView(v: {
@@ -73,3 +81,4 @@ export declare function summarizeViews(rec: RecipientsRecord, views: ViewDoc[],
73
81
  at: string;
74
82
  text: string;
75
83
  }[]): RecipientSummary[];
84
+ export {};
@@ -1,4 +1,4 @@
1
- import { newRid } from './recipients.js';
1
+ import { newRid, ridOfReaderKey } from './recipients.js';
2
2
  export const CAME_BACK_MS = 3_600_000;
3
3
  const LABEL_MAX = 60;
4
4
  const NOTE_MAX = 2000;
@@ -13,7 +13,9 @@ function makeRecipients(input, taken) {
13
13
  rid = newRid();
14
14
  taken.add(rid);
15
15
  const note = typeof i.note === 'string' && i.note.trim() ? i.note.trim().slice(0, NOTE_MAX) : undefined;
16
- return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}) };
16
+ // The owner never carries a window: setCloses skips them and the page hides the closed state
17
+ // from them, so one stored here would offer Share on their link and then refuse it.
18
+ return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}), ...(typeof i.closesAt === 'string' && !i.owner ? { closesAt: i.closesAt } : {}) };
17
19
  });
18
20
  }
19
21
  function revokeIn(rec, rids) {
@@ -29,6 +31,16 @@ function revokeIn(rec, rids) {
29
31
  rec.revokedAll = at;
30
32
  return hit;
31
33
  }
34
+ function closeIn(rec, rids, closesAt) {
35
+ const hit = [];
36
+ for (const r of Object.values(rec.recipients)) {
37
+ if (r.owner || (rids !== '*' && !rids.includes(r.rid)))
38
+ continue;
39
+ r.closesAt = closesAt;
40
+ hit.push(r.rid);
41
+ }
42
+ return hit;
43
+ }
32
44
  function addView(prev, v) {
33
45
  const doc = prev ? { ...prev, plays: { ...prev.plays } } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
34
46
  doc.activeSeconds += Math.max(0, Math.min(60, v.addSeconds));
@@ -54,6 +66,7 @@ export function memoryRecipientsStore() {
54
66
  return structuredClone(out);
55
67
  },
56
68
  async revoke(id, rids) { const rec = recs.get(id); return rec ? revokeIn(rec, rids) : []; },
69
+ async setCloses(id, rids, closesAt) { const rec = recs.get(id); return rec ? closeIn(rec, rids, closesAt) : []; },
57
70
  async setExpiry(id, expireAt) {
58
71
  const rec = recs.get(id) ?? { recipients: {}, expireAt: null, keep: false };
59
72
  rec.expireAt = expireAt;
@@ -105,6 +118,18 @@ export function createRecipientsStore(db, base) {
105
118
  await recsCol().doc(id).set(patch, { merge: true });
106
119
  return hit;
107
120
  },
121
+ async setCloses(id, rids, closesAt) {
122
+ const rec = await read(id);
123
+ if (!rec)
124
+ return [];
125
+ const hit = closeIn(rec, rids, closesAt);
126
+ if (!hit.length)
127
+ return hit;
128
+ // A merge writes only this field on these readers: a revoke or an expiry that landed after the
129
+ // read above is left alone. `null` clears the window (read back as no window).
130
+ await recsCol().doc(id).set({ recipients: Object.fromEntries(hit.map((rid) => [rid, { closesAt }])) }, { merge: true });
131
+ return hit;
132
+ },
108
133
  async setExpiry(id, expireAt) {
109
134
  await recsCol().doc(id).set({ expireAt, keep: expireAt === null }, { merge: true });
110
135
  },
@@ -147,14 +172,14 @@ export function summarizeViews(rec, views, comments) {
147
172
  const mine = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
148
173
  const firsts = mine.map((v) => Date.parse(v.firstAt));
149
174
  return {
150
- rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt,
175
+ rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
151
176
  opened: mine.length > 0,
152
177
  firstOpenAt: mine[0]?.firstAt ?? null,
153
178
  cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
154
179
  activeSeconds: mine.reduce((n, v) => n + v.activeSeconds, 0),
155
180
  plays: mine.reduce((n, v) => n + Object.values(v.plays).reduce((a, b) => a + b, 0), 0),
156
181
  devices: new Set(mine.map((v) => v.device)).size,
157
- comments: comments.filter((c) => c.readerKey === `r:${r.rid}`).map((c) => ({ at: c.at, text: c.text })),
182
+ comments: comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid).map((c) => ({ at: c.at, text: c.text })),
158
183
  };
159
184
  });
160
185
  }
@@ -25,6 +25,15 @@ export declare function verifyRenderToken(secret: string, id: string, rid: strin
25
25
  why: string;
26
26
  };
27
27
  export declare function beatKey(secret: string, id: string, rid: string, session: string): string;
28
+ /** One browser on a group link, as the 24 hex characters its reader key ends in (0.40.0). Derived
29
+ * here from the page, the link's verified rid and the anonymous id this server handed that browser,
30
+ * keyed by the recipient secret: a client cannot compute another browser's, and the stored key is
31
+ * not the cookie, so reading a key (the publisher's exports carry it) never lets anyone write as
32
+ * that reader. */
33
+ export declare function browserIdFor(secret: string, id: string, rid: string, anon: string): string;
34
+ /** The link a reader key was written through: `r:<rid>` (one link, one reader) or
35
+ * `r:<rid>.<browser>` (one browser on a group link). Null for any other key. */
36
+ export declare function ridOfReaderKey(key: string): string | null;
28
37
  /** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
29
38
  * `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
30
39
  export declare const UNFURL_AGENTS: RegExp;
@@ -77,6 +77,22 @@ export function verifyRenderToken(secret, id, rid, token, now = Date.now()) {
77
77
  export function beatKey(secret, id, rid, session) {
78
78
  return mac(secret, `beat\n${id}\n${rid}\n${session}`);
79
79
  }
80
+ /** One browser on a group link, as the 24 hex characters its reader key ends in (0.40.0). Derived
81
+ * here from the page, the link's verified rid and the anonymous id this server handed that browser,
82
+ * keyed by the recipient secret: a client cannot compute another browser's, and the stored key is
83
+ * not the cookie, so reading a key (the publisher's exports carry it) never lets anyone write as
84
+ * that reader. */
85
+ export function browserIdFor(secret, id, rid, anon) {
86
+ if (!secret)
87
+ throw new Error('a signing secret is required');
88
+ return createHmac('sha256', secret).update(`browser\n${id}\n${rid}\n${anon}`).digest('hex').slice(0, 24);
89
+ }
90
+ const RECIPIENT_KEY = /^r:([a-z0-9]{12})(\.[0-9a-f]{24})?$/;
91
+ /** The link a reader key was written through: `r:<rid>` (one link, one reader) or
92
+ * `r:<rid>.<browser>` (one browser on a group link). Null for any other key. */
93
+ export function ridOfReaderKey(key) {
94
+ return RECIPIENT_KEY.exec(key)?.[1] ?? null;
95
+ }
80
96
  /** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
81
97
  * `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
82
98
  export const UNFURL_AGENTS = /facebookexternalhit|facebot|twitterbot|slackbot|whatsapp|linkedinbot|discordbot|telegrambot|skypeuripreview|redditbot|applebot|googlebot|bingbot|embedly|iframely|pinterest|bitlybot|vkshare|mastodon|preview/i;
@@ -82,6 +82,11 @@ export declare const readerKeyFor: {
82
82
  signedIn: (uid: string) => string;
83
83
  anonymous: (id: string) => string;
84
84
  recipient: (rid: string) => string;
85
+ /** One browser on a group link, on a page with `comment_name: required`: the link and the id
86
+ * `browserIdFor` derives. `ridOfReaderKey` reads the link back out of either `r:` shape. */
87
+ groupBrowser: (rid: string, browser: string) => string;
88
+ /** The publisher's one reply in a thread, keyed by the thread's top comment. */
89
+ publisher: (entryId: string) => string;
85
90
  };
86
91
  export declare function createStateStore(db: Firestore, base: string): StateStore;
87
92
  /** The same contract in memory: for tests, and for hosts' own tests. */
@@ -18,6 +18,11 @@ export const readerKeyFor = {
18
18
  signedIn: (uid) => `u:${uid}`,
19
19
  anonymous: (id) => `a:${id}`,
20
20
  recipient: (rid) => `r:${rid}`,
21
+ /** One browser on a group link, on a page with `comment_name: required`: the link and the id
22
+ * `browserIdFor` derives. `ridOfReaderKey` reads the link back out of either `r:` shape. */
23
+ groupBrowser: (rid, browser) => `r:${rid}.${browser}`,
24
+ /** The publisher's one reply in a thread, keyed by the thread's top comment. */
25
+ publisher: (entryId) => `p:${entryId}`,
21
26
  };
22
27
  const writerOf = (w) => ({
23
28
  ...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
@@ -32,6 +32,7 @@ export type ArtifactRecord = {
32
32
  /** The brand pack the page asked for by name; see ArtifactMeta.pack. */
33
33
  pack?: string;
34
34
  toc?: boolean;
35
+ commentName?: 'required';
35
36
  definitions?: Definition[];
36
37
  /** Who may leave shared comments; absent is off. Personal notes need nothing. */
37
38
  comments?: CommentsMode;
@@ -163,6 +163,7 @@ async function saveArtifact(col, input) {
163
163
  ...(input.meta.theme ? { theme: input.meta.theme } : {}),
164
164
  ...(input.meta.pack ? { pack: input.meta.pack } : {}),
165
165
  ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
166
+ ...(input.meta.commentName ? { commentName: input.meta.commentName } : {}),
166
167
  ...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
167
168
  };
168
169
  if (input.id) {
@@ -215,6 +216,7 @@ async function saveArtifact(col, input) {
215
216
  ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
216
217
  ...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
217
218
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
219
+ ...(input.meta.commentName ? {} : { commentName: FieldValue.delete() }),
218
220
  ...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
219
221
  };
220
222
  // `voice: none` is the page declaring it has no audio. An update merges, so the recording it
@@ -84,6 +84,10 @@ export type CoverProps = {
84
84
  /** The version line and, for a signed-in reader, who they are signed in as. Unspoken. */
85
85
  meta: ReactNode;
86
86
  version: number;
87
+ /** Whether this reader may be shown the version number: true for whoever the host's `history`
88
+ * lets see past versions (admins by default), false for everyone else. A Cover that draws
89
+ * `version` itself draws it only when this is true; `meta` already follows it. */
90
+ showVersion: boolean;
87
91
  updatedAt: string;
88
92
  createdAt: string;
89
93
  };