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
+24 -24
View File
@@ -113,10 +113,10 @@ func (s *Server) routes() {
// Authentication.
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
m.HandleFunc("POST /api/v1/auth/logout", s.authed(store.PermNone, s.handleLogout))
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(store.PermNone, s.handleWhoami))
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(store.PermNone, s.handleTOTPEnroll))
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(store.PermNone, s.handleTOTPConfirm))
m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout))
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami))
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll))
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll)
@@ -129,25 +129,25 @@ func (s *Server) routes() {
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
// Synchronization.
m.HandleFunc("GET /api/v1/databases", s.authed(store.PermNone, s.handleListDatabases))
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handleGetBlob))
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handlePutBlob))
m.HandleFunc("GET /api/v1/events", s.authed(store.PermNone, s.handleEvents))
m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases))
m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(s.handleGetBlob))
m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(s.handlePutBlob))
m.HandleFunc("GET /api/v1/events", s.authed(s.handleEvents))
// Administration.
m.HandleFunc("POST /api/v1/databases", s.authed(store.PermNone, s.handleCreateDatabase))
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(store.PermNone, s.handlePatchDatabase))
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(store.PermNone, s.handleDeleteDatabase))
m.HandleFunc("GET /api/v1/groups", s.authed(store.PermNone, s.handleListGroups))
m.HandleFunc("POST /api/v1/groups", s.authed(store.PermNone, s.handleCreateGroup))
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(store.PermNone, s.handleDeleteGroup))
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(store.PermNone, s.handleSetMember))
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(store.PermNone, s.handleRemoveMember))
m.HandleFunc("POST /api/v1/grants", s.authed(store.PermNone, s.handleSetGrant))
m.HandleFunc("DELETE /api/v1/grants", s.authed(store.PermNone, s.handleDeleteGrant))
m.HandleFunc("POST /api/v1/invites", s.authed(store.PermNone, s.handleCreateInvite))
m.HandleFunc("GET /api/v1/invites", s.authed(store.PermNone, s.handleListInvites))
m.HandleFunc("GET /api/v1/users", s.authed(store.PermNone, s.handleListUsers))
m.HandleFunc("POST /api/v1/databases", s.authed(s.handleCreateDatabase))
m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(s.handlePatchDatabase))
m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(s.handleDeleteDatabase))
m.HandleFunc("GET /api/v1/groups", s.authed(s.handleListGroups))
m.HandleFunc("POST /api/v1/groups", s.authed(s.handleCreateGroup))
m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(s.handleDeleteGroup))
m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(s.handleSetMember))
m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(s.handleRemoveMember))
m.HandleFunc("POST /api/v1/grants", s.authed(s.handleSetGrant))
m.HandleFunc("DELETE /api/v1/grants", s.authed(s.handleDeleteGrant))
m.HandleFunc("POST /api/v1/invites", s.authed(s.handleCreateInvite))
m.HandleFunc("GET /api/v1/invites", s.authed(s.handleListInvites))
m.HandleFunc("GET /api/v1/users", s.authed(s.handleListUsers))
}
// ---- request plumbing ----
@@ -185,9 +185,9 @@ func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
type handler func(http.ResponseWriter, *http.Request, *store.User)
// authed resolves the bearer token to a live, enabled user before calling fn.
// Per-object permissions are checked inside each handler, where the object is
// known; min is accepted for documentation of the minimum account level.
func (s *Server) authed(_ store.Perm, fn handler) http.HandlerFunc {
// Per-object permission is then checked inside the handler, where the object
// being acted on is known.
func (s *Server) authed(fn handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, err := s.userFromRequest(r)
if err != nil {