Skip to main content

max / makenotwork

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