@supersuit/artifacts 0.55.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 +26 -0
- package/README.md +32 -0
- package/lib/artifacts/front-matter.d.ts +6 -0
- package/lib/artifacts/front-matter.js +10 -1
- package/lib/artifacts/recipients-store.d.ts +16 -0
- package/lib/artifacts/recipients-store.js +3 -1
- package/lib/artifacts/store.d.ts +3 -1
- package/lib/artifacts/store.js +1 -0
- package/lib/reader/page-menu.d.ts +4 -1
- package/lib/reader/page-menu.js +25 -3
- package/lib/routes/artifacts.d.ts +5 -0
- package/lib/routes/artifacts.js +19 -12
- package/lib/routes/per-reader.d.ts +12 -0
- package/lib/routes/per-reader.js +15 -3
- package/lib/routes/recipients-routes.d.ts +8 -0
- package/lib/routes/recipients-routes.js +55 -3
- package/lib/routes/state-routes.js +19 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,32 @@
|
|
|
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
|
+
|
|
6
32
|
## 0.55.0 (2026-10-26)
|
|
7
33
|
|
|
8
34
|
**`GET /api/artifacts/<id>` now returns the page as written, not only its narration.** It answered
|
package/README.md
CHANGED
|
@@ -283,6 +283,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
283
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 |
|
|
284
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` |
|
|
285
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` |
|
|
286
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 |
|
|
287
288
|
| `change` | One line saying what this version changed (see Version history) |
|
|
288
289
|
| `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
|
|
@@ -1090,6 +1091,37 @@ stage, is refused (400) and nothing is added. It is kept on the reader's row and
|
|
|
1090
1091
|
ignores the field, so a sender that needs it reads it back from `added` and treats `null` as an old
|
|
1091
1092
|
host.
|
|
1092
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
|
+
|
|
1093
1125
|
### Expiry
|
|
1094
1126
|
|
|
1095
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,
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -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[];
|
package/lib/artifacts/store.js
CHANGED
|
@@ -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;
|
package/lib/reader/page-menu.js
CHANGED
|
@@ -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
|
|
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(
|
|
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;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
}
|
|
@@ -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: {
|
package/lib/routes/per-reader.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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',
|
|
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) =>
|
|
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.
|
|
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",
|