Phase 6: schedules, blocklists, allowlists (editors + effective state)
- Weekly schedule editor per device: a Monday-Sunday grid of blocking windows, including windows that cross midnight. - Blocklist/allowlist editor per device: app (Android package / Windows process) and site (hostname) entries, tagged by the device platform. - Effective desired state: a manual lock, an active schedule window, or neither. While blocked the allowlist is honoured (Spotify stays while other apps are blocked). Published retained with reason schedule/manual. - Boundary-aware republish loop so a window turns on and off on time, and manual lock expiry is cleared promptly. - CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so wall-clock windows are evaluated in the family zone, not the container. - Every change is written to the append-only audit log (FR-15). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -34,9 +34,11 @@ type ReportSink interface {
|
||||
}
|
||||
|
||||
// ConfigSource reads the desired state that is published to devices.
|
||||
// EffectiveState combines the manual lock, the weekly schedule and the
|
||||
// allow/block lists (Phase 6).
|
||||
type ConfigSource interface {
|
||||
ListDevices(ctx context.Context) ([]store.Device, error)
|
||||
DeviceLists(ctx context.Context, id int64) (allow, block []string, err error)
|
||||
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
|
||||
}
|
||||
|
||||
// Options configures the link.
|
||||
@@ -60,6 +62,10 @@ type Options struct {
|
||||
|
||||
// Actor is the audit-log actor for reports that arrive over the broker.
|
||||
Actor string
|
||||
// Location is the schedule timezone. Weekly windows are wall-clock, so the
|
||||
// service must evaluate them in the family's zone, not the container's
|
||||
// (usually UTC). Nil means time.Local.
|
||||
Location *time.Location
|
||||
// Log receives structured logs.
|
||||
Log *slog.Logger
|
||||
|
||||
@@ -325,27 +331,43 @@ func (l *Link) noteMessage() {
|
||||
// topic stored with the device. It is the single writer path for config
|
||||
// (AGENTS.md §10 rule 5): only the Control Service publishes.
|
||||
func (l *Link) PublishDeviceConfig(ctx context.Context, dev store.Device) error {
|
||||
allow, block, err := l.opts.Source.DeviceLists(ctx, dev.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := Config{
|
||||
V: ConfigVersion,
|
||||
DeviceID: dev.ID,
|
||||
Name: dev.Name,
|
||||
Platform: dev.Platform,
|
||||
Locked: dev.Locked,
|
||||
Until: dev.Until,
|
||||
Allow: allow,
|
||||
Block: block,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}.Marshal()
|
||||
payload, err := l.buildConfig(ctx, dev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return l.Publish(dev.Topic, payload)
|
||||
}
|
||||
|
||||
// buildConfig computes the effective desired state for one device at the current
|
||||
// time and renders the retained payload. It is separated from the publish so it
|
||||
// can be tested without a broker.
|
||||
func (l *Link) buildConfig(ctx context.Context, dev store.Device) ([]byte, error) {
|
||||
eff, err := l.opts.Source.EffectiveState(ctx, dev, time.Now().In(l.location()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Config{
|
||||
V: ConfigVersion,
|
||||
DeviceID: dev.ID,
|
||||
Name: dev.Name,
|
||||
Platform: dev.Platform,
|
||||
Locked: eff.Locked,
|
||||
Until: eff.Until,
|
||||
Allow: eff.Allow,
|
||||
Block: eff.Block,
|
||||
Reason: eff.Reason,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}.Marshal()
|
||||
}
|
||||
|
||||
// location returns the schedule timezone, defaulting to the process local zone.
|
||||
func (l *Link) location() *time.Location {
|
||||
if l.opts.Location != nil {
|
||||
return l.opts.Location
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
// Publish sends one retained message and waits for the broker to accept it.
|
||||
func (l *Link) Publish(topic string, payload []byte) error {
|
||||
if !l.client.IsConnected() {
|
||||
|
||||
@@ -77,15 +77,19 @@ func deviceFromTopic(topic, suffix string) (string, bool) {
|
||||
// The shape is versioned with "v" and is consumed by the kids' clients in
|
||||
// Phases 4 and 5. Add fields; do not repurpose them.
|
||||
type Config struct {
|
||||
V int `json:"v"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Locked bool `json:"locked"`
|
||||
Until *time.Time `json:"until"`
|
||||
Allow []string `json:"allow"`
|
||||
Block []string `json:"block"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
V int `json:"v"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Locked bool `json:"locked"`
|
||||
Until *time.Time `json:"until"`
|
||||
Allow []string `json:"allow"`
|
||||
Block []string `json:"block"`
|
||||
// Reason is why the device is locked: "schedule" for an active weekly
|
||||
// window, "manual" for a lock from the dashboard. It is omitted when the
|
||||
// device is not locked. It is additive (Phase 6), so v stays 1.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// ConfigVersion is the current schema version of Config.
|
||||
|
||||
@@ -196,8 +196,8 @@ type stubSource struct{}
|
||||
|
||||
func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
|
||||
|
||||
func (stubSource) DeviceLists(ctx context.Context, id int64) ([]string, []string, error) {
|
||||
return nil, nil, nil
|
||||
func (stubSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) {
|
||||
return store.EffectiveState{DeviceID: dev.ID, Allow: []string{}, Block: []string{}}, nil
|
||||
}
|
||||
|
||||
type stubSink struct{}
|
||||
@@ -209,3 +209,73 @@ func (stubSink) SetPresence(ctx context.Context, id int64, online bool, at time.
|
||||
func (stubSink) InsertTelemetry(ctx context.Context, id int64, day string, e []store.TelemetryEntry) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// stateSource returns a fixed effective state, to prove the published payload
|
||||
// without a broker.
|
||||
type stateSource struct {
|
||||
eff store.EffectiveState
|
||||
}
|
||||
|
||||
func (s stateSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
|
||||
|
||||
func (s stateSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) {
|
||||
eff := s.eff
|
||||
eff.DeviceID = dev.ID
|
||||
return eff, nil
|
||||
}
|
||||
|
||||
// TestBuildConfigAllowlistIsolation is the Phase 6 definition of done: with
|
||||
// Spotify allowlisted while other apps are blocked during an active schedule
|
||||
// window, the retained payload says Spotify is allowed and the others are
|
||||
// blocked.
|
||||
func TestBuildConfigAllowlistIsolation(t *testing.T) {
|
||||
until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
|
||||
link, err := New(Options{
|
||||
Broker: "tcp://127.0.0.1:1883",
|
||||
Source: stateSource{eff: store.EffectiveState{
|
||||
Locked: true,
|
||||
Until: &until,
|
||||
Reason: "schedule",
|
||||
Allow: []string{"com.spotify.music"},
|
||||
Block: []string{"com.youtube.android", "roblox.exe"},
|
||||
}},
|
||||
Devices: stubLookup{},
|
||||
Reports: stubSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
payload, err := link.buildConfig(context.Background(), store.Device{
|
||||
ID: 7, Name: "Harry's phone", Platform: store.PlatformAndroid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildConfig: %v", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(payload, &cfg); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if !cfg.Locked || cfg.Reason != "schedule" {
|
||||
t.Errorf("payload = %s, want locked by schedule", payload)
|
||||
}
|
||||
if cfg.Until == nil || !cfg.Until.Equal(until) {
|
||||
t.Errorf("until = %v, want %v", cfg.Until, until)
|
||||
}
|
||||
if len(cfg.Allow) != 1 || cfg.Allow[0] != "com.spotify.music" {
|
||||
t.Errorf("allow = %v, want Spotify allowed", cfg.Allow)
|
||||
}
|
||||
if len(cfg.Block) != 2 {
|
||||
t.Errorf("block = %v, want the other apps blocked", cfg.Block)
|
||||
}
|
||||
// The JSON is the contract the kids' clients read.
|
||||
for _, want := range []string{
|
||||
`"locked":true`, `"reason":"schedule"`,
|
||||
`"allow":["com.spotify.music"]`,
|
||||
} {
|
||||
if !strings.Contains(string(payload), want) {
|
||||
t.Errorf("payload %s missing %s", payload, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user