Skip to main content

max / makenotwork

Split synckit/mod.rs: DTOs to their handlers, router to its own file 1439 lines, of which 813 were 60-odd request and response types and 468 were one router builder. Forty-six lines were logic. Every DTO was imported straight back down into exactly one handler module, so the parent was a holding pen. Each DTO now lives in the module whose endpoints send and receive it, and the router builder gets routes.rs. What is left in mod.rs is the module doc, the declarations, and one re-export. wire.rs holds the six push/pull types, the whole shared surface between personal sync and group sync. Six is the number; a seventh belongs in whichever of them uses it. The cost was 45 paths in openapi.rs gaining one segment each. The alternative was 45 `pub(crate) use` lines in mod.rs, which is most of what this split set out to remove. utoipa takes schema names from the ToSchema derive rather than the module path, so the generated document is unchanged: `export-openapi --stdout` diffs byte-identical against the committed openapi.json. AppWithKey, AppKeysSecret and the two key generators were pub(super) to be reachable from the parent. They live in apps.rs now and are used only there, so they are private. A tightening the move made free.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 03:08 UTC
Signed with PGP, not checked
Commit: 4721d2a801940b0953844c9d952404549e73c9e7
Parent: d6a9bb5
11 files changed, +1482 insertions, -593 deletions
@@ -90,53 +90,53 @@
90 90 crate::routes::api::git_notes::SearchHit,
91 91 crate::routes::api::git_notes::SearchResponse,
92 92 // SyncKit
93 - crate::routes::synckit::SyncAuthRequest,
94 - crate::routes::synckit::SyncAuthResponse,
95 - crate::routes::synckit::ValidateAppQuery,
96 - crate::routes::synckit::ValidateAppResponse,
97 - crate::routes::synckit::PushRequest,
98 - crate::routes::synckit::ChangeEntry,
99 - crate::routes::synckit::PushResponse,
100 - crate::routes::synckit::PullRequest,
101 - crate::routes::synckit::PullResponse,
102 - crate::routes::synckit::PullChangeEntry,
103 - crate::routes::synckit::SyncDeviceResponse,
104 - crate::routes::synckit::RegisterDeviceRequest,
105 - crate::routes::synckit::SyncStatusResponse,
106 - crate::routes::synckit::PutKeyRequest,
107 - crate::routes::synckit::GetKeyResponse,
108 - crate::routes::synckit::BlobUploadUrlRequest,
109 - crate::routes::synckit::BlobUploadUrlResponse,
110 - crate::routes::synckit::BlobConfirmRequest,
111 - crate::routes::synckit::BlobMultipartStartRequest,
112 - crate::routes::synckit::BlobMultipartStartResponse,
113 - crate::routes::synckit::BlobMultipartPartsRequest,
114 - crate::routes::synckit::BlobMultipartPartsResponse,
115 - crate::routes::synckit::BlobMultipartPartUrl,
116 - crate::routes::synckit::BlobMultipartCompleteRequest,
117 - crate::routes::synckit::BlobMultipartCompletedPart,
118 - crate::routes::synckit::BlobMultipartAbortRequest,
119 - crate::routes::synckit::BlobDownloadUrlRequest,
120 - crate::routes::synckit::BlobDownloadUrlResponse,
93 + crate::routes::synckit::auth::SyncAuthRequest,
94 + crate::routes::synckit::auth::SyncAuthResponse,
95 + crate::routes::synckit::auth::ValidateAppQuery,
96 + crate::routes::synckit::auth::ValidateAppResponse,
97 + crate::routes::synckit::wire::PushRequest,
98 + crate::routes::synckit::wire::ChangeEntry,
99 + crate::routes::synckit::wire::PushResponse,
100 + crate::routes::synckit::wire::PullRequest,
101 + crate::routes::synckit::wire::PullResponse,
102 + crate::routes::synckit::wire::PullChangeEntry,
103 + crate::routes::synckit::sync::SyncDeviceResponse,
104 + crate::routes::synckit::sync::RegisterDeviceRequest,
105 + crate::routes::synckit::sync::SyncStatusResponse,
106 + crate::routes::synckit::sync::PutKeyRequest,
107 + crate::routes::synckit::sync::GetKeyResponse,
108 + crate::routes::synckit::blobs::BlobUploadUrlRequest,
109 + crate::routes::synckit::blobs::BlobUploadUrlResponse,
110 + crate::routes::synckit::blobs::BlobConfirmRequest,
111 + crate::routes::synckit::blobs::BlobMultipartStartRequest,
112 + crate::routes::synckit::blobs::BlobMultipartStartResponse,
113 + crate::routes::synckit::blobs::BlobMultipartPartsRequest,
114 + crate::routes::synckit::blobs::BlobMultipartPartsResponse,
115 + crate::routes::synckit::blobs::BlobMultipartPartUrl,
116 + crate::routes::synckit::blobs::BlobMultipartCompleteRequest,
117 + crate::routes::synckit::blobs::BlobMultipartCompletedPart,
118 + crate::routes::synckit::blobs::BlobMultipartAbortRequest,
119 + crate::routes::synckit::blobs::BlobDownloadUrlRequest,
120 + crate::routes::synckit::blobs::BlobDownloadUrlResponse,
121 121 // SyncKit, Account & subscription
122 - crate::routes::synckit::SyncAccountResponse,
123 - crate::routes::synckit::SyncSubscriptionStatusResponse,
124 - crate::routes::synckit::AppPricingRequest,
125 - crate::routes::synckit::AppPricingResponse,
126 - crate::routes::synckit::SyncQuoteRequest,
127 - crate::routes::synckit::SyncQuoteResponse,
128 - crate::routes::synckit::SyncSubscribeRequest,
129 - crate::routes::synckit::SyncCheckoutResponse,
130 - crate::routes::synckit::SyncCapChangeRequest,
122 + crate::routes::synckit::sync::SyncAccountResponse,
123 + crate::routes::synckit::sync::SyncSubscriptionStatusResponse,
124 + crate::routes::synckit::sync::AppPricingRequest,
125 + crate::routes::synckit::sync::AppPricingResponse,
126 + crate::routes::synckit::sync::SyncQuoteRequest,
127 + crate::routes::synckit::sync::SyncQuoteResponse,
128 + crate::routes::synckit::sync::SyncSubscribeRequest,
129 + crate::routes::synckit::sync::SyncCheckoutResponse,
130 + crate::routes::synckit::sync::SyncCapChangeRequest,
131 131 // SyncKit, Key rotation
132 - crate::routes::synckit::BeginRotationRequest,
133 - crate::routes::synckit::BeginRotationResponse,
134 - crate::routes::synckit::RotationEntriesRequest,
135 - crate::routes::synckit::RotationEntriesResponse,
136 - crate::routes::synckit::RotationBatchRequest,
137 - crate::routes::synckit::RotationBatchEntry,
138 - crate::routes::synckit::RotationBatchResponse,
139 - crate::routes::synckit::CompleteRotationRequest,
132 + crate::routes::synckit::sync::BeginRotationRequest,
133 + crate::routes::synckit::sync::BeginRotationResponse,
134 + crate::routes::synckit::sync::RotationEntriesRequest,
135 + crate::routes::synckit::sync::RotationEntriesResponse,
136 + crate::routes::synckit::sync::RotationBatchRequest,
137 + crate::routes::synckit::sync::RotationBatchEntry,
138 + crate::routes::synckit::sync::RotationBatchResponse,
139 + crate::routes::synckit::sync::CompleteRotationRequest,
140 140 )),
141 141 tags(
142 142 (name = "License Keys", description = "Public license key validation, activation, and deactivation. Stable API: response shapes are frozen."),
@@ -1,10 +1,12 @@
1 1 //! SyncKit app management: create, list, delete, regenerate keys, update links.
2 2
3 + use crate::constants;
3 4 use axum::{
4 5 Json,
5 6 extract::{Path, State},
6 7 response::IntoResponse,
7 8 };
9 + use serde::{Deserialize, Serialize};
8 10
9 11 use sqlx::PgPool;
10 12
@@ -15,10 +17,6 @@
15 17 validation,
16 18 };
17 19
18 - use super::UpdateAppSlugRequest;
19 -
20 - use super::{CreateAppRequest, UpdateAppLinkRequest};
21 -
22 20 /// Create a new sync app and generate its API key.
23 21 ///
24 22 /// `POST /api/sync/apps`: Session auth required.
@@ -35,13 +33,13 @@
35 33 let project_id = parse_and_verify_project(&db, user.id, req.project_id.as_deref()).await?;
36 34 let item_id = parse_and_verify_item(&db, user.id, req.item_id.as_deref()).await?;
37 35
38 - let api_key = super::generate_api_key();
36 + let api_key = generate_api_key();
39 37 let app = db::synckit::create_sync_app(&db, user.id, &req.name, &api_key, project_id, item_id)
40 38 .await?;
41 39
42 40 Ok((
43 41 axum::http::StatusCode::CREATED,
44 - Json(super::AppWithKey { app, api_key }),
42 + Json(AppWithKey { app, api_key }),
45 43 ))
46 44 }
47 45
@@ -75,10 +73,10 @@
75 73 return Err(AppError::Forbidden);
76 74 }
77 75
78 - let new_key = super::generate_api_key();
76 + let new_key = generate_api_key();
79 77 let updated = db::synckit::regenerate_sync_app_key(&db, app_id, &new_key).await?;
80 78
81 - Ok(Json(super::AppWithKey {
79 + Ok(Json(AppWithKey {
82 80 app: updated,
83 81 api_key: new_key,
84 82 }))
@@ -109,10 +107,10 @@
109 107 return Err(AppError::Forbidden);
110 108 }
111 109
112 - let new_secret = super::generate_app_secret();
110 + let new_secret = generate_app_secret();
113 111 let updated = db::synckit::set_sync_app_keys_secret(&db, app_id, &new_secret).await?;
114 112
115 - Ok(Json(super::AppKeysSecret {
113 + Ok(Json(AppKeysSecret {
116 114 app: updated,
117 115 app_secret: new_secret,
118 116 }))
@@ -239,3 +237,55 @@
239 237 }
240 238 Ok(Some(iid))
241 239 }
240 +
241 + #[derive(Deserialize)]
242 + pub(crate) struct CreateAppRequest {
243 + pub name: String,
244 + pub project_id: Option<String>,
245 + pub item_id: Option<String>,
246 + }
247 +
248 + #[derive(Deserialize)]
249 + pub(crate) struct UpdateAppLinkRequest {
250 + pub project_id: Option<String>,
251 + pub item_id: Option<String>,
252 + }
253 +
254 + #[derive(Deserialize)]
255 + pub(crate) struct UpdateAppSlugRequest {
256 + pub slug: String,
257 + }
258 +
259 + /// Response for create/regenerate that includes the plaintext API key (shown only once).
260 + #[derive(Serialize)]
261 + struct AppWithKey {
262 + #[serde(flatten)]
263 + pub app: db::DbSyncApp,
264 + /// The plaintext API key. Only returned on create and regenerate; not stored.
265 + pub api_key: String,
266 + }
267 +
268 + /// Response for generating/rotating the keys-endpoint secret. The plaintext is
269 + /// returned once and never again, only its hash is stored.
270 + #[derive(Serialize)]
271 + struct AppKeysSecret {
272 + #[serde(flatten)]
273 + pub app: db::DbSyncApp,
274 + /// The plaintext secret. Keep it on a developer backend; putting it in a
275 + /// shipped client reintroduces exactly the weakness it exists to close.
276 + pub app_secret: String,
277 + }
278 +
279 + fn generate_api_key() -> String {
280 + use rand::Rng;
281 + let mut bytes = [0u8; constants::SYNCKIT_API_KEY_LENGTH];
282 + rand::rng().fill_bytes(&mut bytes);
283 + hex::encode(bytes)
284 + }
285 +
286 + /// Generate the keys-endpoint secret. Same shape and entropy as an api_key;
287 + /// what differs is where it is allowed to live, a developer backend only,
288 + /// never compiled into a shipped client.
289 + fn generate_app_secret() -> String {
290 + generate_api_key()
291 + }
@@ -1,6 +1,8 @@
1 1 //! SyncKit authentication: JWT issuance and app validation.
2 2
3 + use crate::db::{SyncAppId, UserId};
3 4 use axum::{Json, extract::State, response::IntoResponse};
5 + use serde::{Deserialize, Serialize};
4 6
5 7 use sqlx::PgPool;
6 8
@@ -18,8 +20,6 @@
18 20 crate::auth::hash_password("anti-timing-dummy").expect("dummy hash")
19 21 });
20 22
21 - use super::{SyncAuthRequest, SyncAuthResponse, ValidateAppQuery, ValidateAppResponse};
22 -
23 23 /// Authenticate a user and return a JWT for subsequent sync API calls.
24 24 ///
25 25 /// Verifies the app API key, then validates user email/password credentials.
@@ -164,3 +164,32 @@
164 164 .ok_or(AppError::Unauthorized)?;
165 165 Ok(Json(ValidateAppResponse { app_name: app.name }))
166 166 }
167 +
168 + #[derive(Deserialize, utoipa::ToSchema)]
169 + pub(crate) struct SyncAuthRequest {
170 + pub email: String,
171 + pub password: String,
172 + pub api_key: String,
173 + /// Developer-defined SDK key. Identifies which billing slot this session's
174 + /// uploads count against. Required.
175 + pub key: String,
176 + }
177 +
178 + #[derive(Serialize, utoipa::ToSchema)]
179 + pub(crate) struct SyncAuthResponse {
180 + token: String,
181 + #[schema(value_type = String)]
182 + user_id: UserId,
183 + #[schema(value_type = String)]
184 + app_id: SyncAppId,
185 + }
186 +
187 + #[derive(Deserialize, utoipa::ToSchema)]
188 + pub(crate) struct ValidateAppQuery {
189 + pub(crate) api_key: String,
190 + }
191 +
192 + #[derive(Serialize, utoipa::ToSchema)]
193 + pub(crate) struct ValidateAppResponse {
194 + app_name: String,
195 + }
@@ -15,6 +15,8 @@
15 15 extract::{Path, State},
16 16 response::IntoResponse,
17 17 };
18 + use chrono::{DateTime, Utc};
19 + use serde::{Deserialize, Serialize};
18 20
19 21 use sqlx::PgPool;
20 22
@@ -26,11 +28,6 @@
26 28 synckit_billing::monthly_price_cents,
27 29 };
28 30
29 - use super::{
30 - BillingActivateRequest, BillingPatchRequest, BillingSetupResponse, BillingStatusResponse,
31 - BillingUpdatedResponse,
32 - };
33 -
34 31 /// Set up Stripe billing for a draft app: create a Customer, return the
35 32 /// billing-portal URL so the developer can add a payment method.
36 33 ///
@@ -465,3 +462,62 @@
465 462 assert!(validate_knobs("per_key", None, Some(0), Some(1)).is_err());
466 463 }
467 464 }
465 +
466 + /// Request body for `POST /api/sync/apps/{id}/billing/activate`. Knob shape
467 + /// matches the columns after migration 118.
468 + ///
469 + /// In `enforcement_mode = "bulk"`: `storage_gb_cap` is required; `key_cap` and
470 + /// `gb_per_key` must be omitted.
471 + ///
472 + /// In `enforcement_mode = "per_key"`: `key_cap` AND `gb_per_key` are required;
473 + /// `storage_gb_cap` must be omitted.
474 + #[derive(Deserialize)]
475 + pub(crate) struct BillingActivateRequest {
476 + pub enforcement_mode: String,
477 + pub storage_gb_cap: Option<u32>,
478 + pub key_cap: Option<u32>,
479 + pub gb_per_key: Option<u32>,
480 + }
481 +
482 + /// Request body for `PATCH /api/sync/apps/{id}/billing`; same shape as
483 + /// activate. (Reused via alias for clarity at call sites.)
484 + pub(crate) type BillingPatchRequest = BillingActivateRequest;
485 +
486 + /// Response from `POST /api/sync/apps/{id}/billing/setup`.
487 + #[derive(Serialize)]
488 + pub(crate) struct BillingSetupResponse {
489 + pub stripe_customer_id: String,
490 + pub billing_portal_url: String,
491 + }
492 +
493 + /// Response from `POST /api/sync/apps/{id}/billing/activate` and
494 + /// `PATCH /api/sync/apps/{id}/billing`.
495 + #[derive(Serialize)]
496 + pub(crate) struct BillingUpdatedResponse {
497 + pub monthly_price_cents: i64,
498 + pub billing_status: String,
499 + pub stripe_subscription_id: Option<String>,
500 + }
501 +
502 + /// Response from `GET /api/sync/apps/{id}/billing`.
503 + #[derive(Serialize)]
504 + pub(crate) struct BillingStatusResponse {
505 + pub app_id: SyncAppId,
506 + pub billing_status: String,
507 + pub is_internal: bool,
508 + pub enforcement_mode: String,
509 + pub storage_gb_cap: Option<u32>,
510 + pub key_cap: Option<u32>,
511 + pub gb_per_key: Option<u32>,
512 + pub bytes_stored: i64,
513 + /// Egress in the current billing period. Tracked for developer-facing
514 + /// stats only; egress is NOT a price input and NOT enforced as a cap.
515 + pub bytes_egress_period: i64,
516 + pub keys_claimed: u32,
517 + pub last_warning_pct: u8,
518 + pub current_period_start: Option<DateTime<Utc>>,
519 + pub current_period_end: Option<DateTime<Utc>>,
520 + /// Monthly price as computed by `synckit_billing::monthly_price_cents`.
521 + /// `None` while in draft (knobs not yet set).
522 + pub monthly_price_cents: Option<i64>,
523 + }
@@ -6,6 +6,7 @@
6 6 http::StatusCode,
7 7 response::{IntoResponse, Response},
8 8 };
9 + use serde::{Deserialize, Serialize};
9 10 use serde_json::json;
10 11
11 12 use sqlx::PgPool;
@@ -18,13 +19,6 @@
18 19 validation,
19 20 };
20 21
21 - use super::{
22 - BlobConfirmRequest, BlobDownloadUrlRequest, BlobDownloadUrlResponse, BlobMultipartAbortRequest,
23 - BlobMultipartCompleteRequest, BlobMultipartPartUrl, BlobMultipartPartsRequest,
24 - BlobMultipartPartsResponse, BlobMultipartStartRequest, BlobMultipartStartResponse,
25 - BlobUploadUrlRequest, BlobUploadUrlResponse,
26 - };
27 -
28 22 /// Request a pre-signed S3 upload URL for a blob.
29 23 ///
30 24 /// Content-addressed by hash: if a blob with the same hash already exists
@@ -691,3 +685,103 @@
691 685 );
692 686 }
693 687 }
688 +
689 + #[derive(Deserialize, utoipa::ToSchema)]
690 + pub(crate) struct BlobUploadUrlRequest {
691 + pub hash: String,
692 + pub size_bytes: i64,
693 + }
694 +
695 + #[derive(Serialize, utoipa::ToSchema)]
696 + pub(crate) struct BlobUploadUrlResponse {
697 + upload_url: String,
698 + already_exists: bool,
699 + }
700 +
701 + #[derive(Deserialize, utoipa::ToSchema)]
702 + pub(crate) struct BlobConfirmRequest {
703 + pub hash: String,
704 + // The confirm handler reads the authoritative object size from S3 and does
705 + // not trust a client-declared size. Clients may still send `size_bytes`; it
706 + // is ignored by deserialization (no `deny_unknown_fields`).
707 + }
708 +
709 + /// Open a multipart blob session. `size_bytes` is the *ciphertext* length,
710 + /// which the client knows before sealing anything (`blob_encrypted_len`), so
711 + /// both sides derive the same part geometry from it without a round trip.
712 + #[derive(Deserialize, utoipa::ToSchema)]
713 + pub(crate) struct BlobMultipartStartRequest {
714 + pub hash: String,
715 + pub size_bytes: i64,
716 + }
717 +
718 + #[derive(Serialize, utoipa::ToSchema)]
719 + pub(crate) struct BlobMultipartStartResponse {
720 + upload_id: String,
721 + part_size: usize,
722 + part_count: u32,
723 + /// Same dedup short-circuit as the one-shot upload: when true no session
724 + /// was opened and the other fields are empty.
725 + already_exists: bool,
726 + }
727 +
728 + #[derive(Deserialize, utoipa::ToSchema)]
729 + pub(crate) struct BlobMultipartPartsRequest {
730 + pub hash: String,
731 + pub upload_id: String,
732 + /// Must match the `size_bytes` passed to `start`, the plan is deterministic
733 + /// in it, and a different value would sign the wrong `Content-Length`s.
734 + pub size_bytes: i64,
735 + pub first_part: u32,
736 + pub count: u32,
737 + /// SHA-256 of each requested part's bytes, base64 of the raw digest,
738 + /// positionally aligned with `first_part..first_part + count`. Bound into
739 + /// the presigned URL so S3 rehashes the part and rejects a mismatch at write
740 + /// time. Optional for now, since a client can only supply a checksum for a
741 + /// part it has already built, which in practice means asking for one part
742 + /// at a time. When present the length must equal `count`.
743 + #[serde(default)]
744 + pub checksums: Option<Vec<String>>,
745 + }
746 +
747 + #[derive(Serialize, utoipa::ToSchema)]
748 + pub(crate) struct BlobMultipartPartUrl {
749 + part_number: i32,
750 + content_length: u64,
751 + url: String,
752 + }
753 +
754 + #[derive(Serialize, utoipa::ToSchema)]
755 + pub(crate) struct BlobMultipartPartsResponse {
756 + parts: Vec<BlobMultipartPartUrl>,
757 + expires_in: u64,
758 + }
759 +
760 + #[derive(Deserialize, utoipa::ToSchema)]
761 + pub(crate) struct BlobMultipartCompletedPart {
762 + pub part_number: i32,
763 + pub etag: String,
764 + }
765 +
766 + #[derive(Deserialize, utoipa::ToSchema)]
767 + pub(crate) struct BlobMultipartCompleteRequest {
768 + pub hash: String,
769 + pub upload_id: String,
770 + pub parts: Vec<BlobMultipartCompletedPart>,
771 + }
772 +
773 + #[derive(Deserialize, utoipa::ToSchema)]
774 + pub(crate) struct BlobMultipartAbortRequest {
775 + pub hash: String,
776 + pub upload_id: String,
777 + }
778 +
779 + #[derive(Deserialize, utoipa::ToSchema)]
780 + pub(crate) struct BlobDownloadUrlRequest {
781 + pub hash: String,
782 + }
783 +
784 + #[derive(Serialize, utoipa::ToSchema)]
785 + pub(crate) struct BlobDownloadUrlResponse {
786 + download_url: String,
787 + }
@@ -15,32 +15,26 @@
15 15 //! push notifications for groups, noted where they would hook in. Design: wiki
16 16 //! synckit-groups-design.
17 17
18 + use super::wire::{PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse};
18 19 use axum::{
19 20 Json,
20 21 extract::{Path, Query, State},
21 22 http::StatusCode,
22 23 response::{IntoResponse, Response},
23 24 };
24 - use chrono::{Duration, Utc};
25 + use chrono::{DateTime, Duration, Utc};
26 + use serde::{Deserialize, Serialize};
25 27 use serde_json::json;
26 28 use sqlx::PgPool;
27 29
28 30 use crate::{
29 31 constants,
30 - db::{self, DbSyncGroup, SyncGroupId, SyncGroupInvitationId, UserId},
32 + db::{self, DbSyncGroup, SyncAppId, SyncGroupId, SyncGroupInvitationId, UserId},
31 33 error::{AppError, Result},
32 34 synckit_auth::SyncUser,
33 35 validation,
34 36 };
35 37
36 - use super::{
37 - AcceptInvitationRequest, AddMemberRequest, ConfirmInvitationRequest, CreateGroupRequest,
38 - CreateInvitationRequest, CreateInvitationResponse, GrantQuery, GroupGrantResponse,
39 - GroupMemberPubkey, GroupMemberResponse, GroupResponse, InvitationPreviewResponse,
40 - InvitationResponse, PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse,
41 - RotateGroupKeyRequest,
42 - };
43 -
44 38 /// Fetch a group scoped to the caller's app, or 404. Guards every group handler
45 39 /// against cross-app id guessing before any membership check.
46 40 async fn require_group(
@@ -753,7 +747,7 @@
753 747 .filter(|i| i.group_id == group_id)
754 748 .ok_or(AppError::NotFound)?;
755 749
756 - if super::invitation_state(&invitation) != "accepted" {
750 + if invitation_state(&invitation) != "accepted" {
757 751 return Err(AppError::BadRequest(
758 752 "That invitation is not awaiting confirmation".to_string(),
759 753 ));
@@ -861,7 +855,7 @@
861 855 .map(|u| u.email.to_string())
862 856 .unwrap_or_default();
863 857
864 - let state = super::invitation_state(&invitation);
858 + let state = invitation_state(&invitation);
865 859 Ok(Json(InvitationPreviewResponse {
866 860 group_name: group.name,
867 861 inviter_email,
@@ -930,7 +924,257 @@
930 924 }
931 925 None => Err(AppError::BadRequest(format!(
932 926 "That invite link is {}",
933 - super::invitation_state(&existing)
927 + invitation_state(&existing)
934 928 ))),
935 929 }
936 930 }
931 +
932 + #[derive(Deserialize, utoipa::ToSchema)]
933 + pub(crate) struct CreateGroupRequest {
934 + /// The group id, generated client-side. The admin seals the GCK grant bound
935 + /// to this id before the group exists (the grant's AAD binds the group id), so
936 + /// the id must be chosen by the client, not the server. A UUID collision (PK
937 + /// conflict) is rejected.
938 + #[schema(value_type = String)]
939 + pub id: SyncGroupId,
940 + pub name: String,
941 + /// The Group Content Key sealed to the creating admin's own identity public
942 + /// key (base64), opaque to the server.
943 + pub admin_sealed_gck: String,
944 + /// The admin's own identity public key (base64), stored so the GCK can be
945 + /// re-sealed to the admin on a later rotation.
946 + pub admin_pubkey: String,
947 + }
948 +
949 + #[derive(Serialize, utoipa::ToSchema)]
950 + pub(crate) struct GroupResponse {
951 + #[schema(value_type = String)]
952 + id: SyncGroupId,
953 + #[schema(value_type = String)]
954 + app_id: SyncAppId,
955 + #[schema(value_type = String)]
956 + admin_user_id: UserId,
957 + name: String,
958 + gck_version: i32,
959 + #[schema(value_type = String)]
960 + created_at: DateTime<Utc>,
961 + }
962 +
963 + impl From<db::DbSyncGroup> for GroupResponse {
964 + fn from(g: db::DbSyncGroup) -> Self {
965 + Self {
966 + id: g.id,
967 + app_id: g.app_id,
968 + admin_user_id: g.admin_user_id,
969 + name: g.name,
970 + gck_version: g.gck_version,
971 + created_at: g.created_at,
972 + }
973 + }
974 + }
975 +
976 + #[derive(Deserialize, utoipa::ToSchema)]
977 + pub(crate) struct AddMemberRequest {
978 + /// The member's account email, resolved to a verified user server-side.
979 + pub member_email: String,
980 + /// The GCK sealed to the member's identity public key (base64), produced by
981 + /// the admin with the group's current GCK. Opaque to the server.
982 + pub sealed_gck: String,
983 + /// The member's identity public key (base64), stored so the GCK can be
984 + /// re-sealed to them on a later rotation.
985 + pub member_pubkey: String,
986 + /// Optional role: "member" (default) or "admin".
987 + #[serde(default)]
988 + pub role: Option<String>,
989 + }
990 +
991 + /// Issue an invite link, from `POST /groups/{id}/invitations`.
992 + #[derive(Deserialize, utoipa::ToSchema)]
993 + pub(crate) struct CreateInvitationRequest {
994 + /// How long the link stays redeemable, in hours. Clamped server-side; an
995 + /// omitted value takes the default. An unredeemed invitation always expires,
996 + /// so there is no "never" to ask for.
997 + #[serde(default)]
998 + pub expires_in_hours: Option<i64>,
999 + }
1000 +
1001 + /// A freshly issued invitation. The token appears here and nowhere else: the
1002 + /// server keeps only its hash, so this response is the single opportunity to
1003 + /// capture it.
1004 + #[derive(Serialize, utoipa::ToSchema)]
1005 + pub(crate) struct CreateInvitationResponse {
1006 + #[schema(value_type = String)]
1007 + pub id: SyncGroupInvitationId,
1008 + /// The one-use token, to be carried in the link the admin sends.
1009 + pub token: String,
1010 + #[schema(value_type = String)]
1011 + pub expires_at: DateTime<Utc>,
1012 + }
1013 +
1014 + /// Accept an invitation, from `POST /sync/invitations/accept`.
1015 + ///
1016 + /// Not nested under the group: the invitee is not a member yet and cannot be
1017 + /// asked to know a group id they have no access to. The token names the group.
1018 + #[derive(Deserialize, utoipa::ToSchema)]
1019 + pub(crate) struct AcceptInvitationRequest {
1020 + /// The one-use token from the link.
1021 + pub token: String,
1022 + /// The accepting user's identity public key (base64). What the admin will
1023 + /// seal the group key to, once they have confirmed its fingerprint.
1024 + pub invitee_pubkey: String,
1025 + }
1026 +
1027 + /// What an invitee is shown before accepting, from
1028 + /// `GET /sync/invitations/{token}`.
1029 + ///
1030 + /// Deliberately thin. It answers "which group, from whom, is this still good"
1031 + /// and nothing else, because it is readable by anyone holding the link.
1032 + #[derive(Serialize, utoipa::ToSchema)]
1033 + pub(crate) struct InvitationPreviewResponse {
1034 + pub group_name: String,
1035 + /// The inviting admin's email, so the invitee can tell whether the link came
1036 + /// from who they think it did.
1037 + pub inviter_email: String,
1038 + /// Whether the token can still be accepted. False covers expired, revoked,
1039 + /// redeemed, and already-accepted alike; the reason is in `state`.
1040 + pub redeemable: bool,
1041 + /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`.
1042 + pub state: String,
1043 + #[schema(value_type = String)]
1044 + pub expires_at: DateTime<Utc>,
1045 + }
1046 +
1047 + /// Confirm an accepted invitation, from
1048 + /// `POST /groups/{id}/invitations/{invitation_id}/confirm`.
1049 + ///
1050 + /// No public key here on purpose. The grant is sealed to the key recorded on the
1051 + /// invitation, so the key the admin confirmed is the key that gets used; letting
1052 + /// the caller re-supply one would reintroduce the substitution the confirmation
1053 + /// step exists to catch.
1054 + #[derive(Deserialize, utoipa::ToSchema)]
1055 + pub(crate) struct ConfirmInvitationRequest {
1056 + /// The group's current GCK sealed to the invitee's recorded public key
1057 + /// (base64). Opaque to the server.
1058 + pub sealed_gck: String,
1059 + /// Optional role: "member" (default) or "admin".
1060 + #[serde(default)]
1061 + pub role: Option<String>,
1062 + }
1063 +
1064 + /// One invitation in the admin's list, from `GET /groups/{id}/invitations`.
1065 + ///
1066 + /// Carries the invitee's public key so the admin's client can render its
1067 + /// fingerprint for the out-of-band check. The token is absent: the server does
1068 + /// not have it.
1069 + #[derive(Serialize, utoipa::ToSchema)]
1070 + pub(crate) struct InvitationResponse {
1071 + #[schema(value_type = String)]
1072 + pub id: SyncGroupInvitationId,
1073 + /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`.
1074 + pub state: String,
1075 + /// The accepting account's email, or `None` while outstanding.
1076 + pub invitee_email: Option<String>,
1077 + /// The accepting account's identity public key (base64), or `None` while
1078 + /// outstanding. The admin seals the GCK to this after confirming it.
1079 + pub invitee_pubkey: Option<String>,
1080 + #[schema(value_type = String)]
1081 + pub expires_at: DateTime<Utc>,
1082 + #[schema(value_type = String)]
1083 + pub created_at: DateTime<Utc>,
1084 + }
1085 +
1086 + /// The lifecycle state of an invitation as one word.
1087 + ///
1088 + /// Expiry is derived rather than stored as a state, so a row does not need
1089 + /// touching when its deadline passes. Order matters: a redeemed or revoked
1090 + /// invitation reports as such even after its expiry, because what happened to it
1091 + /// is more informative than the clock running out afterwards.
1092 + pub(crate) fn invitation_state(inv: &db::DbSyncGroupInvitation) -> &'static str {
1093 + if inv.redeemed_at.is_some() {
1094 + "redeemed"
1095 + } else if inv.revoked_at.is_some() {
1096 + "revoked"
1097 + } else if inv.accepted_at.is_some() {
1098 + "accepted"
1099 + } else if inv.expires_at <= Utc::now() {
1100 + "expired"
1101 + } else {
1102 + "pending"
1103 + }
1104 + }
1105 +
1106 + impl From<db::DbSyncGroupInvitation> for InvitationResponse {
1107 + fn from(inv: db::DbSyncGroupInvitation) -> Self {
1108 + let state = invitation_state(&inv);
1109 + Self {
1110 + id: inv.id,
1111 + state: state.to_string(),
1112 + invitee_email: inv.invitee_email,
1113 + invitee_pubkey: inv.invitee_pubkey,
1114 + expires_at: inv.expires_at,
1115 + created_at: inv.created_at,
1116 + }
1117 + }
1118 + }
1119 +
1120 + /// One member's identity public key, from `GET /groups/{id}/pubkeys`. The admin
1121 + /// re-seals a rotated GCK to each of these.
1122 + #[derive(Serialize, utoipa::ToSchema)]
1123 + pub(crate) struct GroupMemberPubkey {
1124 + #[schema(value_type = String)]
1125 + user_id: UserId,
1126 + pubkey: String,
1127 + }
1128 +
1129 + /// Query for `GET /groups/{id}/grant`: which GCK generation to fetch.
1130 + #[derive(Deserialize, utoipa::ToSchema)]
1131 + pub(crate) struct GrantQuery {
1132 + /// The generation wanted. Omitted means the newest the caller holds.
1133 + #[serde(default)]
1134 + pub version: Option<i32>,
1135 + }
1136 +
1137 + /// One member's re-sealed grant in a rotation batch.
1138 + #[derive(Deserialize, utoipa::ToSchema)]
1139 + pub(crate) struct RotateGrant {
1140 + #[schema(value_type = String)]
1141 + pub user_id: UserId,
1142 + /// The new GCK sealed to this member's stored identity public key (base64).
1143 + /// Opaque to the server.
1144 + pub sealed_gck: String,
1145 + }
1146 +
1147 + /// Rotate a group's GCK, from `POST /groups/{id}/rotate`.
1148 + ///
1149 + /// The grant set is the new membership: anyone holding a grant today and absent
1150 + /// here is removed by the rotation. That is what makes removal and re-key one
1151 + /// transaction rather than two calls with a window between them.
1152 + #[derive(Deserialize, utoipa::ToSchema)]
1153 + pub(crate) struct RotateGroupKeyRequest {
1154 + /// The new GCK generation. Must be greater than the group's current one, so
1155 + /// a replayed or stale rotation cannot roll the group back onto a key a
1156 + /// removed member still holds.
1157 + pub gck_version: i32,
1158 + /// Every remaining member's re-sealed grant, including the admin's own.
1159 + pub grants: Vec<RotateGrant>,
1160 + }
1161 +
1162 + #[derive(Serialize, utoipa::ToSchema)]
1163 + pub(crate) struct GroupMemberResponse {
1164 + #[schema(value_type = String)]
1165 + user_id: UserId,
1166 + /// The member's account email, so an admin panel can identify them.
1167 + email: String,
1168 + role: String,
1169 + #[schema(value_type = String)]
1170 + added_at: DateTime<Utc>,
1171 + }
1172 +
1173 + #[derive(Serialize, utoipa::ToSchema)]
1174 + pub(crate) struct GroupGrantResponse {
1175 + /// The caller's sealed GCK grant (base64); opened client-side with the
1176 + /// member's identity private key.
1177 + sealed_gck: String,
1178 + /// The GCK generation this grant was sealed under.
1179 + gck_version: i32,
1180 + }
@@ -18,6 +18,8 @@
18 18 //! is a row with `released_at IS NULL`; the unique index is partial).
19 19
20 20 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
21 + use chrono::{DateTime, Utc};
22 + use serde::{Deserialize, Serialize};
21 23 use serde_json::json;
22 24
23 25 use sqlx::PgPool;
@@ -27,11 +29,6 @@
27 29 error::{AppError, Result},
28 30 };
29 31
30 - use super::{
31 - ClaimKeyRequest, ClaimKeyResponse, KeyInfo, ListKeysRequest, ListKeysResponse,
32 - ReleaseKeyRequest, ReleaseKeyResponse,
33 - };
34 -
35 32 /// `POST /api/sync/keys/claim`: server-to-server SDK key claim.
36 33 ///
37 34 /// Looks up the app by `app_secret`, then:
@@ -148,7 +145,7 @@
148 145
149 146 #[cfg(test)]
150 147 mod tests {
151 - use super::super::{
148 + use super::{
152 149 ClaimKeyRequest, ClaimKeyResponse, ListKeysRequest, ListKeysResponse, ReleaseKeyRequest,
153 150 ReleaseKeyResponse,
154 151 };
@@ -218,3 +215,62 @@
218 215 assert_eq!(s, r#"{"keys":[]}"#);
219 216 }
220 217 }
218 +
219 + /// Request body for `POST /api/sync/keys/claim`. Server-to-server: developer's
220 + /// backend sends the app's keys-endpoint secret alongside the SDK key being
221 + /// claimed.
222 + ///
223 + /// `app_secret`, not `api_key`: the api_key is compiled into shipped clients
224 + /// and so cannot gate an endpoint that spends the app's key cap.
225 + #[derive(Deserialize)]
226 + pub(crate) struct ClaimKeyRequest {
227 + pub app_secret: String,
228 + pub key: String,
229 + }
230 +
231 + /// Response body for `POST /api/sync/keys/claim`.
232 + #[derive(Serialize)]
233 + pub(crate) struct ClaimKeyResponse {
234 + pub newly_claimed: bool,
235 + pub total_claimed: i32,
236 + }
237 +
238 + /// Request body for `POST /api/sync/keys/release`.
239 + #[derive(Deserialize)]
240 + pub(crate) struct ReleaseKeyRequest {
241 + pub app_secret: String,
242 + pub key: String,
243 + }
244 +
245 + /// Response body for `POST /api/sync/keys/release`.
246 + #[derive(Serialize)]
247 + pub(crate) struct ReleaseKeyResponse {
248 + pub newly_released: bool,
249 + pub total_claimed: i32,
250 + }
251 +
252 + /// Request body for `POST /api/sync/keys/list`. POST + body (not GET + query)
253 + /// to keep the secret out of access logs.
254 + #[derive(Deserialize)]
255 + pub(crate) struct ListKeysRequest {
256 + pub app_secret: String,
257 + pub limit: Option<u32>,
258 + pub offset: Option<u32>,
259 + }
260 +
261 + /// One row in the active-key list returned by `POST /api/sync/keys/list`.
262 + #[derive(Serialize)]
263 + pub(crate) struct KeyInfo {
264 + pub id: uuid::Uuid,
265 + pub key: String,
266 + pub claimed_at: DateTime<Utc>,
267 + /// Bytes stored under this key (rolling counter, reconciled weekly by
268 + /// the drift job). `0` if no upload has confirmed yet for this key.
269 + pub bytes_stored: i64,
270 + }
271 +
272 + /// Response body for `POST /api/sync/keys/list`.
273 + #[derive(Serialize)]
274 + pub(crate) struct ListKeysResponse {
275 + pub keys: Vec<KeyInfo>,
276 + }
@@ -23,1417 +23,9 @@
23 23 pub(crate) mod blobs;
24 24 pub(crate) mod groups;
25 25 pub(crate) mod keys;
26 + mod routes;
26 27 mod subscribe;
27 28 pub(crate) mod sync;
29 + pub(crate) mod wire;
28 30
29 - use axum::routing::get;
30 - use chrono::{DateTime, Utc};
31 - use serde::{Deserialize, Serialize};
32 - use tower_governor::GovernorLayer;
33 -
34 - use crate::{
35 - AppState, constants,
36 - csrf::{
37 - CsrfRouter, delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf,
38 - put_csrf_skip,
39 - },
40 - db::{
41 - self, SyncAppId, SyncDeviceId, SyncGroupId, SyncGroupInvitationId, SyncOperation,
42 - SyncPlatform, UserId,
43 - },
44 - };
45 -
46 - /// Reason strings for synckit CSRF Skip routes. The auth_routes and
47 - /// sync_routes blocks use server-to-server or JWT bearer auth with no
48 - /// session cookie; CSRF doesn't apply. The app_routes block IS
49 - /// session-authed (dashboard-driven) so those use `post_csrf` etc.
50 - const SYNCKIT_API_KEY_SKIP: &str = "synckit server-to-server: api_key auth, no session";
51 - const SYNCKIT_APP_SECRET_SKIP: &str =
52 - "synckit server-to-server: keys-endpoint app_secret auth, no session";
53 - const SYNCKIT_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session";
54 -
55 - /// Longest client version string we will store. Matches the column width in
56 - /// migration 180; a longer value is a client we don't recognise, so it is
57 - /// dropped rather than truncated into something that reads like a real version.
58 - const CLIENT_VERSION_MAX_LENGTH: usize = 32;
59 -
60 - /// The SDK version out of a `synckit-client/<version>` User-Agent, if the
61 - /// request carries one.
62 - ///
63 - /// Only the version is kept. A request from anything that is not the SDK (a
64 - /// browser, curl, an older client that sends no such header) yields `None`, and
65 - /// `None` is stored as-is: "syncing, version unknown" is a real answer and
66 - /// guessing would corrupt the field-version readout this exists to produce.
67 - /// The version is checked for shape, not parsed as semver, so a client that
68 - /// adds a pre-release suffix still reports.
69 - pub(crate) fn client_version(headers: &axum::http::HeaderMap) -> Option<String> {
70 - let version = headers
71 - .get(axum::http::header::USER_AGENT)?
72 - .to_str()
73 - .ok()?
74 - .split_whitespace()
75 - .next()?
76 - .strip_prefix("synckit-client/")?;
77 - let ok = !version.is_empty()
78 - && version.len() <= CLIENT_VERSION_MAX_LENGTH
79 - && version.starts_with(|c: char| c.is_ascii_digit())
80 - && version
81 - .chars()
82 - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_'));
83 - ok.then(|| version.to_string())
84 - }
85 -
86 - // --- Request/Response types ---
87 -
88 - #[derive(Deserialize, utoipa::ToSchema)]
89 - pub(crate) struct SyncAuthRequest {
90 - pub email: String,
91 - pub password: String,
92 - pub api_key: String,
93 - /// Developer-defined SDK key. Identifies which billing slot this session's
94 - /// uploads count against. Required.
95 - pub key: String,
96 - }
97 -
98 - #[derive(Serialize, utoipa::ToSchema)]
99 - pub(crate) struct SyncAuthResponse {
100 - token: String,
101 - #[schema(value_type = String)]
102 - user_id: UserId,
103 - #[schema(value_type = String)]
104 - app_id: SyncAppId,
105 - }
106 -
107 - #[derive(Deserialize, utoipa::ToSchema)]
108 - pub(crate) struct ValidateAppQuery {
109 - pub(crate) api_key: String,
110 - }
111 -
112 - #[derive(Serialize, utoipa::ToSchema)]
113 - pub(crate) struct ValidateAppResponse {
114 - app_name: String,
115 - }
116 -
117 - #[derive(Deserialize, utoipa::ToSchema)]
118 - pub(crate) struct PushRequest {
119 - #[schema(value_type = String)]
120 - pub device_id: SyncDeviceId,
121 - /// Client-generated UUID for idempotent push. If a push with the same
122 - /// batch_id has already been committed, the server returns the existing
123 - /// cursor without re-inserting.
124 - pub batch_id: uuid::Uuid,
125 - pub changes: Vec<ChangeEntry>,
126 - }
127 -
128 - #[derive(Deserialize, utoipa::ToSchema)]
129 - pub(crate) struct ChangeEntry {
130 - pub table: String,
131 - #[schema(value_type = String)]
132 - pub op: SyncOperation,
133 - pub row_id: String,
134 - #[schema(value_type = String)]
135 - pub timestamp: DateTime<Utc>,
136 - pub data: Option<serde_json::Value>,
137 - }
138 -
139 - #[derive(Serialize, utoipa::ToSchema)]
140 - pub(crate) struct PushResponse {
141 - cursor: i64,
142 - }
143 -
144 - #[derive(Deserialize, utoipa::ToSchema)]
145 - pub(crate) struct PullRequest {
146 - #[schema(value_type = String)]
147 - pub device_id: SyncDeviceId,
148 - pub cursor: i64,
149 - /// Optional table name filter; only return entries for these tables.
150 - #[serde(default)]
151 - pub tables: Option<Vec<String>>,
152 - /// Optional timestamp filter; only return entries at or after this time.
153 - #[serde(default)]
154 - #[schema(value_type = Option<String>)]
155 - pub since: Option<DateTime<Utc>>,
156 - }
157 -
158 - #[derive(Serialize, utoipa::ToSchema)]
159 - pub(crate) struct PullResponse {
160 - changes: Vec<PullChangeEntry>,
161 - cursor: i64,
162 - has_more: bool,
163 - }
164 -
165 - #[derive(Serialize, utoipa::ToSchema)]
166 - pub(crate) struct PullChangeEntry {
167 - seq: i64,
168 - #[schema(value_type = String)]
169 - device_id: SyncDeviceId,
170 - table: String,
171 - op: String,
172 - row_id: String,
173 - #[schema(value_type = String)]
174 - timestamp: DateTime<Utc>,
175 - data: Option<serde_json::Value>,
176 - /// Which encryption key was used. Null means key_id 1 (pre-rotation).
177 - #[serde(skip_serializing_if = "Option::is_none")]
178 - key_id: Option<i32>,
179 - /// For a group entry, the GCK generation its ciphertext is sealed under. The
180 - /// member resolves that generation's grant to decrypt it, which is how entries
181 - /// written before a rotation stay readable. Absent on personal entries, which
182 - /// key off `key_id` instead.
183 - #[serde(skip_serializing_if = "Option::is_none")]
184 - gck_version: Option<i32>,
185 - }
186 -
187 - #[derive(Serialize, utoipa::ToSchema)]
188 - pub(crate) struct SyncDeviceResponse {
189 - #[schema(value_type = String)]
190 - id: SyncDeviceId,
191 - #[schema(value_type = String)]
192 - app_id: SyncAppId,
193 - #[schema(value_type = String)]
194 - user_id: UserId,
195 - device_name: String,
196 - platform: String,
197 - #[schema(value_type = String)]
198 - last_seen_at: DateTime<Utc>,
199 - #[schema(value_type = String)]
200 - created_at: DateTime<Utc>,
201 - }
202 -
203 - #[derive(Deserialize, utoipa::ToSchema)]
204 - pub(crate) struct RegisterDeviceRequest {
205 - pub device_name: String,
206 - #[schema(value_type = String)]
207 - pub platform: SyncPlatform,
208 - }
209 -
210 - #[derive(Deserialize)]
211 - pub struct CreateAppRequest {
212 - pub name: String,
213 - pub project_id: Option<String>,
214 - pub item_id: Option<String>,
215 - }
216 -
217 - #[derive(Deserialize)]
218 - pub struct UpdateAppLinkRequest {
219 - pub project_id: Option<String>,
220 - pub item_id: Option<String>,
221 - }
222 -
223 - #[derive(Deserialize)]
224 - pub struct UpdateAppSlugRequest {
225 - pub slug: String,
226 - }
227 -
228 - #[derive(Serialize, utoipa::ToSchema)]
229 - pub(crate) struct SyncStatusResponse {
230 - total_changes: i64,
231 - latest_cursor: Option<i64>,
232 - }
233 -
234 - #[derive(Serialize, utoipa::ToSchema)]
235 - pub(crate) struct SyncAccountResponse {
236 - pub email: String,
237 - pub username: String,
238 - }
239 -
240 - // --- Group types ---
241 -
242 - #[derive(Deserialize, utoipa::ToSchema)]
243 - pub(crate) struct CreateGroupRequest {
244 - /// The group id, generated client-side. The admin seals the GCK grant bound
245 - /// to this id before the group exists (the grant's AAD binds the group id), so
246 - /// the id must be chosen by the client, not the server. A UUID collision (PK
247 - /// conflict) is rejected.
248 - #[schema(value_type = String)]
249 - pub id: SyncGroupId,
250 - pub name: String,
251 - /// The Group Content Key sealed to the creating admin's own identity public
252 - /// key (base64), opaque to the server.
253 - pub admin_sealed_gck: String,
254 - /// The admin's own identity public key (base64), stored so the GCK can be
255 - /// re-sealed to the admin on a later rotation.
256 - pub admin_pubkey: String,
257 - }
258 -
259 - #[derive(Serialize, utoipa::ToSchema)]
260 - pub(crate) struct GroupResponse {
261 - #[schema(value_type = String)]
262 - id: SyncGroupId,
263 - #[schema(value_type = String)]
264 - app_id: SyncAppId,
265 - #[schema(value_type = String)]
266 - admin_user_id: UserId,
267 - name: String,
268 - gck_version: i32,
269 - #[schema(value_type = String)]
270 - created_at: DateTime<Utc>,
271 - }
272 -
273 - impl From<db::DbSyncGroup> for GroupResponse {
274 - fn from(g: db::DbSyncGroup) -> Self {
275 - Self {
276 - id: g.id,
277 - app_id: g.app_id,
278 - admin_user_id: g.admin_user_id,
279 - name: g.name,
280 - gck_version: g.gck_version,
281 - created_at: g.created_at,
282 - }
283 - }
284 - }
285 -
286 - #[derive(Deserialize, utoipa::ToSchema)]
287 - pub(crate) struct AddMemberRequest {
288 - /// The member's account email, resolved to a verified user server-side.
289 - pub member_email: String,
290 - /// The GCK sealed to the member's identity public key (base64), produced by
291 - /// the admin with the group's current GCK. Opaque to the server.
292 - pub sealed_gck: String,
293 - /// The member's identity public key (base64), stored so the GCK can be
294 - /// re-sealed to them on a later rotation.
295 - pub member_pubkey: String,
296 - /// Optional role: "member" (default) or "admin".
297 - #[serde(default)]
298 - pub role: Option<String>,
299 - }
300 -
301 - /// Issue an invite link, from `POST /groups/{id}/invitations`.
302 - #[derive(Deserialize, utoipa::ToSchema)]
303 - pub(crate) struct CreateInvitationRequest {
304 - /// How long the link stays redeemable, in hours. Clamped server-side; an
305 - /// omitted value takes the default. An unredeemed invitation always expires,
306 - /// so there is no "never" to ask for.
307 - #[serde(default)]
308 - pub expires_in_hours: Option<i64>,
309 - }
310 -
311 - /// A freshly issued invitation. The token appears here and nowhere else: the
312 - /// server keeps only its hash, so this response is the single opportunity to
313 - /// capture it.
314 - #[derive(Serialize, utoipa::ToSchema)]
315 - pub(crate) struct CreateInvitationResponse {
316 - #[schema(value_type = String)]
317 - pub id: SyncGroupInvitationId,
318 - /// The one-use token, to be carried in the link the admin sends.
319 - pub token: String,
320 - #[schema(value_type = String)]
321 - pub expires_at: DateTime<Utc>,
322 - }
323 -
324 - /// Accept an invitation, from `POST /sync/invitations/accept`.
325 - ///
326 - /// Not nested under the group: the invitee is not a member yet and cannot be
327 - /// asked to know a group id they have no access to. The token names the group.
328 - #[derive(Deserialize, utoipa::ToSchema)]
329 - pub(crate) struct AcceptInvitationRequest {
330 - /// The one-use token from the link.
331 - pub token: String,
332 - /// The accepting user's identity public key (base64). What the admin will
333 - /// seal the group key to, once they have confirmed its fingerprint.
334 - pub invitee_pubkey: String,
335 - }
336 -
337 - /// What an invitee is shown before accepting, from
338 - /// `GET /sync/invitations/{token}`.
339 - ///
340 - /// Deliberately thin. It answers "which group, from whom, is this still good"
341 - /// and nothing else, because it is readable by anyone holding the link.
342 - #[derive(Serialize, utoipa::ToSchema)]
343 - pub(crate) struct InvitationPreviewResponse {
344 - pub group_name: String,
345 - /// The inviting admin's email, so the invitee can tell whether the link came
346 - /// from who they think it did.
347 - pub inviter_email: String,
348 - /// Whether the token can still be accepted. False covers expired, revoked,
349 - /// redeemed, and already-accepted alike; the reason is in `state`.
350 - pub redeemable: bool,
351 - /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`.
352 - pub state: String,
353 - #[schema(value_type = String)]
354 - pub expires_at: DateTime<Utc>,
355 - }
356 -
357 - /// Confirm an accepted invitation, from
358 - /// `POST /groups/{id}/invitations/{invitation_id}/confirm`.
359 - ///
360 - /// No public key here on purpose. The grant is sealed to the key recorded on the
361 - /// invitation, so the key the admin confirmed is the key that gets used; letting
362 - /// the caller re-supply one would reintroduce the substitution the confirmation
363 - /// step exists to catch.
364 - #[derive(Deserialize, utoipa::ToSchema)]
365 - pub(crate) struct ConfirmInvitationRequest {
366 - /// The group's current GCK sealed to the invitee's recorded public key
367 - /// (base64). Opaque to the server.
368 - pub sealed_gck: String,
369 - /// Optional role: "member" (default) or "admin".
370 - #[serde(default)]
371 - pub role: Option<String>,
372 - }
373 -
374 - /// One invitation in the admin's list, from `GET /groups/{id}/invitations`.
375 - ///
376 - /// Carries the invitee's public key so the admin's client can render its
377 - /// fingerprint for the out-of-band check. The token is absent: the server does
378 - /// not have it.
379 - #[derive(Serialize, utoipa::ToSchema)]
380 - pub(crate) struct InvitationResponse {
381 - #[schema(value_type = String)]
382 - pub id: SyncGroupInvitationId,
383 - /// `pending` | `accepted` | `redeemed` | `revoked` | `expired`.
384 - pub state: String,
385 - /// The accepting account's email, or `None` while outstanding.
386 - pub invitee_email: Option<String>,
387 - /// The accepting account's identity public key (base64), or `None` while
388 - /// outstanding. The admin seals the GCK to this after confirming it.
389 - pub invitee_pubkey: Option<String>,
390 - #[schema(value_type = String)]
391 - pub expires_at: DateTime<Utc>,
392 - #[schema(value_type = String)]
393 - pub created_at: DateTime<Utc>,
394 - }
395 -
396 - /// The lifecycle state of an invitation as one word.
397 - ///
398 - /// Expiry is derived rather than stored as a state, so a row does not need
399 - /// touching when its deadline passes. Order matters: a redeemed or revoked
400 - /// invitation reports as such even after its expiry, because what happened to it
401 - /// is more informative than the clock running out afterwards.
402 - pub(crate) fn invitation_state(inv: &db::DbSyncGroupInvitation) -> &'static str {
403 - if inv.redeemed_at.is_some() {
404 - "redeemed"
405 - } else if inv.revoked_at.is_some() {
406 - "revoked"
407 - } else if inv.accepted_at.is_some() {
408 - "accepted"
409 - } else if inv.expires_at <= Utc::now() {
410 - "expired"
411 - } else {
412 - "pending"
413 - }
414 - }
415 -
416 - impl From<db::DbSyncGroupInvitation> for InvitationResponse {
417 - fn from(inv: db::DbSyncGroupInvitation) -> Self {
418 - let state = invitation_state(&inv);
419 - Self {
420 - id: inv.id,
421 - state: state.to_string(),
422 - invitee_email: inv.invitee_email,
423 - invitee_pubkey: inv.invitee_pubkey,
424 - expires_at: inv.expires_at,
425 - created_at: inv.created_at,
426 - }
427 - }
428 - }
429 -
430 - /// One member's identity public key, from `GET /groups/{id}/pubkeys`. The admin
431 - /// re-seals a rotated GCK to each of these.
432 - #[derive(Serialize, utoipa::ToSchema)]
433 - pub(crate) struct GroupMemberPubkey {
434 - #[schema(value_type = String)]
435 - user_id: UserId,
436 - pubkey: String,
437 - }
438 -
439 - /// Query for `GET /groups/{id}/grant`: which GCK generation to fetch.
440 - #[derive(Deserialize, utoipa::ToSchema)]
441 - pub(crate) struct GrantQuery {
442 - /// The generation wanted. Omitted means the newest the caller holds.
443 - #[serde(default)]
444 - pub version: Option<i32>,
445 - }
446 -
447 - /// One member's re-sealed grant in a rotation batch.
448 - #[derive(Deserialize, utoipa::ToSchema)]
449 - pub(crate) struct RotateGrant {
450 - #[schema(value_type = String)]
451 - pub user_id: UserId,
452 - /// The new GCK sealed to this member's stored identity public key (base64).
453 - /// Opaque to the server.
454 - pub sealed_gck: String,
455 - }
456 -
457 - /// Rotate a group's GCK, from `POST /groups/{id}/rotate`.
458 - ///
459 - /// The grant set is the new membership: anyone holding a grant today and absent
460 - /// here is removed by the rotation. That is what makes removal and re-key one
461 - /// transaction rather than two calls with a window between them.
462 - #[derive(Deserialize, utoipa::ToSchema)]
463 - pub(crate) struct RotateGroupKeyRequest {
464 - /// The new GCK generation. Must be greater than the group's current one, so
465 - /// a replayed or stale rotation cannot roll the group back onto a key a
466 - /// removed member still holds.
467 - pub gck_version: i32,
468 - /// Every remaining member's re-sealed grant, including the admin's own.
469 - pub grants: Vec<RotateGrant>,
470 - }
471 -
472 - #[derive(Serialize, utoipa::ToSchema)]
473 - pub(crate) struct GroupMemberResponse {
474 - #[schema(value_type = String)]
475 - user_id: UserId,
476 - /// The member's account email, so an admin panel can identify them.
477 - email: String,
478 - role: String,
479 - #[schema(value_type = String)]
480 - added_at: DateTime<Utc>,
481 - }
482 -
483 - #[derive(Serialize, utoipa::ToSchema)]
484 - pub(crate) struct GroupGrantResponse {
485 - /// The caller's sealed GCK grant (base64); opened client-side with the
486 - /// member's identity private key.
487 - sealed_gck: String,
488 - /// The GCK generation this grant was sealed under.
489 - gck_version: i32,
490 - }
491 -
492 - /// Status of the authenticated user's subscription to this app's cloud sync.
493 - /// Shape matches `synckit_client::SubscriptionStatus`.
494 - #[derive(Serialize, utoipa::ToSchema)]
495 - pub(crate) struct SyncSubscriptionStatusResponse {
496 - pub active: bool,
497 - /// Billing interval ("monthly" / "annual"). Kept under the legacy `tier`
498 - /// key for client SDK backwards compatibility.
499 - pub tier: Option<String>,
500 - pub status: Option<String>,
501 - pub storage_limit_bytes: Option<i64>,
502 - /// Queued storage cap, applied at the next billing cycle. `None` when no
503 - /// change is pending.
504 - pub pending_storage_limit_bytes: Option<i64>,
505 - pub storage_used_bytes: Option<i64>,
506 - pub current_period_end: Option<String>,
507 - }
508 -
509 - /// Request body for `POST /api/v1/sync/app/pricing`. Identifies the app by
510 - /// its public API key; no JWT required so the UI can quote pricing pre-login.
511 - #[derive(Deserialize, utoipa::ToSchema)]
512 - pub(crate) struct AppPricingRequest {
513 - pub api_key: String,
514 - }
515 -
516 - /// Pricing formula constants the client uses to quote a price locally as the
517 - /// user drags a cap slider. The same formula is enforced server-side at
518 - /// checkout, clients are not trusted to compute the final price.
519 - #[derive(Serialize, utoipa::ToSchema)]
520 - pub(crate) struct AppPricingResponse {
Lines truncated
@@ -1,11 +1,14 @@
1 1 //! SyncKit push/pull, status, device management, and key management endpoints.
2 2
3 + use super::wire::{PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse};
3 4 use axum::{
4 5 Json,
5 6 extract::{Path, State},
6 7 http::StatusCode,
7 8 response::{IntoResponse, Response},
8 9 };
10 + use chrono::{DateTime, Utc};
11 + use serde::{Deserialize, Serialize};
9 12 use serde_json::json;
10 13
11 14 use sqlx::PgPool;
@@ -13,23 +16,13 @@
13 16 use crate::{
14 17 config::Config,
15 18 constants,
16 - db::{self, SyncDeviceId},
19 + db::{self, SyncAppId, SyncDeviceId, SyncPlatform, UserId},
17 20 error::{AppError, Result},
18 21 payments::{self, SyncBillingInterval},
19 22 synckit_auth::SyncUser,
20 23 validation,
21 24 };
22 25
23 - use super::{
24 - AppPricingRequest, AppPricingResponse, BeginRotationRequest, BeginRotationResponse,
25 - CompleteRotationErrorResponse, CompleteRotationRequest, GetKeyResponse, PendingKeyInfo,
26 - PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse, PutKeyRequest,
27 - RegisterDeviceRequest, RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest,
28 - RotationEntriesResponse, RotationEntry, SyncAccountResponse, SyncCapChangeRequest,
29 - SyncCheckoutResponse, SyncDeviceResponse, SyncQuoteRequest, SyncQuoteResponse,
30 - SyncStatusResponse, SyncSubscribeRequest, SyncSubscriptionStatusResponse,
31 - };
32 -
33 26 // --- Sync endpoints (JWT auth) ---
34 27
35 28 /// Push encrypted changelog entries from a device.
@@ -174,7 +167,7 @@
174 167 &db,
175 168 req.device_id,
176 169 new_cursor,
177 - super::client_version(&headers).as_deref(),
170 + client_version(&headers).as_deref(),
178 171 )
179 172 .await?;
180 173
@@ -539,7 +532,7 @@
539 532 sync_user.user_id,
540 533 &req.device_name,
541 534 req.platform,
542 - super::client_version(&headers).as_deref(),
535 + client_version(&headers).as_deref(),
543 536 )
544 537 .await?;
545 538
@@ -915,3 +908,287 @@
915 908
916 909 Ok(axum::http::StatusCode::NO_CONTENT)
917 910 }
911 +
912 + /// Longest client version string we will store. Matches the column width in
913 + /// migration 180; a longer value is a client we don't recognise, so it is
914 + /// dropped rather than truncated into something that reads like a real version.
915 + const CLIENT_VERSION_MAX_LENGTH: usize = 32;
916 +
917 + /// The SDK version out of a `synckit-client/<version>` User-Agent, if the
918 + /// request carries one.
919 + ///
920 + /// Only the version is kept. A request from anything that is not the SDK (a
921 + /// browser, curl, an older client that sends no such header) yields `None`, and
922 + /// `None` is stored as-is: "syncing, version unknown" is a real answer and
923 + /// guessing would corrupt the field-version readout this exists to produce.
924 + /// The version is checked for shape, not parsed as semver, so a client that
925 + /// adds a pre-release suffix still reports.
926 + pub(crate) fn client_version(headers: &axum::http::HeaderMap) -> Option<String> {
927 + let version = headers
928 + .get(axum::http::header::USER_AGENT)?
929 + .to_str()
930 + .ok()?
931 + .split_whitespace()
932 + .next()?
933 + .strip_prefix("synckit-client/")?;
934 + let ok = !version.is_empty()
935 + && version.len() <= CLIENT_VERSION_MAX_LENGTH
936 + && version.starts_with(|c: char| c.is_ascii_digit())
937 + && version
938 + .chars()
939 + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '_'));
940 + ok.then(|| version.to_string())
941 + }
942 +
943 + #[derive(Serialize, utoipa::ToSchema)]
944 + pub(crate) struct SyncDeviceResponse {
945 + #[schema(value_type = String)]
946 + id: SyncDeviceId,
947 + #[schema(value_type = String)]
948 + app_id: SyncAppId,
949 + #[schema(value_type = String)]
950 + user_id: UserId,
951 + device_name: String,
952 + platform: String,
953 + #[schema(value_type = String)]
954 + last_seen_at: DateTime<Utc>,
955 + #[schema(value_type = String)]
956 + created_at: DateTime<Utc>,
957 + }
958 +
959 + #[derive(Deserialize, utoipa::ToSchema)]
960 + pub(crate) struct RegisterDeviceRequest {
961 + pub device_name: String,
962 + #[schema(value_type = String)]
963 + pub platform: SyncPlatform,
964 + }
965 +
966 + #[derive(Serialize, utoipa::ToSchema)]
967 + pub(crate) struct SyncStatusResponse {
968 + total_changes: i64,
969 + latest_cursor: Option<i64>,
970 + }
971 +
972 + #[derive(Serialize, utoipa::ToSchema)]
973 + pub(crate) struct SyncAccountResponse {
974 + pub email: String,
975 + pub username: String,
976 + }
977 +
978 + /// Status of the authenticated user's subscription to this app's cloud sync.
979 + /// Shape matches `synckit_client::SubscriptionStatus`.
980 + #[derive(Serialize, utoipa::ToSchema)]
981 + pub(crate) struct SyncSubscriptionStatusResponse {
982 + pub active: bool,
983 + /// Billing interval ("monthly" / "annual"). Kept under the legacy `tier`
984 + /// key for client SDK backwards compatibility.
985 + pub tier: Option<String>,
986 + pub status: Option<String>,
987 + pub storage_limit_bytes: Option<i64>,
988 + /// Queued storage cap, applied at the next billing cycle. `None` when no
989 + /// change is pending.
990 + pub pending_storage_limit_bytes: Option<i64>,
991 + pub storage_used_bytes: Option<i64>,
992 + pub current_period_end: Option<String>,
993 + }
994 +
995 + /// Request body for `POST /api/v1/sync/app/pricing`. Identifies the app by
996 + /// its public API key; no JWT required so the UI can quote pricing pre-login.
997 + #[derive(Deserialize, utoipa::ToSchema)]
998 + pub(crate) struct AppPricingRequest {
999 + pub api_key: String,
1000 + }
1001 +
1002 + /// Pricing formula constants the client uses to quote a price locally as the
1003 + /// user drags a cap slider. The same formula is enforced server-side at
1004 + /// checkout, clients are not trusted to compute the final price.
1005 + #[derive(Serialize, utoipa::ToSchema)]
1006 + pub(crate) struct AppPricingResponse {
1007 + pub app_name: String,
1008 + /// Floor charge in cents (monthly or annual, same floor applies to both).
1009 + pub min_charge_cents: i64,
1010 + /// Per-GiB monthly storage rate, in tenths of a cent.
1011 + pub per_gb_tenths_of_cent_per_month: i64,
1012 + /// Annual is monthly × this value.
1013 + pub annual_multiplier: i64,
1014 + pub min_cap_bytes: i64,
1015 + pub max_cap_bytes: i64,
1016 + }
1017 +
1018 + /// Request body for `POST /api/v1/sync/subscription/quote`.
1019 + #[derive(Deserialize, utoipa::ToSchema)]
1020 + pub(crate) struct SyncQuoteRequest {
1021 + pub cap_bytes: i64,
1022 + pub interval: String,
1023 + }
1024 +
1025 + #[derive(Serialize, utoipa::ToSchema)]
1026 + pub(crate) struct SyncQuoteResponse {
1027 + pub cap_bytes: i64,
1028 + pub interval: String,
1029 + pub price_cents: i64,
1030 + }
1031 +
1032 + /// Request body for `POST /api/v1/sync/subscription/checkout`.
1033 + #[derive(Deserialize, utoipa::ToSchema)]
1034 + pub(crate) struct SyncSubscribeRequest {
1035 + pub cap_bytes: i64,
1036 + /// "monthly" or "annual".
1037 + pub interval: String,
1038 + }
1039 +
1040 + #[derive(Serialize, utoipa::ToSchema)]
1041 + pub(crate) struct SyncCheckoutResponse {
1042 + pub checkout_url: String,
1043 + }
1044 +
1045 + /// Request body for `POST /api/v1/sync/subscription/storage-cap`, queues a
1046 + /// cap change that applies at the next billing cycle.
1047 + #[derive(Deserialize, utoipa::ToSchema)]
1048 + pub(crate) struct SyncCapChangeRequest {
1049 + pub cap_bytes: i64,
1050 + }
1051 +
1052 + #[derive(Deserialize, utoipa::ToSchema)]
1053 + pub(crate) struct PutKeyRequest {
1054 + pub encrypted_key: String,
1055 + /// Expected key version for optimistic concurrency control.
1056 + /// Server rejects with 409 Conflict if the current version doesn't match.
1057 + pub expected_version: i32,
1058 + }
1059 +
1060 + #[derive(Serialize, utoipa::ToSchema)]
1061 + pub(crate) struct GetKeyResponse {
1062 + encrypted_key: String,
1063 + key_version: i32,
1064 + /// Current active key identifier.
1065 + key_id: i32,
1066 + /// If a rotation is in progress, the new key envelope and its key_id.
1067 + #[serde(skip_serializing_if = "Option::is_none")]
1068 + pending_key: Option<PendingKeyInfo>,
1069 + }
1070 +
1071 + #[derive(Serialize, utoipa::ToSchema)]
1072 + pub(crate) struct PendingKeyInfo {
1073 + encrypted_key: String,
1074 + key_id: i32,
1075 + }
1076 +
1077 + #[derive(Deserialize, utoipa::ToSchema)]
1078 + pub(crate) struct BeginRotationRequest {
1079 + #[schema(value_type = String)]
1080 + pub device_id: SyncDeviceId,
1081 + pub new_encrypted_key: String,
1082 + pub expected_key_version: i32,
1083 + }
1084 +
1085 + #[derive(Serialize, utoipa::ToSchema)]
1086 + pub(crate) struct BeginRotationResponse {
1087 + rotation_id: uuid::Uuid,
1088 + target_seq: i64,
1089 + new_key_id: i32,
1090 + }
1091 +
1092 + #[derive(Deserialize, utoipa::ToSchema)]
1093 + pub(crate) struct RotationEntriesRequest {
1094 + pub rotation_id: uuid::Uuid,
1095 + pub after_seq: i64,
1096 + }
1097 +
1098 + #[derive(Serialize, utoipa::ToSchema)]
1099 + pub(crate) struct RotationEntriesResponse {
1100 + entries: Vec<RotationEntry>,
1101 + has_more: bool,
1102 + }
1103 +
1104 + #[derive(Serialize, utoipa::ToSchema)]
1105 + pub(crate) struct RotationEntry {
1106 + seq: i64,
1107 + /// Source table and row id, echoed so the client can recompute the entry's
1108 + /// AEAD associated data when re-encrypting under the new key.
1109 + table: String,
1110 + row_id: String,
1111 + data: Option<serde_json::Value>,
1112 + }
1113 +
1114 + #[derive(Deserialize, utoipa::ToSchema)]
1115 + pub(crate) struct RotationBatchRequest {
1116 + pub rotation_id: uuid::Uuid,
1117 + pub entries: Vec<RotationBatchEntry>,
1118 + }
1119 +
1120 + #[derive(Deserialize, utoipa::ToSchema)]
1121 + pub(crate) struct RotationBatchEntry {
1122 + pub seq: i64,
1123 + pub data: Option<serde_json::Value>,
1124 + }
1125 +
1126 + #[derive(Serialize, utoipa::ToSchema)]
1127 + pub(crate) struct RotationBatchResponse {
1128 + updated_count: u64,
1129 + }
1130 +
1131 + #[derive(Deserialize, utoipa::ToSchema)]
1132 + pub(crate) struct CompleteRotationRequest {
1133 + pub rotation_id: uuid::Uuid,
1134 + }
1135 +
1136 + #[derive(Serialize, utoipa::ToSchema)]
1137 + pub(crate) struct CompleteRotationErrorResponse {
1138 + remaining: i64,
1139 + }
1140 + #[cfg(test)]
1141 + mod tests {
1142 + use super::client_version;
1143 + use axum::http::{HeaderMap, HeaderValue, header::USER_AGENT};
1144 +
1145 + fn ua(value: &str) -> HeaderMap {
1146 + let mut headers = HeaderMap::new();
1147 + headers.insert(USER_AGENT, HeaderValue::from_str(value).unwrap());
1148 + headers
1149 + }
1150 +
1151 + #[test]
1152 + fn reads_the_sdk_version() {
1153 + assert_eq!(
1154 + client_version(&ua("synckit-client/0.6.0")).as_deref(),
1155 + Some("0.6.0")
1156 + );
1157 + // Pre-release and build suffixes are real versions, keep them whole.
1158 + assert_eq!(
1159 + client_version(&ua("synckit-client/1.0.0-rc.2")).as_deref(),
1160 + Some("1.0.0-rc.2")
1161 + );
1162 + // A consumer app appending its own product token must not break the read.
1163 + assert_eq!(
1164 + client_version(&ua("synckit-client/0.6.0 audiofiles/0.9.1")).as_deref(),
1165 + Some("0.6.0")
1166 + );
1167 + }
1168 +
1169 + #[test]
1170 + fn ignores_anything_that_is_not_the_sdk() {
1171 + assert_eq!(client_version(&HeaderMap::new()), None);
1172 + assert_eq!(client_version(&ua("curl/8.5.0")), None);
1173 + assert_eq!(client_version(&ua("Mozilla/5.0 (X11; Linux x86_64)")), None);
1174 + // Right prefix, no version.
1175 + assert_eq!(client_version(&ua("synckit-client/")), None);
1176 + // Prefix match must be exact, not a substring of some other product.
1177 + assert_eq!(client_version(&ua("evil-synckit-client/9.9.9")), None);
1178 + }
1179 +
1180 + #[test]
1181 + fn rejects_junk_rather_than_storing_it() {
1182 + // Over the column width: dropped, not truncated into a plausible-looking
1183 + // version.
1184 + let long = format!("synckit-client/{}", "9".repeat(64));
1185 + assert_eq!(client_version(&ua(&long)), None);
1186 + // A version has to start with a digit, so a free-text string cannot
1187 + // smuggle itself into the readout.
1188 + assert_eq!(client_version(&ua("synckit-client/not-a-version")), None);
1189 + assert_eq!(client_version(&ua("synckit-client/../../etc/passwd")), None);
1190 + // At the boundary it is kept.
1191 + let at_max = format!("synckit-client/1{}", "0".repeat(31));
1192 + assert!(client_version(&ua(&at_max)).is_some());
1193 + }
1194 + }
@@ -1,0 +1,493 @@
1 + //! The router: every synckit path, and the auth and rate-limit tier each one
2 + //! sits behind.
3 +
4 + use axum::routing::get;
5 + use tower_governor::GovernorLayer;
6 +
7 + use super::{apps, auth, billing, blobs, groups, keys, subscribe, sync};
8 + use crate::{
9 + AppState, constants,
10 + csrf::{
11 + CsrfRouter, delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf,
12 + put_csrf_skip,
13 + },
14 + };
15 +
16 + /// Reason strings for synckit CSRF Skip routes. The auth_routes and
17 + /// sync_routes blocks use server-to-server or JWT bearer auth with no
18 + /// session cookie; CSRF doesn't apply. The app_routes block IS
19 + /// session-authed (dashboard-driven) so those use `post_csrf` etc.
20 + const SYNCKIT_API_KEY_SKIP: &str = "synckit server-to-server: api_key auth, no session";
21 +
22 + const SYNCKIT_APP_SECRET_SKIP: &str =
23 + "synckit server-to-server: keys-endpoint app_secret auth, no session";
24 +
25 + const SYNCKIT_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session";
26 +
27 + /// Build the SyncKit route tree.
28 + ///
29 + /// Three route groups with different auth and rate-limiting strategies:
30 + ///
31 + /// - **Auth routes**: Public, rate-limited per-second (IP) to prevent
32 + /// credential stuffing. `/api/sync/auth` and `/api/sync/validate-app` present
33 + /// the app's api_key; the three server-to-server SDK key routes
34 + /// (`keys/claim`, `keys/release`, `keys/list`) and `app/pricing` present the
35 + /// app_secret in the body instead. Read the builder below for the current
36 + /// membership.
37 + /// - **Sync routes** (push, pull, status, devices, keys, blobs): JWT-based
38 + /// auth via `SyncUser` extractor, dual rate-limited: per-IP (prevents single
39 + /// client abuse) AND per-app (prevents one developer's app from starving
40 + /// others). Per-app limits are higher since an app may have many users.
41 + /// - **App management routes** (`/api/sync/apps/...`): Session-based auth
42 + /// via `AuthUser` extractor (accessed from the MNW dashboard), no extra
43 + /// rate limit beyond the global middleware.
44 + ///
45 + /// `synckit_jwt_secret` is threaded in (rather than read from a global) so the
46 + /// per-app rate limiter's key extractor can verify token signatures; see
47 + /// [`crate::rate_limit::SyncAppKeyExtractor`].
48 + pub fn synckit_routes(synckit_jwt_secret: Option<std::sync::Arc<String>>) -> CsrfRouter<AppState> {
49 + let auth_rate_limit = crate::helpers::rate_limiter_per_sec(
50 + constants::SYNCKIT_AUTH_RATE_LIMIT_PER_SEC,
51 + constants::SYNCKIT_AUTH_RATE_LIMIT_BURST,
52 + );
53 +
54 + let auth_routes = CsrfRouter::new()
55 + .route(
56 + "/api/sync/auth",
57 + post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth),
58 + )
59 + .route(
60 + "/api/v1/sync/auth",
61 + post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth),
62 + )
63 + .route(
64 + "/api/sync/validate-app",
65 + post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app),
66 + )
67 + .route(
68 + "/api/v1/sync/validate-app",
69 + post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app),
70 + )
71 + // Server-to-server SDK key claim/release/list (app_secret in body, no JWT).
72 + .route(
73 + "/api/sync/keys/claim",
74 + post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::claim),
75 + )
76 + .route(
77 + "/api/v1/sync/keys/claim",
78 + post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::claim),
79 + )
80 + .route(
81 + "/api/sync/keys/release",
82 + post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::release),
83 + )
84 + .route(
85 + "/api/v1/sync/keys/release",
86 + post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::release),
87 + )
88 + .route(
89 + "/api/sync/keys/list",
90 + post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::list),
91 + )
92 + .route(
93 + "/api/v1/sync/keys/list",
94 + post_csrf_skip(SYNCKIT_APP_SECRET_SKIP, keys::list),
95 + )
96 + .route(
97 + "/api/sync/app/pricing",
98 + post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing),
99 + )
100 + .route(
101 + "/api/v1/sync/app/pricing",
102 + post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing),
103 + )
104 + .route_layer(GovernorLayer::new(auth_rate_limit));
105 +
106 + let sync_ip_rate_limit = crate::helpers::rate_limiter_ms(
107 + constants::SYNCKIT_SYNC_RATE_LIMIT_MS,
108 + constants::SYNCKIT_SYNC_RATE_LIMIT_BURST,
109 + );
110 + let sync_app_rate_limit = crate::helpers::synckit_app_rate_limiter_ms(
111 + synckit_jwt_secret,
112 + constants::SYNCKIT_APP_RATE_LIMIT_MS,
113 + constants::SYNCKIT_APP_RATE_LIMIT_BURST,
114 + );
115 +
116 + let sync_routes = CsrfRouter::new()
117 + .route(
118 + "/api/sync/push",
119 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push),
120 + )
121 + .route(
122 + "/api/v1/sync/push",
123 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push),
124 + )
125 + .route(
126 + "/api/sync/pull",
127 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull),
128 + )
129 + .route(
130 + "/api/v1/sync/pull",
131 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull),
132 + )
133 + // Group sync: shared changelogs. Membership/admin gating lives inside the
134 + // handlers (SyncUser identifies the caller); same JWT auth + dual rate
135 + // limit as personal sync. GET+POST on one path merge, as with devices.
136 + .route(
137 + "/api/sync/groups",
138 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_group),
139 + )
140 + .route(
141 + "/api/v1/sync/groups",
142 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_group),
143 + )
144 + .route_get("/api/sync/groups", get(groups::list_groups))
145 + .route_get("/api/v1/sync/groups", get(groups::list_groups))
146 + .route(
147 + "/api/sync/groups/{id}/members",
148 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::add_member),
149 + )
150 + .route(
151 + "/api/v1/sync/groups/{id}/members",
152 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::add_member),
153 + )
154 + .route_get("/api/sync/groups/{id}/members", get(groups::list_members))
155 + .route_get(
156 + "/api/v1/sync/groups/{id}/members",
157 + get(groups::list_members),
158 + )
159 + .route(
160 + "/api/sync/groups/{id}/members/{user_id}",
161 + delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member),
162 + )
163 + .route(
164 + "/api/v1/sync/groups/{id}/members/{user_id}",
165 + delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member),
166 + )
167 + // Invitations. The two accept-side routes are not nested under the group:
168 + // the caller is not a member yet and cannot be asked for a group id they
169 + // have no access to, so the token names the group instead.
170 + .route(
171 + "/api/sync/groups/{id}/invitations",
172 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_invitation),
173 + )
174 + .route(
175 + "/api/v1/sync/groups/{id}/invitations",
176 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_invitation),
177 + )
178 + .route_get(
179 + "/api/sync/groups/{id}/invitations",
180 + get(groups::list_invitations),
181 + )
182 + .route_get(
183 + "/api/v1/sync/groups/{id}/invitations",
184 + get(groups::list_invitations),
185 + )
186 + .route(
187 + "/api/sync/groups/{id}/invitations/{invitation_id}/confirm",
188 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::confirm_invitation),
189 + )
190 + .route(
191 + "/api/v1/sync/groups/{id}/invitations/{invitation_id}/confirm",
192 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::confirm_invitation),
193 + )
194 + .route(
195 + "/api/sync/groups/{id}/invitations/{invitation_id}",
196 + delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::revoke_invitation),
197 + )
198 + .route(
199 + "/api/v1/sync/groups/{id}/invitations/{invitation_id}",
200 + delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::revoke_invitation),
201 + )
202 + .route_get(
203 + "/api/sync/invitations/{token}",
204 + get(groups::preview_invitation),
205 + )
206 + .route_get(
207 + "/api/v1/sync/invitations/{token}",
208 + get(groups::preview_invitation),
209 + )
210 + .route(
211 + "/api/sync/invitations/accept",
212 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::accept_invitation),
213 + )
214 + .route(
215 + "/api/v1/sync/invitations/accept",
216 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::accept_invitation),
217 + )
218 + .route_get("/api/sync/groups/{id}/grant", get(groups::get_grant))
219 + .route_get("/api/v1/sync/groups/{id}/grant", get(groups::get_grant))
220 + .route_get("/api/sync/groups/{id}/pubkeys", get(groups::list_pubkeys))
221 + .route_get(
222 + "/api/v1/sync/groups/{id}/pubkeys",
223 + get(groups::list_pubkeys),
224 + )
225 + .route(
226 + "/api/sync/groups/{id}/rotate",
227 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::rotate_key),
228 + )
229 + .route(
230 + "/api/v1/sync/groups/{id}/rotate",
231 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::rotate_key),
232 + )
233 + .route(
234 + "/api/sync/groups/{id}/push",
235 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push),
236 + )
237 + .route(
238 + "/api/v1/sync/groups/{id}/push",
239 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push),
240 + )
241 + .route(
242 + "/api/sync/groups/{id}/pull",
243 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_pull),
244 + )
245 + .route(
246 + "/api/v1/sync/groups/{id}/pull",
247 + post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_pull),
248 + )
249 + .route_get("/api/sync/subscribe", get(subscribe::sync_subscribe))
250 + .route_get("/api/v1/sync/subscribe", get(subscribe::sync_subscribe))
251 + .route_get("/api/sync/status", get(sync::sync_status))
252 + .route_get("/api/v1/sync/status", get(sync::sync_status))
253 + .route_get("/api/sync/account", get(sync::sync_account))
254 + .route_get("/api/v1/sync/account", get(sync::sync_account))
255 + .route_get(
256 + "/api/sync/subscription",
257 + get(sync::sync_subscription_status),
258 + )
259 + .route_get(
260 + "/api/v1/sync/subscription",
261 + get(sync::sync_subscription_status),
262 + )
263 + .route(
264 + "/api/sync/subscription/quote",
265 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price),
266 + )
267 + .route(
268 + "/api/v1/sync/subscription/quote",
269 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price),
270 + )
271 + .route(
272 + "/api/sync/subscription/checkout",
273 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout),
274 + )
275 + .route(
276 + "/api/v1/sync/subscription/checkout",
277 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout),
278 + )
279 + .route(
280 + "/api/sync/subscription/storage-cap",
281 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change),
282 + )
283 + .route(
284 + "/api/v1/sync/subscription/storage-cap",
285 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change),
286 + )
287 + .route(
288 + "/api/sync/devices",
289 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device),
290 + )
291 + .route(
292 + "/api/v1/sync/devices",
293 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device),
294 + )
295 + .route_get("/api/sync/devices", get(sync::list_devices))
296 + .route_get("/api/v1/sync/devices", get(sync::list_devices))
297 + .route(
298 + "/api/sync/devices/{id}",
299 + delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device),
300 + )
301 + .route(
302 + "/api/v1/sync/devices/{id}",
303 + delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device),
304 + )
305 + .route(
306 + "/api/sync/keys",
307 + put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key),
308 + )
309 + .route(
310 + "/api/v1/sync/keys",
311 + put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key),
312 + )
313 + .route_get("/api/sync/keys", get(sync::get_sync_key))
314 + .route_get("/api/v1/sync/keys", get(sync::get_sync_key))
315 + .route(
316 + "/api/sync/keys/rotate",
317 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation),
318 + )
319 + .route(
320 + "/api/v1/sync/keys/rotate",
321 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation),
322 + )
323 + .route(
324 + "/api/sync/keys/rotate",
325 + delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation),
326 + )
327 + .route(
328 + "/api/v1/sync/keys/rotate",
329 + delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation),
330 + )
331 + .route(
332 + "/api/sync/keys/rotate/entries",
333 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries),
334 + )
335 + .route(
336 + "/api/v1/sync/keys/rotate/entries",
337 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries),
338 + )
339 + .route(
340 + "/api/sync/keys/rotate/batch",
341 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch),
342 + )
343 + .route(
344 + "/api/v1/sync/keys/rotate/batch",
345 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch),
346 + )
347 + .route(
348 + "/api/sync/keys/rotate/complete",
349 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation),
350 + )
351 + .route(
352 + "/api/v1/sync/keys/rotate/complete",
353 + post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation),
354 + )
355 + .route(
356 + "/api/sync/blobs/upload",
357 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url),
358 + )
359 + .route(
360 + "/api/v1/sync/blobs/upload",
361 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url),
362 + )
363 + .route(
364 + "/api/sync/blobs/multipart/start",
365 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_start),
366 + )
367 + .route(
368 + "/api/v1/sync/blobs/multipart/start",
369 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_start),
370 + )
371 + .route(
372 + "/api/sync/blobs/multipart/parts",
373 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_parts),
374 + )
375 + .route(
376 + "/api/v1/sync/blobs/multipart/parts",
377 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_parts),
378 + )
379 + .route(
380 + "/api/sync/blobs/multipart/complete",
381 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_complete),
382 + )
383 + .route(
384 + "/api/v1/sync/blobs/multipart/complete",
385 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_complete),
386 + )
387 + .route(
388 + "/api/sync/blobs/multipart/abort",
389 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_abort),
390 + )
391 + .route(
392 + "/api/v1/sync/blobs/multipart/abort",
393 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_multipart_abort),
394 + )
395 + .route(
396 + "/api/sync/blobs/confirm",
397 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload),
398 + )
399 + .route(
400 + "/api/v1/sync/blobs/confirm",
401 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload),
402 + )
403 + .route(
404 + "/api/sync/blobs/download",
405 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url),
406 + )
407 + .route(
408 + "/api/v1/sync/blobs/download",
409 + post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url),
410 + )
411 + .route(
412 + "/api/sync/blobs/{hash}",
413 + delete_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_delete),
414 + )
415 + .route(
416 + "/api/v1/sync/blobs/{hash}",
417 + delete_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_delete),
418 + )
419 + // Per-app rate limit (inner layer runs first): prevents one developer's
420 + // app from starving other apps. Extracts app ID from JWT payload.
421 + .route_layer(GovernorLayer::new(sync_app_rate_limit))
422 + // Per-IP rate limit (outer layer): prevents a single client from
423 + // overwhelming the endpoint regardless of which app they claim.
424 + .route_layer(GovernorLayer::new(sync_ip_rate_limit));
425 +
426 + // App management endpoints use session auth (no extra rate limit beyond global)
427 + let app_routes = CsrfRouter::new()
428 + .route("/api/sync/apps", post_csrf(apps::create_app))
429 + .route("/api/v1/sync/apps", post_csrf(apps::create_app))
430 + .route_get("/api/sync/apps", get(apps::list_apps))
431 + .route_get("/api/v1/sync/apps", get(apps::list_apps))
432 + .route(
433 + "/api/sync/apps/{id}/regenerate-key",
434 + post_csrf(apps::regenerate_app_key),
435 + )
436 + .route(
437 + "/api/v1/sync/apps/{id}/regenerate-key",
438 + post_csrf(apps::regenerate_app_key),
439 + )
440 + .route(
441 + "/api/sync/apps/{id}/keys-secret",
442 + post_csrf(apps::regenerate_app_keys_secret),
443 + )
444 + .route(
445 + "/api/v1/sync/apps/{id}/keys-secret",
446 + post_csrf(apps::regenerate_app_keys_secret),
447 + )
448 + .route("/api/sync/apps/{id}/link", put_csrf(apps::update_app_link))
449 + .route(
450 + "/api/v1/sync/apps/{id}/link",
451 + put_csrf(apps::update_app_link),
452 + )
453 + .route("/api/sync/apps/{id}/slug", put_csrf(apps::update_app_slug))
454 + .route(
455 + "/api/v1/sync/apps/{id}/slug",
456 + put_csrf(apps::update_app_slug),
457 + )
458 + .route("/api/sync/apps/{id}", delete_csrf(apps::delete_app))
459 + .route("/api/v1/sync/apps/{id}", delete_csrf(apps::delete_app))
460 + // Developer billing (session auth, dashboard-driven).
461 + .route(
462 + "/api/sync/apps/{id}/billing/setup",
463 + post_csrf(billing::setup),
464 + )
465 + .route(
466 + "/api/v1/sync/apps/{id}/billing/setup",
467 + post_csrf(billing::setup),
468 + )
469 + .route(
470 + "/api/sync/apps/{id}/billing/activate",
471 + post_csrf(billing::activate),
472 + )
473 + .route(
474 + "/api/v1/sync/apps/{id}/billing/activate",
475 + post_csrf(billing::activate),
476 + )
477 + .route("/api/sync/apps/{id}/billing", patch_csrf(billing::patch))
478 + .route("/api/v1/sync/apps/{id}/billing", patch_csrf(billing::patch))
479 + .route("/api/sync/apps/{id}/billing", delete_csrf(billing::cancel))
480 + .route(
481 + "/api/v1/sync/apps/{id}/billing",
482 + delete_csrf(billing::cancel),
483 + )
484 + .route_get("/api/sync/apps/{id}/billing", get(billing::get))
485 + .route_get("/api/v1/sync/apps/{id}/billing", get(billing::get))
486 + .route_get("/api/sync/apps/{id}/billing/portal", get(billing::portal))
487 + .route_get(
488 + "/api/v1/sync/apps/{id}/billing/portal",
489 + get(billing::portal),
490 + );
491 +
492 + auth_routes.merge(sync_routes).merge(app_routes)
493 + }
@@ -1,0 +1,80 @@
1 + //! The push/pull changelog wire types, shared by personal sync and group
2 + //! sync and by nothing else.
3 + //!
4 + //! Six types is the whole shared surface between [`super::sync`] and
5 + //! [`super::groups`]; a seventh belongs in whichever of them uses it.
6 +
7 + use chrono::{DateTime, Utc};
8 + use serde::{Deserialize, Serialize};
9 +
10 + use crate::db::{SyncDeviceId, SyncOperation};
11 +
12 + #[derive(Deserialize, utoipa::ToSchema)]
13 + pub(crate) struct PushRequest {
14 + #[schema(value_type = String)]
15 + pub device_id: SyncDeviceId,
16 + /// Client-generated UUID for idempotent push. If a push with the same
17 + /// batch_id has already been committed, the server returns the existing
18 + /// cursor without re-inserting.
19 + pub batch_id: uuid::Uuid,
20 + pub changes: Vec<ChangeEntry>,
21 + }
22 +
23 + #[derive(Deserialize, utoipa::ToSchema)]
24 + pub(crate) struct ChangeEntry {
25 + pub table: String,
26 + #[schema(value_type = String)]
27 + pub op: SyncOperation,
28 + pub row_id: String,
29 + #[schema(value_type = String)]
30 + pub timestamp: DateTime<Utc>,
31 + pub data: Option<serde_json::Value>,
32 + }
33 +
34 + #[derive(Serialize, utoipa::ToSchema)]
35 + pub(crate) struct PushResponse {
36 + pub(super) cursor: i64,
37 + }
38 +
39 + #[derive(Deserialize, utoipa::ToSchema)]
40 + pub(crate) struct PullRequest {
41 + #[schema(value_type = String)]
42 + pub device_id: SyncDeviceId,
43 + pub cursor: i64,
44 + /// Optional table name filter; only return entries for these tables.
45 + #[serde(default)]
46 + pub tables: Option<Vec<String>>,
47 + /// Optional timestamp filter; only return entries at or after this time.
48 + #[serde(default)]
49 + #[schema(value_type = Option<String>)]
50 + pub since: Option<DateTime<Utc>>,
51 + }
52 +
53 + #[derive(Serialize, utoipa::ToSchema)]
54 + pub(crate) struct PullResponse {
55 + pub(super) changes: Vec<PullChangeEntry>,
56 + pub(super) cursor: i64,
57 + pub(super) has_more: bool,
58 + }
59 +
60 + #[derive(Serialize, utoipa::ToSchema)]
61 + pub(crate) struct PullChangeEntry {
62 + pub(super) seq: i64,
63 + #[schema(value_type = String)]
64 + pub(super) device_id: SyncDeviceId,
65 + pub(super) table: String,
66 + pub(super) op: String,
67 + pub(super) row_id: String,
68 + #[schema(value_type = String)]
69 + pub(super) timestamp: DateTime<Utc>,
70 + pub(super) data: Option<serde_json::Value>,
71 + /// Which encryption key was used. Null means key_id 1 (pre-rotation).
72 + #[serde(skip_serializing_if = "Option::is_none")]
73 + pub(super) key_id: Option<i32>,
74 + /// For a group entry, the GCK generation its ciphertext is sealed under. The
75 + /// member resolves that generation's grant to decrypt it, which is how entries
76 + /// written before a rotation stay readable. Absent on personal entries, which
77 + /// key off `key_id` instead.
78 + #[serde(skip_serializing_if = "Option::is_none")]
79 + pub(super) gck_version: Option<i32>,
80 + }