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
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).
| Method | Path | Purpose |
|---|---|---|
GET | /api/v2/tree | Fetch the Merkle node at a path and depth, so the local scripts can compare and descend only where it differs. |
PUT | /api/v2/file | Upload one file and stamp it with its source mtime, so both sides hash it identically. |
DELETE | /api/v2/file | Delete 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>
| Parameter | In | Meaning |
|---|---|---|
path | query | The 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. |
depth | query | 0 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…" }
}
}
depth=1. At depth=0 the children map is omitted.| Field | Type | Meaning |
|---|---|---|
type | string | "dir" or "file". |
hash | string | The 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. |
children | object | Present 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>
| Parameter | In | Meaning |
|---|---|---|
path | query | The relative file path. An empty or unsafe path is rejected with 400. |
mtime | query | The source modification time in milliseconds; the server applies it to the written file so the hashes agree. |
| (body) | body | The raw file bytes, streamed with backpressure. |
Method PUT, plus Authorization: Bearer <token>.
Response
A 200 JSON object describing the write:
{ "size": 1234, "mtime": 1784514159000 }
| Field | Type | Meaning |
|---|---|---|
size | integer | The number of bytes written on disk. |
mtime | integer | The 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>
| Parameter | In | Meaning |
|---|---|---|
path | query | The 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 }
200.| Field | Type | Meaning |
|---|---|---|
deleted | boolean | true 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.
- Resolve the API version. The local scripts builds the
/api/v2prefix from its own version. On an unexpected status it readsGET /api/versionand reports which side to upgrade. - List the top-level names.
GET /api/v2/tree?path=&depth=1returns 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. - 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. - Delete each removed name. For a name on the server but gone locally,
DELETEremoves 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.
| Status | When it appears |
|---|---|
400 | An invalid or unsafe path on a PUT or DELETE (an empty path or a traversal attempt). |
410 | A GET /tree for a path the server does not have. Normal during descent: the local scripts reads it as "absent" and uploads the subtree. |
426 | An authenticated request on a different API version's path. The local scripts reads GET /api/version and reports which side to upgrade. |
500 | A write failed on the server (a failed upload stream or rename). |
404 | The wall: a missing or wrong token, or an unknown route. Identical to a real not-found, so a probe learns nothing. |