Phase 1: Compass/NHS skeleton (Go, SQLite, session login, Docker, Caddy)

Phase 1 of AGENTS.md §11: the app runs on .13 in its own container on port
8792, behind Caddy at school.lab.audasmedia.com.au, with its own session login
and GET /health.

- Go service (Go 1.25, modernc SQLite, no cgo) with its own image.
- SQLite schema per AGENTS.md §7: children, items, scans, notifications,
  session (internal/store/migrations/001_init.sql).
- Separate session login (signed cookie, CSRF double-submit), not SSO.
- HTMX server-rendered shell using the Family Home Lab tokens.css: canvas
  #f6f5f4, action blue #0075de, Inter, 8px grid, hairline borders, pill CTAs,
  dark mode, phone-friendly.
- Fail loudly (FR-12): a missing or failed scan shows as an explicit warning or
  error banner, never as an empty day.
- Dockerfile + docker-compose.yml (own container, own port 8792; uses
  network_mode bridge because .13 has no free default Docker address pool).
- Caddy snippet for .35 in deploy/caddy/, deployment notes, and
  deploy/verify-phase1.sh.

No school scan in this phase; the Compass login and fetch are Phase 2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 13:21:20 +11:00
parent 2fb223b993
commit 7b98e487c0
29 changed files with 2287 additions and 0 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
.git
.gitignore
.gocache
.gobuild
.pi
bin/
*.md
*.db
*.db-shm
*.db-wal
environment.d
deploy

4
.gitignore vendored
View File

@@ -3,9 +3,13 @@
.env.* .env.*
*.local *.local
*.keystore *.keystore
environment.d/*.env
environment.d/*.bak*
# Go # Go
bin/ bin/
.gocache/
.gobuild/
*.exe *.exe
*.test *.test
*.out *.out

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
# ---- build stage ----------------------------------------------------------
# Compass / NHS — its own image, built from source.
FROM golang:1.25-alpine AS build
WORKDIR /src
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/school ./cmd/school
# ---- runtime stage --------------------------------------------------------
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata wget \
&& addgroup -g 10002 -S school \
&& adduser -S -D -H -u 10002 -G school school \
&& mkdir -p /data && chown school:school /data
USER school
WORKDIR /app
COPY --from=build /out/school /app/school
EXPOSE 8792
VOLUME ["/data"]
ENTRYPOINT ["/app/school"]

66
Makefile Normal file
View File

@@ -0,0 +1,66 @@
IMAGE ?= compass-teams-nhs/school:0.1.0-phase1
BUILD_VERSION ?= 0.1.0-phase1
# Bounded docker helper for the Go toolchain. The container runs as the current
# user, so the repository and the module cache stay owned by you.
GO_IMAGE ?= golang:1.25-alpine
GO_RUN = timeout 600 docker run --rm --network host --user "$$(id -u):$$(id -g)" \
-e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \
-v "$$PWD":/src -w /src $(GO_IMAGE)
.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1
help:
@echo "make test run go test ./... in golang:1.25-alpine"
@echo "make vet run go vet ./... in golang:1.25-alpine"
@echo "make build build the container image ($(IMAGE))"
@echo "make up docker compose up -d --build"
@echo "make down docker compose down"
@echo "make logs follow container logs"
@echo "make ps docker compose ps"
@echo "make deps go mod tidy + download (writes go.mod/go.sum)"
@echo "make hash-password generate a bcrypt hash (PASSWORD=...)"
@echo "make gen-secret print a fresh session secret"
@echo "make verify-phase1 prove /health, the login and the Caddy route"
test:
$(GO_RUN) go test ./...
vet:
$(GO_RUN) go vet ./...
deps:
$(GO_RUN) sh -c 'go mod tidy'
fmt:
$(GO_RUN) sh -c 'go fmt ./...'
build:
docker build --build-arg VERSION=$(BUILD_VERSION) -t $(IMAGE) .
up:
docker compose up -d --build
down:
docker compose down
restart:
docker compose restart school
logs:
docker compose logs -f school
ps:
docker compose ps
hash-password:
@test -n "$(PASSWORD)" || (echo "usage: make hash-password PASSWORD=secret"; exit 2)
docker run --rm --entrypoint /app/school $(IMAGE) hash-password '$(PASSWORD)'
gen-secret:
docker run --rm --entrypoint /app/school $(IMAGE) gen-secret
# Phase 1 definition of done: /health 200, login page + session login, and the
# Caddy route on .35.
verify-phase1:
deploy/verify-phase1.sh

80
README.md Normal file
View File

@@ -0,0 +1,80 @@
# Compass / NHS
A school service for Harry and Finn: **Compass** for the daily schedule and
notices, **Teams** for homework and assignments. It stores what is due per child,
puts overdue work first, and tells the family before work is late.
> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 1** (the
> skeleton). See §11 of the spec for the phase order.
## Status — Phase 1
Built and deployed on `.13`:
- Go service, SQLite schema for `children`, `items`, `scans`, `notifications`,
`session` (`internal/store/migrations/001_init.sql`).
- The service's **own session login** — a signed cookie, not SSO.
- Server-rendered **HTMX** shell using the Family Home Lab design tokens
(`internal/web/static/tokens.css`), phone-friendly with dark mode.
- `GET /health`.
- Its own container and port **8792**, behind Caddy at
`school.lab.audasmedia.com.au`.
Not built yet (later phases): the Compass login and fetch, parsing, both
children's data, the dashboard data, the calendar write, notifications, and the
Hub API. It does **not** scan the school in this phase.
### Fail loudly (FR-12)
A missing or failed scan is shown as an explicit warning or error, never as an
empty day. The dashboard reads the `scans` table and shows one of:
- **NO SCAN HAS RUN YET** — amber banner.
- **SCAN FAILED** — red banner with the failure on record.
- **Last scan completed** — green banner.
## Layout
```
cmd/school/ service entry point
internal/config/ environment configuration
internal/store/ SQLite: schema, migrations, queries
internal/auth/ session login (signed cookies, CSRF)
internal/web/ HTTP handlers, templates, static assets
deploy/ Caddy snippet, deployment notes, verify script
environment.d/ env example (the real file is gitignored)
```
## Run
```bash
cp environment.d/school-service.env.example environment.d/school-service.env
chmod 600 environment.d/school-service.env # then fill in the secrets
docker compose up -d --build
```
Generate secrets with the built image:
```bash
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.1.0-phase1 gen-secret
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.1.0-phase1 hash-password 'the-password'
```
Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash
contains `$`, so escape each one as `$$` in the env file.
## Verify
```bash
make verify-phase1
```
It checks the container is healthy, `GET /health` returns 200, the login page
renders and a session login succeeds, and Caddy serves the public URL.
## Rules
- **Read-only against the school.** Never post, message, or submit anything.
- A failed scan must be loud — never report an empty day as "nothing due".
- School credentials and harness session state are secrets; never commit them.
- This project has its own folder, container, and port.

167
cmd/school/main.go Normal file
View File

@@ -0,0 +1,167 @@
// Command school is the Compass / NHS service (school.lab.audasmedia.com.au).
//
// Phase 1 skeleton: Go service, SQLite schema (AGENTS.md §7), the service's own
// session login, HTMX server-rendered shell, and GET /health. It does not scan
// the school; the Compass login and fetch are Phase 2.
package main
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/web"
)
// version is injected with -ldflags at build time.
var version = "dev"
// seedChildren is the fixed set from AGENTS.md: Harry and Finn.
var seedChildren = []string{"Harry", "Finn"}
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "hash-password":
hashPassword(os.Args[2:])
return
case "gen-secret":
genSecret()
return
case "version":
fmt.Println(version)
return
}
}
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "school:", err)
os.Exit(1)
}
}
func run() error {
started := time.Now()
cfg, err := config.Load()
if err != nil {
return err
}
cfg.Version = version
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}))
slog.SetDefault(log)
st, err := store.Open(cfg.DBPath)
if err != nil {
return err
}
defer st.Close()
if err := st.Migrate(context.Background()); err != nil {
return err
}
if err := st.SeedChildren(context.Background(), seedChildren...); err != nil {
return err
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure,
cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword)
if err != nil {
return err
}
if !am.Hashed() {
log.Warn("admin password is plaintext; set SCHOOL_ADMIN_PASSWORD_HASH to use bcrypt")
}
srv, err := web.New(cfg, st, am, log)
if err != nil {
return err
}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Info("school service listening",
"addr", cfg.Addr, "db", cfg.DBPath, "env", cfg.AppEnv, "version", version)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
return err
case sig := <-stop:
log.Info("shutting down", "signal", sig.String())
}
ctxShutdown, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelShutdown()
if err := httpServer.Shutdown(ctxShutdown); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
log.Info("stopped", "uptime", time.Since(started).Round(time.Second).String())
return nil
}
// hashPassword prints a bcrypt hash for the password given as an argument or
// read from stdin. It never prints the password itself.
func hashPassword(args []string) {
var password string
if len(args) > 0 {
password = args[0]
} else {
b, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "school:", err)
os.Exit(1)
}
password = string(b)
}
if password == "" {
fmt.Fprintln(os.Stderr, "school: empty password")
os.Exit(2)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintln(os.Stderr, "school:", err)
os.Exit(1)
}
fmt.Println(string(hash))
}
// genSecret prints a fresh 32-byte hex secret. Use it to set
// SCHOOL_SESSION_SECRET without pasting a secret into a shell history file by
// hand. It never reads or stores anything.
func genSecret() {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
fmt.Fprintln(os.Stderr, "school:", err)
os.Exit(1)
}
fmt.Printf("%x\n", b)
}

58
deploy/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,58 @@
# Deployment — Compass / NHS (school.lab.audasmedia.com.au)
Own image, own container, own port **8792**, host `.13` (nixos-desktop). Caddy
on `.35` (sam-ubuntu1) terminates TLS and proxies to `192.168.20.13:8792`.
## Ports
| Port | Service |
|---|---|
| 8792 | Compass / NHS (this project) |
## Host (.13)
1. Project folder: `/home/sam/paseo/projects/compass_teams_nhs`.
2. Data: the named volume `compass-teams-nhs_compass-teams-nhs-data` at `/data`
(SQLite `school.db`).
3. Create the environment file from the example:
```bash
cd /home/sam/paseo/projects/compass_teams_nhs
cp environment.d/school-service.env.example environment.d/school-service.env
chmod 600 environment.d/school-service.env
# fill SCHOOL_SESSION_SECRET, SCHOOL_ADMIN_PASSWORD_HASH
```
4. Build and start:
```bash
docker compose up -d --build
docker compose ps # compass-teams-nhs Up (healthy) 0.0.0.0:8792->8792/tcp
```
5. Verify:
```bash
make verify-phase1
```
## Caddy (.35)
1. SSH: `ssh sam@192.168.20.35`.
2. Append `deploy/caddy/Caddyfile.snippet` to `/Docker/Containers/caddy/Caddyfile`.
3. Validate and reload (never restart):
```bash
docker exec caddy caddy validate --config /etc/caddy/Caddyfile
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```
4. Test: `curl -sS -o /dev/null -w '%{http_code}\n' https://school.lab.audasmedia.com.au/health`
## Notes
- `.13` has every default Docker address pool subnetted out. This single-container
service therefore uses `network_mode: bridge` and publishes 8792; it does not
create a project network.
- The env file is gitignored. School credentials and harness session state are
secrets (AGENTS.md §10 rule 3); they never enter git.

View File

@@ -0,0 +1,12 @@
# Caddyfile addition for the Compass / NHS service.
# Apply on .35 (sam-ubuntu1, 192.168.20.35) — append to /Docker/Containers/caddy/Caddyfile
# and reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile
#
# The public zone lab.audasmedia.com.au already resolves to 144.6.86.11 -> router
# -> .35. No Pi-hole local record is required.
# Compass / NHS (AUD-103) — school.lab.audasmedia.com.au -> .13:8792
# No basic-auth: the service has its own session login (AGENTS.md §9, §10).
school.lab.audasmedia.com.au {
reverse_proxy 192.168.20.13:8792
}

93
deploy/verify-phase1.sh Executable file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Phase 1 definition of done (AUD-103).
#
# - the container is up and healthy
# - GET /health returns 200 with status ok
# - the login page renders and a session login succeeds
# - the Caddy route on .35 serves school.lab.audasmedia.com.au
#
# Run on .13 from the project folder. It reads the admin password from
# environment.d/school-service.env, never prints it, and never touches the
# school's systems.
set -u
BASE="${SCHOOL_VERIFY_BASE:-http://127.0.0.1:8792}"
PUBLIC="${SCHOOL_VERIFY_PUBLIC:-https://school.lab.audasmedia.com.au}"
ENV_FILE="${SCHOOL_ENV_FILE:-environment.d/school-service.env}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
fail=0
pass() { printf 'PASS %s\n' "$1"; }
bad() { printf 'FAIL %s\n' "$1"; fail=1; }
echo "== container =="
timeout 60 docker compose ps || true
echo
echo "== GET /health =="
code="$(curl -sS -o "$TMP/health.json" -w '%{http_code}' "$BASE/health" || echo 000)"
body="$(cat "$TMP/health.json" 2>/dev/null)"
printf 'HTTP %s\n%s\n' "$code" "$body"
if [ "$code" = "200" ] && printf '%s' "$body" | grep -q '"status":"ok"'; then
pass "/health 200 and status ok"
else
bad "/health did not return 200 status ok"
fi
echo
echo "== login page =="
curl -sS -c "$TMP/cookies" -o "$TMP/login.html" -w 'HTTP %{http_code}\n' "$BASE/login"
if grep -q 'name="csrf"' "$TMP/login.html"; then
pass "login page renders with a csrf token"
else
bad "login page did not render a csrf token"
fi
echo
echo "== session login =="
# Extract the plaintext password or use the hash-only path. With a hash-only
# env file the operator must supply SCHOOL_VERIFY_PASSWORD.
pw="${SCHOOL_VERIFY_PASSWORD:-}"
if [ -z "$pw" ] && [ -f "$ENV_FILE" ]; then
pw="$(sed -n 's/^SCHOOL_ADMIN_PASSWORD=//p' "$ENV_FILE" | head -1)"
fi
csrf="$(grep -o 'name="csrf" value="[^"]*"' "$TMP/login.html" | head -1 | sed 's/.*value="//; s/"//')"
if [ -z "$pw" ]; then
echo "SKIP no SCHOOL_VERIFY_PASSWORD and no plaintext password in $ENV_FILE"
elif [ -z "$csrf" ]; then
bad "no csrf token to submit"
else
code="$(curl -sS -b "$TMP/cookies" -c "$TMP/cookies" -o /dev/null -w '%{http_code}' \
-d "csrf=$csrf" -d "username=sam" -d "password=$pw" "$BASE/login")"
if [ "$code" = "303" ]; then
dash="$(curl -sS -b "$TMP/cookies" -o "$TMP/dash.html" -w '%{http_code}' "$BASE/")"
if [ "$dash" = "200" ] && grep -q 'What is due' "$TMP/dash.html"; then
pass "session login succeeds and the dashboard renders"
else
bad "dashboard did not render after login (HTTP $dash)"
fi
else
bad "login POST returned $code, expected 303"
fi
fi
echo
echo "== Caddy route (.35) =="
code="$(curl -sS -o "$TMP/pub.json" -D "$TMP/pub.headers" -w '%{http_code}' --max-time 25 "$PUBLIC/health" || echo 000)"
printf 'HTTP %s\n' "$code"
grep -i '^server:' "$TMP/pub.headers" 2>/dev/null || true
grep -i '^content-type:' "$TMP/pub.headers" 2>/dev/null || true
if [ "$code" = "200" ] && grep -qi '^server: Caddy' "$TMP/pub.headers"; then
pass "Caddy serves $PUBLIC/health"
else
bad "Caddy route did not answer 200 (HTTP $code)"
fi
echo
if [ "$fail" = "0" ]; then
echo "RESULT: Phase 1 verified"
else
echo "RESULT: Phase 1 verification FAILED"
fi
exit "$fail"

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
# Compass / NHS — its own image, own container, own port (8792).
# Deploy on .13: cd /home/sam/paseo/projects/compass_teams_nhs && docker compose up -d --build
#
# Secrets live in environment.d/school-service.env (gitignored), never here.
name: compass-teams-nhs
services:
school:
build:
context: .
args:
VERSION: "0.1.0-phase1"
image: compass-teams-nhs/school:0.1.0-phase1
container_name: compass-teams-nhs
restart: unless-stopped
env_file:
- environment.d/school-service.env
ports:
- "8792:8792"
volumes:
- compass-teams-nhs-data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8792/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# .13 has every default Docker address pool subnetted out (many projects),
# so this single-container service uses the host's default bridge and does
# not create a project network. The port is published above.
network_mode: bridge
volumes:
compass-teams-nhs-data:

View File

@@ -0,0 +1,30 @@
# Compass / NHS — service environment (EXAMPLE).
#
# Copy to environment.d/school-service.env and fill the secrets in. The real
# file is gitignored; never commit it. Generate values with:
# docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.1.0-phase1 gen-secret
# docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.1.0-phase1 hash-password 'the-password'
# Listen address inside the container. The host maps 8792 -> 8792.
SCHOOL_ADDR=:8792
# SQLite database in the named volume.
SCHOOL_DB_PATH=/data/school.db
# Signs the session cookie. Required, >= 16 characters. Secret.
SCHOOL_SESSION_SECRET=change-me-32-bytes-of-random
# The single dashboard login (Sam). Prefer the bcrypt hash over the plaintext.
SCHOOL_ADMIN_USERNAME=sam
SCHOOL_ADMIN_PASSWORD_HASH=
SCHOOL_ADMIN_PASSWORD=
# Keep true behind HTTPS (Caddy terminates TLS).
SCHOOL_COOKIE_SECURE=true
SCHOOL_SESSION_TTL=12h
# Family timezone for due dates and briefs.
SCHOOL_TIMEZONE=Australia/Sydney
SCHOOL_LOG_LEVEL=info
SCHOOL_APP_ENV=production

20
go.mod Normal file
View File

@@ -0,0 +1,20 @@
module gitea.lab.audasmedia.com.au/sam/compass_teams_nhs
go 1.25
require (
golang.org/x/crypto v0.42.0
modernc.org/sqlite v1.34.5
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)

45
go.sum Normal file
View File

@@ -0,0 +1,45 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

207
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,207 @@
// Package auth implements the service's own session login (AGENTS.md §9 Auth:
// a separate session login, not SSO). Sessions are stateless, signed cookies.
// A random CSRF cookie uses the double-submit pattern for forms.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "school_session"
csrfCookie = "school_csrf"
)
// Manager issues and validates sessions.
type Manager struct {
// Secret signs every cookie. Required.
Secret []byte
// TTL is the session lifetime.
TTL time.Duration
// Secure sets the Secure cookie attribute. Keep true behind HTTPS.
Secure bool
// Username is the single allowed login name.
Username string
// passwordHash is a bcrypt hash, or nil when only a plaintext password is set.
passwordHash []byte
// plaintext is the fallback password, or empty when a hash is set.
plaintext string
}
// New builds a Manager. Exactly one of bcryptHash or plaintext must be set.
func New(secret []byte, ttl time.Duration, secure bool, username, bcryptHash, plaintext string) (*Manager, error) {
if len(secret) < 16 {
return nil, errors.New("session secret too short")
}
m := &Manager{Secret: secret, TTL: ttl, Secure: secure, Username: username}
switch {
case bcryptHash != "":
if _, err := bcrypt.Cost([]byte(bcryptHash)); err != nil {
return nil, fmt.Errorf("SCHOOL_ADMIN_PASSWORD_HASH is not a valid bcrypt hash: %w", err)
}
m.passwordHash = []byte(bcryptHash)
case plaintext != "":
m.plaintext = plaintext
default:
return nil, errors.New("no admin password configured")
}
return m, nil
}
// Hashed reports whether the manager verifies against a bcrypt hash.
func (m *Manager) Hashed() bool { return m.passwordHash != nil }
// CheckCredentials validates a username and password in constant time where
// possible. It never reveals which part was wrong.
func (m *Manager) CheckCredentials(username, password string) bool {
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(m.Username)) == 1
passOK := false
if m.passwordHash != nil {
passOK = bcrypt.CompareHashAndPassword(m.passwordHash, []byte(password)) == nil
} else {
passOK = subtle.ConstantTimeCompare([]byte(password), []byte(m.plaintext)) == 1
}
return userOK && passOK
}
// Issue writes a signed session cookie and the matching CSRF cookie.
func (m *Manager) Issue(w http.ResponseWriter) {
csrf := randomToken()
expires := time.Now().Add(m.TTL).Unix()
payload := strings.Join([]string{m.Username, csrf, strconv.FormatInt(expires, 10)}, "\n")
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: m.sign(payload),
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expires, 0),
MaxAge: int(m.TTL.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: csrf,
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expires, 0),
MaxAge: int(m.TTL.Seconds()),
})
}
// Clear removes the session and CSRF cookies.
func (m *Manager) Clear(w http.ResponseWriter) {
for _, name := range []string{sessionCookie, csrfCookie} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
}
// Authenticated reports whether the request carries a valid, unexpired session.
func (m *Manager) Authenticated(r *http.Request) bool {
c, err := r.Cookie(sessionCookie)
if err != nil {
return false
}
payload, ok := m.verify(c.Value)
if !ok {
return false
}
parts := strings.Split(payload, "\n")
if len(parts) != 3 || parts[0] != m.Username {
return false
}
expires, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || time.Now().Unix() > expires {
return false
}
return true
}
// EnsureCSRF returns the CSRF token for a form, setting the cookie if absent.
func (m *Manager) EnsureCSRF(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" {
return c.Value
}
token := randomToken()
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(m.TTL.Seconds()),
})
return token
}
// CheckCSRF compares the submitted form token with the CSRF cookie.
func (m *Manager) CheckCSRF(r *http.Request) bool {
c, err := r.Cookie(csrfCookie)
if err != nil || c.Value == "" {
return false
}
form := r.PostFormValue("csrf")
return subtle.ConstantTimeCompare([]byte(form), []byte(c.Value)) == 1
}
func (m *Manager) sign(payload string) string {
mac := hmac.New(sha256.New, m.Secret)
mac.Write([]byte(payload))
sig := mac.Sum(nil)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
base64.RawURLEncoding.EncodeToString(sig)
}
func (m *Manager) verify(token string) (string, bool) {
encoded, sigEncoded, ok := strings.Cut(token, ".")
if !ok {
return "", false
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
gotSig, err := base64.RawURLEncoding.DecodeString(sigEncoded)
if err != nil {
return "", false
}
mac := hmac.New(sha256.New, m.Secret)
mac.Write(payload)
if !hmac.Equal(gotSig, mac.Sum(nil)) {
return "", false
}
return string(payload), true
}
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("auth: random source failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}

129
internal/config/config.go Normal file
View File

@@ -0,0 +1,129 @@
// Package config loads the Compass/NHS service configuration from the process
// environment. Every secret comes from the environment (environment.d); nothing
// is compiled into the binary and nothing secret is read from a committed file.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
// Embed the IANA time zone database so SCHOOL_TIMEZONE resolves even in a
// container without tzdata.
_ "time/tzdata"
)
// Config is the fully resolved runtime configuration.
type Config struct {
// Addr is the listen address, for example ":8792".
Addr string
// DBPath is the SQLite database file, for example "/data/school.db".
DBPath string
// SessionSecret signs the session cookie. Required, >= 16 bytes.
SessionSecret []byte
// AdminUsername is the single dashboard login name (Sam).
AdminUsername string
// AdminPassword is the plaintext password. Optional if AdminPasswordHash is set.
AdminPassword string
// AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set.
AdminPasswordHash string
// CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS.
CookieSecure bool
// SessionTTL is how long a session cookie stays valid.
SessionTTL time.Duration
// LogLevel is the slog level.
LogLevel slog.Level
// AppEnv is a label such as "production" or "development".
AppEnv string
// Version is injected at build time.
Version string
// Location is the family timezone. Due dates and briefs are wall-clock, so
// this must be the family's zone, not the container default of UTC.
Location *time.Location
}
// DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset.
const DefaultTimezone = "Australia/Sydney"
// Load reads and validates the configuration.
func Load() (*Config, error) {
cfg := &Config{
Addr: env("SCHOOL_ADDR", ":8792"),
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
AdminUsername: env("SCHOOL_ADMIN_USERNAME", "sam"),
AdminPassword: os.Getenv("SCHOOL_ADMIN_PASSWORD"),
AdminPasswordHash: os.Getenv("SCHOOL_ADMIN_PASSWORD_HASH"),
CookieSecure: envBool("SCHOOL_COOKIE_SECURE", true),
AppEnv: env("SCHOOL_APP_ENV", "production"),
}
secret := os.Getenv("SCHOOL_SESSION_SECRET")
if len(secret) < 16 {
return nil, errors.New("SCHOOL_SESSION_SECRET must be set and at least 16 characters")
}
cfg.SessionSecret = []byte(secret)
if cfg.AdminPassword == "" && cfg.AdminPasswordHash == "" {
return nil, errors.New("set SCHOOL_ADMIN_PASSWORD or SCHOOL_ADMIN_PASSWORD_HASH")
}
if cfg.AdminUsername == "" {
return nil, errors.New("SCHOOL_ADMIN_USERNAME must not be empty")
}
ttl, err := time.ParseDuration(env("SCHOOL_SESSION_TTL", "12h"))
if err != nil {
return nil, fmt.Errorf("SCHOOL_SESSION_TTL: %w", err)
}
cfg.SessionTTL = ttl
lvl, err := parseLevel(env("SCHOOL_LOG_LEVEL", "info"))
if err != nil {
return nil, err
}
cfg.LogLevel = lvl
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
if err != nil {
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
}
cfg.Location = loc
return cfg, nil
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func parseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug, nil
case "info", "":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return 0, fmt.Errorf("SCHOOL_LOG_LEVEL: unknown level %q", s)
}
}

View File

@@ -0,0 +1,67 @@
-- Migration 001 — Phase 1 foundation (AGENTS.md §7).
--
-- Every item is stored with its child, source page, type, subject, due date and
-- link (FR-5). Scans are append-only and record success or an explicit error,
-- so a failed scan can never be mistaken for "nothing due" (FR-12).
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
year TEXT,
compass_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- One row per message, task or calendar event read from Compass or Teams.
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
child_id INTEGER NOT NULL REFERENCES children(id),
source TEXT NOT NULL DEFAULT 'compass' CHECK (source IN ('compass', 'teams')),
type TEXT NOT NULL CHECK (type IN ('message', 'task', 'event')),
subject TEXT,
teacher TEXT,
title TEXT NOT NULL,
body TEXT,
due_at TEXT,
url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_items_child ON items(child_id);
CREATE INDEX IF NOT EXISTS idx_items_due ON items(child_id, due_at);
CREATE INDEX IF NOT EXISTS idx_items_type ON items(child_id, type);
-- Append-only scan log (FR-13). ok = 0 with a message is a loud failure.
CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY,
child_id INTEGER REFERENCES children(id),
started_at TEXT NOT NULL DEFAULT (datetime('now')),
finished_at TEXT,
pages_fetched INTEGER NOT NULL DEFAULT 0,
items_found INTEGER NOT NULL DEFAULT 0,
ok INTEGER NOT NULL DEFAULT 0,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_scans_started ON scans(started_at);
-- One row per delivered brief (FR-9, FR-14).
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY,
recipient TEXT NOT NULL,
summary TEXT NOT NULL,
sent_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_notifications_sent ON notifications(sent_at);
-- Harness session state per child. SECRET: session cookies and tokens live
-- here, in the database volume only. Never exported, never logged (AGENTS.md
-- §10 rule 3).
CREATE TABLE IF NOT EXISTS session (
child_id INTEGER PRIMARY KEY REFERENCES children(id),
state TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

271
internal/store/store.go Normal file
View File

@@ -0,0 +1,271 @@
// Package store owns the SQLite database (AGENTS.md §7). It is the single
// source of truth for stored items, scans, notifications and session state.
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"time"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Store wraps the database handle.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path.
func Open(path string) (*Store, error) {
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// modernc/sqlite serialises writes; one connection avoids lock churn.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &Store{db: db}, nil
}
// DB exposes the handle for read-only queries.
func (s *Store) DB() *sql.DB { return s.db }
// Ping verifies the database is reachable.
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Migrate applies every embedded migration that has not run yet. It tracks the
// applied version with PRAGMA user_version, so reruns are safe.
func (s *Store) Migrate(ctx context.Context) error {
files, err := migrationFiles()
if err != nil {
return err
}
var version int
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
for i := version; i < len(files); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, files[i].sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %s: %w", files[i].name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("set user_version %d: %w", i+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", files[i].name, err)
}
}
return nil
}
type migration struct {
name string
sql string
}
func migrationFiles() ([]migration, error) {
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read migrations: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names) // 001_, 002_, ... apply in order
out := make([]migration, 0, len(names))
for _, n := range names {
b, err := fs.ReadFile(migrationFS, "migrations/"+n)
if err != nil {
return nil, fmt.Errorf("read migration %s: %w", n, err)
}
out = append(out, migration{name: n, sql: string(b)})
}
return out, nil
}
// Child is one school child (AGENTS.md §7).
type Child struct {
ID int64
Name string
Year string
CompassID string
CreatedAt time.Time
}
// SeedChildren inserts the given names if they are not present yet. It is
// idempotent, so it is safe to call on every start. It never deletes a child.
func (s *Store) SeedChildren(ctx context.Context, names ...string) error {
for _, name := range names {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO children (name) VALUES (?) ON CONFLICT(name) DO NOTHING`, name); err != nil {
return fmt.Errorf("seed child %q: %w", name, err)
}
}
return nil
}
// ListChildren returns every child, oldest first.
func (s *Store) ListChildren(ctx context.Context) ([]Child, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
FROM children ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list children: %w", err)
}
defer rows.Close()
var out []Child
for rows.Next() {
var c Child
var created string
if err := rows.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil {
return nil, fmt.Errorf("scan child: %w", err)
}
c.CreatedAt = parseTime(created)
out = append(out, c)
}
return out, rows.Err()
}
// Scan is one row of the append-only scan log (FR-13).
type Scan struct {
ID int64 `json:"id"`
ChildID *int64 `json:"child_id,omitempty"`
ChildName string `json:"child,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
PagesFetched int `json:"pages_fetched"`
ItemsFound int `json:"items_found"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// ScanStatus summarises scan health for the dashboard. It exists so that a
// failed scan is shown as an explicit failure and never as an empty day
// (FR-12).
type ScanStatus struct {
// EverRun is false until the first scan is recorded.
EverRun bool `json:"ever_run"`
// Total is the number of scan rows.
Total int `json:"total"`
// Failed is the number of rows with ok = 0.
Failed int `json:"failed"`
// Last is the most recent scan, when one exists.
Last *Scan `json:"last,omitempty"`
}
// LastScan returns the most recent scan row, or nil when none exists.
func (s *Store) LastScan(ctx context.Context) (*Scan, error) {
row := s.db.QueryRowContext(ctx,
`SELECT sc.id, sc.child_id, COALESCE(ch.name, ''), sc.started_at,
sc.finished_at, sc.pages_fetched, sc.items_found, sc.ok, COALESCE(sc.error, '')
FROM scans sc LEFT JOIN children ch ON ch.id = sc.child_id
ORDER BY sc.started_at DESC, sc.id DESC LIMIT 1`)
sc, err := scanRow(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &sc, nil
}
// ScanSummary builds the scan health status shown on the dashboard.
func (s *Store) ScanSummary(ctx context.Context) (ScanStatus, error) {
var st ScanStatus
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0) FROM scans`).
Scan(&st.Total, &st.Failed); err != nil {
return st, fmt.Errorf("scan summary: %w", err)
}
st.EverRun = st.Total > 0
last, err := s.LastScan(ctx)
if err != nil {
return st, err
}
st.Last = last
return st, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanRow(r rowScanner) (Scan, error) {
var sc Scan
var childID sql.NullInt64
var started string
var finished sql.NullString
var ok int
if err := r.Scan(&sc.ID, &childID, &sc.ChildName, &started, &finished,
&sc.PagesFetched, &sc.ItemsFound, &ok, &sc.Error); err != nil {
return sc, err
}
if childID.Valid {
sc.ChildID = &childID.Int64
}
sc.StartedAt = parseTime(started)
if finished.Valid {
t := parseTime(finished.String)
sc.FinishedAt = &t
}
sc.OK = ok == 1
return sc, nil
}
// parseTime accepts SQLite's default datetime format and RFC3339.
func parseTime(s string) time.Time {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} {
if t, err := time.ParseInLocation(layout, s, time.UTC); err == nil {
return t
}
}
return time.Time{}
}
// RecordScan appends one scan row. It exists so later phases and the tests can
// prove the fail-loudly path. A non-empty errMsg records a failure even when
// nothing was fetched.
func (s *Store) RecordScan(ctx context.Context, childID *int64, pages, items int, ok bool, errMsg string) (int64, error) {
okInt := 0
if ok {
okInt = 1
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO scans (child_id, finished_at, pages_fetched, items_found, ok, error)
VALUES (?, datetime('now'), ?, ?, ?, NULLIF(?, ''))`,
childID, pages, items, okInt, errMsg)
if err != nil {
return 0, fmt.Errorf("record scan: %w", err)
}
return res.LastInsertId()
}

324
internal/web/server.go Normal file
View File

@@ -0,0 +1,324 @@
// Package web is the HTTP surface of the Compass/NHS service: /health, the
// service's own session login, and the server-rendered HTMX dashboard
// (AGENTS.md §5, §9).
package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
//go:embed templates/*.html templates/partials/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// Server holds the dependencies for the HTTP handlers.
type Server struct {
cfg *config.Config
store *store.Store
auth *auth.Manager
tmpl *template.Template
log *slog.Logger
}
// New builds a Server and parses the embedded templates.
func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{
"year": func() int { return time.Now().Year() },
"clock": func() string { return time.Now().Format("15:04:05 MST") },
}
tmpl, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, nil
}
// Handler returns the fully wrapped HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
return s.recoverer(s.securityHeaders(s.requestLogger(mux)))
}
// ---------- middleware ----------
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (s *Server) requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"remote", r.RemoteAddr,
"duration", time.Since(start).String(),
)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic", "err", rec, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.auth.Authenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// ---------- handlers ----------
type healthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Database string `json:"database"`
Scans store.ScanStatus `json:"scans"`
Time string `json:"time"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Service: "compass-teams-nhs",
Version: s.cfg.Version,
Database: "ok",
Time: time.Now().UTC().Format(time.RFC3339),
}
code := http.StatusOK
if err := s.store.Ping(r.Context()); err != nil {
resp.Status = "error"
resp.Database = "error"
code = http.StatusServiceUnavailable
}
if st, err := s.store.ScanSummary(r.Context()); err == nil {
resp.Scans = st
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(resp)
}
type loginData struct {
CSRF string
Error string
Username string
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.auth.Authenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, r, "login.html", loginData{CSRF: s.auth.EnsureCSRF(w, r)})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if !s.auth.CheckCSRF(r) {
s.log.Warn("login rejected: bad csrf token", "remote", r.RemoteAddr)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Session expired. Please try again.",
Username: username,
})
return
}
if !s.auth.CheckCredentials(username, r.PostFormValue("password")) {
s.log.Warn("login failed", "user", username, "remote", r.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Incorrect username or password.",
Username: username,
})
return
}
s.auth.Issue(w)
s.log.Info("login success", "user", username, "remote", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
s.auth.Clear(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// scanStatusView is the fail-loudly view of scan health (FR-12). It is shared
// by the dashboard and the HTMX partial.
type scanStatusView struct {
store.ScanStatus
// Level is "none", "error", "ok" or "warn". The template never renders an
// empty day as "nothing due".
Level string
Message string
LastAt string
}
func (s *Server) scanStatus(ctx context.Context) scanStatusView {
st, err := s.store.ScanSummary(ctx)
if err != nil {
s.log.Error("scan summary", "err", err)
return scanStatusView{Level: "error", Message: "Could not read the scan log: " + err.Error()}
}
v := scanStatusView{ScanStatus: st}
if !st.EverRun {
v.Level = "warn"
v.Message = "NO SCAN HAS RUN YET — do not read this as “nothing due”."
return v
}
if st.Last != nil && !st.Last.OK {
v.Level = "error"
v.Message = "SCAN FAILED — the last scan did not complete, so due work is UNKNOWN."
v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04")
return v
}
v.Level = "ok"
v.Message = "Last scan completed."
if st.Last != nil {
v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04")
}
return v
}
type childView struct {
store.Child
}
type dashboardData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
Children []childView
Scan scanStatusView
ServerUp string
ErrMsg string
}
var serverStarted = time.Now()
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
children, err := s.store.ListChildren(ctx)
if err != nil {
s.log.Error("list children", "err", err)
}
views := make([]childView, 0, len(children))
for _, c := range children {
views = append(views, childView{Child: c})
}
s.render(w, r, "dashboard.html", dashboardData{
Title: "Dashboard",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
Children: views,
Scan: s.scanStatus(ctx),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
})
}
func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
s.renderFragment(w, r, "scan-status.html", s.scanStatus(ctx))
}
func (s *Server) render(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render fragment", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}

328
internal/web/static/app.css Normal file
View File

@@ -0,0 +1,328 @@
/* Compass / Teams — Phase 1 UI
Built on the Family Home Lab design tokens (tokens.css).
Rules: canvas-soft app background, white cards, hairlines, one action blue,
pill CTAs, 8px grid, touch targets >= 44px, dark mode from the tokens. */
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 16px; }
body {
margin: 0;
background: var(--canvas-soft);
color: var(--ink-secondary);
font: var(--type-body-md);
-webkit-font-smoothing: antialiased;
min-height: 100dvh;
}
h1, h2, h3 { margin: 0; color: var(--ink); }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---------- Login ---------- */
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: var(--space-lg);
background:
radial-gradient(1200px 600px at 15% 0%, #2b3d93 0%, transparent 60%),
var(--secondary);
}
.login-band {
width: 100%;
max-width: 960px;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: var(--space-xxl);
align-items: center;
padding: var(--space-xxl) 0;
}
.login-eyebrow {
margin: 0 0 var(--space-sm);
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.72);
}
.login-headline {
margin: 0;
font: var(--type-display-1);
letter-spacing: -1.5px;
color: #ffffff;
}
.login-sub {
margin: var(--space-md) 0 0;
font: var(--type-body-md);
color: rgba(255, 255, 255, 0.78);
}
.auth-card {
background: var(--surface);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-2);
padding: var(--space-xl);
}
.auth-card__title {
font: var(--type-heading-2);
margin-bottom: var(--space-lg);
}
.auth-form { display: grid; gap: var(--space-md); }
.auth-error {
margin: 0 0 var(--space-md);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--sticker-red);
border-left-width: 3px;
border-radius: var(--rounded-sm);
color: var(--sticker-red);
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
font: var(--type-body-sm);
}
.field { display: grid; gap: var(--space-xs); }
.field__label {
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.text-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
font: var(--type-body-md);
color: var(--ink);
background: var(--surface);
border: 1px solid #dddddd;
border-radius: var(--rounded-xs);
}
[data-theme="dark"] .text-input { border-color: var(--hairline); }
.text-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--shadow-level-1), 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
.login-foot {
font: var(--type-caption);
color: rgba(255, 255, 255, 0.55);
}
/* ---------- Buttons ---------- */
.button-primary,
.button-utility {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px 20px;
font: var(--type-button);
cursor: pointer;
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
}
.button-primary {
border: 0;
border-radius: var(--rounded-full);
background: var(--primary);
color: var(--on-primary);
}
.button-primary:active { background: var(--primary-active); transform: scale(0.97); }
.button-primary:focus-visible {
outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent);
outline-offset: 2px;
}
.button-utility {
border: 1px solid var(--hairline);
border-radius: var(--rounded-md);
background: var(--surface);
color: var(--ink);
padding: 8px 14px;
}
.button-utility:hover { box-shadow: var(--shadow-level-1); }
/* ---------- App shell ---------- */
.app { display: flex; flex-direction: column; min-height: 100dvh; }
.nav-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg);
background: var(--surface);
border-bottom: 1px solid var(--hairline);
}
.nav-bar__brand {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--ink);
font: var(--type-title);
}
.nav-bar__brand:hover { text-decoration: none; }
.nav-bar__mark {
width: 12px; height: 12px; border-radius: var(--rounded-full);
background: var(--primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
}
.nav-bar__right { display: flex; align-items: center; gap: var(--space-md); }
.nav-bar__user { font: var(--type-body-sm); color: var(--ink-muted); }
.nav-bar__logout { margin: 0; }
.container {
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
padding: var(--space-xxl) var(--space-lg);
display: grid;
gap: var(--space-xxl);
flex: 1;
}
.page-head { display: grid; gap: var(--space-xs); }
.heading-1 { font: var(--type-heading-1); letter-spacing: -0.8px; }
.heading-2 { font: var(--type-heading-2); }
.page-head__sub { margin: 0; color: var(--ink-muted); max-width: 62ch; }
.section { display: grid; gap: var(--space-md); }
.section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
}
.category-pill {
display: inline-flex;
align-items: center;
gap: var(--space-xxs);
padding: 4px 10px;
border-radius: var(--rounded-full);
background: var(--surface-2);
color: var(--ink-muted);
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dot { width: 7px; height: 7px; border-radius: var(--rounded-full); background: var(--ink-faint); }
.dot--faint { background: var(--ink-faint); }
/* ---------- Flash ---------- */
.flash {
margin: 0;
padding: var(--space-sm) var(--space-md);
border-radius: var(--rounded-md);
border: 1px solid var(--hairline);
font: var(--type-body-sm);
}
.flash--error {
border-color: var(--sticker-red);
border-left-width: 3px;
color: var(--sticker-red);
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
}
/* ---------- Scan status (FR-12: fail loudly) ---------- */
.scan {
display: grid;
gap: var(--space-xs);
padding: var(--space-md) var(--space-lg);
border-radius: var(--rounded-lg);
border: 1px solid var(--hairline);
border-left-width: 4px;
background: var(--surface);
box-shadow: var(--shadow-level-1);
}
.scan__head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); }
.scan__pill { background: var(--surface-2); color: var(--ink-muted); }
.scan__message { margin: 0; font: var(--type-body-md); color: var(--ink-secondary); }
.scan__meta { margin: 0; font: var(--type-caption); color: var(--ink-muted); }
.scan--ok { border-left-color: var(--sticker-green); }
.scan--ok .scan__pill { background: color-mix(in srgb, var(--sticker-green) 16%, var(--surface)); color: #0f7a28; }
.scan--warn { border-left-color: var(--sticker-orange); background: color-mix(in srgb, var(--sticker-orange) 5%, var(--surface)); }
.scan--warn .scan__pill { background: color-mix(in srgb, var(--sticker-orange) 16%, var(--surface)); color: var(--sticker-orange-deep); }
.scan--error { border-left-color: var(--sticker-red); background: color-mix(in srgb, var(--sticker-red) 6%, var(--surface)); }
.scan--error .scan__pill { background: color-mix(in srgb, var(--sticker-red) 16%, var(--surface)); color: var(--sticker-red); }
.scan--error .scan__message { color: var(--sticker-red); font-weight: 600; }
/* ---------- Children ---------- */
.child-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-md);
}
.child-card {
display: grid;
gap: var(--space-xxs);
padding: var(--space-md) var(--space-lg);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-1);
}
.child-card__name { margin: 0; font: var(--type-title); color: var(--ink); }
.child-card__meta { margin: 0; font: var(--type-caption); color: var(--ink-muted); }
.child-card__body { margin: var(--space-xs) 0 0; font: var(--type-body-sm); color: var(--ink-muted); }
.empty-state {
display: grid;
gap: var(--space-xs);
padding: var(--space-xl);
text-align: center;
background: var(--surface);
border: 1px dashed var(--hairline);
border-radius: var(--rounded-lg);
}
.empty-state__title { margin: 0; font: var(--type-title); color: var(--ink); }
.empty-state__body { margin: 0; font: var(--type-body-sm); color: var(--ink-muted); }
/* ---------- Footer ---------- */
.app-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--hairline);
background: var(--surface);
font: var(--type-caption);
color: var(--ink-muted);
}
.app-foot__sep { color: var(--ink-faint); }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
.login-band { grid-template-columns: 1fr; gap: var(--space-xl); text-align: left; }
.login-headline { font: var(--type-heading-1); letter-spacing: -0.8px; }
.container { padding: var(--space-lg) var(--space-md); gap: var(--space-xl); }
.nav-bar { padding: var(--space-sm) var(--space-md); }
.nav-bar__user { display: none; }
.section__head { flex-wrap: wrap; }
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
// Theme controller. The design tokens ship both palettes; nothing switches
// between them unless this runs. It follows the OS preference by default and
// remembers an explicit choice. Loaded with defer from /static/, so it is
// allowed by the page's `script-src 'self'` policy (no inline script).
(function () {
"use strict";
var KEY = "webrestrict.theme";
var root = document.documentElement;
var media = window.matchMedia("(prefers-color-scheme: dark)");
var stored = null;
try {
stored = window.localStorage.getItem(KEY);
} catch (e) {
stored = null;
}
function apply() {
var theme = stored || (media.matches ? "dark" : "light");
root.setAttribute("data-theme", theme);
var buttons = document.querySelectorAll("[data-theme-toggle]");
for (var i = 0; i < buttons.length; i++) {
buttons[i].textContent = theme === "dark" ? "Light" : "Dark";
buttons[i].setAttribute(
"aria-label",
"Switch to " + (theme === "dark" ? "light" : "dark") + " theme"
);
}
}
document.addEventListener("click", function (event) {
var target = event.target && event.target.closest
? event.target.closest("[data-theme-toggle]")
: null;
if (!target) {
return;
}
stored = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
try {
window.localStorage.setItem(KEY, stored);
} catch (e) {
/* private mode: the choice just does not persist */
}
apply();
});
if (media.addEventListener) {
media.addEventListener("change", function () {
if (!stored) {
apply();
}
});
}
apply();
})();

View File

@@ -0,0 +1,110 @@
/* ============================================================
Family Home Lab — Design Tokens
Source: DESIGN.md (Notion design language)
Only structural/semantic tokens here. All values map 1:1.
============================================================ */
/* Inter (@fontsource) — self-hosted substitute for NotionInter */
@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/static/fonts/inter-400.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/static/fonts/inter-500.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/static/fonts/inter-600.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("/static/fonts/inter-700.woff2") format("woff2"); }
:root {
/* --- Surface --- */
--canvas-soft: #f6f5f4;
--surface: #ffffff;
--surface-2: #f2f2f1;
--hairline: #e6e6e6;
/* --- Text --- */
--ink: #000000;
--ink-secondary: #31302e;
--ink-muted: #615d59;
--ink-faint: #a39e98;
/* --- Structural accents --- */
--primary: #0075de; /* the ONLY action colour */
--primary-active: #005bab;
--on-primary: #ffffff;
--secondary: #213183; /* deep indigo — login band + greeting only */
/* --- Sticker palette (decorative only) --- */
--sticker-sky: #62aef0; /* chat */
--sticker-purple: #d6b6f6; /* image */
--sticker-pink: #ff64c8; /* video */
--sticker-orange: #dd5b00; /* audio */
--sticker-teal: #2a9d99; /* docs */
--sticker-green: #1aae39; /* status: online/saved */
--sticker-red: #d33a2b; /* status: failed / error (ON_OFF.md decision) */
--sticker-purple-deep: #391c57; /* illustration only */
--sticker-orange-deep: #793400; /* illustration only */
/* --- Typography (Inter, self-hosted via @font-face) --- */
--font-family: "Inter", -apple-system, system-ui, "Segoe UI",
Helvetica, Arial, sans-serif;
/* Font-family is baked into each token below: the CSS `font` shorthand
resets font-family to the UA default (serif) unless a family is written,
and it cannot carry letter-spacing — so weights/sizes ship inline and the
family is appended per token. */
--type-display-1: 700 48px/1.05 var(--font-family);
--type-heading-1: 700 32px/1.1 var(--font-family);
--type-heading-2: 700 22px/1.27 var(--font-family);
--type-title: 600 17px/1.4 var(--font-family);
--type-body-md: 400 15px/1.5 var(--font-family);
--type-body-sm: 400 14px/1.4 var(--font-family);
--type-button: 500 15px/1.4 var(--font-family);
--type-caption: 400 13px/1.43 var(--font-family);
--type-eyebrow: 600 12px/1.33 var(--font-family);
/* --- Layout (base unit 8px) --- */
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 28px;
--space-xxl: 32px;
--container-max: 1200px;
/* --- Elevation --- */
--shadow-level-1: rgba(0, 0, 0, 0.01) 0 0.175px 1px,
rgba(0, 0, 0, 0.02) 0 0.8px 3px,
rgba(0, 0, 0, 0.027) 0 2px 8px,
rgba(0, 0, 0, 0.04) 0 4px 18px;
--shadow-level-2: rgba(0, 0, 0, 0.0122) 0 0.175px 1px,
rgba(0, 0, 0, 0.024) 0 0.8px 3px,
rgba(0, 0, 0, 0.032) 0 2px 8px,
rgba(0, 0, 0, 0.047) 0 4px 18px,
rgba(0, 0, 0, 0.05) 0 23px 52px;
/* --- Shapes --- */
--rounded-xs: 4px;
--rounded-sm: 5px;
--rounded-md: 8px;
--rounded-lg: 12px;
--rounded-full: 9999px;
}
/* Dark mode — flip the semantic tokens only (components use var(--…)). */
[data-theme="dark"] {
--canvas-soft: #141518;
--surface: #1c1e22;
--surface-2: #24262c;
--hairline: #33363d;
--ink: #f5f6f7;
--ink-secondary: #dcdee2;
--ink-muted: #a9adb6;
--ink-faint: #727680;
--primary: #3d8be0;
--primary-active: #5d9ff0;
--on-primary: #ffffff;
--secondary: #10142a;
--shadow-level-1: rgba(0, 0, 0, 0.45) 0 1px 3px;
--shadow-level-2: rgba(0, 0, 0, 0.55) 0 5px 22px;
}
/* Numerals: enable OpenType lnum */
html { font-feature-settings: "lnum" 1; }

View File

@@ -0,0 +1,78 @@
{{define "dashboard.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Compass / Teams</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Compass / Teams
</a>
<div class="nav-bar__right">
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
<form method="post" action="/logout" class="nav-bar__logout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button-utility" type="submit">Sign out</button>
</form>
</div>
</nav>
<main class="container">
<header class="page-head">
<h1 class="heading-1">What is due</h1>
<p class="page-head__sub">Overdue work first. Compass for the schedule, Teams for homework.
This is Phase 1: the skeleton is live, the school scan is not connected yet.</p>
</header>
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
<div hx-get="/partials/scan-status" hx-trigger="every 60s" hx-swap="outerHTML">
{{template "scan-status.html" .Scan}}
</div>
<section class="section" aria-labelledby="children-title">
<div class="section__head">
<h2 id="children-title" class="heading-2">Children</h2>
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Children}} on file</span>
</div>
{{if .Children}}
<ul class="child-list">
{{range .Children}}
<li class="child-card">
<p class="child-card__name">{{.Name}}</p>
<p class="child-card__meta">
{{if .Year}}Year {{.Year}}{{else}}Year not set{{end}}
{{if not .CompassID}}· Compass ID not set{{end}}
</p>
<p class="child-card__body">No items stored yet. The Compass scan arrives in Phase 2.</p>
</li>
{{end}}
</ul>
{{else}}
<div class="empty-state">
<p class="empty-state__title">No children on file</p>
<p class="empty-state__body">Harry and Finn are seeded on first start. If this is empty,
the database was not seeded.</p>
</div>
{{end}}
</section>
</main>
<footer class="app-foot">
<span>Compass / Teams · {{.Version}} · {{.AppEnv}}</span>
<span class="app-foot__sep">·</span>
<span>up {{.ServerUp}}</span>
<span class="app-foot__sep">·</span>
<span>{{year}} Family Home Lab</span>
</footer>
</body>
</html>{{end}}

View File

@@ -0,0 +1,41 @@
{{define "login.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Sign in — Compass / Teams</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/theme.js" defer></script>
</head>
<body class="login-page">
<main class="login-band">
<div class="login-band__inner">
<p class="login-eyebrow">Compass / Teams</p>
<h1 class="login-headline">Good to see you,<br>Sam.</h1>
<p class="login-sub">Sign in to see what Harry and Finn have due.</p>
</div>
<section class="auth-card" aria-labelledby="signin-title">
<h2 id="signin-title" class="auth-card__title">Sign in</h2>
{{if .Error}}<p class="auth-error" role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="auth-form" autocomplete="on">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label class="field">
<span class="field__label">Username</span>
<input class="text-input" type="text" name="username" value="{{.Username}}"
autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label class="field">
<span class="field__label">Password</span>
<input class="text-input" type="password" name="password"
autocomplete="current-password" required>
</label>
<button class="button-primary" type="submit">Sign in</button>
</form>
</section>
</main>
<footer class="login-foot">Family Home Lab · Compass / Teams</footer>
</body>
</html>{{end}}

View File

@@ -0,0 +1,19 @@
{{define "scan-status.html"}}
{{/* Fail-loudly scan banner (FR-12). A missing or failed scan is never shown as
"nothing due". */}}
<section class="scan scan--{{.Level}}" aria-labelledby="scan-title">
<div class="scan__head">
<h2 id="scan-title" class="heading-2">Scan status</h2>
<span class="category-pill scan__pill">
{{if eq .Level "ok"}}healthy
{{else if eq .Level "error"}}failed
{{else if eq .Level "warn"}}never run
{{else}}unknown{{end}}
</span>
</div>
<p class="scan__message">{{.Message}}</p>
{{if .LastAt}}<p class="scan__meta">Last scan: {{.LastAt}}
{{if .Last}}· {{.Last.PagesFetched}} pages · {{.Last.ItemsFound}} items{{end}}</p>{{end}}
{{if gt .Failed 0}}<p class="scan__meta">{{.Failed}} failed scan(s) on record.</p>{{end}}
</section>
{{end}}