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: <key> (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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+23
-18
@@ -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: <key>`, 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 `<kvitt-pi-address>`.
|
||||
@@ -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/<id>.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-pi-address>: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 <stamp>` (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 <stamp>` (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
|
||||
|
||||
@@ -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://<Mac LAN address>:<port>` (`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://<host>`, 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://<KVITT_PUBLIC_HOST>/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 `<HA_HOST>`); 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://<host>` (`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: <key>` 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` |
|
||||
|
||||
Reference in New Issue
Block a user