Lokale und datenschutzfreundliche Terrarienverwaltung
This document describes the server-owned care API from Issue #157 and local
caregiver authentication from Issue #158. The ARM64 container layout and LAN
HTTPS deployment are described in Shared Care deployment.
The API-backed Flutter Web client is lib/main_shared.dart (Issue #160).
The server opens one collection SQLite database file on persistent local storage. It runs the existing Drift migrations before accepting requests. A separate SQLite file holds local accounts and sessions. The server alone holds both database connections; API clients cannot request either file or execute SQL. Existing standalone Android and Web modes keep their local database behavior. Shared-mode browsers use this API only; their presentation preferences belong to the signed-in account and are stored in the account database.
Media assets remain in the SQLite MediaAssets table. Local accounts and
sessions live in a separate accounts.sqlite database beside the collection
database by default. Keep both files and their WAL files in persistent storage,
not in a disposable container layer. Do not place live SQLite files on a
network share. The portable collection backup does not include accounts.
Set TM_DATABASE_PATH to the absolute collection database path. From an
interactive terminal, run dart run bin/create_admin.dart once. It prompts for
an administrator username and a password of at least 12 characters without
echoing the password or accepting it on the command line. Startup refuses to
serve a collection until this administrator exists; there is no default login.
Then run dart run bin/shared_server.dart with:
| Variable | Required | Meaning |
|---|---|---|
TM_DATABASE_PATH |
yes | Absolute path of the server-owned SQLite file |
TM_PUBLIC_ORIGIN |
yes | Exact browser origin, for example https://terramanager.example |
TM_AUTH_DATABASE_PATH |
no | Account database; defaults to accounts.sqlite beside the collection file |
TM_BIND_ADDRESS |
no | Listener address; defaults to 127.0.0.1 |
TM_PORT |
no | Listener port; defaults to 8080 |
The default loopback binding keeps the API private until an HTTPS reverse
proxy is configured. Non-local TM_PUBLIC_ORIGIN values must use HTTPS. The
server does not contact any external identity, analytics or tracking service.
Account credentials and session cookies must never traverse an unencrypted LAN
connection. Server file permissions should limit access to both databases.
All API paths use the /api/v1 prefix. GET /api/v1/health is an unauthenticated
readiness check that tests the database connection and returns only status.
Collection and media paths require an
active local account session. Login sets a host-only Secure, HttpOnly,
SameSite=Strict cookie. Session tokens are random, stored only as SHA-256
digests on the server, expire after 12 hours and are revoked on logout or
account changes. The login response and GET /auth/session provide a
session-specific CSRF token; send it in X-CSRF-Token on every POST, PUT, PATCH
and DELETE. Cross-origin writes are rejected. No CORS access is granted.
Requests and responses containing records use JSON. The Animal list includes
latestFeedingAt as a nullable UTC timestamp calculated for the whole list in
one database query. This read-only overview field is not part of the Animal
revision token and updates after a Feeding is added, edited or deleted.
Instant dates in write requests
must use ISO-8601 with Z or a numeric time-zone offset. Server responses use
UTC ISO-8601 timestamps. A client should clear its local login state on
401 unauthorized, show the denied operation on 403 forbidden or 403 csrf_failed,
and redirect to login after expiry. The browser client refreshes visible
overviews and details every 15 seconds, refreshes immediately after a save or
when the browser returns to the foreground, and offers a manual reload after
connection failures. Hidden browser tabs do not poll. It does not queue edits while
disconnected.
Overview media reads reuse the authenticated client. The browser retains a
bounded, in-memory picture snapshot for the current overview. Routine polling
updates records without downloading unchanged pictures; manual reload, detail
return and a new overview visit renew the snapshot. Changing a primary media ID
loads the new image. The cache does not modify server media ownership, media
routes, authorization or the no-store response policy.
From v1.14.1, all collection DELETE routes (Animals, Boxes, Feedings, weight
and shedding history, picture associations and future media deletion routes)
require an administrator and a valid CSRF token. A caregiver receives 403
before dispatch and the record is unchanged. Creating, editing, duplicating,
archiving, restoring and selecting a primary picture remain available to
caregivers. Changing a primary picture does not delete its gallery association.
Standalone deletion is unaffected.
GET /auth/preferences returns the current account’s personal preferences;
PATCH /auth/preferences updates only supplied allowlisted fields, with CSRF.
There is no account-ID parameter, even for administrators. Login and session
responses include the same preferences snapshot. Invalid/unknown fields or
values return 400 without changing the saved settings.
Preferences are stored in accounts.sqlite with an account foreign key and
are removed when that account is removed. They include theme, accent, language,
Animal name order, Box/Animal sorting, category grouping, Big Picture Mode,
Next Feeding visibility and both archive sort orders. Defaults are system
theme/language, green accent, common-name-first, oldest-created Animal sorting,
ascending Box labels, newest-archived-first, and all three display toggles off.
Browser preferences are not imported: a new account receives these defaults.
Sign-out and session expiry reset the displayed settings; a late save response
cannot overwrite a subsequently signed-in account. Standalone preferences
remain device-local. Account preferences are excluded from portable collection
backups; include the account database in operator disaster-recovery backups.
Birth-date request/response values are YYYY-MM-DD; legacy time-bearing birth
dates are accepted using their written calendar components. All other dates
are instants with explicit offsets. See backup timestamps
for the legacy restore policy and the X-Backup-Time-Zone restore header.
| Route | Access | Purpose |
|---|---|---|
POST /auth/login |
public | Log in with local username and password; returns user, CSRF token and expiry |
GET /auth/session |
signed in | Recover current user, CSRF token and expiry after a reload |
POST /auth/logout |
signed in, CSRF | Revoke the current session |
GET /admin/accounts |
administrator | List local accounts without password hashes |
POST /admin/accounts |
administrator, CSRF | Add an administrator or caregiver |
PATCH /admin/accounts/{id} |
administrator, CSRF | Change username/role, reset password or activate/deactivate; revokes that user’s sessions |
DELETE /admin/accounts/{id} |
administrator, CSRF | Confirmed removal of credentials and sessions; preserves audit attribution |
Usernames use 3–64 lower-case ASCII letters, digits, dots, underscores or dashes and begin with a letter or digit. Names are trimmed and case-normalized before uniqueness checks. Passwords are individually salted and hashed with Argon2id (19 MiB, two iterations, one lane). No plaintext password is written to either SQLite file. The final active administrator cannot be demoted, deactivated or removed. Caregivers may read and edit collection and care records, including media, but cannot manage accounts or portable backups. Administrator backup export and full restore include collection records and picture galleries, but not accounts or sessions.
The administrator interface is Settings → Server → Manage accounts. PATCH
accepts a nonempty subset of username, role, password and active.
An omitted password retains the existing hash; an empty or too-short password
is rejected. DELETE requires the JSON body {"confirmation":"remove-account"}
and an expectedAuditId matching the account selected for removal, as well as
a valid administrator session, same-origin request and CSRF token. Administrator
account responses expose this non-secret stable identity. The browser also
sends it with PATCH, and the store checks it again inside the transaction so an
old form cannot target a replacement account that reused the same row number.
Edit and delete responses include sessionRevoked to tell the caller whether
its own session was revoked. Self-edit/removal returns the client to sign-in.
Account changes and their audit rows commit in the same transaction. The store
rechecks the acting administrator and last-active-administrator constraint at
commit time, including after asynchronous password hashing. Removing an account
cascades to its sessions, deletes its credential hash and preserves earlier
metadata in shared_audit_events; a new account with a reused name or row ID
receives a new audit identity. Portable collection restore is unaffected.
| Resource | Routes |
|---|---|
| Boxes | GET/POST /boxes, GET/PATCH/DELETE /boxes/{id}, POST /boxes/{id}/duplicate, POST /boxes/{id}/archive, POST /boxes/{id}/restore, GET /boxes/qr/{qrId} |
| Animals | GET/POST /animals, GET/PUT/DELETE /animals/{id}, PUT /animals/{id}/feeding-reminder, POST /animals/{id}/duplicate, POST /animals/{id}/move, POST /animals/{id}/archive, POST /animals/{id}/restore |
| Feeding | POST /feedings with a nonempty animalIds list and optional boxId for Box-scoped Feeding Mode, GET/PUT/DELETE /feedings/{id}, GET /animals/{id}/feedings |
| Feeding reminders | GET /reminders returns configured active Animals with animalId, dueAt and optional latestFeedingAt |
| Weight | GET/POST /animals/{id}/weights, PUT/DELETE /animals/{id}/weights/{entryId} |
| Shedding | GET/POST /animals/{id}/shedding, PUT/DELETE /animals/{id}/shedding/{eventId} |
| Picture galleries | GET/POST /boxes/{id}/pictures and /animals/{id}/pictures; DELETE /.../pictures/{mediaId}; POST /.../pictures/{mediaId}/primary |
| Media | GET /media/{id} returns authenticated image bytes; POST /media is no longer supported |
| Portable backup | Administrator-only GET /admin/backups downloads a .tmbackup; POST /admin/backups/restore replaces the collection after confirmation |
Creation of a Box generates its permanent QR identifier on the server.
The Shared Care browser scanner decodes a printed Box code locally and sends
only its validated identifier to GET /boxes/qr/{qrId} over the same HTTPS
origin. The server returns the current Box or 404; the client refuses to
open archived Boxes from the scanner. Camera frames are not an API payload.
Animal creation and replacement require boxId, commonName,
latinName, tempMin, tempMax, humidityMin, and humidityMax.
Animal PUT is a full replacement of editable fields and also requires
category, showWeightOnDetail, and showSheddingOnDetail. Box
PATCH changes only supplied fields. Clients cannot assign record IDs,
status, QR identifiers or archive timestamps through ordinary edit requests.
Every Box and Animal response includes an opaque revision. A Box PATCH or
Animal PUT must include the expectedRevision from the record the editor
opened. The server compares it inside the write transaction. If another
caregiver changed the record, it returns 409 stale_record and writes nothing;
the client must reload and review the latest record before another attempt.
Revisions cover record data rather than relying on the timestamp precision of
SQLite. New records do not need an expected revision.
POST /animals/{id}/move accepts the destination boxId and an optional
expectedRevision. Shared Care’s QR Rehouse flow always sends the revision
from the opened Animal. The server compares it in the same transaction as the
move, then rechecks that the Animal and destination Box are active and that the
destination differs from the current Box. A stale revision returns
409 stale_record; no assignment changes.
PUT /animals/{id}/feeding-reminder updates only the reminder configuration.
It requires expectedRevision; intervalDays and baseline must either
both be set or both be null. The interval must be a positive integer and the
baseline an ISO-8601 timestamp with an offset. The server rejects archived
Animals and stale revisions. GET /reminders uses the same database-backed
reminder calculation as the standalone app: the latest Feeding supersedes the
baseline, and the next due time is that timestamp plus the interval. The
response contains no browser-specific preferences or notification state.
POST /feedings requires a UUID Idempotency-Key header. Repeating the same
request with the same key during the server process lifetime returns its first
result without adding more events; reusing a key with different data returns
409 idempotency_conflict. The server retains successful keys for 24 hours.
The browser generates one key for each new feeding submission and reuses it
when retrying the unchanged request. If boxId is supplied, the server also
requires that Box to be active and every selected Animal to be active and still
assigned to it inside the write transaction. A changed assignment rejects the
entire group. If a connection fails after submitting, check the server history
before creating a new entry.
The API applies the existing environmental, taxonomy, Box-assignment and lifecycle rules on the server. Grouped feeding and Animal reassignment run inside database transactions. Only archived Boxes and Animals can be permanently deleted. Direct gallery uploads add the image and association in one transaction. Image uploads accept static PNG, JPEG and WebP up to 8 MiB, 8,192 pixels on either side and 20,971,520 pixels in total. The server decodes each image before storage and keeps the original bytes, including orientation metadata. The total stored image data in Shared Care is limited to 512 MiB; gallery copies and restored pictures count toward that total. An existing collection above the limit remains readable and can delete pictures, but cannot add more until it is below the limit. Uploads are authenticated like every collection endpoint.
Gallery uploads should send a UUID in Idempotency-Key. The server stores a
receipt in the collection database in the same transaction as the picture and
its Box or Animal association. Repeating the same key, record and payload
returns the original picture instead of adding another; reusing the key with
different data returns 409 idempotency_conflict. Receipts are retained for
30 days and survive server restarts. Older clients without the header remain
supported, but their uploads cannot be safely retried after an uncertain
response. Replacing the collection from a portable backup clears old receipts;
receipts are not included in portable backups.
Errors have the stable shape
{"error":{"code":"invalid_data","message":"..."}}.
| HTTP status | Code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_data |
Malformed or rejected input |
| 401 | unauthorized, invalid_credentials |
Missing/expired session or invalid login |
| 403 | forbidden, csrf_failed |
Role, origin or CSRF token denied the request |
| 404 | not_found |
Unknown route or record |
| 409 | conflict |
Lifecycle or relationship rule prevents the change |
| 409 | stale_record |
Record changed since the editor opened; reload and review it |
| 409 | idempotency_conflict |
A feeding request key was reused with different data |
| 413 | too_large |
Request exceeds the body limit |
| 413 | media_storage_limit |
Stored Shared Care pictures exceed the collection limit |
| 415 | unsupported_media_type |
Write request is not JSON |
| 429 | rate_limited |
Too many failed login attempts in five minutes |
| 500 | internal_error |
Unexpected server failure; details are not returned |
GET /admin/backups returns the standard Backup Format 2 archive with
application/vnd.terramanager.backup+zip. The browser saves it to an
administrator-chosen location. Its settings.json has scope: collectionOnly
and neutral required settings values; no caregiver’s account preferences are
included. Restoring this archive in a standalone installation leaves that
installation’s personal preferences intact. Existing compatible .tmbackup
archives can initialize an empty shared collection or replace a populated one.
The response also issues a short-lived, session-bound safety token. The admin
interface requires this current collection archive to be saved when collection
data exists. GET /admin/backups/restore-status is administrator-only and
returns {"safetyBackupRequired": true|false} with Cache-Control: no-store.
The browser checks it before selecting an archive; this response is advisory,
not authorization to overwrite later changes. An empty collection can be
initialized without X-Safety-Token. Emptiness means no rows in any collection
table, including archived records, care histories, picture associations and
unassociated media. Accounts and SQLite bookkeeping do not prevent first import.
The server checks emptiness again after acquiring the exclusive restore gate,
which drains existing mutations and prevents new writes through replacement.
If a caregiver added data during selection or upload, a missing safety token
returns 409 safety_backup_required and preserves the collection. Populated
collections still require a valid, unexpired, session-bound safety token with
an unchanged generation. There is no opt-out for populated collections. The restore request sends the selected archive as the binary request
body, the normal CSRF token, the safety token, and
X-Restore-Confirmation: replace-shared-collection. The server validates the
archive again and refuses the restore if the shared collection changed after
the safety download. Export and restore temporarily block new collection
edits; restore uses one database transaction, so a failed replacement leaves
the previous collection intact. The token expires after 30 minutes and is
cleared after a successful restore. Restore requests above 256 MiB compressed
or 512 MiB expanded are refused before media extraction; larger collections
require a separate migration procedure.
During export and restore, the server stages only an encrypted temporary
archive. A fresh random spool secret stays in process memory for that request;
the temporary file is removed afterward. The HTTP export is still a plain ZIP,
and the restore endpoint still accepts a plain ZIP over the authenticated
connection. The server can read collection data while processing either request.
Protect downloaded .tmbackup files: they contain records, notes and pictures
without encryption. The separate account database is not exported or replaced,
so caregiver logins remain in place. The shared Flutter Web client uses this API
for Box, Animal, archive, care, picture, backup and account workflows; its
deployment is described in
Shared Care deployment.
Issue #177 records authenticated collection mutations (Boxes, Animals, Feeding,
weight and shedding history, media, archive/restore and reassignment), portable
restore, and account administration. Each event stores UTC time, a stable actor
identifier, actor name/role snapshots, a fixed action, affected record type/ID,
outcome and HTTP status. Grouped Feedings identify each affected Feeding record;
an idempotent retry is marked replayed, not a second Feeding creation.
Collection audit rows commit in the same transaction as the collection change; account audit rows commit in the same transaction as the account change. If an audit write fails, the mutation is rolled back and receives an error response. This includes backup replacement and prevents cached Feeding successes from surviving rollback. A lost HTTP response still requires checking the current collection before retrying an operation without idempotency protection.
The server stores collection events in collection.sqlite and account events
in accounts.sqlite. Both use shared_audit_events; neither stream is a
portable collection table. Portable export excludes them and portable restore
preserves them. Account target identifiers are stable audit identifiers rather
than reusable account row numbers. Collection record IDs refer to their
collection state at the event time; collection.restore marks replacement.
Audit fields never contain request bodies, notes, picture/file bytes, filenames,
password hashes, passwords, CSRF values, session tokens or idempotency keys.
Audit metadata is pruned after 365 days at startup and on new audit writes. Account deactivation/removal does not cascade into these rows. Unauthorized and CSRF-denied requests are not collection-change audit entries.
Settings > Shared server > Audit opens the read-only event viewer for
administrators. Caregivers do not see this subsection. The server independently
checks the current session and role on every GET /api/v1/admin/audit request;
missing/revoked sessions receive 401 and caregivers receive 403. Responses use
Cache-Control: no-store. Reading the log does not create another audit event.
The endpoint merges the collection and account streams, newest first, with
timestamp, source and event ID as deterministic ordering keys. Each source
loads at most limit + 1 rows. The response is
{"events": [...], "nextCursor": "..." | null}. Events contain only id,
source (collection or account), occurredAt, actorId, actorName,
actorRole, action, recordType, recordId, outcome, and statusCode.
No record payload, note, password, hash, session value or filename is joined
into the response. Deleted accounts retain their original attribution.
Optional query parameters:
| Parameter | Meaning |
|---|---|
limit |
Page size, default 20, between 1 and 100 |
cursor |
Opaque nextCursor from the previous page; keep the same filters |
from |
Inclusive UTC timestamp in Dart ISO format, e.g. 2026-09-27T00:00:00.000Z |
until |
Exclusive UTC timestamp in the same format |
actor |
Literal case-insensitive substring of the actor name snapshot, 1–64 characters |
action |
Operation: create, update, delete, archive, restore, duplicate, move, feeding-reminder, or set_primary |
Unknown/repeated parameters, invalid dates/ranges, actions, page sizes and malformed cursors receive 400. Queries always exclude expired audit metadata. The UI accepts inclusive local calendar dates and converts their boundaries to UTC, including the day after the end date. Times are displayed locally. Actor matching uses names at event time, so historical events keep their old names after a rename; stable actor identifiers remain in the API metadata.
Seek pagination avoids offset shifts when newer events arrive. Previous reuses the stored page cursor; Refresh and applying/resetting filters start at the newest page. Events may age out under the existing retention policy. The empty state distinguishes no retained events from no filter matches. Initial administrator creation is already an account event and can therefore appear on a newly bootstrapped server. Loading, retry and denied-access states hide stale results; a rejected administrator request hides the viewer.