@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 +23 -0
- package/lib/artifacts/assets.d.ts +16 -0
- package/lib/artifacts/assets.js +38 -2
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/index.js +1 -1
- package/lib/routes/artifacts.d.ts +12 -0
- package/lib/routes/artifacts.js +36 -2
- package/package.json +1 -1
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;
|
package/lib/artifacts/assets.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -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>>;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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.
|
|
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",
|