@supersuit/artifacts 0.54.0 → 0.56.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,46 @@
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.56.0 (2026-10-26)
7
+
8
+ **A reader can pass a per-reader page on, and the link they make says who made it and from which
9
+ link.** Superlinks step 3. Before this, Share on a per-reader page copied the page's bare address,
10
+ which opens for nobody, so the only way to pass one on was the address bar, and every new reader
11
+ counted as the first.
12
+
13
+ - Front matter `forward: off | signed-in | open` (absent is off; needs `links: per-reader`; sticky).
14
+ - `POST /api/artifacts/<id>/forward`: mints a forward reader with `parent`, `forwardedBy` and
15
+ `provenance`, and answers `{ link }`. 20 a day per reader per page. See Forward links.
16
+ - A revoked link shuts every forward made from it (`perReaderGate` walks the parent chain).
17
+ - Share on a per-reader page asks the route for a link (sending a signed-out reader to sign in on a
18
+ `signed-in` page), and is absent where forwarding is off. The owner and admins keep the address.
19
+ - Acting through a group link on a page with `forward:` on, or through any forward link, needs
20
+ sign-in; the note is filed under the link with the signed-in name. Personal links are unchanged.
21
+ - `GET /recipients` and `GET /views` carry `parent` and `forwardedBy` per reader.
22
+ - DETECTOR: a per-reader page whose menu Share copies `https://<host>/<id>` with no `?r=`; or a
23
+ host whose `POST /api/artifacts/<id>/forward` answers 404 on a page with `forward: signed-in`
24
+ (no route file).
25
+ - REMEDY: bump, and add the route file `app/api/artifacts/[id]/forward/route.ts` exporting
26
+ `POST = routes.FORWARD`. Pages without `forward:` behave as before, except that Share no longer
27
+ offers their dead address to a reader.
28
+ - Tests: forward.test.tsx (each `forward:` value against signed in, signed out, revoked parent, no
29
+ link; the row's fields; the daily limit; the parent chain; the listing and views; Share present
30
+ exactly where forwarding is on; sign-in to act on thread and forward links, never on a personal one).
31
+
32
+ ## 0.55.0 (2026-10-26)
33
+
34
+ **`GET /api/artifacts/<id>` now returns the page as written, not only its narration.** It answered
35
+ `{ id, text }`, and `text` is the narration, which drops every list item, so an agent reading a
36
+ prep sheet through the key got its headings ("Questions for him", "About him, first:") with the
37
+ questions missing.
38
+
39
+ - The response adds `title`, `subtitle` (when set), `summary`, `to` (when set), `markdown`,
40
+ `version` and `updatedAt`. `text` is unchanged, so existing callers keep working.
41
+ - Never the password, the access list or reader data.
42
+ - DETECTOR: `GET /api/artifacts/<id>` with the publish key answers no `markdown` field.
43
+ - REMEDY: update the host.
44
+ - Tests: `src/routes/artifacts.test.tsx` "GET returns the narration text and the markdown".
45
+
6
46
  ## 0.54.0 (2026-10-25)
7
47
 
8
48
  **A page no longer records under a reader left on the browser by another page.** posthog-js keeps
package/README.md CHANGED
@@ -248,6 +248,12 @@ createArtifactRoutes({ ..., ownerEmail: 'owner@example.com', admins: ['editor@ex
248
248
  - `GET /api/artifacts/<id>/versions` lists the history; `POST` it `{ "version": 2, "note": "..." }`
249
249
  (or `"note": null`) to write a note onto a version after the fact.
250
250
 
251
+ **Reading a page as its owner.** `GET /api/artifacts/<id>` with the publish key answers
252
+ `{ id, title, subtitle?, summary, to?, markdown, version, updatedAt, text }`. `markdown` is the
253
+ body as written, lists included, and is what an agent should read; `text` is the narration, which
254
+ drops list items. Neither ever carries the page's password or anything about its readers. A page
255
+ behind a sign-in door, a password or per-reader links is readable this way by whoever holds the key.
256
+
251
257
  ## Front matter
252
258
 
253
259
  ```markdown
@@ -277,6 +283,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
277
283
  | `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 |
278
284
  | `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
279
285
  | `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` |
286
+ | `forward` | `off`, `signed-in` or `open`: whether a reader of a per-reader page may make a link to pass it on (see Forward links). Absent is `off`. Needs `links: per-reader`. Sticky like `links` |
280
287
  | `history` | `everyone` or `admins`: who sees this page's version number and past versions, overriding the host's `history` for this one page (see Version history). Absent, the host decides. Refused as `everyone` on a per-reader page |
281
288
  | `change` | One line saying what this version changed (see Version history) |
282
289
  | `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
@@ -1084,6 +1091,37 @@ stage, is refused (400) and nothing is added. It is kept on the reader's row and
1084
1091
  ignores the field, so a sender that needs it reads it back from `added` and treats `null` as an old
1085
1092
  host.
1086
1093
 
1094
+ ### Forward links (0.56.0)
1095
+
1096
+ A reader passes a per-reader page on through a link of its own, never the page's address, which
1097
+ opens for nobody. The page's `forward:` line decides who may:
1098
+
1099
+ | `forward:` | Share in the page menu | Who can make a forward link |
1100
+ |---|---|---|
1101
+ | `off` (or absent) | absent | nobody; the route answers 404 |
1102
+ | `signed-in` | present, "Your name goes with this link." | a reader signed in through `signInOrigin` |
1103
+ | `open` | present | anyone holding a valid link; the forwarder is recorded as that link |
1104
+
1105
+ `POST /api/artifacts/<id>/forward` is called by the page (same origin, the reader's link cookie).
1106
+ Body `{ to?: first name }`, up to 60 characters. It answers `{ link }`, a new reader row with
1107
+ `group: true`, `parent` (the rid it was made from), `forwardedBy` (`{ uid, email, name }` from the
1108
+ pass, or `{ rid, label }` on an `open` page) and `provenance` (`actor` "<name> (signed in as
1109
+ <email>)", `authority` "forwarded from <label>'s link"). Labelled "<to> (from <name>)", or "From
1110
+ <name>". Refusals: 404 for `forward: off`, no valid link, a revoked parent, or the owner; 401
1111
+ `{ signIn }` signed out on a `signed-in` page; 400 for a bad `to`; 429 past 20 forwards by one
1112
+ reader on one page in a day; 502 when the store fails, and the page says so rather than copying
1113
+ anything else. **A forward never outlives the link it came from**: revoking a link shuts every
1114
+ forward made from it, and their forwards. `GET /recipients` and `GET /views` carry `parent` and
1115
+ `forwardedBy` on every row (`null` on the publisher's own sends), so the page's travel is a tree.
1116
+
1117
+ **Acting through a thread or forward link needs sign-in.** On a group link of a page whose
1118
+ `forward:` is on, and on any forward link, a comment asks the reader to sign in at the moment of
1119
+ commenting (401 `{ signIn }` from the state API; the comment card offers the sign-in). The note is
1120
+ stored under `r:<rid>.<24 hex>`, derived from the link and the signed-in uid, so it still counts for
1121
+ that link, and it carries the name they signed in with. A named (personal) link comments without
1122
+ sign-in, under its label, as before. A page without the line, and a page with
1123
+ `comment_name: required`, keep their old rules.
1124
+
1087
1125
  ### Expiry
1088
1126
 
1089
1127
  `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.
@@ -68,7 +68,13 @@ export type ArtifactMeta = {
68
68
  * follow from draft to draft; a page that reads as a message leaves the line out. Content, like
69
69
  * `toc:`: a republish without the line goes back to the host's setting. */
70
70
  history?: PageHistory;
71
+ /** `forward: off | signed-in | open` (0.56.0): whether a reader of a per-reader page may make a
72
+ * link to pass it on, and whether they must sign in first. Absent is off. A door like `links:`,
73
+ * so sticky: a republish that leaves the line out changes nothing. */
74
+ forward?: ForwardMode;
71
75
  };
76
+ export declare const FORWARD_MODES: readonly ["off", "signed-in", "open"];
77
+ export type ForwardMode = (typeof FORWARD_MODES)[number];
72
78
  /** Who sees a page's version number and past versions. */
73
79
  export type PageHistory = 'admins' | 'everyone';
74
80
  export declare const PAGE_HISTORY: readonly PageHistory[];
@@ -7,6 +7,7 @@ import { mergeChecklistState, mergeWidgetState, scanWidgets } from './widgets.js
7
7
  import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
8
8
  import { THEME_MODES } from '../brand/theme.js';
9
9
  import { parseDefinitions } from './definitions.js';
10
+ export const FORWARD_MODES = ['off', 'signed-in', 'open'];
10
11
  export const PAGE_HISTORY = ['admins', 'everyone'];
11
12
  export const ARTIFACT_KINDS = ['document', 'broadcast'];
12
13
  /** Any media in a body: an image, a video or an audio clip, written as markdown image syntax. */
@@ -20,7 +21,7 @@ const PRIVATE_REF_IN_BODY = /^private:/;
20
21
  export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
21
22
  /** The longest `to:` a page may carry: a name or two, never a sentence. */
22
23
  export const MAX_TO_CHARS = 60;
23
- 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', 'history']);
24
+ 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', 'history', 'forward']);
24
25
  export function parseArtifactSource(text,
25
26
  /** The kind a file that leaves `template:` out is read as: the stored page's kind on a
26
27
  * republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
@@ -93,6 +94,11 @@ opts = {}) {
93
94
  return { ok: false, error: 'comment_name must be required' };
94
95
  meta.commentName = 'required';
95
96
  }
97
+ if (d.forward !== undefined && d.forward !== null) {
98
+ if (!FORWARD_MODES.includes(d.forward))
99
+ return { ok: false, error: 'forward must be off, signed-in or open' };
100
+ meta.forward = d.forward;
101
+ }
96
102
  if (d.theme !== undefined) {
97
103
  if (!THEME_MODES.includes(d.theme))
98
104
  return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
@@ -159,6 +165,9 @@ opts = {}) {
159
165
  // /<id>/v/<n> of a per-reader page is never served, so the history list would be dead links.
160
166
  if (meta.history === 'everyone' && meta.links === 'per-reader')
161
167
  return { ok: false, error: 'a per-reader page serves no past versions: drop history: everyone' };
168
+ // A page anyone can open is forwarded by its own address: forward links are for per-reader pages.
169
+ if (meta.forward && meta.links !== 'per-reader')
170
+ return { ok: false, error: 'forward needs links: per-reader' };
162
171
  if (meta.commentName && meta.links !== 'per-reader')
163
172
  return { ok: false, error: 'comment_name: required needs links: per-reader' };
164
173
  // Widgets declare their own slots, so a notes block (and a checklist with send) is merged into state: here, before the
@@ -12,6 +12,18 @@ export type Recipient = {
12
12
  closesAt?: string | null;
13
13
  sentAt?: string;
14
14
  provenance?: Provenance;
15
+ parent?: string;
16
+ forwardedBy?: ForwardedBy;
17
+ };
18
+ /** Who made a forward link (0.56.0): the signed-in reader, or on a `forward: open` page the link
19
+ * they made it from. */
20
+ export type ForwardedBy = {
21
+ uid: string;
22
+ email: string;
23
+ name: string | null;
24
+ } | {
25
+ rid: string;
26
+ label: string;
15
27
  };
16
28
  /** Who minted a link, on whose authority, and when (a superlink, 0.53.0): `actor` is a person by
17
29
  * hand or "<person>'s Pluma"; `authority` is the request, recurrent or approval it acted under, with
@@ -57,6 +69,8 @@ export type RecipientSummary = {
57
69
  closesAt: string | null;
58
70
  sentAt: string | null;
59
71
  provenance: Provenance | null;
72
+ parent: string | null;
73
+ forwardedBy: ForwardedBy | null;
60
74
  beforeSend: BeforeSend;
61
75
  opened: boolean;
62
76
  firstOpenAt: string | null;
@@ -78,6 +92,8 @@ type NewRecipient = {
78
92
  group?: boolean;
79
93
  closesAt?: string;
80
94
  provenance?: Provenance;
95
+ parent?: string;
96
+ forwardedBy?: ForwardedBy;
81
97
  };
82
98
  export interface RecipientsStore {
83
99
  get(artifactId: string): Promise<RecipientsRecord | null>;
@@ -15,7 +15,7 @@ function makeRecipients(input, taken) {
15
15
  const note = typeof i.note === 'string' && i.note.trim() ? i.note.trim().slice(0, NOTE_MAX) : undefined;
16
16
  // The owner never carries a window: setCloses skips them and the page hides the closed state
17
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 } : {}), ...(i.provenance ? { provenance: { ...i.provenance } } : {}) };
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 } : {}), ...(i.provenance ? { provenance: { ...i.provenance } } : {}), ...(i.parent ? { parent: i.parent } : {}), ...(i.forwardedBy ? { forwardedBy: { ...i.forwardedBy } } : {}) };
19
19
  });
20
20
  }
21
21
  function revokeIn(rec, rids) {
@@ -213,6 +213,8 @@ export function summarizeViews(rec, views, comments) {
213
213
  rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
214
214
  sentAt: r.sentAt ?? null,
215
215
  provenance: r.provenance ?? null,
216
+ parent: r.parent ?? null,
217
+ forwardedBy: r.forwardedBy ?? null,
216
218
  beforeSend: Number.isFinite(sentMs) ? { sessions: early.length, devices: preDevices.size, activeSeconds: early.reduce((n, v) => n + v.activeSeconds, 0), firstAt: early[0]?.firstAt ?? null } : null,
217
219
  opened: mine.length > 0,
218
220
  firstOpenAt: mine[0]?.firstAt ?? null,
@@ -1,5 +1,5 @@
1
1
  import { type Firestore } from 'firebase-admin/firestore';
2
- import type { ArtifactKind, ArtifactMeta } from './front-matter.js';
2
+ import type { ArtifactKind, ArtifactMeta, ForwardMode } from './front-matter.js';
3
3
  import type { Access } from './reader.js';
4
4
  import type { StateConfig } from './state.js';
5
5
  import type { ThemeMode } from '../brand/theme.js';
@@ -33,6 +33,8 @@ export type ArtifactRecord = {
33
33
  pack?: string;
34
34
  toc?: boolean;
35
35
  commentName?: 'required';
36
+ /** Whether a reader may make a forward link (front-matter `forward:`); absent is off. */
37
+ forward?: ForwardMode;
36
38
  /** The page's own `history:` line; absent, the host's setting decides. */
37
39
  history?: 'admins' | 'everyone';
38
40
  definitions?: Definition[];
@@ -171,6 +171,7 @@ async function saveArtifact(col, input) {
171
171
  ...(input.meta.pack ? { pack: input.meta.pack } : {}),
172
172
  ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
173
173
  ...(input.meta.commentName ? { commentName: input.meta.commentName } : {}),
174
+ ...(input.meta.forward ? { forward: input.meta.forward } : {}),
174
175
  ...(input.meta.history ? { history: input.meta.history } : {}),
175
176
  ...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
176
177
  };
@@ -7,11 +7,14 @@ export type PageMenuProps = {
7
7
  name: string | null;
8
8
  /** The page is gated and this reader is not let in (they see its door). */
9
9
  shutOut?: boolean;
10
- /** The link to share, or null when this reader cannot share this page. `note` sits under it. */
10
+ /** The link to share, or null when this reader cannot share this page. `note` sits under it.
11
+ * `forward` (0.56.0): on a per-reader page, the route that makes a forward link for this reader;
12
+ * Share asks it for the link (signing the reader in first when it says to) instead of `url`. */
11
13
  share: {
12
14
  url: string;
13
15
  title: string;
14
16
  note?: string;
17
+ forward?: string;
15
18
  } | null;
16
19
  /** Shared comments are on here, so the pins can be shown or hidden. */
17
20
  comments: boolean;
@@ -142,10 +142,32 @@ export function PageMenu(props) {
142
142
  const onShare = async () => {
143
143
  if (!share)
144
144
  return;
145
+ let url = share.url;
146
+ if (share.forward) {
147
+ // A per-reader page is passed on through a link of its own, never its bare address, which
148
+ // opens for nobody. Signed out on a page that needs sign-in, the route says where to go.
149
+ try {
150
+ const res = await fetch(share.forward, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', credentials: 'same-origin' });
151
+ const body = (await res.json().catch(() => ({})));
152
+ if (res.status === 401 && body.signIn) {
153
+ window.location.href = body.signIn;
154
+ return;
155
+ }
156
+ if (!res.ok || !body.link) {
157
+ setSaid(body.error ?? "Couldn't make a link. Try again.");
158
+ return;
159
+ }
160
+ url = body.link;
161
+ }
162
+ catch {
163
+ setSaid("Couldn't make a link. Try again.");
164
+ return;
165
+ }
166
+ }
145
167
  // A phone hands the link to its own share sheet (Messages, Mail, AirDrop); a desk copies it.
146
168
  if (coarse() && typeof navigator.share === 'function') {
147
169
  try {
148
- await navigator.share({ title: share.title, url: share.url });
170
+ await navigator.share({ title: share.title, url });
149
171
  close();
150
172
  return;
151
173
  }
@@ -156,7 +178,7 @@ export function PageMenu(props) {
156
178
  return;
157
179
  }
158
180
  }
159
- setSaid((await copyText(share.url)) ? 'Link copied' : 'Could not copy the link');
181
+ setSaid((await copyText(url)) ? 'Link copied' : 'Could not copy the link');
160
182
  };
161
183
  const onMarkdown = async () => {
162
184
  if (markdown === null)
@@ -199,5 +221,5 @@ export function PageMenu(props) {
199
221
  border: '1px solid var(--a-line, rgba(127,127,127,0.3))', boxShadow: '0 1px 4px rgba(0,0,0,0.12)',
200
222
  backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
201
223
  }, children: _jsxs("svg", { width: "18", height: "18", viewBox: "0 0 18 18", "aria-hidden": true, focusable: "false", children: [_jsx("circle", { cx: "3.5", cy: "9", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "9", cy: "9", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "14.5", cy: "9", r: "1.6", fill: "currentColor" })] }) }), open && phone ? _jsx("div", { "aria-hidden": true, "data-page-menu-scrim": true, style: { position: 'fixed', inset: 0, zIndex: 59, background: 'rgba(0,0,0,0.28)' } }) : null, open ? (_jsxs("div", { ref: menu, "data-page-menu": true, role: "menu", "aria-label": "Page menu", style: panelStyle, children: [_jsx("p", { "data-page-menu-role": true, style: { margin: 0, padding: phone ? '10px 12px 8px' : '8px 10px 6px', fontSize: 12, lineHeight: 1.35, opacity: 0.7, overflowWrap: 'anywhere' }, children: line }), items.length ? _jsx("div", { "aria-hidden": true, style: { height: 1, margin: '0 6px 4px', background: 'var(--a-line, rgba(127,127,127,0.3))' } }) : null, share ? (_jsxs("button", { type: "button", role: "menuitem", "data-page-menu-item": "share", onClick: () => void onShare(), style: itemStyle, children: [said === 'Link copied' || said === 'Could not copy the link' ? said : 'Share', share.note ? _jsx("span", { "data-page-menu-note": true, style: sub, children: share.note }) : null] })) : null, comments ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "comments", onClick: onPins, style: itemStyle, children: pins ? 'Hide comments' : 'Show comments' })) : null, settings ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "share-settings", "aria-haspopup": "dialog", onClick: () => onSheet('share'), style: itemStyle, children: "Share settings" })) : null, settings && settings.comments !== null ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "comment-settings", "aria-haspopup": "dialog", onClick: () => onSheet('comments'), style: itemStyle, children: "Comment settings" })) : null, history ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "history", "aria-haspopup": "dialog", onClick: onHistory, style: itemStyle, children: "Version history" })) : null, markdown !== null ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "markdown", onClick: () => void onMarkdown(), style: itemStyle, children: said === 'Markdown copied' || said === 'Could not copy the markdown' ? said : 'Copy the markdown' })) : null, viewAsReader ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "view-as-reader", href: viewAsReader, style: itemStyle, children: "View as a reader" })) : null, signOut ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "sign-out", href: signOut, style: itemStyle, children: "Sign out" })) : signIn ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "sign-in", href: signIn, onClick: (e) => { e.currentTarget.href = signInBack(signIn); }, style: itemStyle, children: "Sign in" })) : null, _jsx("span", { role: "status", "aria-live": "polite", style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, children: said ?? '' })] })) : null, sheet && settings ? (_jsx(SettingsSheet, { kind: sheet.kind, settings: settings, saved: sheet.saved, phone: phone, vars: vars, onCopy: copyText, onClose: () => { setSheet(null); button.current?.focus(); } })) : null, historyOpen && history ? (_jsx(HistoryPanel, { items: history.items, base: history.base, viewing: history.viewing, anchor: button, onClose: () => { setHistoryOpen(false); button.current?.focus(); }, onOutside: () => setHistoryOpen(false) })) : null] })) : null;
202
- return (_jsxs(_Fragment, { children: [_jsx("span", { ref: marker, hidden: true, "data-page-menu-for": role, "data-page-menu-items": items.join(' '), children: history ? _jsx(VersionList, { items: history.items, base: history.base, marker: true }) : null }), ui ? createPortal(ui, document.body) : null] }));
224
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: marker, hidden: true, "data-page-menu-for": role, "data-page-menu-items": items.join(' '), "data-page-menu-forward": share?.forward ? share.note ?? '' : undefined, children: history ? _jsx(VersionList, { items: history.items, base: history.base, marker: true }) : null }), ui ? createPortal(ui, document.body) : null] }));
203
225
  }
@@ -176,6 +176,11 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
176
176
  id: string;
177
177
  }>;
178
178
  }) => Promise<NextResponse<unknown>>;
179
+ FORWARD: (req: NextRequest, { params }: {
180
+ params: Promise<{
181
+ id: string;
182
+ }>;
183
+ }) => Promise<NextResponse<unknown>>;
179
184
  sweepExpired: (now?: Date) => Promise<{
180
185
  pages: number;
181
186
  files: number;
@@ -251,8 +256,15 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
251
256
  GET: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
252
257
  error: string;
253
258
  }> | NextResponse<{
259
+ markdown: string;
260
+ version: number;
261
+ updatedAt: string;
262
+ to?: string | undefined;
263
+ summary: string;
264
+ subtitle?: string | undefined;
254
265
  id: string;
255
266
  text: string;
267
+ title: string;
256
268
  }>>;
257
269
  DELETE: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
258
270
  error: string;
@@ -22,7 +22,7 @@ import { CommentLayer } from '../reader/comment-layer.js';
22
22
  import { ReaderWatch } from '../reader/reader-watch.js';
23
23
  import { UpdatedTime } from '../reader/updated-time.js';
24
24
  import { FeedbackWindow } from '../reader/feedback-window.js';
25
- import { perReaderGate, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
25
+ import { perReaderGate, signInToAct, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
26
26
  import { ridCookieName, mintRenderToken, usableSecret, isUnfurlAgent, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
27
27
  import { PageMenu } from '../reader/page-menu.js';
28
28
  import { COMMENTS_MODES } from '../artifacts/comments.js';
@@ -139,7 +139,7 @@ export function createArtifactRoutes(config) {
139
139
  });
140
140
  const recipientsRoutes = createRecipientsRoutes({
141
141
  store, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
142
- state: config.state, assets: config.assets, pageUrl, authed,
142
+ state: config.state, assets: config.assets, pageUrl, authed, readerSecret, signInOrigin, siteUrl,
143
143
  });
144
144
  const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
145
145
  const voiceRoutes = createVoiceRoutes({
@@ -246,15 +246,22 @@ export function createArtifactRoutes(config) {
246
246
  * link, since only they hold it.
247
247
  * - Version history: whoever `history` lets see past versions (admins by default).
248
248
  * - Copy the markdown: the owner and admins. */
249
- function Menu({ a, reader, shutOut, allow, comments, history, viewing, source, settings }) {
249
+ function Menu({ a, reader, shutOut, allow, comments, history, viewing, source, settings, perReader }) {
250
250
  const role = roleOf(reader);
251
251
  const admin = isAdmin(reader);
252
252
  const name = reader ? (firstName(reader, allow ?? []) ?? reader.email) : null;
253
- const share = a.access
254
- ? { url: pageUrl(a.id), title: a.title, ...(a.access === 'invite' ? { note: 'Only people on the list can open it.' } : {}) }
255
- : a.password
256
- ? (admin ? { url: `${pageUrl(a.id)}?key=${encodeURIComponent(a.password)}`, title: a.title, note: 'The link carries the password.' } : null)
257
- : { url: pageUrl(a.id), title: a.title };
253
+ // A per-reader page opens only from a reader's own link, so its address is useless to pass on:
254
+ // a reader shares through a forward link, where the page allows one, and otherwise not at all.
255
+ const forwardNote = a.forward === 'open' ? 'This link says it came from yours.' : 'Your name goes with this link.';
256
+ const share = a.links === 'per-reader' && !admin
257
+ ? (perReader && !perReader.owner && (a.forward === 'signed-in' || a.forward === 'open')
258
+ ? { url: pageUrl(a.id), title: a.title, note: forwardNote, forward: `/api/artifacts/${a.id}/forward` }
259
+ : null)
260
+ : a.access
261
+ ? { url: pageUrl(a.id), title: a.title, ...(a.access === 'invite' ? { note: 'Only people on the list can open it.' } : {}) }
262
+ : a.password
263
+ ? (admin ? { url: `${pageUrl(a.id)}?key=${encodeURIComponent(a.password)}`, title: a.title, note: 'The link carries the password.' } : null)
264
+ : { url: pageUrl(a.id), title: a.title };
258
265
  return (_jsx(PageMenu, { role: role, name: name, shutOut: shutOut, share: share, comments: !!comments, signIn: !reader && signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, signOut: reader ? signOutUrl(a.id) : null, history: history && history.length > 1 && seesHistory(a, reader) && viewing ? { items: history, base: pagePath(a.id), viewing } : null, markdown: admin && source ? source : null, settings: admin ? settings ?? null : null, viewAsReader: admin && source ? `${pagePath(a.id)}?as=reader` : null }));
259
266
  }
260
267
  /** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
@@ -352,7 +359,7 @@ export function createArtifactRoutes(config) {
352
359
  const remember = g.fromLink && !bot
353
360
  ? [pagePath(id), `/api/artifacts/${id}`].map((p) => `document.cookie=${JSON.stringify(`${ridCookieName(id)}=${g.rid}.${g.sig}; Path=${p}; Max-Age=${RID_COOKIE_MAX_AGE}; SameSite=Lax; Secure`)}`).join(';')
354
361
  : null;
355
- const perReader = bot ? undefined : { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, group: g.group, closesAt: g.closesAt, token: mintRenderToken(secret, id, g.rid) };
362
+ const perReader = bot ? undefined : { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, group: g.group, forwarded: g.forwarded, closesAt: g.closesAt, token: mintRenderToken(secret, id, g.rid) };
356
363
  const markdown = await media(a.markdown);
357
364
  const body = broadcast
358
365
  ? await BroadcastCard({ ...a, markdown }, { reader: null, perReader, bot })
@@ -491,7 +498,7 @@ export function createArtifactRoutes(config) {
491
498
  const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
492
499
  return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
493
500
  // eslint-disable-next-line @next/next/no-img-element
494
- _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked, bot })] }));
501
+ _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || (effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone' && !(perReader && signInToAct(a, perReader))), signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked, bot })] }));
495
502
  }
496
503
  /** The page itself, shared by open and gated pages. */
497
504
  async function Body(a, { top, reader, perReader, untracked = false }) {
@@ -512,14 +519,14 @@ export function createArtifactRoutes(config) {
512
519
  const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
513
520
  const history = await historyOf(a.id);
514
521
  const current = a.version ?? (a.versions?.length ?? 0) + 1;
515
- return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, comments: mode !== 'off', history: history, viewing: current, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, a, a.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
522
+ return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, perReader: perReader, comments: mode !== 'off', history: history, viewing: current, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, a, a.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
516
523
  const n = a.version ?? (a.versions?.length ?? 0) + 1;
517
524
  const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt, numbered: seesHistory(a, reader) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
518
525
  const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, showVersion: seesHistory(a, reader), updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
519
526
  return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
520
527
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
521
528
  checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, embeds: embeds }) }));
522
- })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
529
+ })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || (effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone' && !(perReader && signInToAct(a, perReader)))), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
523
530
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
524
531
  recorded at publish, or there is none. */] }));
525
532
  }
@@ -891,7 +898,15 @@ export function createArtifactRoutes(config) {
891
898
  const a = await kept.get(id);
892
899
  if (!a)
893
900
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
894
- return NextResponse.json({ id, text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }) });
901
+ // `text` is the narration (lists and headings without their items read badly aloud, so it
902
+ // drops them); `markdown` is the page as written, which is what an agent reading the page
903
+ // needs. Never the password or any reader data.
904
+ return NextResponse.json({
905
+ id,
906
+ text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }),
907
+ title: a.title, ...(a.subtitle ? { subtitle: a.subtitle } : {}), summary: a.summary, ...(a.to ? { to: a.to } : {}),
908
+ markdown: a.markdown, version: a.version ?? 1, updatedAt: a.updatedAt,
909
+ });
895
910
  }
896
911
  /** PUT /api/artifacts/<id>/assets/<name>: raw bytes in, public URL out. 16 MiB cap. */
897
912
  async function PUT_ASSET(request, { params }) {
@@ -9,11 +9,23 @@ export type PerReaderOpen = {
9
9
  group: boolean;
10
10
  closesAt: string | null;
11
11
  fromLink: boolean;
12
+ forwarded: boolean;
12
13
  };
13
14
  export type PerReaderShut = {
14
15
  state: 'shut';
15
16
  why: 'no-link' | 'revoked' | 'expired' | 'closed';
16
17
  };
18
+ /** Superlinks (0.56.0): a link shared by several people, or passed on by a reader, says who nobody
19
+ * is, so acting through it needs sign-in, at the moment of acting. Only on a forward link, or a
20
+ * page that turned forwarding on: a page without the line keeps its old rule, and
21
+ * `comment_name: required` keeps its typed name. One rule for the page and the state API. */
22
+ export declare function signInToAct(page: {
23
+ forward?: string;
24
+ commentName?: string;
25
+ }, link: {
26
+ group: boolean;
27
+ forwarded?: boolean;
28
+ }): boolean;
17
29
  export declare const UNAVAILABLE = "This page is no longer available.";
18
30
  export declare const NO_LINK = "This page opens from the link it was sent with.";
19
31
  export declare function perReaderGate(o: {
@@ -2,6 +2,13 @@
2
2
  // Every refusal is the same quiet page; a revoked or expired reader learns only that it is gone.
3
3
  import { parseRidParam, verifyRid } from '../artifacts/recipients.js';
4
4
  import { PRIVATE_REF } from '../artifacts/assets.js';
5
+ /** Superlinks (0.56.0): a link shared by several people, or passed on by a reader, says who nobody
6
+ * is, so acting through it needs sign-in, at the moment of acting. Only on a forward link, or a
7
+ * page that turned forwarding on: a page without the line keeps its old rule, and
8
+ * `comment_name: required` keeps its typed name. One rule for the page and the state API. */
9
+ export function signInToAct(page, link) {
10
+ return link.group && page.commentName !== 'required' && (!!link.forwarded || (!!page.forward && page.forward !== 'off'));
11
+ }
5
12
  export const UNAVAILABLE = 'This page is no longer available.';
6
13
  export const NO_LINK = 'This page opens from the link it was sent with.';
7
14
  export async function perReaderGate(o) {
@@ -18,12 +25,17 @@ export async function perReaderGate(o) {
18
25
  const r = rec?.recipients[pick.rid];
19
26
  if (!rec || !r)
20
27
  return { state: 'shut', why: 'no-link' };
21
- if (r.revokedAt)
22
- return { state: 'shut', why: 'revoked' };
28
+ // A forward never outlives the link it came from: any revoked link up its chain shuts it.
29
+ const seen = new Set();
30
+ for (let at = r; at; at = at.parent ? rec.recipients[at.parent] : undefined) {
31
+ if (at.revokedAt || seen.has(at.rid))
32
+ return { state: 'shut', why: 'revoked' };
33
+ seen.add(at.rid);
34
+ }
23
35
  // A malformed expireAt parses to NaN: treat it as expired, never as no expiry.
24
36
  if (rec.expireAt && !(Date.parse(rec.expireAt) > (o.now ?? Date.now())))
25
37
  return { state: 'shut', why: 'expired' };
26
- return { state: 'open', rid: pick.rid, sig: pick.sig, label: r.label, note: r.note ?? null, owner: r.owner === true, group: r.group === true, closesAt: r.closesAt ?? null, fromLink: pick.fromLink };
38
+ return { state: 'open', rid: pick.rid, sig: pick.sig, label: r.label, note: r.note ?? null, owner: r.owner === true, group: r.group === true, closesAt: r.closesAt ?? null, fromLink: pick.fromLink, forwarded: !!r.parent };
27
39
  }
28
40
  const REF_IN_MD = /\]\((private:[A-Za-z0-9][A-Za-z0-9._-]{0,140})\)/g;
29
41
  export async function withPrivateUrls(markdown, id, privateUrl) {
@@ -19,7 +19,14 @@ export type RecipientsRoutesCtx = {
19
19
  authed: (r: Request) => boolean;
20
20
  /** Where the sweep reports a page it skipped or could not finish. Defaults to console.warn. */
21
21
  log?: (message: string) => void;
22
+ /** For FORWARD: who is signed in (the grant cookie's secret), where to send someone to sign in,
23
+ * and this host's own address, the only origin a forward may be asked from. */
24
+ readerSecret?: () => string | undefined;
25
+ signInOrigin?: string;
26
+ siteUrl?: string;
22
27
  };
28
+ /** How many forward links one reader may make on one page in a day. */
29
+ export declare const FORWARDS_PER_DAY = 20;
23
30
  /** A reader's provenance as sent, checked: who minted the link, on whose authority, when (default
24
31
  * now), and the funnel step it serves. -> the clean record, or the reason it is refused. */
25
32
  export declare function readProvenance(v: unknown, now?: Date): {
@@ -33,6 +40,7 @@ export declare function createRecipientsRoutes(given: RecipientsRoutesCtx): {
33
40
  SEEN: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
34
41
  RECIPIENTS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
35
42
  VIEWS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
43
+ FORWARD: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
36
44
  sweepExpired: (now?: Date) => Promise<{
37
45
  pages: number;
38
46
  files: number;
@@ -1,9 +1,14 @@
1
1
  // The per-reader routes. SEEN is public (the page calls it); everything else needs the publish key.
2
2
  import { NextResponse } from 'next/server';
3
- import { RID_RE, usableSecret, beatKey, isUnfurlAgent, ridLink, verifyRenderToken, verifyRid } from '../artifacts/recipients.js';
3
+ import { RID_RE, usableSecret, beatKey, isUnfurlAgent, ridLink, ridCookieName, verifyRenderToken, verifyRid } from '../artifacts/recipients.js';
4
+ import { GRANT_COOKIE, signInUrl, verifyGrant } from '../artifacts/reader.js';
5
+ import { perReaderGate } from './per-reader.js';
4
6
  import { summarizeViews } from '../artifacts/recipients-store.js';
5
7
  import { COMMENTS_SLOT } from '../artifacts/comments.js';
6
8
  import { ARTIFACT_ID_RE } from './ids.js';
9
+ /** How many forward links one reader may make on one page in a day. */
10
+ export const FORWARDS_PER_DAY = 20;
11
+ const TO_MAX = 60;
7
12
  // Session and device ids become part of a stored view's id, so only a short plain shape is let in.
8
13
  const SESSION = /^[a-z0-9]{1,32}$/;
9
14
  const MEDIA = /^[A-Za-z0-9][A-Za-z0-9._-]{0,140}$/;
@@ -97,7 +102,7 @@ export function createRecipientsRoutes(given) {
97
102
  const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
98
103
  return {
99
104
  id, expireAt: rec.expireAt, keep: rec.keep,
100
- recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, sentAt: r.sentAt ?? null, provenance: r.provenance ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
105
+ recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, sentAt: r.sentAt ?? null, provenance: r.provenance ?? null, parent: r.parent ?? null, forwardedBy: r.forwardedBy ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
101
106
  };
102
107
  }
103
108
  /** GET and POST /api/artifacts/<id>/recipients: who a per-reader page was sent to (publish key). */
@@ -267,5 +272,52 @@ export function createRecipientsRoutes(given) {
267
272
  }
268
273
  return out;
269
274
  }
270
- return { SEEN, RECIPIENTS, VIEWS, sweepExpired, forgetReaders };
275
+ /** POST /api/artifacts/<id>/forward (0.56.0): the page itself, for a reader passing it on.
276
+ * Body `{ to?: first name }`. Mints a forward link that names who made it, from which link, and
277
+ * when, and answers `{ link }`. Never mints from a page with `forward:` off, from a link that is
278
+ * not valid, or (on `forward: signed-in`) for a reader who has not signed in. */
279
+ async function FORWARD(req, { params }) {
280
+ const { id } = await params;
281
+ const secret = ctx.recipientSecret?.();
282
+ if (!secret || !ctx.recipients || !ARTIFACT_ID_RE.test(id))
283
+ return json({ error: 'not found' }, 404);
284
+ const origin = req.headers.get('origin');
285
+ if (ctx.siteUrl && origin !== null && origin !== new URL(ctx.siteUrl).origin)
286
+ return json({ error: 'wrong origin' }, 403);
287
+ const a = await ctx.store.get(id);
288
+ if (!a || a.links !== 'per-reader' || !a.forward || a.forward === 'off')
289
+ return json({ error: 'not found' }, 404);
290
+ const g = await perReaderGate({ id, param: undefined, cookie: req.cookies.get(ridCookieName(id))?.value, secret, recipients: ctx.recipients });
291
+ if (g.state === 'shut' || g.owner)
292
+ return json({ error: 'not found' }, 404);
293
+ let by;
294
+ if (a.forward === 'signed-in') {
295
+ const reader = verifyGrant(ctx.readerSecret?.(), req.cookies.get(GRANT_COOKIE)?.value);
296
+ if (!reader)
297
+ return json({ error: 'sign in to share this page', signIn: ctx.signInOrigin ? signInUrl(ctx.signInOrigin, ctx.pageUrl(id)) : null }, 401);
298
+ by = { uid: reader.uid, email: reader.email, name: reader.name };
299
+ }
300
+ else
301
+ by = { rid: g.rid, label: g.label };
302
+ const body = (await req.json().catch(() => ({})));
303
+ if (body.to !== undefined && (typeof body.to !== 'string' || body.to.trim().length > TO_MAX || /[\r\n]/.test(body.to)))
304
+ return json({ error: `to must be a first name, up to ${TO_MAX} characters` }, 400);
305
+ const to = typeof body.to === 'string' ? body.to.trim() : '';
306
+ const rec = await ctx.recipients.get(id);
307
+ const dayAgo = new Date(Date.now() - 86_400_000).toISOString();
308
+ const same = (f) => !!f && ('uid' in by ? 'uid' in f && f.uid === by.uid : 'rid' in f && f.rid === by.rid);
309
+ const made = Object.values(rec?.recipients ?? {}).filter((r) => same(r.forwardedBy) && r.addedAt > dayAgo).length;
310
+ if (made >= FORWARDS_PER_DAY)
311
+ return json({ error: 'too many links today; try again tomorrow' }, 429);
312
+ const who = 'uid' in by ? by.name?.trim() || by.email : by.label;
313
+ const provenance = { actor: 'uid' in by ? `${who} (signed in as ${by.email})` : `${who}'s link`, authority: `forwarded from ${g.label}'s link`, at: new Date().toISOString() };
314
+ try {
315
+ const [row] = await ctx.recipients.add(id, [{ label: to ? `${to} (from ${who})` : `From ${who}`, group: true, parent: g.rid, forwardedBy: by, provenance }]);
316
+ return json({ link: ridLink(ctx.pageUrl(id), id, row.rid, secret) });
317
+ }
318
+ catch {
319
+ return json({ error: "Couldn't make a link. Try again." }, 502);
320
+ }
321
+ }
322
+ return { SEEN, RECIPIENTS, VIEWS, FORWARD, sweepExpired, forgetReaders };
271
323
  }
@@ -24,7 +24,7 @@ import { ARTIFACT_ID_RE } from './ids.js';
24
24
  import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
25
25
  import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
26
26
  import { browserIdFor, ridCookieName, ridOfReaderKey, usableSecret } from '../artifacts/recipients.js';
27
- import { perReaderGate } from './per-reader.js';
27
+ import { perReaderGate, signInToAct } from './per-reader.js';
28
28
  import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
29
29
  import { COMMENTS_SLOT, FEEDBACK_CLOSED, MAX_COMMENT_CHARS, MAX_COMMENT_NAME_CHARS, NAME_NEEDED, cleanResolveNote, resolutionOf, validateComment } from '../artifacts/comments.js';
30
30
  import { MAX_QUOTE_CHARS } from '../artifacts/anchor.js';
@@ -102,7 +102,9 @@ export function createStateRoutes(ctx) {
102
102
  if (g.state === 'shut')
103
103
  return { error: json({ error: 'this page is locked' }, 403) };
104
104
  // The gate opened, so the secret is usable: it is what the browser id below is derived with.
105
- recipient = { rid: g.rid, label: g.label, group: g.group, closesAt: g.closesAt, perBrowser: g.group && a.commentName === 'required', secret: secret };
105
+ recipient = { rid: g.rid, label: g.label, group: g.group, closesAt: g.closesAt, perBrowser: g.group && a.commentName === 'required',
106
+ signInToAct: signInToAct(a, g),
107
+ secret: secret };
106
108
  }
107
109
  const rawAnon = req.cookies.get(ANON_COOKIE)?.value;
108
110
  const anonId = rawAnon && ANON_ID.test(rawAnon) ? rawAnon : null;
@@ -113,7 +115,8 @@ export function createStateRoutes(ctx) {
113
115
  * the link's verified rid and an id derived from the anonymous cookie this server set. Null for
114
116
  * such a browser before its first write, when it has no cookie and nothing is its own. The
115
117
  * client never names the key and cannot work out another browser's. */
116
- const recipientKey = (id, r, anonId) => !r.perBrowser ? readerKeyFor.recipient(r.rid) : anonId ? readerKeyFor.groupBrowser(r.rid, browserIdFor(r.secret, id, r.rid, anonId)) : null;
118
+ const recipientKey = (id, r, anonId, reader = null) => r.signInToAct ? (reader ? readerKeyFor.groupBrowser(r.rid, browserIdFor(r.secret, id, r.rid, `u.${reader.uid}`)) : null)
119
+ : !r.perBrowser ? readerKeyFor.recipient(r.rid) : anonId ? readerKeyFor.groupBrowser(r.rid, browserIdFor(r.secret, id, r.rid, anonId)) : null;
117
120
  const ownerEmail = ctx.ownerEmail?.trim().toLowerCase();
118
121
  const admins = new Set((ctx.admins ?? []).map((e) => e.trim().toLowerCase()));
119
122
  // The owner and the page's admins read every comment, with names.
@@ -204,7 +207,7 @@ export function createStateRoutes(ctx) {
204
207
  await ctx.state.moveReader(readerKeyFor.anonymous(anonId), writerFor(reader));
205
208
  clearAnon = true;
206
209
  }
207
- const key = recipient ? recipientKey(id, recipient, anonId) : reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
210
+ const key = recipient ? recipientKey(id, recipient, anonId, reader) : reader ? readerKeyFor.signedIn(reader.uid) : anonId ? readerKeyFor.anonymous(anonId) : null;
208
211
  const feedback = recipient?.closesAt ? { feedback: { closesAt: recipient.closesAt, closed: windowClosed(recipient) } } : {};
209
212
  const res = json({ ...(await viewBody(a, reader, key, !!recipient)), ...feedback, ...(reader || recipient ? {} : { signIn }) });
210
213
  if (clearAnon)
@@ -268,7 +271,18 @@ export function createStateRoutes(ctx) {
268
271
  return json({ error: FEEDBACK_CLOSED, closed: true }, 403);
269
272
  let setCookie = null;
270
273
  let writer;
271
- if (recipient) {
274
+ if (recipient?.signInToAct) {
275
+ // Signed in, the note is the reader's own under the link: still counted for the link, and
276
+ // carrying the name they signed in with rather than one anybody could type.
277
+ const key = recipientKey(id, recipient, anonId, reader);
278
+ if (!key || !reader) {
279
+ if (op === 'remove')
280
+ return isComments && entryId ? json({ error: 'no comment of yours with that id' }, 404) : json(await viewBody(a, reader, null, true));
281
+ return json({ error: 'sign in to comment', signIn }, 401);
282
+ }
283
+ writer = { ...writerFor(reader), key };
284
+ }
285
+ else if (recipient) {
272
286
  let key = recipientKey(id, recipient, anonId);
273
287
  if (!key) {
274
288
  // A browser on a group link that has written nothing: there is nothing of its own to delete
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.54.0",
3
+ "version": "0.56.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",