@yojahny/wp-design-library 0.5.1 → 0.6.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/bin/library.mjs +1 -0
- package/entries/ada/entry.md +24 -6
- package/entries/air/entry.md +39 -10
- package/entries/ais-community-dark-depth/entry.md +44 -19
- package/entries/apple-home/entry.md +43 -10
- package/entries/apple-macbook-neo/entry.md +39 -10
- package/entries/apple-watch-ultra-4/entry.md +36 -11
- package/entries/bpco/entry.md +15 -7
- package/entries/column/entry.md +22 -9
- package/entries/cthdrl/entry.md +41 -19
- package/entries/dala-craftedbygc/entry.md +41 -16
- package/entries/dock/entry.md +36 -10
- package/entries/dub/entry.md +35 -10
- package/entries/duolingo/entry.md +42 -10
- package/entries/elevenlabs/entry.md +42 -10
- package/entries/flying-papers/entry.md +32 -10
- package/entries/gsap/entry.md +40 -10
- package/entries/letters/entry.md +40 -10
- package/entries/micro/entry.md +34 -10
- package/entries/monopo-saigon/entry.md +41 -12
- package/entries/notion/entry.md +42 -10
- package/entries/origin-financial/entry.md +42 -9
- package/entries/oryzo-ai/entry.md +46 -11
- package/entries/silencio/entry.md +6 -2
- package/entries/steep/entry.md +38 -12
- package/entries/stripe/entry.md +34 -10
- package/entries/superpower/entry.md +43 -9
- package/entries/superr/entry.md +22 -6
- package/entries/svz-weare/entry.md +15 -5
- package/entries/thoughtlab/entry.md +42 -10
- package/entries/ventriloc/entry.md +22 -6
- package/entries/vivid-and-co/entry.md +30 -5
- package/package.json +4 -1
- package/src/artifacts.mjs +31 -0
- package/src/cli/add.mjs +55 -24
- package/src/cli/index.mjs +1 -1
- package/src/cli/migrate-artifacts.mjs +92 -0
- package/src/cli/motion.mjs +19 -39
- package/src/cli/refresh.mjs +40 -10
- package/src/cli/save.mjs +1 -1
- package/src/cli/serve.mjs +27 -7
- package/src/cli/ui.mjs +1 -1
- package/src/entry.mjs +15 -11
- package/src/index/build.mjs +13 -3
- package/src/ingest/agreement.mjs +14 -1
- package/src/ingest/attach.mjs +8 -15
- package/src/ingest/save.mjs +2 -2
- package/src/mcp/tools.mjs +29 -17
- package/src/r2.mjs +95 -0
- package/src/ui/build.mjs +30 -12
- package/src/ui/templates/index.html +2 -0
- package/entries/ada/strip.webp +0 -0
- package/entries/air/captures/2/measure.json +0 -325
- package/entries/air/captures/2/strip.webp +0 -0
- package/entries/air/strip.webp +0 -0
- package/entries/ais-community-dark-depth/strip.webp +0 -0
- package/entries/apple-home/captures/2/measure.json +0 -91
- package/entries/apple-home/captures/2/strip.webp +0 -0
- package/entries/apple-home/strip.webp +0 -0
- package/entries/apple-macbook-neo/captures/2/measure.json +0 -570
- package/entries/apple-macbook-neo/captures/2/strip.webp +0 -0
- package/entries/apple-macbook-neo/strip.webp +0 -0
- package/entries/apple-watch-ultra-4/measure.json +0 -481
- package/entries/apple-watch-ultra-4/strip.webp +0 -0
- package/entries/bpco/captures/2/strip.webp +0 -0
- package/entries/bpco/captures/3/strip.webp +0 -0
- package/entries/bpco/strip.webp +0 -0
- package/entries/column/captures/2/measure.json +0 -996
- package/entries/column/captures/2/strip.webp +0 -0
- package/entries/column/strip.webp +0 -0
- package/entries/cthdrl/captures/2/measure.json +0 -251
- package/entries/cthdrl/captures/2/strip.webp +0 -0
- package/entries/cthdrl/strip.webp +0 -0
- package/entries/dala-craftedbygc/strip.webp +0 -0
- package/entries/dock/captures/2/measure.json +0 -206
- package/entries/dock/captures/2/strip.webp +0 -0
- package/entries/dock/strip.webp +0 -0
- package/entries/dub/captures/2/measure.json +0 -37
- package/entries/dub/captures/2/strip.webp +0 -0
- package/entries/dub/strip.webp +0 -0
- package/entries/duolingo/captures/2/measure.json +0 -138
- package/entries/duolingo/captures/2/strip.webp +0 -0
- package/entries/duolingo/strip.webp +0 -0
- package/entries/elevenlabs/captures/2/measure.json +0 -160
- package/entries/elevenlabs/captures/2/strip.webp +0 -0
- package/entries/elevenlabs/strip.webp +0 -0
- package/entries/flying-papers/captures/2/measure.json +0 -291
- package/entries/flying-papers/captures/2/strip.webp +0 -0
- package/entries/flying-papers/strip.webp +0 -0
- package/entries/gsap/captures/2/measure.json +0 -120
- package/entries/gsap/captures/2/strip.webp +0 -0
- package/entries/gsap/strip.webp +0 -0
- package/entries/letters/captures/2/measure.json +0 -20
- package/entries/letters/captures/2/strip.webp +0 -0
- package/entries/letters/strip.webp +0 -0
- package/entries/micro/captures/2/measure.json +0 -852
- package/entries/micro/captures/2/strip.webp +0 -0
- package/entries/micro/strip.webp +0 -0
- package/entries/monopo-saigon/captures/2/measure.json +0 -145
- package/entries/monopo-saigon/captures/2/strip.webp +0 -0
- package/entries/monopo-saigon/strip.webp +0 -0
- package/entries/notion/captures/2/measure.json +0 -338
- package/entries/notion/captures/2/strip.webp +0 -0
- package/entries/notion/strip.webp +0 -0
- package/entries/origin-financial/captures/2/measure.json +0 -169
- package/entries/origin-financial/captures/2/strip.webp +0 -0
- package/entries/origin-financial/strip.webp +0 -0
- package/entries/oryzo-ai/captures/2/measure.json +0 -39
- package/entries/oryzo-ai/captures/2/strip.webp +0 -0
- package/entries/oryzo-ai/strip.webp +0 -0
- package/entries/silencio/captures/2/strip.webp +0 -0
- package/entries/silencio/strip.webp +0 -0
- package/entries/steep/captures/2/measure.json +0 -20
- package/entries/steep/captures/2/strip.webp +0 -0
- package/entries/steep/strip.webp +0 -0
- package/entries/stripe/captures/2/measure.json +0 -892
- package/entries/stripe/captures/2/strip.webp +0 -0
- package/entries/stripe/strip.webp +0 -0
- package/entries/superpower/captures/2/measure.json +0 -179
- package/entries/superpower/captures/2/strip.webp +0 -0
- package/entries/superpower/strip.webp +0 -0
- package/entries/superr/strip.webp +0 -0
- package/entries/svz-weare/captures/2/measure.json +0 -129
- package/entries/svz-weare/captures/2/strip.webp +0 -0
- package/entries/svz-weare/strip.webp +0 -0
- package/entries/thoughtlab/captures/2/measure.json +0 -93
- package/entries/thoughtlab/captures/2/strip.webp +0 -0
- package/entries/thoughtlab/strip.webp +0 -0
- package/entries/ventriloc/strip.webp +0 -0
- package/entries/vivid-and-co/strip.webp +0 -0
package/src/cli/motion.mjs
CHANGED
|
@@ -8,7 +8,8 @@ import yaml from 'js-yaml';
|
|
|
8
8
|
import sharp from 'sharp';
|
|
9
9
|
import { paths } from '../paths.mjs';
|
|
10
10
|
import { parseEntry } from '../entry.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { localMotionPath, validateMotion } from '../motion.mjs';
|
|
12
|
+
import { objectUrl, r2Config, s3Upload } from '../r2.mjs';
|
|
12
13
|
|
|
13
14
|
const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
14
15
|
function probe(file) {
|
|
@@ -90,19 +91,6 @@ export async function prepareMotion({ p = paths(), slug, input, id, title, notes
|
|
|
90
91
|
} finally { fs.rmSync(tmp, { recursive: true, force: true }); }
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
export function r2Config(env = process.env) {
|
|
94
|
-
const required = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET', 'R2_PUBLIC_BASE_URL'];
|
|
95
|
-
const missing = required.filter((k) => !env[k]);
|
|
96
|
-
if (missing.length) throw new Error(`configure ${missing.join(', ')} locally before publishing`);
|
|
97
|
-
if (!/^[a-f0-9]{32}$/.test(env.R2_ACCOUNT_ID)) throw new Error('invalid R2_ACCOUNT_ID');
|
|
98
|
-
if (!isHttpsUrl(env.R2_PUBLIC_BASE_URL)) throw new Error('R2_PUBLIC_BASE_URL must be HTTPS without credentials');
|
|
99
|
-
const base = new URL(env.R2_PUBLIC_BASE_URL);
|
|
100
|
-
if (base.search || base.hash) throw new Error('R2_PUBLIC_BASE_URL must not contain a query or fragment');
|
|
101
|
-
if (base.hostname.endsWith('.r2.dev')) throw new Error('use a production custom domain for R2_PUBLIC_BASE_URL');
|
|
102
|
-
return { account: env.R2_ACCOUNT_ID, bucket: env.R2_BUCKET, base: base.href.replace(/\/$/, ''),
|
|
103
|
-
accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
94
|
export async function publishMotion({ p = paths(), slug, id, config = r2Config(), upload, fetcher = fetch }) {
|
|
107
95
|
const entry = entryFor(p, slug);
|
|
108
96
|
const errors = validateMotion(entry);
|
|
@@ -115,33 +103,25 @@ export async function publishMotion({ p = paths(), slug, id, config = r2Config()
|
|
|
115
103
|
const body = fs.readFileSync(file);
|
|
116
104
|
if (body.length !== clip.bytes || createHash('sha256').update(body).digest('hex') !== clip.sha256)
|
|
117
105
|
throw new Error(`local video checksum mismatch: ${clip.id}`);
|
|
118
|
-
return { clip, body, url:
|
|
106
|
+
return { clip, body, url: objectUrl(config, clip.key) };
|
|
119
107
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
CacheControl: 'public, max-age=31536000, immutable', Metadata: { sha256: clip.sha256 } });
|
|
131
|
-
const response = await fetcher(url, { headers: { Range: 'bytes=0-31' }, signal: AbortSignal.timeout(20000) });
|
|
132
|
-
const type = response.headers.get('content-type')?.split(';')[0];
|
|
133
|
-
const range = response.headers.get('content-range');
|
|
134
|
-
if (response.status !== 206 || type !== 'video/mp4' || range !== `bytes 0-31/${clip.bytes}`) {
|
|
135
|
-
await response.body?.cancel();
|
|
136
|
-
throw new Error(`uploaded ${clip.id}, but public playback verification failed (HTTP ${response.status}); entry URL was not changed`);
|
|
137
|
-
}
|
|
138
|
-
const bytes = Buffer.from(await response.arrayBuffer());
|
|
139
|
-
if (!bytes.equals(body.subarray(0, 32))) throw new Error(`public video bytes differ for ${clip.id}`);
|
|
108
|
+
if (!upload) upload = await s3Upload(config);
|
|
109
|
+
for (const { clip, body, url } of work) {
|
|
110
|
+
await upload({ Bucket: config.bucket, Key: clip.key, Body: body, ContentType: 'video/mp4',
|
|
111
|
+
CacheControl: 'public, max-age=31536000, immutable', Metadata: { sha256: clip.sha256 } });
|
|
112
|
+
const response = await fetcher(url, { headers: { Range: 'bytes=0-31' }, signal: AbortSignal.timeout(20000) });
|
|
113
|
+
const type = response.headers.get('content-type')?.split(';')[0];
|
|
114
|
+
const range = response.headers.get('content-range');
|
|
115
|
+
if (response.status !== 206 || type !== 'video/mp4' || range !== `bytes 0-31/${clip.bytes}`) {
|
|
116
|
+
await response.body?.cancel();
|
|
117
|
+
throw new Error(`uploaded ${clip.id}, but public playback verification failed (HTTP ${response.status}); entry URL was not changed`);
|
|
140
118
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
119
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
120
|
+
if (!bytes.equals(body.subarray(0, 32))) throw new Error(`public video bytes differ for ${clip.id}`);
|
|
121
|
+
}
|
|
122
|
+
for (const { clip, url } of work) clip.url = url;
|
|
123
|
+
writeEntry(entry);
|
|
124
|
+
return { slug, published: work.map(({ clip, url }) => ({ id: clip.id, url })) };
|
|
145
125
|
}
|
|
146
126
|
|
|
147
127
|
export async function run(args = []) {
|
package/src/cli/refresh.mjs
CHANGED
|
@@ -8,13 +8,18 @@ import { loadVocab } from '../vocab.mjs';
|
|
|
8
8
|
import { buildIndex } from '../index/build.mjs';
|
|
9
9
|
import { measure } from '../ingest/measure.mjs';
|
|
10
10
|
import { refuseUnsafeUrl } from '../ingest/url-guard.mjs';
|
|
11
|
+
import { artifactKey, putAndVerify, r2Config, s3Upload } from '../r2.mjs';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
13
|
+
const REFRESHED = ['measure.json', 'screen-1440.png', 'screen-390.png'];
|
|
14
|
+
const TYPES = { '.png': 'image/png', '.json': 'application/json' };
|
|
15
|
+
|
|
16
|
+
// Re-measures an entry that was drafted from a live URL: re-uploads measure.json and
|
|
17
|
+
// the two screenshots, rewrites their artifact records, bumps captured.at, and reindexes.
|
|
18
|
+
// Every judgement field (roles, feel, palette, type, motion, body…) is untouched — only
|
|
19
|
+
// the measurement and captured.at change. `transport` is undefined for the CLI, 'http'
|
|
20
|
+
// for the hosted MCP server; the SSRF guard only consults DNS in the latter case.
|
|
21
|
+
// `measurer` exists so this path can be driven without a browser; nothing but a test passes it.
|
|
22
|
+
export async function refreshEntry({ slug, transport, config, upload, fetcher = fetch, measurer = measure }) {
|
|
18
23
|
const p = paths();
|
|
19
24
|
const dir = path.join(p.entries, slug);
|
|
20
25
|
let e;
|
|
@@ -29,13 +34,17 @@ export async function refreshEntry({ slug, transport }) {
|
|
|
29
34
|
// so it would attribute a fresh measurement to a capture that never made it.
|
|
30
35
|
if ((e.fm.captured?.captures?.length ?? 0) > 1)
|
|
31
36
|
return { refused: `${slug}: refresh cannot update a multi-capture entry; it does not know which capture's slot it would write` };
|
|
37
|
+
// The records are what a reader resolves, so a refresh with nowhere to write them would leave
|
|
38
|
+
// the entry naming the OLD objects while the new bytes sat unreferenced.
|
|
39
|
+
const capture = e.fm.captured?.captures?.[0];
|
|
40
|
+
if (!capture) return { refused: `${slug}: refresh needs a recorded capture; run the artifacts migration first` };
|
|
32
41
|
const refusal = await refuseUnsafeUrl(url, { transport });
|
|
33
42
|
if (refusal) return { refused: refusal };
|
|
34
43
|
|
|
35
44
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'wpdl-refresh-'));
|
|
36
45
|
let result;
|
|
37
46
|
try {
|
|
38
|
-
result = await
|
|
47
|
+
result = await measurer(url, scratch, {});
|
|
39
48
|
} catch (err) {
|
|
40
49
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
41
50
|
return { refused: `measure failed: ${err.message}` };
|
|
@@ -44,16 +53,37 @@ export async function refreshEntry({ slug, transport }) {
|
|
|
44
53
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
45
54
|
return { refused: result.error };
|
|
46
55
|
}
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
// Upload and re-record before anything is written into the entry. The bytes live in R2 now:
|
|
57
|
+
// rewriting the three files locally and leaving the records alone left the entry describing the
|
|
58
|
+
// OLD objects — right key, stale {bytes, sha256} — which every reader verifies the fetch against.
|
|
59
|
+
// Same key, new digest: the key is stable across re-captures by design.
|
|
60
|
+
let uploaded;
|
|
61
|
+
try {
|
|
62
|
+
config ??= r2Config();
|
|
63
|
+
if (!upload) upload = await s3Upload(config);
|
|
64
|
+
uploaded = [];
|
|
65
|
+
for (const f of REFRESHED) {
|
|
66
|
+
uploaded.push(await putAndVerify({ config, key: artifactKey(slug, 1, f), body: fs.readFileSync(path.join(scratch, f)),
|
|
67
|
+
contentType: TYPES[path.extname(f)], upload, fetcher }));
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
71
|
+
// The loop has no rollback, so an earlier object may already be live in R2 while entry.md
|
|
72
|
+
// still records the old digest — a cold reader then reports "expected N bytes, got M". Nothing
|
|
73
|
+
// LOCAL changed and a successful re-run converges the two, so say that rather than "nothing was
|
|
74
|
+
// changed", which sends the operator looking for corruption instead of re-running.
|
|
75
|
+
return { refused: `${slug}: upload failed: ${err.message}; nothing local was changed, re-run to converge R2` };
|
|
49
76
|
}
|
|
77
|
+
for (const f of REFRESHED) fs.copyFileSync(path.join(scratch, f), path.join(dir, f));
|
|
50
78
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
51
79
|
|
|
80
|
+
const keys = new Set(uploaded.map((a) => a.key));
|
|
81
|
+
capture.artifacts = [...(capture.artifacts ?? []).filter((a) => !keys.has(a?.key)), ...uploaded];
|
|
52
82
|
e.fm.captured.at = new Date().toISOString().slice(0, 10);
|
|
53
83
|
const body = e.body ? `\n${e.body}\n` : '';
|
|
54
84
|
fs.writeFileSync(path.join(dir, 'entry.md'), `---\n${yaml.dump(e.fm, { lineWidth: 120 })}---\n${body}`);
|
|
55
85
|
|
|
56
|
-
await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models });
|
|
86
|
+
await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models, p });
|
|
57
87
|
return { ok: true, slug, captured: e.fm.captured };
|
|
58
88
|
}
|
|
59
89
|
|
package/src/cli/save.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { saveEntry } from '../ingest/save.mjs';
|
|
|
5
5
|
|
|
6
6
|
export async function run(args) {
|
|
7
7
|
const p = paths();
|
|
8
|
-
const r = await saveEntry({ dir: path.join(p.entries, args[0]), vocab: loadVocab(p.vocab), indexPath: p.index, entriesDir: p.entries, models: p.models });
|
|
8
|
+
const r = await saveEntry({ dir: path.join(p.entries, args[0]), vocab: loadVocab(p.vocab), indexPath: p.index, entriesDir: p.entries, models: p.models, p });
|
|
9
9
|
if (!r.ok) {
|
|
10
10
|
for (const m of r.errors) process.stderr.write(`${r.slug}: ${m}\n`);
|
|
11
11
|
process.stdout.write(JSON.stringify(r) + '\n');
|
package/src/cli/serve.mjs
CHANGED
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
async function syncSeed(p) {
|
|
2
2
|
const fs = await import('node:fs');
|
|
3
|
-
// Every start: copy seed entries the volume does not have yet
|
|
4
|
-
//
|
|
3
|
+
// Every start: copy seed entries the volume does not have yet, and heal an entry the
|
|
4
|
+
// volume already has by copying in only the files it is missing (a new required
|
|
5
|
+
// artifact, or one renamed since the volume was last seeded — e.g. strip.png ->
|
|
6
|
+
// strip.webp in 0.5.1). Never overwrite a file the volume already has, and never
|
|
7
|
+
// delete one the seed no longer ships: the volume may hold hosted ingests and operator
|
|
8
|
+
// edits the image knows nothing about, and a superseded file (the old strip.png) is
|
|
9
|
+
// inert dead weight, not a landmine — deleting it on every operator's behalf is a
|
|
10
|
+
// destructive guess this server is not positioned to make.
|
|
5
11
|
if (p.seed !== p.entries && fs.existsSync(p.seed)) {
|
|
6
12
|
const path = await import('node:path');
|
|
7
13
|
fs.mkdirSync(p.entries, { recursive: true });
|
|
8
14
|
let copied = 0;
|
|
15
|
+
let healed = 0;
|
|
9
16
|
for (const d of fs.readdirSync(p.seed, { withFileTypes: true })) {
|
|
10
17
|
if (!d.isDirectory() || !fs.existsSync(path.join(p.seed, d.name, 'entry.md'))) continue;
|
|
18
|
+
const src = path.join(p.seed, d.name);
|
|
11
19
|
const dest = path.join(p.entries, d.name);
|
|
12
|
-
if (fs.existsSync(dest))
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
if (!fs.existsSync(dest)) {
|
|
21
|
+
fs.cpSync(src, dest, { recursive: true });
|
|
22
|
+
copied++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
fs.cpSync(src, dest, {
|
|
26
|
+
recursive: true,
|
|
27
|
+
filter: (s, dst) => {
|
|
28
|
+
if (fs.statSync(s).isDirectory()) return true;
|
|
29
|
+
if (fs.existsSync(dst)) return false;
|
|
30
|
+
healed++;
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
});
|
|
15
34
|
}
|
|
16
35
|
process.stderr.write(`seed: copied ${copied} new entries into ${p.entries}\n`);
|
|
36
|
+
process.stderr.write(`seed: healed ${healed} missing files into existing entries\n`);
|
|
17
37
|
}
|
|
18
38
|
}
|
|
19
39
|
|
|
20
40
|
async function buildTheIndex(p) {
|
|
21
41
|
const { loadVocab } = await import('../vocab.mjs');
|
|
22
42
|
const { buildIndex } = await import('../index/build.mjs');
|
|
23
|
-
const r = await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models });
|
|
43
|
+
const r = await buildIndex({ entriesDir: p.entries, indexPath: p.index, vocab: loadVocab(p.vocab), models: p.models, p });
|
|
24
44
|
process.stderr.write(`index: ${r.entries} entries, refused ${r.refused.length}\n`);
|
|
25
45
|
for (const s of r.skipped) process.stderr.write(`embeddings: skipped (${s.reason})\n`);
|
|
26
46
|
}
|
|
@@ -48,7 +68,7 @@ export async function run(args) {
|
|
|
48
68
|
await buildTheIndex(p);
|
|
49
69
|
try {
|
|
50
70
|
const { buildUi } = await import('../ui/build.mjs');
|
|
51
|
-
await buildUi({ indexPath: p.index, entriesDir: p.entries, outDir: path.join(p.data, 'ui') });
|
|
71
|
+
await buildUi({ indexPath: p.index, entriesDir: p.entries, outDir: path.join(p.data, 'ui'), p });
|
|
52
72
|
} catch (err) {
|
|
53
73
|
// the gallery is a convenience for a human; the MCP API must not depend on it
|
|
54
74
|
process.stderr.write(`ui: build failed, serving without it: ${err.stack || err}\n`);
|
package/src/cli/ui.mjs
CHANGED
|
@@ -8,7 +8,7 @@ export async function run(args = []) {
|
|
|
8
8
|
const p = paths();
|
|
9
9
|
const outDir = path.join(p.data, 'ui');
|
|
10
10
|
const previewMediaDir = args.includes('--local-media') ? path.join(p.data, '.library', 'media') : undefined;
|
|
11
|
-
const r = await buildUi({ indexPath: p.index, entriesDir: p.entries, outDir, previewMediaDir });
|
|
11
|
+
const r = await buildUi({ indexPath: p.index, entriesDir: p.entries, outDir, previewMediaDir, p });
|
|
12
12
|
process.stdout.write(`ui: ${r.entries} entries, ${r.drafts} drafts, built into ${outDir}\n`);
|
|
13
13
|
if (args.includes('--serve')) {
|
|
14
14
|
const port = Number(opt(args, '--port') ?? 4180);
|
package/src/entry.mjs
CHANGED
|
@@ -2,12 +2,13 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
import { validateMotion } from './motion.mjs';
|
|
5
|
+
import { isArtifactKey } from './r2.mjs';
|
|
5
6
|
import { resolveTerm } from './vocab.mjs';
|
|
6
7
|
|
|
7
8
|
export const REQUIRED_BLANKS = ['roles', 'feel', 'motion.devices', 'motion.notes', 'type.display', 'type.body', 'body',
|
|
8
9
|
'palette.canvas', 'palette.ink', 'palette.accent'];
|
|
9
10
|
const REQUIRED = ['slug', 'title', 'tier', 'source.kind', 'source.license', 'captured.method', 'captured.at', 'captured.tool',
|
|
10
|
-
...REQUIRED_BLANKS];
|
|
11
|
+
'captured.captures', ...REQUIRED_BLANKS];
|
|
11
12
|
|
|
12
13
|
export function parseEntry(dir) {
|
|
13
14
|
const file = path.join(dir, 'entry.md');
|
|
@@ -58,18 +59,21 @@ export function validateEntry(entry, vocab) {
|
|
|
58
59
|
errors.push(`licence gate: tier ported is not allowed with source.kind ${fm.source.kind}`);
|
|
59
60
|
if (blank(fm.ported_from)) errors.push('ported_from: required for tier ported');
|
|
60
61
|
}
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// reported ok by `check` and then serve a path to a file that is not there.
|
|
64
|
-
// The same resolution get_entry uses, basename equality and the conventional fallback
|
|
65
|
-
// included — validating anything else would pass a path the server never serves, or refuse
|
|
66
|
-
// one it does.
|
|
67
|
-
const strips = new Set(['strip.webp']);
|
|
62
|
+
// An artifact is a record, not a file: {key, bytes, sha256}. The bytes live in R2, so validity is
|
|
63
|
+
// a question about what the entry claims, and the claim must be strong enough to detect a swap.
|
|
68
64
|
(fm.captured?.captures ?? []).forEach((c, i) => {
|
|
69
|
-
const
|
|
70
|
-
|
|
65
|
+
const list = c.artifacts ?? [];
|
|
66
|
+
if (!list.length) errors.push(`capture ${i + 1}: no artifacts recorded`);
|
|
67
|
+
for (const a of list) {
|
|
68
|
+
if (!a || typeof a !== 'object') { errors.push(`capture ${i + 1}: an artifact is not a record`); continue; }
|
|
69
|
+
// isArtifactKey, not startsWith('entries/'): the shared shape cachePath resolves against, so
|
|
70
|
+
// a key cannot be valid and indexed while being permanently unresolvable.
|
|
71
|
+
if (!isArtifactKey(a.key)) errors.push(`capture ${i + 1}: artifact key is missing or not an entries/ key`);
|
|
72
|
+
if (!Number.isInteger(a.bytes) || a.bytes <= 0) errors.push(`${a.key ?? 'artifact'}: bytes must be a positive integer`);
|
|
73
|
+
if (typeof a.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(a.sha256)) errors.push(`${a.key ?? 'artifact'}: sha256 must be 64 hex characters`);
|
|
74
|
+
}
|
|
75
|
+
if (!list.some((a) => String(a?.key ?? '').endsWith('/strip.webp'))) errors.push(`capture ${i + 1}: no strip recorded`);
|
|
71
76
|
});
|
|
72
|
-
for (const s of strips) if (!fs.existsSync(path.join(entry.dir, s))) errors.push(`${s}: missing`);
|
|
73
77
|
errors.push(...validateMotion(entry));
|
|
74
78
|
return { ok: errors.length === 0, errors };
|
|
75
79
|
}
|
package/src/index/build.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { listEntries, parseEntry, validateEntry } from '../entry.mjs';
|
|
5
5
|
import { openIndex } from './query.mjs';
|
|
6
6
|
import { getEmbedder, absentReason } from './embed.mjs';
|
|
7
|
+
import { artifactFile } from '../artifacts.mjs';
|
|
7
8
|
|
|
8
9
|
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
const SCHEMA = fs.readFileSync(path.join(DIR, 'schema.sql'), 'utf8');
|
|
@@ -19,7 +20,7 @@ function searchableBody(entry) {
|
|
|
19
20
|
.filter(Boolean).join('\n');
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export async function buildIndex({ entriesDir, indexPath, vocab, models }) {
|
|
23
|
+
export async function buildIndex({ entriesDir, indexPath, vocab, models, p }) {
|
|
23
24
|
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
|
24
25
|
const tmpPath = indexPath + '.tmp';
|
|
25
26
|
// a killed build can leave a half-written .tmp behind; clear it so the open below always starts fresh
|
|
@@ -59,8 +60,17 @@ export async function buildIndex({ entriesDir, indexPath, vocab, models }) {
|
|
|
59
60
|
const tagsStr = tags.map(([f, t]) => `${f}/${t}`).join(' ');
|
|
60
61
|
const tv = await emb.text(e.fm.title + '\n' + searchableBody(e) + '\n' + tagsStr);
|
|
61
62
|
insVecText.run(e.slug, Buffer.from(tv.buffer));
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
// An unreachable strip must not abort the whole index: skip this entry's image
|
|
64
|
+
// vector and keep going, the same degradation buildUi applies to its thumbnail.
|
|
65
|
+
const strip = (e.fm.captured?.captures?.[0]?.artifacts ?? []).find((a) => a?.key?.endsWith('/strip.webp'));
|
|
66
|
+
if (strip) {
|
|
67
|
+
try {
|
|
68
|
+
const iv = await emb.image(await artifactFile(p, strip));
|
|
69
|
+
insVecImage.run(e.slug, Buffer.from(iv.buffer));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
process.stderr.write(`index: ${e.slug}: image vector skipped: ${err.message}\n`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
64
74
|
}
|
|
65
75
|
n++;
|
|
66
76
|
}
|
package/src/ingest/agreement.mjs
CHANGED
|
@@ -2,6 +2,17 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { observedColours } from './observed.mjs';
|
|
4
4
|
|
|
5
|
+
// An artifact's key is entries/<slug>/<n>/<name>. Capture 1's files live at the entry root and
|
|
6
|
+
// every other capture's under captures/<n>/ (add.mjs's captureDir), so the local path this
|
|
7
|
+
// process can still read is recoverable from the key alone, without add.mjs handing it over
|
|
8
|
+
// separately -- the bytes live in R2 now, but the just-written local copy is what this reads.
|
|
9
|
+
function localArtifactPath(key) {
|
|
10
|
+
const m = /^entries\/[^/]+\/(\d+)\/(.+)$/.exec(key ?? '');
|
|
11
|
+
if (!m) return null;
|
|
12
|
+
const [, n, name] = m;
|
|
13
|
+
return n === '1' ? name : path.join('captures', n, name);
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// Records numbers, never prose. "canvas declared dark-blue but the frames read warm" is an
|
|
6
17
|
// interpretation, and this library's rule is that the session is the tagger — the server
|
|
7
18
|
// never guesses what a design feels like. Here are the declared values and the observed
|
|
@@ -21,7 +32,9 @@ export async function computeAgreement({ fm, dir }) {
|
|
|
21
32
|
const declared = fm.palette;
|
|
22
33
|
if (!declared || Object.values(declared).every((v) => !v)) return null;
|
|
23
34
|
|
|
24
|
-
const
|
|
35
|
+
const stripArtifact = (frames.artifacts ?? []).find((a) => a?.key?.endsWith('/strip.webp'));
|
|
36
|
+
if (!stripArtifact) return null;
|
|
37
|
+
const strip = localArtifactPath(stripArtifact.key);
|
|
25
38
|
if (!strip) return null;
|
|
26
39
|
const file = path.join(dir, strip);
|
|
27
40
|
if (!fs.existsSync(file)) return null;
|
package/src/ingest/attach.mjs
CHANGED
|
@@ -9,17 +9,6 @@ function isBlank(v) {
|
|
|
9
9
|
|| (typeof v === 'object' && Object.values(v).every((x) => x === '' || x == null));
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
// An entry written before captures[] existed describes one capture in its top-level fields.
|
|
13
|
-
// Synthesise it rather than dropping it, so attaching to an old entry does not lose what it
|
|
14
|
-
// already recorded.
|
|
15
|
-
function existingCaptureFrom(fm) {
|
|
16
|
-
const c = { method: fm.captured.method, input: fm.source?.url ?? null, artifacts: ['strip.webp'] };
|
|
17
|
-
if (fm.captured.method !== 'frames') c.artifacts.push('measure.json', 'screen-1440.png', 'screen-390.png');
|
|
18
|
-
if (fm.media?.frames) c.frames = fm.media.frames;
|
|
19
|
-
if (fm.media?.duration_ms) c.duration_ms = fm.media.duration_ms;
|
|
20
|
-
return c;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
// `both` keeps its existing meaning — measured AND frames are present — which is what
|
|
24
13
|
// refresh.mjs is really asking when it says "needs measured or both, not frames": is there
|
|
25
14
|
// a URL here I can re-measure.
|
|
@@ -35,10 +24,14 @@ export async function attachCapture({ entriesDir, slug, capture, palette, type,
|
|
|
35
24
|
const e = parseEntry(dir);
|
|
36
25
|
const fm = e.fm;
|
|
37
26
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
27
|
+
// A pre-captures[] entry has no recorded keys, and inventing one would name an object that was
|
|
28
|
+
// never uploaded. Migration writes them; this only carries across what is already there. An
|
|
29
|
+
// EMPTY captures[] is the same case in disguise -- nextCaptureIndex already counts it as
|
|
30
|
+
// capture 1, so an attach reaching here with nothing recorded has nothing safe to carry forward.
|
|
31
|
+
if (!fm.captured.captures?.length) {
|
|
32
|
+
throw new Error(`entry ${slug} has no recorded capture 1 artifacts; run the artifacts migration before attaching`);
|
|
33
|
+
}
|
|
34
|
+
const captures = fm.captured.captures;
|
|
42
35
|
captures.push(capture);
|
|
43
36
|
|
|
44
37
|
// Fill only if blank. palette and type are mechanically derived, but the session may have
|
package/src/ingest/save.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { parseEntry, validateEntry } from '../entry.mjs';
|
|
2
2
|
import { buildIndex } from '../index/build.mjs';
|
|
3
3
|
|
|
4
|
-
export async function saveEntry({ dir, vocab, indexPath, entriesDir, models }) {
|
|
4
|
+
export async function saveEntry({ dir, vocab, indexPath, entriesDir, models, p }) {
|
|
5
5
|
const e = parseEntry(dir);
|
|
6
6
|
const r = validateEntry(e, vocab);
|
|
7
7
|
if (!r.ok) return { ok: false, slug: e.slug, errors: r.errors, refused: r.errors.join('; ') };
|
|
8
8
|
// Phase 1: a full rebuild is cheap; single-entry upsert arrives with the vector arms.
|
|
9
|
-
await buildIndex({ entriesDir, indexPath, vocab, models });
|
|
9
|
+
await buildIndex({ entriesDir, indexPath, vocab, models, p });
|
|
10
10
|
return { ok: true, slug: e.slug, errors: [] };
|
|
11
11
|
}
|
package/src/mcp/tools.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { resolveTerm } from '../vocab.mjs';
|
|
|
6
6
|
import { refuseUnsafeUrl } from '../ingest/url-guard.mjs';
|
|
7
7
|
import { getMotion, motionFrame } from '../motion.mjs';
|
|
8
8
|
import { parseEntry } from '../entry.mjs';
|
|
9
|
+
import { artifactFile } from '../artifacts.mjs';
|
|
10
|
+
import { readConfig, objectUrl } from '../r2.mjs';
|
|
9
11
|
|
|
10
12
|
const facetList = z.array(z.string()).optional();
|
|
11
13
|
const FILTER_FACETS = ['role', 'feel', 'motion', 'source'];
|
|
@@ -78,30 +80,40 @@ export function makeTools(ctx) {
|
|
|
78
80
|
},
|
|
79
81
|
{
|
|
80
82
|
name: 'get_entry',
|
|
81
|
-
description: "Return one entry: frontmatter, prose body, and
|
|
83
|
+
description: "Return one entry: frontmatter, prose body, and its strip.webp as both a cached local path (strip/strips) and a public URL (strip_url/strip_urls); either may be null if the object cannot be resolved right now. detail:'summary' returns the body's first paragraph instead of the whole body — enough to decide whether to fetch the rest.",
|
|
82
84
|
schema: z.object({
|
|
83
85
|
slug: z.string(),
|
|
84
86
|
detail: z.enum(['summary', 'full']).default('full'),
|
|
85
87
|
}),
|
|
86
|
-
handler: withDb((db, { slug, detail }) => {
|
|
88
|
+
handler: withDb(async (db, { slug, detail }) => {
|
|
87
89
|
const e = getEntry(db, slug);
|
|
88
90
|
if (!e) return { refused: `no entry with slug ${slug}` };
|
|
89
91
|
// Every capture's strip, one slot per capture, capture 1 first — strips[i] always
|
|
90
|
-
// describes captures[i].
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
92
|
+
// describes captures[i]. An artifact is a {key, bytes, sha256} record now (Task 2),
|
|
93
|
+
// matched by its key ending in "/strip.webp" so an unrelated file like
|
|
94
|
+
// extra-strip.webp is never picked up by a loose suffix match. validateEntry
|
|
95
|
+
// requires every capture to record one, so an indexed entry always has one to
|
|
96
|
+
// resolve — but the bytes live in R2, and R2 may be unreachable (not yet migrated,
|
|
97
|
+
// or simply down). One unreachable object must not refuse the whole entry: it
|
|
98
|
+
// resolves to null, the same degradation buildUi and buildIndex apply, while
|
|
99
|
+
// strip_url — built from the key alone, no fetch required — is still returned.
|
|
96
100
|
const captures = e.fm?.captured?.captures ?? [];
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
let config = null;
|
|
102
|
+
// readConfig, not r2Config: building a public URL (and fetching one) needs the base URL,
|
|
103
|
+
// not a write secret. get_entry never writes.
|
|
104
|
+
try { config = readConfig(); } catch { config = null; }
|
|
105
|
+
const strips = [];
|
|
106
|
+
const stripUrls = [];
|
|
107
|
+
for (const c of captures) {
|
|
108
|
+
const artifact = (c.artifacts ?? []).find((a) => a?.key?.endsWith('/strip.webp'));
|
|
109
|
+
if (!artifact) { strips.push(null); stripUrls.push(null); continue; }
|
|
110
|
+
let local = null;
|
|
111
|
+
try { local = await artifactFile(ctx.paths, artifact); } catch { local = null; }
|
|
112
|
+
strips.push(local);
|
|
113
|
+
stripUrls.push(config ? objectUrl(config, artifact.key) : null);
|
|
114
|
+
}
|
|
115
|
+
// strips[0] === strip holds by construction: strip is never computed separately.
|
|
116
|
+
const out = { ...e, strip: strips[0] ?? null, strips, strip_url: stripUrls[0] ?? null, strip_urls: stripUrls };
|
|
105
117
|
if (detail !== 'summary') return out;
|
|
106
118
|
// The first PROSE paragraph. Bodies conventionally open with `## What it does`,
|
|
107
119
|
// so splitting on the first blank line alone would return a bare heading, which
|
|
@@ -238,7 +250,7 @@ export function makeTools(ctx) {
|
|
|
238
250
|
schema: z.object({ slug: z.string().regex(/^[a-z0-9-]+$/) }),
|
|
239
251
|
handler: async ({ slug }) => {
|
|
240
252
|
const { saveEntry } = await import('../ingest/save.mjs');
|
|
241
|
-
return saveEntry({ dir: path.join(ctx.paths.entries, slug), vocab: ctx.vocab, indexPath: ctx.paths.index, entriesDir: ctx.paths.entries, models: ctx.paths.models });
|
|
253
|
+
return saveEntry({ dir: path.join(ctx.paths.entries, slug), vocab: ctx.vocab, indexPath: ctx.paths.index, entriesDir: ctx.paths.entries, models: ctx.paths.models, p: ctx.paths });
|
|
242
254
|
},
|
|
243
255
|
},
|
|
244
256
|
];
|
package/src/r2.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The only file that knows R2 exists. Everything else speaks in keys.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export function isHttpsUrl(value) {
|
|
5
|
+
try { const u = new URL(value); return u.protocol === 'https:' && !u.username && !u.password; } catch { return false; }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function r2Config(env = process.env) {
|
|
9
|
+
const required = ['R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_BUCKET', 'R2_PUBLIC_BASE_URL'];
|
|
10
|
+
const missing = required.filter((k) => !env[k]);
|
|
11
|
+
if (missing.length) throw new Error(`configure ${missing.join(', ')} locally before publishing`);
|
|
12
|
+
if (!/^[a-f0-9]{32}$/.test(env.R2_ACCOUNT_ID)) throw new Error('invalid R2_ACCOUNT_ID');
|
|
13
|
+
if (!isHttpsUrl(env.R2_PUBLIC_BASE_URL)) throw new Error('R2_PUBLIC_BASE_URL must be HTTPS without credentials');
|
|
14
|
+
const base = new URL(env.R2_PUBLIC_BASE_URL);
|
|
15
|
+
if (base.search || base.hash) throw new Error('R2_PUBLIC_BASE_URL must not contain a query or fragment');
|
|
16
|
+
if (base.hostname.endsWith('.r2.dev')) throw new Error('use a production custom domain for R2_PUBLIC_BASE_URL');
|
|
17
|
+
return { account: env.R2_ACCOUNT_ID, bucket: env.R2_BUCKET, base: base.href.replace(/\/$/, ''),
|
|
18
|
+
accessKeyId: env.R2_ACCESS_KEY_ID, secretAccessKey: env.R2_SECRET_ACCESS_KEY };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Reading a public object needs the base URL and nothing else: a range GET of
|
|
22
|
+
// entries/<slug>/<n>/<name> is unauthenticated. The hosted server only ever reads, so requiring
|
|
23
|
+
// R2_SECRET_ACCESS_KEY there would make a write credential the price of rendering a thumbnail —
|
|
24
|
+
// and the failure is soft (a gallery with no images), so nobody would notice the misconfiguration.
|
|
25
|
+
export function readConfig(env = process.env) {
|
|
26
|
+
if (!env.R2_PUBLIC_BASE_URL) throw new Error('configure R2_PUBLIC_BASE_URL to read artifacts from R2');
|
|
27
|
+
if (!isHttpsUrl(env.R2_PUBLIC_BASE_URL)) throw new Error('R2_PUBLIC_BASE_URL must be HTTPS without credentials');
|
|
28
|
+
const base = new URL(env.R2_PUBLIC_BASE_URL);
|
|
29
|
+
if (base.search || base.hash) throw new Error('R2_PUBLIC_BASE_URL must not contain a query or fragment');
|
|
30
|
+
return { base: base.href.replace(/\/$/, '') };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
34
|
+
const NAME = /^[a-z0-9][a-z0-9.-]*$/;
|
|
35
|
+
|
|
36
|
+
// The one definition of what artifactKey can produce, so a key that validates is a key that
|
|
37
|
+
// resolves. validateEntry used to accept anything starting with `entries/`, which let
|
|
38
|
+
// `entries/../../etc/strip.webp` be valid, indexed and permanently unresolvable — and get_entry
|
|
39
|
+
// handed that to an agent as a URL an HTTP client normalizes to a different prefix of the bucket.
|
|
40
|
+
export function isArtifactKey(key) {
|
|
41
|
+
return typeof key === 'string' && !key.includes('..')
|
|
42
|
+
&& /^entries\/[a-z0-9-]+\/[0-9]+\/[a-z0-9][a-z0-9.-]*$/.test(key);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// entries/<slug>/<capture>/<name>. Recomputable from the entry that owns it, so a key is never
|
|
46
|
+
// a fact you have to store twice.
|
|
47
|
+
export function artifactKey(slug, captureIndex, name) {
|
|
48
|
+
if (!SLUG.test(slug)) throw new Error('invalid slug for an artifact key');
|
|
49
|
+
if (!Number.isInteger(captureIndex) || captureIndex < 1) throw new Error('capture index must be a positive integer');
|
|
50
|
+
if (!NAME.test(name) || name.includes('..')) throw new Error('invalid artifact name');
|
|
51
|
+
return `entries/${slug}/${captureIndex}/${name}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function objectUrl(config, key) {
|
|
55
|
+
return `${config.base}/${key}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function sha256(body) {
|
|
59
|
+
return createHash('sha256').update(body).digest('hex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function s3Upload(config) {
|
|
63
|
+
const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
64
|
+
const client = new S3Client({ region: 'auto', endpoint: `https://${config.account}.r2.cloudflarestorage.com`,
|
|
65
|
+
credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey } });
|
|
66
|
+
return (args) => client.send(new PutObjectCommand(args));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Upload, then prove the object is publicly readable and byte-identical before any caller records
|
|
70
|
+
// the key. motion.mjs established this order: an entry must never name an object nobody can read.
|
|
71
|
+
//
|
|
72
|
+
// `immutable` is NOT carried over from motion.mjs with it. That key embeds the digest
|
|
73
|
+
// (motion/<slug>/<id>-<sha256[0:16]>.mp4) so it never names two different bodies; an artifact key
|
|
74
|
+
// is entries/<slug>/<n>/<name>, stable across re-captures on purpose — get_entry hands those keys
|
|
75
|
+
// to agents and the migration's value is that a key is predictable from a slug — so `add --force`
|
|
76
|
+
// PUTs different bytes at the same key. Take the header, not the key: putting a digest in the key
|
|
77
|
+
// would make every re-capture a fresh key and re-open the orphan problem. It also breaks the
|
|
78
|
+
// verification below, which range-fetches through R2_PUBLIC_BASE_URL (the edge): under `immutable`
|
|
79
|
+
// it can compare the new body against a cached old object and throw `public bytes differ` AFTER
|
|
80
|
+
// the object was overwritten. The cost is a re-capture taking up to max-age to reach the edge.
|
|
81
|
+
export async function putAndVerify({ config, key, body, contentType, upload, fetcher = fetch }) {
|
|
82
|
+
const digest = sha256(body);
|
|
83
|
+
await upload({ Bucket: config.bucket, Key: key, Body: body, ContentType: contentType,
|
|
84
|
+
CacheControl: 'public, max-age=300', Metadata: { sha256: digest } });
|
|
85
|
+
const url = objectUrl(config, key);
|
|
86
|
+
const response = await fetcher(url, { headers: { Range: 'bytes=0-31' }, signal: AbortSignal.timeout(20000) });
|
|
87
|
+
const range = response.headers.get('content-range');
|
|
88
|
+
if (response.status !== 206 || range !== `bytes 0-31/${body.length}`) {
|
|
89
|
+
await response.body?.cancel();
|
|
90
|
+
throw new Error(`uploaded ${key}, but public verification failed (HTTP ${response.status}); nothing was recorded`);
|
|
91
|
+
}
|
|
92
|
+
const head = Buffer.from(await response.arrayBuffer());
|
|
93
|
+
if (!head.equals(body.subarray(0, 32))) throw new Error(`public bytes differ for ${key}`);
|
|
94
|
+
return { key, bytes: body.length, sha256: digest };
|
|
95
|
+
}
|