Sync

Cronhoster pushes your local cron directories to the server over a private, token-protected API on the server's IP. Caddy terminates TLS and reverse-proxies to a localhost-only Node service, and the local sync pushes only the cron directories the server does not already have. cronhoster hosts no public web content, so this sync API is the only public surface. The read-only endpoints behind the log and stats browsers share the same shape and live on the Tools page.

GET    /api/v2/tree
PUT    /api/v2/file
DELETE /api/v2/file
The sync API: walk the Merkle tree, then upload or delete a file.

Private API

Every call is authenticated with a bearer token, sent as Authorization: Bearer <token>. There is nothing else to the surface. There is no dashboard, login, session or cookie. The file routes are versioned under /api/v2 (API_VERSION = 2); the local scripts pins the prefix to its own version, so a server on a different version answers 426 Upgrade Required rather than silently following the local scripts onto a contract it does not speak. On such a skew the local scripts reads the unversioned GET /api/version, which reports the server's version, and says which side to upgrade.

With a valid token the HTTP status line carries the outcome: 200 ok, 400 a bad path, 410 the path is absent (normal descent, the local scripts then creates it), 500 a failed write, and 426 a request on a different API version's path. Without a valid token, or on an unknown route, every reply is an identical 404 <h1>Not Found</h1> wall, so an unauthenticated probe cannot tell the API is there at all (see Server).

MethodPathPurpose
GET/api/v2/treeFetch the Merkle node at a path and depth, so the local scripts can compare and descend only where it differs.
PUT/api/v2/fileUpload one file and stamp it with its source mtime, so both sides hash it identically.
DELETE/api/v2/fileDelete a file, or a whole cron directory, that is on the server but gone locally.

GET /api/v2/tree

Returns the Merkle node at a path, so the local scripts can compare hashes and descend only into the subtrees that differ. A file node is its metadata hash; a directory node is the hash of its children, optionally with those children one level down. The tree is built by the server's manifest.js with skipUnderscore, so an entry whose name starts with _, and any dotted entry, never appears on either side and the two hashes agree. Every hash is derived from stat() alone, size and whole-second mtime, so the tree is built without reading any file contents.

Request

GET /api/v2/tree?path=backup&depth=1 HTTP/1.1
Authorization: Bearer <token>
Fetch one cron directory one level deep.
ParameterInMeaning
pathqueryThe relative path. Empty (path=) is the content root. An illegal or unsafe path is answered with 410, the same as a path that does not exist.
depthquery0 is this node's hash only, no children; 1 includes the immediate children; omitted or -1 is the whole subtree.

Plus Authorization: Bearer <token>; no body.

Response

A 200 with the tree node, or 410 Gone when the path is not present. The local scripts read a 410 as "the server has nothing here" and uploads the subtree. A directory fetched at depth=1 looks like:

{
  "type": "dir",
  "hash": "b1946ac9…",
  "children": {
    "backup": { "type": "dir",  "hash": "9f86d081…" },
    "report":   { "type": "dir",  "hash": "3a7bd3e2…" }
  }
}
A directory node at depth=1. At depth=0 the children map is omitted.
FieldTypeMeaning
typestring"dir" or "file".
hashstringThe node's Merkle hash. For a file, sha256(size:mtime); for a directory, sha256 of its sorted name:childhash lines. Equal hashes mean identical subtrees.
childrenobjectPresent on a directory only when depth is not 0: a map of child name to node, each trimmed to the remaining depth.

A path that does not exist, or is not a legal path, is 410 Gone rather than a 200 body. See Errors.

PUT /api/v2/file

Uploads one file, creating or overwriting it, and stamps it with the source mtime, so the metadata hash matches on both sides next run. The body streams straight to a hidden .chtmp-* temp file in the same directory and is renamed into place atomically, so a half-written file is never served and memory stays bounded regardless of file size.

Request

PUT /api/v2/file?path=backup/cronhoster-cron-handler.js&mtime=1784514159000 HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/octet-stream

<file bytes>
Upload one file, stamped with its source mtime.
ParameterInMeaning
pathqueryThe relative file path. An empty or unsafe path is rejected with 400.
mtimequeryThe source modification time in milliseconds; the server applies it to the written file so the hashes agree.
(body)bodyThe raw file bytes, streamed with backpressure.

Method PUT, plus Authorization: Bearer <token>.

Response

A 200 JSON object describing the write:

{ "size": 1234, "mtime": 1784514159000 }
The bytes written and the applied modification time.
FieldTypeMeaning
sizeintegerThe number of bytes written on disk.
mtimeintegerThe applied modification time in milliseconds.

An invalid path is 400, and a failed upload or write is 500, each with an { "error": … } body. See Errors.

DELETE /api/v2/file

Removes a file, or a whole cron directory subtree, that exists on the server but is gone locally. A directory delete removes its subtree and then prunes any now-empty parent directories, since the server never stores empty directories. Deleting a path that is already absent is still a 200. The desired state is reached either way, so delete is idempotent.

Request

DELETE /api/v2/file?path=report HTTP/1.1
Authorization: Bearer <token>
Remove a cron directory that is gone locally.
ParameterInMeaning
pathqueryThe relative file or directory path to remove. An unsafe path is rejected with 400.

Method DELETE, plus Authorization: Bearer <token>; no body.

Response

A 200 JSON object:

{ "deleted": true }
Whether something was removed; an absent path is still a 200.
FieldTypeMeaning
deletedbooleantrue if something was removed; false if the path was already absent (still a 200).

An invalid path is 400 with an { "error": … } body. See Errors.

Synchronisation Process

A sync is a comparison of two Merkle trees built from file metadata; the local scripts only ever sends the differences. Each file's hash is sha256(size : mtime-seconds), derived from stat() alone, so building the tree reads no file data; a directory's hash is the hash of its sorted child name:hash lines. Two directories therefore share a hash only when every file beneath them has identical size and timestamp. The local scripts walks from the root, compares hashes, and descends only where they differ, so it transfers only the files the server does not already have. The tree builder is the same algorithm on both sides — the server's manifest.js and the local scripts' own copy — so the two sides can never disagree.

  1. Resolve the API version. The local scripts builds the /api/v2 prefix from its own version. On an unexpected status it reads GET /api/version and reports which side to upgrade.
  2. List the top-level names. GET /api/v2/tree?path=&depth=1 returns the server's top-level cron directories. The local scripts compares them by name with its own. If the root hash already matches, nothing changed and the run finishes in a single request.
  3. Upload each new cron. For a name present locally but not on the server, the local scripts descends the local tree and PUTs every file in that cron directory, streamed with backpressure. The server applies the sent mtime so the hashes match next time.
  4. Delete each removed name. For a name on the server but gone locally, DELETE removes the whole subtree. Deletes run first, so a type change is handled cleanly, then the uploads.

Sync once, then immutable

cronhoster's local scripts reconciles only the top-level directory names. A new top-level cron directory is uploaded whole; a removed one is deleted; a directory present on both sides is never re-synced, even if its contents differ locally. So a live cron's files never change under the runner. This is the key difference from a file-by-file mirror.

To change a cron you publish a new directory under a new name. Stage it as _<name>, which never syncs, test it, then drop the underscore to go live and remove or underscore the old one. The _ prefix and dotted entries are dropped at every level by skipUnderscore on both sides, so a draft or any other local-only file stays on your machine and never reaches the server.

Errors

With a valid token, a failure is an HTTP status with a short { "error": … } body. Without one, every reply is the same 404 wall.

StatusWhen it appears
400An invalid or unsafe path on a PUT or DELETE (an empty path or a traversal attempt).
410A GET /tree for a path the server does not have. Normal during descent: the local scripts reads it as "absent" and uploads the subtree.
426An authenticated request on a different API version's path. The local scripts reads GET /api/version and reports which side to upgrade.
500A write failed on the server (a failed upload stream or rename).
404The wall: a missing or wrong token, or an unknown route. Identical to a real not-found, so a probe learns nothing.