openapi.json 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. {
  2. "openapi": "3.1.0",
  3. "info": {
  4. "title": "Manage – Protocol v1",
  5. "version": "1.0.0",
  6. "summary": "Update and backup interface between a project instance and the Manage server.",
  7. "description": "The four endpoints a client needs: fetch the release manifest, download the release package, upload a backup, report status.\n\nEvery request authenticates with two headers (`X-Manage-Instance`, `X-Manage-Token`). There is no session, no cookie and no shared password. The server stores only the SHA-256 hash of the token.\n\nEvery successfully authenticated request additionally updates `last_seen_at` and the instance's latest IP.\n\nThe reference implementation of the client is fully documented in the handbook; see `lib/remote.php`, `lib/updater.php` and `lib/backup.php`.\n\nNote: the server's actual `error` strings in the response examples below are still German text — this document's descriptions are in English, the API's literal responses are not.",
  8. "license": {
  9. "name": "See the handbook"
  10. }
  11. },
  12. "servers": [
  13. {
  14. "url": "/api/v1",
  15. "description": "This Manage installation"
  16. }
  17. ],
  18. "tags": [
  19. {
  20. "name": "Update",
  21. "description": "Determine the release and fetch the package."
  22. },
  23. {
  24. "name": "Backup",
  25. "description": "Transfer backup archives to the server."
  26. },
  27. {
  28. "name": "Status",
  29. "description": "Report the instance's state."
  30. }
  31. ],
  32. "security": [
  33. {
  34. "instanceId": [],
  35. "instanceToken": []
  36. }
  37. ],
  38. "paths": {
  39. "/manifest.php": {
  40. "get": {
  41. "tags": ["Update"],
  42. "operationId": "getManifest",
  43. "summary": "Fetch the release the instance should install",
  44. "description": "Returns version, download address, size and SHA-256 of the current release.\n\n`package_url` is built from the server configuration (`MANAGE_PUBLIC_URL`), not from the request's `Host` header. A spoofed header therefore can't redirect a client to a foreign server.\n\nRelease metadata is not public: the endpoint requires a valid token.",
  45. "responses": {
  46. "200": {
  47. "description": "Current release",
  48. "content": {
  49. "application/json": {
  50. "schema": { "$ref": "#/components/schemas/Manifest" },
  51. "example": {
  52. "success": true,
  53. "latest": "v1.3.0",
  54. "version": "v1.3.0",
  55. "package_url": "https://manage.example.org/api/v1/package.php?version=v1.3.0",
  56. "sha256": "70f17aae44a9afdd948de1767daa61f936bbecb52096753757791e230a22f024",
  57. "size": 2199,
  58. "published_at": "2026-08-20T09:20:43+00:00"
  59. }
  60. }
  61. }
  62. },
  63. "401": { "$ref": "#/components/responses/Unauthorized" },
  64. "403": { "$ref": "#/components/responses/Disabled" },
  65. "404": {
  66. "description": "No valid release is published.",
  67. "content": {
  68. "application/json": {
  69. "schema": { "$ref": "#/components/schemas/Error" },
  70. "example": { "success": false, "error": "Es ist kein gültiges Release veröffentlicht." }
  71. }
  72. }
  73. },
  74. "405": { "$ref": "#/components/responses/MethodNotAllowed" },
  75. "429": { "$ref": "#/components/responses/RateLimited" },
  76. "500": { "$ref": "#/components/responses/ServerError" }
  77. }
  78. }
  79. },
  80. "/package.php": {
  81. "get": {
  82. "tags": ["Update"],
  83. "operationId": "getPackage",
  84. "summary": "Download the release ZIP",
  85. "description": "Streams the release archive. The response is `application/zip` with `Content-Length` and `Cache-Control: private, no-store`; no JSON on success.\n\nThe client **must** check size and SHA-256 against the manifest and delete the file on mismatch. Without this check, arbitrary code gets deployed.",
  86. "parameters": [
  87. {
  88. "name": "version",
  89. "in": "query",
  90. "required": true,
  91. "description": "Version in `vMAJOR.MINOR.PATCH` format.",
  92. "schema": { "$ref": "#/components/schemas/Version" },
  93. "example": "v1.3.0"
  94. }
  95. ],
  96. "responses": {
  97. "200": {
  98. "description": "The release archive",
  99. "headers": {
  100. "Content-Disposition": {
  101. "description": "attachment; filename=\"…zip\"",
  102. "schema": { "type": "string" }
  103. },
  104. "Content-Length": {
  105. "description": "Size in bytes, identical to `size` from the manifest.",
  106. "schema": { "type": "integer" }
  107. },
  108. "Cache-Control": {
  109. "description": "Always `private, no-store`.",
  110. "schema": { "type": "string" }
  111. }
  112. },
  113. "content": {
  114. "application/zip": {
  115. "schema": { "type": "string", "format": "binary" }
  116. }
  117. }
  118. },
  119. "400": {
  120. "description": "Invalid version format",
  121. "content": {
  122. "application/json": {
  123. "schema": { "$ref": "#/components/schemas/Error" },
  124. "example": { "success": false, "error": "Ungültige Version." }
  125. }
  126. }
  127. },
  128. "401": { "$ref": "#/components/responses/Unauthorized" },
  129. "403": { "$ref": "#/components/responses/Disabled" },
  130. "404": {
  131. "description": "Release does not exist",
  132. "content": {
  133. "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
  134. }
  135. },
  136. "405": { "$ref": "#/components/responses/MethodNotAllowed" },
  137. "429": { "$ref": "#/components/responses/RateLimited" },
  138. "500": { "$ref": "#/components/responses/ServerError" }
  139. }
  140. }
  141. },
  142. "/backup.php": {
  143. "post": {
  144. "tags": ["Backup"],
  145. "operationId": "uploadBackup",
  146. "summary": "Upload a backup archive",
  147. "description": "Accepts a ZIP and stores it under the instance.\n\nChecked in this order: upload error code, `is_uploaded_file`, size limit (`MANAGE_BACKUP_MAX_UPLOAD_BYTES`), ZIP signature, filename pattern, checksum after storing. If the checksum doesn't match, the file is deleted again and `400` is reported.\n\nAn existing filename is never overwritten: the server appends `-2`, `-3` and reports the name actually used.\n\nErrors during S3 archiving do **not** fail the upload – the local copy is stored and gets caught up later.",
  148. "requestBody": {
  149. "required": true,
  150. "content": {
  151. "multipart/form-data": {
  152. "schema": {
  153. "type": "object",
  154. "required": ["backup"],
  155. "properties": {
  156. "backup": {
  157. "type": "string",
  158. "format": "binary",
  159. "description": "The ZIP archive."
  160. },
  161. "filename": {
  162. "type": "string",
  163. "pattern": "^backup-\\d{8}-\\d{6}(?:-\\d+)?\\.zip$",
  164. "description": "Desired name. The server assigns `backup-YYYYmmdd-HHMMSS.zip` if omitted.",
  165. "example": "backup-20260820-092104.zip"
  166. },
  167. "sha256": {
  168. "type": "string",
  169. "pattern": "^[a-f0-9]{64}$",
  170. "description": "Checksum of the archive. Recomputed and compared server-side."
  171. },
  172. "meta": {
  173. "type": "string",
  174. "description": "JSON object with `trigger`, `file_count`, `source_bytes`, `app_version`.",
  175. "example": "{\"trigger\":\"cron\",\"file_count\":3,\"source_bytes\":63,\"app_version\":\"v1.3.0\"}"
  176. }
  177. }
  178. },
  179. "encoding": {
  180. "backup": { "contentType": "application/zip" }
  181. }
  182. }
  183. }
  184. },
  185. "responses": {
  186. "200": {
  187. "description": "Backup stored",
  188. "content": {
  189. "application/json": {
  190. "schema": { "$ref": "#/components/schemas/BackupResult" },
  191. "example": {
  192. "success": true,
  193. "instance": "myproject-prod",
  194. "filename": "backup-20260820-092104.zip",
  195. "size": 427,
  196. "sha256": "824f3f8000000000000000000000000000000000000000000000000000000000",
  197. "retention": 30,
  198. "s3": { "enabled": false, "uploaded": false, "pending": 0 }
  199. }
  200. }
  201. }
  202. },
  203. "400": {
  204. "description": "Upload rejected: file missing, limit exceeded, not a ZIP, wrong name or checksum.",
  205. "content": {
  206. "application/json": {
  207. "schema": { "$ref": "#/components/schemas/Error" },
  208. "example": { "success": false, "error": "Die hochgeladene Datei muss ein ZIP-Archiv sein." }
  209. }
  210. }
  211. },
  212. "401": { "$ref": "#/components/responses/Unauthorized" },
  213. "403": { "$ref": "#/components/responses/Disabled" },
  214. "405": { "$ref": "#/components/responses/MethodNotAllowed" },
  215. "429": { "$ref": "#/components/responses/RateLimited" }
  216. }
  217. }
  218. },
  219. "/heartbeat.php": {
  220. "post": {
  221. "tags": ["Status"],
  222. "operationId": "sendHeartbeat",
  223. "summary": "Report status and receive update information",
  224. "description": "Reports the instance's state. All fields are optional; missing fields leave the server's current value unchanged.\n\nThe response includes the update information, so it replaces a separate call to `manifest.php` for simple monitoring.",
  225. "requestBody": {
  226. "required": false,
  227. "content": {
  228. "application/json": {
  229. "schema": { "$ref": "#/components/schemas/HeartbeatRequest" },
  230. "example": {
  231. "version": "v1.3.0",
  232. "php_version": "8.3.6",
  233. "disk_free": 12884901888,
  234. "pending_migrations": 0,
  235. "last_backup_at": "2026-08-20T09:21:04+00:00"
  236. }
  237. }
  238. }
  239. },
  240. "responses": {
  241. "200": {
  242. "description": "Status accepted",
  243. "content": {
  244. "application/json": {
  245. "schema": { "$ref": "#/components/schemas/HeartbeatResponse" },
  246. "example": {
  247. "success": true,
  248. "instance": "myproject-prod",
  249. "latest": "v1.3.0",
  250. "update_available": false,
  251. "server_time": "2026-08-20T09:23:11+00:00"
  252. }
  253. }
  254. }
  255. },
  256. "400": {
  257. "description": "Request body is not valid JSON.",
  258. "content": {
  259. "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
  260. }
  261. },
  262. "401": { "$ref": "#/components/responses/Unauthorized" },
  263. "403": { "$ref": "#/components/responses/Disabled" },
  264. "405": { "$ref": "#/components/responses/MethodNotAllowed" },
  265. "429": { "$ref": "#/components/responses/RateLimited" }
  266. }
  267. }
  268. }
  269. },
  270. "components": {
  271. "securitySchemes": {
  272. "instanceId": {
  273. "type": "apiKey",
  274. "in": "header",
  275. "name": "X-Manage-Instance",
  276. "description": "The instance id, for example `myproject-prod`."
  277. },
  278. "instanceToken": {
  279. "type": "apiKey",
  280. "in": "header",
  281. "name": "X-Manage-Token",
  282. "description": "The token shown once when the instance was created."
  283. }
  284. },
  285. "responses": {
  286. "Unauthorized": {
  287. "description": "Headers missing, instance unknown, or token wrong – deliberately indistinguishable, so instance ids can't be brute-forced.",
  288. "content": {
  289. "application/json": {
  290. "schema": { "$ref": "#/components/schemas/Error" },
  291. "example": { "success": false, "error": "Authentifizierung fehlgeschlagen." }
  292. }
  293. }
  294. },
  295. "Disabled": {
  296. "description": "Instance exists but is deactivated.",
  297. "content": {
  298. "application/json": {
  299. "schema": { "$ref": "#/components/schemas/Error" },
  300. "example": { "success": false, "error": "Diese Instanz ist deaktiviert." }
  301. }
  302. }
  303. },
  304. "MethodNotAllowed": {
  305. "description": "Wrong HTTP method. The response carries an `Allow` header.",
  306. "headers": {
  307. "Allow": { "schema": { "type": "string" } }
  308. },
  309. "content": {
  310. "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
  311. }
  312. },
  313. "RateLimited": {
  314. "description": "Too many failed authentications from this IP (`MANAGE_API_RATE_LIMIT_MAX` per `MANAGE_API_RATE_LIMIT_WINDOW` seconds, 240 per 300s out of the box). A successful login resets the counter.",
  315. "content": {
  316. "application/json": {
  317. "schema": { "$ref": "#/components/schemas/Error" },
  318. "example": { "success": false, "error": "Zu viele Anfragen. Bitte später erneut versuchen." }
  319. }
  320. }
  321. },
  322. "ServerError": {
  323. "description": "Unexpected server error; details are in the server log.",
  324. "content": {
  325. "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
  326. }
  327. }
  328. },
  329. "schemas": {
  330. "Version": {
  331. "type": "string",
  332. "pattern": "^v\\d+\\.\\d+\\.\\d+$",
  333. "examples": ["v1.3.0"]
  334. },
  335. "Error": {
  336. "type": "object",
  337. "description": "Shape of **every** error response.",
  338. "required": ["success", "error"],
  339. "properties": {
  340. "success": { "type": "boolean", "const": false },
  341. "error": { "type": "string", "description": "Plain-text message, for logging and display." }
  342. }
  343. },
  344. "Manifest": {
  345. "type": "object",
  346. "required": ["success", "latest", "version", "package_url", "sha256", "size", "published_at"],
  347. "properties": {
  348. "success": { "type": "boolean", "const": true },
  349. "latest": { "$ref": "#/components/schemas/Version" },
  350. "version": {
  351. "allOf": [{ "$ref": "#/components/schemas/Version" }],
  352. "description": "Equivalent to `latest`; both fields exist for compatibility reasons."
  353. },
  354. "package_url": {
  355. "type": "string",
  356. "format": "uri",
  357. "description": "Absolute download address, built from `MANAGE_PUBLIC_URL`."
  358. },
  359. "sha256": {
  360. "type": "string",
  361. "pattern": "^[a-f0-9]{64}$",
  362. "description": "Checksum of the package. Must be verified by the client."
  363. },
  364. "size": { "type": "integer", "description": "Package size in bytes." },
  365. "published_at": { "type": "string", "format": "date-time" }
  366. }
  367. },
  368. "BackupResult": {
  369. "type": "object",
  370. "required": ["success", "instance", "filename", "size", "sha256", "retention", "s3"],
  371. "properties": {
  372. "success": { "type": "boolean", "const": true },
  373. "instance": { "type": "string" },
  374. "filename": {
  375. "type": "string",
  376. "description": "The name actually assigned – may differ from the one requested if it was already taken."
  377. },
  378. "size": { "type": "integer" },
  379. "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
  380. "retention": {
  381. "type": "integer",
  382. "description": "How many days the server keeps this archive."
  383. },
  384. "s3": {
  385. "type": "object",
  386. "description": "State of the optional S3 archive. Never a reason for failure.",
  387. "properties": {
  388. "enabled": { "type": "boolean" },
  389. "uploaded": { "type": "boolean" },
  390. "pending": { "type": "integer" }
  391. }
  392. }
  393. }
  394. },
  395. "HeartbeatRequest": {
  396. "type": "object",
  397. "properties": {
  398. "version": {
  399. "type": "string",
  400. "description": "Installed version of the application. Empty if it can't be determined."
  401. },
  402. "php_version": { "type": "string", "examples": ["8.3.6"] },
  403. "disk_free": { "type": "integer", "description": "Free disk space in bytes." },
  404. "pending_migrations": { "type": "integer", "minimum": 0 },
  405. "last_backup_at": { "type": "string", "format": "date-time" }
  406. }
  407. },
  408. "HeartbeatResponse": {
  409. "type": "object",
  410. "required": ["success", "instance", "latest", "update_available", "server_time"],
  411. "properties": {
  412. "success": { "type": "boolean", "const": true },
  413. "instance": { "type": "string" },
  414. "latest": {
  415. "type": "string",
  416. "description": "Current release on the server, or empty if none is published."
  417. },
  418. "update_available": {
  419. "type": "boolean",
  420. "description": "Result of a `version_compare` between `latest` and the reported version. `false` as long as the instance reports no version."
  421. },
  422. "server_time": { "type": "string", "format": "date-time" }
  423. }
  424. }
  425. }
  426. }
  427. }