ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
79 lines
2.7 KiB
Go
79 lines
2.7 KiB
Go
package onboarding
|
|
|
|
import (
|
|
"context"
|
|
"data-terminal-cloud/internal/outbox"
|
|
"encoding/json"
|
|
"github.com/google/uuid"
|
|
"time"
|
|
)
|
|
|
|
// Recover durable jobs after an outbox retry budget was exhausted during a
|
|
// pause. ProcessActivation owns the device lock and the no-repeat fence.
|
|
func (s *Service) RunMaintenance(ctx context.Context) {
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
if err := s.MaintainOnce(ctx); err != nil && ctx.Err() == nil {
|
|
s.options.Logger.Error("onboarding recovery scan failed")
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
func (s *Service) MaintainOnce(ctx context.Context) error {
|
|
rows, err := s.pool.Query(ctx, `SELECT j.device_id::text,j.id::text,j.binding_generation FROM device_activation_job j JOIN device d ON d.id=j.device_id JOIN device_binding b ON b.device_id=d.id AND b.unbound_at IS NULL AND b.generation=j.binding_generation WHERE d.status='registered' AND d.activation_state NOT IN ('LOST','REVOKED') AND j.binding_generation=d.binding_generation AND j.updated_at<now()-interval '3 minutes' AND (j.status IN ('NOT_STARTED','RESERVED','CONTACT_POSSIBLE') OR (j.status='REGISTERED_OBSERVED' AND j.config_ciphertext IS NULL)) ORDER BY j.updated_at LIMIT 10`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
type item struct {
|
|
DeviceID string `json:"device_id"`
|
|
ActivationID string `json:"activation_id"`
|
|
Generation int64 `json:"binding_generation"`
|
|
}
|
|
var items []item
|
|
for rows.Next() {
|
|
var v item
|
|
if err = rows.Scan(&v.DeviceID, &v.ActivationID, &v.Generation); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
items = append(items, v)
|
|
}
|
|
rows.Close()
|
|
if err = rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if s.options.ActivationEnabled {
|
|
for _, v := range items {
|
|
raw, _ := json.Marshal(v)
|
|
if err = s.ProcessActivation(ctx, outbox.Event{ID: uuid.NewString(), Payload: raw}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
// Retain business attempts and idempotency tombstones for replay protection.
|
|
_, err = s.pool.Exec(ctx, `DELETE FROM device_handoff WHERE expires_at<now()-interval '1 day'`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.pool.Exec(ctx, `DELETE FROM device_time_challenge WHERE expires_at<now()-interval '1 day'`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Keep one day for rate limiting; delete expired OTP ciphertext with its session.
|
|
_, err = s.pool.Exec(ctx, `DELETE FROM customer_login_session WHERE expires_at<now()-interval '1 day'`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.pool.Exec(ctx, `DELETE FROM operator_login_session WHERE expires_at<now()-interval '1 day'`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.pool.Exec(ctx, `DELETE FROM trusted_login_device WHERE expires_at<now() OR revoked_at<now()-interval '1 day'`)
|
|
return err
|
|
}
|