@supersuit/artifacts 0.4.0 → 0.4.1

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';
@@ -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.1",
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",