@supersuit/artifacts 0.1.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 +37 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/fonts/Newsreader-600.ttf +0 -0
- package/lib/artifacts/assets.d.ts +27 -0
- package/lib/artifacts/assets.js +63 -0
- package/lib/artifacts/auth.d.ts +1 -0
- package/lib/artifacts/auth.js +8 -0
- package/lib/artifacts/confidential.d.ts +47 -0
- package/lib/artifacts/confidential.js +39 -0
- package/lib/artifacts/door.d.ts +5 -0
- package/lib/artifacts/door.js +4 -0
- package/lib/artifacts/front-matter.d.ts +34 -0
- package/lib/artifacts/front-matter.js +37 -0
- package/lib/artifacts/index.d.ts +9 -0
- package/lib/artifacts/index.js +9 -0
- package/lib/artifacts/narration.d.ts +8 -0
- package/lib/artifacts/narration.js +85 -0
- package/lib/artifacts/reader.d.ts +54 -0
- package/lib/artifacts/reader.js +109 -0
- package/lib/artifacts/readers-store.d.ts +90 -0
- package/lib/artifacts/readers-store.js +141 -0
- package/lib/artifacts/render.d.ts +4 -0
- package/lib/artifacts/render.js +90 -0
- package/lib/artifacts/store.d.ts +52 -0
- package/lib/artifacts/store.js +114 -0
- package/lib/artifacts/unlock.d.ts +9 -0
- package/lib/artifacts/unlock.js +24 -0
- package/lib/brand/default-share.d.ts +5 -0
- package/lib/brand/default-share.js +37 -0
- package/lib/brand/index.d.ts +3 -0
- package/lib/brand/index.js +3 -0
- package/lib/brand/pack.d.ts +48 -0
- package/lib/brand/pack.js +17 -0
- package/lib/brand/share-card.d.ts +13 -0
- package/lib/brand/share-card.js +50 -0
- package/lib/brand/wrapper.d.ts +12 -0
- package/lib/brand/wrapper.js +13 -0
- package/lib/gate.d.ts +1 -0
- package/lib/gate.js +5 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +4 -0
- package/lib/reader/artifact-reader.d.ts +14 -0
- package/lib/reader/artifact-reader.js +165 -0
- package/lib/reader/reader-watch.d.ts +7 -0
- package/lib/reader/reader-watch.js +125 -0
- package/lib/routes/artifacts.d.ts +115 -0
- package/lib/routes/artifacts.js +345 -0
- package/package.json +74 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// What a gated page does in the reader's browser: count how long they actually read, how far
|
|
4
|
+
// they got, and catch the moves that take the page away from here (save, print, a large copy,
|
|
5
|
+
// saving an image, opening the inspector). Each of those is stopped where a browser lets a page
|
|
6
|
+
// stop it, told to the reader in a line, and recorded under their name.
|
|
7
|
+
//
|
|
8
|
+
// HONEST LIMITS. A screenshot, a phone camera, or a reader who disables scripts cannot be seen
|
|
9
|
+
// by any web page. That is what the banner and the watermark are for: they make every copy
|
|
10
|
+
// carry the name of the person it was shown to.
|
|
11
|
+
import { useEffect, useRef, useState } from 'react';
|
|
12
|
+
const BEAT_MS = 15_000;
|
|
13
|
+
/** A reader idle this long stops earning time, so a tab left open overnight is not "reading". */
|
|
14
|
+
const IDLE_MS = 60_000;
|
|
15
|
+
/** Quoting a sentence is fine; lifting a section is not. */
|
|
16
|
+
export const COPY_LIMIT = 280;
|
|
17
|
+
export function ReaderWatch({ artifactId, endpoint, accent }) {
|
|
18
|
+
const [notice, setNotice] = useState(null);
|
|
19
|
+
const state = useRef({ session: '', lastActive: Date.now(), maxScroll: 0, pending: 0, lastBeat: Date.now() });
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const s = state.current;
|
|
22
|
+
s.session = (crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`).replace(/[^A-Za-z0-9-]/g, '');
|
|
23
|
+
const send = (body, beacon = false) => {
|
|
24
|
+
const payload = JSON.stringify({ id: artifactId, session: s.session, ...body });
|
|
25
|
+
if (beacon && navigator.sendBeacon) {
|
|
26
|
+
navigator.sendBeacon(endpoint, new Blob([payload], { type: 'application/json' }));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
fetch(endpoint, { method: 'POST', body: payload, headers: { 'content-type': 'application/json' }, keepalive: true, credentials: 'same-origin' }).catch(() => { });
|
|
30
|
+
};
|
|
31
|
+
const scroll = () => {
|
|
32
|
+
const h = document.documentElement.scrollHeight - window.innerHeight;
|
|
33
|
+
const pct = h <= 0 ? 100 : Math.min(100, Math.round((window.scrollY / h) * 100));
|
|
34
|
+
s.maxScroll = Math.max(s.maxScroll, pct);
|
|
35
|
+
};
|
|
36
|
+
const active = () => { s.lastActive = Date.now(); };
|
|
37
|
+
// Seconds actually read since the last beat: visible, and touched within the idle window.
|
|
38
|
+
const accrue = () => {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
if (document.visibilityState === 'visible' && now - s.lastActive < IDLE_MS)
|
|
41
|
+
s.pending += (now - s.lastBeat) / 1000;
|
|
42
|
+
s.lastBeat = now;
|
|
43
|
+
};
|
|
44
|
+
const beat = (beacon = false) => {
|
|
45
|
+
accrue();
|
|
46
|
+
const add = Math.min(60, Math.round(s.pending));
|
|
47
|
+
s.pending -= add;
|
|
48
|
+
send({ kind: 'beat', active: add, scroll: s.maxScroll }, beacon);
|
|
49
|
+
};
|
|
50
|
+
const flag = (kind, detail, message) => {
|
|
51
|
+
send({ kind: 'flag', flag: kind, ...(detail ? { detail } : {}) });
|
|
52
|
+
if (message) {
|
|
53
|
+
setNotice(message);
|
|
54
|
+
window.setTimeout(() => setNotice(null), 6000);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
scroll();
|
|
58
|
+
send({ kind: 'beat', active: 0, scroll: s.maxScroll });
|
|
59
|
+
const timer = window.setInterval(() => beat(), BEAT_MS);
|
|
60
|
+
const onVisibility = () => { if (document.visibilityState === 'hidden')
|
|
61
|
+
beat(true);
|
|
62
|
+
else
|
|
63
|
+
s.lastBeat = Date.now(); };
|
|
64
|
+
const onHide = () => beat(true);
|
|
65
|
+
const onKey = (e) => {
|
|
66
|
+
active();
|
|
67
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
68
|
+
const k = e.key.toLowerCase();
|
|
69
|
+
if (mod && k === 's') {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
flag('save', 'keyboard', 'Saving is turned off for this page, and the attempt was recorded.');
|
|
72
|
+
}
|
|
73
|
+
else if (mod && k === 'p') {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
flag('print', 'keyboard', 'Printing is turned off for this page, and the attempt was recorded.');
|
|
76
|
+
}
|
|
77
|
+
else if (e.key === 'F12' || (mod && e.altKey && k === 'i') || (mod && e.shiftKey && (k === 'i' || k === 'c')))
|
|
78
|
+
flag('devtools', 'keyboard');
|
|
79
|
+
};
|
|
80
|
+
const onBeforePrint = () => flag('print', 'menu', 'Printing is turned off for this page, and the attempt was recorded.');
|
|
81
|
+
const onCopy = (e) => {
|
|
82
|
+
const text = String(window.getSelection() ?? '');
|
|
83
|
+
if (text.length <= COPY_LIMIT)
|
|
84
|
+
return;
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
e.clipboardData?.setData('text/plain', 'This passage is confidential and was not copied.');
|
|
87
|
+
flag('copy', `${text.length} characters`, 'Copying more than a sentence or two is turned off for this page, and the attempt was recorded.');
|
|
88
|
+
};
|
|
89
|
+
const onContext = (e) => {
|
|
90
|
+
if (e.target?.closest?.('img')) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
flag('image', 'right-click', 'Saving images is turned off for this page, and the attempt was recorded.');
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const onDrag = (e) => {
|
|
96
|
+
if (e.target?.closest?.('img')) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
flag('image', 'drag');
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
window.addEventListener('scroll', () => { scroll(); active(); }, { passive: true });
|
|
102
|
+
for (const ev of ['mousemove', 'pointerdown', 'touchstart', 'wheel'])
|
|
103
|
+
window.addEventListener(ev, active, { passive: true });
|
|
104
|
+
window.addEventListener('keydown', onKey);
|
|
105
|
+
window.addEventListener('beforeprint', onBeforePrint);
|
|
106
|
+
document.addEventListener('copy', onCopy);
|
|
107
|
+
document.addEventListener('contextmenu', onContext);
|
|
108
|
+
document.addEventListener('dragstart', onDrag);
|
|
109
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
110
|
+
window.addEventListener('pagehide', onHide);
|
|
111
|
+
return () => {
|
|
112
|
+
window.clearInterval(timer);
|
|
113
|
+
window.removeEventListener('keydown', onKey);
|
|
114
|
+
window.removeEventListener('beforeprint', onBeforePrint);
|
|
115
|
+
document.removeEventListener('copy', onCopy);
|
|
116
|
+
document.removeEventListener('contextmenu', onContext);
|
|
117
|
+
document.removeEventListener('dragstart', onDrag);
|
|
118
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
119
|
+
window.removeEventListener('pagehide', onHide);
|
|
120
|
+
for (const ev of ['mousemove', 'pointerdown', 'touchstart', 'wheel'])
|
|
121
|
+
window.removeEventListener(ev, active);
|
|
122
|
+
};
|
|
123
|
+
}, [artifactId, endpoint]);
|
|
124
|
+
return notice ? (_jsx("div", { role: "status", "data-nospeak": true, className: "fixed inset-x-0 bottom-6 z-50 mx-auto w-fit max-w-[90vw] rounded-lg px-4 py-3 text-sm shadow-lg", style: { background: '#111', color: '#fff', border: `1px solid ${accent}` }, children: notice })) : null;
|
|
125
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
|
+
import { type ArtifactAssets } from '../artifacts/assets.js';
|
|
5
|
+
import type { BrandPack } from '../brand/pack.js';
|
|
6
|
+
import { type Access } from '../artifacts/reader.js';
|
|
7
|
+
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
8
|
+
export type ArtifactRoutesConfig = {
|
|
9
|
+
store: ArtifactStore;
|
|
10
|
+
/** Where uploaded files go. Optional; without it PUT_ASSET answers 501. */
|
|
11
|
+
assets?: ArtifactAssets;
|
|
12
|
+
brand: BrandPack;
|
|
13
|
+
/** The host that serves a 200, e.g. https://artifacts.example.com. No trailing slash. */
|
|
14
|
+
siteUrl: string;
|
|
15
|
+
/** Read at request time, so a rotated key needs no rebuild. */
|
|
16
|
+
publishKey: () => string | undefined;
|
|
17
|
+
/** Share image when an artifact has no cover. Absolute or site-relative. */
|
|
18
|
+
defaultShareImage?: string;
|
|
19
|
+
/** Where a page lives under the origin. '/' on a dedicated host (artifacts.<name>/<id>);
|
|
20
|
+
* '/a/' when the instance is a route inside a larger site. Default '/'. */
|
|
21
|
+
pagePrefix?: string;
|
|
22
|
+
/** Read one request cookie by name. Default reads through next/headers; tests inject one. */
|
|
23
|
+
readCookie?: (name: string) => Promise<string | undefined>;
|
|
24
|
+
/** The record for gated pages (`access:` in front matter). Without it a gated page stays shut
|
|
25
|
+
* to everyone, never open: failing closed is the only safe default for a confidential page. */
|
|
26
|
+
readers?: ReadersStore;
|
|
27
|
+
/** Shared with the sign-in authority, which mints the pass. Read at request time. */
|
|
28
|
+
readerSecret?: () => string | undefined;
|
|
29
|
+
/** The sign-in authority's origin; readers go to `<signInOrigin>/artifact/sign-in?to=<page>`.
|
|
30
|
+
* No default: without it a gated page shows its door with no way through (fails closed). */
|
|
31
|
+
signInOrigin?: string;
|
|
32
|
+
/** Who the banner says grants access, e.g. "Example Co". Default the brand name. */
|
|
33
|
+
owner?: string;
|
|
34
|
+
};
|
|
35
|
+
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
36
|
+
export declare const ARTIFACT_ID: RegExp;
|
|
37
|
+
type Params = {
|
|
38
|
+
params: Promise<{
|
|
39
|
+
id: string;
|
|
40
|
+
}>;
|
|
41
|
+
};
|
|
42
|
+
type PageProps = Params & {
|
|
43
|
+
searchParams?: Promise<{
|
|
44
|
+
key?: string | string[];
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
48
|
+
Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
|
|
49
|
+
generateMetadata: ({ params }: Params) => Promise<Metadata>;
|
|
50
|
+
POST: (request: NextRequest) => Promise<NextResponse<{
|
|
51
|
+
error: string;
|
|
52
|
+
}> | NextResponse<{
|
|
53
|
+
id: string;
|
|
54
|
+
url: string;
|
|
55
|
+
version: number;
|
|
56
|
+
}>>;
|
|
57
|
+
GET: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
|
|
58
|
+
error: string;
|
|
59
|
+
}> | NextResponse<{
|
|
60
|
+
id: string;
|
|
61
|
+
text: string;
|
|
62
|
+
}>>;
|
|
63
|
+
DELETE: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
|
|
64
|
+
error: string;
|
|
65
|
+
}> | NextResponse<{
|
|
66
|
+
deleted: boolean;
|
|
67
|
+
}>>;
|
|
68
|
+
PUT_ASSET: (request: NextRequest, { params }: {
|
|
69
|
+
params: Promise<{
|
|
70
|
+
id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
}>;
|
|
73
|
+
}) => Promise<NextResponse<{
|
|
74
|
+
error: string;
|
|
75
|
+
}> | NextResponse<{
|
|
76
|
+
id: string;
|
|
77
|
+
name: string;
|
|
78
|
+
url: string;
|
|
79
|
+
}>>;
|
|
80
|
+
SHARE_IMAGE: (_request: NextRequest, { params }: Params) => Promise<Response>;
|
|
81
|
+
ENTER: (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
82
|
+
LEAVE: (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
83
|
+
TRACK: (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
84
|
+
ACK: (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
85
|
+
ACCESS: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
|
|
86
|
+
error: string;
|
|
87
|
+
}> | NextResponse<{
|
|
88
|
+
id: string;
|
|
89
|
+
access: Access | null;
|
|
90
|
+
readers: import("../index.js").AllowEntry[];
|
|
91
|
+
}>>;
|
|
92
|
+
READS: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
|
|
93
|
+
error: string;
|
|
94
|
+
}> | NextResponse<{
|
|
95
|
+
acks: {
|
|
96
|
+
email: string;
|
|
97
|
+
name: string | null;
|
|
98
|
+
at: string;
|
|
99
|
+
}[];
|
|
100
|
+
readers: import("../artifacts/readers-store.js").ReaderSummary[];
|
|
101
|
+
refused: {
|
|
102
|
+
email: string;
|
|
103
|
+
name: string | null;
|
|
104
|
+
attempts: number;
|
|
105
|
+
lastAt: string;
|
|
106
|
+
}[];
|
|
107
|
+
id: string;
|
|
108
|
+
title: string;
|
|
109
|
+
access: Access | null;
|
|
110
|
+
views: number;
|
|
111
|
+
}>>;
|
|
112
|
+
dynamic: "force-dynamic";
|
|
113
|
+
maxDuration: number;
|
|
114
|
+
};
|
|
115
|
+
export {};
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { revalidatePath } from 'next/cache';
|
|
3
|
+
import { notFound } from 'next/navigation';
|
|
4
|
+
import { NextResponse } from 'next/server';
|
|
5
|
+
import { isPublishAuthed } from '../artifacts/auth.js';
|
|
6
|
+
import { parseArtifactSource } from '../artifacts/front-matter.js';
|
|
7
|
+
import { narrationText } from '../artifacts/narration.js';
|
|
8
|
+
import { ArtifactMarkdown } from '../artifacts/render.js';
|
|
9
|
+
import { ArtifactDoor } from '../artifacts/door.js';
|
|
10
|
+
import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
|
|
11
|
+
import { ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
|
|
12
|
+
import { BrandGround } from '../brand/wrapper.js';
|
|
13
|
+
import { renderShareCard } from '../brand/share-card.js';
|
|
14
|
+
import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
15
|
+
import { ReaderWatch } from '../reader/reader-watch.js';
|
|
16
|
+
import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
|
|
17
|
+
import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
|
|
18
|
+
import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
|
|
19
|
+
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
20
|
+
export const ARTIFACT_ID = /^[abcdefghjkmnpqrstuvwxyz23456789]{8}$/;
|
|
21
|
+
async function defaultReadCookie(name) {
|
|
22
|
+
const { cookies } = await import('next/headers');
|
|
23
|
+
return (await cookies()).get(name)?.value;
|
|
24
|
+
}
|
|
25
|
+
export function createArtifactRoutes(config) {
|
|
26
|
+
const { store, brand, siteUrl } = config;
|
|
27
|
+
const prefix = config.pagePrefix ?? '/';
|
|
28
|
+
const pageUrl = (id) => `${siteUrl}${prefix}${id}`;
|
|
29
|
+
const pagePath = (id) => `${prefix}${id}`;
|
|
30
|
+
const absolute = (p) => (p ? (/^https?:\/\//.test(p) ? p : `${siteUrl}${p}`) : undefined);
|
|
31
|
+
// The version in the URL is what makes a re-publish show up: every unfurler caches by URL.
|
|
32
|
+
const signInOrigin = config.signInOrigin;
|
|
33
|
+
const owner = config.owner ?? brand.name;
|
|
34
|
+
const readerSecret = () => config.readerSecret?.();
|
|
35
|
+
const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
|
|
36
|
+
const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
|
|
37
|
+
async function generateMetadata({ params }) {
|
|
38
|
+
const { id } = await params;
|
|
39
|
+
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
40
|
+
if (!a)
|
|
41
|
+
return { title: 'Not found', robots: { index: false, follow: false } };
|
|
42
|
+
const url = pageUrl(a.id);
|
|
43
|
+
const image = absolute(a.cover) ?? (brand.share ? shareCardUrl(a.id, a.updatedAt) : absolute(config.defaultShareImage));
|
|
44
|
+
return {
|
|
45
|
+
title: a.title,
|
|
46
|
+
description: a.summary,
|
|
47
|
+
alternates: { canonical: pagePath(a.id) },
|
|
48
|
+
robots: { index: false, follow: false },
|
|
49
|
+
openGraph: {
|
|
50
|
+
title: a.title,
|
|
51
|
+
description: a.summary,
|
|
52
|
+
url,
|
|
53
|
+
siteName: brand.name,
|
|
54
|
+
type: 'article',
|
|
55
|
+
...(image ? { images: [{ url: image, alt: a.cover ? a.title : brand.name }] } : {}),
|
|
56
|
+
},
|
|
57
|
+
twitter: {
|
|
58
|
+
card: image ? 'summary_large_image' : 'summary',
|
|
59
|
+
title: a.title,
|
|
60
|
+
description: a.summary,
|
|
61
|
+
...(image ? { images: [image] } : {}),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function Page({ params, searchParams }) {
|
|
66
|
+
const { id } = await params;
|
|
67
|
+
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
68
|
+
if (!a)
|
|
69
|
+
notFound();
|
|
70
|
+
if (a.access)
|
|
71
|
+
return GatedPage(a, id);
|
|
72
|
+
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
73
|
+
// page they were sent, and the unfurl (generateMetadata) keeps reading as the page.
|
|
74
|
+
const sp = (await searchParams) ?? {};
|
|
75
|
+
const key = Array.isArray(sp.key) ? sp.key[0] : sp.key;
|
|
76
|
+
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
77
|
+
const cookie = a.password ? await readCookie(unlockCookieName(id)) : undefined;
|
|
78
|
+
const open = isUnlocked({ id, password: a.password, key, cookie });
|
|
79
|
+
// Opened by the key in the URL: remember it in a cookie holding the hash, never the
|
|
80
|
+
// password, scoped to this page, so a refresh or a shared device does not ask again.
|
|
81
|
+
const remember = open && a.password && key !== undefined && cookie !== keyHash(id, a.password)
|
|
82
|
+
? `document.cookie=${JSON.stringify(`${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${pagePath(id)}; Max-Age=31536000; SameSite=Lax; Secure`)}`
|
|
83
|
+
: null;
|
|
84
|
+
if (!open) {
|
|
85
|
+
return (_jsxs(BrandGround, { pack: brand, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
|
|
86
|
+
}
|
|
87
|
+
void store.bumpViews(id);
|
|
88
|
+
let words = [];
|
|
89
|
+
if (a.narration && a.timings) {
|
|
90
|
+
try {
|
|
91
|
+
const r = await fetch(a.timings, { next: { revalidate: 3600 } });
|
|
92
|
+
if (r.ok)
|
|
93
|
+
words = (await r.json()).words ?? [];
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
words = [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
100
|
+
return (_jsxs(BrandGround, { pack: brand, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown }) })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null] }));
|
|
101
|
+
}
|
|
102
|
+
/** A page with `access:`. The body is rendered only after the reader is known and allowed;
|
|
103
|
+
* everyone else gets the title, the summary, and a door. */
|
|
104
|
+
async function GatedPage(a, id) {
|
|
105
|
+
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
106
|
+
const reader = verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE));
|
|
107
|
+
const allow = config.readers && reader ? await config.readers.allowList(id) : [];
|
|
108
|
+
const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
|
|
109
|
+
const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }));
|
|
110
|
+
if (!d.open) {
|
|
111
|
+
if (d.why === 'not-allowed' && config.readers)
|
|
112
|
+
void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
|
|
113
|
+
return (_jsxs(BrandGround, { pack: brand, children: [header, d.why === 'not-allowed'
|
|
114
|
+
? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
|
|
115
|
+
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, pageUrl(id)) : undefined })] }));
|
|
116
|
+
}
|
|
117
|
+
const r = reader;
|
|
118
|
+
// The agreement comes before the body, every reader, once per page. No agreement, no body.
|
|
119
|
+
if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
|
|
120
|
+
return (_jsxs(BrandGround, { pack: brand, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: signOutUrl(id) })] }));
|
|
121
|
+
}
|
|
122
|
+
void store.bumpViews(id);
|
|
123
|
+
return (_jsxs(BrandGround, { pack: brand, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), await Body(a, { top: false }), _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
|
|
124
|
+
}
|
|
125
|
+
/** The page itself, shared by open and gated pages. */
|
|
126
|
+
async function Body(a, { top }) {
|
|
127
|
+
let words = [];
|
|
128
|
+
if (a.narration && a.timings) {
|
|
129
|
+
try {
|
|
130
|
+
const r = await fetch(a.timings, { next: { revalidate: 3600 } });
|
|
131
|
+
if (r.ok)
|
|
132
|
+
words = (await r.json()).words ?? [];
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
words = [];
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
139
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown }) })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null] }));
|
|
140
|
+
}
|
|
141
|
+
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
142
|
+
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
|
143
|
+
* reader. Swaps the five-minute pass for this host's grant and lands them on the page, with
|
|
144
|
+
* the pass gone from the address bar. A bad pass lands them on the page's door again. */
|
|
145
|
+
async function ENTER(request) {
|
|
146
|
+
const to = safeReturnPath(request.nextUrl.searchParams.get('to'), prefix);
|
|
147
|
+
if (!to)
|
|
148
|
+
return NextResponse.json({ error: 'to must be a page on this host' }, { status: 400 });
|
|
149
|
+
const secret = readerSecret();
|
|
150
|
+
const reader = verifyPass(secret, request.nextUrl.searchParams.get('pass'));
|
|
151
|
+
const res = NextResponse.redirect(`${siteUrl}${to}`, 303);
|
|
152
|
+
res.headers.set('cache-control', 'no-store');
|
|
153
|
+
if (reader && secret)
|
|
154
|
+
res.headers.append('set-cookie', cookieLine(mintGrant(secret, reader), GRANT_TTL_SECONDS));
|
|
155
|
+
return res;
|
|
156
|
+
}
|
|
157
|
+
/** GET /api/reader/leave?to=/<id>: sign out of this host's gated pages. */
|
|
158
|
+
async function LEAVE(request) {
|
|
159
|
+
const to = safeReturnPath(request.nextUrl.searchParams.get('to'), prefix) ?? '/';
|
|
160
|
+
const res = NextResponse.redirect(`${siteUrl}${to}`, 303);
|
|
161
|
+
res.headers.append('set-cookie', cookieLine('', 0));
|
|
162
|
+
return res;
|
|
163
|
+
}
|
|
164
|
+
/** POST /api/reader/ack: the reader agrees to keep the page confidential. A plain form post
|
|
165
|
+
* from AckDoor; records the exact wording and lands them on the page. Only a reader the page
|
|
166
|
+
* is open to can agree, and only with the box ticked. */
|
|
167
|
+
async function ACK(request) {
|
|
168
|
+
const form = await request.formData().catch(() => null);
|
|
169
|
+
const id = String(form?.get('id') ?? '');
|
|
170
|
+
if (!ARTIFACT_ID.test(id))
|
|
171
|
+
return NextResponse.json({ error: 'no page' }, { status: 400 });
|
|
172
|
+
const back = NextResponse.redirect(`${siteUrl}${pagePath(id)}`, 303);
|
|
173
|
+
if (!config.readers || form?.get('agree') !== 'yes')
|
|
174
|
+
return back;
|
|
175
|
+
const reader = verifyGrant(readerSecret(), request.cookies.get(GRANT_COOKIE)?.value);
|
|
176
|
+
const a = await store.get(id);
|
|
177
|
+
if (!reader || !a?.access)
|
|
178
|
+
return back;
|
|
179
|
+
if (!decide(a.access, reader, await config.readers.allowList(id)).open)
|
|
180
|
+
return back;
|
|
181
|
+
await config.readers.acknowledge({ artifactId: id, reader, text: ackText(owner), country: request.headers.get('x-vercel-ip-country') ?? undefined });
|
|
182
|
+
return back;
|
|
183
|
+
}
|
|
184
|
+
/** POST /api/reader/track: the reading heartbeat and the flags, from ReaderWatch. Recorded
|
|
185
|
+
* only for a reader the page is open to right now, so the record cannot be written into by
|
|
186
|
+
* anyone the page would refuse. */
|
|
187
|
+
async function TRACK(request) {
|
|
188
|
+
if (!config.readers)
|
|
189
|
+
return new NextResponse(null, { status: 204 });
|
|
190
|
+
const reader = verifyGrant(readerSecret(), request.cookies.get(GRANT_COOKIE)?.value);
|
|
191
|
+
if (!reader)
|
|
192
|
+
return new NextResponse(null, { status: 401 });
|
|
193
|
+
const raw = await request.text();
|
|
194
|
+
if (raw.length > 2048)
|
|
195
|
+
return new NextResponse(null, { status: 413 });
|
|
196
|
+
let b;
|
|
197
|
+
try {
|
|
198
|
+
b = JSON.parse(raw);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return new NextResponse(null, { status: 400 });
|
|
202
|
+
}
|
|
203
|
+
const id = String(b.id ?? '');
|
|
204
|
+
const session = String(b.session ?? '');
|
|
205
|
+
if (!ARTIFACT_ID.test(id) || !/^[A-Za-z0-9-]{8,64}$/.test(session))
|
|
206
|
+
return new NextResponse(null, { status: 400 });
|
|
207
|
+
const a = await store.get(id);
|
|
208
|
+
if (!a?.access)
|
|
209
|
+
return new NextResponse(null, { status: 404 });
|
|
210
|
+
if (!decide(a.access, reader, await config.readers.allowList(id)).open)
|
|
211
|
+
return new NextResponse(null, { status: 403 });
|
|
212
|
+
const country = request.headers.get('x-vercel-ip-country') ?? undefined;
|
|
213
|
+
if (b.kind === 'beat') {
|
|
214
|
+
const add = Math.max(0, Math.min(60, Math.round(Number(b.active) || 0)));
|
|
215
|
+
const scroll = Math.max(0, Math.min(100, Math.round(Number(b.scroll) || 0)));
|
|
216
|
+
const device = /Mobi|Android|iPhone|iPad/i.test(request.headers.get('user-agent') ?? '') ? 'mobile' : 'desktop';
|
|
217
|
+
await config.readers.touchSession({ artifactId: id, session, reader, addSeconds: add, scroll, device, country });
|
|
218
|
+
return new NextResponse(null, { status: 204 });
|
|
219
|
+
}
|
|
220
|
+
if (b.kind === 'flag' && FLAG_KINDS.includes(b.flag) && b.flag !== 'refused') {
|
|
221
|
+
const detail = typeof b.detail === 'string' ? b.detail.slice(0, 80) : undefined;
|
|
222
|
+
await config.readers.flag({ artifactId: id, reader, kind: b.flag, detail, country });
|
|
223
|
+
return new NextResponse(null, { status: 204 });
|
|
224
|
+
}
|
|
225
|
+
return new NextResponse(null, { status: 400 });
|
|
226
|
+
}
|
|
227
|
+
/** GET|POST /api/artifacts/<id>/access, publish key: the page's level and its list.
|
|
228
|
+
* POST body: { access?: 'freedom'|'invite'|'public', add?: [{ email, name?, reason? }], remove?: [email] }. */
|
|
229
|
+
async function ACCESS(request, { params }) {
|
|
230
|
+
if (!isPublishAuthed(request, config.publishKey()))
|
|
231
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
232
|
+
if (!config.readers)
|
|
233
|
+
return NextResponse.json({ error: 'this host keeps no reader record' }, { status: 501 });
|
|
234
|
+
const { id } = await params;
|
|
235
|
+
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
236
|
+
if (!a)
|
|
237
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
238
|
+
if (request.method === 'GET')
|
|
239
|
+
return NextResponse.json({ id, access: a.access ?? null, readers: await config.readers.allowList(id) });
|
|
240
|
+
const b = (await request.json().catch(() => null));
|
|
241
|
+
let access = a.access ?? null;
|
|
242
|
+
if (b?.access !== undefined) {
|
|
243
|
+
if (b.access !== 'public' && !ACCESS_LEVELS.includes(b.access))
|
|
244
|
+
return NextResponse.json({ error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` }, { status: 400 });
|
|
245
|
+
if (!store.setAccess)
|
|
246
|
+
return NextResponse.json({ error: 'this store cannot change access' }, { status: 501 });
|
|
247
|
+
await store.setAccess(id, b.access);
|
|
248
|
+
access = b.access === 'public' ? null : b.access;
|
|
249
|
+
}
|
|
250
|
+
const add = Array.isArray(b?.add) ? b.add : [];
|
|
251
|
+
const remove = Array.isArray(b?.remove) ? b.remove : [];
|
|
252
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
253
|
+
for (const e of add) {
|
|
254
|
+
if (!e || typeof e !== 'object' || typeof e.email !== 'string' || !EMAIL.test(e.email.trim()))
|
|
255
|
+
return NextResponse.json({ error: 'each add needs a valid email' }, { status: 400 });
|
|
256
|
+
}
|
|
257
|
+
if (!remove.every((e) => typeof e === 'string'))
|
|
258
|
+
return NextResponse.json({ error: 'remove is a list of emails' }, { status: 400 });
|
|
259
|
+
const readers = await config.readers.allow(id, add, remove);
|
|
260
|
+
return NextResponse.json({ id, access, readers });
|
|
261
|
+
}
|
|
262
|
+
/** GET /api/artifacts/<id>/reads, publish key: who read it, for how long, how far, and every
|
|
263
|
+
* attempt to take it away or to open it without being let in. */
|
|
264
|
+
async function READS(request, { params }) {
|
|
265
|
+
if (!isPublishAuthed(request, config.publishKey()))
|
|
266
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
267
|
+
if (!config.readers)
|
|
268
|
+
return NextResponse.json({ error: 'this host keeps no reader record' }, { status: 501 });
|
|
269
|
+
const { id } = await params;
|
|
270
|
+
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
271
|
+
if (!a)
|
|
272
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
273
|
+
const [sessions, flags, acks] = await Promise.all([config.readers.sessions(id), config.readers.flags(id), config.readers.acks(id)]);
|
|
274
|
+
return NextResponse.json({ id, title: a.title, access: a.access ?? null, views: a.views, ...summarize(sessions, flags), acks: acks.map((k) => ({ email: k.email, name: k.name, at: k.at })) }, { headers: { 'cache-control': 'no-store' } });
|
|
275
|
+
}
|
|
276
|
+
async function POST(request) {
|
|
277
|
+
if (!isPublishAuthed(request, config.publishKey()))
|
|
278
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
279
|
+
const text = await request.text();
|
|
280
|
+
const parsed = parseArtifactSource(text);
|
|
281
|
+
if (!parsed.ok)
|
|
282
|
+
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
|
283
|
+
const id = request.nextUrl.searchParams.get('id') ?? parsed.meta.id ?? undefined;
|
|
284
|
+
const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body });
|
|
285
|
+
if ('notFound' in result)
|
|
286
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
287
|
+
revalidatePath(pagePath(result.id));
|
|
288
|
+
return NextResponse.json({ id: result.id, url: pageUrl(result.id), version: result.version }, { status: result.created ? 201 : 200 });
|
|
289
|
+
}
|
|
290
|
+
async function GET(request, { params }) {
|
|
291
|
+
if (!isPublishAuthed(request, config.publishKey()))
|
|
292
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
293
|
+
const { id } = await params;
|
|
294
|
+
const a = await store.get(id);
|
|
295
|
+
if (!a)
|
|
296
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
297
|
+
return NextResponse.json({ id, text: narrationText({ title: a.title, summary: a.summary, markdown: a.markdown }) });
|
|
298
|
+
}
|
|
299
|
+
/** PUT /api/artifacts/<id>/assets/<name>: raw bytes in, public URL out. 16 MiB cap. */
|
|
300
|
+
async function PUT_ASSET(request, { params }) {
|
|
301
|
+
if (!isPublishAuthed(request, config.publishKey()))
|
|
302
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
303
|
+
if (!config.assets)
|
|
304
|
+
return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
|
|
305
|
+
const { id, name } = await params;
|
|
306
|
+
if (!ARTIFACT_ID.test(id))
|
|
307
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
308
|
+
if (!(await store.get(id)))
|
|
309
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
310
|
+
if (!ASSET_NAME.test(name))
|
|
311
|
+
return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
|
|
312
|
+
const type = contentTypeFor(name);
|
|
313
|
+
if (!type)
|
|
314
|
+
return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3 or json' }, { status: 415 });
|
|
315
|
+
const bytes = Buffer.from(await request.arrayBuffer());
|
|
316
|
+
if (bytes.length === 0)
|
|
317
|
+
return NextResponse.json({ error: 'empty body' }, { status: 400 });
|
|
318
|
+
if (bytes.length > 16 * 1024 * 1024)
|
|
319
|
+
return NextResponse.json({ error: 'asset over 16 MiB' }, { status: 413 });
|
|
320
|
+
const url = await config.assets.put(id, name, bytes, type);
|
|
321
|
+
return NextResponse.json({ id, name, url }, { status: 201 });
|
|
322
|
+
}
|
|
323
|
+
/** GET /<id>/share.png: the page's title card. 404 when the pack draws none, so a
|
|
324
|
+
* tenant on the static default never serves a half-styled card. */
|
|
325
|
+
async function SHARE_IMAGE(_request, { params }) {
|
|
326
|
+
const { id } = await params;
|
|
327
|
+
if (!brand.share)
|
|
328
|
+
return new NextResponse('no share card for this host', { status: 404 });
|
|
329
|
+
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
330
|
+
if (!a)
|
|
331
|
+
return new NextResponse(`no artifact with id ${id}`, { status: 404 });
|
|
332
|
+
return renderShareCard(brand, a.title);
|
|
333
|
+
}
|
|
334
|
+
async function DELETE(request, { params }) {
|
|
335
|
+
if (!isPublishAuthed(request, config.publishKey()))
|
|
336
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
337
|
+
const { id } = await params;
|
|
338
|
+
const ok = await store.delete(id);
|
|
339
|
+
if (!ok)
|
|
340
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
341
|
+
revalidatePath(pagePath(id));
|
|
342
|
+
return NextResponse.json({ deleted: true });
|
|
343
|
+
}
|
|
344
|
+
return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
345
|
+
}
|