Skip to main content

max / makenotwork

Scope internal-API identity to a signed SSH-authenticated assertion The internal creator API authenticated only with the shared ServiceAuth token plus a caller-supplied user_id, so a leaked token could act as any user. The server now mints a short-lived HMAC actor assertion (keyed by the server-only signing_secret) at ssh-key-lookup; the CLI forwards it as X-MNW-Actor. A new InternalActor extractor verifies it and is the sole source of the acting identity across the internal creator/upload/git endpoints -- the caller-supplied user_id fields are gone, so a leaked service token alone can no longer name an arbitrary user (assertions are only mintable after SSH-key auth). Server: crypto mint/verify (+ unit tests), InternalActor extractor, ssh-key-lookup returns actor_token, ~35 handlers derive identity from the extractor. CLI: UserInfo carries actor_token, MnwApiClient forwards X-MNW-Actor. Test harness sends the header; regression: internal confirm without an actor is rejected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 17:35 UTC
Commit: 4192670238b071eb877d2a46486ffd01b23560b5
Parent: 4835beb
13 files changed, +439 insertions, -147 deletions
@@ -11,6 +11,11 @@ pub struct UserInfo {
11 11 pub creator_tier: Option<String>,
12 12 pub can_create_projects: bool,
13 13 pub suspended: bool,
14 + /// Signed actor assertion the server mints at lookup; forwarded as
15 + /// `X-MNW-Actor` on internal calls so the server derives identity from an
16 + /// SSH-authenticated token rather than a caller-supplied `user_id`.
17 + #[serde(default)]
18 + pub actor_token: String,
14 19 }
15 20
16 21 /// A creator's project with item count and revenue.
@@ -305,6 +310,9 @@ pub struct MnwApiClient {
305 310 http: reqwest::Client,
306 311 base_url: String,
307 312 service_token: String,
313 + /// Set once per session from the SSH-key-lookup response; forwarded on
314 + /// internal creator calls as `X-MNW-Actor`.
315 + actor_token: Option<String>,
308 316 }
309 317
310 318 impl MnwApiClient {
@@ -318,9 +326,21 @@ impl MnwApiClient {
318 326 http,
319 327 base_url,
320 328 service_token,
329 + actor_token: None,
321 330 }
322 331 }
323 332
333 + /// Record the actor assertion for the authenticated session. Subsequent
334 + /// internal calls forward it so the server can verify the acting identity.
335 + pub fn set_actor_token(&mut self, token: String) {
336 + self.actor_token = Some(token);
337 + }
338 +
339 + /// The `X-MNW-Actor` header value for internal calls (empty before lookup).
340 + fn actor_header(&self) -> &str {
341 + self.actor_token.as_deref().unwrap_or("")
342 + }
343 +
324 344 /// Look up a user by SSH key fingerprint.
325 345 /// Returns `Ok(Some(info))` if found, `Ok(None)` if not found.
326 346 pub async fn lookup_ssh_key(&self, fingerprint: &str) -> anyhow::Result<Option<UserInfo>> {
@@ -329,6 +349,7 @@ impl MnwApiClient {
329 349 .http
330 350 .get(&url)
331 351 .bearer_auth(&self.service_token)
352 + .header("X-MNW-Actor", self.actor_header())
332 353 .query(&[("fingerprint", fingerprint)])
333 354 .send()
334 355 .await?;
@@ -352,6 +373,7 @@ impl MnwApiClient {
352 373 .http
353 374 .get(&url)
354 375 .bearer_auth(&self.service_token)
376 + .header("X-MNW-Actor", self.actor_header())
355 377 .query(&[("user_id", user_id)])
356 378 .send()
357 379 .await?;
@@ -380,6 +402,7 @@ impl MnwApiClient {
380 402 .http
381 403 .post(&url)
382 404 .bearer_auth(&self.service_token)
405 + .header("X-MNW-Actor", self.actor_header())
383 406 .json(&body)
384 407 .send()
385 408 .await?;
@@ -401,6 +424,7 @@ impl MnwApiClient {
401 424 .http
402 425 .get(&url)
403 426 .bearer_auth(&self.service_token)
427 + .header("X-MNW-Actor", self.actor_header())
404 428 .query(&[("user_id", user_id)])
405 429 .send()
406 430 .await?;
@@ -415,6 +439,7 @@ impl MnwApiClient {
415 439 .http
416 440 .get(&url)
417 441 .bearer_auth(&self.service_token)
442 + .header("X-MNW-Actor", self.actor_header())
418 443 .query(&[("user_id", user_id), ("range", range)])
419 444 .send()
420 445 .await?;
@@ -429,6 +454,7 @@ impl MnwApiClient {
429 454 .http
430 455 .get(&url)
431 456 .bearer_auth(&self.service_token)
457 + .header("X-MNW-Actor", self.actor_header())
432 458 .query(&[("user_id", user_id)])
433 459 .send()
434 460 .await?;
@@ -450,6 +476,7 @@ impl MnwApiClient {
450 476 .http
451 477 .post(&url)
452 478 .bearer_auth(&self.service_token)
479 + .header("X-MNW-Actor", self.actor_header())
453 480 .json(&serde_json::json!({
454 481 "user_id": user_id,
455 482 "project_id": project_id,
@@ -477,6 +504,7 @@ impl MnwApiClient {
477 504 .http
478 505 .post(&url)
479 506 .bearer_auth(&self.service_token)
507 + .header("X-MNW-Actor", self.actor_header())
480 508 .json(&serde_json::json!({
481 509 "user_id": user_id,
482 510 "item_id": item_id,
@@ -503,6 +531,7 @@ impl MnwApiClient {
503 531 .http
504 532 .post(&url)
505 533 .bearer_auth(&self.service_token)
534 + .header("X-MNW-Actor", self.actor_header())
506 535 .json(&serde_json::json!({
507 536 "user_id": user_id,
508 537 "item_id": item_id,
@@ -531,6 +560,7 @@ impl MnwApiClient {
531 560 .http
532 561 .get(&url)
533 562 .bearer_auth(&self.service_token)
563 + .header("X-MNW-Actor", self.actor_header())
534 564 .query(&[("user_id", user_id)])
535 565 .send()
536 566 .await?;
@@ -567,6 +597,7 @@ impl MnwApiClient {
567 597 .http
568 598 .put(&url)
569 599 .bearer_auth(&self.service_token)
600 + .header("X-MNW-Actor", self.actor_header())
570 601 .json(&body)
571 602 .send()
572 603 .await?;
@@ -581,6 +612,7 @@ impl MnwApiClient {
581 612 .http
582 613 .delete(&url)
583 614 .bearer_auth(&self.service_token)
615 + .header("X-MNW-Actor", self.actor_header())
584 616 .query(&[("user_id", user_id)])
585 617 .send()
586 618 .await?;
@@ -602,6 +634,7 @@ impl MnwApiClient {
602 634 .http
603 635 .post(&url)
604 636 .bearer_auth(&self.service_token)
637 + .header("X-MNW-Actor", self.actor_header())
605 638 .json(&serde_json::json!({ "user_id": user_id }))
606 639 .send()
607 640 .await?;
@@ -623,6 +656,7 @@ impl MnwApiClient {
623 656 .http
624 657 .post(&url)
625 658 .bearer_auth(&self.service_token)
659 + .header("X-MNW-Actor", self.actor_header())
626 660 .json(&serde_json::json!({ "user_id": user_id }))
627 661 .send()
628 662 .await?;
@@ -644,6 +678,7 @@ impl MnwApiClient {
644 678 .http
645 679 .get(&url)
646 680 .bearer_auth(&self.service_token)
681 + .header("X-MNW-Actor", self.actor_header())
647 682 .query(&[("user_id", user_id)])
648 683 .send()
649 684 .await?;
@@ -695,6 +730,7 @@ impl MnwApiClient {
695 730 .http
696 731 .get(&url)
697 732 .bearer_auth(&self.service_token)
733 + .header("X-MNW-Actor", self.actor_header())
698 734 .query(&[("user_id", user_id)])
699 735 .send()
700 736 .await?;
@@ -727,6 +763,7 @@ impl MnwApiClient {
727 763 .http
728 764 .post(&url)
729 765 .bearer_auth(&self.service_token)
766 + .header("X-MNW-Actor", self.actor_header())
730 767 .json(&body)
731 768 .send()
732 769 .await?;
@@ -741,6 +778,7 @@ impl MnwApiClient {
741 778 .http
742 779 .delete(&url)
743 780 .bearer_auth(&self.service_token)
781 + .header("X-MNW-Actor", self.actor_header())
744 782 .query(&[("user_id", user_id)])
745 783 .send()
746 784 .await?;
@@ -757,6 +795,7 @@ impl MnwApiClient {
757 795 .http
758 796 .get(&url)
759 797 .bearer_auth(&self.service_token)
798 + .header("X-MNW-Actor", self.actor_header())
760 799 .query(&[("user_id", user_id)])
761 800 .send()
762 801 .await?;
@@ -793,6 +832,7 @@ impl MnwApiClient {
793 832 .http
794 833 .post(&url)
795 834 .bearer_auth(&self.service_token)
835 + .header("X-MNW-Actor", self.actor_header())
796 836 .json(&body)
797 837 .send()
798 838 .await?;
@@ -810,6 +850,7 @@ impl MnwApiClient {
810 850 .http
811 851 .delete(&url)
812 852 .bearer_auth(&self.service_token)
853 + .header("X-MNW-Actor", self.actor_header())
813 854 .query(&[("user_id", user_id)])
814 855 .send()
815 856 .await?;
@@ -833,6 +874,7 @@ impl MnwApiClient {
833 874 .http
834 875 .get(&url)
835 876 .bearer_auth(&self.service_token)
877 + .header("X-MNW-Actor", self.actor_header())
836 878 .query(&[("user_id", user_id)])
837 879 .send()
838 880 .await?;
@@ -854,6 +896,7 @@ impl MnwApiClient {
854 896 .http
855 897 .post(&url)
856 898 .bearer_auth(&self.service_token)
899 + .header("X-MNW-Actor", self.actor_header())
857 900 .json(&serde_json::json!({ "user_id": user_id }))
858 901 .send()
859 902 .await?;
@@ -875,6 +918,7 @@ impl MnwApiClient {
875 918 .http
876 919 .post(&url)
877 920 .bearer_auth(&self.service_token)
921 + .header("X-MNW-Actor", self.actor_header())
878 922 .json(&serde_json::json!({ "user_id": user_id }))
879 923 .send()
880 924 .await?;
@@ -895,6 +939,7 @@ impl MnwApiClient {
895 939 .http
896 940 .get(&url)
897 941 .bearer_auth(&self.service_token)
942 + .header("X-MNW-Actor", self.actor_header())
898 943 .query(&[("user_id", user_id), ("range", range)])
899 944 .send()
900 945 .await?;
@@ -909,6 +954,7 @@ impl MnwApiClient {
909 954 .http
910 955 .get(&url)
911 956 .bearer_auth(&self.service_token)
957 + .header("X-MNW-Actor", self.actor_header())
912 958 .query(&[("user_id", user_id)])
913 959 .send()
914 960 .await?;
@@ -923,6 +969,7 @@ impl MnwApiClient {
923 969 .http
924 970 .get(&url)
925 971 .bearer_auth(&self.service_token)
972 + .header("X-MNW-Actor", self.actor_header())
926 973 .query(&[("user_id", user_id)])
927 974 .send()
928 975 .await?;
@@ -945,6 +992,7 @@ impl MnwApiClient {
945 992 .http
946 993 .post(&url)
947 994 .bearer_auth(&self.service_token)
995 + .header("X-MNW-Actor", self.actor_header())
948 996 .json(&serde_json::json!({
949 997 "user_id": user_id,
950 998 "operation": operation,
@@ -978,6 +1026,7 @@ impl MnwApiClient {
978 1026 .http
979 1027 .get(&url)
980 1028 .bearer_auth(&self.service_token)
1029 + .header("X-MNW-Actor", self.actor_header())
981 1030 .query(&[("user_id", user_id)])
982 1031 .send()
983 1032 .await?;
@@ -990,6 +1039,7 @@ impl MnwApiClient {
990 1039 pub async fn list_item_tags(&self, user_id: &str, item_id: &str) -> anyhow::Result<Vec<TagInfo>> {
991 1040 let url = format!("{}/api/internal/creator/items/{}/tags", self.base_url, item_id);
992 1041 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1042 + .header("X-MNW-Actor", self.actor_header())
993 1043 .query(&[("user_id", user_id)]).send().await?;
994 1044 json_response(resp, "list_item_tags").await
995 1045 }
@@ -997,6 +1047,7 @@ impl MnwApiClient {
997 1047 pub async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> {
998 1048 let url = format!("{}/api/internal/tags/search", self.base_url);
999 1049 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1050 + .header("X-MNW-Actor", self.actor_header())
1000 1051 .query(&[("q", query)]).send().await?;
1001 1052 json_response(resp, "search_tags").await
1002 1053 }
@@ -1004,6 +1055,7 @@ impl MnwApiClient {
1004 1055 pub async fn add_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
1005 1056 let url = format!("{}/api/internal/creator/items/tags", self.base_url);
1006 1057 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1058 + .header("X-MNW-Actor", self.actor_header())
1007 1059 .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
1008 1060 .send().await?;
1009 1061 empty_response(resp, "add_item_tag").await
@@ -1012,6 +1064,7 @@ impl MnwApiClient {
1012 1064 pub async fn remove_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
1013 1065 let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url);
1014 1066 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1067 + .header("X-MNW-Actor", self.actor_header())
1015 1068 .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
1016 1069 .send().await?;
1017 1070 empty_response(resp, "remove_item_tag").await
@@ -1022,6 +1075,7 @@ impl MnwApiClient {
1022 1075 pub async fn send_broadcast(&self, user_id: &str, subject: &str, body: &str) -> anyhow::Result<BroadcastResult> {
1023 1076 let url = format!("{}/api/internal/creator/broadcast", self.base_url);
1024 1077 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1078 + .header("X-MNW-Actor", self.actor_header())
1025 1079 .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body}))
1026 1080 .send().await?;
1027 1081 json_response(resp, "send_broadcast").await
@@ -1032,6 +1086,7 @@ impl MnwApiClient {
1032 1086 pub async fn list_tiers(&self, user_id: &str, project_id: &str) -> anyhow::Result<Vec<TierInfo>> {
1033 1087 let url = format!("{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id);
1034 1088 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1089 + .header("X-MNW-Actor", self.actor_header())
1035 1090 .query(&[("user_id", user_id)]).send().await?;
1036 1091 json_response(resp, "list_tiers").await
1037 1092 }
@@ -1041,6 +1096,7 @@ impl MnwApiClient {
1041 1096 pub async fn list_collections(&self, user_id: &str) -> anyhow::Result<Vec<CollectionInfo>> {
1042 1097 let url = format!("{}/api/internal/creator/collections", self.base_url);
1043 1098 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1099 + .header("X-MNW-Actor", self.actor_header())
1044 1100 .query(&[("user_id", user_id)]).send().await?;
1045 1101 json_response(resp, "list_collections").await
1046 1102 }
@@ -1048,6 +1104,7 @@ impl MnwApiClient {
1048 1104 pub async fn create_collection(&self, user_id: &str, slug: &str, title: &str) -> anyhow::Result<serde_json::Value> {
1049 1105 let url = format!("{}/api/internal/creator/collections", self.base_url);
1050 1106 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1107 + .header("X-MNW-Actor", self.actor_header())
1051 1108 .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title}))
1052 1109 .send().await?;
1053 1110 json_response(resp, "create_collection").await
@@ -1056,6 +1113,7 @@ impl MnwApiClient {
1056 1113 pub async fn delete_collection(&self, user_id: &str, collection_id: &str) -> anyhow::Result<()> {
1057 1114 let url = format!("{}/api/internal/creator/collections/{}", self.base_url, collection_id);
1058 1115 let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1116 + .header("X-MNW-Actor", self.actor_header())
1059 1117 .query(&[("user_id", user_id)]).send().await?;
1060 1118 empty_response(resp, "delete_collection").await
1061 1119 }
@@ -1065,6 +1123,7 @@ impl MnwApiClient {
1065 1123 pub async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> {
1066 1124 let url = format!("{}/api/internal/creator/domain", self.base_url);
1067 1125 let resp = self.http.get(&url).bearer_auth(&self.service_token)
1126 + .header("X-MNW-Actor", self.actor_header())
1068 1127 .query(&[("user_id", user_id)]).send().await?;
1069 1128 let val: serde_json::Value = json_response(resp, "get_domain").await?;
1070 1129 if val.is_null() { return Ok(None); }
@@ -1074,6 +1133,7 @@ impl MnwApiClient {
1074 1133 pub async fn add_domain(&self, user_id: &str, domain: &str) -> anyhow::Result<DomainInfo> {
1075 1134 let url = format!("{}/api/internal/creator/domain", self.base_url);
1076 1135 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1136 + .header("X-MNW-Actor", self.actor_header())
1077 1137 .json(&serde_json::json!({"user_id": user_id, "domain": domain}))
1078 1138 .send().await?;
1079 1139 json_response(resp, "add_domain").await
@@ -1082,6 +1142,7 @@ impl MnwApiClient {
1082 1142 pub async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> {
1083 1143 let url = format!("{}/api/internal/creator/domain/verify", self.base_url);
1084 1144 let resp = self.http.post(&url).bearer_auth(&self.service_token)
1145 + .header("X-MNW-Actor", self.actor_header())
1085 1146 .query(&[("user_id", user_id)]).send().await?;
1086 1147 json_response(resp, "verify_domain").await
1087 1148 }
@@ -1089,6 +1150,7 @@ impl MnwApiClient {
1089 1150 pub async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> {
1090 1151 let url = format!("{}/api/internal/creator/domain", self.base_url);
1091 1152 let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1153 + .header("X-MNW-Actor", self.actor_header())
1092 1154 .query(&[("user_id", user_id)]).send().await?;
1093 1155 empty_response(resp, "remove_domain").await
1094 1156 }
@@ -107,6 +107,10 @@ impl russh::server::Handler for MnwHandler {
107 107 partial_success: false,
108 108 });
109 109 }
110 + // Forward the SSH-authenticated actor assertion on this session's
111 + // internal calls so the server verifies identity from the token,
112 + // not a caller-supplied user_id.
113 + self.api.set_actor_token(info.actor_token.clone());
110 114 self.user = Some(info);
111 115 Ok(Auth::Accept)
112 116 }
@@ -425,6 +425,54 @@ impl FromRequestParts<crate::AppState> for ServiceAuth {
425 425 }
426 426 }
427 427
428 + /// The SSH-authenticated user identity behind an internal-API request.
429 + ///
430 + /// Sourced from the `X-MNW-Actor` header, a signed assertion the server mints
431 + /// during `ssh-key-lookup` (after authenticating the user by SSH key) and the
432 + /// CLI forwards. Because the assertion is keyed by the server-only
433 + /// `signing_secret`, a leaked `ServiceAuth` token cannot forge one for another
434 + /// user — so internal handlers derive identity from this, never from a
435 + /// caller-supplied `user_id` field. Handlers use `actor.user_id()` for scoping
436 + /// and `actor.ensure_owns(resource.user_id)?` for ownership checks.
437 + pub struct InternalActor(pub UserId);
438 +
439 + impl InternalActor {
440 + pub fn user_id(&self) -> UserId {
441 + self.0
442 + }
443 +
444 + /// Reject with 403 unless the asserted identity owns the resource.
445 + pub fn ensure_owns(&self, owner: UserId) -> Result<(), AppError> {
446 + if self.0 == owner {
447 + Ok(())
448 + } else {
449 + Err(AppError::Forbidden)
450 + }
451 + }
452 + }
453 +
454 + impl FromRequestParts<crate::AppState> for InternalActor {
455 + type Rejection = AppError;
456 +
457 + async fn from_request_parts(
458 + parts: &mut Parts,
459 + state: &crate::AppState,
460 + ) -> Result<Self, Self::Rejection> {
461 + let token = parts
462 + .headers
463 + .get("x-mnw-actor")
464 + .and_then(|v| v.to_str().ok())
465 + .ok_or(AppError::Unauthorized)?;
466 +
467 + let now = chrono::Utc::now().timestamp();
468 + let user_id =
469 + crate::crypto::verify_internal_actor_token(token, &state.config.signing_secret, now)
470 + .ok_or(AppError::Unauthorized)?;
471 +
472 + Ok(InternalActor(user_id))
473 + }
474 + }
475 +
428 476 /// Hash a password using Argon2id.
429 477 ///
430 478 /// Production: 46 MiB, 2 iterations (~600ms). With `fast-tests` feature: 8 MiB, 1 iteration (~10ms).
@@ -13,6 +13,10 @@ pub const DB_MAX_LIFETIME_SECS: u64 = 1800;
13 13 /// Prune idle connections after 10 minutes.
14 14 pub const DB_IDLE_TIMEOUT_SECS: u64 = 600;
15 15
16 + /// Lifetime of an internal-API actor assertion, minted at `ssh-key-lookup` and
17 + /// forwarded by the CLI for the session. 24h comfortably exceeds any SSH session.
18 + pub const INTERNAL_ACTOR_TTL_SECS: i64 = 86_400;
19 +
16 20 // -- Sessions --
17 21 pub const SESSION_EXPIRY_DAYS: i64 = 7;
18 22 /// Skip DB touch if validated within this window. Doubles as the upper bound on
@@ -171,6 +171,58 @@ pub fn invite_code_hash(code: &str) -> String {
171 171 sha256_hex(code)
172 172 }
173 173
174 + /// Domain-separation prefix for the internal-API actor assertion HMAC.
175 + const INTERNAL_ACTOR_DOMAIN: &str = "internal-actor:v1";
176 +
177 + /// Mint a signed actor assertion binding `user_id` for the internal API, valid
178 + /// until `expiry_unix`. Format: `{user_id}.{expiry}.{hmac_hex}`, HMAC-SHA256
179 + /// over `internal-actor:v1:{user_id}:{expiry}` keyed by `signing_secret`.
180 + ///
181 + /// The server mints this during `ssh-key-lookup` (after authenticating the user
182 + /// by SSH key) and the CLI forwards it on internal calls. Because the key is the
183 + /// server-only `signing_secret` (never held by the CLI or embedded in the shared
184 + /// `cli_service_token`), a leaked service token alone cannot forge an assertion
185 + /// for another user — so it cannot act as an arbitrary user.
186 + pub fn mint_internal_actor_token(
187 + user_id: crate::db::UserId,
188 + expiry_unix: i64,
189 + signing_secret: &str,
190 + ) -> String {
191 + use hmac::{Hmac, Mac};
192 + use sha2::Sha256;
193 + let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry_unix}");
194 + let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes())
195 + .expect("HMAC-SHA256 accepts any key length");
196 + mac.update(message.as_bytes());
197 + format!("{user_id}.{expiry_unix}.{}", hex::encode(mac.finalize().into_bytes()))
198 + }
199 +
200 + /// Verify an actor assertion and return the asserted `UserId` if the signature
201 + /// is valid (constant-time) and the token has not expired at `now_unix`.
202 + pub fn verify_internal_actor_token(
203 + token: &str,
204 + signing_secret: &str,
205 + now_unix: i64,
206 + ) -> Option<crate::db::UserId> {
207 + use hmac::{Hmac, Mac};
208 + use sha2::Sha256;
209 +
210 + let (user_part, rest) = token.split_once('.')?;
211 + let (expiry_part, sig_hex) = rest.split_once('.')?;
212 + let user_id = crate::db::UserId::from_uuid(user_part.parse().ok()?);
213 + let expiry: i64 = expiry_part.parse().ok()?;
214 + if expiry <= now_unix {
215 + return None;
216 + }
217 + let sig = hex::decode(sig_hex).ok()?;
218 + let message = format!("{INTERNAL_ACTOR_DOMAIN}:{user_id}:{expiry}");
219 + let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes())
220 + .expect("HMAC-SHA256 accepts any key length");
221 + mac.update(message.as_bytes());
222 + mac.verify_slice(&sig).ok()?;
223 + Some(user_id)
224 + }
225 +
174 226 /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`.
175 227 fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String {
176 228 use hmac::{Hmac, Mac};
@@ -218,6 +270,46 @@ pub fn verify_feed_signature(
218 270 mod tests {
219 271 use super::*;
220 272
273 + // ── Internal-API actor assertions ──
274 +
275 + #[test]
276 + fn internal_actor_token_round_trips() {
277 + let uid = crate::db::UserId::new();
278 + let secret = "a-stable-signing-secret-at-least-32c";
279 + let tok = mint_internal_actor_token(uid, 10_000_000_000, secret);
280 + assert_eq!(verify_internal_actor_token(&tok, secret, 1_000), Some(uid));
281 + }
282 +
283 + #[test]
284 + fn internal_actor_token_rejects_expired() {
285 + let uid = crate::db::UserId::new();
286 + let secret = "a-stable-signing-secret-at-least-32c";
287 + let tok = mint_internal_actor_token(uid, 1_000, secret);
288 + assert_eq!(verify_internal_actor_token(&tok, secret, 2_000), None);
289 + }
290 +
291 + #[test]
292 + fn internal_actor_token_rejects_wrong_secret() {
293 + let uid = crate::db::UserId::new();
294 + let tok = mint_internal_actor_token(uid, 10_000_000_000, "secret-one-that-is-long-enough!!");
295 + assert_eq!(
296 + verify_internal_actor_token(&tok, "secret-two-that-is-long-enough!!", 1_000),
297 + None
298 + );
299 + }
300 +
301 + #[test]
302 + fn internal_actor_token_rejects_tampered_user() {
303 + let uid = crate::db::UserId::new();
304 + let other = crate::db::UserId::new();
305 + let secret = "a-stable-signing-secret-at-least-32c";
306 + let tok = mint_internal_actor_token(uid, 10_000_000_000, secret);
307 + // Swap the user_id component; the signature no longer matches.
308 + let rest = tok.split_once('.').unwrap().1;
309 + let forged = format!("{other}.{rest}");
310 + assert_eq!(verify_internal_actor_token(&forged, secret, 1_000), None);
311 + }
312 +
221 313 // ── TOTP secret encryption at rest ──
222 314
223 315 #[test]
@@ -2,27 +2,26 @@
2 2 //! collections, custom domains).
3 3
4 4 use axum::extract::State;
5 + use crate::auth::InternalActor;
5 6 use axum::response::IntoResponse;
6 7 use axum::Json;
7 8 use serde::{Deserialize, Serialize};
8 9
9 10 use crate::auth::ServiceAuth;
10 11 use crate::constants;
11 - use crate::db::{self, CollectionId, ItemId, ProjectId, Slug, UserId};
12 + use crate::db::{self, CollectionId, ItemId, ProjectId, Slug};
12 13 use crate::error::{AppError, Result, ResultExt};
13 14 use crate::AppState;
14 15
15 16 /// User ID query parameter shared by all internal endpoints.
16 17 #[derive(Deserialize)]
17 18 pub(super) struct UserIdParam {
18 - pub user_id: UserId,
19 19 }
20 20
21 21 // ── Tags ──
22 22
23 23 #[derive(Deserialize)]
24 24 pub(super) struct TagItemRequest {
25 - user_id: UserId,
26 25 item_id: ItemId,
27 26 tag_id: String,
28 27 }
@@ -39,9 +38,10 @@ struct TagView {
39 38 #[tracing::instrument(skip_all, name = "internal::list_item_tags")]
40 39 pub(super) async fn list_item_tags(
41 40 State(state): State<AppState>,
41 + actor: InternalActor,
42 42 _auth: ServiceAuth,
43 43 axum::extract::Path(item_id): axum::extract::Path<ItemId>,
44 - axum::extract::Query(q): axum::extract::Query<UserIdParam>,
44 + axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
45 45 ) -> Result<impl IntoResponse> {
46 46 let item = db::items::get_item_by_id(&state.db, item_id)
47 47 .await?
@@ -49,7 +49,7 @@ pub(super) async fn list_item_tags(
49 49 let project = db::projects::get_project_by_id(&state.db, item.project_id)
50 50 .await?
51 51 .ok_or(AppError::NotFound)?;
52 - if project.user_id != q.user_id {
52 + if project.user_id != actor.user_id() {
53 53 return Err(AppError::Forbidden);
54 54 }
55 55
@@ -71,6 +71,7 @@ pub(super) async fn list_item_tags(
71 71 #[tracing::instrument(skip_all, name = "internal::add_item_tag")]
72 72 pub(super) async fn add_item_tag(
73 73 State(state): State<AppState>,
74 + actor: InternalActor,
74 75 _auth: ServiceAuth,
75 76 Json(req): Json<TagItemRequest>,
76 77 ) -> Result<impl IntoResponse> {
@@ -80,7 +81,7 @@ pub(super) async fn add_item_tag(
80 81 let project = db::projects::get_project_by_id(&state.db, item.project_id)
81 82 .await?
82 83 .ok_or(AppError::NotFound)?;
83 - if project.user_id != req.user_id {
84 + if project.user_id != actor.user_id() {
84 85 return Err(AppError::Forbidden);
85 86 }
86 87
@@ -101,6 +102,7 @@ pub(super) async fn add_item_tag(
101 102 #[tracing::instrument(skip_all, name = "internal::remove_item_tag")]
102 103 pub(super) async fn remove_item_tag(
103 104 State(state): State<AppState>,
105 + actor: InternalActor,
104 106 _auth: ServiceAuth,
105 107 Json(req): Json<TagItemRequest>,
106 108 ) -> Result<impl IntoResponse> {
@@ -110,7 +112,7 @@ pub(super) async fn remove_item_tag(
110 112 let project = db::projects::get_project_by_id(&state.db, item.project_id)
111 113 .await?
112 114 .ok_or(AppError::NotFound)?;
113 - if project.user_id != req.user_id {
115 + if project.user_id != actor.user_id() {
114 116 return Err(AppError::Forbidden);
115 117 }
116 118
@@ -152,7 +154,6 @@ pub(super) async fn search_tags(
152 154
153 155 #[derive(Deserialize)]
154 156 pub(super) struct BroadcastRequest {
155 - user_id: UserId,
156 157 subject: String,
157 158 body: String,
158 159 }
@@ -161,6 +162,7 @@ pub(super) struct BroadcastRequest {
161 162 #[tracing::instrument(skip_all, name = "internal::send_broadcast")]
162 163 pub(super) async fn send_broadcast(
163 164 State(state): State<AppState>,
165 + actor: InternalActor,
164 166 _auth: ServiceAuth,
165 167 Json(req): Json<BroadcastRequest>,
166 168 ) -> Result<impl IntoResponse> {
@@ -171,7 +173,7 @@ pub(super) async fn send_broadcast(
171 173 return Err(AppError::validation("Body must be 1-5000 characters".to_string()));
172 174 }
173 175
174 - let db_user = db::users::get_user_by_id(&state.db, req.user_id)
176 + let db_user = db::users::get_user_by_id(&state.db, actor.user_id())
175 177 .await?
176 178 .ok_or(AppError::NotFound)?;
177 179
@@ -179,7 +181,7 @@ pub(super) async fn send_broadcast(
179 181 return Err(AppError::Forbidden);
180 182 }
181 183
182 - let set = db::users::try_set_broadcast_at(&state.db, req.user_id).await?;
184 + let set = db::users::try_set_broadcast_at(&state.db, actor.user_id()).await?;
183 185 if !set {
184 186 return Err(AppError::validation("You can only send one broadcast per 24 hours".to_string()));
185 187 }
@@ -187,12 +189,12 @@ pub(super) async fn send_broadcast(
187 189 // Enforce the broadcast recipient cap at the type level — the same
188 190 // `BoundedRecipients` seal the public twin (routes/api/users/broadcast.rs)
189 191 // uses, so the two handlers can no longer drift on the cap check.
190 - let followers = db::follows::get_follower_emails(&state.db, req.user_id).await?;
192 + let followers = db::follows::get_follower_emails(&state.db, actor.user_id()).await?;
191 193 let recipients = match crate::email::BoundedRecipients::new(followers) {
192 194 Ok(r) => r,
193 195 Err(count) => {
194 196 // Roll back the 24h rate-limit slot so the creator can retry once the cap is lifted.
195 - let _ = db::users::clear_broadcast_at(&state.db, req.user_id).await;
197 + let _ = db::users::clear_broadcast_at(&state.db, actor.user_id()).await;
196 198 return Err(AppError::validation(format!(
197 199 "Broadcast would reach {count} followers, above the per-send limit of 10,000. \
198 200 Email info@makenot.work to lift the cap for your account."
@@ -208,7 +210,7 @@ pub(super) async fn send_broadcast(
208 210 .to_string();
209 211 let host_url = state.config.host_url.clone();
210 212 let signing_secret = state.config.signing_secret.clone();
211 - let creator_id = req.user_id;
213 + let creator_id = actor.user_id();
212 214 let subject = req.subject.clone();
213 215 let body = req.body.clone();
214 216 let email_client = state.email.clone();
@@ -273,14 +275,15 @@ struct TierView {
273 275 #[tracing::instrument(skip_all, name = "internal::list_tiers")]
274 276 pub(super) async fn list_tiers(
275 277 State(state): State<AppState>,
278 + actor: InternalActor,
276 279 _auth: ServiceAuth,
277 280 axum::extract::Path(project_id): axum::extract::Path<ProjectId>,
278 - axum::extract::Query(q): axum::extract::Query<UserIdParam>,
281 + axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
279 282 ) -> Result<impl IntoResponse> {
280 283 let project = db::projects::get_project_by_id(&state.db, project_id)
281 284 .await?
282 285 .ok_or(AppError::NotFound)?;
283 - if project.user_id != q.user_id {
286 + if project.user_id != actor.user_id() {
284 287 return Err(AppError::Forbidden);
285 288 }
286 289
@@ -303,7 +306,6 @@ pub(super) async fn list_tiers(
303 306
304 307 #[derive(Deserialize)]
305 308 pub(super) struct CreateCollectionRequest {
306 - user_id: UserId,
307 309 slug: String,
308 310 title: String,
309 311 description: Option<String>,
@@ -324,10 +326,11 @@ struct CollectionView {
324 326 #[tracing::instrument(skip_all, name = "internal::list_collections")]
325 327 pub(super) async fn list_collections(
326 328 State(state): State<AppState>,
329 + actor: InternalActor,
327 330 _auth: ServiceAuth,
328 - axum::extract::Query(q): axum::extract::Query<UserIdParam>,
331 + axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
329 332 ) -> Result<impl IntoResponse> {
330 - let collections = db::collections::get_collections_by_user(&state.db, q.user_id).await?;
333 + let collections = db::collections::get_collections_by_user(&state.db, actor.user_id()).await?;
331 334 let views: Vec<CollectionView> = collections
332 335 .iter()
333 336 .map(|c| CollectionView {
@@ -347,6 +350,7 @@ pub(super) async fn list_collections(
347 350 #[tracing::instrument(skip_all, name = "internal::create_collection")]
348 351 pub(super) async fn create_collection(
349 352 State(state): State<AppState>,
353 + actor: InternalActor,
350 354 _auth: ServiceAuth,
351 355 Json(req): Json<CreateCollectionRequest>,
352 356 ) -> Result<impl IntoResponse> {
@@ -355,7 +359,7 @@ pub(super) async fn create_collection(
355 359
356 360 let collection = db::collections::create_collection(
357 361 &state.db,
358 - req.user_id,
362 + actor.user_id(),
359 363 &slug,
360 364 &req.title,
361 365 req.description.as_deref(),
@@ -373,14 +377,15 @@ pub(super) async fn create_collection(
373 377 #[tracing::instrument(skip_all, name = "internal::delete_collection")]
374 378 pub(super) async fn delete_collection(
375 379 State(state): State<AppState>,
380 + actor: InternalActor,
376 381 _auth: ServiceAuth,
377 382 axum::extract::Path(collection_id): axum::extract::Path<CollectionId>,
378 - axum::extract::Query(q): axum::extract::Query<UserIdParam>,
383 + axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
379 384 ) -> Result<impl IntoResponse> {
380 385 let collection = db::collections::get_collection_by_id(&state.db, collection_id)
381 386 .await?
382 387 .ok_or(AppError::NotFound)?;
383 - if collection.user_id != q.user_id {
388 + if collection.user_id != actor.user_id() {
384 389 return Err(AppError::Forbidden);
385 390 }
386 391
@@ -393,7 +398,6 @@ pub(super) async fn delete_collection(
393 398
394 399 #[derive(Deserialize)]
395 400 pub(super) struct AddDomainRequest {
396 - user_id: UserId,
397 401 domain: String,
398 402 }
399 403
@@ -401,10 +405,11 @@ pub(super) struct AddDomainRequest {
401 405 #[tracing::instrument(skip_all, name = "internal::get_domain")]
402 406 pub(super) async fn get_domain(
403 407 State(state): State<AppState>,
408 + actor: InternalActor,
404 409 _auth: ServiceAuth,
405 - axum::extract::Query(q): axum::extract::Query<UserIdParam>,
410 + axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
406 411 ) -> Result<impl IntoResponse> {
407 - let domain = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id).await?;
412 + let domain = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id()).await?;
408 413 match domain {
409 414 Some(d) => Ok(Json(serde_json::json!({
410 415 "id": d.id.to_string(),
@@ -420,6 +425,7 @@ pub(super) async fn get_domain(
420 425 #[tracing::instrument(skip_all, name = "internal::add_domain")]
421 426 pub(super) async fn add_domain(
422 427 State(state): State<AppState>,
428 + actor: InternalActor,
423 429 _auth: ServiceAuth,
424 430 Json(req): Json<AddDomainRequest>,
425 431 ) -> Result<impl IntoResponse> {
@@ -432,7 +438,7 @@ pub(super) async fn add_domain(
432 438 }
433 439
434 440 let token = generate_verification_token();
435 - let record = db::custom_domains::create_custom_domain(&state.db, req.user_id, &domain, &token).await?;
441 + let record = db::custom_domains::create_custom_domain(&state.db, actor.user_id(), &domain, &token).await?;
436 442
437 443 Ok(Json(serde_json::json!({
438 444 "id": record.id.to_string(),
@@ -447,10 +453,11 @@ pub(super) async fn add_domain(
447 453 #[tracing::instrument(skip_all, name = "internal::verify_domain")]
448 454 pub(super) async fn verify_domain(
449 455 State(state): State<AppState>,
456 + actor: InternalActor,
450 457 _auth: ServiceAuth,
451 - axum::extract::Query(q): axum::extract::Query<UserIdParam>,
458 + axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
452 459 ) -> Result<impl IntoResponse> {
453 - let record = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id)
460 + let record = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id())
454 461 .await?
455 462 .ok_or(AppError::NotFound)?;
456 463
@@ -489,7 +496,7 @@ pub(super) async fn verify_domain(
489 496
490 497 if verified {
491 498 db::custom_domains::mark_domain_verified(&state.db, record.id).await?;
492 - state.domain_cache.insert(record.domain.clone(), q.user_id);
499 + state.domain_cache.insert(record.domain.clone(), actor.user_id());
493 500 Ok(Json(serde_json::json!({"verified": true, "message": "Domain verified"})))
494 501 } else {
495 502 Ok(Json(serde_json::json!({"verified": false, "message": format!("TXT record not found. Add _mnw-verify.{} = {}", record.domain, record.verification_token)})))
@@ -500,14 +507,15 @@ pub(super) async fn verify_domain(
500 507 #[tracing::instrument(skip_all, name = "internal::remove_domain")]
501 508 pub(super) async fn remove_domain(
502 509 State(state): State<AppState>,
510 + actor: InternalActor,
503 511 _auth: ServiceAuth,
504 - axum::extract::Query(q): axum::extract::Query<UserIdParam>,
512 + axum::extract::Query(_q): axum::extract::Query<UserIdParam>,
505 513 ) -> Result<impl IntoResponse> {
506 - let record = db::custom_domains::get_custom_domain_by_user(&state.db, q.user_id)
514 + let record = db::custom_domains::get_custom_domain_by_user(&state.db, actor.user_id())
507 515 .await?
508 516 .ok_or(AppError::NotFound)?;
509 517
510 - db::custom_domains::delete_custom_domain(&state.db, record.id, q.user_id).await?;
518 + db::custom_domains::delete_custom_domain(&state.db, record.id, actor.user_id()).await?;
511 519 state.domain_cache.remove(&record.domain);
512 520
513 521 Ok(axum::http::StatusCode::NO_CONTENT)
@@ -551,7 +559,6 @@ fn slug_from_title(title: &str) -> String {
551 559
552 560 #[derive(Deserialize)]
553 561 pub(super) struct CreateProjectRequest {
554 - user_id: UserId,
555 562 title: String,
556 563 project_type: String,
557 564 description: Option<String>,
@@ -569,11 +576,12 @@ struct CreateProjectResponse {
569 576 #[tracing::instrument(skip_all, name = "internal::create_project")]
570 577 pub(super) async fn create_project(
571 578 State(state): State<AppState>,
579 + actor: InternalActor,
572 580 _auth: ServiceAuth,
573 581 Json(req): Json<CreateProjectRequest>,
574 582 ) -> Result<impl IntoResponse> {
575 583 // Verify user can create projects
576 - let user = db::users::get_user_by_id(&state.db, req.user_id)
584 + let user = db::users::get_user_by_id(&state.db, actor.user_id())
577 585 .await?
578 586 .ok_or(AppError::NotFound)?;
579 587
@@ -590,7 +598,7 @@ pub(super) async fn create_project(
590 598
591 599 let project = db::projects::create_project(
592 600 &state.db,
593 - req.user_id,
601 + actor.user_id(),
594 602 &slug,
595 603 &req.title,
596 604 req.description.as_deref(),
@@ -6,12 +6,13 @@ use axum::{
6 6 Json,
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 + use crate::auth::InternalActor;
9 10
10 11 use crate::{
11 12 auth::ServiceAuth,
12 13 db::{
13 14 self, BlogPostId, CodePurpose, DiscountType, ItemId, KeyCode, LicenseKeyId, PromoCodeId,
14 - ProjectId, Slug, UserId,
15 + ProjectId, Slug,
15 16 },
16 17 error::{AppError, Result},
17 18 helpers,
@@ -23,17 +24,14 @@ use crate::{
23 24
24 25 #[derive(Deserialize)]
25 26 pub(super) struct UserIdQuery {
26 - user_id: UserId,
27 27 }
28 28
29 29 #[derive(Deserialize)]
30 30 pub(super) struct ItemUserQuery {
31 - user_id: UserId,
32 31 }
33 32
34 33 #[derive(Deserialize)]
35 34 pub(super) struct ProjectUserQuery {
36 - user_id: UserId,
37 35 }
38 36
39 37 // ── Blog posts ──
@@ -69,14 +67,15 @@ impl BlogPostResponse {
69 67 #[tracing::instrument(skip_all, name = "internal::list_blog_posts")]
70 68 pub(super) async fn list_blog_posts(
71 69 State(state): State<AppState>,
70 + actor: InternalActor,
72 71 _auth: ServiceAuth,
73 72 Path(project_id): Path<ProjectId>,
74 - Query(query): Query<ProjectUserQuery>,
73 + Query(_query): Query<ProjectUserQuery>,
75 74 ) -> Result<impl IntoResponse> {
76 75 let project = db::projects::get_project_by_id(&state.db, project_id)
77 76 .await?
78 77 .ok_or(AppError::NotFound)?;
79 - if project.user_id != query.user_id {
78 + if project.user_id != actor.user_id() {
80 79 return Err(AppError::Forbidden);
81 80 }
82 81
@@ -88,7 +87,6 @@ pub(super) async fn list_blog_posts(
88 87
89 88 #[derive(Deserialize)]
90 89 pub(super) struct CreateBlogPostRequest {
91 - user_id: UserId,
92 90 project_id: ProjectId,
93 91 title: String,
94 92 #[serde(default)]
@@ -106,13 +104,14 @@ pub(super) struct CreateBlogPostRequest {
106 104 #[tracing::instrument(skip_all, name = "internal::create_blog_post")]
107 105 pub(super) async fn create_blog_post(
108 106 State(state): State<AppState>,
107 + actor: InternalActor,
109 108 _auth: ServiceAuth,
110 109 Json(req): Json<CreateBlogPostRequest>,
111 110 ) -> Result<impl IntoResponse> {
112 111 let project = db::projects::get_project_by_id(&state.db, req.project_id)
113 112 .await?
114 113 .ok_or(AppError::NotFound)?;
115 - if project.user_id != req.user_id {
114 + if project.user_id != actor.user_id() {
116 115 return Err(AppError::Forbidden);
117 116 }
118 117
@@ -124,7 +123,7 @@ pub(super) async fn create_blog_post(
124 123 let base = helpers::slugify(&req.title).to_string();
125 124
126 125 let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work");
127 - let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, req.user_id, cdn_base);
126 + let body_html = crate::markdown::render_creator_markdown(&req.body_markdown, actor.user_id(), cdn_base);
128 127
129 128 // If publish_at is set, create as draft and then set the schedule
130 129 let publish = if req.publish_at.is_some() { false } else { req.publish };
@@ -133,7 +132,7 @@ pub(super) async fn create_blog_post(
133 132 // code pre-checked existence but did NOT retry the insert, so a concurrent
134 133 // create racing the same slug surfaced a raw 500 (ultra-fuzz Run #1 UX D1).
135 134 let pool = &state.db;
136 - let (project_id, user_id) = (req.project_id, req.user_id);
135 + let (project_id, user_id) = (req.project_id, actor.user_id());
137 136 let (title_s, body_md_s, body_html_s) =
138 137 (req.title.as_str(), req.body_markdown.as_str(), body_html.as_str());
139 138 let post = crate::helpers::insert_with_unique_slug(&base, |slug| async move {
@@ -171,7 +170,7 @@ pub(super) async fn create_blog_post(
171 170 post
172 171 };
173 172
174 - tracing::info!(user = %req.user_id, post = %post.id, "blog post created via CLI");
173 + tracing::info!(user = %actor.user_id(), post = %post.id, "blog post created via CLI");
175 174
176 175 Ok(Json(BlogPostResponse::from_db(&post)))
177 176 }
@@ -182,9 +181,10 @@ pub(super) async fn create_blog_post(
182 181 #[tracing::instrument(skip_all, name = "internal::delete_blog_post")]
183 182 pub(super) async fn delete_blog_post(
184 183 State(state): State<AppState>,
184 + actor: InternalActor,
185 185 _auth: ServiceAuth,
186 186 Path(post_id): Path<BlogPostId>,
187 - Query(query): Query<ItemUserQuery>,
187 + Query(_query): Query<ItemUserQuery>,
188 188 ) -> Result<impl IntoResponse> {
189 189 let post = db::blog_posts::get_blog_post_by_id(&state.db, post_id)
190 190 .await?
@@ -193,13 +193,13 @@ pub(super) async fn delete_blog_post(
193 193 let project = db::projects::get_project_by_id(&state.db, post.project_id)
194 194 .await?
195 195 .ok_or(AppError::NotFound)?;
196 - if project.user_id != query.user_id {
196 + if project.user_id != actor.user_id() {
197 197 return Err(AppError::Forbidden);
198 198 }
199 199
200 - db::blog_posts::delete_blog_post(&state.db, post_id, query.user_id).await?;
200 + db::blog_posts::delete_blog_post(&state.db, post_id, actor.user_id()).await?;
201 201
202 - tracing::info!(user = %query.user_id, post = %post_id, "blog post deleted via CLI");
202 + tracing::info!(user = %actor.user_id(), post = %post_id, "blog post deleted via CLI");
203 203
204 204 Ok(axum::http::StatusCode::NO_CONTENT)
205 205 }
@@ -226,10 +226,11 @@ struct PromoCodeResponse {
226 226 #[tracing::instrument(skip_all, name = "internal::list_promo_codes")]
227 227 pub(super) async fn list_promo_codes(
228 228 State(state): State<AppState>,
229 + actor: InternalActor,
229 230 _auth: ServiceAuth,
230 - Query(query): Query<UserIdQuery>,
231 + Query(_query): Query<UserIdQuery>,
231 232 ) -> Result<impl IntoResponse> {
232 - let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, query.user_id).await?;
233 + let codes = db::promo_codes::get_promo_codes_by_creator(&state.db, actor.user_id()).await?;
233 234 let data: Vec<PromoCodeResponse> = codes
234 235 .into_iter()
235 236 .map(|c| PromoCodeResponse {
@@ -251,7 +252,6 @@ pub(super) async fn list_promo_codes(
251 252
252 253 #[derive(Deserialize)]
253 254 pub(super) struct CreatePromoCodeRequest {
254 - user_id: UserId,
255 255 code: String,
256 256 #[serde(default = "default_code_purpose")]
257 257 code_purpose: CodePurpose,
@@ -275,6 +275,7 @@ fn default_code_purpose() -> CodePurpose {
275 275 #[tracing::instrument(skip_all, name = "internal::create_promo_code")]
276 276 pub(super) async fn create_promo_code(
277 277 State(state): State<AppState>,
278 + actor: InternalActor,
278 279 _auth: ServiceAuth,
279 280 Json(req): Json<CreatePromoCodeRequest>,
280 281 ) -> Result<impl IntoResponse> {
@@ -291,7 +292,7 @@ pub(super) async fn create_promo_code(
291 292 let owner = db::items::get_item_owner(&state.db, item_id)
292 293 .await?
293 294 .ok_or(AppError::NotFound)?;
294 - if owner != req.user_id {
295 + if owner != actor.user_id() {
295 296 return Err(AppError::Forbidden);
296 297 }
297 298 }
@@ -301,14 +302,14 @@ pub(super) async fn create_promo_code(
301 302 let project = db::projects::get_project_by_id(&state.db, project_id)
302 303 .await?
303 304 .ok_or(AppError::NotFound)?;
304 - if project.user_id != req.user_id {
305 + if project.user_id != actor.user_id() {
305 306 return Err(AppError::Forbidden);
306 307 }
307 308 }
308 309
309 310 let code = db::promo_codes::create_promo_code(
310 311 &state.db,
311 - req.user_id,
312 + actor.user_id(),
312 313 &req.code,
313 314 req.code_purpose,
314 315 req.discount_type,
@@ -324,7 +325,7 @@ pub(super) async fn create_promo_code(
324 325 )
325 326 .await?;
326 327
327 - tracing::info!(user = %req.user_id, code = %code.code, "promo code created via CLI");
328 + tracing::info!(user = %actor.user_id(), code = %code.code, "promo code created via CLI");
328 329
329 330 Ok(Json(PromoCodeResponse {
330 331 id: code.id,
@@ -346,20 +347,21 @@ pub(super) async fn create_promo_code(
346 347 #[tracing::instrument(skip_all, name = "internal::delete_promo_code")]
347 348 pub(super) async fn delete_promo_code(
348 349 State(state): State<AppState>,
350 + actor: InternalActor,
349 351 _auth: ServiceAuth,
350 352 Path(code_id): Path<PromoCodeId>,
351 - Query(query): Query<UserIdQuery>,
353 + Query(_query): Query<UserIdQuery>,
352 354 ) -> Result<impl IntoResponse> {
353 355 let code = db::promo_codes::get_promo_code_by_id(&state.db, code_id)
354 356 .await?
355 357 .ok_or(AppError::NotFound)?;
356 - if code.creator_id != query.user_id {
358 + if code.creator_id != actor.user_id() {
357 359 return Err(AppError::Forbidden);
358 360 }
359 361
360 362 db::promo_codes::delete_promo_code(&state.db, code_id).await?;
361 363
362 - tracing::info!(user = %query.user_id, code = %code.code, "promo code deleted via CLI");
364 + tracing::info!(user = %actor.user_id(), code = %code.code, "promo code deleted via CLI");
363 365
364 366 Ok(axum::http::StatusCode::NO_CONTENT)
365 367 }
@@ -382,14 +384,15 @@ struct LicenseKeyResponse {
382 384 #[tracing::instrument(skip_all, name = "internal::list_license_keys")]
383 385 pub(super) async fn list_license_keys(
384 386 State(state): State<AppState>,
387 + actor: InternalActor,
385 388 _auth: ServiceAuth,
386 389 Path(item_id): Path<ItemId>,
387 - Query(query): Query<ItemUserQuery>,
390 + Query(_query): Query<ItemUserQuery>,
388 391 ) -> Result<impl IntoResponse> {
389 392 let owner = db::items::get_item_owner(&state.db, item_id)
390 393 .await?
391 394 .ok_or(AppError::NotFound)?;
392 - if owner != query.user_id {
395 + if owner != actor.user_id() {
393 396 return Err(AppError::Forbidden);
394 397 }
395 398
@@ -411,7 +414,6 @@ pub(super) async fn list_license_keys(
411 414
412 415 #[derive(Deserialize)]
413 416 pub(super) struct GenerateKeyRequest {
414 - user_id: UserId,
415 417 }
416 418
417 419 /// POST /api/internal/creator/items/{id}/keys
@@ -420,9 +422,10 @@ pub(super) struct GenerateKeyRequest {
420 422 #[tracing::instrument(skip_all, name = "internal::generate_license_key")]
421 423 pub(super) async fn generate_license_key(
422 424 State(state): State<AppState>,
425 + actor: InternalActor,
423 426 _auth: ServiceAuth,
424 427 Path(item_id): Path<ItemId>,
425 - Json(req): Json<GenerateKeyRequest>,
428 + Json(_req): Json<GenerateKeyRequest>,
426 429 ) -> Result<impl IntoResponse> {
427 430 let item = db::items::get_item_by_id(&state.db, item_id)
428 431 .await?
@@ -431,7 +434,7 @@ pub(super) async fn generate_license_key(
431 434 let project = db::projects::get_project_by_id(&state.db, item.project_id)
432 435 .await?
433 436 .ok_or(AppError::NotFound)?;
434 - if project.user_id != req.user_id {
437 + if project.user_id != actor.user_id() {
435 438 return Err(AppError::Forbidden);
436 439 }
437 440
@@ -447,14 +450,14 @@ pub(super) async fn generate_license_key(
447 450 let key = db::license_keys::create_license_key(
448 451 &state.db,
449 452 item_id,
450 - req.user_id,
453 + actor.user_id(),
451 454 None, // transaction_id
452 455 &key_code,
453 456 max_activations,
454 457 )
455 458 .await?;
456 459
457 - tracing::info!(user = %req.user_id, item = %item_id, "license key generated via CLI");
460 + tracing::info!(user = %actor.user_id(), item = %item_id, "license key generated via CLI");
458 461
459 462 Ok(Json(LicenseKeyResponse {
460 463 id: key.id,
@@ -468,7 +471,6 @@ pub(super) async fn generate_license_key(
468 471
469 472 #[derive(Deserialize)]
470 473 pub(super) struct RevokeKeyRequest {
471 - user_id: UserId,
472 474 }
473 475
474 476 /// POST /api/internal/creator/keys/{id}/revoke
@@ -477,9 +479,10 @@ pub(super) struct RevokeKeyRequest {
477 479 #[tracing::instrument(skip_all, name = "internal::revoke_license_key")]
478 480 pub(super) async fn revoke_license_key(
479 481 State(state): State<AppState>,
482 + actor: InternalActor,
480 483 _auth: ServiceAuth,
481 484 Path(key_id): Path<LicenseKeyId>,
482 - Json(req): Json<RevokeKeyRequest>,
485 + Json(_req): Json<RevokeKeyRequest>,
483 486 ) -> Result<impl IntoResponse> {
484 487 let key = db::license_keys::get_license_key_by_id_unchecked(&state.db, key_id)
485 488 .await?
@@ -489,13 +492,13 @@ pub(super) async fn revoke_license_key(
489 492 let owner = db::items::get_item_owner(&state.db, key.item_id)
490 493 .await?
491 494 .ok_or(AppError::NotFound)?;
492 - if owner != req.user_id {
495 + if owner != actor.user_id() {
493 496 return Err(AppError::Forbidden);
494 497 }
495 498
496 499 db::license_keys::revoke_license_key(&state.db, key_id).await?;
497 500
498 - tracing::info!(user = %req.user_id, key = %key_id, "license key revoked via CLI");
501 + tracing::info!(user = %actor.user_id(), key = %key_id, "license key revoked via CLI");
499 502
500 503 Ok(axum::http::StatusCode::NO_CONTENT)
501 504 }
@@ -6,10 +6,11 @@ use axum::{
6 6 Json,
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 + use crate::auth::InternalActor;
9 10
10 11 use crate::{
11 12 auth::ServiceAuth,
12 - db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId, UserId},
13 + db::{self, ItemId, ItemType, ProjectId, ProjectType, TransactionId},
13 14 error::{AppError, Result},
14 15 helpers,
15 16 AppState,
@@ -19,7 +20,6 @@ use crate::{
19 20
20 21 #[derive(Deserialize)]
21 22 pub(super) struct UserIdQuery {
22 - user_id: UserId,
23 23 }
24 24
25 25 #[derive(Serialize)]
@@ -39,11 +39,12 @@ struct CreatorProject {
39 39 #[tracing::instrument(skip_all, name = "internal::creator_projects")]
40 40 pub(super) async fn creator_projects(
41 41 State(state): State<AppState>,
42 + actor: InternalActor,
42 43 _auth: ServiceAuth,
43 - Query(query): Query<UserIdQuery>,
44 + Query(_query): Query<UserIdQuery>,
44 45 ) -> Result<impl IntoResponse> {
45 - let projects = db::projects::get_projects_by_user(&state.db, query.user_id).await?;
46 - let revenue = db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?;
46 + let projects = db::projects::get_projects_by_user(&state.db, actor.user_id()).await?;
47 + let revenue = db::transactions::get_revenue_by_user_projects(&state.db, actor.user_id()).await?;
47 48
48 49 // Build revenue lookup: project_id -> cents
49 50 let revenue_map: std::collections::HashMap<ProjectId, i64> = revenue
@@ -52,7 +53,7 @@ pub(super) async fn creator_projects(
52 53 .collect();
53 54
54 55 // Count items per project in a single query
55 - let item_counts = db::items::count_items_by_user_projects(&state.db, query.user_id).await?;
56 + let item_counts = db::items::count_items_by_user_projects(&state.db, actor.user_id()).await?;
56 57 let count_map: std::collections::HashMap<ProjectId, i64> = item_counts.into_iter().collect();
57 58
58 59 let data: Vec<CreatorProject> = projects
@@ -89,15 +90,16 @@ struct CreatorItem {
89 90 #[tracing::instrument(skip_all, name = "internal::creator_project_items")]
90 91 pub(super) async fn creator_project_items(
91 92 State(state): State<AppState>,
93 + actor: InternalActor,
92 94 _auth: ServiceAuth,
93 95 Path(project_id): Path<ProjectId>,
94 - Query(query): Query<UserIdQuery>,
96 + Query(_query): Query<UserIdQuery>,
95 97 ) -> Result<impl IntoResponse> {
96 98 // Verify ownership
97 99 let project = db::projects::get_project_by_id(&state.db, project_id)
98 100 .await?
99 101 .ok_or(AppError::NotFound)?;
100 - if project.user_id != query.user_id {
102 + if project.user_id != actor.user_id() {
101 103 return Err(AppError::Forbidden);
102 104 }
103 105
@@ -122,7 +124,6 @@ pub(super) async fn creator_project_items(
122 124
123 125 #[derive(Deserialize)]
124 126 pub(super) struct StatsQuery {
125 - user_id: UserId,
126 127 /// Time range: "7d", "30d", "90d", or "all"
127 128 #[serde(default = "default_range")]
128 129 range: String,
@@ -150,6 +151,7 @@ struct CreatorStats {
150 151 #[tracing::instrument(skip_all, name = "internal::creator_stats")]
151 152 pub(super) async fn creator_stats(
152 153 State(state): State<AppState>,
154 + actor: InternalActor,
153 155 _auth: ServiceAuth,
154 156 Query(query): Query<StatsQuery>,
155 157 ) -> Result<impl IntoResponse> {
@@ -159,10 +161,10 @@ pub(super) async fn creator_stats(
159 161 .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
160 162
161 163 let comparison =
162 - db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?;
164 + db::analytics::get_period_comparison(&state.db, actor.user_id(), None, None, &range).await?;
163 165
164 - let total_projects = db::projects::count_projects_by_user(&state.db, query.user_id).await?;
165 - let total_items = db::items::count_items_by_user(&state.db, query.user_id).await?;
166 + let total_projects = db::projects::count_projects_by_user(&state.db, actor.user_id()).await?;
167 + let total_items = db::items::count_items_by_user(&state.db, actor.user_id()).await?;
166 168
167 169 Ok(Json(CreatorStats {
168 170 current_revenue_cents: comparison.current_revenue_cents.as_i64(),
@@ -210,6 +212,7 @@ struct AnalyticsResponse {
210 212 #[tracing::instrument(skip_all, name = "internal::creator_analytics")]
211 213 pub(super) async fn creator_analytics(
212 214 State(state): State<AppState>,
215 + actor: InternalActor,
213 216 _auth: ServiceAuth,
214 217 Query(query): Query<StatsQuery>,
215 218 ) -> Result<impl IntoResponse> {
@@ -219,12 +222,12 @@ pub(super) async fn creator_analytics(
219 222 .map_err(|_| AppError::BadRequest("invalid range: use 7d, 30d, 90d, or all".into()))?;
220 223
221 224 let buckets =
222 - db::analytics::get_revenue_timeseries(&state.db, query.user_id, None, None, &range)
225 + db::analytics::get_revenue_timeseries(&state.db, actor.user_id(), None, None, &range)
223 226 .await?;
224 227 let comparison =
225 - db::analytics::get_period_comparison(&state.db, query.user_id, None, None, &range).await?;
228 + db::analytics::get_period_comparison(&state.db, actor.user_id(), None, None, &range).await?;
226 229 let revenue =
227 - db::transactions::get_revenue_by_user_projects(&state.db, query.user_id).await?;
230 + db::transactions::get_revenue_by_user_projects(&state.db, actor.user_id()).await?;
228 231
229 232 let top_projects: Vec<ProjectRevenueSummary> = revenue
230 233 .into_iter()
@@ -272,11 +275,12 @@ struct TransactionResponse {
272 275 #[tracing::instrument(skip_all, name = "internal::creator_transactions")]
273 276 pub(super) async fn creator_transactions(
274 277 State(state): State<AppState>,
278 + actor: InternalActor,
275 279 _auth: ServiceAuth,
276 - Query(query): Query<UserIdQuery>,
280 + Query(_query): Query<UserIdQuery>,
277 281 ) -> Result<impl IntoResponse> {
278 282 let txs =
279 - db::transactions::get_transactions_by_seller(&state.db, query.user_id, Some(100)).await?;
283 + db::transactions::get_transactions_by_seller(&state.db, actor.user_id(), Some(100)).await?;
280 284
281 285 let data: Vec<TransactionResponse> = txs
282 286 .into_iter()
@@ -301,8 +305,9 @@ pub(super) async fn creator_transactions(
301 305 #[tracing::instrument(skip_all, name = "internal::export_sales")]
302 306 pub(super) async fn export_sales(
303 307 State(state): State<AppState>,
308 + actor: InternalActor,
304 309 _auth: ServiceAuth,
305 - Query(query): Query<UserIdQuery>,
310 + Query(_query): Query<UserIdQuery>,
306 311 ) -> Result<impl IntoResponse> {
307 312 // Page the read so a whale seller's full history isn't loaded by one
308 313 // unbounded query; peak memory is one batch plus the accumulating CSV.
@@ -317,7 +322,7 @@ pub(super) async fn export_sales(
317 322 loop {
318 323 let rows = db::transactions::get_seller_transactions_for_export_page(
319 324 &state.db,
320 - query.user_id,
325 + actor.user_id(),
321 326 BATCH,
322 327 offset,
323 328 )
@@ -348,7 +353,7 @@ pub(super) async fn export_sales(
348 353 }
349 354 if total >= MAX_ROWS {
350 355 tracing::warn!(
351 - user_id = %query.user_id,
356 + user_id = %actor.user_id(),
352 357 rows = total,
353 358 "internal sales export hit the row ceiling; truncating"
354 359 );
@@ -6,6 +6,7 @@ use axum::{
6 6 Json,
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 + use crate::auth::InternalActor;
9 10 use std::sync::atomic::Ordering;
10 11
11 12 use crate::{
@@ -30,6 +31,10 @@ struct SshKeyLookupResponse {
30 31 creator_tier: Option<CreatorTier>,
31 32 can_create_projects: bool,
32 33 suspended: bool,
34 + /// Signed actor assertion the CLI forwards (as `X-MNW-Actor`) on subsequent
35 + /// internal calls so the server derives identity from an SSH-authenticated
36 + /// token, not a caller-supplied `user_id`.
37 + actor_token: String,
33 38 }
34 39
35 40 /// GET /api/internal/ssh-key-lookup?fingerprint={sha256}
@@ -45,6 +50,12 @@ pub(super) async fn ssh_key_lookup(
45 50 .await?
46 51 .ok_or(AppError::NotFound)?;
47 52
53 + // Mint an actor assertion for the resolved user; the CLI forwards it on the
54 + // session's internal calls. TTL comfortably exceeds any SSH session.
55 + let expiry = chrono::Utc::now().timestamp() + crate::constants::INTERNAL_ACTOR_TTL_SECS;
56 + let actor_token =
57 + crate::crypto::mint_internal_actor_token(user.user_id, expiry, &state.config.signing_secret);
58 +
48 59 Ok(Json(SshKeyLookupResponse {
49 60 user_id: user.user_id,
50 61 username: user.username,
@@ -52,6 +63,7 @@ pub(super) async fn ssh_key_lookup(
52 63 creator_tier: user.creator_tier,
53 64 can_create_projects: user.can_create_projects,
54 65 suspended: user.suspended,
66 + actor_token,
55 67 }))
56 68 }
57 69
@@ -59,7 +71,6 @@ pub(super) async fn ssh_key_lookup(
59 71
60 72 #[derive(Deserialize)]
61 73 pub(super) struct UserIdQuery {
62 - user_id: UserId,
63 74 }
64 75
65 76 #[derive(Serialize)]
@@ -76,10 +87,11 @@ struct SshKeyResponse {
76 87 #[tracing::instrument(skip_all, name = "internal::list_ssh_keys")]
77 88 pub(super) async fn list_ssh_keys(
78 89 State(state): State<AppState>,
90 + actor: InternalActor,
79 91 _auth: ServiceAuth,
80 - Query(query): Query<UserIdQuery>,
92 + Query(_query): Query<UserIdQuery>,
81 93 ) -> Result<impl IntoResponse> {
82 - let keys = db::ssh_keys::list_keys_by_user(&state.db, query.user_id).await?;
94 + let keys = db::ssh_keys::list_keys_by_user(&state.db, actor.user_id()).await?;
83 95 let data: Vec<SshKeyResponse> = keys
84 96 .into_iter()
85 97 .map(|k| SshKeyResponse {
@@ -97,7 +109,6 @@ pub(super) async fn list_ssh_keys(
97 109
98 110 #[derive(Deserialize)]
99 111 pub(super) struct GitAuthorizeRequest {
100 - user_id: UserId,
101 112 /// "git-upload-pack", "git-receive-pack", or "git-upload-archive"
102 113 operation: String,
103 114 owner: String,
@@ -116,6 +127,7 @@ struct GitAuthorizeResponse {
116 127 #[tracing::instrument(skip_all, name = "internal::git_authorize")]
117 128 pub(super) async fn git_authorize(
118 129 State(state): State<AppState>,
130 + actor: InternalActor,
119 131 _auth: ServiceAuth,
120 132 Json(req): Json<GitAuthorizeRequest>,
121 133 ) -> Result<impl IntoResponse> {
@@ -145,7 +157,7 @@ pub(super) async fn git_authorize(
145 157 // Auto-create on push if the authenticated user owns the namespace.
146 158 // Only register in the DB here — mnw-cli creates the bare repo on
147 159 // disk as the git user (avoids ownership/privilege issues).
148 - if req.operation != "git-receive-pack" || req.user_id != owner_user.id {
160 + if req.operation != "git-receive-pack" || actor.user_id() != owner_user.id {
149 161 return Err(AppError::NotFound);
150 162 }
151 163
@@ -168,12 +180,12 @@ pub(super) async fn git_authorize(
168 180 // Permission check
169 181 match req.operation.as_str() {
170 182 "git-receive-pack" => {
171 - if req.user_id != owner_user.id {
183 + if actor.user_id() != owner_user.id {
172 184 return Err(AppError::Forbidden);
173 185 }
174 186 }
175 187 "git-upload-pack" | "git-upload-archive" => {
176 - if repo.visibility == Visibility::Private && req.user_id != owner_user.id {
188 + if repo.visibility == Visibility::Private && actor.user_id() != owner_user.id {
177 189 return Err(AppError::NotFound);
178 190 }
179 191 }
@@ -6,10 +6,11 @@ use axum::{
6 6 Json,
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 + use crate::auth::InternalActor;
9 10
10 11 use crate::{
11 12 auth::ServiceAuth,
12 - db::{self, AiTier, ItemId, ItemType, PriceCents, ProjectId, UserId},
13 + db::{self, AiTier, ItemId, ItemType, PriceCents, ProjectId},
13 14 error::{AppError, Result},
14 15 validation,
15 16 AppState,
@@ -68,14 +69,12 @@ impl ItemDetailResponse {
68 69
69 70 #[derive(Deserialize)]
70 71 pub(super) struct ItemUserQuery {
71 - user_id: UserId,
72 72 }
73 73
74 74 // ── Create item (for CLI upload pipeline) ──
75 75
76 76 #[derive(Deserialize)]
77 77 pub(super) struct CreateItemRequest {
78 - user_id: UserId,
79 78 project_id: ProjectId,
80 79 title: String,
81 80 item_type: String,
@@ -99,6 +98,7 @@ struct CreateItemResponse {
99 98 #[tracing::instrument(skip_all, name = "internal::create_item")]
100 99 pub(super) async fn create_item(
101 100 State(state): State<AppState>,
101 + actor: InternalActor,
102 102 _auth: ServiceAuth,
103 103 Json(req): Json<CreateItemRequest>,
104 104 ) -> Result<impl IntoResponse> {
@@ -115,7 +115,7 @@ pub(super) async fn create_item(
115 115 let project = db::projects::get_project_by_id(&state.db, req.project_id)
116 116 .await?
117 117 .ok_or(AppError::NotFound)?;
118 - if project.user_id != req.user_id {
118 + if project.user_id != actor.user_id() {
119 119 return Err(AppError::Forbidden);
120 120 }
121 121
@@ -133,7 +133,7 @@ pub(super) async fn create_item(
133 133 .await?;
134 134
135 135 tracing::info!(
136 - user = %req.user_id,
136 + user = %actor.user_id(),
137 137 item = %item.id,
138 138 "item created via CLI"
139 139 );
@@ -152,9 +152,10 @@ pub(super) async fn create_item(
152 152 #[tracing::instrument(skip_all, name = "internal::get_item")]
153 153 pub(super) async fn get_item(
154 154 State(state): State<AppState>,
155 + actor: InternalActor,
155 156 _auth: ServiceAuth,
156 157 Path(item_id): Path<ItemId>,
157 - Query(query): Query<ItemUserQuery>,
158 + Query(_query): Query<ItemUserQuery>,
158 159 ) -> Result<impl IntoResponse> {
159 160 let item = db::items::get_item_by_id(&state.db, item_id)
160 161 .await?
@@ -164,7 +165,7 @@ pub(super) async fn get_item(
164 165 let project = db::projects::get_project_by_id(&state.db, item.project_id)
165 166 .await?
166 167 .ok_or(AppError::NotFound)?;
167 - if project.user_id != query.user_id {
168 + if project.user_id != actor.user_id() {
168 169 return Err(AppError::Forbidden);
169 170 }
170 171
@@ -175,7 +176,6 @@ pub(super) async fn get_item(
175 176
176 177 #[derive(Deserialize)]
177 178 pub(super) struct UpdateItemRequest {
178 - user_id: UserId,
179 179 #[serde(default)]
180 180 title: Option<String>,
181 181 #[serde(default)]
@@ -200,6 +200,7 @@ pub(super) struct UpdateItemRequest {
200 200 #[tracing::instrument(skip_all, name = "internal::update_item")]
201 201 pub(super) async fn update_item(
202 202 State(state): State<AppState>,
203 + actor: InternalActor,
203 204 _auth: ServiceAuth,
204 205 Path(item_id): Path<ItemId>,
205 206 Json(req): Json<UpdateItemRequest>,
@@ -211,7 +212,7 @@ pub(super) async fn update_item(
211 212 let project = db::projects::get_project_by_id(&state.db, item.project_id)
212 213 .await?
213 214 .ok_or(AppError::NotFound)?;
214 - if project.user_id != req.user_id {
215 + if project.user_id != actor.user_id() {
215 216 return Err(AppError::Forbidden);
216 217 }
217 218
@@ -234,7 +235,7 @@ pub(super) async fn update_item(
234 235 let updated = db::items::update_item(
235 236 &state.db,
236 237 item_id,
237 - req.user_id,
238 + actor.user_id(),
238 239 req.title.as_deref(),
239 240 req.description.as_deref(),
240 241 req.price_cents.map(PriceCents::new).transpose()?,
@@ -249,7 +250,7 @@ pub(super) async fn update_item(
249 250 )
250 251 .await?;
251 252
252 - tracing::info!(user = %req.user_id, item = %item_id, "item updated via CLI");
253 + tracing::info!(user = %actor.user_id(), item = %item_id, "item updated via CLI");
253 254
254 255 Ok(Json(ItemDetailResponse::from_db(&updated)))
255 256 }
@@ -262,9 +263,10 @@ pub(super) async fn update_item(
262 263 #[tracing::instrument(skip_all, name = "internal::delete_item")]
263 264 pub(super) async fn delete_item(
264 265 State(state): State<AppState>,
266 + actor: InternalActor,
265 267 _auth: ServiceAuth,
266 268 Path(item_id): Path<ItemId>,
267 - Query(query): Query<ItemUserQuery>,
269 + Query(_query): Query<ItemUserQuery>,
268 270 ) -> Result<impl IntoResponse> {
269 271 let item = db::items::get_item_by_id(&state.db, item_id)
270 272 .await?
@@ -273,7 +275,7 @@ pub(super) async fn delete_item(
273 275 let project = db::projects::get_project_by_id(&state.db, item.project_id)
274 276 .await?
275 277 .ok_or(AppError::NotFound)?;
276 - if project.user_id != query.user_id {
278 + if project.user_id != actor.user_id() {
277 279 return Err(AppError::Forbidden);
278 280 }
279 281
@@ -285,12 +287,12 @@ pub(super) async fn delete_item(
285 287 + file_sizes.video_file_size_bytes.unwrap_or(0)
286 288 + version_size;
287 289 if total_bytes > 0 {
288 - db::creator_tiers::decrement_storage_used(&state.db, query.user_id, total_bytes).await?;
290 + db::creator_tiers::decrement_storage_used(&state.db, actor.user_id(), total_bytes).await?;
289 291 }
290 292
291 - db::items::delete_item(&state.db, item_id, query.user_id).await?;
293 + db::items::delete_item(&state.db, item_id, actor.user_id()).await?;
292 294
293 - tracing::info!(user = %query.user_id, item = %item_id, "item deleted via CLI");
295 + tracing::info!(user = %actor.user_id(), item = %item_id, "item deleted via CLI");
294 296
295 297 Ok(axum::http::StatusCode::NO_CONTENT)
296 298 }
@@ -299,7 +301,6 @@ pub(super) async fn delete_item(
299 301
300 302 #[derive(Deserialize)]
301 303 pub(super) struct PublishRequest {
302 - user_id: UserId,
303 304 }
304 305
305 306 /// POST /api/internal/creator/items/{id}/publish
@@ -308,9 +309,10 @@ pub(super) struct PublishRequest {
308 309 #[tracing::instrument(skip_all, name = "internal::publish_item")]
309 310 pub(super) async fn publish_item(
310 311 State(state): State<AppState>,
312 + actor: InternalActor,
311 313 _auth: ServiceAuth,
312 314 Path(item_id): Path<ItemId>,
313 - Json(req): Json<PublishRequest>,
315 + Json(_req): Json<PublishRequest>,
314 316 ) -> Result<impl IntoResponse> {
315 317 let item = db::items::get_item_by_id(&state.db, item_id)
316 318 .await?
@@ -319,14 +321,14 @@ pub(super) async fn publish_item(
319 321 let project = db::projects::get_project_by_id(&state.db, item.project_id)
320 322 .await?
321 323 .ok_or(AppError::NotFound)?;
322 - if project.user_id != req.user_id {
324 + if project.user_id != actor.user_id() {
323 325 return Err(AppError::Forbidden);
324 326 }
325 327
326 328 let updated = db::items::update_item(
327 329 &state.db,
328 330 item_id,
329 - req.user_id,
331 + actor.user_id(),
330 332 None, None, None, None,
331 333 Some(true), // is_public
332 334 None, None, None, None,
@@ -337,7 +339,7 @@ pub(super) async fn publish_item(
337 339 if let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await {
338 340 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after publish");
339 341 }
340 - tracing::info!(user = %req.user_id, item = %item_id, "item published via CLI");
342 + tracing::info!(user = %actor.user_id(), item = %item_id, "item published via CLI");
341 343
342 344 Ok(Json(ItemDetailResponse::from_db(&updated)))
343 345 }
@@ -348,9 +350,10 @@ pub(super) async fn publish_item(
348 350 #[tracing::instrument(skip_all, name = "internal::unpublish_item")]
349 351 pub(super) async fn unpublish_item(
350 352 State(state): State<AppState>,
353 + actor: InternalActor,
351 354 _auth: ServiceAuth,
352 355 Path(item_id): Path<ItemId>,
353 - Json(req): Json<PublishRequest>,
356 + Json(_req): Json<PublishRequest>,
354 357 ) -> Result<impl IntoResponse> {
355 358 let item = db::items::get_item_by_id(&state.db, item_id)
356 359 .await?
@@ -359,14 +362,14 @@ pub(super) async fn unpublish_item(
359 362 let project = db::projects::get_project_by_id(&state.db, item.project_id)
360 363 .await?
361 364 .ok_or(AppError::NotFound)?;
362 - if project.user_id != req.user_id {
365 + if project.user_id != actor.user_id() {
363 366 return Err(AppError::Forbidden);
364 367 }
365 368
366 369 let updated = db::items::update_item(
367 370 &state.db,
368 371 item_id,
369 - req.user_id,
372 + actor.user_id(),
370 373 None, None, None, None,
371 374 Some(false), // is_public
372 375 None, None, None, None,
@@ -377,7 +380,7 @@ pub(super) async fn unpublish_item(
377 380 if let Err(e) = db::projects::bump_cache_generation(&state.db, item.project_id).await {
378 381 tracing::warn!(project_id = %item.project_id, error = ?e, "failed to bump cache generation after unpublish");
379 382 }
380 - tracing::info!(user = %req.user_id, item = %item_id, "item unpublished via CLI");
383 + tracing::info!(user = %actor.user_id(), item = %item_id, "item unpublished via CLI");
381 384
382 385 Ok(Json(ItemDetailResponse::from_db(&updated)))
383 386 }
@@ -402,9 +405,10 @@ struct VersionResponse {
402 405 #[tracing::instrument(skip_all, name = "internal::item_versions")]
403 406 pub(super) async fn item_versions(
404 407 State(state): State<AppState>,
408 + actor: InternalActor,
405 409 _auth: ServiceAuth,
406 410 Path(item_id): Path<ItemId>,
407 - Query(query): Query<ItemUserQuery>,
411 + Query(_query): Query<ItemUserQuery>,
408 412 ) -> Result<impl IntoResponse> {
409 413 let item = db::items::get_item_by_id(&state.db, item_id)
410 414 .await?
@@ -413,7 +417,7 @@ pub(super) async fn item_versions(
413 417 let project = db::projects::get_project_by_id(&state.db, item.project_id)
414 418 .await?
415 419 .ok_or(AppError::NotFound)?;
416 - if project.user_id != query.user_id {
420 + if project.user_id != actor.user_id() {
417 421 return Err(AppError::Forbidden);
418 422 }
419 423
@@ -6,11 +6,12 @@ use axum::{
6 6 Json,
7 7 };
8 8 use serde::{Deserialize, Serialize};
9 + use crate::auth::InternalActor;
9 10 use std::str::FromStr;
10 11
11 12 use crate::{
12 13 auth::ServiceAuth,
13 - db::{self, ItemId, UserId},
14 + db::{self, ItemId},
14 15 error::{AppError, Result},
15 16 storage::{FileType, S3Client, CACHE_CONTROL_IMMUTABLE},
16 17 AppState,
@@ -20,7 +21,6 @@ use crate::{
20 21
21 22 #[derive(Deserialize)]
22 23 pub(super) struct InternalPresignRequest {
23 - user_id: UserId,
24 24 item_id: ItemId,
25 25 file_type: String,
26 26 file_name: String,
@@ -42,6 +42,7 @@ struct InternalPresignResponse {
42 42 #[tracing::instrument(skip_all, name = "internal::presign_upload")]
43 43 pub(super) async fn presign_upload(
44 44 State(state): State<AppState>,
45 + actor: InternalActor,
45 46 _auth: ServiceAuth,
46 47 Json(req): Json<InternalPresignRequest>,
47 48 ) -> Result<impl IntoResponse> {
@@ -57,17 +58,17 @@ pub(super) async fn presign_upload(
57 58 let owner = db::items::get_item_owner(&state.db, req.item_id)
58 59 .await?
59 60 .ok_or(AppError::NotFound)?;
60 - if owner != req.user_id {
61 + if owner != actor.user_id() {
61 62 return Err(AppError::Forbidden);
62 63 }
63 64
64 65 // Early quota check
65 - db::creator_tiers::check_presign_allowed(&state.db, req.user_id, file_type).await?;
66 + db::creator_tiers::check_presign_allowed(&state.db, actor.user_id(), file_type).await?;
66 67
67 - let s3_key = S3Client::generate_key(req.user_id, req.item_id, file_type, &req.file_name);
68 + let s3_key = S3Client::generate_key(actor.user_id(), req.item_id, file_type, &req.file_name);
68 69
69 70 // Track the pending upload so the reaper can clean it up if never confirmed
70 - db::pending_uploads::record_pending_upload(&state.db, req.user_id, &s3_key, "main").await?;
71 + db::pending_uploads::record_pending_upload(&state.db, actor.user_id(), &s3_key, "main").await?;
71 72
72 73 let expires_in = 3600;
73 74 let upload_url = s3
@@ -92,7 +93,6 @@ pub(super) async fn presign_upload(
92 93
93 94 #[derive(Deserialize)]
94 95 pub(super) struct InternalConfirmRequest {
95 - user_id: UserId,
96 96 item_id: ItemId,
97 97 file_type: String,
98 98 s3_key: String,
@@ -109,6 +109,7 @@ struct InternalConfirmResponse {
109 109 #[tracing::instrument(skip_all, name = "internal::confirm_upload")]
110 110 pub(super) async fn confirm_upload(
111 111 State(state): State<AppState>,
112 + actor: InternalActor,
112 113 _auth: ServiceAuth,
113 114 Json(req): Json<InternalConfirmRequest>,
114 115 ) -> Result<impl IntoResponse> {
@@ -121,12 +122,12 @@ pub(super) async fn confirm_upload(
121 122 let owner = db::items::get_item_owner(&state.db, req.item_id)
122 123 .await?
123 124 .ok_or(AppError::NotFound)?;
124 - if owner != req.user_id {
125 + if owner != actor.user_id() {
125 126 return Err(AppError::Forbidden);
126 127 }
127 128
128 129 // Validate S3 key belongs to this user + item (prevent cross-user file reference)
129 - let expected_prefix = format!("{}/{}/", req.user_id, req.item_id);
130 + let expected_prefix = format!("{}/{}/", actor.user_id(), req.item_id);
130 131 if !req.s3_key.starts_with(&expected_prefix) {
131 132 return Err(AppError::BadRequest("Invalid upload key".to_string()));
132 133 }
@@ -161,7 +162,7 @@ pub(super) async fn confirm_upload(
161 162 };
162 163 if already_committed {
163 164 tracing::info!(
164 - user = %req.user_id, item = %req.item_id, s3_key = %req.s3_key,
165 + user = %actor.user_id(), item = %req.item_id, s3_key = %req.s3_key,
165 166 "CLI upload confirm replay — already committed, skipping duplicate side effects"
166 167 );
167 168 return Ok(Json(InternalConfirmResponse { success: true }));
@@ -182,7 +183,7 @@ pub(super) async fn confirm_upload(
182 183 // Enforce tier-based limits
183 184 let max_storage = match db::creator_tiers::check_upload_allowed(
184 185 &state.db,
185 - req.user_id,
186 + actor.user_id(),
186 187 file_type,
187 188 file_size_bytes,
188 189 )
@@ -206,19 +207,19 @@ pub(super) async fn confirm_upload(
206 207
207 208 // Increment storage BEFORE writing the DB record (if quota exceeded, the
208 209 // S3 object is cleaned up and no DB record is created).
209 - if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, req.user_id, file_size_bytes, max_storage).await {
210 + if let Err(e) = db::creator_tiers::try_increment_storage(&state.db, actor.user_id(), file_size_bytes, max_storage).await {
210 211 crate::routes::storage::enqueue_s3_orphan(&state.db, &req.s3_key, crate::storage::S3Bucket::Main, "cli_upload_rejected").await;
211 212 return Err(e);
212 213 }
213 214
214 - db::pending_uploads::remove_pending_upload(&state.db, req.user_id, &req.s3_key, "main").await?;
215 + db::pending_uploads::remove_pending_upload(&state.db, actor.user_id(), &req.s3_key, "main").await?;
215 216
216 217 // Update the database with S3 key and file size.
217 218 let file_name = req.s3_key.rsplit('/').next().map(|s| s.to_string());
218 219 let commit_target = match file_type {
219 220 FileType::Audio => {
220 - db::items::update_item_audio_s3_key(&state.db, req.item_id, req.user_id, &req.s3_key).await?;
221 - db::items::update_item_audio_file_size(&state.db, req.item_id, req.user_id, file_size_bytes)
221 + db::items::update_item_audio_s3_key(&state.db, req.item_id, actor.user_id(), &req.s3_key).await?;
222 + db::items::update_item_audio_file_size(&state.db, req.item_id, actor.user_id(), file_size_bytes)
222 223 .await?;
223 224 crate::routes::storage::CommitTarget::Item(req.item_id)
224 225 }
@@ -237,8 +238,8 @@ pub(super) async fn confirm_upload(
237 238 crate::routes::storage::CommitTarget::Version(version.id)
238 239 }
239 240 FileType::Video => {
240 - db::items::update_item_video_s3_key(&state.db, req.item_id, req.user_id, &req.s3_key).await?;
241 - db::items::update_item_video_file_size(&state.db, req.item_id, req.user_id, file_size_bytes)
241 + db::items::update_item_video_s3_key(&state.db, req.item_id, actor.user_id(), &req.s3_key).await?;
242 + db::items::update_item_video_file_size(&state.db, req.item_id, actor.user_id(), file_size_bytes)
242 243 .await?;
243 244 crate::routes::storage::CommitTarget::Item(req.item_id)
244 245 }
@@ -252,7 +253,7 @@ pub(super) async fn confirm_upload(
252 253 commit_target,
253 254 &req.s3_key,
254 255 file_type,
255 - req.user_id,
256 + actor.user_id(),
256 257 file_size_bytes,
257 258 ).await?;
258 259
@@ -264,7 +265,7 @@ pub(super) async fn confirm_upload(
264 265 }
265 266
266 267 tracing::info!(
267 - user = %req.user_id,
268 + user = %actor.user_id(),
268 269 item = %req.item_id,
269 270 file_type = ?file_type,
270 271 s3_key = %req.s3_key,
@@ -279,7 +280,6 @@ pub(super) async fn confirm_upload(
279 280
280 281 #[derive(Deserialize)]
281 282 pub(super) struct UserIdQuery {
282 - user_id: UserId,
283 283 }
284 284
285 285 #[derive(Serialize)]
@@ -295,13 +295,14 @@ struct StorageInfoResponse {
295 295 #[tracing::instrument(skip_all, name = "internal::creator_storage")]
296 296 pub(super) async fn creator_storage(
297 297 State(state): State<AppState>,
298 + actor: InternalActor,
298 299 _auth: ServiceAuth,
299 - Query(query): Query<UserIdQuery>,
300 + Query(_query): Query<UserIdQuery>,
300 301 ) -> Result<impl IntoResponse> {
301 - let used = db::creator_tiers::get_storage_used(&state.db, query.user_id).await?;
302 + let used = db::creator_tiers::get_storage_used(&state.db, actor.user_id()).await?;
302 303
303 304 // Resolve effective tier
304 - let tier = db::creator_tiers::get_active_creator_tier(&state.db, query.user_id).await?;
305 + let tier = db::creator_tiers::get_active_creator_tier(&state.db, actor.user_id()).await?;
305 306
306 307 let (max_storage, allows_uploads) = match tier {
307 308 Some(t) => (t.max_storage_bytes(), t.allows_file_uploads()),
@@ -21,6 +21,7 @@ pub struct TestClient {
21 21 csrf_token: Option<String>,
22 22 forwarded_ip: String,
23 23 bearer_token: Option<String>,
24 + actor_token: Option<String>,
24 25 }
25 26
26 27 impl TestClient {
@@ -34,6 +35,7 @@ impl TestClient {
34 35 csrf_token: None,
35 36 forwarded_ip: format!("10.1.{}.{}", octet3, octet4),
36 37 bearer_token: None,
38 + actor_token: None,
37 39 }
38 40 }
39 41
@@ -55,6 +57,13 @@ impl TestClient {
55 57 self.bearer_token = None;
56 58 }
57 59
60 + /// Set the `X-MNW-Actor` assertion sent on internal-API requests (mirrors
61 + /// what the CLI forwards after ssh-key-lookup).
62 + #[allow(dead_code)]
63 + pub fn set_actor_token(&mut self, token: &str) {
64 + self.actor_token = Some(token.to_string());
65 + }
66 +
58 67 /// Drop all stored cookies — simulates a fresh client with no session, e.g.
59 68 /// a CLI `git` request that authenticates via a token rather than a browser
60 69 /// cookie.
@@ -251,6 +260,9 @@ impl TestClient {
251 260 if let Some(ref token) = self.bearer_token {
252 261 builder = builder.header(header::AUTHORIZATION, format!("Bearer {}", token));
253 262 }
263 + if let Some(ref actor) = self.actor_token {
264 + builder = builder.header("X-MNW-Actor", actor);
265 + }
254 266
255 267 // Set content type
256 268 if let Some(ct) = content_type {
@@ -410,6 +422,9 @@ impl TestClient {
410 422 if let Some(ref token) = self.bearer_token {
411 423 builder = builder.header(header::AUTHORIZATION, format!("Bearer {}", token));
412 424 }
425 + if let Some(ref actor) = self.actor_token {
426 + builder = builder.header("X-MNW-Actor", actor);
427 + }
413 428
414 429 if !self.cookies.is_empty() {
415 430 let cookie_header: String = self
@@ -121,6 +121,12 @@ async fn internal_confirm_upload_replay_is_idempotent() {
121 121 "file_type": "audio", "s3_key": s3_key,
122 122 });
123 123 h.client.set_bearer_token("test-cli-token");
124 + let actor = makenotwork::crypto::mint_internal_actor_token(
125 + user_id,
126 + chrono::Utc::now().timestamp() + 3600,
127 + "test-signing-secret-for-integration-tests",
128 + );
129 + h.client.set_actor_token(&actor);
124 130 let r1 = h.client.post_json("/api/internal/upload/confirm", &confirm.to_string()).await;
125 131 assert!(r1.status.is_success(), "first internal confirm failed: {}", r1.text);
126 132 let r2 = h.client.post_json("/api/internal/upload/confirm", &confirm.to_string()).await;
@@ -145,6 +151,34 @@ async fn internal_confirm_upload_replay_is_idempotent() {
145 151 assert_eq!(storage_used, expected_size, "replay must not double-charge storage");
146 152 }
147 153
154 + /// A valid ServiceAuth bearer alone is not enough to act on the internal API:
155 + /// without a valid `X-MNW-Actor` assertion the request is rejected. Proves a
156 + /// leaked service token cannot name an arbitrary user (audit 2026-07-01).
157 + #[tokio::test]
158 + async fn internal_confirm_without_actor_token_rejected() {
159 + let mem = std::sync::Arc::new(crate::harness::storage::InMemoryStorage::new());
160 + let mut h = TestHarness::build(crate::harness::BuildOptions {
161 + storage: Some(mem),
162 + cli_service_token: Some("test-cli-token".to_string()),
163 + ..Default::default()
164 + })
165 + .await;
166 +
167 + let setup = h.create_creator_with_item("noactor", "audio", 0).await;
168 + h.trust_user(setup.user_id).await;
169 + h.grant_tier(setup.user_id, "small_files").await;
170 +
171 + let confirm = json!({
172 + "user_id": setup.user_id, "item_id": setup.item_id,
173 + "file_type": "audio", "s3_key": "noactor/whatever.mp3",
174 + });
175 + // Bearer set, but no actor assertion.
176 + h.client.set_bearer_token("test-cli-token");
177 + let resp = h.client.post_json("/api/internal/upload/confirm", &confirm.to_string()).await;
178 + assert_eq!(resp.status.as_u16(), 401, "missing actor assertion must be rejected: {}", resp.text);
179 + h.client.clear_bearer_token();
180 + }
181 +
148 182 #[tokio::test]
149 183 async fn confirm_item_cover_via_dedicated_route_writes_key_and_url() {
150 184 // Covers go through /api/items/image/{presign,confirm}, which writes