@supersuit/artifacts 0.33.1 → 0.35.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 +67 -0
- package/README.md +83 -0
- package/lib/artifacts/assets.d.ts +16 -0
- package/lib/artifacts/assets.js +35 -0
- package/lib/artifacts/front-matter.d.ts +4 -0
- package/lib/artifacts/front-matter.js +19 -1
- package/lib/artifacts/index.d.ts +4 -0
- package/lib/artifacts/index.js +5 -0
- package/lib/artifacts/recipients-store.d.ts +75 -0
- package/lib/artifacts/recipients-store.js +160 -0
- package/lib/artifacts/recipients.d.ts +31 -0
- package/lib/artifacts/recipients.js +87 -0
- package/lib/artifacts/render.js +18 -3
- package/lib/artifacts/resolve-digest-store.d.ts +8 -0
- package/lib/artifacts/resolve-digest-store.js +194 -0
- package/lib/artifacts/resolve-digest.d.ts +236 -0
- package/lib/artifacts/resolve-digest.js +282 -0
- package/lib/artifacts/state-store.d.ts +1 -0
- package/lib/artifacts/state-store.js +1 -0
- package/lib/artifacts/store.d.ts +4 -0
- package/lib/artifacts/store.js +9 -0
- package/lib/reader/index.d.ts +1 -0
- package/lib/reader/index.js +1 -0
- package/lib/reader/quiet-view.d.ts +7 -0
- package/lib/reader/quiet-view.js +118 -0
- package/lib/routes/artifacts.d.ts +35 -0
- package/lib/routes/artifacts.js +57 -10
- package/lib/routes/per-reader.d.ts +25 -0
- package/lib/routes/per-reader.js +38 -0
- package/lib/routes/recipients-routes.d.ts +34 -0
- package/lib/routes/recipients-routes.js +182 -0
- package/lib/routes/state-routes.d.ts +10 -0
- package/lib/routes/state-routes.js +52 -15
- package/package.json +1 -1
package/lib/routes/artifacts.js
CHANGED
|
@@ -21,6 +21,8 @@ import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
|
21
21
|
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
|
+
import { perReaderGate, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
|
|
25
|
+
import { ridCookieName, mintRenderToken, usableSecret, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
|
|
24
26
|
import { PageMenu } from '../reader/page-menu.js';
|
|
25
27
|
import { COMMENTS_MODES } from '../artifacts/comments.js';
|
|
26
28
|
import { newPageKey } from '../artifacts/settings.js';
|
|
@@ -31,6 +33,8 @@ import { readSecondsFor, wordCount } from '../artifacts/read-alerts.js';
|
|
|
31
33
|
import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
|
|
32
34
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
33
35
|
import { createStateRoutes } from './state-routes.js';
|
|
36
|
+
import { createRecipientsRoutes } from './recipients-routes.js';
|
|
37
|
+
import { QuietView } from '../reader/quiet-view.js';
|
|
34
38
|
import { createPersonalRoutes } from './personal-routes.js';
|
|
35
39
|
import { createVoiceRoutes } from './voice-routes.js';
|
|
36
40
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
@@ -87,9 +91,13 @@ export function createArtifactRoutes(config) {
|
|
|
87
91
|
const publishAuth = { publishKey: config.publishKey, publisherSecret: config.publisherSecret, publisherHost: config.publisherHost };
|
|
88
92
|
const authed = (request) => isPublisherAuthed(request, publishAuth);
|
|
89
93
|
const stateRoutes = createStateRoutes({
|
|
90
|
-
store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey,
|
|
94
|
+
store, state: config.state, readers: config.readers, readerSecret, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
|
|
91
95
|
publisherSecret: config.publisherSecret, publisherHost: config.publisherHost, pageUrl, signInOrigin, siteUrl,
|
|
92
|
-
clientIp: config.clientIp, ownerEmail: config.ownerEmail, admins: config.admins, audioUrl,
|
|
96
|
+
clientIp: config.clientIp, ownerEmail: config.ownerEmail, admins: config.admins, audioUrl, onResolve: config.onResolve,
|
|
97
|
+
});
|
|
98
|
+
const recipientsRoutes = createRecipientsRoutes({
|
|
99
|
+
store, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
|
|
100
|
+
state: config.state, assets: config.assets, pageUrl, authed,
|
|
93
101
|
});
|
|
94
102
|
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
|
|
95
103
|
const voiceRoutes = createVoiceRoutes({
|
|
@@ -272,6 +280,32 @@ export function createArtifactRoutes(config) {
|
|
|
272
280
|
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
|
|
273
281
|
if (a.access)
|
|
274
282
|
return GatedPage(a, id, render, n);
|
|
283
|
+
if (a.links === 'per-reader') {
|
|
284
|
+
if (n !== null)
|
|
285
|
+
notFound(); // a past version of a per-reader page is never served
|
|
286
|
+
const sp = (await searchParams) ?? {};
|
|
287
|
+
const param = Array.isArray(sp.r) ? sp.r[0] : sp.r;
|
|
288
|
+
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
289
|
+
const privateUrl = config.assets?.privateUrl?.bind(config.assets);
|
|
290
|
+
// A signed-in owner or admin gets through as the owner, with no link and no tracking.
|
|
291
|
+
const me = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
292
|
+
if (isAdmin(me)) {
|
|
293
|
+
const markdown = a.privateMedia ? await withPrivateUrls(a.markdown, id, privateUrl) : a.markdown;
|
|
294
|
+
return _jsx(BrandGround, { pack: pack, mode: a.theme, children: await Body({ ...a, markdown }, { top: true, reader: me }) });
|
|
295
|
+
}
|
|
296
|
+
const secret = usableSecret(config.recipientSecret?.());
|
|
297
|
+
const g = await perReaderGate({ id, param, cookie: await readCookie(ridCookieName(id)).catch(() => undefined), secret, recipients: config.recipients });
|
|
298
|
+
if (g.state === 'shut') {
|
|
299
|
+
return (_jsx(BrandGround, { pack: pack, mode: a.theme, children: _jsx("p", { "data-per-reader-shut": true, className: "mx-auto max-w-xl px-6 pt-32 text-center text-lg opacity-80", children: g.why === 'no-link' ? NO_LINK : UNAVAILABLE }) }));
|
|
300
|
+
}
|
|
301
|
+
const token = mintRenderToken(secret, id, g.rid);
|
|
302
|
+
const remember = g.fromLink
|
|
303
|
+
? [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(';')
|
|
304
|
+
: null;
|
|
305
|
+
const markdown = a.privateMedia ? await withPrivateUrls(a.markdown, id, privateUrl) : a.markdown;
|
|
306
|
+
const body = await Body({ ...a, markdown }, { top: true, reader: null, perReader: { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, token } });
|
|
307
|
+
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, body] }));
|
|
308
|
+
}
|
|
275
309
|
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
276
310
|
// page they were sent, and the unfurl (generateMetadata) keeps reading as the page.
|
|
277
311
|
const sp = (await searchParams) ?? {};
|
|
@@ -385,7 +419,7 @@ export function createArtifactRoutes(config) {
|
|
|
385
419
|
return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.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: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { n: n, at: v.at })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
|
|
386
420
|
}
|
|
387
421
|
/** The page itself, shared by open and gated pages. */
|
|
388
|
-
async function Body(a, { top, reader }) {
|
|
422
|
+
async function Body(a, { top, reader, perReader }) {
|
|
389
423
|
const { pack, brand } = lookOf(a);
|
|
390
424
|
let words = [];
|
|
391
425
|
if (a.narration && a.timings) {
|
|
@@ -407,10 +441,10 @@ export function createArtifactRoutes(config) {
|
|
|
407
441
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
408
442
|
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt }), 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] }));
|
|
409
443
|
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, 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] }));
|
|
410
|
-
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [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,
|
|
444
|
+
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [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,
|
|
411
445
|
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
412
446
|
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, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
|
|
413
|
-
})() }), _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 }), 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,
|
|
447
|
+
})() }), _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 }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, 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,
|
|
414
448
|
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
415
449
|
recorded at publish, or there is none. */] }));
|
|
416
450
|
}
|
|
@@ -523,6 +557,8 @@ export function createArtifactRoutes(config) {
|
|
|
523
557
|
if (request.method === 'GET')
|
|
524
558
|
return NextResponse.json({ id, access: a.access ?? null, readers: await config.readers.allowList(id) });
|
|
525
559
|
const b = (await request.json().catch(() => null));
|
|
560
|
+
if (a.links === 'per-reader' && b?.access !== undefined)
|
|
561
|
+
return NextResponse.json({ error: 'a per-reader page opens only from its own links, so its access and password cannot be changed' }, { status: 409 });
|
|
526
562
|
let access = a.access ?? null;
|
|
527
563
|
if (b?.access !== undefined) {
|
|
528
564
|
if (b.access !== 'public' && !ACCESS_LEVELS.includes(b.access))
|
|
@@ -587,6 +623,8 @@ export function createArtifactRoutes(config) {
|
|
|
587
623
|
}
|
|
588
624
|
if (!b || typeof b !== 'object' || Array.isArray(b))
|
|
589
625
|
return no('the body is an object', 400);
|
|
626
|
+
if (a.links === 'per-reader' && (b.access !== undefined || b.password !== undefined))
|
|
627
|
+
return no('a per-reader page opens only from its own links, so its access and password cannot be changed', 409);
|
|
590
628
|
const KEYS = ['access', 'password', 'comments', 'commentsVisible', 'add', 'remove'];
|
|
591
629
|
const unknown = Object.keys(b).filter((k) => !KEYS.includes(k));
|
|
592
630
|
if (unknown.length)
|
|
@@ -719,6 +757,9 @@ export function createArtifactRoutes(config) {
|
|
|
719
757
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
720
758
|
if (!config.assets)
|
|
721
759
|
return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
|
|
760
|
+
const priv = request.nextUrl.searchParams.get('private') === '1';
|
|
761
|
+
if (priv && !config.assets.putPrivate)
|
|
762
|
+
return NextResponse.json({ error: 'this host keeps no private media' }, { status: 501 });
|
|
722
763
|
const { id, name } = await params;
|
|
723
764
|
if (!ARTIFACT_ID.test(id))
|
|
724
765
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
@@ -734,7 +775,7 @@ export function createArtifactRoutes(config) {
|
|
|
734
775
|
return NextResponse.json({ error: 'empty body' }, { status: 400 });
|
|
735
776
|
if (bytes.length > 16 * 1024 * 1024)
|
|
736
777
|
return NextResponse.json({ error: 'asset over 16 MiB' }, { status: 413 });
|
|
737
|
-
const url = await config.assets.put(id, name, bytes, type);
|
|
778
|
+
const url = priv ? await config.assets.putPrivate(id, name, bytes, type) : await config.assets.put(id, name, bytes, type);
|
|
738
779
|
return NextResponse.json({ id, name, url }, { status: 201 });
|
|
739
780
|
}
|
|
740
781
|
/**
|
|
@@ -762,7 +803,12 @@ export function createArtifactRoutes(config) {
|
|
|
762
803
|
if (!authed(request))
|
|
763
804
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
764
805
|
const assets = config.assets;
|
|
765
|
-
|
|
806
|
+
const priv = request.nextUrl.searchParams.get('private') === '1';
|
|
807
|
+
if (priv) {
|
|
808
|
+
if (!assets?.signPrivateUpload || !assets.finishPrivateUpload)
|
|
809
|
+
return NextResponse.json({ error: 'this host keeps no private media' }, { status: 501 });
|
|
810
|
+
}
|
|
811
|
+
else if (!assets?.signUpload || !assets.finishUpload)
|
|
766
812
|
return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
|
|
767
813
|
const { id, name } = await params;
|
|
768
814
|
if (!ARTIFACT_ID.test(id))
|
|
@@ -778,12 +824,13 @@ export function createArtifactRoutes(config) {
|
|
|
778
824
|
if (!ASSET_DIGEST.test(digest))
|
|
779
825
|
return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
|
|
780
826
|
if (body.done === true) {
|
|
781
|
-
const url = await assets.finishUpload(id, name, digest);
|
|
827
|
+
const url = priv ? await assets.finishPrivateUpload(id, name, digest) : await assets.finishUpload(id, name, digest);
|
|
782
828
|
if (!url)
|
|
783
829
|
return NextResponse.json({ error: 'nothing was uploaded to that url' }, { status: 409 });
|
|
784
830
|
return NextResponse.json({ id, name, url }, { status: 201 });
|
|
785
831
|
}
|
|
786
|
-
|
|
832
|
+
const signed = priv ? await assets.signPrivateUpload(id, name, digest, type) : await assets.signUpload(id, name, digest, type);
|
|
833
|
+
return NextResponse.json({ id, name, ...signed }, { status: 201 });
|
|
787
834
|
}
|
|
788
835
|
/** GET /<id>/share.png: the page's title card. 404 when the pack draws none, so a
|
|
789
836
|
* tenant on the static default never serves a half-styled card. */
|
|
@@ -828,5 +875,5 @@ export function createArtifactRoutes(config) {
|
|
|
828
875
|
revalidatePath(pagePath(id));
|
|
829
876
|
return NextResponse.json({ deleted: true });
|
|
830
877
|
}
|
|
831
|
-
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
878
|
+
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, SETTINGS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, ...recipientsRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
832
879
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RecipientsStore } from '../artifacts/recipients-store.js';
|
|
2
|
+
export type PerReaderOpen = {
|
|
3
|
+
state: 'open';
|
|
4
|
+
rid: string;
|
|
5
|
+
sig: string;
|
|
6
|
+
label: string;
|
|
7
|
+
note: string | null;
|
|
8
|
+
owner: boolean;
|
|
9
|
+
fromLink: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type PerReaderShut = {
|
|
12
|
+
state: 'shut';
|
|
13
|
+
why: 'no-link' | 'revoked' | 'expired' | 'closed';
|
|
14
|
+
};
|
|
15
|
+
export declare const UNAVAILABLE = "This page is no longer available.";
|
|
16
|
+
export declare const NO_LINK = "This page opens from the link it was sent with.";
|
|
17
|
+
export declare function perReaderGate(o: {
|
|
18
|
+
id: string;
|
|
19
|
+
param: string | undefined;
|
|
20
|
+
cookie: string | undefined;
|
|
21
|
+
secret: string | undefined;
|
|
22
|
+
recipients: RecipientsStore | undefined;
|
|
23
|
+
now?: number;
|
|
24
|
+
}): Promise<PerReaderOpen | PerReaderShut>;
|
|
25
|
+
export declare function withPrivateUrls(markdown: string, id: string, privateUrl: ((id: string, stored: string) => Promise<string>) | undefined): Promise<string>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Who is reading a `links: per-reader` page, decided before anything of the page is rendered.
|
|
2
|
+
// Every refusal is the same quiet page; a revoked or expired reader learns only that it is gone.
|
|
3
|
+
import { parseRidParam, verifyRid } from '../artifacts/recipients.js';
|
|
4
|
+
import { PRIVATE_REF } from '../artifacts/assets.js';
|
|
5
|
+
export const UNAVAILABLE = 'This page is no longer available.';
|
|
6
|
+
export const NO_LINK = 'This page opens from the link it was sent with.';
|
|
7
|
+
export async function perReaderGate(o) {
|
|
8
|
+
// An unset or empty secret verifies nothing: fail closed before any signature is compared.
|
|
9
|
+
if (!o.secret || !o.recipients)
|
|
10
|
+
return { state: 'shut', why: 'closed' };
|
|
11
|
+
const fromParam = parseRidParam(o.param);
|
|
12
|
+
const fromCookie = parseRidParam(o.cookie);
|
|
13
|
+
const pick = fromParam && verifyRid(o.secret, o.id, fromParam.rid, fromParam.sig) ? { ...fromParam, fromLink: true }
|
|
14
|
+
: fromCookie && verifyRid(o.secret, o.id, fromCookie.rid, fromCookie.sig) ? { ...fromCookie, fromLink: false } : null;
|
|
15
|
+
if (!pick)
|
|
16
|
+
return { state: 'shut', why: 'no-link' };
|
|
17
|
+
const rec = await o.recipients.get(o.id);
|
|
18
|
+
const r = rec?.recipients[pick.rid];
|
|
19
|
+
if (!rec || !r)
|
|
20
|
+
return { state: 'shut', why: 'no-link' };
|
|
21
|
+
if (r.revokedAt)
|
|
22
|
+
return { state: 'shut', why: 'revoked' };
|
|
23
|
+
// A malformed expireAt parses to NaN: treat it as expired, never as no expiry.
|
|
24
|
+
if (rec.expireAt && !(Date.parse(rec.expireAt) > (o.now ?? Date.now())))
|
|
25
|
+
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, fromLink: pick.fromLink };
|
|
27
|
+
}
|
|
28
|
+
const REF_IN_MD = /\]\((private:[A-Za-z0-9][A-Za-z0-9._-]{0,140})\)/g;
|
|
29
|
+
export async function withPrivateUrls(markdown, id, privateUrl) {
|
|
30
|
+
const refs = [...new Set([...markdown.matchAll(REF_IN_MD)].map((m) => m[1]))];
|
|
31
|
+
let out = markdown;
|
|
32
|
+
for (const ref of refs) {
|
|
33
|
+
const stored = PRIVATE_REF.exec(ref)?.[1];
|
|
34
|
+
const url = stored && privateUrl ? await privateUrl(id, stored) : '#';
|
|
35
|
+
out = out.split(`](${ref})`).join(`](${url})`);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import { type RecipientsStore } from '../artifacts/recipients-store.js';
|
|
3
|
+
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
|
+
import type { StateStore } from '../artifacts/state-store.js';
|
|
5
|
+
import type { ArtifactAssets } from '../artifacts/assets.js';
|
|
6
|
+
type Params = {
|
|
7
|
+
params: Promise<{
|
|
8
|
+
id: string;
|
|
9
|
+
}>;
|
|
10
|
+
};
|
|
11
|
+
export type RecipientsRoutesCtx = {
|
|
12
|
+
store: ArtifactStore;
|
|
13
|
+
recipients?: RecipientsStore;
|
|
14
|
+
recipientSecret?: () => string | undefined;
|
|
15
|
+
publishKey: () => string | undefined;
|
|
16
|
+
state?: StateStore;
|
|
17
|
+
assets?: ArtifactAssets;
|
|
18
|
+
pageUrl: (id: string) => string;
|
|
19
|
+
authed: (r: Request) => boolean;
|
|
20
|
+
/** Where the sweep reports a page it skipped or could not finish. Defaults to console.warn. */
|
|
21
|
+
log?: (message: string) => void;
|
|
22
|
+
};
|
|
23
|
+
export declare function createRecipientsRoutes(given: RecipientsRoutesCtx): {
|
|
24
|
+
SEEN: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
25
|
+
RECIPIENTS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
26
|
+
VIEWS: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
27
|
+
sweepExpired: (now?: Date) => Promise<{
|
|
28
|
+
pages: number;
|
|
29
|
+
files: number;
|
|
30
|
+
skipped: string[];
|
|
31
|
+
failed: string[];
|
|
32
|
+
}>;
|
|
33
|
+
};
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The per-reader routes. SEEN is public (the page calls it); everything else needs the publish key.
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { RID_RE, usableSecret, beatKey, isUnfurlAgent, ridLink, verifyRenderToken, verifyRid } from '../artifacts/recipients.js';
|
|
4
|
+
import { summarizeViews } from '../artifacts/recipients-store.js';
|
|
5
|
+
import { COMMENTS_SLOT } from '../artifacts/comments.js';
|
|
6
|
+
import { ARTIFACT_ID_RE } from './ids.js';
|
|
7
|
+
// Session and device ids become part of a stored view's id, so only a short plain shape is let in.
|
|
8
|
+
const SESSION = /^[a-z0-9]{1,32}$/;
|
|
9
|
+
const MEDIA = /^[A-Za-z0-9][A-Za-z0-9._-]{0,140}$/;
|
|
10
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
11
|
+
const none = () => new NextResponse(null, { status: 204, headers: { 'cache-control': 'no-store' } });
|
|
12
|
+
const json = (b, status = 200) => NextResponse.json(b, { status, headers: { 'cache-control': 'no-store' } });
|
|
13
|
+
export function createRecipientsRoutes(given) {
|
|
14
|
+
// A secret under the documented minimum is no secret: every route treats it as unset.
|
|
15
|
+
const ctx = { ...given, recipientSecret: () => usableSecret(given.recipientSecret?.()) };
|
|
16
|
+
/** POST /api/artifacts/<id>/seen: the page's own signal that a reader looked at it. */
|
|
17
|
+
async function SEEN(req, { params }) {
|
|
18
|
+
const { id } = await params;
|
|
19
|
+
// An unfurl agent is dropped before anything is read: no state, no answer worth reading.
|
|
20
|
+
if (isUnfurlAgent(req.headers.get('user-agent')))
|
|
21
|
+
return none();
|
|
22
|
+
// An unset or empty secret verifies nothing: fail closed before any signature is compared.
|
|
23
|
+
const secret = ctx.recipientSecret?.();
|
|
24
|
+
if (!secret || !ctx.recipients || !ARTIFACT_ID_RE.test(id))
|
|
25
|
+
return json({ error: 'not found' }, 404);
|
|
26
|
+
const b = (await req.json().catch(() => null));
|
|
27
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
28
|
+
const rid = str(b?.rid), sig = str(b?.sig), session = str(b?.session), device = str(b?.device);
|
|
29
|
+
if (!RID_RE.test(rid) || !SESSION.test(session) || !SESSION.test(device))
|
|
30
|
+
return json({ error: 'bad signal' }, 400);
|
|
31
|
+
if (!verifyRid(secret, id, rid, sig))
|
|
32
|
+
return json({ error: 'forbidden' }, 403);
|
|
33
|
+
// The rid must be one this page was sent to, still live: a signature alone proves only that
|
|
34
|
+
// the host minted it once.
|
|
35
|
+
const rec = await ctx.recipients.get(id);
|
|
36
|
+
const r = rec?.recipients[rid];
|
|
37
|
+
// A malformed expireAt parses to NaN: treat it as expired, never as no expiry.
|
|
38
|
+
if (!rec || !r || r.revokedAt || (rec.expireAt && !(Date.parse(rec.expireAt) > Date.now())))
|
|
39
|
+
return json({ error: 'gone' }, 410);
|
|
40
|
+
// The owner's own opens record zero.
|
|
41
|
+
if (r.owner)
|
|
42
|
+
return none();
|
|
43
|
+
const at = new Date().toISOString();
|
|
44
|
+
const event = b?.event;
|
|
45
|
+
if (event === 'view') {
|
|
46
|
+
const t = verifyRenderToken(secret, id, rid, str(b?.token));
|
|
47
|
+
if (!t.ok || !(await ctx.recipients.useToken(t.key, t.expiresAt)))
|
|
48
|
+
return json({ error: 'not a fresh render' }, 409);
|
|
49
|
+
await ctx.recipients.recordView({ artifactId: id, rid, session, device, at, addSeconds: 2 });
|
|
50
|
+
return json({ beat: beatKey(secret, id, rid, session) });
|
|
51
|
+
}
|
|
52
|
+
if (event === 'beat' || event === 'play') {
|
|
53
|
+
if (str(b?.beat) !== beatKey(secret, id, rid, session))
|
|
54
|
+
return json({ error: 'forbidden' }, 403);
|
|
55
|
+
const media = event === 'play' && typeof b?.media === 'string' && MEDIA.test(b.media) ? b.media : undefined;
|
|
56
|
+
const seconds = event === 'beat' ? Math.max(0, Math.min(20, Number(b?.seconds) || 0)) : 0;
|
|
57
|
+
await ctx.recipients.recordView({ artifactId: id, rid, session, device, at, addSeconds: seconds, ...(media ? { play: media } : {}) });
|
|
58
|
+
return json({});
|
|
59
|
+
}
|
|
60
|
+
return json({ error: 'event must be view, beat or play' }, 400);
|
|
61
|
+
}
|
|
62
|
+
const unauth = () => json({ error: 'publish key required' }, 401);
|
|
63
|
+
async function listing(id) {
|
|
64
|
+
const secret = ctx.recipientSecret();
|
|
65
|
+
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
66
|
+
return {
|
|
67
|
+
id, expireAt: rec.expireAt, keep: rec.keep,
|
|
68
|
+
recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** GET and POST /api/artifacts/<id>/recipients: who a per-reader page was sent to (publish key). */
|
|
72
|
+
async function RECIPIENTS(req, { params }) {
|
|
73
|
+
const { id } = await params;
|
|
74
|
+
if (!ctx.authed(req))
|
|
75
|
+
return unauth();
|
|
76
|
+
if (!ctx.recipients || !ctx.recipientSecret?.())
|
|
77
|
+
return json({ error: 'this host keeps no per-reader links' }, 501);
|
|
78
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
79
|
+
if (!a)
|
|
80
|
+
return json({ error: `no artifact with id ${id}` }, 404);
|
|
81
|
+
if (req.method === 'GET')
|
|
82
|
+
return json(await listing(id));
|
|
83
|
+
if (a.links !== 'per-reader')
|
|
84
|
+
return json({ error: 'this page is not links: per-reader' }, 409);
|
|
85
|
+
const b = (await req.json().catch(() => null));
|
|
86
|
+
if (!b)
|
|
87
|
+
return json({ error: 'body must be JSON' }, 400);
|
|
88
|
+
const secret = ctx.recipientSecret();
|
|
89
|
+
let added = [];
|
|
90
|
+
if (Array.isArray(b.add) && b.add.some((x) => !x || typeof x !== 'object' || typeof x.label !== 'string' || !x.label.trim()))
|
|
91
|
+
return json({ error: 'each reader to add needs a label' }, 400);
|
|
92
|
+
if (typeof b.expires === 'string' && Number.isNaN(Date.parse(b.expires)))
|
|
93
|
+
return json({ error: 'expires must be an ISO time or null' }, 400);
|
|
94
|
+
if (typeof b.expires === 'string' && Date.parse(b.expires) <= Date.now())
|
|
95
|
+
return json({ error: 'expires must be in the future' }, 400);
|
|
96
|
+
if (Array.isArray(b.add) && b.add.length) {
|
|
97
|
+
const made = await ctx.recipients.add(id, b.add);
|
|
98
|
+
added = made.map((r) => ({ rid: r.rid, label: r.label, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) }));
|
|
99
|
+
}
|
|
100
|
+
const revoked = b.revoke === '*' || Array.isArray(b.revoke)
|
|
101
|
+
? await ctx.recipients.revoke(id, b.revoke === '*' ? '*' : b.revoke.filter((x) => typeof x === 'string' && RID_RE.test(x)))
|
|
102
|
+
: [];
|
|
103
|
+
if (b.expires === null || typeof b.expires === 'string') {
|
|
104
|
+
await ctx.recipients.setExpiry(id, b.expires === null ? null : new Date(b.expires).toISOString());
|
|
105
|
+
}
|
|
106
|
+
return json({ ...(await listing(id)), added, revoked });
|
|
107
|
+
}
|
|
108
|
+
/** GET /api/artifacts/<id>/views: one line per reader, with their comments (publish key). */
|
|
109
|
+
async function VIEWS(req, { params }) {
|
|
110
|
+
const { id } = await params;
|
|
111
|
+
if (!ctx.authed(req))
|
|
112
|
+
return unauth();
|
|
113
|
+
if (!ctx.recipients)
|
|
114
|
+
return json({ error: 'this host keeps no per-reader links' }, 501);
|
|
115
|
+
const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
|
|
116
|
+
if (!a)
|
|
117
|
+
return json({ error: `no artifact with id ${id}` }, 404);
|
|
118
|
+
// A page nobody has been added to yet has no readers, which is an answer and not a missing page.
|
|
119
|
+
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
120
|
+
const entries = ctx.state ? await ctx.state.entries(id) : [];
|
|
121
|
+
const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, at: e.at, text: String(e.value?.body ?? '') }));
|
|
122
|
+
return json({ id, expireAt: rec.expireAt, keep: rec.keep, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments) });
|
|
123
|
+
}
|
|
124
|
+
/** The daily sweep: deletes only pages whose recipients record carries an expireAt that is a
|
|
125
|
+
* well-formed time in the past and is not kept. An unparseable expireAt is never deleted (the
|
|
126
|
+
* gate already shuts the page); it is reported in `skipped`. A page that fails midway stays
|
|
127
|
+
* listed (the expiry record is forgotten last) and is retried by the next sweep. */
|
|
128
|
+
async function sweepExpired(now = new Date()) {
|
|
129
|
+
const out = { pages: 0, files: 0, skipped: [], failed: [] };
|
|
130
|
+
if (!ctx.recipients)
|
|
131
|
+
return out;
|
|
132
|
+
const log = ctx.log ?? ((m) => console.warn(m));
|
|
133
|
+
for (const id of await ctx.recipients.expiring(now.toISOString())) {
|
|
134
|
+
try {
|
|
135
|
+
const rec = await ctx.recipients.get(id);
|
|
136
|
+
const at = rec && !rec.keep && typeof rec.expireAt === 'string' && ISO.test(rec.expireAt) ? Date.parse(rec.expireAt) : NaN;
|
|
137
|
+
if (!rec || rec.keep || !(at <= now.getTime())) {
|
|
138
|
+
if (rec && !rec.keep) {
|
|
139
|
+
out.skipped.push(id);
|
|
140
|
+
log(`sweepExpired: not deleting ${id}, expireAt is not a past ISO time`);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const page = await ctx.store.get(id);
|
|
145
|
+
// The page is gone already: only the leftover record is cleaned up.
|
|
146
|
+
if (!page) {
|
|
147
|
+
await ctx.recipients.forget(id);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// A page republished without per-reader links keeps its old record; it is no longer a broadcast page.
|
|
151
|
+
if (page.links !== 'per-reader') {
|
|
152
|
+
out.skipped.push(id);
|
|
153
|
+
log(`sweepExpired: not deleting ${id}, it is not a per-reader page`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!ctx.assets?.deletePage)
|
|
157
|
+
throw new Error('media deletion is not configured, page record kept so no media is orphaned');
|
|
158
|
+
const files = await ctx.assets.deletePage(id);
|
|
159
|
+
// Keep may have landed while the files were being removed: the page record and comments stay.
|
|
160
|
+
const again = await ctx.recipients.get(id);
|
|
161
|
+
if (!again || again.keep) {
|
|
162
|
+
log(`sweepExpired: ${id} was kept during the sweep`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Reader text expires with the page: every comment and answer, whoever wrote it.
|
|
166
|
+
if (ctx.state)
|
|
167
|
+
for (const key of new Set((await ctx.state.entries(id)).map((e) => e.readerKey)))
|
|
168
|
+
await ctx.state.removeReader(id, key);
|
|
169
|
+
await ctx.store.delete(id);
|
|
170
|
+
await ctx.recipients.forget(id);
|
|
171
|
+
out.pages++;
|
|
172
|
+
out.files += files;
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
out.failed.push(id);
|
|
176
|
+
log(`sweepExpired: ${id} not finished, will retry: ${e instanceof Error ? e.message : String(e)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
return { SEEN, RECIPIENTS, VIEWS, sweepExpired };
|
|
182
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import type { RecipientsStore } from '../artifacts/recipients-store.js';
|
|
2
3
|
import { type StateStore } from '../artifacts/state-store.js';
|
|
3
4
|
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
5
|
import type { ReadersStore } from '../artifacts/readers-store.js';
|
|
6
|
+
import type { ResolveEvent } from '../artifacts/resolve-digest.js';
|
|
5
7
|
export declare const ANON_COOKIE = "artifact_anon";
|
|
6
8
|
/** The most comments one feed answer carries. A caller that gets this many moves its cursor to the
|
|
7
9
|
* last row's `at` and asks again. */
|
|
@@ -19,6 +21,10 @@ export type StateRoutesContext = {
|
|
|
19
21
|
state?: StateStore;
|
|
20
22
|
readers?: ReadersStore;
|
|
21
23
|
readerSecret: () => string | undefined;
|
|
24
|
+
/** Who each per-reader page was sent to, and the secret their links are signed with. Without
|
|
25
|
+
* both, a `links: per-reader` page takes no answers. */
|
|
26
|
+
recipients?: RecipientsStore;
|
|
27
|
+
recipientSecret?: () => string | undefined;
|
|
22
28
|
publishKey: () => string | undefined;
|
|
23
29
|
/** Publisher passes, as in ArtifactRoutesConfig. Both absent means passes are refused. */
|
|
24
30
|
publisherSecret?: () => string | undefined;
|
|
@@ -39,6 +45,10 @@ export type StateRoutesContext = {
|
|
|
39
45
|
/** A signed URL to play a comment's recording. Called only for `comments/` paths, and only for
|
|
40
46
|
* comments the asker is already being shown. Without it a recording has no play control. */
|
|
41
47
|
audioUrl?: (id: string, path: string) => Promise<string>;
|
|
48
|
+
/** Told after a thread is resolved or reopened (0.34.0), with the whole thread and who did it.
|
|
49
|
+
* `createResolveDigests(...).onResolve` turns it into resolution emails. Awaited, and anything
|
|
50
|
+
* it throws is logged and swallowed: a broken mailer never costs the owner their resolve. */
|
|
51
|
+
onResolve?: (event: ResolveEvent) => void | Promise<void>;
|
|
42
52
|
};
|
|
43
53
|
type Params = {
|
|
44
54
|
params: Promise<{
|