Add Android client, packaging, Makefile entrypoint and handover docs

Why: the set has to be installable and publishable, not just buildable, and
the next person needs the reasoning, not only the code.

What:
- android/: Kotlin client with no third-party dependencies. A
  DocumentsProvider publishes the mirrored databases to every file picker, so
  KeePassDX opens them as ordinary documents; closing a file it wrote
  triggers a sync. A foreground service holds the event stream open.
  Mirror.decide transcribes the Go merge table, so both platforms resolve a
  conflict identically.
- Makefile as the single entrypoint, with file targets so only what changed
  is rebuilt: host and cross builds, both .deb packages, the macOS DMG with
  its /Applications symlink, the Windows zip, the APK under its F-Droid
  publishing name, and manual-only android-publish.
- versions.mk holds one version per platform plus the Android versionCode;
  `make bump` and `make changelog` move them. Debian changelog is tracked.
- systemd units (hardened system unit for the server, user unit for the
  agent), macOS bundle and launch agent, Windows Task Scheduler job.
- docs/HANDOVER.md on architecture and every decision that shaped it,
  docs/PROTOCOL.md for the API, README for the quick start.

Proved: make check and make test-race both pass; built both .deb packages and
inspected their contents, built the Windows zip, cross-compiled all five
desktop targets, and exercised make bump. The Android module is written but
not compiled: no JDK or Android SDK in this environment, which HANDOVER.md
states plainly.
This commit is contained in:
2026-10-29 16:26:33 +01:00
parent 4d0d53ff04
commit 0e83acb9b1
40 changed files with 2563 additions and 24 deletions
+91
View File
@@ -0,0 +1,91 @@
# KeepOrg HTTP API
Every `/api/` endpoint takes and returns JSON and needs
`Authorization: Bearer <token>`, except the login, device and invite
endpoints. Errors carry `{"code": "...", "error": "..."}`; `code` is stable
and meant to be matched on, `error` is for humans.
Three endpoints serve HTML, because a browser is the right place for them:
`/device`, `/invite/{token}` and `/oidc/callback`.
## Authentication
| Method | Path | Body | Answer |
| --- | --- | --- | --- |
| POST | `/api/v1/auth/login` | `{username, password, totp, label}` | `{token, expires_at, user}`; `401 totp_required` when a second factor is enrolled and missing, `401 totp_invalid`, `401 bad_credentials` |
| POST | `/api/v1/auth/logout` | – | revokes the calling token |
| GET | `/api/v1/auth/whoami` | – | the account and its group memberships |
| POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed |
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
| POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` |
| POST | `/api/v1/auth/device/poll` | `{device_code}` | `202 {status: "pending"}`, then `200 {token, ...}` once; `410 expired` |
A device code is single use: the first successful poll consumes it.
## Synchronization
| Method | Path | Notes |
| --- | --- | --- |
| GET | `/api/v1/databases` | `{databases: [{id, name, perm, merge_strategy, version, sha256, size, updated_at, updated_by}]}` |
| GET | `/api/v1/databases/{id}/blob` | bytes; `ETag: "<version>"`, `X-KeepOrg-SHA256`; `204` when the database has no content yet; `?version=<n>` reads a stored older version |
| PUT | `/api/v1/databases/{id}/blob` | body is the KDBX file. `If-Match` is required |
| GET | `/api/v1/events` | SSE. Accepts `?access_token=` because an event stream cannot always set headers |
### PUT semantics
`If-Match` carries the version the edit was based on, or `*` to force.
| Condition | Status | Body |
| --- | --- | --- |
| `If-Match` missing | `428 precondition_required` | – |
| version matches, bytes differ | `200` | `{version, sha256, unchanged: false}` |
| version matches, bytes identical | `200` | `{version, sha256, unchanged: true}` |
| version stale | `409 conflict` | `{current_version, merge_strategy}`, plus `ETag` and `X-KeepOrg-Strategy` |
| `If-Match: *`, strategy is `client-wins` | `200` | the server copy is replaced |
| `If-Match: *`, any other strategy | `409 force_not_allowed` | – |
| read-only access | `403 forbidden` | – |
| no access | `404 not_found` | a database you cannot read does not exist to you |
| larger than `max_db_size_mb` | `413 too_large` | – |
`X-KeepOrg-SHA256` on a `PUT` is optional; when present it must match the
body or the request is rejected with `400 checksum_mismatch`.
### Events
```
event: db-updated
data: {"type":"db-updated","database_id":"...","name":"Team","version":7,"sha256":"...","by":"alice"}
event: db-list-changed
data: {"type":"db-list-changed","database_id":"..."}
```
`db-updated` follows an accepted push; `db-list-changed` follows a grant,
rename, strategy change or deletion. A comment line (`: keepalive`) arrives
every 25 seconds. Reconnect on any error and reconcile fully; events are
notifications, never state.
## Administration
Permission needed is in the last column. "Group admin" means administering
the group named or any group above it.
| Method | Path | Body / query | Needs |
| --- | --- | --- | --- |
| GET | `/api/v1/groups` | – | any account; lists what you can see |
| POST | `/api/v1/groups` | `{name, parent_id}` | group admin of the parent; server admin for a root group |
| DELETE | `/api/v1/groups/{id}` | – | group admin; refuses while it has children, members or databases |
| POST | `/api/v1/groups/{id}/members` | `{username or user_id, role}` | group admin |
| DELETE | `/api/v1/groups/{id}/members/{user}` | – | group admin |
| POST | `/api/v1/databases` | `{name, owner_group_id, merge_strategy}` | group admin of the owner |
| PATCH | `/api/v1/databases/{id}` | `{name?, merge_strategy?}` | database admin |
| DELETE | `/api/v1/databases/{id}` | – | database admin; deletes every stored version |
| POST | `/api/v1/grants` | `{group_id, database_id, perm}` | database admin |
| DELETE | `/api/v1/grants` | `?group_id=&database_id=` | database admin |
| POST | `/api/v1/invites` | `{group_id, role, ttl_hours, max_uses}` | group admin |
| GET | `/api/v1/invites` | – | your own invites, and those of groups you administer |
| GET | `/api/v1/users` | – | yourself, plus members of groups you administer |
| GET | `/healthz` | – | unauthenticated liveness |
`merge_strategy` is one of `server-wins`, `client-wins`, `manual`. `perm` is
one of `read`, `write`, `admin`. `role` is `member` or `admin`.