// 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) } }