@supersuit/artifacts 0.35.0 → 0.36.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 CHANGED
@@ -3,6 +3,36 @@
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.36.0
7
+
8
+ **Private media moves to its own private bucket, and a host without one refuses it.** 0.35.0 kept
9
+ `media: private` files under `<prefix>/<id>/private/` in the same bucket as public assets and
10
+ never called `makePublic` on them. That is no protection on a bucket that carries a bucket-wide
11
+ public read grant: every "private" object was readable by anyone holding its URL.
12
+
13
+ - `createArtifactAssets(bucket, prefix, { privateBucket })`. With `privateBucket`, `putPrivate`,
14
+ `signPrivateUpload`, `finishPrivateUpload` and `privateUrl` use it (same `<prefix>/<id>/private/`
15
+ keys); `put`, `signUpload`, `finishUpload`, covers and narration stay on `bucket`. `deletePage`
16
+ clears the page from both. Passing the public bucket again as the private one throws.
17
+ - Without `privateBucket` the four private methods are not defined, so the host fails closed:
18
+ `POST /api/artifacts` refuses a page with `media: private` (or a republish of a page that already
19
+ keeps private media) with 501 and saves nothing, and `?private=1` uploads answer 501, all with
20
+ the same words naming `privateBucket`.
21
+ - `PRIVATE_READ_TTL_MS` (exported, `60 * 60 * 1000`): the life of a signed V4 read URL. One is
22
+ minted per private file on every render that passed the per-reader gate, and none for a revoked
23
+ or expired link.
24
+ - DETECTOR: your host passes `media: private` pages and calls `createArtifactAssets(bucket, prefix)`
25
+ with two arguments; or `gcloud storage buckets get-iam-policy` on your bucket lists `allUsers`
26
+ and you have objects under `*/private/`.
27
+ - REMEDY: create a separate bucket (uniform access, public access prevention enforced, object
28
+ access for the host's service account only), pass it as `privateBucket`, give it CORS for `PUT`
29
+ from your origin if browsers upload to it directly, and republish any page whose private media
30
+ sits in the public bucket (then delete those old objects). A host that publishes no private
31
+ media needs nothing; until it adds a private bucket, `media: private` is refused.
32
+ - TESTS: `src/artifacts/assets.test.ts` (private media), `src/routes/private-media.test.tsx`
33
+ (publishing media: private), `src/routes/per-reader.test.tsx` (mints a fresh signed URL on every
34
+ passing render), `src/artifacts/index.test.ts`.
35
+
6
36
  ## 0.35.0
7
37
 
8
38
  **Per-reader links, private media, video with sound, quiet views, and expiry.** A page can now go
package/README.md CHANGED
@@ -22,7 +22,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
22
22
  | `createArtifactRoutes(config)` | Every route handler: the page, its metadata, the publish API, the share card, the reader sign-in exchange, access control |
23
23
  | `createArtifactStore(db, path)` | Firestore store for pages and their version history, under any collection path |
24
24
  | `createReadersStore(db, path)` | The record for confidential pages: allowlist, agreements, reading sessions, flags |
25
- | `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
25
+ | `createArtifactAssets(bucket, prefix, { privateBucket? })` | Uploaded images for a page, in a Cloud Storage bucket; private media in a separate private bucket |
26
26
  | `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
27
27
  | `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
28
28
  | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
@@ -849,6 +849,14 @@ media: private
849
849
 
850
850
  `links: per-reader` shuts the page to everyone who does not arrive through a valid, unrevoked, unexpired link. It cannot be combined with `password`, `access` or `comments_visible: readers` (readers never see each other's comments). `media: private` keeps body media under the page's private prefix; `.mp4` and `.m4a` (and `.mp3`) are supported, and `sound:` at the start of a video's alt makes it a clip with controls and sound. Both lines are sticky: republishing a file that leaves them out changes nothing.
851
851
 
852
+ ### Private media lives in its own bucket
853
+
854
+ `media: private` needs a private store: `createArtifactAssets(bucket, prefix, { privateBucket })`. Private files go to `privateBucket` under `<prefix>/<id>/private/`; covers, images and narration stay on the public `bucket`. Without `privateBucket` the private methods are not defined, and the host refuses private media with 501 at every door: publishing a page with `media: private` (or republishing a page that already keeps it), and `?private=1` uploads. Nothing is saved. This is on purpose: a public bucket often carries a bucket-wide public read grant, so an object written there is readable by anyone who has its URL, whatever the object's own settings say.
855
+
856
+ The private bucket must be a different bucket from the public one (passing the same bucket twice throws), with uniform bucket-level access, public access prevention enforced, and object access granted only to the host's own service account. The page signs a V4 read URL per private file on every render that passed the per-reader gate, valid `PRIVATE_READ_TTL_MS` (one hour); a revoked or expired link renders nothing and mints nothing. A reader revoked after a render can keep loading a URL already handed to them for at most that hour. Expiry's `deletePage` clears the page from both buckets.
857
+
858
+ If the publisher uploads straight to the private bucket from a browser (`?private=1` on `UPLOAD`), give that bucket a CORS rule allowing `PUT` from your origin with the `content-type` and `cache-control` headers; a reader's browser playing a signed URL needs `GET` (and `HEAD`) from your origin only if your player fetches it with CORS.
859
+
852
860
  Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `VIEWS`, `SEEN`, and `sweepExpired`.
853
861
 
854
862
  | Route | Who | What |
@@ -868,7 +876,7 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
868
876
 
869
877
  ### Expiry
870
878
 
871
- `expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
879
+ `expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. With a private bucket configured it deletes from both buckets. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
872
880
 
873
881
  ## Brand packs
874
882
 
@@ -35,15 +35,27 @@ export interface ArtifactAssets {
35
35
  }>;
36
36
  /** After a signed private upload: `private:<stored name>`, or null if nothing arrived. */
37
37
  finishPrivateUpload?(id: string, name: string, digest: string): Promise<string | null>;
38
- /** A signed read URL for a stored private name, valid one hour. */
38
+ /** A V4 signed read URL for a stored private name, valid PRIVATE_READ_TTL_MS (one hour). */
39
39
  privateUrl?(id: string, stored: string): Promise<string>;
40
- /** Delete every object under `<prefix>/<id>/`; returns how many. */
40
+ /** Delete every object under `<prefix>/<id>/`, in the public bucket and the private one; returns how many. */
41
41
  deletePage?(id: string): Promise<number>;
42
42
  }
43
43
  /** A reference to a private file as it appears in a page: `private:<stored name>`. */
44
44
  export declare const PRIVATE_REF: RegExp;
45
45
  /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
46
46
  export declare const ASSET_DIGEST: RegExp;
47
+ /** How long a signed read URL for private media stays valid: one hour. A reader revoked after a
48
+ * render can keep loading a URL already handed to them for at most this long. */
49
+ export declare const PRIVATE_READ_TTL_MS: number;
50
+ export interface ArtifactAssetsOptions {
51
+ /**
52
+ * The bucket private media lives in. It must be a different bucket from the public one, with
53
+ * uniform access, public access prevention enforced, and object access granted only to the
54
+ * host's own service account. Without it the private methods are not defined at all, so a
55
+ * `media: private` page is refused instead of being stored where anyone could read it by URL.
56
+ */
57
+ privateBucket?: Bucket;
58
+ }
47
59
  /** Names are one path segment: letters, digits, dot, dash, underscore. */
48
60
  export declare const ASSET_NAME: RegExp;
49
61
  export declare const ASSET_TYPES: Record<string, string>;
@@ -67,4 +79,4 @@ export declare function contentTypeFor(name: string): string | undefined;
67
79
  export declare function hashedName(name: string, bytes: Buffer): string;
68
80
  /** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
69
81
  export declare function storedName(name: string, digest: string): string;
70
- export declare function createArtifactAssets(bucket: Bucket, prefix: string): ArtifactAssets;
82
+ export declare function createArtifactAssets(bucket: Bucket, prefix: string, options?: ArtifactAssetsOptions): ArtifactAssets;
@@ -10,6 +10,9 @@ export const PRIVATE_REF = /^private:([A-Za-z0-9][A-Za-z0-9._-]{0,140})$/;
10
10
  /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
11
11
  export const ASSET_DIGEST = /^[0-9a-f]{8}$/;
12
12
  const CACHE_CONTROL = 'public, max-age=31536000, immutable';
13
+ /** How long a signed read URL for private media stays valid: one hour. A reader revoked after a
14
+ * render can keep loading a URL already handed to them for at most this long. */
15
+ export const PRIVATE_READ_TTL_MS = 60 * 60 * 1000;
13
16
  /** Names are one path segment: letters, digits, dot, dash, underscore. */
14
17
  export const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
15
18
  export const ASSET_TYPES = {
@@ -52,9 +55,13 @@ export function storedName(name, digest) {
52
55
  // A name with no extension is legal here, so append rather than assume there is a dot.
53
56
  return dot <= 0 ? `${name}.${digest}` : `${name.slice(0, dot)}.${digest}${name.slice(dot)}`;
54
57
  }
55
- export function createArtifactAssets(bucket, prefix) {
58
+ export function createArtifactAssets(bucket, prefix, options = {}) {
56
59
  const clean = prefix.replace(/^\/+|\/+$/g, '');
57
- return {
60
+ const priv = options.privateBucket;
61
+ // The same bucket twice would put "private" media back where a public read grant reaches it.
62
+ if (priv && priv.name === bucket.name)
63
+ throw new Error('the private bucket must be a different bucket from the public one');
64
+ const assets = {
58
65
  async put(id, name, bytes, contentType) {
59
66
  const key = `${clean}/${id}/${hashedName(name, bytes)}`;
60
67
  const file = bucket.file(key);
@@ -135,36 +142,46 @@ export function createArtifactAssets(bucket, prefix) {
135
142
  const [url] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
136
143
  return url;
137
144
  },
145
+ async deletePage(id) {
146
+ // An empty or path-shaped id would make the prefix wider than one page.
147
+ if (!ARTIFACT_ID_RE.test(id))
148
+ throw new Error('not an artifact id');
149
+ let n = 0;
150
+ for (const b of priv ? [bucket, priv] : [bucket]) {
151
+ const [files] = await b.getFiles({ prefix: `${clean}/${id}/` });
152
+ for (const f of files)
153
+ await f.delete();
154
+ n += files.length;
155
+ }
156
+ return n;
157
+ },
158
+ };
159
+ if (!priv)
160
+ return assets;
161
+ const privateFile = (id, stored) => priv.file(`${clean}/${id}/private/${stored}`);
162
+ return {
163
+ ...assets,
138
164
  async putPrivate(id, name, bytes, contentType) {
139
165
  const stored = hashedName(name, bytes);
140
- await bucket.file(`${clean}/${id}/private/${stored}`).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
166
+ await privateFile(id, stored).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
141
167
  return `private:${stored}`;
142
168
  },
143
169
  async signPrivateUpload(id, name, digest, contentType) {
144
170
  const stored = storedName(name, digest);
145
171
  const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600' };
146
- const [uploadUrl] = await bucket.file(`${clean}/${id}/private/${stored}`).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType, extensionHeaders: { 'cache-control': headers['cache-control'] } });
172
+ const [uploadUrl] = await privateFile(id, stored).getSignedUrl({ version: 'v4', action: 'write', expires: Date.now() + 15 * 60 * 1000, contentType, extensionHeaders: { 'cache-control': headers['cache-control'] } });
147
173
  return { uploadUrl, headers, url: `private:${stored}` };
148
174
  },
149
175
  async finishPrivateUpload(id, name, digest) {
150
176
  const stored = storedName(name, digest);
151
- const [there] = await bucket.file(`${clean}/${id}/private/${stored}`).exists();
177
+ const [there] = await privateFile(id, stored).exists();
152
178
  return there ? `private:${stored}` : null;
153
179
  },
154
180
  async privateUrl(id, stored) {
155
181
  if (!PRIVATE_REF.test(`private:${stored}`))
156
182
  throw new Error('not a private file name');
157
- const [url] = await bucket.file(`${clean}/${id}/private/${stored}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
183
+ const [url] = await privateFile(id, stored).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + PRIVATE_READ_TTL_MS });
158
184
  return url;
159
185
  },
160
- async deletePage(id) {
161
- // An empty or path-shaped id would make the prefix wider than one page.
162
- if (!ARTIFACT_ID_RE.test(id))
163
- throw new Error('not an artifact id');
164
- const [files] = await bucket.getFiles({ prefix: `${clean}/${id}/` });
165
- for (const f of files)
166
- await f.delete();
167
- return files.length;
168
- },
169
186
  };
170
187
  }
@@ -4,7 +4,7 @@ export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narra
4
4
  export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
5
5
  export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
6
6
  export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS, type Publisher } from './publisher.js';
7
- export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
7
+ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS, type ArtifactAssets, type ArtifactAssetsOptions } from './assets.js';
8
8
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
9
9
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
10
10
  export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc, type ReaderSummary } from './readers-store.js';
@@ -4,7 +4,7 @@ export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narra
4
4
  export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
5
5
  export { isPublishAuthed, isPublisherAuthed } from './auth.js';
6
6
  export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS } from './publisher.js';
7
- export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
7
+ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, PRIVATE_READ_TTL_MS } from './assets.js';
8
8
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
9
9
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
10
10
  export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
@@ -41,6 +41,8 @@ import { createVoiceRoutes } from './voice-routes.js';
41
41
  export const ARTIFACT_ID = ARTIFACT_ID_RE;
42
42
  /** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
43
43
  const VERSION_N = /^[1-9]\d{0,5}$/;
44
+ /** What a host with no private store answers to private media, in words that say what to set up. */
45
+ const NO_PRIVATE_STORE = 'this host keeps no private store, so media: private is refused and nothing was published. Give createArtifactAssets a privateBucket (its own bucket, never the public one) to keep private media';
44
46
  async function defaultReadCookie(name) {
45
47
  const { cookies } = await import('next/headers');
46
48
  return (await cookies()).get(name)?.value;
@@ -697,6 +699,8 @@ export function createArtifactRoutes(config) {
697
699
  const [sessions, flags, acks] = await Promise.all([config.readers.sessions(id), config.readers.flags(id), config.readers.acks(id)]);
698
700
  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' } });
699
701
  }
702
+ /** Whether this host can store and serve private media: both halves come from a private bucket. */
703
+ const keepsPrivateMedia = () => Boolean(config.assets?.putPrivate && config.assets.privateUrl);
700
704
  async function POST(request) {
701
705
  if (!authed(request))
702
706
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -705,6 +709,11 @@ export function createArtifactRoutes(config) {
705
709
  if (!parsed.ok)
706
710
  return NextResponse.json({ error: parsed.error }, { status: 400 });
707
711
  const id = request.nextUrl.searchParams.get('id') ?? parsed.meta.id ?? undefined;
712
+ // Fail closed: private media needs a private store. Without one the media would have to sit in
713
+ // the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
714
+ // `media:` is sticky, so a stored page that already keeps private media counts too.
715
+ if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await store.get(id))?.privateMedia)))
716
+ return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
708
717
  if (id && parsed.meta.state) {
709
718
  const existing = await store.get(id);
710
719
  const changed = shapeChanges(existing?.state, parsed.meta.state);
@@ -759,7 +768,7 @@ export function createArtifactRoutes(config) {
759
768
  return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
760
769
  const priv = request.nextUrl.searchParams.get('private') === '1';
761
770
  if (priv && !config.assets.putPrivate)
762
- return NextResponse.json({ error: 'this host keeps no private media' }, { status: 501 });
771
+ return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
763
772
  const { id, name } = await params;
764
773
  if (!ARTIFACT_ID.test(id))
765
774
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
@@ -806,7 +815,7 @@ export function createArtifactRoutes(config) {
806
815
  const priv = request.nextUrl.searchParams.get('private') === '1';
807
816
  if (priv) {
808
817
  if (!assets?.signPrivateUpload || !assets.finishPrivateUpload)
809
- return NextResponse.json({ error: 'this host keeps no private media' }, { status: 501 });
818
+ return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
810
819
  }
811
820
  else if (!assets?.signUpload || !assets.finishUpload)
812
821
  return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
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",