API
A Hono app on Cloudflare Workers, bound to a D1 database. Source: apps/api/src.
Base URLs:
| Environment | URL |
|---|---|
| Local | http://localhost:8787 |
| PR preview | A per-version *.workers.dev URL, posted on the PR — see CI/CD |
| Production | Set as the PRODUCTION_API_URL repository variable |
Conventions
Every request body and query string is validated against a schema from
@crag-topo/shared via @hono/zod-validator. A validation failure is a 400 before any handler
runs.
Every error has the same shape. One shape, so clients never branch on two:
{
"error": {
"code": "not_found",
"message": "Crag \"nowhere\" not found",
"details": { "bbox": ["Invalid format"] }
}
}
code is one of bad_request, unauthorized, forbidden, not_found, conflict,
rate_limited, internal. details is present only for field-level validation problems.
Internal errors never leak. An unhandled exception logs the request id and returns a generic
internal. D1 error text and stack traces do not go to a client we do not control.
Every response carries x-request-id and x-revision. The revision is the git SHA the Worker
was built from, which is how you confirm a preview is serving the commit you just pushed.
Pagination is keyset, never offset. cursor is opaque. Clients pass back what they were
given and never parse it.
GET /health
Liveness plus a real D1 round-trip. Used by the deploy workflows to verify a deployment before declaring it good.
{
"status": "ok",
"revision": "a1b2c3d4e5f6…",
"environment": "preview",
"database": "ok",
"time": 1786988278121
}
Returns 503 if the database round-trip fails; the body shape is identical, with
"database": "unreachable".
GET /v1/crags
Browse and search. Auth optional.
| Query | Type | Notes |
|---|---|---|
q | string | Case-insensitive match on name or region |
country | string(2) | ISO 3166-1 alpha-2 |
routeType | enum | sport, trad, boulder, … |
bbox | string | minLat,minLng,maxLat,maxLng for map queries. Malformed → 400 |
cursor | string | From the previous response |
limit | int | 1–100, default 25 |
{
"crags": [ { "id": "crg_01J…", "slug": "stanage-popular", "name": "Stanage Popular", … } ],
"nextCursor": "crg_01J…"
}
GET /v1/crags/:slug/bundle
Everything needed to use one crag offline, in a single response: the crag, its sectors, its photos, its routes, and its route lines.
Why one endpoint instead of four: the "download this crag" button needs one thing to retry when you are on one bar of signal in a car park. Four requests means four ways to end up with a half-downloaded crag.
{
"crag": { … },
"sectors": [ … ],
"photos": [ … ],
"routes": [ … ],
"routeLines": [ … ]
}
404 with code: "not_found" if the slug is unknown or the crag is soft-deleted.
:::caution Response size This response is unbounded — a large crag returns a large body. Fine at current data volumes, not fine forever. When it stops being fine the fix is paging within the bundle, not four endpoints again. :::
GET /v1/crags/:slug/routes
Filtered route list for one crag.
| Query | Type | Notes |
|---|---|---|
sectorId | string | |
routeType | enum | |
gradeSystem | enum | |
minBand, maxBand | int 0–100 | The lossy normalised band — see data model |
minStars | int 0–3 | |
cursor, limit |
POST /v1/sync/pull
Delta sync, download direction. Requires auth. Full protocol in Offline-first.
Request:
{
"cursors": { "route": "1735689600000:rte_01J…", "crag": "1735689600000:crg_01J…" },
"cragIds": ["crg_01J…"],
"limit": 500
}
Response:
{
"changes": {
"crag": [],
"sector": [],
"photo": [],
"route": [],
"routeLine": [],
"ascent": [],
"wishlistItem": []
},
"cursors": { "route": "1735689700000:rte_01J…" },
"hasMore": false,
"serverTime": 1786988278121
}
Notes that matter:
- Soft-deleted records are included. Tombstones are the payload, not noise.
- A malformed cursor is a
400, not a silent full download. cragIdsscopes server-owned content. Empty means everything.- User-owned kinds are scoped to the caller regardless.
POST /v1/sync/push
Delta sync, upload direction. Requires auth. Only ascent and wishlistItem are writable.
Request:
{
"mutations": [
{ "kind": "ascent", "op": "upsert", "baseUpdatedAt": null, "record": { "id": "asc_01J…", … } }
]
}
Response — one result per mutation, never a single all-or-nothing status:
{
"results": [
{
"id": "asc_01J…",
"kind": "ascent",
"status": "applied",
"serverRecord": null,
"message": null
}
],
"serverTime": 1786988278200
}
status | Meaning | Client should |
|---|---|---|
applied | Written | Clear the outbox row |
conflict | The stored row moved on; serverRecord holds it | Today: clear and let the pull overwrite. See Offline-first |
rejected | Not allowed — e.g. another user's record | Clear and log; retrying will not help |
The whole request never fails because one mutation did. A batch of 100 with one bad record applies 99.
Identity
There are no accounts (ADR-0007). The app mints a
local usr_<ULID> on first launch and sends it as the bearer token:
Authorization: Bearer usr_01J4X8ZK9M2N3P4Q5R6S7T8V9W
The server validates the shape and takes the value at face value. A malformed id is a 401; a
well-formed one is accepted, whoever sent it.
:::danger This is identity, not authentication
Anyone who knows a userId can read and write that user's ticks. That is acceptable with zero
users and disposable data, and it is not acceptable at launch.
The Worker therefore returns 501 for the sync endpoints in production unless
ALLOW_UNAUTHENTICATED is explicitly set to true. Shipping open writes to real users has to be
a deliberate act, not an oversight.
:::
Things this API does not have yet
Recorded so nobody assumes they exist:
- Rate limiting. A public sync endpoint with no limits is a bad idea. Needs an ADR.
- Write endpoints for guidebook content. Crags and routes are seeded by SQL today. Curation tooling is roadmap phase 3.
- Image upload / delivery. No R2 binding exists yet.
- Versioning beyond the
/v1prefix. The prefix is there; there is no deprecation policy behind it.
Testing
apps/api/test/api.test.ts runs inside workerd with a real D1, migrated from the same migration
files production uses. Run it:
pnpm --filter @crag-topo/api test
Related
- Offline-first — the sync protocol in full
- Data model — what the shapes above contain
- Environments — secrets and bindings