// Device registry and desired state (AGENTS.md §7 devices/state, §11 Phase 2). // // The Control Service is the only writer of device config (SYSTEM_MAP.md). // Every mutation appends to the audit log (FR-15), and nothing is ever // hard-deleted (§10 rule 10). package store import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) // ErrNotFound is returned when a device id does not exist. var ErrNotFound = errors.New("device not found") // Platform values allowed by the devices.platform CHECK constraint. const ( PlatformAndroid = "android" PlatformWindows = "windows" ) // ValidPlatform reports whether p is one of the supported platforms. func ValidPlatform(p string) bool { return p == PlatformAndroid || p == PlatformWindows } // Device is one registered device plus its desired state. type Device struct { ID int64 Name string Child string Platform string Topic string Online bool LastSeen *time.Time CreatedAt time.Time UpdatedAt time.Time // Desired state, from the state table. Locked bool Until *time.Time StateAt time.Time } // AuditEntry is one row of the append-only audit log (FR-15). type AuditEntry struct { ID int64 Actor string Action string DeviceID *int64 DeviceName string Detail string CreatedAt time.Time } // selectDevice is the shared projection for reading a device with its state. const selectDevice = ` SELECT d.id, d.name, d.child, d.platform, d.topic, d.online, d.last_seen, d.created_at, d.updated_at, COALESCE(s.locked, 0), s.until, s.updated_at FROM devices d LEFT JOIN state s ON s.device_id = d.id` func scanDevice(row interface { Scan(dest ...any) error }) (*Device, error) { var ( dev Device online int lastSeen, until, stateAt sql.NullString createdAt, updatedAt sql.NullString ) if err := row.Scan( &dev.ID, &dev.Name, &dev.Child, &dev.Platform, &dev.Topic, &online, &lastSeen, &createdAt, &updatedAt, &dev.Locked, &until, &stateAt, ); err != nil { return nil, err } dev.Online = online != 0 dev.LastSeen = parseTimePtr(lastSeen) dev.Until = parseTimePtr(until) if t := parseTime(createdAt.String); !t.IsZero() { dev.CreatedAt = t } if t := parseTime(updatedAt.String); !t.IsZero() { dev.UpdatedAt = t } if t := parseTime(stateAt.String); !t.IsZero() { dev.StateAt = t } return &dev, nil } // ListDevices returns every registered device with its desired state, in // registration order. Expired locks are already cleared by ExpireLocks. func (s *Store) ListDevices(ctx context.Context) ([]Device, error) { rows, err := s.db.QueryContext(ctx, selectDevice+` ORDER BY d.id`) if err != nil { return nil, fmt.Errorf("list devices: %w", err) } defer rows.Close() out := make([]Device, 0, 8) for rows.Next() { dev, err := scanDevice(rows) if err != nil { return nil, fmt.Errorf("scan device: %w", err) } out = append(out, *dev) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list devices: %w", err) } return out, nil } // GetDevice returns one device with its state, or ErrNotFound. func (s *Store) GetDevice(ctx context.Context, id int64) (*Device, error) { dev, err := scanDevice(s.db.QueryRowContext(ctx, selectDevice+` WHERE d.id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("get device: %w", err) } return dev, nil } // CreateDevice inserts a device and its initial (unlocked) state row, and // appends one audit entry. The topic is derived from the name and the new id, // so it is stable across renames. func (s *Store) CreateDevice(ctx context.Context, name, child, platform, actor string) (*Device, error) { name = strings.TrimSpace(name) child = strings.TrimSpace(child) platform = strings.ToLower(strings.TrimSpace(platform)) if name == "" { return nil, errors.New("device name is required") } if len(name) > 80 { return nil, errors.New("device name must be 80 characters or fewer") } if child == "" { return nil, errors.New("child is required") } if len(child) > 40 { return nil, errors.New("child must be 40 characters or fewer") } if !ValidPlatform(platform) { return nil, errors.New(`platform must be "android" or "windows"`) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("create device: %w", err) } defer func() { _ = tx.Rollback() }() // The topic needs the new id, so insert with a placeholder then set it. res, err := tx.ExecContext(ctx, `INSERT INTO devices (name, child, platform, topic) VALUES (?, ?, ?, '')`, name, child, platform) if err != nil { return nil, fmt.Errorf("insert device: %w", err) } id, err := res.LastInsertId() if err != nil { return nil, fmt.Errorf("device id: %w", err) } topic := fmt.Sprintf("restrict/%s_%d/config", slug(name), id) if _, err := tx.ExecContext(ctx, `UPDATE devices SET topic = ? WHERE id = ?`, topic, id); err != nil { return nil, fmt.Errorf("set topic: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO state (device_id, locked, until) VALUES (?, 0, NULL)`, id); err != nil { return nil, fmt.Errorf("insert state: %w", err) } if err := insertAudit(ctx, tx, actor, "device.created", &id, fmt.Sprintf("%s (%s · %s)", name, child, platform)); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit device: %w", err) } return s.GetDevice(ctx, id) } // UpdateDevice renames or reassigns a device. The topic and id never change, so // a device's MQTT identity is stable. It appends one audit entry. func (s *Store) UpdateDevice(ctx context.Context, id int64, name, child, platform, actor string) (*Device, error) { name = strings.TrimSpace(name) child = strings.TrimSpace(child) platform = strings.ToLower(strings.TrimSpace(platform)) if name == "" { return nil, errors.New("device name is required") } if len(name) > 80 { return nil, errors.New("device name must be 80 characters or fewer") } if child == "" { return nil, errors.New("child is required") } if len(child) > 40 { return nil, errors.New("child must be 40 characters or fewer") } if !ValidPlatform(platform) { return nil, errors.New(`platform must be "android" or "windows"`) } res, err := s.db.ExecContext(ctx, `UPDATE devices SET name = ?, child = ?, platform = ?, updated_at = datetime('now') WHERE id = ?`, name, child, platform, id) if err != nil { return nil, fmt.Errorf("update device: %w", err) } n, err := res.RowsAffected() if err != nil { return nil, fmt.Errorf("update device: %w", err) } if n == 0 { return nil, ErrNotFound } if err := s.Audit(ctx, actor, "device.updated", &id, fmt.Sprintf("%s (%s · %s)", name, child, platform)); err != nil { return nil, err } return s.GetDevice(ctx, id) } // SetDeviceLock writes the desired lock state for one device and appends one // audit entry in the same transaction. When locked is false, until is ignored. func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until *time.Time, actor, action string) (*Device, error) { if !locked { until = nil } if locked && until != nil { t := until.UTC() until = &t } if _, err := s.GetDevice(ctx, id); err != nil { return nil, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("set state: %w", err) } defer func() { _ = tx.Rollback() }() var untilArg any detail := "unlocked" if locked { detail = "locked" if until != nil { untilArg = until.Format(time.RFC3339) detail = "locked until " + until.Format(time.RFC3339) } else { detail = "locked (no end time)" } } if _, err := tx.ExecContext(ctx, ` INSERT INTO state (device_id, locked, until, updated_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(device_id) DO UPDATE SET locked = excluded.locked, until = excluded.until, updated_at = datetime('now')`, id, boolToInt(locked), untilArg); err != nil { return nil, fmt.Errorf("set state: %w", err) } if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit state: %w", err) } return s.GetDevice(ctx, id) } // ExpireLocks unlocks every device whose timed lock has passed and appends one // audit entry per device. It returns how many locks it cleared. It is safe to // call often. func (s *Store) ExpireLocks(ctx context.Context) (int, error) { now := time.Now().UTC().Format(time.RFC3339) rows, err := s.db.QueryContext(ctx, `SELECT device_id FROM state WHERE locked = 1 AND until IS NOT NULL AND until <= ?`, now) if err != nil { return 0, fmt.Errorf("expire locks: %w", err) } var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { rows.Close() return 0, fmt.Errorf("scan expired lock: %w", err) } ids = append(ids, id) } if err := rows.Err(); err != nil { rows.Close() return 0, fmt.Errorf("expire locks: %w", err) } rows.Close() for _, id := range ids { if _, err := s.SetDeviceLock(ctx, id, false, nil, "system", "state.expired"); err != nil { return 0, err } } return len(ids), nil } // ListAudit returns the newest audit entries first (FR-15). func (s *Store) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) { if limit <= 0 || limit > 500 { limit = 50 } rows, err := s.db.QueryContext(ctx, ` SELECT a.id, a.actor, a.action, a.device_id, COALESCE(d.name, ''), COALESCE(a.detail, ''), a.created_at FROM audit a LEFT JOIN devices d ON d.id = a.device_id ORDER BY a.id DESC LIMIT ?`, limit) if err != nil { return nil, fmt.Errorf("list audit: %w", err) } defer rows.Close() out := make([]AuditEntry, 0, limit) for rows.Next() { var ( e AuditEntry deviceID sql.NullInt64 created sql.NullString ) if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &deviceID, &e.DeviceName, &e.Detail, &created); err != nil { return nil, fmt.Errorf("scan audit: %w", err) } if deviceID.Valid { id := deviceID.Int64 e.DeviceID = &id } e.CreatedAt = parseTime(created.String) out = append(out, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list audit: %w", err) } return out, nil } // insertAudit appends one audit row using the given executor (a transaction or // the database handle), so callers can audit atomically with a state change. func insertAudit(ctx context.Context, ex interface { ExecContext(context.Context, string, ...any) (sql.Result, error) }, actor, action string, deviceID *int64, detail string) error { var id any if deviceID != nil { id = *deviceID } if _, err := ex.ExecContext(ctx, `INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`, actor, action, id, detail); err != nil { return fmt.Errorf("audit insert: %w", err) } return nil } func boolToInt(b bool) int { if b { return 1 } return 0 } // parseTime accepts the RFC3339 strings this package writes and the // "YYYY-MM-DD HH:MM:SS" strings SQLite's datetime() writes. func parseTime(s string) time.Time { s = strings.TrimSpace(s) if s == "" { return time.Time{} } for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} { if t, err := time.Parse(layout, s); err == nil { return t.UTC() } } return time.Time{} } func parseTimePtr(ns sql.NullString) *time.Time { if !ns.Valid { return nil } t := parseTime(ns.String) if t.IsZero() { return nil } return &t } // slug makes a short ASCII token for an MQTT topic from a device name. func slug(name string) string { var b strings.Builder lastUnderscore := false for _, r := range strings.ToLower(name) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) lastUnderscore = false default: if !lastUnderscore && b.Len() > 0 { b.WriteByte('_') lastUnderscore = true } } } out := strings.Trim(b.String(), "_") if out == "" { return "device" } return out }