Skip to main content

max / makenotwork

Add sandbox mode: ephemeral creator accounts for dashboard exploration Sandbox route (GET/POST /sandbox): creates 1-hour ephemeral account with SmallFiles tier, rate-limited per IP (max 3 concurrent, 2 per 30s). Seeds demo project with sample items on creation. Session support: is_sandbox field in SessionUser, propagated through all login paths (password, passkey, login link, 2FA, join wizard). Access guards: check_not_sandbox() blocks Stripe, email, follows, 2FA, passkeys, SSH keys, promo claims, broadcasts, invites, library, tips, account deletion/deactivation, support tickets, password changes. Subscription tiers: sandbox users get fake Stripe product/price IDs. Discover: exclude sandbox users from all discover queries. Public profiles: return 404 for sandbox user pages. Dashboard banners: show sandbox expiry warning on all dashboard pages. Landing page: "Try the Dashboard" CTA replaces "See Use Cases". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-26 19:44 UTC
Commit: cb20647d28382b33786939f725800037c148a689
Parent: c9720b2
31 files changed, +327 insertions, -37 deletions
@@ -198,7 +198,7 @@ pub async fn discover_items(
198 198 JOIN users u ON p.user_id = u.id
199 199 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
200 200 LEFT JOIN tags pt ON pt.id = pit.tag_id
201 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
201 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
202 202 "#,
203 203 )
204 204 } else if has_search {
@@ -224,7 +224,7 @@ pub async fn discover_items(
224 224 JOIN users u ON p.user_id = u.id
225 225 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
226 226 LEFT JOIN tags pt ON pt.id = pit.tag_id
227 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
227 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
228 228 "#,
229 229 )
230 230 } else {
@@ -249,7 +249,7 @@ pub async fn discover_items(
249 249 JOIN users u ON p.user_id = u.id
250 250 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
251 251 LEFT JOIN tags pt ON pt.id = pit.tag_id
252 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
252 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
253 253 "#,
254 254 )
255 255 };
@@ -298,7 +298,8 @@ pub async fn count_discover_items(
298 298 SELECT COUNT(*)
299 299 FROM items i
300 300 JOIN projects p ON i.project_id = p.id
301 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
301 + JOIN users u ON p.user_id = u.id
302 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
302 303 "#,
303 304 );
304 305
@@ -354,7 +355,7 @@ pub async fn discover_projects(
354 355 JOIN users u ON p.user_id = u.id
355 356 LEFT JOIN items i ON i.project_id = p.id
356 357 LEFT JOIN project_categories pc ON pc.id = p.category_id
357 - WHERE p.is_public = true
358 + WHERE p.is_public = true AND u.is_sandbox = FALSE
358 359 "#,
359 360 )
360 361 } else if has_search {
@@ -376,7 +377,7 @@ pub async fn discover_projects(
376 377 JOIN users u ON p.user_id = u.id
377 378 LEFT JOIN items i ON i.project_id = p.id
378 379 LEFT JOIN project_categories pc ON pc.id = p.category_id
379 - WHERE p.is_public = true
380 + WHERE p.is_public = true AND u.is_sandbox = FALSE
380 381 "#,
381 382 )
382 383 } else {
@@ -397,7 +398,7 @@ pub async fn discover_projects(
397 398 JOIN users u ON p.user_id = u.id
398 399 LEFT JOIN items i ON i.project_id = p.id
399 400 LEFT JOIN project_categories pc ON pc.id = p.category_id
400 - WHERE p.is_public = true
401 + WHERE p.is_public = true AND u.is_sandbox = FALSE
401 402 "#,
402 403 )
403 404 };
@@ -462,7 +463,8 @@ pub async fn count_discover_projects(
462 463 r#"
463 464 SELECT COUNT(*)
464 465 FROM projects p
465 - WHERE p.is_public = true
466 + JOIN users u ON p.user_id = u.id
467 + WHERE p.is_public = true AND u.is_sandbox = FALSE
466 468 "#,
467 469 );
468 470
@@ -518,7 +520,8 @@ pub async fn get_item_type_counts(
518 520 SELECT i.item_type as category, COUNT(*) as count
519 521 FROM items i
520 522 JOIN projects p ON i.project_id = p.id
521 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
523 + JOIN users u ON p.user_id = u.id
524 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
522 525 "#,
523 526 );
524 527
@@ -588,7 +591,8 @@ pub async fn get_price_range_counts(
588 591 COUNT(*) FILTER (WHERE i.price_cents >= 10000) as over_100
589 592 FROM items i
590 593 JOIN projects p ON i.project_id = p.id
591 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined'
594 + JOIN users u ON p.user_id = u.id
595 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
592 596 "#,
593 597 );
594 598
@@ -22,6 +22,7 @@ pub(super) async fn follow_target(
22 22 AuthUser(user): AuthUser,
23 23 Path((target_type_str, target_id)): Path<(String, Uuid)>,
24 24 ) -> Result<Response> {
25 + user.check_not_sandbox()?;
25 26 let target_type: FollowTargetType = target_type_str.parse()
26 27 .map_err(|_| AppError::BadRequest("Invalid target type".to_string()))?;
27 28
@@ -31,6 +31,7 @@ pub(super) async fn register_start(
31 31 AuthUser(user): AuthUser,
32 32 session: Session,
33 33 ) -> Result<Response> {
34 + user.check_not_sandbox()?;
34 35 // Enforce registration cap
35 36 let count = db::passkeys::count_passkeys(&state.db, user.id).await?;
36 37 if count >= MAX_PASSKEYS_PER_USER {
@@ -357,6 +357,7 @@ pub(super) async fn claim_promo_code(
357 357 Form(req): Form<ClaimPromoCodeForm>,
358 358 ) -> Result<Response> {
359 359 user.check_not_suspended()?;
360 + user.check_not_sandbox()?;
360 361
361 362 let is_htmx = is_htmx_request(&headers);
362 363
@@ -33,6 +33,7 @@ pub async fn submit_report(
33 33 AuthUser(user): AuthUser,
34 34 Form(form): Form<ReportForm>,
35 35 ) -> Result<impl IntoResponse> {
36 + user.check_not_sandbox()?;
36 37 // Parse target_type
37 38 let target_type: ReportTargetType = form.target_type.parse()
38 39 .map_err(|_| AppError::Validation("Invalid target type".to_string()))?;
@@ -72,6 +72,7 @@ pub(super) async fn add_key(
72 72 Form(req): Form<AddKeyRequest>,
73 73 ) -> Result<Response> {
74 74 user.check_not_suspended()?;
75 + user.check_not_sandbox()?;
75 76
76 77 // Validate and normalize the key
77 78 let (normalized_key, fingerprint) = validation::validate_ssh_public_key(&req.public_key)?;
@@ -67,29 +67,6 @@ pub(super) async fn create_tier(
67 67 }
68 68 validation::validate_tier_price(req.price_cents.as_i32())?;
69 69
70 - // Verify creator has Stripe connected
71 - let creator = db::users::get_user_by_id(&state.db, user.id)
72 - .await?
73 - .ok_or(AppError::NotFound)?;
74 -
75 - let stripe_account_id = creator.stripe_account_id.as_ref()
76 - .ok_or_else(|| AppError::BadRequest("Connect your Stripe account before creating subscription tiers".to_string()))?;
77 -
78 - if !creator.stripe_charges_enabled {
79 - return Err(AppError::BadRequest("Your Stripe account is not ready for charges".to_string()));
80 - }
81 -
82 - let stripe = state.stripe.as_ref()
83 - .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
84 -
85 - // Create Stripe Product + Price on connected account
86 - let (product_id, price_id) = stripe.create_subscription_product_and_price(
87 - stripe_account_id,
88 - &req.name,
89 - req.description.as_deref(),
90 - req.price_cents.as_i32() as i64,
91 - ).await?;
92 -
93 70 // Create tier in our database
94 71 let tier = db::subscriptions::create_subscription_tier(
95 72 &state.db,
@@ -99,8 +76,35 @@ pub(super) async fn create_tier(
99 76 req.price_cents.as_i32(),
100 77 ).await?;
101 78
102 - // Store Stripe IDs
103 - db::subscriptions::update_tier_stripe_ids(&state.db, tier.id, &product_id, &price_id).await?;
79 + // Sandbox users get fake Stripe IDs; real users create Stripe Product + Price
80 + if user.is_sandbox {
81 + let fake_product = format!("sandbox_prod_{}", tier.id);
82 + let fake_price = format!("sandbox_price_{}", tier.id);
83 + db::subscriptions::update_tier_stripe_ids(&state.db, tier.id, &fake_product, &fake_price).await?;
84 + } else {
85 + let creator = db::users::get_user_by_id(&state.db, user.id)
86 + .await?
87 + .ok_or(AppError::NotFound)?;
88 +
89 + let stripe_account_id = creator.stripe_account_id.as_ref()
90 + .ok_or_else(|| AppError::BadRequest("Connect your Stripe account before creating subscription tiers".to_string()))?;
91 +
92 + if !creator.stripe_charges_enabled {
93 + return Err(AppError::BadRequest("Your Stripe account is not ready for charges".to_string()));
94 + }
95 +
96 + let stripe = state.stripe.as_ref()
97 + .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
98 +
99 + let (product_id, price_id) = stripe.create_subscription_product_and_price(
100 + stripe_account_id,
101 + &req.name,
102 + req.description.as_deref(),
103 + req.price_cents.as_i32() as i64,
104 + ).await?;
105 +
106 + db::subscriptions::update_tier_stripe_ids(&state.db, tier.id, &product_id, &price_id).await?;
107 + }
104 108
105 109 db::projects::bump_cache_generation(&state.db, project_id).await?;
106 110
@@ -23,6 +23,7 @@ pub(super) async fn setup(
23 23 State(state): State<AppState>,
24 24 AuthUser(user): AuthUser,
25 25 ) -> Result<Response> {
26 + user.check_not_sandbox()?;
26 27 // Generate a 20-byte (160-bit) random secret
27 28 use rand::Rng;
28 29 let secret_bytes: Vec<u8> = (0..20).map(|_| rand::thread_rng().r#gen()).collect();
@@ -29,6 +29,7 @@ pub(in crate::routes::api) async fn broadcast_send(
29 29 AuthUser(user): AuthUser,
30 30 Form(form): Form<BroadcastForm>,
31 31 ) -> Result<Response> {
32 + user.check_not_sandbox()?;
32 33 // Only creators can broadcast
33 34 if !user.can_create_projects {
34 35 return Ok(Html(FormStatusTemplate {
@@ -20,6 +20,7 @@ pub(in crate::routes::api) async fn create_invite(
20 20 State(state): State<AppState>,
21 21 AuthUser(user): AuthUser,
22 22 ) -> Result<impl IntoResponse> {
23 + user.check_not_sandbox()?;
23 24 if !constants::INVITES_ENABLED {
24 25 return Ok(AlertTemplate::new("error", "Invites are currently disabled.").into_response());
25 26 }
@@ -35,6 +35,7 @@ pub(in crate::routes::api) async fn add_to_library(
35 35 AuthUser(user): AuthUser,
36 36 Path(item_id): Path<ItemId>,
37 37 ) -> Result<Response> {
38 + user.check_not_sandbox()?;
38 39 let is_htmx = is_htmx_request(&headers);
39 40
40 41 // Get the item
@@ -93,6 +93,7 @@ pub(in crate::routes::api) async fn update_password(
93 93 AuthUser(user): AuthUser,
94 94 Form(req): Form<UpdatePasswordRequest>,
95 95 ) -> Result<Response> {
96 + user.check_not_sandbox()?;
96 97 let is_htmx = is_htmx_request(&headers);
97 98
98 99 // Get current user with password hash
@@ -182,6 +183,7 @@ pub(in crate::routes::api) async fn delete_account(
182 183 State(state): State<AppState>,
183 184 AuthUser(user): AuthUser,
184 185 ) -> Result<impl IntoResponse> {
186 + user.check_not_sandbox()?;
185 187 db::users::delete_user(&state.db, user.id).await?;
186 188 Ok(StatusCode::NO_CONTENT)
187 189 }
@@ -192,6 +194,7 @@ pub(in crate::routes::api) async fn deactivate_account(
192 194 State(state): State<AppState>,
193 195 AuthUser(user): AuthUser,
194 196 ) -> Result<impl IntoResponse> {
197 + user.check_not_sandbox()?;
195 198 db::users::deactivate_user(&state.db, user.id).await?;
196 199 tracing::info!(user_id = %user.id, "user self-deactivated account");
197 200 Ok(StatusCode::NO_CONTENT)
@@ -289,6 +292,7 @@ pub(in crate::routes::api) async fn request_account_deletion(
289 292 AuthUser(user): AuthUser,
290 293 Form(form): Form<RequestDeletionForm>,
291 294 ) -> Result<Response> {
295 + user.check_not_sandbox()?;
292 296 let is_htmx = is_htmx_request(&headers);
293 297
294 298 // Get user from DB
@@ -29,6 +29,7 @@ pub(in crate::routes::api) async fn submit_support_ticket(
29 29 AuthUser(user): AuthUser,
30 30 Form(form): Form<SupportTicketForm>,
31 31 ) -> Result<Response> {
32 + user.check_not_sandbox()?;
32 33 let subject = form.subject.trim();
33 34 let message = form.message.trim();
34 35 let category = form.category.trim();
@@ -209,6 +209,7 @@ async fn login_handler(
209 209 let creator_tier = db::creator_tiers::get_active_creator_tier(&state.db, user.id)
210 210 .await.ok().flatten().map(|t| t.to_string());
211 211 let deactivated = user.is_deactivated();
212 + let is_sandbox = user.is_sandbox;
212 213 let session_user = SessionUser {
213 214 id: user.id,
214 215 username: user.username,
@@ -220,6 +221,7 @@ async fn login_handler(
220 221 is_fan_plus,
221 222 creator_tier,
222 223 deactivated,
224 + is_sandbox,
223 225 };
224 226
225 227 login_user(&session, session_user).await?;
@@ -451,6 +453,7 @@ async fn passkey_auth_finish(
451 453 let creator_tier = db::creator_tiers::get_active_creator_tier(&state.db, user.id)
452 454 .await.ok().flatten().map(|t| t.to_string());
453 455 let deactivated = user.is_deactivated();
456 + let is_sandbox = user.is_sandbox;
454 457 let session_user = SessionUser {
455 458 id: user.id,
456 459 username: user.username,
@@ -462,6 +465,7 @@ async fn passkey_auth_finish(
462 465 is_fan_plus,
463 466 creator_tier,
464 467 deactivated,
468 + is_sandbox,
465 469 };
466 470
467 471 login_user(&session, session_user).await?;
@@ -221,6 +221,7 @@ pub(super) async fn login_link_handler(
221 221 .ok()
222 222 .flatten()
223 223 .map(|t| t.to_string());
224 + let is_sandbox = user.is_sandbox;
224 225 let session_user = SessionUser {
225 226 id: user.id,
226 227 username: user.username,
@@ -232,6 +233,7 @@ pub(super) async fn login_link_handler(
232 233 is_fan_plus,
233 234 creator_tier,
234 235 deactivated,
236 + is_sandbox,
235 237 };
236 238
237 239 login_user(&session, session_user).await?;
@@ -5,6 +5,7 @@ pub(crate) mod dashboard;
5 5 mod email_actions;
6 6 mod feeds;
7 7 pub(crate) mod public;
8 + mod sandbox;
8 9
9 10 use axum::Router;
10 11 use crate::AppState;
@@ -12,6 +13,7 @@ use crate::AppState;
12 13 pub fn page_routes() -> Router<AppState> {
13 14 Router::new()
14 15 .merge(public::public_routes())
16 + .merge(sandbox::sandbox_routes())
15 17 .merge(dashboard::dashboard_routes())
16 18 .merge(email_actions::email_action_routes())
17 19 .merge(feeds::feed_routes())
@@ -44,6 +44,10 @@ pub(super) async fn user_page(
44 44 let db_user = db::users::get_user_by_username(&state.db, &username)
45 45 .await?
46 46 .ok_or(AppError::NotFound)?;
47 + // Sandbox accounts are not publicly visible
48 + if db_user.is_sandbox {
49 + return Err(AppError::NotFound);
50 + }
47 51 render_user_profile(&state, &db_user, csrf_token, maybe_user).await
48 52 }
49 53
@@ -182,6 +182,7 @@ pub async fn step_account_create(
182 182 is_fan_plus: false,
183 183 creator_tier: None,
184 184 deactivated: false,
185 + is_sandbox: false,
185 186 };
186 187 login_user(&session, session_user).await?;
187 188 track_session(&session, &state.db, user_id, &headers).await?;
@@ -182,6 +182,7 @@ pub(super) async fn verify_two_factor(
182 182 let is_fan_plus = db::fan_plus::is_fan_plus_active(&state.db, user.id).await.unwrap_or(false);
183 183 let creator_tier = db::creator_tiers::get_active_creator_tier(&state.db, user.id)
184 184 .await.ok().flatten().map(|t| t.to_string());
185 + let is_sandbox = user.is_sandbox;
185 186 let session_user = SessionUser {
186 187 id: user.id,
187 188 username: user.username,
@@ -193,6 +194,7 @@ pub(super) async fn verify_two_factor(
193 194 is_fan_plus,
194 195 creator_tier,
195 196 deactivated,
197 + is_sandbox,
196 198 };
197 199
198 200 login_user(&session, session_user).await?;
@@ -0,0 +1,174 @@
1 + //! Sandbox account creation: ephemeral creator accounts for exploring the dashboard.
2 +
3 + use axum::{
4 + extract::State,
5 + http::HeaderMap,
6 + response::{IntoResponse, Redirect, Response},
7 + routing::{get, post},
8 + Router,
9 + };
10 + use rand::Rng;
11 + use tower_governor::GovernorLayer;
12 + use tower_sessions::Session;
13 +
14 + use crate::{
15 + auth::{self, SessionUser},
16 + constants,
17 + db,
18 + error::{AppError, Result},
19 + helpers::get_csrf_token,
20 + templates::*,
21 + AppState,
22 + };
23 +
24 + /// Register sandbox routes with rate limiting.
25 + pub fn sandbox_routes() -> Router<AppState> {
26 + let sandbox_rate_limit = crate::helpers::rate_limiter_ms(
27 + constants::SANDBOX_RATE_LIMIT_MS,
28 + constants::SANDBOX_RATE_LIMIT_BURST,
29 + );
30 +
31 + Router::new()
32 + .route("/sandbox", get(sandbox_page))
33 + .route(
34 + "/sandbox",
35 + post(create_sandbox).layer(GovernorLayer {
36 + config: sandbox_rate_limit,
37 + }),
38 + )
39 + }
40 +
41 + /// GET /sandbox — info page explaining sandbox mode.
42 + #[tracing::instrument(skip_all, name = "sandbox::info")]
43 + pub(super) async fn sandbox_page(session: Session) -> Result<impl IntoResponse> {
44 + Ok(SandboxTemplate {
45 + csrf_token: get_csrf_token(&session).await,
46 + })
47 + }
48 +
49 + /// POST /sandbox — create an ephemeral sandbox account and redirect to dashboard.
50 + #[tracing::instrument(skip_all, name = "sandbox::create")]
51 + pub(super) async fn create_sandbox(
52 + State(state): State<AppState>,
53 + session: Session,
54 + headers: HeaderMap,
55 + ) -> Result<Response> {
56 + // Extract IP for per-IP cap enforcement.
57 + // Check cf-connecting-ip (Cloudflare), then x-forwarded-for, then reject if unknown.
58 + let ip = headers
59 + .get("cf-connecting-ip")
60 + .or_else(|| headers.get("x-forwarded-for"))
61 + .and_then(|v| v.to_str().ok())
62 + .and_then(|s| s.split(',').next())
63 + .map(|s| s.trim().to_string())
64 + .unwrap_or_default();
65 +
66 + if ip.is_empty() {
67 + return Err(AppError::BadRequest(
68 + "Could not determine client address".to_string(),
69 + ));
70 + }
71 +
72 + // Enforce per-IP concurrent sandbox cap
73 + let active = db::users::count_active_sandboxes_by_ip(&state.db, &ip).await?;
74 + if active >= constants::SANDBOX_MAX_PER_IP {
75 + return Err(AppError::BadRequest(
76 + "Too many active sandboxes from this address".to_string(),
77 + ));
78 + }
79 +
80 + // Generate random sandbox credentials
81 + let suffix: String = rand::thread_rng()
82 + .sample_iter(&rand::distributions::Alphanumeric)
83 + .take(8)
84 + .map(char::from)
85 + .collect::<String>()
86 + .to_lowercase();
87 +
88 + let username = db::Username::from_trusted(format!("sandbox_{}", suffix));
89 + let email = format!("sandbox_{}@sandbox.local", suffix);
90 + let password_hash = auth::hash_password(&format!("sandbox_{}", uuid::Uuid::new_v4()))?;
91 +
92 + // Create the sandbox user
93 + let user = db::users::create_sandbox_user(
94 + &state.db,
95 + &username,
96 + &email,
97 + &password_hash,
98 + constants::SANDBOX_EXPIRY_SECS,
99 + constants::SANDBOX_MAX_FILE_BYTES,
100 + )
101 + .await?;
102 +
103 + // Create session
104 + let session_user = SessionUser {
105 + id: user.id,
106 + username: user.username,
107 + email: user.email,
108 + display_name: user.display_name,
109 + can_create_projects: true,
110 + suspended: false,
111 + is_admin: false,
112 + is_fan_plus: false,
113 + creator_tier: Some("SmallFiles".to_string()),
114 + deactivated: false,
115 + is_sandbox: true,
116 + };
117 +
118 + auth::login_user(&session, session_user).await?;
119 + auth::track_session(&session, &state.db, user.id, &headers).await?;
120 +
121 + // Session ends when the browser closes; the scheduler handles DB cleanup
122 + session.set_expiry(Some(tower_sessions::Expiry::OnSessionEnd));
123 +
124 + tracing::info!(user_id = %user.id, event = "sandbox_created", "Sandbox account created");
125 +
126 + // Pre-seed a demo project so the dashboard isn't empty
127 + seed_demo_content(&state, user.id).await;
128 +
129 + Ok(Redirect::to("/dashboard").into_response())
130 + }
131 +
132 + /// Create a demo project with a couple of items so the sandbox feels populated.
133 + async fn seed_demo_content(state: &AppState, user_id: db::UserId) {
134 + let slug = db::Slug::from_trusted("my-demo-project".to_string());
135 + let features = vec!["audio".to_string(), "downloads".to_string()];
136 +
137 + let project = match db::projects::create_project(
138 + &state.db,
139 + user_id,
140 + &slug,
141 + "My Demo Project",
142 + Some("A sample project to explore the creator dashboard."),
143 + &features,
144 + )
145 + .await
146 + {
147 + Ok(p) => p,
148 + Err(e) => {
149 + tracing::warn!(error = ?e, "failed to seed sandbox project");
150 + return;
151 + }
152 + };
153 +
154 + // Create a couple of demo items
155 + for (title, price, item_type) in [
156 + ("Sample Track", 500, db::ItemType::Digital),
157 + ("Demo Plugin", 1500, db::ItemType::Digital),
158 + ] {
159 + if let Err(e) = db::items::create_item(
160 + &state.db,
161 + project.id,
162 + title,
163 + Some("Edit this item to see how content management works."),
164 + price,
165 + item_type,
166 + db::AiTier::Handmade,
167 + None,
168 + )
169 + .await
170 + {
171 + tracing::warn!(error = ?e, "failed to seed sandbox item");
172 + }
173 + }
174 + }