Skip to content

Commit bd41d42

Browse files
authored
chore: align halo with the updated homebrew skills (#1002)
* chore: adopt the house .editorconfig and reflow to 100 columns coding-style:lint-format makes .editorconfig the cross-language source of truth for indentation and line width. Adding it moves Prettier off its bare 80-column default (it reads max_line_length), so every touched file reflows. Pure formatting — no behaviour change. The hand-written changes ride in the following commits. * feat(frontend): align with halo-design + spa-frontend Design tokens (themes.ts) had drifted from the canonical colors_and_type.css: - light text.muted #a0a0a0 -> #767676. The old value is ~2.6:1 on white and fails AA; the canonical file calls it out by name. - dark theme brightened: text.main #ededed, muted #9c9c9c, light #6f6f6f. - dark border #1f1f1f -> #3a3a3a. #1f1f1f sat between the body (#0f0f0f) and the surfaces (#1c1c1c/#252525), so dividers and outlined controls vanished. - dark error pink -> #ff8a8a, dark offBackground #404040 -> #4a4a4a. - new onAccent (#1a1a1a) for text on an accent fill (white is ~2.4:1 on it), plus radiusPill, cardPadding and the motion durations. touch-action: manipulation on buttons/labels/summary — kills iOS double-tap-to-zoom, which misfires on quick repeated taps (brightness steppers, view tabs) on a wall panel. Installable PWA: web manifest, the full icon set + scripts/gen-icons.sh to regenerate it from favicon.svg, per-scheme theme-color, and viewport-fit=cover. Icons are full-bleed squares on the light body colour — the OS does its own rounding, and the #525252 ring is invisible on a dark ground. iOS full-height: #root becomes the scroll container and pads off env(safe-area-inset-*), and height is 100vh when installed (html.standalone, set in main.tsx) vs 100dvh in a tab. iOS resolves 100dvh short at cold start in a standalone PWA, leaving a dead band until the device is rotated. Stale-tab guard: frontend and backend ship in one image, so a deploy replaces both and a panel left running serves a stale bundle. A Vite plugin emits version.json holding the baked VITE_HALO_IMAGE_TAG; useDeployReload polls it and reloads on change. Keyed off the image tag, not a semver — a :main rebuild never bumps a semver. * chore: align repo tooling with sibling-app Drop corepack. Yarn is already vendored at .yarn/releases and pinned by yarnPath, so CI and the Dockerfile now invoke it through node. node 25+ ships no bundled corepack, so the old `corepack enable` would have broken on the next node bump — and the Dockerfile needed it on the build step too, not just the install. CI reads node-version-file: frontend/.node-version instead of a hardcoded "24". A literal silently drifts: dependabot bumps the Dockerfile node stage but cannot touch a workflow literal or .node-version. Rust checks widen to the house flags in CI and the pre-commit hook (cargo fmt --all, clippy --workspace --all-targets, test/build --workspace); CI also gained the missing typecheck step. New: cve-scan.yaml (weekly trivy of the :main image, SARIF to the Security tab, report-only; action SHAs resolved fresh), SECURITY.md, a root justfile with a `just dev` that runs both halves and tears them down on one Ctrl-C, and [jobs.check] + background in bacon.toml for the headless dev flow. dependabot gains the docker ecosystem (default-days only — the semver-* keys break parsing for docker and github-actions). The frontend package was still named vite_react_template; renaming it rewrites its workspace entry in yarn.lock. * docs: correct halo scope, routing and icon-migration claims Mobile is no longer out of scope. The family default is that every app is touch-first and installable; only a deliberately desktop-only app opts out, and halo is a touch panel that also gets opened from phones. Documents the PWA and stale-tab invariants that now back that up, plus `just dev`. The layout section claimed TanStack Router (file-based). There is no router — App.tsx switches views from useState. halo-app-design claimed the lucide migration was done. It is not: 5 files use lucide-react, ~12 still render Material Icons through components/Icon.tsx and index.html still loads both Material stylesheets. Say so, and say new code uses lucide.
1 parent fb885d9 commit bd41d42

61 files changed

Lines changed: 703 additions & 630 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.claude/skills/halo-app-design/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@ talking. Optional warm/cool weather hues alongside the accent when data demands.
5252
| Device | wall-mounted touch panel — no hover, big targets/numbers |
5353
| Density | data-dense (clock, energy charts, Hue cards) |
5454
| Motion | drawer unfold (150ms), colon pulse each second, breathing lit bulbs, energy-flow stroke scroll, eased counters |
55-
| Icons | `lucide-react`, inline per-icon import, `currentColor` (migrated off Material Icons) |
55+
| Icons | `lucide-react`, inline per-icon import, `currentColor` — **migration in progress**: 5 files done, ~12 still render Material Icons through `components/Icon.tsx` (and `index.html` still loads the two Material Icons stylesheets). New code uses lucide; convert the Material call sites you touch. |

‎.editorconfig‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# EditorConfig — https://editorconfig.org
2+
# The cross-language source of truth for indentation, line width, and newline
3+
# hygiene. Prettier reads it (indent_style, indent_size, max_line_length,
4+
# end_of_line); editors read it natively; rustfmt ignores it but already matches.
5+
root = true
6+
7+
[*]
8+
charset = utf-8
9+
end_of_line = lf
10+
insert_final_newline = true
11+
trim_trailing_whitespace = true
12+
indent_style = space
13+
indent_size = 2
14+
# Prettier's default is 80. Line width is the one knob most repos override; set
15+
# it here once and every editor + Prettier follows.
16+
max_line_length = 100
17+
18+
# Rust (rustfmt) indents with 4 spaces.
19+
[*.rs]
20+
indent_size = 4
21+
22+
[Makefile]
23+
indent_style = tab
24+
25+
# Markdown keeps trailing whitespace (two trailing spaces = a hard line break).
26+
[*.md]
27+
trim_trailing_whitespace = false
28+
29+
# YAML inherits spaces from [*] above — never set it to tabs (illegal in YAML).

‎.githooks/pre-commit‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ fi
1212

1313
if echo "$STAGED" | grep -q "^backend/"; then
1414
echo "Running backend lint..."
15-
(cd "$ROOT/backend" && cargo fmt --check)
16-
(cd "$ROOT/backend" && cargo clippy -- -D warnings)
15+
(cd "$ROOT/backend" && cargo fmt --all -- --check)
16+
(cd "$ROOT/backend" && cargo clippy --workspace --all-targets -- -D warnings)
1717
fi

‎.github/dependabot.yaml‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@ updates:
3838
- "minor"
3939
- "patch"
4040

41-
# github-actions only honours `default-days` — the semver-* keys silently
42-
# break parsing for this ecosystem, so they're omitted.
41+
# docker + github-actions only honour `default-days` — the semver-* keys
42+
# silently break parsing for these ecosystems, so they're omitted.
43+
- package-ecosystem: "docker"
44+
directory: "/"
45+
schedule:
46+
interval: "weekly"
47+
cooldown:
48+
default-days: 7
49+
4350
- package-ecosystem: "github-actions"
4451
directory: "/"
4552
schedule:

‎.github/workflows/ci.yaml‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,21 @@ jobs:
1717
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
1818
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
1919
with:
20-
node-version: "24"
21-
- run: corepack enable
22-
- run: yarn install --immutable
23-
- run: yarn playwright install chromium
24-
- run: yarn format
25-
- run: yarn lint
26-
- run: yarn test
27-
- run: yarn build
20+
# The pinned major lives in one place; a hardcoded literal here would
21+
# silently drift from the file and the Dockerfile's node stage.
22+
node-version-file: frontend/.node-version
23+
# Yarn is vendored (.yarn/releases/*.cjs) and invoked via node — no
24+
# corepack (node 25+ dropped the bundle).
25+
- name: Frontend checks
26+
run: |
27+
yarn() { node .yarn/releases/yarn-*.cjs "$@"; }
28+
yarn install --immutable
29+
yarn playwright install chromium
30+
yarn format
31+
yarn lint
32+
yarn typecheck
33+
yarn test
34+
yarn build
2835
2936
backend:
3037
name: "Backend"
@@ -40,7 +47,7 @@ jobs:
4047
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
4148
with:
4249
workspaces: backend
43-
- run: cargo fmt --check
44-
- run: cargo clippy -- -D warnings
45-
- run: cargo test
46-
- run: cargo build
50+
- run: cargo fmt --all -- --check
51+
- run: cargo clippy --workspace --all-targets -- -D warnings
52+
- run: cargo test --workspace
53+
- run: cargo build --workspace --release

‎.github/workflows/cve-scan.yaml‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Weekly trivy scan of the published image → SARIF to the Security tab.
2+
# Report-only: never fails the run.
3+
name: CVE scan (GHCR image)
4+
5+
on:
6+
schedule:
7+
- cron: "0 6 * * 1" # Mondays 06:00 UTC
8+
workflow_dispatch:
9+
10+
env:
11+
REGISTRY: ghcr.io
12+
13+
jobs:
14+
scan:
15+
runs-on: ubuntu-latest
16+
permissions:
17+
contents: read
18+
packages: read
19+
security-events: write
20+
21+
steps:
22+
- name: Log in to the Container registry
23+
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
24+
with:
25+
registry: ${{ env.REGISTRY }}
26+
username: ${{ github.actor }}
27+
password: ${{ secrets.GITHUB_TOKEN }}
28+
29+
- name: Trivy scan
30+
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
31+
with:
32+
image-ref: ${{ env.REGISTRY }}/${{ github.repository }}:main
33+
format: sarif
34+
output: trivy.sarif
35+
severity: CRITICAL,HIGH
36+
exit-code: "0"
37+
# Drop CVEs with no upstream fix — the scratch Rust image carries
38+
# almost no userland; self-heals once a fix ships (the CVE resurfaces).
39+
ignore-unfixed: true
40+
env:
41+
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db
42+
43+
- name: Upload SARIF to Security tab
44+
uses: github/codeql-action/upload-sarif@c54b30b7df092240050e69945842bc67aee0f0f4 # v4.37.3
45+
if: always()
46+
with:
47+
sarif_file: trivy.sarif

‎CLAUDE.md‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ siblings [chat](../chat), [scribe](../scribe), [ocular](../ocular),
99

1010
```
1111
backend/ actix-web 4 — Hue/FMI/SolisCloud clients, SQLite (WAL), polling loops
12-
frontend/ Vite + React 19 + Emotion + TanStack Router (file-based)
12+
frontend/ Vite + React 19 + Emotion; views are switched by App.tsx state,
13+
not a router
14+
justfile task runner — `just dev` runs backend (bacon) + frontend together
1315
scripts/ refresh-pv-forecast.sh (cron-driven PV upsert) + misc
1416
documentation/ design notes
1517
.claude/skills/ halo-app-design skill (visual language, brand)
@@ -32,11 +34,25 @@ Per-area instructions in `backend/CLAUDE.md` and `frontend/CLAUDE.md`.
3234
disabled).
3335
- **Hue events.** `GET /api/hue/events` is an SSE stream off a broadcast channel
3436
— live bridge-state pushes, not polling.
37+
- **Installable + touch-first.** The SPA ships a web manifest and a full icon set
38+
(`frontend/scripts/gen-icons.sh` regenerates the PNGs from `favicon.svg` —
39+
rerun and commit after editing it). The layout assumes `viewport-fit=cover`:
40+
`#root` is the scroll container and pads off `env(safe-area-inset-*)`, and
41+
height is `100vh` when installed (`html.standalone`, set in `main.tsx`) vs
42+
`100dvh` in a tab — iOS resolves `100dvh` short at cold start in a PWA.
43+
- **Stale-tab guard.** Frontend and backend ship in one image, so a deploy
44+
replaces both. `vite.config.ts` emits `version.json` holding the baked
45+
`VITE_HALO_IMAGE_TAG`; `useDeployReload` polls it every 60 s and reloads when
46+
it differs. Keyed off the image tag, not a semver — a `:main` rebuild never
47+
bumps a semver.
3548

3649
## Working on this repo
3750

38-
- Backend `:3000` (`PORT`); needs a `.env` with at least `HUE_BRIDGE_ADDRESS` /
39-
`HUE_BRIDGE_USER` (pair via `POST /api/hue/pair`). Run: `cargo run -p halo-backend`.
51+
- `just dev` runs both halves together (backend headless under bacon + Vite) and
52+
tears them down on one Ctrl-C. `just --list` for the rest.
53+
- Backend `:3000` (`PORT`); needs `backend/.env` with at least
54+
`HUE_BRIDGE_ADDRESS` / `HUE_BRIDGE_USER` (pair via `POST /api/hue/pair`).
55+
Alone: `cd backend && bacon` (or `cargo run`).
4056
- Frontend dev `:5173` (`yarn dev`); Vite proxies `/api`, `/hue/events`,
4157
`/status` to `:3000`.
4258
- Key env: `SOLIS_KEY_ID/SECRET/STATION_ID`, `TOMORROW_IO_API_KEY` (fallback
@@ -47,7 +63,9 @@ Per-area instructions in `backend/CLAUDE.md` and `frontend/CLAUDE.md`.
4763

4864
- Authentication / per-user data
4965
- Shared/team dashboards, admin UI
50-
- Mobile/native app (web only, touch-friendly)
66+
- A native app — but the web UI is touch-first and installable (see
67+
"Installable + touch-first"); phones are a supported target, not an
68+
afterthought
5169
- Non-Hue smart-home integrations
5270

5371
If a feature crosses into those areas, raise it before implementing.

‎Dockerfile‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ WORKDIR /app
1111
COPY frontend/package.json frontend/yarn.lock frontend/.yarnrc.yml* ./
1212
# .yarnrc.yml pins yarnPath to a committed release; copy it in before install
1313
COPY frontend/.yarn/ ./.yarn/
14-
RUN corepack enable && yarn install --immutable --network-timeout 1000000
14+
# Invoke the vendored yarn through node — no corepack (node 25+ dropped the
15+
# bundle), so this stage stays independent of the node version.
16+
RUN node .yarn/releases/yarn-*.cjs install --immutable --network-timeout 1000000
1517
COPY frontend/ .
16-
RUN yarn build
18+
RUN node .yarn/releases/yarn-*.cjs build
1719

1820
# --- Stage 2: Build backend dependencies (native, cross-compiled) ---
1921
FROM --platform=$BUILDPLATFORM rust:1-alpine AS backend-deps

‎SECURITY.md‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Security
2+
3+
halo is a self-hosted, single-household wall dashboard. It runs on a home Pi and
4+
is reachable only from the LAN — it is **not** a public or multi-tenant service.
5+
The threat model is: everything on the LAN is trusted, so the controls that
6+
matter are keeping the upstream credentials (Hue bridge user, SolisCloud API
7+
key/secret, weather API key) out of the image and out of the browser, and never
8+
exposing the app beyond the LAN.
9+
10+
## Trust boundaries
11+
12+
- **Network is the boundary — there is no app auth.** Every route is public by
13+
design: no login, no session, no forward-auth, no `DEV_AUTH`. Anyone who can
14+
reach the port can read every reading and toggle every light. This is
15+
deliberate for a wall panel, and the only thing keeping it safe is reachability:
16+
halo sits behind Traefik on a subdomain whose DNS record — public though it is
17+
(`public_dns: True` in `../raspi`) — resolves to the **LAN IP**, with no port
18+
forward. Its route is deliberately **not** in Traefik's `_gated_hosts`, so
19+
there is no oauth2-proxy in front of it either. Making halo reachable from the
20+
internet therefore requires adding auth first; nothing in the app would stop
21+
it otherwise.
22+
23+
- **No per-user isolation, by design.** There is no user model. `user_settings`
24+
is a single global row (`id=1`) shared by every viewer; anything written there
25+
is visible to everyone on the LAN.
26+
27+
- **CORS is `Cors::permissive()`.** Acceptable only because the app is LAN-only
28+
and holds nothing a cross-origin read could escalate — there are no cookies,
29+
no session, and no auth header to steal, so a hostile page can at most do what
30+
any LAN client can already do directly. Tighten this to an origin allowlist if
31+
halo ever gains auth or leaves the LAN.
32+
33+
- **No CSP / security response headers are set.** Same rationale; the SPA is
34+
served from `STATIC_DIR` with no user-supplied HTML anywhere in the response
35+
path. Worth adding if the trust model changes.
36+
37+
- **Outbound fetches.** The backend calls fixed, config-pinned upstreams over
38+
verified TLS: the Hue bridge (LAN), FMI (WFS/WMS/download), tomorrow.io,
39+
SolisCloud, and the spot-price API. Base URLs come from env, not from request
40+
input, so no request can steer an outbound fetch at an arbitrary host. halo is
41+
deliberately **not** in `../raspi`'s `network_restrict.RESTRICTED` — unlike the
42+
LAN-only siblings it needs egress for those upstreams.
43+
44+
- **`POST /api/pv/forecast` is an unauthenticated write.** The cron-driven
45+
`scripts/refresh-pv-forecast.sh` upserts ~66 rows through it. Any LAN client
46+
can also write those rows; the blast radius is a wrong PV forecast until the
47+
next 3-hourly refresh overwrites it.
48+
49+
## Secrets
50+
51+
All secrets are injected at runtime via env, never baked into the image or
52+
committed: `HUE_BRIDGE_USER`, `SOLIS_KEY_ID`, `SOLIS_KEY_SECRET`,
53+
`SOLIS_STATION_ID`, `TOMORROW_IO_API_KEY`. On the Pi they come from
54+
`/etc/secrets/halo.env` (written by `../raspi`'s `tasks/secrets.py`). `.env`,
55+
`.env.pv`, and `*.db*` are gitignored. The container runs as UID 1000 on
56+
`scratch`.
57+
58+
## Accepted risks
59+
60+
- **Unauthenticated light control and history writes from the LAN.** Accepted:
61+
the panel exists to be tapped without a login, and the LAN is the trust
62+
boundary. Revisit if halo is ever exposed beyond the LAN, or if guest/IoT
63+
devices share the same segment.
64+
- **Permissive CORS + no CSP.** Accepted for the same reason (see above).
65+
Revisit alongside any auth work.
66+
67+
## Out of scope
68+
69+
Authentication, per-user data, shared/team dashboards, and rate-limiting against
70+
a hostile LAN client are deliberately not built — see the root `CLAUDE.md`
71+
"Out of scope". Don't add them piecemeal; they change the trust model above.
72+
73+
## Reporting
74+
75+
This is a personal project. Flag an issue privately to the maintainer rather
76+
than opening a public issue with exploit detail.

‎backend/bacon.toml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1+
# Run `bacon` (TUI) for local work, or `bacon --headless -j run` (what the root
2+
# `just dev` recipe invokes) to stream logs into the composed dev output. Both
3+
# auto-reload on source + .env changes; the binary loads backend/.env via
4+
# dotenvy, and watching .env restarts when that changes too.
15
default_job = "run"
26

7+
[jobs.check]
8+
command = ["cargo", "check", "--color", "always"]
9+
need_stdout = false
10+
311
[jobs.run]
4-
command = ["cargo", "run"]
12+
command = ["cargo", "run", "--color", "always"]
513
watch = ["src", ".env"]
614
default_watch = true
715
need_stdout = true
16+
allow_warnings = true
17+
background = true
818
on_change_strategy = "kill_then_restart"
919
apply_gitignore = false

0 commit comments

Comments
 (0)