A metadata registry for reef surveys processed on the desktop.
Laptops run the DeepReefMap pipeline and hold the frames, clouds and orthomosaics. This server holds the catalogue: sites, campaigns, transects, passes, runs and benthic cover. Metadata syncs both ways through a change ledger: a transect made in the browser reaches every laptop, a run finished in the field reaches everyone else, and the console has the last word on any row it has touched.
Footage never passes through the API. Clients send it straight to S3-compatible storage.
Bring the stack up from the UI repository, where the browser and the API meet:
cd ../deepreefmap-ui
docker compose up -dThat runs Postgres, Keycloak with a seeded realm, MinIO, this API with hot reload, and the web interface behind one traefik.
| What | Where |
|---|---|
| Web interface | http://localhost:88 |
| API reference | http://localhost:88/docs |
| Keycloak | http://localhost:8280 (admin / admin) |
To run the API alone, copy .env.example to .env, point DATABASE_URL at your own
Postgres, and cargo run. Migrations apply at boot under an advisory lock, so several
replicas can start together.
The browser presents a Keycloak JWT. The desktop application presents a device token.
A person writes through the CRUD routes and is named as author in the ledger; a
device writes through /api/sync/push and is named as device_id.
The desktop application ships with no server address in it. Enrol one in a single paste:
- Mint a code:
POST /api/devices/connect-codes. - Copy the
drm1.<base64url>string the interface shows. It wraps the server URL and a single-use secret. - Paste it into the application. It redeems the code at
POST /api/enroland keeps the device token.
Codes are single use and expire in CONNECT_CODE_TTL_SECONDS. Device tokens are
argon2id-hashed at rest, and revoked from the interface.
GET /api/sync/pull?since=<n> # rows changed since a cursor, tombstones included
POST /api/sync/push # apply a document of rowsA client tracks one integer, cursor, and both endpoints return the next one. The
replicated tables and their columns are contract/sync-contract.json, checked in
rather than served.
Each side declares a range, and the exchange runs at min(client_max, server_max):
Deepreefmap-Contract: 1-1
Deepreefmap-Sections: sites,campaigns,transects,videos,passes,pass_videos,runs,cover_rowsBoth headers are optional, and a malformed value counts as absent. Send neither and you get contract 1 with no narrowing. Every response carries the server's own range back.
Every pushed row becomes an entry in change_log, decided against the base_seq the
device last saw for it:
| Outcome | When |
|---|---|
applied |
nothing moved since the base, or only other fields did (merged) |
proposed |
the console edited the same field, validated, deleted or authored the row |
superseded |
another device's row, or a field this device has since moved past |
rejected |
a unique collision, a missing parent, or a value outside its vocabulary |
Nothing a laptop sends is lost: a proposal keeps its values, and GET /api/changes
lists them for a curator to accept (POST /api/changes/{seq}/accept) or dismiss.
POST /api/changes/validate marks rows checked, after which every laptop's change to
them is a proposal. Deletes are tombstones, because an absent row is indistinguishable
from one a client has not seen yet.
A device also pulls its own rows of the upload sections back, so it learns what the
console curated, validated or deleted, and outbox in the pull carries the decisions
on its proposals.
/api/archive/* catalogues footage and run outputs held in S3-compatible storage.
Without the S3_* variables it answers 503 and nothing else changes. Clients never
talk to the store: every byte flows through the API under the caller's credential,
and the store stays on its internal network. Initiate, PUT each missing part, then
complete:
POST /api/archive/initiate # part size and parts already stored
PUT /api/archive/{id}/parts/{n} # one part's raw bytes
POST /api/archive/{id}/complete
GET /api/archive/{id}/download # a signed fetch link on this API
GET /api/archive/by-hash/{content_hash}
POST /api/archive/probe # state for many hashes
POST /api/archive/runs-probe # artefact counts for many runs
GET /api/archive/overview # console only: by run, by clip, unlinked
GET /api/runs/{id}/outputs # console only: the run's files by group
GET /api/runs/{id}/outputs/files?purpose= # console only: one group, paged
GET /api/runs/{id}/outputs/bundle?purpose= # a signed link to one zip of a runKeys are content-addressed, {S3_PREFIX}/videos/imohash/{hex} and
{S3_PREFIX}/runs/{run_id}/{relpath}, so re-uploading a clip is a no-op. Upload state
lives in Postgres, so a 4 GB transfer resumes after a restart. Downloads redeem a
short-lived HMAC-signed link at /api/archive/{id}/fetch, minted only to
authenticated callers and bound to one object, so a plain browser navigation works
without exposing anything. Nothing here deletes or overwrites.
A run arrives as hundreds of objects, so outputs/bundle mints the same kind of link
for a whole group of them: purpose takes all (the default), one of Results,
Record or Working data, or a run subdirectory such as frames. The zip streams
from /api/archive/runs/{id}/outputs.zip, stored rather than deflated, so nothing is
buffered on the way through. src/archive/purpose.rs decides which files a purpose
covers, and outputs reports the same groups with their counts and sizes so a reader
never pages through a run's thousands of artefact rows to see what is there.
GET /api/config/classes publishes the segmentation classes by label id, each with its
colour and the group it rolls into at the intermediate and coarse levels.
GET /api/config/class-groups is the same table rolled up, one entry per group and
level. The groups derive from the classes in src/contract/classes.rs, so a cover
figure, a class ortho and the desktop viewer draw one colour set. A label id in a run's
ortho.npz is one of these ids.
Everything above device in this table replicates. The desktop application's batch,
batch-item and notification tables do not: they are one workstation's queue.
change_log is the ledger behind sync, server-side only. A survey event is derived, the
passes of one transect in one campaign, so there is nothing to curate by hand.
| Entity | Notes |
|---|---|
site |
Reef location, unique within its country. Transect names are unique within one. |
campaign |
One trip, ie. 2025_10_eritrea. Visits many sites; a repeat visit is a new campaign. |
transect |
Survey line: end points where known, the tape length, and the depth at each end. depth_m is their mean, written by trigger. |
video_asset |
Input clip, identified by imohash, with its camera, rig position and review verdict. Paths stay device-local. |
transect_pass |
One swim: a time window over one or more clips, on a day, in a campaign. |
pass_video |
Which clips a pass spans, in playing order. |
run_record |
A reconstruction that already ran, with its provenance and scale. |
cover_row |
Benthic cover in long format, one row per class group. |
preset |
Named run settings the server defines. Pull only. |
camera_profile |
A rig, by the name a preset and a run resolve. Names which calibration laptops run it under. Pull only. |
camera_calibration |
One measurement of one rig, versioned. Holds the profile JSON the pipeline reads. Pull only. |
device |
Enrolled desktop installation. Server-side only. |
stored_object |
An archived blob and its upload state. Server-side only. |
run_artifact |
One file of a run's output directory and the stored object holding it. Server-side only. |
The CRUD routers speak the Content-Range list dialect, so ra-data-simple-rest works
against them unchanged.
Preset settings are validated against contract/preset-schema.json, generated from
src/contract/preset_schema.rs. That file mirrors the desktop application's
survey/preset_schema.py and models/cache.py, and the web console builds its
preset form from the published copy. A lagging mirror degrades to a missing dropdown
entry on the laptop, never a broken run: the desktop drops names it cannot offer.
A camera profile is a rig: a body, a lens mode, a housing, a resolution, under the name a preset carries and a laptop resolves against its own profile directory. A calibration is one measurement of that rig, and versions coexist, so a housing change invalidates the last measurement without invalidating the runs made under it.
Publishing and deploying are separate acts. A laptop publishes what it measured through
POST /api/camera_calibrations/upload, which is idempotent on the whole document and
takes the next version otherwise. A curator then deploys one, by setting
camera_profile.current_calibration_id, and that is what every laptop materialises on
its next pull. A profile deploying nothing follows the newest, which is what a profile
did before deploying existed. Deploying an earlier version is the rollback.
Two rules the code cannot enforce:
- A profile name is a rig, not a camera model. Two of the same camera in different housings are two names. Once a name is in the registry its calibration shadows the bundled document on every laptop that syncs, so naming is the whole game. The console warns when a new calibration changes the optics of a name it already holds.
- After release the migration seed is frozen history. The library catches up with the registry by publishing, never by editing the seeded document: editing it desynchronises every deployed database from every freshly migrated one.
The historical catalogue lives in two spreadsheets: one row per clip with comma-joined
lists of passes, and a results sheet with per-transect coordinates. import-field-csv
folds them onto the schema, with import/site_aliases.json mapping the free-text
places onto sites. Every imported row is console-owned and validated. Dry run by
default; ids derive from the natural keys, so running it twice changes nothing.
cargo run --bin import-field-csv -- --videos csv_videos_timestamp_export.csv --results results.csv
DATABASE_URL=... cargo run --bin import-field-csv -- --videos ... --results ... --applyA pass whose window runs to the end of a clip has no end time the sheet can give, so it is noted on the clip rather than invented.
cargo clippy --all-targets -- -D warnings
cargo test --lib # no database needed
DATABASE_URL=postgresql://postgres:psql@localhost:5444/deepreefmap_test cargo test
cargo run --bin export-contract -- --checkEach test builds its own database, so a local run and the watcher container do not
collide. Start deepreefmap-test-watcher in the compose stack to rerun the suite on
every save.
See .env.example for the full set. The ones worth knowing:
| Variable | Why it matters |
|---|---|
DATABASE_URL |
Or DB_USER/DB_PASSWORD/DB_HOST/DB_NAME. |
DEPLOYMENT |
stage and prod refuse to start without Keycloak. |
PUBLIC_BASE_URL |
Embedded in connect codes. Without it, no client can enrol. |
KEYCLOAK_BROWSER_URL |
When the browser cannot resolve KEYCLOAK_URL, ie. in containers. |
S3_* |
The archive. All five or none. |
SYNC_BODY_LIMIT_BYTES |
Ceiling on a push body. |
Roles are realm roles. deepreefmap-member reads and writes survey metadata and enrols
its own devices, deepreefmap-admin also revokes anyone's devices. A login carrying
neither is refused with no_deepreefmap_role.
- The enrol rate limiter trusts
X-Forwarded-For. Your reverse proxy has to overwrite that header, or the per-IP bucket is spoofable. - An unredeemed connect code cannot be revoked. Its TTL is the whole mitigation.
- Conflict resolution is whole-row, not per field. Two people editing different fields of one transect within a sync interval will see one edit win entirely.
- Tombstones are never collected, so the row count only grows.
- A content hash is an identity a client asserts, not a proof. imohash samples a file rather than reading it, and the integrity of the bytes in flight is S3's own.
cover_row.metric_sourcerecords which cloud a fraction was measured on. The pipeline can produce either, and the numbers are not comparable across the two.
MIT