// Per-device allow and block lists, and device lookup by MQTT topic // (AGENTS.md §7 blocklist/allowlist, §11 Phase 3). // // The editors for these lists arrive in Phase 6. Phase 3 already publishes them // in the device config, so a device that is onboarded later gets the real lists // without another protocol change. Migration 001 created both tables. package store import ( "context" "database/sql" "errors" "fmt" "strings" ) // DeviceLists returns the site/app values that stay allowed and the values that // are blocked for one device. A row with a NULL device_id applies to every // device (§7). Rows are returned in insertion order, de-duplicated. func (s *Store) DeviceLists(ctx context.Context, id int64) (allow, block []string, err error) { if allow, err = s.listValues(ctx, "allowlist", id); err != nil { return nil, nil, err } if block, err = s.listValues(ctx, "blocklist", id); err != nil { return nil, nil, err } return allow, block, nil } func (s *Store) listValues(ctx context.Context, table string, id int64) ([]string, error) { // The table name is a package constant, never caller input. rows, err := s.db.QueryContext(ctx, `SELECT value FROM `+table+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id) if err != nil { return nil, fmt.Errorf("list %s: %w", table, err) } defer rows.Close() out := make([]string, 0, 4) seen := make(map[string]struct{}, 4) for rows.Next() { var v string if err := rows.Scan(&v); err != nil { return nil, fmt.Errorf("scan %s: %w", table, err) } v = strings.TrimSpace(v) if v == "" { continue } if _, dup := seen[v]; dup { continue } seen[v] = struct{}{} out = append(out, v) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list %s: %w", table, err) } return out, nil } // DeviceByConfigTopic finds a device from a topic the device wrote to, for // example "restrict/harry_phone_2/status". Only the Control Service reads the // topic table, so a topic the service does not know is ignored. func (s *Store) DeviceByConfigTopic(ctx context.Context, topic string) (*Device, error) { dev, err := scanDevice(s.db.QueryRowContext(ctx, selectDevice+` WHERE d.topic = ?`, topic)) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("device by topic: %w", err) } return dev, nil }