Skip to main content

max / makenotwork

23.7 KB · 474 lines History Blame Raw
1 //! JSON API endpoints for projects, items, links, and tags.
2 //!
3 //! ## Response conventions
4 //!
5 //! - **Create / Update**: return the full resource as JSON.
6 //! - **Delete**: return `204 No Content`.
7 //! - **List**: return `{"data": [...]}` via [`ListResponse`](crate::types::ListResponse).
8 //! - **Action** (no resource to return): `204` or `{"message": "..."}`.
9 //! - **Errors**: `{"error": "..."}` with appropriate HTTP status (via [`json_error_layer`]).
10 //! - **HTMX**: response pattern varies by UX need (toast, redirect, partial,
11 //! save-status); intentional, not inconsistency.
12 //! - **License key public endpoints** (`/api/keys/*`): stable API contract,
13 //! response shapes are frozen.
14 //!
15 //! List endpoints on the creator dashboard (tiers, keys, codes, chapters, etc.)
16 //! are intentionally unpaginated; each is scoped to a single project or user,
17 //! producing bounded result sets (typically <100 items).
18
19 mod blog;
20 mod cart;
21 mod categories;
22 mod collections;
23 mod content_insertions;
24 mod domains;
25 mod exports;
26 mod follows;
27 mod guest_checkout;
28 mod imports;
29 mod internal;
30 mod items;
31 pub(crate) mod license_keys;
32 mod links;
33 mod passkeys;
34 mod project_sections;
35 mod projects;
36 mod promo_codes;
37 mod reports;
38 pub(crate) mod ssh_keys;
39 mod subscriptions;
40 mod tags;
41 pub(crate) mod totp;
42 mod users;
43 mod validate;
44 mod wishlists;
45
46 use axum::{
47 extract::{Request, State},
48 middleware::Next,
49 response::{IntoResponse, Response},
50 routing::{get, options},
51 Json,
52 };
53 use serde::{Deserialize, Serialize};
54 use serde_json::json;
55 use tower_governor::GovernorLayer;
56
57 use crate::{
58 constants,
59 csrf::{delete_csrf, post_csrf, post_csrf_skip, put_csrf, CsrfRouter},
60 db::{self, BlogPostId, ItemId, ProjectId, ProjectType, UserId},
61 error::{ApiErrorMessage, AppError, Result},
62 AppState,
63 };
64
65 const LICENSE_BEARER_SKIP: &str = "license API: bearer license key, no session";
66 const GUEST_CHECKOUT_SKIP: &str = "guest checkout: pre-auth, no session";
67
68 /// Fetch a project and verify the user owns it. Shared by all ownership checks
69 /// that go through a project (items, blog posts, direct project access).
70 pub(super) async fn verify_project_ownership(
71 state: &AppState,
72 project_id: ProjectId,
73 user_id: UserId,
74 ) -> Result<db::DbProject> {
75 let project = db::projects::get_project_by_id(&state.db, project_id)
76 .await?
77 .ok_or(AppError::NotFound)?;
78 if project.user_id != user_id {
79 return Err(AppError::Forbidden);
80 }
81 Ok(project)
82 }
83
84 pub(super) async fn verify_item_ownership(
85 state: &AppState,
86 item_id: ItemId,
87 user_id: UserId,
88 ) -> Result<(db::DbItem, db::DbProject)> {
89 let item = db::items::get_item_by_id(&state.db, item_id)
90 .await?
91 .ok_or(AppError::NotFound)?;
92 let project = verify_project_ownership(state, item.project_id, user_id).await?;
93 Ok((item, project))
94 }
95
96 pub(super) async fn verify_blog_post_ownership(
97 state: &AppState,
98 blog_post_id: BlogPostId,
99 user_id: UserId,
100 ) -> Result<db::DbBlogPost> {
101 let post = db::blog_posts::get_blog_post_by_id(&state.db, blog_post_id)
102 .await?
103 .ok_or(AppError::NotFound)?;
104 verify_project_ownership(state, post.project_id, user_id).await?;
105 Ok(post)
106 }
107
108 /// Middleware that converts HTML error responses into JSON on API routes.
109 ///
110 /// When `AppError::into_response()` fires it stashes an [`ApiErrorMessage`] in
111 /// the response extensions. This layer checks for that extension and, if
112 /// present, replaces the HTML body with `{"error": "..."}` while preserving the
113 /// original status code. Page routes never hit this layer, so they keep
114 /// getting the full HTML error template.
115 async fn json_error_layer(req: Request, next: Next) -> Response {
116 let response = next.run(req).await;
117 if let Some(ApiErrorMessage(msg)) = response.extensions().get::<ApiErrorMessage>().cloned() {
118 let status = response.status();
119 return (status, Json(json!({"error": msg}))).into_response();
120 }
121 response
122 }
123
124 // ── Public project listing (no auth) ──
125
126 #[derive(Serialize)]
127 struct PublicProject {
128 slug: String,
129 title: String,
130 description: Option<String>,
131 project_type: ProjectType,
132 username: String,
133 item_count: i64,
134 }
135
136 #[tracing::instrument(skip_all, name = "api::public_projects")]
137 async fn public_projects(
138 State(state): State<AppState>,
139 ) -> Result<impl IntoResponse> {
140 let rows = db::discover::discover_projects(&state.db, None, None, None, false, 50, 0).await?;
141 let data: Vec<PublicProject> = rows
142 .into_iter()
143 .map(|r| PublicProject {
144 slug: r.slug.to_string(),
145 title: r.title,
146 description: r.description,
147 project_type: r.project_type,
148 username: r.username.to_string(),
149 item_count: r.item_count,
150 })
151 .collect();
152 Ok(Json(json!({ "data": data })))
153 }
154
155 // ── Email signup (public, no auth) ──
156
157 #[derive(Deserialize)]
158 struct EmailSignupForm {
159 email: String,
160 }
161
162 #[tracing::instrument(skip_all, name = "api::email_signup")]
163 async fn email_signup(
164 State(state): State<AppState>,
165 Json(form): Json<EmailSignupForm>,
166 ) -> Result<impl IntoResponse> {
167 let email = db::Email::new(&form.email)?;
168 db::email_signups::insert_email_signup(&state.db, email.as_str(), "landing").await?;
169 Ok(Json(json!({"success": true})))
170 }
171
172 /// Register all JSON API routes for projects, items, links, tags, and account management.
173 ///
174 /// Routes are split into three tiers with different rate limits:
175 /// - Write routes (POST/PUT/DELETE): burst 10, 2/sec per IP
176 /// - Export routes: burst 3, 1/sec per IP (stricter, prevents bulk extraction)
177 /// - Read routes (GET): no rate limit (alpha scale)
178 pub fn api_routes() -> CsrfRouter<AppState> {
179 let write_rate_limit = crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST);
180 let export_rate_limit = crate::helpers::rate_limiter_per_sec(constants::API_EXPORT_RATE_LIMIT_PER_SEC, constants::API_EXPORT_RATE_LIMIT_BURST);
181
182 // Write routes — rate limited
183 let write_routes = CsrfRouter::new()
184 // User routes
185 .route("/api/users/me", put_csrf(users::update_profile))
186 .route("/api/users/me/password", put_csrf(users::update_password))
187 .route("/api/users/me/preferences", put_csrf(users::update_preferences))
188 .route("/api/users/me/stripe", delete_csrf(users::disconnect_stripe))
189 .route("/api/users/me/stripe-tax", put_csrf(users::toggle_stripe_tax))
190 .route("/api/users/me", delete_csrf(users::delete_account))
191 .route("/api/users/me/deactivate", post_csrf(users::deactivate_account))
192 .route("/api/users/me/reactivate", post_csrf(users::reactivate_account))
193 .route("/api/users/me/pause-creator", post_csrf(users::pause_creator))
194 // Broadcast
195 .route("/api/broadcast", post_csrf(users::broadcast_send))
196 .route("/api/support/ticket", post_csrf(users::submit_support_ticket))
197 // Project routes
198 .route("/api/projects", post_csrf(projects::create_project))
199 .route("/api/projects/{id}", put_csrf(projects::update_project))
200 .route("/api/projects/{id}", delete_csrf(projects::delete_project))
201 // Git repo management
202 .route("/api/repos", post_csrf(projects::create_repo))
203 .route("/api/repos/{id}/visibility", put_csrf(projects::update_repo_visibility))
204 .route_get("/api/repos/{id}/collaborators", get(projects::list_repo_collaborators))
205 .route("/api/repos/{id}/collaborators", post_csrf(projects::add_repo_collaborator))
206 .route("/api/repos/{repo_id}/collaborators/{user_id}", delete_csrf(projects::remove_repo_collaborator))
207 .route("/api/projects/{id}/repos", post_csrf(projects::link_repo))
208 .route("/api/projects/{id}/repos/{repo_name}", delete_csrf(projects::unlink_repo))
209 // Project members
210 .route("/api/projects/{id}/members", post_csrf(projects::add_project_member))
211 .route("/api/projects/{project_id}/members/{user_id}", delete_csrf(projects::remove_project_member))
212 // Item routes
213 .route("/api/projects/{id}/items", post_csrf(items::create_item))
214 .route("/api/items/{id}", put_csrf(items::update_item))
215 .route("/api/items/{id}", delete_csrf(items::delete_item))
216 .route("/api/items/{id}/duplicate", post_csrf(items::duplicate_item))
217 // Bulk item operations
218 .route("/api/items/bulk/publish", post_csrf(items::bulk_publish))
219 .route("/api/items/bulk/unpublish", post_csrf(items::bulk_unpublish))
220 .route("/api/items/bulk/delete", post_csrf(items::bulk_delete))
221 .route("/api/items/bulk/price", post_csrf(items::bulk_price))
222 .route("/api/items/bulk/tag", post_csrf(items::bulk_tag))
223 .route("/api/items/{id}/move", put_csrf(items::move_item))
224 // Bundle management
225 .route("/api/items/{id}/bundle/add", post_csrf(items::bundle_add))
226 .route("/api/items/{id}/bundle/create-child", post_csrf(items::bundle_create_child))
227 .route("/api/items/{id}/bundle/{child_id}", delete_csrf(items::bundle_remove))
228 .route("/api/items/{id}/bundle/{child_id}/listed", put_csrf(items::bundle_toggle_listed))
229 // Refund
230 .route("/api/items/{id}/refund", post_csrf(items::refund_transaction))
231 .route("/api/items/{id}/restore", post_csrf(items::restore_item))
232 // Tag routes (HTMX)
233 .route("/api/items/{id}/tags", post_csrf(items::add_tag))
234 .route("/api/items/{id}/tags/{tag_id}", delete_csrf(items::remove_tag))
235 .route("/api/items/{id}/primary-tag", put_csrf(items::set_primary_tag))
236 // Text content route
237 .route("/api/items/{id}/text", put_csrf(items::update_item_text))
238 // Version routes
239 .route("/api/items/{id}/versions", post_csrf(items::create_version))
240 .route("/api/items/{id}/versions/{version_id}", delete_csrf(items::delete_version))
241 // Custom link routes
242 .route("/api/links", post_csrf(links::create_link))
243 .route("/api/links/{id}", put_csrf(links::update_link))
244 .route("/api/links/{id}", delete_csrf(links::delete_link))
245 .route("/api/links/reorder", put_csrf(links::reorder_links))
246 // Chapter routes
247 .route("/api/items/{id}/chapters", post_csrf(items::create_chapter))
248 .route("/api/chapters/{id}", put_csrf(items::update_chapter))
249 .route("/api/chapters/{id}", delete_csrf(items::delete_chapter))
250 // Section routes
251 .route("/api/items/{id}/sections", post_csrf(items::create_section))
252 .route("/api/sections/{id}", put_csrf(items::update_section))
253 .route("/api/sections/{id}", delete_csrf(items::delete_section))
254 .route("/api/items/{id}/sections/reorder", put_csrf(items::reorder_sections))
255 // Project section routes
256 .route("/api/projects/{id}/sections", post_csrf(project_sections::create_section))
257 .route("/api/project-sections/{id}", put_csrf(project_sections::update_section))
258 .route("/api/project-sections/{id}", delete_csrf(project_sections::delete_section))
259 .route("/api/projects/{id}/sections/reorder", put_csrf(project_sections::reorder_sections))
260 // Library routes
261 .route("/api/library/add/{item_id}", post_csrf(users::add_to_library))
262 .route("/api/library/remove/{item_id}", delete_csrf(users::remove_from_library))
263 // Contact sharing revocation
264 .route("/api/contacts/{seller_id}", delete_csrf(users::revoke_contact))
265 // Waitlist
266 .route("/api/waitlist/apply", post_csrf(users::waitlist_apply))
267 // Email verification
268 .route("/api/resend-verification", post_csrf(users::resend_verification))
269 // Account management
270 .route("/api/account/request-deletion", post_csrf(users::request_account_deletion))
271 // Suspension appeal
272 .route("/api/users/me/appeal", post_csrf(users::submit_appeal))
273 // Session management
274 .route("/api/users/me/sessions/{id}", delete_csrf(users::revoke_session))
275 .route("/api/users/me/sessions", delete_csrf(users::revoke_other_sessions))
276 // Blog routes
277 .route("/api/projects/{id}/blog", post_csrf(blog::create_blog_post))
278 .route("/api/blog/{id}", put_csrf(blog::update_blog_post))
279 .route("/api/blog/{id}", delete_csrf(blog::delete_blog_post))
280 // License key management (creator)
281 .route("/api/items/{id}/license-settings", put_csrf(license_keys::update_license_settings))
282 .route("/api/items/{id}/keys", post_csrf(license_keys::generate_key))
283 .route("/api/keys/{id}/revoke", post_csrf(license_keys::revoke_key))
284 // Promo code management (creator)
285 .route("/api/promo-codes", post_csrf(promo_codes::create_promo_code))
286 .route_get("/api/promo-codes", get(promo_codes::list_promo_codes))
287 .route("/api/promo-codes/expired", delete_csrf(promo_codes::delete_expired_promo_codes))
288 .route("/api/promo-codes/{id}", put_csrf(promo_codes::update_promo_code))
289 .route("/api/promo-codes/{id}", delete_csrf(promo_codes::delete_promo_code))
290 .route_get("/api/promo-codes/{id}/redemptions", get(promo_codes::list_redemptions))
291 // Promo code claim (buyer — free_access codes)
292 .route("/api/promo-codes/claim", post_csrf(promo_codes::claim_promo_code))
293 // Subscription tier management (creator)
294 .route("/api/projects/{id}/tiers", post_csrf(subscriptions::create_tier))
295 .route("/api/tiers/{id}", put_csrf(subscriptions::update_tier))
296 .route("/api/tiers/{id}", delete_csrf(subscriptions::delete_tier))
297 // Follow system
298 .route("/api/follow/{target_type}/{target_id}", post_csrf(follows::follow_target))
299 .route("/api/follow/{target_type}/{target_id}", delete_csrf(follows::unfollow_target))
300 // Category management
301 .route("/api/categories", post_csrf(categories::create_category))
302 // TOTP 2FA management
303 .route("/api/users/me/totp/setup", post_csrf(totp::setup))
304 .route("/api/users/me/totp/confirm", post_csrf(totp::confirm))
305 .route("/api/users/me/totp/disable", post_csrf(totp::disable))
306 .route("/api/users/me/totp/backup-codes", post_csrf(totp::regenerate_backup_codes))
307 // Passkey management
308 .route("/api/users/me/passkeys/register/start", post_csrf(passkeys::register_start))
309 .route("/api/users/me/passkeys/register/finish", post_csrf(passkeys::register_finish))
310 .route("/api/users/me/passkeys/{id}", put_csrf(passkeys::rename))
311 .route("/api/users/me/passkeys/{id}", delete_csrf(passkeys::delete))
312 // Content insertion management
313 .route("/api/users/me/insertions/presign", post_csrf(content_insertions::presign_insertion))
314 .route("/api/users/me/insertions/confirm", post_csrf(content_insertions::confirm_insertion))
315 .route("/api/insertions/{id}", put_csrf(content_insertions::rename_insertion))
316 .route("/api/insertions/{id}", delete_csrf(content_insertions::delete_insertion))
317 // Content insertion placements
318 .route("/api/items/{id}/insertions", post_csrf(content_insertions::create_placement))
319 .route("/api/item-insertions/{id}", delete_csrf(content_insertions::delete_placement))
320 // SSH key management
321 .route_get("/api/users/me/ssh-keys", get(ssh_keys::list_keys))
322 .route("/api/users/me/ssh-keys", post_csrf(ssh_keys::add_key))
323 .route("/api/users/me/ssh-keys/{id}", delete_csrf(ssh_keys::delete_key))
324 // Reports
325 .route("/api/reports", post_csrf(reports::submit_report))
326 // Collections
327 .route("/api/collections", post_csrf(collections::create_collection))
328 .route("/api/collections/{id}", put_csrf(collections::update_collection))
329 .route("/api/collections/{id}", delete_csrf(collections::delete_collection))
330 .route("/api/collections/{id}/items/{item_id}", post_csrf(collections::add_item))
331 .route("/api/collections/{id}/items/{item_id}", delete_csrf(collections::remove_item))
332 .route("/api/collections/{id}/items/reorder", put_csrf(collections::reorder_items))
333 // Wishlists
334 .route("/api/wishlists/{item_id}", post_csrf(wishlists::toggle_wishlist))
335 // Cart
336 .route("/api/cart/{item_id}", post_csrf(cart::toggle_cart))
337 .route("/api/cart/{item_id}", put_csrf(cart::update_cart_amount))
338 .route("/api/cart/{item_id}", delete_csrf(cart::remove_from_cart))
339 // Custom domains
340 .route("/api/domains", post_csrf(domains::add_domain))
341 .route("/api/domains/verify", post_csrf(domains::verify_domain))
342 .route("/api/domains/{id}", delete_csrf(domains::remove_domain))
343 // Invite codes
344 .route("/api/invites/create", post_csrf(users::create_invite))
345 // Email signup (public, landing page notify-me)
346 .route("/api/email-signup", post_csrf_skip("pre-auth landing signup, no session", email_signup))
347 .route_layer(GovernorLayer {
348 config: write_rate_limit,
349 });
350
351 // Export routes — stricter rate limit
352 let export_routes = CsrfRouter::new()
353 .route("/api/export/projects", post_csrf(exports::export_projects))
354 .route("/api/export/sales", post_csrf(exports::export_sales))
355 .route("/api/export/purchases", post_csrf(exports::export_purchases))
356 .route("/api/export/splits", post_csrf(exports::export_splits))
357 .route("/api/export/followers", post_csrf(exports::export_followers))
358 .route("/api/export/subscriptions", post_csrf(exports::export_subscriptions))
359 .route("/api/export/content", post_csrf(exports::export_content))
360 .route("/api/export/contacts", post_csrf(exports::export_contacts))
361 .route_layer(GovernorLayer {
362 config: export_rate_limit,
363 });
364
365 let key_rate_limit = crate::helpers::rate_limiter_ms(constants::LICENSE_KEY_RATE_LIMIT_MS, constants::LICENSE_KEY_RATE_LIMIT_BURST);
366
367 let key_routes = CsrfRouter::new()
368 .route("/api/keys/validate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key))
369 .route("/api/v1/keys/validate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key))
370 .route("/api/keys/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key))
371 .route("/api/v1/keys/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key))
372 .route_get("/api/keys/{key_code}/status", get(license_keys::key_status))
373 .route_get("/api/v1/keys/{key_code}/status", get(license_keys::key_status))
374 .route("/api/v1/license/verify", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_verify))
375 .route("/api/v1/license/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_deactivate))
376 .route_layer(GovernorLayer {
377 config: key_rate_limit,
378 });
379
380 let read_rate_limit = crate::helpers::rate_limiter_ms(constants::API_READ_RATE_LIMIT_MS, constants::API_READ_RATE_LIMIT_BURST);
381
382 // Read routes
383 let read_routes = CsrfRouter::new()
384 .route_get("/api/public/projects", get(public_projects))
385 .route_get("/api/v1/public/projects", get(public_projects))
386 .route_get("/api/projects", get(projects::list_projects))
387 .route_get("/api/items/{id}/versions", get(items::list_versions))
388 .route_get("/api/items/{id}/chapters", get(items::list_chapters))
389 .route_get("/api/items/{id}/sections", get(items::list_sections))
390 .route_get("/api/items/{id}/keys", get(license_keys::list_keys))
391 .route_get("/api/projects/{id}/sections", get(project_sections::list_sections))
392 .route_get("/api/projects/{id}/blog", get(blog::list_blog_posts))
393 .route_get("/api/blog/{id}", get(blog::get_blog_post))
394 .route_get("/api/tags/search", get(tags::search_tags))
395 .route_get("/api/categories/search", get(categories::search_categories))
396 .route_get("/api/items/{id}/tag-suggestions", get(tags::suggest_tags))
397 .route_get("/api/projects/{id}/tiers", get(subscriptions::list_tiers))
398 // TOTP status (HTMX partial for dashboard)
399 .route_get("/api/users/me/totp/status", get(totp::status))
400 // Passkey list (HTMX partial for dashboard)
401 .route_get("/api/users/me/passkeys", get(passkeys::list))
402 // SSH key list (HTMX partial for dashboard)
403 .route_get("/api/users/me/ssh-keys/list", get(ssh_keys::list_keys_html))
404 // Content insertion list (HTMX partials for dashboard)
405 .route_get("/api/users/me/insertions", get(content_insertions::list_insertions))
406 .route_get("/api/items/{id}/insertions", get(content_insertions::list_placements))
407 // Collections (read)
408 .route_get("/api/collections/for-item/{item_id}", get(collections::collections_for_item))
409 // Custom domains (read)
410 .route_get("/api/domains", get(domains::get_domain))
411 .route_get("/api/domains/caddy-ask", get(domains::caddy_ask))
412 .route_get("/api/restart-status", get(internal::restart_status))
413 // Cart (read)
414 .route_get("/api/cart/count", get(cart::cart_count))
415 // Import system (read)
416 .route_get("/api/users/me/import/{id}", get(imports::get_import_status))
417 .route_get("/api/users/me/imports", get(imports::list_imports))
418 // License text (public)
419 .route_get("/api/items/{id}/license.txt", get(license_keys::license_text))
420 .route_get("/api/v1/items/{id}/license.txt", get(license_keys::license_text))
421 .route_layer(GovernorLayer {
422 config: read_rate_limit,
423 });
424
425 let validate_rate_limit = crate::helpers::rate_limiter_per_sec(constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST);
426
427 let validate_routes = CsrfRouter::new()
428 .route("/api/validate/project-slug", post_csrf(validate::validate_project_slug))
429 .route("/api/validate/collection-slug", post_csrf(validate::validate_collection_slug))
430 .route("/api/validate/blog-slug", post_csrf(validate::validate_blog_slug))
431 .route_layer(GovernorLayer {
432 config: validate_rate_limit,
433 });
434
435 // Import route needs a higher body limit (base64-encoded CSV up to 10 MB
436 // ≈ 14 MB encoded). The global 1 MB RequestBodyLimitLayer would reject it,
437 // so we override with a per-route layer.
438 let import_routes = CsrfRouter::new()
439 .route("/api/users/me/import", post_csrf(imports::start_import))
440 .layer(axum::extract::DefaultBodyLimit::max(15 * 1024 * 1024))
441 .route_layer(GovernorLayer {
442 config: crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST),
443 });
444
445 // Guest checkout routes — public, no auth, CORS-enabled, stricter rate limit
446 let guest_checkout_rate_limit = crate::helpers::rate_limiter_per_sec(
447 constants::GUEST_CHECKOUT_RATE_LIMIT_PER_SEC,
448 constants::GUEST_CHECKOUT_RATE_LIMIT_BURST,
449 );
450 let guest_routes = CsrfRouter::new()
451 .route("/api/checkout/guest/{item_id}", post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::create_guest_checkout))
452 .route_get("/api/checkout/guest/{item_id}", options(guest_checkout::guest_checkout_preflight))
453 .route("/api/checkout/guest-free/{item_id}", post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::claim_free_guest))
454 .route("/api/purchases/claim", post_csrf(guest_checkout::claim_purchase))
455 .route_layer(GovernorLayer {
456 config: guest_checkout_rate_limit,
457 });
458
459 // Guest download route — separate, more lenient rate limit
460 let download_routes = CsrfRouter::new()
461 .route_get("/download/{token}", get(guest_checkout::guest_download));
462
463 write_routes
464 .merge(export_routes)
465 .merge(key_routes)
466 .merge(validate_routes)
467 .merge(read_routes)
468 .merge(import_routes)
469 .merge(guest_routes)
470 .merge(download_routes)
471 .merge(internal::internal_routes())
472 .layer(axum::middleware::from_fn(json_error_layer))
473 }
474