From 2acfc34a0e3c4ff3906541e8c22b79a4695a3b5d Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 21 Oct 2026 18:59:45 +0200 Subject: [PATCH] SPEC v0.25: D-126 app gate in NPM; infra README step 4a Outside the home LAN NPM closes every request without X-Kvitt-App: (return 444); the LAN check reads $realip_remote_addr (NPM's X-Real-IP realip makes $remote_addr forgeable) and Let's Encrypt's challenge passes on the normalised $uri. Block rehearsed in NPM v2.16.0's openresty. README: step 4a (key, keyed APK, gate, acceptance test), health checks, stale CARGO_BUILD_JOBS text and owner decisions fixed. HANDOFF as of 2026-10-21. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- SPEC.md | 41 ++++++++++---- docs/HANDOFF.md | 41 ++++++++------ docs/decisions.md | 1 + infra/README.md | 138 ++++++++++++++++++++++++++++++++++++---------- 4 files changed, 162 insertions(+), 59 deletions(-) diff --git a/SPEC.md b/SPEC.md index 4837c68..6028017 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.24** (2026-10-20). Schema version **3**. Sync protocol version **1**. Push payload version **1**. +Version **0.25** (2026-10-21). 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. @@ -119,7 +119,7 @@ The server starts with an empty database. The household is created from the comm * AC-2.2 The admin creates a `device` invite for either user from Settings (to pair a second phone). * 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/` 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=&code=` (followed by `&from=` 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.5 `GET /i/` 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=&code=` (followed by `&from=` 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". Since the app gate (§8.5, D-126) the page opens in a browser only on the home LAN; from outside, a browser gets a closed connection, while the app opens the link (AC-2.6, `autoVerify="false"`, so no App Links check depends on the page). * AC-2.6 The app opens from `kvitt://invite?server=…&code=…[&from=…]` and from `https:///i/[?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://`, 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). @@ -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 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). +Two trusted users. The server is on the public internet on port 443, through NPM (D-120, D-124); outside the home LAN NPM passes only requests that carry the app key (§8.5, D-126). 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 @@ -584,6 +584,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM ### 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), 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). +* App gate (D-126, owner decision 2026-10-21): NPM closes the connection (`return 444`, nothing sent) of every request that neither carries the header `X-Kvitt-App: ` with the app key nor comes from the home LAN `192.168.1.0/24`. Every route is covered, `/health`, `/setup/status`, `/invites/accept`, `/i/{code}` and `/apk/*` included; only Let's Encrypt's `/.well-known/acme-challenge/`, which NPM serves from its own folder, passes (§10.5.2). The LAN check reads nginx's `$realip_remote_addr`, the address that really connected, because NPM's `real_ip_header X-Real-IP` for private peers lets a client set `$remote_addr`. The release app sends `X-Kvitt-App` on every request when its build carries the key: the Gradle property `kvitt.appKey` (`-Pkvitt.appKey=…`, else the key `kvitt.appKey` in `android/local.properties`, else empty), 16 to 128 printable ASCII characters without white space or the build stops with a message giving the length, never the value; exposed as `BuildConfig.APP_KEY` and to `:data` as `AppBuildInfo.appKey` (whose `toString` hides it); added in `KvittApi`'s common headers (every API call: `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` for profile pictures from the paired server; an empty key sends no header (debug builds against the development stack need none). The key is generated by the owner (`openssl rand -hex 32`) and lives only in `android/local.properties` and NPM's Custom Nginx Configuration: never in git, never in `.env`, never printed in docs or logs. The Rust server neither sees nor checks the header and `contracts/openapi.yaml` is unchanged (§14.3.1). The per-device bearer token stays the authentication (§8.2); the gate is a perimeter that keeps scanners and anyone without the app from the server. On the home LAN the "secret" is the Wi-Fi password, and the owner controls the APK's distribution (he installs it on both phones). Rotation: a second `if` line with the new key next to the old in NPM, the new APK through the update path (FR-16), then the old line removed. Accepted: the invite landing page and any browser use work only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; nothing at home protects against a bandwidth flood. * 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 (an IP literal in the Pi's `.env`, D-125), 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,7 +879,7 @@ 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; a prerequisite, already installed, usable without sudo by the owner's user `jacobhagstedt`, uid and gid 1000, D-125), static LAN address. Kvitt runs from `/home/jacobhagstedt/kvitt` (D-125). LAN address ``, known to the owner (OQ-3, D-100; no `.env` key since `200c99f`); also NPM's Forward Hostname / IP; no agent and no Mac script reaches 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 `:8080` (§10.5, D-120) | Its LAN IPv4 address is `HA_HOST` in the Pi'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 | +| 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 `:8080` (§10.5, D-120); closes every request from outside the home LAN without the app key (§8.5, D-126) | Its LAN IPv4 address is `HA_HOST` in the Pi'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`) | 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). Pushes use the Gitea secret `kvitt_cicd` (§10.3). The Kvitt packages are public, so the Pi pulls anonymously; were they private, the owner runs `docker login gitea.lan` once on the Pi with a `read:package` token (HUMAN, D-125). Agents never hold credentials (D-100) | @@ -953,7 +954,7 @@ Run them on the Pi in `~/kvitt` with `docker compose exec server kvitt-server , `. The server takes the last entry only when the TCP peer is `KVITT_TRUSTED_PROXY`, which compose sets to `HA_HOST`, NPM's LAN IPv4 address (an IP literal in `.env`, because the container cannot resolve an mDNS name such as `homeassistant.local`; nothing checks it since D-125); the server is unchanged (D-032, D-115, D-119). The router's forward keeps the internet client's source address, and Docker's published port keeps NPM's (DNAT, no SNAT, for traffic arriving on the LAN interface), so the per-IP limits see real addresses (checked once, §10.10). The server does not use `X-Forwarded-Proto` or `Host`: links are built from `KVITT_PUBLIC_URL` (E-12). * `X-Real-IP` (accepted): NPM's `nginx.conf` takes the client address from `X-Real-IP` when the connection comes from 10/8, 172.16/12 or 192.168/16 (and the CDN ranges it downloads), so a device on the home LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the router's forward keeps the source address), so their `X-Real-IP` is ignored. * HSTS: NPM sends `Strict-Transport-Security` with `max-age=63072000; preload` (two years, NPM's value) at server level. A browser then uses only HTTPS for ``, on every port, and refuses certificate warnings for it; turning HSTS off later does not clear browsers that already stored it. Because the name is Kvitt's own nothing else is affected, but anything ever served under `` (any port) must be HTTPS with a valid certificate too. The app does not rely on HSTS (release builds allow no cleartext, §8.5); it protects the landing page in a browser. -* Health checks (D-125, revises D-120 point 6; no script runs them): after a start or a deploy the owner runs `curl -s localhost:8080/health` on the Kvitt Pi, and checks `/health` through NPM, from outside (`https:///health` on the phone with Wi-Fi off) or from the Pi (`curl -fsS --resolve :443: https:///health`, with the certificate checked); `infra/README.md` "Pi: what the owner does" steps 3, 4 and 6. +* Health checks (D-125, revises D-120 point 6; no script runs them): after a start or a deploy the owner runs `curl -s localhost:8080/health` on the Kvitt Pi, and checks `/health` through NPM from the Pi (`curl -fsS --resolve :443: https:///health`, with the certificate checked; the Kvitt Pi is on the LAN, so no key is needed, D-126) and from outside: with the app gate in place (D-126) a browser outside the LAN gets a closed connection by design, so the outside check is the app's About screen (S-22) on the phone with Wi-Fi off, or `curl -fsS -H "X-Kvitt-App: $KEY" https:///health` from outside the home network (before the gate: `https:///health` in Chrome on the phone with Wi-Fi off); `infra/README.md` "Pi: what the owner does" steps 3, 4, 4a and 6. #### 10.5.2 Custom Nginx Configuration -The block pasted into the proxy host's Advanced tab (D-120; it carries over D-117's response headers and log redaction). Written against NPM v2.16.0's proxy host template (`backend/templates/proxy_host.conf`); as of `62f5279` it reads (`infra/README.md`, step 4 since D-125, unchanged): +The block pasted into the proxy host's Advanced tab (D-120; it carries over D-117's response headers and log redaction). Written against NPM v2.16.0's proxy host template (`backend/templates/proxy_host.conf`); as of v0.25 it reads (`infra/README.md`, step 4; the app gate lines added by D-126, the rest as of `62f5279`): ```nginx -# Kvitt (SPEC §10.5.2, D-120). NPM, Proxy Host , Advanced tab, Custom Nginx Configuration. +# Kvitt (SPEC §10.5.2, D-120, D-126). NPM, Proxy Host , Advanced tab, Custom Nginx Configuration. +# App gate (SPEC §8.5, D-126): outside the home LAN only a request carrying the key built into the app passes; +# everything else gets the connection closed (444: nothing sent, nothing to fingerprint). Put the key from +# android/local.properties (kvitt.appKey) in place of KVITT_APP_KEY. To rotate, add a second if-line with the new key. +# The LAN check reads $realip_remote_addr, the address that really connected: NPM replaces $remote_addr with the +# request's own X-Real-IP header for private peers, so $remote_addr could be forged. Let's Encrypt's renewal passes. +set $kvitt_gate 0; +if ($http_x_kvitt_app = "KVITT_APP_KEY") { set $kvitt_gate 1; } +if ($realip_remote_addr ~ "^192\.168\.1\.") { set $kvitt_gate 1; } +if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; } +if ($kvitt_gate = 0) { return 444; } # Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413. client_max_body_size 6m; # No long-poll in the API (SPEC §14.3); 60 s between two reads from the server. @@ -1032,6 +1043,8 @@ location ^~ /i/ { * Its own `location /`: nginx drops every `add_header` inherited from the server level in a location that has an `add_header` of its own, and NPM's default `location /` has one (`X-Served-By`), which would silently drop the four headers. NPM leaves its default location out when the Custom Nginx Configuration contains `location /` (NPM matches it with the regex `^(?:.*;)?\s*?location\s*?\/\s*?{`), so the block brings its own, without `add_header`. Its `proxy_pass` uses NPM's Details values (`$forward_scheme`, `$server`, `$port`). If a later NPM release changes the proxy host template, the headers are checked again (§10.10). * `client_max_body_size 6m`: above the server's own limits (1 MiB, 5 MiB for `PUT /me/picture`, §8.6), which answer `payload_too_large` themselves; larger bodies get NPM's 413. `proxy_read_timeout 60s`: the API has no long-poll. * Logs (§8.7): NPM writes one access log line per request to `/data/logs/proxy-host-_access.log` inside NPM, in its `proxy` format: time, cache and upstream status, status, method, scheme, host, `"$request_uri"` (the path with its query string), `[Client
]`, length, gzip ratio, `[Sent-to ]`, user agent and referer. It logs no request headers, so the `Authorization` token is never logged. Query strings are logged for every path except `/i/`; the API's other query parameters are `since` and `limit` (`GET /sync`) and `size` and `v` (pictures), which hold no personal content. `location ^~ /i/` turns the access log off and sends its error log to `/dev/null`, so neither the invite code nor the inviter's name (`?from=`) reaches a log, a 502 included. Errors on other paths (for example a 502 while the server is down) go to `proxy-host-_error.log` with the request line. +* App gate (§8.5, D-126): `set`, `if` and `return` are rewrite-module directives; at server level they run in NPM's server rewrite phase, before a location is chosen, so the gate covers every location, `/i/` included, and no `add_header` is involved. `KVITT_APP_KEY` is replaced by the key from `android/local.properties`; a rotation adds a second `if ($http_x_kvitt_app = …)` line. `$realip_remote_addr` is the TCP peer: NPM's `nginx.conf` has `set_real_ip_from` 10/8, 172.16/12 and 192.168/16 with `real_ip_header X-Real-IP`, which would let a LAN client, or every client if NPM saw a Docker bridge address (security review I-5), choose `$remote_addr`. The Let's Encrypt exemption tests the normalised `$uri`, which location matching uses, so `/.well-known/acme-challenge/../../health` is closed; that path is served by NPM's own `location ^~ /.well-known/acme-challenge/` from its webroot, never proxied. NPM's own server-level rules come first in its template: Force SSL answers port 80 with a 301 to `https://` (Let's Encrypt follows it, which is why the exemption is needed on 443), and Block Common Exploits answers its patterns with 403. If NPM saw a Docker bridge address for every client, the LAN check would match nothing; if the router rewrote internet clients to its LAN address, everything; NAT hairpin makes a LAN client appear as the router's `192.168.1.1`, still inside the /24. The owner's acceptance test (`infra/README.md` step 4a): on the home Wi-Fi `https:///i/TEST` shows the page in a browser, with Wi-Fi off it gives a connection error, and the app's About screen still shows the server version. +* Verified on the Mac on 2026-10-21 (D-126): the block above, with a test key, inside a server block with NPM v2.16.0's `letsencrypt-acme-challenge.conf` and `block-exploits.conf` includes and its `real_ip` settings, run by the openresty 1.31.1.1 of the `jc21/nginx-proxy-manager:2` image (v2.16.0): `nginx -t` successful; without the header, with a wrong key, with a forged `X-Real-IP: 192.168.1.50`, on `/i/` and `/apk/files/` without the header, and on `/.well-known/acme-challenge/../../health`, the connection was closed with no reply; with the key `/health` and `/i/` reached the upstream with the D-117 headers; an ACME token file was served; a second key line passed; a peer matching the LAN pattern passed without the header; with `$remote_addr` instead of `$realip_remote_addr` the forged `X-Real-IP` passed (why the block uses the latter). Not checked: HTTPS and HTTP/2 through NPM's real proxy host, the router and the real LAN addresses (the owner's acceptance test). * Verified on the Mac on 2026-10-19 (D-120): `nginx -t` (nginx:alpine 1.31.7) of the block inside a minimal server block and inside a copy of NPM v2.16.0's `nginx.conf` with the proxy host rendered as NPM's template renders it for the §10.5.1 settings, both successful; a Podman rehearsal of the server image built from `server/Dockerfile` (arm64, hardened as on the Pi) behind that nginx with a self-signed certificate, 21 checks passed: the four headers and `Strict-Transport-Security` once each over HTTP/2 and no `X-Served-By`; one `Referrer-Policy` on the landing page; `http://` answered 301 to `https://`; a 7 MB body refused by nginx (413), a 2 MB body answered by the server (`payload_too_large`); the rate limit applied per forwarded client and a forged `X-Forwarded-For` did not escape it; the invite code, the name and `/i/` absent from nginx's access and error logs after `/i/`, `//i/` and `/%69/` requests and a 502 on `/i/`. Not checked (needs the real hosts): NPM's own UI and Let's Encrypt for the Kvitt host name, the router forward. #### 10.5.3 Fallback: NPM down @@ -1065,9 +1078,10 @@ 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, 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://` (`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 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://` (`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`. Its sibling `kvitt.appKey` (D-126, next item) is read the same way. +* The app key is a build property too (D-126, modelled on D-121): `kvitt.appKey` from the Gradle property (`-Pkvitt.appKey=…`), else the key `kvitt.appKey` in `android/local.properties`, else empty (no `X-Kvitt-App` header sent); `BuildConfig.APP_KEY` (§8.5). A release APK carries the key it was built with, so the APK must be rebuilt after the key is set or rotated; `infra/publish-apk.sh` needs no change, because it builds on the Mac, where `android/local.properties` holds the key. An APK without the key cannot reach the server from outside the home LAN once the app gate is in NPM, its update download included, so both phones run a keyed APK before the gate goes in (`infra/README.md` step 4a). Anyone holding the APK holds the key; the owner alone distributes it (first install on both phones by hand) and rotates the key if an APK leaks (§8.5). * 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. 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`). +* 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`). Since the app gate (D-126) a request from outside without the app key never reaches the server, so this concerns the home LAN or a leaked key. * Older APKs stay in `data/apk/` on the Pi until the owner removes them by hand (D-125; `install-apk.sh` kept the newest 5, D-103); `infra/pi/out/apk/` on the Mac keeps the newest 5. ### 10.7 Firebase setup (M2c-dev, with a HUMAN sign-in) @@ -1179,7 +1193,8 @@ Restore (step by step in `infra/RESTORE.md`, run on the Pi in `~/kvitt` as the o * Bootstrap and invites: `docker compose exec server kvitt-server bootstrap` (first install only: the admin invite code, link and expiry of FR-1); once the admin is paired, the partner's invite `docker compose exec server kvitt-server invite-partner`; an extra device `docker compose exec server kvitt-server pair-device --user ` (§10.4). * Memory check: `docker stats --no-stream` after the release milestone; server RSS must be under 100 MB with both phones syncing. * Image digests (D-117 point 5, revised by D-125): the Pi keeps no digest record. After a pull, `docker image inspect --format '{{.RepoDigests}}' gitea.lan/jacobh2/kvitt-server:` (and `kvitt-backup`) prints the `sha256:` digest, which the owner compares with the workflow's "Print image digests" step for that run (§10.3); the registry is plain HTTP on the LAN (security review L-9), so a mismatch is investigated before the stack is trusted. -* 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:///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=` 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 the app's About screen (S-22; before the app gate of D-126, `https:///health` in Chrome does as well), 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=` 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). +* Health from outside (D-126): the app's About screen (S-22) on the phone with Wi-Fi off shows the server version, or `curl -fsS -H "X-Kvitt-App: $KEY" https:///health` from outside the home network; without the header the connection is closed (§8.5). * Headers through NPM (after the first setup and after every NPM upgrade): `curl -sS -D - -o /dev/null --resolve :443: https:///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). --- @@ -2112,6 +2127,7 @@ Milestone M1 writes `contracts/openapi.yaml` (OpenAPI 3.1.0, `info.title` `Kvitt * Content type `application/json; charset=utf-8` for every request and response body, except: `PUT /me/picture` (request `image/jpeg`, `image/png` or `image/webp`, raw bytes), `GET /users/{user_id}/picture` (response `image/jpeg`), `GET /i/{code}` (response `text/html; charset=utf-8`), `GET /apk/files/{file_name}` (response `application/vnd.android.package-archive`). * Request headers the app sends on every call: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version: `, `X-Kvitt-App-Version-Name: ` (optional for the server; stored per device at E-3 and on the hourly last-seen update, §8.2; blank or longer than 32 characters is ignored; declared on every operation through `CommonRules`, D-090), `Accept-Language: `, and `Authorization: Bearer ` on authenticated endpoints. A request to an authenticated endpoint without `X-Kvitt-Protocol` or with a value other than `1` gets 426 `protocol_unsupported` (the app then stops syncing until its installed version code changes, and shows the update banner when a newer APK is published, §14.2.4). The protocol check comes before the token check. +* `X-Kvitt-App: ` is a proxy-level header, not part of the API contract: the release app sends it on every request when built with `kvitt.appKey`, NPM's app gate checks it (§8.5, D-126), and the server neither reads nor declares it (`contracts/openapi.yaml` unchanged). * Every response carries `X-Kvitt-Server-Version: `. * A known path with a method it does not support gets 405 with an empty body (no error code; the only error response without the JSON error shape), the `X-Kvitt-Server-Version` header included. An unknown path gets 404 `not_found` with the JSON error shape. * Unknown JSON fields in requests are ignored. Missing required fields or wrong types: 400 `invalid_request`. Values outside §9.6: 422 `validation_failed` with `field`. The §9.6 value limits are not expressed as JSON Schema constraints in `contracts/openapi.yaml`; the schema fixes types only, and breaking a limit gives 422 `validation_failed`. @@ -2870,3 +2886,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op | 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://`, 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://` (`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). | | 0.24 | 2026-10-20 | The self-contained Pi compose project (`6e5981d`), the `.env` without informational keys (`200c99f`) and the sparse-checkout workflow (`3820f9c`, no decision), with the workflow's first results; the compose deploy hint in the workflow and `infra/build-images.sh` (`28fa71b`, no decision). D-125 (owner decision 2026-10-20, revises D-103, D-105, D-117 point 5, D-120 point 6): the Kvitt Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project (`compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`); both containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi) with a read-only root and a 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; each mounts only its own secret file read-only (`create_host_path: false`), secret files the owner's, mode 0600; `.env` holds exactly the seven keys compose reads, `RCLONE_REMOTE` fixed to `gdrive:`; no install script, no `/opt`, no Docker daemon or system changes (Docker and the registry trust are prerequisites; private packages mean a one-time `docker login gitea.lan`); the scripts `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh` removed, their equivalents plain `docker compose` commands (backup `exec backup /backup.sh`, bootstrap and partner invite through `exec server kvitt-server`, deploy = backup, set `KVITT_TAG`, `pull`, `up -d`, rollback = the previous tag, APK = `scp` of the APK and then `latest.json` into `data/apk/`, old APKs removed by hand, digests with `docker image inspect`, no digest record, health checks by hand); restore moves files on the host, only rclone and sqlite3 run in the backup container; the compose default network; the development stack unchanged (uid 10001); rehearsed with Podman as uid 501 (§8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 OQ-3, §14.7). Without a decision id: the `.env` keys `KVITT_PI_HOST` and `KVITT_PI_SSH_USER` dropped (`200c99f`, revises D-103), `` is now `` (§8.9, §10.1, §10.5.1, §10.5.3, OQ-3); the Gitea workflow's results on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (a misnamed user-level secret, fixed by the owner 2026-10-19), run 69 built `v1.0.0` (`kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min, first build with `CARGO_BUILD_JOBS=1`), the sparse-checkout push built `v1.0.1`, the tag the Pi uses; the checkout is a cone-mode sparse checkout of `server` and `infra/backup` with `fetch-depth: 0` and `--filter=blob:none`; the diagnostic step removed (§10.3, §11.2). | +| 0.25 | 2026-10-21 | The app gate (D-126, owner decision 2026-10-21; Android `34e5cad`): outside the home LAN `192.168.1.0/24` NPM closes the connection (`return 444`) of every request without `X-Kvitt-App: `, every route included (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*`); only `/.well-known/acme-challenge/` passes (Let's Encrypt renewal), tested on `$uri`; the LAN check reads `$realip_remote_addr`, because NPM's `real_ip_header X-Real-IP` for private peers makes `$remote_addr` forgeable; the server and `contracts/openapi.yaml` are unchanged, the bearer token stays the authentication. The app sends the header in `KvittApi`'s common headers and `PictureAuth` when built with `kvitt.appKey` (Gradle property, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey`; 952 Android tests); the key is the owner's `openssl rand -hex 32`, only in `local.properties` and NPM; rotation by a second `if` line, a new APK through FR-16, then the old line removed. Accepted: landing page and browser use only on the LAN, outside health check through the app's About screen or `curl -H`, a leaked APK leaks the key until rotation, no flood protection. The gate rehearsed in NPM v2.16.0's openresty with its includes (AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1). Without a decision id: `infra/README.md` step 4a (key, APK, gate, acceptance test), its `CARGO_BUILD_JOBS` text (in effect since `b4d9467`; run 69: `kvitt-server` 2 h 42 min) and its owner decisions marked answered. | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index c702317..e5ce450 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -3,13 +3,17 @@ Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. ## 1. Where things stand -- `main` at `28fa71b` (the compose deploy hint in the workflow and `infra/build-images.sh`, after `6e5981d`, the self-contained Pi compose project, D-125); `origin/main` (Gitea) at `6e5981d`, so `28fa71b` is not pushed yet; plus the SPEC v0.24 docs commit from `agent-docs-v024b` once it lands. Landed since SPEC v0.23 (`b1f4c10`): `3820f9c`, `200c99f`, `6e5981d`, `28fa71b` (below). +- State as of 2026-10-21. `main` at `34e5cad` (the Android side of D-126: `X-Kvitt-App` on every request), after `d9e189a` (SPEC v0.24, D-125); `origin/main` (Gitea) at `d9e189a`, so `34e5cad` is not pushed yet; plus the SPEC v0.25 docs commit from `agent-gate-docs` once it lands. Landed since SPEC v0.24: `34e5cad` (below). - **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env.example` and `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`, git-ignored). SPEC §10.1 and §10.5.1 point here. - **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124). - **Images**: `gitea.lan/jacobh2/kvitt-server` and `gitea.lan/jacobh2/kvitt-backup`, tags `v1.0.0` (run 69) and `v1.0.1` (the sparse-checkout push; the tag the Pi uses), plus `latest`; the packages are public, so the Pi pulls without `docker login`. Gitea Actions on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (the user-level secret was misnamed on the owner's side, fixed 2026-10-19; a user-level secret of the repository owner reaches the repository); run 69 succeeded: login ok, `v1.0.0`, `kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min on the Pi 3 (first build, `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache); the tag push works with the `write:package` token. SPEC §10.3. -- **Kvitt Pi** (D-125): Kvitt runs from `/home/jacobhagstedt/kvitt` as the owner's user `jacobhagstedt`, uid and gid 1000:1000 (`KVITT_UID`/`KVITT_GID`); Docker and the `gitea.lan` registry trust are already on the Pi; nothing outside that folder is installed or changed. The owner is installing now (2026-10-20, §3 item 1). +- **Kvitt Pi** (D-125): Kvitt runs from `/home/jacobhagstedt/kvitt` as the owner's user `jacobhagstedt`, uid and gid 1000:1000 (`KVITT_UID`/`KVITT_GID`); Docker and the `gitea.lan` registry trust were already on the Pi; nothing outside that folder is installed or changed. **The stack is running** (owner, 2026-10-21): image `v1.0.1`, `server` and `backup` both healthy after a `chown` of `data/` (it had been created as root); compose's `mem_limit` warning (the Pi kernel has no memory cgroup, so the 160 MiB and 96 MiB limits are not enforced) is accepted. `/health` through NPM from outside answered `"status":"ok"` (public health OK). +- **App gate** (D-126, owner decision 2026-10-21, in progress): outside the home LAN `192.168.1.0/24` NPM closes (`return 444`) every request without `X-Kvitt-App: `, every route included; the key is the build property `kvitt.appKey` (`android/local.properties`, git-ignored), generated by the owner with `openssl rand -hex 32` and pasted into NPM's block; the server and `contracts/openapi.yaml` are unchanged. Android side landed (`34e5cad`); the docs (SPEC v0.25, `infra/README.md` step 4a with the block and the acceptance test) are `agent-gate-docs`. Not yet in NPM: `android/local.properties` has no `kvitt.appKey` yet (checked 2026-10-21), so no keyed APK exists. +- **First release APK**: `apk/kvitt-123.apk` (10.0 MB, git-ignored by `*.apk`) handed to the owner from the main checkout for sideloading on both phones; it was built before any `kvitt.appKey` existed, so it sends no `X-Kvitt-App`: it works only until the gate is pasted (on the home Wi-Fi it keeps working afterwards). The phones need a keyed APK before the gate goes in (§3). - History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); since v0.19 the plans cite rewritten hashes too (eight pre-rewrite hashes in `PLAN-*.md` replaced by the commit with the same subject); `docs/spikes/pi-image-arm64.md` and the READMEs had none stale. For any other old hash, find the commit by its subject with `git log --oneline --grep`. - History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119), `481889a` SPEC v0.20, `f1abb0d` `cargo audit` in the server gate, `4f69ae0` Gitea login in the run line, `d05f13a` Pi stack behind NPM, `62f5279` and `9231a1d` infra README for NPM (D-120), `d090c6c` Android host property (D-121), `4bcff2c` SPEC v0.21, `dd07ca4` temporary workflow diagnostic step, `4a4e45f` S-22's release server and `4b1ae0c` `publish-apk.sh` host (D-122), `c9abd24` and `e381e37` server host defaults and OpenAPI `{host}` (D-123), `ac17b41` `install.sh` step number, `cce3e4e` SPEC v0.22, `a86ad24` server and contracts on port 443, `4852803` bundle on port 443 (D-124), `038c603` Android on port 443 and the pinned-root template, `ad35231` portless Android fixtures, `b1f4c10` SPEC v0.23, `3820f9c` sparse-checkout workflow, `200c99f` `.env` without informational keys, `6e5981d` self-contained Pi compose project (D-125), `28fa71b` compose deploy hint in the workflow and `infra/build-images.sh`. +- Landed since SPEC v0.24 (recorded in v0.25): + - App key header (`34e5cad`, D-126): `kvitt.appKey` (`-Pkvitt.appKey`, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space, else the build stops giving the length only), `BuildConfig.APP_KEY`, `AppBuildInfo.appKey` (`:data`, `toString` hides it); `X-Kvitt-App` in `KvittApi`'s common headers and `PictureAuth`; 952 Android tests. - Landed since SPEC v0.23 (recorded in v0.24): - Sparse-checkout workflow (`3820f9c`, no decision): cone-mode `sparse-checkout` of `server` and `infra/backup` (the two build contexts), so the action fetches with `--filter=blob:none` and no PNG is checked out; `fetch-depth: 0` stays because `fetch-tags` with a shallow fetch only brings tags on the tip; the temporary "Diagnose secrets" step removed. Its push built `v1.0.1`. - `.env` without the informational keys (`200c99f`, no decision; revises D-103): `KVITT_PI_HOST` and `KVITT_PI_SSH_USER` dropped, read by nothing on the Pi; the README's `scp`, `ssh` and NPM steps name ``. @@ -40,22 +44,22 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 is fixed (`725901c`). - Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18. - In flight: - - This docs pass (`agent-docs-v024b`: SPEC v0.24, D-125, HANDOFF, `prompts/README.md`, notes in `PLAN-M11.md` and `PLAN-M2c-pi.md`). - - The owner installing Kvitt on the Kvitt Pi (§3 item 1). + - This docs pass (`agent-gate-docs`: SPEC v0.25, D-126, `infra/README.md` step 4a and the gate block, its stale `CARGO_BUILD_JOBS` text and owner decisions fixed, this file). + - The owner: first backup, bootstrap and release pairing; then the app key and the gate (§3). - Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2). - Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3. - Remote: `origin` on Gitea, SSH key works; pushed at `6e5981d`. The Gitea secret `kvitt_cicd` (a user-level secret of `jacobh2`, renamed by the owner 2026-10-19) works; the Drive token is `infra/secrets/rclone.conf` (`[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted). -- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 944 tests at `d090c6c`, one more test in `4a4e45f`; server 276 tests at `c9abd24`). +- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 952 tests at `34e5cad`; server 276 tests at `c9abd24`). - Importer: the dry run of 2026-10-18 reconciled on both real files (Vardag 1746 rows, Hemma 37 rows, 0 unmapped). The CSV files stay in `export/` (git-ignored). - Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`. -- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, rebuilt with the host `kvitt.suorra.duckdns.org` and no port (D-124) and installed 2026-10-19; release pairing waits for the Kvitt Pi (release builds require `https`, AC-2.8; §3 item 3). +- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, rebuilt with the host `kvitt.suorra.duckdns.org` and no port (D-124) and installed 2026-10-19; release pairing against the Kvitt Pi is next (§3 item 1, with `apk/kvitt-123.apk`). - Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB. ## 2. Open work (one line each, with who owns it: agent or owner) -- Land `agent-docs-v024b` (orchestrator): SPEC v0.24, D-125, HANDOFF, `prompts/README.md`, the plan notes. +- Land `agent-gate-docs` (orchestrator): SPEC v0.25, D-126, `infra/README.md`, HANDOFF. Push `34e5cad` and it to `origin` (neither touches `server/**`, `infra/backup/**` or `.gitea/**`, so no workflow run). - Closed by `28fa71b`: the `/opt/kvitt` leftovers in `.gitea/workflows/build-images.yaml` and `infra/build-images.sh` (compose deploy hint, `docker image inspect`). Still stale in the workflow header (agent, next infra pass): "NOT YET RUN" and "First-build duration on the Pi: unknown" (now 2 h 42 min). -- Push `28fa71b` and the v0.24 docs commit to `origin` (orchestrator). `28fa71b` touches `.gitea/workflows/**`, so the push starts a workflow run (next tag `v1.0.2`, same image content; the server build should reuse the builder's cargo cache). -- `infra/README.md` "Gitea Actions workflow" is stale in two places (agent, next infra pass): `CARGO_BUILD_JOBS` "takes effect only once `server/Dockerfile` declares it" (it does since `b4d9467`) and "the first build's duration on the Pi is unknown"; its "Owner decisions (open)" 1 (`write:repository`, moot: the tag push works) and 2 (host name, answered) are answered. +- Pushed: `28fa71b` and the v0.24 docs commit (`origin/main` at `d9e189a`). +- Closed by `agent-gate-docs`: `infra/README.md`'s stale `CARGO_BUILD_JOBS` text and its "Owner decisions (open)" (now answered). - `infra/backup/Dockerfile`: stale comment "on the Pi the container runs as uid 10001" (since D-125 the owner's uid); left as is, because editing it triggers a 5-minute image build (agent, with the next change there). - Historical `:8443` and `icehack.asuscomm.com` mentions in `docs/ux/` and `docs/validation/` are left as the record of their time (no action unless the owner wants them rewritten). - Restore drill AC-18.2 (`docs/validation/restore-drill.md`, M10; the one Missing row of the AC-coverage audit); `infra/RESTORE.md` is the runbook since D-125. @@ -69,27 +73,28 @@ Living document, rewritten in place (never a second copy). A new orchestrator se - `PLAN-M2c-pi.md` is superseded where it names Caddy, cert-sync, scripts or `/opt/kvitt` (note at its top, D-120 and D-125); SPEC §10 and `infra/README.md` are current. `PLAN-M11.md` has a note that `/opt/kvitt/import` is now `~/kvitt/import`. - Plans (SPEC §11.2 v0.24), in dependency order; prompt `prompts/.md`: - M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle with its NPM rework and the self-contained compose project (D-125), server gap pass, infra overhaul, infra security, the server overhaul, the Android overhaul, the APK download rate limit, `cargo audit`, the Android host property and its follow-ups (D-122), the server host defaults (D-123), port 443 (D-124): landed. - - Next: the owner installs Kvitt on the Kvitt Pi (§3; image `v1.0.1`, which carries the H-1 fix like every image since `725901c`); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure SPEC §10.8 "Production run", now in `~/kvitt/import/`, D-025, D-125; each run must reconcile, D-045). + - Next: the owner pairs the release app against the Kvitt Pi (stack running, `v1.0.1`), then the app key and gate (D-126, §3); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure SPEC §10.8 "Production run", now in `~/kvitt/import/`, D-025, D-125; each run must reconcile, D-045). ## 3. HUMAN steps pending (relay in chat) -1. Pi install (D-125; `infra/README.md` "Pi: what the owner does", steps 1 to 3). On the Mac: `cp infra/pi/.env.example infra/pi/.env`, set `HA_HOST` (NPM's LAN IPv4 address) and `KVITT_TAG=v1.0.1`, check `KVITT_UID`/`KVITT_GID` are 1000; `mkdir -p infra/pi/secrets && cp infra/secrets/fcm-service-account.json infra/secrets/rclone.conf infra/pi/secrets/`; `scp -r infra/pi/compose.yaml infra/pi/.env infra/pi/secrets jacobhagstedt@:kvitt/`; `rm -r infra/pi/secrets`. On the Pi: `cd ~/kvitt`, `chmod 700 secrets && chmod 600 secrets/*`, `mkdir -p data/apk backups import`, `docker compose pull && docker compose up -d`, `docker compose ps`, `curl -s localhost:8080/health`. Expected: `server` and `backup` both `(healthy)` within a minute; `curl` prints `{"status":"ok",...,"db_ok":true}` (yes/no). -2. From outside: on the phone with Wi-Fi off, open `https://kvitt.suorra.duckdns.org/health` in Chrome. Expected: `"status":"ok"` without a certificate warning (yes/no). -3. First backup, bootstrap, release pairing. On the Pi in `~/kvitt`: `docker compose exec backup /backup.sh` ends with `backup: ok ` (yes/no); then `docker compose exec server kvitt-server bootstrap` prints the admin invite code, link and expiry; pair the release app `se.hagstedt.kvitt` with it (the orchestrator relays the exact screen labels then). -4. Partner phone, after the owner's release app is paired: `docker compose exec server kvitt-server invite-partner` on the Pi; the partner installs the release APK and pairs with that invite (relayed with screen labels when the time comes). +Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers healthy) and the public health check through NPM. +1. First backup, bootstrap, release pairing. On the Pi in `~/kvitt`: `docker compose exec backup /backup.sh` ends with `backup: ok ` (yes/no); then `docker compose exec server kvitt-server bootstrap` prints the admin invite code, link and expiry; sideload `apk/kvitt-123.apk` and pair the release app `se.hagstedt.kvitt` with it (the orchestrator relays the exact screen labels then). +2. Partner phone, after the owner's release app is paired: `docker compose exec server kvitt-server invite-partner` on the Pi; sideload the release APK on the partner's phone and pair with that invite (relayed with screen labels when the time comes). +3. App key (D-126; `infra/README.md` step 4a items 1 to 3), after `agent-gate-docs` lands: generate `kvitt.appKey` in `android/local.properties` on the Mac (an agent may run the one-line `openssl` command; the value is never printed); build and publish a keyed APK (`infra/publish-apk.sh`, copy to `data/apk/`); both phones on it (update banner, or sideload). +4. Gate (step 4a items 4 and 5), only after both phones run the keyed APK: NPM, Hosts, Proxy Hosts, Kvitt row, Edit, Advanced: paste the step 4 block with the key in place of `KVITT_APP_KEY`, Save (row Online, yes/no). Acceptance test: on home Wi-Fi Chrome opens `https://kvitt.suorra.duckdns.org/i/TEST` and the invite page shows (yes/no); with Wi-Fi off the same URL gives a connection error (yes/no); with Wi-Fi off Kvitt's About screen shows the server version (yes/no). ## 4. Owner questions (asked, unanswered) - OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1". - Security review L-10: should a member get `not_found` instead of `forbidden` for a device id of the other user (hides whose it is)? As built and as the spec says: `forbidden`. - State-class naming (`*Ui`, `*UiState`, `*State` mixed): fix one convention, or leave as built? -- Answered 2026-10-20: Kvitt on the Pi is a self-contained compose project in `~/kvitt` with nothing installed elsewhere (D-125). Answered 2026-10-19: Kvitt's host name is `kvitt.suorra.duckdns.org` (§1); Kvitt on port 443 through the existing router forward (D-124). Moot: `write:repository` for the registry secret (the tag push works with `write:package`). +- Answered 2026-10-21: auth on everything, the key is the app itself, the landing page on the LAN only is OK (D-126). Answered 2026-10-20: Kvitt on the Pi is a self-contained compose project in `~/kvitt` with nothing installed elsewhere (D-125). Answered 2026-10-19: Kvitt's host name is `kvitt.suorra.duckdns.org` (§1); Kvitt on port 443 through the existing router forward (D-124). Moot: `write:repository` for the registry secret (the tag push works with `write:package`). ## 5. Paused (waiting on something outside the repo) - Nothing. ## 6. Numbering -- Decisions: D-001 to D-125 used, except D-008 and D-009 (never used); nothing reserved. Next free: **D-126**. -- Spec version: v0.24 written; next **v0.25**. -- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.24. +- Decisions: D-001 to D-126 used, except D-008 and D-009 (never used); nothing reserved. Next free: **D-127**. +- Spec version: v0.25 written; next **v0.26**. +- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.25. - Sync protocol **1**, push payload **1**. ## 7. How the work is run diff --git a/docs/decisions.md b/docs/decisions.md index e2d8ba3..a9e971b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -130,3 +130,4 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th | D-123 | 2026-10-19 | The server has no built-in Kvitt host (revises D-120's leftovers). (1) `KVITT_PUBLIC_URL` has no default (`DEFAULT_PUBLIC_URL` removed from `config.rs`): unset or empty, every `kvitt-server` command exits 1 with a configuration error (`configuration: KVITT_PUBLIC_URL is not set …`) before opening the database; the Pi compose sets ~~`https://${KVITT_PUBLIC_HOST}:8443`~~ (revised by D-124: `https://${KVITT_PUBLIC_HOST}`), the dev stack `http://:` (`infra/dev/up.sh`; compose fallback `http://localhost:8080`). (2) `KVITT_TRUSTED_PROXY` defaults to `none` (`NO_TRUSTED_PROXY`, ignoring case and surrounding white space): no peer trusted and the value never looked up; empty or unresolvable also means no proxy (D-022 unchanged); compose sets `HA_HOST`. (3) The OpenAPI server is ~~`https://{host}:8443`~~ (revised by D-124: `https://{host}`) with server variable `host`, default `kvitt.example.invalid` (RFC 6761 reserved), description 'the owner-chosen Kvitt host name, SPEC §10.5', in `contracts/openapi.yaml` and the generated document (`openapi.rs` `PUBLIC_SERVER_URL`, `HOST_VARIABLE_DEFAULT`); operations unchanged (representational for §14.8). (4) Examples E-7 `public_url` and E-12 `link` use ~~`https://kvitt.example.invalid:8443`~~ (revised by D-124: `https://kvitt.example.invalid`). `tools/contracts-check.py` expects the `{host}` URL (`e381e37`). Tests: CLI `every_command_refuses_to_start_without_a_public_url` (`serve`, `bootstrap`, `migrate`; no database created), config `public_url_is_required`, rate limiter `the_default_none_trusts_no_peer`, `an_unresolvable_name_trusts_no_peer`, `an_ip_literal_is_trusted`; 276 server tests. `server/README.md` updated. Landed `c9abd24`, `e381e37`. | Since D-120 the old name `icehack.asuscomm.com` is Home Assistant's; a wrong built-in host is worse than none, and `caddy` named a container that no longer exists. | SPEC §10.4, §11.3, §14.3, §14.3.4 (E-7, E-12) | | D-124 | 2026-10-19 | Kvitt is served on port 443 (`https://`, no explicit port) because NPM listens on 443 and selects the proxy host by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, and there is no 8443 rule (revises D-120 point 2 and the `:8443` of D-120 to D-123). (1) Server and contracts (`a86ad24`): the OpenAPI server is `https://{host}` (`contracts/openapi.yaml`, `openapi.rs` `PUBLIC_SERVER_URL`); examples E-7 `public_url` and E-12 `link` use `https://kvitt.example.invalid` without a port; `tools/contracts-check.py` expects the portless header; the server's tests, configuration message and README name no port. (2) Bundle (`4852803`): `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` in `infra/pi/compose.yaml`; the health checks through NPM use `https:///health` on 443 (`infra/pi/lib.sh`); `infra/publish-apk.sh` prints the release server without a port; `infra/README.md` step 7 is "Router: no new rule" (verify the existing TCP 443 rule to ``); the APK-abuse stop-gap is now "disable Kvitt's proxy host in NPM", because the 443 forward also serves Home Assistant. (3) Android (`038c603`): `releaseServerUrl(host)` = `https://` (`RELEASE_SERVER_PORT` removed); the AC-2.6 `https` intent filter carries no `android:port`, which 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` for `https`, `:80` for `http`); the S-1 fixture `GOLDEN_SERVER` is `https://kvitt.example.org` (28 S-1 goldens re-recorded, only the server row changed). | Owner decision 2026-10-19: NPM serves every proxy host on 443 and picks Kvitt's by name, so a second external port adds a router rule and nothing else; the owner confirmed that external 443 already forwards to the Home Assistant Pi. | SPEC §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, §14.2.4, §14.3, E-7, E-12 | | D-125 | 2026-10-20 | The Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project: `compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`; containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi); no install script, no `/opt`, no Docker daemon or system changes (registry trust and Docker are prerequisites); operations are plain `docker compose` commands. Details: `.env` holds exactly the seven keys compose reads (`KVITT_REGISTRY`, `KVITT_TAG`, `KVITT_PUBLIC_HOST`, `HA_HOST`, `KVITT_UID`, `KVITT_GID`, `RCLONE_FOLDER_ID`); `RCLONE_REMOTE` fixed to `gdrive:` in compose; private packages → one-time `docker login gitea.lan`; each container mounts only its own secret file read-only (`create_host_path: false`); read-only root, 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; secret files the owner's, mode 0600. Equivalents of the removed scripts (`install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh`): backup `docker compose exec backup /backup.sh`; bootstrap `docker compose exec server kvitt-server bootstrap`; partner invite `docker compose exec server kvitt-server invite-partner`; deploy = backup, set `KVITT_TAG`, `pull`, `up -d`; rollback = the previous tag; APK = `scp` the APK, then `latest.json`, into `data/apk/` (old APKs removed by hand); digests via `docker image inspect`. Restore moves files on the host; only rclone and sqlite3 run in the backup container. Compose default network. The development stack is unchanged (uid 10001). Revises D-103 (scripts, `/opt/kvitt`, `.env` keys), D-105 (uid and `rclone.conf` owner on the Pi), D-117 point 5 (the digest record is dropped), D-120 point 6 (health checks via scripts dropped). Landed `6e5981d`. Rehearsed locally with Podman as uid 501: both containers healthy, `/health` 200, bootstrap, APK served, backup in three modes (no folder id, a local remote, a failing remote), the restore steps. | Owner decision 2026-10-20: 'It should be a self contained docker compose thing', 'do not install anything in any other place'. | SPEC §8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 (OQ-3), §14.7; `infra/pi/compose.yaml`, `infra/pi/.env.example`, `infra/README.md` "Pi: what the owner does", `infra/RESTORE.md`, `PLAN-M11.md` and `PLAN-M2c-pi.md` notes | +| D-126 | 2026-10-21 | App gate in NPM. Outside the home LAN, Nginx Proxy Manager closes the connection (`return 444`, nothing sent) of every request that does not carry the header `X-Kvitt-App: ` with the app key; requests from the home LAN `192.168.1.0/24` pass without it. Every route is covered (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*` included); only `/.well-known/acme-challenge/` passes, served by NPM from its own webroot, so Let's Encrypt can renew (Force SSL redirects the challenge to 443). The gate is five server-level rewrite lines at the top of the Custom Nginx Configuration (`set $kvitt_gate 0`; an `if` per accepted key; `if ($realip_remote_addr ~ "^192\.168\.1\.")`; `if ($uri ~ "^/\.well-known/acme-challenge/")`; `if ($kvitt_gate = 0) { return 444; }`), which run before location selection. The LAN check reads `$realip_remote_addr` (the TCP peer), not `$remote_addr`, because NPM's `nginx.conf` applies `real_ip_header X-Real-IP` to 10/8, 172.16/12 and 192.168/16 peers, so `$remote_addr` can be set by the client (by every client if NPM saw a Docker bridge address, security review I-5); the ACME exemption tests the normalised `$uri`, so `../` cannot reach Kvitt through it. The Rust server neither sees nor checks the header; `contracts/openapi.yaml` is unchanged (a proxy-level header, §14.3.1); the per-device bearer token stays the authentication (§8.2), and the gate is a perimeter. The app (`34e5cad`) sends `X-Kvitt-App` in `KvittApi`'s common headers (every API call, `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` when built with the property `kvitt.appKey` (`-Pkvitt.appKey`, else `kvitt.appKey` in `android/local.properties`, else empty = no header; set, it must be 16 to 128 printable ASCII characters without white space, or the build stops with a message giving the length, never the value), modelled on D-121; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey` in `:data` (`toString` hides it); `infra/publish-apk.sh` unchanged (it builds on the Mac). The key: the owner's `openssl rand -hex 32`, only in `android/local.properties` and NPM's block; never in git, `.env`, docs or logs. Rotation: a second `if` line with the new key, the new APK through the update path (FR-16), then the old line removed. Both phones must run a keyed APK before the gate goes into NPM (`infra/README.md` step 4a). Accepted consequences: the invite landing page and any browser use work only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; port 80 still answers NPM's 301, Block Common Exploits' patterns NPM's 403, and the TLS handshake shows the certificate; nothing protects against a bandwidth flood (not winnable at home). Caveat: the LAN check needs NPM to see real client addresses (a bridge address matches nothing, a router-rewritten address everything; NAT hairpin's `192.168.1.1` is inside the /24); the owner's acceptance test is the landing page on home Wi-Fi (shows) and with Wi-Fi off (connection error). Rehearsed on the Mac in NPM v2.16.0's openresty 1.31.1.1 with its includes. | Owner decision 2026-10-21: "have auth on everything", the key "is the app itself", the owner controls the APK's distribution (first install on both phones by hand), and "the landing page should work if you are on the same LAN, that is OK. In this case, the 'secret' is that you have my wifi password." An NPM access list cannot check a header, and its basic auth would take the `Authorization` header the bearer token needs. | SPEC AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1; `infra/README.md` steps 4, 4a, 5, 6, Operations; `android/app/build.gradle.kts`, `KvittApi.kt`, `PictureAuth.kt`, `DataModule.kt`, `android/README.md` | diff --git a/infra/README.md b/infra/README.md index 9e8c786..72bb043 100644 --- a/infra/README.md +++ b/infra/README.md @@ -164,7 +164,7 @@ The key goes to the Pi as `~/kvitt/secrets/fcm-service-account.json` ("Pi: what | Gitea Actions (runner label `host`, a Raspberry Pi) | `.gitea/workflows/build-images.yaml`: builds `kvitt-server` and `kvitt-backup` for linux/arm64, pushes them to `gitea.lan/jacobh2/...` tagged `vMAJOR.MINOR.PATCH` and `latest`, pushes the git tag | Gitea, on a push to `server/**`, `infra/backup/**`, `.gitea/workflows/**`, or by hand (Actions, "Run workflow") | | Owner's Mac | `infra/build-images.sh [tag]` (fallback image build), `infra/publish-apk.sh` (release APK into `infra/pi/out/apk/`) | The owner (or an agent, for the APK build only) | | Kvitt Pi, `/home/jacobhagstedt/kvitt/` | `compose.yaml`, `.env`, `secrets/`, and the bind-mounted `data/` (SQLite, pictures, `apk/`), `backups/`, `import/`; nothing outside this folder (D-125) | The owner, in their own SSH session, with plain `docker compose` commands | -| Home Assistant Pi | Nginx Proxy Manager (NPM): a proxy host for `` with its own Let's Encrypt certificate, TLS on 443, plain HTTP to `:8080` over the LAN (D-120) | The owner, in NPM's web UI (step 4) | +| Home Assistant Pi | Nginx Proxy Manager (NPM): a proxy host for `` with its own Let's Encrypt certificate, TLS on 443, plain HTTP to `:8080` over the LAN (D-120); the app gate closes every request from outside the LAN without the app key (D-126) | The owner, in NPM's web UI (steps 4 and 4a) | | Router (ASUS) | No new rule: the existing external TCP 443 forward to the Home Assistant Pi (NPM, for Home Assistant) serves Kvitt too, NPM picks the proxy host by name; external 80 already reaches NPM for Let's Encrypt; nothing is forwarded to the Kvitt Pi (D-124) | The owner checks it (step 5) | Stack (`pi/compose.yaml`, project name `kvitt`): `server` (publishes `8080:8080` on the Pi's LAN interfaces, the @@ -281,21 +281,20 @@ The token is interpolated into the login step's `run` line (`printf '%s' "${{ se (`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) on act_runner 0.6.1 and failed with "password is empty"; act_runner masks the secret in the log. That first run got as far as the login (container, `apk add`, checkout all worked). `kvitt_cicd` needs only the `write:package` scope: the tag push uses the credentials `actions/checkout` stored (the job's own token). -If the "Tag and Push Version" step fails with a permission error, the job token cannot push tags on this Gitea; -then add `write:repository` to the token (owner decision below). The step "Print image digests" prints each +The tag push works that way (run 69 pushed `v1.0.0`, 2026-10-19), so the token needs no `write:repository`. The step "Print image digests" prints each image's `sha256:` digest, which must match the digest the Pi pulled (Operations, "Image digests"; D-117). The builder's `buildkitd.toml` is read only when the builder is created. After changing the registry setting in the workflow, remove the builder once on the runner Pi: `docker buildx rm local-http-builder` (this also drops its build cache, so the next server build is a full one). -**Risk: the runner is a Raspberry Pi 3 with 1 GB RAM.** The server image compiles about 232 Rust crates, -aws-lc-sys's C code among them. The workflow passes `--build-arg CARGO_BUILD_JOBS=1`; it takes effect only once -`server/Dockerfile` declares `ARG CARGO_BUILD_JOBS` in its build stage (requested from the server's owner; until -then buildx warns about an unused build argument and cargo uses all four cores, which may run out of memory). The -Dockerfile already keeps the cargo registry and target directory in BuildKit cache mounts, so only the first build -is a full one. The first build's duration on the Pi is unknown (a cold arm64 build takes 48 s on the M4 Mac). If -it fails for memory, add swap on the runner Pi, or build on the Mac with `infra/build-images.sh`. +**The runner is a Raspberry Pi 3 with 1 GB RAM.** The server image compiles about 232 Rust crates, aws-lc-sys's C +code among them. The workflow passes `--build-arg CARGO_BUILD_JOBS=1` and `server/Dockerfile` declares +`ARG CARGO_BUILD_JOBS` in its build stage (`b4d9467`), so cargo builds one crate at a time on the Pi. The Dockerfile +keeps the cargo registry and target directory in BuildKit cache mounts, so only the first build is a full one: run +69 (2026-10-19) built `kvitt-server` in 2 h 42 min and `kvitt-backup` in 5 min 20 s with it (a cold arm64 build +takes 48 s on the M4 Mac); later runs reuse the builder's cargo cache. If a build fails for memory, add swap on the +runner Pi, or build on the Mac with `infra/build-images.sh`. ### Mac fallback: `infra/build-images.sh [tag]` @@ -424,10 +423,22 @@ SSL tab: | HSTS Sub-domains | off | | Trust Upstream Forwarded Proto Headers | off | -Advanced tab, Custom Nginx Configuration: paste this block exactly (all of it, comments included): +Advanced tab, Custom Nginx Configuration: paste this block exactly (all of it, comments included), with the app key in +place of `KVITT_APP_KEY`. Before the app gate first goes in, do step 4a items 1 to 3 (both phones on an APK that +carries the key); until then paste the block without the five lines from `set $kvitt_gate 0;` to `return 444;`. ```nginx -# Kvitt (SPEC §10.5.2, D-120). NPM, Proxy Host , Advanced tab, Custom Nginx Configuration. +# Kvitt (SPEC §10.5.2, D-120, D-126). NPM, Proxy Host , Advanced tab, Custom Nginx Configuration. +# App gate (SPEC §8.5, D-126): outside the home LAN only a request carrying the key built into the app passes; +# everything else gets the connection closed (444: nothing sent, nothing to fingerprint). Put the key from +# android/local.properties (kvitt.appKey) in place of KVITT_APP_KEY. To rotate, add a second if-line with the new key. +# The LAN check reads $realip_remote_addr, the address that really connected: NPM replaces $remote_addr with the +# request's own X-Real-IP header for private peers, so $remote_addr could be forged. Let's Encrypt's renewal passes. +set $kvitt_gate 0; +if ($http_x_kvitt_app = "KVITT_APP_KEY") { set $kvitt_gate 1; } +if ($realip_remote_addr ~ "^192\.168\.1\.") { set $kvitt_gate 1; } +if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; } +if ($kvitt_gate = 0) { return 444; } # Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413. client_max_body_size 6m; # No long-poll in the API (SPEC §14.3); 60 s between two reads from the server. @@ -473,7 +484,8 @@ the Kvitt Pi: curl -fsS --resolve :443: https:///health ``` -It prints `{"status":"ok",...}` (no `-k`: this also checks the certificate). +It prints `{"status":"ok",...}` (no `-k`: this also checks the certificate). The Kvitt Pi is on the home LAN, so the +app gate lets it through without the key. **Why the block has its own `location /`.** nginx drops every `add_header` inherited from the server level in a location that has an `add_header` of its own, and NPM's default `location /` has one (`X-Served-By`), which would @@ -503,7 +515,67 @@ browser. **Accepted: NPM trusts `X-Real-IP` from the LAN.** NPM's `nginx.conf` takes the client address from `X-Real-IP` when the connection comes from 10/8, 172.16/12, 192.168/16 (and the CDN ranges it downloads), so a device on the home LAN can choose its own client address for the rate limits. Internet clients arrive with their public address (the -router's forward keeps the source address), so their `X-Real-IP` is ignored. +router's forward keeps the source address), so their `X-Real-IP` is ignored. The app gate reads +`$realip_remote_addr` (the address that really connected), so a forged `X-Real-IP` cannot open it. + +### 4a. The app key and the app gate (D-126) + +Outside the home LAN, NPM lets a request through only when it carries the header `X-Kvitt-App: `, and closes +the connection of every other request without answering (nginx's 444). Every path is covered: `/health`, +`/setup/status`, `/invites/accept`, `/i/`, `/apk/*`. The release app sends the header on every request once +its build carries the key (`kvitt.appKey`). On the home Wi-Fi (`192.168.1.0/24`) everything passes without it: the +"secret" there is the Wi-Fi password. Each phone's own token stays the authentication (SPEC §8.2); the gate keeps +scanners and anyone without the app away from the server altogether. The Kvitt server neither sees nor checks the +header. + +1. On the Mac, once: create the key in `android/local.properties` (git-ignored). The key never goes into git, into + `infra/pi/.env`, into a chat or into any file other than `android/local.properties` and NPM's block: + + ```bash + cd /Users/jhag/projects/kvitt + grep -q '^kvitt.appKey=' android/local.properties || printf '\nkvitt.appKey=%s\n' "$(openssl rand -hex 32)" >> android/local.properties + ``` + + The build accepts 16 to 128 printable ASCII characters without white space and stops otherwise (its message gives + the length, never the value). Keep to the hex key: a `"`, `\`, `$` or `;` in it would break NPM's line. +2. Build the APK after the key is set: `infra/publish-apk.sh` (the build reads `kvitt.appKey` from + `android/local.properties`; an APK built before that carries no key and sends no header). Copy it to the Pi + ("Publishing an APK") and install it on both phones: the first time by hand, later through the app's update + banner. +3. Both phones must run an APK from item 2 or later before the gate goes into NPM: an APK without the key cannot + reach the server from outside the home LAN, its update download included (on the home Wi-Fi it still can). +4. In NPM: Hosts, Proxy Hosts, the Kvitt row's menu (three dots), Edit, Advanced tab: paste the block of step 4 + again, with the key in place of `KVITT_APP_KEY` (the value after `kvitt.appKey=` in `android/local.properties`), + Save. The row still shows Online (yes/no). +5. Acceptance test, on the phone: (a) on the home Wi-Fi, open `https://kvitt.suorra.duckdns.org/i/TEST` in Chrome: + the Kvitt invite page shows (yes/no); (b) the same with Wi-Fi off: Chrome shows a connection error, for example + "This site can't be reached" (yes/no); (c) still with Wi-Fi off, open Kvitt's About screen (S-22): it shows the + server version (yes/no). If (a) is no, NPM does not see LAN clients' addresses (below): take the five gate + lines out again and tell the orchestrator. If (b) is no, NPM sees internet clients as LAN addresses and the gate + is open to everyone: tell the orchestrator. + +**If NPM does not see the real client address.** The LAN check relies on NPM seeing the address that really +connected, the same question as security review I-5 (Operations, "Client IPs are real"). If NPM saw a Docker bridge +address (`172.*`) for every client, the LAN check would match nothing: the page fails at home too (item 5a no). If +the router rewrote internet clients to its own LAN address, it would match everything (item 5b no). NAT hairpin: a +phone at home that asks for the public name may reach NPM through the router and appear as the router's LAN address +(`192.168.1.1`); that is still in `192.168.1.0/24`, so it passes as intended. Because the check reads +`$realip_remote_addr`, a forged `X-Real-IP` header cannot fake a LAN address in either case. + +**What still answers before the gate.** NPM's own server-level rules come before the block: on port 80 every request +gets Force SSL's 301 to `https://`; a request matching "Block Common Exploits" patterns gets NPM's 403; the TLS +handshake shows the certificate for ``. Let's Encrypt's challenge (`/.well-known/acme-challenge/`, +served from NPM's own folder, never from Kvitt) passes the gate, or the certificate could not be renewed; the check +reads the normalised path `$uri`, so `/.well-known/acme-challenge/../../health` does not get through. + +**Rotating the key** (a leaked APK leaks the key until then): put a new key in `android/local.properties` in place of +the old one (item 1's `openssl rand -hex 32`); in NPM add a second line +`if ($http_x_kvitt_app = "") { set $kvitt_gate 1; }` next to the old one and Save; publish an APK built with +the new key and update both phones; then remove the old key's line in NPM and Save. + +Accepted (D-126): the invite landing page and any browser use work only on the home Wi-Fi; `/health` from outside +without the header gets a closed connection, so the outside check is the app's About screen or +`curl -H "X-Kvitt-App: $KEY"`; nothing at home stops a flood of traffic. ### 5. Router: no new rule @@ -511,13 +583,16 @@ No new rule (D-124): external TCP 443 already reaches the Home Assistant Pi (NPM Kvitt's proxy host by name on the same port. Verify it exists: ASUS router web UI, WAN, Virtual Server / Port Forwarding: the list has a TCP rule with External Port `443` and Internal IP Address `` (yes/no). Nothing is forwarded to the Kvitt Pi: remove a rule to `` if you made one for the Caddy version, and a rule with -External Port `8443` if you made one for the earlier NPM version (D-120). Then on the phone with Wi-Fi off, open -`https:///health` in Chrome: it shows `"status":"ok"` without a certificate warning (yes/no). +External Port `8443` if you made one for the earlier NPM version (D-120). Then the outside check of step 6. ### 6. From outside, first backup, bootstrap -On the phone with Wi-Fi off (or from the Mac), `https://kvitt.suorra.duckdns.org/health` shows `"status":"ok"` -(yes/no). Then on the Pi: +From outside: before the app gate (step 4a), on the phone with Wi-Fi off, `https://kvitt.suorra.duckdns.org/health` +in Chrome shows `"status":"ok"` without a certificate warning (yes/no). With the gate in place Chrome gets a +connection error there by design; the outside check is then Kvitt's About screen (S-22) on the phone with Wi-Fi off, +which shows the server version (yes/no), or, from a computer outside the home network, +`curl -fsS -H "X-Kvitt-App: $KEY" https://kvitt.suorra.duckdns.org/health` with `KEY` set to the value of +`kvitt.appKey`. On the home LAN, the Kvitt Pi's `curl --resolve` checks included, no key is needed. Then on the Pi: ```bash cd ~/kvitt @@ -528,7 +603,8 @@ docker compose exec server kvitt-server bootstrap # first install only: prin The invite link starts with `https:///i/` (no port). The release app opens such links and connects to that host only once its Android build carries the host name (`kvitt.publicHost`, D-121) and the default port 443 (D-124; builds made for port 8443 do not match a link without a port): tell the orchestrator before pairing a -release build. The partner's invite, once the admin is paired: `docker compose exec server kvitt-server invite-partner`. +release build. With the app gate (D-126) the link's landing page opens in a browser only on the home Wi-Fi; the app +itself opens the link anywhere. The partner's invite, once the admin is paired: `docker compose exec server kvitt-server invite-partner`. ### 7. Deploying a new version @@ -571,7 +647,8 @@ up -d`; if a migration had partially applied, restore the pre-deploy backup (`in | Backup now | `docker compose exec backup /backup.sh` (ends with `backup: ok `) | | Upgrade | Step 7: backup, set `KVITT_TAG` in `.env`, `docker compose pull && docker compose up -d` | | Roll back | Set the previous `KVITT_TAG` in `.env`, `docker compose pull && docker compose up -d` | -| Health through NPM | `curl -fsS --resolve :443: https:///health` | +| Health through NPM | `curl -fsS --resolve :443: https:///health` (the Kvitt Pi is on the LAN: no key needed) | +| Health from outside (D-126) | Kvitt's About screen (S-22) on the phone with Wi-Fi off shows the server version; or, outside the home network, `curl -fsS -H "X-Kvitt-App: $KEY" https:///health`. Without the header curl fails with an empty reply: the app gate closed the connection | | Headers through NPM | `curl -sS -D - -o /dev/null --resolve :443: https:///health` lists `content-security-policy`, `x-content-type-options`, `x-frame-options`, `referrer-policy`, `strict-transport-security` once each and no `x-served-by` | | Certificate expiry and issuer | `echo \| openssl s_client -connect :443 -servername 2>/dev/null \| openssl x509 -noout -enddate -issuer` (NPM renews it; NPM, SSL Certificates shows the same) | | Partner invite | `docker compose exec server kvitt-server invite-partner` | @@ -579,22 +656,25 @@ up -d`; if a migration had partially applied, restore the pre-deploy backup (`in | Memory | `docker stats --no-stream` (server RSS under 100 MB with both phones syncing) | | Disk | `df -h ~` and `du -sh ~/kvitt/*`; SQLite a few MB, pictures under 1 MB, APKs about 10 MB each since R8, 5 kept | | Image digests | `docker compose images` names the images; `docker image inspect --format '{{.RepoDigests}}' gitea.lan/jacobh2/kvitt-server:` (and `kvitt-backup`) prints the `sha256:` digest to compare with the workflow's | -| Client IPs are real (once, after step 5) | Open `https:///health` on the phone with Wi-Fi off, then within a minute on the Kvitt Pi `sudo apt install -y conntrack` (once) and `sudo conntrack -L -p tcp --orig-port-dst 8080`: in every line the first `src=` is `` and the second half (from the second `src=`) has `dst=`, no `172.*` there, so the server sees NPM's address and trusts its `X-Forwarded-For`. If the second half shows a `172.*` address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator | +| Client IPs are real (once, after step 5) | With the phone's Wi-Fi off, open Kvitt's About screen (S-22) (before the app gate: or `https:///health` in Chrome), then within a minute on the Kvitt Pi `sudo apt install -y conntrack` (once) and `sudo conntrack -L -p tcp --orig-port-dst 8080`: in every line the first `src=` is `` and the second half (from the second `src=`) has `dst=`, no `172.*` there, so the server sees NPM's address and trusts its `X-Forwarded-For`. If the second half shows a `172.*` address instead, every internet client shares one per-IP limit (SPEC §8.6): tell the orchestrator | | Restore | `infra/RESTORE.md` | **APK downloads (security review L-3, D-119).** `GET /apk/files/` is public (the landing page links it) and about 11 MB per response; the server allows 10 per minute per client IP. If one address still fetches `/apk/files/` over and over (NPM's access log), disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's -menu, Disable) until it stops; the router's 443 forward also serves Home Assistant, so leave it in place. +menu, Disable) until it stops; the router's 443 forward also serves Home Assistant, so leave it in place. Since the +app gate (D-126) a request from outside without the app key never reaches the server, so this concerns the home LAN +or a leaked key (rotate it, step 4a). -### Owner decisions (open) +### Owner decisions (answered) -Backup encryption (security review M-1) is decided: no rclone `crypt`, the `drive` scope stays (SPEC §8.7, 2026-10-19). - -1. **Gitea token scope.** Does the workflow's tag push work with `kvitt_cicd` limited to `write:package`? If not, - is adding `write:repository` acceptable? -2. **Kvitt's host name (D-120).** Which name is ``? It goes into the release app and - `docs/HANDOFF.md`. +* Backup encryption (security review M-1): no rclone `crypt`, the `drive` scope stays (SPEC §8.7, 2026-10-19). +* Gitea token scope: `kvitt_cicd` with `write:package` only is enough; the workflow's tag push works with it (run 69, + 2026-10-19). +* Kvitt's host name (D-120): `kvitt.suorra.duckdns.org` (2026-10-19). NPM's proxy host for it is Online with a Let's + Encrypt certificate; the router's existing external 443 forward to the Home Assistant Pi (NPM) serves it, and there + is no 8443 rule (D-124). +* App gate (D-126, 2026-10-21): outside the home LAN every request needs the key built into the app (step 4a). ### Troubleshooting