Skip to main content

max / makenotwork

Give the testnot demo real media, a buyer, and a repeatable capture Three halves of the same blocked chain, all of them the mechanical part somebody else's task named and left. Media. The seed grew a manifest (src/seed/media-manifest.toml, embedded) naming every asset it can attach, a fetch step that verifies a pinned sha256 and caches, and a fallback per slot to the generated placeholder. Curation is per asset, so a filled-in url swaps one cover and nothing else moves; the file is the checklist. A curated asset that will not fetch fails the seed before it writes, rather than quietly reverting to grey, because that reversion is what the testnot smoke check exists to catch. Attribution goes to a seeded credits post, not /docs/credits: that page enumerates what the software is built on and ships identically to prod, where none of this media exists. Buyer. /library was 401 to anonymous with no account holding anything, so a third of the pitch could not be shown. src/seed/buyer.rs seeds one opt-in account with nine purchases, a subscription, license keys and download history. Amounts are read off the item because the Free badge is derived from them; the platform fee is zero because it is zero. It buys nine of eleven items on purpose: a library holding the whole catalog reads as a fixture. Capture. scripts/capture-landing-carousel.mjs drives CDP directly to clip all three frames to one rectangle in one session, logging in as the buyer through the real form. A fixed crop width is the point: deriving it per page gave a 2400px storefront beside a 1600px item page, same ratio, different zoom, text resizing between slides. Framing is left untuned. The trial run showed the item grid falling below a 16:10 crop, but it was shot against placeholder covers and three items in a five-wide grid, and both move when the media lands.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 16:08 UTC
Signed with PGP, not checked
Commit: 87877f78bf7bcb9b942eab36f63054569e7607c2
Parent: 3aae857
10 files changed, +2174 insertions, -30 deletions
@@ -244,6 +244,17 @@
244 244 s3: seed_client(config.storage.as_ref(), &config.host_url).await,
245 245 public_s3: seed_client(config.public_storage.as_ref(), &config.host_url).await,
246 246 cdn_base_url: Some(config.cdn_base_url.clone()),
247 + assets: makenotwork::seed::manifest::ResolvedAssets::default(),
248 + };
249 +
250 + // Fetch the curated public-domain media before touching the database, so
251 + // a manifest that cannot be resolved leaves the existing catalog alone.
252 + let media = match media.with_manifest().await {
253 + Ok(media) => media,
254 + Err(e) => {
255 + tracing::error!(error = %e, "example seed: media manifest could not be resolved");
256 + std::process::exit(1);
257 + }
247 258 };
248 259
249 260 match makenotwork::seed::run(&db, &opts, &media).await {
@@ -38,3 +38,72 @@
38 38 }
39 39 Ok(())
40 40 }
41 +
42 + /// Project the media-credits post is published under. Commonshare is the benefit
43 + /// account whose whole premise is the commons, so the attribution for the box's
44 + /// public-domain media belongs there rather than on a creator's storefront.
45 + const CREDITS_PROJECT_SLUG: &str = "commons-sampler";
46 +
47 + /// Title (and, slugified, the URL) of the media-credits post.
48 + const CREDITS_TITLE: &str = "Media credits and sources";
49 +
50 + /// Publish a credits post listing every curated asset on the box, with its
51 + /// licence and a link to the page that substantiates the claim.
52 + ///
53 + /// A licence field in `media-manifest.toml` is a note to ourselves; a visitor
54 + /// looking at a CC0 photograph on a public site needs somewhere to go. This is
55 + /// that somewhere. The public `/docs/credits` page is deliberately not it: that
56 + /// page enumerates what the *software* is built on and ships identically to
57 + /// production, where none of this media exists.
58 + ///
59 + /// No-ops when nothing is curated, so an uncurated box does not publish an empty
60 + /// credits page.
61 + pub async fn seed_media_credits(
62 + pool: &sqlx::PgPool,
63 + projects: &[SeededProject],
64 + assets: &super::manifest::ResolvedAssets,
65 + ) -> Result<(), SeedError> {
66 + let list = assets.attribution_markdown();
67 + if list.is_empty() {
68 + tracing::info!("example seed: no curated media, skipping the credits post");
69 + return Ok(());
70 + }
71 + let Some(project) = projects
72 + .iter()
73 + .find(|p| p.spec.slug == CREDITS_PROJECT_SLUG)
74 + else {
75 + tracing::warn!(
76 + slug = CREDITS_PROJECT_SLUG,
77 + "example seed: credits project missing from the roster; credits not published"
78 + );
79 + return Ok(());
80 + };
81 +
82 + let body = format!(
83 + "Every file on this demo is public domain or CC0. This is where each one \
84 + came from.\n\n{list}\n\nNothing here is a real listing. The catalog is \
85 + fabricated for demonstration; only the media is real, and only because \
86 + its licence allows it."
87 + );
88 + let slug = slugify(CREDITS_TITLE);
89 + let body_html = render_creator_markdown(&body, project.user_id, "");
90 + db::blog_posts::create_blog_post(
91 + pool,
92 + project.project.id,
93 + project.user_id,
94 + CREDITS_TITLE,
95 + &slug,
96 + &body,
97 + &body_html,
98 + true, // publish
99 + false, // web_only
100 + false, // show_on_landing
101 + )
102 + .await?;
103 + tracing::info!(
104 + assets = assets.len(),
105 + slug = %slug,
106 + "example seed: published media credits"
107 + );
108 + Ok(())
109 + }
@@ -65,6 +65,13 @@
65 65 pub tags: &'static [&'static str],
66 66 /// Markdown body for Text items; `None` for everything else.
67 67 pub body: Option<&'static str>,
68 + /// Manifest id of the item's primary file (`media-manifest.toml`). `None`
69 + /// for item types that serve a generated blob rather than a real file, and
70 + /// for Image items, whose cover *is* the work. An id that is declared but
71 + /// uncurated falls back to the generated placeholder.
72 + pub media: Option<&'static str>,
73 + /// Manifest id of the item's cover art. `None` keeps the grey placeholder.
74 + pub cover: Option<&'static str>,
68 75 }
69 76
70 77 /// The single project a seeded creator owns, plus its content.
@@ -99,6 +106,9 @@
99 106 pub items: &'static [ItemSpec],
100 107 /// Blog posts to publish on this project (Phase 4).
101 108 pub blog: &'static [BlogSpec],
109 + /// Manifest id of the project's cover art (`media-manifest.toml`). `None`
110 + /// keeps the grey placeholder.
111 + pub cover: Option<&'static str>,
102 112 }
103 113
104 114 /// One fabricated creator plus the project they own.
@@ -144,6 +154,7 @@
144 154 Every release is free to share; pay what it's worth if it moves you.",
145 155 project: ProjectSpec {
146 156 slug: "restored-reels-vol-1",
157 + cover: Some("openreels-project-cover"),
147 158 blog: &[
148 159 BlogSpec {
149 160 title: "Vol. 1 is out",
@@ -174,6 +185,8 @@
174 185 pricing: ItemPricing::Pwyw { min: 100 },
175 186 tags: &["audio", "audio.format.music"],
176 187 body: None,
188 + media: Some("restoration-1-audio"),
189 + cover: Some("restoration-1-cover"),
177 190 },
178 191 ItemSpec {
179 192 title: "Stem Pack: Strings",
@@ -183,6 +196,8 @@
183 196 pricing: ItemPricing::Pwyw { min: 200 },
184 197 tags: &["audio.format.samples", "audio.technique.sampling"],
185 198 body: None,
199 + media: Some("stem-pack-strings-audio"),
200 + cover: Some("stem-pack-strings-cover"),
186 201 },
187 202 ItemSpec {
188 203 title: "Session Take (Video)",
@@ -192,6 +207,8 @@
192 207 pricing: ItemPricing::Pwyw { min: 100 },
193 208 tags: &["video", "video.genre.music-video"],
194 209 body: None,
210 + media: Some("session-take-video"),
211 + cover: Some("session-take-cover"),
195 212 },
196 213 ],
197 214 },
@@ -204,6 +221,7 @@
204 221 lock-in.",
205 222 project: ProjectSpec {
206 223 slug: "deskriver-suite",
224 + cover: Some("deskriver-project-cover"),
207 225 blog: &[
208 226 BlogSpec {
209 227 title: "One download, buy it once",
@@ -234,6 +252,8 @@
234 252 pricing: ItemPricing::BuyOnce(1200),
235 253 tags: &["software.format.plugin", "software.format.vst3"],
236 254 body: None,
255 + media: None,
256 + cover: Some("deskriver-focus-cover"),
237 257 },
238 258 ItemSpec {
239 259 title: "Minimal Preset Pack",
@@ -243,6 +263,8 @@
243 263 pricing: ItemPricing::BuyOnce(500),
244 264 tags: &["software", "software.platform.macos"],
245 265 body: None,
266 + media: None,
267 + cover: Some("deskriver-presets-cover"),
246 268 },
247 269 ItemSpec {
248 270 title: "Weekly-Review Template",
@@ -252,6 +274,8 @@
252 274 pricing: ItemPricing::BuyOnce(300),
253 275 tags: &["writing.topic.productivity", "software"],
254 276 body: None,
277 + media: None,
278 + cover: Some("deskriver-template-cover"),
255 279 },
256 280 ItemSpec {
257 281 title: "Deskriver Utility (Download)",
@@ -265,6 +289,8 @@
265 289 "software.format.desktop",
266 290 ],
267 291 body: None,
292 + media: None,
293 + cover: Some("deskriver-utility-cover"),
268 294 },
269 295 ],
270 296 },
@@ -276,6 +302,7 @@
276 302 domain. Download, print, remix, no permission needed.",
277 303 project: ProjectSpec {
278 304 slug: "cc0-field-library",
305 + cover: Some("stillfield-project-cover"),
279 306 blog: &[
280 307 BlogSpec {
281 308 title: "Opening the field library",
@@ -302,6 +329,8 @@
302 329 pricing: ItemPricing::Free,
303 330 tags: &["visual.medium.photography", "visual"],
304 331 body: None,
332 + media: None,
333 + cover: Some("field-study-01-cover"),
305 334 }],
306 335 },
307 336 },
@@ -312,6 +341,7 @@
312 341 readable editions. New volumes for subscribers each month.",
313 342 project: ProjectSpec {
314 343 slug: "the-marginalia-reader",
344 + cover: Some("marginalia-project-cover"),
315 345 blog: &[
316 346 BlogSpec {
317 347 title: "The first monthly volume",
@@ -356,6 +386,8 @@
356 386 pricing: ItemPricing::Free,
357 387 tags: &["writing.format.essay", "writing.topic.creativity"],
358 388 body: Some(SLOW_READING_BODY),
389 + media: None,
390 + cover: Some("on-slow-reading-cover"),
359 391 },
360 392 ItemSpec {
361 393 title: "Typesetting the Commons",
@@ -365,6 +397,8 @@
365 397 pricing: ItemPricing::Free,
366 398 tags: &["education.format.course", "education.topic.writing"],
367 399 body: None,
400 + media: None,
401 + cover: Some("typesetting-commons-cover"),
368 402 },
369 403 ],
370 404 },
@@ -377,6 +411,7 @@
377 411 commons, not to us.",
378 412 project: ProjectSpec {
379 413 slug: "commons-sampler",
414 + cover: Some("commonshare-project-cover"),
380 415 blog: &[
381 416 BlogSpec {
382 417 title: "What the account funds",
@@ -403,6 +438,8 @@
403 438 pricing: ItemPricing::Free,
404 439 tags: &["audio", "visual"],
405 440 body: None,
441 + media: None,
442 + cover: Some("community-bundle-cover"),
406 443 }],
407 444 },
408 445 },
@@ -443,3 +480,75 @@
443 480 }
444 481 Ok(seeded)
445 482 }
483 +
484 + #[cfg(test)]
485 + mod tests {
486 + use super::*;
487 + use std::collections::HashSet;
488 +
489 + /// Every manifest id the roster names.
490 + fn referenced_ids() -> Vec<&'static str> {
491 + let mut ids = Vec::new();
492 + for creator in ROSTER {
493 + ids.extend(creator.project.cover);
494 + for item in creator.project.items {
495 + ids.extend(item.media);
496 + ids.extend(item.cover);
497 + }
498 + }
499 + ids
500 + }
501 +
502 + /// The roster and the manifest have to agree, in both directions. A typo in
503 + /// either one is otherwise silent: the slot keeps its grey placeholder and
504 + /// the box looks merely uncurated rather than broken.
505 + #[test]
506 + fn roster_and_manifest_agree() {
507 + let manifest =
508 + super::super::manifest::Manifest::load().expect("the manifest must be loadable");
509 + let declared: HashSet<&str> = manifest.ids().collect();
510 + let referenced: HashSet<&str> = referenced_ids().into_iter().collect();
511 +
512 + let undeclared: Vec<&str> = referenced.difference(&declared).copied().collect();
513 + assert!(
514 + undeclared.is_empty(),
515 + "roster references ids the manifest does not declare: {undeclared:?}"
516 + );
517 +
518 + let unused: Vec<&str> = declared.difference(&referenced).copied().collect();
519 + assert!(
520 + unused.is_empty(),
521 + "manifest declares ids nothing references; curating them would upload \
522 + files no page shows: {unused:?}"
523 + );
524 + }
525 +
526 + /// Two slots may share an asset, but not an id by accident: a duplicate here
527 + /// usually means a copy-paste, not a deliberate reuse.
528 + #[test]
529 + fn each_id_is_referenced_once() {
530 + let ids = referenced_ids();
531 + let mut seen = HashSet::with_capacity(ids.len());
532 + for id in ids {
533 + assert!(seen.insert(id), "asset id {id:?} is referenced twice");
534 + }
535 + }
536 +
537 + /// Image items serve their cover as the work, so a `media` id on one would
538 + /// upload a file nothing links to.
539 + #[test]
540 + fn image_items_carry_no_separate_media() {
541 + for creator in ROSTER {
542 + for item in creator.project.items {
543 + if matches!(item.item_type, ItemType::Image | ItemType::Text) {
544 + assert!(
545 + item.media.is_none(),
546 + "{:?} item {:?} declares media, which nothing uploads",
547 + item.item_type,
548 + item.title
549 + );
550 + }
551 + }
552 + }
553 + }
554 + }
@@ -4,13 +4,19 @@
4 4 //! file per item through the storage layer and promotes the item to `'clean'`, so
5 5 //! previews/downloads resolve and the catalog surfaces in discover.
6 6 //!
7 - //! This pass uploads **generated placeholders** (a silent WAV, a tiny PNG cover, a
8 - //! short byte blob), not real assets, enough to make the pipeline functional and
9 - //! reproducible with no external URLs. Real public-domain / CC0 media swaps in
10 - //! later via a `media-manifest.toml` + fetch step. When storage is unconfigured
11 - //! (testnot's stubbed default until MinIO is stood up), the whole phase is skipped
12 - //! and items stay hidden.
7 + //! Every slot here has two sources. The real one is a curated public-domain / CC0
8 + //! asset named by `media-manifest.toml` and fetched by [`super::manifest`]. The
9 + //! fallback is a generated placeholder (a silent WAV, a 16x16 grey PNG, a short
10 + //! byte blob), which keeps the pipeline functional and reproducible with no
11 + //! external URLs. Curation is per asset, so the two mix freely: an item with real
12 + //! cover art and a placeholder download is a normal intermediate state.
13 + //!
14 + //! When storage is unconfigured the whole phase is skipped and items stay hidden.
13 15
16 + use std::collections::HashMap;
17 +
18 + use super::creators::ItemSpec;
19 + use super::manifest::ResolvedAssets;
14 20 use super::projects::SeededProject;
15 21 use super::{SeedError, SeedMedia};
16 22 use crate::db::scan_jobs::ScanTargetKind;
@@ -20,7 +26,7 @@
20 26 /// A minimal valid 16x16 grayscale PNG, used as a placeholder cover.
21 27 static PLACEHOLDER_PNG: &[u8] = include_bytes!("assets/placeholder.png");
22 28
23 - /// Attach placeholder media to every seeded item and promote it to visible.
29 + /// Attach media to every seeded item and promote it to visible.
24 30 ///
25 31 /// No-ops (leaving items hidden) when the main storage bucket is unconfigured.
26 32 pub async fn seed_media(
@@ -37,10 +43,16 @@
37 43 // Project cover (best-effort; needs the public/CDN bucket).
38 44 attach_project_cover(pool, media, project).await?;
39 45
46 + // Items come back from the database; their specs carry the manifest ids.
47 + // Titles are unique within a project, which is what makes this join safe.
48 + let specs: HashMap<&str, &ItemSpec> =
49 + project.spec.items.iter().map(|s| (s.title, s)).collect();
50 +
40 51 let items = db::items::get_items_by_project(pool, project.project.id).await?;
41 52 for item in &items {
42 - attach_item_media(pool, s3, project, item).await?;
43 - attach_item_cover(pool, media, project, item).await?;
53 + let spec = specs.get(item.title.as_str()).copied();
54 + attach_item_media(pool, s3, &media.assets, project, item, spec).await?;
55 + attach_item_cover(pool, media, project, item, spec).await?;
44 56 }
45 57 }
46 58 Ok(())
@@ -50,15 +62,24 @@
50 62 async fn attach_item_media(
51 63 pool: &sqlx::PgPool,
52 64 s3: &dyn StorageBackend,
65 + assets: &ResolvedAssets,
53 66 project: &SeededProject,
54 67 item: &db::DbItem,
68 + spec: Option<&ItemSpec>,
55 69 ) -> Result<(), SeedError> {
56 70 let user = project.user_id;
71 + // The curated file for this item, when the manifest declares one and it
72 + // resolved. Everything below falls back to a generated placeholder.
73 + let curated = assets.lookup(spec.and_then(|s| s.media));
74 +
57 75 match item.item_type {
58 76 ItemType::Audio => {
59 - let key = S3Client::generate_key(user, item.id, FileType::Audio, "placeholder.wav");
60 - s3.upload_object(&key, "audio/wav", silent_wav(), None)
61 - .await?;
77 + let (filename, content_type, bytes) = curated.map_or_else(
78 + || ("placeholder.wav", "audio/wav", silent_wav()),
79 + |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()),
80 + );
81 + let key = S3Client::generate_key(user, item.id, FileType::Audio, filename);
82 + s3.upload_object(&key, content_type, bytes, None).await?;
62 83 db::scanning::promote_gated(
63 84 pool,
64 85 ScanTargetKind::Item,
@@ -69,10 +90,13 @@
69 90 .await?;
70 91 }
71 92 ItemType::Video => {
72 - let key = S3Client::generate_key(user, item.id, FileType::Video, "placeholder.mp4");
73 - // Placeholder bytes, not a playable video; real media swaps in later.
74 - s3.upload_object(&key, "video/mp4", placeholder_blob("video"), None)
75 - .await?;
93 + let (filename, content_type, bytes) = curated.map_or_else(
94 + // Placeholder bytes, not a playable video.
95 + || ("placeholder.mp4", "video/mp4", placeholder_blob("video")),
96 + |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()),
97 + );
98 + let key = S3Client::generate_key(user, item.id, FileType::Video, filename);
99 + s3.upload_object(&key, content_type, bytes, None).await?;
76 100 db::scanning::promote_gated(
77 101 pool,
78 102 ScanTargetKind::Item,
@@ -98,22 +122,38 @@
98 122 | ItemType::Template
99 123 | ItemType::Digital
100 124 | ItemType::Bundle => {
101 - let blob = placeholder_blob(&item.title);
125 + let (filename, content_type, blob, notes) = curated.map_or_else(
126 + || {
127 + (
128 + "placeholder.txt",
129 + "application/octet-stream",
130 + placeholder_blob(&item.title),
131 + "Initial placeholder release.",
132 + )
133 + },
134 + |(a, b)| {
135 + (
136 + a.filename.as_str(),
137 + a.media_type.as_str(),
138 + b.to_vec(),
139 + "Initial release.",
140 + )
141 + },
142 + );
102 143 let size = blob.len() as i64;
103 144 let version = db::versions::create_version(
104 145 pool,
105 146 item.id,
106 147 "1.0.0",
107 - Some("Initial placeholder release."),
148 + Some(notes),
108 149 None,
109 150 Some(size),
110 - Some("placeholder.txt"),
151 + Some(filename),
111 152 None,
112 153 )
113 154 .await?;
114 - let key = S3Client::generate_version_key(user, item.id, version.id, "placeholder.txt");
115 - s3.upload_object(&key, "application/octet-stream", blob, None)
116 - .await?;
155 + let key = S3Client::generate_version_key(user, item.id, version.id, filename);
156 + s3.upload_object(&key, content_type, blob, None).await?;
117 157 db::scanning::promote_gated(
118 158 pool,
119 159 ScanTargetKind::Version,
@@ -127,25 +167,32 @@
127 167 db::scanning::update_item_scan_status(pool, item.id, FileScanStatus::Clean).await?;
128 168 }
129 169 }
130 - tracing::info!(title = %item.title, item_type = ?item.item_type, "example seed: attached media");
170 + tracing::info!(
171 + title = %item.title,
172 + item_type = ?item.item_type,
173 + curated = curated.is_some(),
174 + "example seed: attached media"
175 + );
131 176 Ok(())
132 177 }
133 178
134 - /// Attach a placeholder cover to an item. Best-effort: requires the public/CDN
135 - /// bucket + render base, else skipped.
179 + /// Attach a cover to an item, curated when the manifest has one. Best-effort:
180 + /// requires the public/CDN bucket + render base, else skipped.
136 181 async fn attach_item_cover(
137 182 pool: &sqlx::PgPool,
138 183 media: &SeedMedia,
139 184 project: &SeededProject,
140 185 item: &db::DbItem,
186 + spec: Option<&ItemSpec>,
141 187 ) -> Result<(), SeedError> {
142 188 let (Some(public), Some(cdn)) = (media.public_s3.as_deref(), media.cdn_base_url.as_deref())
143 189 else {
144 190 return Ok(());
145 191 };
146 - let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, "cover.png");
192 + let (filename, content_type, bytes) = cover_source(&media.assets, spec.and_then(|s| s.cover));
193 + let key = S3Client::generate_key(project.user_id, item.id, FileType::Cover, filename);
147 194 public
148 - .upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None)
195 + .upload_object(&key, content_type, bytes, None)
149 196 .await?;
150 197 let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str());
151 198 sqlx::query(
@@ -160,7 +207,7 @@
160 207 Ok(())
161 208 }
162 209
163 - /// Attach a placeholder cover to a project. Best-effort (see [`attach_item_cover`]).
210 + /// Attach a cover to a project. Best-effort (see [`attach_item_cover`]).
164 211 async fn attach_project_cover(
165 212 pool: &sqlx::PgPool,
166 213 media: &SeedMedia,
@@ -170,9 +217,10 @@
170 217 else {
171 218 return Ok(());
172 219 };
173 - let key = S3Client::generate_project_image_key(project.project.id, "cover.png");
220 + let (filename, content_type, bytes) = cover_source(&media.assets, project.spec.cover);
221 + let key = S3Client::generate_project_image_key(project.project.id, filename);
174 222 public
175 - .upload_object(&key, "image/png", PLACEHOLDER_PNG.to_vec(), None)
223 + .upload_object(&key, content_type, bytes, None)
176 224 .await?;
177 225 let url = format!("{}/{}", cdn.trim_end_matches('/'), key.as_str());
178 226 sqlx::query(
@@ -187,6 +235,18 @@
187 235 Ok(())
188 236 }
189 237
238 + /// The bytes to upload for a cover slot: the curated asset when the id resolved,
239 + /// the generated grey PNG otherwise.
240 + fn cover_source<'a>(
241 + assets: &'a ResolvedAssets,
242 + id: Option<&'a str>,
243 + ) -> (&'a str, &'a str, Vec<u8>) {
244 + assets.lookup(id).map_or_else(
245 + || ("cover.png", "image/png", PLACEHOLDER_PNG.to_vec()),
246 + |(a, b)| (a.filename.as_str(), a.media_type.as_str(), b.to_vec()),
247 + )
248 + }
249 +
190 250 /// Synthesize a short silent PCM WAV (8 kHz, 16-bit mono, ~0.5 s), a valid,
191 251 /// tiny audio file for the placeholder audio player.
192 252 fn silent_wav() -> Vec<u8> {
@@ -31,9 +31,11 @@
31 31 //! that.
32 32
33 33 pub mod blog;
34 + pub mod buyer;
34 35 pub mod creators;
35 36 pub mod harness;
36 37 pub mod items;
38 + pub mod manifest;
37 39 pub mod media;
38 40 pub mod projects;
39 41 pub mod social;
@@ -55,6 +57,11 @@
55 57 pub public_s3: Option<Arc<dyn StorageBackend>>,
56 58 /// CDN render base for `cover_image_url` (`{cdn_base}/{key}`).
57 59 pub cdn_base_url: Option<String>,
60 + /// Curated public-domain / CC0 files, already fetched and verified. Empty
61 + /// means every slot falls back to its generated placeholder, which is the
62 + /// state the box was in before any curation. Populate with
63 + /// [`Self::with_manifest`].
64 + pub assets: manifest::ResolvedAssets,
58 65 }
59 66
60 67 impl SeedMedia {
@@ -65,8 +72,20 @@
65 72 s3: None,
66 73 public_s3: None,
67 74 cdn_base_url: None,
75 + assets: manifest::ResolvedAssets::default(),
68 76 }
69 77 }
78 +
79 + /// Load `media-manifest.toml` and fetch everything curated in it.
80 + ///
81 + /// Fails before the seed writes anything when a curated asset will not fetch
82 + /// or does not match its pinned digest, so a broken manifest leaves the
83 + /// existing catalog standing rather than half-replacing it. A manifest with
84 + /// nothing curated yet succeeds and resolves to nothing.
85 + pub async fn with_manifest(mut self) -> Result<Self, manifest::ManifestError> {
86 + self.assets = manifest::Manifest::load()?.resolve().await?;
87 + Ok(self)
88 + }
70 89 }
71 90
72 91 /// Reserved email domain for every seeded example account. It is the marker the
@@ -121,6 +140,11 @@
121 140 /// other seed test asserts, and a stray env var in a dev shell should not be
122 141 /// able to fail them.
123 142 pub harness: Option<harness::HarnessOptions>,
143 + /// Credential for the demo buyer, when the box carries it. `None` skips that
144 + /// phase, and `/library` stays empty of anything to photograph. Carried
145 + /// explicitly for the same reason as `harness`: the buyer adds an account
146 + /// and nine transactions, which every count-asserting test would feel.
147 + pub buyer: Option<buyer::BuyerOptions>,
124 148 }
125 149
126 150 impl SeedOptions {
@@ -131,6 +155,7 @@
131 155 allow_example_seed: std::env::var("ALLOW_EXAMPLE_SEED").ok().as_deref() == Some("1"),
132 156 host_url: host_url.to_string(),
133 157 harness: harness::HarnessOptions::from_env(),
158 + buyer: buyer::BuyerOptions::from_env(),
134 159 }
135 160 }
136 161 }
@@ -175,6 +200,7 @@
175 200
176 201 // Phase 4: blog posts (blog page + RSS) and a follow graph (renders counts).
177 202 blog::seed_blog(pool, &projects).await?;
203 + blog::seed_media_credits(pool, &projects, &media.assets).await?;
178 204 social::seed_social(pool, &projects).await?;
179 205 tracing::warn!("example seed: forum (Phase 5) deferred; refresh-flow swap is Phase 6");
180 206
@@ -191,6 +217,18 @@
191 217 ),
192 218 }
193 219
220 + // Demo buyer: one login-capable account with a purchase history, so the
221 + // landing carousel's third frame (`/library`) has something to photograph.
222 + // Opt-in per box for the same reason as the harness, and after the catalog
223 + // phases because every purchase references an item they created.
224 + match opts.buyer.as_ref() {
225 + Some(buyer_opts) => buyer::seed_buyer(pool, buyer_opts, &projects).await?,
226 + None => tracing::info!(
227 + "example seed: demo-buyer phase skipped ({} must be set)",
228 + buyer::PASSWORD_ENV,
229 + ),
230 + }
231 +
194 232 Ok(())
195 233 }
196 234
@@ -20,6 +20,7 @@
20 20
21 21 use crate::harness::db::TestDb;
22 22 use crate::harness::storage::InMemoryStorage;
23 + use makenotwork::seed::buyer::{self, BUYER_ACCOUNT_ID};
23 24 use makenotwork::seed::{self, SeedMedia, SeedOptions};
24 25 use makenotwork::storage::StorageBackend;
25 26
@@ -34,6 +35,7 @@
34 35 allow_example_seed: true,
35 36 host_url: "https://testnot.work".to_string(),
36 37 harness: None,
38 + buyer: None,
37 39 }
38 40 }
39 41
@@ -291,6 +293,7 @@
291 293 allow_example_seed: true,
292 294 host_url: "https://makenot.work".to_string(),
293 295 harness: None,
296 + buyer: None,
294 297 },
295 298 &SeedMedia::none(),
296 299 )
@@ -381,6 +384,10 @@
381 384 s3: Some(s3),
382 385 public_s3: Some(public),
383 386 cdn_base_url: Some("https://cdn.example.test".to_string()),
387 + // No curated assets: every slot takes its generated placeholder, and the
388 + // test never reaches the network. Resolving the real manifest is a
389 + // separate, network-touching concern (`seed::manifest`).
390 + assets: makenotwork::seed::manifest::ResolvedAssets::default(),
384 391 }
385 392 }
386 393
@@ -630,3 +637,219 @@
630 637 assert_eq!(harness_accounts, 0, "harness phase must be opt-in");
631 638 assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
632 639 }
640 +
641 + // ── Demo buyer (GoingsOn 839a8e5a, option B) ────────────────────────────────
642 + //
643 + // One login-capable account with a purchase history, so the landing carousel's
644 + // third frame has a library to photograph. Opt-in per box, like the harness.
645 +
646 + /// The demo buyer's password for a test run. Not a secret here; on testnot it
647 + /// comes from the box's EnvironmentFile.
648 + const BUYER_PASSWORD: &str = "demo-buyer-test-password";
649 +
650 + /// Purchases seeded for the demo buyer (see `seed::buyer::PURCHASES`).
651 + const BUYER_PURCHASES: i64 = 9;
652 +
653 + fn buyer_opts() -> buyer::BuyerOptions {
654 + buyer::BuyerOptions {
655 + password: BUYER_PASSWORD.to_string(),
656 + }
657 + }
658 +
659 + fn testnot_opts_with_buyer() -> SeedOptions {
660 + SeedOptions {
661 + buyer: Some(buyer_opts()),
662 + ..testnot_opts()
663 + }
664 + }
665 +
666 + async fn buyer_purchase_count(pool: &sqlx::PgPool) -> i64 {
667 + sqlx::query_scalar(
668 + "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND status = 'completed'",
669 + )
670 + .bind(BUYER_ACCOUNT_ID)
671 + .fetch_one(pool)
672 + .await
673 + .expect("count buyer purchases")
674 + }
675 +
676 + #[tokio::test]
677 + async fn demo_buyer_can_log_in_and_owns_a_library() {
678 + let db = TestDb::new().await;
679 + seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx())
680 + .await
681 + .expect("seed with buyer phase");
682 +
683 + // The login the capture run performs.
684 + let hash = password_hash_of(&db.pool, BUYER_ACCOUNT_ID).await;
685 + assert!(
686 + makenotwork::auth::verify_password_async(BUYER_PASSWORD.to_string(), hash)
687 + .await
688 + .expect("verify"),
689 + "the demo buyer's password should verify"
690 + );
691 +
692 + // Not a sandbox account: `SessionUser::check_not_sandbox` would refuse the
693 + // session, and not a creator, because the point is a buyer.
694 + let (is_sandbox, can_create): (bool, bool) =
695 + sqlx::query_as("SELECT is_sandbox, can_create_projects FROM users WHERE id = $1")
696 + .bind(BUYER_ACCOUNT_ID)
697 + .fetch_one(&db.pool)
698 + .await
699 + .expect("buyer account");
700 + assert!(!is_sandbox, "a sandbox account cannot hold a session");
701 + assert!(!can_create, "the demo buyer is a buyer");
702 +
703 + // Every purchase landed, and the library reads them through the `purchases`
704 + // view, which filters on status = 'completed'.
705 + assert_eq!(buyer_purchase_count(&db.pool).await, BUYER_PURCHASES);
706 + let in_view: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM purchases WHERE buyer_id = $1")
707 + .bind(BUYER_ACCOUNT_ID)
708 + .fetch_one(&db.pool)
709 + .await
710 + .expect("purchases view");
711 + assert_eq!(in_view, BUYER_PURCHASES, "every purchase should be visible");
712 +
713 + // But not the whole catalog: a library holding all eleven reads as a
714 + // fixture rather than as somebody's shelf. (The constant-only half of this
715 + // is `seed::buyer`'s own unit test; here it is checked against the items
716 + // actually in the database.)
717 + let unbought = count_example_items(&db.pool).await - BUYER_PURCHASES;
718 + assert!(
719 + unbought > 0,
720 + "the buyer should leave some of the catalog unbought"
721 + );
722 +
723 + // Platform fee is zero on every row. MNW charges 0%, so a demo receipt
724 + // showing anything else would misrepresent the product.
725 + let nonzero_fees: i64 = sqlx::query_scalar(
726 + "SELECT COUNT(*) FROM transactions WHERE buyer_id = $1 AND platform_fee_cents <> 0",
727 + )
728 + .bind(BUYER_ACCOUNT_ID)
729 + .fetch_one(&db.pool)
730 + .await
731 + .expect("fee check");
732 + assert_eq!(nonzero_fees, 0);
733 +
734 + // Paid rows recorded what was paid. `get_user_purchases` derives its Free
735 + // badge from `amount_cents = 0`, so a paid item at zero would badge wrong.
736 + let paid: i64 = sqlx::query_scalar(
737 + "SELECT COUNT(*) FROM transactions t JOIN items i ON i.id = t.item_id \
738 + WHERE t.buyer_id = $1 AND t.amount_cents > 0",
739 + )
740 + .bind(BUYER_ACCOUNT_ID)
741 + .fetch_one(&db.pool)
742 + .await
743 + .expect("paid count");
744 + assert!(
745 + paid >= 4,
746 + "the history should include real payments, got {paid}"
747 + );
748 +
749 + // An active subscription, so the library's subscription block is not empty.
750 + let (tier, status): (String, String) = sqlx::query_as(
751 + "SELECT t.name, s.status FROM subscriptions s \
752 + JOIN subscription_tiers t ON t.id = s.tier_id WHERE s.subscriber_id = $1",
753 + )
754 + .bind(BUYER_ACCOUNT_ID)
755 + .fetch_one(&db.pool)
756 + .await
757 + .expect("buyer subscription");
758 + assert_eq!((tier.as_str(), status.as_str()), ("Patron", "active"));
759 +
760 + // License keys for the project that sells them: one of the things the
761 + // library page shows, and one of the things MNW sells.
762 + let keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM license_keys WHERE owner_id = $1")
763 + .bind(BUYER_ACCOUNT_ID)
764 + .fetch_one(&db.pool)
765 + .await
766 + .expect("license keys");
767 + assert!(keys >= 1, "a license-keyed purchase should carry a key");
768 +
769 + // Most rows are marked downloaded, so the "new version" badge is a signal
770 + // rather than the default state of every row.
771 + let downloaded_items: i64 =
772 + sqlx::query_scalar("SELECT COUNT(DISTINCT item_id) FROM user_downloads WHERE user_id = $1")
773 + .bind(BUYER_ACCOUNT_ID)
774 + .fetch_one(&db.pool)
775 + .await
776 + .expect("downloads");
777 + assert!(
778 + downloaded_items >= 1,
779 + "the buyer should have downloaded something"
780 + );
781 + }
782 +
783 + #[tokio::test]
784 + async fn demo_buyer_history_does_not_duplicate_across_reseeds() {
785 + let db = TestDb::new().await;
786 + seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx())
787 + .await
788 + .expect("first seed");
789 + seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx())
790 + .await
791 + .expect("reseed");
792 +
793 + // The catalog phases get idempotency from the example-data wipe, but this
794 + // account is keyed by a fixed id and is not in that set, so its history has
795 + // to be cleared explicitly. Without that, every reseed doubles the library.
796 + assert_eq!(buyer_purchase_count(&db.pool).await, BUYER_PURCHASES);
797 +
798 + let subs: i64 =
799 + sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE subscriber_id = $1")
800 + .bind(BUYER_ACCOUNT_ID)
801 + .fetch_one(&db.pool)
802 + .await
803 + .expect("subscription count");
804 + assert_eq!(subs, 1, "a reseed must not stack subscriptions");
805 +
806 + // And the id is stable, which is what lets a stored session cookie or a
807 + // scripted login survive a reset.
808 + let id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users WHERE username = $1")
809 + .bind("demo_collector")
810 + .fetch_one(&db.pool)
811 + .await
812 + .expect("buyer id");
813 + assert_eq!(id, BUYER_ACCOUNT_ID);
814 + }
815 +
816 + #[tokio::test]
817 + async fn without_buyer_options_the_phase_does_not_run() {
818 + let db = TestDb::new().await;
819 + seed::run(&db.pool, &testnot_opts(), &media_ctx())
820 + .await
821 + .expect("seed without buyer");
822 +
823 + let accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = $1")
824 + .bind(BUYER_ACCOUNT_ID)
825 + .fetch_one(&db.pool)
826 + .await
827 + .expect("count");
828 + assert_eq!(accounts, 0, "the demo-buyer phase must be opt-in");
829 + assert_eq!(count_example_creators(&db.pool).await, SEEDED_CREATORS);
830 + }
831 +
832 + #[tokio::test]
833 + async fn the_demo_buyer_changes_nothing_a_visitor_can_see() {
834 + let db = TestDb::new().await;
835 + seed::run(&db.pool, &testnot_opts_with_buyer(), &media_ctx())
836 + .await
837 + .expect("seed with buyer phase");
838 +
839 + // Option B's boundary, asserted rather than merely documented: the buyer is
840 + // a capture credential, not a demo surface. It owns no project, so it never
841 + // appears on /discover or /creators, and it publishes nothing.
842 + let owned_projects: i64 =
843 + sqlx::query_scalar("SELECT COUNT(*) FROM projects WHERE user_id = $1")
844 + .bind(BUYER_ACCOUNT_ID)
845 + .fetch_one(&db.pool)
846 + .await
847 + .expect("owned projects");
848 + assert_eq!(owned_projects, 0);
849 +
850 + // The catalog a visitor sees is exactly what it was without the phase.
851 + assert_eq!(
852 + discover_visible_example_slugs(&db.pool).await.len() as i64,
853 + SEEDED_CREATORS
854 + );
855 + }
@@ -1,0 +1,429 @@
1 + #!/usr/bin/env node
2 + //
3 + // Capture the three landing-carousel frames from one run.
4 + //
5 + // The frames have to agree on viewport, scale, theme, crop and aspect ratio.
6 + // Shooting them by hand, or one at a time, is what makes a carousel look wrong,
7 + // so this produces all three from a single browser session and a single set of
8 + // constants. A re-capture after a UI change is one command.
9 + //
10 + // node scripts/capture-landing-carousel.mjs
11 + // BASE=https://testnot.work node scripts/capture-landing-carousel.mjs
12 + //
13 + // Output lands in static/images/shots/. Nothing is swapped into landing.rs
14 + // automatically: the captures are reviewed before they ship (GoingsOn 7f3da540).
15 + //
16 + // SOURCE IS ALWAYS testnot.work. It carries a seeded catalog and no production
17 + // data, so nothing real leaks into a public marketing asset and the shots stay
18 + // reshootable. Never point this at prod or at a dev box.
19 + //
20 + // WHY CDP AND NOT --screenshot. Plain `chrome --screenshot` is viewport-only, so
21 + // a storefront grid taller than the window falls below the fold. Driving the
22 + // DevTools protocol lets each frame be clipped to the same element at the same
23 + // aspect ratio, which is how the three end up agreeing by construction rather
24 + // than by luck. Node 22+ ships a global WebSocket, so this needs no packages.
25 + //
26 + // THE LIBRARY FRAME NEEDS A SESSION. /library is 401 to anonymous, so the run
27 + // logs in as the demo buyer the seed creates (src/seed/buyer.rs, GoingsOn
28 + // 839a8e5a option B). Pass its password, the same value testnot's
29 + // TESTNOT_BUYER_PASSWORD carries:
30 + //
31 + // CAPTURE_BUYER_PASSWORD=... node scripts/capture-landing-carousel.mjs
32 + //
33 + // It logs in through the real form, so the session is a real session and CSRF
34 + // is satisfied by reading the token off the page rather than by faking one.
35 + // CAPTURE_SESSION_COOKIE still works as an escape hatch when you already hold a
36 + // cookie and would rather not put a password in the environment.
37 + //
38 + // With neither, the script captures the first two frames, says plainly that the
39 + // third is missing, and exits non-zero. Two frames out of three is not a
40 + // carousel, and a green exit would say otherwise.
41 +
42 + import { spawn } from 'node:child_process';
43 + import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
44 + import { tmpdir } from 'node:os';
45 + import path from 'node:path';
46 + import process from 'node:process';
47 +
48 + const BASE = (process.env.BASE ?? 'https://testnot.work').replace(/\/+$/, '');
49 + const CHROME = process.env.CHROME ?? `${process.env.HOME}/.local/bin/chrome-for-testing`;
50 + const OUT_DIR = process.env.OUT_DIR ?? path.join(import.meta.dirname, '..', 'static', 'images', 'shots');
51 + const SESSION_COOKIE = process.env.CAPTURE_SESSION_COOKIE ?? '';
52 + const SESSION_COOKIE_NAME = process.env.CAPTURE_SESSION_COOKIE_NAME ?? 'id';
53 + const BUYER_PASSWORD = process.env.CAPTURE_BUYER_PASSWORD ?? '';
54 + // Matches HANDLE in src/seed/buyer.rs. Override only if that changes.
55 + const BUYER_LOGIN = process.env.CAPTURE_BUYER_LOGIN ?? 'demo_collector';
56 + const CAN_AUTH = Boolean(SESSION_COOKIE || BUYER_PASSWORD);
57 +
58 + // Shared frame geometry. Every frame is clipped to these, which is the whole
59 + // point of one script: change a number here and all three move together.
60 + const VIEWPORT = { width: 1440, height: 1600 };
61 + const SCALE = 2; // 2x for a crisp asset on retina displays
62 + const ASPECT = Number(process.env.CAPTURE_ASPECT ?? 16 / 10); // the carousel's frame ratio
63 + // Every frame is cropped to exactly this many CSS pixels wide, then to ASPECT.
64 + // Deriving the width from each page's own container instead produced a 2400px
65 + // storefront next to a 1600px item page: same ratio, different effective zoom,
66 + // so text and controls changed size between slides. A fixed width is what makes
67 + // the three interchangeable.
68 + const CROP_WIDTH = Number(process.env.CAPTURE_WIDTH ?? 1280);
69 + const SETTLE_MS = 1200; // after load + fonts, for lazy images and islands
70 +
71 + // The element each frame is cropped to, unless the frame names its own.
72 + // `.container` is the outer content wrapper on all three pages, so the three
73 + // crops share their left/right margins.
74 + //
75 + // FRAMING IS NOT SETTLED. A trial run on 2026-08-07 showed the storefront's item
76 + // grid falling below a 16:10 crop anchored at the page top, but the page it was
77 + // shot against had placeholder covers and three items in a five-wide grid. Both
78 + // change when the media (GoingsOn ed68814e) and the wider catalog (32f1b8d1)
79 + // land, and re-tuning against the current content would be fitting to a state
80 + // that is about to move. So the knobs are env vars rather than edits: set
81 + // CAPTURE_ASPECT, or a frame's `selector`, once there is real content to judge.
82 + const DEFAULT_SELECTOR = '.container';
83 +
84 + /**
85 + * The three frames, in carousel order. Paths are resolved against BASE.
86 + * `alt` mirrors what landing.rs claims the frame shows; keep them in step.
87 + */
88 + const FRAMES = [
89 + {
90 + name: 'storefront',
91 + path: '/p/restored-reels-vol-1',
92 + alt: "A creator's storefront showing their listed items with prices and cover art",
93 + needsAuth: false,
94 + },
95 + {
96 + name: 'item',
97 + // Item URLs carry a uuid that changes every reseed, so the item frame
98 + // resolves one from the storefront at capture time rather than pinning an
99 + // id that dies on the next seed.
100 + //
101 + // Two shapes are possible and which one appears depends on the visitor.
102 + // `/i/<id>` is the item page proper; `/purchase/<id>` is what a storefront
103 + // links to for an item the visitor cannot access, which on a no-login demo
104 + // is every paid item. Prefer the item page, take the purchase page when
105 + // that is all there is: it carries the price and the buy button the frame's
106 + // alt text promises. Override with CAPTURE_ITEM_PATH.
107 + resolvePath: async (send) => {
108 + if (process.env.CAPTURE_ITEM_PATH) return process.env.CAPTURE_ITEM_PATH;
109 + const { result } = await send('Runtime.evaluate', {
110 + expression: `(() => {
111 + const pick = (sel) => {
112 + const a = document.querySelector(sel);
113 + return a ? new URL(a.href).pathname : '';
114 + };
115 + return pick('a[href^="/i/"]') || pick('a[href^="/purchase/"]');
116 + })()`,
117 + returnByValue: true,
118 + });
119 + return result.value;
120 + },
121 + from: '/p/restored-reels-vol-1',
122 + alt: 'An item page with its price, buy button, and download details',
123 + needsAuth: false,
124 + },
125 + {
126 + name: 'library',
127 + path: '/library',
128 + alt: 'A buyer library listing the files they have purchased, ready to download',
129 + needsAuth: true,
130 + },
131 + ];
132 +
133 + /** Wait, without pulling in a timers import at every call site. */
134 + const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
135 +
136 + /** Launch headless Chrome and return its DevTools browser WebSocket URL. */
137 + async function launchChrome(userDataDir) {
138 + const child = spawn(
139 + CHROME,
140 + [
141 + '--headless',
142 + '--disable-gpu',
143 + '--hide-scrollbars',
144 + '--no-sandbox',
145 + '--no-first-run',
146 + '--remote-debugging-port=0',
147 + `--user-data-dir=${userDataDir}`,
148 + '--force-color-profile=srgb',
149 + 'about:blank',
150 + ],
151 + { stdio: ['ignore', 'ignore', 'pipe'] },
152 + );
153 +
154 + const wsUrl = await new Promise((resolve, reject) => {
155 + let buffered = '';
156 + const timer = setTimeout(
157 + () => reject(new Error(`Chrome did not report a DevTools endpoint.\n${buffered}`)),
158 + 20_000,
159 + );
160 + child.stderr.on('data', (chunk) => {
161 + buffered += chunk;
162 + const match = buffered.match(/DevTools listening on (ws:\/\/\S+)/);
163 + if (match) {
164 + clearTimeout(timer);
165 + resolve(match[1]);
166 + }
167 + });
168 + child.on('exit', (code) => {
169 + clearTimeout(timer);
170 + reject(new Error(`Chrome exited (${code}) before listening.\n${buffered}`));
171 + });
172 + });
173 +
174 + return { child, wsUrl };
175 + }
176 +
177 + /**
178 + * A minimal CDP client: id-tagged requests over one socket, flat-mode sessions.
179 + */
180 + async function connect(wsUrl) {
181 + const socket = new WebSocket(wsUrl);
182 + await new Promise((resolve, reject) => {
183 + socket.addEventListener('open', resolve, { once: true });
184 + socket.addEventListener('error', () => reject(new Error(`cannot connect to ${wsUrl}`)), {
185 + once: true,
186 + });
187 + });
188 +
189 + let nextId = 1;
190 + const pending = new Map();
191 + const listeners = new Set();
192 +
193 + socket.addEventListener('message', (event) => {
194 + const message = JSON.parse(event.data);
195 + if (message.id && pending.has(message.id)) {
196 + const { resolve, reject } = pending.get(message.id);
197 + pending.delete(message.id);
198 + if (message.error) reject(new Error(`${message.error.message} (${message.error.code})`));
199 + else resolve(message.result);
200 + return;
201 + }
202 + for (const listener of listeners) listener(message);
203 + });
204 +
205 + const send = (method, params = {}, sessionId) =>
206 + new Promise((resolve, reject) => {
207 + const id = nextId++;
208 + pending.set(id, { resolve, reject });
209 + socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
210 + });
211 +
212 + const once = (method, sessionId, timeoutMs = 30_000) =>
213 + new Promise((resolve, reject) => {
214 + const timer = setTimeout(() => {
215 + listeners.delete(listener);
216 + reject(new Error(`timed out waiting for ${method}`));
217 + }, timeoutMs);
218 + const listener = (message) => {
219 + if (message.method === method && (!sessionId || message.sessionId === sessionId)) {
220 + clearTimeout(timer);
221 + listeners.delete(listener);
222 + resolve(message.params);
223 + }
224 + };
225 + listeners.add(listener);
226 + });
227 +
228 + return { socket, send, once };
229 + }
230 +
231 + /**
232 + * Sign in as the demo buyer through the real login form.
233 + *
234 + * Submitting the page's own form would go through htmx, which swaps the
235 + * response into an error div instead of navigating. So this reads the CSRF
236 + * token and field names off that form and posts a plain detached copy, which
237 + * the handler treats as a full-page POST and answers with a redirect. Reading
238 + * the token rather than minting one is the point: the session that results is
239 + * the same session a person would get.
240 + *
241 + * Returns null on success, or a string saying what went wrong.
242 + */
243 + async function logIn(call, awaitLoad) {
244 + await call('Page.navigate', { url: `${BASE}/login` });
245 + await awaitLoad();
246 +
247 + const submitted = await call('Runtime.evaluate', {
248 + expression: `(() => {
249 + const src = document.querySelector('form.login-form');
250 + if (!src) return 'no login form at /login';
251 + const form = document.createElement('form');
252 + form.method = 'post';
253 + form.action = '/login';
254 + const put = (name, value) => {
255 + const input = document.createElement('input');
256 + input.type = 'hidden';
257 + input.name = name;
258 + input.value = value;
259 + form.appendChild(input);
260 + };
261 + // Carry every hidden field the real form has (the CSRF token above all),
262 + // then set the credentials.
263 + for (const [name, value] of new FormData(src).entries()) {
264 + if (name !== 'login' && name !== 'password') put(name, value);
265 + }
266 + put('login', ${JSON.stringify(BUYER_LOGIN)});
267 + put('password', ${JSON.stringify(BUYER_PASSWORD)});
268 + document.body.appendChild(form);
269 + form.submit();
270 + return '';
271 + })()`,
272 + returnByValue: true,
273 + });
274 + if (submitted.result.value) return submitted.result.value;
275 + await awaitLoad();
276 +
277 + // Confirm rather than assume: a wrong password re-renders the login form with
278 + // a 200, so status alone proves nothing.
279 + const check = await call('Runtime.evaluate', {
280 + expression: `document.querySelector('form.login-form') ? 'rejected' : ''`,
281 + returnByValue: true,
282 + });
283 + return check.result.value ? 'credentials rejected' : null;
284 + }
285 +
286 + /**
287 + * Clip rectangle for a frame: CROP_WIDTH x (CROP_WIDTH / ASPECT), centred
288 + * horizontally on the element and anchored at its top, because the top of these
289 + * pages is the part worth showing. Every frame gets the same rectangle, which is
290 + * what lets the carousel cross-fade without anything appearing to resize.
291 + */
292 + function clipFor(box) {
293 + const width = CROP_WIDTH;
294 + const height = width / ASPECT;
295 + return { x: Math.max(0, box.x + (box.width - width) / 2), y: box.y, width, height };
296 + }
297 +
298 + async function main() {
299 + const userDataDir = await mkdtemp(path.join(tmpdir(), 'mnw-capture-'));
300 + await mkdir(OUT_DIR, { recursive: true });
301 + const { child, wsUrl } = await launchChrome(userDataDir);
302 + const { socket, send, once } = await connect(wsUrl);
303 +
304 + const missing = [];
305 + try {
306 + const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
307 + const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
308 + const call = (method, params) => send(method, params, sessionId);
309 +
310 + await call('Page.enable');
311 + await call('Network.enable');
312 + await call('Emulation.setDeviceMetricsOverride', {
313 + width: VIEWPORT.width,
314 + height: VIEWPORT.height,
315 + deviceScaleFactor: SCALE,
316 + mobile: false,
317 + });
318 +
319 + if (SESSION_COOKIE) {
320 + const { host } = new URL(BASE);
321 + await call('Network.setCookie', {
322 + name: SESSION_COOKIE_NAME,
323 + value: SESSION_COOKIE,
324 + domain: host,
325 + path: '/',
326 + httpOnly: true,
327 + secure: BASE.startsWith('https'),
328 + });
329 + }
330 +
331 + if (BUYER_PASSWORD) {
332 + const failure = await logIn(call, () => once('Page.loadEventFired', sessionId));
333 + if (failure) {
334 + console.error(` login: ${failure}`);
335 + console.error(' the library frame will be skipped');
336 + } else {
337 + console.error(` login: signed in as ${BUYER_LOGIN}`);
338 + }
339 + }
340 +
341 + for (const frame of FRAMES) {
342 + if (frame.needsAuth && !CAN_AUTH) {
343 + console.error(
344 + ` ${frame.name}: skipped, needs CAPTURE_BUYER_PASSWORD (or CAPTURE_SESSION_COOKIE)`,
345 + );
346 + missing.push(frame.name);
347 + continue;
348 + }
349 +
350 + // Resolve the URL, navigating to a source page first when the frame's
351 + // target is only discoverable from one.
352 + let target = frame.path;
353 + if (frame.resolvePath) {
354 + await call('Page.navigate', { url: BASE + frame.from });
355 + await once('Page.loadEventFired', sessionId);
356 + target = await frame.resolvePath(call);
357 + if (!target) {
358 + console.error(` ${frame.name}: skipped, no item link on ${frame.from}`);
359 + missing.push(frame.name);
360 + continue;
361 + }
362 + }
363 +
364 + const url = BASE + target;
365 + await call('Page.navigate', { url });
366 + await once('Page.loadEventFired', sessionId);
367 + await call('Runtime.evaluate', {
368 + expression: 'document.fonts.ready.then(() => true)',
369 + awaitPromise: true,
370 + });
371 + await sleep(SETTLE_MS);
372 +
373 + const selector = frame.selector ?? DEFAULT_SELECTOR;
374 + const { result } = await call('Runtime.evaluate', {
375 + expression: `(() => {
376 + const el = document.querySelector(${JSON.stringify(selector)});
377 + if (!el) return null;
378 + const r = el.getBoundingClientRect();
379 + return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height };
380 + })()`,
381 + returnByValue: true,
382 + });
383 + if (!result.value) {
384 + console.error(` ${frame.name}: skipped, ${selector} not present at ${url}`);
385 + missing.push(frame.name);
386 + continue;
387 + }
388 +
389 + const clip = { ...clipFor(result.value), scale: SCALE };
390 + const shot = await call('Page.captureScreenshot', {
391 + format: 'png',
392 + clip,
393 + captureBeyondViewport: true,
394 + fromSurface: true,
395 + });
396 + const file = path.join(OUT_DIR, `${frame.name}.png`);
397 + const bytes = Buffer.from(shot.data, 'base64');
398 + await writeFile(file, bytes);
399 + console.error(
400 + ` ${frame.name}: ${Math.round(clip.width * SCALE)}x${Math.round(clip.height * SCALE)}, ` +
401 + `${Math.round(bytes.length / 1024)}KB -> ${path.relative(process.cwd(), file)}`,
402 + );
403 + }
404 + } finally {
405 + socket.close();
406 + // Wait for Chrome to actually exit before removing its profile: kill() only
407 + // sends the signal, and a still-flushing process repopulates the directory
408 + // under rm(). A leftover temp profile is not worth failing a good capture
409 + // over, so cleanup errors are swallowed.
410 + const exited = new Promise((resolve) => child.once('exit', resolve));
411 + child.kill();
412 + await Promise.race([exited, sleep(5_000)]);
413 + await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
414 + }
415 +
416 + if (missing.length > 0) {
417 + console.error(
418 + `\n${missing.length} of ${FRAMES.length} frames missing (${missing.join(', ')}). ` +
419 + 'The carousel needs all three; nothing has been swapped into landing.rs.',
420 + );
421 + process.exit(1);
422 + }
423 +
424 + console.error(`\nAll ${FRAMES.length} frames captured from ${BASE}.`);
425 + console.error('Next: review them (GoingsOn 7f3da540), then point landing.rs at the .png files');
426 + console.error('and delete static/images/shots/placeholder-*.svg.');
427 + }
428 +
429 + await main();
@@ -1,0 +1,545 @@
1 + //! The demo buyer: one login-capable account with a purchase history, so
2 + //! `/library` can be photographed.
3 + //!
4 + //! `/library` is 401 to anonymous and every other seeded account is a creator
5 + //! with nothing bought, so a third of the pitch (buyers keep what they bought,
6 + //! one-click export) could not be shown to anyone. This account exists to close
7 + //! that, and nothing more.
8 + //!
9 + //! # Scope, decided 2026-08-05 (GoingsOn 839a8e5a)
10 + //!
11 + //! Two ways in were on the table. **A**: a visitor-facing temp-account path on
12 + //! testnot, which also retires the prod `/sandbox` funnel (Phase 8-9 of
13 + //! `_private/docs/mnw/testnot-example-seed.md`). **B**: a capture-only credential
14 + //! used by the screenshot run and nobody else. B was chosen to unblock the
15 + //! landing carousel, with A to follow on its own schedule.
16 + //!
17 + //! So this is B, and the boundary matters: **nothing on the public demo
18 + //! changes**. No login CTA, no temp-account endpoint, no `ALLOW_TEMP_ACCOUNTS`,
19 + //! and the account is linked from nowhere. An anonymous visitor to testnot still
20 + //! cannot reach `/library`. That is a known, deliberate gap and A is what closes
21 + //! it.
22 + //!
23 + //! # Why it is opt-in, and why it lives in the seed
24 + //!
25 + //! Same two reasons as [`super::harness`]. The password is a credential and does
26 + //! not belong in a public repo, so the phase runs only when [`PASSWORD_ENV`] is
27 + //! set and skips with a warning otherwise. And `mnw-testnot-seed.sh` drops every
28 + //! schema before it reseeds, so an account created by hand is gone on the next
29 + //! reset with nothing in the diff to explain why the capture run started failing
30 + //! at login.
31 + //!
32 + //! The prod guards in [`super::run`] run first: this is unreachable without
33 + //! `ALLOW_EXAMPLE_SEED=1` on an approved host with no real accounts present.
34 +
35 + use chrono::{DateTime, Duration, Utc};
36 + use uuid::Uuid;
37 +
38 + use super::projects::SeededProject;
39 + use super::{EXAMPLE_EMAIL_DOMAIN, SeedError};
40 + use crate::auth;
41 + use crate::db::{self, ItemId};
42 +
43 + /// Env var holding the demo buyer's password. Set in testnot's `EnvironmentFile`
44 + /// alongside the other box secrets, never in this repo.
45 + pub const PASSWORD_ENV: &str = "TESTNOT_BUYER_PASSWORD";
46 +
47 + /// Fixed id, for the same reason the harness accounts have one: a reseed has to
48 + /// reproduce the same account rather than a new one each time.
49 + pub const BUYER_ACCOUNT_ID: Uuid = Uuid::from_u128(0x0000_0000_0000_0000_0000_0000_0000_b001);
50 +
51 + /// Login handle, and the local part of `{handle}@example.test`.
52 + const HANDLE: &str = "demo_collector";
53 +
54 + /// Shown on the profile and in the header while the capture runs.
55 + const DISPLAY_NAME: &str = "Demo Collector";
56 +
57 + /// One purchase in the demo buyer's history.
58 + struct PurchaseSpec {
59 + /// Item title, matched against the seeded catalog. Titles are unique within
60 + /// a project and, across this roster, unique overall.
61 + title: &'static str,
62 + /// Days before the seed run to date the purchase. Spread on purpose: a
63 + /// library where every row says the same timestamp reads as a fixture, and
64 + /// the list is ordered by date, so the spread is what gives it a shape.
65 + days_ago: i64,
66 + /// Cents paid above the pay-what-you-want minimum. Ignored for fixed-price
67 + /// and free items. A buyer who always pays exactly the floor is a buyer
68 + /// nobody recognises.
69 + tip_cents: i32,
70 + /// Whether the buyer has already downloaded the current version. `false`
71 + /// leaves the "new version" badge lit, which is worth showing on one or two
72 + /// rows and noise on all of them.
73 + downloaded: bool,
74 + }
75 +
76 + /// Nine of the eleven seeded items, spanning every purchasable type.
77 + ///
78 + /// Not all eleven: a library holding the entire catalog reads as seeded data
79 + /// rather than as somebody's shelf. "Weekly-Review Template" and "Typesetting
80 + /// the Commons" are deliberately left unbought.
81 + const PURCHASES: &[PurchaseSpec] = &[
82 + PurchaseSpec {
83 + title: "Restoration No. 1 (Full Mix)",
84 + days_ago: 2,
85 + tip_cents: 400,
86 + downloaded: false,
87 + },
88 + PurchaseSpec {
89 + title: "Field Study 01 (Print)",
90 + days_ago: 5,
91 + tip_cents: 0,
92 + downloaded: true,
93 + },
94 + PurchaseSpec {
95 + title: "Deskriver Focus (Plugin)",
96 + days_ago: 11,
97 + tip_cents: 0,
98 + downloaded: false,
99 + },
100 + PurchaseSpec {
101 + title: "Stem Pack: Strings",
102 + days_ago: 19,
103 + tip_cents: 300,
104 + downloaded: true,
105 + },
106 + PurchaseSpec {
107 + title: "On Slow Reading",
108 + days_ago: 24,
109 + tip_cents: 0,
110 + downloaded: true,
111 + },
112 + PurchaseSpec {
113 + title: "Deskriver Utility (Download)",
114 + days_ago: 38,
115 + tip_cents: 0,
116 + downloaded: true,
117 + },
118 + PurchaseSpec {
119 + title: "Session Take (Video)",
120 + days_ago: 52,
121 + tip_cents: 150,
122 + downloaded: true,
123 + },
124 + PurchaseSpec {
125 + title: "Community Bundle Vol. 1",
126 + days_ago: 66,
127 + tip_cents: 0,
128 + downloaded: true,
129 + },
130 + PurchaseSpec {
131 + title: "Minimal Preset Pack",
132 + days_ago: 91,
133 + tip_cents: 0,
134 + downloaded: true,
135 + },
136 + ];
137 +
138 + /// The subscription the buyer holds. Marginalia is the roster's one subscription
139 + /// project, and the library renders subscriptions beside purchases, so without
140 + /// this the frame shows half of what the page does.
141 + const SUBSCRIBED_PROJECT_SLUG: &str = "the-marginalia-reader";
142 +
143 + /// Which tier. The middle one: the cheapest reads as a trial and the dearest as
144 + /// a plant.
145 + const SUBSCRIBED_TIER_NAME: &str = "Patron";
146 +
147 + /// The one value the phase needs from the environment.
148 + #[derive(Clone)]
149 + pub struct BuyerOptions {
150 + /// The demo buyer's password, used by the capture run to log in.
151 + pub password: String,
152 + }
153 +
154 + /// Hand-written so the password cannot reach a log through
155 + /// [`super::SeedOptions`]'s derived `Debug`.
156 + impl std::fmt::Debug for BuyerOptions {
157 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 + f.debug_struct("BuyerOptions")
159 + .field("password", &"<redacted>")
160 + .finish()
161 + }
162 + }
163 +
164 + impl BuyerOptions {
165 + /// Read the password from the environment; `None` when unset or empty, which
166 + /// is the signal to skip the phase.
167 + pub fn from_env() -> Option<Self> {
168 + let password = std::env::var(PASSWORD_ENV).ok().filter(|s| !s.is_empty())?;
169 + Some(Self { password })
170 + }
171 + }
172 +
173 + /// Seed the demo buyer, their purchase history, and their subscription.
174 + ///
175 + /// Called from [`super::run`] after the catalog phases, because every purchase
176 + /// references an item those phases created. Re-runnable: the account is an
177 + /// upsert on its fixed id, and the rows hanging off it are rebuilt from scratch
178 + /// each run so a reseed cannot accumulate duplicates.
179 + pub async fn seed_buyer(
180 + pool: &sqlx::PgPool,
181 + opts: &BuyerOptions,
182 + projects: &[SeededProject],
183 + ) -> Result<(), SeedError> {
184 + let password_hash = auth::hash_password_async(opts.password.clone()).await?;
185 + seed_account(pool, &password_hash).await?;
186 + clear_prior_history(pool).await?;
187 +
188 + let mut bought = 0;
189 + for project in projects {
190 + let items = db::items::get_items_by_project(pool, project.project.id).await?;
191 + for item in &items {
192 + let Some(spec) = PURCHASES.iter().find(|p| p.title == item.title) else {
193 + continue;
194 + };
195 + let purchased_at = Utc::now() - Duration::days(spec.days_ago);
196 + let amount_cents = amount_for(item, spec);
197 + let transaction_id =
198 + record_purchase(pool, project, item, amount_cents, purchased_at).await?;
199 + if project.spec.features.contains(&"license_keys") {
200 + issue_license_key(pool, item.id, transaction_id, spec.days_ago).await?;
201 + }
202 + if spec.downloaded {
203 + record_download(pool, item.id, purchased_at).await?;
204 + }
205 + bought += 1;
206 + }
207 + }
208 +
209 + if bought != PURCHASES.len() {
210 + // A renamed item silently drops a row from the library, and the frame
211 + // just looks thin. Say so instead.
212 + tracing::warn!(
213 + matched = bought,
214 + expected = PURCHASES.len(),
215 + "example seed: some demo-buyer purchases matched no item; check the titles in buyer.rs"
216 + );
217 + }
218 +
219 + seed_subscription(pool, projects).await?;
220 +
221 + tracing::warn!(
222 + user_id = %BUYER_ACCOUNT_ID,
223 + handle = HANDLE,
224 + purchases = bought,
225 + "example seed: demo buyer seeded (login-capable, for the capture run only)"
226 + );
227 + Ok(())
228 + }
229 +
230 + /// What the buyer paid: the fixed price, or the pay-what-you-want floor plus the
231 + /// spec's tip, or nothing for a free item.
232 + ///
233 + /// Reading it off the item rather than hardcoding it keeps the library's badges
234 + /// honest. `get_user_purchases` derives its Free badge from `amount_cents = 0`,
235 + /// so a paid item recorded at zero would badge wrong.
236 + fn amount_for(item: &db::DbItem, spec: &PurchaseSpec) -> i32 {
237 + if item.pwyw_enabled {
238 + return item.pwyw_min_cents.unwrap_or(0) + spec.tip_cents;
239 + }
240 + item.price_cents
241 + }
242 +
243 + /// Insert (or reset) the buyer account at its fixed id.
244 + ///
245 + /// `is_sandbox` stays FALSE: a sandbox account is refused by
246 + /// `SessionUser::check_not_sandbox`, and this one has to hold a real session.
247 + /// `can_create_projects` stays FALSE, because the whole point is a buyer.
248 + async fn seed_account(pool: &sqlx::PgPool, password_hash: &str) -> Result<(), SeedError> {
249 + let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-"));
250 + sqlx::query(
251 + r"
252 + INSERT INTO users (
253 + id, username, email, password_hash, display_name,
254 + can_create_projects, email_verified
255 + )
256 + VALUES ($1, $2, $3, $4, $5, FALSE, TRUE)
257 + ON CONFLICT (id) DO UPDATE SET
258 + username = EXCLUDED.username,
259 + email = EXCLUDED.email,
260 + password_hash = EXCLUDED.password_hash,
261 + display_name = EXCLUDED.display_name,
262 + -- A capture run that tripped the lockout must not survive the
263 + -- reseed: the point of the reset is a known state.
264 + failed_login_attempts = 0,
265 + locked_until = NULL,
266 + suspended_at = NULL,
267 + deactivated_at = NULL
268 + ",
269 + )
270 + .bind(BUYER_ACCOUNT_ID)
271 + .bind(HANDLE)
272 + .bind(&email)
273 + .bind(password_hash)
274 + .bind(DISPLAY_NAME)
275 + .execute(pool)
276 + .await?;
277 + Ok(())
278 + }
279 +
280 + /// Drop everything hanging off the buyer from a previous run.
281 + ///
282 + /// The catalog phases get idempotency from the example-data wipe, which deletes
283 + /// the creators and cascades their items. This account is not in that set (it is
284 + /// keyed by id, not created per run), so its purchases would otherwise survive a
285 + /// reseed pointing at items that no longer exist.
286 + async fn clear_prior_history(pool: &sqlx::PgPool) -> Result<(), SeedError> {
287 + // license_keys and user_downloads cascade from transactions and items
288 + // respectively, but the buyer's own rows are keyed by owner/user, so clear
289 + // them explicitly rather than relying on which side of the join went first.
290 + for statement in [
291 + "DELETE FROM user_downloads WHERE user_id = $1",
292 + "DELETE FROM license_keys WHERE owner_id = $1",
293 + "DELETE FROM subscriptions WHERE subscriber_id = $1",
294 + "DELETE FROM transactions WHERE buyer_id = $1",
295 + ] {
296 + sqlx::query(statement)
297 + .bind(BUYER_ACCOUNT_ID)
298 + .execute(pool)
299 + .await?;
300 + }
301 + Ok(())
302 + }
303 +
304 + /// Record one completed transaction, which is what the `purchases` view reads.
305 + ///
306 + /// `platform_fee_cents` is zero and that is not a placeholder: MNW's platform
307 + /// fee is 0%, so a demo receipt showing anything else would misrepresent the
308 + /// product. The Stripe ids are fabricated and marked `demo_`; nothing on testnot
309 + /// talks to live Stripe, and the prefix makes a stray row obvious.
310 + ///
311 + /// The currency comes off the seller rather than being hardcoded. `transactions`
312 + /// constrains it to a lowercase supported code, and the real payment path
313 + /// settles in the seller's currency, so reading it keeps a demo receipt true to
314 + /// what a live one would say if a seeded creator is ever given a non-USD
315 + /// settlement currency.
316 + async fn record_purchase(
317 + pool: &sqlx::PgPool,
318 + project: &SeededProject,
319 + item: &db::DbItem,
320 + amount_cents: i32,
321 + purchased_at: DateTime<Utc>,
322 + ) -> Result<Uuid, SeedError> {
323 + let (seller_username, currency): (String, String) = sqlx::query_as(
324 + "SELECT username, lower(settlement_currency::text) FROM users WHERE id = $1",
325 + )
326 + .bind(project.user_id)
327 + .fetch_one(pool)
328 + .await?;
329 +
330 + let transaction_id: Uuid = sqlx::query_scalar(
331 + r"
332 + INSERT INTO transactions (
333 + buyer_id, seller_id, item_id, amount_cents, platform_fee_cents,
334 + currency, status, stripe_payment_intent_id,
335 + created_at, completed_at, item_title, seller_username
336 + )
337 + VALUES ($1, $2, $3, $4, 0, $5, 'completed', $6, $7, $7, $8, $9)
338 + RETURNING id
339 + ",
340 + )
341 + .bind(BUYER_ACCOUNT_ID)
342 + .bind(project.user_id)
343 + .bind(item.id)
344 + .bind(amount_cents)
345 + .bind(&currency)
346 + .bind(format!("pi_demo_{}", item.id))
347 + .bind(purchased_at)
348 + .bind(&item.title)
349 + .bind(&seller_username)
350 + .fetch_one(pool)
351 + .await?;
352 +
353 + Ok(transaction_id)
354 + }
355 +
356 + /// Issue a license key for a purchase from a project that sells them.
357 + ///
358 + /// License keys are one of the things the library page shows and one of the
359 + /// things MNW sells, so the frame is worth more with one visible. The code shape
360 + /// mirrors the real generator's grouping without reusing it: this is display
361 + /// data on a demo box, not a key anything validates.
362 + async fn issue_license_key(
363 + pool: &sqlx::PgPool,
364 + item_id: ItemId,
365 + transaction_id: Uuid,
366 + days_ago: i64,
367 + ) -> Result<(), SeedError> {
368 + // Derived from the item id so a reseed of the same catalog produces the same
369 + // key, and no two items collide on the UNIQUE constraint.
370 + let raw = item_id.as_uuid().simple().to_string().to_uppercase();
371 + let key_code = format!("DEMO-{}-{}-{}", &raw[0..4], &raw[4..8], &raw[8..12]);
372 + sqlx::query(
373 + r"
374 + INSERT INTO license_keys (
375 + item_id, owner_id, transaction_id, key_code, max_activations, created_at
376 + )
377 + VALUES ($1, $2, $3, $4, 3, $5)
378 + ON CONFLICT (key_code) DO NOTHING
379 + ",
380 + )
381 + .bind(item_id)
382 + .bind(BUYER_ACCOUNT_ID)
383 + .bind(transaction_id)
384 + .bind(&key_code)
385 + .bind(Utc::now() - Duration::days(days_ago))
386 + .execute(pool)
387 + .await?;
388 + Ok(())
389 + }
390 +
391 + /// Mark every current version of an item as already downloaded.
392 + ///
393 + /// `get_user_purchases` lights its "new version" badge when the item has more
394 + /// versions than the buyer has downloads, so this is what turns the badge off.
395 + /// Leaving it on for a row or two is the point; leaving it on for all nine would
396 + /// read as a broken library rather than a used one.
397 + async fn record_download(
398 + pool: &sqlx::PgPool,
399 + item_id: ItemId,
400 + downloaded_at: DateTime<Utc>,
401 + ) -> Result<(), SeedError> {
402 + sqlx::query(
403 + r"
404 + INSERT INTO user_downloads (user_id, item_id, version_id, downloaded_at)
405 + SELECT $1, $2, v.id, $3
406 + FROM versions v
407 + WHERE v.item_id = $2 AND v.s3_key IS NOT NULL
408 + ON CONFLICT DO NOTHING
409 + ",
410 + )
411 + .bind(BUYER_ACCOUNT_ID)
412 + .bind(item_id)
413 + .bind(downloaded_at)
414 + .execute(pool)
415 + .await?;
416 + Ok(())
417 + }
418 +
419 + /// Give the buyer an active subscription to the roster's subscription project.
420 + ///
421 + /// `get_user_subscriptions_with_details` joins the tier and the project and
422 + /// filters on nothing but the subscriber, so an `active` row with a future
423 + /// `current_period_end` is the whole requirement. No Stripe call: the ids are
424 + /// fabricated and prefixed, as with the purchases.
425 + async fn seed_subscription(
426 + pool: &sqlx::PgPool,
427 + projects: &[SeededProject],
428 + ) -> Result<(), SeedError> {
429 + let Some(project) = projects
430 + .iter()
431 + .find(|p| p.spec.slug == SUBSCRIBED_PROJECT_SLUG)
432 + else {
433 + tracing::warn!(
434 + slug = SUBSCRIBED_PROJECT_SLUG,
435 + "example seed: subscription project missing; demo buyer has no subscription"
436 + );
437 + return Ok(());
438 + };
439 +
440 + let tier_id: Option<Uuid> =
441 + sqlx::query_scalar("SELECT id FROM subscription_tiers WHERE project_id = $1 AND name = $2")
442 + .bind(project.project.id)
443 + .bind(SUBSCRIBED_TIER_NAME)
444 + .fetch_optional(pool)
445 + .await?;
446 + let Some(tier_id) = tier_id else {
447 + tracing::warn!(
448 + tier = SUBSCRIBED_TIER_NAME,
449 + "example seed: subscription tier missing; demo buyer has no subscription"
450 + );
451 + return Ok(());
452 + };
453 +
454 + // Started three months back, renewing in a fortnight: a subscription that is
455 + // established rather than brand new, and visibly current.
456 + let started = Utc::now() - Duration::days(92);
457 + let period_start = Utc::now() - Duration::days(16);
458 + let period_end = Utc::now() + Duration::days(14);
459 + sqlx::query(
460 + r"
461 + INSERT INTO subscriptions (
462 + subscriber_id, tier_id, project_id, stripe_subscription_id,
463 + stripe_customer_id, status, current_period_start, current_period_end,
464 + created_at
465 + )
466 + VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8)
467 + ",
468 + )
469 + .bind(BUYER_ACCOUNT_ID)
470 + .bind(tier_id)
471 + .bind(project.project.id)
472 + .bind(format!("sub_demo_{BUYER_ACCOUNT_ID}"))
473 + .bind(format!("cus_demo_{BUYER_ACCOUNT_ID}"))
474 + .bind(period_start)
475 + .bind(period_end)
476 + .bind(started)
477 + .execute(pool)
478 + .await?;
479 + Ok(())
480 + }
481 +
482 + #[cfg(test)]
483 + mod tests {
484 + use super::*;
485 +
486 + #[test]
487 + fn buyer_email_stays_inside_the_reserved_domain() {
488 + // The seed's reset only deletes @example.test accounts, and its third
489 + // guard refuses to run at all when a non-example account exists. A
490 + // handle producing an address outside the domain would both survive
491 + // resets and block the next seed.
492 + let email = format!("{}@{EXAMPLE_EMAIL_DOMAIN}", HANDLE.replace('_', "-"));
493 + assert!(email.ends_with("@example.test"), "{email}");
494 + }
495 +
496 + #[test]
497 + fn the_buyer_does_not_own_the_whole_catalog() {
498 + // Eleven items are seeded. A library holding all of them reads as a
499 + // fixture; this is the assertion that keeps someone from "fixing" the
500 + // gap by adding the last two.
Lines truncated
@@ -1,0 +1,448 @@
1 + //! Real public-domain / CC0 media for the example seed: the manifest that names
2 + //! each asset, and the fetch step that turns it into bytes.
3 + //!
4 + //! The seed's other phases are pure data in `creators.rs`. Media cannot be,
5 + //! because a real asset is a file somebody has to choose, license-check, and
6 + //! host. So the choosing lives in `media-manifest.toml` (curated by hand, checked
7 + //! into the repo, embedded in the binary) and everything downstream of it is
8 + //! mechanical: fetch by URL, verify the digest, upload through the storage layer,
9 + //! record the attribution.
10 + //!
11 + //! # Curation state is per asset, not per manifest
12 + //!
13 + //! An entry with no `url` is *declared but not yet curated*. It resolves to
14 + //! nothing and [`super::media`] falls back to the generated placeholder for that
15 + //! one slot. So the manifest doubles as the curation checklist: every id the seed
16 + //! can use is listed, and the ones still carrying grey boxes are exactly the ones
17 + //! with an empty `url`. Partial curation is a first-class state, and the box
18 + //! stays seedable throughout.
19 + //!
20 + //! # Failure is loud, and it happens before any write
21 + //!
22 + //! A curated asset that will not fetch, or that fetches to the wrong bytes, fails
23 + //! the whole seed. It does not silently degrade to the placeholder: that is the
24 + //! failure mode `sando/deploy/mnw-testnot-smoke.sh` exists to catch, and a demo
25 + //! box that quietly reverts to grey squares is worse than one that refuses to
26 + //! reseed. Resolution runs to completion before the seed touches the database, so
27 + //! a failure leaves the previous catalog standing.
28 +
29 + use std::collections::HashMap;
30 + use std::path::PathBuf;
31 + use std::time::Duration;
32 +
33 + use serde::Deserialize;
34 +
35 + /// The curated manifest, embedded so a deployed binary carries it. Override with
36 + /// [`MANIFEST_PATH_ENV`] when iterating locally.
37 + const EMBEDDED_MANIFEST: &str = include_str!("media-manifest.toml");
38 +
39 + /// Path to a manifest file to read instead of the embedded copy.
40 + pub const MANIFEST_PATH_ENV: &str = "SEED_MEDIA_MANIFEST";
41 +
42 + /// Directory holding fetched assets between runs. Defaults to
43 + /// `{temp_dir}/mnw-seed-media`.
44 + pub const CACHE_DIR_ENV: &str = "SEED_MEDIA_CACHE";
45 +
46 + /// How long a single asset fetch may take.
47 + const FETCH_TIMEOUT: Duration = Duration::from_mins(1);
48 +
49 + /// Refuse an asset larger than this. Demo media, not a distribution channel; a
50 + /// multi-gigabyte URL in the manifest is a mistake, not a big file.
51 + const MAX_ASSET_BYTES: u64 = 64 * 1024 * 1024;
52 +
53 + /// One curated file: where it comes from, what it is, and who to credit.
54 + #[derive(Debug, Clone, Deserialize)]
55 + pub struct Asset {
56 + /// Stable id referenced from `creators.rs` (`ItemSpec::media`,
57 + /// `ItemSpec::cover`, `ProjectSpec::cover`).
58 + pub id: String,
59 + /// Direct download URL. Absent = declared but not yet curated; the slot keeps
60 + /// its generated placeholder.
61 + #[serde(default)]
62 + pub url: Option<String>,
63 + /// Lowercase hex SHA-256 of the fetched bytes. Absent on a curated asset is
64 + /// allowed but warned about: the run logs the digest it saw so it can be
65 + /// pinned. Present and mismatched is a hard failure.
66 + #[serde(default)]
67 + pub sha256: Option<String>,
68 + /// Content type to upload under (`audio/wav`, `image/jpeg`, ...).
69 + pub media_type: String,
70 + /// Filename to store the object as, and to show on the download.
71 + pub filename: String,
72 + /// SPDX-ish licence string. Public domain / CC0 only; see the manifest header.
73 + pub license: String,
74 + /// Human title of the work, for the credits page.
75 + pub title: String,
76 + /// Creator to credit. `None` for anonymous or corporate-anonymous works.
77 + #[serde(default)]
78 + pub author: Option<String>,
79 + /// The page a visitor can reach to verify the licence claim. Not the direct
80 + /// download URL, the landing page.
81 + pub source: String,
82 + }
83 +
84 + impl Asset {
85 + /// Whether this asset has been curated (has somewhere to fetch from).
86 + fn is_curated(&self) -> bool {
87 + self.url.as_deref().is_some_and(|u| !u.trim().is_empty())
88 + }
89 + }
90 +
91 + /// Shape of the TOML file: a flat array of assets.
92 + #[derive(Debug, Deserialize)]
93 + struct ManifestFile {
94 + #[serde(default)]
95 + asset: Vec<Asset>,
96 + }
97 +
98 + /// Why the manifest could not be loaded, or an asset could not be resolved.
99 + #[derive(Debug, thiserror::Error)]
100 + pub enum ManifestError {
101 + /// The override path was set but unreadable.
102 + #[error("cannot read media manifest at {path}: {source}")]
103 + Read {
104 + path: PathBuf,
105 + #[source]
106 + source: std::io::Error,
107 + },
108 + /// The manifest is not valid TOML, or does not match the schema.
109 + #[error("media manifest is not valid: {0}")]
110 + Parse(#[from] toml::de::Error),
111 + /// Two entries claim the same id, so a reference is ambiguous.
112 + #[error("media manifest declares id {0:?} more than once")]
113 + DuplicateId(String),
114 + /// One or more curated assets failed to fetch or verify. Collected rather
115 + /// than returned one at a time: a curation pass wants the whole list.
116 + #[error("{} media asset(s) failed to resolve:\n{}", .0.len(), .0.join("\n"))]
117 + Unresolved(Vec<String>),
118 + }
119 +
120 + /// The parsed manifest, indexed by asset id.
121 + #[derive(Debug, Default)]
122 + pub struct Manifest {
123 + assets: Vec<Asset>,
124 + }
125 +
126 + impl Manifest {
127 + /// Load the manifest: the file named by [`MANIFEST_PATH_ENV`] if set,
128 + /// otherwise the copy embedded at build time.
129 + pub fn load() -> Result<Self, ManifestError> {
130 + match std::env::var(MANIFEST_PATH_ENV) {
131 + Ok(path) if !path.trim().is_empty() => {
132 + let path = PathBuf::from(path);
133 + let text =
134 + std::fs::read_to_string(&path).map_err(|source| ManifestError::Read {
135 + path: path.clone(),
136 + source,
137 + })?;
138 + tracing::info!(path = %path.display(), "example seed: using media manifest override");
139 + Self::parse(&text)
140 + }
141 + _ => Self::parse(EMBEDDED_MANIFEST),
142 + }
143 + }
144 +
145 + /// Parse and validate manifest text. Separated from [`Self::load`] so the
146 + /// embedded manifest can be checked by a unit test with no filesystem.
147 + pub fn parse(text: &str) -> Result<Self, ManifestError> {
148 + let file: ManifestFile = toml::from_str(text)?;
149 + let mut seen = std::collections::HashSet::with_capacity(file.asset.len());
150 + for asset in &file.asset {
151 + if !seen.insert(asset.id.as_str()) {
152 + return Err(ManifestError::DuplicateId(asset.id.clone()));
153 + }
154 + }
155 + Ok(Self { assets: file.asset })
156 + }
157 +
158 + /// Every declared id, curated or not. Used by the roster-coverage test.
159 + pub fn ids(&self) -> impl Iterator<Item = &str> {
160 + self.assets.iter().map(|a| a.id.as_str())
161 + }
162 +
163 + /// Fetch every curated asset, verify its digest, and return the resolved set.
164 + ///
165 + /// Uncurated entries are skipped (their slots keep the placeholder). Any
166 + /// curated asset that fails is collected; the call returns
167 + /// [`ManifestError::Unresolved`] listing all of them rather than stopping at
168 + /// the first.
169 + pub async fn resolve(&self) -> Result<ResolvedAssets, ManifestError> {
170 + let curated: Vec<&Asset> = self.assets.iter().filter(|a| a.is_curated()).collect();
171 + let total = self.assets.len();
172 + if curated.is_empty() {
173 + tracing::warn!(
174 + declared = total,
175 + "example seed: no media curated yet; every slot keeps its generated placeholder"
176 + );
177 + return Ok(ResolvedAssets::default());
178 + }
179 + tracing::info!(
180 + curated = curated.len(),
181 + declared = total,
182 + "example seed: resolving curated media"
183 + );
184 +
185 + let cache = cache_dir();
186 + if let Err(e) = std::fs::create_dir_all(&cache) {
187 + tracing::warn!(dir = %cache.display(), error = ?e, "example seed: media cache unusable; fetching every asset fresh");
188 + }
189 +
190 + let client = reqwest::Client::builder()
191 + .timeout(FETCH_TIMEOUT)
192 + .user_agent("makenotwork-example-seed")
193 + .build()
194 + .map_err(|e| ManifestError::Unresolved(vec![format!("http client: {e}")]))?;
195 +
196 + let mut resolved = HashMap::with_capacity(curated.len());
197 + let mut failures = Vec::new();
198 + for asset in curated {
199 + match fetch_asset(&client, &cache, asset).await {
200 + Ok(bytes) => {
201 + resolved.insert(asset.id.clone(), (asset.clone(), bytes));
202 + }
203 + Err(reason) => failures.push(format!(" {}: {reason}", asset.id)),
204 + }
205 + }
206 +
207 + if !failures.is_empty() {
208 + return Err(ManifestError::Unresolved(failures));
209 + }
210 + Ok(ResolvedAssets { assets: resolved })
211 + }
212 + }
213 +
214 + /// Curated media, fetched and verified, ready to upload.
215 + #[derive(Debug, Default)]
216 + pub struct ResolvedAssets {
217 + assets: HashMap<String, (Asset, Vec<u8>)>,
218 + }
219 +
220 + impl ResolvedAssets {
221 + /// The asset behind an id, if it was curated and resolved.
222 + pub fn get(&self, id: &str) -> Option<(&Asset, &[u8])> {
223 + self.assets.get(id).map(|(a, b)| (a, b.as_slice()))
224 + }
225 +
226 + /// The asset behind an optional reference, so call sites can pass
227 + /// `spec.cover` straight through.
228 + pub fn lookup(&self, id: Option<&str>) -> Option<(&Asset, &[u8])> {
229 + self.get(id?)
230 + }
231 +
232 + /// How many assets resolved.
233 + pub fn len(&self) -> usize {
234 + self.assets.len()
235 + }
236 +
237 + /// Whether nothing resolved (every slot is on its placeholder).
238 + pub fn is_empty(&self) -> bool {
239 + self.assets.is_empty()
240 + }
241 +
242 + /// A markdown credits list: one line per resolved asset, title linked to the
243 + /// source page, with author and licence. Sorted by title so a reseed with the
244 + /// same manifest produces the same page.
245 + ///
246 + /// Empty string when nothing resolved, so the caller can skip publishing.
247 + pub fn attribution_markdown(&self) -> String {
248 + if self.assets.is_empty() {
249 + return String::new();
250 + }
251 + let mut lines: Vec<String> = self
252 + .assets
253 + .values()
254 + .map(|(a, _)| {
255 + let author = a
256 + .author
257 + .as_deref()
258 + .map_or_else(String::new, |author| format!(" by {author}"));
259 + format!("- [{}]({}){} — {}", a.title, a.source, author, a.license)
260 + })
261 + .collect();
262 + lines.sort();
263 + lines.dedup();
264 + lines.join("\n")
265 + }
266 + }
267 +
268 + /// Where fetched assets are cached between runs.
269 + fn cache_dir() -> PathBuf {
270 + match std::env::var(CACHE_DIR_ENV) {
271 + Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
272 + _ => std::env::temp_dir().join("mnw-seed-media"),
273 + }
274 + }
275 +
276 + /// Fetch one asset, preferring the cache, and verify its digest.
277 + ///
278 + /// The cache is keyed by id, and a cached file is only trusted when the manifest
279 + /// pins a digest and the file matches it. An unpinned asset is re-fetched every
280 + /// run: without a digest there is nothing to tell a stale cache entry from a
281 + /// current one.
282 + async fn fetch_asset(
283 + client: &reqwest::Client,
284 + cache: &std::path::Path,
285 + asset: &Asset,
286 + ) -> Result<Vec<u8>, String> {
287 + let cached = cache.join(&asset.id);
288 + if let (Some(want), Ok(bytes)) = (asset.sha256.as_deref(), std::fs::read(&cached))
289 + && digest_hex(&bytes).eq_ignore_ascii_case(want.trim())
290 + {
291 + tracing::debug!(id = %asset.id, "example seed: media cache hit");
292 + return Ok(bytes);
293 + }
294 +
295 + let url = asset.url.as_deref().unwrap_or_default().trim();
296 + let response = client
297 + .get(url)
298 + .send()
299 + .await
300 + .map_err(|e| format!("fetching {url}: {e}"))?;
301 + if !response.status().is_success() {
302 + return Err(format!("fetching {url}: HTTP {}", response.status()));
303 + }
304 + // Refuse an oversized body before buffering it, when the server declares one.
305 + if let Some(len) = response.content_length()
306 + && len > MAX_ASSET_BYTES
307 + {
308 + return Err(format!(
309 + "fetching {url}: {len} bytes exceeds the {MAX_ASSET_BYTES}-byte asset ceiling"
310 + ));
311 + }
312 + let bytes = response
313 + .bytes()
314 + .await
315 + .map_err(|e| format!("reading {url}: {e}"))?
316 + .to_vec();
317 + if bytes.len() as u64 > MAX_ASSET_BYTES {
318 + return Err(format!(
319 + "fetching {url}: {} bytes exceeds the {MAX_ASSET_BYTES}-byte asset ceiling",
320 + bytes.len()
321 + ));
322 + }
323 + if bytes.is_empty() {
324 + return Err(format!("fetching {url}: empty body"));
325 + }
326 +
327 + let got = digest_hex(&bytes);
328 + match asset.sha256.as_deref().map(str::trim) {
329 + Some(want) if !want.is_empty() => {
330 + if !got.eq_ignore_ascii_case(want) {
331 + return Err(format!(
332 + "digest mismatch for {url}: manifest pins {want}, fetched {got}. \
333 + The asset changed at the source; re-check the licence before repinning."
334 + ));
335 + }
336 + }
337 + _ => tracing::warn!(
338 + id = %asset.id,
339 + sha256 = %got,
340 + "example seed: asset is unpinned; add sha256 = \"{got}\" to media-manifest.toml"
341 + ),
342 + }
343 +
344 + if let Err(e) = std::fs::write(&cached, &bytes) {
345 + tracing::debug!(id = %asset.id, error = ?e, "example seed: could not cache asset");
346 + }
347 + tracing::info!(id = %asset.id, bytes = bytes.len(), "example seed: fetched media asset");
348 + Ok(bytes)
349 + }
350 +
351 + /// Lowercase hex SHA-256.
352 + fn digest_hex(bytes: &[u8]) -> String {
353 + use sha2::{Digest, Sha256};
354 + hex::encode(Sha256::digest(bytes))
355 + }
356 +
357 + #[cfg(test)]
358 + mod tests {
359 + use super::*;
360 +
361 + #[test]
362 + fn embedded_manifest_parses() {
363 + Manifest::parse(EMBEDDED_MANIFEST).expect("the embedded manifest must always be valid");
364 + }
365 +
366 + #[test]
367 + fn duplicate_ids_are_refused() {
368 + let text = r#"
369 + [[asset]]
370 + id = "a"
371 + media_type = "image/png"
372 + filename = "a.png"
373 + license = "CC0-1.0"
374 + title = "A"
375 + source = "https://example.test/a"
376 +
377 + [[asset]]
378 + id = "a"
379 + media_type = "image/png"
380 + filename = "b.png"
381 + license = "CC0-1.0"
382 + title = "B"
383 + source = "https://example.test/b"
384 + "#;
385 + assert!(matches!(
386 + Manifest::parse(text).unwrap_err(),
387 + ManifestError::DuplicateId(id) if id == "a"
388 + ));
389 + }
390 +
391 + #[test]
392 + fn an_asset_without_a_url_is_uncurated() {
393 + let text = r#"
394 + [[asset]]
395 + id = "a"
396 + url = " "
397 + media_type = "image/png"
398 + filename = "a.png"
399 + license = "CC0-1.0"
400 + title = "A"
401 + source = "https://example.test/a"
402 + "#;
403 + let manifest = Manifest::parse(text).unwrap();
404 + assert!(!manifest.assets[0].is_curated());
405 + }
406 +
407 + #[tokio::test]
408 + async fn an_uncurated_manifest_resolves_to_nothing_without_network() {
409 + let manifest = Manifest::parse(EMBEDDED_MANIFEST).unwrap();
410 + // True until the first asset is curated; the point is that a fully
411 + // uncurated manifest never reaches for the network.
412 + if manifest.assets.iter().all(|a| !a.is_curated()) {
413 + assert!(manifest.resolve().await.unwrap().is_empty());
414 + }
415 + }
416 +
417 + #[test]
418 + fn attribution_lists_resolved_assets_only() {
419 + let asset = Asset {
420 + id: "x".into(),
421 + url: Some("https://example.test/x.jpg".into()),
422 + sha256: None,
423 + media_type: "image/jpeg".into(),
424 + filename: "x.jpg".into(),
425 + license: "CC0-1.0".into(),
426 + title: "A Study".into(),
427 + author: Some("A. Person".into()),
428 + source: "https://example.test/x".into(),
429 + };
430 + let mut assets = HashMap::new();
431 + assets.insert("x".to_string(), (asset, vec![1, 2, 3]));
432 + let resolved = ResolvedAssets { assets };
433 + assert_eq!(
434 + resolved.attribution_markdown(),
435 + "- [A Study](https://example.test/x) by A. Person — CC0-1.0"
436 + );
437 + assert!(ResolvedAssets::default().attribution_markdown().is_empty());
438 + }
439 +
440 + #[test]
441 + fn digest_is_lowercase_hex_sha256() {
442 + // Known vector: SHA-256 of the empty string.
443 + assert_eq!(
444 + digest_hex(b""),
445 + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
446 + );
447 + }
448 + }
@@ -1,0 +1,257 @@
1 + # Curated public-domain / CC0 media for the testnot.work example seed.
2 + #
3 + # This file is the curation checklist. Every id the seed can attach is declared
4 + # here; an entry with an empty `url` is not curated yet and its slot keeps the
5 + # generated placeholder (a 16x16 grey PNG, or a silent WAV). Filling in a `url`
6 + # is the whole act of curation. Nothing else has to change.
7 + #
8 + # Loaded by src/seed/manifest.rs, embedded into the binary at build time. Set
9 + # SEED_MEDIA_MANIFEST to point at a different file while iterating.
10 + #
11 + # RULES, not preferences:
12 + #
13 + # 1. Public domain or CC0 only. No CC-BY, no NC, no "free for personal use".
14 + # testnot.work is a public box and these files sit in object storage under a
15 + # licence claim this file makes on the project's behalf.
16 + # 2. `source` is the landing page a person can open to check that claim, not the
17 + # direct download. If there is no such page, the asset is not usable here.
18 + # 3. `url` is a direct, stable download. Prefer the hosting institution over a
19 + # mirror.
20 + # 4. Pin `sha256` once the asset is chosen. A run with no pin logs the digest it
21 + # saw; paste it back. Pinned and mismatched fails the seed, which is the
22 + # point: it means the file at that URL changed under us.
23 + # 5. Reusing one `url` across several ids is allowed (the same photographer's
24 + # series across a storefront reads fine). Reusing it across *every* id does
25 + # not: a grid of one repeated image is the problem this replaces.
26 + #
27 + # SOURCES implied by the locked naming convention (src/seed/creators.rs):
28 + # audio Musopen (https://musopen.org) — PD recordings of PD scores
29 + # texts Project Gutenberg (https://gutenberg.org)
30 + # photography Wikimedia Commons CC0 (https://commons.wikimedia.org)
31 + #
32 + # COVERS ARE THE LOAD-BEARING HALF. Sixteen of the nineteen entries below are
33 + # cover art, because a storefront of grey squares reads as unfinished however
34 + # many items are in it. The three media files matter less: only Audio, Video and
35 + # Image items serve a real file to a visitor. Every other item type downloads a
36 + # short text blob, and a demo download that says "example file" is honest rather
37 + # than broken, so those are deliberately not declared here.
38 +
39 + # ── Project covers ──────────────────────────────────────────────────────────
40 + # One per storefront (5). These are the largest images on the site: they head
41 + # /p/<slug> and appear on /discover.
42 +
43 + [[asset]]
44 + id = "openreels-project-cover"
45 + url = ""
46 + sha256 = ""
47 + media_type = "image/jpeg"
48 + filename = "openreels-cover.jpg"
49 + license = ""
50 + title = ""
51 + source = ""
52 + # Restored Reels, Vol. 1 — a reissue label. Archival studio or tape imagery.
53 +
54 + [[asset]]
55 + id = "deskriver-project-cover"
56 + url = ""
57 + sha256 = ""
58 + media_type = "image/jpeg"
59 + filename = "deskriver-cover.jpg"
60 + license = ""
61 + title = ""
62 + source = ""
63 + # Deskriver Suite — offline desktop tools. Something spare and geometric; avoid
64 + # stock-photo laptops.
65 +
66 + [[asset]]
67 + id = "stillfield-project-cover"
68 + url = ""
69 + sha256 = ""
70 + media_type = "image/jpeg"
71 + filename = "stillfield-cover.jpg"
72 + license = ""
73 + title = ""
74 + source = ""
75 + # CC0 Field Library — landscape and still-life photography. This one should be
76 + # the strongest image on the box; it is the storefront most likely to be the
77 + # carousel's frame 1.
78 +
79 + [[asset]]
80 + id = "marginalia-project-cover"
81 + url = ""
82 + sha256 = ""
83 + media_type = "image/jpeg"
84 + filename = "marginalia-cover.jpg"
85 + license = ""
86 + title = ""
87 + source = ""
88 + # The Marginalia Reader — a one-person press. Type specimens, letterpress,
89 + # bookbinding.
90 +
91 + [[asset]]
92 + id = "commonshare-project-cover"
93 + url = ""
94 + sha256 = ""
95 + media_type = "image/jpeg"
96 + filename = "commonshare-cover.jpg"
97 + license = ""
98 + title = ""
99 + source = ""
100 + # Commons Sampler — the benefit account. Anything communal and unbranded.
101 +
102 + # ── Item covers ─────────────────────────────────────────────────────────────
103 + # One per item (11), in roster order.
104 +
105 + [[asset]]
106 + id = "restoration-1-cover"
107 + url = ""
108 + sha256 = ""
109 + media_type = "image/jpeg"
110 + filename = "restoration-1-cover.jpg"
111 + license = ""
112 + title = ""
113 + source = ""
114 +
115 + [[asset]]
116 + id = "stem-pack-strings-cover"
117 + url = ""
118 + sha256 = ""
119 + media_type = "image/jpeg"
120 + filename = "stem-pack-strings-cover.jpg"
121 + license = ""
122 + title = ""
123 + source = ""
124 +
125 + [[asset]]
126 + id = "session-take-cover"
127 + url = ""
128 + sha256 = ""
129 + media_type = "image/jpeg"
130 + filename = "session-take-cover.jpg"
131 + license = ""
132 + title = ""
133 + source = ""
134 +
135 + [[asset]]
136 + id = "deskriver-focus-cover"
137 + url = ""
138 + sha256 = ""
139 + media_type = "image/jpeg"
140 + filename = "deskriver-focus-cover.jpg"
141 + license = ""
142 + title = ""
143 + source = ""
144 +
145 + [[asset]]
146 + id = "deskriver-presets-cover"
147 + url = ""
148 + sha256 = ""
149 + media_type = "image/jpeg"
150 + filename = "deskriver-presets-cover.jpg"
151 + license = ""
152 + title = ""
153 + source = ""
154 +
155 + [[asset]]
156 + id = "deskriver-template-cover"
157 + url = ""
158 + sha256 = ""
159 + media_type = "image/jpeg"
160 + filename = "deskriver-template-cover.jpg"
161 + license = ""
162 + title = ""
163 + source = ""
164 +
165 + [[asset]]
166 + id = "deskriver-utility-cover"
167 + url = ""
168 + sha256 = ""
169 + media_type = "image/jpeg"
170 + filename = "deskriver-utility-cover.jpg"
171 + license = ""
172 + title = ""
173 + source = ""
174 +
175 + [[asset]]
176 + id = "field-study-01-cover"
177 + url = ""
178 + sha256 = ""
179 + media_type = "image/jpeg"
180 + filename = "field-study-01.jpg"
181 + license = ""
182 + title = ""
183 + source = ""
184 + # This one is both the cover and the work: "Field Study 01 (Print)" is an Image
185 + # item, so the cover is what a buyer downloads. Use a full-resolution file, not
186 + # a thumbnail.
187 +
188 + [[asset]]
189 + id = "on-slow-reading-cover"
190 + url = ""
191 + sha256 = ""
192 + media_type = "image/jpeg"
193 + filename = "on-slow-reading-cover.jpg"
194 + license = ""
195 + title = ""
196 + source = ""
197 +
198 + [[asset]]
199 + id = "typesetting-commons-cover"
200 + url = ""
201 + sha256 = ""
202 + media_type = "image/jpeg"
203 + filename = "typesetting-commons-cover.jpg"
204 + license = ""
205 + title = ""
206 + source = ""
207 +
208 + [[asset]]
209 + id = "community-bundle-cover"
210 + url = ""
211 + sha256 = ""
212 + media_type = "image/jpeg"
213 + filename = "community-bundle-cover.jpg"
214 + license = ""
215 + title = ""
216 + source = ""
217 +
218 + # ── Media files ─────────────────────────────────────────────────────────────
219 + # The three items that serve a real file to a visitor. Audio has to be actually
220 + # playable: the player is a surface visitors touch, and a silent WAV in it reads
221 + # as a broken site rather than an unfinished one.
222 +
223 + [[asset]]
224 + id = "restoration-1-audio"
225 + url = ""
226 + sha256 = ""
227 + media_type = "audio/mpeg"
228 + filename = "restoration-1.mp3"
229 + license = ""
230 + title = ""
231 + source = ""
232 + # "Restoration No. 1 (Full Mix)". A PD recording of a PD score, Musopen. Keep it
233 + # a few minutes at most; it is fetched on every uncached reseed.
234 +
235 + [[asset]]
236 + id = "stem-pack-strings-audio"
237 + url = ""
238 + sha256 = ""
239 + media_type = "audio/wav"
240 + filename = "stem-pack-strings.wav"
241 + license = ""
242 + title = ""
243 + source = ""
244 + # "Stem Pack: Strings", a Sample item, so this downloads rather than streams.
245 + # A strings-only excerpt of the same recording is the coherent choice.
246 +
247 + [[asset]]
248 + id = "session-take-video"
249 + url = ""
250 + sha256 = ""
251 + media_type = "video/mp4"
252 + filename = "session-take.mp4"
253 + license = ""
254 + title = ""
255 + source = ""
256 + # "Session Take (Video)". Short, PD archival footage. Watch the size ceiling in
257 + # manifest.rs (64 MB); a long clip belongs nowhere near a reseed.