Skip to main content

max / makenotwork

Commit the OpenAPI spec as a checked artifact The spec is the SyncKit wire contract, and the client lives in another repo, so a served endpoint alone is not something the client's suite can assert against. Export it to openapi.json via a small binary, and add a test that fails when the committed copy drifts from the generated one. Without that test, annotating or renaming a field leaves the vendored copy describing a server that no longer exists while both suites stay green, which is the imitation-oracle failure wiki `testing-posture` names. Serialization goes through one `spec_json` so the endpoint, the exporter and the drift test cannot disagree about formatting.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 13:54 UTC
Signed with PGP, not checked
Commit: 71f8c447ed9f4aafb083f063e339af90ecd47e75
Parent: 361c404
4 files changed, +570 insertions, -0 deletions
@@ -178,6 +178,13 @@
178 178 name = "mnw-admin"
179 179 path = "src/bin/mnw-admin.rs"
180 180
181 + # Writes the OpenAPI spec to openapi.json. The spec is the SyncKit wire
182 + # contract and the client is in another repo, so it ships as a committed
183 + # artifact rather than only as a served endpoint.
184 + [[bin]]
185 + name = "export-openapi"
186 + path = "src/bin/export-openapi.rs"
187 +
181 188 [build-dependencies]
182 189 # Emits static/geometry.css (makeover-geometry) and static/layout.css
183 190 # (makeover-webview). The same generator GO and BB run; only the output paths
@@ -138,10 +138,31 @@
138 138 Json(ApiDoc::openapi())
139 139 }
140 140
141 + /// The spec as pretty JSON, with a trailing newline.
142 + ///
143 + /// One function so the served endpoint, the exporter binary and the drift test
144 + /// cannot disagree about formatting. Pretty-printed on purpose: the committed
145 + /// copy is reviewed as a diff, and a single-line spec makes every change look
146 + /// like a rewrite.
147 + pub fn spec_json() -> String {
148 + let mut s = serde_json::to_string_pretty(&ApiDoc::openapi())
149 + .expect("the generated spec always serializes");
150 + s.push('\n');
151 + s
152 + }
153 +
141 154 #[cfg(test)]
142 155 mod tests {
143 156 use super::*;
144 157
158 + /// Path to the committed spec, resolved at compile time.
159 + ///
160 + /// `CARGO_MANIFEST_DIR` rather than a relative path: cargo-mutants copies the
161 + /// crate to a temp dir and runs the suite there, and a test that opens `"./..."`
162 + /// at runtime fails in the copy. That is exactly how the server's mutation
163 + /// baseline was broken for months (wiki `testing-posture`).
164 + const COMMITTED_SPEC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/openapi.json");
165 +
145 166 /// The SyncKit rotation + subscription endpoints are fully annotated but were
146 167 /// historically missing from the published spec (Run 20). This asserts the
147 168 /// spec documents them, so dropping a handler from `paths(...)` regresses
@@ -166,6 +187,24 @@
166 187 }
167 188 }
168 189
190 + /// The committed `openapi.json` is the artifact `synckit-client` validates
191 + /// its wiremock fixtures against, so it has to track the handlers. Without
192 + /// this, annotating a new field or renaming one leaves the vendored copy
193 + /// describing a server that no longer exists, and the client's suite stays
194 + /// green while asserting the old shape. That is the imitation-oracle failure
195 + /// this whole exercise is about (wiki `testing-posture`).
196 + #[test]
197 + fn committed_spec_matches_generated() {
198 + let committed = std::fs::read_to_string(COMMITTED_SPEC)
199 + .expect("openapi.json is committed at the crate root");
200 + assert_eq!(
201 + committed,
202 + spec_json(),
203 + "openapi.json is stale. Regenerate it with `cargo run --bin export-openapi`, \
204 + then vendor the new copy into synckit-client (tests/openapi.json)."
205 + );
206 + }
207 +
169 208 /// Regression (v0.10.14): the hand-rolled `/api/openapi.json` route and the
170 209 /// SwaggerUi mount must serve the spec at *distinct* paths. Reusing the same
171 210 /// path makes axum panic ("Overlapping method route") at `build_app` time,
@@ -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
@@ -1,0 +1,24 @@
1 + //! Write the OpenAPI spec to `openapi.json` at the crate root.
2 + //!
3 + //! The spec is a checked-in artifact rather than something only served at
4 + //! runtime, because it is the SyncKit wire contract and the client lives in
5 + //! another repo. A file can be vendored and diffed; a live endpoint cannot be
6 + //! reached from `synckit-client`'s test suite.
7 + //!
8 + //! `openapi::tests::committed_spec_matches_generated` fails when this output is
9 + //! stale, so the regeneration step is:
10 + //!
11 + //! ```sh
12 + //! cargo run --bin export-openapi
13 + //! ```
14 +
15 + use std::io::Write as _;
16 +
17 + fn main() -> std::io::Result<()> {
18 + let spec = makenotwork::openapi::spec_json();
19 + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/openapi.json");
20 + let mut file = std::fs::File::create(path)?;
21 + file.write_all(spec.as_bytes())?;
22 + println!("wrote {path}");
23 + Ok(())
24 + }