@supersuit/artifacts 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
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.4.1 (2026-09-26)
7
+
8
+ **Big files upload straight to the bucket.**
9
+
10
+ - A route handler on Vercel refuses a body over 4.5 MB (`FUNCTION_PAYLOAD_TOO_LARGE`), about five
11
+ minutes of narration, so a long page's audio could never publish; `PUT_ASSET`'s own 16 MiB cap
12
+ was never reached. New route `UPLOAD` (`POST /api/artifacts/<id>/uploads/<name>`, publish key,
13
+ JSON `{ digest }`) returns a v4 signed URL and the headers to send; the publisher PUTs the bytes
14
+ there, then calls again with `{ digest, done: true }`, which makes the object readable and
15
+ returns its URL (409 if nothing arrived). The key is the same hashed name `put()` writes, with
16
+ the same `immutable` cache header. `ArtifactAssets` gains optional `signUpload` and
17
+ `finishUpload`; `createArtifactAssets` implements both. `storedName`, `hashedName` and
18
+ `ASSET_DIGEST` are exported.
19
+ - **DETECTOR:** publishing a page whose narration is longer than about five minutes fails with
20
+ `narration upload failed 413: ... FUNCTION_PAYLOAD_TOO_LARGE`.
21
+ - **REMEDY:** add `app/api/artifacts/[id]/uploads/[name]/route.ts` exporting `POST` that calls
22
+ the routes' `UPLOAD`, the same shape as the assets route. The bucket's service account needs no
23
+ new permission when the host signs with a service-account key. Until the route exists the
24
+ publisher gets a 404 and falls back to `PUT_ASSET`.
25
+ - **Tests:** signing targets put()'s key with its cache header; finishing refuses a missing
26
+ object; the route's refusals match `PUT_ASSET`'s, a bad digest is 400, and a host whose assets
27
+ cannot sign answers 501.
28
+
6
29
  ## 0.4.0 (2026-09-26)
7
30
 
8
31
  **Light and dark modes, a table of contents, and an animated ground.**
@@ -2,7 +2,21 @@ import type { Bucket } from '@google-cloud/storage';
2
2
  export interface ArtifactAssets {
3
3
  /** Store `bytes` for artifact `id` under `name`; return the public URL. */
4
4
  put(id: string, name: string, bytes: Buffer, contentType: string): Promise<string>;
5
+ /**
6
+ * A signed URL the publisher PUTs a big file to directly, skipping the route handler.
7
+ * `digest` is the first 8 hex of the bytes' sha256, so the key is the one put() would use.
8
+ * Optional: a host without it answers 501 and the publisher falls back to put().
9
+ */
10
+ signUpload?(id: string, name: string, digest: string, contentType: string): Promise<{
11
+ uploadUrl: string;
12
+ headers: Record<string, string>;
13
+ url: string;
14
+ }>;
15
+ /** After a signed upload: make it readable and return its URL, or null if nothing arrived. */
16
+ finishUpload?(id: string, name: string, digest: string): Promise<string | null>;
5
17
  }
18
+ /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
19
+ export declare const ASSET_DIGEST: RegExp;
6
20
  /** Names are one path segment: letters, digits, dot, dash, underscore. */
7
21
  export declare const ASSET_NAME: RegExp;
8
22
  export declare const ASSET_TYPES: Record<string, string>;
@@ -24,4 +38,6 @@ export declare function contentTypeFor(name: string): string | undefined;
24
38
  * URL and stay cached, which is the whole benefit.
25
39
  */
26
40
  export declare function hashedName(name: string, bytes: Buffer): string;
41
+ /** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
42
+ export declare function storedName(name: string, digest: string): string;
27
43
  export declare function createArtifactAssets(bucket: Bucket, prefix: string): ArtifactAssets;
@@ -3,6 +3,9 @@
3
3
  // publisher shelled out to gsutil against the maintainer's bucket, which no other operator
4
4
  // could do, so every operator gets the same door: PUT bytes with their key, get a URL back.
5
5
  import { createHash } from 'node:crypto';
6
+ /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
7
+ export const ASSET_DIGEST = /^[0-9a-f]{8}$/;
8
+ const CACHE_CONTROL = 'public, max-age=31536000, immutable';
6
9
  /** Names are one path segment: letters, digits, dot, dash, underscore. */
7
10
  export const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
8
11
  export const ASSET_TYPES = {
@@ -35,7 +38,10 @@ export function contentTypeFor(name) {
35
38
  * URL and stay cached, which is the whole benefit.
36
39
  */
37
40
  export function hashedName(name, bytes) {
38
- const digest = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
41
+ return storedName(name, createHash('sha256').update(bytes).digest('hex').slice(0, 8));
42
+ }
43
+ /** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
44
+ export function storedName(name, digest) {
39
45
  const dot = name.lastIndexOf('.');
40
46
  // A name with no extension is legal here, so append rather than assume there is a dot.
41
47
  return dot <= 0 ? `${name}.${digest}` : `${name.slice(0, dot)}.${digest}${name.slice(dot)}`;
@@ -49,7 +55,7 @@ export function createArtifactAssets(bucket, prefix) {
49
55
  await file.save(bytes, {
50
56
  contentType,
51
57
  resumable: false,
52
- metadata: { cacheControl: 'public, max-age=31536000, immutable' },
58
+ metadata: { cacheControl: CACHE_CONTROL },
53
59
  });
54
60
  try {
55
61
  await file.makePublic();
@@ -59,5 +65,35 @@ export function createArtifactAssets(bucket, prefix) {
59
65
  }
60
66
  return `https://storage.googleapis.com/${bucket.name}/${key}`;
61
67
  },
68
+ // A route handler on Vercel refuses a body over 4.5 MB (FUNCTION_PAYLOAD_TOO_LARGE), which is
69
+ // about five minutes of narration. An hour-long paper's audio (~55 MB) could never publish.
70
+ // So a big file goes to the bucket directly, on a URL signed with the host's own service
71
+ // account, to the same hashed key and with the same cache header put() writes.
72
+ async signUpload(id, name, digest, contentType) {
73
+ const key = `${clean}/${id}/${storedName(name, digest)}`;
74
+ const headers = { 'content-type': contentType, 'cache-control': CACHE_CONTROL };
75
+ const [uploadUrl] = await bucket.file(key).getSignedUrl({
76
+ version: 'v4',
77
+ action: 'write',
78
+ expires: Date.now() + 15 * 60 * 1000,
79
+ contentType,
80
+ extensionHeaders: { 'cache-control': CACHE_CONTROL },
81
+ });
82
+ return { uploadUrl, headers, url: `https://storage.googleapis.com/${bucket.name}/${key}` };
83
+ },
84
+ async finishUpload(id, name, digest) {
85
+ const key = `${clean}/${id}/${storedName(name, digest)}`;
86
+ const file = bucket.file(key);
87
+ const [there] = await file.exists();
88
+ if (!there)
89
+ return null;
90
+ try {
91
+ await file.makePublic();
92
+ }
93
+ catch {
94
+ // uniform bucket access: already public
95
+ }
96
+ return `https://storage.googleapis.com/${bucket.name}/${key}`;
97
+ },
62
98
  };
63
99
  }
@@ -3,7 +3,7 @@ export { ArtifactMarkdown } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId, type ArtifactStore, type ArtifactRecord, type SaveResult } from './store.js';
5
5
  export { isPublishAuthed } from './auth.js';
6
- export { createArtifactAssets, contentTypeFor, ASSET_NAME, type ArtifactAssets } from './assets.js';
6
+ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
7
7
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
8
8
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
9
9
  export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc } from './readers-store.js';
@@ -3,7 +3,7 @@ export { ArtifactMarkdown } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId } from './store.js';
5
5
  export { isPublishAuthed } from './auth.js';
6
- export { createArtifactAssets, contentTypeFor, ASSET_NAME } from './assets.js';
6
+ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
7
7
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
8
8
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
9
9
  export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
@@ -3,6 +3,9 @@ export type WordTiming = {
3
3
  s: number;
4
4
  e: number;
5
5
  };
6
+ /** The word to light at `now`, or -1. Nothing lights until narration has started: the first
7
+ * word begins at 0, so an untouched page would otherwise sit with its first word highlighted. */
8
+ export declare function litWordIndex(words: WordTiming[], now: number, started: boolean): number;
6
9
  export declare function ArtifactReader({ src, words, rootId, label, accent, ground, }: {
7
10
  src: string;
8
11
  words: WordTiming[];
@@ -70,6 +70,25 @@ function align(spans, words) {
70
70
  }
71
71
  return out;
72
72
  }
73
+ /** The word to light at `now`, or -1. Nothing lights until narration has started: the first
74
+ * word begins at 0, so an untouched page would otherwise sit with its first word highlighted. */
75
+ export function litWordIndex(words, now, started) {
76
+ if (!started)
77
+ return -1;
78
+ let lo = 0;
79
+ let hi = words.length - 1;
80
+ let idx = -1;
81
+ while (lo <= hi) {
82
+ const mid = (lo + hi) >> 1;
83
+ if (words[mid].s <= now) {
84
+ idx = mid;
85
+ lo = mid + 1;
86
+ }
87
+ else
88
+ hi = mid - 1;
89
+ }
90
+ return idx >= 0 && now <= words[idx].e + 0.25 ? idx : -1;
91
+ }
73
92
  function fmt(t) {
74
93
  const m = Math.floor(t / 60);
75
94
  const s = Math.floor(t % 60);
@@ -80,6 +99,8 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
80
99
  const audioRef = useRef(null);
81
100
  const mapRef = useRef([]);
82
101
  const litRef = useRef(null);
102
+ // Set on the first play or seek; the highlight stays off until then.
103
+ const startedRef = useRef(false);
83
104
  const [playing, setPlaying] = useState(false);
84
105
  const [t, setT] = useState(0);
85
106
  const [dur, setDur] = useState(0);
@@ -111,20 +132,8 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
111
132
  const tick = () => {
112
133
  const now = a.currentTime;
113
134
  setT(now);
114
- // Binary search the word at `now`.
115
- let lo = 0;
116
- let hi = words.length - 1;
117
- let idx = -1;
118
- while (lo <= hi) {
119
- const mid = (lo + hi) >> 1;
120
- if (words[mid].s <= now) {
121
- idx = mid;
122
- lo = mid + 1;
123
- }
124
- else
125
- hi = mid - 1;
126
- }
127
- const el = idx >= 0 && now <= words[idx].e + 0.25 ? mapRef.current[idx] : null;
135
+ const idx = litWordIndex(words, now, startedRef.current);
136
+ const el = idx >= 0 ? mapRef.current[idx] : null;
128
137
  if (el !== litRef.current) {
129
138
  litRef.current?.classList.remove('artifact-word-lit');
130
139
  el?.classList.add('artifact-word-lit');
@@ -154,7 +163,12 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
154
163
  .artifact-word { border-radius: 3px; transition: background-color 120ms; }
155
164
  .artifact-word:hover { background: var(--a-surface-strong, rgba(255,255,255,0.08)); cursor: pointer; }
156
165
  .artifact-word-lit { background: color-mix(in srgb, ${accent} 35%, transparent); color: var(--a-strong, #fff); }
157
- ` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => setPlaying(true), onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { className: "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--a-line)] backdrop-blur", style: { background: `color-mix(in srgb, ${ground} 90%, transparent)` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: toggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: narrator }), _jsx("input", { type: "range", min: 0, max: dur || 0, step: 0.1, value: Math.min(t, dur || 0), onChange: (e) => {
166
+ ` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
167
+ startedRef.current = true;
168
+ setPlaying(true);
169
+ }, onSeeking: () => {
170
+ startedRef.current = true;
171
+ }, onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { className: "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--a-line)] backdrop-blur", style: { background: `color-mix(in srgb, ${ground} 90%, transparent)` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: toggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: narrator }), _jsx("input", { type: "range", min: 0, max: dur || 0, step: 0.1, value: Math.min(t, dur || 0), onChange: (e) => {
158
172
  if (audioRef.current)
159
173
  audioRef.current.currentTime = Number(e.target.value);
160
174
  }, "aria-label": "Seek", className: "mt-1 w-full", style: { accentColor: accent } })] }), _jsxs("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-[color:var(--a-muted)]", children: [fmt(t), " / ", fmt(dur)] }), _jsxs("button", { type: "button", onClick: () => {
@@ -102,6 +102,18 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
102
102
  name: string;
103
103
  url: string;
104
104
  }>>;
105
+ UPLOAD: (request: NextRequest, { params }: {
106
+ params: Promise<{
107
+ id: string;
108
+ name: string;
109
+ }>;
110
+ }) => Promise<NextResponse<{
111
+ error: string;
112
+ }> | NextResponse<{
113
+ id: string;
114
+ name: string;
115
+ url: string;
116
+ }>>;
105
117
  SHARE_IMAGE: (_request: NextRequest, { params }: Params) => Promise<Response>;
106
118
  ENTER: (request: NextRequest) => Promise<NextResponse<unknown>>;
107
119
  LEAVE: (request: NextRequest) => Promise<NextResponse<unknown>>;
@@ -9,7 +9,7 @@ import { ArtifactMarkdown } from '../artifacts/render.js';
9
9
  import { ArtifactDoor } from '../artifacts/door.js';
10
10
  import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
11
11
  import { shapeChanges } from '../artifacts/state.js';
12
- import { ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
12
+ import { ASSET_DIGEST, ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
13
13
  import { BrandGround } from '../brand/wrapper.js';
14
14
  import { themedPack } from '../brand/theme.js';
15
15
  import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
@@ -358,6 +358,40 @@ export function createArtifactRoutes(config) {
358
358
  const url = await config.assets.put(id, name, bytes, type);
359
359
  return NextResponse.json({ id, name, url }, { status: 201 });
360
360
  }
361
+ /**
362
+ * POST /api/artifacts/<id>/uploads/<name> with `{ digest }`: a signed URL to PUT a big file to
363
+ * directly, because a route handler cannot take one (Vercel caps a body at 4.5 MB). Then the
364
+ * same call with `{ digest, done: true }` makes it readable and returns its URL; 409 when the
365
+ * bytes never arrived. 501 on a host whose assets cannot sign, so the publisher falls back.
366
+ */
367
+ async function UPLOAD(request, { params }) {
368
+ if (!isPublishAuthed(request, config.publishKey()))
369
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
370
+ const assets = config.assets;
371
+ if (!assets?.signUpload || !assets.finishUpload)
372
+ return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
373
+ const { id, name } = await params;
374
+ if (!ARTIFACT_ID.test(id))
375
+ return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
376
+ if (!(await store.get(id)))
377
+ return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
378
+ if (!ASSET_NAME.test(name))
379
+ return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
380
+ const type = contentTypeFor(name);
381
+ if (!type)
382
+ return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3 or json' }, { status: 415 });
383
+ const body = (await request.json().catch(() => ({})));
384
+ const digest = typeof body.digest === 'string' ? body.digest : '';
385
+ if (!ASSET_DIGEST.test(digest))
386
+ return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
387
+ if (body.done === true) {
388
+ const url = await assets.finishUpload(id, name, digest);
389
+ if (!url)
390
+ return NextResponse.json({ error: 'nothing was uploaded to that url' }, { status: 409 });
391
+ return NextResponse.json({ id, name, url }, { status: 201 });
392
+ }
393
+ return NextResponse.json({ id, name, ...(await assets.signUpload(id, name, digest, type)) }, { status: 201 });
394
+ }
361
395
  /** GET /<id>/share.png: the page's title card. 404 when the pack draws none, so a
362
396
  * tenant on the static default never serves a half-styled card. */
363
397
  async function SHARE_IMAGE(_request, { params }) {
@@ -379,5 +413,5 @@ export function createArtifactRoutes(config) {
379
413
  revalidatePath(pagePath(id));
380
414
  return NextResponse.json({ deleted: true });
381
415
  }
382
- return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
416
+ return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
383
417
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",