@supersuit/artifacts 0.37.0 → 0.38.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,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.38.0
7
+
8
+ **Readers' voice memos live in the private bucket.** A memo left on a page (`comments/<memo>`, or a
9
+ reader's own `personal/<dir>/<memo>`) was written to the PUBLIC bucket. Every URL the package handed
10
+ out was signed, but that bucket carries a bucket-wide public read grant, so the memo itself answered
11
+ an unsigned GET to anyone holding its path. Memos are now written to and read from the private
12
+ bucket (`privateBucket`), under the same key. A host with no private bucket defines no memo method,
13
+ so memos stay on the reader's device instead of going anywhere public.
14
+
15
+ DETECTOR: an unsigned `curl -o /dev/null -w '%{http_code}'` of
16
+ `https://storage.googleapis.com/<public bucket>/<prefix>/<id>/comments/<memo>.webm` answers 200.
17
+
18
+ REMEDY: take 0.38.0, then move what is already there:
19
+ `node scripts/migrate-memos-private.mjs --public <bucket> --private <bucket> --prefix tenants/<t>/artifacts`
20
+ (copies each memo to the same key in the private bucket, confirms it arrived, then deletes the
21
+ public one; safe to re-run; `--dry-run` lists). Until a memo is moved the package reads it from the
22
+ public bucket, so nothing stops playing during the move. The same unsigned GET then answers 403.
23
+
24
+ Tests: `assets.test.ts` (memos sign, size, read and play in the private bucket and never touch the
25
+ public one; an unmoved memo is still read; no private bucket, no memo methods),
26
+ `migrate-memos.test.ts` (moves only memos, never deletes a public memo whose copy did not arrive,
27
+ dry run, same bucket refused).
28
+
6
29
  ## 0.37.0
7
30
 
8
31
  **A broadcast is its own page kind.** `template: broadcast` in front matter makes a page one
package/README.md CHANGED
@@ -673,12 +673,13 @@ recognition, the words appear in the box as the reader speaks. A memo stops at t
673
673
 
674
674
  Recordings go up through the same `uploads/<name>` route the publisher uses, to
675
675
  `<page>/comments/<memo>.<ext>` or, for a signed-in reader's personal note,
676
- `<page>/personal/<dir>/<memo>.<ext>`. They are never made public: playback is a signed URL
677
- issued beside the comment or note it belongs to. That needs the four audio methods
678
- `createArtifactAssets` provides (`signAudioUpload`, `audioSize`, `readAudio`, `audioUrl`); a host
679
- with its own `ArtifactAssets` and none of them keeps memos on the reader's device. On a bucket
680
- with bucket-wide public read, a recording is readable by anyone holding its path (the paths are
681
- unguessable); keep the bucket per-object public to have the signed-URL guarantee.
676
+ `<page>/personal/<dir>/<memo>.<ext>`, in the PRIVATE bucket (`privateBucket`, the same one
677
+ `media: private` uses). They are never made public: playback is a signed URL, valid about an hour,
678
+ issued beside the comment or note it belongs to, and an unsigned GET is refused (403). That needs the
679
+ four audio methods `createArtifactAssets` provides (`signAudioUpload`, `audioSize`, `readAudio`,
680
+ `audioUrl`), which it defines only when it is given a private bucket; a host without one, or with
681
+ its own `ArtifactAssets` and none of them, keeps memos on the reader's device. Memos recorded before
682
+ 0.38.0 sat in the public bucket: `scripts/migrate-memos-private.mjs` moves them (see CHANGELOG).
682
683
 
683
684
  To transcribe on the server, pass `transcribe` and add the route. The key stays on the server;
684
685
  without `transcribe` the route answers 404 and readers keep the browser's live transcript (or an
@@ -108,12 +108,44 @@ export function createArtifactAssets(bucket, prefix, options = {}) {
108
108
  }
109
109
  return `https://storage.googleapis.com/${bucket.name}/${key}`;
110
110
  },
111
+ async deletePage(id) {
112
+ // An empty or path-shaped id would make the prefix wider than one page.
113
+ if (!ARTIFACT_ID_RE.test(id))
114
+ throw new Error('not an artifact id');
115
+ let n = 0;
116
+ for (const b of priv ? [bucket, priv] : [bucket]) {
117
+ const [files] = await b.getFiles({ prefix: `${clean}/${id}/` });
118
+ for (const f of files)
119
+ await f.delete();
120
+ n += files.length;
121
+ }
122
+ return n;
123
+ },
124
+ };
125
+ if (!priv)
126
+ return assets;
127
+ const privateFile = (id, stored) => priv.file(`${clean}/${id}/private/${stored}`);
128
+ // READERS' VOICE MEMOS LIVE IN THE PRIVATE BUCKET (0.38.0). Until then they went to the public
129
+ // bucket, unlisted and read through signed URLs, but a bucket-wide public read grant served any
130
+ // memo to anyone holding its path, unsigned. A memo recorded before 0.38.0 is still read from the
131
+ // public bucket until scripts/migrate-memos-private.mjs moves it; nothing new is written there.
132
+ const memo = (id, path) => priv.file(`${clean}/${id}/${path}`);
133
+ const legacyMemo = (id, path) => bucket.file(`${clean}/${id}/${path}`);
134
+ const memoFile = async (id, path) => {
135
+ const f = memo(id, path);
136
+ if ((await f.exists())[0])
137
+ return f;
138
+ const old = legacyMemo(id, path);
139
+ return (await old.exists())[0] ? old : null;
140
+ };
141
+ return {
142
+ ...assets,
111
143
  // The size cap rides in the signature (`x-goog-content-length-range`), so the bucket itself
112
144
  // refuses an oversize body; the browser must send the returned headers verbatim.
113
145
  async signAudioUpload(id, path, contentType) {
114
146
  const range = `0,${MAX_AUDIO_BYTES}`;
115
147
  const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600', 'x-goog-content-length-range': range };
116
- const [uploadUrl] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({
148
+ const [uploadUrl] = await memo(id, path).getSignedUrl({
117
149
  version: 'v4',
118
150
  action: 'write',
119
151
  expires: Date.now() + 15 * 60 * 1000,
@@ -123,44 +155,24 @@ export function createArtifactAssets(bucket, prefix, options = {}) {
123
155
  return { uploadUrl, headers };
124
156
  },
125
157
  async audioSize(id, path) {
126
- const file = bucket.file(`${clean}/${id}/${path}`);
127
- const [there] = await file.exists();
128
- if (!there)
158
+ const file = await memoFile(id, path);
159
+ if (!file)
129
160
  return null;
130
161
  const [meta] = await file.getMetadata();
131
162
  return Number(meta.size ?? 0);
132
163
  },
133
164
  async readAudio(id, path) {
134
- const file = bucket.file(`${clean}/${id}/${path}`);
135
- const [there] = await file.exists();
136
- if (!there)
165
+ const file = await memoFile(id, path);
166
+ if (!file)
137
167
  return null;
138
168
  const [bytes] = await file.download();
139
169
  return bytes;
140
170
  },
141
171
  async audioUrl(id, path) {
142
- const [url] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
172
+ const file = (await memoFile(id, path)) ?? memo(id, path);
173
+ const [url] = await file.getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
143
174
  return url;
144
175
  },
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,
164
176
  async putPrivate(id, name, bytes, contentType) {
165
177
  const stored = hashedName(name, bytes);
166
178
  await privateFile(id, stored).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
@@ -26,3 +26,4 @@ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, password
26
26
  export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
27
27
  export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
28
28
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
29
+ export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
@@ -26,3 +26,4 @@ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, password
26
26
  // Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
27
27
  export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
28
28
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
29
+ export { migrateMemosToPrivate, MEMO_KEY } from './migrate-memos.js';
@@ -0,0 +1,14 @@
1
+ import type { Bucket } from '@google-cloud/storage';
2
+ /** A reader's memo, by key under the prefix: `<id>/comments/<memo>.<ext>` or `<id>/personal/<dir>/<memo>.<ext>`. */
3
+ export declare const MEMO_KEY: RegExp;
4
+ export interface MemoMigration {
5
+ moved: string[];
6
+ alreadyPrivate: string[];
7
+ failed: {
8
+ key: string;
9
+ error: string;
10
+ }[];
11
+ }
12
+ export declare function migrateMemosToPrivate(bucket: Bucket, privateBucket: Bucket, prefix: string, { dryRun }?: {
13
+ dryRun?: boolean | undefined;
14
+ }): Promise<MemoMigration>;
@@ -0,0 +1,36 @@
1
+ /** A reader's memo, by key under the prefix: `<id>/comments/<memo>.<ext>` or `<id>/personal/<dir>/<memo>.<ext>`. */
2
+ export const MEMO_KEY = /^[^/]+\/(?:comments\/[^/]+|personal\/[^/]+\/[^/]+)\.(?:webm|m4a|mp4|ogg|wav)$/;
3
+ export async function migrateMemosToPrivate(bucket, privateBucket, prefix, { dryRun = false } = {}) {
4
+ if (privateBucket.name === bucket.name)
5
+ throw new Error('the private bucket must be a different bucket from the public one');
6
+ const clean = prefix.replace(/^\/+|\/+$/g, '');
7
+ const [files] = await bucket.getFiles({ prefix: `${clean}/` });
8
+ const out = { moved: [], alreadyPrivate: [], failed: [] };
9
+ for (const f of files) {
10
+ const rel = f.name.slice(clean.length + 1);
11
+ if (!MEMO_KEY.test(rel))
12
+ continue;
13
+ try {
14
+ const dest = privateBucket.file(f.name);
15
+ if ((await dest.exists())[0]) {
16
+ if (!dryRun)
17
+ await f.delete();
18
+ out.alreadyPrivate.push(f.name);
19
+ continue;
20
+ }
21
+ if (dryRun) {
22
+ out.moved.push(f.name);
23
+ continue;
24
+ }
25
+ await f.copy(dest);
26
+ if (!(await dest.exists())[0])
27
+ throw new Error('the copy did not arrive in the private bucket');
28
+ await f.delete();
29
+ out.moved.push(f.name);
30
+ }
31
+ catch (e) {
32
+ out.failed.push({ key: f.name, error: e.message });
33
+ }
34
+ }
35
+ return out;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.37.0",
3
+ "version": "0.38.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",