Skip to main content

max / synckit

Check the wire fixtures against the server's OpenAPI spec This suite asserted against wiremock fixtures it wrote itself, and MNW asserts its /api/v1/sync/* routes against its own harness. Neither side depended on the other, so both could be green while the two disagreed. The contract was a comment in the server: "Shape matches synckit_client::SubscriptionStatus". Two implementations of one interface have to be checked against the interface, not against each other's imitation of it. The server generates openapi.json from its handlers and fails a test there if the committed copy drifts; this vendors that spec and validates each response fixture against the schema it claims to match. Also pins the endpoint surface: every path this client calls must be documented, with the three that are not (subscribe, ota, groups) listed explicitly so the number can only go down. That list is checked against the client source rather than hand-maintained, which is what caught validate-app building its URL outside Endpoints. Phase 1 of wiki `testing-posture`, the imitation-oracle half.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 14:38 UTC
Signed with PGP, not checked
Commit: 559c9d0007876aa252817d18fad9d1cf5f5d5910
Parent: f007473
4 files changed, +744 insertions, -0 deletions
@@ -119,6 +119,12 @@
119 119 # keychain OFF (making `keystore::store_key` the no-op stub) to run the keychain-
120 120 # free rotation orchestration test below.
121 121 synckit-client = { path = ".", default-features = false, features = ["testing"] }
122 + # Validates the test fixtures against the server's OpenAPI schemas
123 + # (tests/openapi.json). `default-features = false` drops the reqwest-backed
124 + # remote-reference resolver: the spec is a local file and every `$ref` in it is
125 + # an internal JSON pointer, so nothing should ever be fetched over the network
126 + # to validate a fixture.
127 + jsonschema = { version = "0.49.5", default-features = false }
122 128
123 129 [lints.rust]
124 130 unused = "warn"
@@ -13,6 +13,7 @@
13 13 mod blob;
14 14 mod blob_multipart;
15 15 mod concurrency;
16 + mod contract;
16 17 mod device;
17 18 mod encryption;
18 19 mod group_rotation;
@@ -1,0 +1,237 @@
1 + //! The wire contract, checked against the server's OpenAPI spec.
2 + //!
3 + //! Every other module in this suite asserts against fixtures this crate wrote
4 + //! itself, and the MNW server asserts its `/api/v1/sync/*` routes against its
5 + //! own harness. Neither side depends on the other, so both suites can be green
6 + //! while the two disagree, and the disagreement surfaces in production. The
7 + //! contract used to be held by a comment in the server
8 + //! (`routes/synckit/mod.rs`: "Shape matches synckit_client::SubscriptionStatus").
9 + //!
10 + //! The fix is not to make the two suites talk to each other. Two implementations
11 + //! of one interface must be checked against the interface, never against each
12 + //! other's imitation of it. `tests/openapi.json` is that interface: the server
13 + //! generates it from its handlers (`cargo run --bin export-openapi`) and a test
14 + //! there fails if the committed copy drifts from the code.
15 + //!
16 + //! Refreshing the vendored copy is a deliberate step, and the diff is the point:
17 + //! a changed response schema shows up as a review-sized change here rather than
18 + //! as a production incident.
19 + //!
20 + //! Rationale: wiki `testing-posture`, the "imitation oracle" section.
21 +
22 + use crate::common::*;
23 +
24 + /// The vendored spec, resolved at compile time.
25 + ///
26 + /// `CARGO_MANIFEST_DIR` rather than a relative path so the suite still finds it
27 + /// when cargo-mutants copies the crate to a temp directory and runs there.
28 + const SPEC_JSON: &str = include_str!("../openapi.json");
29 +
30 + fn spec() -> serde_json::Value {
31 + serde_json::from_str(SPEC_JSON).expect("the vendored spec is valid JSON")
32 + }
33 +
34 + /// Validate `body` against the named schema in `components/schemas`.
35 + ///
36 + /// jsonschema resolves `$ref` against the document root, so the whole spec is
37 + /// handed over as the schema document with a `$ref` bolted on at the top. That
38 + /// keeps every internal pointer working without inlining anything by hand.
39 + fn assert_matches_schema(schema_name: &str, body: &serde_json::Value) {
40 + let mut doc = spec();
41 + doc.as_object_mut().expect("spec root is an object").insert(
42 + "$ref".to_string(),
43 + serde_json::Value::String(format!("#/components/schemas/{schema_name}")),
44 + );
45 +
46 + let validator = jsonschema::validator_for(&doc)
47 + .unwrap_or_else(|e| panic!("{schema_name} is not a usable schema: {e}"));
48 +
49 + let errors: Vec<String> = validator.iter_errors(body).map(|e| e.to_string()).collect();
50 + assert!(
51 + errors.is_empty(),
52 + "fixture does not match the server's {schema_name} schema:\n {}\n\nfixture was:\n{}",
53 + errors.join("\n "),
54 + serde_json::to_string_pretty(body).unwrap_or_default(),
55 + );
56 + }
57 +
58 + // The shared fixtures
59 +
60 + #[test]
61 + fn auth_response_fixture_matches_the_spec() {
62 + assert_matches_schema("SyncAuthResponse", &auth_response_json());
63 + }
64 +
65 + #[test]
66 + fn device_fixture_matches_the_spec() {
67 + assert_matches_schema("SyncDeviceResponse", &device_json());
68 + }
69 +
70 + // Per-endpoint response fixtures
71 + //
72 + // These mirror the bodies the modules named in each test actually mount. A
73 + // fixture that drifts from its module is a bug in this file, and the module's
74 + // own assertions still cover behaviour; what these add is that the shape the
75 + // module assumes is the shape the server documents.
76 +
77 + #[test]
78 + fn push_response_fixture_matches_the_spec() {
79 + // sync.rs: `json!({"cursor": 1})`
80 + assert_matches_schema("PushResponse", &json!({"cursor": 1}));
81 + }
82 +
83 + #[test]
84 + fn pull_response_fixture_matches_the_spec() {
85 + // sync.rs, the paginated form.
86 + assert_matches_schema(
87 + "PullResponse",
88 + &json!({"changes": [], "cursor": 50, "has_more": true}),
89 + );
90 + }
91 +
92 + #[test]
93 + fn pull_change_entry_fixture_matches_the_spec() {
94 + // sync.rs, one decryptable row.
95 + let (_user_id, _app_id) = test_ids();
96 + assert_matches_schema(
97 + "PullChangeEntry",
98 + &json!({
99 + "seq": 1,
100 + "device_id": Uuid::new_v4(),
101 + "table": "tasks",
102 + "op": "INSERT",
103 + "row_id": "row-1",
104 + "timestamp": "2025-06-01T12:00:00Z",
105 + "data": "ciphertext",
106 + }),
107 + );
108 + }
109 +
110 + #[test]
111 + fn blob_upload_url_fixture_matches_the_spec() {
112 + // blob.rs
113 + assert_matches_schema(
114 + "BlobUploadUrlResponse",
115 + &json!({"upload_url": "https://s3.example.com/put", "already_exists": false}),
116 + );
117 + }
118 +
119 + #[test]
120 + fn blob_download_url_fixture_matches_the_spec() {
121 + // blob.rs
122 + assert_matches_schema(
123 + "BlobDownloadUrlResponse",
124 + &json!({"download_url": "https://s3.example.com/get"}),
125 + );
126 + }
127 +
128 + // Endpoint coverage
129 +
130 + /// Static paths this client calls. Mostly `Endpoints::new` in
131 + /// `src/client/mod.rs`, plus `validate-app`, which builds its URL inline at
132 + /// `client/mod.rs:554` instead of going through `Endpoints`.
133 + ///
134 + /// Kept as a literal list rather than reaching into the private type: the
135 + /// duplication is the point, because [`every_client_path_is_listed_here`] fails
136 + /// if the two ever diverge, and a list is what the ratchet below can be stated
137 + /// against. That test is also what caught `validate-app`, which reading
138 + /// `Endpoints::new` alone would have missed.
139 + const CLIENT_PATHS: &[&str] = &[
140 + "/api/v1/sync/auth",
141 + "/api/v1/sync/validate-app",
142 + "/api/v1/sync/devices",
143 + "/api/v1/sync/push",
144 + "/api/v1/sync/pull",
145 + "/api/v1/sync/subscribe",
146 + "/api/v1/sync/status",
147 + "/api/v1/sync/keys",
148 + "/api/v1/sync/blobs/upload",
149 + "/api/v1/sync/blobs/confirm",
150 + "/api/v1/sync/blobs/download",
151 + "/api/v1/sync/blobs/multipart/start",
152 + "/api/v1/sync/blobs/multipart/parts",
153 + "/api/v1/sync/blobs/multipart/complete",
154 + "/api/v1/sync/blobs/multipart/abort",
155 + "/api/v1/sync/subscription",
156 + "/api/v1/sync/subscription/checkout",
157 + "/api/v1/sync/subscription/quote",
158 + "/api/v1/sync/subscription/storage-cap",
159 + "/api/v1/sync/app/pricing",
160 + "/api/v1/sync/account",
161 + "/api/v1/sync/ota",
162 + "/api/v1/sync/groups",
163 + ];
164 +
165 + /// Client paths the server's spec does not document, pinned so the number can
166 + /// only go down.
167 + ///
168 + /// This is a real gap, not a formatting quirk. `subscribe` is the SSE stream the
169 + /// whole reconnect state machine in `subscribe.rs` is built on, and the OTA and
170 + /// group surfaces are entire feature areas. An undocumented endpoint has no
171 + /// schema, so nothing here or on the server checks that the two agree about it,
172 + /// which is precisely the condition this module exists to remove.
173 + ///
174 + /// Fix one by annotating the handler and adding it to `openapi::ApiDoc`, then
175 + /// re-exporting and re-vendoring the spec. Never add to this list: a new
176 + /// undocumented endpoint is the thing it exists to refuse.
177 + const UNDOCUMENTED: &[&str] = &[
178 + "/api/v1/sync/subscribe",
179 + "/api/v1/sync/ota",
180 + "/api/v1/sync/groups",
181 + ];
182 +
183 + fn documented_paths() -> std::collections::BTreeSet<String> {
184 + spec()["paths"]
185 + .as_object()
186 + .expect("spec has a paths object")
187 + .keys()
188 + .cloned()
189 + .collect()
190 + }
191 +
192 + #[test]
193 + fn every_client_path_is_documented_or_pinned_as_a_known_gap() {
194 + let documented = documented_paths();
195 + let missing: Vec<&str> = CLIENT_PATHS
196 + .iter()
197 + .copied()
198 + .filter(|p| !documented.contains(*p))
199 + .collect();
200 +
201 + assert_eq!(
202 + missing, UNDOCUMENTED,
203 + "the set of undocumented client endpoints changed.\n\
204 + If it shrank, remove the fixed entries from UNDOCUMENTED.\n\
205 + If it grew, the new endpoint needs a utoipa annotation on the server \
206 + and a re-exported spec, not an entry here."
207 + );
208 + }
209 +
210 + /// The list above is hand-maintained, so this checks it against the source it
211 + /// mirrors. Deliberately dumb: it greps `client/mod.rs` for `/api/v1/sync`
212 + /// literals. A rule cheap enough to state as "which paths appear in the client"
213 + /// is a rule that stays honest.
214 + #[test]
215 + fn every_client_path_is_listed_here() {
216 + let src = include_str!("../../src/client/mod.rs");
217 + let mut found: Vec<String> = Vec::new();
218 + for (idx, _) in src.match_indices("/api/v1/sync") {
219 + let tail = &src[idx..];
220 + let end = tail
221 + .find(|c: char| !(c.is_ascii_alphanumeric() || "/-_".contains(c)))
222 + .unwrap_or(tail.len());
223 + let path = tail[..end].trim_end_matches('/').to_string();
224 + if !found.contains(&path) {
225 + found.push(path);
226 + }
227 + }
228 + found.sort();
229 +
230 + let mut listed: Vec<String> = CLIENT_PATHS.iter().map(|s| (*s).to_string()).collect();
231 + listed.sort();
232 +
233 + assert_eq!(
234 + found, listed,
235 + "CLIENT_PATHS has drifted from Endpoints::new in src/client/mod.rs"
236 + );
237 + }
@@ -1,0 +1,2391 @@
1 + {
2 + "openapi": "3.1.0",
3 + "info": {
4 + "title": "Makenotwork API",
5 + "description": "Creator marketplace API. Only public and stable endpoints are documented.",
6 + "license": {
7 + "name": "PolyForm Noncommercial 1.0.0"
8 + },
9 + "version": "0.11.5"
10 + },
11 + "paths": {
12 + "/api/v1/items/{item_id}/license.txt": {
13 + "get": {
14 + "tags": [
15 + "License Keys"
16 + ],
17 + "summary": "Serve rendered license text for an item as plain text.",
18 + "operationId": "license_text",
19 + "parameters": [
20 + {
21 + "name": "item_id",
22 + "in": "path",
23 + "description": "The item ID",
24 + "required": true,
25 + "schema": {
26 + "type": "string"
27 + }
28 + }
29 + ],
30 + "responses": {
31 + "200": {
32 + "description": "License text",
33 + "content": {
34 + "text/plain": {}
35 + }
36 + },
37 + "404": {
38 + "description": "Item not found or no license configured"
39 + }
40 + }
41 + }
42 + },
43 + "/api/v1/keys/deactivate": {
44 + "post": {
45 + "tags": [
46 + "License Keys"
47 + ],
48 + "summary": "Release an activation slot (user uninstalls).",
49 + "operationId": "deactivate_key",
50 + "requestBody": {
51 + "content": {
52 + "application/json": {
53 + "schema": {
54 + "$ref": "#/components/schemas/DeactivateKeyRequest"
55 + }
56 + }
57 + },
58 + "required": true
59 + },
60 + "responses": {
61 + "200": {
62 + "description": "Deactivation result",
63 + "content": {
64 + "application/json": {
65 + "schema": {
66 + "$ref": "#/components/schemas/DeactivateKeyResponse"
67 + }
68 + }
69 + }
70 + }
71 + }
72 + }
73 + },
74 + "/api/v1/keys/status": {
75 + "post": {
76 + "tags": [
77 + "License Keys"
78 + ],
79 + "summary": "Quick validity check without activating, key in the POST body.",
80 + "description": "Prefer this over the GET `/keys/{key_code}/status` form: the key code is a\npurchase-proof secret, and putting it in the URL path leaks it into access\nand proxy logs. This matches the other license endpoints (validate,\ndeactivate, verify), which all carry the key in the body.",
81 + "operationId": "key_status_post",
82 + "requestBody": {
83 + "content": {
84 + "application/json": {
85 + "schema": {
86 + "$ref": "#/components/schemas/KeyStatusRequest"
87 + }
88 + }
89 + },
90 + "required": true
91 + },
92 + "responses": {
93 + "200": {
94 + "description": "Key status",
95 + "content": {
96 + "application/json": {
97 + "schema": {
98 + "$ref": "#/components/schemas/KeyStatusResponse"
99 + }
100 + }
101 + }
102 + }
103 + }
104 + }
105 + },
106 + "/api/v1/keys/validate": {
107 + "post": {
108 + "tags": [
109 + "License Keys"
110 + ],
111 + "summary": "Validate a license key and optionally activate it on a machine.",
112 + "operationId": "validate_key",
113 + "requestBody": {
114 + "content": {
115 + "application/json": {
116 + "schema": {
117 + "$ref": "#/components/schemas/ValidateKeyRequest"
118 + }
119 + }
120 + },
121 + "required": true
122 + },
123 + "responses": {
124 + "200": {
125 + "description": "Validation result",
126 + "content": {
127 + "application/json": {
128 + "schema": {
129 + "$ref": "#/components/schemas/ValidateKeyResponse"
130 + }
131 + }
132 + }
133 + }
134 + }
135 + }
136 + },
137 + "/api/v1/keys/{key_code}/status": {
138 + "get": {
139 + "tags": [
140 + "License Keys"
141 + ],
142 + "summary": "Quick validity check without activating.",
143 + "description": "DEPRECATED: the key code rides in the URL path, which leaks this\npurchase-proof secret into access/proxy logs. Use `POST /api/v1/keys/status`\n(key in the body) instead. Kept for backward compatibility with SDK\nconsumers that predate the POST form.",
144 + "operationId": "key_status",
145 + "parameters": [
146 + {
147 + "name": "key_code",
148 + "in": "path",
149 + "description": "The license key code",
150 + "required": true,
151 + "schema": {
152 + "type": "string"
153 + }
154 + }
155 + ],
156 + "responses": {
157 + "200": {
158 + "description": "Key status (DEPRECATED. Prefer POST /api/v1/keys/status)",
159 + "content": {
160 + "application/json": {
161 + "schema": {
162 + "$ref": "#/components/schemas/KeyStatusResponse"
163 + }
164 + }
165 + }
166 + }
167 + }
168 + }
169 + },
170 + "/api/v1/license/deactivate": {
171 + "post": {
172 + "tags": [
173 + "License Keys"
174 + ],
175 + "summary": "Deactivate a license on a specific machine (free up a slot).",
176 + "operationId": "license_deactivate",
177 + "requestBody": {
178 + "content": {
179 + "application/json": {
180 + "schema": {
181 + "$ref": "#/components/schemas/LicenseDeactivateRequest"
182 + }
183 + }
184 + },
185 + "required": true
186 + },
187 + "responses": {
188 + "200": {
189 + "description": "Deactivation result",
190 + "content": {
191 + "application/json": {
192 + "schema": {
193 + "$ref": "#/components/schemas/DeactivateKeyResponse"
194 + }
195 + }
196 + }
197 + }
198 + }
199 + }
200 + },
201 + "/api/v1/license/verify": {
202 + "post": {
203 + "tags": [
204 + "License Keys"
205 + ],
206 + "summary": "Verify a license key and bind it to a machine fingerprint.",
207 + "description": "If the project has `license_verification_enabled`, validates the key,\nchecks/creates an activation (using machine_fingerprint as machine_id),\nand returns a signed JWT for offline verification (valid 7 days).",
208 + "operationId": "license_verify",
209 + "requestBody": {
210 + "content": {
211 + "application/json": {
212 + "schema": {
213 + "$ref": "#/components/schemas/LicenseVerifyRequest"
214 + }
215 + }
216 + },
217 + "required": true
218 + },
219 + "responses": {
220 + "200": {
221 + "description": "Verification result with optional offline JWT",
222 + "content": {
223 + "application/json": {
224 + "schema": {
225 + "$ref": "#/components/schemas/LicenseVerifyResponse"
226 + }
227 + }
228 + }
229 + }
230 + }
231 + }
232 + },
233 + "/api/v1/sync/account": {
234 + "get": {
235 + "tags": [
236 + "SyncKit"
237 + ],
238 + "summary": "Return the authenticated user's email and username, for the app to display\n\"logged in as ...\" in its sync UI.",
239 + "operationId": "sync_account",
240 + "responses": {
241 + "200": {
242 + "description": "Account info",
243 + "content": {
244 + "application/json": {
245 + "schema": {
246 + "$ref": "#/components/schemas/SyncAccountResponse"
247 + }
248 + }
249 + }
250 + }
251 + },
252 + "security": [
253 + {
254 + "bearer": []
255 + }
256 + ]
257 + }
258 + },
259 + "/api/v1/sync/app/pricing": {
260 + "post": {
261 + "tags": [
262 + "SyncKit"
263 + ],
264 + "summary": "Return the pricing-formula constants for an app. The client uses these to\nquote a price locally as the user adjusts the cap slider; the same formula\nis enforced server-side at checkout so the client number is only advisory.",
265 + "operationId": "get_app_pricing",
266 + "requestBody": {
267 + "content": {
268 + "application/json": {
269 + "schema": {
270 + "$ref": "#/components/schemas/AppPricingRequest"
271 + }
272 + }
273 + },
274 + "required": true
275 + },
276 + "responses": {
277 + "200": {
278 + "description": "Pricing formula",
279 + "content": {
280 + "application/json": {
281 + "schema": {
282 + "$ref": "#/components/schemas/AppPricingResponse"
283 + }
284 + }
285 + }
286 + }
287 + }
288 + }
289 + },
290 + "/api/v1/sync/auth": {
291 + "post": {
292 + "tags": [
293 + "SyncKit"
294 + ],
295 + "summary": "Authenticate a user and return a JWT for subsequent sync API calls.",
296 + "description": "Verifies the app API key, then validates user email/password credentials.\nReturns a short-lived JWT containing the user ID and app ID, which the\nclient SDK includes as a Bearer token on all other sync endpoints.",
297 + "operationId": "sync_auth",
298 + "requestBody": {
299 + "content": {
300 + "application/json": {
301 + "schema": {
302 + "$ref": "#/components/schemas/SyncAuthRequest"
303 + }
304 + }
305 + },
306 + "required": true
307 + },
308 + "responses": {
309 + "200": {
310 + "description": "JWT token for sync API access",
311 + "content": {
312 + "application/json": {
313 + "schema": {
314 + "$ref": "#/components/schemas/SyncAuthResponse"
315 + }
316 + }
317 + }
318 + },
319 + "401": {
320 + "description": "Invalid credentials or API key"
321 + }
322 + }
323 + }
324 + },
325 + "/api/v1/sync/blobs/confirm": {
326 + "post": {
327 + "tags": [
328 + "SyncKit"
329 + ],
330 + "summary": "Confirm that a blob upload to S3 completed successfully.",
331 + "description": "Verifies the object exists in S3, then records it in the database.\nIdempotent: returns success without creating a duplicate.\n\nContent-addressing trust model (ultra-fuzz Run 4 Storage NOTE, decision\n2026-06-23; revised 2026-07-21): the blob `hash` is treated as a\ncontent-address LABEL, confirm reads the authoritative `object_size` from S3\nbut does not re-hash the bytes to prove they match `hash`. The blast radius\nis per-user only: the key is `{app_id}/{user_id}/{hash}` and storage is\n`UNIQUE(app_id, user_id, hash)`, so a client that stores mismatched bytes can\npoison only its OWN dedup namespace, no cross-user effect, no data exposure.\n\nThis note used to say the A+ fix was binding `x-amz-checksum-sha256` into the\npresigned PUT so S3 rejects a mismatched upload at write time. That reasoning\ndoes not hold for these blobs, and the correction is worth keeping: the stored\nobject is E2E *ciphertext* sealed with random per-chunk nonces, while `hash`\nis the SHA-256 of the *plaintext*. The server never sees plaintext, so it\ncannot derive the expected ciphertext digest at presign time, any checksum it\nbinds has to come from the client, i.e. the party whose honesty was in\nquestion. Checksum binding (which the multipart path now does per part) buys\ntransport integrity, not content-address enforcement.\n\nWhat actually binds the bytes to the address is the AEAD: each chunk is sealed\nwith `(hash, chunk_index, chunk_count)` as associated data, so ciphertext that\nopens under `hash` is cryptographically tied to it, and the client re-hashes\nthe plaintext after decrypting. A client storing mismatched bytes breaks only\nits own blob. Server-side re-hashing would cost a full object download per\nconfirm to defend a client against itself, which is why it is not done.",
332 + "operationId": "blob_confirm_upload",
333 + "requestBody": {
334 + "content": {
335 + "application/json": {
336 + "schema": {
337 + "$ref": "#/components/schemas/BlobConfirmRequest"
338 + }
339 + }
340 + },
341 + "required": true
342 + },
343 + "responses": {
344 + "204": {
345 + "description": "Upload confirmed"
346 + }
347 + },
348 + "security": [
349 + {
350 + "bearer": []
351 + }
352 + ]
353 + }
354 + },
355 + "/api/v1/sync/blobs/download": {
356 + "post": {
357 + "tags": [
358 + "SyncKit"
359 + ],
360 + "summary": "Request a pre-signed S3 download URL for a blob by hash.",
361 + "operationId": "blob_download_url",
362 + "requestBody": {
363 + "content": {
364 + "application/json": {
365 + "schema": {
366 + "$ref": "#/components/schemas/BlobDownloadUrlRequest"
367 + }
368 + }
369 + },
370 + "required": true
371 + },
372 + "responses": {
373 + "200": {
374 + "description": "Pre-signed download URL",
375 + "content": {
376 + "application/json": {
377 + "schema": {
378 + "$ref": "#/components/schemas/BlobDownloadUrlResponse"
379 + }
380 + }
381 + }
382 + },
383 + "404": {
384 + "description": "Blob not found"
385 + }
386 + },
387 + "security": [
388 + {
389 + "bearer": []
390 + }
391 + ]
392 + }
393 + },
394 + "/api/v1/sync/blobs/multipart/abort": {
395 + "post": {
396 + "tags": [
397 + "SyncKit"
398 + ],
399 + "summary": "Release the parts of an abandoned session (client cancel).",
400 + "description": "Incomplete multipart uploads bill for their parts until aborted, so a client\nthat cleans up on cancel is the cheapest fix; the orphan reaper is the\nbackstop for clients that vanish.",
401 + "operationId": "blob_multipart_abort",
402 + "requestBody": {
403 + "content": {
404 + "application/json": {
405 + "schema": {
406 + "$ref": "#/components/schemas/BlobMultipartAbortRequest"
407 + }
408 + }
409 + },
410 + "required": true
411 + },
412 + "responses": {
413 + "204": {
414 + "description": "Session aborted"
415 + }
416 + },
417 + "security": [
418 + {
419 + "bearer": []
420 + }
421 + ]
422 + }
423 + },
424 + "/api/v1/sync/blobs/multipart/complete": {
425 + "post": {
426 + "tags": [
427 + "SyncKit"
428 + ],
429 + "summary": "Assemble the uploaded parts into the blob object.",
430 + "description": "Transport only: the client then calls `/blobs/confirm`, which reads the real\nobject size from S3 and applies every quota and billing rule.",
431 + "operationId": "blob_multipart_complete",
432 + "requestBody": {
433 + "content": {
434 + "application/json": {
435 + "schema": {
436 + "$ref": "#/components/schemas/BlobMultipartCompleteRequest"
437 + }
438 + }
439 + },
440 + "required": true
441 + },
442 + "responses": {
443 + "204": {
444 + "description": "Parts assembled"
445 + }
446 + },
447 + "security": [
448 + {
449 + "bearer": []
450 + }
451 + ]
452 + }
453 + },
454 + "/api/v1/sync/blobs/multipart/parts": {
455 + "post": {
456 + "tags": [
457 + "SyncKit"
458 + ],
459 + "summary": "Mint a bounded window of presigned `UploadPart` URLs, each carrying its exact\nsigned `Content-Length`, the same defense-in-depth the one-shot presign\napplies.",
460 + "operationId": "blob_multipart_parts",
461 + "requestBody": {
462 + "content": {
463 + "application/json": {
464 + "schema": {
465 + "$ref": "#/components/schemas/BlobMultipartPartsRequest"
466 + }
467 + }
468 + },
469 + "required": true
470 + },
471 + "responses": {
472 + "200": {
473 + "description": "Presigned part URLs",
474 + "content": {
475 + "application/json": {
476 + "schema": {
477 + "$ref": "#/components/schemas/BlobMultipartPartsResponse"
478 + }
479 + }
480 + }
481 + }
482 + },
483 + "security": [
484 + {
485 + "bearer": []
486 + }
487 + ]
488 + }
489 + },
490 + "/api/v1/sync/blobs/multipart/start": {
491 + "post": {
492 + "tags": [
493 + "SyncKit"
494 + ],
495 + "summary": "Open a multipart upload session for a large blob.",
496 + "description": "`size_bytes` is the ciphertext length, which the client derives from the\nplaintext length alone (`blob_encrypted_len`) before sealing anything. The\npart geometry is pure arithmetic over it, so both sides compute identical\nboundaries without a round trip.",
497 + "operationId": "blob_multipart_start",
498 + "requestBody": {
499 + "content": {
500 + "application/json": {
Lines truncated