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:
@@ -0,0 +1,170 @@
|
||||
// Controlled, explicitly approved migration of one legacy device. Never deletes history.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/deviceactivation"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func secret(name string) (string, error) {
|
||||
if path := os.Getenv(name + "_FILE"); path != "" {
|
||||
b, err := os.ReadFile(path)
|
||||
return strings.TrimSpace(string(b)), err
|
||||
}
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is not configured", name)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
public := flag.String("device", "", "exact existing public UUID")
|
||||
binding := flag.String("expected-binding", "", "exact current binding UUID, or none")
|
||||
mac := flag.String("factory-mac", "", "verified hardware MAC, uppercase 12 hex")
|
||||
output := flag.String("output", "", "new private credential JSON file")
|
||||
confirmed := flag.Bool("confirm-unbind-and-migrate", false, "requires explicit owner authorization")
|
||||
dryRun := flag.Bool("dry-run", false, "validate the complete transaction, then roll it back without exporting keys")
|
||||
flag.Parse()
|
||||
if !*confirmed || uuid.Validate(*public) != nil || (*binding != "none" && uuid.Validate(*binding) != nil) || !regexp.MustCompile(`^[0-9A-F]{12}$`).MatchString(*mac) || *output == "" {
|
||||
return errors.New("exact device, expected binding, factory MAC, private output and explicit confirmation are required")
|
||||
}
|
||||
if _, err := os.Stat(*output); !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("credential output already exists or is inaccessible; inspect previous attempt before retry")
|
||||
}
|
||||
dsn, err := secret("DATABASE_URL")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
masterHex, err := secret("ONBOARDING_MASTER_KEY")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
master, err := hex.DecodeString(masterHex)
|
||||
if err != nil || len(master) != 32 {
|
||||
return errors.New("invalid onboarding master key")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer pool.Close()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var id, tenant, mode, lifecycle, status string
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,tenant_id::text,enrollment_mode,activation_state,status FROM device WHERE public_id=$1::uuid FOR UPDATE`, *public).Scan(&id, &tenant, &mode, &lifecycle, &status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode != "legacy" || status != "registered" || (lifecycle != "ACTIVE" && lifecycle != "BOUND" && lifecycle != "CLAIMABLE") {
|
||||
return errors.New("device is not an eligible legacy identity; no changes made")
|
||||
}
|
||||
var current string
|
||||
var busy, hasKey bool
|
||||
err = tx.QueryRow(ctx, `SELECT COALESCE((SELECT id::text FROM device_binding WHERE device_id=$1::uuid AND unbound_at IS NULL),'none'),EXISTS(SELECT 1 FROM conversation WHERE device_id=$1::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED')),EXISTS(SELECT 1 FROM device_onboarding_key WHERE device_id=$1::uuid)`, id).Scan(¤t, &busy, &hasKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != *binding || busy || hasKey {
|
||||
return errors.New("binding changed, conversation still running, or onboarding key already exists; no changes made")
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
if current != "none" {
|
||||
if _, err = tx.Exec(ctx, `UPDATE device_binding SET unbound_at=now(),reason='用户明确授权:迁移蓝牙配网,保留历史记录' WHERE id=$1::uuid AND unbound_at IS NULL`, current); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err = deviceactivation.Apply(ctx, tx, deviceactivation.Input{DeviceID: id, TenantID: tenant, Target: deviceactivation.Claimable, Reason: "用户授权旧设备迁移蓝牙注册", Source: "ble-device-migrate", ActorType: "system", RequestID: requestID}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sql := range []string{
|
||||
`UPDATE device_claim SET revoked_at=now() WHERE device_id=$1::uuid AND used_at IS NULL AND revoked_at IS NULL`,
|
||||
`UPDATE hardware_agent_provider_credential SET status='SUSPENDED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`,
|
||||
`UPDATE hardware_agent_device_binding SET status='DISABLED',updated_at=now() WHERE device_id=$1::uuid AND status='ACTIVE'`,
|
||||
} {
|
||||
if _, err = tx.Exec(ctx, sql, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
factoryID := "KP01-" + *mac
|
||||
sum := sha256.Sum256([]byte(factoryID))
|
||||
_, err = tx.Exec(ctx, `UPDATE device SET enrollment_mode='physical_confirm',factory_device_id=$2,factory_mac=$3,model='ECHOEAR-S3',hardware_revision='V1.2',credential_ref=$4,serial_digest=$5,onboarding_released_at=now(),onboarding_boot_id=NULL,onboarding_revision=onboarding_revision+1,version=version+1,updated_at=now() WHERE id=$1::uuid`, id, factoryID, *mac, "onboarding://"+factoryID, hex.EncodeToString(sum[:]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
return err
|
||||
}
|
||||
block, err := aes.NewCipher(master)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
encrypted := aead.Seal(nonce, nonce, key, []byte("ONBOARDING-KEY1\n"+id+"\n1"))
|
||||
_, err = tx.Exec(ctx, `INSERT INTO device_onboarding_key(device_id,tenant_id,key_version,ciphertext,status,activated_at) VALUES($1::uuid,$2::uuid,1,$3,'ACTIVE',now())`, id, tenant, encrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = audit.NewRepository().Record(ctx, tx, audit.Event{TenantID: &tenant, ActorType: "system", Action: "device.ble.migrate", ResourceType: "device", ResourceID: public, RequestID: requestID, Result: "success", Detail: map[string]any{"factory_device_id": factoryID, "previous_binding_id": current, "user_approved_unbind": true, "history_preserved": true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if *dryRun {
|
||||
if err = tx.Rollback(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"validated": true, "rolled_back": true, "device_id": factoryID})
|
||||
}
|
||||
// Persist private recovery material before committing the identity mutation.
|
||||
f, err := os.OpenFile(*output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.NewEncoder(f).Encode(map[string]any{"device_id": factoryID, "platform_device_id": *public, "model": "ECHOEAR-S3", "key_version": 1, "device_key_hex": hex.EncodeToString(key), "request_id": requestID})
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit uncertain; retain private output and inspect device state: %w", err)
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"migrated": true, "device_id": factoryID, "platform_device_id": *public, "history_preserved": true, "request_id": requestID})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// call-memory-admin is an operator-only maintenance command. It requires direct
|
||||
// database access; evidence references are audit attestations, not test results.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/callmemory"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if e := run(); e != nil {
|
||||
fmt.Fprintln(os.Stderr, "call-memory-admin:", e)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type profileFile struct {
|
||||
ID string `json:"id"`
|
||||
SDK string `json:"sdk_version"`
|
||||
Bot string `json:"bot_id"`
|
||||
Model string `json:"model_version"`
|
||||
Transport string `json:"transport"`
|
||||
Audio map[string]any `json:"audio_profile"`
|
||||
Template map[string]any `json:"vendor_template"`
|
||||
Mapping map[string]string `json:"field_mapping"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
Evidence map[string]string `json:"evidence"`
|
||||
Schema json.RawMessage `json:"projection_schema"`
|
||||
}
|
||||
|
||||
func run() error {
|
||||
op := flag.String("operation", "", "import-profile, attest-call-stopped, attest-legacy-cleanup, attest-backup-cleanup, export-deletion-ledger, replay-deletion-ledger, retry-projection")
|
||||
file := flag.String("file", "", "input JSON file (export writes to stdout)")
|
||||
subject := flag.String("subject-id", "", "internal subject UUID")
|
||||
session := flag.String("session-id", "", "business session UUID for attest-call-stopped")
|
||||
evidence := flag.String("evidence", "", "immutable audit record reference")
|
||||
publish := flag.Bool("publish", false, "publish profile after capability validation")
|
||||
flag.Parse()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
p, e := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
|
||||
if e != nil {
|
||||
return fmt.Errorf("database configuration invalid")
|
||||
}
|
||||
defer p.Close()
|
||||
bytes := func(v any) []byte { b, _ := json.Marshal(v); return b }
|
||||
switch *op {
|
||||
case "attest-call-stopped":
|
||||
if _, e = uuid.Parse(*session); e != nil || strings.TrimSpace(*evidence) == "" {
|
||||
return fmt.Errorf("session-id and evidence are required")
|
||||
}
|
||||
tx, e := p.Begin(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var sid string
|
||||
var localOff, sdkStopped bool
|
||||
if e = tx.QueryRow(ctx, `SELECT subject_id::text,local_audio_off,sdk_stopped FROM call_session WHERE id=$1::uuid FOR UPDATE`, *session).Scan(&sid, &localOff, &sdkStopped); e != nil {
|
||||
return e
|
||||
}
|
||||
if !localOff || !sdkStopped {
|
||||
return fmt.Errorf("device must first prove local audio off and SDK stopped")
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `UPDATE call_session SET remote_state='STOPPED_OBSERVED' WHERE id=$1::uuid`, *session); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `INSERT INTO call_memory_operator_evidence(subject_id,call_session_id,operation,evidence_ref) VALUES($1::uuid,$2::uuid,'attest-call-stopped',$3)`, sid, *session, *evidence); e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
case "import-profile":
|
||||
raw, e := os.ReadFile(*file)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var in profileFile
|
||||
if e = callmemory.StrictJSON(raw, &in); e != nil {
|
||||
return e
|
||||
}
|
||||
profile := callmemory.Profile{ID: in.ID, SDKVersion: in.SDK, BotID: in.Bot, ModelVersion: in.Model, Transport: in.Transport, Audio: in.Audio, Template: in.Template, Mapping: in.Mapping, Capabilities: in.Capabilities, Evidence: in.Evidence}
|
||||
if *publish {
|
||||
if _, e = profile.Compile("OFF", "", "", "", "", "test"); e != nil {
|
||||
return e
|
||||
}
|
||||
if profile.Capabilities["H03"] || profile.Capabilities["H12"] {
|
||||
if e = profile.Check("NATIVE_VIKING"); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(in.Schema) == 0 {
|
||||
in.Schema = json.RawMessage(`{}`)
|
||||
}
|
||||
_, e = p.Exec(ctx, `INSERT INTO call_provider_profile(id,protocol_version,sdk_version,bot_id,model_version,transport,audio_profile,vendor_template,field_mapping,capabilities,evidence,projection_schema,published) VALUES($1,'CALL-MEMORY-1.1',$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11::jsonb,$12)`, in.ID, in.SDK, in.Bot, in.Model, in.Transport, bytes(in.Audio), bytes(in.Template), bytes(in.Mapping), bytes(in.Capabilities), bytes(in.Evidence), in.Schema, *publish)
|
||||
if e != nil {
|
||||
return fmt.Errorf("profile insert failed (existing published profiles are immutable)")
|
||||
}
|
||||
case "attest-legacy-cleanup", "attest-backup-cleanup", "retry-projection":
|
||||
if _, e = uuid.Parse(*subject); e != nil || strings.TrimSpace(*evidence) == "" {
|
||||
return fmt.Errorf("subject-id and evidence are required")
|
||||
}
|
||||
tx, e := p.Begin(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
if *op == "attest-legacy-cleanup" {
|
||||
// Requires proof of old namespace cleanup and no unresolved old runtime.
|
||||
var active bool
|
||||
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM conversation WHERE subject_id=$1::uuid AND runtime_type='volc_hardware_agent' AND status NOT IN ('COMPLETED','FAILED'))`, *subject).Scan(&active); e != nil {
|
||||
return e
|
||||
}
|
||||
if active {
|
||||
return fmt.Errorf("legacy call still unresolved")
|
||||
}
|
||||
_, e = tx.Exec(ctx, `UPDATE memory_user_state SET legacy_projection_pending=false,updated_at=now() WHERE subject_id=$1::uuid`, *subject)
|
||||
} else if *op == "attest-backup-cleanup" {
|
||||
_, e = tx.Exec(ctx, `UPDATE memory_deletion_ledger SET backup_cleanup_verified_at=now(),evidence_ref=$2 WHERE subject_id=$1::uuid AND backup_cleanup_verified_at IS NULL`, *subject, *evidence)
|
||||
} else {
|
||||
_, e = tx.Exec(ctx, `UPDATE memory_projection_job SET state='RECONCILING',last_error=NULL,next_attempt_at=now() WHERE subject_id=$1::uuid AND state='BLOCKED'`, *subject)
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO call_memory_operator_evidence(subject_id,operation,evidence_ref) VALUES($1::uuid,$2,$3)`, *subject, *op, *evidence)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = tx.Commit(ctx); e != nil {
|
||||
return e
|
||||
}
|
||||
case "export-deletion-ledger":
|
||||
var data []byte
|
||||
e = p.QueryRow(ctx, `SELECT COALESCE(jsonb_agg(jsonb_build_object('memory_id',memory_id,'subject_id',subject_id,'privacy_epoch',privacy_epoch,'scope',scope)),'[]'::jsonb) FROM memory_deletion_ledger`).Scan(&data)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
case "replay-deletion-ledger":
|
||||
raw, e := os.ReadFile(*file)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var entries []struct {
|
||||
Memory string `json:"memory_id"`
|
||||
Subject string `json:"subject_id"`
|
||||
Epoch int64 `json:"privacy_epoch"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if e = callmemory.StrictJSON(raw, &entries); e != nil {
|
||||
return e
|
||||
}
|
||||
tx, e := p.Begin(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
for _, r := range entries {
|
||||
if _, e = uuid.Parse(r.Memory); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = uuid.Parse(r.Subject); e != nil {
|
||||
return e
|
||||
}
|
||||
if r.Epoch < 0 || (r.Scope != "MEMORY_AND_DERIVED" && r.Scope != "SOURCE_AND_DERIVED") {
|
||||
return fmt.Errorf("invalid ledger")
|
||||
}
|
||||
// Restore only matching records. The normal deletion pipeline creates fresh
|
||||
// projection work and revokes calls; external service stays offline until done.
|
||||
_, e = tx.Exec(ctx, `UPDATE curated_memory SET status='PENDING_DELETE',proactive_policy='NEVER',version=version+1 WHERE id=$1::uuid AND subject_id=$2::uuid AND status NOT IN('PENDING_DELETE','DELETED')`, r.Memory, r.Subject)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO memory_deletion_ledger(memory_id,privacy_epoch,subject_id,scope) SELECT id,$3,subject_id,$4 FROM curated_memory WHERE id=$1::uuid AND subject_id=$2::uuid ON CONFLICT(memory_id,privacy_epoch) DO UPDATE SET scope=CASE WHEN excluded.scope='SOURCE_AND_DERIVED' THEN excluded.scope ELSE memory_deletion_ledger.scope END,backup_cleanup_verified_at=NULL`, r.Memory, r.Subject, r.Epoch, r.Scope)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO memory_mutation(id,tenant_id,subject_id,action,request_digest,state,targets,scope,memory_revision,privacy_epoch) SELECT gen_random_uuid(),cm.tenant_id,cm.subject_id,'DELETE','restore-ledger','PROPAGATING',jsonb_build_array(cm.public_id::text),$3,us.memory_revision,us.privacy_epoch FROM curated_memory cm JOIN memory_user_state us ON us.subject_id=cm.subject_id WHERE cm.id=$1::uuid AND cm.subject_id=$2::uuid AND NOT EXISTS(SELECT 1 FROM memory_mutation mm WHERE mm.subject_id=cm.subject_id AND mm.action='DELETE' AND mm.targets @> jsonb_build_array(cm.public_id::text) AND mm.state IN('PROPAGATING','BLOCKED'))`, r.Memory, r.Subject, r.Scope)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if e = tx.Commit(ctx); e != nil {
|
||||
return e
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown operation; use -help")
|
||||
}
|
||||
fmt.Println("operation recorded")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Regenerates the CALL-MEMORY extension from the public Go request models.
|
||||
package main
|
||||
|
||||
import (
|
||||
"data-terminal-cloud/internal/callmemory"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func schema(t reflect.Type) map[string]any {
|
||||
if t.Kind() == reflect.Pointer {
|
||||
return map[string]any{"anyOf": []any{schema(t.Elem()), map[string]any{"type": "null"}}}
|
||||
}
|
||||
if t == reflect.TypeOf(time.Time{}) {
|
||||
return map[string]any{"type": "string", "format": "date-time"}
|
||||
}
|
||||
if t == reflect.TypeOf(json.RawMessage{}) {
|
||||
return map[string]any{}
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return map[string]any{"type": "string"}
|
||||
case reflect.Bool:
|
||||
return map[string]any{"type": "boolean"}
|
||||
case reflect.Int, reflect.Int64:
|
||||
return map[string]any{"type": "integer", "format": "int64", "minimum": 0}
|
||||
case reflect.Slice:
|
||||
return map[string]any{"type": "array", "items": schema(t.Elem())}
|
||||
case reflect.Map:
|
||||
return map[string]any{"type": "object", "additionalProperties": schema(t.Elem())}
|
||||
case reflect.Struct:
|
||||
props := map[string]any{}
|
||||
required := []string{}
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
tag := strings.Split(f.Tag.Get("json"), ",")
|
||||
if tag[0] == "-" {
|
||||
continue
|
||||
}
|
||||
if f.Anonymous {
|
||||
nested := schema(f.Type)
|
||||
for k, v := range nested["properties"].(map[string]any) {
|
||||
props[k] = v
|
||||
}
|
||||
required = append(required, nested["required"].([]string)...)
|
||||
continue
|
||||
}
|
||||
name := tag[0]
|
||||
if name == "" {
|
||||
name = f.Name
|
||||
}
|
||||
props[name] = schema(f.Type)
|
||||
if !strings.Contains(f.Tag.Get("json"), "omitempty") {
|
||||
required = append(required, name)
|
||||
}
|
||||
}
|
||||
return map[string]any{"type": "object", "additionalProperties": false, "properties": props, "required": required}
|
||||
default:
|
||||
return map[string]any{}
|
||||
}
|
||||
}
|
||||
func main() {
|
||||
device := map[string]any{"warmup": callmemory.WarmupInput{}, "prepare": callmemory.PrepareInput{}, "events": callmemory.EventsInput{}, "heartbeat": callmemory.HeartbeatInput{}, "end": callmemory.EndInput{}, "status": callmemory.StatusInput{}, "command-acks": callmemory.AckInput{}}
|
||||
memory := map[string]any{"query": callmemory.QueryInput{}, "mutations": callmemory.MutationInput{}, "mutations/resolve": callmemory.ResolveInput{}, "mutations/confirm": callmemory.ConfirmInput{}, "mutations/status": struct {
|
||||
ID string `json:"mutation_id"`
|
||||
}{}, "sync/status": struct {
|
||||
ID string `json:"elder_id"`
|
||||
}{}, "access/query": struct {
|
||||
Elder string `json:"elder_id"`
|
||||
}{}, "access/grant": callmemory.AccessInput{}}
|
||||
paths := map[string]any{}
|
||||
schemas := map[string]any{"Manifest": schema(reflect.TypeOf(callmemory.Manifest{}))}
|
||||
for family, ops := range map[string]map[string]any{"device/calls": device, "memories": memory} {
|
||||
for name, model := range ops {
|
||||
key := strings.ReplaceAll(family+"/"+name, "/", "_")
|
||||
security := "CustomerBearer"
|
||||
description := "登录用户,独立记忆权限校验。正文上限 64 KiB,拒绝重复 JSON 键和未知字段。变更需提交稳定 mutation_id 与目标 record_version。"
|
||||
parameters := []any{map[string]any{"in": "header", "name": "X-Request-ID", "required": family == "device/calls", "schema": map[string]any{"type": "string", "format": "uuid"}}}
|
||||
if family == "device/calls" {
|
||||
security = "DeviceHMAC"
|
||||
description = "ONBOARDING-1.1 DEVREQ1 签名原始正文。nonce 5 分钟去重,时钟偏差不超过 120 秒。事件和 status 不续租;heartbeat 必须在启动 SDK 前成功,后续每 5 秒续租,绝对期限最长 45 秒。"
|
||||
for _, header := range []string{"X-Device-ID", "X-Key-Version", "X-Timestamp-Ms", "X-Nonce"} {
|
||||
parameters = append(parameters, map[string]any{"in": "header", "name": header, "required": true, "schema": map[string]any{"type": "string"}})
|
||||
}
|
||||
}
|
||||
schemas[key] = schema(reflect.TypeOf(model))
|
||||
responses := map[string]any{}
|
||||
for _, status := range []string{"200", "400", "401", "403", "404", "409", "410", "413", "422", "429", "503"} {
|
||||
responses[status] = map[string]any{"description": "CALL-MEMORY envelope; code/recovery_action are stable, data is present on success", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/Envelope"}}}}
|
||||
}
|
||||
paths["/api/v1/"+family+"/"+name] = map[string]any{"post": map[string]any{"operationId": key, "description": description, "security": []any{map[string]any{security: []string{}}}, "parameters": parameters, "requestBody": map[string]any{"required": true, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"$ref": "#/components/schemas/" + key}}}}, "responses": responses}}
|
||||
}
|
||||
}
|
||||
schemas["Envelope"] = map[string]any{"type": "object", "required": []string{"ok", "code", "request_id", "server_time_ms"}, "properties": map[string]any{"ok": map[string]any{"type": "boolean"}, "code": map[string]any{"type": "string"}, "request_id": map[string]any{"type": "string"}, "server_time_ms": map[string]any{"type": "integer"}, "message": map[string]any{"type": "string"}, "recovery_action": map[string]any{"type": "string"}, "retry_after_ms": map[string]any{"type": "integer"}, "data": map[string]any{"type": "object", "additionalProperties": true}}}
|
||||
doc := map[string]any{"openapi": "3.1.0", "info": map[string]any{"title": "CALL-MEMORY-1.1", "version": "1.1.0"}, "paths": paths, "components": map[string]any{"schemas": schemas, "securitySchemes": map[string]any{"DeviceHMAC": map[string]any{"type": "apiKey", "in": "header", "name": "Authorization", "description": "Device-HMAC-SHA256 <hex HMAC-SHA256 of DEVREQ1 canonical bytes>"}, "CustomerBearer": map[string]any{"type": "http", "scheme": "bearer"}}}}
|
||||
b, e := json.MarshalIndent(doc, "", " ")
|
||||
if e != nil {
|
||||
panic(e)
|
||||
}
|
||||
if e = os.WriteFile("openapi/call-memory.yaml", append(b, '\n'), 0644); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/onboarding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func secret(name string) (string, error) {
|
||||
if file := os.Getenv(name + "_FILE"); file != "" {
|
||||
b, e := os.ReadFile(file)
|
||||
if e != nil {
|
||||
return "", fmt.Errorf("read %s_FILE: %w", name, e)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
return "", fmt.Errorf("missing %s", name)
|
||||
}
|
||||
func run() error {
|
||||
if len(os.Args) != 1 {
|
||||
return errors.New("customer-phone-account reads provisioning JSON from standard input")
|
||||
}
|
||||
var in struct {
|
||||
AccountKind string `json:"account_kind"`
|
||||
UserID string `json:"user_id"`
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"subject"`
|
||||
Phone string `json:"phone_e164"`
|
||||
Password string `json:"password"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(os.Stdin, 8193))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = onboarding.StrictJSON(raw, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := secret("DATABASE_URL")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
master, err := secret("ONBOARDING_MASTER_KEY")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, db)
|
||||
if err != nil {
|
||||
return errors.New("database configuration invalid")
|
||||
}
|
||||
defer pool.Close()
|
||||
r := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_ADDR")})
|
||||
defer r.Close()
|
||||
s, err := onboarding.NewService(pool, r, onboarding.Options{MasterKey: master, EntryURL: os.Getenv("ONBOARDING_ENTRY_URL")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provision := s.ProvisionCustomerPhone
|
||||
if in.AccountKind == "operator" {
|
||||
provision = s.ProvisionOperatorPhone
|
||||
} else if in.AccountKind != "" && in.AccountKind != "customer" {
|
||||
return errors.New("invalid account_kind")
|
||||
}
|
||||
created, err := provision(ctx, in.UserID, in.Issuer, in.Subject, in.Phone, in.Password, in.RequestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(map[string]any{"configured": true, "created": created, "user_id": in.UserID, "phone_masked": in.Phone[:len(in.Phone)-8] + "****" + in.Phone[len(in.Phone)-4:], "sms_verified": false})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Backfill an existing elder using the same provisioner as POST /subjects.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"data-terminal-cloud/internal/config"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/elderagent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
subject := flag.String("subject", "", "existing elder public ID")
|
||||
flag.Parse()
|
||||
if *subject == "" {
|
||||
fmt.Fprintln(os.Stderr, "--subject is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
cfg, err := config.Load("api")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
p, err := database.Open(ctx, cfg.DatabaseURL, "elder-agent")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer p.Close()
|
||||
path := os.Getenv("DIRECT_CHAT_TEMPLATE_FILE")
|
||||
if path == "" {
|
||||
path = cfg.VolcVoiceChatTemplate
|
||||
}
|
||||
template, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s := elderagent.Service{Pool: p, Provider: elderagent.NewClient(cfg.VolcAccessKeyID, cfg.VolcSecretAccessKey, cfg.VolcSessionToken, cfg.VolcRTCBaseURL, cfg.VolcRTCRegion).ForProduct(cfg.VolcHardwareInstanceID, cfg.VolcHardwareProductKey), Template: template, CallbackURL: cfg.SubtitleCallbackURL, CallbackSecret: []byte(cfg.SubtitleCallbackSecret)}
|
||||
info, err := s.Ensure(ctx, *subject)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("subject=%s bot_id=%s status=%s\n", *subject, info.BotID, info.Status)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Operator utility for an explicitly requested, attributed chat import.
|
||||
// It uses the same permission, consent, extraction and memoir services as Web.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/extraction"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
"data-terminal-cloud/internal/memoir"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func secret(name string) string {
|
||||
if file := os.Getenv(name + "_FILE"); file != "" {
|
||||
b, e := os.ReadFile(file)
|
||||
if e != nil {
|
||||
panic("secret file unavailable")
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
return os.Getenv(name)
|
||||
}
|
||||
func main() {
|
||||
if e := run(); e != nil {
|
||||
fmt.Fprintln(os.Stderr, e)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
input := flag.String("input", "", "JSON file with issuer, account_subject, elder_id, import_id, title, transcript")
|
||||
mode := flag.String("mode", "import", "import, extract, generate, inspect")
|
||||
flag.Parse()
|
||||
var in struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Subject string `json:"account_subject"`
|
||||
memoir.ImportInput
|
||||
}
|
||||
b, e := os.ReadFile(*input)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = json.Unmarshal(b, &in); e != nil {
|
||||
return e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
pool, e := pgxpool.New(ctx, secret("DATABASE_URL"))
|
||||
if e != nil {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
defer pool.Close()
|
||||
key := secret("ARK_API_KEY")
|
||||
base := "https://ark.cn-beijing.volces.com/api/v3"
|
||||
extractor, e := extraction.NewArkGenerator(base, key, "deepseek-v4-1-flash-260910", 90*time.Second)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
editor, e := extraction.NewArkJSONGenerator(base, key, "doubao-seed-evolving", 180*time.Second, 65536)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
s := &memoir.Service{Pool: pool, Generator: editor, Illustrator: memoir.NewIllustrator(base, key, "doubao-seedream-5-0-pro-260628"), Model: "doubao-seed-evolving"}
|
||||
p := identity.Principal{Issuer: in.Issuer, Subject: in.Subject}
|
||||
switch *mode {
|
||||
case "import":
|
||||
id, e := s.Import(ctx, p, in.ImportInput)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
fmt.Println("import", id)
|
||||
case "extract":
|
||||
processor, e := extraction.NewProcessor(pool, extractor)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if e = processor.HandleImport(ctx, outbox.Event{AggregateID: in.ID}); e != nil {
|
||||
return e
|
||||
}
|
||||
case "generate":
|
||||
id, e := s.Enqueue(ctx, p, in.ElderID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
fmt.Println("job", id)
|
||||
if e = s.Process(ctx, id); e != nil {
|
||||
return e
|
||||
}
|
||||
case "inspect":
|
||||
default:
|
||||
return errors.New("unknown mode")
|
||||
}
|
||||
result, e := s.Query(ctx, p, in.ElderID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"data-terminal-cloud/internal/workflowbridge"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func secret(name string) string {
|
||||
if p := os.Getenv(name + "_FILE"); p != "" {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
log.Fatal("secret file unavailable")
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv(name))
|
||||
}
|
||||
func main() {
|
||||
key, admin := secret("ARK_API_KEY"), secret("DIFY_BRIDGE_SECRET")
|
||||
if key == "" || admin == "" {
|
||||
log.Fatal("bridge credentials missing")
|
||||
}
|
||||
base := os.Getenv("ARK_BASE_URL")
|
||||
if base == "" {
|
||||
base = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
}
|
||||
service, err := workflowbridge.NewArkService(admin, base, key)
|
||||
if err != nil {
|
||||
log.Fatal("model configuration invalid")
|
||||
}
|
||||
server := &http.Server{Addr: ":8188", Handler: service, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second}
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
Reference in New Issue
Block a user