Skip to main content

max / makenotwork

Add internal API endpoints for MNW CLI ServiceAuth-protected endpoints for the SSH CLI server: creator projects/items/analytics/transactions/blog/promo/license-keys, upload presign/confirm, storage info, SSH key listing, and sales CSV export. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-29 20:47 UTC
Commit: 289cca15a64deaa8a81d3bae68e7e8f0d329e512
Parent: fd0b2fe
14 files changed, +646 insertions, -6 deletions
@@ -196,6 +196,38 @@ impl FromRequestParts<crate::AppState> for AdminUser {
196 196 }
197 197 }
198 198
199 + /// Extractor for internal service-to-service auth (CLI SSH server → MNW API).
200 + ///
201 + /// Validates `Authorization: Bearer {token}` against `config.cli_service_token`.
202 + /// Returns 401 if the token is missing/invalid, 503 if the token is not configured.
203 + pub struct ServiceAuth;
204 +
205 + impl FromRequestParts<crate::AppState> for ServiceAuth {
206 + type Rejection = AppError;
207 +
208 + async fn from_request_parts(
209 + parts: &mut Parts,
210 + state: &crate::AppState,
211 + ) -> Result<Self, Self::Rejection> {
212 + let expected = state.config.cli_service_token.as_deref().ok_or_else(|| {
213 + AppError::ServiceUnavailable("Internal API not configured".to_string())
214 + })?;
215 +
216 + let header = parts
217 + .headers
218 + .get("authorization")
219 + .and_then(|v| v.to_str().ok())
220 + .and_then(|v| v.strip_prefix("Bearer "))
221 + .ok_or(AppError::Unauthorized)?;
222 +
223 + if header != expected {
224 + return Err(AppError::Unauthorized);
225 + }
226 +
227 + Ok(ServiceAuth)
228 + }
229 + }
230 +
199 231 /// Hash a password using Argon2id (46 MiB, 2 iterations, 1 thread).
200 232 pub fn hash_password(password: &str) -> Result<String, AppError> {
201 233 let salt = SaltString::generate(&mut OsRng);
@@ -387,6 +419,7 @@ mod tests {
387 419 cdn_base_url: None,
388 420 postmark_inbound_webhook_token: None,
389 421 internal_shared_secret: None,
422 + cli_service_token: None,
390 423 };
391 424 assert!(require_admin(&user, &config).is_ok());
392 425 }
@@ -446,6 +479,7 @@ mod tests {
446 479 cdn_base_url: None,
447 480 postmark_inbound_webhook_token: None,
448 481 internal_shared_secret: None,
482 + cli_service_token: None,
449 483 };
450 484 assert!(require_admin(&user, &config).is_err());
451 485 }
@@ -61,6 +61,9 @@ pub struct Config {
61 61 /// Shared secret for HMAC-signed internal API requests to MT.
62 62 /// Must match `INTERNAL_SHARED_SECRET` on the MT instance.
63 63 pub internal_shared_secret: Option<String>,
64 + /// Bearer token for authenticating CLI SSH server → MNW internal API calls.
65 + /// When unset, internal API endpoints return 503.
66 + pub cli_service_token: Option<String>,
64 67 }
65 68
66 69 /// S3-compatible storage configuration (Hetzner Object Storage)
@@ -181,6 +184,9 @@ impl Config {
181 184 // Internal shared secret for MT communication
182 185 let internal_shared_secret = std::env::var("INTERNAL_SHARED_SECRET").ok();
183 186
187 + // CLI service token for SSH server → internal API authentication
188 + let cli_service_token = std::env::var("CLI_SERVICE_TOKEN").ok();
189 +
184 190 Ok(Config {
185 191 host,
186 192 port,
@@ -206,6 +212,7 @@ impl Config {
206 212 cdn_base_url,
207 213 postmark_inbound_webhook_token,
208 214 internal_shared_secret,
215 + cli_service_token,
209 216 })
210 217 }
211 218
@@ -341,6 +348,7 @@ impl std::fmt::Debug for Config {
341 348 .field("cdn_base_url", &self.cdn_base_url)
342 349 .field("postmark_inbound_webhook_token", &self.postmark_inbound_webhook_token.as_ref().map(|_| "[REDACTED]"))
343 350 .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]"))
351 + .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]"))
344 352 .finish()
345 353 }
346 354 }
@@ -410,6 +418,7 @@ mod tests {
410 418 cdn_base_url: None,
411 419 postmark_inbound_webhook_token: None,
412 420 internal_shared_secret: None,
421 + cli_service_token: None,
413 422 };
414 423 let addr = config.socket_addr();
415 424 assert_eq!(addr.port(), 8080);
@@ -130,7 +130,7 @@ pub async fn discover_items(
130 130 GREATEST(
131 131 similarity(i.title, $1),
132 132 similarity(COALESCE(i.description, ''), $1) * 0.5
133 - ) as match_score
133 + )::real as match_score
134 134 FROM items i
135 135 JOIN projects p ON i.project_id = p.id
136 136 JOIN users u ON p.user_id = u.id
@@ -255,7 +255,7 @@ pub async fn discover_projects(
255 255 GREATEST(
256 256 similarity(p.title, $1),
257 257 similarity(COALESCE(p.description, ''), $1) * 0.5
258 - ) as match_score,
258 + )::real as match_score,
259 259 pc.name as category_name,
260 260 pc.slug as category_slug
261 261 FROM projects p
@@ -147,6 +147,29 @@ pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbI
147 147 Ok(items)
148 148 }
149 149
150 + /// Count items per project for all projects owned by a user.
151 + ///
152 + /// Returns `(project_id, count)` tuples. Used by the CLI to avoid N+1 queries.
153 + pub async fn count_items_by_user_projects(
154 + pool: &PgPool,
155 + user_id: UserId,
156 + ) -> Result<Vec<(ProjectId, i64)>> {
157 + let rows: Vec<(ProjectId, i64)> = sqlx::query_as(
158 + r#"
159 + SELECT i.project_id, COUNT(*) AS cnt
160 + FROM items i
161 + JOIN projects p ON i.project_id = p.id
162 + WHERE p.user_id = $1
163 + GROUP BY i.project_id
164 + "#,
165 + )
166 + .bind(user_id)
167 + .fetch_all(pool)
168 + .await?;
169 +
170 + Ok(rows)
171 + }
172 +
150 173 /// List only public items in a project, ordered by sort_order then newest.
151 174 ///
152 175 /// Capped at 500 as a safety limit.
@@ -1359,6 +1359,18 @@ pub struct SshKeyWithUsername {
1359 1359 pub username: String,
1360 1360 }
1361 1361
1362 + /// User info returned by SSH key fingerprint lookup (for CLI auth).
1363 + #[derive(Debug, Clone, FromRow, Serialize)]
1364 + pub struct SshKeyUserLookup {
1365 + pub user_id: UserId,
1366 + pub username: Username,
1367 + pub display_name: Option<String>,
1368 + pub email: String,
1369 + pub creator_tier: Option<CreatorTier>,
1370 + pub can_create_projects: bool,
1371 + pub suspended: bool,
1372 + }
1373 +
1362 1374 /// A tracked login session for remote revocation.
1363 1375 #[derive(Debug, Clone, FromRow)]
1364 1376 pub struct DbUserSession {
@@ -2,7 +2,7 @@
2 2
3 3 use sqlx::PgPool;
4 4
5 - use super::models::{DbSshKey, SshKeyWithUsername};
5 + use super::models::{DbSshKey, SshKeyUserLookup, SshKeyWithUsername};
6 6 use super::{SshKeyId, UserId};
7 7 use crate::error::Result;
8 8
@@ -87,6 +87,28 @@ pub async fn get_all_keys_with_username(pool: &PgPool) -> Result<Vec<SshKeyWithU
87 87 Ok(rows)
88 88 }
89 89
90 + /// Look up a user by their SSH key fingerprint. Used by the CLI SSH server
91 + /// to authenticate connections.
92 + pub async fn lookup_user_by_fingerprint(
93 + pool: &PgPool,
94 + fingerprint: &str,
95 + ) -> Result<Option<SshKeyUserLookup>> {
96 + let row = sqlx::query_as::<_, SshKeyUserLookup>(
97 + r#"
98 + SELECT u.id AS user_id, u.username, u.display_name, u.email,
99 + u.creator_tier, u.can_create_projects, u.suspended
100 + FROM ssh_keys sk
101 + JOIN users u ON u.id = sk.user_id
102 + WHERE sk.fingerprint = $1
103 + "#,
104 + )
105 + .bind(fingerprint)
106 + .fetch_optional(pool)
107 + .await?;
108 +
109 + Ok(row)
110 + }
111 +
90 112 /// Look up an SSH key by ID, returning the key and its owner. For git-auth.
91 113 pub async fn get_key_with_user(
92 114 pool: &PgPool,
@@ -0,0 +1,1469 @@
1 + //! Internal API endpoints for service-to-service communication.
2 + //!
3 + //! These endpoints are protected by `ServiceAuth` (Bearer token) and are
4 + //! called by the CLI SSH server running on the same host.
5 +
6 + use axum::{
7 + extract::{Path, Query, State},
8 + response::IntoResponse,
9 + Json,
10 + };
11 + use serde::{Deserialize, Serialize};
12 +
13 + use std::str::FromStr;
14 +
15 + use crate::{
16 + auth::ServiceAuth,
17 + db::{
18 + self, BlogPostId, CodePurpose, CreatorTier, DiscountType, ItemId, ItemType, KeyCode,
19 + LicenseKeyId, PromoCodeId, ProjectId, ProjectType, Slug, TransactionId, UserId, Username,
20 + },
21 + error::{AppError, Result},
22 + helpers,
23 + storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
24 + validation,
25 + AppState,
26 + };
27 +
28 + // ── SSH key lookup ──
29 +
30 + #[derive(Deserialize)]
31 + pub(super) struct SshKeyLookupQuery {
32 + fingerprint: String,
33 + }
34 +
35 + #[derive(Serialize)]
36 + struct SshKeyLookupResponse {
37 + user_id: UserId,
38 + username: Username,
39 + display_name: Option<String>,
40 + creator_tier: Option<CreatorTier>,
41 + can_create_projects: bool,
42 + suspended: bool,
43 + }
44 +
45 + /// GET /api/internal/ssh-key-lookup?fingerprint={sha256}
46 + ///
47 + /// Look up a user by SSH key fingerprint. Returns user info if found, 404 if not.
48 + #[tracing::instrument(skip_all, name = "internal::ssh_key_lookup")]
49 + pub(super) async fn ssh_key_lookup(
50 + State(state): State<AppState>,
51 + _auth: ServiceAuth,
52 + Query(query): Query<SshKeyLookupQuery>,
53 + ) -> Result<impl IntoResponse> {
54 + let user = db::ssh_keys::lookup_user_by_fingerprint(&state.db, &query.fingerprint)
55 + .await?
56 + .ok_or(AppError::NotFound)?;
57 +
58 + Ok(Json(SshKeyLookupResponse {
59 + user_id: user.user_id,
60 + username: user.username,
61 + display_name: user.display_name,
62 + creator_tier: user.creator_tier,
63 + can_create_projects: user.can_create_projects,
64 + suspended: user.suspended,
65 + }))
66 + }
67 +
68 + // ── Creator projects ──
69 +
70 + #[derive(Deserialize)]
71 + pub(super) struct UserIdQuery {
72 + user_id: UserId,
73 + }
74 +
75 + #[derive(Serialize)]
76 + struct CreatorProject {
77 + id: ProjectId,
78 + slug: String,
79 + title: String,
80 + project_type: ProjectType,
81 + is_public: bool,
82 + item_count: i64,
83 + revenue_cents: i64,
84 + }
85 +
86 + /// GET /api/internal/creator/projects?user_id={uuid}
87 + ///
88 + /// List all projects for a creator with item counts and revenue.
89 + #[tracing::instrument(skip_all, name = "internal::creator_projects")]
90 + pub(super) async fn creator_projects(
91 + State(state): State<AppState>,
92 + _auth: ServiceAuth,
93 + Query(query): Query<UserIdQuery>,
94 + ) -> Result<impl IntoResponse> {
95 + let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?;
96 + let revenue = db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?;
97 +
98 + // Build revenue lookup: project_id -> cents
99 + let revenue_map: std::collections::HashMap<ProjectId, i64> = revenue
100 + .into_iter()
101 + .map(|(pid, _title, cents)| (pid, cents))
102 + .collect();
103 +
104 + // Count items per project in a single query
105 + let item_counts = db::items::count_items_by_user_projects(&state.db, query.user_id).await?;
106 + let count_map: std::collections::HashMap<ProjectId, i64> = item_counts.into_iter().collect();
107 +
108 + let data: Vec<CreatorProject> = projects
109 + .into_iter()
110 + .map(|p| CreatorProject {
111 + id: p.id,
112 + slug: p.slug.to_string(),
113 + title: p.title,
114 + project_type: p.project_type,
115 + is_public: p.is_public,
116 + item_count: count_map.get(&p.id).copied().unwrap_or(0),
117 + revenue_cents: revenue_map.get(&p.id).copied().unwrap_or(0),
118 + })
119 + .collect();
120 +
121 + Ok(Json(data))
122 + }
123 +
124 + // ── Project items ──
125 +
126 + #[derive(Serialize)]
127 + struct CreatorItem {
128 + id: db::ItemId,
129 + title: String,
130 + item_type: ItemType,
131 + price_cents: i32,
132 + is_public: bool,
133 + sort_order: i32,
134 + }
135 +
136 + /// GET /api/internal/creator/projects/{id}/items?user_id={uuid}
137 + ///
138 + /// List items in a project (verifies ownership).
139 + #[tracing::instrument(skip_all, name = "internal::creator_project_items")]
140 + pub(super) async fn creator_project_items(
141 + State(state): State<AppState>,
142 + _auth: ServiceAuth,
143 + Path(project_id): Path<ProjectId>,
144 + Query(query): Query<UserIdQuery>,
145 + ) -> Result<impl IntoResponse> {
146 + // Verify ownership
147 + let project = db::projects::get_project_by_id(&state.db, project_id)
148 + .await?
149 + .ok_or(AppError::NotFound)?;
150 + if project.user_id != query.user_id {
151 + return Err(AppError::Forbidden);
152 + }
153 +
154 + let items = db::items::get_items_by_project(&state.db, project_id).await?;
155 +
156 + let data: Vec<CreatorItem> = items
157 + .into_iter()
158 + .map(|i| CreatorItem {
159 + id: i.id,
160 + title: i.title,
161 + item_type: i.item_type,
162 + price_cents: i.price_cents,
163 + is_public: i.is_public,
164 + sort_order: i.sort_order,
165 + })
166 + .collect();
167 +
168 + Ok(Json(data))
169 + }
170 +
171 + // ── Creator stats ──
172 +
173 + #[derive(Deserialize)]
174 + pub(super) struct StatsQuery {
175 + user_id: UserId,
176 + /// Time range: "7d", "30d", "90d", or "all"
177 + #[serde(default = "default_range")]
178 + range: String,
179 + }
180 +
181 + fn default_range() -> String {
182 + "30d".to_string()
183 + }
184 +
185 + #[derive(Serialize)]
186 + struct CreatorStats {
187 + current_revenue_cents: i64,
188 + previous_revenue_cents: i64,
189 + current_sales: i64,
190 + previous_sales: i64,
191 + current_followers: i64,
192 + previous_followers: i64,
193 + total_projects: i64,
194 + total_items: i64,
195 + }
196 +
197 + /// GET /api/internal/creator/stats?user_id={uuid}&range=30d
198 + ///
199 + /// Period comparison stats for the creator dashboard.
200 + #[tracing::instrument(skip_all, name = "internal::creator_stats")]
201 + pub(super) async fn creator_stats(
202 + State(state): State<AppState>,
203 + _auth: ServiceAuth,
204 + Query(query): Query<StatsQuery>,
205 + ) -> Result<impl IntoResponse> {
206 + let range: db::analytics::TimeRange = query
207 + .range
208 + .parse()
209 + .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
210 +
211 + let comparison =
212 + db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?;
213 +
214 + let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?;
215 + let items = db::items::get_items_by_user(&state.db, query.user_id).await?;
216 +
217 + Ok(Json(CreatorStats {
218 + current_revenue_cents: comparison.current_revenue_cents,
219 + previous_revenue_cents: comparison.previous_revenue_cents,
220 + current_sales: comparison.current_sales,
221 + previous_sales: comparison.previous_sales,
222 + current_followers: comparison.current_followers,
223 + previous_followers: comparison.previous_followers,
224 + total_projects: projects.len() as i64,
225 + total_items: items.len() as i64,
226 + }))
227 + }
228 +
229 + // ── Create item (for CLI upload pipeline) ──
230 +
231 + #[derive(Deserialize)]
232 + pub(super) struct CreateItemRequest {
233 + user_id: UserId,
234 + project_id: ProjectId,
235 + title: String,
236 + item_type: String,
237 + #[serde(default)]
238 + price_cents: i32,
239 + }
240 +
241 + #[derive(Serialize)]
242 + struct CreateItemResponse {
243 + item_id: ItemId,
244 + project_id: ProjectId,
245 + }
246 +
247 + /// POST /api/internal/creator/items
248 + ///
249 + /// Create a new item in a project. Used by the CLI upload pipeline.
250 + #[tracing::instrument(skip_all, name = "internal::create_item")]
251 + pub(super) async fn create_item(
252 + State(state): State<AppState>,
253 + _auth: ServiceAuth,
254 + Json(req): Json<CreateItemRequest>,
255 + ) -> Result<impl IntoResponse> {
256 + // Validate title
257 + validation::validate_item_title(&req.title)?;
258 +
259 + // Parse item type
260 + let item_type: ItemType = req
261 + .item_type
262 + .parse()
263 + .map_err(|_| AppError::BadRequest(format!("Invalid item type: {}", req.item_type)))?;
264 +
265 + // Verify project ownership
266 + let project = db::projects::get_project_by_id(&state.db, req.project_id)
267 + .await?
268 + .ok_or(AppError::NotFound)?;
269 + if project.user_id != req.user_id {
270 + return Err(AppError::Forbidden);
271 + }
272 +
273 + let item = db::items::create_item(
274 + &state.db,
275 + req.project_id,
276 + &req.title,
277 + None,
278 + req.price_cents,
279 + item_type,
280 + )
281 + .await?;
282 +
283 + tracing::info!(
284 + user = %req.user_id,
285 + item = %item.id,
286 + "item created via CLI"
287 + );
288 +
289 + Ok(Json(CreateItemResponse {
290 + item_id: item.id,
291 + project_id: req.project_id,
292 + }))
293 + }
294 +
295 + // ── Presign upload (for CLI upload pipeline) ──
296 +
297 + #[derive(Deserialize)]
298 + pub(super) struct InternalPresignRequest {
299 + user_id: UserId,
300 + item_id: ItemId,
301 + file_type: String,
302 + file_name: String,
303 + content_type: String,
304 + }
305 +
306 + #[derive(Serialize)]
307 + struct InternalPresignResponse {
308 + upload_url: String,
309 + s3_key: String,
310 + expires_in: u64,
311 + #[serde(skip_serializing_if = "Option::is_none")]
312 + cache_control: Option<String>,
313 + }
314 +
315 + /// POST /api/internal/upload/presign
316 + ///
317 + /// Generate a presigned S3 upload URL. Used by the CLI upload pipeline.
318 + #[tracing::instrument(skip_all, name = "internal::presign_upload")]
319 + pub(super) async fn presign_upload(
320 + State(state): State<AppState>,
321 + _auth: ServiceAuth,
322 + Json(req): Json<InternalPresignRequest>,
323 + ) -> Result<impl IntoResponse> {
324 + let s3 = state.require_s3()?;
325 +
326 + let file_type = FileType::from_str(&req.file_type)
327 + .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
328 +
329 + S3Client::validate_content_type(file_type, &req.content_type)?;
330 + S3Client::validate_extension(file_type, &req.file_name)?;
331 +
332 + // Verify user owns the item
333 + let owner = db::items::get_item_owner(&state.db, req.item_id)
334 + .await?
335 + .ok_or(AppError::NotFound)?;
336 + if owner != req.user_id {
337 + return Err(AppError::Forbidden);
338 + }
339 +
340 + // Early quota check
341 + db::creator_tiers::check_presign_allowed(&state.db, req.user_id, file_type).await?;
342 +
343 + let s3_key = S3Client::generate_key(req.user_id, req.item_id, file_type, &req.file_name);
344 + let expires_in = 3600;
345 + let upload_url = s3
346 + .presign_upload(
347 + &s3_key,
348 + &req.content_type,
349 + Some(expires_in),
350 + Some(CACHE_CONTROL_IMMUTABLE),
351 + )
352 + .await?;
353 +
354 + Ok(Json(InternalPresignResponse {
355 + upload_url,
356 + s3_key,
357 + expires_in,
358 + cache_control: Some(CACHE_CONTROL_IMMUTABLE.to_string()),
359 + }))
360 + }
361 +
362 + // ── Confirm upload (for CLI upload pipeline) ──
363 +
364 + #[derive(Deserialize)]
365 + pub(super) struct InternalConfirmRequest {
366 + user_id: UserId,
367 + item_id: ItemId,
368 + file_type: String,
369 + s3_key: String,
370 + }
371 +
372 + #[derive(Serialize)]
373 + struct InternalConfirmResponse {
374 + success: bool,
375 + }
376 +
377 + /// POST /api/internal/upload/confirm
378 + ///
379 + /// Confirm a completed S3 upload: verify, scan, update DB. Used by the CLI upload pipeline.
380 + #[tracing::instrument(skip_all, name = "internal::confirm_upload")]
381 + pub(super) async fn confirm_upload(
382 + State(state): State<AppState>,
383 + _auth: ServiceAuth,
384 + Json(req): Json<InternalConfirmRequest>,
385 + ) -> Result<impl IntoResponse> {
386 + let s3 = state.require_s3()?;
387 +
388 + let file_type = FileType::from_str(&req.file_type)
389 + .map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
390 +
391 + // Verify user owns the item
392 + let owner = db::items::get_item_owner(&state.db, req.item_id)
393 + .await?
394 + .ok_or(AppError::NotFound)?;
395 + if owner != req.user_id {
396 + return Err(AppError::Forbidden);
397 + }
398 +
399 + // Verify the object exists in S3
400 + if !s3.object_exists(&req.s3_key).await? {
401 + return Err(AppError::BadRequest(
402 + "Upload not found. Please try uploading again.".to_string(),
403 + ));
404 + }
405 +
406 + // Enforce file size limit
407 + let file_size_bytes = s3.object_size(&req.s3_key).await?.unwrap_or(0);
408 + if file_size_bytes as u64 > file_type.max_size() {
409 + s3.delete_object(&req.s3_key).await.ok();
410 + return Err(AppError::BadRequest(format!(
411 + "File exceeds maximum size of {} MB",
412 + file_type.max_size() / (1024 * 1024)
413 + )));
414 + }
415 +
416 + // Enforce tier-based limits
417 + let max_storage = match db::creator_tiers::check_upload_allowed(
418 + &state.db,
419 + req.user_id,
420 + file_type,
421 + file_size_bytes,
422 + )
423 + .await
424 + {
425 + Ok(max) => max,
426 + Err(e) => {
427 + s3.delete_object(&req.s3_key).await.ok();
428 + return Err(e);
429 + }
430 + };
431 +
432 + // Scan + classify
433 + let (status, malware_err) =
434 + crate::routes::storage::scan_and_classify(&state, s3.as_ref(), &req.s3_key, file_type, req.user_id)
435 + .await?;
436 + db::scanning::update_item_scan_status(&state.db, req.item_id, status).await?;
437 + if let Some(err) = malware_err {
438 + return Err(err);
439 + }
440 +
441 + // Update the database with S3 key and file size
442 + let file_name = req.s3_key.rsplit('/').next().map(|s| s.to_string());
443 + match file_type {
444 + FileType::Audio => {
445 + db::items::update_item_audio_s3_key(&state.db, req.item_id, &req.s3_key).await?;
446 + db::items::update_item_audio_file_size(&state.db, req.item_id, file_size_bytes)
447 + .await?;
448 + }
449 + FileType::Download => {
450 + // Create a version to hold the download file (matches web flow)
451 + let version = db::versions::create_version(
452 + &state.db,
453 + req.item_id,
454 + "1.0",
455 + None,
456 + Some(&req.s3_key),
457 + Some(file_size_bytes),
458 + file_name.as_deref(),
459 + )
460 + .await?;
461 + db::scanning::update_version_scan_status(&state.db, version.id, status).await?;
462 + }
463 + FileType::Cover | FileType::Insertion => {
464 + return Err(AppError::BadRequest(
465 + "CLI upload only supports audio and download file types".to_string(),
466 + ));
467 + }
468 + }
469 +
470 + // Atomically increment storage
471 + db::creator_tiers::try_increment_storage(&state.db, req.user_id, file_size_bytes, max_storage)
472 + .await?;
473 +
474 + // Bump project cache
475 + if let Some(item) = db::items::get_item_by_id(&state.db, req.item_id).await? {
476 + let _ = db::projects::bump_cache_generation(&state.db, item.project_id).await;
477 + }
478 +
479 + tracing::info!(
480 + user = %req.user_id,
481 + item = %req.item_id,
482 + file_type = ?file_type,
483 + s3_key = %req.s3_key,
484 + size = file_size_bytes,
485 + "CLI upload confirmed"
486 + );
487 +
488 + Ok(Json(InternalConfirmResponse { success: true }))
489 + }
490 +
491 + // ── Item detail ──
492 +
493 + #[derive(Serialize)]
494 + struct ItemDetailResponse {
495 + id: ItemId,
496 + title: String,
497 + description: Option<String>,
498 + price_cents: i32,
499 + item_type: ItemType,
500 + is_public: bool,
Lines truncated
@@ -37,6 +37,7 @@ mod reports;
37 37 mod collections;
38 38 mod validate;
39 39 mod domains;
40 + mod internal;
40 41
41 42 use axum::{
42 43 extract::{Request, State},
@@ -356,10 +357,44 @@ pub fn api_routes() -> Router<AppState> {
356 357 config: validate_rate_limit,
357 358 });
358 359
360 + // Internal service-to-service routes (ServiceAuth, no rate limit)
361 + let internal_routes = Router::new()
362 + .route("/api/internal/ssh-key-lookup", get(internal::ssh_key_lookup))
363 + .route("/api/internal/creator/projects", get(internal::creator_projects))
364 + .route("/api/internal/creator/projects/{id}/items", get(internal::creator_project_items))
365 + .route("/api/internal/creator/stats", get(internal::creator_stats))
366 + .route("/api/internal/creator/items", post(internal::create_item))
367 + .route("/api/internal/upload/presign", post(internal::presign_upload))
368 + .route("/api/internal/upload/confirm", post(internal::confirm_upload))
369 + .route("/api/internal/creator/storage", get(internal::creator_storage))
370 + .route("/api/internal/creator/items/{id}", get(internal::get_item))
371 + .route("/api/internal/creator/items/{id}", put(internal::update_item))
372 + .route("/api/internal/creator/items/{id}", delete(internal::delete_item))
373 + .route("/api/internal/creator/items/{id}/publish", post(internal::publish_item))
374 + .route("/api/internal/creator/items/{id}/unpublish", post(internal::unpublish_item))
375 + .route("/api/internal/creator/items/{id}/versions", get(internal::item_versions))
376 + // Blog posts
377 + .route("/api/internal/creator/projects/{id}/blog", get(internal::list_blog_posts))
378 + .route("/api/internal/creator/blog", post(internal::create_blog_post))
379 + .route("/api/internal/creator/blog/{id}", delete(internal::delete_blog_post))
380 + // Promo codes
381 + .route("/api/internal/creator/promo-codes", get(internal::list_promo_codes).post(internal::create_promo_code))
382 + .route("/api/internal/creator/promo-codes/{id}", delete(internal::delete_promo_code))
383 + // License keys
384 + .route("/api/internal/creator/items/{id}/keys", get(internal::list_license_keys).post(internal::generate_license_key))
385 + .route("/api/internal/creator/keys/{id}/revoke", post(internal::revoke_license_key))
386 + // Analytics + export
387 + .route("/api/internal/creator/analytics", get(internal::creator_analytics))
388 + .route("/api/internal/creator/transactions", get(internal::creator_transactions))
389 + .route("/api/internal/creator/export/sales", get(internal::export_sales))
390 + // Settings
391 + .route("/api/internal/creator/ssh-keys", get(internal::list_ssh_keys));
392 +
359 393 write_routes
360 394 .merge(export_routes)
361 395 .merge(key_routes)
362 396 .merge(validate_routes)
363 397 .merge(read_routes)
398 + .merge(internal_routes)
364 399 .layer(axum::middleware::from_fn(json_error_layer))
365 400 }
@@ -159,7 +159,7 @@ pub struct ItemImageConfirmRequest {
159 159 /// and optionally a `MalwareDetected` error if the file was quarantined.
160 160 /// The caller must update the entity-specific scan status before propagating
161 161 /// the quarantine error.
162 - async fn scan_and_classify(
162 + pub(crate) async fn scan_and_classify(
163 163 state: &AppState,
164 164 s3: &dyn crate::storage::StorageBackend,
165 165 s3_key: &str,
@@ -55,6 +55,7 @@ impl From<db::DbDiscoverItemRow> for DiscoverItem {
55 55 id: i.id.to_string(),
56 56 name: i.title,
57 57 creator: i.username.to_string(),
58 + project: i.project_title,
58 59 item_type: i.item_type.to_string(),
59 60 primary_tag: i.primary_tag_name.unwrap_or_default(),
60 61 price,
@@ -251,6 +251,7 @@ pub struct DiscoverItem {
251 251 pub id: String,
252 252 pub name: String,
253 253 pub creator: String,
254 + pub project: String,
254 255 pub item_type: String,
255 256 pub primary_tag: String,
256 257 pub price: String,
@@ -19,7 +19,7 @@
19 19 {% for item in items %}
20 20 <a href="{% if item.is_free %}/i/{{ item.id }}{% else %}/purchase/{{ item.id }}{% endif %}" class="table-row">
21 21 <span class="row-type">{{ item.item_type }}</span>
22 - <span class="row-info"><span class="row-name">{{ item.name }}</span><span class="row-creator">{{ item.creator }}</span></span>
22 + <span class="row-info"><span class="row-name">{{ item.name }}</span><span class="row-creator">{{ item.project }} &middot; {{ item.creator }}</span></span>
23 23 <span class="row-category">{{ item.primary_tag }}</span>
24 24 <span class="row-price">{{ item.price }}</span>
25 25 <span class="row-date">{{ item.date }}</span>
@@ -54,7 +54,7 @@
54 54 <div class="grid-card-thumbnail">{{ item.item_type }}</div>
55 55 <div class="grid-card-content">
56 56 <div class="grid-card-title">{{ item.name }}</div>
57 - <div class="grid-card-meta">{{ item.creator }}</div>
57 + <div class="grid-card-meta">{{ item.project }} &middot; {{ item.creator }}</div>
58 58 <div class="grid-card-footer">
59 59 <span class="grid-card-category">{{ item.primary_tag }}</span>
60 60 <span class="grid-card-price">{{ item.price }}</span>
@@ -49,6 +49,7 @@ pub struct BuildOptions {
49 49 pub postmark_inbound_webhook_token: Option<String>,
50 50 pub mt_base_url: Option<String>,
51 51 pub internal_shared_secret: Option<String>,
52 + pub cli_service_token: Option<String>,
52 53 }
53 54
54 55 /// Full test harness: isolated database, in-process app, cookie-aware client.
@@ -232,6 +233,7 @@ impl TestHarness {
232 233 cdn_base_url: None,
233 234 postmark_inbound_webhook_token: opts.postmark_inbound_webhook_token,
234 235 internal_shared_secret: opts.internal_shared_secret.clone(),
236 + cli_service_token: opts.cli_service_token.clone(),
235 237 };
236 238
237 239 let email = EmailClient::new(EmailConfig {
@@ -74,6 +74,7 @@ pub async fn run(config: LoadConfig) {
74 74 cdn_base_url: None,
75 75 postmark_inbound_webhook_token: None,
76 76 internal_shared_secret: None,
77 + cli_service_token: None,
77 78 };
78 79
79 80 let email = EmailClient::new(EmailConfig {