SPEC v0.23: D-124 Kvitt on port 443 through NPM; pinned-root template
D-124 (owner decision 2026-10-19;a86ad24,4852803,038c603): Kvitt is served at https://<host> with no explicit port; the router's existing external 443 forward to the Home Assistant Pi serves it, no 8443 rule. Every :8443 in SPEC replaced; D-120 to D-123 struck through with pointers. The pinned-root config is generated from its template with kvitt.publicHost (038c603). HANDOFF, prompts/README.md status and the S-1 TalkBack line follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Kvitt: specification
|
||||
|
||||
Version **0.22** (2026-10-19). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
|
||||
Version **0.23** (2026-10-19). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
|
||||
|
||||
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
|
||||
|
||||
@@ -24,7 +24,7 @@ Kvitt is a shared-expense app for one household of exactly two people living in
|
||||
It consists of:
|
||||
|
||||
* A native Android app (Kotlin, Jetpack Compose) on two Samsung phones (Galaxy S26 and Galaxy S26+, Android 16). The app keeps a full copy of the household's data in a local Room database and works fully offline for expenses and settlements.
|
||||
* A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at `https://<KVITT_PUBLIC_HOST>:8443`, where `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), through the owner's Nginx Proxy Manager on the Home Assistant Pi (D-120). The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates.
|
||||
* A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at `https://<KVITT_PUBLIC_HOST>` (the default HTTPS port 443, D-124), where `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), through the owner's Nginx Proxy Manager on the Home Assistant Pi (D-120). The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates.
|
||||
|
||||
Goals for v1:
|
||||
|
||||
@@ -106,7 +106,7 @@ Each requirement has acceptance criteria (AC). An AC is satisfied only when a te
|
||||
|
||||
The server starts with an empty database. The household is created from the command line on the Pi.
|
||||
|
||||
* AC-1.1 `kvitt-server bootstrap` on a database with no users creates an invite of kind `admin_setup`, prints its code (format §8.3), its landing link `https://<KVITT_PUBLIC_HOST>:8443/i/<CODE>` (the base URL from `KVITT_PUBLIC_URL`), its expiry, and exits 0.
|
||||
* AC-1.1 `kvitt-server bootstrap` on a database with no users creates an invite of kind `admin_setup`, prints its code (format §8.3), its landing link `https://<KVITT_PUBLIC_HOST>/i/<CODE>` (the base URL from `KVITT_PUBLIC_URL`), its expiry, and exits 0.
|
||||
* AC-1.2 `kvitt-server bootstrap` on a database that has users prints `already initialized` to stderr and exits 2. It creates nothing.
|
||||
* AC-1.3 Running `kvitt-server bootstrap` again before the first code is used cancels the previous `admin_setup` invite and prints a new one.
|
||||
* AC-1.4 Accepting the `admin_setup` invite (FR-2) creates the `admin` user with the given display name and the first device, and returns a device token.
|
||||
@@ -120,7 +120,7 @@ The server starts with an empty database. The household is created from the comm
|
||||
* AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with `invite_invalid`; the response does not reveal which.
|
||||
* AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the kind of the accepted invite (`invite_kind`, D-086), the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050).
|
||||
* AC-2.5 `GET /i/<CODE>` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>` (followed by `&from=<urlencoded name>` when the request carries a valid `from` query parameter, D-087), the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data of its own: the `from` name is only passed on into the `kvitt://invite` link and never shown on the page; an invalid `from` (not UTF-8 after percent-decoding, a control character, or more than 40 code points after trimming) is dropped and the page still opens (D-087). While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404".
|
||||
* AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https://<kvitt.publicHost>:8443/i/<CODE>[?from=…]` (the manifest placeholder `publicHost`, D-121; unverified link, the user may get a chooser) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). A link may name any server: as built, S-1 opens its server row expanded when the link's server is not the release default (`RELEASE_SERVER_URL` = `https://<kvitt.publicHost>:8443`, D-121), so the address is visible and editable before pairing; v1 adds no confirmation step for an unusual server (security review L-6, D-116; a confirm step would need a design state, OQ-6).
|
||||
* AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https://<kvitt.publicHost>/i/<CODE>[?from=…]` (the manifest placeholder `publicHost`, D-121; unverified link, the user may get a chooser; the intent filter names no port, so it matches every port of the host and an older `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port, `:443` for `https` and `:80` for `http`, D-124) directly into the pairing screen with both fields filled. When the link names the inviting admin (`from`, E-12, D-087), S-1's help line names them (`onboarding_code_help`); links without a name (older links) still open, with the generic help line. The `https` link is declared with `android:autoVerify="false"`. An invite link opened while the phone is paired is ignored: the app stays where it is and never re-pairs over live data (D-084). A link may name any server: as built, S-1 opens its server row expanded when the link's server is not the release default (`RELEASE_SERVER_URL` = `https://<kvitt.publicHost>`, D-121, D-124), so the address is visible and editable before pairing; v1 adds no confirmation step for an unusual server (security review L-6, D-116; a confirm step would need a design state, OQ-6).
|
||||
* AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `RELEASE_SERVER_URL` in release builds, D-121) and the code. Codes are normalised per §8.3 before sending.
|
||||
* AC-2.8 Release builds accept only `https://` server URLs. Debug builds also accept `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`-`172.31.*`, `192.168.*`). The limit is enforced in code by `SessionRepositoryImpl.normaliseServerUrl`, not by the network security config, which cannot express IP ranges (§8.5, D-082). A release build therefore pairs with any `https://` server a link or the user names; what S-1 shows then is in AC-2.6 (D-116).
|
||||
* AC-2.9 After pairing, the app runs a full sync (§14.4) and then asks for the notification permission (Android `POST_NOTIFICATIONS`). As built (M8), the request is made after the first successful sync after pairing.
|
||||
@@ -381,9 +381,9 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2).
|
||||
│ domain (pure) │ │ │
|
||||
│ Room (full copy) │ │ │
|
||||
└───────┬──────────┘ └────────┬─────────┘
|
||||
│ HTTPS :8443 (bearer token) │
|
||||
│ HTTPS :443 (bearer token) │
|
||||
▼ ▼
|
||||
router: external 8443 ──► Home Assistant Pi :443 (external 80 too, for Let's Encrypt)
|
||||
router: external 443 ──► Home Assistant Pi :443 (already in place for Home Assistant, D-124; external 80 too, for Let's Encrypt)
|
||||
┌──────────────── Home Assistant Pi ────────────────┐
|
||||
│ Nginx Proxy Manager (TLS, Let's Encrypt, D-120) │
|
||||
└─────────────────────────┬─────────────────────────┘
|
||||
@@ -502,7 +502,7 @@ Cargo workspace (directory `server/`):
|
||||
|
||||
## 7. API outline
|
||||
|
||||
Base URL: `https://<KVITT_PUBLIC_HOST>:8443` (the value of `KVITT_PUBLIC_URL`; `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), D-120). All paths below are relative to it. There is no path prefix and no API version in the path; the protocol version travels in a header (§14.3.1).
|
||||
Base URL: `https://<KVITT_PUBLIC_HOST>` (the value of `KVITT_PUBLIC_URL`, port 443, D-124; `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), D-120). All paths below are relative to it. There is no path prefix and no API version in the path; the protocol version travels in a header (§14.3.1).
|
||||
|
||||
| # | Method | Path | Auth | Purpose |
|
||||
|---|---|---|---|---|
|
||||
@@ -550,7 +550,7 @@ Request and response shapes, error codes and status codes: §14.3. The OpenAPI 3
|
||||
|
||||
### 8.1 Threat model
|
||||
|
||||
Two trusted users. The server is on the public internet on port 8443. Threats in scope: random internet scanners, a stolen or lost phone, leaked invite links, a compromised backup location, accidental personal data in logs or push payloads. Out of scope: a malicious partner, an attacker with physical access to the Pi. Accepted risk (owner, 2026-10-19, security review M-1): the Drive backups stay unencrypted and the rclone token keeps the full `drive` scope, so a compromised Google account or `rclone.conf` exposes every backup (§8.7, §10.9).
|
||||
Two trusted users. The server is on the public internet on port 443, through NPM (D-120, D-124). Threats in scope: random internet scanners, a stolen or lost phone, leaked invite links, a compromised backup location, accidental personal data in logs or push payloads. Out of scope: a malicious partner, an attacker with physical access to the Pi. Accepted risk (owner, 2026-10-19, security review M-1): the Drive backups stay unencrypted and the rclone token keeps the full `drive` scope, so a compromised Google account or `rclone.conf` exposes every backup (§8.7, §10.9).
|
||||
|
||||
### 8.2 Device tokens
|
||||
|
||||
@@ -583,8 +583,8 @@ Two trusted users. The server is on the public internet on port 8443. Threats in
|
||||
|
||||
### 8.5 Transport
|
||||
|
||||
* TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi, with NPM's Let's Encrypt certificate for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF); the router forwards external TCP 8443 to NPM's 443 (§10.1, §10.5, D-120). TLS 1.2 minimum, NPM defaults otherwise. NPM forwards plain HTTP over the home LAN to the server's port 8080 on the Kvitt Pi (accepted by the owner, D-120).
|
||||
* Release builds' network security config (`android/app/src/main/res/xml/network_security_config.xml`): cleartext disabled, system trust anchors only, unless the pinned-root switch is on, in which case a pinned root replaces the system anchors for one domain only (available but unprepared since D-120, §10.5.3). That config's domain is still the literal `icehack.asuscomm.com`; if the fallback is ever prepared it must follow `kvitt.publicHost` (D-121). The pinned-root config is in `android/app/src/releasePinnedRoot/res`, added to the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082).
|
||||
* TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi, with NPM's Let's Encrypt certificate for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), on the default HTTPS port 443 (D-124); the router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant; nothing is forwarded to the Kvitt Pi (§10.1, §10.5, D-120, D-124). TLS 1.2 minimum, NPM defaults otherwise. NPM forwards plain HTTP over the home LAN to the server's port 8080 on the Kvitt Pi (accepted by the owner, D-120).
|
||||
* Release builds' network security config (`android/app/src/main/res/xml/network_security_config.xml`): cleartext disabled, system trust anchors only, unless the pinned-root switch is on, in which case a pinned root replaces the system anchors for one domain only (available but unprepared since D-120, §10.5.3). The pinned-root config is generated at build time (task `generatePinnedRootNetworkSecurityConfig`) from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost` (D-121), into the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082); the build stops with a message while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (`038c603`).
|
||||
* Debug builds override the config in `android/app/src/debug` and allow cleartext to every host, because a network security config cannot express IP ranges; the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended (D-082).
|
||||
* The server trusts `X-Forwarded-For` only from NPM: `KVITT_TRUSTED_PROXY` is `HA_HOST`, NPM's LAN IPv4 address (checked by `install.sh`), and from that peer the server takes the last `X-Forwarded-For` entry as the client IP for rate limiting (D-120; the server is unchanged). A request from any other peer counts against the peer's own address.
|
||||
|
||||
@@ -878,9 +878,9 @@ All infrastructure files live in `infra/` (ownership §14.7). Secrets live in `i
|
||||
| Host | Role | Facts |
|
||||
|---|---|---|
|
||||
| Kvitt Pi | Runs the compose stack; publishes the server's port 8080 on the LAN for NPM (D-120) | Raspberry Pi 3, 1 GB RAM, 64-bit Raspberry Pi OS Lite (Debian bookworm or later), Docker Engine with the compose plugin (from Docker's Debian arm64 apt repository, D-104), static LAN address. LAN address: a placeholder in the bundle's `.env` (`KVITT_PI_HOST`), filled by the owner (OQ-3, D-100); also NPM's Forward Hostname / IP; the Mac does not reach it over SSH |
|
||||
| Home Assistant Pi | Runs the owner's Nginx Proxy Manager (NPM), which owns ports 80/443: terminates TLS for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) with NPM's own Let's Encrypt certificate (HTTP-01 on port 80) and forwards plain HTTP to `<KVITT_PI_HOST>:8080` (§10.5, D-120) | Its LAN IPv4 address is `HA_HOST` in the bundle's `.env`, filled by the owner (OQ-3), and is the server's `KVITT_TRUSTED_PROXY`. NPM is also the proxy host for Home Assistant under `icehack.asuscomm.com` and selects the proxy host by name, so Kvitt has its own name. The owner sets up the proxy host in NPM's web UI (§10.5.1); nothing of Kvitt runs there and nothing connects to it over SSH |
|
||||
| Home Assistant Pi | Runs the owner's Nginx Proxy Manager (NPM), which owns ports 80/443: terminates TLS for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) with NPM's own Let's Encrypt certificate (HTTP-01 on port 80) and forwards plain HTTP to `<KVITT_PI_HOST>:8080` (§10.5, D-120) | Its LAN IPv4 address is `HA_HOST` in the bundle's `.env`, filled by the owner (OQ-3), and is the server's `KVITT_TRUSTED_PROXY`. NPM is also the proxy host for Home Assistant under `icehack.asuscomm.com`, on the same port 443, and selects the proxy host by name, so Kvitt has its own name (D-124). The owner sets up the proxy host in NPM's web UI (§10.5.1); nothing of Kvitt runs there and nothing connects to it over SSH |
|
||||
| Owner's M4 Mac | Builds release APKs (`infra/publish-apk.sh`); builds images only as the fallback of the Gitea workflow (`infra/build-images.sh`, D-104); runs the development server (D-001) | Apple Silicon, so `linux/arm64` builds run natively in Docker |
|
||||
| Router (ASUS) | `icehack.asuscomm.com` DDNS (Home Assistant's name); the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen; currently `kvitt.suorra.duckdns.org`, HANDOFF) points at the same public address (a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`) | Forwards external TCP 8443 to the Home Assistant Pi's TCP 443 (NPM); external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (HUMAN step, D-120) |
|
||||
| Router (ASUS) | `icehack.asuscomm.com` DDNS (Home Assistant's name); the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen; currently `kvitt.suorra.duckdns.org`, HANDOFF) points at the same public address (a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`) | External TCP 443 → Home Assistant Pi 443 (NPM), already in place for Home Assistant, serves Kvitt too: no new rule (D-124; the owner confirmed the 443 forward, 2026-10-19); external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-120) |
|
||||
| Container registry | Holds Kvitt images | A Gitea instance on another Pi at `http://gitea.lan`: plain HTTP, reachable only on the home Wi-Fi (D-001). Kvitt images live under the owner path `gitea.lan/jacobh2` (D-104); Gitea Actions builds them (§10.3). If Gitea requires credentials for push or pull, the owner enters them where the bundle's checklist says (HUMAN, D-100); agents never hold them |
|
||||
|
||||
The Kvitt Pi's compose stack never builds anything. It only pulls images and runs them.
|
||||
@@ -891,7 +891,7 @@ File `infra/pi/compose.yaml` (compose project name `kvitt`), installed by `infra
|
||||
|
||||
| Service | Image | Ports / volumes | Memory limit | Healthcheck |
|
||||
|---|---|---|---|---|
|
||||
| `server` | `${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}` | `data:/data`; `/opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro`; `/opt/kvitt/import:/import:ro` (empty except during the one-off import, §10.8, D-025); publishes `8080:8080` on all of the Kvitt Pi's interfaces (NPM connects over the LAN, D-120); environment `KVITT_DATA_DIR=/data`, `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`, `KVITT_TRUSTED_PROXY=${HA_HOST}` | 160 MiB (budget: RSS under 100 MB in steady state) | `GET http://localhost:8080/health` via the binary's own `kvitt-server healthcheck` subcommand (no curl in the image) |
|
||||
| `server` | `${KVITT_REGISTRY}/kvitt-server:${KVITT_TAG}` | `data:/data`; `/opt/kvitt/secrets/fcm-service-account.json:/secrets/fcm-service-account.json:ro`; `/opt/kvitt/import:/import:ro` (empty except during the one-off import, §10.8, D-025); publishes `8080:8080` on all of the Kvitt Pi's interfaces (NPM connects over the LAN, D-120); environment `KVITT_DATA_DIR=/data`, `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` (port 443, D-124), `KVITT_TRUSTED_PROXY=${HA_HOST}` | 160 MiB (budget: RSS under 100 MB in steady state) | `GET http://localhost:8080/health` via the binary's own `kvitt-server healthcheck` subcommand (no curl in the image) |
|
||||
| `backup` | `${KVITT_REGISTRY}/kvitt-backup:${KVITT_TAG}` | `data:/data`; `backups:/backups`; `/opt/kvitt/secrets/rclone.conf:/config/rclone/rclone.conf:ro` | 96 MiB | last successful run marker newer than 26 hours, or no run yet since start |
|
||||
|
||||
* Named volumes: `data` (SQLite, pictures, APKs), `backups` (`certs` and `caddy_data` removed by D-120).
|
||||
@@ -925,7 +925,7 @@ Environment variables (exhaustive):
|
||||
|---|---|---|
|
||||
| `KVITT_DATA_DIR` | `/data` | Holds `kvitt.sqlite`, `pictures/`, `apk/` |
|
||||
| `KVITT_BIND` | `0.0.0.0:8080` | Listen address |
|
||||
| `KVITT_PUBLIC_URL` | none: required (D-123) | Used in invite links and the landing page. Unset or empty, every `kvitt-server` command exits 1 with a configuration error (`configuration: KVITT_PUBLIC_URL is not set …` on stderr) before opening the database; there is no built-in host (the fallback `DEFAULT_PUBLIC_URL` was removed by D-123). The Pi's compose sets `https://${KVITT_PUBLIC_HOST}:8443` from the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) (D-120); the development stack sets `http://<Mac LAN address>:<port>` (`infra/dev/up.sh`; the dev compose falls back to `http://localhost:8080`) |
|
||||
| `KVITT_PUBLIC_URL` | none: required (D-123) | Used in invite links and the landing page. Unset or empty, every `kvitt-server` command exits 1 with a configuration error (`configuration: KVITT_PUBLIC_URL is not set …` on stderr) before opening the database; there is no built-in host (the fallback `DEFAULT_PUBLIC_URL` was removed by D-123). The Pi's compose sets `https://${KVITT_PUBLIC_HOST}` (port 443, D-124) from the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF) (D-120); the development stack sets `http://<Mac LAN address>:<port>` (`infra/dev/up.sh`; the dev compose falls back to `http://localhost:8080`) |
|
||||
| `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | Service-account key; if the file is missing, push is disabled and a warning is logged at start |
|
||||
| `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project |
|
||||
| `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name whose resolved address may set `X-Forwarded-For` (not a CIDR). The default `none` (`NO_TRUSTED_PROXY` in `config.rs`, compared ignoring case and surrounding white space) means no proxy and is never looked up (D-123; was `caddy`); an empty value counts as unset. Compose sets it to `HA_HOST`, NPM's LAN IPv4 address (D-120). The server must start when the name does not resolve (as on the development stack, which has no proxy, D-022 unchanged); with `none` or an unresolvable name no peer is trusted and the peer address is the client IP (D-022). The name is re-resolved at most every 60 seconds (D-032) |
|
||||
@@ -955,7 +955,7 @@ Run them on the Pi with `docker compose exec server kvitt-server <command>` (or
|
||||
|
||||
### 10.5 TLS
|
||||
|
||||
TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi (D-120; Caddy and cert-sync, which held this section until v0.20, are removed). Kvitt has its own public host name, `KVITT_PUBLIC_HOST` (owner-chosen, recorded in `docs/HANDOFF.md`), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. `KVITT_PUBLIC_URL` is `https://${KVITT_PUBLIC_HOST}:8443`. Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 8443 to the Home Assistant Pi's 443 (NPM); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 6; no agent and no script touches NPM.
|
||||
TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi (D-120; Caddy and cert-sync, which held this section until v0.20, are removed). Kvitt has its own public host name, `KVITT_PUBLIC_HOST` (owner-chosen, recorded in `docs/HANDOFF.md`), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. `KVITT_PUBLIC_URL` is `https://${KVITT_PUBLIC_HOST}`, with no explicit port: Kvitt is served on the default HTTPS port 443, on which NPM listens for every proxy host and selects by name (D-124). Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant, which serves Kvitt too: no new router rule (D-124; the 8443 rule of D-120 is not used); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 6; no agent and no script touches NPM.
|
||||
|
||||
#### 10.5.1 Nginx Proxy Manager
|
||||
|
||||
@@ -1038,10 +1038,10 @@ location ^~ /i/ {
|
||||
|
||||
#### 10.5.3 Fallback: NPM down
|
||||
|
||||
* NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over `https://<KVITT_PUBLIC_HOST>:8443` (that path goes through NPM too). The server on the Kvitt Pi keeps running and nothing there needs to change; the phones keep working on their full local copy and sync when NPM is back (FR-15).
|
||||
* NPM or the Home Assistant Pi down means Kvitt is unreachable from outside, and also from home over `https://<KVITT_PUBLIC_HOST>` (that path goes through NPM too). The server on the Kvitt Pi keeps running and nothing there needs to change; the phones keep working on their full local copy and sync when NPM is back (FR-15).
|
||||
* On the home Wi-Fi only debug builds can reach the server directly, at `http://<KVITT_PI_HOST>:8080` (AC-2.8 allows `http://` to private LAN addresses in debug builds only). Release builds require HTTPS with a publicly trusted certificate (§8.5): there is no LAN fallback for the release app. A debug build is a separate app (`se.hagstedt.kvitt.debug`) with its own pairing and data, not a stand-in for the release app.
|
||||
* If NPM cannot renew the Let's Encrypt certificate, it is fixed in NPM (SSL Certificates, the certificate, Renew Now; port 80 must reach NPM).
|
||||
* The pinned-root switch of D-082 (`kvitt.tls.pinnedRoot=true` in `android/gradle.properties`, which adds `android/app/src/releasePinnedRoot/res`, a `domain-config` trusting only `@raw/kvitt_root_ca` whose domain is still the literal `icehack.asuscomm.com` and must follow `kvitt.publicHost` if this fallback is prepared, D-121) remains available for a self-made root whose certificate would be uploaded to NPM as a Custom certificate. It is not prepared: no such root exists and none is committed.
|
||||
* The pinned-root switch of D-082 (`kvitt.tls.pinnedRoot=true` in `android/gradle.properties`; the pinned-root config is generated at build time from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost`, into the release resources only when `kvitt.tls.pinnedRoot=true`, a `domain-config` trusting only `@raw/kvitt_root_ca`; the build stops with a message while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed, D-121, `038c603`) remains available for a self-made root whose certificate would be uploaded to NPM as a Custom certificate. It is not prepared: no such root exists and none is committed.
|
||||
|
||||
### 10.6 APK hosting and release
|
||||
|
||||
@@ -1054,7 +1054,7 @@ location ^~ /i/ {
|
||||
|
||||
The file is git-ignored by the `*.jks` pattern and never committed (D-101, revises D-024's "outside the repository"). `android/local.properties` holds its absolute path and secrets in the keys `kvitt.release.storeFile`, `kvitt.release.storePassword`, `kvitt.release.keyAlias` (`kvitt`), `kvitt.release.keyPassword`. Losing it means the phones must uninstall and reinstall (data comes back by sync). The owner keeps a copy of the file and its password in their password manager. Without these keys in `local.properties`, `:app:assembleRelease` builds an unsigned APK (M8). Release signing was verified on 2026-10-18: alias `kvitt`, RSA 4096, APK signature scheme v2.
|
||||
* Release builds are R8-shrunk, optimised and obfuscated (`isMinifyEnabled`, `isShrinkResources`, `proguard-android-optimize.txt` plus `android/app/proguard-rules.pro`, no project keep rules needed as of 2026-10-18; landed `c30a49e`); the signed APK is about 10 MB (10.0 MB at `a0259ab`; 43 MB before R8); `infra/publish-apk.sh` saves `mapping.txt` next to the APK as `kvitt-<version_code>.mapping.txt` for retrace; mappings are never served and stay outside the `data` volume (D-103).
|
||||
* `infra/publish-apk.sh` on the Mac (D-103): refuses a dirty working tree, reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` (read, not sourced; it refuses a missing file or key, a `CHANGE_ME` value, anything that is not a host name, a URL included, and the old name `icehack.asuscomm.com`, the checks of `install.sh`; landed `4b1ae0c`, D-122), builds `:app:assembleRelease` through `tools/gradle-serial.sh` with `-Pkvitt.versionCode`, `-Pkvitt.versionName` and `-Pkvitt.publicHost=<KVITT_PUBLIC_HOST>`, prints the release server `https://<KVITT_PUBLIC_HOST>:8443` in its header and summary, refuses an unsigned APK, computes SHA-256 and size, and writes into `infra/pi/out/apk/` (git-ignored) `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` and `latest.json` (written to a temp file, then renamed), keeping the newest 5 APKs with their mappings there. The owner copies the folder to `/opt/kvitt/apk/` on the Pi (`scp`) and runs `/opt/kvitt/install-apk.sh`, which checks each SHA-256 against `latest.json`, copies the APKs into the `data` volume as `apk/kvitt-<version_code>.apk` and writes `apk/latest.json` last (temp file, rename), so the server never announces a file it does not have; the mapping files stay in `/opt/kvitt/apk/`. `latest.json`:
|
||||
* `infra/publish-apk.sh` on the Mac (D-103): refuses a dirty working tree, reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` (read, not sourced; it refuses a missing file or key, a `CHANGE_ME` value, anything that is not a host name, a URL included, and the old name `icehack.asuscomm.com`, the checks of `install.sh`; landed `4b1ae0c`, D-122), builds `:app:assembleRelease` through `tools/gradle-serial.sh` with `-Pkvitt.versionCode`, `-Pkvitt.versionName` and `-Pkvitt.publicHost=<KVITT_PUBLIC_HOST>`, prints the release server `https://<KVITT_PUBLIC_HOST>` (D-124) in its header and summary, refuses an unsigned APK, computes SHA-256 and size, and writes into `infra/pi/out/apk/` (git-ignored) `kvitt-<version_code>.apk`, `kvitt-<version_code>.mapping.txt` and `latest.json` (written to a temp file, then renamed), keeping the newest 5 APKs with their mappings there. The owner copies the folder to `/opt/kvitt/apk/` on the Pi (`scp`) and runs `/opt/kvitt/install-apk.sh`, which checks each SHA-256 against `latest.json`, copies the APKs into the `data` volume as `apk/kvitt-<version_code>.apk` and writes `apk/latest.json` last (temp file, rename), so the server never announces a file it does not have; the mapping files stay in `/opt/kvitt/apk/`. `latest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1067,9 +1067,9 @@ location ^~ /i/ {
|
||||
}
|
||||
```
|
||||
|
||||
* The release server's host name is a build property (D-121): `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (the owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, port 8443, path prefix `/i/`, `android:autoVerify="false"`) and as `RELEASE_SERVER_URL` = `https://<host>:8443` (`link/ReleaseServer.kt`); `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` (§13.3 S-1), and `:data` through `AppBuildInfo.releaseServerUrl`, which S-22 shows before pairing (D-122). Invite-link parsing still accepts a server named by the link (AC-2.6). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` in `infra/pi/.env` (above, §10.5, D-122); the owner may instead set the key in `android/local.properties`.
|
||||
* The release server's host name is a build property (D-121): `kvitt.publicHost` is read from the Gradle property (`-Pkvitt.publicHost=…`), else the key `kvitt.publicHost` in `android/local.properties` (the owner's, git-ignored), else the placeholder `kvitt.example.invalid` (RFC 2606; it never resolves, so a release built without the property cannot reach a real host). The build refuses a value that is not a host name. `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the AC-2.6 `https` intent filter, no port, so it matches every port of the host, path prefix `/i/`, `android:autoVerify="false"`, D-124) and as `RELEASE_SERVER_URL` = `releaseServerUrl(host)` = `https://<host>` (`link/ReleaseServer.kt`, `releaseServerUrl` in `:feature:onboarding`'s `ServerAddress.kt`; `RELEASE_SERVER_PORT` removed, D-124); `:feature:onboarding` receives that URL through the Hilt qualifier `@ReleaseServerUrl` (§13.3 S-1), and `:data` through `AppBuildInfo.releaseServerUrl`, which S-22 shows before pairing (D-122). Invite-link parsing still accepts a server named by the link and drops an explicit default port (`:443`, `:80`) (AC-2.6, D-124). Tests and goldens use fixed fixture hosts. `infra/publish-apk.sh` passes `-Pkvitt.publicHost` from `KVITT_PUBLIC_HOST` in `infra/pi/.env` (above, §10.5, D-122); the owner may instead set the key in `android/local.properties`.
|
||||
* The server reads `latest.json` on each `GET /apk/latest` (no cache) and serves `GET /apk/files/{file_name}` only for a `file_name` matching `^kvitt-[0-9]+\.apk$` that exists, with `Content-Type: application/vnd.android.package-archive`.
|
||||
* Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints.
|
||||
* Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints. If one address still fetches `/apk/files/` over and over (NPM's access log), the owner's stop-gap is to disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's menu, Disable) until it stops; the router's 443 forward stays, because it also serves Home Assistant (D-124, `infra/README.md`).
|
||||
* Older APKs stay on the Pi; `install-apk.sh` keeps the newest 5 in the `data` volume and deletes older ones (D-103).
|
||||
|
||||
### 10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)
|
||||
@@ -1179,7 +1179,7 @@ Restore (documented step by step in `infra/RESTORE.md`; its commands run through
|
||||
* Who runs what (D-100): every command on the Pi in this section is run by the owner from the M2c-pi bundle's scripts and README checklist; the deploy script is `infra/pi/deploy.sh`, installed as `/opt/kvitt/deploy.sh` (D-103); the owner's operations table is in `infra/README.md`. Agents prepare and check the bundle on the Mac and never connect to the Pi.
|
||||
* Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing.
|
||||
* Image digests: `/opt/kvitt/deployed-digests` holds one line per image and deploy (§10.3, D-117); a WARNING from `deploy.sh` or `install.sh` that a tag changed digest is investigated before the stack is trusted.
|
||||
* Client IPs (once, after the router forward, D-117, D-120): with the phone off Wi-Fi, open `https://<KVITT_PUBLIC_HOST>:8443/health`, then within a minute on the Kvitt Pi `sudo conntrack -L -p tcp --orig-port-dst 8080` (package `conntrack`, installed once): in every line the first `src=` is `HA_HOST` and the reply half (from the second `src=`) has `dst=<HA_HOST>` with no `172.*` address, so the server sees NPM's address and trusts its `X-Forwarded-For`; a `172.*` address there means every internet client shares one per-IP limit of §8.6 (security review I-5; the commands are in `infra/README.md`'s operations table).
|
||||
* Client IPs (once, after the NPM proxy host and the router check, D-117, D-120, D-124): with the phone off Wi-Fi, open `https://<KVITT_PUBLIC_HOST>/health`, then within a minute on the Kvitt Pi `sudo conntrack -L -p tcp --orig-port-dst 8080` (package `conntrack`, installed once): in every line the first `src=` is `HA_HOST` and the reply half (from the second `src=`) has `dst=<HA_HOST>` with no `172.*` address, so the server sees NPM's address and trusts its `X-Forwarded-For`; a `172.*` address there means every internet client shares one per-IP limit of §8.6 (security review I-5; the commands are in `infra/README.md`'s operations table).
|
||||
* Headers through NPM (after the first setup and after every NPM upgrade): `curl -sS -D - -o /dev/null --resolve <KVITT_PUBLIC_HOST>:443:<HA_HOST> https://<KVITT_PUBLIC_HOST>/health` lists `content-security-policy`, `x-content-type-options`, `x-frame-options`, `referrer-policy` and `strict-transport-security` once each and no `x-served-by` (§10.5.2).
|
||||
|
||||
---
|
||||
@@ -1251,7 +1251,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
| M2a | Server skeleton and `kvitt-domain` | Cargo workspace, three crates, config, DB pool, migration `0001_init.sql` byte-identical to `contracts/schema/server-v1.sql`, error type with every §14.3.2 code, auth extractor (§8.2), all routes of §7 registered with handlers returning 501 `not_implemented`, utoipa document equal to `contracts/openapi.yaml`, `GET /health` and `GET /setup/status` implemented, `kvitt-domain` passing all vectors plus `next_occurrence_date`, `server/Dockerfile`; shell files in M3, M4 and M9 paths (D-004); the seams of §6.3 (D-015) | Server gate (includes the OpenAPI match test, the migration identity test, the vector tests) | none | M1 |
|
||||
| M2b | Android skeleton and `:domain` | Gradle project in `android/` with every module of §6.2, version catalog, architecture check, `:core:model` types and repository interfaces of §14.2.4, `:domain` passing all vectors (including analytics), `:core:fakes` with the empty scenario (§6.2, D-018) for each repository, the Room 3 entities of §14.2.1 and §14.2.2 and `KvittDatabase` version 1 in `:data` with the exported schema (D-012), screenshot tests with Roborazzi (D-010), module shells for every module with one empty entry composable per feature (D-004), the feature convention of §6.2 in `build-logic` (D-017), `:app` launching to an empty themed screen, `:app` string files with the app name only in both languages, lint `MissingTranslation` as an error in every module | Android gate | Plug the phone in once to confirm `installDebug` works | M1 |
|
||||
| M2c-dev | Infra for development (D-001, D-022) | Landed `c885aba`. `infra/dev/compose.yaml` (`server` built from `server/Dockerfile`, no Caddy, no TLS, bind-mounted data, `infra/secrets/` mounted read-only at `/secrets`, `backup` runnable on demand), `infra/dev/.env.example`, the dev scripts in `infra/dev/` (build, up, logs, backup, `copy-google-services.sh`), `infra/README.md` (dev stack and Firebase), `infra/backup/{Dockerfile, backup.sh}` (Drive upload skipped when `RCLONE_FOLDER_ID` is empty), Firebase project and apps through the REST API (§10.7), service-account key and `google-services.json` in `infra/secrets/` | `docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example config` succeeds; `shellcheck $(find infra -name '*.sh')` clean; on the Mac (with the M2a image): `GET /health` answers 200 from the Mac; one local backup run produces a snapshot that passes `PRAGMA integrity_check` | `gcloud auth login`; on the phone on the home Wi-Fi, open the dev server's `/health` and answer yes/no that it shows `"status":"ok"` | M0 (uses the M2a image for the health check when available) |
|
||||
| M2c-pi | Infra on the Pi (D-001, D-100, D-120) | Bundle landed `2476667` 2026-10-18 (D-103 to D-106); the NPM rework (D-120: `4f69ae0` workflow login, `d05f13a` stack without Caddy and cert-sync, `62f5279` and `9231a1d` README) landed `9231a1d` 2026-10-19; the owner's run on the Pi remains. A self-contained bundle in `infra/pi/` that the owner copies to the Kvitt Pi and runs (D-100): compose stack (`server`, `backup`), env template with the OQ-3 values and `KVITT_PUBLIC_HOST` as placeholders, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, Docker, `.env` and secrets, `install.sh`, the NPM proxy host and its Custom Nginx Configuration of §10.5, the router forward 8443 → Home Assistant Pi 443, trust of the `gitea.lan` registry, first backup and bootstrap, deploy), `infra/RESTORE.md`, Drive backups to folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`, Gitea Actions image build from the owner's example workflow in `.gitea/workflows/`, the `/opt/kvitt/import` bind mount (§10.2) | On the Mac: `docker compose -f infra/pi/compose.yaml config` succeeds with the env template (D-103); `shellcheck $(find infra -name '*.sh')` clean (green at the NPM rework: shellcheck clean on every script under `infra/` and `tools/`, compose config valid for `infra/pi/` and `infra/dev/`, `nginx -t` of the §10.5.2 block against NPM v2.16.0's templates, the Podman rehearsal of the server behind nginx with 21 checks passed (§10.5.2); from 2026-10-18 still valid: backup upload with `rclone lsf` confirmation and deletion after confirmation, retry after a failed upload, backup healthcheck red at 26 h, APK prune keeps 5; not verifiable from the Mac: NPM's UI and Let's Encrypt for the Kvitt host name, the router forward, the Gitea workflow run, pulls from `gitea.lan`, Drive, real Docker compose). On the Pi, run by the owner from the checklist: `install.sh` ends healthy on port 8080 and through NPM, `https://<KVITT_PUBLIC_HOST>:8443/health` answers from the phone on mobile data without a certificate warning; one backup file appears in the Drive folder | Choose the Kvitt host name (a DuckDNS name is being set up); copy the bundle to the Kvitt Pi and fill the placeholders in its `.env` (OQ-3); run the nine steps of `infra/README.md` "Pi: what the owner does" (provision, Docker, `.env` and secrets, copy, `install.sh`, the NPM proxy host, router forward 8443 → Home Assistant Pi 443, first backup and bootstrap, deploy), answering yes/no per check. Done by 2026-10-18: the Drive token (`rclone.conf`), the example workflow, the Gitea secret `kvitt_cicd`. No allowances for agents on the Pis, Home Assistant, NPM, Gitea or Drive (D-100) | M2c-dev, M8; runs last, after the app is proven (D-100) |
|
||||
| M2c-pi | Infra on the Pi (D-001, D-100, D-120) | Bundle landed `2476667` 2026-10-18 (D-103 to D-106); the NPM rework (D-120: `4f69ae0` workflow login, `d05f13a` stack without Caddy and cert-sync, `62f5279` and `9231a1d` README) landed `9231a1d` 2026-10-19; port 443 (D-124: `4852803` bundle) landed 2026-10-19; the NPM proxy host for `kvitt.suorra.duckdns.org` is Online with its Let's Encrypt certificate (owner, 2026-10-19); the owner's run on the Pi remains. A self-contained bundle in `infra/pi/` that the owner copies to the Kvitt Pi and runs (D-100): compose stack (`server`, `backup`), env template with the OQ-3 values and `KVITT_PUBLIC_HOST` as placeholders, install, deploy, bootstrap and backup scripts, a README checklist for the owner (Pi provisioning, Docker, `.env` and secrets, `install.sh`, the NPM proxy host and its Custom Nginx Configuration of §10.5, the router check (external 443 → Home Assistant Pi 443, already in place, no new rule, D-124), trust of the `gitea.lan` registry, first backup and bootstrap, deploy), `infra/RESTORE.md`, Drive backups to folder id `1jDp1dGE5R3H2DBcXCqIPN1iWzQnPkdlV`, Gitea Actions image build from the owner's example workflow in `.gitea/workflows/`, the `/opt/kvitt/import` bind mount (§10.2) | On the Mac: `docker compose -f infra/pi/compose.yaml config` succeeds with the env template (D-103); `shellcheck $(find infra -name '*.sh')` clean (green at the NPM rework: shellcheck clean on every script under `infra/` and `tools/`, compose config valid for `infra/pi/` and `infra/dev/`, `nginx -t` of the §10.5.2 block against NPM v2.16.0's templates, the Podman rehearsal of the server behind nginx with 21 checks passed (§10.5.2); from 2026-10-18 still valid: backup upload with `rclone lsf` confirmation and deletion after confirmation, retry after a failed upload, backup healthcheck red at 26 h, APK prune keeps 5; not verifiable from the Mac: NPM's UI and Let's Encrypt for the Kvitt host name, the router forward, the Gitea workflow run, pulls from `gitea.lan`, Drive, real Docker compose). On the Pi, run by the owner from the checklist: `install.sh` ends healthy on port 8080 and through NPM, `https://<KVITT_PUBLIC_HOST>/health` answers from the phone on mobile data without a certificate warning; one backup file appears in the Drive folder | Copy the bundle to the Kvitt Pi and fill the placeholders in its `.env` (OQ-3); run the nine steps of `infra/README.md` "Pi: what the owner does" (provision, Docker, `.env` and secrets, copy, `install.sh`, the NPM proxy host, the router check (external 443 → Home Assistant Pi 443, no new rule, D-124), first backup and bootstrap, deploy), answering yes/no per check. Done by 2026-10-18: the Drive token (`rclone.conf`), the example workflow, the Gitea secret `kvitt_cicd` (on 2026-10-19 not found under that name on the `jacobh2` account; the owner is fixing the name, HANDOFF). Done 2026-10-19: the Kvitt host name `kvitt.suorra.duckdns.org` and its NPM proxy host. No allowances for agents on the Pis, Home Assistant, NPM, Gitea or Drive (D-100) | M2c-dev, M8; runs last, after the app is proven (D-100) |
|
||||
| M3 | Server core API | Landed `7869ec4` (D-030 to D-034). E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, `next_occurrence_date`) | none | M2a |
|
||||
| M4 | Server sync, audit, push, scheduler | Landed `15056dc` (D-035 to D-039). E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) |
|
||||
| M5 | Android data layer | Landed `8f0e156` (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b |
|
||||
@@ -1277,7 +1277,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
|
||||
### 11.3 Notes per milestone
|
||||
|
||||
* **M1** writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it; the equality test compares parsed values, not text (D-016), after the normalisations of §14.8 (D-005). M2a may change `contracts/openapi.yaml` only representationally (key order, `$ref` naming, description wording), listed in the plan report, with no spec bump (§14.8). Any semantic change follows the contract-change rule (§14.8). `tools/contracts-check.py` checks the file's header: OpenAPI 3.1.0, title `Kvitt`, version `1` and one server `https://{host}:8443` whose variable `host` has the default `kvitt.example.invalid` (v0.22, D-123; was the literal `https://icehack.asuscomm.com:8443`).
|
||||
* **M1** writes the OpenAPI file by hand from §14.3 (no code exists yet). M2a then annotates handlers so the generated document matches it; the equality test compares parsed values, not text (D-016), after the normalisations of §14.8 (D-005). M2a may change `contracts/openapi.yaml` only representationally (key order, `$ref` naming, description wording), listed in the plan report, with no spec bump (§14.8). Any semantic change follows the contract-change rule (§14.8). `tools/contracts-check.py` checks the file's header: OpenAPI 3.1.0, title `Kvitt`, version `1` and one server `https://{host}` whose variable `host` has the default `kvitt.example.invalid` (v0.23, D-124; `https://{host}:8443` in v0.22, D-123; before that the literal `https://icehack.asuscomm.com:8443`).
|
||||
* **M2a** owns the router and registers every route up front so that M3 and M4 only fill in handler bodies in their own files. The same for the utoipa `ApiDoc`. M2a creates the shell files of the later server milestones (D-004, §14.7) and the seams of §6.3 (D-015). `server/rust-toolchain.toml` pins the exact current stable Rust release and the Dockerfile uses the same version (§10.3).
|
||||
* **M2b** writes the `*State` classes and repository interfaces of §14.2.4 so that M5 and M7 work against the same interfaces in parallel. It creates a module shell for every module of §6.2, with one empty entry composable per feature module (D-004), puts the feature convention of §6.2 (D-017) into the feature convention plugin, and fixes how `resolveRange` signals an invalid `CUSTOM` range (§14.5.4, D-018: `IncompleteCustomRangeException`, D-011). Its screenshot-test choice is D-010 (Roborazzi). It also created the Room 3 entities and `KvittDatabase` v1 in `:data` (D-012), so M5 builds on them.
|
||||
* **M5** owns `:data` and builds on the entities, `KvittDatabase` v1 and the exported schema `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` that M2b created (D-012); it adds DAOs, the API client, sync and the repositories. Room 3 migrations take an `SQLiteConnection` (§14.2.3).
|
||||
@@ -1309,7 +1309,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
|
||||
Reports in `docs/validation/` are deliverables (`CLAUDE.md`); they carry aggregates only and record the commit they were taken at. The release reports `first-release.md` and `restore-drill.md` (M10, AC-18.2) are still to be written.
|
||||
|
||||
* `docs/validation/ac-coverage.md`, the AC-coverage audit taken at `956908a` (`ed1b130` before the history rewrite; SPEC v0.16), read-only: 270 rows, 260 Done, 5 Partial, 1 Missing, 4 not verifiable without a device. Verdict: feature complete in code; not yet complete against §4's acceptance rule. Its two mismatches in shipped behaviour are fixed by M12f: S-16 opens the lost change read-only (D-109) and S-22 reads the server version from `/health` (D-110); M12f also added tests for the §9.2 effect cases, AC-2.9, the 429 retry, D-040 refusals and WorkManager unique work. Still open: the AC-18.2 restore drill (Missing), AC-18.1 and AC-20.1 observed on the Pi, AC-16.3 the real PackageInstaller install (all M2c-pi or M10), and a `KvittWorkerFactory` test.
|
||||
* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device) is left as the spec says, decision pending with the owner. Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 must be fixed before port 8443 is opened to the internet. D-120 (2026-10-19): L-1's redaction and D-117's headers now live in NPM's Custom Nginx Configuration (§10.5.2); L-2 and M-2 are moot (no Caddy container, no Home Assistant SSH key); the L-8 fix is reversed (the token is interpolated in the run line again, masked by act_runner, §10.3); I-5's check is the conntrack check of §10.10. The review carries dated notes under those findings.
|
||||
* `docs/validation/security-review.md`, the security review taken at `18c527f` (SPEC v0.17), read-only: Critical 0, High 1, Medium 3, Low 10, Info 8; the design matches §8 and is sound. Status at v0.18: L-6 kept as built and L-7 fixed (D-116); L-1, L-2, L-5, L-9 and M-2 fixed in infra (D-117); L-8 fixed (`e70fe0d`); H-1 (the in-memory rate limiter, `725901c`), M-3 (picture decoding memory, `baa5806`) and L-4 (`fcm_token` length, `b985ce9`) were fixed by the server overhaul (D-114, D-115); L-10 (a member gets `forbidden` rather than `not_found` for another user's device) is left as the spec says, decision pending with the owner. Owner answers of 2026-10-19: M-1 (backup encryption) no, closed as an accepted risk (§8.1, §8.7); L-3 (APK download rate) accepted and fixed (`96e4cbd`, D-119: 10 per minute per IP for E-6); the 16 megapixel picture cap and the 512-character `fcm_token` limit (D-114) confirmed. H-1 had to be fixed before the server was opened to the internet (fixed, `725901c`). D-120 (2026-10-19): L-1's redaction and D-117's headers now live in NPM's Custom Nginx Configuration (§10.5.2); L-2 and M-2 are moot (no Caddy container, no Home Assistant SSH key); the L-8 fix is reversed (the token is interpolated in the run line again, masked by act_runner, §10.3); I-5's check is the conntrack check of §10.10. The review carries dated notes under those findings.
|
||||
|
||||
---
|
||||
|
||||
@@ -2040,7 +2040,7 @@ data class AboutState(val versionName: String, val versionCode: Int, val serverU
|
||||
// D-122: serverUrl is the paired server's base URL; while none is stored, AppBuildInfo.releaseServerUrl.
|
||||
interface AboutRepository { fun about(): Flow<AboutState> }
|
||||
// Not a :core:model type: defined in :data (di/DataModule.kt), provided by :app (di/AppBuildInfoModule.kt) from BuildConfig and
|
||||
// RELEASE_SERVER_URL = https://<kvitt.publicHost>:8443 (D-121), consumed by :data.
|
||||
// RELEASE_SERVER_URL = https://<kvitt.publicHost> (D-121, D-124), consumed by :data.
|
||||
data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean, val releaseServerUrl: String) // releaseServerUrl: v0.22, D-122
|
||||
interface ConnectivityRepository { val online: Flow<Boolean> } // §9.9
|
||||
|
||||
@@ -2106,7 +2106,7 @@ Readings fixed in v0.5 (no type changes):
|
||||
|
||||
### 14.3 OpenAPI 3.1 outline
|
||||
|
||||
Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt`, `info.version` `1`, one server `https://{host}:8443` with the server variable `host`, default `kvitt.example.invalid` (reserved by RFC 6761), description "the owner-chosen Kvitt host name, SPEC §10.5" (D-123; was `https://icehack.asuscomm.com:8443`; no operation changed, representational under §14.8), security scheme `device` = HTTP bearer) from this outline. This outline is binding; the YAML file must not add or drop anything.
|
||||
Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt`, `info.version` `1`, one server `https://{host}` (port 443, D-124; `https://{host}:8443` in v0.22) with the server variable `host`, default `kvitt.example.invalid` (reserved by RFC 6761), description "the owner-chosen Kvitt host name, SPEC §10.5" (D-123; was `https://icehack.asuscomm.com:8443`; no operation changed), security scheme `device` = HTTP bearer) from this outline. This outline is binding; the YAML file must not add or drop anything.
|
||||
|
||||
#### 14.3.1 Common rules
|
||||
|
||||
@@ -2255,10 +2255,10 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
|
||||
|
||||
**E-7 `GET /me`** (device). 200:
|
||||
```json
|
||||
{ "user": User, "device": Device, "household": [User, ...], "public_url": "https://<KVITT_PUBLIC_HOST>:8443" }
|
||||
{ "user": User, "device": Device, "household": [User, ...], "public_url": "https://<KVITT_PUBLIC_HOST>" }
|
||||
```
|
||||
|
||||
The example (`contracts/examples/E-7-me/response.json`) has `"public_url": "https://kvitt.example.invalid:8443"` (D-123).
|
||||
The example (`contracts/examples/E-7-me/response.json`) has `"public_url": "https://kvitt.example.invalid"` (D-123, D-124).
|
||||
|
||||
**E-8 `PATCH /me`** (device). Request `{ "display_name": string }`. 200 `User`. Errors: `invalid_request`, `validation_failed`.
|
||||
|
||||
@@ -2268,7 +2268,7 @@ The example (`contracts/examples/E-7-me/response.json`) has `"public_url": "http
|
||||
|
||||
**E-11 `GET /users/{user_id}/picture?size=128|512&v=<picture_version>`** (device). 200 `image/jpeg`, `ETag: "<picture_version>"`, `Cache-Control: private, max-age=31536000, immutable`. `size` defaults to 512; other values `invalid_request`. `v` is a cache key only and is ignored. Errors: `not_found` (unknown user or no picture), `invalid_request`.
|
||||
|
||||
**E-12 `POST /invites`** (admin). Request `{ "kind": "partner" | "device", "target_user_id": uuid? }` (`target_user_id` is optional and nullable in the schema, absent meaning null; it is required for `device` and must be null for `partner`). 201 `Invite` with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>?from=<percent-encoded admin display name>`, D-087; the app also reads links without `from`; the example's `link` is `https://kvitt.example.invalid:8443/i/4N8RVT2KW6ZC?from=Jacob`, D-123). Errors: `forbidden`, `invalid_request`, `household_full`, `not_found` (target user).
|
||||
**E-12 `POST /invites`** (admin). Request `{ "kind": "partner" | "device", "target_user_id": uuid? }` (`target_user_id` is optional and nullable in the schema, absent meaning null; it is required for `device` and must be null for `partner`). 201 `Invite` with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>?from=<percent-encoded admin display name>`, D-087; the app also reads links without `from`; the example's `link` is `https://kvitt.example.invalid/i/4N8RVT2KW6ZC?from=Jacob`, D-123, D-124). Errors: `forbidden`, `invalid_request`, `household_full`, `not_found` (target user).
|
||||
|
||||
**E-13 `GET /invites`** (admin). 200 `{ "invites": [Invite, ...] }` (pending only: not accepted, not cancelled, not expired; `code` and `link` null). Errors: `forbidden`.
|
||||
|
||||
@@ -2868,3 +2868,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
|
||||
| 0.20 | 2026-10-19 | APK download rate limit (`96e4cbd`) and owner answers to the security review. D-119: E-6 `GET /apk/files/{file_name}` at most 10 requests per minute per client IP in its own fixed window, not counted against the 60 per minute of the other unauthenticated endpoints, every request counted (a 404 included), 429 `rate_limited` with `Retry-After`, client IP as in D-032, in memory; E-5 and E-4 stay on the 60 (§8.6, §10.6, E-6). Without a decision id: security review M-1 closed by the owner (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in §8.1 and §8.7); L-3 fixed; the D-114 limits (16 megapixels, 512-character `fcm_token`) confirmed by the owner (§11.4). |
|
||||
| 0.21 | 2026-10-19 | The NPM rework (`4f69ae0`, `d05f13a`, `62f5279`, `9231a1d`) and the Android host property (`d090c6c`). D-120: TLS terminates in the owner's Nginx Proxy Manager on the Home Assistant Pi instead of Caddy and cert-sync, which are removed (`infra/cert-sync/**`, `infra/pi/Caddyfile`, images, volumes `certs` and `caddy_data`, secrets `ha_ssh_key` and `ha_known_hosts`, `.env` keys `HA_PORT`, `HA_USER`, `HA_CERT_DIR`); Kvitt gets its own host name `KVITT_PUBLIC_HOST` (owner-chosen, recorded in HANDOFF), `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}:8443`; router 8443 → NPM 443, nothing forwarded to the Kvitt Pi; the server publishes 8080 on the LAN, `KVITT_TRUSTED_PROXY=${HA_HOST}`; the NPM proxy host and its Custom Nginx Configuration carry over D-117's headers and log redaction (§10.5 rewritten: 10.5.1 Nginx Proxy Manager, 10.5.2 Custom Nginx Configuration, 10.5.3 Fallback: NPM down); health checks on 8080 and through NPM; the Gitea login interpolates the secret in the `run` line (reverses L-8's fix) (§1, AC-1.1, §6.1, §7, §8.5, §8.7, §8.9, §10 intro, §10.1 to §10.5, §10.7, §10.10, §11.2, §11.3, §11.4, OQ-3, OQ-6, E-7, §14.7). D-121 (`d090c6c`): the Android app's public host is the build property `kvitt.publicHost` (Gradle property, else `android/local.properties`, else the placeholder `kvitt.example.invalid`), exposed as `BuildConfig.PUBLIC_HOST`, the manifest placeholder `publicHost` and `RELEASE_SERVER_URL` = `https://<host>:8443`, injected into `:feature:onboarding` as `@ReleaseServerUrl` (AC-2.6, AC-2.7, §8.5, §10.5.3, §10.6, §13.3 S-1, OQ-6). Without a decision id: `cargo audit` in the server gate (`f1abb0d`). |
|
||||
| 0.22 | 2026-10-19 | The D-121 follow-ups (`4a4e45f`, `4b1ae0c`), the server's host defaults (`c9abd24`, `e381e37`) and the `install.sh` step number (`ac17b41`, no decision). D-122 (`4a4e45f`, `4b1ae0c`; extends D-121): `AppBuildInfo` (defined in `:data`'s `DataModule.kt`, provided by `:app`'s `AppBuildInfoModule`) gains `releaseServerUrl` = `RELEASE_SERVER_URL`; S-22 shows the paired server, before pairing `AppBuildInfo.releaseServerUrl`; `DataModule.DEFAULT_SERVER_URL` removed; fakes and tests on `https://kvitt.example.org:8443` (30 S-21/S-22 goldens re-recorded); `infra/publish-apk.sh` reads `KVITT_PUBLIC_HOST` from `infra/pi/.env` and passes `-Pkvitt.publicHost` (§6.2, §10.6, §13.3 S-22, §14.2.4). D-123 (`c9abd24`, `e381e37`; revises D-120's leftovers): `KVITT_PUBLIC_URL` has no default and every `kvitt-server` command refuses to start without it; `KVITT_TRUSTED_PROXY` defaults to `none`, never looked up; the OpenAPI server is `https://{host}:8443` with variable `host` (default `kvitt.example.invalid`), representational for §14.8; E-7 and E-12 examples on `kvitt.example.invalid`; `tools/contracts-check.py` expects the `{host}` URL; 276 server tests (§10.4, §11.3, §14.3, E-7, E-12). The owner's Kvitt host name `kvitt.suorra.duckdns.org` (2026-10-19, recorded in HANDOFF; §10.1, §10.5, §10.5.1). |
|
||||
| 0.23 | 2026-10-19 | Port 443 (`a86ad24` server and contracts, `4852803` bundle, `038c603` Android, `ad35231` portless Android fixtures, 30 S-21/S-22 goldens re-recorded) and the pinned-root template (`038c603`, no decision). D-124 (owner decision 2026-10-19): Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, no 8443 rule. Server and contracts: OpenAPI server `https://{host}`; E-7 and E-12 examples without a port; `tools/contracts-check.py` expects the portless header. Bundle: `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}`; health checks through NPM on 443; README router step "no new rule"; the APK-abuse stop-gap is disabling Kvitt's proxy host in NPM. Android: `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 intent filter has no port and matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443`, `:80`); S-1 goldens on `https://kvitt.example.org` (28 re-recorded) (§1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §11.4, §14.2.4, §14.3, E-7, E-12). Without a decision id: the pinned-root config is generated at build time from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost`, only when `kvitt.tls.pinnedRoot=true`, and the build stops while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (§8.5, §10.5.3). |
|
||||
|
||||
Reference in New Issue
Block a user