Skip to main content

max / makenotwork

7.7 KB · 221 lines History Blame Raw
1 //! SyncKit SDK key claim / release / list endpoints.
2 //!
3 //! Server-to-server: the developer's backend sends the app's keys-endpoint
4 //! secret (`app_secret`) in the JSON body (no JWT, no session). Each call
5 //! looks up the app via `db::synckit::get_sync_app_by_keys_secret`, enforces
6 //! billing status and (for `per_key` apps) the key cap, then performs the
7 //! operation.
8 //!
9 //! The secret is deliberately not the app's `api_key`. That value is compiled
10 //! into every shipped client, so anyone with a binary could recover it; when
11 //! it gated these routes, they could spend the app's key cap and pollute claim
12 //! attribution under the app's identity. The secret is generated from the
13 //! dashboard, shown once, and belongs on a developer backend. An app that has
14 //! not generated one cannot call these routes at all. There is no fallback
15 //! to the api_key, by design (migration 175).
16 //!
17 //! See migration 117 for the underlying `sync_app_keys` schema (active claim
18 //! is a row with `released_at IS NULL`; the unique index is partial).
19
20 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
21 use serde_json::json;
22
23 use sqlx::PgPool;
24
25 use crate::{
26 db::{self, synckit_billing},
27 error::{AppError, Result},
28 };
29
30 use super::{
31 ClaimKeyRequest, ClaimKeyResponse, KeyInfo, ListKeysRequest, ListKeysResponse,
32 ReleaseKeyRequest, ReleaseKeyResponse,
33 };
34
35 /// `POST /api/sync/keys/claim`: server-to-server SDK key claim.
36 ///
37 /// Looks up the app by `app_secret`, then:
38 /// - Internal apps bypass all billing checks.
39 /// - Returns 402 `{ reason: "billing_inactive" }` when billing isn't active.
40 /// - In `per_key` mode, returns 402
41 /// `{ reason: "key_limit_reached", key_cap, keys_claimed }` if the cap is
42 /// reached and the key is not already actively claimed (re-claims are
43 /// always idempotent OK).
44 #[tracing::instrument(skip_all, name = "synckit::keys::claim")]
45 pub(super) async fn claim(
46 State(db): State<PgPool>,
47 Json(req): Json<ClaimKeyRequest>,
48 ) -> Result<axum::response::Response> {
49 let app = db::synckit::get_sync_app_by_keys_secret(&db, &req.app_secret)
50 .await?
51 .ok_or(AppError::Unauthorized)?;
52
53 let billing = synckit_billing::get_app_with_billing(&db, app.id)
54 .await?
55 .ok_or(AppError::NotFound)?;
56
57 // `key_cap` is enforced inside `claim_key`, under the usage-row lock, so
58 // concurrent claims of distinct keys can't over-allocate. `None` means
59 // uncapped (internal apps, or `bulk` developer apps).
60 let key_cap = if billing.is_internal {
61 None
62 } else {
63 if billing.billing_status != crate::db::SyncBillingStatus::Active {
64 return Ok((
65 StatusCode::PAYMENT_REQUIRED,
66 Json(json!({ "reason": "billing_inactive" })),
67 )
68 .into_response());
69 }
70 if billing.enforcement_mode == "per_key" {
71 Some(billing.key_cap.unwrap_or(0))
72 } else {
73 None
74 }
75 };
76
77 let result = synckit_billing::claim_key(&db, app.id, &req.key, key_cap).await?;
78 if result.cap_reached {
79 return Ok((
80 StatusCode::PAYMENT_REQUIRED,
81 Json(json!({
82 "reason": "key_limit_reached",
83 "key_cap": key_cap.unwrap_or(0),
84 "keys_claimed": result.total_claimed,
85 })),
86 )
87 .into_response());
88 }
89 Ok(Json(ClaimKeyResponse {
90 newly_claimed: result.newly_claimed,
91 total_claimed: result.total_claimed,
92 })
93 .into_response())
94 }
95
96 /// `POST /api/sync/keys/release`: server-to-server SDK key release.
97 ///
98 /// Always permitted (even when the app is canceled or suspended) so that
99 /// cleanup paths can drain stale claims.
100 #[tracing::instrument(skip_all, name = "synckit::keys::release")]
101 pub(super) async fn release(
102 State(db): State<PgPool>,
103 Json(req): Json<ReleaseKeyRequest>,
104 ) -> Result<impl IntoResponse> {
105 let app = db::synckit::get_sync_app_by_keys_secret(&db, &req.app_secret)
106 .await?
107 .ok_or(AppError::Unauthorized)?;
108
109 let result = synckit_billing::release_key(&db, app.id, &req.key).await?;
110 Ok(Json(ReleaseKeyResponse {
111 newly_released: result.newly_released,
112 total_claimed: result.total_claimed,
113 }))
114 }
115
116 /// `POST /api/sync/keys/list`: paginated list of active key claims.
117 ///
118 /// Uses POST + body (not GET + query) for consistency with `/validate-app`,
119 /// keeping the secret out of access logs.
120 #[tracing::instrument(skip_all, name = "synckit::keys::list")]
121 pub(super) async fn list(
122 State(db): State<PgPool>,
123 Json(req): Json<ListKeysRequest>,
124 ) -> Result<impl IntoResponse> {
125 let app = db::synckit::get_sync_app_by_keys_secret(&db, &req.app_secret)
126 .await?
127 .ok_or(AppError::Unauthorized)?;
128
129 let limit = req.limit.unwrap_or(100).clamp(1, 1000) as i64;
130 // Clamp the offset too (UX-M2): an unbounded offset becomes a giant SQL OFFSET
131 // deep-scan, the same DoS-shaped cost the limit clamp guards against.
132 let offset = (req.offset.unwrap_or(0) as i64).clamp(0, 1_000_000_000);
133
134 let rows = synckit_billing::list_active_keys(&db, app.id, limit, offset).await?;
135
136 let keys = rows
137 .into_iter()
138 .map(|r| KeyInfo {
139 id: r.id,
140 key: r.key,
141 claimed_at: r.claimed_at,
142 bytes_stored: r.bytes_stored,
143 })
144 .collect();
145
146 Ok(Json(ListKeysResponse { keys }))
147 }
148
149 #[cfg(test)]
150 mod tests {
151 use super::super::{
152 ClaimKeyRequest, ClaimKeyResponse, ListKeysRequest, ListKeysResponse, ReleaseKeyRequest,
153 ReleaseKeyResponse,
154 };
155
156 #[test]
157 fn claim_request_roundtrips() {
158 let json = r#"{"app_secret":"abc","key":"dev-1"}"#;
159 let req: ClaimKeyRequest = serde_json::from_str(json).unwrap();
160 assert_eq!(req.app_secret, "abc");
161 assert_eq!(req.key, "dev-1");
162 }
163
164 #[test]
165 fn claim_response_roundtrips() {
166 let resp = ClaimKeyResponse {
167 newly_claimed: true,
168 total_claimed: 7,
169 };
170 let s = serde_json::to_string(&resp).unwrap();
171 assert!(s.contains("\"newly_claimed\":true"));
172 assert!(s.contains("\"total_claimed\":7"));
173 }
174
175 #[test]
176 fn release_request_roundtrips() {
177 let json = r#"{"app_secret":"abc","key":"dev-1"}"#;
178 let req: ReleaseKeyRequest = serde_json::from_str(json).unwrap();
179 assert_eq!(req.app_secret, "abc");
180 assert_eq!(req.key, "dev-1");
181 }
182
183 #[test]
184 fn release_response_roundtrips() {
185 let resp = ReleaseKeyResponse {
186 newly_released: false,
187 total_claimed: 3,
188 };
189 let s = serde_json::to_string(&resp).unwrap();
190 assert!(s.contains("\"newly_released\":false"));
191 assert!(s.contains("\"total_claimed\":3"));
192 }
193
194 #[test]
195 fn list_request_defaults() {
196 let json = r#"{"app_secret":"abc"}"#;
197 let req: ListKeysRequest = serde_json::from_str(json).unwrap();
198 assert_eq!(req.app_secret, "abc");
199 assert!(req.limit.is_none());
200 assert!(req.offset.is_none());
201 }
202
203 /// The api_key ships inside every client binary. A body naming it must not
204 /// deserialize into a keys-endpoint request, or the closed hole reopens the
205 /// first time someone copies an old snippet.
206 #[test]
207 fn api_key_field_is_not_accepted() {
208 let json = r#"{"api_key":"abc","key":"dev-1"}"#;
209 assert!(serde_json::from_str::<ClaimKeyRequest>(json).is_err());
210 assert!(serde_json::from_str::<ReleaseKeyRequest>(json).is_err());
211 assert!(serde_json::from_str::<ListKeysRequest>(r#"{"api_key":"abc"}"#).is_err());
212 }
213
214 #[test]
215 fn list_response_empty_roundtrips() {
216 let resp = ListKeysResponse { keys: vec![] };
217 let s = serde_json::to_string(&resp).unwrap();
218 assert_eq!(s, r#"{"keys":[]}"#);
219 }
220 }
221