Compare commits

..

23 Commits

Author SHA1 Message Date
jeremy 6e94a0c998 feat(hobbs-organizer): v1.4.0 per-TV morning routine (admin TVs tab) + photo app 1.2.1
- Admin TVs tab: wake time/days, YouTube Music playlist, Shield volume per TV,
  pushed via 5-min TV check-ins (/api/tv/config, /api/tv/report, LAN-only)
- com.dayline.photos 1.2.1: MorningActivity (screen on, volume, playlist),
  calendar alarm at wake time, Doze-safe chained check-ins
- Both TVs configured for 06:30 daily with the family playlist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 22:48:50 -06:00
jeremy 2d16ded0c1 docs: 6:30 AM organizer wake enabled on .186 too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 22:17:49 -06:00
jeremy 00e0cc42cf feat(hobbs-organizer): v1.3.0 photo gallery + Shield photo app 1.1, slideshow 15s
- /photo/gallery: D-pad grid, OK opens full screen, arrows browse, Back returns
- 400px gallery tiles cached per photo (sync + uploads)
- com.dayline.photos 1.1: launcher tile opens the gallery, takes remote input,
  Back steps through WebView history; screensaver unchanged
- slideshow interval 20s -> 15s (live settings)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 21:19:24 -06:00
jeremy 5cfd2de432 docs: bart left Swarm + fstab nofail; photo app on .179
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 20:40:14 -06:00
jeremy f4f64eb5d4 docs: swarm-leave DNS side effect on hf-pdocker-02 and its fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 19:32:38 -06:00
jeremy 0a3ec27774 feat(hobbs-organizer): Shield photo app (com.dayline.photos) + monitoring, links, .100 boot fix
- shield/photos-source: launcher tile + DreamService screensaver showing /photo
  (locked-down WebView, default URL baked in, files/base_url override)
- build-photos-apk.sh: containerized build on hf-pdocker-02 (JDK17, SDK 35);
  signing key kept off-repo on .100 + NAS
- Hobbs-Family-Photos-1.0.apk installed on .186 (canary) and set as its screensaver
- Prometheus probes for /api/health and /api/health/photos; Farm Manager links + group
- CLAUDE.md: .100 left the stale Swarm and its NFS mount waits for network (11-03 outage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 19:28:12 -06:00
jeremy c2e5c1d9dc feat(hobbs-organizer): v1.2.0 photo frame (/photo) with Google Photos shared albums
- /photo full-screen slideshow (crossfade, blurred backdrop or fill, shuffle, date)
- admin Photos tab: add shared-album links (check, sync now, status/stale warnings),
  slideshow options, browser-resized uploads
- 6-hourly album sync scraping the share page (Library API no longer exposes
  user albums), caches 1920px + 64px copies in /data/photos
- /api/health/photos -> 503 when an album has not synced for 48h
- history/restore covers photo settings; min admin password length 6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 19:18:02 -06:00
jeremy 8715589cbb feat(hobbs-organizer): v1.1.0 password-protected LAN-only admin for calendar links
- /admin: first-visit password setup (scrypt), signed 30-day session cookie,
  calendars add/remove/reorder/rename/color/hide, masked URLs, per-feed test,
  history (20) with restore; /photo-admin -> /admin#photos placeholder
- settings in local /data (settings.json + auth.json + history/), seeded once
  from the calendar secret; dashboard + health read from settings
- start.mjs drops privileges after reading secrets and runs a front proxy that
  stamps the real client IP; admin refuses non-LAN and proxied requests
- deploy: HOBBS_DATA_DIR=/var/lib/hobbs-organizer/data, image 1.1.0 (1.0.0 kept)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 18:55:00 -06:00
jeremy e9c7e9df1a feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340
- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 18:27:22 -06:00
jeremy 122ea392d1 infra: 2026-10-26 version audit, upgrades, UniFi re-removal
- Synapse v1.163.2, MAS 1.27.1, Traefik v3.7, node-exporter v1.12.1 (all
  nodes), cadvisor -> ghcr.io v0.60.6, alertmanager-receiver 2026.9.23,
  Gitea 1.26.4, Jellyfin 12.3, Seerr v3.5.1
- Watchtower -> nickfedor/watchtower fork, twice daily (04:00/16:00)
- Monitoring legacy node-exporter gated behind a compose profile
- unifi-os-server on .100 removed again; compose file renamed so it
  cannot be revived
- CLAUDE.md: Known Issues #40-43, service tree updates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQhgEUqcaV4WM2S4hfXgHD
2026-10-26 16:27:43 -05:00
jeremy f2374506b4 infra: pin hermes, add llamacpp backend + pinned agent Dockerfiles
- hermes: pin nousresearch/hermes-agent:v2026.8.18 (was :latest, a
  rolling dev build that Watchtower never re-pulled -> 7 weeks stale).
- llamacpp: add compose for the llama.cpp inference backend serving
  hermes on .150:8081 (q8_0 KV cache; 2.4x ollama on the same GGUF).
- ai-agents: add reconstructed, version-pinned Dockerfiles for margaret
  and spirit (openclaw was built from @latest with no Dockerfile stored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-06 19:06:06 -05:00
jeremy beb727528a docs: UniFi controller consolidation, STP fix, NFS recovery
- Remove duplicate unifi-os-server container from .100; sole UniFi
  controller is now the .150 Podman instance (UI :11443, inform :8080).
  Data preserved; compose dir kept.
- Document STP root-cause/fix for wifi client drops: version mismatch
  (stp vs rstp) + default priorities -> root flapping; fixed by RSTP
  everywhere + .159 pinned root @4096.
- Known Issue #20: correct the stale .100:11443 blackbox probe note -
  the probe DID exist in the live config and was removed 2026-10-06
  (backup saved) + Prometheus reloaded; UnifiDeviceDown rule is inert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-06 19:06:05 -05:00
jeremy 9a81138cc9 docs: QR sign-in confirmed working end-to-end
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:36:27 -05:00
jeremy 84021278f9 docs: MAS deployment - CLAUDE.md service lists and known issues 24,27,28,29
- Synapse DB history rewritten (SQLite -> local -> PostgreSQL, and why)
- #27: Synapse no longer serves /login; Traefik compat routing is load-bearing
- #28: MAS tags are not v-prefixed; distroless; /health only on :8021
- #29: MAS signing keys need a vault home (vault currently locked out)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:21:21 -05:00
jeremy a0bbf801f1 feat: enable MSC4108/MSC4388 - QR sign-in now advertised
org.matrix.msc4108 = True (was False); rendezvous endpoint live (400 not 404).
msc4388_mode: authenticated so unauthenticated internet parties cannot open
rendezvous sessions. All bots syncing, 0 errors.
New containers total ~263MB (mas 39MB, mas-db 40MB, synapse-db 184MB).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:18:10 -05:00
jeremy 8825cc3d4a feat: publish MAS at auth.hobbs.farm; route login/logout/refresh to MAS
Under MAS, Synapse returns M_UNRECOGNIZED for the login endpoints, so the
mess-auth-compat router (priority 1000) sends /_matrix/client/*/login,
/logout and /refresh to MAS while everything else stays on Synapse.

Cloudflare A record for auth.hobbs.farm already existed (98.160.103.126,
DNS-only). Blackbox probes added for MAS internal health (:8021) and the
public discovery endpoint; all four matrix-related probes UP.

Snapshot of the node's traefik dynamic config committed for reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:15:57 -05:00
jeremy 9dff3c2d95 feat: synapse auth delegated to MAS; 20 users/53 tokens/53 devices migrated
Verified: auth_metadata 200, migrated password authenticates via MAS compat
layer, migrated access tokens still valid against Synapse, all bots syncing.
Downtime 55s (20:09:17Z-20:10:12Z).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:12:27 -05:00
jeremy f9a024ce10 docs: syn2mas dry-run results and count reconciliation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:09:07 -05:00
jeremy af2b560686 feat: deploy matrix authentication service 1.23.0 on :8020
Config at /mnt/docker-data/configs/mas/config.yaml (node-only, holds signing keys).
Health on :8021 (distroless image - no container healthcheck possible).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:07:23 -05:00
jeremy 89eddfc134 chore: synapse ported sqlite -> postgres (20 users/93888 events/65 devices verified)
Node-side config change; homeserver.yaml is not tracked in the repo.
Downtime 2m35s. SQLite file retained inert at /var/lib/synapse/homeserver.db.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:04:32 -05:00
jeremy d7ca70db32 feat: add mas-db postgres for matrix authentication service
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:01:45 -05:00
jeremy a220e7903b feat: add postgres 16 for synapse (local disk, C collation) ahead of MAS
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:59:35 -05:00
jeremy 986f219135 docs: matrix auth inventory and MAS QR sign-in plan
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:57:55 -05:00
141 changed files with 18373 additions and 109 deletions
+73 -26
View File
@@ -23,14 +23,15 @@ This repository manages a **homelab infrastructure** with self-hosted services r
hf-pdocker-01 (23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, hardware repaired)
├── [COMPOSE] Frigate NVR (:5000) -- Coral USB TPU (edgetpu), ~11ms inference, 13 cameras active
├── [COMPOSE] Frigate-Notify v2 (:5199) -- MQTT->AI->Matrix notifications
├── [REMOVED] clip-service -- decommissioned 2026-07-13 (CLIP embeddings; ~2GB RAM freed, no config references from frigate-notify existed)
├── [COMPOSE] clip-service (:5200) -- **RUNNING, and actively used.** CORRECTED 2026-09-21: the 2026-07-13 decommission note below was wrong for the current deployment — it was redeployed and the *live* `frigate-notify/docker-compose.yml` sets `CLIP_SERVICE_URL=http://localhost:5200`, with live `app/recognition.py` calling `/embed`. ~1.2GB RSS. **Do not remove without first rewiring frigate-notify's recognition pipeline.**
├── [COMPOSE] Home Assistant (:8123)
├── [COMPOSE] ESPHome (:6052)
├── [COMPOSE] ESPHome (:6052) -- manages **Pool Value Controls** (ESP8266 at static **192.168.86.6**, drives pool pumps/valves; only ESPHome device registered in HA). Also polelightpond (.14) and living_room_lights (.153) configs, NOT in HA. See Known Issue #39.
├── [COMPOSE] Mosquitto (:1883, :8883)
├── [COMPOSE] Synapse/Matrix (:8008) + synapse-db (postgres) + wellknown (:8010)
├── [COMPOSE] Synapse/Matrix (:8008) + wellknown (:8010) + synapse-db (**postgres:16-alpine, LOCAL /var/lib/synapse-db, C collation**) -- Synapse data on LOCAL /var/lib/synapse. Migrated SQLite->PostgreSQL 2026-08-20 (required by syn2mas). **Auth is delegated to MAS** -- Synapse no longer serves /login.
├── [COMPOSE] **MAS / Matrix Authentication Service (:8020 web, :8021 health) + mas-db (postgres:16-alpine, LOCAL /var/lib/mas-db)** -- OAuth2/OIDC provider for Matrix. Public: auth.hobbs.farm. Deployed 2026-08-20 to enable QR sign-in (MSC4108). QR sign-in **verified working end-to-end 2026-08-20**. **Every Matrix login now depends on this service.**
├── [COMPOSE] Guacamole (:8199) + guacd + guacamole-db (mysql, LOCAL /var/lib/guacamole-db)
├── [COMPOSE] AI Agents (margaret:18790, spirit:18795 [infra ops])
├── [COMPOSE] Traefik (reverse proxy, ports 80/443/8090)
├── [COMPOSE] AI Agents (margaret:18790, spirit:18795 [infra ops]) -- **OpenClaw 2026.7.1-2, images PINNED via Dockerfile ARG** (`compose/hf-pdocker-01/ai-agents/{margaret,spirit}/Dockerfile`). Both primary `anthropic/claude-sonnet-4-6`, fallback `google/gemini-2.5-flash`.
├── [COMPOSE] Traefik (reverse proxy, ports 80/443/8090) -- `traefik:v3.7` (3.7.16, bumped from v3.6 2026-10-26; 3.7 migration notes are k8s-only for us)
├── [REMOVED] AdGuard Home -- decommissioned 2026-07-13 (conf dir had been empty since 2026-05-17, DNS engine never ran, LAN uses router DNS). Ports 53/3000 freed on .192.
├── [COMPOSE] Monitoring (Grafana :3001, Prometheus :9095, Alertmanager :9093, InfluxDB :8086, Blackbox :9115, matrix-receiver :9094)
├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server
@@ -38,35 +39,40 @@ hf-pdocker-01 (23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, ha
├── [COMPOSE] Rongorongo decipherment engine -- 2 containers (rongorongo-web :8181, rongorongo-api). Server-synced state at /mnt/docker-data/configs/rongorongo/data. Traefik basicAuth on rongorongo.hobbs.farm.
├── [COMPOSE] LinkedStorm (:8300 pipeline, :8310 api, cron no-port) -- whole-home audio assistant. VAD+Whisper STT+speaker-ID. Deployed 2026-05-28. Public: linkedstorm.hobbs.farm -> :8310.
├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04
├── [COMPOSE] **Onyx (RAG/search) -- 10 containers, `/mnt/docker-data/compose/hf-pdocker-01/onyx`.** NOT decommissioned (the bart entry below refers to an older, separate deployment). Public: **onyx.hobbs.farm** -> nginx :3005. Custom build: `gitea_patch.py` + `unstructured_patch.py` index Gitea (`git.hobbs.farm/api/v1`) and call unstructured-api on .100:8000. LLM -> `192.168.86.150:18020/v1` (`qwen3.8-27b`) — **dead while .150 is down.** `onyx-opensearch-1` **stopped 2026-09-21** (`ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false`, held no Onyx data, nothing `depends_on` it; freed ~1.1GB). **2026-10-10: it came back on its own after .192's crash/reboot (1.5GB) — now stopped, `restart=no`, and gated behind compose `profiles: ["opensearch"]` so neither reboots nor `compose up` revive it.** Restart with `docker compose --profile opensearch up -d opensearch` if the flag is ever flipped.
├── [COMPOSE] hermes -- **moved here from .150** (host network, `/mnt/docker-data/compose/hf-pdocker-01/hermes`, pinned `v2026.8.18`). Points at `http://192.168.86.150:18020/v1` model `qwen3.8-27b`; that endpoint is **dead**, so every call burns 200s x3 retries and fails. Note this is a different host:port than the `.150:8081` llamacpp setup documented in the GPU Machine section.
├── [COMPOSE] litellm (:4000?) + litellm-db -- LLM proxy (see Known Issue #15)
├── [COMPOSE] synapse-admin -- Synapse admin UI
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
├── [CRON] sync-frigate-to-nas (daily 2am), cleanup-frigate-media (weekly Sun 3am), restart-frigate (4x daily 4am/10am/4pm/10pm, log ~/frigate-restart.log)
├── 5TB USB disk at /mnt/frigate-recordings (Frigate recordings storage)
bart (15GB RAM, i5-4570T, HDD)
├── [COMPOSE] Media Stack (Jellyfin, Sonarr, Radarr, SABnzbd, NZBHydra2, Jellyseerr) -- Sonarr config on LOCAL /var/lib/sonarr, NZBHydra config on LOCAL /var/lib/nzbhydra (2026-07-13), **Jellyfin config on LOCAL /var/lib/jellyfin (2026-07-19)** -- all moved off NFS after SQLite/lock crashes. **Jellyfin PINNED to jellyfin/jellyfin:2026060106 (v12.0.0 unstable)** -- its DB is on the v12 schema (ExtraIds column dropped); :latest is still 10.11.x and breaks on it. Do not unpin until official stable >= 12.
├── [REMOVED] Onyx -- fully decommissioned 2026-07-13 (had been down since ~2026-06-08 unnoticed; containers, volumes, images removed)
├── [COMPOSE] Media Stack (Jellyfin, Sonarr, Radarr, SABnzbd, NZBHydra2, **Seerr** -- replaced Jellyseerr 2026-10-17, `seerr/seerr:v3.5.1`, config LOCAL /var/lib/seerr) -- Sonarr config on LOCAL /var/lib/sonarr, NZBHydra config on LOCAL /var/lib/nzbhydra (2026-07-13), **Jellyfin config on LOCAL /var/lib/jellyfin (2026-07-19)** -- all moved off NFS after SQLite/lock crashes. **Jellyfin on official STABLE 12.x, pinned to the dated tag `jellyfin/jellyfin:12.3.20261023-013714` (12.3.0, 2026-10-26).** History: ran the 2026060106 unstable build from 2026-07-14 because the DB had been migrated to the v12 schema (10.11.x cannot read it); stable 12.0 shipped 2026-09-08, moved to 12.2 on 2026-10-17, 12.3 on 2026-10-26 (DB backup `/var/lib/jellyfin-backup-20261026-pre123`). **Never go below 12.**
├── [REMOVED] Onyx -- this *bart* deployment was decommissioned 2026-07-13. **A different, newer Onyx now runs on hf-pdocker-01 (see above) — 'Onyx is gone' is false.**
├── [REMOVED] OpenVAS -- decommissioned 2026-07-19 per user (containers, volumes incl. PG DB, image removed; ~39GB freed with other cleanup). Compose dir kept for reference. History: pg_upgrade crash-loop (Known Issue #21), DB wiped fresh 2026-07-15.
├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
├── [COMPOSE] Watchtower
├── [COMPOSE] Watchtower -- `nickfedor/watchtower` (maintained fork; containrrr is archived), runs **twice daily 04:00 + 16:00** (`WATCHTOWER_SCHEDULE=0 0 4,16 * * *`)
hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD)
├── [COMPOSE] UniFi OS Server (container `unifi-os-server`, HTTPS on host :11443)
├── [REMOVED] UniFi OS Server -- the `unifi-os-server` container (ghcr.io/lemker/unifi-os-server, v5.1.15) was decommissioned 2026-10-05. It was a **duplicate/idle** controller -- the real UniFi controller is the **Podman UniFi OS Server on the Windows box (.150)**, which manages all UniFi devices (e.g. `downstairsswitch` US-24-250W at 192.168.86.159, inform `http://192.168.86.150:8080/inform`). Container + image + network removed; ports 11443/8080/3478/etc freed on .100. **Data preserved** at `/var/lib/unifi-os/*` (971M local) + `/mnt/docker-data/configs/unifi-os/persistent` (NAS); compose dir kept for reference. **Only .150 manages UniFi, by design.** **2026-10-26: it was found RUNNING again** (the 10-05 removal never stuck -- journal shows it up again later that same day, then recreated from its compose dir 2026-10-17 21:06Z). Removed again (container, `unifi_default` network, `ghcr.io/lemker/unifi-os-server` + stale `linuxserver/unifi-network-application` images) and the compose file renamed to `docker-compose.yml.decommissioned-20261026` + `DECOMMISSIONED.txt` so a stray `compose up` cannot revive it. Data still preserved.
├── [COMPOSE] Gitea + gitea-db (PostgreSQL) -- LevelDB queues on LOCAL /var/lib/gitea-queues (2026-07-13, stale NFS lock caused 7,972-restart loop)
├── [COMPOSE] Semaphore + semaphore-db (MySQL, LOCAL /var/lib/semaphore-db since 2026-07-13)
├── [COMPOSE] Wiki.js + wikijs-db (PostgreSQL)
├── [COMPOSE] Vaultwarden, MCPO, Trivy
├── [COMPOSE] SearXNG (`searxng/searxng:latest`) -- found running 2026-10-26, previously undocumented here
├── [COMPOSE] unstructured-api (:8000, `downloads.unstructured.io/unstructured-io/unstructured-api:latest`) -- used by Onyx's `unstructured_patch.py` on .192
├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget)
├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation
├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS).
├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, image **built locally** (`hobbs-family-organizer:1.4.0` since 2026-11-04 late night; `1.0.0`–`1.3.0` kept for rollback; Watchtower disabled). **v1.4.0 adds the admin TVs tab**: per-TV morning routine (wake time + days, YouTube Music playlist, Shield volume) pushed to TVs by check-in (`GET /api/tv/config` every 5 min, `POST /api/tv/report`, both LAN-only; status in `data/tv-status.json`). TVs are identified by the photo app's per-app Android ID (.179 = `2f49d9d58ea552b4`, .186 "Jack's tv" = `cc516afd2195c2cc`). **v1.3.0 adds `/photo/gallery`** (remote-driven grid: arrows move, OK opens full screen, ◀ ▶ browse, Back returns; Slideshow button; idle 10 min -> slideshow) and a 400px tile per photo (`<key>.m.jpg`). Slideshow interval set to **15s** (was 20s) per user. **v1.2.0 adds the photo frame `/photo`** (slideshow of Google Photos shared albums, scraped from the share page because Google's Library API lost album access 2025-03-31, + admin uploads; synced every 6h into LOCAL `data/photos/`, ~0.5MB/photo; current album "TV screensaver", 46 photos). Photo health `GET /api/health/photos` = 503 when an album has not synced for 48h. **Shield photo app `com.dayline.photos` v1.2.1** (2026-11-04; runs the morning routine: alarm at wake−30s -> MorningActivity turns screen on, sets volume, starts the playlist in YouTube Music (`music.youtube.com/watch?list=`); second alarm at the wake time opens `com.dayline.tv` on top. **Needs two ADB grants per TV, already done on both:** `appops set com.dayline.photos SYSTEM_ALERT_WINDOW allow` (start other apps from background) and `dumpsys deviceidle whitelist +com.dayline.photos` (network during Doze -- without it a sleeping TV never checks in). The old `com.dayline.tv` 6:30 alarm flag is still set on both as a backup -- **remove it once the new routine is proven**, or admin time changes will still get a 6:30 calendar launch; separate APK: launcher tile opens `/photo/gallery` and takes D-pad input, Back steps back through WebView history; screensaver/DreamService loads `/photo`, non-interactive; default URL baked in, override via `files/base_url` with `run-as`). Built on .100 by `compose/hf-pdocker-02/hobbs-organizer/build-photos-apk.sh` (JDK17 container, SDK cached in volume `hobbs-android-sdk`). **Signing key** `/var/lib/hobbs-organizer/apk-signing/` (root 600) + NAS copy `/mnt/docker-data/configs/hobbs-organizer/apk-signing/` -- losing it means uninstall/reinstall for any update. Installed on **both Shields** (.186 canary, then .179 on 2026-11-04 after the user confirmed .186) and set as the screensaver on both (previous on each: `com.google.android.backdrop/.Backdrop`, saved in `~/hobbs-shield/screensaver-before-{186,179}.txt` on .100; rollback = `adb shell settings put secure screensaver_components <that value>`). **v1.1.0 adds `/admin`** (password, LAN-only, manages calendar links with masked display/test/history-revert); settings + scrypt password hash live in LOCAL `/var/lib/hobbs-organizer/data` (uid 10001, 700) — calendars now come from `data/settings.json`, the secrets file is only a first-start seed. Inside the container a small front proxy on :3000 stamps the real client IP (Next.js runs on 127.0.0.1:3001) so admin can refuse non-LAN and proxied requests. Password reset: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`. Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it, and both have the daily 6:30 AM wake-and-launch enabled (flag `files/daily_wake_0630_enabled`; .186 added 2026-11-04). It wakes a sleeping Shield; the TV powers on only via HDMI-CEC. See memory `hobbs-family-organizer`.
├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26)
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
├── [COMPOSE] Watchtower
├── [COMPOSE] Watchtower -- `nickfedor/watchtower` (maintained fork; containrrr is archived), runs **twice daily 04:00 + 16:00** (`WATCHTOWER_SCHEDULE=0 0 4,16 * * *`)
```
@@ -94,6 +100,8 @@ All service data lives on the **Synology NAS** via NFS, mounted at `/mnt/docker-
└── bart/{media,openvas,watchtower}/
```
**hf-pdocker-02 fixed 2026-11-04 after the 11-03 power event** (dockerd was dead 27h: stale Swarm membership made it fatal-exit when .192:2377 was unreachable, and `/mnt/docker-data` had not mounted): `docker swarm leave --force` done (**side effect: it wiped Docker's embedded-DNS container-name records, so `wikijs-db`/`gitea-db`/… stopped resolving even for new containers -- external DNS was fine, existing DB pools kept working, new connections failed. Fixed by restarting all 18 containers DBs-first. When doing bart: restart its containers or dockerd right after `swarm leave`**), and the `/mnt/docker-data` fstab line now has `nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s` (backup `/etc/fstab.bak-20261104-nfs-nofail`). Its other NAS lines (`/mnt/infrastructure`, `/mnt/NAS` cifs, `/data/media`, `/mnt/nas-media`) still lack it. **bart fixed the same way 2026-11-04** (user-approved): `docker swarm leave`, then `systemctl restart docker` straight away (every bart container is `unless-stopped`, so this rebuilds embedded DNS in one step -- verified all names resolve on `media_default`), and its `/mnt/docker-data` fstab line got the same `nofail,...` options (backup `/etc/fstab.bak-20261104-nfs-nofail`). bart's other NAS lines (`/data/media`, `/mnt/infrastructure`, `/mnt/nas-media`, the `/mnt/frigate-storage` cifs line -- which has the node password in plaintext) were left alone: `nofail` on the media mounts would let Jellyfin/Sonarr/Radarr start against empty dirs. The stale bart/.100 entries were `docker node rm`'d on .192, which is now a **single-node swarm of itself** (harmless: it is the manager, so it never blocks at boot; `docker swarm leave --force` there is optional cleanup).
**NFS mount (all nodes):** `192.168.86.30:/volume1/Docker /mnt/docker-data nfs rw,soft,timeo=300,intr,_netdev,nfsvers=3,nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s 0 0`
**Note (hf-pdocker-01):** Both NFS mounts hardened with `nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s` on 2026-05-20 to prevent boot-time race (network not up when NFS tried to mount, cascading ~10 container failures).
@@ -145,7 +153,9 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Home Assistant: `yoda.hobbs.farm`
- Vaultwarden: `vault.hobbs.farm`
- Gitea: `git.hobbs.farm`
- FRIDAY Voice: `coach.hobbs.farm` (replaced VoiceCoach 2026-07-05; runs on .150 :8320)
- FRIDAY Voice: `coach.hobbs.farm` — **DECOMMISSIONED 2026-08-26; `coach.hobbs.farm` returns 404, which is EXPECTED, not an outage.** CORRECTED 2026-09-21: the "UP as of 2026-08-20" text previously here was overtaken by events. The live Prometheus config records the `voicecoach` teardown (2026-08-24) and the FRIDAY Voice + speaches teardown (2026-08-26) — routing and monitoring sides done, with "container/volume teardown on .150 still pending manual action next Windows boot". The 6-container `voicecoach` project is indeed gone from .192 (verified 2026-09-21). Its `.150` images/volumes may still exist. See memory `friday-voice`.
- MAS (Matrix auth): `auth.hobbs.farm` -- OAuth2/OIDC issuer. **If this is down, nobody can log into Matrix** (existing tokens keep working).
- Onyx (RAG/search): `onyx.hobbs.farm` -- **self-registration is LAN-only** (Traefik `onyx-register-lockdown` router). See Known Issue #34.
- Rongorongo: `rongorongo.hobbs.farm`
- LinkedStorm: `linkedstorm.hobbs.farm`
@@ -158,6 +168,7 @@ compose/ # Standalone Compose definitions (deployed per-node)
- ESPHome: :6052
- Mosquitto: :1883
- Matrix/Synapse: :8008
- MAS: :8020 (web/oauth/compat), :8021 (health only -- /health is NOT on :8020)
- Well-known (Matrix federation): :8010
- Guacamole: :8199
- Traefik Dashboard: :8090 (HTTP: :80, HTTPS: :443)
@@ -167,16 +178,18 @@ compose/ # Standalone Compose definitions (deployed per-node)
- InfluxDB: :8086
- Blackbox Exporter: :9115
- matrix-alertmanager-receiver: :9094
- AI Agents (margaret :18790, spirit :18795 [infra ops — monitors #notifications, auto-remediates alerts]) -- LAN-accessible (bind=lan, 0.0.0.0)
- AI Agents (margaret :18790, spirit :18795 [infra ops — monitors #notifications, auto-remediates alerts]) -- LAN-accessible (bind=lan, 0.0.0.0). OpenClaw **2026.7.1-2** (upgraded 2026-08-20 from 2026.5.27). Matrix: `@margaret:hobbs.farm` / `@spirit:hobbs.farm`, token auth against `http://192.168.86.192:8008`, encryption off, `requireMention: false`. Rollback images kept as `margaret-openclaw:rollback-2026.5.27` / `spirit-ops:rollback-2026.5.27`.
- Onyx: :3005 (onyx-nginx). Public onyx.hobbs.farm. `onyx-opensearch-1` deliberately stopped 2026-09-21.
- hermes: no published port (host network) -- see service tree; its LLM endpoint is currently dead.
- Farm Manager: :8888 (dashboard + API)
- Farm Agent: :8889
- Rongorongo: :8181 (rongorongo-web nginx; reverse-proxies /api/* to rongorongo-api Flask backend on the internal `rongorongo` bridge net). State + images at /mnt/docker-data/configs/rongorongo/data/. Public URL gated by Traefik basicAuth (creds in Vaultwarden).
- LinkedStorm: :8300 (pipeline/VAD+STT+speaker-ID), :8310 (API/dashboard), cron (no port). Public: linkedstorm.hobbs.farm -> :8310. Source: /opt/linkedstorm (rsync from WSL). Data: /mnt/docker-data/configs/linkedstorm/. STT via Speaches GPU machine :8000, LLM via Ollama :11434 (qwen2.5:14b).
- ~~VoiceCoach~~ -- fully decommissioned 2026-07-05, replaced by FRIDAY Voice on .150 (see GPU Machine section + memory `friday-voice`)
- ~~VoiceCoach~~ -- replaced by FRIDAY Voice on .150 (see GPU Machine section + memory `friday-voice`). **CORRECTION 2026-08-20: the teardown claimed below never actually happened.** All 6 containers of compose project `voicecoach` (`voicecoach-web` :8085, `-pipecat`, `-speaches`, `-speechbrain`, `-openclaw` :3100, `-livekit` :7880-7881 + 50000-50100/udp) are **running on .192, up since ~2026-08-06**. Earlier text said both projects were `docker compose down`'d on 2026-08-05; that is false for `voicecoach` (`voicecoach-v2` is genuinely gone). ~1GB RAM. `voicecoach-openclaw` was upgraded to OpenClaw 2026.7.1-2 on 2026-08-20 via `docker compose up -d --no-deps voicecoach-openclaw` (single service only — does not resurrect anything, the rest were already up). Its model is `ollama-local/qwen2.5:14b` at the **dead** `192.168.86.27:11434` with **no fallbacks**, so it cannot serve a turn. **Decide: fix its model chain or actually tear the stack down.**
**hf-pdocker-02 (192.168.86.100):**
- Farm Agent: :8889
- UniFi OS Server: :11443 (HTTPS, container 443)
- ~~UniFi OS Server: :11443~~ -- **REMOVED 2026-10-05** (duplicate controller; see service tree). The UniFi controller lives on **.150 (Podman), UI at https://192.168.86.150:11443, inform :8080**.
- Gitea: :3003 (web), :2222 (SSH)
- Semaphore: :3004
- Wiki.js: :3002
@@ -184,6 +197,7 @@ compose/ # Standalone Compose definitions (deployed per-node)
- MCPO: :8001
- Trivy: :4954
- CouchDB: :5984 -- Obsidian LiveSync backend. DB `secondbrain`; admin creds in Vaultwarden ("CouchDB - Obsidian LiveSync"). LAN-only (no Traefik route). Data on LOCAL disk `/var/lib/couchdb` (NFS breaks CouchDB file locking). **`local.ini` must be mounted READ-WRITE** -- with `:ro` CouchDB exits 1 and logs nothing. Auth required on everything except `/_up` (left open for the blackbox probe). Vault repo: git.hobbs.farm/jeremy/second-brain (private); vault lives at `C:\Users\Jeremy\second-brain` on the Windows box.
- Hobbs Family Organizer: :8340 -- Shield TV dashboard, `GET /api/health` (JSON, `calendarConfigurationValid`, `adminConfigured`). Dashboard open on LAN (trusted-LAN mode); `/photo` = photo frame; `/api/health/photos` (503 = album sync stale >48h, **probe this**); `/admin` is password-protected and LAN-only (direct 192.168.86.0/24 only -- anything arriving via a proxy gets 404, so do NOT add a Traefik route expecting admin to work through it). No Traefik route.
**bart (192.168.86.167):**
- Farm Agent: :8889
@@ -195,13 +209,18 @@ compose/ # Standalone Compose definitions (deployed per-node)
- Jellyseerr: :5055
**GPU Machine (192.168.86.150, RTX 3090):**
- **NOTE (2026-07-05):** .150 is actually the user's **Windows workstation** (runs the WSL/Claude Code session), not a headless Linux server. It runs **Docker Desktop** (WSL2 backend, RTX 3090 GPU passthrough working) alongside **UniFi OS Server** (Podman). Docker Desktop panics if UniFi/Podman claims `\\.\pipe\docker_engine` first — start Docker Desktop before UniFi OS Server. See memory `docker-desktop-unifi-podman-conflict`.
- **NOTE (2026-07-05):** .150 is actually the user's **Windows workstation** (runs the WSL/Claude Code session), not a headless Linux server. It runs **Docker Desktop** (WSL2 backend, RTX 3090 GPU passthrough working) alongside **UniFi OS Server** (Podman) — **this is the ONE real UniFi controller** (the duplicate container on .100 was removed 2026-10-05). UI https://192.168.86.150:11443, inform http://192.168.86.150:8080. Manages all UniFi devices incl. `downstairsswitch` (US-24-250W, 192.168.86.159; SSH `admin`/node-password). Docker Desktop panics if UniFi/Podman claims `\\.\pipe\docker_engine` first — start Docker Desktop before UniFi OS Server. See memory `docker-desktop-unifi-podman-conflict`.
- **FRIDAY Voice replaced VoiceCoach (2026-07-05).** VoiceCoach fully decommissioned (v1+v2 removed from hf-pdocker-01; voice.hobbs.farm route removed). See memory `friday-voice`.
- [COMPOSE] **FRIDAY Voice stack (all on .150 Docker Desktop, via `docker.exe -c desktop-linux`):**
- **voicecoach-speaches-gpu**: :8000 — GPU Whisper large-v3-turbo (STT) + Kokoro-82M af_nova (TTS), CUDA float16. Compose: `compose/gpu-machine/voicecoach-speaches/`.
- **hermes**: `nousresearch/hermes-agent:latest` gateway — FRIDAY's reasoning agent. Settings transferred from WSL `~/.hermes` into named volume `hermes-data`. Compose: `compose/gpu-machine/hermes/`.
- **voicecoach-speaches-gpu**: :8000 — GPU Whisper large-v3-turbo (STT) + Kokoro-82M af_nova (TTS), CUDA float16. Compose: `compose/gpu-machine/voicecoach-speaches/`. **REMOVED 2026-08-21 (see the speaches note at the end of this section) — rebuild when voice I/O is wanted again.**
- **hermes**: **PINNED `nousresearch/hermes-agent:v2026.8.18`** (upgraded 2026-08-20 from v2026.7.1 / v0.18.0 → v0.20.4). FRIDAY's reasoning agent. Settings in named volume `hermes-data`. Compose: `compose/gpu-machine/hermes/`. Was on `:latest` **with Watchtower disabled**, so nothing ever re-pulled it — it sat 7 weeks / 9 releases behind. Note `:latest` on this repo is the same digest as `main` (rolling dev build), so pin a `vYYYY.M.D` tag. Rollback image `nousresearch/hermes-agent:rollback-2026.7.1`; volume backup in volume `hermes-data-backup-20260820` (`hermes-data-20260820.tgz`, 29MB). **Matrix pairing:** hermes denies unknown senders by default — approve with `docker exec hermes hermes pairing approve matrix <CODE>` (`@jeremy:hobbs.farm` approved 2026-08-20; state persists in the volume). **RESOLVED 2026-08-21 by moving off ChatGPT entirely.** Its Codex refresh token had expired (`Could not validate your refresh token`) and, with **no API key set for any provider**, that was the only auth path — both Matrix and `/api/ask` failed. Verified NOT an upgrade regression (the rollback image failed identically on the same volume). Rather than re-auth interactively, hermes was pointed at the **local llamacpp** endpoint: `model.provider=llamacpp`, `model.default=qwen3.8-64k`, `model.base_url=http://192.168.86.150:8081/v1`. No tokens, no TTY, no subscription. Config backups: `/opt/data/config.yaml.bak-20260820-ollama` and `.bak-20260821-llamacpp`. **`hermes model` is interactive-only — use `hermes config set` for non-interactive changes.** hermes also **hard-refuses any model advertising <64K context** ("Choose a model with at least 64K context"), which is why the served model is a 64k build, not 32k.
- **llamacpp**: :8081 — **the inference backend for hermes as of 2026-08-21.** `ghcr.io/ggml-org/llama.cpp` **pinned by digest** `sha256:995fe9f2...`. Serves `qwen3.8-64k` (alias) from ollama's GGUF blob, mounted read-only. Compose: `compose/gpu-machine/llamacpp/`. Replaced ollama as the server after a same-model/same-context benchmark: **ollama 15.8 tok/s vs llama.cpp 39.7 tok/s (2.4x)**, because `--cache-type-k/v q8_0` halves the KV cache (23.2GB -> 18.6GB) so the model fits the 3090 instead of spilling layers to CPU. Ollama has no KV-quantization knob. The image's own HEALTHCHECK probes :8080 and can never pass here, so it is `disable: true`d — liveness is an external GET of `:8081/health`.
- **friday-voice**: :8320 — push-to-talk console. Calls speaches (`host.docker.internal:8000`) + hermes (`docker exec hermes hermes -Q` via wrapper). Public: **coach.hobbs.farm** → :8320. Compose: `compose/gpu-machine/friday-voice/`.
- Ollama: :11434 (qwen2.5:14b, Hermes-4.3-36B-GGUF, others)
- Ollama: **TWO separate instances on this box, easy to confuse** —
- **Native Windows** `ollama.exe` v0.32.15, listens on `::11434` -> reachable as **`192.168.86.150:11434`**. Holds `qwen3.8`, `qwen3.8-64k`, `gemma4`. This is the LAN-visible one and the source of the GGUF that llamacpp serves.
- **Inside WSL** (`/usr/local/bin/ollama serve`, systemd unit, **enabled+autostart**) v0.15.5, relayed to Windows `127.0.0.1:11434` by `wslrelay.exe`. Holds `qwen2.5:14b`, `hf.co/NousResearch/Hermes-4.3-36B-GGUF:Q4_K_M`, `qwen3.6`, `glm-4.7-flash`, `qwen3-coder:30b`, `gpt-oss:20b`, `llama3.1`. **`host.docker.internal:11434` from a Docker Desktop container resolves to THIS one**, not the native one — that cost real debugging time.
- Both share the one physical 3090. Native ollama env (User scope): `OLLAMA_KEEP_ALIVE=30s`, `OLLAMA_MAX_LOADED_MODELS=1`, `OLLAMA_NUM_PARALLEL=1` — keep-alive was deliberately dropped from `-1` back to `30s` once llamacpp became the server, so ollama stops pinning ~22GB of VRAM. **These are read from the registry only by processes launched fresh (e.g. at login); a process started from an existing shell inherits that shell's env instead.**
- **speaches (voicecoach-speaches-gpu) REMOVED 2026-08-21** to free ~3.3GB VRAM for the llama.cpp benchmark, per user ("they can be rebuilt later"). **Image and the `voicecoach-speaches_speaches-models` volume were kept**, so rebuild is just `docker compose -f 'C:\Users\Jeremy\projects\docker\compose\gpu-machine\voicecoach-speaches\docker-compose.yml' up -d` with no model re-download. **FRIDAY's voice in/out (STT+TTS) is down until then; the text `/api/ask` path works.** Its compose sets `WHISPER__TTL=-1`, which is why it pinned VRAM permanently — consider a finite TTL on rebuild.
### Port Conflict Notes
@@ -231,7 +250,7 @@ After ANY infrastructure change (deploy, remove, reconfigure, add service, chang
2. **MEMORY.md** — Add/update relevant memory entries
3. **Prometheus probes** — Add/remove blackbox targets, hot-reload Prometheus
4. **Wiki** — Launch documentation-keeper agent to update wiki pages (run in background)
5. **Farm Manager links page** — If a service was added/removed or its URL/port changed: edit the `serverLinks` array in `/opt/farm-manager/server/static/app.js` on hf-pdocker-01, then rebuild + redeploy: `docker build -f /opt/farm-manager/Dockerfile.server -t farm-manager:latest /opt/farm-manager && cd /mnt/docker-data/compose/hf-pdocker-01/farm-manager && docker compose up -d` (links are baked into the image, not mounted)
5. **Farm Manager links page** (last audited **2026-10-26**: every link probed; removed both FRIDAY Voice links, added Onyx (public + :3005), Guacamole :8199, Synapse Admin :8080, SearXNG :8888, n8n :5678, CouchDB Fauxton; MCPO -> `/docs`, Trivy -> `/healthz` (API only, no UI); Jellyseerr -> Seerr. Groups live in `/mnt/docker-data/configs/farm-manager/groups.json` -- **update them too when containers are added/renamed**. Previous audit 2026-08-20: dropped dead Clippy/Bob/Aaron links, added FRIDAY Voice + LinkedStorm + Rongorongo and a new `GPU Machine (192.168.86.150)` category; `hermes` intentionally absent — it publishes no ports. Served path is `/app.js`, not `/static/app.js`.) — If a service was added/removed or its URL/port changed: edit the `serverLinks` array in `/opt/farm-manager/server/static/app.js` on hf-pdocker-01, then rebuild + redeploy: `docker build -f /opt/farm-manager/Dockerfile.server -t farm-manager:latest /opt/farm-manager && cd /mnt/docker-data/compose/hf-pdocker-01/farm-manager && docker compose up -d` (links are baked into the image, not mounted)
Do NOT wait for the user to ask. Do NOT skip "because the change was small." Every change gets documented.
@@ -292,21 +311,49 @@ All persistent data on NAS at `/mnt/docker-data/`:
2. **Telegraf Docker Input**: Disabled due to Docker socket permission issues across nodes
3. **Watchtower API Version**: Requires `DOCKER_API_VERSION=1.44`
4. **InfluxDB Connection**: Telegraf may show timeout errors if InfluxDB is not running
5. **Frigate cameras**: Cameras on 192.168.44.x subnet may show RTSP timeouts (check camera network)
5. **Frigate cameras**: 192.168.44.0/24 is the **"Camera" VLAN 200** on the Firewalla (gateway 192.168.86.1 / 44.1). Cameras with 44.x IPs MUST sit on Camera-native switch ports. **2026-10-08:** house_north/.51, house_east/.58, house_south/.53, house_west/.52 are wired to downstairsswitch (.159) ports 3/4/18/19 (verified from the switch MAC table 2026-10-11), which are **VLAN 1**, so they can't be reached by routing. They only half-worked because .192's Wi-Fi NIC held a wrong-VLAN 44.x lease. The fix is to make those ports Camera-native (see memory `unifi-estate`). The nodes' `wlp2s0` Wi-Fi NICs must hold **86.x** leases; a 44.x lease there is stale and breaks routing to the real Camera VLAN. **2026-10-10: with the Wi-Fi workaround gone (86.x lease since 10-08), those 4 cameras are now fully DOWN (0 fps, no ping) until the ports are re-profiled.**
6. **Registry container**: Stopped on hf-pdocker-01 to free port 5000 for Frigate
7. **systemd-resolved DNSStubListener**: disabled on hf-pdocker-01 and hf-pdocker-02 via `/etc/systemd/resolved.conf.d/nostub.conf` (`DNSStubListener=no`) — originally for AdGuard Home's port 53. AdGuard was decommissioned 2026-07-13; the stub-listener change is harmless and left in place (re-enable only if something needs 127.0.0.53).
8. **NFS boot-mount race (hf-pdocker-01)**: NFS mounts failed at boot (network not ready in time). Fixed 2026-05-20 by adding `nofail,x-systemd.after=network-online.target,x-systemd.mount-timeout=60s` to both fstab entries. If mounts are missing after reboot: `sudo mount /mnt/docker-data && sudo mount /mnt/infrastructure`.
9. **Guacamole schema**: App image pinned to `guacamole/guacamole:1.6.0` + `guacd:1.6.0` with Watchtower disabled. DB schema was reset to fresh 1.6.0 on 2026-05-20 (old schema was non-standard). Recreate connections after any DB reset.
10. **Gitea image**: Pinned to `gitea/gitea:1.26.1` with Watchtower disabled (2026-05-20). Watchtower previously pulled broken `:latest` images causing missing git binary errors.
11. **Home Assistant reverse proxy**: Config at `/mnt/docker-data/configs/homeassistant/configuration.yaml` (no hyphen). Must have `http: use_x_forwarded_for: true` + `trusted_proxies: [172.16.0.0/12, 127.0.0.1]` or proxied requests return 400.
12. **hf-pdocker-01 memory pressure (2026-07-05, retrimmed 2026-07-11)**: Node runs hot (~15-18/23GB, chronic swap). Mitigations applied: `vm.swappiness` lowered 60→10 (persisted in `/etc/sysctl.d/99-swappiness.conf`), and VoiceCoach (v1+v2, ~10 containers) fully removed (replaced by FRIDAY Voice on .150). Frigate is the main consumer. **2026-07-11:** Frigate's built-in AI features (`semantic_search: large`, `face_recognition`, `lpr`, `classification.bird`) were found *enabled* (contradicting the intended baseline) and re-disabled — dropped the embeddings CLIP model ~1.2GB→168MB and cut CPU inference; host mem 18→~9GB, swap 3.9→1.3GB right after. Config `/mnt/docker-data/configs/frigate/config/config.yml` (backup `.bak.20260711-aitrim`). Recognition is instead handled externally by frigate-notify v2 (clip-service was removed 2026-07-13 — frigate-notify's config never referenced it). **2026-07-12 — WHOLE-NODE CRASH:** .192 froze hard (~14:15, sshd/HA/DNS all dead, required hard reboot). Cause: Frigate `memswap_limit` had drifted to **16G** vs `mem_limit` 12G, letting it spill ~4GB into host swap; with the ffmpeg leak pinning it at 12G all day (cgroup OOM every ~45min), the host swap-thrashed to death. **Fix: node compose `memswap_limit: 12G` (= mem_limit) so Frigate can never use host swap** — a hit now trims its own ffmpeg (contained cgroup OOM), not the host. Repo already had 12G; node had drifted (never redeployed). NOTE: recording ffmpeg still leaks ~0.8GB/h atop ~6GB baseline (fills 12G in ~7.5h); twice-daily 4am+4pm restart-frigate cron mitigates but doesn't fully beat it — consider every-6h restart + fixing broken cameras (gate_camera, garage_2). Always keep `memswap_limit == mem_limit` on this node.
12. **hf-pdocker-01 memory pressure (2026-07-05, retrimmed 2026-07-11)**: Node runs hot (~15-18/23GB, chronic swap). Mitigations applied: `vm.swappiness` lowered 60→10 (persisted in `/etc/sysctl.d/99-swappiness.conf`), and VoiceCoach (v1+v2, ~10 containers) removed (replaced by FRIDAY Voice on .150) — **but see correction: the VoiceCoach compose stacks on .192 were only *stopped* in July, not `down`'d, and reappeared (~3GB) on the 2026-08-05 reboot; properly torn down 2026-08-05 (`docker compose down` on `voicecoach` + `voicecoach-v2`), node mem 9.0→6.7GB used.** — **this is WRONG, see 2026-08-20: only `voicecoach-v2` is gone; the 6-container `voicecoach` project is still running on .192.** Frigate is the main consumer. **2026-07-11:** Frigate's built-in AI features (`semantic_search: large`, `face_recognition`, `lpr`, `classification.bird`) were found *enabled* (contradicting the intended baseline) and re-disabled — dropped the embeddings CLIP model ~1.2GB→168MB and cut CPU inference; host mem 18→~9GB, swap 3.9→1.3GB right after. Config `/mnt/docker-data/configs/frigate/config/config.yml` (backup `.bak.20260711-aitrim`). Recognition is instead handled externally by frigate-notify v2 (clip-service was removed 2026-07-13 — frigate-notify's config never referenced it). **2026-07-12 — WHOLE-NODE CRASH:** .192 froze hard (~14:15, sshd/HA/DNS all dead, required hard reboot). Cause: Frigate `memswap_limit` had drifted to **16G** vs `mem_limit` 12G, letting it spill ~4GB into host swap; with the ffmpeg leak pinning it at 12G all day (cgroup OOM every ~45min), the host swap-thrashed to death. **Fix: node compose `memswap_limit: 12G` (= mem_limit) so Frigate can never use host swap** — a hit now trims its own ffmpeg (contained cgroup OOM), not the host. Repo already had 12G; node had drifted (never redeployed). NOTE: recording ffmpeg still leaks ~0.8GB/h atop ~6GB baseline (fills 12G in ~7.5h); twice-daily 4am+4pm restart-frigate cron mitigates but doesn't fully beat it — consider every-6h restart + fixing broken cameras (gate_camera, garage_2). Always keep `memswap_limit == mem_limit` on this node.
13. **Docker Desktop vs UniFi OS Server pipe conflict (Windows box .150, 2026-07-05)**: Both fight over `\\.\pipe\docker_engine`; Docker Desktop's backend nil-panics ("backend exited before becoming ready") if Podman wins. Fix: let Docker Desktop (auto-starts at login) claim the pipe first, launch UniFi OS Server manually after. Full detail in memory `docker-desktop-unifi-podman-conflict`.
14. **MySQL InnoDB corruption on NFS**: `semaphore-db` (mysql:8.0 on NFS) hit system-tablespace page corruption and crash-looped 3789× (2026-07-11), same class as the Guacamole-on-NFS issue (#9). Recovered in-place via `--innodb-force-recovery=1` → mysqldump → fresh volume → reimport (DB was empty, no data lost); admin recreated with `semaphore user add`. Any MySQL DB on NFS is at risk — prefer LOCAL storage like guacamole-db. See memory `semaphore-db-nfs-corruption`.
15. **litellm hangs after DB blip**: litellm does NOT self-recover from a transient `litellm-db` disconnect — it loops on "Prisma DB reconnect failed" forever; needs manual `docker restart litellm`. Its healthcheck was fixed from `curl` (absent from image, permanent false-unhealthy) to a python urllib check (2026-07-11).
16. **Stale NFS locks after unclean reboot/power loss (2026-07-12 power event)**: NFS file locks held by pre-reboot client identities survive on the Synology and block relocking — gitea crash-looped 7,972× on its LevelDB queue lock, semaphore-db 975× on `ibdata1`, and nzbhydra2 sat unhealthy on its H2 database lock. Fix applied 2026-07-13: lock-prone data moved to LOCAL disk (gitea queues → `/var/lib/gitea-queues`, semaphore-db → `/var/lib/semaphore-db` on .100; sonarr config → `/var/lib/sonarr`, nzbhydra config → `/var/lib/nzbhydra` on bart; jellyfin config → `/var/lib/jellyfin` on bart 2026-07-19 after 60-100s "database is locked" stalls on jellyfin.db caused playback lockups). **Rule: anything holding long-lived file locks (MySQL, SQLite, LevelDB) goes on local disk, never NFS.**
16. **Stale NFS locks after unclean reboot/power loss (2026-07-12 power event)**: NFS file locks held by pre-reboot client identities survive on the Synology and block relocking — gitea crash-looped 7,972× on its LevelDB queue lock, semaphore-db 975× on `ibdata1`, and nzbhydra2 sat unhealthy on its H2 database lock. Fix applied 2026-07-13: lock-prone data moved to LOCAL disk (gitea queues → `/var/lib/gitea-queues`, semaphore-db → `/var/lib/semaphore-db` on .100; sonarr config → `/var/lib/sonarr`, nzbhydra config → `/var/lib/nzbhydra` on bart; jellyfin config → `/var/lib/jellyfin` on bart 2026-07-19 after 60-100s "database is locked" stalls on jellyfin.db caused playback lockups; **synapse data → `/var/lib/synapse` on .192 2026-08-20**, pre-emptively — its 279MB SQLite homeserver.db was on NFS). **Rule: anything holding long-lived file locks (MySQL, SQLite, LevelDB) goes on local disk, never NFS.**
17. **Synapse rate-limited Alertmanager notifications**: default `rc_message` (0.2 msg/s) made every matrix-alertmanager-receiver send fail with M_LIMIT_EXCEEDED 429 — **no alerts reached Matrix for weeks** while outages accumulated (Onyx down 5 weeks, AdGuard dead 2 months, unnoticed). Fixed 2026-07-13: `rc_message: {per_second: 5, burst_count: 50}` in homeserver.yaml (backup `.bak-20260713-ratelimit`).
18. **AdGuard Home decommissioned 2026-07-13**: its conf dir had been empty since 2026-05-17 (container ran the first-launch wizard, served no DNS, no backup existed) and the LAN had been using router DNS ever since. Container, data, compose dir, and Prometheus probe all removed; ports 53/3000 freed on .192. LAN DNS = the router.
19. **Duplicate failover stacks removed from .100 and bart (2026-07-13)**: .100 ran a second monitoring stack (prometheus/grafana/alertmanager/influxdb/blackbox), an idle traefik (shared acme.json with .192 — ACME conflict risk), and a stray guacamole; bart ran a failover guacamole. All removed (~2GB RAM reclaimed). Compose dirs kept for reference; data volumes untouched.
20. **UniFi OS Server probe port**: `unifi-os-server` on .100 serves HTTPS on host port **11443** (container 443), not 8443. Blackbox probe fixed 2026-07-13. solo.hobbs.farm returns 401 (Traefik basicAuth) when healthy — probed via `http_2xx_or_401` module.
20. **UniFi OS Server probe port** (historical): the `unifi-os-server` container on .100 served HTTPS on host port **11443** (container 443), not 8443. **That container was removed 2026-10-05** (duplicate controller) — the sole UniFi controller is now the Podman instance on **.150** (UI https://192.168.86.150:11443, inform :8080). The live Prometheus config **did** carry a blackbox probe for `https://192.168.86.100:11443` (line 132) — it was **removed 2026-10-06** (backup `prometheus.yml.bak-20261006-unifi-probe-remove`) and Prometheus hot-reloaded, since nothing listens there now. **No probe added for the .150 controller** because that box is a workstation that is sometimes off (it would false-alarm); the switch itself (192.168.86.159) could be ICMP-probed instead if device-down alerting is wanted. (Note: the `UnifiDeviceDown` alert in `alerts.yml` keys on `unifi_device_up`, which needs a `unifi-exporter` that is NOT deployed — the metric doesn't exist, so that rule is inert, not firing.)
21. **OpenVAS pg_upgrade loop saturated bart's HDD (2026-07-15)**: `immauss/openvas:latest` looped on the PG 13→15 auto-upgrade for ~21h (158 restarts) — each cycle re-copied the 21GB DB, pinning sda at ~90% util/40% iowait and making Jellyfin (same disk) hang. The upgrade itself succeeded every time but the final `mv /data/database /data/database_13_old` failed with "Device or resource busy" because the openvas-db volume is mounted **at** `/data/database` (can't rename a mountpoint). Fixed by wiping the DB (`docker volume rm openvas_openvas-db`) and letting it initdb fresh on PG15 — scan history lost, feeds re-synced. **On the next major PG bump this will recur** unless the upgrade is done out-of-band (content-level swap) or the volume is remounted at `/data`. General rule: a service "hanging" on bart usually means another container is eating the single HDD — check `iostat -x`/`pidstat -d` first. **OpenVAS fully decommissioned 2026-07-19** (moot unless redeployed).
22. **Jellyfin SQLite-on-NFS lockups (fixed 2026-07-19)**: jellyfin.db (WAL mode) on `/mnt/docker-data/configs/jellyfin` hit escalating `SQLite Error 5: 'database is locked'` (22→33→125/day), DB commands blocking 60-102s → healthcheck flaps + playback stalls ("Unexpected end of request content" on /Sessions/Playing/Progress). Config moved to LOCAL `/var/lib/jellyfin` (old copy left on NFS as backup). Same class as Known Issue #16.
23. **Matrix registration was wide open to the internet (fixed 2026-08-20)**: `mess.hobbs.farm` is publicly reachable (Traefik, port 443, federation + client API on :8008) and `homeserver.yaml` had `enable_registration: true` + `enable_registration_without_verification: true` — the registration flow was a bare `m.login.dummy`, i.e. **anyone on the internet could create an account on hobbs.farm with no email, captcha, or token**. No abuse had occurred (all 18 accounts are known/expected; newest legit account was @fry 2026-07-05). Fixed by setting both to `false` and `restrict_public_rooms_to_local_users: true` (was `false`, which published the public room directory to any remote server). Backup: `/var/lib/synapse/homeserver.yaml.bak-20260820-registration`. New accounts are still creatable on the box via `docker exec synapse register_new_matrix_user -k <registration_shared_secret>`.
24. **Synapse database history (SQLite -> local disk -> PostgreSQL, all 2026-08-20)**: Synapse ran on **SQLite on NFS** while CLAUDE.md claimed it used a `synapse-db` postgres that in fact had **0 tables**. First the data was moved to LOCAL `/var/lib/synapse` and the empty postgres deleted; then, because `syn2mas` **only reads PostgreSQL** ("The migration tool only supports reading from PostgreSQL for the Synapse database as well"), a real `synapse-db` was created and the DB ported with `synapse_port_db` (20 users / 93,888 events / 65 devices, verified identical; 2m35s downtime). The PG database MUST have `LC_COLLATE=C` -- verify with `select datcollate from pg_database where datname='synapse'` (PG16 removed the `SHOW lc_collate` GUC). The old SQLite file is still at `/var/lib/synapse/homeserver.db`, now inert; NFS copy at `/mnt/docker-data/configs/matrix/data` is an older backup -- **never remount either**.
25. **Synapse upgraded 1.148.0 -> 1.159.0 and moved to the element-hq registry (2026-08-20)**: it had been on `matrixdotorg/synapse:latest`, an **abandoned Docker Hub repo** whose `:latest` froze at 1.148.0 (image built 2026-02-24) — a moving tag that silently stopped moving on a publicly-exposed homeserver. Now pinned to `ghcr.io/element-hq/synapse:v1.159.0` (note the **`v` prefix** — `:1.159.0` without it 404s). Upgrade was clean: schema 93 -> 94, all background updates finished in ~5s, 0 errors, 19 users / 93,884 events intact, memory 112MB/1G. None of the v1.149-v1.159 breaking changes applied to us (MSC3861 removal — unused; `quarantined_media_changes` stream writer — workers only; the rest are Debian/Ubuntu packaging). Pre-upgrade backup: `/var/lib/synapse.bak-20260820-pre1159` (646MB) — delete once you're satisfied. **Pinned on purpose: bump it manually and deliberately, since the last moving tag rotted for 6 months unnoticed.** **2026-10-26: bumped v1.159.0 -> v1.163.2** (schema 94 deltas applied in <1s; config contains none of the options affected by the 1.160-1.163 upgrade notes). pg_dump backups taken first: `/var/lib/upgrade-backups-20261026/{synapse-db,mas-db}.dump` on .192. MAS bumped 1.23.0 -> **1.27.1** at the same time.
26. **`SYNAPSE_SERVER_NAME` in the matrix compose disagrees with homeserver.yaml**: compose passes `SYNAPSE_SERVER_NAME=mess.hobbs.farm` but the real `server_name` is **`hobbs.farm`** (user IDs are `@user:hobbs.farm`). The env var is only read when the image *generates* a config, so it's inert today — but if `homeserver.yaml` ever went missing, the container would generate a fresh config with the wrong `server_name` AND a new signing key, which would break federation and every existing user ID. Left as-is (changing it has no effect while the config exists); just never let the container auto-generate..
27. **Matrix auth is delegated to MAS -- Synapse does NOT serve /login any more (2026-08-20)**: with `matrix_authentication_service.enabled: true`, Synapse answers `M_UNRECOGNIZED` on `/_matrix/client/*/login`. The **reverse proxy must route the three compat paths to MAS or nobody can sign in**: `login`, `logout`, `refresh` (verified: MAS returns 200/405 on those and 404 on `register`/`whoami`, which stay on Synapse). Implemented as the `mess-auth-compat` router in `/mnt/docker-data/traefik/dynamic/temp-routes.yml` with explicit `priority: 1000` so it beats the catch-all `mess` router. **Anything that talks to `192.168.86.192:8008` directly bypasses Traefik and therefore cannot log in with a password** -- token-based bots (spirit, frigate, alertbot) are unaffected because they never call /login, but `@clippyauto` (n8n) and `@pluto` historically re-login with a password (17 and 15 accumulated devices). They currently run on migrated tokens; if either ever needs a fresh password login, point it at `https://mess.hobbs.farm` instead of `:8008`.
28. **MAS image tags are NOT `v`-prefixed** -- `ghcr.io/element-hq/matrix-authentication-service:1.23.0` is correct, `:v1.23.0` does not exist. This is the **opposite** of Synapse (`ghcr.io/element-hq/synapse:v1.159.0`, where `:1.159.0` 404s). The MAS image is also **distroless** -- no shell, so a container healthcheck is impossible; liveness comes from the blackbox probe of `http://192.168.86.192:8021/health`. Note `/health` lives on the internal listener only: `https://auth.hobbs.farm/health` returns 404 by design, so the public probe targets `/.well-known/openid-configuration`.
29. **MAS config holds unrecoverable signing keys**: `/mnt/docker-data/configs/mas/config.yaml` (mode 640) contains the RSA/EC signing keys and the `matrix.secret` shared with Synapse. Regenerating them invalidates every session. It is **not yet backed up to Vaultwarden** because the vault master password is currently unknown (see memory `vault-master-password-stale`) -- resolve that and store it. Also note `http.issuer`/`public_base` (`https://auth.hobbs.farm/`) are baked into session state; changing the hostname later invalidates sessions.
30. **OpenClaw agent images were built from `openclaw@latest` and rotted for months (fixed 2026-08-20)**: margaret and spirit sat on **2026.5.27** while upstream was on **2026.7.1-2** — same failure mode as the abandoned `matrixdotorg/synapse:latest` (Known Issue #25). Worse, **the Dockerfiles existed nowhere** — not in the repo, not on any node — and had to be reconstructed from `docker history` layer output. They now live at `compose/hf-pdocker-01/ai-agents/{margaret,spirit}/Dockerfile` (staged on the NAS at `/mnt/docker-data/compose/hf-pdocker-01/ai-agents/`) with `ARG OPENCLAW_VERSION` **pinned**; bump it deliberately. Rebuild: `cd /mnt/docker-data/compose/hf-pdocker-01/ai-agents && docker build -t margaret-openclaw:<ver> -t margaret-openclaw:latest ./margaret` (same for `./spirit` → `spirit-ops`). npm dist-tags to check: `latest`, `extended-stable`, `beta`. **Upgrading migrates the state dir one-way** (JSON sidecars → shared SQLite; originals archived as `*.migrated` under `/mnt/infrastructure/{margaret,spirit}/`), so a rollback to `*:rollback-2026.5.27` also needs those `.migrated` files restored. Config backups: `/mnt/infrastructure/{margaret,spirit}/openclaw.json.bak-20260820-preupgrade`.
31. **`192.168.86.27` is dead and several configs still point at it (2026-08-20)**: spirit's *primary* model was `vllm-local/qwen` at `192.168.86.27:8001`, so **every** message burned ~12s timing out before failing over to Anthropic (2,913 consecutive primary-model failures). margaret had the same host as a `vllm-local/hermes` provider. Both `vllm-local` provider blocks were deleted; both agents are now `anthropic/claude-sonnet-4-6` primary with `google/gemini-2.5-flash` fallback. `voicecoach-openclaw` **still** points at `192.168.86.27:11434` with no fallbacks. **There is no host at .27** — grep any new agent config for it. Related: margaret listed `google/gemini-2.5-flash` as a fallback while defining **no** google provider or auth profile, so it could never have fired; a google provider block (api `google-generative-ai`, key verified against the live models list) was added to match spirit's working shape.
32. **CORRECTED 2026-08-21 — there are TWO ollama instances on .150, which is why the model lists disagreed**: a **native Windows** `ollama.exe` (v0.32.15) on `192.168.86.150:11434` holding only `gemma4` + the new `qwen3.8*`, and one **inside WSL** (v0.15.5, systemd, autostart) relayed to `127.0.0.1:11434`, holding `qwen2.5:14b`, `Hermes-4.3-36B-GGUF`, `qwen3.6`, `qwen3-coder:30b`, `gpt-oss:20b`, `llama3.1`. So the older docs were **right** — those models exist, just in the WSL instance. An earlier note here claiming "only gemma4" was measuring the native one alone. **`host.docker.internal:11434` from a Docker Desktop container hits the WSL instance; the LAN IP hits the native one.** Both share the single 3090, so either can starve the other. spirit's `ollama` provider still advertises a nonexistent `qwen3.5` (harmless, not in its chain). **LinkedStorm on .192 points at `192.168.86.150:11434` for `qwen2.5:14b` — that model is NOT on the native instance, so LinkedStorm's LLM calls are probably 404ing. Still unverified.**
33. **Docker Desktop on .150 died twice while loading a large model (2026-08-21)**: the WSL2 VM had **no `.wslconfig`**, so it defaulted to 50% of host RAM = **15GB** shared by the Ubuntu distro, the docker-desktop VM and podman-uosserver. Loading llama.cpp's 16.8GB GGUF alongside the running containers **OOM-killed hermes and friday-voice (exit 137)**, and on the first attempt took the whole Docker Desktop backend down — it went `unable to start` -> HTTP 500 on every API call -> engine pipe missing entirely. **Recovery that worked:** kill all `Docker Desktop`/`com.docker` processes, `wsl --terminate docker-desktop`, then start Docker Desktop **before** UniFi OS Server (the pipe-conflict rule in issue #13 still applies — UniFi went down in the crash and had to be restarted after). Mitigation applied: `C:\Users\Jeremy\.wslconfig` now sets `memory=22GB`, `swap=8GB` — **this needs `wsl --shutdown` or a reboot to take effect, and `wsl --shutdown` kills the Claude Code session too**, so it has NOT been applied yet. Until it is, expect OOM churn when a big model loads.
34. **Onyx self-registration was open to the internet (fixed 2026-09-21)**: `onyx.hobbs.farm` is publicly reachable and Onyx runs `AUTH_TYPE=basic` with **no** `REQUIRE_EMAIL_VERIFICATION` and **no** `VALID_EMAIL_DOMAINS` — so `POST /api/auth/register` let anyone on the internet create an account. External scanners were already probing the host (a DigitalOcean IP hit `/` on 2026-09-21). No abuse had occurred: only 2 accounts existed (`glyptic@gmail.com` + Onyx's internal `anonymous@onyx.app`). **Fix: a Traefik router `onyx-register-lockdown` (`priority: 1000`, beats the catch-all `onyx` router) matching `PathPrefix(/api/auth/register)` behind an `ipAllowList` middleware limited to `192.168.86.0/24` + `127.0.0.1/32`.** Verified by temporarily narrowing the range: register returned **403** from a non-allowlisted source while `/api/auth/login` (405) and `/` (200) were unaffected; range then restored. Config `/mnt/docker-data/traefik/dynamic/onyx.yml` (backup `.bak-20260920-195851`). **To add a user: register from the LAN, or widen the `sourceRange`.** Note `VALID_EMAIL_DOMAINS` was rejected as the control because it is domain-level only — `gmail.com` would admit any Gmail user. Same class as Known Issue #23 (open Matrix registration).
35. **The live Prometheus config is `/mnt/docker-data/configs/prometheus/prometheus.yml` — the copy at `compose/hf-pdocker-01/monitoring/prometheus.yml` is STALE and misleading (2026-09-21)**: the compose-dir copy still lists decommissioned targets, including Onyx at the wrong node (`192.168.86.167:3005`, bart) and OpenVAS (`:9392`). Neither is in the live config, so neither was ever probed — grepping the compose copy to answer "is X monitored?" gives the wrong answer. Onyx had **no probe at all** until one was added to the live file on 2026-09-21 (`http://192.168.86.192:3005`, now `probe_success=1`). Backup `prometheus.yml.bak-20260920-*`. The live file is also the better changelog: it records the voicecoach (2026-08-24) and FRIDAY Voice / speaches (2026-08-26) teardowns that CLAUDE.md still described as live.
36. **SSH key auth to hf-pdocker-01 is broken — password only (2026-09-21)**: all of `~/.ssh/id_docker_server`, `id_bart`, `id_nas` are rejected by `192.168.86.192` (`Permission denied (publickey,password)`), while `id_docker_server` still works for `.100` and `id_bart` for bart. Connect with `sshpass -p '&Rip8a115' ssh -o PubkeyAuthentication=no glyptic@192.168.86.192`. **Any key-based automation targeting .192 is silently failing** — check `~/.ssh/authorized_keys` on that node (likely lost when the host was rebuilt/restored).
37. **hf-pdocker-01 hard-hung for ~27h (2026-10-09 13:55 → 2026-10-10 17:08 CDT)**: journal simply stops at 13:55 with no OOM, kernel error, or shutdown record; `sar` at 13:50 showed a healthy box (62% mem, 23% swap, load ~4), and .100/bart stayed up (9d uptime), so it was neither memory exhaustion nor a house-wide power event. Came back on kernel 7.0.0-38 (was -34) after a power cycle. Cause unknown — if it recurs, suspect hardware (this box was repaired 2026-03) and consider enabling persistent `kdump`/`netconsole`. Side effects found on 2026-10-10: `onyx-opensearch-1` revived itself (fixed, see Onyx entry). Also fixed then: `ServiceHighMemory` used `container_memory_usage_bytes`, which counts reclaimable page cache, so clip-service false-alarmed at 94% while really at 64% — rule now uses `container_memory_working_set_bytes` (backup `alerts.yml.bak-20261010-workingset`).
38. **bart shows ~20 nameless `Dead` containers in `docker ps -a`** — orphaned `/var/lib/docker/containers/<id>` dirs for containers whose images Watchtower already deleted. `docker rm` says "No such container". Harmless clutter; clearing them requires stopping dockerd (moving the dirs aside) which restarts the whole media stack, so it was left for a maintenance window.
39. **[RESOLVED 2026-10-11] Static-IP ESPHome boards were invisible behind UniFi Proxy ARP (found 2026-10-10)**: the `ASUS_F0_2G` WLAN has **Proxy ARP on** (`proxy_arp=1` in every AP's `/etc/hostapd/wifi0ap0.cfg`; it also has `ap_isolate=1` and `mcast.enhance=1`). With Proxy ARP, the AP answers ARP for wireless clients **from a table it learns by snooping DHCP** and stops passing ARP broadcasts through to them. Boards with ESPHome `manual_ip` never DHCP, so after any reconnect nobody answers ARP for them: they associate fine and even announce themselves over mDNS, but are unreachable. **Pool Value Controls (.6, MAC `a4:cf:12:f1:6e:c8`) went dark 2026-10-09 11:04** at a Wi-Fi reconnect on AP .187 and then looped every 15 min on ESPHome's API `reboot_timeout`. Diagnosed by capture on .187: ARP who-has .6 reached the radio and got no reply, while a static neighbor entry made ping and API work at once. **Workaround (live):** systemd unit `static-arp-esphome.service` on .192 pins `.6 -> a4:cf:12:f1:6e:c8 nud permanent` on `enp0s31f6` (HA and ESPHome are bridge-networked on .192, so they use the host's neighbor table). **Real fix (pending, needs controller access on .150):** turn Proxy ARP off on the `ASUS_F0_2G` WLAN, OR switch the boards to DHCP + Firewalla reservations. Once Proxy ARP is off, `systemctl disable --now static-arp-esphome` on .192. A `power_save_mode: none` change was also OTA'd to the board (backup `pool-value-controls.yaml.bak-20261010-powersave`). It did NOT fix this, but it's harmless and was left in. **Any other static-IP Wi-Fi device on that SSID has the same problem.** **RESOLVED 2026-10-11:** Proxy ARP (and `ap_isolate`) were turned off on `ASUS_F0_2G` in the controller (pushed to all APs 11:09–11:18; the `proxy_arp`/`ap_isolate` lines are gone from every AP's hostapd config). `static-arp-esphome.service` was then disabled and deleted on .192. Verified afterwards: .6 resolves by normal ARP (neighbor `REACHABLE`, not `PERMANENT`), ping OK, HA API session established. If a static-IP Wi-Fi device goes dark again, check that Proxy ARP hasn't been switched back on. polelightpond (.14) and living_room_lights (.153) were still unreachable after the fix, so they have some other problem (unpowered or offline).
40. **Container version audit + upgrade round (2026-10-26)**. Upgraded: Synapse v1.163.2, MAS 1.27.1, Traefik v3.7 (3.7.16), node-exporter v1.12.1 (all 3 nodes), cadvisor v0.60.6, matrix-alertmanager-receiver 2026.9.23, Gitea 1.26.4 (patch only; pg_dump at `~/gitea-db-20261026-pre1264.dump` on .100), Jellyfin 12.3, Seerr v3.5.1, plus re-pulls of every moving tag on .192 (HA 2026.10.4, ESPHome, Mosquitto, Prometheus, Blackbox, Telegraf, nginx, litellm, and the postgres/mysql/redis patch images). Every compose file touched has a `.bak-20261026` next to it on the NAS. **Deliberately NOT done, each needs its own decision:** Onyx `:latest` now = **v5.0.0 (major, released 2026-10-24)**, still on v4.9.2 (no Watchtower on .192, so it only moves on a manual pull); Gitea 1.27.x / **28.x** (major jumps); OpenClaw 2026.7.1-2 -> 2026.10.7 (one-way state migration, see #30); hermes v2026.8.18 -> v0.22.5 (upstream switched to semver tags, and its LLM endpoint is dead anyway); **MySQL 8.0 (guacamole-db, semaphore-db) went EOL April 2026 -> needs an 8.4 LTS migration with a dump/restore**; InfluxDB 1.8 (EOL); Onyx's `postgres:15.2-alpine` (2023 patch level, managed by Onyx's compose); `awesometechnologies/synapse-admin` (stale, maintained fork is `etkecc/synapse-admin`). How to audit: compare each container's `RepoDigests` against registry HEAD digests -- for Docker Hub use the hub.docker.com web API (`/v2/namespaces/<ns>/repositories/<repo>/tags/<tag>` -> `digest`), which does not count against pull limits. `docker buildx imagetools inspect --format` is ignored by the nodes' buildx v0.31.1.
41. **Docker Hub anonymous pull limit is shared by the whole house** (public IP 98.160.103.126, 100 pulls/hour): on 2026-10-26 every node AND WSL got 429 `toomanyrequests`, meaning Watchtower updates of Docker Hub images were silently failing. Mitigation applied: both Watchtowers moved from a 24h poll interval to a **twice-daily cron schedule** (04:00/16:00), and the archived `containrrr/watchtower` swapped for the maintained drop-in fork `nickfedor/watchtower` (**image name is `nickfedor/`, not `nicholas-fedor/`** -- the latter is the GitHub org and 404s on Hub). Same env vars and still honors `com.centurylinklabs.watchtower.enable`. A `docker login` on the nodes would raise the limit further. **cadvisor no longer publishes to gcr.io** after v0.55 -- the image is now `ghcr.io/google/cadvisor`.
42. **node-exporter had been launched from `/tmp` (compose project `tmp`) on all 3 nodes**, so `docker compose` in the real dirs didn't own it. Fixed 2026-10-26: removed and redeployed from `/mnt/docker-data/compose/<node>/node-exporter/`. Separately, the **monitoring** compose on .192 still defines a legacy `node-exporter:latest` service on :9100 that a bare `docker compose up -d` would try to start; it is now gated behind `profiles: ["legacy-node-exporter"]`. Also: the matrix-alertmanager-receiver container is now named `monitoring-matrix-alertmanager-receiver-1` (compose default) -- Alertmanager reaches it by service name `matrix-alertmanager-receiver`, which still resolves.
43. **Farm Manager showed ZERO containers for hf-pdocker-01 (fixed 2026-10-26)**: the server fetches each agent's `/containers` with a timeout and on any error silently returns `[]` for that node. .192's agent took ~12s (47 containers, per-container `stats(stream=False)` = Docker samples twice ~1s apart, only 10 workers) vs a 10s timeout, so the main host's containers simply vanished from the dashboard while `/api/nodes` still said "healthy, 47". Fix: agent uses `stats(..., one_shot=True)` + 20 workers (now ~2s), server timeout 30s. Agent rebuilt on .192 and shipped to .100/bart via `docker save | docker load` (only .192 has the `/opt/farm-manager` source); rollback tags `farm-agent:rollback-20261026` / `farm-manager:rollback-20261026`. Same day: removed a dead `gpu-workstation` node (192.168.86.27) from the live `config.json`, made `/health` ignore Dead containers (bart reported 30 instead of 10), and rebuilt `groups.json` -- every group still used Swarm-era / decommissioned names (`ai-agents_margaret`, `jellyseerr`, `adguardhome`, bart Onyx, `openvas`, `unifi`...), so all group actions were no-ops. Now 14 groups / 63 containers; per-node utilities (farm-agent, node-exporter, telegraf, watchtower) are deliberately ungrouped so a group "stop" can't kill the agents. **4 tests in `tests/test_agent_docker_ops.py` fail -- pre-existing** (they mock `containers.list` but the code uses low-level `api.containers`); not caused by this change.
+14 -10
View File
@@ -1,11 +1,10 @@
services:
jellyfin:
# PINNED to the 12.0.0 unstable build that migrated the DB to the v12
# schema (DropExtraIdsColumn) — :latest is still 10.11.x and CANNOT read
# this DB ("no such column: b.ExtraIds"). Do not unpin until official
# stable >= 12 ships. (2026-07-14: a :latest deploy downgraded it and
# broke Resume/home sections; repinned.)
image: jellyfin/jellyfin:2026060106
# Pinned to official stable 12.2. History: ran the 2026060106 unstable
# build from 2026-07-14 because the DB had been migrated to the v12 schema
# and 10.11.x could not read it. Stable 12 shipped 2026-09; moved to
# 12.2 on 2026-10-17, 12.3 on 2026-10-26. Never go back below 12.
image: jellyfin/jellyfin:12.3.20261023-013714
container_name: jellyfin
restart: unless-stopped
environment:
@@ -145,14 +144,19 @@ services:
max-size: "50m"
max-file: "3"
jellyseerr:
image: fallenbagel/jellyseerr:latest
container_name: jellyseerr
seerr:
# Seerr = merged successor of Jellyseerr/Overseerr (fallenbagel/jellyseerr
# is deprecated). Migrated 2026-10-17; DB auto-migrated on first start.
# Runs as node (UID 1000), needs init. LOCAL disk (SQLite). Old config left
# at /mnt/docker-data/configs/jellyseerr for rollback.
image: seerr/seerr:v3.5.1
init: true
container_name: seerr
restart: unless-stopped
environment:
- TZ=America/Chicago
volumes:
- /mnt/docker-data/configs/jellyseerr:/app/config
- /var/lib/seerr:/app/config
ports:
- "5055:5055"
mem_limit: 512M
@@ -1,6 +1,6 @@
services:
node-exporter:
image: prom/node-exporter:v1.7.0
image: prom/node-exporter:v1.12.1
container_name: node-exporter
hostname: bart
network_mode: host
@@ -11,14 +11,14 @@ services:
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
- "--path.procfs=/host/proc"
- "--path.rootfs=/rootfs"
- "--path.sysfs=/host/sys"
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
logging:
driver: "json-file"
driver: json-file
options:
max-size: "50m"
max-size: 50m
max-file: "3"
labels:
- "com.centurylinklabs.watchtower.enable=false"
- com.centurylinklabs.watchtower.enable=false
+2 -2
View File
@@ -1,6 +1,6 @@
services:
watchtower:
image: containrrr/watchtower:latest
image: nickfedor/watchtower:latest # containrrr/watchtower is archived upstream (2026-10-26)
container_name: watchtower
restart: unless-stopped
volumes:
@@ -8,7 +8,7 @@ services:
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- WATCHTOWER_POLL_INTERVAL=86400
- WATCHTOWER_SCHEDULE=0 0 4,16 * * * # twice daily, 04:00 + 16:00 local
- WATCHTOWER_MONITOR_ONLY=false
- TZ=America/Chicago
- DOCKER_API_VERSION=1.44
@@ -4,7 +4,11 @@
# bridged bind mount). friday-voice calls this via `docker exec hermes hermes ...`.
services:
gateway:
image: nousresearch/hermes-agent:latest
# PINNED 2026-08-20. Was `:latest`, which on this repo is the same digest as
# `main` (a rolling dev build), and with Watchtower disabled nothing ever
# re-pulled it — the container sat on v2026.7.1 for ~7 weeks / 9 releases.
# Bump deliberately; rollback image: nousresearch/hermes-agent:rollback-2026.7.1
image: nousresearch/hermes-agent:v2026.8.18
container_name: hermes
restart: unless-stopped
volumes:
@@ -0,0 +1,62 @@
# llama.cpp server on .150 (Docker Desktop) — inference backend for hermes/FRIDAY.
#
# Replaced ollama as the *serving* engine on 2026-08-21 after a head-to-head
# benchmark on the same GGUF, same 64k context, same GPU:
# ollama (f16 KV): 15.8 tok/s aggregate, 16.5 sustained, 23207 MiB VRAM
# llama.cpp (q8_0 KV): 39.7 tok/s aggregate, 39.9 sustained, 19545 MiB VRAM
# The difference is the quantized KV cache: it drops the footprint from 23.2GB
# to 18.6GB, so the whole model fits on the 3090 instead of spilling layers to
# CPU. Ollama exposes no knob for KV quantization.
#
# Image is pinned BY DIGEST on purpose — `:server-cuda` is a moving tag, and
# this homelab has been bitten by tag-rot three times (synapse, gitea, openclaw).
services:
llamacpp:
image: ghcr.io/ggml-org/llama.cpp@sha256:995fe9f237c622ac257905948c3cdde447ab343db22e0686322445b5ec6d3131
container_name: llamacpp
restart: unless-stopped
ports:
- "8081:8081"
volumes:
# Read-only mount of ollama's blob store. NOTE the coupling: this is the
# GGUF that `qwen3.8` resolves to. C: only had 26GB free so a dedicated
# copy wasn't practical. If you ever `ollama rm qwen3.8`, this breaks —
# copy the blob out first.
- "C:/Users/Jeremy/.ollama/models/blobs:/models:ro"
command:
- "-m"
- "/models/sha256-f5f1dd8920d417aac2718b0bda3403da274301efdd6760b4f0f4b864ff2ad57d"
- "--alias"
- "qwen3.8-64k"
- "-ngl"
- "99" # all 65 layers to GPU
- "-c"
- "65536" # hermes hard-refuses anything under 64K
- "--cache-type-k"
- "q8_0" # the whole point — halves KV cache
- "--cache-type-v"
- "q8_0"
- "--host"
- "0.0.0.0"
- "--port"
- "8081"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
labels:
- "com.centurylinklabs.watchtower.enable=false"
# The image ships its own HEALTHCHECK that cannot run here, so the
# container reports permanently "unhealthy" while serving fine (cf. litellm,
# CLAUDE.md known issue #15). Disable it; liveness is the blackbox probe of
# GET http://192.168.86.150:8081/health from outside.
healthcheck:
disable: true
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
@@ -0,0 +1,35 @@
# Margaret — OpenClaw gateway agent (hf-pdocker-01, :18790)
#
# Reconstructed 2026-08-20 from the layer history of margaret-openclaw:latest;
# the original Dockerfile was never committed anywhere.
#
# OPENCLAW_VERSION is pinned on purpose. This image was previously built with
# `openclaw@latest`, which is how it sat on 2026.5.27 for months without anyone
# noticing (same failure mode as the matrixdotorg/synapse :latest rot).
# Bump this ARG deliberately.
FROM node:22.22.3-bookworm
ARG OPENCLAW_VERSION=2026.7.1-2
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g openclaw@${OPENCLAW_VERSION} 2>&1 | tail -5
# The bundled matrix extension ships without its runtime deps installed.
RUN if [ -d /usr/local/lib/node_modules/openclaw/dist/extensions/matrix ]; then \
cd /usr/local/lib/node_modules/openclaw/dist/extensions/matrix && \
npm install --no-fund --no-audit 2>&1 | tail -5; \
fi
# Shim for a plugin-sdk export path some builds omit.
RUN if [ ! -f /usr/local/lib/node_modules/openclaw/dist/plugin-sdk/keyed-async-queue.js ]; then \
printf 'export { KeyedAsyncQueue, enqueueKeyedTask } from "./index.js";\n' \
> /usr/local/lib/node_modules/openclaw/dist/plugin-sdk/keyed-async-queue.js; \
fi
USER node
WORKDIR /home/node
EXPOSE 18790
CMD ["openclaw", "gateway", "start", "--foreground"]
@@ -0,0 +1,38 @@
# Spirit — OpenClaw infra-ops agent (hf-pdocker-01, :18795)
#
# Reconstructed 2026-08-20 from the layer history of spirit-ops:latest;
# the original Dockerfile was never committed anywhere.
#
# OPENCLAW_VERSION is pinned on purpose. This image was previously built with
# `openclaw@latest`, which is how it sat on 2026.5.27 for months without anyone
# noticing (same failure mode as the matrixdotorg/synapse :latest rot).
# Bump this ARG deliberately.
FROM node:22.22.3-bookworm
ARG OPENCLAW_VERSION=2026.7.1-2
# spirit does infra ops: needs ssh out to the nodes plus basic diagnostics.
RUN apt-get update && apt-get install -y --no-install-recommends \
git ca-certificates openssh-client curl jq procps net-tools \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g openclaw@${OPENCLAW_VERSION} 2>&1 | tail -5
RUN npm install -g @google/gemini-cli 2>&1 | tail -3
# The bundled matrix extension ships without its runtime deps installed.
RUN if [ -d /usr/local/lib/node_modules/openclaw/dist/extensions/matrix ]; then \
cd /usr/local/lib/node_modules/openclaw/dist/extensions/matrix && \
npm install --no-fund --no-audit 2>&1 | tail -5; \
fi
# Shim for a plugin-sdk export path some builds omit.
RUN if [ ! -f /usr/local/lib/node_modules/openclaw/dist/plugin-sdk/keyed-async-queue.js ]; then \
printf 'export { KeyedAsyncQueue, enqueueKeyedTask } from "./index.js";\n' \
> /usr/local/lib/node_modules/openclaw/dist/plugin-sdk/keyed-async-queue.js; \
fi
USER node
WORKDIR /home/node
EXPOSE 18795
CMD ["openclaw", "gateway", "start", "--foreground"]
@@ -0,0 +1,67 @@
services:
mas-db:
image: postgres:16-alpine
container_name: mas-db
restart: unless-stopped
environment:
- POSTGRES_USER=mas
- POSTGRES_PASSWORD=3XRBtczkPrdbNgdJftKGWu7QwhT3
- POSTGRES_DB=mas
volumes:
# LOCAL disk, never NFS (Known Issue #16)
- /var/lib/mas-db:/var/lib/postgresql/data
networks:
- mas
mem_limit: 256M
memswap_limit: 256M
labels:
- "com.centurylinklabs.watchtower.enable=false"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mas -d mas"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
mas:
# NOTE: MAS tags are NOT v-prefixed (1.23.0), unlike Synapse (v1.159.0).
# Distroless image - no shell, so no container healthcheck is possible.
# Liveness is covered by the blackbox probe of https://auth.hobbs.farm/health.
image: ghcr.io/element-hq/matrix-authentication-service:1.27.1
container_name: mas
restart: unless-stopped
command: ["server", "--config", "/config/config.yaml"]
volumes:
- /mnt/docker-data/configs/mas:/config:ro
# read-only synapse data dir: syn2mas reads homeserver.yaml from here
- /var/lib/synapse:/synapse:ro
ports:
- "8020:8080" # web: oauth / compat / discovery / human UI
- "8021:8081" # internal: /health only
networks:
- mas # to mas-db
- matrix-internal # to synapse (admin API) and back
depends_on:
mas-db:
condition: service_healthy
mem_limit: 256M
memswap_limit: 256M
labels:
- "com.centurylinklabs.watchtower.enable=false"
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
mas:
driver: bridge
matrix-internal:
external: true
name: matrix_matrix-internal
+12 -11
View File
@@ -1,20 +1,20 @@
services:
synapse:
image: matrixdotorg/synapse:latest
# 2026-08-20: matrixdotorg repo is abandoned (froze at 1.148.0); upstream is now element-hq.
# Pinned deliberately so the running version is visible in the file - bump manually.
image: ghcr.io/element-hq/synapse:v1.163.2
container_name: synapse
restart: unless-stopped
environment:
- SYNAPSE_SERVER_NAME=mess.hobbs.farm
- SYNAPSE_REPORT_STATS=no
volumes:
- /mnt/docker-data/configs/matrix/data:/data
# 2026-08-20: moved off NFS -> local NVMe (SQLite locking, Known Issue #16)
- /var/lib/synapse:/data
ports:
- "8008:8008"
networks:
- matrix-internal
depends_on:
synapse-db:
condition: service_healthy
mem_limit: 1G
memswap_limit: 1G
healthcheck:
@@ -52,21 +52,22 @@ services:
max-file: "3"
synapse-db:
image: postgres:15-alpine
image: postgres:16-alpine
container_name: synapse-db
user: "1024:100"
restart: unless-stopped
environment:
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=synapse
- POSTGRES_PASSWORD=MnLASftPXou9ubOmawmALYwROwgM
- POSTGRES_DB=synapse
# C collation is MANDATORY - Synapse refuses any other
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
volumes:
- /mnt/docker-data/databases/matrix-db:/var/lib/postgresql/data
# LOCAL disk, never NFS (Known Issue #16). No user: 1024:100 - that is an NFS workaround.
- /var/lib/synapse-db:/var/lib/postgresql/data
networks:
- matrix-internal
mem_limit: 256M
memswap_limit: 256M
mem_limit: 512M
memswap_limit: 512M
labels:
- "com.centurylinklabs.watchtower.enable=false"
healthcheck:
@@ -1,21 +1,12 @@
networks:
monitoring-net:
name: monitoring-net
services:
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
user: "104"
networks:
- monitoring-net
ports:
- "3001:3000"
volumes:
- /mnt/docker-data/configs/grafana:/var/lib/grafana
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"]
interval: 30s
@@ -27,13 +18,19 @@ services:
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
networks:
- monitoring-net
ports:
- "9095:9090"
command:
@@ -44,8 +41,6 @@ services:
- '--web.enable-lifecycle'
volumes:
- /mnt/docker-data/configs/prometheus:/etc/prometheus
mem_limit: 512M
memswap_limit: 512M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:9090/-/healthy || exit 1"]
interval: 30s
@@ -57,13 +52,19 @@ services:
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 512M
alertmanager:
image: prom/alertmanager:latest
container_name: alertmanager
restart: unless-stopped
networks:
- monitoring-net
ports:
- "9093:9093"
command:
@@ -72,8 +73,6 @@ services:
volumes:
- /mnt/docker-data/configs/alertmanager:/etc/alertmanager
- alertmanager-data:/alertmanager
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:9093/-/healthy || exit 1"]
interval: 30s
@@ -85,19 +84,49 @@ services:
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 256M
node-exporter:
# DISABLED 2026-10-26: node-exporter runs STANDALONE (compose/<node>/node-exporter, pid:host).
# This legacy copy would collide on :9100 if started; gated behind a profile so a
# bare `docker compose up -d` here never starts it.
profiles: ["legacy-node-exporter"]
image: prom/node-exporter:latest
restart: unless-stopped
ports:
- "9100:9100"
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: global
resources:
limits:
memory: 128M
influxdb:
image: influxdb:1.8-alpine
container_name: influxdb
restart: unless-stopped
networks:
- monitoring-net
ports:
- "8086:8086"
volumes:
- /mnt/docker-data/configs/influxdb:/var/lib/influxdb
mem_limit: 1G
memswap_limit: 1G
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8086/ping || exit 1"]
interval: 30s
@@ -109,45 +138,99 @@ services:
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 1G
matrix-alertmanager-receiver:
image: metio/matrix-alertmanager-receiver:2026.2.25
container_name: matrix-alertmanager-receiver
image: metio/matrix-alertmanager-receiver:2026.9.23
restart: unless-stopped
networks:
- monitoring-net
ports:
- "9094:9094"
volumes:
- /mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml:/config/config.yml:ro
command: ["--config-path", "/config/config.yml"]
mem_limit: 128M
memswap_limit: 128M
# NOTE: No healthcheck - this image is distroless (no shell), so CMD-SHELL healthchecks fail.
# The service is verified healthy by Alertmanager successfully POSTing to it.
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 128M
cadvisor:
image: ghcr.io/google/cadvisor:v0.60.6 # moved off gcr.io (no releases there after v0.55)
restart: unless-stopped
privileged: true
command:
- '--docker_only=true'
- '--disable_metrics=advtcp,cpu_topology,cpuset,hugetlb,memory_numa,process,referenced_memory,resctrl,sched,tcp,udp,disk,diskIO,percpu'
- '--housekeeping_interval=30s'
- '--store_container_labels=false'
ports:
- "8081:8080"
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
- /sys/fs/cgroup:/sys/fs/cgroup:ro
devices:
- /dev/kmsg
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 256M
blackbox-exporter:
image: prom/blackbox-exporter:latest
container_name: blackbox-exporter
restart: unless-stopped
networks:
- monitoring-net
ports:
- "9115:9115"
command:
- '--config.file=/config/blackbox.yml'
volumes:
- /mnt/docker-data/configs/blackbox:/config:ro
mem_limit: 128M
memswap_limit: 128M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == hf-pdocker-01
resources:
limits:
memory: 128M
volumes:
alertmanager-data:
@@ -1,6 +1,6 @@
services:
node-exporter:
image: prom/node-exporter:v1.7.0
image: prom/node-exporter:v1.12.1
container_name: node-exporter
hostname: hf-pdocker-01
network_mode: host
@@ -0,0 +1,102 @@
http:
routers:
auth:
rule: "Host(`auth.hobbs.farm`)"
service: mas
entryPoints:
- websecure
middlewares:
- security-headers
tls:
certResolver: letsencrypt
# 2026-08-20: under MAS, Synapse no longer serves the login endpoints
# (it returns M_UNRECOGNIZED). These three compat paths must reach MAS or
# no client can sign in. Explicit high priority so it wins over `mess`.
mess-auth-compat:
rule: "Host(`mess.hobbs.farm`) && PathRegexp(`^/_matrix/client/(r0|v3|v1|unstable)/(login|logout|refresh)`)"
priority: 1000
service: mas
entryPoints:
- websecure
middlewares:
- security-headers
tls:
certResolver: letsencrypt
yoda:
rule: "Host(`yoda.hobbs.farm`)"
service: yoda
entryPoints:
- websecure
middlewares:
- security-headers-allow-frames
tls:
certResolver: letsencrypt
mess:
rule: "Host(`mess.hobbs.farm`)"
service: mess
entryPoints:
- websecure
middlewares:
- security-headers
tls:
certResolver: letsencrypt
mess-federation:
rule: "Host(`mess.hobbs.farm`) && PathPrefix(`/_matrix/federation`)"
service: mess
entryPoints:
- websecure
middlewares:
- security-headers
tls:
certResolver: letsencrypt
wellknown:
rule: "Host(`hobbs.farm`) && PathPrefix(`/.well-known/matrix`)"
service: wellknown
entryPoints:
- websecure
middlewares:
- security-headers
tls:
certResolver: letsencrypt
solo:
rule: "Host(`solo.hobbs.farm`)"
service: solo
entryPoints:
- websecure
middlewares:
- solo-basicauth
- security-headers-allow-frames
tls:
certResolver: letsencrypt
services:
mas:
loadBalancer:
servers:
- url: "http://192.168.86.192:8020"
yoda:
loadBalancer:
servers:
- url: "http://192.168.86.192:8123"
mess:
loadBalancer:
servers:
- url: "http://192.168.86.192:8008"
wellknown:
loadBalancer:
servers:
- url: "http://192.168.86.192:8010"
solo:
loadBalancer:
servers:
- url: "http://192.168.86.192:8199"
@@ -1,6 +1,6 @@
services:
traefik:
image: traefik:v3.6
image: traefik:v3.7
container_name: traefik
restart: unless-stopped
command:
@@ -1,6 +1,6 @@
services:
gitea:
image: gitea/gitea:1.26.1
image: gitea/gitea:1.26.4
container_name: gitea
restart: unless-stopped
labels:
@@ -0,0 +1,25 @@
# Hobbs' Family Organizer - nonsecret runtime settings
# Copy this file to .env. Keep private calendar URLs and the access token in
# ./secrets; never put either value in this file.
# Docker publishes the app on this server address and port. Set the bind
# address to a specific LAN interface if you do not want to listen everywhere.
HOBBS_BIND_ADDRESS=0.0.0.0
HOBBS_HTTP_PORT=8340
# Display and calendar defaults.
ORGANIZER_TIMEZONE=America/Chicago
# Seven-day forecast defaults for Sapulpa, Oklahoma.
WEATHER_LOCATION="Sapulpa, OK"
WEATHER_LATITUDE=35.9987
WEATHER_LONGITUDE=-96.11417
NWS_FORECAST_URL=https://api.weather.gov/gridpoints/TSA/37,98/forecast
# The National Weather Service asks clients to identify themselves. Replace
# the example contact with an address monitored by the server operator.
WEATHER_USER_AGENT="Hobbs-Family-Organizer/1.0 (self-hosted)"
# Admin (v1.1.0)
HOBBS_DATA_DIR=/var/lib/hobbs-organizer/data
ADMIN_ALLOWED_CIDRS=192.168.86.0/24,127.0.0.1/32
@@ -0,0 +1,34 @@
# Hobbs Family Organizer (Shield TV dashboard) — hf-pdocker-02
**Shield photo app (2026-11-04): `com.dayline.photos` v1.0.** A launcher tile plus a screensaver, both showing `/photo`.
- Build: `sudo ./build-photos-apk.sh`. It writes `~/hobbs-shield/Hobbs-Family-Photos-<ver>.apk` on .100.
- Install: `adb install -r <apk>`.
- Set the screensaver: `adb shell settings put secure screensaver_components com.dayline.photos/.PhotoDreamService`.
- Signing key: `/var/lib/hobbs-organizer/apk-signing/` plus a NAS copy. Keep it; every update must use it.
**v1.2.0 (2026-11-04): photo frame at http://192.168.86.100:8340/photo** — Google Photos shared albums
(admin → Photos tab: add share link, Check, Sync now; display interval/fit/shuffle/date) + uploads (resized in the browser).
Cache: `/var/lib/hobbs-organizer/data/photos/<album-id>/` (+ `uploads/`), re-synced every 6h. Stale check: `/api/health/photos` (503 after 48h).
Scraping is unofficial (share-page `ds:1` JSON); if Google changes the page, sync errors show in admin and cached photos keep playing.
**v1.1.0 (2026-11-04): admin screen at http://192.168.86.100:8340/admin** — password (set on first visit, LAN only),
calendar links (add/remove/rename/color/hide, masked after save, per-calendar Test, history + Restore).
Settings live in `/var/lib/hobbs-organizer/data/` (local disk, owner 10001, mode 700):
`settings.json` (calendars, incl. URLs — treat as secret), `auth.json` (scrypt hash + session key), `history/` (last 20).
Forgot password: `docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs`.
The secrets-file calendar list is now only a seed used when `settings.json` does not exist yet.
Rollback: set `image: hobbs-family-organizer:1.0.0` (still on .100) — it ignores /data and reads the secrets file.
Deployed 2026-11-04. URL: http://192.168.86.100:8340/ (LAN only, trusted-LAN mode = no token).
- App source / release bundle: `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/` (repo root).
- Deploy dir on NAS: `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer/` = the release bundle
+ `compose.override.yaml` + `.env` from this folder. Image is built locally (`hobbs-family-organizer:1.0.0`).
- Secrets on LOCAL disk of .100 (root:root 600): `/var/lib/hobbs-organizer/secrets/`
- `calendar_ical_urls.json` — JSON array of private Google Calendar basic.ics URLs (treat as passwords; never commit)
- `organizer_access_token.txt` — empty = trusted-LAN mode. Put a 32+ char token here for bearer mode.
- Redeploy: `cd /mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer && docker compose up -d --build --wait`
- Health: `curl http://192.168.86.100:8340/api/health`
- Shields (package `com.dayline.tv`, APK v1.4) read `files/base_url` + `files/allow_insecure_http`
provisioned over ADB `run-as`. adb client lives on .100 (`apt install adb`).
- Shield provisioning (bash port of configure-shield.ps1, run on .100): `provision-shield.sh <ip> <base_url>`
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Builds the Hobbs Family Photos Shield APK (com.dayline.photos) on hf-pdocker-02.
# Usage: sudo ./build-photos-apk.sh -> ~/hobbs-shield/Hobbs-Family-Photos-<ver>.apk
# The signing key is created on first run in /var/lib/hobbs-organizer/apk-signing (root 600)
# and copied to the NAS. NEVER delete it: updates must be signed with the same key.
set -euo pipefail
DEPLOY=/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer
SRC="$DEPLOY/shield/photos-source"
SIGN=/var/lib/hobbs-organizer/apk-signing
NAS_SIGN=/mnt/docker-data/configs/hobbs-organizer/apk-signing
WORK=/var/tmp/hfo-photos-build
OUT=/home/glyptic/hobbs-shield
IMAGE=eclipse-temurin:17-jdk-jammy
[ "$(id -u)" = 0 ] || { echo "run with sudo"; exit 1; }
install -d -m 700 "$SIGN"
if [ ! -f "$SIGN/hobbs-photos.jks" ]; then
echo "== creating signing key"
PASS=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-32)
docker run --rm -v "$SIGN:/signing" "$IMAGE" keytool -genkeypair -keystore /signing/hobbs-photos.jks \
-storetype PKCS12 -alias hobbs-photos -keyalg RSA -keysize 4096 -validity 20000 \
-dname "CN=Hobbs Family Photos, O=Hobbs Farm" -storepass "$PASS" -keypass "$PASS" >/dev/null
printf 'storeFile=/signing/hobbs-photos.jks\nstorePassword=%s\nkeyAlias=hobbs-photos\nkeyPassword=%s\n' "$PASS" "$PASS" > "$SIGN/keystore.properties"
chmod 600 "$SIGN"/*
fi
# Off-box copy (NAS root_squash: write as glyptic, keep it private).
sudo -u glyptic install -d -m 700 "$NAS_SIGN"
for f in hobbs-photos.jks keystore.properties; do
cat "$SIGN/$f" | sudo -u glyptic sh -c "umask 077; cat > '$NAS_SIGN/$f'"
done
rm -rf "$WORK"; mkdir -p "$WORK"; cp -a "$SRC/." "$WORK/"
cp "$SIGN/keystore.properties" "$WORK/keystore.properties"
docker volume create hobbs-android-sdk >/dev/null
docker run --rm -v "$WORK:/src" -v hobbs-android-sdk:/opt/cache -v "$SIGN:/signing:ro" -w /src "$IMAGE" bash -euo pipefail -c '
export ANDROID_HOME=/opt/cache/sdk GRADLE_USER_HOME=/opt/cache/gradle-home
command -v unzip >/dev/null || { apt-get update -qq && apt-get install -y -qq unzip curl >/dev/null; }
if [ ! -x $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager ]; then
echo "== downloading Android command-line tools"
curl -fsSL -o /tmp/clt.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
mkdir -p $ANDROID_HOME/cmdline-tools && unzip -q /tmp/clt.zip -d /tmp/clt && mv /tmp/clt/cmdline-tools $ANDROID_HOME/cmdline-tools/latest
fi
if [ ! -d $ANDROID_HOME/platforms/android-35 ] || [ ! -d $ANDROID_HOME/build-tools/35.0.0 ]; then
echo "== installing SDK platform 35 + build-tools 35.0.0"
(yes 2>/dev/null || true) | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null || true
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "platforms;android-35" "build-tools;35.0.0" >/dev/null
fi
if [ ! -x /opt/cache/gradle-8.9/bin/gradle ]; then
echo "== downloading Gradle 8.9"
curl -fsSL -o /tmp/gradle.zip https://services.gradle.org/distributions/gradle-8.9-bin.zip
unzip -q /tmp/gradle.zip -d /opt/cache
fi
echo "sdk.dir=$ANDROID_HOME" > local.properties
/opt/cache/gradle-8.9/bin/gradle --no-daemon -q assembleRelease
$ANDROID_HOME/build-tools/35.0.0/apksigner verify --print-certs app/build/outputs/apk/release/app-release.apk | grep -E "Signer #1 certificate (DN|SHA-256)"
$ANDROID_HOME/build-tools/35.0.0/aapt2 dump badging app/build/outputs/apk/release/app-release.apk | grep -E "^package:|application-debuggable|leanback"
'
VER=$(grep -oP 'versionName "\K[^"]+' "$SRC/app/build.gradle")
install -d -o glyptic -g glyptic "$OUT"
install -o glyptic -g glyptic -m 644 "$WORK/app/build/outputs/apk/release/app-release.apk" "$OUT/Hobbs-Family-Photos-$VER.apk"
sha256sum "$OUT/Hobbs-Family-Photos-$VER.apk"
rm -rf "$WORK"
@@ -0,0 +1,12 @@
# Homelab overrides (hf-pdocker-02). Secrets live on LOCAL disk, not NFS:
# start.mjs reads them as root, and NFS root_squash would deny that.
# Mode: trusted-LAN (empty token file) per user 2026-11-04.
services:
organizer:
labels:
com.centurylinklabs.watchtower.enable: "false"
secrets:
calendar_ical_urls:
file: /var/lib/hobbs-organizer/secrets/calendar_ical_urls.json
organizer_access_token:
file: /var/lib/hobbs-organizer/secrets/organizer_access_token.txt
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Usage: provision-shield.sh <ip> <base_url> (bash port of configure-shield.ps1, trusted-LAN http)
set -euo pipefail
D="$1:5555"; URL="$2"; PKG=com.dayline.tv; APK=/home/glyptic/hobbs-shield/Hobbs-Family-Organizer-Local-v1.4.apk
adb connect "$D" >/dev/null; [ "$(adb -s "$D" get-state)" = device ]
echo "before: $(adb -s "$D" shell dumpsys package $PKG | grep -m1 versionName)"
adb -s "$D" shell run-as $PKG ls files
adb -s "$D" install -r "$APK"
echo "after: $(adb -s "$D" shell dumpsys package $PKG | grep -m1 versionName)"
put() { printf '%s' "$2" | adb -s "$D" shell "run-as $PKG sh -c 'umask 077; cat > files/.$1.new && chmod 600 files/.$1.new && mv files/.$1.new files/$1'"; }
put base_url "$URL"
adb -s "$D" shell "run-as $PKG rm -f files/access_token"
put allow_insecure_http enabled
adb -s "$D" shell run-as $PKG ls -l files
FG=$(adb -s "$D" shell dumpsys activity activities | grep -m1 mResumedActivity); echo "foreground: $FG"; echo "$FG" | grep -q -E "com.dayline.tv|launcher|tvlauncher|projengmenu" || { echo SKIP-LAUNCH: TV in use; exit 0; }
adb -s "$D" shell am force-stop $PKG
adb -s "$D" shell am start -W -n $PKG/.MainActivity | grep -E 'Status|Activity'
@@ -1,6 +1,6 @@
services:
node-exporter:
image: prom/node-exporter:v1.7.0
image: prom/node-exporter:v1.12.1
container_name: node-exporter
hostname: hf-pdocker-02
network_mode: host
@@ -11,14 +11,14 @@ services:
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
- "--path.procfs=/host/proc"
- "--path.rootfs=/rootfs"
- "--path.sysfs=/host/sys"
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"
logging:
driver: "json-file"
driver: json-file
options:
max-size: "50m"
max-size: 50m
max-file: "3"
labels:
- "com.centurylinklabs.watchtower.enable=false"
- com.centurylinklabs.watchtower.enable=false
@@ -0,0 +1,3 @@
Decommissioned 2026-10-05 and again 2026-10-26 (it had been revived and was running).
Duplicate controller; the real UniFi controller is the Podman UniFi OS Server on .150.
Do NOT compose up. Data kept in /var/lib/unifi-os (.100) + /mnt/docker-data/configs/unifi-os/persistent.
@@ -1,6 +1,6 @@
services:
watchtower:
image: containrrr/watchtower:latest
image: nickfedor/watchtower:latest # containrrr/watchtower is archived upstream (2026-10-26)
container_name: watchtower
restart: unless-stopped
volumes:
@@ -8,12 +8,11 @@ services:
environment:
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- WATCHTOWER_POLL_INTERVAL=86400
- WATCHTOWER_SCHEDULE=0 0 4,16 * * * # twice daily, 04:00 + 16:00 local
- WATCHTOWER_MONITOR_ONLY=false
- TZ=America/Chicago
- DOCKER_API_VERSION=1.44
mem_limit: 256M
memswap_limit: 256M
logging:
driver: "json-file"
options:
@@ -0,0 +1,668 @@
# QR Sign-In via Matrix Authentication Service — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable "sign in with QR code" (MSC4108) for Matrix accounts on hobbs.farm, which requires deploying Matrix Authentication Service (MAS) as the homeserver's auth provider.
**Architecture:** QR sign-in is an OAuth 2.0 device grant, not a password login. Synapse 1.159.0 refuses to start with `msc4108_enabled: true` unless `matrix_authentication_service.enabled` is also true. MAS requires PostgreSQL, and — critically — **`syn2mas` can only read a Synapse whose own database is PostgreSQL**. Synapse here is on SQLite, so this is a three-stage chain: port Synapse to PostgreSQL, deploy MAS and migrate the accounts into it, then turn on the QR MSCs.
**Tech Stack:** Synapse 1.159.0 (SQLite → PostgreSQL 16), Matrix Authentication Service (Docker, ghcr.io/element-hq), PostgreSQL 16-alpine ×2, Traefik, Cloudflare DNS, Docker Compose on hf-pdocker-01.
## Why this is three migrations, not a config flag
Verified in the running image's own source (`synapse/config/experimental.py`):
```python
if (self.msc4108_enabled or self.msc4108_delegation_endpoint is not None) and not auth_delegated:
raise ConfigError("MSC4108 requires matrix_authentication_service to be enabled", ...)
```
The same guard exists for MSC4388 (the QR out-of-band channel). And from the MAS docs:
> "It is worth noting that MAS currently only supports PostgreSQL as a database backend."
> "The migration tool only supports reading from PostgreSQL for the Synapse database as well."
Synapse on this node currently runs `database: {name: sqlite3, database: /data/homeserver.db}`.
## Global Constraints
- Synapse image stays pinned at `ghcr.io/element-hq/synapse:v1.159.0` (**`v` prefix required**; `:1.159.0` 404s). Do not bump during this work.
- Synapse ≥1.143 requires **PostgreSQL 14+**. Use `postgres:16-alpine`.
- Synapse's PostgreSQL database MUST be created with `ENCODING 'UTF8' LC_COLLATE 'C' LC_CTYPE 'C'` — Synapse refuses any other collation.
- MAS requires its **own separate, blank** PostgreSQL database. Do not share Synapse's.
- All lock-prone databases go on **LOCAL disk**, never NFS (CLAUDE.md Known Issue #16). Use `/var/lib/synapse-db` and `/var/lib/mas-db`.
- On hf-pdocker-01, **`memswap_limit` must equal `mem_limit`** on every container (Known Issue #12 — a drift here crashed the whole node on 2026-07-12).
- Node headroom: ~8GB available of 23GB, Frigate is the main consumer. Budget ≤600MB total for the new containers.
- **hobbs.farm has no wildcard DNS record** (memory `hobbs-farm-dns`). Traefik uses DNS-01, so a certificate will be issued for a hostname with **no A record** — a valid cert does NOT mean the name resolves. Any new public hostname needs an explicit Cloudflare A record → `98.160.103.126`, DNS-only (grey cloud).
- Watchtower does not run on .192; pin every image tag explicitly.
- Synapse data lives at `/var/lib/synapse` on local NVMe. The NFS copy at `/mnt/docker-data/configs/matrix/data` is a stale backup — **never remount it**.
- The mandatory post-change checklist in CLAUDE.md applies (CLAUDE.md, MEMORY.md, Prometheus probes, wiki, Farm Manager links).
## Blast Radius
Authentication for **all 19 accounts** moves to MAS. Known consumers that must keep working:
| Account | How it authenticates | Migrates via |
|---|---|---|
| `@jeremy` | password `bigdog01` + 5 Element devices (2× Element X Android, 2× Element Desktop) | password hash + devices |
| `@frigate` | long-lived access token `syt_ZnJpZ2F0…` in `frigate-notify/.env` | access token |
| `@alertbot` | long-lived access token `syt_YWxlcnRib3Q…` in `matrix-receiver-config.yml` | access token |
| `@margaret` | openclaw.json contains **both** `accessToken` and `password` | both |
| `@spirit` | openclaw config (path differs from margaret's — resolve in Task 1) | TBD in Task 1 |
| `@clippyauto` | n8n credential on .100, polls `/sync` every ~2s | TBD in Task 1 |
| `@fry`, `@hermes`, `@julie`, `@pluto`, `@testbot`, `@admin1`, `@adminuser` | unknown | TBD in Task 1 |
MAS exposes a compatibility layer proxying `/_matrix/client/*/login`, so password-based bots are expected to keep working — **this is the single biggest assumption in the plan and Task 1 exists to test it, not trust it.**
## File Structure
- Create `compose/hf-pdocker-01/mas/docker-compose.yml` — MAS + `mas-db` PostgreSQL. One file, one responsibility: authentication.
- Modify `compose/hf-pdocker-01/matrix/docker-compose.yml` — re-add a `synapse-db` service (PostgreSQL 16, local disk). Note this partly reverses the 2026-08-20 removal of the *empty* postgres; this one will actually hold data.
- Create `/var/lib/synapse/homeserver-postgres.yaml` on the node — port-target config for `synapse_port_db` (node-only, not in repo — contains DB credentials).
- Create `/mnt/docker-data/configs/mas/config.yaml` on the node — MAS config (node-only, contains signing keys and secrets; back up to Vaultwarden).
- Modify `/mnt/docker-data/traefik/dynamic/temp-routes.yml` — add the `auth.hobbs.farm` router + service. (Yes, the Matrix, Home Assistant and Guacamole routes all live in a file called `temp-routes.yml`. It is load-bearing despite the name.)
- Modify `CLAUDE.md`, `MEMORY.md` — post-change checklist.
---
### Task 1: Auth inventory and full backup
**Files:**
- Create: `/var/lib/synapse.bak-premas/` (node), `docs/superpowers/plans/mas-auth-inventory.md` (repo)
**Interfaces:**
- Produces: a complete table of every account → auth method → config file location, used by Tasks 7 and 10 to verify nothing broke.
- [ ] **Step 1: Enumerate every device and its last-seen IP, to know what "still working" means later**
```bash
ssh glyptic@192.168.86.192 'docker exec synapse python3 -c "
import sqlite3
c=sqlite3.connect(\"file:/var/lib/synapse/homeserver.db?mode=ro\",uri=True)
for u,d,n,ip in c.execute(\"select user_id,device_id,display_name,last_seen_ip from devices order by user_id\"):
print(u,d,n,ip)
"'
```
Expected: rows for `@jeremy` (5 devices), plus bot devices for margaret/spirit/clippyauto/frigate/alertbot/fry.
- [ ] **Step 2: Locate spirit's and clippyauto's credentials (unresolved in planning)**
```bash
ssh glyptic@192.168.86.192 'docker inspect spirit --format "{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}"; docker exec spirit sh -c "ls -la /home/glyptic/.openclaw/ 2>/dev/null || find / -name openclaw.json -maxdepth 6 2>/dev/null | head"'
ssh glyptic@192.168.86.100 'docker exec n8n sh -c "printenv | grep -i matrix" 2>/dev/null; echo "---"; docker exec n8n-db psql -U n8n -tc "select name, type from credentials_entity;" 2>/dev/null | head'
```
Expected: a config path for spirit and a credential type for n8n. Record whether each uses an **access token** or a **password** — access tokens migrate untouched; passwords depend on the MAS compat layer.
- [ ] **Step 3: Write the inventory file**
Record one row per account: user id, auth method, exact file path holding the credential, and how to verify it post-migration. Save as `docs/superpowers/plans/mas-auth-inventory.md`.
- [ ] **Step 4: Take a cold, complete backup**
```bash
ssh glyptic@192.168.86.192 '
cd /mnt/docker-data/compose/hf-pdocker-01/matrix && docker compose stop synapse
sudo rsync -a /var/lib/synapse/ /var/lib/synapse.bak-premas/
sudo python3 -c "
import sqlite3;c=sqlite3.connect(\"/var/lib/synapse.bak-premas/homeserver.db\")
print(\"integrity:\", c.execute(\"pragma integrity_check\").fetchone()[0])
print(\"users:\", c.execute(\"select count(*) from users\").fetchone()[0])
print(\"events:\", c.execute(\"select count(*) from events\").fetchone()[0])
"
docker compose start synapse'
```
Expected: `integrity: ok`, 19 users, ~93,884 events. **Do not proceed if integrity is anything but `ok`.**
- [ ] **Step 5: Commit the inventory**
```bash
git add docs/superpowers/plans/mas-auth-inventory.md
git commit -m "docs: matrix auth inventory ahead of MAS migration"
```
---
### Task 2: PostgreSQL for Synapse (blank, correct collation)
**Files:**
- Modify: `compose/hf-pdocker-01/matrix/docker-compose.yml`
**Interfaces:**
- Produces: `synapse-db` reachable at `synapse-db:5432` on the `matrix-internal` network, database `synapse`, user `synapse`, C collation. Task 3 ports data into it.
- [ ] **Step 1: Add the service to the compose file**
```yaml
synapse-db:
image: postgres:16-alpine
container_name: synapse-db
restart: unless-stopped
environment:
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=<generate: openssl rand -base64 24>
- POSTGRES_DB=synapse
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
volumes:
- /var/lib/synapse-db:/var/lib/postgresql/data # LOCAL disk - Known Issue #16
networks:
- matrix-internal
mem_limit: 512M
memswap_limit: 512M # MUST equal mem_limit - Known Issue #12
labels:
- "com.centurylinklabs.watchtower.enable=false"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U synapse -d synapse"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: "json-file"
options: {max-size: "50m", max-file: "3"}
```
Note: **no `user: "1024:100"`** — that workaround is only for NFS root_squash. This volume is local.
- [ ] **Step 2: Deploy and verify collation**
```bash
scp compose/hf-pdocker-01/matrix/docker-compose.yml glyptic@192.168.86.192:/mnt/docker-data/compose/hf-pdocker-01/matrix/
ssh glyptic@192.168.86.192 'cd /mnt/docker-data/compose/hf-pdocker-01/matrix && docker compose up -d synapse-db && sleep 20 && docker exec synapse-db psql -U synapse -d synapse -c "show lc_collate;"'
```
Expected: `lc_collate` = `C`. **If it says anything else, delete `/var/lib/synapse-db` and redo** — Synapse will refuse to start otherwise, and fixing it later means re-porting.
- [ ] **Step 3: Commit**
```bash
git add compose/hf-pdocker-01/matrix/docker-compose.yml
git commit -m "feat: add postgres for synapse (local disk, C collation) ahead of MAS"
```
---
### Task 3: Port Synapse SQLite → PostgreSQL
**Files:**
- Create: `/var/lib/synapse/homeserver-postgres.yaml` (node only)
- Modify: `/var/lib/synapse/homeserver.yaml` (node only)
**Interfaces:**
- Consumes: `synapse-db` from Task 2.
- Produces: a Synapse running on PostgreSQL — the precondition `syn2mas` requires in Task 7.
- [ ] **Step 1: Write the port-target config**
Copy `/var/lib/synapse/homeserver.yaml` to `homeserver-postgres.yaml` and replace the `database:` block with:
```yaml
database:
name: psycopg2
args:
user: synapse
password: <the password from Task 2>
dbname: synapse
host: synapse-db
cp_min: 5
cp_max: 10
```
- [ ] **Step 2: Stop Synapse (downtime starts)**
```bash
ssh glyptic@192.168.86.192 'cd /mnt/docker-data/compose/hf-pdocker-01/matrix && docker compose stop synapse'
```
- [ ] **Step 3: Run the port**
```bash
ssh glyptic@192.168.86.192 'docker run --rm --network matrix_matrix-internal \
-v /var/lib/synapse:/data ghcr.io/element-hq/synapse:v1.159.0 \
synapse_port_db --sqlite-database /data/homeserver.db --postgres-config /data/homeserver-postgres.yaml'
```
Expected: a progress table per-table, ending in `Success!`. Budget 10–40 minutes for ~94k events on this hardware. If it fails partway, drop and recreate the database before retrying — **the port tool is not idempotent against a partially-filled database.**
- [ ] **Step 4: Verify row counts match the source**
```bash
ssh glyptic@192.168.86.192 'docker exec synapse-db psql -U synapse -d synapse -tc "select (select count(*) from users) as users, (select count(*) from events) as events;"'
```
Expected: `19 | 93884` (or higher for events if the server ran between backup and port). **Must not be lower.**
- [ ] **Step 5: Cut Synapse over**
```bash
ssh glyptic@192.168.86.192 'sudo cp /var/lib/synapse/homeserver.yaml /var/lib/synapse/homeserver.yaml.bak-presql
sudo cp /var/lib/synapse/homeserver-postgres.yaml /var/lib/synapse/homeserver.yaml
cd /mnt/docker-data/compose/hf-pdocker-01/matrix && docker compose start synapse'
```
- [ ] **Step 6: Verify the service on PostgreSQL**
```bash
ssh glyptic@192.168.86.192 '
for i in $(seq 1 30); do [ "$(docker inspect synapse --format "{{.State.Health.Status}}")" = healthy ] && echo healthy && break; sleep 10; done
curl -s http://localhost:8008/_synapse/admin/v1/server_version; echo
curl -s -X POST http://localhost:8008/_matrix/client/v3/login -H "Content-Type: application/json" \
-d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"jeremy\"},\"password\":\"bigdog01\"}" | head -c 80
docker logs synapse --since 5m 2>&1 | grep -cE " ERROR "'
```
Expected: `healthy`, version `1.159.0`, an `access_token` in the login response, `0` errors. Log out that verification session afterwards (`POST /_matrix/client/v3/logout`).
- [ ] **Step 7: Confirm bots reconnected before declaring the task done**
```bash
ssh glyptic@192.168.86.192 'sleep 60; docker logs synapse --since 60s 2>&1 | grep -oE "\{@[a-z0-9]+:hobbs.farm\}" | sort | uniq -c'
```
Expected: clippyauto, margaret, spirit, fry all present. **Rollback if not:** restore `homeserver.yaml.bak-presql` and restart — SQLite file is untouched and still authoritative until Task 7.
- [ ] **Step 8: Commit**
```bash
git commit --allow-empty -m "chore: synapse ported from sqlite to postgres (node config, not in repo)"
```
---
### Task 4: PostgreSQL for MAS
**Files:**
- Create: `compose/hf-pdocker-01/mas/docker-compose.yml`
**Interfaces:**
- Produces: `mas-db:5432`, database `mas`, user `mas`, on a new `mas` bridge network that also carries the MAS container.
- [ ] **Step 1: Create the compose file with the database service only**
Same shape as Task 2 Step 1, but: `container_name: mas-db`, `POSTGRES_USER=mas`, `POSTGRES_DB=mas`, volume `/var/lib/mas-db:/var/lib/postgresql/data`, `mem_limit: 256M`, `memswap_limit: 256M`, network `mas`. Default collation is fine — the C-collation requirement is Synapse's, not MAS's.
- [ ] **Step 2: Deploy and verify**
```bash
ssh glyptic@192.168.86.192 'cd /mnt/docker-data/compose/hf-pdocker-01/mas && docker compose up -d mas-db && sleep 20 && docker exec mas-db psql -U mas -d mas -tc "select 1;"'
```
Expected: `1`.
- [ ] **Step 3: Commit**
```bash
git add compose/hf-pdocker-01/mas/docker-compose.yml
git commit -m "feat: add mas-db postgres for matrix authentication service"
```
---
### Task 5: Generate MAS config and deploy the service
**Files:**
- Create: `/mnt/docker-data/configs/mas/config.yaml` (node only — contains secrets)
- Modify: `compose/hf-pdocker-01/mas/docker-compose.yml`
**Interfaces:**
- Consumes: `mas-db` from Task 4.
- Produces: MAS listening on host port **8020** (verified free on .192: 8008/8010/8080/8082/8085/8086/8090/8123/8181/8199/8300/8310 are taken).
- [ ] **Step 1: Generate a config seeded from the Synapse config**
```bash
ssh glyptic@192.168.86.192 'sudo mkdir -p /mnt/docker-data/configs/mas && docker run --rm \
-v /var/lib/synapse:/synapse:ro -v /mnt/docker-data/configs/mas:/out \
ghcr.io/element-hq/matrix-authentication-service:latest \
config generate --synapse-config /synapse/homeserver.yaml --output /out/config.yaml'
```
Expected: `config.yaml` written, containing generated signing keys and a `matrix.secret` shared secret.
- [ ] **Step 2: Set the database URI, public base, and password hashing scheme**
Edit `/mnt/docker-data/configs/mas/config.yaml`:
```yaml
database:
uri: postgresql://mas:<password>@mas-db/mas
http:
public_base: https://auth.hobbs.farm
listeners:
- name: web
resources: [{name: discovery}, {name: human}, {name: oauth}, {name: compat}, {name: graphql}, {name: assets}]
binds: [{address: "[::]:8080"}]
passwords:
enabled: true
schemes:
- version: 1
algorithm: bcrypt
unicode_normalization: true # REQUIRED for imported Synapse hashes
- version: 2
algorithm: argon2id
```
The `unicode_normalization: true` on the bcrypt v1 scheme is what makes migrated Synapse password hashes verify. Without it, **every migrated password silently fails**.
- [ ] **Step 3: Add the MAS service to compose**
```yaml
mas:
image: ghcr.io/element-hq/matrix-authentication-service:latest # pin to the digest after first pull
container_name: mas
restart: unless-stopped
command: ["server", "--config", "/config/config.yaml"]
volumes:
- /mnt/docker-data/configs/mas:/config:ro
ports:
- "8020:8080"
networks: [mas, matrix-internal]
depends_on:
mas-db: {condition: service_healthy}
mem_limit: 256M
memswap_limit: 256M
labels:
- "com.centurylinklabs.watchtower.enable=false"
```
MAS needs to reach Synapse's admin API and Synapse needs to reach MAS, hence both networks.
- [ ] **Step 4: Run migrations and start**
```bash
ssh glyptic@192.168.86.192 'cd /mnt/docker-data/compose/hf-pdocker-01/mas && docker compose run --rm mas database migrate --config /config/config.yaml && docker compose up -d mas && sleep 15 && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8020/health'
```
Expected: migrations apply cleanly, health endpoint returns `200`.
- [ ] **Step 5: Store the MAS secrets in Vaultwarden and commit**
The generated `config.yaml` holds signing keys that cannot be regenerated without invalidating sessions. Store a copy in Vaultwarden (note: the vault master password is currently unknown — see memory `vault-master-password-stale`; resolve that first or keep an encrypted copy on the NAS).
```bash
git add compose/hf-pdocker-01/mas/docker-compose.yml
git commit -m "feat: deploy matrix authentication service on :8020"
```
---
### Task 6: syn2mas check and dry-run
**Interfaces:**
- Consumes: Synapse-on-PostgreSQL (Task 3), MAS + DB (Tasks 4–5).
- Produces: a clean dry-run, the go/no-go gate for Task 7.
- [ ] **Step 1: Run the checker**
```bash
ssh glyptic@192.168.86.192 'docker exec mas mas-cli syn2mas check --config /config/config.yaml --synapse-config /synapse/homeserver.yaml'
```
(Mount `/var/lib/synapse` read-only into the MAS container as `/synapse` for this — add it in Task 5 Step 3 if not already present.)
Expected: no blocking findings. Known blockers it looks for: custom password provider modules, SAML/LDAP providers. This deployment has neither.
- [ ] **Step 2: Dry run**
```bash
ssh glyptic@192.168.86.192 'docker exec mas mas-cli syn2mas migrate --config /config/config.yaml --synapse-config /synapse/homeserver.yaml --dry-run'
```
Expected: a summary of users, password hashes, devices, and access tokens it *would* import. Cross-check the user count against **19** and the device count against Task 1 Step 1. **Do not proceed to Task 7 if the counts disagree.**
- [ ] **Step 3: Record the dry-run output in the inventory doc and commit**
```bash
git add docs/superpowers/plans/mas-auth-inventory.md
git commit -m "docs: syn2mas dry-run results"
```
---
### Task 7: Migrate accounts into MAS and delegate Synapse auth
**Files:**
- Modify: `/var/lib/synapse/homeserver.yaml` (node only)
**Interfaces:**
- Produces: Synapse delegating all authentication to MAS — the precondition MSC4108 requires.
**This is the point of no return.** The MAS docs state migration "is not easily reversible and requires careful backup procedures before proceeding." Rollback means restoring both databases from backup.
- [ ] **Step 1: Fresh backup of both databases immediately before migrating**
```bash
ssh glyptic@192.168.86.192 '
cd /mnt/docker-data/compose/hf-pdocker-01/matrix && docker compose stop synapse
docker exec synapse-db pg_dump -U synapse synapse | gzip > /tmp/synapse-premas.sql.gz
docker exec mas-db pg_dump -U mas mas | gzip > /tmp/mas-premigrate.sql.gz
sudo mv /tmp/*.gz /var/lib/synapse.bak-premas/ && ls -la /var/lib/synapse.bak-premas/*.gz'
```
Expected: two non-empty `.gz` files.
- [ ] **Step 2: Run the migration (Synapse stays stopped)**
```bash
ssh glyptic@192.168.86.192 'docker exec mas mas-cli syn2mas migrate --config /config/config.yaml --synapse-config /synapse/homeserver.yaml'
```
Expected: completes with an import summary matching the dry-run.
- [ ] **Step 3: Point Synapse at MAS**
Add to `/var/lib/synapse/homeserver.yaml`:
```yaml
matrix_authentication_service:
enabled: true
endpoint: http://mas:8080/
secret: <the matrix.secret value from /mnt/docker-data/configs/mas/config.yaml>
```
- [ ] **Step 4: Start Synapse and verify delegation is live**
```bash
ssh glyptic@192.168.86.192 '
cd /mnt/docker-data/compose/hf-pdocker-01/matrix && docker compose start synapse
for i in $(seq 1 30); do [ "$(docker inspect synapse --format "{{.State.Health.Status}}")" = healthy ] && echo healthy && break; sleep 10; done
curl -s -o /dev/null -w "auth_metadata: %{http_code}\n" http://localhost:8008/_matrix/client/unstable/org.matrix.msc2965/auth_metadata'
```
Expected: `healthy`, and auth_metadata now returns **200** (it returns 404 today).
- [ ] **Step 5: Verify every account from the Task 1 inventory still authenticates**
```bash
ssh glyptic@192.168.86.192 '
echo "--- password login via MAS compat layer:"
curl -s -X POST http://localhost:8008/_matrix/client/v3/login -H "Content-Type: application/json" \
-d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"jeremy\"},\"password\":\"bigdog01\"}" | head -c 100; echo
echo "--- migrated access token (frigate):"
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8008/_matrix/client/v3/account/whoami -H "Authorization: Bearer syt_ZnJpZ2F0<rest-from-.env>"
echo "--- bots syncing:"
sleep 90; docker logs synapse --since 90s 2>&1 | grep -oE "\{@[a-z0-9]+:hobbs.farm\}" | sort | uniq -c'
```
Expected: an `access_token` for jeremy, `200` for the frigate token, and margaret/spirit/clippyauto/fry all syncing. **If password login fails, the bcrypt `unicode_normalization: true` setting from Task 5 Step 2 is the first thing to check.**
- [ ] **Step 6: Commit**
```bash
git commit --allow-empty -m "feat: synapse auth delegated to MAS (node config)"
```
---
### Task 8: Publish MAS and add DNS
**Files:**
- Modify: `/mnt/docker-data/traefik/dynamic/temp-routes.yml` (confirmed: this is the file holding the `mess`, `wellknown`, `yoda` and `solo` routers)
- External: Cloudflare DNS
- [ ] **Step 1: Add the Cloudflare A record FIRST**
`auth.hobbs.farm` → `98.160.103.126`, **DNS-only (grey cloud)**. There is no wildcard record on this zone, and Traefik's DNS-01 challenge will happily issue a valid certificate for a hostname that does not resolve — so a working cert proves nothing here (memory `hobbs-farm-dns`).
Verify from outside the LAN before continuing:
```bash
getent hosts auth.hobbs.farm
```
Expected: `98.160.103.126`.
- [ ] **Step 2: Add the Traefik router and service**
```yaml
auth:
rule: "Host(`auth.hobbs.farm`)"
service: auth
entryPoints: [websecure]
middlewares: [security-headers]
tls: {certResolver: letsencrypt}
```
```yaml
auth:
loadBalancer:
servers:
- url: "http://192.168.86.192:8020"
```
Do **not** add basicAuth — clients must reach this unauthenticated to log in.
- [ ] **Step 3: Verify publicly**
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://auth.hobbs.farm/health
curl -s https://mess.hobbs.farm/_matrix/client/unstable/org.matrix.msc2965/auth_metadata | head -c 300
```
Expected: `200`, and auth metadata advertising `https://auth.hobbs.farm` endpoints.
- [ ] **Step 4: Confirm `.well-known` needs no change (verify, do not assume)**
Current contents, served from `/mnt/docker-data/configs/matrix/wellknown/` via `matrix-wellknown` on :8010:
```
client -> {"m.homeserver": {"base_url": "https://mess.hobbs.farm"}}
server -> {"m.server": "mess.hobbs.farm:443"}
```
Under MAS, clients discover the auth server from Synapse's `auth_metadata` endpoint, not from `.well-known`, so **both files should stay as they are**. Verify a client can complete discovery end to end:
```bash
curl -s https://hobbs.farm/.well-known/matrix/client
curl -s https://mess.hobbs.farm/_matrix/client/unstable/org.matrix.msc2965/auth_metadata | head -c 200
```
Expected: `.well-known` unchanged and still pointing at `mess.hobbs.farm`; auth_metadata returning the `auth.hobbs.farm` issuer. If a client cannot find the auth server, revisit `http.public_base` in the MAS config (Task 5 Step 2) before touching `.well-known`.
- [ ] **Step 5: Add a Prometheus blackbox probe for the new endpoint**
Add `https://auth.hobbs.farm/health` to the blackbox target list in `/mnt/docker-data/configs/prometheus/prometheus.yml`, then hot-reload:
```bash
ssh glyptic@192.168.86.192 'curl -s -X POST http://localhost:9095/-/reload && sleep 5 && curl -s "http://localhost:9095/api/v1/query?query=probe_success" | grep -o "auth.hobbs.farm"'
```
Expected: the target appears and probes `1`. **Auth being down now means nobody can log in — this probe is not optional.**
- [ ] **Step 6: Commit**
```bash
git add compose/ && git commit -m "feat: publish MAS at auth.hobbs.farm with blackbox probe"
```
---
### Task 9: Enable the QR MSCs
**Files:**
- Modify: `/var/lib/synapse/homeserver.yaml` (node only)
- [ ] **Step 1: Enable both MSCs**
```yaml
experimental_features:
msc4108_enabled: true
msc4388_mode: authenticated
```
`msc4388_mode: authenticated` requires the rendezvous channel user to be logged in — prefer it over `open`, which would let unauthenticated parties open rendezvous sessions on an internet-facing server.
- [ ] **Step 2: Restart and confirm Synapse actually starts**
```bash
ssh glyptic@192.168.86.192 'docker restart synapse && sleep 45 && docker logs synapse --since 2m 2>&1 | grep -iE "ConfigError|MSC4108|MSC4388" | head'
```
Expected: **no `ConfigError`**. If you see `MSC4108 requires matrix_authentication_service to be enabled`, Task 7 Step 3 did not take effect.
- [ ] **Step 3: Verify the feature is advertised**
```bash
ssh glyptic@192.168.86.192 'curl -s http://localhost:8008/_matrix/client/versions | python3 -c "
import sys,json; uf=json.load(sys.stdin)[\"unstable_features\"]
print(\"msc4108:\", uf.get(\"org.matrix.msc4108\"))
"'
```
Expected: `msc4108: True` (it is `False` today).
- [ ] **Step 4: Commit**
```bash
git commit --allow-empty -m "feat: enable MSC4108/MSC4388 QR sign-in"
```
---
### Task 10: End-to-end QR sign-in test and documentation
- [ ] **Step 1: Test the actual feature**
On an already-signed-in Element X Android device: Settings → Sessions → "Sign in with QR code" / "Link new device". On a second client, choose "Sign in with QR code" and scan.
Expected: the new device signs in and receives E2EE keys **without** the password being typed. This is the deliverable — if it does not work here, the feature is not done regardless of what the config says.
- [ ] **Step 2: Re-verify the full account inventory one more time**
Re-run Task 7 Step 5. Expected: unchanged — all bots syncing, all tokens valid.
- [ ] **Step 3: Complete the mandatory post-change checklist (CLAUDE.md)**
- CLAUDE.md: MAS + two PostgreSQL containers on .192, port 8020, `auth.hobbs.farm`, Synapse now on PostgreSQL (supersedes Known Issues #24/#25 wording), new known issue for the MAS dependency — **auth is now a hard dependency for every login, including bots**
- MEMORY.md: new entry for the MAS migration; update `matrix-account` (the reset recipe changes — user management moves to MAS)
- Prometheus: done in Task 8 Step 4
- Farm Manager links page: add `auth.hobbs.farm` to `serverLinks` in `/opt/farm-manager/server/static/app.js`, then **rebuild the image** (links are baked in, not mounted) and redeploy
- Wiki: run the documentation-keeper agent
- [ ] **Step 4: Retire the backups once stable**
Leave `/var/lib/synapse.bak-premas/`, `/var/lib/synapse.bak-20260820-pre1159/`, and the SQLite file in place for **at least a week** of normal use. Only then delete.
- [ ] **Step 5: Final commit**
```bash
git add -A && git commit -m "docs: MAS QR sign-in deployment complete"
```
---
## Rollback Summary
| Failed at | Recovery |
|---|---|
| Task 3 (port to PG) | Restore `homeserver.yaml.bak-presql`; SQLite file untouched and still authoritative |
| Task 7 (syn2mas) | Restore `synapse-premas.sql.gz` + `mas-premigrate.sql.gz`; remove `matrix_authentication_service` block |
| Task 9 (MSCs) | Remove `experimental_features` block; restart — no data implications |
## Open Questions for the Operator
1. **Element Desktop compatibility.** Your two Element Desktop sessions are on the legacy auth stack. OIDC-native support in Element Web/Desktop has historically lagged Element X. Worth confirming before migrating, since a broken desktop client is a worse day than no QR code.
2. **Is `auth.hobbs.farm` the hostname you want?** It becomes permanent — it is baked into session state, and changing `public_base` later invalidates sessions.
3. **Vaultwarden is currently locked out** (memory `vault-master-password-stale`). MAS generates signing keys that must be backed up somewhere durable. Resolve the vault access first, or agree on an alternative.
4. **Node memory.** .192 sits at ~14/23GB with Frigate as the main consumer. This adds ~1GB across three containers (Synapse's PG, MAS's PG, MAS). If that is too tight, the MAS stack could go on .100 instead — it only needs network reachability to Synapse, not co-location.
@@ -0,0 +1,70 @@
# Matrix Auth Inventory — baseline before MAS migration
Captured 2026-08-20 from `/var/lib/synapse/homeserver.db` (SQLite, pre-migration).
**Totals:** 20 users (7 deactivated), **65 devices**, **54 access tokens**, 9 e2e device key entries.
## Per-account baseline
| Account | Devices | Tokens | Password hash | Credential location | Auth style |
|---|---|---|---|---|---|
| `@jeremy` | 12 (incl. cross-signing keys) | 3 | yes | user-typed (`bigdog01`) | password + Element sessions |
| `@clippyauto` | **17** | **17** | yes | n8n credential store (encrypted, `/home/node/.n8n/database.sqlite` on .100) | **repeated password login** — new device per login |
| `@pluto` | **15** | **15** | yes | unknown (OpenClaw Gateway devices) | **repeated password login** |
| `@julie` | 5 | 2 | yes | Element X Android | password |
| `@spirit` | 2 | 3 | yes | `/mnt/infrastructure/spirit/openclaw.json` → key `accessToken` only | **access token** |
| `@margaret` | 2 | 2 | yes | `/mnt/infrastructure/margaret/openclaw.json` → keys `accessToken` **and** `password` | token + password |
| `@frigate` | 2 | 2 | yes | `compose/hf-pdocker-01/frigate-notify/.env` → `MATRIX_ACCESS_TOKEN=syt_ZnJpZ2F0…` | **access token** |
| `@alertbot` | 2 | 2 | yes | `/mnt/docker-data/configs/alertmanager/matrix-receiver-config.yml` → `token: "syt_YWxlcnRib3Q…"` | **access token** |
| `@hermes` | 2 | 2 | yes | unknown | unknown |
| `@fry` | 1 | 1 | yes | unknown | unknown |
| `@testbot` | 1 | 1 | yes | `TESTBOT_CI` device | unknown |
| `@admin1`, `@adminuser` | 2 each | 2 each | yes | unknown | admin accounts |
| `@aaron`, `@bob`, `@clippy`, `@travis`, `@tempadmin`, `@resetadmin`, `@resetadmin2` | 0 | 0 | none | — | deactivated |
## Risk read
- **Access-token accounts** (`spirit`, `frigate`, `alertbot`) are the safest: syn2mas imports access tokens, so they should continue uninterrupted.
- **`@clippyauto` and `@pluto` are the canaries.** The 1:1 device-to-token ratio and high counts mean they authenticate with a password on each connect, creating a fresh device every time. Post-migration they exercise the MAS **compatibility layer** (`/_matrix/client/*/login` proxied to MAS). If the bcrypt `unicode_normalization: true` scheme is misconfigured, these break first and loudest — `@clippyauto` polls every ~2s, so failure is visible within seconds.
- `@jeremy`'s 12 devices include cross-signing keys (master/self_signing/user_signing), not just sessions. Cross-signing state must survive or Element will prompt to re-verify.
## Post-migration verification command
```bash
ssh glyptic@192.168.86.192 'docker exec synapse-db psql -U synapse -d synapse -tc "
select u.name,
(select count(*) from devices d where d.user_id=u.name) dev,
(select count(*) from access_tokens a where a.user_id=u.name) tok
from users u where u.deactivated=0 order by u.name;"'
```
Compare against the table above. Device counts may **grow** (new logins) but must not shrink for token-based accounts.
---
## syn2mas dry-run results (2026-08-20 20:07Z)
`syn2mas check`: **"Check completed successfully with no errors or warnings."**
Dry-run migration summary (rolled back automatically):
```
20 users migrated (0 skipped)
0 third-party IDs migrated (0 skipped)
0 upstream links (external IDs) migrated (0 skipped)
53 non-refreshable access tokens migrated (0 skipped)
0 refreshable token pairs migrated (0 skipped)
53 devices migrated (0 skipped)
```
### Reconciliation against the pre-migration baseline
| Metric | Synapse | syn2mas | Explanation |
|---|---|---|---|
| Users | 20 | 20 | exact match |
| Devices | 65 | 53 | 12 of the 65 are **cross-signing key pseudo-devices** (`display_name like '%signing key%'`), not login sessions. Real login devices = 53. Verified by query. Cross-signing keys stay in Synapse's own e2e tables. |
| Access tokens | 54 | 53 | The one token not migrated is **device-less**, and MAS compat sessions are device-scoped. Identified as `syt_Ym9i_…` — `Ym9i` is base64 for **"bob"**, a leftover token from the decommissioned `@bob` agent, attached to `@spirit`. Last validated months ago. |
**Spirit is safe:** its configured `accessToken` (`syt_c3Bpcml0…`, base64 `c3Bpcml0` = "spirit") maps to device `CEBHTAXUAY`, which migrates. Verified by joining the config token against `access_tokens`.
**Conclusion: go.** No account loses a credential it actually uses.
@@ -0,0 +1,42 @@
.git
.gitignore
.openai
.agents
.codex
.env
.env.*
!.env.example
.next
.next-build
.sites-runtime
.vinext
.wrangler
cloudflare-env.d.ts
backups
components
coverage
db
dist
drizzle
examples
hooks
lib
node_modules
outputs
shield
scripts
secrets/*.json
secrets/*.txt
!secrets/*.example.json
!secrets/*.example.txt
vite.config.ts
worker
work
MIGRATION.md
README.md
RELEASE-NOTES.md
VERSION
*.log
*.tar
*.tar.gz
*.zip
@@ -0,0 +1,46 @@
# Dependencies
/node_modules/
/.pnp
.pnp.*
# Keep authored Yarn tooling if the project adopts Yarn.
/.yarn/*
!/.yarn/patches/
!/.yarn/plugins/
!/.yarn/releases/
!/.yarn/versions/
# Generated builds and framework state
/dist/
/.next/
/.next-build/
/.vinext/
/out/
next-env.d.ts
*.tsbuildinfo
# Test output
/coverage/
# Package-manager logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Local environment and keys
.env*
.dev.vars*
*.pem
# Checkout-local tool state
/.wrangler/
/.sites-runtime/
# Workspace scaffolding and publication archives
/.agents/
/.codex/
/outputs/
/work/
# OS metadata
.DS_Store
@@ -0,0 +1,3 @@
audit=false
fund=false
update-notifier=false
@@ -0,0 +1,40 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS dependencies
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
FROM dependencies AS build
WORKDIR /app
COPY . .
RUN npm run build && test -f .next-build/BUILD_ID
FROM node:22-bookworm-slim AS production-dependencies
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund
FROM node:22-bookworm-slim AS runtime
RUN groupadd --system --gid 10001 organizer \
&& useradd --system --uid 10001 --gid 10001 --home-dir /nonexistent --shell /usr/sbin/nologin organizer
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
HOSTNAME=0.0.0.0 \
PORT=3000
COPY --from=production-dependencies --chown=10001:10001 /app/node_modules/ ./node_modules/
COPY --from=build --chown=10001:10001 /app/.next-build/ ./.next-build/
COPY --from=build --chown=10001:10001 /app/public/ ./public/
COPY --from=build --chown=10001:10001 /app/container/start.mjs /app/container/reset-admin.mjs ./container/
COPY --from=build --chown=10001:10001 /app/package.json /app/next.config.mjs ./
RUN mkdir -p /app/.next-build/cache /data && chown -R 10001:10001 /app/.next-build /data && chmod 700 /data
# The tiny supervisor starts as root only long enough to read Docker secrets,
# then drops to uid/gid 10001 (Compose grants only SETUID/SETGID), prepares
# /data, runs the LAN-aware front proxy on :3000 and Next.js on 127.0.0.1:3001.
USER 0:0
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
CMD ["node", "container/start.mjs"]
@@ -0,0 +1,80 @@
# Migration and rollback
This procedure keeps the hosted dashboard and both Shields working until the local service has passed its checks.
## 1. Prepare the server
1. Extract the release bundle on the internal container host.
2. Regenerate the private Google Calendar ICS URLs that were used during development.
3. Run `scripts/configure.sh` or `scripts/configure.ps1`.
4. Review `.env`, especially the bind address, port, timezone, and NWS grid.
5. Start the container with `docker compose up -d --build --wait`.
Do not expose port 8080 to the public Internet.
## 2. Validate before touching a Shield
Check liveness:
```bash
curl -fsS http://SERVER-IP:8080/api/health
```
In trusted-LAN mode, open `http://SERVER-IP:8080/` and verify:
- calendar events appear in agenda, week, and month views;
- the seven-day forecast and sunrise/sunset values appear;
- the FRED ticker reports live or partial data;
- AI/tech headlines appear and their QR dialogs scan correctly.
In bearer mode, validate with a client capable of adding the `Authorization` header or proceed with one Shield as the test client.
## 3. Upgrade one Shield first
Use the Shield with the least operational impact as the canary:
```powershell
& $adb -s DEVICE:5555 install -r .\shield\Hobbs-Family-Organizer-Local-v1.4.apk
```
The `-r` upgrade is important. It preserves app-private files, including the legacy hosted token and the `daily_wake_0630_enabled` schedule flag.
Configure the local server:
```powershell
.\shield\configure-shield.ps1 `
-Device DEVICE:5555 `
-BaseUrl http://SERVER-IP:8080/ `
-AllowInsecureHttp `
-AdbPath $adb
```
Add `-TokenFile .\secrets\organizer_access_token.txt` if bearer mode is enabled. Omit `-AllowInsecureHttp` for HTTPS.
Verify remote navigation, automatic refresh, QR dialogs, reboot behavior, and the 6:30 AM alarm on any Shield where that schedule was already enabled.
## 4. Upgrade the second Shield
After the canary has run successfully, repeat the APK upgrade and configuration with the other device address.
## 5. Rollback
The local cutover does not delete the old hosted access token. To switch a Shield back:
```powershell
.\shield\restore-hosted-site.ps1 -Device DEVICE:5555 -AdbPath $adb
```
This removes `base_url`, `access_token`, and `allow_insecure_http`, then relaunches the same app against the hosted Site.
To roll back the server release, restore the previous release directory and its protected configuration backup, then run `docker compose up -d --build --wait`.
## 6. After the observation period
Once the local deployment is stable:
- keep the hosted Site only as long as you want the rollback option;
- retain an encrypted configuration backup;
- keep the prebuilt signed APK with the release archive;
- document the server address/DNS record and certificate renewal process;
- periodically review outbound-feed failures in `docker compose logs organizer`.
@@ -0,0 +1,274 @@
# Hobbs' Family Organizer App — self-hosted package
This release moves the organizer off ChatGPT Sites and onto your own container host. The dashboard, API routes, calendar parser, weather, FRED market ticker, RSS headlines, QR handoff, and Shield wrapper are included.
The local deployment does **not** require ChatGPT, an OpenAI account, or an OpenAI API key. It is stateless and has no database or persistent application volume.
## What still uses the Internet
The container needs outbound DNS and HTTPS for:
- `calendar.google.com` — private Google Calendar ICS feeds
- `api.weather.gov` — seven-day National Weather Service forecast
- `fred.stlouisfed.org` — public FRED series pages used by the ticker
- the configured OpenAI, Google AI, DeepMind, NVIDIA, Ars Technica, and Wired RSS feeds
No inbound Internet access is required. Keep the service on your LAN, VPN, or behind your existing reverse proxy.
## Package contents
- `Dockerfile` — production Node 22 image
- `compose.yaml` — hardened Compose deployment on port 8080 by default
- `app/` — complete Next.js dashboard and API source
- `container/start.mjs` — validates secrets, then drops the app to uid/gid 10001
- `scripts/configure.*` — creates runtime secrets without printing them
- `scripts/backup.*` and `scripts/restore.*` — configuration backup and restore
- `secrets/` — ignored runtime secret location and a calendar template
- `shield/` — signed v1.4 APK, provisioning scripts, and Android source
- `MIGRATION.md` — ordered cutover and rollback procedure
## Requirements
- A Linux, Windows, or macOS host with Docker Engine/Desktop and Docker Compose v2
- Outbound HTTPS from the container host
- One to ten private Google Calendar `basic.ics` URLs
- For Shield deployment: Android Platform Tools (`adb`) and network debugging enabled on the Shield
The image builds for both `linux/amd64` and `linux/arm64` when the selected Node base image supports the host architecture.
## Quick start
### Linux or macOS
```bash
chmod +x scripts/*.sh
./scripts/configure.sh --mode trusted-lan
docker compose config --quiet
docker compose up -d --build --wait
curl -fsS http://127.0.0.1:8080/api/health
```
### Windows PowerShell
```powershell
Set-ExecutionPolicy -Scope Process Bypass
.\scripts\configure.ps1 -Mode trusted-lan
docker compose config --quiet
docker compose up -d --build --wait
Invoke-RestMethod http://127.0.0.1:8080/api/health
```
The configuration script:
1. copies `.env.example` to `.env` if needed;
2. prompts privately for each Google Calendar ICS URL;
3. writes `secrets/calendar_ical_urls.json` with restrictive permissions;
4. creates `secrets/organizer_access_token.txt`, empty for trusted-LAN mode or populated with a random token for bearer mode.
After startup, open `http://SERVER-IP:8080/` or the HTTPS name provided by your reverse proxy.
## Access modes
### Trusted LAN
Use this when firewall/VLAN/VPN rules already restrict the dashboard:
```bash
./scripts/configure.sh --mode trusted-lan
```
```powershell
.\scripts\configure.ps1 -Mode trusted-lan
```
The token file exists but is empty. Anyone who can reach the published port can view calendar details, so bind to the intended interface and enforce access in your network or reverse proxy.
### Bearer token
Use this for the Shield or a reverse proxy that can inject an authorization header:
```bash
./scripts/configure.sh --mode bearer --rotate-token
```
```powershell
.\scripts\configure.ps1 -Mode bearer -RotateToken
```
Every route except `/api/health` then requires:
```text
Authorization: Bearer TOKEN
```
The Shield wrapper injects this header into every same-origin request. An ordinary browser address bar cannot add it, so browser access in bearer mode should go through an authenticated reverse proxy that injects the header. The configuration scripts intentionally do not print the token.
## Admin screen (v1.1.0)
Open `http://SERVER-IP:8080/admin` from a phone or laptop on the home network.
- **First visit:** choose the admin password. This works only while no password exists, and only from an allowed network.
- **Calendars tab:** add, remove, reorder, rename, recolor or hide calendars.
- Saved addresses are masked and never shown again.
- **Test** fetches a feed and reports its name and how many events are upcoming.
- Every save keeps the previous version. **History → Restore** brings any of the last 20 back.
- **Account tab:** change the password (this signs out other devices) or sign out.
- **Forgot the password:** `docker exec -u 10001 <container> node container/reset-admin.mjs`, then visit `/admin` again.
Settings live in the `/data` volume (`HOBBS_DATA_DIR`, default `./data`). It must be writable by uid 10001 and should be on local disk.
| File | Contents |
| --- | --- |
| `settings.json` | Calendars, including their private URLs. Treat it like a secret. |
| `auth.json` | The scrypt password hash and the session key. |
| `history/` | The last 20 saved versions. |
The `calendar_ical_urls` secret is now optional. It is used only to seed `settings.json` on first start.
Admin is refused unless the TCP client address is inside `ADMIN_ALLOWED_CIDRS`. It is also refused, with a 404, for any request carrying `X-Forwarded-For`, `Forwarded` or `X-Real-IP`, so it cannot be reached through a reverse proxy. The container's own front proxy (port 3000, with Next.js on 127.0.0.1:3001) records the real client address, and forged copies of that header are discarded.
## Configuration
Edit `.env` for nonsecret settings:
| Setting | Default | Purpose |
|---|---|---|
| `HOBBS_BIND_ADDRESS` | `0.0.0.0` | Host interface used by Docker |
| `HOBBS_HTTP_PORT` | `8080` | Published HTTP port |
| `ORGANIZER_TIMEZONE` | `America/Chicago` | Clock and calendar timezone |
| `WEATHER_LOCATION` | `Sapulpa, OK` | Display label |
| `WEATHER_LATITUDE` | `35.9987` | Sunrise/sunset latitude |
| `WEATHER_LONGITUDE` | `-96.11417` | Sunrise/sunset longitude |
| `NWS_FORECAST_URL` | `https://api.weather.gov/gridpoints/TSA/37,98/forecast` | NWS grid forecast |
| `WEATHER_USER_AGENT` | example value | NWS client identifier; add a monitored contact |
Keep these secrets out of `.env`, Git, image layers, and tickets:
- `secrets/calendar_ical_urls.json`
- `secrets/organizer_access_token.txt`
Private Google Calendar URLs act like passwords. Because the current URLs were previously shared during development, reset/regenerate each private ICS URL in Google Calendar before the final production cutover, then run the configure script again.
## Operations
Status and logs:
```bash
docker compose ps
docker compose logs --tail=200 organizer
curl -fsS http://127.0.0.1:8080/api/health
```
The health endpoint never calls Google, FRED, NWS, or RSS providers. It reports liveness plus booleans for calendar configuration and authentication, but no secret values.
Stop and start:
```bash
docker compose stop
docker compose start
```
Update after replacing the release files:
```bash
docker compose build --pull
docker compose up -d --wait
docker image prune
```
`docker image prune` is optional and removes only unused images after confirmation.
## Backup and restore
The app has no database. Back up only `.env` and the two runtime secret files.
Linux/macOS:
```bash
./scripts/backup.sh
./scripts/restore.sh --force backups/hobbs-organizer-config-YYYYMMDD-HHMMSS.tar.gz
```
Windows:
```powershell
.\scripts\backup.ps1
.\scripts\restore.ps1 -Archive .\backups\hobbs-organizer-config-YYYYMMDD-HHMMSS-XXXXXXXX.zip -Force
```
Backup archives contain private calendar URLs and possibly the bearer token. Store them in an encrypted or tightly access-controlled backup system.
## Reverse proxy and HTTPS
HTTPS with a stable internal DNS name is recommended, for example `organizer.home.arpa`. Sample Caddy and Nginx configurations are in `examples/`.
If you use an internal certificate authority, install its root certificate in the Shield's user certificate store. The wrapper trusts system and user-installed CAs but always rejects certificate errors; it has no trust-all bypass.
For direct LAN HTTP, the Shield configuration script requires both an explicit `-AllowInsecureHttp` switch and a private IP, `.local`, `.lan`, or `.home.arpa` host.
## NVIDIA Shield deployment
Install Android Platform Tools, enable network debugging, and authorize the computer on the Shield. Upgrading the existing package preserves the hosted rollback token and the per-device 6:30 AM schedule flag.
```powershell
$adb = "C:\path\to\platform-tools\adb.exe"
& $adb connect 192.168.86.179:5555
& $adb -s 192.168.86.179:5555 install -r .\shield\Hobbs-Family-Organizer-Local-v1.4.apk
```
For HTTPS with bearer authentication:
```powershell
.\shield\configure-shield.ps1 `
-Device 192.168.86.179:5555 `
-BaseUrl https://organizer.home.arpa/ `
-TokenFile .\secrets\organizer_access_token.txt `
-AdbPath $adb
```
For trusted-LAN HTTP:
```powershell
.\shield\configure-shield.ps1 `
-Device 192.168.86.179:5555 `
-BaseUrl http://192.168.86.50:8080/ `
-AllowInsecureHttp `
-AdbPath $adb
```
The script streams the token through ADB standard input and never places it in the process arguments. Repeat for the second Shield using its address. Do not change the package ID: `com.dayline.tv` is intentionally preserved so existing app-private data survives `adb install -r`.
The wrapper is debuggable so the scripts can use Android `run-as` to provision private files. After the migration, disable network debugging on each Shield and enable it again only during controlled maintenance.
To return a Shield to the existing hosted Site:
```powershell
.\shield\restore-hosted-site.ps1 -Device 192.168.86.179:5555 -AdbPath $adb
```
That removes only the local URL/token flags and keeps the old hosted `service_token` intact.
## Build from source
Container application:
```bash
npm ci
npm run lint
npm run build
npm start
```
For local `npm start`, set `CALENDAR_ICAL_URLS` to a JSON array and optionally set `ORGANIZER_ACCESS_TOKEN`. The Compose path is preferred because it loads secrets from files and applies the security profile.
Android source is under `shield/source/`. It targets Android SDK 35, Java 17, and Gradle 8.9/Android Gradle Plugin 8.7.3. The included APK is already signed with the same certificate as the installed v1.3 app. The private signing key is deliberately not included in this package.
## Troubleshooting
- `calendarConfigurationValid: false`: rerun `scripts/configure.*` and confirm the secret contains a JSON array of Google `basic.ics` URLs.
- Dashboard returns `401`: token mode is enabled; provision the same token to the Shield or reverse proxy.
- Weather is empty: verify the NWS grid URL, outbound HTTPS, DNS, and `WEATHER_USER_AGENT`.
- FRED shows demo data: allow outbound HTTPS to `fred.stlouisfed.org`; the feed uses public pages and no API key.
- Shield shows setup required: confirm the APK is installed and rerun `shield/configure-shield.ps1`.
- Shield rejects HTTPS: install the internal CA certificate or use a certificate trusted by Android; never bypass validation.
- Container does not become healthy: run `docker compose logs organizer` and validate that both secret files exist.
@@ -0,0 +1,67 @@
# 1.4.0 (2026-11-04)
- Admin **TVs** tab: a morning routine for each TV.
- Wake time and days of the week.
- A YouTube Music playlist (paste any playlist link; the `si=` tracking part is dropped).
- The Shield's volume (0–100%).
- Settings are pushed to each TV on its next check-in (every 5 minutes). Each TV card shows when the TV has applied them and how its last morning routine went.
- TV endpoints, direct LAN only and refused through a proxy:
- `GET /api/tv/config?device=…` returns that TV's settings and records the check-in.
- `POST /api/tv/report` records the outcome of the morning routine.
- Check-in status lives in `/data/tv-status.json`, kept separate from the settings history.
- TVs that check in without being configured show up under "TVs found on the network" with an **Add** button.
- Settings history Restore leaves TV settings unchanged.
- Shield photo app `com.dayline.photos` 1.2.1 (versionCode 4) runs the routine:
- At the wake time minus 30 s, it turns the screen on (the TV follows over HDMI-CEC). After 5 s it sets the volume and starts the playlist in YouTube Music.
- At the wake time, a second alarm opens the calendar app (`com.dayline.tv`) on top while the music keeps playing (needs YouTube Music Premium).
- With music off, it opens the calendar straight away.
- It needs two one-time grants over ADB: `appops set com.dayline.photos SYSTEM_ALERT_WINDOW allow`, so it can open other apps from the background, and `dumpsys deviceidle whitelist +com.dayline.photos`, so check-ins keep network access while the Shield is in Doze.
# 1.3.0 (2026-11-04)
- `/photo/gallery`: a remote-driven photo grid for the Shield photo app's launcher tile.
- Arrows move between tiles, OK opens a photo full screen with a zoom-in, and ◀ ▶ browse while it is open.
- Back (or OK) returns to the grid, and a **Slideshow** button at the top starts `/photo`.
- Photos are shown newest first.
- After 10 minutes without a key press, the slideshow starts on its own. The app keeps the screen on, so the TV screensaver never would.
- Sync now also caches a 400px gallery tile (`<key>.m.jpg`) per photo. Existing albums backfill on their next sync.
- Admin uploads send a tile too. Older uploads without one use the full photo in the grid.
- `/api/photos` entries gain a `tile` field.
- Shield photo app `com.dayline.photos` 1.1 (versionCode 2):
- The launcher tile opens the gallery and takes D-pad input.
- Back steps back through the gallery's history (closing a full-screen photo) before exiting.
- The screensaver is unchanged (non-interactive slideshow).
# 1.2.0 (2026-11-04)
- `/photo`: full-screen photo frame with crossfade, a blurred backdrop or fill mode, shuffle, and the date taken.
- It picks up new settings and photos within about 30 seconds and reloads itself every 12 hours.
- Admin **Photos** tab:
- Google Photos shared albums: paste the share link, use **Check**, and **Sync now**. The tab shows sync status, stale warnings and errors.
- Slideshow options.
- Photo uploads. Each upload is resized in the browser to 1920px plus a 64px backdrop thumbnail.
- Albums sync every 6 hours (and 15 seconds after start).
- Each sync caches up to 500 photos per album at 1920px under `/data/photos`, skips videos, and removes photos deleted from the album.
- `/api/health/photos` returns 503 when an enabled album has not synced for 48 hours.
- History and Restore now cover photo settings too. A restore re-syncs any album it brings back.
- The minimum admin password length is now 6.
# 1.1.0 (2026-11-04)
- Password-protected, LAN-only `/admin` screen for calendar links (masked, test, history/restore).
- Calendars are read from `/data/settings.json` (seeded once from the calendar secret).
- The supervisor drops privileges right after reading secrets. A front proxy on :3000 records the real client IP.
- `/photo-admin` redirects to `/admin#photos`, a placeholder for the upcoming photo frame.
# Release 1.0.0
Built and validated on 2026-11-04.
- Replaced the hosted Cloudflare/Vinext runtime with a standard production Next.js container.
- Added runtime-only calendar secrets and optional bearer authentication.
- Added configurable timezone and weather settings, bounded feed responses, redirect validation, health checks, and hardened Compose settings.
- Included cross-platform configuration, backup, restore, and migration documentation.
- Included NVIDIA Shield wrapper v1.4 (`com.dayline.tv`, version code 5) with local URL support, exact-origin enforcement, optional bearer injection, controlled private-LAN HTTP, internal-CA support, and hosted rollback.
- Preserved the existing app identity, signing certificate, private app data, and per-device schedule flag during in-place upgrades.
The current hosted dashboard and installed Shield applications are not changed merely by extracting this package. Follow `MIGRATION.md` when the internal server URL is ready.
@@ -0,0 +1,64 @@
0800fd869da63e37610ecf417fefa701f70d06abe559909145fe0ceaee95415e app/api/config/route.ts
0faf67bca2879084e9259dca3e1ddb911a4714669f81b4b23684f07f275f4012 .dockerignore
13c79859132210fd64a0bc38f8a6528a33c9b4d74ee84f41596e2907a1cee533 scripts/backup.ps1
150dc144c5ff007803cf7660e174a0122200bfa5a4caea0bc32a56affa74065d RELEASE-NOTES.md
1834aab6e34acc20ae57b1a28d327c2d6fd1206a158f170dc5438251ed64038b app/api/health/route.ts
2247e2fb201b7e4d47de515b8c01c189cc2580a5ca53339b671674feef588713 shield/source/app/src/main/res/values/styles.xml
297319b72a62826c2beebcf67a3916c60b647635ff43e3bf1dcbc5b45ac9de21 shield/source/app/src/main/java/com/dayline/tv/ScheduleReceiver.java
350a753aff373eb283dc82c4722435f66f0cfeddb7ed3561251495a165ddfaa0 .env.example
3e8863288c2d43dd33d2a71de317dcf9198f09932ea8d61e0def1b846a865435 public/favicon.svg
4386abbca613dcc781ba5128acdb9c319ce29b76044854b2a947efcd11676235 shield/configure-shield.ps1
45de38714943c75220b77c95bac7356a5b81c519ac02f9055d23498d3b0fb725 app/api/headlines/route.ts
4906e019df437c43d409ddb6e3ee27295d93d1dce6ffdc870f017759acc31d92 app/layout.tsx
498ff029fd558e35acabab74d9db8280d688a2c2d7061d7a92799f57fefba9b4 scripts/configure.sh
4d0a6cf06aee2721b65d93228ce1feb0368e94cc7e01c0bb3ca25ae0b1a05559 shield/source/settings.gradle
50244a55348072213daeddf6f1ead7ae09720cbc27e5d3b4ecf36af72425325e secrets/.gitignore
5556c7b137de362d770d473ed7d9e83aeaec1aaee243a1dcacbd524e13d27aee app/api/weather/route.ts
59854984853104df5c353e2f681a15fc7924742f9a2e468c29af248dce45ce03 VERSION
60e0cf332d6ca64095b4e3b2d2e0f2f4089266effac57e18e5a1a56122e3bcfa app/globals.css
6bffd3afe4faf1626ad81d1313a358c24e765accbaad19076ac2850b3863942d tsconfig.json
6f51557541aaab18fa69b61fa3d5afbb74ae494abf7a248ea6043719fbf7e6e4 shield/source/app/src/test/java/com/dayline/tv/DashboardConfigTest.java
7017c28c214eca8e0b4ce01f5c7106aac631d0169e895caff8ba07b11f01a799 container/start.mjs
70250691787e467bbbca3d97530d60d6a07718dcdfa66006fc7a3d98e59e5a6f shield/source/app/src/main/res/drawable/ic_dayline.xml
73096b003fedba8491cfdaca71cbccb0246f765971c2935ffa1117e20dfb523d next-env.d.ts
73a5fe77f5aa78693aa65609c1d7ec19f8210bdefe843818e8a2fdaf0f6e96f4 shield/README.md
751ae6b9ae65e578a7f609a55f6bf8dd7269a4fd4602cffa8d3bed1fc3b1a1a2 MIGRATION.md
79a1a184c6c3e65c1189f20b36bacaa77a825a8f365a4907e645b43274f765c7 examples/nginx.conf
7b299d3d3b16699ddda397c0b2373b3af4f25f8fc9ccc9b3d9e64ef083bc1c21 postcss.config.mjs
7cd80af2e968119afd7b1803b6ac51b51ea010b8da57c3606ea1ef8071a86068 .npmrc
7defc63c4c4528c51442f2f772ff96970af9d86a213366e3f254d45fd265615a shield/source/app/src/main/java/com/dayline/tv/MainActivity.java
81577b6a53a2759c0559cdaf8e05e37ae1e91354c450333974fd32a105b64c9f shield/source/app/src/main/java/com/dayline/tv/DaylineScheduler.java
88da59fb1cbec9af0d81f2d654af5827a93b77232a82ec25b3528a4b3ebfed7a scripts/configure.ps1
8bf7d6fe745ad8082cd9c0edc4af97d8a4c547ff7aa6e2cb543467bfc70359f1 app/api/markets/route.ts
8cb3d6b6680242d9d18f3b0ea86c901c30a21e7646d8a85887c4b27791aad490 proxy.ts
92638a43482e2938e1a461ca5efdd7a8b7175e79ade8b9a79af288c3ed680fca .gitignore
95128f47a746eea8b710a1ba708f20e2f83f59cf8fc06d10cbf8abcc3bec56b8 shield/source/.gitignore
9779e64941ef00fdb75ecd9ccbf290bad2a4924ea0e1688bb1c617884e2753b6 shield/source/build.gradle
9ccf53e8c648736adbf95a27d70ace297c7e3b56d6e48e878194f98c51b36b21 shield/source/app/src/main/res/xml/data_extraction_rules.xml
9f71fa926f764c9d6579a62a530b783bbec894c409381cb6047c21ad0697f88e shield/source/app/src/main/res/xml/network_security_config.xml
a14c2d6fb5ba9925cba04633226a3b93e6c5566ca6b550dbfaead7ab3fa93b24 vendor/shadcn-tailwind-4.13.0.LICENSE.md
a1bad3fc1c31829cdb85451911e5cab98dac778c64842f8b58f38e15853e2ae5 scripts/restore.ps1
a52b91e3ed169cad985e6f70644bd13f6dd88fffaaa8d9f1ca47928418495412 shield/Hobbs-Family-Organizer-Local-v1.4.apk
a7e608e83587d8b9378d430dfa4e5f944ad031cf13598d9f26f190ed43e21992 compose.yaml
a87af2bc93b15147531d9ab768d5b47491ceaf0c62875295cf861e21c13ac711 Dockerfile
bc39cd36a8474161c64cdbbbcf70fae1865f5926597cd0085d8b433653b82144 scripts/restore.sh
bd57e28eba96a29974e2a15aa1c27820d17809a37dbc6a08d1c0a1ac828ac425 eslint.config.mjs
be5c30e2fe156ded97fc0e6ac42e2150eb6695d6b59d21a5237572e12295bd33 package-lock.json
c0d416f28accc223a693a47d38ba274d1f46c196309ee5d061a0f218ad8af1db shield/source/BUILDING.md
c9d434920bf4d3188dec353c295f1307521ad20299a7bb76cf6978a928381017 shield/source/app/proguard-rules.pro
c9f120abb901e2767512ba629a56a51dc3fd68b947eb8392ad36a90136248907 examples/Caddyfile
cc38bc495ca4e4d7fa2fe7c9c1112f36af7a63fbf4fc3e7a088e215942403385 shield/source/gradle.properties
cd4cc80f038ef1b4b7b9102fe701203684506fe003951b2ceebd0c19eff3632f app/api/calendar/route.ts
cf618a848c283c1ab6525fb9bb1a4c86285b12a8284bd2d947bde54514981027 app/page.tsx
dd6fcf0d3388de060d9d1822e87c80846c01eb2dcf32906c26d9c41b0a2c0d65 README.md
e316b4d4fe3185bfc25a8f88a094e99114db7a07e6d62f7e816e37cd077b626a next.config.mjs
e52c0aad9c35df9c5d7933e60315c3b66ae1ca3faee735d07afca499ce688dbe package.json
e7049dd99c0af5246d9ca4ae4f2ee14b361c21ad1136658b65c1821ebf2b4599 shield/source/app/src/main/res/values/strings.xml
e855e4c4653adcd315a47906679607b185fff8d4303310b4f847cf6271f92248 shield/source/app/build.gradle
e8bc6ecdd7bf7e86623adf2b788303efadba2927664e855312820bbc99c8f665 shield/source/app/src/main/java/com/dayline/tv/DashboardConfig.java
ea9853f33652e0c76ddfba1b7810ca2962ecbb1a334b4f76464c06c04453f8ea scripts/backup.sh
eb47dafa34b01137544c631e675b8f749f4431c09ca96e676b62ca123419c15e shield/source/app/src/main/AndroidManifest.xml
eb8de54b2f07ed84358822e00f8325e5b896f782b9f7eaccefb81bae853cfa7a secrets/calendar_ical_urls.example.json
f221d959e80b4738fba93b4b4f01472b11cccaf6043ba7f6f9376f6768dad28e vendor/shadcn-tailwind-4.13.0.css
f6e5c76522ab3d6c8dcbed63b59cde25e115b46786aae2e7a0b1746130a4a91b shield/restore-hosted-site.ps1
fae716f0342674996a6318ed43c4d07f66044d931c735101012f013b51acb0a0 shield/source/app/src/main/res/drawable/tv_banner.xml
@@ -0,0 +1 @@
1.4.0
@@ -0,0 +1,167 @@
import { createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
import { readAuth, writeAuth } from "./store";
const scrypt = promisify(scryptCallback) as (password: string, salt: Buffer, keylen: number, options: object) => Promise<Buffer>;
const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
const SESSION_COOKIE = "hfo_admin";
const SESSION_SECONDS = 30 * 24 * 60 * 60;
export const MIN_PASSWORD_LENGTH = 6;
// ---------- network policy ----------
function ipv4ToInt(ip: string) {
const parts = ip.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
}
function inCidr(ip: string, cidr: string) {
const [base, bitsText] = cidr.trim().split("/");
const address = ipv4ToInt(ip);
const network = ipv4ToInt(base);
const bits = Number(bitsText ?? 32);
if (address === null || network === null || !(bits >= 0 && bits <= 32)) return false;
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
return (address & mask) === (network & mask);
}
/**
* Admin is reachable only from a direct LAN connection. The container's front proxy
* stamps x-hfo-client-ip with the TCP peer and x-hfo-forwarded when the request came
* through another proxy (e.g. a future Traefik route), which is always refused.
*/
export function isAdminNetworkAllowed(headers: Headers) {
if (headers.get("x-hfo-forwarded")) return false;
const ip = headers.get("x-hfo-client-ip") || "";
const allowed = (process.env.ADMIN_ALLOWED_CIDRS || "192.168.86.0/24,127.0.0.1/32").split(",");
return allowed.some((cidr) => inCidr(ip, cidr));
}
export function clientIp(headers: Headers) {
return headers.get("x-hfo-client-ip") || "unknown";
}
/** Mutating requests must come from this same origin (CSRF guard on top of SameSite=Strict). */
export function sameOrigin(request: Request) {
const origin = request.headers.get("origin");
const host = request.headers.get("host");
if (!origin || !host) return false;
try {
return new URL(origin).host === host;
} catch {
return false;
}
}
// ---------- passwords ----------
export async function hashPassword(password: string) {
const salt = randomBytes(16);
const key = await scrypt(password, salt, 32, SCRYPT);
return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString("base64url")}$${key.toString("base64url")}`;
}
export async function verifyPassword(password: string, stored: string) {
const [scheme, n, r, p, saltText, keyText] = stored.split("$");
if (scheme !== "scrypt" || !saltText || !keyText) return false;
const expected = Buffer.from(keyText, "base64url");
const actual = await scrypt(password, Buffer.from(saltText, "base64url"), expected.length, {
N: Number(n),
r: Number(r),
p: Number(p),
maxmem: 64 * 1024 * 1024,
});
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
// ---------- login throttling (in memory, per client IP) ----------
const failures = new Map<string, { count: number; lockedUntil: number }>();
export function loginLockedFor(ip: string) {
const entry = failures.get(ip);
return entry && entry.lockedUntil > Date.now() ? Math.ceil((entry.lockedUntil - Date.now()) / 1000) : 0;
}
export function recordLoginFailure(ip: string) {
const entry = failures.get(ip) ?? { count: 0, lockedUntil: 0 };
entry.count += 1;
if (entry.count >= 5) entry.lockedUntil = Date.now() + Math.min(15 * 60, 30 * 2 ** (entry.count - 5)) * 1000;
failures.set(ip, entry);
}
export function clearLoginFailures(ip: string) {
failures.delete(ip);
}
// ---------- sessions ----------
function sign(payload: string, secret: string) {
return createHmac("sha256", secret).update(payload).digest("base64url");
}
export function sessionCookie(secret: string) {
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + SESSION_SECONDS })).toString("base64url");
const value = `${payload}.${sign(payload, secret)}`;
return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_SECONDS}`;
}
export function clearedSessionCookie() {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
}
function readCookie(headers: Headers, name: string) {
for (const part of (headers.get("cookie") || "").split(";")) {
const [key, ...rest] = part.trim().split("=");
if (key === name) return rest.join("=");
}
return "";
}
export async function hasValidSession(headers: Headers) {
const value = readCookie(headers, SESSION_COOKIE);
const [payload, signature] = value.split(".");
if (!payload || !signature) return false;
const auth = await readAuth();
if (!auth.passwordHash) return false;
const expected = Buffer.from(sign(payload, auth.sessionSecret));
const actual = Buffer.from(signature);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
try {
const { exp } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp: number };
return typeof exp === "number" && exp > Date.now() / 1000;
} catch {
return false;
}
}
/** Sets a new password and rotates the session secret, signing out every other session. */
export async function setPassword(password: string) {
const sessionSecret = randomBytes(32).toString("base64url");
await writeAuth({ passwordHash: await hashPassword(password), sessionSecret });
return sessionCookie(sessionSecret);
}
// ---------- route helpers ----------
export function json(body: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
return Response.json(body, { status, headers: { "Cache-Control": "no-store", ...extraHeaders } });
}
/** Returns an error Response when the caller may not use admin APIs, otherwise null. */
export async function requireAdmin(request: Request, { mutating = false } = {}) {
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
if (mutating && !sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
if (!(await hasValidSession(request.headers))) return json({ error: "Sign in required." }, 401);
return null;
}
export async function readJsonBody<T>(request: Request): Promise<T | null> {
if (!(request.headers.get("content-type") || "").includes("application/json")) return null;
try {
return (await request.json()) as T;
} catch {
return null;
}
}
@@ -0,0 +1,232 @@
import ICAL from "ical.js";
import type { CalendarTone } from "./store";
export type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: CalendarTone;
};
const maxFeedBytes = 5 * 1024 * 1024;
const maxOccurrencesPerEvent = 10_000;
function cleanText(value: unknown, fallback: string, maxLength: number) {
return String(value ?? fallback).replace(/\s+/g, " ").trim().slice(0, maxLength) || fallback;
}
function calendarDate(value: ICAL.Time) {
return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`;
}
export function organizerDate(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.ORGANIZER_TIMEZONE || "America/Chicago",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
return `${value("year")}-${value("month")}-${value("day")}`;
}
export function addCalendarDays(isoDate: string, days: number) {
const [year, month, day] = isoDate.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1, day + days));
return value.toISOString().slice(0, 10);
}
function hashId(value: string) {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Calendar feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Calendar feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
function componentString(component: ICAL.Component, name: string) {
const value = component.getFirstPropertyValue(name);
return typeof value === "string" ? value : "";
}
function isCancelled(event: ICAL.Event) {
return componentString(event.component, "status").toUpperCase() === "CANCELLED";
}
function detach(component: ICAL.Component) {
return new ICAL.Component(structuredClone(component.toJSON()));
}
function eventIsInWindow(
start: ICAL.Time,
end: ICAL.Time,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
if (start.isDate) {
return calendarDate(end) > windowStartDate && calendarDate(start) <= horizonDate;
}
return end.toJSDate().getTime() > windowStart && start.toJSDate().getTime() <= horizon;
}
function occurrenceIsPastHorizon(start: ICAL.Time, horizon: number, horizonDate: string) {
return start.isDate ? calendarDate(start) > horizonDate : start.toJSDate().getTime() > horizon;
}
function serializeOccurrence(
event: ICAL.Event,
start: ICAL.Time,
end: ICAL.Time,
calendar: string,
tone: CalendarTone,
feedIndex: number,
) {
const allDay = start.isDate;
const startValue = allDay ? calendarDate(start) : start.toJSDate().toISOString();
const endValue = allDay ? calendarDate(end) : end.toJSDate().toISOString();
const uid = cleanText(event.uid, "event", 180);
const location = cleanText(event.location, "", 120);
return {
id: hashId(`${feedIndex}|${uid}|${startValue}`),
calendar,
title: cleanText(event.summary, "Busy", 140),
...(location ? { location } : {}),
start: startValue,
end: endValue,
allDay,
tone,
} satisfies CalendarEvent;
}
export type FeedOptions = { feedIndex: number; tone: CalendarTone; nameOverride: string };
function expandCalendar(
ics: string,
{ feedIndex, tone, nameOverride }: FeedOptions,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
const root = new ICAL.Component(ICAL.parse(ics));
for (const timezone of root.getAllSubcomponents("vtimezone")) {
try {
ICAL.TimezoneService.register(timezone);
} catch {
// A feed may repeat a timezone that was already registered.
}
}
const sourceName = cleanText(root.getFirstPropertyValue("x-wr-calname"), `Calendar ${feedIndex + 1}`, 50);
const calendar = nameOverride || (sourceName.includes("@") ? "Personal" : sourceName);
const components = root.getAllSubcomponents("vevent");
const exceptions = new Map<string, ICAL.Component[]>();
for (const component of components) {
if (!component.hasProperty("recurrence-id")) continue;
const uid = componentString(component, "uid");
if (!uid) continue;
exceptions.set(uid, [...(exceptions.get(uid) ?? []), component]);
}
const items: CalendarEvent[] = [];
for (const component of components) {
if (component.hasProperty("recurrence-id")) continue;
try {
const event = new ICAL.Event(detach(component));
for (const exception of exceptions.get(event.uid) ?? []) {
event.relateException(detach(exception));
}
if (isCancelled(event)) continue;
if (!event.isRecurring()) {
if (eventIsInWindow(event.startDate, event.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(event, event.startDate, event.endDate, calendar, tone, feedIndex));
}
continue;
}
const iterator = event.iterator();
for (let count = 0; count < maxOccurrencesPerEvent; count += 1) {
const occurrence = iterator.next();
if (!occurrence) break;
if (occurrenceIsPastHorizon(occurrence, horizon, horizonDate)) break;
const details = event.getOccurrenceDetails(occurrence);
if (isCancelled(details.item)) continue;
if (eventIsInWindow(details.startDate, details.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(details.item, details.startDate, details.endDate, calendar, tone, feedIndex));
}
}
} catch {
// Ignore a malformed event while keeping the rest of the calendar usable.
}
}
return { calendar, sourceName, eventCount: components.length, items };
}
export async function readCalendar(
url: string,
options: FeedOptions,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
let currentUrl = url;
let response: Response | undefined;
for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
const validatedUrl = new URL(currentUrl);
if (validatedUrl.protocol !== "https:" || validatedUrl.hostname !== "calendar.google.com" || !validatedUrl.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar redirect host is not allowed");
}
response = await fetch(validatedUrl, {
headers: { Accept: "text/calendar" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get("location");
if (!location || redirectCount === 3) throw new Error("Calendar feed redirected too many times");
currentUrl = new URL(location, validatedUrl).toString();
}
if (!response) throw new Error("Calendar feed request failed");
if (!response.ok) throw new Error("Calendar feed request failed");
return expandCalendar(await readLimitedText(response), options, windowStart, horizon, windowStartDate, horizonDate);
}
@@ -0,0 +1,381 @@
import { createHash } from "node:crypto";
import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type PhotoAlbum, readSettings } from "./store";
/*
* Google Photos shared albums. Google removed third-party read access to user albums from the
* Library API on 2025-03-31, so this reads the public share page the same way a browser does:
* the album's items are embedded as JSON in an AF_initDataCallback({key: 'ds:1', ...}) block.
* This is unofficial and may break when Google changes the page. Cached photos keep showing
* if it does, and the album reports lastError plus a stale lastSuccessAt.
*/
export const MAX_PHOTOS_PER_ALBUM = 500;
export const MAX_UPLOADS = 500;
export const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
export const STALE_AFTER_MS = 48 * 60 * 60 * 1000;
const PHOTO_EDGE = 1920;
const THUMB_EDGE = 64;
/** Gallery tile (the Shield app's grid view). */
const TILE_EDGE = 400;
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36";
const VIDEO_MARKER = "76647426";
export type CachedPhoto = { key: string; takenAt: number | null; width: number; height: number; tile?: boolean };
export type AlbumIndex = {
title: string;
firstAttemptAt: number;
lastAttemptAt: number;
lastSuccessAt: number | null;
lastError: string;
truncated: boolean;
photos: CachedPhoto[];
};
const photosRoot = () => path.join(process.env.ORGANIZER_DATA_DIR || "/data", "photos");
export const UPLOADS_DIR = "uploads";
const dirFor = (name: string) => path.join(photosRoot(), name);
const indexPath = (name: string) => path.join(dirFor(name), "index.json");
export function validPhotoDir(name: string) {
return name === UPLOADS_DIR || /^[A-Za-z0-9-]{8,64}$/.test(name);
}
export function validPhotoFile(name: string) {
return /^[a-f0-9]{20}(\.t|\.m)?\.jpg$/.test(name);
}
export const photoFilePath = (dir: string, file: string) => path.join(dirFor(dir), file);
function emptyIndex(): AlbumIndex {
return { title: "", firstAttemptAt: 0, lastAttemptAt: 0, lastSuccessAt: null, lastError: "", truncated: false, photos: [] };
}
export async function readIndex(name: string): Promise<AlbumIndex> {
try {
return { ...emptyIndex(), ...(JSON.parse(await readFile(indexPath(name), "utf8")) as Partial<AlbumIndex>) };
} catch {
return emptyIndex();
}
}
async function writeIndex(name: string, index: AlbumIndex) {
await mkdir(dirFor(name), { recursive: true, mode: 0o700 });
const temporary = `${indexPath(name)}.${process.pid}.tmp`;
await writeFile(temporary, JSON.stringify(index), { mode: 0o600 });
await rename(temporary, indexPath(name));
}
async function writeFileAtomic(file: string, data: Uint8Array) {
const temporary = `${file}.${process.pid}.tmp`;
await writeFile(temporary, data, { mode: 0o600 });
await rename(temporary, file);
}
export function isJpeg(data: Uint8Array) {
return data.length > 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
}
async function readLimited(response: Response, maxBytes: number) {
const declared = Number(response.headers.get("content-length") ?? 0);
if (declared > maxBytes) throw new Error("Response too large");
if (!response.body) return new Uint8Array();
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new Error("Response too large");
}
chunks.push(value);
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}
// ---------- scraping ----------
type ScrapedItem = { googleId: string; baseUrl: string; width: number; height: number; takenAt: number | null; video: boolean };
export type ScrapedAlbum = { title: string; items: ScrapedItem[]; hasMorePages: boolean };
const ALLOWED_PAGE_HOSTS = new Set(["photos.app.goo.gl", "photos.google.com"]);
async function fetchAlbumPage(shareUrl: string) {
let current = shareUrl;
for (let hop = 0; hop <= 5; hop += 1) {
const url = new URL(current);
if (url.protocol !== "https:" || !ALLOWED_PAGE_HOSTS.has(url.hostname)) throw new Error(`Album link redirected to an unexpected host (${url.hostname})`);
const response = await fetch(url, {
headers: { "User-Agent": USER_AGENT, "Accept-Language": "en-US,en;q=0.9", Accept: "text/html" },
redirect: "manual",
signal: AbortSignal.timeout(20_000),
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get("location");
if (!location) throw new Error("Album link redirect had no target");
current = new URL(location, url).toString();
continue;
}
if (response.status === 404) throw new Error("Album not found. Was the share link removed?");
if (!response.ok) throw new Error(`Google Photos returned HTTP ${response.status}`);
return new TextDecoder().decode(await readLimited(response, 20 * 1024 * 1024));
}
throw new Error("Album link redirected too many times");
}
export function parseAlbumPage(html: string): ScrapedAlbum {
const match = /AF_initDataCallback\(\{key: 'ds:1'[\s\S]*?data:([\s\S]*?), sideChannel: \{\}\}\);<\/script>/.exec(html);
if (!match) throw new Error("Could not find the photo list on the album page (Google may have changed the page format)");
let data: unknown;
try {
data = JSON.parse(match[1]);
} catch {
throw new Error("Could not read the photo list on the album page (Google may have changed the page format)");
}
if (!Array.isArray(data) || !Array.isArray(data[1])) throw new Error("Unexpected album data format");
const meta = Array.isArray(data[3]) ? data[3] : [];
const items: ScrapedItem[] = [];
for (const raw of data[1] as unknown[]) {
if (!Array.isArray(raw) || typeof raw[0] !== "string" || !Array.isArray(raw[1])) continue;
const media = raw[1] as unknown[];
const baseUrl = String(media[0] ?? "");
let host = "";
try {
host = new URL(baseUrl).hostname;
} catch {
continue;
}
if (host !== "lh3.googleusercontent.com") continue;
const extras = raw[raw.length - 1];
items.push({
googleId: raw[0],
baseUrl,
width: Number(media[1]) || 0,
height: Number(media[2]) || 0,
takenAt: typeof raw[2] === "number" ? raw[2] : null,
video: Boolean(extras && typeof extras === "object" && !Array.isArray(extras) && VIDEO_MARKER in extras),
});
}
return {
title: typeof meta[1] === "string" ? meta[1] : "",
items,
hasMorePages: typeof data[2] === "string" && data[2].length > 0,
};
}
export async function scrapeAlbum(shareUrl: string) {
return parseAlbumPage(await fetchAlbumPage(shareUrl));
}
async function downloadImage(baseUrl: string, edge: number, maxBytes: number) {
const url = new URL(`${baseUrl}=w${edge}-h${edge}`);
if (url.hostname !== "lh3.googleusercontent.com") throw new Error("Unexpected image host");
const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, redirect: "error", signal: AbortSignal.timeout(30_000) });
if (!response.ok) throw new Error(`Image download failed (HTTP ${response.status})`);
const data = await readLimited(response, maxBytes);
if (!isJpeg(data)) throw new Error("Image was not a JPEG");
return data;
}
const photoKey = (googleId: string) => createHash("sha1").update(googleId).digest("hex").slice(0, 20);
// ---------- syncing ----------
const syncing = new Set<string>();
export const isSyncing = (albumId: string) => syncing.has(albumId);
async function mapLimit<T>(values: T[], limit: number, task: (value: T) => Promise<void>) {
let next = 0;
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
while (next < values.length) {
const value = values[next];
next += 1;
await task(value);
}
});
await Promise.all(workers);
}
export async function syncAlbum(album: PhotoAlbum) {
if (syncing.has(album.id)) return readIndex(album.id);
syncing.add(album.id);
const index = await readIndex(album.id);
const now = Date.now();
index.lastAttemptAt = now;
if (!index.firstAttemptAt) index.firstAttemptAt = now;
try {
const scraped = await scrapeAlbum(album.shareUrl);
const wanted = scraped.items.filter((item) => !item.video).slice(0, MAX_PHOTOS_PER_ALBUM);
if (!wanted.length && index.photos.length) throw new Error("Album page listed no photos; keeping the cached ones");
await mkdir(dirFor(album.id), { recursive: true, mode: 0o700 });
const existing = new Set((await readdir(dirFor(album.id))).filter(validPhotoFile));
const photos: CachedPhoto[] = [];
const failures: string[] = [];
await mapLimit(wanted, 4, async (item) => {
const key = photoKey(item.googleId);
try {
if (!existing.has(`${key}.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.jpg`), await downloadImage(item.baseUrl, PHOTO_EDGE, 15 * 1024 * 1024));
if (!existing.has(`${key}.t.jpg`)) await writeFileAtomic(photoFilePath(album.id, `${key}.t.jpg`), await downloadImage(item.baseUrl, THUMB_EDGE, 512 * 1024));
// The tile is optional: the gallery falls back to the full photo if it can't be fetched.
let tile = existing.has(`${key}.m.jpg`);
if (!tile) {
try {
await writeFileAtomic(photoFilePath(album.id, `${key}.m.jpg`), await downloadImage(item.baseUrl, TILE_EDGE, 2 * 1024 * 1024));
tile = true;
} catch {
tile = false;
}
}
photos.push({ key, takenAt: item.takenAt, width: item.width, height: item.height, tile });
} catch (error) {
failures.push(error instanceof Error ? error.message : String(error));
}
});
const keep = new Set(photos.flatMap((photo) => [`${photo.key}.jpg`, `${photo.key}.t.jpg`, `${photo.key}.m.jpg`]));
for (const file of existing) if (!keep.has(file)) await unlink(photoFilePath(album.id, file)).catch(() => undefined);
const order = new Map(wanted.map((item, position) => [photoKey(item.googleId), position]));
photos.sort((left, right) => (order.get(left.key) ?? 0) - (order.get(right.key) ?? 0));
index.title = scraped.title;
index.photos = photos;
index.truncated = scraped.hasMorePages || scraped.items.length > MAX_PHOTOS_PER_ALBUM;
if (failures.length && !photos.length) throw new Error(`Every download failed: ${failures[0]}`);
index.lastSuccessAt = Date.now();
index.lastError = failures.length ? `${failures.length} photo(s) failed to download: ${failures[0]}` : "";
console.log(`[photos] synced "${scraped.title}" (${album.id.slice(0, 8)}): ${photos.length} photos${failures.length ? `, ${failures.length} failed` : ""}`);
} catch (error) {
index.lastError = error instanceof Error ? error.message : String(error);
console.warn(`[photos] sync failed for ${album.id.slice(0, 8)}: ${index.lastError}`);
} finally {
await writeIndex(album.id, index).catch(() => undefined);
syncing.delete(album.id);
}
return index;
}
/** Deletes cache folders of albums that are no longer in settings. */
export async function removeOrphanedAlbumFolders() {
const { photos } = await readSettings();
await mkdir(photosRoot(), { recursive: true, mode: 0o700 });
const known = new Set(photos.albums.map((album) => album.id));
for (const entry of await readdir(photosRoot())) {
if (entry !== UPLOADS_DIR && validPhotoDir(entry) && !known.has(entry) && !syncing.has(entry)) {
await rm(dirFor(entry), { recursive: true, force: true });
}
}
}
let syncAllRunning: Promise<void> | null = null;
/** Syncs every enabled album and deletes cache folders of albums that were removed. */
export function syncAll() {
if (syncAllRunning) return syncAllRunning;
syncAllRunning = (async () => {
await removeOrphanedAlbumFolders();
const { photos } = await readSettings();
for (const album of photos.albums.filter((entry) => entry.enabled)) await syncAlbum(album);
})()
.catch((error) => console.warn(`[photos] sync run failed: ${error instanceof Error ? error.message : String(error)}`))
.finally(() => {
syncAllRunning = null;
});
return syncAllRunning;
}
let schedulerStarted = false;
export function startPhotoScheduler() {
if (schedulerStarted) return;
schedulerStarted = true;
const run = () => void syncAll();
setTimeout(run, 15_000).unref();
setInterval(run, SYNC_INTERVAL_MS).unref();
}
// ---------- uploads ----------
let uploadQueue: Promise<unknown> = Promise.resolve();
function serializedUploads<T>(task: () => Promise<T>) {
const run = uploadQueue.then(task, task);
uploadQueue = run.catch(() => undefined);
return run;
}
export function addUpload(photo: Uint8Array, thumb: Uint8Array, tile: Uint8Array | null, meta: { takenAt: number | null; width: number; height: number }) {
return serializedUploads(async () => {
const index = await readIndex(UPLOADS_DIR);
if (index.photos.length >= MAX_UPLOADS) throw new Error(`At most ${MAX_UPLOADS} uploaded photos`);
const key = createHash("sha1").update(photo).digest("hex").slice(0, 20);
if (index.photos.some((entry) => entry.key === key)) return { key, duplicate: true };
await mkdir(dirFor(UPLOADS_DIR), { recursive: true, mode: 0o700 });
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.jpg`), photo);
await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.t.jpg`), thumb);
if (tile) await writeFileAtomic(photoFilePath(UPLOADS_DIR, `${key}.m.jpg`), tile);
index.photos.push({ key, ...meta, tile: Boolean(tile) });
index.title = "Uploaded photos";
index.lastSuccessAt = Date.now();
await writeIndex(UPLOADS_DIR, index);
return { key, duplicate: false };
});
}
export function removeUpload(key: string) {
return serializedUploads(async () => {
if (!/^[a-f0-9]{20}$/.test(key)) return false;
const index = await readIndex(UPLOADS_DIR);
const before = index.photos.length;
index.photos = index.photos.filter((entry) => entry.key !== key);
if (index.photos.length === before) return false;
await writeIndex(UPLOADS_DIR, index);
for (const file of [`${key}.jpg`, `${key}.t.jpg`, `${key}.m.jpg`]) await unlink(photoFilePath(UPLOADS_DIR, file)).catch(() => undefined);
return true;
});
}
// ---------- what the slideshow shows ----------
export type SlidePhoto = { src: string; thumb: string; tile: string; takenAt: number | null; width: number; height: number };
export async function currentSlideshow() {
const settings = await readSettings();
const { albums, ...display } = settings.photos;
const sources = albums.filter((album) => album.enabled).map((album) => album.id);
if (display.includeUploads) sources.push(UPLOADS_DIR);
const photos: SlidePhoto[] = [];
const fingerprint = createHash("sha1").update(settings.updatedAt);
for (const source of sources) {
const index = await readIndex(source);
fingerprint.update(`${source}:${index.lastSuccessAt}:${index.photos.length}:${index.photos.filter((photo) => photo.tile).length}`);
for (const photo of index.photos) {
photos.push({
src: `/api/photos/img/${source}/${photo.key}.jpg`,
thumb: `/api/photos/img/${source}/${photo.key}.t.jpg`,
tile: photo.tile ? `/api/photos/img/${source}/${photo.key}.m.jpg` : `/api/photos/img/${source}/${photo.key}.jpg`,
takenAt: photo.takenAt,
width: photo.width,
height: photo.height,
});
}
}
return { display, photos, version: fingerprint.digest("hex").slice(0, 16) };
}
/** Albums whose last successful sync is older than 48h (or never succeeded within 48h of being added). */
export async function staleAlbums() {
const { photos } = await readSettings();
const now = Date.now();
const stale: { id: string; title: string; lastSuccessAt: number | null; lastError: string }[] = [];
for (const album of photos.albums.filter((entry) => entry.enabled)) {
const index = await readIndex(album.id);
const reference = index.lastSuccessAt ?? (index.firstAttemptAt || now);
if (now - reference > STALE_AFTER_MS) stale.push({ id: album.id, title: album.name || index.title, lastSuccessAt: index.lastSuccessAt, lastError: index.lastError });
}
return stale;
}
@@ -0,0 +1,310 @@
import { randomUUID } from "node:crypto";
import { readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
export const CALENDAR_TONES = ["cyan", "amber", "violet", "emerald", "rose"] as const;
export type CalendarTone = (typeof CALENDAR_TONES)[number];
export const MAX_CALENDARS = 10;
const HISTORY_LIMIT = 20;
export type CalendarSetting = {
id: string;
/** Display name override; empty means "use the feed's own name". */
name: string;
url: string;
tone: CalendarTone;
enabled: boolean;
};
export type PhotoAlbum = {
id: string;
/** Display name override; empty means "use the album's own title". */
name: string;
shareUrl: string;
enabled: boolean;
};
export type PhotoDisplay = {
intervalSeconds: number;
shuffle: boolean;
showDate: boolean;
fit: "contain-blur" | "cover";
includeUploads: boolean;
};
export type PhotoSettings = PhotoDisplay & { albums: PhotoAlbum[] };
export const MAX_ALBUMS = 10;
export const DEFAULT_PHOTO_SETTINGS: PhotoSettings = {
intervalSeconds: 20,
shuffle: true,
showDate: true,
fit: "contain-blur",
includeUploads: true,
albums: [],
};
/**
* Per-TV morning routine, run by the Shield photo app (com.dayline.photos) on each TV:
* wake the TV, set the volume, start a YouTube Music playlist, then bring up the calendar.
* `id` is the TV's Android ID, reported by the app when it checks in.
*/
export type TvSetting = {
id: string;
name: string;
wake: { enabled: boolean; time: string; days: number[] };
music: { enabled: boolean; playlistId: string };
volume: { enabled: boolean; level: number };
};
export const MAX_TVS = 10;
export const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
export function validTvId(value: unknown) {
return typeof value === "string" && /^[a-f0-9]{8,32}$/.test(value) ? value : null;
}
/** Accepts a YouTube / YouTube Music playlist link or a bare playlist ID; returns the ID. */
export function validPlaylistId(value: unknown) {
const text = String(value ?? "").trim();
if (/^[A-Za-z0-9_-]{10,64}$/.test(text)) return text;
let url: URL;
try {
url = new URL(text);
} catch {
return null;
}
if (url.protocol !== "https:" || !["music.youtube.com", "www.youtube.com", "youtube.com", "m.youtube.com"].includes(url.hostname)) return null;
const list = url.searchParams.get("list") ?? "";
return /^[A-Za-z0-9_-]{10,64}$/.test(list) ? list : null;
}
export const playlistUrl = (playlistId: string) => `https://music.youtube.com/playlist?list=${playlistId}`;
function validTime(value: unknown) {
return typeof value === "string" && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : null;
}
function normalizeTvs(raw: unknown): TvSetting[] {
const seen = new Set<string>();
return (Array.isArray(raw) ? raw : [])
.map((entry) => {
const id = validTvId(entry?.id);
if (!id || seen.has(id)) return null;
seen.add(id);
const days = Array.isArray(entry.wake?.days)
? [...new Set((entry.wake.days as unknown[]).map(Number).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort()
: ALL_DAYS;
const level = Number(entry.volume?.level);
return {
id,
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 40) : "",
wake: { enabled: entry.wake?.enabled === true, time: validTime(entry.wake?.time) ?? "06:30", days },
music: { enabled: entry.music?.enabled === true, playlistId: validPlaylistId(entry.music?.playlistId) ?? "" },
volume: { enabled: entry.volume?.enabled === true, level: Number.isFinite(level) ? Math.min(100, Math.max(0, Math.round(level))) : 40 },
} satisfies TvSetting;
})
.filter((entry): entry is TvSetting => entry !== null)
.slice(0, MAX_TVS);
}
export type Settings = {
schema: 1;
updatedAt: string;
calendars: CalendarSetting[];
photos: PhotoSettings;
tvs: TvSetting[];
};
/** Accepts photos.app.goo.gl short links and photos.google.com/share links. */
export function validAlbumUrl(value: unknown) {
let url: URL;
try {
url = new URL(String(value).trim());
} catch {
return null;
}
if (url.protocol !== "https:") return null;
if (url.hostname === "photos.app.goo.gl" && /^\/[A-Za-z0-9_-]{6,64}$/.test(url.pathname)) return `https://photos.app.goo.gl${url.pathname}`;
if (url.hostname === "photos.google.com" && /^\/share\/[A-Za-z0-9_-]{10,200}$/.test(url.pathname)) return url.toString();
return null;
}
export function maskAlbumUrl(url: string) {
const parsed = new URL(url);
const id = parsed.pathname.split("/").pop() || "";
return `${parsed.hostname}/…${id.slice(-4)}`;
}
function normalizePhotos(raw: unknown): PhotoSettings {
const value = (raw ?? {}) as Partial<PhotoSettings>;
const interval = Number(value.intervalSeconds);
return {
intervalSeconds: Number.isFinite(interval) ? Math.min(3600, Math.max(5, Math.round(interval))) : DEFAULT_PHOTO_SETTINGS.intervalSeconds,
shuffle: value.shuffle !== false,
showDate: value.showDate !== false,
fit: value.fit === "cover" ? "cover" : "contain-blur",
includeUploads: value.includeUploads !== false,
albums: (Array.isArray(value.albums) ? value.albums : [])
.map((entry) => {
const shareUrl = validAlbumUrl(entry?.shareUrl);
if (!shareUrl) return null;
return {
id: typeof entry.id === "string" && /^[A-Za-z0-9-]{8,64}$/.test(entry.id) ? entry.id : randomUUID(),
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 60) : "",
shareUrl,
enabled: entry.enabled !== false,
} satisfies PhotoAlbum;
})
.filter((entry): entry is PhotoAlbum => entry !== null)
.slice(0, MAX_ALBUMS),
};
}
export type AuthState = {
passwordHash: string;
sessionSecret: string;
};
const dataDir = () => process.env.ORGANIZER_DATA_DIR || "/data";
const settingsPath = () => path.join(dataDir(), "settings.json");
const authPath = () => path.join(dataDir(), "auth.json");
const historyDir = () => path.join(dataDir(), "history");
export function validCalendarUrl(value: unknown) {
let url: URL;
try {
url = new URL(String(value).trim());
} catch {
return null;
}
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) return null;
if (!/^\/calendar\/ical\/[A-Za-z0-9._~%+@-]+\/(private-[A-Za-z0-9._~%+@-]+|public)\/basic\.ics$/.test(url.pathname)) return null;
return url.toString();
}
/** "…/ical/fam…%40group…/private-••••3854/basic.ics" — enough to tell feeds apart, not enough to use one. */
export function maskCalendarUrl(url: string) {
const match = /\/calendar\/ical\/([^/]+)\/(private-[^/]+|public)\/basic\.ics$/.exec(new URL(url).pathname);
if (!match) return "calendar.google.com/…";
const owner = decodeURIComponent(match[1]);
const shortOwner = owner.length > 18 ? `${owner.slice(0, 8)}…${owner.slice(owner.indexOf("@"))}` : owner;
const key = match[2] === "public" ? "public" : `private-••••${match[2].slice(-4)}`;
return `${shortOwner} · ${key}`;
}
function normalizeSettings(raw: unknown): Settings {
const value = (raw ?? {}) as Partial<Settings>;
const calendars = Array.isArray(value.calendars) ? value.calendars : [];
return {
schema: 1,
updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date(0).toISOString(),
calendars: calendars
.map((entry) => {
const url = validCalendarUrl(entry?.url);
if (!url) return null;
return {
id: typeof entry.id === "string" && entry.id ? entry.id : randomUUID(),
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 50) : "",
url,
tone: CALENDAR_TONES.includes(entry.tone as CalendarTone) ? (entry.tone as CalendarTone) : "cyan",
enabled: entry.enabled !== false,
} satisfies CalendarSetting;
})
.filter((entry): entry is CalendarSetting => entry !== null)
.slice(0, MAX_CALENDARS),
photos: normalizePhotos(value.photos),
tvs: normalizeTvs(value.tvs),
};
}
async function readJson(file: string) {
return JSON.parse(await readFile(file, "utf8")) as unknown;
}
async function writeJsonAtomic(file: string, value: unknown) {
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, file);
}
// All writes go through one queue so concurrent saves cannot interleave.
let writeQueue: Promise<unknown> = Promise.resolve();
function serialized<T>(task: () => Promise<T>) {
const run = writeQueue.then(task, task);
writeQueue = run.catch(() => undefined);
return run;
}
export async function readSettings(): Promise<Settings> {
try {
return normalizeSettings(await readJson(settingsPath()));
} catch {
return normalizeSettings({});
}
}
export type HistoryEntry = { id: string; savedAt: string; calendarCount: number; albumCount: number; note: string };
async function pruneHistory() {
const files = (await readdir(historyDir())).filter((name) => name.endsWith(".json")).sort();
for (const name of files.slice(0, Math.max(0, files.length - HISTORY_LIMIT))) {
await unlink(path.join(historyDir(), name)).catch(() => undefined);
}
}
/** Saves new settings (only the parts given), first snapshotting the current ones into history. */
export function saveSettings(patch: Partial<Pick<Settings, "calendars" | "photos" | "tvs">>, note: string) {
return serialized(async () => {
const current = await readSettings();
const calendars = patch.calendars ?? current.calendars;
const photos = patch.photos ?? current.photos;
const tvs = patch.tvs ?? current.tvs;
const stamp = new Date().toISOString();
const snapshotId = `${stamp.replace(/[:.]/g, "-")}`;
await writeJsonAtomic(path.join(historyDir(), `${snapshotId}.json`), { ...current, note });
await pruneHistory();
const next = normalizeSettings({ schema: 1, updatedAt: stamp, calendars, photos, tvs });
await writeJsonAtomic(settingsPath(), next);
return next;
});
}
export async function listHistory(): Promise<HistoryEntry[]> {
const files = (await readdir(historyDir()).catch(() => [] as string[])).filter((name) => name.endsWith(".json")).sort().reverse();
const entries: HistoryEntry[] = [];
for (const name of files) {
try {
const raw = (await readJson(path.join(historyDir(), name))) as Settings & { note?: string };
entries.push({
id: name.slice(0, -5),
savedAt: raw.updatedAt,
calendarCount: Array.isArray(raw.calendars) ? raw.calendars.length : 0,
albumCount: Array.isArray(raw.photos?.albums) ? raw.photos.albums.length : 0,
note: typeof raw.note === "string" ? raw.note : "",
});
} catch {
// Skip an unreadable snapshot.
}
}
return entries;
}
export async function readHistorySnapshot(id: string) {
if (!/^[0-9TZ-]+$/.test(id)) return null;
try {
return normalizeSettings(await readJson(path.join(historyDir(), `${id}.json`)));
} catch {
return null;
}
}
export async function readAuth(): Promise<AuthState> {
const raw = (await readJson(authPath())) as Partial<AuthState>;
if (typeof raw.sessionSecret !== "string" || raw.sessionSecret.length < 32) throw new Error("auth.json is missing a session secret");
return { passwordHash: typeof raw.passwordHash === "string" ? raw.passwordHash : "", sessionSecret: raw.sessionSecret };
}
export function writeAuth(next: AuthState) {
return serialized(() => writeJsonAtomic(authPath(), next));
}
@@ -0,0 +1,125 @@
import { createHash } from "node:crypto";
import { readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { playlistUrl, type TvSetting } from "./store";
/*
* TV check-ins. Each Shield's photo app polls GET /api/tv/config every few minutes with its
* Android ID; the server answers with that TV's morning-routine settings and records what the
* TV last reported (so the admin page can show "applied" vs "waiting for the TV").
* Status is runtime data, kept apart from settings.json so it never lands in settings history.
*/
/** How often the app checks in (the app uses this value; keep in sync with the APK default). */
export const CHECK_IN_MINUTES = 5;
/** Music starts this many seconds before the wake time; the calendar comes up at the wake time. */
export const MUSIC_LEAD_SECONDS = 30;
const MAX_STATUS_ENTRIES = 20;
export type TvEvent = { at: number; kind: string; ok: boolean; detail: string };
export type TvStatus = {
id: string;
deviceName: string;
model: string;
ip: string;
appVersion: string;
lastSeenAt: number;
appliedVersion: string;
lastEvent: TvEvent | null;
};
const statusPath = () => path.join(process.env.ORGANIZER_DATA_DIR || "/data", "tv-status.json");
let queue: Promise<unknown> = Promise.resolve();
function serialized<T>(task: () => Promise<T>) {
const run = queue.then(task, task);
queue = run.catch(() => undefined);
return run;
}
export async function readTvStatus(): Promise<Record<string, TvStatus>> {
try {
const raw = JSON.parse(await readFile(statusPath(), "utf8")) as Record<string, TvStatus>;
return raw && typeof raw === "object" ? raw : {};
} catch {
return {};
}
}
async function writeTvStatus(next: Record<string, TvStatus>) {
const temporary = `${statusPath()}.${process.pid}.tmp`;
await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
await rename(temporary, statusPath());
}
const clean = (value: unknown, max: number) =>
String(value ?? "")
.replace(/[^\x20-\x7e]/g, "")
.trim()
.slice(0, max);
/** Records a check-in (and optionally an event such as the morning routine's result). */
export function recordTvCheckIn(
id: string,
report: { deviceName?: unknown; model?: unknown; appVersion?: unknown; appliedVersion?: unknown; ip: string },
event?: Omit<TvEvent, "at">,
knownIds: Set<string> = new Set(),
) {
return serialized(async () => {
const all = await readTvStatus();
const previous = all[id];
// Unknown TVs are remembered (so admin can add them) but capped, oldest dropped first.
if (!previous && Object.keys(all).length >= MAX_STATUS_ENTRIES) {
const evictable = Object.values(all)
.filter((entry) => !knownIds.has(entry.id))
.sort((left, right) => left.lastSeenAt - right.lastSeenAt);
if (!evictable.length) return;
delete all[evictable[0].id];
}
all[id] = {
id,
deviceName: clean(report.deviceName, 60) || previous?.deviceName || "",
model: clean(report.model, 60) || previous?.model || "",
ip: report.ip,
appVersion: clean(report.appVersion, 20) || previous?.appVersion || "",
lastSeenAt: Date.now(),
appliedVersion: clean(report.appliedVersion, 20) || previous?.appliedVersion || "",
lastEvent: event ? { ...event, kind: clean(event.kind, 20), detail: clean(event.detail, 200), at: Date.now() } : (previous?.lastEvent ?? null),
};
await writeTvStatus(all);
});
}
export function forgetTvStatus(id: string) {
return serialized(async () => {
const all = await readTvStatus();
if (!all[id]) return;
delete all[id];
await writeTvStatus(all);
});
}
/** What the TV app receives. Unknown TVs get everything disabled. */
export function tvConfig(setting: TvSetting | undefined) {
const body = {
registered: Boolean(setting),
name: setting?.name ?? "",
wake: setting?.wake ?? { enabled: false, time: "06:30", days: [] as number[] },
music: {
enabled: Boolean(setting?.music.enabled && setting.music.playlistId),
// watch?list= starts playback; playlist?list= would only open the playlist page.
url: setting?.music.playlistId ? `https://music.youtube.com/watch?list=${setting.music.playlistId}` : "",
},
volume: setting?.volume ?? { enabled: false, level: 40 },
musicLeadSeconds: MUSIC_LEAD_SECONDS,
checkInMinutes: CHECK_IN_MINUTES,
calendarPackage: "com.dayline.tv",
};
return { ...body, version: configVersion(body) };
}
export function configVersion(body: unknown) {
return createHash("sha1").update(JSON.stringify(body)).digest("hex").slice(0, 12);
}
export { playlistUrl };
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Organizer Admin",
robots: { index: false, follow: false },
};
export default function AdminLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return children;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import { randomUUID } from "node:crypto";
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
import {
CALENDAR_TONES,
type CalendarSetting,
type CalendarTone,
MAX_CALENDARS,
maskCalendarUrl,
readSettings,
saveSettings,
validCalendarUrl,
} from "../../../_lib/store";
export const dynamic = "force-dynamic";
function publicView(calendars: CalendarSetting[]) {
return calendars.map(({ id, name, tone, enabled, url }) => ({ id, name, tone, enabled, masked: maskCalendarUrl(url) }));
}
export async function GET(request: Request) {
const denied = await requireAdmin(request);
if (denied) return denied;
const settings = await readSettings();
return json({ calendars: publicView(settings.calendars), updatedAt: settings.updatedAt, tones: CALENDAR_TONES });
}
type IncomingCalendar = { id?: string; name?: string; tone?: string; enabled?: boolean; url?: string };
export async function PUT(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ calendars?: IncomingCalendar[] }>(request);
if (!body || !Array.isArray(body.calendars)) return json({ error: "Expected a calendars list." }, 400);
if (body.calendars.length > MAX_CALENDARS) return json({ error: `At most ${MAX_CALENDARS} calendars.` }, 400);
const existing = new Map((await readSettings()).calendars.map((calendar) => [calendar.id, calendar]));
const next: CalendarSetting[] = [];
for (const [index, entry] of body.calendars.entries()) {
const label = entry.name?.trim() || `Calendar ${index + 1}`;
const previous = entry.id ? existing.get(entry.id) : undefined;
if (entry.id && !previous) return json({ error: `${label}: unknown calendar; reload the page.` }, 409);
const suppliedUrl = typeof entry.url === "string" && entry.url.trim() ? entry.url : "";
const url = suppliedUrl ? validCalendarUrl(suppliedUrl) : previous?.url;
if (!url) {
return json({ error: `${label}: paste a Google Calendar "Secret address in iCal format" (https://calendar.google.com/…/basic.ics).` }, 400);
}
next.push({
id: previous?.id ?? randomUUID(),
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 50) : "",
url,
tone: CALENDAR_TONES.includes(entry.tone as CalendarTone) ? (entry.tone as CalendarTone) : "cyan",
enabled: entry.enabled !== false,
});
}
if (new Set(next.map((calendar) => calendar.url)).size !== next.length) return json({ error: "The same calendar is listed twice." }, 400);
const saved = await saveSettings({ calendars: next }, `Saved ${next.length} calendar(s)`);
console.log(`[admin] calendars saved (${saved.calendars.length})`);
return json({ calendars: publicView(saved.calendars), updatedAt: saved.updatedAt });
}
@@ -0,0 +1,32 @@
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
import { addCalendarDays, organizerDate, readCalendar } from "../../../../_lib/calendar-feed";
import { readSettings, validCalendarUrl } from "../../../../_lib/store";
export const dynamic = "force-dynamic";
/** Fetches one feed (saved by id, or a not-yet-saved URL) and reports what it found. */
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ id?: string; url?: string }>(request);
let url: string | null = null;
if (body?.url) url = validCalendarUrl(body.url);
else if (body?.id) url = (await readSettings()).calendars.find((calendar) => calendar.id === body.id)?.url ?? null;
if (!url) return json({ ok: false, error: "Not a Google Calendar basic.ics address." }, 400);
const now = Date.now();
const today = organizerDate(new Date(now));
try {
const result = await readCalendar(
url,
{ feedIndex: 0, tone: "cyan", nameOverride: "" },
now,
now + 60 * 24 * 60 * 60 * 1_000,
today,
addCalendarDays(today, 60),
);
return json({ ok: true, sourceName: result.sourceName, eventCount: result.eventCount, upcoming: result.items.length });
} catch (error) {
return json({ ok: false, error: error instanceof Error ? error.message : "Fetch failed" });
}
}
@@ -0,0 +1,24 @@
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
import { syncAll } from "../../../_lib/photos";
import { listHistory, readHistorySnapshot, saveSettings } from "../../../_lib/store";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const denied = await requireAdmin(request);
if (denied) return denied;
return json({ history: await listHistory() });
}
/** Reverts calendars and photo settings to a snapshot (TV settings are left as they are). The current state is itself snapshotted first, so a revert can be undone. */
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ id?: string }>(request);
const snapshot = body?.id ? await readHistorySnapshot(body.id) : null;
if (!snapshot) return json({ error: "That version no longer exists." }, 404);
const saved = await saveSettings({ calendars: snapshot.calendars, photos: snapshot.photos }, `Reverted to version from ${snapshot.updatedAt}`);
console.log(`[admin] settings reverted to ${body?.id}`);
void syncAll(); // re-fetch any album the restored version brings back
return json({ ok: true, updatedAt: saved.updatedAt });
}
@@ -0,0 +1,36 @@
import {
clearLoginFailures,
clientIp,
isAdminNetworkAllowed,
json,
loginLockedFor,
readJsonBody,
recordLoginFailure,
sameOrigin,
sessionCookie,
verifyPassword,
} from "../../../_lib/auth";
import { readAuth } from "../../../_lib/store";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
if (!sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
const ip = clientIp(request.headers);
const locked = loginLockedFor(ip);
if (locked) return json({ error: `Too many attempts. Try again in ${locked} seconds.` }, 429);
const auth = await readAuth();
if (!auth.passwordHash) return json({ error: "No password is set yet." }, 409);
const body = await readJsonBody<{ password?: string }>(request);
const password = typeof body?.password === "string" ? body.password : "";
if (!password || !(await verifyPassword(password, auth.passwordHash))) {
recordLoginFailure(ip);
console.warn(`[admin] failed login from ${ip}`);
return json({ error: "Wrong password." }, 401);
}
clearLoginFailures(ip);
console.log(`[admin] login from ${ip}`);
return json({ ok: true }, 200, { "Set-Cookie": sessionCookie(auth.sessionSecret) });
}
@@ -0,0 +1,7 @@
import { clearedSessionCookie, json } from "../../../_lib/auth";
export const dynamic = "force-dynamic";
export async function POST() {
return json({ ok: true }, 200, { "Set-Cookie": clearedSessionCookie() });
}
@@ -0,0 +1,16 @@
import { json, MIN_PASSWORD_LENGTH, readJsonBody, requireAdmin, setPassword, verifyPassword } from "../../../_lib/auth";
import { readAuth } from "../../../_lib/store";
export const dynamic = "force-dynamic";
/** Changes the password; signs out every other session. */
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ currentPassword?: string; newPassword?: string }>(request);
const current = typeof body?.currentPassword === "string" ? body.currentPassword : "";
const next = typeof body?.newPassword === "string" ? body.newPassword : "";
if (!(await verifyPassword(current, (await readAuth()).passwordHash))) return json({ error: "Current password is wrong." }, 401);
if (next.length < MIN_PASSWORD_LENGTH || next.length > 256) return json({ error: `Use at least ${MIN_PASSWORD_LENGTH} characters.` }, 400);
return json({ ok: true }, 200, { "Set-Cookie": await setPassword(next) });
}
@@ -0,0 +1,27 @@
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
import { MAX_PHOTOS_PER_ALBUM, scrapeAlbum } from "../../../../_lib/photos";
import { validAlbumUrl } from "../../../../_lib/store";
export const dynamic = "force-dynamic";
/** Reads a share link without saving it: album title and how many photos/videos it has. */
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ shareUrl?: string }>(request);
const shareUrl = validAlbumUrl(body?.shareUrl);
if (!shareUrl) return json({ ok: false, error: "Not a Google Photos share link." }, 400);
try {
const album = await scrapeAlbum(shareUrl);
const videos = album.items.filter((item) => item.video).length;
return json({
ok: true,
title: album.title,
photos: album.items.length - videos,
videos,
truncated: album.hasMorePages || album.items.length - videos > MAX_PHOTOS_PER_ALBUM,
});
} catch (error) {
return json({ ok: false, error: error instanceof Error ? error.message : "Could not read the album." });
}
}
@@ -0,0 +1,83 @@
import { randomUUID } from "node:crypto";
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
import { isSyncing, readIndex, removeOrphanedAlbumFolders, STALE_AFTER_MS, syncAlbum, UPLOADS_DIR } from "../../../_lib/photos";
import { MAX_ALBUMS, maskAlbumUrl, type PhotoAlbum, type PhotoSettings, readSettings, saveSettings, validAlbumUrl } from "../../../_lib/store";
export const dynamic = "force-dynamic";
async function view(photos: PhotoSettings) {
const { albums, ...display } = photos;
const uploads = await readIndex(UPLOADS_DIR);
return {
display,
albums: await Promise.all(
albums.map(async (album) => {
const index = await readIndex(album.id);
return {
id: album.id,
name: album.name,
enabled: album.enabled,
masked: maskAlbumUrl(album.shareUrl),
title: index.title,
photoCount: index.photos.length,
lastSuccessAt: index.lastSuccessAt,
lastAttemptAt: index.lastAttemptAt,
lastError: index.lastError,
truncated: index.truncated,
stale: album.enabled && Date.now() - (index.lastSuccessAt ?? (index.firstAttemptAt || Date.now())) > STALE_AFTER_MS,
syncing: isSyncing(album.id),
};
}),
),
uploads: uploads.photos.map((photo) => ({ key: photo.key, thumb: `/api/photos/img/${UPLOADS_DIR}/${photo.key}.t.jpg`, src: `/api/photos/img/${UPLOADS_DIR}/${photo.key}.jpg` })),
};
}
export async function GET(request: Request) {
const denied = await requireAdmin(request);
if (denied) return denied;
return json(await view((await readSettings()).photos));
}
type IncomingAlbum = { id?: string; name?: string; enabled?: boolean; shareUrl?: string };
type Incoming = { display?: Partial<Omit<PhotoSettings, "albums">>; albums?: IncomingAlbum[] };
export async function PUT(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<Incoming>(request);
if (!body || !Array.isArray(body.albums)) return json({ error: "Expected albums and display settings." }, 400);
if (body.albums.length > MAX_ALBUMS) return json({ error: `At most ${MAX_ALBUMS} albums.` }, 400);
const current = (await readSettings()).photos;
const existing = new Map(current.albums.map((album) => [album.id, album]));
const albums: PhotoAlbum[] = [];
const toSync: PhotoAlbum[] = [];
for (const [position, entry] of body.albums.entries()) {
const label = entry.name?.trim() || `Album ${position + 1}`;
const previous = entry.id ? existing.get(entry.id) : undefined;
if (entry.id && !previous) return json({ error: `${label}: unknown album; reload the page.` }, 409);
const supplied = typeof entry.shareUrl === "string" && entry.shareUrl.trim() ? validAlbumUrl(entry.shareUrl) : null;
if (entry.shareUrl?.trim() && !supplied) return json({ error: `${label}: paste a Google Photos share link (https://photos.app.goo.gl/… or https://photos.google.com/share/…).` }, 400);
const shareUrl = supplied ?? previous?.shareUrl;
if (!shareUrl) return json({ error: `${label}: a share link is required.` }, 400);
const album: PhotoAlbum = {
id: previous?.id ?? randomUUID(),
name: typeof entry.name === "string" ? entry.name.trim().slice(0, 60) : "",
shareUrl,
enabled: entry.enabled !== false,
};
albums.push(album);
if (album.enabled && (!previous || previous.shareUrl !== shareUrl || !previous.enabled)) toSync.push(album);
}
if (new Set(albums.map((album) => album.shareUrl)).size !== albums.length) return json({ error: "The same album is listed twice." }, 400);
const saved = await saveSettings({ photos: { ...current, ...(body.display ?? {}), albums } }, `Saved photo settings (${albums.length} album(s))`);
console.log(`[admin] photo settings saved (${albums.length} album(s))`);
// Fetch new/changed albums now, and drop cache folders of removed ones, without making the browser wait.
void (async () => {
await removeOrphanedAlbumFolders();
for (const album of toSync) await syncAlbum(album);
})();
return json(await view(saved.photos));
}
@@ -0,0 +1,20 @@
import { json, readJsonBody, requireAdmin } from "../../../../_lib/auth";
import { syncAlbum, syncAll } from "../../../../_lib/photos";
import { readSettings } from "../../../../_lib/store";
export const dynamic = "force-dynamic";
/** Syncs one album ({ id }) or all of them, and waits for the result. */
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ id?: string }>(request);
if (body?.id) {
const album = (await readSettings()).photos.albums.find((entry) => entry.id === body.id);
if (!album) return json({ error: "Unknown album." }, 404);
const index = await syncAlbum(album);
return json({ ok: !index.lastError || index.photos.length > 0, photoCount: index.photos.length, lastError: index.lastError });
}
await syncAll();
return json({ ok: true });
}
@@ -0,0 +1,52 @@
import { json, requireAdmin } from "../../../../_lib/auth";
import { addUpload, isJpeg, removeUpload } from "../../../../_lib/photos";
export const dynamic = "force-dynamic";
/**
* The admin page resizes photos in the browser (max 1920px JPEG, a 400px gallery tile and a 64px thumbnail)
* before sending them, so the server only validates and stores.
*/
export async function POST(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
let form: FormData;
try {
form = await request.formData();
} catch {
return json({ error: "Expected a photo upload." }, 400);
}
const photo = form.get("photo");
const thumb = form.get("thumb");
if (!(photo instanceof Blob) || !(thumb instanceof Blob)) return json({ error: "Missing photo." }, 400);
if (photo.size > 8 * 1024 * 1024 || thumb.size > 256 * 1024) return json({ error: "Photo is too large." }, 413);
const photoBytes = new Uint8Array(await photo.arrayBuffer());
const thumbBytes = new Uint8Array(await thumb.arrayBuffer());
if (!isJpeg(photoBytes) || !isJpeg(thumbBytes)) return json({ error: "Photos must be JPEG." }, 415);
// The gallery tile is optional (older admin pages don't send one); the gallery then uses the full photo.
const tile = form.get("tile");
let tileBytes: Uint8Array | null = null;
if (tile instanceof Blob) {
if (tile.size > 1024 * 1024) return json({ error: "Photo is too large." }, 413);
tileBytes = new Uint8Array(await tile.arrayBuffer());
if (!isJpeg(tileBytes)) return json({ error: "Photos must be JPEG." }, 415);
}
const takenAt = Number(form.get("takenAt"));
try {
const result = await addUpload(photoBytes, thumbBytes, tileBytes, {
takenAt: Number.isFinite(takenAt) && takenAt > 0 ? takenAt : null,
width: Math.max(0, Math.round(Number(form.get("width")) || 0)),
height: Math.max(0, Math.round(Number(form.get("height")) || 0)),
});
return json({ ok: true, ...result });
} catch (error) {
return json({ error: error instanceof Error ? error.message : "Upload failed." }, 400);
}
}
export async function DELETE(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const key = new URL(request.url).searchParams.get("key") || "";
return (await removeUpload(key)) ? json({ ok: true }) : json({ error: "Not found." }, 404);
}
@@ -0,0 +1,17 @@
import { isAdminNetworkAllowed, json, MIN_PASSWORD_LENGTH, readJsonBody, sameOrigin, setPassword } from "../../../_lib/auth";
import { readAuth } from "../../../_lib/store";
export const dynamic = "force-dynamic";
/** First-run only: sets the admin password when none exists yet. */
export async function POST(request: Request) {
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
if (!sameOrigin(request)) return json({ error: "Cross-origin request refused." }, 403);
if ((await readAuth()).passwordHash) return json({ error: "A password is already set. Sign in instead." }, 409);
const body = await readJsonBody<{ password?: string }>(request);
const password = typeof body?.password === "string" ? body.password : "";
if (password.length < MIN_PASSWORD_LENGTH || password.length > 256) {
return json({ error: `Use at least ${MIN_PASSWORD_LENGTH} characters.` }, 400);
}
return json({ ok: true }, 200, { "Set-Cookie": await setPassword(password) });
}
@@ -0,0 +1,10 @@
import { hasValidSession, isAdminNetworkAllowed, json } from "../../../_lib/auth";
import { readAuth } from "../../../_lib/store";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
if (!isAdminNetworkAllowed(request.headers)) return json({ error: "Admin is only available on the home network." }, 403);
const { passwordHash } = await readAuth();
return json({ setupRequired: !passwordHash, authenticated: passwordHash ? await hasValidSession(request.headers) : false });
}
@@ -0,0 +1,72 @@
import { json, readJsonBody, requireAdmin } from "../../../_lib/auth";
import { ALL_DAYS, MAX_TVS, playlistUrl, readSettings, saveSettings, type TvSetting, validPlaylistId, validTvId } from "../../../_lib/store";
import { CHECK_IN_MINUTES, readTvStatus, tvConfig } from "../../../_lib/tvs";
export const dynamic = "force-dynamic";
async function view(tvs: TvSetting[]) {
const status = await readTvStatus();
const known = new Set(tvs.map((tv) => tv.id));
return {
checkInMinutes: CHECK_IN_MINUTES,
tvs: tvs.map((tv) => ({
...tv,
playlistUrl: tv.music.playlistId ? playlistUrl(tv.music.playlistId) : "",
version: tvConfig(tv).version,
status: status[tv.id] ?? null,
})),
// TVs running the photo app that have checked in but are not configured yet.
found: Object.values(status)
.filter((entry) => !known.has(entry.id))
.sort((left, right) => right.lastSeenAt - left.lastSeenAt),
};
}
export async function GET(request: Request) {
const denied = await requireAdmin(request);
if (denied) return denied;
return json(await view((await readSettings()).tvs));
}
type IncomingTv = {
id?: string;
name?: string;
wake?: { enabled?: boolean; time?: string; days?: number[] };
music?: { enabled?: boolean; playlist?: string };
volume?: { enabled?: boolean; level?: number };
};
export async function PUT(request: Request) {
const denied = await requireAdmin(request, { mutating: true });
if (denied) return denied;
const body = await readJsonBody<{ tvs?: IncomingTv[] }>(request);
if (!body || !Array.isArray(body.tvs)) return json({ error: "Expected a list of TVs." }, 400);
if (body.tvs.length > MAX_TVS) return json({ error: `At most ${MAX_TVS} TVs.` }, 400);
const tvs: TvSetting[] = [];
for (const [position, entry] of body.tvs.entries()) {
const label = entry.name?.trim() || `TV ${position + 1}`;
const id = validTvId(entry.id);
if (!id) return json({ error: `${label}: unknown TV id; reload the page.` }, 400);
const time = String(entry.wake?.time ?? "");
if (entry.wake?.enabled && !/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) return json({ error: `${label}: pick a wake time.` }, 400);
const playlistText = String(entry.music?.playlist ?? "").trim();
const playlistId = playlistText ? validPlaylistId(playlistText) : "";
if (playlistText && !playlistId) return json({ error: `${label}: paste a YouTube Music playlist link (https://music.youtube.com/playlist?list=…).` }, 400);
if (entry.music?.enabled && !playlistId) return json({ error: `${label}: music is on but no playlist is set.` }, 400);
const days = Array.isArray(entry.wake?.days) ? entry.wake.days : ALL_DAYS;
if (entry.wake?.enabled && !days.length) return json({ error: `${label}: pick at least one day.` }, 400);
tvs.push({
id,
name: (entry.name ?? "").trim().slice(0, 40),
wake: { enabled: entry.wake?.enabled === true, time: time || "06:30", days },
music: { enabled: entry.music?.enabled === true, playlistId: playlistId || "" },
volume: { enabled: entry.volume?.enabled === true, level: Number(entry.volume?.level ?? 40) },
});
}
if (new Set(tvs.map((tv) => tv.id)).size !== tvs.length) return json({ error: "The same TV is listed twice." }, 400);
const saved = await saveSettings({ tvs }, `Saved TV settings (${tvs.length} TV(s))`);
console.log(`[admin] TV settings saved (${tvs.length} TV(s))`);
return json(await view(saved.tvs));
}
@@ -0,0 +1,47 @@
import { addCalendarDays, organizerDate, readCalendar } from "../../_lib/calendar-feed";
import { readSettings } from "../../_lib/store";
export const dynamic = "force-dynamic";
export async function GET() {
const { calendars } = await readSettings();
const feeds = calendars.filter((calendar) => calendar.enabled);
if (!feeds.length) {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
const now = Date.now();
const windowStart = now - 45 * 24 * 60 * 60 * 1_000;
const horizon = now + 60 * 24 * 60 * 60 * 1_000;
const today = organizerDate(new Date(now));
const windowStartDate = addCalendarDays(today, -45);
const horizonDate = addCalendarDays(today, 60);
const results = await Promise.allSettled(
feeds.map((feed, feedIndex) =>
readCalendar(feed.url, { feedIndex, tone: feed.tone, nameOverride: feed.name }, windowStart, horizon, windowStartDate, horizonDate),
),
);
const successful = results.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof readCalendar>>> => result.status === "fulfilled");
const items = successful
.flatMap((result) => result.value.items)
.filter((item, index, all) => all.findIndex((candidate) => candidate.id === item.id) === index)
.sort((left, right) => {
const leftValue = left.allDay ? `${left.start}T00:00:00` : left.start;
const rightValue = right.allDay ? `${right.start}T00:00:00` : right.start;
return leftValue.localeCompare(rightValue);
})
.slice(0, 160);
return Response.json(
{
items,
calendarsAvailable: successful.length,
calendarsTotal: feeds.length,
updatedAt: new Date().toISOString(),
},
{
status: successful.length ? 200 : 503,
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=240" },
},
);
}
@@ -0,0 +1,17 @@
export const dynamic = "force-dynamic";
export async function GET() {
const configuredTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
let timeZone = "America/Chicago";
try {
new Intl.DateTimeFormat("en-US", { timeZone: configuredTimeZone }).format(new Date());
timeZone = configuredTimeZone;
} catch {
// Keep a valid default if a deployment typo reaches the process.
}
return Response.json(
{ timeZone },
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}
@@ -0,0 +1,126 @@
type FeedSource = {
name: string;
url: string;
};
type FeedItem = {
source: string;
title: string;
url: string;
publishedAt: string | null;
};
const feeds: FeedSource[] = [
{ name: "OPENAI", url: "https://openai.com/news/rss.xml" },
{ name: "GOOGLE AI", url: "https://blog.google/technology/ai/rss/" },
{ name: "DEEPMIND", url: "https://deepmind.google/blog/rss.xml" },
{ name: "NVIDIA", url: "https://nvidianews.nvidia.com/cats/generative_al.xml" },
{ name: "ARS TECHNICA", url: "https://feeds.arstechnica.com/arstechnica/technology-lab" },
{ name: "WIRED", url: "https://www.wired.com/feed/tag/ai/latest/rss" },
];
const maxFeedBytes = 1024 * 1024;
export const dynamic = "force-dynamic";
function decodeEntities(value: string) {
return value
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
.replace(/\s+/g, " ")
.trim();
}
function extract(block: string, names: string[]) {
for (const name of names) {
const match = block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, "i"));
if (match?.[1]) return decodeEntities(match[1]);
}
return "";
}
function extractLink(block: string) {
const atomLink = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)?.[1];
if (atomLink) return decodeEntities(atomLink);
return extract(block, ["link", "guid"]);
}
function parseFeed(xml: string, source: string): FeedItem[] {
const blocks = xml.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi)
?? xml.match(/<entry(?:\s[^>]*)?>[\s\S]*?<\/entry>/gi)
?? [];
return blocks.slice(0, 6).flatMap((block) => {
const title = extract(block, ["title"]);
const url = extractLink(block);
const rawDate = extract(block, ["pubDate", "published", "updated", "dc:date"]);
const parsedDate = rawDate ? new Date(rawDate) : null;
if (!title || !url) return [];
return [{
source,
title: title.slice(0, 180),
url,
publishedAt: parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : null,
}];
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Headline feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Headline feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
async function readFeed(feed: FeedSource) {
const response = await fetch(feed.url, {
headers: { Accept: "application/atom+xml, application/rss+xml, application/xml, text/xml" },
signal: AbortSignal.timeout(6_000),
});
if (!response.ok) throw new Error(`${feed.name} returned ${response.status}`);
return parseFeed(await readLimitedText(response), feed.name);
}
export async function GET() {
const results = await Promise.allSettled(feeds.map(readFeed));
const items = results
.flatMap((result) => result.status === "fulfilled" ? result.value : [])
.filter((item, index, all) => all.findIndex((candidate) => candidate.url === item.url || candidate.title === item.title) === index)
.sort((a, b) => (Date.parse(b.publishedAt ?? "") || 0) - (Date.parse(a.publishedAt ?? "") || 0))
.slice(0, 10);
return Response.json(
{
items,
updatedAt: new Date().toISOString(),
feedsAvailable: results.filter((result) => result.status === "fulfilled").length,
feedsTotal: feeds.length,
},
{
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=900",
},
},
);
}
@@ -0,0 +1,13 @@
import { currentSlideshow, staleAlbums } from "../../../_lib/photos";
export const dynamic = "force-dynamic";
/** 503 when any enabled album has not synced successfully for 48 hours. Probed by Prometheus. */
export async function GET() {
const stale = await staleAlbums();
const { photos } = await currentSlideshow();
return Response.json(
{ status: stale.length ? "stale" : "ok", photos: photos.length, staleAlbums: stale.map(({ title, lastSuccessAt, lastError }) => ({ title, lastSuccessAt, lastError })) },
{ status: stale.length ? 503 : 200, headers: { "Cache-Control": "no-store" } },
);
}
@@ -0,0 +1,27 @@
import { readAuth, readSettings } from "../../_lib/store";
export const dynamic = "force-dynamic";
export async function GET() {
const { calendars } = await readSettings();
let adminConfigured = false;
try {
adminConfigured = Boolean((await readAuth()).passwordHash);
} catch {
adminConfigured = false;
}
return Response.json(
{
status: "ok",
service: "hobbs-family-organizer",
version: "1.4.0",
calendarConfigurationValid: calendars.some((calendar) => calendar.enabled),
calendarsEnabled: calendars.filter((calendar) => calendar.enabled).length,
adminConfigured,
authenticationEnabled: Boolean(process.env.ORGANIZER_ACCESS_TOKEN),
checkedAt: new Date().toISOString(),
},
{ headers: { "Cache-Control": "no-store" } },
);
}
@@ -0,0 +1,134 @@
type Direction = "up" | "down" | "flat";
type FredSeries = {
id: string;
symbol: string;
name: string;
currency: boolean;
decimals: number;
multiplier?: number;
};
type Observation = {
date: string;
value: number;
};
const series: FredSeries[] = [
{ id: "SP500", symbol: "S&P 500", name: "Index", currency: false, decimals: 2 },
{ id: "NASDAQCOM", symbol: "NASDAQ", name: "Index", currency: false, decimals: 2 },
{ id: "DJIA", symbol: "DOW", name: "Index", currency: false, decimals: 2 },
{ id: "DCOILWTICO", symbol: "WTI", name: "USD / barrel", currency: true, decimals: 2 },
{ id: "DHHNGSP", symbol: "NAT GAS", name: "USD / MMBtu", currency: true, decimals: 2 },
{ id: "PCOPPUSDM", symbol: "COPPER", name: "USD / lb", currency: true, decimals: 2, multiplier: 1 / 2_204.62262185 },
];
const maxResponseBytes = 1024 * 1024;
const monthNumbers: Record<string, string> = {
Jan: "01",
Feb: "02",
Mar: "03",
Apr: "04",
May: "05",
Jun: "06",
Jul: "07",
Aug: "08",
Sep: "09",
Oct: "10",
Nov: "11",
Dec: "12",
};
export const dynamic = "force-dynamic";
function normalizeDate(value: string) {
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) return value;
const monthly = value.match(/^([A-Z][a-z]{2})\s+(\d{4})$/);
if (monthly && monthNumbers[monthly[1]]) return `${monthly[2]}-${monthNumbers[monthly[1]]}-01`;
return null;
}
function parseSeriesPage(html: string) {
const observations: Observation[] = [];
for (const row of html.match(/<tr(?:\s[^>]*)?>[\s\S]*?<\/tr>/gi) ?? []) {
const rawDate = row.match(/<td[^>]*>\s*([^<]+?):(?:&nbsp;|\s)*<\/td>/i)?.[1]?.trim();
const rawValue = row.match(/<td[^>]*class=["'][^"']*\bseries-obs\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i)?.[1];
if (!rawDate || !rawValue) continue;
const date = normalizeDate(rawDate);
const value = Number(rawValue.replace(/<[^>]+>/g, "").replace(/,/g, "").replace(/&minus;/g, "-").trim());
if (!date || !Number.isFinite(value)) continue;
observations.push({ date, value });
}
return observations.sort((left, right) => left.date.localeCompare(right.date));
}
function formatValue(value: number, config: FredSeries) {
const formatted = new Intl.NumberFormat("en-US", {
minimumFractionDigits: config.decimals,
maximumFractionDigits: config.decimals,
}).format(value);
return config.currency ? `$${formatted}` : formatted;
}
async function readSeries(config: FredSeries) {
const url = new URL(`/series/${encodeURIComponent(config.id)}`, "https://fred.stlouisfed.org");
const response = await fetch(url, {
headers: {
Accept: "text/html,application/xhtml+xml",
"User-Agent": "Dayline-TV/1.0 (+https://fred.stlouisfed.org)",
},
signal: AbortSignal.timeout(12_000),
});
if (!response.ok) throw new Error(`${config.id} returned ${response.status}`);
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
const html = await response.text();
if (new TextEncoder().encode(html).byteLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
const observations = parseSeriesPage(html);
if (observations.length < 2) throw new Error(`${config.id} has fewer than two observations`);
const previous = observations.at(-2)!;
const latest = observations.at(-1)!;
const multiplier = config.multiplier ?? 1;
const value = latest.value * multiplier;
const move = ((latest.value - previous.value) / previous.value) * 100;
const direction: Direction = move > 0.004 ? "up" : move < -0.004 ? "down" : "flat";
return {
symbol: config.symbol,
name: config.name,
value: formatValue(value, config),
move: `${move > 0 ? "+" : ""}${move.toFixed(2)}%`,
direction,
seriesId: config.id,
observedAt: latest.date,
};
}
export async function GET() {
const results: PromiseSettledResult<Awaited<ReturnType<typeof readSeries>>>[] = [];
for (let index = 0; index < series.length; index += 2) {
const batch = await Promise.allSettled(series.slice(index, index + 2).map(readSeries));
results.push(...batch);
}
const items = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
return Response.json(
{
items,
seriesAvailable: items.length,
seriesTotal: series.length,
updatedAt: new Date().toISOString(),
},
{
status: items.length ? 200 : 503,
headers: {
"Cache-Control": "public, max-age=900, stale-while-revalidate=21600",
"X-Content-Type-Options": "nosniff",
},
},
);
}
@@ -0,0 +1,17 @@
import { readFile } from "node:fs/promises";
import { photoFilePath, validPhotoDir, validPhotoFile } from "../../../../../_lib/photos";
export const dynamic = "force-dynamic";
export async function GET(_request: Request, { params }: { params: Promise<{ dir: string; file: string }> }) {
const { dir, file } = await params;
if (!validPhotoDir(dir) || !validPhotoFile(file)) return new Response("Not found", { status: 404 });
try {
const data = await readFile(photoFilePath(dir, file));
return new Response(new Uint8Array(data), {
headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=31536000, immutable", "Content-Length": String(data.byteLength) },
});
} catch {
return new Response("Not found", { status: 404 });
}
}
@@ -0,0 +1,7 @@
import { currentSlideshow } from "../../_lib/photos";
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json(await currentSlideshow(), { headers: { "Cache-Control": "no-store" } });
}
@@ -0,0 +1,26 @@
import { clientIp, isAdminNetworkAllowed, json } from "../../../_lib/auth";
import { readSettings, validTvId } from "../../../_lib/store";
import { recordTvCheckIn, tvConfig } from "../../../_lib/tvs";
export const dynamic = "force-dynamic";
/**
* Called by the Shield photo app every few minutes:
* GET /api/tv/config?device=<android id>&name=<device name>&model=<model>&app=<app version>&applied=<config version>
* Same network rule as admin: direct LAN only, never through a proxy.
*/
export async function GET(request: Request) {
if (!isAdminNetworkAllowed(request.headers)) return new Response("Not found", { status: 404 });
const params = new URL(request.url).searchParams;
const id = validTvId(params.get("device"));
if (!id) return json({ error: "Missing or invalid device id." }, 400);
const settings = await readSettings();
const setting = settings.tvs.find((tv) => tv.id === id);
await recordTvCheckIn(
id,
{ deviceName: params.get("name"), model: params.get("model"), appVersion: params.get("app"), appliedVersion: params.get("applied"), ip: clientIp(request.headers) },
undefined,
new Set(settings.tvs.map((tv) => tv.id)),
);
return json(tvConfig(setting));
}
@@ -0,0 +1,23 @@
import { clientIp, isAdminNetworkAllowed, json, readJsonBody } from "../../../_lib/auth";
import { readSettings, validTvId } from "../../../_lib/store";
import { recordTvCheckIn } from "../../../_lib/tvs";
export const dynamic = "force-dynamic";
type Report = { device?: string; kind?: string; ok?: boolean; detail?: string; applied?: string; app?: string };
/** The Shield photo app reports the outcome of its morning routine here (shown in admin). */
export async function POST(request: Request) {
if (!isAdminNetworkAllowed(request.headers)) return new Response("Not found", { status: 404 });
const body = await readJsonBody<Report>(request);
const id = validTvId(body?.device);
if (!body || !id) return json({ error: "Missing or invalid device id." }, 400);
const settings = await readSettings();
await recordTvCheckIn(
id,
{ appVersion: body.app, appliedVersion: body.applied, ip: clientIp(request.headers) },
{ kind: body.kind || "event", ok: body.ok === true, detail: body.detail || "" },
new Set(settings.tvs.map((tv) => tv.id)),
);
return json({ ok: true });
}
@@ -0,0 +1,183 @@
type WeatherIcon = "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm";
type NwsPeriod = {
number?: number;
startTime?: string;
isDaytime?: boolean;
temperature?: number;
temperatureUnit?: string;
shortForecast?: string;
probabilityOfPrecipitation?: { value?: number | null };
};
type NwsForecast = {
properties?: { periods?: NwsPeriod[] };
};
type DayAggregate = {
date: string;
high?: number;
low?: number;
temperatures: number[];
condition: string;
icon: WeatherIcon;
precipitationChance: number;
};
function numericSetting(name: string, fallback: number, minimum: number, maximum: number) {
const value = Number(process.env[name] ?? fallback);
return Number.isFinite(value) && value >= minimum && value <= maximum ? value : fallback;
}
const latitude = numericSetting("WEATHER_LATITUDE", 35.9987, -90, 90);
const longitude = numericSetting("WEATHER_LONGITUDE", -96.11417, -180, 180);
const location = (process.env.WEATHER_LOCATION || "Sapulpa, OK").trim().slice(0, 100);
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
const userAgent = (process.env.WEATHER_USER_AGENT || "Hobbs-Family-Organizer/1.0 (self-hosted)").trim().slice(0, 200);
function configuredForecastUrl() {
const value = new URL(process.env.NWS_FORECAST_URL || "https://api.weather.gov/gridpoints/TSA/37,98/forecast");
if (value.protocol !== "https:" || value.hostname !== "api.weather.gov") {
throw new Error("NWS forecast URL must use https://api.weather.gov");
}
return value;
}
export const dynamic = "force-dynamic";
function weatherIcon(condition: string): WeatherIcon {
const value = condition.toLowerCase();
if (value.includes("thunder")) return "storm";
if (value.includes("snow") || value.includes("sleet") || value.includes("ice")) return "snow";
if (value.includes("rain") || value.includes("shower") || value.includes("drizzle")) return "rain";
if (value.includes("fog") || value.includes("haze")) return "fog";
if (value.includes("partly") || value.includes("mostly sunny") || value.includes("mostly clear")) return "partly-cloudy";
if (value.includes("cloud") || value.includes("overcast")) return "cloudy";
return "clear";
}
function fahrenheit(value: number, unit?: string) {
return unit?.toUpperCase() === "C" ? (value * 9) / 5 + 32 : value;
}
function normalizeDegrees(value: number) {
return ((value % 360) + 360) % 360;
}
function normalizeHours(value: number) {
return ((value % 24) + 24) % 24;
}
function dayOfYear(dateKey: string) {
const [year, month, day] = dateKey.split("-").map(Number);
const current = Date.UTC(year, month - 1, day);
return Math.floor((current - Date.UTC(year, 0, 0)) / 86_400_000);
}
function solarTime(dateKey: string, sunrise: boolean) {
const radians = (degrees: number) => degrees * Math.PI / 180;
const degrees = (value: number) => value * 180 / Math.PI;
const longitudeHour = longitude / 15;
const approximateTime = dayOfYear(dateKey) + (((sunrise ? 6 : 18) - longitudeHour) / 24);
const meanAnomaly = (0.9856 * approximateTime) - 3.289;
const trueLongitude = normalizeDegrees(
meanAnomaly + (1.916 * Math.sin(radians(meanAnomaly))) + (0.02 * Math.sin(radians(2 * meanAnomaly))) + 282.634,
);
let rightAscension = normalizeDegrees(degrees(Math.atan(0.91764 * Math.tan(radians(trueLongitude)))));
rightAscension += Math.floor(trueLongitude / 90) * 90 - Math.floor(rightAscension / 90) * 90;
rightAscension /= 15;
const sinDeclination = 0.39782 * Math.sin(radians(trueLongitude));
const cosDeclination = Math.cos(Math.asin(sinDeclination));
const cosHour = (
Math.cos(radians(90.833)) - (sinDeclination * Math.sin(radians(latitude)))
) / (cosDeclination * Math.cos(radians(latitude)));
if (cosHour > 1 || cosHour < -1) return "";
const hourAngle = (sunrise ? 360 - degrees(Math.acos(cosHour)) : degrees(Math.acos(cosHour))) / 15;
const localMeanTime = hourAngle + rightAscension - (0.06571 * approximateTime) - 6.622;
const utcHours = normalizeHours(localMeanTime - longitudeHour);
const [year, month, day] = dateKey.split("-").map(Number);
const instant = new Date(Date.UTC(year, month - 1, day, 0, Math.round(utcHours * 60)));
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(instant);
const part = (type: string) => parts.find((entry) => entry.type === type)?.value ?? "00";
return `${dateKey}T${part("hour")}:${part("minute")}`;
}
function dailyForecast(periods: NwsPeriod[]) {
const days = new Map<string, DayAggregate>();
for (const period of periods) {
if (!period.startTime || typeof period.temperature !== "number") continue;
const date = period.startTime.slice(0, 10);
const temperature = fahrenheit(period.temperature, period.temperatureUnit);
const condition = period.shortForecast?.trim() || "Clear";
const existing = days.get(date) ?? {
date,
temperatures: [],
condition,
icon: weatherIcon(condition),
precipitationChance: 0,
};
existing.temperatures.push(temperature);
if (period.isDaytime) {
existing.high = temperature;
existing.condition = condition;
existing.icon = weatherIcon(condition);
} else {
existing.low = temperature;
}
existing.precipitationChance = Math.max(
existing.precipitationChance,
Number(period.probabilityOfPrecipitation?.value) || 0,
);
days.set(date, existing);
}
return [...days.values()].slice(0, 7).map((day) => ({
date: day.date,
code: 0,
condition: day.condition,
icon: day.icon,
high: day.high ?? Math.max(...day.temperatures),
low: day.low ?? Math.min(...day.temperatures),
sunrise: solarTime(day.date, true),
sunset: solarTime(day.date, false),
precipitationChance: day.precipitationChance,
}));
}
export async function GET() {
try {
const response = await fetch(configuredForecastUrl(), {
headers: {
Accept: "application/geo+json",
"User-Agent": userAgent,
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) throw new Error(`NWS returned ${response.status}`);
const payload = await response.json() as NwsForecast;
const items = dailyForecast(payload.properties?.periods ?? []);
if (items.length < 7) throw new Error("NWS forecast did not include seven days");
return Response.json(
{ location, latitude, longitude, source: "National Weather Service", items, updatedAt: new Date().toISOString() },
{ headers: { "Cache-Control": "public, max-age=900, stale-while-revalidate=1800" } },
);
} catch (error) {
console.error("Weather refresh failed", error instanceof Error ? error.message : "Unknown error");
return Response.json(
{ location, items: [], updatedAt: new Date().toISOString() },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
}
@@ -0,0 +1,765 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../vendor/shadcn-tailwind-4.13.0.css";
:root {
color-scheme: dark;
--background: #070b12;
--foreground: #f8fafc;
--card: #0c121d;
--card-foreground: #f8fafc;
--popover: #0c121d;
--popover-foreground: #f8fafc;
--primary: #a5f3fc;
--primary-foreground: #071018;
--secondary: #121a28;
--secondary-foreground: #e2e8f0;
--muted: #121a28;
--muted-foreground: #64748b;
--accent: #162132;
--accent-foreground: #f8fafc;
--destructive: #fb7185;
--border: #ffffff12;
--input: #ffffff16;
--ring: #67e8f9;
--radius: 1rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 6px);
--radius-md: calc(var(--radius) - 3px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 6px);
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html,
body {
min-height: 100%;
margin: 0;
background: #070b12;
}
body {
overflow: hidden;
color: var(--foreground);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.dashboard-shell {
position: relative;
display: flex;
min-height: 100vh;
height: 100vh;
flex-direction: column;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 24% 0%, rgb(34 211 238 / 7%), transparent 34%),
radial-gradient(circle at 82% 18%, rgb(167 139 250 / 7%), transparent 30%),
linear-gradient(145deg, #080d16 0%, #060a11 48%, #080c14 100%);
}
.dashboard-shell::before {
content: "";
position: absolute;
inset: 0;
z-index: -2;
opacity: 0.24;
background-image:
linear-gradient(rgb(255 255 255 / 2.4%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2.4%) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: linear-gradient(to bottom, black, transparent 72%);
}
.ambient {
position: absolute;
z-index: -1;
width: 34vw;
height: 34vw;
border-radius: 999px;
filter: blur(120px);
opacity: 0.08;
animation: ambient-drift 22s ease-in-out infinite alternate;
}
.ambient-one { left: -14vw; top: 22vh; background: #22d3ee; }
.ambient-two { right: -12vw; top: 10vh; background: #8b5cf6; animation-delay: -9s; }
.brand-mark {
display: grid;
grid-template-columns: repeat(3, 7px);
align-items: end;
gap: 4px;
width: 43px;
height: 43px;
padding: 9px;
border: 1px solid rgb(103 232 249 / 22%);
border-radius: 13px;
background: linear-gradient(145deg, rgb(103 232 249 / 13%), rgb(103 232 249 / 3%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%), 0 10px 30px rgb(0 0 0 / 18%);
}
.brand-mark span { display: block; border-radius: 99px; background: linear-gradient(to top, #22d3ee, #a5f3fc); }
.brand-mark span:nth-child(1) { height: 46%; }
.brand-mark span:nth-child(2) { height: 100%; }
.brand-mark span:nth-child(3) { height: 72%; }
.panel {
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 22px;
padding: 20px;
background: linear-gradient(155deg, rgb(17 25 39 / 88%), rgb(9 15 25 / 86%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 24px 70px rgb(0 0 0 / 16%);
backdrop-filter: blur(20px);
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.panel-heading h2 { font-size: 1.1rem; font-weight: 800; letter-spacing: -0.02em; color: #f8fafc; }
.source-pill {
display: flex;
align-items: center;
gap: 7px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 999px;
padding: 6px 10px;
color: #94a3b8;
background: rgb(255 255 255 / 3%);
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.source-demo { border-color: rgb(251 191 36 / 15%); color: #fde68a; background: rgb(251 191 36 / 5%); }
.source-live {
border-color: rgb(52 211 153 / 18%);
color: #a7f3d0;
background: rgb(52 211 153 / 6%);
}
.source-loading { border-color: rgb(103 232 249 / 15%); color: #a5f3fc; background: rgb(103 232 249 / 5%); }
.source-partial,
.source-stale,
.source-error {
border-color: rgb(251 191 36 / 18%);
color: #fde68a;
background: rgb(251 191 36 / 6%);
}
.weather-strip {
display: grid;
min-height: 112px;
grid-template-columns: minmax(190px, 0.9fr) minmax(0, 5.1fr);
gap: 9px;
margin: 14px 24px 0;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 20px;
padding: 9px;
background: linear-gradient(105deg, rgb(34 211 238 / 6%), rgb(9 15 25 / 86%) 42%, rgb(251 191 36 / 4%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 18px 50px rgb(0 0 0 / 12%);
backdrop-filter: blur(18px);
}
.weather-summary {
min-width: 0;
border: 1px solid rgb(103 232 249 / 12%);
border-radius: 14px;
padding: 10px 12px;
background: rgb(103 232 249 / 4%);
}
.weather-summary-top,
.forecast-day-heading,
.weather-location,
.weather-now,
.forecast-condition,
.sun-times,
.sun-times span {
display: flex;
align-items: center;
}
.weather-summary-top,
.forecast-day-heading,
.sun-times { justify-content: space-between; }
.weather-location {
min-width: 0;
gap: 5px;
color: #cbd5e1;
font-size: 0.64rem;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.weather-location span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.weather-status {
flex: none;
color: #6ee7b7;
font-size: 0.54rem;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.weather-loading,
.weather-stale,
.weather-error { color: #fcd34d; }
.weather-now { gap: 10px; margin-top: 8px; }
.weather-now-icon { width: 38px; height: 38px; flex: none; color: #fde68a; stroke-width: 1.65; }
.weather-now-temp { display: flex; align-items: baseline; gap: 7px; line-height: 1; }
.weather-now-temp span { color: #fff; font-size: 1.65rem; font-weight: 300; letter-spacing: -0.06em; }
.weather-now-temp small { color: #64748b; font-size: 0.9rem; font-weight: 650; }
.weather-now p { overflow: hidden; margin-top: 4px; color: #94a3b8; font-size: 0.67rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.weather-pending { opacity: 0.72; }
.forecast-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 5px;
}
.forecast-day {
min-width: 0;
border: 1px solid rgb(255 255 255 / 7%);
border-radius: 12px;
padding: 7px 8px;
background: rgb(255 255 255 / 2.2%);
}
.forecast-today {
border-color: rgb(103 232 249 / 24%);
background: rgb(103 232 249 / 5.5%);
}
.forecast-day-heading span { color: #94a3b8; font-size: 0.59rem; font-weight: 900; letter-spacing: 0.09em; }
.forecast-today .forecast-day-heading span { color: #a5f3fc; }
.forecast-day-heading small { color: #475569; font-size: 0.54rem; font-weight: 700; }
.forecast-condition { justify-content: center; gap: 8px; margin-top: 5px; }
.forecast-icon { width: 25px; height: 25px; flex: none; color: #fde68a; stroke-width: 1.6; }
.forecast-condition > span { display: flex; align-items: baseline; gap: 4px; }
.forecast-condition strong { color: #f8fafc; font-size: 1rem; font-weight: 650; }
.forecast-condition small { color: #64748b; font-size: 0.68rem; font-weight: 650; }
.forecast-rain { margin-top: 3px; color: #64748b; font-size: 0.49rem; font-weight: 800; letter-spacing: 0.08em; text-align: center; }
.sun-times { gap: 4px; margin-top: 5px; color: #64748b; font-size: 0.51rem; font-weight: 650; }
.sun-times span { min-width: 0; gap: 2px; }
.sun-times svg { width: 10px; height: 10px; flex: none; color: #fcd34d; }
.forecast-placeholder { display: grid; align-content: center; gap: 8px; opacity: 0.45; }
.forecast-placeholder span { height: 7px; border-radius: 999px; background: rgb(148 163 184 / 12%); }
.forecast-placeholder span:nth-child(2) { width: 70%; }
.forecast-placeholder span:nth-child(3) { width: 48%; }
.calendar-heading-actions,
.calendar-view-tabs,
.calendar-period-bar {
display: flex;
align-items: center;
}
.calendar-heading-actions { gap: 10px; }
.calendar-view-tabs {
gap: 2px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 999px;
padding: 2px;
background: rgb(255 255 255 / 3%);
}
.calendar-view-tabs button {
border: 0;
border-radius: 999px;
padding: 5px 10px;
color: #64748b;
background: transparent;
font-size: 0.62rem;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.calendar-view-tabs button.is-active {
color: #cffafe;
background: rgb(34 211 238 / 13%);
box-shadow: inset 0 0 0 1px rgb(103 232 249 / 18%);
}
.calendar-view-tabs button:focus-visible,
.calendar-period-bar button:focus-visible,
.week-event:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgb(103 232 249 / 60%), 0 0 0 5px rgb(34 211 238 / 12%);
}
.calendar-view-content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.calendar-period-bar {
min-height: 34px;
justify-content: flex-end;
gap: 6px;
margin-bottom: 8px;
}
.calendar-period-bar p {
min-width: 150px;
margin-right: auto;
color: #cbd5e1;
font-size: 0.77rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.calendar-period-bar button {
display: grid;
width: 30px;
height: 28px;
place-items: center;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 8px;
color: #94a3b8;
background: rgb(255 255 255 / 3%);
}
.calendar-period-bar button svg { width: 15px; height: 15px; }
.calendar-period-bar .calendar-today-button { width: auto; padding: 0 10px; font-size: 0.58rem; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; }
.week-grid {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 5px;
}
.week-day {
min-width: 0;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 7%);
border-radius: 12px;
padding: 7px;
background: rgb(255 255 255 / 2%);
}
.week-day.is-today { border-color: rgb(103 232 249 / 26%); background: rgb(103 232 249 / 4.5%); }
.week-day-heading { display: flex; align-items: baseline; justify-content: space-between; border-bottom: 1px solid rgb(255 255 255 / 6%); padding-bottom: 6px; }
.week-day-heading span { color: #64748b; font-size: 0.57rem; font-weight: 900; letter-spacing: 0.08em; }
.week-day-heading strong { color: #cbd5e1; font-size: 0.92rem; font-weight: 500; }
.week-day.is-today .week-day-heading span,
.week-day.is-today .week-day-heading strong { color: #a5f3fc; }
.week-events { display: grid; gap: 5px; margin-top: 7px; }
.week-event { min-width: 0; border: 1px solid rgb(255 255 255 / 6%); border-left: 3px solid; border-radius: 7px; padding: 6px; background: rgb(2 6 13 / 32%); }
.week-event strong,
.week-event span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.week-event strong { color: #e2e8f0; font-size: 0.65rem; font-weight: 750; }
.week-event span { margin-top: 2px; color: #64748b; font-size: 0.55rem; font-weight: 650; }
.calendar-no-events { display: block; padding-top: 8px; color: #334155; font-size: 0.78rem; text-align: center; }
.calendar-more { color: #67e8f9; font-size: 0.54rem; font-weight: 800; }
.month-grid {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: auto repeat(6, minmax(0, 1fr));
gap: 3px;
}
.month-weekday { padding: 0 5px 3px; color: #475569; font-size: 0.55rem; font-weight: 900; letter-spacing: 0.1em; text-align: right; }
.month-cell { min-width: 0; overflow: hidden; border: 1px solid rgb(255 255 255 / 6%); border-radius: 8px; padding: 4px 5px; background: rgb(255 255 255 / 1.8%); }
.month-cell.is-outside { opacity: 0.34; }
.month-cell.is-today { border-color: rgb(103 232 249 / 45%); background: rgb(103 232 249 / 6%); box-shadow: inset 0 0 0 1px rgb(103 232 249 / 10%); }
.month-date { display: block; color: #94a3b8; font-size: 0.61rem; font-weight: 750; line-height: 1; text-align: right; }
.month-cell.is-today .month-date { color: #cffafe; }
.month-events { display: grid; gap: 2px; margin-top: 3px; }
.month-event { display: flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; color: #cbd5e1; font-size: 0.53rem; font-weight: 650; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
.month-event span { width: 5px; height: 5px; flex: none; border-radius: 999px; }
.next-event-card {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 128px;
border: 1px solid rgb(103 232 249 / 14%);
border-radius: 18px;
padding: 19px 22px;
background: linear-gradient(100deg, rgb(34 211 238 / 10%), transparent 58%), rgb(255 255 255 / 2%);
}
.eyebrow { font-size: 0.66rem; font-weight: 900; letter-spacing: 0.18em; color: #64748b; }
.calendar-row,
.headline-row,
.ticker-cell {
outline: none;
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
}
.calendar-row {
display: flex;
min-height: 72px;
align-items: center;
gap: 14px;
border: 1px solid transparent;
border-radius: 15px;
padding: 8px 13px;
background: rgb(255 255 255 / 2.3%);
}
.calendar-accent {
width: 4px;
height: 38px;
flex: none;
border-radius: 999px;
}
.calendar-detail {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
margin-top: 2px;
color: #64748b;
font-size: 0.78rem;
}
.calendar-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 999px;
}
.calendar-empty {
display: grid;
min-height: 210px;
place-items: center;
border: 1px dashed rgb(255 255 255 / 10%);
border-radius: 16px;
color: #64748b;
text-align: center;
}
.calendar-row:focus-visible,
.headline-row:focus-visible,
.ticker-cell:focus-visible {
border-color: rgb(103 232 249 / 65%);
background: rgb(103 232 249 / 7%);
box-shadow: 0 0 0 3px rgb(34 211 238 / 12%);
}
.calendar-row:focus-visible { transform: translateX(4px); }
.headline-row {
display: flex;
align-items: flex-start;
gap: 13px;
border: 1px solid transparent;
border-radius: 12px;
padding: 13px 10px;
cursor: pointer;
}
.headline-row:disabled { cursor: default; }
.headline-qr-cue {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid rgb(196 181 253 / 16%);
border-radius: 999px;
padding: 2px 6px;
color: #c4b5fd;
background: rgb(196 181 253 / 5%);
font-size: 0.54rem;
letter-spacing: 0.1em;
}
.headline-row:focus-visible { transform: translateX(-3px); }
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #34d399;
box-shadow: 0 0 0 0 rgb(52 211 153 / 45%);
animation: pulse-ring 2.4s ease-out infinite;
}
.market-pulse-loading,
.market-pulse-demo,
.market-pulse-stale,
.market-pulse-partial {
background: #fbbf24;
box-shadow: 0 0 0 0 rgb(251 191 36 / 45%);
}
.ticker-window {
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, black 3.5%, black 96.5%, transparent);
}
.ticker-track { display: flex; width: max-content; animation: ticker-scroll 38s linear infinite; }
.ticker-cell {
width: 290px;
flex: none;
border: 1px solid transparent;
border-right-color: rgb(255 255 255 / 8%);
padding: 4px 24px;
}
.qr-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: rgb(2 6 13 / 88%);
backdrop-filter: blur(18px);
}
.qr-dialog {
position: relative;
display: grid;
width: min(820px, calc(100vw - 48px));
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 30px;
border: 1px solid rgb(196 181 253 / 24%);
border-radius: 28px;
padding: 26px;
background:
radial-gradient(circle at 12% 16%, rgb(34 211 238 / 10%), transparent 38%),
linear-gradient(145deg, #111a2a, #090f1a);
box-shadow: 0 36px 120px rgb(0 0 0 / 60%), inset 0 1px 0 rgb(255 255 255 / 7%);
}
.qr-code-frame {
display: grid;
place-items: center;
border-radius: 20px;
padding: 17px;
background: #fff;
box-shadow: 0 18px 55px rgb(34 211 238 / 14%);
}
.qr-code-frame svg { display: block; width: 264px; height: 264px; }
.qr-copy { min-width: 0; padding-right: 8px; }
.qr-source {
display: flex;
align-items: center;
gap: 8px;
color: #c4b5fd;
font-size: 0.66rem;
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.qr-source span { color: #475569; }
.qr-copy h2 {
margin: 12px 0 10px;
color: #fff;
font-size: 1.65rem;
font-weight: 750;
line-height: 1.16;
letter-spacing: -0.035em;
}
.qr-copy p { margin: 0; color: #94a3b8; font-size: 0.92rem; line-height: 1.55; }
.qr-close {
position: absolute;
top: 14px;
right: 14px;
display: flex;
align-items: center;
gap: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 999px;
padding: 7px 11px;
color: #cbd5e1;
background: rgb(255 255 255 / 5%);
font-size: 0.7rem;
font-weight: 800;
}
.qr-close:focus-visible {
outline: none;
border-color: rgb(103 232 249 / 70%);
box-shadow: 0 0 0 4px rgb(34 211 238 / 14%);
color: #fff;
}
@keyframes ticker-scroll { to { transform: translateX(-50%); } }
@keyframes pulse-ring { 70% { box-shadow: 0 0 0 8px rgb(52 211 153 / 0%); } 100% { box-shadow: 0 0 0 0 rgb(52 211 153 / 0%); } }
@keyframes ambient-drift { from { transform: translate3d(0, -2%, 0) scale(0.96); } to { transform: translate3d(5%, 5%, 0) scale(1.07); } }
@media (max-width: 1280px) {
.dashboard-shell { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 60px 86px minmax(0, 1fr) 56px; }
.dashboard-header { min-height: 60px; padding: 7px 14px; }
.brand-group { gap: 11px; }
.brand-mark { width: 35px; height: 35px; padding: 7px; border-radius: 11px; }
.weather-strip { min-height: 0; margin: 5px 11px 0; gap: 5px; border-radius: 14px; padding: 5px; }
.weather-summary { border-radius: 10px; padding: 5px 8px; }
.weather-location { gap: 3px; font-size: 0.51rem; }
.weather-location svg { width: 10px; height: 10px; }
.weather-status { font-size: 0.45rem; }
.weather-now { gap: 6px; margin-top: 4px; }
.weather-now-icon { width: 27px; height: 27px; }
.weather-now-temp { gap: 4px; }
.weather-now-temp span { font-size: 1.15rem; }
.weather-now-temp small { font-size: 0.66rem; }
.weather-now p { margin-top: 2px; font-size: 0.52rem; }
.forecast-grid { gap: 4px; }
.forecast-day { border-radius: 9px; padding: 3px 5px; }
.forecast-day-heading span { font-size: 0.48rem; }
.forecast-day-heading small { font-size: 0.44rem; }
.forecast-condition { gap: 4px; margin-top: 2px; }
.forecast-icon { width: 17px; height: 17px; }
.forecast-condition > span { gap: 3px; }
.forecast-condition strong { font-size: 0.76rem; }
.forecast-condition small { font-size: 0.53rem; }
.forecast-rain { margin-top: 1px; font-size: 0.4rem; }
.sun-times { gap: 2px; margin-top: 2px; font-size: 0.43rem; }
.sun-times svg { width: 8px; height: 8px; }
.dashboard-grid { grid-template-columns: minmax(0, 1.6fr) minmax(310px, 0.9fr); gap: 8px; padding: 7px 11px; }
.panel { padding: 10px; border-radius: 16px; }
.panel-heading { margin-bottom: 7px; }
.panel-heading h2 { font-size: 0.96rem; }
.source-pill { gap: 5px; padding: 4px 8px; font-size: 0.57rem; }
.calendar-heading-actions { gap: 6px; }
.calendar-view-tabs button { padding: 4px 7px; font-size: 0.51rem; }
.calendar-period-bar { min-height: 25px; gap: 4px; margin-bottom: 5px; }
.calendar-period-bar p { min-width: 120px; font-size: 0.59rem; }
.calendar-period-bar button { width: 25px; height: 23px; border-radius: 6px; }
.calendar-period-bar button svg { width: 13px; height: 13px; }
.calendar-period-bar .calendar-today-button { padding-inline: 7px; font-size: 0.48rem; }
.next-event-card { min-height: 66px; padding: 7px 11px; border-radius: 13px; }
.next-event-title { margin-top: 3px; font-size: 1.22rem; }
.next-event-meta { margin-top: 1px; font-size: 0.73rem; }
.next-event-countdown { padding-left: 12px; }
.next-event-countdown > p:first-child { font-size: 1.45rem; }
.next-event-countdown > p:last-child { margin-top: 0; font-size: 0.56rem; }
.eyebrow { font-size: 0.55rem; }
.event-list { margin-top: 7px; }
.event-list-heading { margin-bottom: 4px; }
.event-list-heading > p:last-child { font-size: 0.62rem; }
.event-list-grid { gap: 4px; }
.calendar-row { min-height: 43px; gap: 7px; border-radius: 10px; padding: 3px 7px; }
.calendar-date { width: 53px; padding-right: 7px; }
.calendar-day { font-size: 0.51rem; }
.calendar-date-number { margin-top: 0; font-size: 1.12rem; line-height: 1.05; }
.calendar-accent { width: 3px; height: 25px; }
.calendar-event-title { font-size: 0.83rem; }
.calendar-event-time { font-size: 0.69rem; }
.calendar-detail { gap: 4px; margin-top: 0; font-size: 0.62rem; }
.calendar-dot { width: 5px; height: 5px; }
.week-grid { gap: 4px; }
.week-day { border-radius: 9px; padding: 5px; }
.week-day-heading { padding-bottom: 4px; }
.week-day-heading span { font-size: 0.47rem; }
.week-day-heading strong { font-size: 0.72rem; }
.week-events { gap: 4px; margin-top: 5px; }
.week-event { border-radius: 6px; padding: 4px; }
.week-event strong { font-size: 0.55rem; }
.week-event span { margin-top: 1px; font-size: 0.47rem; }
.calendar-more { font-size: 0.47rem; }
.month-grid { gap: 2px; }
.month-weekday { padding: 0 3px 2px; font-size: 0.45rem; }
.month-cell { border-radius: 6px; padding: 3px 4px; }
.month-date { font-size: 0.5rem; }
.month-events { gap: 1px; margin-top: 2px; }
.month-event { gap: 3px; font-size: 0.44rem; }
.month-event span { width: 4px; height: 4px; }
.headline-row { gap: 8px; padding: 5px 6px; border-radius: 9px; }
.headline-row > div:first-child { width: 7px; height: 7px; }
.headline-row h3 { margin-top: 3px; font-size: 0.79rem; line-height: 1.2; }
.headline-row:first-child h3 { font-size: 0.87rem; }
.headline-qr-cue { padding: 1px 5px; font-size: 0.48rem; }
.phone-handoff { margin-top: 6px; padding: 7px 10px; border-radius: 12px; }
.phone-handoff p:first-child { font-size: 0.76rem; }
.phone-handoff p:last-child { font-size: 0.64rem; }
.dashboard-footer { overflow: hidden; padding-block: 2px; }
.footer-meta { min-height: 14px; margin-bottom: 0; padding-inline: 14px; line-height: 1; }
.ticker-cell { display: flex; width: 224px; height: 36px; flex-direction: column; justify-content: center; padding: 0 16px; }
.ticker-cell > div:first-child { min-height: 11px; line-height: 1; }
.ticker-cell > div:nth-child(2) { min-height: 18px; margin-top: 1px; line-height: 1; }
.quote-symbol { font-size: 0.63rem; line-height: 1; }
.quote-name { font-size: 0.58rem; line-height: 1; }
.quote-value { font-size: 0.96rem; line-height: 1; }
.quote-move { font-size: 0.68rem; line-height: 1; }
.quote-move svg { width: 12px; height: 12px; }
.qr-dialog { width: min(720px, calc(100vw - 28px)); gap: 20px; border-radius: 20px; padding: 18px; }
.qr-code-frame { border-radius: 14px; padding: 12px; }
.qr-code-frame svg { width: 220px; height: 220px; }
.qr-copy h2 { margin-block: 8px; font-size: 1.22rem; }
.qr-copy p { font-size: 0.72rem; line-height: 1.42; }
.qr-source { gap: 6px; font-size: 0.56rem; }
.qr-close { top: 9px; right: 9px; padding: 5px 8px; font-size: 0.62rem; }
}
@media (max-width: 700px) {
body { overflow: auto; }
.dashboard-shell { display: flex; height: auto; min-height: 100vh; overflow: visible; }
.dashboard-shell > section { grid-template-columns: 1fr; }
.weather-strip { min-height: 208px; margin: 10px; }
.weather-summary { min-height: 70px; }
.forecast-grid { grid-template-columns: repeat(7, minmax(76px, 1fr)); overflow-x: auto; }
.panel { min-height: 560px; }
.qr-dialog { grid-template-columns: 1fr; text-align: center; }
.qr-code-frame { width: max-content; margin: 0 auto; }
.qr-copy { padding: 0; }
.qr-source { justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
.ticker-track,
.pulse-dot,
.ambient { animation: none; }
}
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Hobbs' Family Organizer App",
description: "A private Hobbs family organizer for calendars, weather, markets, commodities, and AI and technology headlines.",
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
);
}
@@ -0,0 +1,880 @@
"use client";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
Cloud,
CloudFog,
CloudLightning,
CloudRain,
CloudSun,
MapPin,
Minus,
Newspaper,
QrCode,
Snowflake,
Sparkles,
Sun,
Sunrise,
Sunset,
TrendingDown,
TrendingUp,
Wifi,
X,
} from "lucide-react";
import QRCode from "react-qr-code";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: "cyan" | "amber" | "violet" | "emerald" | "rose";
};
type CalendarState = "loading" | "live" | "partial" | "stale" | "error";
type CalendarView = "agenda" | "week" | "month";
type WeatherDay = {
date: string;
code: number;
condition: string;
icon: "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm";
high: number;
low: number;
sunrise: string;
sunset: string;
precipitationChance: number;
};
type WeatherState = "loading" | "live" | "stale" | "error";
type Headline = {
source: string;
age: string;
title: string;
tone: "cyan" | "amber" | "violet";
url?: string;
};
type Quote = {
symbol: string;
name: string;
value: string;
move: string;
direction: "up" | "down" | "flat";
seriesId?: string;
observedAt?: string;
};
type MarketState = "loading" | "live" | "partial" | "stale" | "demo";
const demoHeadlines: Headline[] = [
{ source: "OPENAI", age: "18m", title: "New tools push multimodal assistants deeper into everyday work", tone: "cyan" },
{ source: "ARS TECHNICA", age: "42m", title: "Chipmakers rethink the data-center roadmap for efficient inference", tone: "amber" },
{ source: "GOOGLE AI", age: "1h", title: "Research update explores smaller models with stronger reasoning", tone: "violet" },
{ source: "WIRED", age: "2h", title: "The next wave of personal AI devices is taking shape", tone: "cyan" },
{ source: "NVIDIA", age: "3h", title: "New accelerated-computing platform targets local AI workloads", tone: "amber" },
];
const demoQuotes: Quote[] = [
{ symbol: "S&P 500", name: "Previous close", value: "5,782.41", move: "+0.62%", direction: "up" },
{ symbol: "NASDAQ", name: "Previous close", value: "18,439.17", move: "+0.91%", direction: "up" },
{ symbol: "DOW", name: "Previous close", value: "42,114.40", move: "+0.38%", direction: "up" },
{ symbol: "WTI", name: "Crude oil", value: "$67.42", move: "-1.18%", direction: "down" },
{ symbol: "NAT GAS", name: "Henry Hub", value: "$3.12", move: "-0.54%", direction: "down" },
{ symbol: "COPPER", name: "Per pound", value: "$4.31", move: "+0.21%", direction: "up" },
];
const toneClasses = {
cyan: "bg-cyan-300",
amber: "bg-amber-300",
violet: "bg-violet-300",
emerald: "bg-emerald-300",
rose: "bg-rose-300",
};
const calendarToneColors = {
cyan: "#67e8f9",
amber: "#fcd34d",
violet: "#c4b5fd",
emerald: "#6ee7b7",
rose: "#fda4af",
};
const calendarViews: CalendarView[] = ["agenda", "week", "month"];
let organizerTimeZone = "America/Chicago";
const weatherIcons = {
clear: Sun,
"partly-cloudy": CloudSun,
cloudy: Cloud,
fog: CloudFog,
rain: CloudRain,
snow: Snowflake,
storm: CloudLightning,
};
function formatClock(date: Date | null) {
if (!date) return { time: "--:--", period: "", date: "Loading dashboard…" };
const clock = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "numeric",
minute: "2-digit",
}).formatToParts(date);
return {
time: `${clock.find((part) => part.type === "hour")?.value ?? ""}:${clock.find((part) => part.type === "minute")?.value ?? ""}`,
period: clock.find((part) => part.type === "dayPeriod")?.value ?? "",
date: new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
weekday: "long",
month: "long",
day: "numeric",
}).format(date),
};
}
function organizerDateKey(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
return `${value("year")}-${value("month")}-${value("day")}`;
}
function allDayDate(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day, 12));
}
function addDateKey(value: string, days: number) {
const date = allDayDate(value);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function addMonthsToDateKey(value: string, months: number) {
const [year, month] = value.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1 + months, 1, 12));
return date.toISOString().slice(0, 10);
}
function startOfWeekKey(value: string) {
const date = allDayDate(value);
const mondayOffset = (date.getUTCDay() + 6) % 7;
return addDateKey(value, -mondayOffset);
}
function weekDateKeys(value: string) {
const start = startOfWeekKey(value);
return Array.from({ length: 7 }, (_, index) => addDateKey(start, index));
}
function monthDateKeys(value: string) {
const first = `${value.slice(0, 7)}-01`;
const start = startOfWeekKey(first);
return Array.from({ length: 42 }, (_, index) => addDateKey(start, index));
}
function eventDateKey(event: CalendarEvent) {
return event.allDay ? event.start : organizerDateKey(new Date(event.start));
}
function eventOccursOnDate(event: CalendarEvent, dateKey: string) {
if (event.allDay) return event.start <= dateKey && event.end > dateKey;
const start = organizerDateKey(new Date(event.start));
const inclusiveEnd = organizerDateKey(new Date(Math.max(Date.parse(event.start), Date.parse(event.end) - 1)));
return start <= dateKey && inclusiveEnd >= dateKey;
}
function eventIsUpcoming(event: CalendarEvent, now: Date) {
if (event.allDay) return event.end > organizerDateKey(now);
return Date.parse(event.end) > now.getTime();
}
function formatDateKey(value: string, options: Intl.DateTimeFormatOptions) {
return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(allDayDate(value));
}
function compactEventTime(event: CalendarEvent, dateKey: string) {
if (event.allDay) return "All day";
if (eventDateKey(event) !== dateKey) return "Continues";
return new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "numeric",
minute: "2-digit",
}).format(new Date(event.start));
}
function formatSunTime(value: string) {
const time = value.split("T")[1]?.slice(0, 5);
if (!time) return "--";
const [hour, minute] = time.split(":").map(Number);
const displayHour = hour % 12 || 12;
return `${displayHour}:${String(minute).padStart(2, "0")}`;
}
function WeatherGlyph({ icon, className }: { icon: WeatherDay["icon"]; className?: string }) {
const Glyph = weatherIcons[icon] ?? CloudSun;
return <Glyph className={className} aria-hidden="true" />;
}
function formatEventRow(event: CalendarEvent, now: Date) {
const today = organizerDateKey(now);
const startDate = event.allDay ? allDayDate(event.start) : new Date(event.start);
const eventDate = event.allDay ? event.start : organizerDateKey(startDate);
const isToday = eventDate === today;
const timeZone = event.allDay ? "UTC" : organizerTimeZone;
return {
day: isToday ? "TODAY" : new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(startDate).toUpperCase(),
date: new Intl.DateTimeFormat("en-US", { timeZone, day: "2-digit" }).format(startDate),
time: event.allDay ? "ALL DAY" : new Intl.DateTimeFormat("en-US", { timeZone, hour: "numeric", minute: "2-digit" }).format(startDate),
};
}
function eventIsHappening(event: CalendarEvent, now: Date) {
if (event.allDay) {
const today = organizerDateKey(now);
return event.start <= today && event.end > today;
}
const time = now.getTime();
return Date.parse(event.start) <= time && Date.parse(event.end) > time;
}
function formatEventWhen(event: CalendarEvent, now: Date) {
const row = formatEventRow(event, now);
const startDate = event.allDay ? allDayDate(event.start) : new Date(event.start);
const timeZone = event.allDay ? "UTC" : organizerTimeZone;
const dateLabel = event.allDay && event.start === organizerDateKey(now)
? "Today"
: new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", month: "short", day: "numeric" }).format(startDate);
return `${dateLabel} · ${event.allDay ? "All day" : row.time} · ${event.calendar}`;
}
function formatCountdown(event: CalendarEvent, now: Date) {
if (eventIsHappening(event, now)) return "NOW";
if (event.allDay) {
const today = allDayDate(organizerDateKey(now)).getTime();
const start = allDayDate(event.start).getTime();
const days = Math.max(1, Math.round((start - today) / 86_400_000));
return `${days}d`;
}
const minutes = Math.max(1, Math.round((Date.parse(event.start) - now.getTime()) / 60_000));
if (minutes < 60) return `${minutes}m`;
if (minutes < 1_440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
return `${Math.floor(minutes / 1_440)}d ${Math.floor((minutes % 1_440) / 60)}h`;
}
function formatObservationDate(value?: string) {
if (!value) return "";
const date = new Date(`${value}T12:00:00Z`);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date);
}
function headlineHost(value?: string) {
if (!value) return "";
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return "Article link";
}
}
function QuoteCell({ quote }: { quote: Quote }) {
const rising = quote.direction === "up";
const falling = quote.direction === "down";
const TrendIcon = rising ? TrendingUp : falling ? TrendingDown : Minus;
const movementColor = rising ? "text-emerald-300" : falling ? "text-rose-300" : "text-slate-400";
const observed = formatObservationDate(quote.observedAt);
return (
<div className="ticker-cell" tabIndex={0}>
<div className="flex items-center gap-3">
<span className="quote-symbol text-[0.78rem] font-black tracking-[0.12em] text-slate-100">{quote.symbol}</span>
<span className="quote-name text-[0.76rem] text-slate-500">{quote.name}{observed && ` · ${observed}`}</span>
</div>
<div className="mt-1 flex items-center gap-3">
<span className="quote-value text-[1.35rem] font-semibold tabular-nums text-white">{quote.value}</span>
<span className={`quote-move flex items-center gap-1 text-[0.9rem] font-bold tabular-nums ${movementColor}`}>
<TrendIcon className="h-4 w-4" strokeWidth={2.5} />
{quote.move}
</span>
</div>
</div>
);
}
export default function Home() {
const [now, setNow] = useState<Date | null>(null);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [calendarState, setCalendarState] = useState<CalendarState>("loading");
const [calendarFeeds, setCalendarFeeds] = useState({ available: 0, total: 0 });
const [calendarView, setCalendarView] = useState<CalendarView>("agenda");
const [calendarAnchor, setCalendarAnchor] = useState("");
const [weatherDays, setWeatherDays] = useState<WeatherDay[]>([]);
const [weatherState, setWeatherState] = useState<WeatherState>("loading");
const [weatherLocation, setWeatherLocation] = useState("Sapulpa, OK");
const [headlines, setHeadlines] = useState<Headline[]>(demoHeadlines);
const [newsLive, setNewsLive] = useState(false);
const [quotes, setQuotes] = useState<Quote[]>(demoQuotes);
const [marketState, setMarketState] = useState<MarketState>("loading");
const [marketFeeds, setMarketFeeds] = useState({ available: 0, total: demoQuotes.length });
const [selectedHeadline, setSelectedHeadline] = useState<Headline | null>(null);
const hasCalendarData = useRef(false);
const hasWeatherData = useRef(false);
const hasMarketData = useRef(false);
const qrCloseRef = useRef<HTMLButtonElement>(null);
const [, setConfigurationVersion] = useState(0);
useEffect(() => {
let active = true;
void fetch("/api/config", { cache: "no-store" })
.then(async (response) => {
const payload = await response.json() as { timeZone?: string };
if (!response.ok || !active || !payload.timeZone) return;
new Intl.DateTimeFormat("en-US", { timeZone: payload.timeZone }).format(new Date());
organizerTimeZone = payload.timeZone;
const value = new Date();
setNow(value);
setCalendarAnchor(organizerDateKey(value));
setConfigurationVersion((current) => current + 1);
})
.catch(() => {
// Keep the built-in timezone when runtime configuration is unavailable.
});
return () => {
active = false;
};
}, []);
useEffect(() => {
const updateClock = () => {
const value = new Date();
setNow(value);
setCalendarAnchor((current) => current || organizerDateKey(value));
};
const frame = window.requestAnimationFrame(updateClock);
const timer = window.setInterval(updateClock, 15_000);
return () => {
window.cancelAnimationFrame(frame);
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshWeather = async () => {
try {
const response = await fetch("/api/weather", { cache: "no-store" });
const payload = await response.json() as { location?: string; items?: WeatherDay[] };
if (!response.ok || !payload.items?.length || !active) throw new Error("Weather unavailable");
hasWeatherData.current = true;
setWeatherDays(payload.items.slice(0, 7));
setWeatherLocation(payload.location || "Sapulpa, OK");
setWeatherState("live");
} catch {
if (!active) return;
setWeatherState(hasWeatherData.current ? "stale" : "error");
}
};
void refreshWeather();
const timer = window.setInterval(refreshWeather, 30 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
const closeQrFromHistory = () => setSelectedHeadline(null);
window.addEventListener("popstate", closeQrFromHistory);
return () => window.removeEventListener("popstate", closeQrFromHistory);
}, []);
useEffect(() => {
if (!selectedHeadline) return;
const frame = window.requestAnimationFrame(() => qrCloseRef.current?.focus());
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
const state = window.history.state as { daylineQr?: boolean } | null;
if (state?.daylineQr) window.history.back();
else setSelectedHeadline(null);
};
window.addEventListener("keydown", closeOnEscape);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("keydown", closeOnEscape);
};
}, [selectedHeadline]);
useEffect(() => {
let active = true;
const refreshMarkets = async () => {
try {
const response = await fetch("/api/markets", { cache: "no-store" });
const payload = await response.json() as {
items?: Quote[];
seriesAvailable?: number;
seriesTotal?: number;
};
if (!response.ok || !payload.items?.length || !active) throw new Error("FRED unavailable");
hasMarketData.current = true;
setQuotes(payload.items);
setMarketFeeds({
available: payload.seriesAvailable ?? payload.items.length,
total: payload.seriesTotal ?? payload.items.length,
});
setMarketState(
payload.seriesAvailable === payload.seriesTotal ? "live" : "partial",
);
} catch {
if (!active) return;
setMarketState(hasMarketData.current ? "stale" : "demo");
}
};
void refreshMarkets();
const timer = window.setInterval(refreshMarkets, 15 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshCalendar = async () => {
try {
const response = await fetch("/api/calendar", { cache: "no-store" });
const payload = await response.json() as {
items?: CalendarEvent[];
calendarsAvailable?: number;
calendarsTotal?: number;
};
if (!response.ok || !payload.items || !active) throw new Error("Calendar unavailable");
hasCalendarData.current = true;
setCalendarEvents(payload.items);
setCalendarFeeds({
available: payload.calendarsAvailable ?? 0,
total: payload.calendarsTotal ?? 0,
});
setCalendarState(
payload.calendarsAvailable === payload.calendarsTotal ? "live" : "partial",
);
} catch {
if (!active) return;
setCalendarState(hasCalendarData.current ? "stale" : "error");
}
};
void refreshCalendar();
const timer = window.setInterval(refreshCalendar, 5 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshHeadlines = async () => {
try {
const response = await fetch("/api/headlines", { cache: "no-store" });
const payload = await response.json() as {
items?: Array<{ source: string; title: string; url: string; publishedAt: string | null }>;
};
if (!response.ok || !payload.items?.length || !active) return;
const tones: Headline["tone"][] = ["cyan", "amber", "violet"];
setHeadlines(payload.items.slice(0, 5).map((item, index) => {
const published = item.publishedAt ? Date.parse(item.publishedAt) : Number.NaN;
const minutes = Number.isNaN(published) ? null : Math.max(1, Math.round((Date.now() - published) / 60_000));
const age = minutes === null ? "new" : minutes < 60 ? `${minutes}m` : minutes < 1_440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1_440)}d`;
return { ...item, age, tone: tones[index % tones.length] };
}));
setNewsLive(true);
} catch {
// The demo headlines remain visible whenever live RSS is unavailable.
}
};
void refreshHeadlines();
const timer = window.setInterval(refreshHeadlines, 10 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
const clock = useMemo(() => formatClock(now), [now]);
const calendarNow = useMemo(() => now ?? new Date(), [now]);
const todayKey = now ? organizerDateKey(now) : "";
const activeAnchor = calendarAnchor || todayKey || "2000-01-01";
const upcomingEvents = useMemo(
() => calendarEvents.filter((event) => eventIsUpcoming(event, calendarNow)),
[calendarEvents, calendarNow],
);
const nextEvent = upcomingEvents[0];
const weekKeys = useMemo(() => weekDateKeys(activeAnchor), [activeAnchor]);
const monthKeys = useMemo(() => monthDateKeys(activeAnchor), [activeAnchor]);
const visibleDateKeys = useMemo(
() => calendarView === "week" ? weekKeys : calendarView === "month" ? monthKeys : [],
[calendarView, monthKeys, weekKeys],
);
const eventsByDate = useMemo(() => {
const result = new Map<string, CalendarEvent[]>();
for (const dateKey of visibleDateKeys) {
result.set(dateKey, calendarEvents.filter((event) => eventOccursOnDate(event, dateKey)));
}
return result;
}, [calendarEvents, visibleDateKeys]);
const currentWeather = weatherDays[0];
const weatherStatus = weatherState === "loading"
? "Updating"
: weatherState === "live"
? "Live forecast"
: weatherState === "stale"
? "Last forecast"
: "Retrying";
const calendarStatus = calendarState === "loading"
? "Syncing"
: calendarState === "live"
? `${calendarFeeds.total} calendars live`
: calendarState === "partial"
? `${calendarFeeds.available}/${calendarFeeds.total} calendars`
: calendarState === "stale"
? "Last update"
: "Retrying";
const marketStatus = marketState === "loading"
? "FRED connecting"
: marketState === "live"
? `FRED live · ${marketFeeds.available}/${marketFeeds.total}`
: marketState === "partial"
? `FRED partial · ${marketFeeds.available}/${marketFeeds.total}`
: marketState === "stale"
? "FRED last update"
: "Demo data · FRED unavailable";
const periodLabel = calendarView === "week"
? `${formatDateKey(weekKeys[0], { month: "short", day: "numeric" })} – ${formatDateKey(weekKeys[6], { month: "short", day: "numeric" })}`
: formatDateKey(`${activeAnchor.slice(0, 7)}-01`, { month: "long", year: "numeric" });
const moveCalendarPeriod = (direction: -1 | 1) => {
setCalendarAnchor((current) => {
const base = current || todayKey || activeAnchor;
return calendarView === "week" ? addDateKey(base, direction * 7) : addMonthsToDateKey(base, direction);
});
};
const handleCalendarTabKey = (event: ReactKeyboardEvent<HTMLButtonElement>, view: CalendarView) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const currentIndex = calendarViews.indexOf(view);
const offset = event.key === "ArrowRight" ? 1 : -1;
const nextView = calendarViews[(currentIndex + offset + calendarViews.length) % calendarViews.length];
setCalendarView(nextView);
window.requestAnimationFrame(() => document.getElementById(`calendar-tab-${nextView}`)?.focus());
};
const showHeadlineQr = (headline: Headline) => {
if (!headline.url) return;
const current = window.history.state;
const base = current && typeof current === "object" ? current : {};
window.history.pushState({ ...base, daylineQr: true }, "");
setSelectedHeadline(headline);
};
const closeHeadlineQr = () => {
const state = window.history.state as { daylineQr?: boolean } | null;
if (state?.daylineQr) window.history.back();
else setSelectedHeadline(null);
};
return (
<main className="dashboard-shell">
<div className="ambient ambient-one" />
<div className="ambient ambient-two" />
<header className="dashboard-header relative z-10 flex min-h-[92px] items-center justify-between border-b border-white/8 px-8 py-4 2xl:px-12">
<div className="brand-group flex items-center gap-5">
<div className="brand-mark" aria-hidden="true"><span /><span /><span /></div>
<div>
<div className="flex items-baseline gap-3">
<h1 className="whitespace-nowrap text-[1.45rem] font-black tracking-[-0.03em] text-white">HOBBS&apos; FAMILY ORGANIZER</h1>
<span className="rounded-full border border-cyan-300/25 bg-cyan-300/8 px-2.5 py-1 text-[0.62rem] font-black tracking-[0.18em] text-cyan-200">APP</span>
</div>
<p className="mt-1 text-[0.72rem] font-semibold tracking-[0.14em] text-slate-500">YOUR FAMILY, AT A GLANCE</p>
</div>
</div>
<div className="flex items-center gap-8">
<div className="text-right">
<div className="flex items-baseline justify-end gap-2">
<span className="text-[2.65rem] font-extralight leading-none tracking-[-0.055em] text-white tabular-nums">{clock.time}</span>
<span className="text-sm font-black tracking-[0.14em] text-cyan-200">{clock.period}</span>
</div>
<p className="mt-1 text-sm font-semibold text-slate-400">{clock.date}</p>
</div>
</div>
</header>
<section className="weather-strip relative z-10" aria-labelledby="weather-title">
<div className="weather-summary">
<div className="weather-summary-top">
<div className="weather-location"><MapPin className="h-3.5 w-3.5" /><span id="weather-title">{weatherLocation}</span></div>
<span className={`weather-status weather-${weatherState}`}>{weatherStatus}</span>
</div>
{currentWeather ? (
<div className="weather-now">
<WeatherGlyph icon={currentWeather.icon} className="weather-now-icon" />
<div className="min-w-0">
<div className="weather-now-temp"><span>{Math.round(currentWeather.high)}°</span><small>{Math.round(currentWeather.low)}°</small></div>
<p>{currentWeather.condition}</p>
</div>
</div>
) : (
<div className="weather-now weather-pending"><CloudSun className="weather-now-icon" /><div><div className="weather-now-temp"><span>--°</span></div><p>{weatherState === "error" ? "Forecast unavailable" : "Loading forecast…"}</p></div></div>
)}
</div>
<div className="forecast-grid" aria-label="Seven day weather forecast">
{weatherDays.length > 0 ? weatherDays.map((day) => (
<article className={`forecast-day ${day.date === todayKey ? "forecast-today" : ""}`} key={day.date}>
<div className="forecast-day-heading"><span>{day.date === todayKey ? "TODAY" : formatDateKey(day.date, { weekday: "short" }).toUpperCase()}</span><small>{formatDateKey(day.date, { month: "numeric", day: "numeric" })}</small></div>
<div className="forecast-condition"><WeatherGlyph icon={day.icon} className="forecast-icon" /><span><strong>{Math.round(day.high)}°</strong><small>{Math.round(day.low)}°</small></span></div>
<p className="forecast-rain">RAIN {Math.round(day.precipitationChance)}%</p>
<div className="sun-times" aria-label={`Sunrise ${formatSunTime(day.sunrise)}, sunset ${formatSunTime(day.sunset)}`}>
<span><Sunrise />{formatSunTime(day.sunrise)}</span><span><Sunset />{formatSunTime(day.sunset)}</span>
</div>
</article>
)) : Array.from({ length: 7 }, (_, index) => <div className="forecast-day forecast-placeholder" key={index}><span /><span /><span /></div>)}
</div>
</section>
<section className="dashboard-grid relative z-10 grid min-h-0 flex-1 grid-cols-[minmax(0,1.48fr)_minmax(420px,0.92fr)] gap-5 px-6 py-5 2xl:gap-7 2xl:px-10">
<section className="panel flex min-h-0 flex-col" aria-labelledby="calendar-title">
<div className="panel-heading calendar-heading">
<div className="flex items-center gap-3"><CalendarDays className="h-6 w-6 text-cyan-200" /><h2 id="calendar-title">Calendar</h2></div>
<div className="calendar-heading-actions">
<div className="calendar-view-tabs" role="tablist" aria-label="Calendar view">
{calendarViews.map((view) => (
<button
aria-controls={`calendar-panel-${view}`}
aria-selected={calendarView === view}
className={calendarView === view ? "is-active" : ""}
id={`calendar-tab-${view}`}
key={view}
onClick={() => setCalendarView(view)}
onKeyDown={(event) => handleCalendarTabKey(event, view)}
role="tab"
type="button"
>
{view}
</button>
))}
</div>
<div className={`source-pill source-${calendarState}`}><Wifi className="h-3.5 w-3.5" />{calendarStatus}</div>
</div>
</div>
{calendarView === "agenda" && (
<div className="calendar-view-content" id="calendar-panel-agenda" role="tabpanel" aria-labelledby="calendar-tab-agenda">
<div className="next-event-card">
{nextEvent ? (
<>
<div className="min-w-0">
<p className="eyebrow text-cyan-200">{eventIsHappening(nextEvent, calendarNow) ? "HAPPENING NOW" : "NEXT EVENT"}</p>
<h3 className="next-event-title mt-2 truncate text-[1.9rem] font-semibold tracking-[-0.035em] text-white">{nextEvent.title}</h3>
<p className="next-event-meta mt-1 truncate text-base text-slate-400">{formatEventWhen(nextEvent, calendarNow)}</p>
</div>
<div className="next-event-countdown shrink-0 pl-6 text-right"><p className="text-[2.2rem] font-extralight tracking-[-0.05em] text-cyan-100">{formatCountdown(nextEvent, calendarNow)}</p><p className="mt-1 text-xs font-bold tracking-[0.12em] text-slate-500">{eventIsHappening(nextEvent, calendarNow) ? "IN PROGRESS" : "UNTIL START"}</p></div>
</>
) : (
<div>
<p className="eyebrow text-cyan-200">{calendarState === "loading" ? "SYNCING" : calendarState === "error" ? "RETRYING" : "CALENDAR CLEAR"}</p>
<h3 className="next-event-title mt-2 text-[1.9rem] font-semibold tracking-[-0.035em] text-white">{calendarState === "loading" ? "Loading calendars…" : calendarState === "error" ? "Calendar temporarily unavailable" : "No upcoming events"}</h3>
<p className="next-event-meta mt-1 text-base text-slate-400">{calendarState === "error" ? "The organizer will retry automatically" : "Looking ahead 60 days"}</p>
</div>
)}
</div>
<div className="event-list mt-4 min-h-0 flex-1 overflow-hidden">
<div className="event-list-heading mb-3 flex items-center justify-between px-1"><p className="eyebrow">UP NEXT</p><p className="text-xs font-semibold text-slate-500">60-day view</p></div>
<div className="event-list-grid grid gap-2">
{upcomingEvents.slice(1, 9).map((event) => {
const row = formatEventRow(event, calendarNow);
return (
<article className="calendar-row" key={event.id} tabIndex={0} aria-label={`${event.title}, ${formatEventWhen(event, calendarNow)}`}>
<div className="calendar-date w-[76px] shrink-0 border-r border-white/8 pr-4 text-center"><p className="calendar-day text-[0.66rem] font-black tracking-[0.16em] text-slate-500">{row.day}</p><p className="calendar-date-number mt-0.5 text-2xl font-light text-slate-200 tabular-nums">{row.date}</p></div>
<div className="calendar-accent" style={{ backgroundColor: calendarToneColors[event.tone] }} aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-4"><h3 className="calendar-event-title truncate text-[1.03rem] font-bold text-slate-100">{event.title}</h3><span className="calendar-event-time shrink-0 text-sm font-bold text-slate-300 tabular-nums">{row.time}</span></div>
<p className="calendar-detail"><span className="calendar-dot" style={{ backgroundColor: calendarToneColors[event.tone] }} /><span>{event.calendar}</span>{event.location && <><span>·</span><span className="truncate">{event.location}</span></>}</p>
</div>
</article>
);
})}
{!upcomingEvents.length && calendarState !== "loading" && <div className="calendar-empty"><div><CalendarDays className="mx-auto mb-3 h-7 w-7 text-slate-600" /><p>{calendarState === "error" ? "Calendar feed unavailable" : "Nothing scheduled"}</p></div></div>}
</div>
</div>
</div>
)}
{calendarView !== "agenda" && (
<div className="calendar-view-content" id={`calendar-panel-${calendarView}`} role="tabpanel" aria-labelledby={`calendar-tab-${calendarView}`}>
<div className="calendar-period-bar">
<button onClick={() => moveCalendarPeriod(-1)} type="button" aria-label={`Previous ${calendarView}`}><ChevronLeft /></button>
<p>{periodLabel}</p>
<button className="calendar-today-button" onClick={() => setCalendarAnchor(todayKey || activeAnchor)} type="button">Today</button>
<button onClick={() => moveCalendarPeriod(1)} type="button" aria-label={`Next ${calendarView}`}><ChevronRight /></button>
</div>
{calendarView === "week" && (
<div className="week-grid" role="grid" aria-label={periodLabel}>
{weekKeys.map((dateKey) => {
const dayEvents = eventsByDate.get(dateKey) ?? [];
return (
<section className={`week-day ${dateKey === todayKey ? "is-today" : ""}`} key={dateKey} role="gridcell">
<div className="week-day-heading"><span>{formatDateKey(dateKey, { weekday: "short" }).toUpperCase()}</span><strong>{formatDateKey(dateKey, { day: "numeric" })}</strong></div>
<div className="week-events">
{dayEvents.slice(0, 3).map((event) => (
<article className="week-event" key={`${dateKey}-${event.id}`} tabIndex={0} title={`${event.title} · ${compactEventTime(event, dateKey)}`} style={{ borderLeftColor: calendarToneColors[event.tone] }}>
<strong>{event.title}</strong><span>{compactEventTime(event, dateKey)}</span>
</article>
))}
{!dayEvents.length && <span className="calendar-no-events">—</span>}
{dayEvents.length > 3 && <span className="calendar-more">+{dayEvents.length - 3} more</span>}
</div>
</section>
);
})}
</div>
)}
{calendarView === "month" && (
<div className="month-grid" role="grid" aria-label={periodLabel}>
{weekDateKeys("2024-01-01").map((dateKey) => <div className="month-weekday" key={dateKey} role="columnheader">{formatDateKey(dateKey, { weekday: "short" }).slice(0, 2).toUpperCase()}</div>)}
{monthKeys.map((dateKey) => {
const dayEvents = eventsByDate.get(dateKey) ?? [];
const outsideMonth = dateKey.slice(0, 7) !== activeAnchor.slice(0, 7);
return (
<section className={`month-cell ${outsideMonth ? "is-outside" : ""} ${dateKey === todayKey ? "is-today" : ""}`} key={dateKey} role="gridcell">
<span className="month-date">{formatDateKey(dateKey, { day: "numeric" })}</span>
<div className="month-events">
{dayEvents.slice(0, 2).map((event) => (
<div className="month-event" key={`${dateKey}-${event.id}`} title={event.title}><span style={{ backgroundColor: calendarToneColors[event.tone] }} />{event.title}</div>
))}
{dayEvents.length > 2 && <span className="calendar-more">+{dayEvents.length - 2}</span>}
</div>
</section>
);
})}
</div>
)}
</div>
)}
</section>
<aside className="panel flex min-h-0 flex-col" aria-labelledby="headlines-title">
<div className="panel-heading">
<div className="flex items-center gap-3"><Newspaper className="h-6 w-6 text-violet-200" /><h2 id="headlines-title">AI + Tech</h2></div>
<div className="source-pill"><Wifi className="h-3.5 w-3.5" />{newsLive ? "RSS live" : "RSS demo"}</div>
</div>
<div className="min-h-0 flex-1 divide-y divide-white/7 overflow-hidden">
{headlines.map((headline, index) => (
<button
className="headline-row w-full text-left"
disabled={!headline.url}
key={headline.title}
onClick={() => showHeadlineQr(headline)}
type="button"
aria-label={headline.url ? `${headline.title}. Show QR code for this article.` : headline.title}
>
<div className={`mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full ${toneClasses[headline.tone]}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2 text-[0.65rem] font-black tracking-[0.12em]">
<span className="flex items-center gap-2"><span className="text-slate-400">{headline.source}</span><span className="text-slate-700">•</span><span className="text-slate-600">{headline.age}</span></span>
{headline.url && <span className="headline-qr-cue"><QrCode className="h-3 w-3" />SCAN</span>}
</div>
<h3 className={`mt-1.5 font-semibold leading-[1.28] text-slate-100 ${index === 0 ? "text-[1.12rem]" : "text-[1rem]"}`}>{headline.title}</h3>
</div>
</button>
))}
</div>
<div className="phone-handoff mt-3 flex items-center justify-between rounded-2xl border border-violet-300/12 bg-violet-300/[0.045] px-4 py-3">
<div className="flex items-center gap-3"><Sparkles className="h-5 w-5 text-violet-200" /><div><p className="text-sm font-bold text-slate-200">Phone handoff</p><p className="text-xs text-slate-500">Select a live headline, then scan</p></div></div>
<span className="text-xs font-black tracking-[0.12em] text-violet-200">READY</span>
</div>
</aside>
</section>
<footer className="dashboard-footer relative z-10 border-t border-white/8 bg-[#050911]/92 py-3 backdrop-blur-xl">
<div className="footer-meta mb-2 flex items-center justify-between px-8 2xl:px-12">
<div className="flex items-center gap-2"><span className={`pulse-dot market-pulse-${marketState}`} /><span className="text-[0.66rem] font-black tracking-[0.16em] text-slate-400">MARKETS + COMMODITIES</span><span className="rounded-full border border-white/8 px-2 py-0.5 text-[0.6rem] font-black tracking-[0.12em] text-slate-600">LATEST FRED OBSERVATION</span></div>
<span className="text-[0.68rem] font-semibold text-slate-600">{marketStatus}</span>
</div>
<div className="ticker-window" aria-label="Market ticker">
<div className="ticker-track">
<div className="flex shrink-0">{quotes.map((quote) => <QuoteCell key={`a-${quote.symbol}`} quote={quote} />)}</div>
<div className="flex shrink-0" aria-hidden="true">{quotes.map((quote) => <QuoteCell key={`b-${quote.symbol}`} quote={quote} />)}</div>
</div>
</div>
</footer>
{selectedHeadline?.url && (
<div className="qr-overlay" role="presentation" onClick={closeHeadlineQr}>
<section
className="qr-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="qr-headline-title"
onClick={(event) => event.stopPropagation()}
>
<button className="qr-close" onClick={closeHeadlineQr} ref={qrCloseRef} type="button">
<X className="h-4 w-4" />Close
</button>
<div className="qr-code-frame" aria-label={`QR code for ${selectedHeadline.title}`}>
<QRCode
bgColor="#ffffff"
fgColor="#071018"
level="M"
size={264}
value={selectedHeadline.url}
viewBox="0 0 256 256"
/>
</div>
<div className="qr-copy">
<div className="qr-source"><QrCode className="h-4 w-4" />{selectedHeadline.source}<span>•</span>{headlineHost(selectedHeadline.url)}</div>
<h2 id="qr-headline-title">{selectedHeadline.title}</h2>
<p>Open your phone camera and point it at the code. Press Back on the Shield remote when you are finished.</p>
</div>
</section>
</div>
)}
</main>
);
}
@@ -0,0 +1,274 @@
/* eslint-disable @next/next/no-img-element -- plain <img> is intended: same-origin cached JPEGs, no optimizer */
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/*
* Gallery for the Shield photo app's launcher tile: a grid of tiles driven by the remote's
* D-pad (arrow keys), OK (Enter) opens a photo full screen, Back returns to the grid.
* Android's Back key never reaches the page; the app maps it to WebView history, so opening
* a photo pushes a history entry and closing it pops that entry.
*/
type GalleryPhoto = { src: string; thumb: string; tile: string; takenAt: number | null; width: number; height: number };
type Payload = { photos: GalleryPhoto[]; version: string };
const REFRESH_MS = 5 * 60 * 1000;
/** With no key press for this long, start the slideshow (the app keeps the screen on). */
const IDLE_SLIDESHOW_MS = 10 * 60 * 1000;
const TILE_TARGET_PX = 300;
const SLIDESHOW = -1;
const TIME_ZONE = "America/Chicago";
function formatTaken(takenAt: number | null) {
if (!takenAt) return "";
return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt));
}
/** Newest first; photos without a date keep their album order at the end. */
function sortPhotos(photos: GalleryPhoto[]) {
return photos
.map((photo, position) => ({ photo, position }))
.sort((left, right) => (right.photo.takenAt ?? -1) - (left.photo.takenAt ?? -1) || left.position - right.position)
.map((entry) => entry.photo);
}
function useColumns() {
const [columns, setColumns] = useState(6);
useEffect(() => {
const update = () => setColumns(Math.max(2, Math.min(8, Math.round(window.innerWidth / TILE_TARGET_PX))));
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
return columns;
}
export default function PhotoGallery() {
const [photos, setPhotos] = useState<GalleryPhoto[] | null>(null);
const [failed, setFailed] = useState(false);
const [focus, setFocus] = useState(0);
const [viewer, setViewer] = useState<number | null>(null);
const columns = useColumns();
const versionRef = useRef("");
const focusSrc = useRef("");
const tileRefs = useRef<(HTMLDivElement | null)[]>([]);
const slideshowRef = useRef<HTMLDivElement | null>(null);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const viewerOpenedHere = useRef(false);
// Load (and occasionally refresh) the list, keeping focus on the same photo.
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const response = await fetch("/api/photos", { cache: "no-store" });
if (!response.ok) throw new Error(String(response.status));
const next = (await response.json()) as Payload;
if (cancelled) return;
setFailed(false);
if (next.version === versionRef.current) return;
versionRef.current = next.version;
const sorted = sortPhotos(next.photos);
setPhotos(sorted);
const kept = sorted.findIndex((photo) => photo.src === focusSrc.current);
setFocus((current) => (current === SLIDESHOW ? SLIDESHOW : kept >= 0 ? kept : Math.min(current, Math.max(0, sorted.length - 1))));
setViewer((current) => (current === null ? null : kept >= 0 ? kept : null));
} catch {
if (!cancelled) setFailed(true);
}
};
void load();
const timer = window.setInterval(load, REFRESH_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
const count = photos?.length ?? 0;
useEffect(() => {
focusSrc.current = photos && focus >= 0 ? (photos[focus]?.src ?? "") : "";
}, [photos, focus]);
// Keep the focused tile on screen.
useEffect(() => {
if (viewer !== null) return;
// On the top row (or the Slideshow button) show the whole header, not just the focused item.
if (focus < columns) {
scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
return;
}
const element = focus === SLIDESHOW ? slideshowRef.current : tileRefs.current[focus];
element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [focus, viewer, columns]);
const openViewer = useCallback((index: number) => {
window.history.pushState({ hfoViewer: true }, "");
viewerOpenedHere.current = true;
setViewer(index);
}, []);
const closeViewer = useCallback(() => {
if (viewerOpenedHere.current) window.history.back();
else setViewer(null);
}, []);
// Back (the app turns the remote's Back into history.back()) closes the viewer.
useEffect(() => {
const onPop = () => {
viewerOpenedHere.current = false;
setViewer((current) => {
if (current !== null) setFocus(current);
return null;
});
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
const startSlideshow = useCallback(() => {
// A full page load on purpose: the slideshow is a separate page with its own 12h self-reload,
// and the Shield app's Back key then returns here through WebView history.
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
window.location.assign("/photo");
}, []);
// Idle -> slideshow.
const idleTimer = useRef<number | undefined>(undefined);
const resetIdle = useCallback(() => {
window.clearTimeout(idleTimer.current);
idleTimer.current = window.setTimeout(startSlideshow, IDLE_SLIDESHOW_MS);
}, [startSlideshow]);
useEffect(() => {
resetIdle();
return () => window.clearTimeout(idleTimer.current);
}, [resetIdle]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
resetIdle();
const key = event.key;
const handled = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter", " ", "Escape", "Backspace"].includes(key);
if (!handled) return;
event.preventDefault();
if (!count && key !== "Enter") return;
if (viewer !== null) {
if (key === "ArrowRight") setViewer((viewer + 1) % count);
else if (key === "ArrowLeft") setViewer((viewer - 1 + count) % count);
else if (key === "Enter" || key === " " || key === "Escape" || key === "Backspace") closeViewer();
return;
}
if (key === "Enter" || key === " ") {
if (focus === SLIDESHOW || !count) startSlideshow();
else openViewer(focus);
return;
}
setFocus((current) => {
if (current === SLIDESHOW) return key === "ArrowDown" || key === "ArrowRight" ? 0 : SLIDESHOW;
if (key === "ArrowRight") return Math.min(count - 1, current + 1);
if (key === "ArrowLeft") return Math.max(0, current - 1);
if (key === "ArrowDown") return Math.min(count - 1, current + columns);
if (key === "ArrowUp") return current - columns < 0 ? SLIDESHOW : current - columns;
return current;
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [closeViewer, columns, count, focus, openViewer, resetIdle, startSlideshow, viewer]);
const focused = photos && focus >= 0 ? photos[focus] : undefined;
const shown = photos && viewer !== null ? photos[viewer] : undefined;
// Neighbours to prefetch while the viewer is open, so ◀ ▶ feel instant.
const neighbours = useMemo(() => {
if (!photos || viewer === null || count < 2) return [];
return [photos[(viewer + 1) % count], photos[(viewer - 1 + count) % count]];
}, [photos, viewer, count]);
return (
<div className="fixed inset-0 overflow-hidden bg-neutral-950 text-white" style={{ cursor: viewer === null ? "default" : "none" }}>
<div ref={scrollerRef} className="h-full overflow-y-auto px-14 pb-16 pt-10" style={{ scrollPaddingTop: 40, scrollPaddingBottom: 56 }}>
<header className="mb-8 flex items-center gap-6">
<div className="min-w-0 flex-1">
<h1 className="text-4xl font-semibold tracking-tight">Family Photos</h1>
<p className="mt-1 text-lg text-white/55">
{photos === null ? (failed ? "Can't reach the photo server…" : "Loading…") : `${count} photo${count === 1 ? "" : "s"}`}
{focused?.takenAt ? <span className="text-white/80"> · {formatTaken(focused.takenAt)}</span> : null}
</p>
</div>
<div
ref={slideshowRef}
onClick={startSlideshow}
className={`flex cursor-pointer items-center gap-3 rounded-full px-7 py-3 text-xl font-semibold transition-all duration-150 ${
focus === SLIDESHOW ? "scale-105 bg-white text-neutral-950 shadow-[0_0_0_4px_rgb(255_255_255/0.35)]" : "bg-white/10 text-white/85"
}`}
>
<span aria-hidden>▶</span> Slideshow
</div>
</header>
{photos && !count && (
<div className="mt-24 text-center text-2xl text-white/60">No photos yet. Add a Google Photos album in the admin screen.</div>
)}
<div className="grid gap-5" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}>
{photos?.map((photo, index) => {
const active = index === focus && viewer === null;
return (
<div
key={photo.src}
ref={(element) => {
tileRefs.current[index] = element;
}}
onClick={() => {
setFocus(index);
openViewer(index);
}}
className={`relative aspect-square cursor-pointer overflow-hidden rounded-2xl bg-white/5 transition-all duration-150 ${
active ? "z-10 scale-[1.08] shadow-[0_0_0_4px_#fff,0_18px_40px_rgb(0_0_0/0.6)]" : "opacity-80"
}`}
>
<img src={photo.tile} alt="" loading="lazy" decoding="async" className="h-full w-full object-cover" />
</div>
);
})}
</div>
{count > 0 && (
<p className="mt-10 text-center text-base text-white/35">Arrows to move · OK to open · Back to return</p>
)}
</div>
{shown && (
<div key={shown.src} className="absolute inset-0 z-20 bg-black" style={{ animation: "gallery-zoom-in 260ms ease-out both" }}>
<img
src={shown.thumb}
alt=""
aria-hidden
className="absolute inset-0 h-full w-full object-cover"
style={{ filter: "blur(28px) brightness(0.55) saturate(1.2)", transform: "scale(1.15)" }}
/>
{/* The tile is already cached, so it shows at once while the full photo loads on top. */}
<img src={shown.tile} alt="" aria-hidden className="absolute inset-0 h-full w-full object-contain" />
<img src={shown.src} alt="" className="absolute inset-0 h-full w-full object-contain" />
<div className="absolute bottom-6 left-8 rounded-full bg-black/40 px-4 py-1.5 text-lg font-semibold text-white/85">
◀ {(viewer ?? 0) + 1} / {count} ▶
</div>
{shown.takenAt ? (
<div className="absolute bottom-6 right-8 rounded-full bg-black/40 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85">
{formatTaken(shown.takenAt)}
</div>
) : null}
</div>
)}
{neighbours.map((photo) => (
<link key={photo.src} rel="prefetch" as="image" href={photo.src} />
))}
<style>{`@keyframes gallery-zoom-in { from { opacity: 0; transform: scale(0.82) } to { opacity: 1; transform: scale(1) } }`}</style>
</div>
);
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Hobbs Family Photos",
robots: { index: false, follow: false },
};
export default function PhotoLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return children;
}
@@ -0,0 +1,162 @@
/* eslint-disable @next/next/no-img-element -- plain <img> is intended: same-origin cached JPEGs, no optimizer */
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
type SlidePhoto = { src: string; thumb: string; takenAt: number | null; width: number; height: number };
type Display = { intervalSeconds: number; shuffle: boolean; showDate: boolean; fit: "contain-blur" | "cover"; includeUploads: boolean };
type Slideshow = { display: Display; photos: SlidePhoto[]; version: string };
type Layer = { photo: SlidePhoto; id: number };
const POLL_MS = 30_000;
const FADE_MS = 1_400;
const FULL_RELOAD_MS = 12 * 60 * 60 * 1000;
const TIME_ZONE = "America/Chicago";
function shuffled<T>(values: T[]) {
const copy = [...values];
for (let index = copy.length - 1; index > 0; index -= 1) {
const other = Math.floor(Math.random() * (index + 1));
[copy[index], copy[other]] = [copy[other], copy[index]];
}
return copy;
}
function formatTaken(takenAt: number | null) {
if (!takenAt) return "";
return new Intl.DateTimeFormat("en-US", { timeZone: TIME_ZONE, month: "long", day: "numeric", year: "numeric" }).format(new Date(takenAt));
}
function preload(src: string) {
return new Promise<void>((resolve) => {
const image = new Image();
image.onload = () => resolve();
image.onerror = () => resolve();
image.src = src;
});
}
export default function PhotoFrame() {
const [show, setShow] = useState<Slideshow | null>(null);
const [layers, setLayers] = useState<Layer[]>([]);
const [failed, setFailed] = useState(false);
const queue = useRef<SlidePhoto[]>([]);
const versionRef = useRef("");
const showRef = useRef<Slideshow | null>(null);
const layerId = useRef(0);
// The newest layer's src, readable from callbacks without depending on layers state.
const topSrc = useRef("");
const refill = useCallback((photos: SlidePhoto[], shuffle: boolean, avoid?: string) => {
let next = shuffle ? shuffled(photos) : [...photos];
if (next.length > 1 && next[0].src === avoid) next = [...next.slice(1), next[0]];
queue.current = next;
}, []);
const advance = useCallback(async () => {
const current = showRef.current;
if (!current || !current.photos.length) return;
if (!queue.current.length) refill(current.photos, current.display.shuffle, topSrc.current);
const photo = queue.current.shift();
if (!photo) return;
await preload(photo.src);
layerId.current += 1;
const id = layerId.current;
setLayers((existing) => [...existing.slice(-1), { photo, id }]);
}, [refill]);
useEffect(() => {
topSrc.current = layers[layers.length - 1]?.photo.src ?? "";
}, [layers]);
// Poll settings + photo list; rebuild the queue only when something changed.
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const response = await fetch("/api/photos", { cache: "no-store" });
if (!response.ok) throw new Error(String(response.status));
const next = (await response.json()) as Slideshow;
if (cancelled) return;
setFailed(false);
if (next.version === versionRef.current) return;
const first = !versionRef.current;
versionRef.current = next.version;
showRef.current = next;
setShow(next);
refill(next.photos, next.display.shuffle, topSrc.current);
if (first || !topSrc.current || !next.photos.some((photo) => photo.src === topSrc.current)) void advance();
} catch {
if (!cancelled) setFailed(true);
}
};
void poll();
const timer = window.setInterval(poll, POLL_MS);
const reload = window.setTimeout(() => window.location.reload(), FULL_RELOAD_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
window.clearTimeout(reload);
};
}, [advance, refill]);
const intervalSeconds = show?.display.intervalSeconds ?? 20;
const photoCount = show?.photos.length ?? 0;
useEffect(() => {
if (photoCount < 2) return;
const timer = window.setInterval(() => void advance(), intervalSeconds * 1000);
return () => window.clearInterval(timer);
}, [advance, intervalSeconds, photoCount]);
const fit = show?.display.fit ?? "contain-blur";
const showDate = show?.display.showDate ?? true;
const top = layers[layers.length - 1];
return (
<div className="fixed inset-0 overflow-hidden bg-black" style={{ cursor: "none" }}>
{layers.map((layer) => (
<div
key={layer.id}
className="absolute inset-0"
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both` }}
>
{fit === "contain-blur" && (
<img
src={layer.photo.thumb}
alt=""
aria-hidden
className="absolute inset-0 h-full w-full object-cover"
style={{ filter: "blur(28px) brightness(0.55) saturate(1.2)", transform: "scale(1.15)" }}
/>
)}
<img
src={layer.photo.src}
alt=""
className={`absolute inset-0 h-full w-full ${fit === "cover" ? "object-cover" : "object-contain"}`}
/>
</div>
))}
{showDate && top?.photo.takenAt ? (
<div
key={`date-${top.id}`}
className="absolute bottom-6 right-8 rounded-full bg-black/35 px-4 py-1.5 text-lg font-semibold tracking-wide text-white/85"
style={{ animation: `photo-fade-in ${FADE_MS}ms ease-in-out both`, textShadow: "0 1px 4px rgb(0 0 0 / 60%)" }}
>
{formatTaken(top.photo.takenAt)}
</div>
) : null}
{show && !show.photos.length && (
<div className="absolute inset-0 flex items-center justify-center text-center text-2xl text-white/60">
No photos yet. Add a Google Photos album in the admin screen.
</div>
)}
{failed && !layers.length && (
<div className="absolute inset-0 flex items-center justify-center text-2xl text-white/60">Can&apos;t reach the photo server…</div>
)}
<style>{`@keyframes photo-fade-in { from { opacity: 0 } to { opacity: 1 } }`}</style>
</div>
);
}
@@ -0,0 +1,64 @@
name: hobbs-family-organizer
services:
organizer:
image: hobbs-family-organizer:1.4.0
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
init: true
environment:
NODE_ENV: production
HOSTNAME: 0.0.0.0
PORT: "3000"
TZ: ${ORGANIZER_TIMEZONE:-America/Chicago}
ORGANIZER_TIMEZONE: ${ORGANIZER_TIMEZONE:-America/Chicago}
WEATHER_LOCATION: ${WEATHER_LOCATION:-Sapulpa, OK}
WEATHER_LATITUDE: ${WEATHER_LATITUDE:-35.9987}
WEATHER_LONGITUDE: ${WEATHER_LONGITUDE:--96.11417}
NWS_FORECAST_URL: ${NWS_FORECAST_URL:-https://api.weather.gov/gridpoints/TSA/37,98/forecast}
WEATHER_USER_AGENT: ${WEATHER_USER_AGENT:-Hobbs-Family-Organizer/1.0 (self-hosted)}
CALENDAR_ICAL_URLS_FILE: /run/secrets/calendar_ical_urls
ORGANIZER_ACCESS_TOKEN_FILE: /run/secrets/organizer_access_token
ORGANIZER_DATA_DIR: /data
ADMIN_ALLOWED_CIDRS: ${ADMIN_ALLOWED_CIDRS:-192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,127.0.0.1/32}
secrets:
- calendar_ical_urls
- organizer_access_token
ports:
- "${HOBBS_BIND_ADDRESS:-0.0.0.0}:${HOBBS_HTTP_PORT:-8080}:3000"
volumes:
# Admin settings, password hash and history. Must be writable by uid 10001,
# and must be local disk (not NFS with root_squash).
- ${HOBBS_DATA_DIR:-./data}:/data
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
- /app/.next-build/cache:size=128m,uid=10001,gid=10001,mode=0750
cap_drop:
- ALL
cap_add:
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
pids_limit: 128
stop_grace_period: 20s
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
secrets:
calendar_ical_urls:
file: ./secrets/calendar_ical_urls.json
organizer_access_token:
file: ./secrets/organizer_access_token.txt
@@ -0,0 +1,15 @@
// Clears the admin password so the next visit to /admin (from the LAN) can set a new one.
// Also rotates the session secret, signing out every existing admin session.
// Usage: docker exec -u 10001 hobbs-family-organizer-organizer-1 node container/reset-admin.mjs
import { randomBytes } from "node:crypto";
import { rename, writeFile } from "node:fs/promises";
const path = `${process.env.ORGANIZER_DATA_DIR || "/data"}/auth.json`;
const temporary = `${path}.reset.tmp`;
await writeFile(
temporary,
`${JSON.stringify({ passwordHash: "", sessionSecret: randomBytes(32).toString("base64url") }, null, 2)}\n`,
{ mode: 0o600 },
);
await rename(temporary, path);
console.log("Admin password cleared. Visit /admin from the LAN to set a new one.");
@@ -0,0 +1,186 @@
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { spawn } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import http from "node:http";
import process from "node:process";
const APP_UID = 10001;
const APP_GID = 10001;
const DATA_DIR = process.env.ORGANIZER_DATA_DIR || "/data";
const PUBLIC_PORT = Number(process.env.PORT || "3000");
const INTERNAL_PORT = Number(process.env.ORGANIZER_INTERNAL_PORT || "3001");
const TONES = ["cyan", "amber", "violet", "emerald", "rose"];
async function readable(path) {
try {
await access(path, constants.R_OK);
return true;
} catch {
return false;
}
}
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile) {
const direct = process.env[environmentName];
if (direct !== undefined) return direct.trim();
const configuredFile = process.env[fileEnvironmentName] || defaultFile;
if (await readable(configuredFile)) return (await readFile(configuredFile, "utf8")).trim();
return "";
}
function validCalendarUrl(value) {
const url = new URL(String(value));
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) {
throw new Error("Every calendar URL must be an HTTPS calendar.google.com basic.ics URL");
}
return url.toString();
}
function parseCalendarSecret(raw) {
if (!raw) return [];
let values;
try {
values = JSON.parse(raw);
} catch {
throw new Error("Calendar secret must be a JSON array");
}
if (!Array.isArray(values) || values.length > 10) throw new Error("Calendar secret must contain at most 10 URLs");
return [...new Set(values.map(validCalendarUrl))];
}
async function writeJsonAtomic(path, value) {
const temporary = `${path}.${process.pid}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, path);
}
function dropPrivileges() {
if (process.platform === "win32" || typeof process.getuid !== "function" || process.getuid() !== 0) return;
process.setgroups([]);
process.setgid(APP_GID);
process.setuid(APP_UID);
}
async function prepareDataDirectory(seedUrls) {
await mkdir(`${DATA_DIR}/history`, { recursive: true, mode: 0o700 });
const probe = `${DATA_DIR}/.write-test`;
try {
await writeFile(probe, "ok", { mode: 0o600 });
} catch {
throw new Error(`${DATA_DIR} must be writable by uid ${APP_UID}`);
}
const settingsPath = `${DATA_DIR}/settings.json`;
if (!(await readable(settingsPath))) {
const now = new Date().toISOString();
await writeJsonAtomic(settingsPath, {
schema: 1,
updatedAt: now,
calendars: seedUrls.map((url, index) => ({
id: randomUUID(),
name: "",
url,
tone: TONES[index % TONES.length],
enabled: true,
})),
});
console.log(`[organizer] created settings.json with ${seedUrls.length} calendar(s) from the startup secret`);
}
const authPath = `${DATA_DIR}/auth.json`;
if (!(await readable(authPath))) {
await writeJsonAtomic(authPath, { passwordHash: "", sessionSecret: randomBytes(32).toString("base64url") });
}
}
const FORWARDING_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded", "x-real-ip"];
const INTERNAL_HEADERS = ["x-hfo-client-ip", "x-hfo-forwarded"];
// The public listener stamps each request with the real TCP peer address so the
// app can enforce LAN-only admin access. Client-supplied copies are discarded.
function startFrontProxy() {
const agent = new http.Agent({ keepAlive: true, maxSockets: 64 });
const server = http.createServer((request, response) => {
const headers = { ...request.headers };
for (const name of INTERNAL_HEADERS) delete headers[name];
headers["x-hfo-client-ip"] = String(request.socket.remoteAddress || "").replace(/^::ffff:/, "");
if (FORWARDING_HEADERS.some((name) => request.headers[name] !== undefined)) headers["x-hfo-forwarded"] = "1";
const upstream = http.request(
{ host: "127.0.0.1", port: INTERNAL_PORT, method: request.method, path: request.url, headers, agent },
(upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
upstreamResponse.pipe(response);
},
);
upstream.on("error", () => {
if (!response.headersSent) response.writeHead(502, { "Content-Type": "text/plain" });
response.end("Upstream unavailable");
});
request.pipe(upstream);
});
server.headersTimeout = 20_000;
server.requestTimeout = 60_000;
server.listen(PUBLIC_PORT, process.env.HOSTNAME || "0.0.0.0");
return server;
}
async function main() {
const calendarSecret = await settingFromFile(
"CALENDAR_ICAL_URLS",
"CALENDAR_ICAL_URLS_FILE",
"/run/secrets/calendar_ical_urls",
);
const accessToken = await settingFromFile(
"ORGANIZER_ACCESS_TOKEN",
"ORGANIZER_ACCESS_TOKEN_FILE",
"/run/secrets/organizer_access_token",
);
if (accessToken && !/^[A-Za-z0-9._~-]{32,512}$/.test(accessToken)) {
throw new Error("ORGANIZER_ACCESS_TOKEN must be empty or 32 to 512 URL-safe characters");
}
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
try {
new Intl.DateTimeFormat("en-US", { timeZone: organizerTimeZone }).format(new Date());
} catch {
throw new Error(`ORGANIZER_TIMEZONE is not a valid IANA timezone: ${organizerTimeZone}`);
}
const seedUrls = parseCalendarSecret(calendarSecret);
// Secrets are read; everything from here on runs unprivileged.
dropPrivileges();
await prepareDataDirectory(seedUrls);
const environment = { ...process.env };
delete environment.CALENDAR_ICAL_URLS;
Object.assign(environment, {
ORGANIZER_ACCESS_TOKEN: accessToken,
ORGANIZER_TIMEZONE: organizerTimeZone,
ORGANIZER_DATA_DIR: DATA_DIR,
HOSTNAME: "127.0.0.1",
PORT: String(INTERNAL_PORT),
});
const child = spawn(
process.execPath,
["node_modules/next/dist/bin/next", "start", "-H", "127.0.0.1", "-p", String(INTERNAL_PORT)],
{ cwd: process.cwd(), env: environment, stdio: "inherit" },
);
const server = startFrontProxy();
const forwardSignal = (signal) => {
server.close();
if (!child.killed) child.kill(signal);
};
process.on("SIGINT", () => forwardSignal("SIGINT"));
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 1);
});
}
main().catch((error) => {
console.error(`[organizer] startup failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
@@ -0,0 +1,37 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
"dist/**",
".vinext/**",
".wrangler/**",
".sites-runtime/**",
".openai/**",
"db/**",
"scripts/**",
"worker/**",
"cloudflare-env.d.ts",
"components/**",
"hooks/**",
"lib/**",
"vite.config.ts",
"outputs/**",
"work/**",
]),
{
files: ["components/ui/**/*.{ts,tsx}", "hooks/use-mobile.ts"],
rules: {
// These files are vendored verbatim from shadcn@4.17.0. Keep the
// registry source intact while applying the stricter rules to Site code.
"@typescript-eslint/no-unused-vars": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off",
},
},
]);
export default eslintConfig;
@@ -0,0 +1,7 @@
# Trusted-LAN example. Caddy obtains an internal certificate and proxies to the
# host-published organizer port. Install Caddy's root CA on each Shield.
organizer.home.arpa {
tls internal
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
@@ -0,0 +1,18 @@
# Place this server block inside nginx's http context. Replace the certificate
# paths with your internal or publicly trusted certificate.
server {
listen 443 ssl;
server_name organizer.home.arpa;
ssl_certificate /etc/nginx/tls/organizer.crt;
ssl_certificate_key /etc/nginx/tls/organizer.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 60s;
}
}
@@ -0,0 +1,5 @@
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
const { startPhotoScheduler } = await import("./app/_lib/photos");
startPhotoScheduler();
}
@@ -0,0 +1,24 @@
/** @type {import("next").NextConfig} */
const nextConfig = {
distDir: ".next-build",
poweredByHeader: false,
turbopack: {
root: process.cwd(),
},
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Content-Security-Policy", value: "default-src 'self'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" },
{ key: "Permissions-Policy", value: "camera=(), geolocation=(), microphone=()" },
{ key: "Referrer-Policy", value: "no-referrer" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
],
},
];
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
{
"name": "hobbs-family-organizer",
"version": "1.4.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint app next.config.mjs proxy.ts --ignore-pattern .next --ignore-pattern .next-build"
},
"dependencies": {
"ical.js": "^2.2.1",
"lucide-react": "^1.31.0",
"next": "16.3.4",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-qr-code": "2.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "9.39.4",
"eslint-config-next": "16.3.4",
"tailwindcss": "4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3"
},
"type": "module"
}
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,51 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { isAdminNetworkAllowed } from "./app/_lib/auth";
function tokenMatches(actual: string, expected: string) {
const actualValue = createHash("sha256").update(actual).digest();
const expectedValue = createHash("sha256").update(expected).digest();
return timingSafeEqual(actualValue, expectedValue);
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === "/api/health") return NextResponse.next();
// Admin has its own password + session and is reachable only from a direct LAN connection.
if (pathname === "/admin" || pathname.startsWith("/admin/") || pathname.startsWith("/api/admin/") || pathname === "/photo-admin") {
if (!isAdminNetworkAllowed(request.headers)) {
return new NextResponse("Not found", { status: 404, headers: { "Cache-Control": "no-store" } });
}
if (pathname === "/photo-admin") {
const target = new URL("/admin", request.url);
target.host = request.headers.get("host") || target.host;
target.hash = "photos";
return NextResponse.redirect(target, 307);
}
return NextResponse.next();
}
const configuredToken = process.env.ORGANIZER_ACCESS_TOKEN?.trim();
if (!configuredToken) return NextResponse.next();
const authorization = request.headers.get("authorization") || "";
const suppliedToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
if (tokenMatches(suppliedToken, configuredToken)) return NextResponse.next();
return NextResponse.json(
{ error: "Unauthorized" },
{
status: 401,
headers: {
"Cache-Control": "no-store",
"WWW-Authenticate": "Bearer realm=\"Hobbs Family Organizer\"",
},
},
);
}
export const config = {
matcher: ["/((?!api/health).*)"],
};
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
</svg>

After

Width:  |  Height:  |  Size: 718 B

@@ -0,0 +1,76 @@
[CmdletBinding()]
param(
[string]$DestinationDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $DestinationDirectory) {
$DestinationDirectory = Join-Path $ProjectRoot "backups"
}
$DestinationDirectory = [IO.Path]::GetFullPath($DestinationDirectory)
$Sources = @{
Environment = Join-Path $ProjectRoot ".env"
Calendars = Join-Path $ProjectRoot "secrets/calendar_ical_urls.json"
Token = Join-Path $ProjectRoot "secrets/organizer_access_token.txt"
}
foreach ($source in $Sources.GetEnumerator()) {
if (-not (Test-Path -LiteralPath $source.Value -PathType Leaf)) {
throw "Required configuration is missing. Run scripts/configure.ps1 before creating a backup."
}
}
New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$suffix = [Guid]::NewGuid().ToString("N").Substring(0, 8)
$archivePath = Join-Path $DestinationDirectory "hobbs-organizer-config-$timestamp-$suffix.zip"
$stagingPath = Join-Path $DestinationDirectory (".backup-staging-" + [Guid]::NewGuid().ToString("N"))
$stagingFullPath = [IO.Path]::GetFullPath($stagingPath)
$destinationPrefix = $DestinationDirectory.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if (-not $stagingFullPath.StartsWith($destinationPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to create a staging directory outside the selected backup directory."
}
try {
New-Item -ItemType Directory -Path (Join-Path $stagingPath "config") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $stagingPath "secrets") -Force | Out-Null
Copy-Item -LiteralPath $Sources.Environment -Destination (Join-Path $stagingPath "config/environment.env")
Copy-Item -LiteralPath $Sources.Calendars -Destination (Join-Path $stagingPath "secrets/calendar_ical_urls.json")
Copy-Item -LiteralPath $Sources.Token -Destination (Join-Path $stagingPath "secrets/organizer_access_token.txt")
$manifest = @(
"format=HOBBS_ORGANIZER_CONFIG_BACKUP_V1",
"created_utc=$((Get-Date).ToUniversalTime().ToString('o'))",
"contains_secrets=true"
) -join [Environment]::NewLine
[IO.File]::WriteAllText(
(Join-Path $stagingPath "manifest.txt"),
$manifest + [Environment]::NewLine,
(New-Object System.Text.UTF8Encoding($false))
)
Compress-Archive -Path (Join-Path $stagingPath "*") -DestinationPath $archivePath -CompressionLevel Optimal
if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe $archivePath "/inheritance:r" "/grant:r" "${userName}:(F)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not restrict backup archive permissions." }
}
else {
& chmod 600 -- $archivePath
if ($LASTEXITCODE -ne 0) { throw "Could not restrict backup archive permissions." }
}
}
finally {
if (Test-Path -LiteralPath $stagingFullPath) {
Remove-Item -LiteralPath $stagingFullPath -Recurse -Force
}
}
Write-Host "Created a protected configuration backup at: $archivePath"
Write-Host "The archive contains secrets. Store it in an encrypted or access-controlled backup system."
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
DESTINATION_DIRECTORY="${1:-$PROJECT_ROOT/backups}"
ENVIRONMENT_FILE="$PROJECT_ROOT/.env"
CALENDAR_FILE="$PROJECT_ROOT/secrets/calendar_ical_urls.json"
TOKEN_FILE="$PROJECT_ROOT/secrets/organizer_access_token.txt"
for required_file in "$ENVIRONMENT_FILE" "$CALENDAR_FILE" "$TOKEN_FILE"; do
if [[ ! -f "$required_file" ]]; then
printf '%s\n' "Required configuration is missing. Run scripts/configure.sh before creating a backup." >&2
exit 1
fi
done
command -v tar >/dev/null 2>&1 || { printf '%s\n' "tar is required to create a backup." >&2; exit 1; }
mkdir -p -- "$DESTINATION_DIRECTORY"
DESTINATION_DIRECTORY="$(cd -- "$DESTINATION_DIRECTORY" && pwd -P)"
chmod 700 -- "$DESTINATION_DIRECTORY"
timestamp="$(date -u +%Y%m%d-%H%M%S)"
archive_path="$DESTINATION_DIRECTORY/hobbs-organizer-config-$timestamp.tar.gz"
archive_counter=1
while [[ -e "$archive_path" ]]; do
archive_path="$DESTINATION_DIRECTORY/hobbs-organizer-config-$timestamp-$archive_counter.tar.gz"
((archive_counter += 1))
done
staging_path="$(mktemp -d "$DESTINATION_DIRECTORY/.backup-staging-XXXXXXXX")"
cleanup_staging() {
[[ -n "${staging_path:-}" && -d "$staging_path" ]] && rm -rf -- "$staging_path"
}
trap cleanup_staging EXIT HUP INT TERM
mkdir -p -- "$staging_path/config" "$staging_path/secrets"
cp -- "$ENVIRONMENT_FILE" "$staging_path/config/environment.env"
cp -- "$CALENDAR_FILE" "$staging_path/secrets/calendar_ical_urls.json"
cp -- "$TOKEN_FILE" "$staging_path/secrets/organizer_access_token.txt"
chmod 600 -- "$staging_path/config/environment.env" "$staging_path/secrets/calendar_ical_urls.json" "$staging_path/secrets/organizer_access_token.txt"
{
printf '%s\n' 'format=HOBBS_ORGANIZER_CONFIG_BACKUP_V1'
printf 'created_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '%s\n' 'contains_secrets=true'
} >"$staging_path/manifest.txt"
(
cd -- "$staging_path"
tar -czf "$archive_path" config secrets manifest.txt
)
chmod 600 -- "$archive_path"
rm -rf -- "$staging_path"
staging_path=""
trap - EXIT HUP INT TERM
printf 'Created a protected configuration backup at: %s\n' "$archive_path"
printf '%s\n' "The archive contains secrets. Store it in an encrypted or access-controlled backup system."
@@ -0,0 +1,194 @@
[CmdletBinding()]
param(
[ValidateSet("trusted-lan", "bearer")]
[string]$Mode,
[switch]$RotateToken
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$SecretsDirectory = Join-Path $ProjectRoot "secrets"
$CalendarFile = Join-Path $SecretsDirectory "calendar_ical_urls.json"
$TokenFile = Join-Path $SecretsDirectory "organizer_access_token.txt"
$EnvironmentFile = Join-Path $ProjectRoot ".env"
$EnvironmentExample = Join-Path $ProjectRoot ".env.example"
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
function Test-IsWindows {
if ($PSVersionTable.PSEdition -eq "Desktop") { return $true }
return [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
[Runtime.InteropServices.OSPlatform]::Windows
)
}
function Read-HiddenText {
param([Parameter(Mandatory)][string]$Prompt)
$secureValue = Read-Host $Prompt -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
function Read-YesNo {
param(
[Parameter(Mandatory)][string]$Prompt,
[bool]$Default = $false
)
$answer = (Read-Host $Prompt).Trim().ToLowerInvariant()
if (-not $answer) { return $Default }
return $answer -in @("y", "yes")
}
function Test-PrivateGoogleCalendarUrl {
param([Parameter(Mandatory)][string]$Value)
$uri = $null
if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri)) { return $false }
if ($uri.Scheme -ne "https" -or $uri.Host -ne "calendar.google.com") { return $false }
if (-not $uri.IsDefaultPort -or $uri.UserInfo -or $uri.Fragment) { return $false }
return $uri.AbsolutePath -match "^/calendar/ical/[^/]+/(?:private-[^/]+|public)/basic[.]ics$"
}
function Protect-SecretFile {
param([Parameter(Mandatory)][string]$Path)
if (Test-IsWindows) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe $Path "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Could not restrict permissions on a secret file."
}
}
else {
& chmod 600 -- $Path
if ($LASTEXITCODE -ne 0) {
throw "Could not restrict permissions on a secret file."
}
}
}
function Write-SecretFile {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][AllowEmptyString()][string]$Content
)
$directory = Split-Path -Parent $Path
$temporaryPath = Join-Path $directory (".configure-" + [Guid]::NewGuid().ToString("N") + ".tmp")
try {
[IO.File]::WriteAllText($temporaryPath, $Content, $Utf8NoBom)
Protect-SecretFile -Path $temporaryPath
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
Protect-SecretFile -Path $Path
}
finally {
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item -LiteralPath $temporaryPath -Force
}
}
}
function New-AccessToken {
$bytes = New-Object byte[] 48
$generator = [Security.Cryptography.RandomNumberGenerator]::Create()
try {
$generator.GetBytes($bytes)
}
finally {
$generator.Dispose()
}
return [Convert]::ToBase64String($bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
}
if (-not (Test-Path -LiteralPath $EnvironmentExample -PathType Leaf)) {
throw "Missing .env.example. Run this script from an intact release bundle."
}
New-Item -ItemType Directory -Path $SecretsDirectory -Force | Out-Null
if (-not (Test-IsWindows)) {
& chmod 700 -- $SecretsDirectory
if ($LASTEXITCODE -ne 0) { throw "Could not restrict the secrets directory permissions." }
}
if (-not (Test-Path -LiteralPath $EnvironmentFile)) {
Copy-Item -LiteralPath $EnvironmentExample -Destination $EnvironmentFile
Write-Host "Created .env from .env.example."
}
else {
Write-Host "Preserved the existing .env file."
}
$writeCalendars = -not (Test-Path -LiteralPath $CalendarFile -PathType Leaf)
if (-not $writeCalendars) {
$writeCalendars = Read-YesNo "A calendar secret already exists. Replace it? [y/N]"
}
if ($writeCalendars) {
$calendarUrls = [Collections.Generic.List[string]]::new()
Write-Host "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished."
while ($true) {
$calendarUrl = Read-HiddenText ("Calendar URL #{0}" -f ($calendarUrls.Count + 1))
if ([string]::IsNullOrWhiteSpace($calendarUrl)) { break }
if (-not (Test-PrivateGoogleCalendarUrl -Value $calendarUrl.Trim())) {
Write-Warning "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved."
continue
}
$calendarUrls.Add($calendarUrl.Trim())
}
if ($calendarUrls.Count -eq 0) {
throw "At least one calendar URL is required when creating the calendar secret."
}
$calendarJson = ConvertTo-Json -InputObject @($calendarUrls.ToArray()) -Compress
Write-SecretFile -Path $CalendarFile -Content ($calendarJson + [Environment]::NewLine)
Write-Host ("Saved {0} calendar source(s) without displaying their URLs." -f $calendarUrls.Count)
}
else {
Protect-SecretFile -Path $CalendarFile
Write-Host "Preserved the existing calendar secret."
}
$hasExistingToken = (Test-Path -LiteralPath $TokenFile -PathType Leaf) -and ((Get-Item -LiteralPath $TokenFile).Length -gt 0)
if (-not $Mode) {
$defaultMode = if ($hasExistingToken) { "bearer" } else { "trusted-lan" }
$selectedMode = (Read-Host "Access mode [trusted-lan/bearer] (default: $defaultMode)").Trim().ToLowerInvariant()
$Mode = if ($selectedMode) { $selectedMode } else { $defaultMode }
if ($Mode -notin @("trusted-lan", "bearer")) {
throw "Access mode must be trusted-lan or bearer."
}
}
if ($Mode -eq "trusted-lan") {
if ($hasExistingToken -and -not (Read-YesNo "This will erase the current access token and disable bearer authentication. Continue? [y/N]")) {
throw "Configuration cancelled before changing the access mode."
}
Write-SecretFile -Path $TokenFile -Content ""
Write-Host "Configured trusted-LAN mode with an empty token file."
}
elseif ($hasExistingToken -and -not $RotateToken) {
Protect-SecretFile -Path $TokenFile
Write-Host "Preserved the existing bearer token. Use -RotateToken to replace it."
}
else {
$accessToken = New-AccessToken
try {
Write-SecretFile -Path $TokenFile -Content ($accessToken + [Environment]::NewLine)
}
finally {
$accessToken = $null
}
Write-Host "Generated and stored a new bearer token. The token was not displayed."
}
Write-Host "Configuration is ready. Review .env, then run: docker compose up -d --build"
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
SECRETS_DIRECTORY="$PROJECT_ROOT/secrets"
CALENDAR_FILE="$SECRETS_DIRECTORY/calendar_ical_urls.json"
TOKEN_FILE="$SECRETS_DIRECTORY/organizer_access_token.txt"
ENVIRONMENT_FILE="$PROJECT_ROOT/.env"
ENVIRONMENT_EXAMPLE="$PROJECT_ROOT/.env.example"
MODE=""
ROTATE_TOKEN=0
usage() {
printf '%s\n' "Usage: $0 [--mode trusted-lan|bearer] [--rotate-token]" >&2
}
while (($#)); do
case "$1" in
--mode)
(($# >= 2)) || { usage; exit 2; }
MODE="$2"
shift 2
;;
--mode=*)
MODE="${1#*=}"
shift
;;
--rotate-token)
ROTATE_TOKEN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage
exit 2
;;
esac
done
if [[ -n "$MODE" && "$MODE" != "trusted-lan" && "$MODE" != "bearer" ]]; then
printf '%s\n' "Access mode must be trusted-lan or bearer." >&2
exit 2
fi
if [[ ! -f "$ENVIRONMENT_EXAMPLE" ]]; then
printf '%s\n' "Missing .env.example. Run this script from an intact release bundle." >&2
exit 1
fi
mkdir -p -- "$SECRETS_DIRECTORY"
chmod 700 -- "$SECRETS_DIRECTORY"
if [[ ! -e "$ENVIRONMENT_FILE" ]]; then
cp -- "$ENVIRONMENT_EXAMPLE" "$ENVIRONMENT_FILE"
chmod 600 -- "$ENVIRONMENT_FILE"
printf '%s\n' "Created .env from .env.example."
else
printf '%s\n' "Preserved the existing .env file."
fi
replace_calendars=1
if [[ -f "$CALENDAR_FILE" ]]; then
read -r -p "A calendar secret already exists. Replace it? [y/N] " calendar_answer
case "${calendar_answer,,}" in
y|yes) replace_calendars=1 ;;
*) replace_calendars=0 ;;
esac
fi
if ((replace_calendars)); then
calendar_pattern='^https://calendar[.]google[.]com/calendar/ical/[A-Za-z0-9._~%+@-]+/(private-[A-Za-z0-9._~%+@-]+|public)/basic[.]ics$'
calendar_urls=()
printf '%s\n' "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished."
while true; do
printf 'Calendar URL #%d: ' "$(( ${#calendar_urls[@]} + 1 ))" >&2
IFS= read -r -s calendar_url
printf '\n' >&2
[[ -n "$calendar_url" ]] || break
if [[ ! "$calendar_url" =~ $calendar_pattern ]]; then
printf '%s\n' "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved." >&2
unset calendar_url
continue
fi
calendar_urls+=("$calendar_url")
unset calendar_url
done
if ((${#calendar_urls[@]} == 0)); then
printf '%s\n' "At least one calendar URL is required when creating the calendar secret." >&2
exit 1
fi
calendar_temporary="$(mktemp "$SECRETS_DIRECTORY/.calendar-XXXXXXXX")"
cleanup_calendar() {
[[ -n "${calendar_temporary:-}" && -e "$calendar_temporary" ]] && rm -f -- "$calendar_temporary"
}
trap cleanup_calendar EXIT HUP INT TERM
chmod 600 -- "$calendar_temporary"
{
printf '['
for calendar_index in "${!calendar_urls[@]}"; do
((calendar_index == 0)) || printf ','
printf '"%s"' "${calendar_urls[$calendar_index]}"
done
printf ']\n'
} >"$calendar_temporary"
mv -f -- "$calendar_temporary" "$CALENDAR_FILE"
calendar_temporary=""
trap - EXIT HUP INT TERM
chmod 600 -- "$CALENDAR_FILE"
printf 'Saved %d calendar source(s) without displaying their URLs.\n' "${#calendar_urls[@]}"
unset calendar_urls
else
chmod 600 -- "$CALENDAR_FILE"
printf '%s\n' "Preserved the existing calendar secret."
fi
has_existing_token=0
[[ -s "$TOKEN_FILE" ]] && has_existing_token=1
if [[ -z "$MODE" ]]; then
default_mode="trusted-lan"
((has_existing_token)) && default_mode="bearer"
read -r -p "Access mode [trusted-lan/bearer] (default: $default_mode) " selected_mode
MODE="${selected_mode:-$default_mode}"
if [[ "$MODE" != "trusted-lan" && "$MODE" != "bearer" ]]; then
printf '%s\n' "Access mode must be trusted-lan or bearer." >&2
exit 2
fi
fi
if [[ "$MODE" == "trusted-lan" ]]; then
if ((ROTATE_TOKEN)); then
printf '%s\n' "--rotate-token can only be used with bearer mode." >&2
exit 2
fi
if ((has_existing_token)); then
read -r -p "This will erase the current access token and disable bearer authentication. Continue? [y/N] " erase_answer
case "${erase_answer,,}" in
y|yes) ;;
*) printf '%s\n' "Configuration cancelled before changing the access mode." >&2; exit 1 ;;
esac
fi
token_temporary="$(mktemp "$SECRETS_DIRECTORY/.token-XXXXXXXX")"
chmod 600 -- "$token_temporary"
: >"$token_temporary"
mv -f -- "$token_temporary" "$TOKEN_FILE"
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Configured trusted-LAN mode with an empty token file."
elif ((has_existing_token)) && ((ROTATE_TOKEN == 0)); then
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Preserved the existing bearer token. Use --rotate-token to replace it."
else
token_temporary="$(mktemp "$SECRETS_DIRECTORY/.token-XXXXXXXX")"
cleanup_token() {
[[ -n "${token_temporary:-}" && -e "$token_temporary" ]] && rm -f -- "$token_temporary"
}
trap cleanup_token EXIT HUP INT TERM
chmod 600 -- "$token_temporary"
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 48 2>/dev/null | tr -d '\r\n=' | tr '+/' '-_' >"$token_temporary"
elif command -v base64 >/dev/null 2>&1 && [[ -r /dev/urandom ]]; then
dd if=/dev/urandom bs=48 count=1 2>/dev/null | base64 | tr -d '\r\n=' | tr '+/' '-_' >"$token_temporary"
else
printf '%s\n' "Bearer mode requires openssl, or base64 with /dev/urandom." >&2
exit 1
fi
[[ -s "$token_temporary" ]] || { printf '%s\n' "Secure token generation failed." >&2; exit 1; }
mv -f -- "$token_temporary" "$TOKEN_FILE"
token_temporary=""
trap - EXIT HUP INT TERM
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Generated and stored a new bearer token. The token was not displayed."
fi
printf '%s\n' "Configuration is ready. Review .env, then run: docker compose up -d --build"
@@ -0,0 +1,97 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Archive,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$Archive = [IO.Path]::GetFullPath($Archive)
if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) {
throw "The backup archive does not exist."
}
if (-not $Force) {
$answer = (Read-Host "Restore will overwrite the current .env and runtime secrets. Continue? [y/N]").Trim().ToLowerInvariant()
if ($answer -notin @("y", "yes")) {
throw "Restore cancelled."
}
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
$allowedEntries = @(
"config/environment.env",
"secrets/calendar_ical_urls.json",
"secrets/organizer_access_token.txt",
"manifest.txt"
)
$totalBytes = 0L
foreach ($entry in $zip.Entries) {
$entryName = $entry.FullName.Replace("\", "/").TrimEnd("/")
if (-not $entryName) { continue }
if ($entryName -notin $allowedEntries -and $entryName -notin @("config", "secrets")) {
throw "The archive contains an unexpected path and will not be restored."
}
$totalBytes += $entry.Length
}
if ($totalBytes -gt 10MB) {
throw "The configuration archive is unexpectedly large."
}
}
finally {
$zip.Dispose()
}
$stagingPath = Join-Path $ProjectRoot (".restore-staging-" + [Guid]::NewGuid().ToString("N"))
$stagingFullPath = [IO.Path]::GetFullPath($stagingPath)
$projectPrefix = $ProjectRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if (-not $stagingFullPath.StartsWith($projectPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to create a restore staging directory outside the application directory."
}
try {
Expand-Archive -LiteralPath $Archive -DestinationPath $stagingPath
$restoredEnvironment = Join-Path $stagingPath "config/environment.env"
$restoredCalendars = Join-Path $stagingPath "secrets/calendar_ical_urls.json"
$restoredToken = Join-Path $stagingPath "secrets/organizer_access_token.txt"
foreach ($required in @($restoredEnvironment, $restoredCalendars, $restoredToken)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "The archive is missing required configuration files."
}
}
$secretsDirectory = Join-Path $ProjectRoot "secrets"
New-Item -ItemType Directory -Path $secretsDirectory -Force | Out-Null
Copy-Item -LiteralPath $restoredEnvironment -Destination (Join-Path $ProjectRoot ".env") -Force
Copy-Item -LiteralPath $restoredCalendars -Destination (Join-Path $secretsDirectory "calendar_ical_urls.json") -Force
Copy-Item -LiteralPath $restoredToken -Destination (Join-Path $secretsDirectory "organizer_access_token.txt") -Force
$secretFiles = @(
(Join-Path $secretsDirectory "calendar_ical_urls.json"),
(Join-Path $secretsDirectory "organizer_access_token.txt")
)
if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
foreach ($secretFile in $secretFiles) {
& icacls.exe $secretFile "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." }
}
}
else {
& chmod 600 -- @secretFiles
if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." }
}
}
finally {
if (Test-Path -LiteralPath $stagingFullPath) {
Remove-Item -LiteralPath $stagingFullPath -Recurse -Force
}
}
Write-Host "Configuration restored. Restart with: docker compose up -d --build"
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
FORCE=0
usage() {
printf '%s\n' "Usage: $0 [--force] BACKUP.tar.gz" >&2
}
if [[ "${1:-}" == "--force" ]]; then
FORCE=1
shift
fi
[[ $# -eq 1 ]] || { usage; exit 2; }
archive_argument="$1"
archive_directory="$(cd -- "$(dirname -- "$archive_argument")" && pwd -P)"
archive_path="$archive_directory/$(basename -- "$archive_argument")"
[[ -f "$archive_path" ]] || { printf '%s\n' "The backup archive does not exist." >&2; exit 1; }
command -v tar >/dev/null 2>&1 || { printf '%s\n' "tar is required to restore a backup." >&2; exit 1; }
if ((FORCE == 0)); then
read -r -p "Restore will overwrite the current .env and runtime secrets. Continue? [y/N] " restore_answer
case "${restore_answer,,}" in
y|yes) ;;
*) printf '%s\n' "Restore cancelled." >&2; exit 1 ;;
esac
fi
while IFS= read -r archive_entry; do
normalized_entry="${archive_entry%/}"
case "$normalized_entry" in
config|config/environment.env|secrets|secrets/calendar_ical_urls.json|secrets/organizer_access_token.txt|manifest.txt) ;;
*) printf '%s\n' "The archive contains an unexpected path and will not be restored." >&2; exit 1 ;;
esac
done < <(tar -tzf "$archive_path")
staging_path="$(mktemp -d "$PROJECT_ROOT/.restore-staging-XXXXXXXX")"
cleanup_staging() {
[[ -n "${staging_path:-}" && -d "$staging_path" ]] && rm -rf -- "$staging_path"
}
trap cleanup_staging EXIT HUP INT TERM
tar -xzf "$archive_path" -C "$staging_path"
for required_file in \
"$staging_path/config/environment.env" \
"$staging_path/secrets/calendar_ical_urls.json" \
"$staging_path/secrets/organizer_access_token.txt"; do
[[ -f "$required_file" && ! -L "$required_file" ]] || {
printf '%s\n' "The archive is missing a required regular file." >&2
exit 1
}
done
mkdir -p -- "$PROJECT_ROOT/secrets"
cp -- "$staging_path/config/environment.env" "$PROJECT_ROOT/.env"
cp -- "$staging_path/secrets/calendar_ical_urls.json" "$PROJECT_ROOT/secrets/calendar_ical_urls.json"
cp -- "$staging_path/secrets/organizer_access_token.txt" "$PROJECT_ROOT/secrets/organizer_access_token.txt"
chmod 600 -- "$PROJECT_ROOT/.env" "$PROJECT_ROOT/secrets/calendar_ical_urls.json" "$PROJECT_ROOT/secrets/organizer_access_token.txt"
rm -rf -- "$staging_path"
staging_path=""
trap - EXIT HUP INT TERM
printf '%s\n' "Configuration restored. Restart with: docker compose up -d --build"
@@ -0,0 +1,4 @@
# Runtime secrets are created by scripts/configure.* and must never be committed.
*
!.gitignore
!calendar_ical_urls.example.json
@@ -0,0 +1,3 @@
[
"https://calendar.google.com/calendar/ical/your-calendar-id%40group.calendar.google.com/private-REPLACE_WITH_YOUR_PRIVATE_KEY/basic.ics"
]
@@ -0,0 +1,39 @@
# Hobbs' Family Organizer App for NVIDIA Shield
Android TV wrapper for the private Hobbs family organizer dashboard. Secrets are never stored in source or the APK; configuration is provisioned into app-private files over ADB.
## Dashboard configuration
The wrapper supports both the hosted Site and a self-hosted server:
- No `files/base_url`: use `https://dayline-tv-jeremy.tautfoal1.chatgpt.site/` with the legacy `files/service_token` and `OAI-Sites-Authorization` header.
- `files/base_url` present: use that exact origin. An optional `files/access_token` is sent as `Authorization: Bearer ...`.
- HTTP additionally requires `files/allow_insecure_http` and is restricted to private LAN IPs or `.local`, `.lan`, and `.home.arpa` names.
Every navigation, resource request, and redirect is restricted to the configured scheme, host, and effective port. TLS errors are always rejected. The network policy trusts system certificates and user-installed CAs so an internal CA can be used without disabling certificate validation.
Use `scripts/configure-shield.ps1` to write local settings. The script streams tokens through ADB standard input so they never appear in command arguments or shell history:
```powershell
.\scripts\configure-shield.ps1 `
-Device 192.168.86.179:5555 `
-BaseUrl https://organizer.home.arpa/ `
-PromptForToken `
-AdbPath C:\path\to\adb.exe
```
For an unauthenticated HTTP service on a trusted LAN, omit `-PromptForToken` and add `-AllowInsecureHttp`. Run `scripts/restore-hosted-site.ps1` to remove only the local settings and return to the preserved hosted token.
The daily 6:30 AM wake-and-launch schedule is enabled per device by creating the app-private `files/daily_wake_0630_enabled` flag. When enabled, the alarm is restored after app updates, device restarts, and wall-clock or timezone changes. It can wake a sleeping Shield, but not one that is fully powered off or disconnected from power. HDMI-CEC power control must be enabled on the Shield and television for the TV to wake with it.
## Included APK identity
- File: `Hobbs-Family-Organizer-Local-v1.4.apk`
- Package: `com.dayline.tv`
- Version: `1.4` (`versionCode 5`)
- APK SHA-256: `A52B91E3ED169CAD985E6F70644BD13F6DD88FFFAAA8D9F1CA47928418495412`
- Signing certificate SHA-256: `27a386f6529b25831ed4139859d308a0ea4351b16dfc92a5ffd159a4227ccafe`
The signing certificate matches the installed v1.3 build, so `adb install -r` performs an in-place upgrade. The private signing key is not included.
This internal wrapper is a debuggable build because its provisioning and rollback scripts use Android's `run-as` command to write app-private configuration without exposing tokens in ADB arguments. Disable network debugging on the Shield after provisioning and re-enable it only for controlled maintenance.
@@ -0,0 +1,227 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Device,
[Parameter(Mandatory = $true)]
[string]$BaseUrl,
[string]$AdbPath = "adb",
[switch]$AllowInsecureHttp,
[switch]$PromptForToken,
[string]$TokenFile,
[switch]$NoLaunch
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ($PromptForToken -and $PSBoundParameters.ContainsKey("TokenFile")) {
throw "Use either -PromptForToken or -TokenFile, not both."
}
function Resolve-AdbExecutable {
param([string]$RequestedPath)
if (Test-Path -LiteralPath $RequestedPath -PathType Leaf) {
return (Resolve-Path -LiteralPath $RequestedPath).Path
}
$command = Get-Command -Name $RequestedPath -CommandType Application -ErrorAction SilentlyContinue
if ($null -eq $command) {
throw "ADB was not found. Install Android platform-tools or pass -AdbPath with the full path to adb.exe."
}
return $command.Source
}
function Invoke-Adb {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[AllowNull()]
[string]$StandardInput = $null
)
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $script:ResolvedAdbPath
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardInput = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in $Arguments) {
[void]$startInfo.ArgumentList.Add($argument)
}
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "ADB could not be started."
}
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
if ($null -ne $StandardInput) {
$process.StandardInput.Write($StandardInput)
}
$process.StandardInput.Close()
$process.WaitForExit()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
if ($process.ExitCode -ne 0) {
$detail = $stderr.Trim()
if ([string]::IsNullOrWhiteSpace($detail)) {
$detail = $stdout.Trim()
}
throw "ADB failed with exit code $($process.ExitCode): $detail"
}
return $stdout.Trim()
}
function Set-AppPrivateFile {
param(
[Parameter(Mandatory = $true)]
[ValidateSet("base_url", "access_token", "allow_insecure_http")]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$Value
)
$remoteCommand = "run-as com.dayline.tv sh -c 'umask 077; cat > files/.$Name.new && chmod 600 files/.$Name.new && mv files/.$Name.new files/$Name'"
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand) -StandardInput $Value)
}
function Remove-AppPrivateFile {
param(
[Parameter(Mandatory = $true)]
[ValidateSet("access_token", "allow_insecure_http")]
[string]$Name
)
$remoteCommand = "run-as com.dayline.tv sh -c 'rm -f files/$Name files/.$Name.new'"
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand))
}
function Test-PrivateLanHost {
param([string]$HostName)
$normalized = $HostName.Trim().Trim("[", "]").ToLowerInvariant()
if ($normalized -eq "localhost" -or
$normalized.EndsWith(".local") -or
$normalized.EndsWith(".lan") -or
$normalized.EndsWith(".home.arpa")) {
return $true
}
$address = $null
if (-not [System.Net.IPAddress]::TryParse($normalized, [ref]$address)) {
return $false
}
$bytes = $address.GetAddressBytes()
if ($address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) {
return $bytes[0] -eq 10 -or
$bytes[0] -eq 127 -or
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
($bytes[0] -eq 192 -and $bytes[1] -eq 168) -or
($bytes[0] -eq 169 -and $bytes[1] -eq 254)
}
if ($address.Equals([System.Net.IPAddress]::IPv6Loopback)) {
return $true
}
return (($bytes[0] -band 0xFE) -eq 0xFC) -or
($bytes[0] -eq 0xFE -and ($bytes[1] -band 0xC0) -eq 0x80)
}
$script:ResolvedAdbPath = Resolve-AdbExecutable -RequestedPath $AdbPath
$dashboardUri = $null
if (-not [Uri]::TryCreate($BaseUrl.Trim(), [UriKind]::Absolute, [ref]$dashboardUri)) {
throw "BaseUrl is not a valid absolute URL."
}
if ($dashboardUri.Scheme -notin @("http", "https")) {
throw "BaseUrl must use http or https."
}
if (-not [string]::IsNullOrEmpty($dashboardUri.UserInfo) -or
-not [string]::IsNullOrEmpty($dashboardUri.Query) -or
-not [string]::IsNullOrEmpty($dashboardUri.Fragment)) {
throw "BaseUrl cannot contain credentials, a query, or a fragment."
}
if ($dashboardUri.AbsolutePath -notin @("", "/")) {
throw "The organizer must be served from the URL root path."
}
if ($dashboardUri.Scheme -eq "http") {
if (-not $AllowInsecureHttp) {
throw "HTTP requires the explicit -AllowInsecureHttp switch. HTTPS is recommended."
}
if (-not (Test-PrivateLanHost -HostName $dashboardUri.Host)) {
throw "HTTP is restricted to private LAN IP addresses or .local, .lan, and .home.arpa hosts."
}
}
$normalizedBaseUrl = $dashboardUri.GetLeftPart([UriPartial]::Authority) + "/"
$plainToken = $null
try {
if ($PSBoundParameters.ContainsKey("TokenFile")) {
if (-not (Test-Path -LiteralPath $TokenFile -PathType Leaf)) {
throw "TokenFile was not found."
}
$plainToken = (Get-Content -LiteralPath $TokenFile -Raw).Trim()
} elseif ($PromptForToken) {
$secureToken = Read-Host "Local bearer token" -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
try {
$plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
if ($null -ne $plainToken -and $plainToken -notmatch "^[A-Za-z0-9._~-]{32,512}$") {
throw "The bearer token must contain 32 to 512 URL-safe characters."
}
[void](Invoke-Adb -Arguments @("connect", $Device))
$state = Invoke-Adb -Arguments @("-s", $Device, "get-state")
if ($state -ne "device") {
throw "The Shield is not connected and authorized."
}
$packagePath = Invoke-Adb -Arguments @("-s", $Device, "shell", "pm path com.dayline.tv")
if (-not $packagePath.StartsWith("package:")) {
throw "Hobbs' Family Organizer App is not installed on the selected Shield."
}
Set-AppPrivateFile -Name "base_url" -Value $normalizedBaseUrl
if ($null -ne $plainToken) {
Set-AppPrivateFile -Name "access_token" -Value $plainToken
} else {
Remove-AppPrivateFile -Name "access_token"
}
if ($dashboardUri.Scheme -eq "http") {
Set-AppPrivateFile -Name "allow_insecure_http" -Value "enabled"
} else {
Remove-AppPrivateFile -Name "allow_insecure_http"
}
if (-not $NoLaunch) {
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am force-stop com.dayline.tv"))
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am start -W -n com.dayline.tv/.MainActivity"))
}
Write-Host "Configured Hobbs' Family Organizer App on $Device"
Write-Host "Dashboard: $normalizedBaseUrl"
Write-Host "Bearer token: $(if ($null -eq $plainToken) { 'not configured' } else { 'configured' })"
Write-Host "Insecure HTTP: $(if ($dashboardUri.Scheme -eq 'http') { 'enabled for this private LAN origin' } else { 'disabled' })"
Write-Host "The legacy hosted service_token was preserved for rollback."
} finally {
$plainToken = $null
}
@@ -0,0 +1,7 @@
.gradle/
build/
app/build/
local.properties
keystore.properties
*.jks
*.keystore

Some files were not shown because too many files have changed in this diff Show More