Skip to main content

max / makenotwork

6.8 KB · 208 lines History Blame Raw
1 //! SyncKit SDK key claim / release / list endpoints.
2 //!
3 //! Server-to-server: the developer's backend sends the SyncKit app's
4 //! `api_key` in the JSON body (no JWT, no session). Each call looks up the
5 //! app via `db::synckit::get_sync_app_by_api_key`, enforces billing status
6 //! and (for `per_key` apps) the key cap, then performs the operation.
7 //!
8 //! See migration 117 for the underlying `sync_app_keys` schema (active claim
9 //! is a row with `released_at IS NULL`; the unique index is partial).
10 //
11 // TODO Phase 4 integration tests: blocked on migration 117 applied to test DB
12
13 use axum::{
14 extract::State,
15 http::StatusCode,
16 response::IntoResponse,
17 Json,
18 };
19 use serde_json::json;
20
21 use crate::{
22 db::{self, synckit_billing},
23 error::{AppError, Result},
24 AppState,
25 };
26
27 use super::{
28 ClaimKeyRequest, ClaimKeyResponse, KeyInfo, ListKeysRequest, ListKeysResponse,
29 ReleaseKeyRequest, ReleaseKeyResponse,
30 };
31
32 /// `POST /api/sync/keys/claim`: server-to-server SDK key claim.
33 ///
34 /// Looks up the app by `api_key`, then:
35 /// - Internal apps bypass all billing checks.
36 /// - Returns 402 `{ reason: "billing_inactive" }` when billing isn't active.
37 /// - In `per_key` mode, returns 402
38 /// `{ reason: "key_limit_reached", key_cap, keys_claimed }` if the cap is
39 /// reached and the key is not already actively claimed (re-claims are
40 /// always idempotent OK).
41 #[tracing::instrument(skip_all, name = "synckit::keys::claim")]
42 pub(super) async fn claim(
43 State(state): State<AppState>,
44 Json(req): Json<ClaimKeyRequest>,
45 ) -> Result<axum::response::Response> {
46 let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key)
47 .await?
48 .ok_or(AppError::Unauthorized)?;
49
50 let billing = synckit_billing::get_app_with_billing(&state.db, app.id)
51 .await?
52 .ok_or(AppError::NotFound)?;
53
54 if !billing.is_internal {
55 if billing.billing_status != "active" {
56 return Ok((
57 StatusCode::PAYMENT_REQUIRED,
58 Json(json!({ "reason": "billing_inactive" })),
59 )
60 .into_response());
61 }
62
63 if billing.enforcement_mode == "per_key" {
64 let key_cap = billing.key_cap.unwrap_or(0);
65 let keys_claimed = billing.keys_claimed.unwrap_or(0);
66 if keys_claimed >= key_cap {
67 // Re-claim of an already-active key is always OK — it doesn't
68 // consume a new slot.
69 let already_active = synckit_billing::is_key_actively_claimed(
70 &state.db, app.id, &req.key,
71 )
72 .await?;
73 if !already_active {
74 return Ok((
75 StatusCode::PAYMENT_REQUIRED,
76 Json(json!({
77 "reason": "key_limit_reached",
78 "key_cap": key_cap,
79 "keys_claimed": keys_claimed,
80 })),
81 )
82 .into_response());
83 }
84 }
85 }
86 }
87
88 let result = synckit_billing::claim_key(&state.db, app.id, &req.key).await?;
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(state): State<AppState>,
103 Json(req): Json<ReleaseKeyRequest>,
104 ) -> Result<impl IntoResponse> {
105 let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key)
106 .await?
107 .ok_or(AppError::Unauthorized)?;
108
109 let result = synckit_billing::release_key(&state.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 api_key out of access logs.
120 #[tracing::instrument(skip_all, name = "synckit::keys::list")]
121 pub(super) async fn list(
122 State(state): State<AppState>,
123 Json(req): Json<ListKeysRequest>,
124 ) -> Result<impl IntoResponse> {
125 let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key)
126 .await?
127 .ok_or(AppError::Unauthorized)?;
128
129 let limit = req.limit.unwrap_or(100).clamp(1, 1000) as i64;
130 let offset = req.offset.unwrap_or(0) as i64;
131
132 let rows = synckit_billing::list_active_keys(&state.db, app.id, limit, offset).await?;
133
134 let keys = rows
135 .into_iter()
136 .map(|r| KeyInfo {
137 id: r.id,
138 key: r.key,
139 claimed_at: r.claimed_at,
140 bytes_stored: r.bytes_stored,
141 })
142 .collect();
143
144 Ok(Json(ListKeysResponse { keys }))
145 }
146
147 #[cfg(test)]
148 mod tests {
149 use super::super::{
150 ClaimKeyRequest, ClaimKeyResponse, ListKeysRequest, ListKeysResponse,
151 ReleaseKeyRequest, ReleaseKeyResponse,
152 };
153
154 #[test]
155 fn claim_request_roundtrips() {
156 let json = r#"{"api_key":"abc","key":"dev-1"}"#;
157 let req: ClaimKeyRequest = serde_json::from_str(json).unwrap();
158 assert_eq!(req.api_key, "abc");
159 assert_eq!(req.key, "dev-1");
160 }
161
162 #[test]
163 fn claim_response_roundtrips() {
164 let resp = ClaimKeyResponse {
165 newly_claimed: true,
166 total_claimed: 7,
167 };
168 let s = serde_json::to_string(&resp).unwrap();
169 assert!(s.contains("\"newly_claimed\":true"));
170 assert!(s.contains("\"total_claimed\":7"));
171 }
172
173 #[test]
174 fn release_request_roundtrips() {
175 let json = r#"{"api_key":"abc","key":"dev-1"}"#;
176 let req: ReleaseKeyRequest = serde_json::from_str(json).unwrap();
177 assert_eq!(req.api_key, "abc");
178 assert_eq!(req.key, "dev-1");
179 }
180
181 #[test]
182 fn release_response_roundtrips() {
183 let resp = ReleaseKeyResponse {
184 newly_released: false,
185 total_claimed: 3,
186 };
187 let s = serde_json::to_string(&resp).unwrap();
188 assert!(s.contains("\"newly_released\":false"));
189 assert!(s.contains("\"total_claimed\":3"));
190 }
191
192 #[test]
193 fn list_request_defaults() {
194 let json = r#"{"api_key":"abc"}"#;
195 let req: ListKeysRequest = serde_json::from_str(json).unwrap();
196 assert_eq!(req.api_key, "abc");
197 assert!(req.limit.is_none());
198 assert!(req.offset.is_none());
199 }
200
201 #[test]
202 fn list_response_empty_roundtrips() {
203 let resp = ListKeysResponse { keys: vec![] };
204 let s = serde_json::to_string(&resp).unwrap();
205 assert_eq!(s, r#"{"keys":[]}"#);
206 }
207 }
208