feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
+108
-126
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"data-terminal-cloud/internal/deviceauth"
|
||||
"data-terminal-cloud/internal/devicebinding"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/onboarding"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -24,24 +26,35 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAccessDenied = errors.New("operator access denied")
|
||||
ErrInvalid = errors.New("invalid device management input")
|
||||
ErrNotFound = errors.New("device not found")
|
||||
ErrVersionConflict = errors.New("device version conflict")
|
||||
ErrClaimUnavailable = errors.New("device claim is unavailable")
|
||||
ErrActivationConflict = errors.New("device activation transition conflict")
|
||||
ErrActivationNotReady = errors.New("device activation prerequisites are not ready")
|
||||
ErrAccessDenied = errors.New("operator access denied")
|
||||
ErrInvalid = errors.New("invalid device management input")
|
||||
ErrNotFound = errors.New("device not found")
|
||||
ErrVersionConflict = errors.New("device version conflict")
|
||||
ErrClaimUnavailable = errors.New("device claim is unavailable")
|
||||
ErrActivationConflict = errors.New("device activation transition conflict")
|
||||
ErrActivationNotReady = errors.New("device activation prerequisites are not ready")
|
||||
ErrProvisioningUnavailable = errors.New("device provisioning is unavailable")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
credential *deviceauth.DatabaseSecretResolver
|
||||
audit *audit.Repository
|
||||
pool *pgxpool.Pool
|
||||
credential *deviceauth.DatabaseSecretResolver
|
||||
audit *audit.Repository
|
||||
onboardingMasterKey string
|
||||
deviceOrigin string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
OnboardingMasterKey string
|
||||
DeviceOrigin string
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
FactoryID string `json:"factory_id"`
|
||||
EnrollmentMode string `json:"enrollment_mode"`
|
||||
Bound bool `json:"bound"`
|
||||
Model string `json:"model"`
|
||||
HardwareRevision *string `json:"hardware_revision,omitempty"`
|
||||
Status string `json:"status"`
|
||||
@@ -76,19 +89,7 @@ type AssignmentOption struct {
|
||||
SubjectName string `json:"subject_name"`
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Model string
|
||||
HardwareRevision string
|
||||
MinimumSecurityVersion string
|
||||
MinimumFirmwareVersion string
|
||||
HouseholdID string
|
||||
SubjectID string
|
||||
VoiceRuntime string
|
||||
HardwareAgentBotID string
|
||||
HardwareAgentTransport string
|
||||
HardwareAgentMinimumSDKVersion string
|
||||
InitialActivationState string
|
||||
}
|
||||
type CreateInput struct{ Model string }
|
||||
|
||||
type ActivationTransitionInput struct {
|
||||
TargetState string
|
||||
@@ -124,16 +125,16 @@ type UpdateInput struct {
|
||||
}
|
||||
|
||||
type CredentialIssue struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Secret string `json:"secret"`
|
||||
KeyVersion int64 `json:"key_version"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
FactoryID string `json:"factory_id"`
|
||||
Model string `json:"model"`
|
||||
Origin string `json:"origin"`
|
||||
DeviceKeyHex string `json:"device_key_hex"`
|
||||
KeyVersion int64 `json:"key_version"`
|
||||
}
|
||||
|
||||
type CreateResult struct {
|
||||
Device Device `json:"device"`
|
||||
Credential CredentialIssue `json:"credential"`
|
||||
Claim *DeviceClaimIssue `json:"claim,omitempty"`
|
||||
Device Device `json:"device"`
|
||||
Credential CredentialIssue `json:"credential"`
|
||||
}
|
||||
|
||||
type DeviceClaimIssue struct {
|
||||
@@ -151,11 +152,26 @@ type queryRow interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, credential *deviceauth.DatabaseSecretResolver, auditRepository *audit.Repository) (*Service, error) {
|
||||
func NewService(pool *pgxpool.Pool, credential *deviceauth.DatabaseSecretResolver, auditRepository *audit.Repository, option ...Options) (*Service, error) {
|
||||
if pool == nil || credential == nil || auditRepository == nil {
|
||||
return nil, errors.New("operations device service dependencies are required")
|
||||
}
|
||||
return &Service{pool: pool, credential: credential, audit: auditRepository}, nil
|
||||
if len(option) > 1 {
|
||||
return nil, errors.New("only one operations device service option is allowed")
|
||||
}
|
||||
var configured Options
|
||||
if len(option) == 1 {
|
||||
configured = option[0]
|
||||
}
|
||||
origin := strings.TrimSpace(configured.DeviceOrigin)
|
||||
if origin != "" {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
|
||||
return nil, errors.New("device origin must be an absolute HTTP URL")
|
||||
}
|
||||
origin = parsed.Scheme + "://" + parsed.Host
|
||||
}
|
||||
return &Service{pool: pool, credential: credential, audit: auditRepository, onboardingMasterKey: strings.TrimSpace(configured.OnboardingMasterKey), deviceOrigin: origin}, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Device, error) {
|
||||
@@ -244,51 +260,32 @@ func (s *Service) ListActivationTransitions(ctx context.Context, principal ident
|
||||
|
||||
func (s *Service) Create(ctx context.Context, principal identity.Principal, tenantPublicID, requestID string, input CreateInput) (CreateResult, error) {
|
||||
input.Model = strings.TrimSpace(input.Model)
|
||||
input.HardwareRevision = strings.TrimSpace(input.HardwareRevision)
|
||||
input.MinimumSecurityVersion = strings.TrimSpace(input.MinimumSecurityVersion)
|
||||
input.MinimumFirmwareVersion = strings.TrimSpace(input.MinimumFirmwareVersion)
|
||||
input.VoiceRuntime = strings.TrimSpace(input.VoiceRuntime)
|
||||
input.HardwareAgentBotID = strings.TrimSpace(input.HardwareAgentBotID)
|
||||
input.HardwareAgentTransport = strings.TrimSpace(input.HardwareAgentTransport)
|
||||
input.HardwareAgentMinimumSDKVersion = strings.TrimSpace(input.HardwareAgentMinimumSDKVersion)
|
||||
input.InitialActivationState = strings.ToUpper(strings.TrimSpace(input.InitialActivationState))
|
||||
if input.VoiceRuntime == "" {
|
||||
input.VoiceRuntime = "legacy_voicechat"
|
||||
}
|
||||
if input.HardwareAgentTransport == "" {
|
||||
input.HardwareAgentTransport = "high_quality"
|
||||
}
|
||||
activationState := deviceactivation.Claimable
|
||||
if input.HouseholdID != "" {
|
||||
activationState = deviceactivation.Bound
|
||||
}
|
||||
if input.InitialActivationState != "" {
|
||||
parsed, err := deviceactivation.Parse(input.InitialActivationState)
|
||||
if err != nil || (parsed != deviceactivation.Factory && parsed != activationState) {
|
||||
return CreateResult{}, ErrInvalid
|
||||
}
|
||||
activationState = parsed
|
||||
}
|
||||
if activationState == deviceactivation.Factory && input.HouseholdID != "" {
|
||||
if input.Model == "" || len([]rune(input.Model)) > 120 {
|
||||
return CreateResult{}, ErrInvalid
|
||||
}
|
||||
if input.Model == "" || len([]rune(input.Model)) > 120 || len([]rune(input.HardwareRevision)) > 80 ||
|
||||
len([]rune(input.MinimumSecurityVersion)) > 80 || len([]rune(input.MinimumFirmwareVersion)) > 80 ||
|
||||
((input.HouseholdID == "") != (input.SubjectID == "")) || !validVoiceRuntime(input.VoiceRuntime) {
|
||||
return CreateResult{}, ErrInvalid
|
||||
if s.onboardingMasterKey == "" || s.deviceOrigin == "" {
|
||||
return CreateResult{}, ErrProvisioningUnavailable
|
||||
}
|
||||
secretBytes := make([]byte, 32)
|
||||
serialBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(secretBytes); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("generate device secret: %w", err)
|
||||
}
|
||||
if _, err := rand.Read(serialBytes); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("generate device serial material: %w", err)
|
||||
factoryBytes := make([]byte, 6)
|
||||
if _, err := rand.Read(factoryBytes); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("generate factory device ID: %w", err)
|
||||
}
|
||||
secret := hex.EncodeToString(secretBytes)
|
||||
serialDigest := sha256.Sum256(serialBytes)
|
||||
factoryMAC := strings.ToUpper(hex.EncodeToString(factoryBytes))
|
||||
factoryID := "KP01-" + factoryMAC
|
||||
serialDigest := sha256.Sum256([]byte(factoryID))
|
||||
deviceID := uuid.NewString()
|
||||
publicID := uuid.NewString()
|
||||
credentialRef := "device-secret://" + publicID
|
||||
credentialRef := "onboarding://" + factoryID
|
||||
const keyVersion int64 = 1
|
||||
ciphertext, err := onboarding.SealDeviceKey(s.onboardingMasterKey, deviceID, keyVersion, secretBytes)
|
||||
if err != nil {
|
||||
return CreateResult{}, fmt.Errorf("seal onboarding device key: %w", err)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -299,63 +296,29 @@ func (s *Service) Create(ctx context.Context, principal identity.Principal, tena
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
if input.HouseholdID != "" {
|
||||
if err := validateAssignment(ctx, tx, access.tenantID, input.HouseholdID, input.SubjectID); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
}
|
||||
var deviceID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO device(public_id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version,activation_state,activation_reason)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,NULLIF($5,''),'registered',$6,NULLIF($7,''),NULLIF($8,''),$9,$10) RETURNING id::text`,
|
||||
publicID, access.tenantID, hex.EncodeToString(serialDigest[:]), input.Model, input.HardwareRevision, credentialRef,
|
||||
input.MinimumSecurityVersion, input.MinimumFirmwareVersion, activationState, "ops-device-create").Scan(&deviceID)
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO device(id,public_id,tenant_id,serial_digest,model,status,credential_ref,activation_state,activation_reason,enrollment_mode,factory_device_id,factory_mac,onboarding_released_at)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,'registered',$6,'CLAIMABLE','ops-device-create','physical_confirm',$7,$8,now())`,
|
||||
deviceID, publicID, access.tenantID, hex.EncodeToString(serialDigest[:]), input.Model, credentialRef, factoryID, factoryMAC)
|
||||
if err != nil {
|
||||
return CreateResult{}, fmt.Errorf("insert device: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_activation_transition(tenant_id,device_id,from_state,to_state,reason,source,actor_type,actor_public_id,request_id,activation_revision)
|
||||
VALUES($1::uuid,$2::uuid,NULL,$3,'ops-device-create','operator','user',$4::uuid,NULLIF($5,''),1)`, access.tenantID, deviceID, activationState, access.userPublicID, requestID); err != nil {
|
||||
VALUES($1::uuid,$2::uuid,NULL,'CLAIMABLE','ops-device-create','operator','user',$3::uuid,NULLIF($4,''),1)`, access.tenantID, deviceID, access.userPublicID, requestID); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("record initial device activation: %w", err)
|
||||
}
|
||||
desiredConfig, _ := json.Marshal(map[string]string{"voice_runtime": input.VoiceRuntime})
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_config) VALUES($1::uuid,$2::uuid,$3::jsonb)`, deviceID, access.tenantID, desiredConfig); err != nil {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_twin(device_id,tenant_id,desired_config) VALUES($1::uuid,$2::uuid,'{}'::jsonb)`, deviceID, access.tenantID); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("insert device twin: %w", err)
|
||||
}
|
||||
if input.HouseholdID != "" {
|
||||
if err := insertBinding(ctx, tx, access, deviceID, input.HouseholdID, input.SubjectID, "运维端登记设备"); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
}
|
||||
keyVersion, err := s.credential.ProvisionTx(ctx, tx, publicID, []byte(secret), false)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
if activationState == deviceactivation.Bound && input.VoiceRuntime == "legacy_voicechat" {
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Provisioning, Reason: "旧版语音运行时平台凭据预配", Source: "platform-provisioning", ActorType: "user",
|
||||
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
|
||||
return CreateResult{}, mapActivationError(err)
|
||||
}
|
||||
if _, err := deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: deviceID, TenantID: access.tenantID,
|
||||
Target: deviceactivation.Active, Reason: "旧版语音运行时已具备绑定与平台凭据", Source: "platform-provisioning", ActorType: "user",
|
||||
ActorPublicID: &access.userPublicID, RequestID: requestID}); err != nil {
|
||||
return CreateResult{}, mapActivationError(err)
|
||||
}
|
||||
activationState = deviceactivation.Active
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status,activated_at)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,'ACTIVE',now())`, deviceID, access.tenantID, keyVersion, ciphertext); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("insert onboarding device key: %w", err)
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
|
||||
Action: "ops.device.create", ResourceType: "device", ResourceID: &publicID, RequestID: requestID, Result: "success",
|
||||
Detail: map[string]any{"model": input.Model, "assigned": input.HouseholdID != "", "key_version": keyVersion, "activation_state": activationState}}); err != nil {
|
||||
Detail: map[string]any{"factory_id": factoryID, "model": input.Model, "key_version": keyVersion, "enrollment_mode": "physical_confirm"}}); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
var claim *DeviceClaimIssue
|
||||
if activationState == deviceactivation.Claimable {
|
||||
issued, err := s.issueDeviceClaimTx(ctx, tx, access, publicID, deviceID, requestID)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
claim = &issued
|
||||
}
|
||||
item, err := readDevice(ctx, tx, access.tenantID, publicID)
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
@@ -363,7 +326,7 @@ func (s *Service) Create(ctx context.Context, principal identity.Principal, tena
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return CreateResult{}, fmt.Errorf("commit device registration: %w", err)
|
||||
}
|
||||
return CreateResult{Device: item, Credential: CredentialIssue{DeviceID: publicID, Secret: secret, KeyVersion: keyVersion, IssuedAt: time.Now().UTC()}, Claim: claim}, nil
|
||||
return CreateResult{Device: item, Credential: CredentialIssue{FactoryID: factoryID, Model: input.Model, Origin: s.deviceOrigin, DeviceKeyHex: secret, KeyVersion: keyVersion}}, nil
|
||||
}
|
||||
|
||||
func (s *Service) IssueDeviceClaim(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, requestID string) (DeviceClaimIssue, error) {
|
||||
@@ -622,9 +585,15 @@ func (s *Service) Delete(ctx context.Context, principal identity.Principal, tena
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_credential SET status='revoked',revoked_at=COALESCE(revoked_at,now()) WHERE device_id=$1::uuid`, deviceID); err != nil {
|
||||
return Device{}, fmt.Errorf("revoke device credential: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_onboarding_key SET status='REVOKED' WHERE device_id=$1::uuid AND status IN ('ACTIVE','PENDING')`, deviceID); err != nil {
|
||||
return Device{}, fmt.Errorf("revoke onboarding device key: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),unbound_by=$2::uuid,reason='运维端删除设备' WHERE device_id=$1::uuid AND unbound_at IS NULL`, deviceID, access.userID); err != nil {
|
||||
return Device{}, fmt.Errorf("close retired device binding: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_account_binding SET released_at=now() WHERE device_id=$1::uuid AND released_at IS NULL`, deviceID); err != nil {
|
||||
return Device{}, fmt.Errorf("close retired device account binding: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_claim SET revoked_at=now() WHERE device_id=$1::uuid AND used_at IS NULL AND revoked_at IS NULL`, deviceID); err != nil {
|
||||
return Device{}, fmt.Errorf("revoke retired device claims: %w", err)
|
||||
}
|
||||
@@ -734,7 +703,6 @@ func (s *Service) RotateCredential(ctx context.Context, principal identity.Princ
|
||||
if _, err := rand.Read(secretBytes); err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("generate device credential: %w", err)
|
||||
}
|
||||
secret := hex.EncodeToString(secretBytes)
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("begin credential rotation: %w", err)
|
||||
@@ -744,19 +712,29 @@ func (s *Service) RotateCredential(ctx context.Context, principal identity.Princ
|
||||
if err != nil {
|
||||
return CredentialIssue{}, err
|
||||
}
|
||||
var rotationDeviceID string
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid FOR UPDATE`, devicePublicID, access.tenantID).Scan(&rotationDeviceID); err != nil {
|
||||
var rotationDeviceID, enrollmentMode, factoryID, model string
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,enrollment_mode,COALESCE(factory_device_id,''),model FROM device WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND status='registered' AND activation_state NOT IN ('LOST','REVOKED') FOR UPDATE`, devicePublicID, access.tenantID).Scan(&rotationDeviceID, &enrollmentMode, &factoryID, &model); err != nil {
|
||||
return CredentialIssue{}, ErrNotFound
|
||||
}
|
||||
if err := devicebinding.RequireLegacy(ctx, tx, rotationDeviceID); err != nil {
|
||||
return CredentialIssue{}, err
|
||||
if enrollmentMode != "physical_confirm" || factoryID == "" || s.onboardingMasterKey == "" || s.deviceOrigin == "" {
|
||||
return CredentialIssue{}, ErrProvisioningUnavailable
|
||||
}
|
||||
version, err := s.credential.ProvisionTx(ctx, tx, devicePublicID, []byte(secret), true)
|
||||
if errors.Is(err, deviceauth.ErrDeviceNotFound) {
|
||||
return CredentialIssue{}, ErrNotFound
|
||||
var version int64
|
||||
if err := tx.QueryRow(ctx, `SELECT COALESCE(max(key_version),0)+1 FROM device_onboarding_key WHERE device_id=$1::uuid`, rotationDeviceID).Scan(&version); err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("read onboarding key version: %w", err)
|
||||
}
|
||||
ciphertext, err := onboarding.SealDeviceKey(s.onboardingMasterKey, rotationDeviceID, version, secretBytes)
|
||||
if err != nil {
|
||||
return CredentialIssue{}, err
|
||||
return CredentialIssue{}, fmt.Errorf("seal rotated onboarding device key: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device_onboarding_key SET status='REVOKED' WHERE device_id=$1::uuid AND status='ACTIVE'`, rotationDeviceID); err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("revoke previous onboarding device key: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status,activated_at) VALUES($1::uuid,$2::uuid,$3,$4,'ACTIVE',now())`, rotationDeviceID, access.tenantID, version, ciphertext); err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("insert rotated onboarding device key: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE device SET onboarding_revision=onboarding_revision+1,version=version+1,updated_at=now() WHERE id=$1::uuid`, rotationDeviceID); err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("update onboarding key revision: %w", err)
|
||||
}
|
||||
if err := s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID,
|
||||
Action: "ops.device.credential.rotate", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Result: "success",
|
||||
@@ -766,7 +744,7 @@ func (s *Service) RotateCredential(ctx context.Context, principal identity.Princ
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return CredentialIssue{}, fmt.Errorf("commit credential rotation: %w", err)
|
||||
}
|
||||
return CredentialIssue{DeviceID: devicePublicID, Secret: secret, KeyVersion: version, IssuedAt: time.Now().UTC()}, nil
|
||||
return CredentialIssue{FactoryID: factoryID, Model: model, Origin: s.deviceOrigin, DeviceKeyHex: hex.EncodeToString(secretBytes), KeyVersion: version}, nil
|
||||
}
|
||||
|
||||
func requireAccess(ctx context.Context, q queryRow, principal identity.Principal, tenantPublicID string, mutate bool) (operatorAccess, error) {
|
||||
@@ -841,23 +819,27 @@ func mapActivationError(err error) error {
|
||||
}
|
||||
|
||||
const deviceSelectSQL = `
|
||||
SELECT d.public_id::text,t.public_id::text,d.model,d.hardware_revision,d.status,d.activation_state,d.activation_revision,d.activation_reason,d.activation_updated_at,d.minimum_security_version,d.minimum_firmware_version,
|
||||
h.public_id::text,h.name,sp.public_id::text,sp.preferred_name,COALESCE(dc.status,'missing'),COALESCE(dc.key_version,0),
|
||||
SELECT d.public_id::text,t.public_id::text,COALESCE(d.factory_device_id,''),d.enrollment_mode,
|
||||
(EXISTS(SELECT 1 FROM device_binding current_binding WHERE current_binding.device_id=d.id AND current_binding.unbound_at IS NULL)
|
||||
OR EXISTS(SELECT 1 FROM device_account_binding account_binding WHERE account_binding.device_id=d.id AND account_binding.released_at IS NULL AND account_binding.generation=d.binding_generation)),
|
||||
d.model,d.hardware_revision,d.status,d.activation_state,d.activation_revision,d.activation_reason,d.activation_updated_at,d.minimum_security_version,d.minimum_firmware_version,
|
||||
h.public_id::text,h.name,sp.public_id::text,sp.preferred_name,COALESCE(lower(ok.status),dc.status,'missing'),COALESCE(ok.key_version,dc.key_version,0),
|
||||
dt.firmware_version,dt.last_reported_at,d.registered_at,d.updated_at,d.version,
|
||||
COALESCE(dt.desired_config->>'voice_runtime','legacy_voicechat'),COALESCE(hb.bot_id,''),COALESCE(hb.transport,''),COALESCE(hb.minimum_sdk_version,''),COALESCE(hb.status,'')
|
||||
COALESCE(dt.desired_config->>'voice_runtime',CASE WHEN d.enrollment_mode='physical_confirm' THEN 'volc_hardware_agent' ELSE 'legacy_voicechat' END),COALESCE(hb.bot_id,''),COALESCE(hb.transport,''),COALESCE(hb.minimum_sdk_version,''),COALESCE(hb.status,'')
|
||||
FROM device d JOIN tenant t ON t.id=d.tenant_id
|
||||
LEFT JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
|
||||
LEFT JOIN device_binding b ON b.device_id=d.id AND b.tenant_id=d.tenant_id AND b.unbound_at IS NULL
|
||||
LEFT JOIN household h ON h.id=b.household_id AND h.tenant_id=b.tenant_id
|
||||
LEFT JOIN subject_profile sp ON sp.id=b.subject_id AND sp.tenant_id=b.tenant_id
|
||||
LEFT JOIN device_credential dc ON dc.device_id=d.id AND dc.tenant_id=d.tenant_id
|
||||
LEFT JOIN device_onboarding_key ok ON ok.device_id=d.id AND ok.tenant_id=d.tenant_id AND ok.status='ACTIVE'
|
||||
LEFT JOIN hardware_agent_device_binding hb ON hb.device_id=d.id AND hb.tenant_id=d.tenant_id`
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanDevice(row scanner) (Device, error) {
|
||||
var item Device
|
||||
if err := row.Scan(&item.ID, &item.TenantID, &item.Model, &item.HardwareRevision, &item.Status, &item.ActivationState, &item.ActivationRevision, &item.ActivationReason, &item.ActivationUpdatedAt, &item.MinimumSecurityVersion, &item.MinimumFirmwareVersion,
|
||||
if err := row.Scan(&item.ID, &item.TenantID, &item.FactoryID, &item.EnrollmentMode, &item.Bound, &item.Model, &item.HardwareRevision, &item.Status, &item.ActivationState, &item.ActivationRevision, &item.ActivationReason, &item.ActivationUpdatedAt, &item.MinimumSecurityVersion, &item.MinimumFirmwareVersion,
|
||||
&item.HouseholdID, &item.HouseholdName, &item.SubjectID, &item.SubjectName, &item.CredentialStatus, &item.KeyVersion,
|
||||
&item.FirmwareVersion, &item.LastReportedAt, &item.RegisteredAt, &item.UpdatedAt, &item.Version,
|
||||
&item.VoiceRuntime, &item.HardwareAgentBotID, &item.HardwareAgentTransport, &item.HardwareAgentMinimumSDKVersion, &item.HardwareAgentStatus); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user