Files
compass_teams_nhs/internal/config/config.go
Sam Rolfe 9cbfa7db9b Phase 8: Hub API (/capabilities, read-only /ask, /notify)
The project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md), on this service's own port:

  GET  /capabilities  the routing topics the Hub reads, project "compass_nhs"
  POST /ask           one short, read-only answer about a child's school work
  POST /notify        accept the contract's notification shape, deliver via Apprise

All three require the shared bearer token SCHOOL_HUB_TOKEN; with no token they
answer 503. GET /health now also carries "ok": true and "hub_api".

/ask answers deterministically (no model on the path) from the stored Compass
items, reusing dashboard.Classify so the answer and the dashboard never
disagree. It is one short sentence, for example "Harry has 8 school events
today from 8:55am to 6:00pm and 6 overdue tasks", with the buckets in `data`.
It reads only, enforced by a Reader interface with no write method and by
TestHubAskReadsOnly.

Fail loudly (FR-12): a missing or failed scan is never an empty day. /ask says
due work is unknown, and an answer older than 36 hours carries a stale warning.
Permission follows the contract: a parent may ask about any child; anyone else
only about themselves.

/notify follows the house interpretation already used by internet_use_control:
the contract shape is accepted and delivered to the named person, recorded in
notifications with scope "hub". An unknown person is refused, not misdelivered.

Live proof on .13 (deploy/verify-phase8.sh):
  /health        200, ok true
  /capabilities  200, project compass_nhs; unsigned 401
  /ask           200, the sentence above, duration 1ms
  /notify        400 on a bad level (auth proven, nothing sent), then 200 with
                 a real delivery to Sam on email and WhatsApp

The live Hub registration (HUB_PROJECTS + HUB_PROJECT_COMPASS_NHS_TOKEN +
hub restart) is a change to a running family service and is pending approval.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:47:32 +11:00

442 lines
14 KiB
Go

// 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
// Calendar is the Home Assistant calendar target for important school
// events (AGENTS.md §4 FR-8).
Calendar CalendarConfig
// Apprise is the notification gateway for the morning briefs (FR-9,
// FR-14).
Apprise AppriseConfig
// HubToken authenticates the Hub API (AGENTS.md §4 FR-11):
// GET /capabilities, POST /ask and POST /notify. It is a shared
// service-to-service secret; the Hub sends it. When it is empty those
// endpoints answer 503 and nothing else changes.
HubToken string
}
// HubConfigured reports whether the Hub API can serve authenticated requests.
func (c *Config) HubConfigured() bool {
return strings.TrimSpace(c.HubToken) != ""
}
// CalendarConfig points at Sam's Home Assistant calendar. Every value comes
// from the environment (environment.d); the token never reaches git, the
// database or the logs (AGENTS.md §10 rule 3, AUD-110).
//
// Environment variables:
//
// SCHOOL_HA_URL base URL, for example http://192.168.20.30:8123
// SCHOOL_HA_TOKEN long-lived access token (secret)
// SCHOOL_HA_CALENDAR_ENTITY calendar entity id, for example calendar.school
// SCHOOL_HA_INSECURE true to skip TLS verification (internal CA)
// SCHOOL_HA_TIMEOUT per-request timeout, default 20s
//
// Calendar writing stays off until all three of URL, token and entity are set,
// so the service still starts before the calendar is configured.
type CalendarConfig struct {
BaseURL string
Token string
EntityID string
Insecure bool
Timeout time.Duration
}
// Enabled reports whether the calendar target is fully configured.
func (c CalendarConfig) Enabled() bool {
return c.BaseURL != "" && c.Token != "" && c.EntityID != ""
}
// Missing lists the environment variables that are still unset, in the order
// the operator should set them. An empty result means the target is usable.
func (c CalendarConfig) Missing() []string {
var out []string
if c.BaseURL == "" {
out = append(out, "SCHOOL_HA_URL")
}
if c.Token == "" {
out = append(out, "SCHOOL_HA_TOKEN")
}
if c.EntityID == "" {
out = append(out, "SCHOOL_HA_CALENDAR_ENTITY")
}
return out
}
// AppriseConfig points at the house Apprise API on `.35:8210` (AGENTS.md §8)
// and names the destinations by tag. The school service never holds an email
// address, a phone number or an SMTP password; the Apprise API holds those and
// selects them by tag (AGENTS.md §10 rule 3).
//
// Environment variables:
//
// SCHOOL_APPRISE_URL API base, default http://192.168.20.35:8210
// SCHOOL_APPRISE_KEY stored configuration key (required to send)
// SCHOOL_APPRISE_TOKEN optional bearer token
// SCHOOL_APPRISE_TIMEOUT per-request timeout, default 15s
// SCHOOL_APPRISE_CHANNELS channels to use, default "email,whatsapp"
// SCHOOL_APPRISE_TAGS recipient=tag-base list, default
// "Harry=harry,Finn=finn,Jo=joanna,Sam=sam"
// SCHOOL_APPRISE_PARENTS who gets the combined brief, default "Jo,Sam"
// SCHOOL_NOTIFY_TOKEN bearer token for POST /notify/morning (n8n)
//
// A channel tag is the recipient's tag base plus "_email" or "_whatsapp",
// which is the naming the house Apprise configuration already uses.
type AppriseConfig struct {
BaseURL string
// Key names the stored Apprise configuration that holds every destination.
Key string
// Token is an optional bearer token when the Apprise API is protected.
Token string
// Timeout bounds one delivery attempt.
Timeout time.Duration
// Channels are the channels the morning brief uses, in order. Each entry is
// "email" or "whatsapp".
Channels []string
// Recipients are the people a brief can be sent to, in order.
Recipients []Recipient
// Parents are the recipient names that receive the combined brief.
Parents []string
// NotifyToken guards POST /notify/morning, the endpoint n8n calls.
NotifyToken string
}
// Recipient is one person a brief can go to. TagBase is the Apprise tag prefix,
// for example "harry" for the tags harry_email and harry_whatsapp.
type Recipient struct {
Name string
TagBase string
}
// The channels Phase 7 delivers on (AGENTS.md §13 decision 8).
const (
ChannelEmail = "email"
ChannelWhatsApp = "whatsapp"
)
// DefaultAppriseURL is the house Apprise API on `.35` (AGENTS.md §2a, §8).
const DefaultAppriseURL = "http://192.168.20.35:8210"
// DefaultAppriseTags is the recipient list already present in the house
// Apprise configuration: each boy, and both parents.
const DefaultAppriseTags = "Harry=harry,Finn=finn,Jo=joanna,Sam=sam"
// DefaultAppriseParents is who receives the combined brief (FR-14).
const DefaultAppriseParents = "Jo,Sam"
// Enabled reports whether a brief can be delivered at all.
func (c AppriseConfig) Enabled() bool {
return c.BaseURL != "" && c.Key != ""
}
// Missing lists the environment variables that are still unset, in the order
// the operator should set them. An empty result means the gateway is usable.
func (c AppriseConfig) Missing() []string {
var out []string
if c.BaseURL == "" {
out = append(out, "SCHOOL_APPRISE_URL")
}
if c.Key == "" {
out = append(out, "SCHOOL_APPRISE_KEY")
}
return out
}
// RecipientByName returns the configured recipient with the given name.
func (c AppriseConfig) RecipientByName(name string) (Recipient, bool) {
for _, r := range c.Recipients {
if strings.EqualFold(r.Name, strings.TrimSpace(name)) {
return r, true
}
}
return Recipient{}, false
}
// ChannelTag is the Apprise tag for one recipient and channel, for example
// "harry_email".
func (c AppriseConfig) ChannelTag(r Recipient, channel string) string {
return r.TagBase + "_" + strings.ToLower(strings.TrimSpace(channel))
}
// IsParent reports whether the recipient receives the combined brief.
func (c AppriseConfig) IsParent(name string) bool {
for _, p := range c.Parents {
if strings.EqualFold(strings.TrimSpace(p), strings.TrimSpace(name)) {
return true
}
}
return false
}
// ParentRecipients returns the configured parents, in order.
func (c AppriseConfig) ParentRecipients() []Recipient {
out := make([]Recipient, 0, len(c.Parents))
for _, name := range c.Parents {
if r, ok := c.RecipientByName(name); ok {
out = append(out, r)
}
}
return out
}
// ValidChannels returns the requested channels that are configured, in order,
// and the ones that are not.
func (c AppriseConfig) ValidChannels() ([]string, []string) {
var ok, bad []string
for _, ch := range c.Channels {
switch ch {
case ChannelEmail, ChannelWhatsApp:
ok = append(ok, ch)
default:
bad = append(bad, ch)
}
}
return ok, bad
}
// 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
cfg.Calendar = loadCalendar()
cfg.HubToken = strings.TrimSpace(os.Getenv("SCHOOL_HUB_TOKEN"))
apprise, err := loadApprise()
if err != nil {
return nil, err
}
cfg.Apprise = apprise
return cfg, nil
}
// DBConfig is the minimum a database-only command needs. The parse subcommand
// opens the same SQLite file as the service without requiring the service's
// session secret or login.
type DBConfig struct {
DBPath string
Location *time.Location
Calendar CalendarConfig
Apprise AppriseConfig
}
// LoadDB reads the database path, the family timezone, the calendar target and
// the notification gateway only.
func LoadDB() (*DBConfig, error) {
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
if err != nil {
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
}
apprise, err := loadApprise()
if err != nil {
return nil, err
}
return &DBConfig{
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
Location: loc,
Calendar: loadCalendar(),
Apprise: apprise,
}, nil
}
// loadApprise reads the Apprise gateway settings. Like the calendar target it
// never fails on an empty value: the service boots before the gateway is
// configured, and every send then fails loudly instead of silently.
func loadApprise() (AppriseConfig, error) {
c := AppriseConfig{
BaseURL: strings.TrimRight(env("SCHOOL_APPRISE_URL", DefaultAppriseURL), "/"),
Key: strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_KEY")),
Token: strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_TOKEN")),
Timeout: 15 * time.Second,
Channels: splitList(env("SCHOOL_APPRISE_CHANNELS", ChannelEmail+","+ChannelWhatsApp)),
Parents: splitList(env("SCHOOL_APPRISE_PARENTS", DefaultAppriseParents)),
NotifyToken: strings.TrimSpace(os.Getenv("SCHOOL_NOTIFY_TOKEN")),
}
if v := strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
c.Timeout = d
}
}
recipients, err := parseRecipients(env("SCHOOL_APPRISE_TAGS", DefaultAppriseTags))
if err != nil {
return c, fmt.Errorf("SCHOOL_APPRISE_TAGS: %w", err)
}
c.Recipients = recipients
for _, p := range c.Parents {
if _, ok := c.RecipientByName(p); !ok {
return c, fmt.Errorf("SCHOOL_APPRISE_PARENTS: %q is not in SCHOOL_APPRISE_TAGS", p)
}
}
if _, bad := c.ValidChannels(); len(bad) > 0 {
return c, fmt.Errorf("SCHOOL_APPRISE_CHANNELS: unknown channel(s) %s", strings.Join(bad, ", "))
}
return c, nil
}
// parseRecipients reads the "Name=tagbase,Name=tagbase" recipient list. The tag
// base names the Apprise destinations, so a name that is wrong fails at send
// time with the Apprise error, not here.
func parseRecipients(v string) ([]Recipient, error) {
var out []Recipient
for _, entry := range splitList(v) {
name, tag, found := strings.Cut(entry, "=")
name = strings.TrimSpace(name)
tag = strings.TrimSpace(tag)
if !found || name == "" || tag == "" {
return nil, fmt.Errorf("expected Name=tag entries, got %q", entry)
}
out = append(out, Recipient{Name: name, TagBase: tag})
}
if len(out) == 0 {
return nil, fmt.Errorf("no recipients configured")
}
return out, nil
}
// splitList splits a comma list and drops empty entries.
func splitList(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// loadCalendar reads the Home Assistant calendar target. It never fails: an
// empty value simply leaves the target disabled, so the service boots before
// the calendar is configured and the calendar commands fail loudly instead.
func loadCalendar() CalendarConfig {
timeout := 20 * time.Second
if v := strings.TrimSpace(os.Getenv("SCHOOL_HA_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
timeout = d
}
}
return CalendarConfig{
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SCHOOL_HA_URL")), "/"),
Token: strings.TrimSpace(os.Getenv("SCHOOL_HA_TOKEN")),
EntityID: strings.TrimSpace(os.Getenv("SCHOOL_HA_CALENDAR_ENTITY")),
Insecure: envBool("SCHOOL_HA_INSECURE", false),
Timeout: timeout,
}
}
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)
}
}