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
Signed with PGP, not checked
Commit: 4192670238b071eb877d2a46486ffd01b23560b5
Parent: 4835beb
13 files changed, +439 insertions, -147 deletions
@@ -11,6 +11,11 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 }
@@ -425,6 +425,54 @@
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 @@
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 @@
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 @@
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]
@@ -107,6 +107,10 @@
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 }
@@ -21,6 +21,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 }