feat(owner): 扫码绑定确认时自动把主人id登记进owner.yaml白名单(热生效)
This commit is contained in:
1 parent
0aa387f83c
commit
5a7ee9d772
7 files changed
+487
-65
No files matched your search
+58
-24
@@ -114,6 +114,40 @@ type capacityEmitter interface {
|
|||||||
BroadcastCapacityAwaken(sessionID, fromState, toState, tool string)
|
BroadcastCapacityAwaken(sessionID, fromState, toState, tool string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerRegistrar implements gateway.OwnerRegistrar: it persists a channel account
|
||||||
|
// id into the owner whitelist (configs/users/owner.yaml) AND hot-updates the live
|
||||||
|
// owner profile the channel bridge holds, so a weixin scan-login confirmation is
|
||||||
|
// recognized as the owner immediately — no restart, no manual edit. path and the
|
||||||
|
// live profile are captured at wiring time: the boot-loaded *user.OwnerProfile is
|
||||||
|
// the same pointer the bridge resolves actors against, so RegisterAccount on it is
|
||||||
|
// visible to ResolveActor without touching the bridge.
|
||||||
|
type ownerRegistrar struct {
|
||||||
|
path string
|
||||||
|
owner *user.OwnerProfile
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error {
|
||||||
|
if strings.TrimSpace(accountID) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := user.RegisterOwnerAccount(r.path, platform, accountID); err != nil {
|
||||||
|
return fmt.Errorf("register owner %s:%s: %w", platform, accountID, err)
|
||||||
|
}
|
||||||
|
// Hot-effect: extend the live profile the bridge already points at. If the
|
||||||
|
// profile failed to load at boot (r.owner nil) the persist above still lands
|
||||||
|
// on disk and is recognized after a restart; log so the operator knows the
|
||||||
|
// hot path was unavailable.
|
||||||
|
if r.owner != nil {
|
||||||
|
r.owner.RegisterAccount(platform, accountID)
|
||||||
|
} else {
|
||||||
|
r.log.Warn("owner registrar: no live profile, whitelist persisted to disk only (restart to take effect)",
|
||||||
|
"platform", platform, "accountId", accountID)
|
||||||
|
}
|
||||||
|
r.log.Info("owner whitelist registered", "platform", platform, "accountId", accountID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
|
// newApp assembles the full Cyrene runtime from cfg and log. It opens the
|
||||||
// database, migrates the schema, wires the persona registry, model adapter, tool
|
// database, migrates the schema, wires the persona registry, model adapter, tool
|
||||||
// dispatcher, memory stack, agent loop, orchestration, execution, IoT, channel
|
// dispatcher, memory stack, agent loop, orchestration, execution, IoT, channel
|
||||||
@@ -625,29 +659,30 @@ func (a *app) wireFrontend() error {
|
|||||||
// and the gateway share the same persistent store. History was a no-op dependency
|
// and the gateway share the same persistent store. History was a no-op dependency
|
||||||
// before the IM path, so nothing downstream depends on its creation order change.
|
// before the IM path, so nothing downstream depends on its creation order change.
|
||||||
gw, err := gateway.New(a.cfg, gateway.Deps{
|
gw, err := gateway.New(a.cfg, gateway.Deps{
|
||||||
Log: logger.WithSource(a.log, "gateway"),
|
Log: logger.WithSource(a.log, "gateway"),
|
||||||
Auth: jwt,
|
Auth: jwt,
|
||||||
Bus: a.bus,
|
Bus: a.bus,
|
||||||
Registry: a.caps,
|
Registry: a.caps,
|
||||||
Loop: a.loop,
|
Loop: a.loop,
|
||||||
Session: a.sessions,
|
Session: a.sessions,
|
||||||
Personas: a.personas,
|
Personas: a.personas,
|
||||||
Memories: a.memStore,
|
Memories: a.memStore,
|
||||||
History: a.history,
|
History: a.history,
|
||||||
IoT: a.iot,
|
IoT: a.iot,
|
||||||
Scheduler: a.scheduler,
|
Scheduler: a.scheduler,
|
||||||
Tasks: a.tasks,
|
Tasks: a.tasks,
|
||||||
Automations: a.automation,
|
Automations: a.automation,
|
||||||
Users: apiKeys,
|
Users: apiKeys,
|
||||||
Backthink: a.backthink,
|
Backthink: a.backthink,
|
||||||
RAG: ragStore,
|
RAG: ragStore,
|
||||||
LLM: a.llm,
|
LLM: a.llm,
|
||||||
Channels: a.channels,
|
Channels: a.channels,
|
||||||
AuditStore: auditStore,
|
OwnerRegistrar: &ownerRegistrar{path: defaultOwnerPath, owner: a.owner, log: a.log},
|
||||||
Streaming: true,
|
AuditStore: auditStore,
|
||||||
Frontend: resolveFrontendFS(a.log),
|
Streaming: true,
|
||||||
Webhooks: a.channelBridge.WebhookHandlers(),
|
Frontend: resolveFrontendFS(a.log),
|
||||||
DatabasePath: a.dbPath,
|
Webhooks: a.channelBridge.WebhookHandlers(),
|
||||||
|
DatabasePath: a.dbPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("gateway: %w", err)
|
return fmt.Errorf("gateway: %w", err)
|
||||||
@@ -668,7 +703,6 @@ func (a *app) wireFrontend() error {
|
|||||||
}
|
}
|
||||||
a.capacitySink = gw
|
a.capacitySink = gw
|
||||||
|
|
||||||
|
|
||||||
// Wire the backthink proactive sinks now that the gateway hub (active-client
|
// Wire the backthink proactive sinks now that the gateway hub (active-client
|
||||||
// route) and the channel bridge (IM fallback) both exist. They were absent when
|
// route) and the channel bridge (IM fallback) both exist. They were absent when
|
||||||
// backthink.New ran (the hub needs the loop and the bridge needs the loop and the
|
// backthink.New ran (the hub needs the loop and the bridge needs the loop and the
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ type weixinLoginAdmin interface {
|
|||||||
// identifier (qrcode); the frontend renders a scannable QR from qrUrl, since the
|
// identifier (qrcode); the frontend renders a scannable QR from qrUrl, since the
|
||||||
// liteapp URL is an HTML page that cannot be fetched as an image.
|
// liteapp URL is an HTML page that cannot be fetched as an image.
|
||||||
type weixinLoginQRResponse struct {
|
type weixinLoginQRResponse struct {
|
||||||
QrUrl string `json:"qrUrl,omitempty"`
|
QrUrl string `json:"qrUrl,omitempty"`
|
||||||
QrCode string `json:"qrCode,omitempty"`
|
QrCode string `json:"qrCode,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// weixinLoginStatusResponse is the wire shape of GET /admin/channels/{platform}/login-status.
|
// weixinLoginStatusResponse is the wire shape of GET /admin/channels/{platform}/login-status.
|
||||||
@@ -106,7 +106,7 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
ownerID, _ := cfg["owner_id"].(string)
|
ownerID, _ := cfg["owner_id"].(string)
|
||||||
resp.BotID = botID
|
resp.BotID = botID
|
||||||
resp.BotToken = maskToken(token)
|
resp.BotToken = maskToken(token)
|
||||||
if err := s.persistWeixinConfirmed(platform, accountID, token, botID, ownerID); err != nil {
|
if err := s.persistWeixinConfirmed(r.Context(), platform, accountID, token, botID, ownerID); err != nil {
|
||||||
s.log.Error("weixin login persist", "platform", platform, "err", err)
|
s.log.Error("weixin login persist", "platform", platform, "err", err)
|
||||||
} else {
|
} else {
|
||||||
s.auditClaims(r, "channel.weixin.login.persist", "ok")
|
s.auditClaims(r, "channel.weixin.login.persist", "ok")
|
||||||
@@ -122,8 +122,13 @@ func (s *Server) adminWeixinLoginStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
// persistWeixinConfirmed writes the login-obtained credentials into the account's
|
// persistWeixinConfirmed writes the login-obtained credentials into the account's
|
||||||
// channels.json config (merging onto the user's curated base_url/cdn fields and
|
// channels.json config (merging onto the user's curated base_url/cdn fields and
|
||||||
// preserving its allow/deny/limit lists) and hot-reloads the platform so the
|
// preserving its allow/deny/limit lists) and hot-reloads the platform so the
|
||||||
// bridge connects the now-configured adapter.
|
// bridge connects the now-configured adapter. When ownerID is non-empty it also
|
||||||
func (s *Server) persistWeixinConfirmed(platform, accountID, token, botID, ownerID string) error {
|
// registers the scanner's bound ilink_user_id into the owner whitelist
|
||||||
|
// (owner.yaml) so the bridge recognizes it as the owner without a manual edit.
|
||||||
|
// The whitelist registration is best-effort: a failure is logged but does not
|
||||||
|
// fail the already-persisted channels.json, since the identity red line never
|
||||||
|
// blocks a successful bind.
|
||||||
|
func (s *Server) persistWeixinConfirmed(ctx context.Context, platform, accountID, token, botID, ownerID string) error {
|
||||||
if s.channels == nil {
|
if s.channels == nil {
|
||||||
return fmt.Errorf("weixin login: channel config store not injected")
|
return fmt.Errorf("weixin login: channel config store not injected")
|
||||||
}
|
}
|
||||||
@@ -183,7 +188,19 @@ func (s *Server) persistWeixinConfirmed(platform, accountID, token, botID, owner
|
|||||||
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
|
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.channels.Reload(platform)
|
if err := s.channels.Reload(platform); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Stamp the scanner (the bound owner) into the owner whitelist so the bridge
|
||||||
|
// recognizes it structurally. Best-effort: never fail an already-successful
|
||||||
|
// bind because a whitelist write hiccupped — the operator is alerted by a log
|
||||||
|
// line instead.
|
||||||
|
if ownerID != "" && s.ownerRegistrar != nil {
|
||||||
|
if err := s.ownerRegistrar.RegisterChannelOwner(ctx, platform, ownerID); err != nil {
|
||||||
|
s.log.Error("weixin login owner register", "platform", platform, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// weixinAccountFor resolves the *weixin_ilink.Adapter registered for the
|
// weixinAccountFor resolves the *weixin_ilink.Adapter registered for the
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package gateway
|
|||||||
// unwrap the gateway uses to reach the concrete *weixin_ilink.Adapter.
|
// unwrap the gateway uses to reach the concrete *weixin_ilink.Adapter.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -81,9 +82,9 @@ func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (s
|
|||||||
wsrv := weixinLoginServer(t)
|
wsrv := weixinLoginServer(t)
|
||||||
wa, err := weixin_ilink.New(
|
wa, err := weixin_ilink.New(
|
||||||
weixin_ilink.Config{
|
weixin_ilink.Config{
|
||||||
BaseURL: wsrv.URL,
|
BaseURL: wsrv.URL,
|
||||||
LoginInterval: time.Millisecond,
|
LoginInterval: time.Millisecond,
|
||||||
LoginTimeout: 5 * time.Second,
|
LoginTimeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
wsrv.Client(),
|
wsrv.Client(),
|
||||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
@@ -176,7 +177,9 @@ func TestAdminWeixinLoginStatus_Waiting(t *testing.T) {
|
|||||||
t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body)
|
t.Fatalf("login-status status = %d, body=%s", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
Data struct{ Status string `json:"status"` } `json:"data"`
|
Data struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &out); err != nil {
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
t.Fatalf("decode login-status: %v", err)
|
t.Fatalf("decode login-status: %v", err)
|
||||||
@@ -290,7 +293,9 @@ func TestAdminWeixinLoginStatus_AlreadyConfirmed(t *testing.T) {
|
|||||||
t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body)
|
t.Fatalf("login-qr status = %d, body=%s", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
var qr struct {
|
var qr struct {
|
||||||
Data struct{ Status string `json:"status"` } `json:"data"`
|
Data struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &qr); err != nil {
|
if err := json.Unmarshal(body, &qr); err != nil {
|
||||||
t.Fatalf("decode login-qr: %v", err)
|
t.Fatalf("decode login-qr: %v", err)
|
||||||
@@ -327,3 +332,66 @@ func TestAdminWeixinLogin_NonWeixinNotImplemented(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
|
t.Fatalf("status = %d, want 501 (body=%s)", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownerRegCall records one RegisterChannelOwner invocation.
|
||||||
|
type ownerRegCall struct {
|
||||||
|
platform, accountID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureOwnerRegistrar records whitelist registrations for the weixin confirm path.
|
||||||
|
type captureOwnerRegistrar struct {
|
||||||
|
calls []ownerRegCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *captureOwnerRegistrar) RegisterChannelOwner(_ context.Context, platform, accountID string) error {
|
||||||
|
c.calls = append(c.calls, ownerRegCall{platform, accountID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPersistWeixinConfirmed_RegistersOwner verifies that once a scan-login
|
||||||
|
// confirmation persists the channels.json credentials, the bound owner id is
|
||||||
|
// handed to the OwnerRegistrar as (weixin-ilink, ilink_user_id), and that an empty
|
||||||
|
// owner id skips the registration entirely.
|
||||||
|
func TestPersistWeixinConfirmed_RegistersOwner(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := filepath.Join(t.TempDir(), "channels.json")
|
||||||
|
chs := &fakeWeixinChannels{
|
||||||
|
path: path,
|
||||||
|
data: map[string][]config.ChannelAccount{
|
||||||
|
"weixin-ilink": {{ID: "conf0", Config: map[string]any{"base_url": "https://base"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reg := &captureOwnerRegistrar{}
|
||||||
|
s := &Server{
|
||||||
|
channels: chs,
|
||||||
|
ownerRegistrar: reg,
|
||||||
|
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-x", "bot-x", "o-1"); err != nil {
|
||||||
|
t.Fatalf("persistWeixinConfirmed: %v", err)
|
||||||
|
}
|
||||||
|
if len(reg.calls) != 1 || reg.calls[0].platform != "weixin-ilink" || reg.calls[0].accountID != "o-1" {
|
||||||
|
t.Fatalf("registrar calls = %+v, want [weixin-ilink/o-1]", reg.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty owner id (a confirmed bind that yields no bound user) must not
|
||||||
|
// register anything.
|
||||||
|
if err := s.persistWeixinConfirmed(context.Background(), "weixin-ilink", "conf0", "tok-y", "bot-y", ""); err != nil {
|
||||||
|
t.Fatalf("persistWeixinConfirmed(no owner): %v", err)
|
||||||
|
}
|
||||||
|
if len(reg.calls) != 1 {
|
||||||
|
t.Fatalf("registrar calls after empty ownerID = %+v, want still 1", reg.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNew_WiresOwnerRegistrar verifies the composition point: Deps.OwnerRegistrar
|
||||||
|
// is threaded onto the Server so the weixin confirm flow has a live registrar.
|
||||||
|
func TestNew_WiresOwnerRegistrar(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
reg := &captureOwnerRegistrar{}
|
||||||
|
srv, _, _ := buildEnv(t, cfgDefault(), Deps{OwnerRegistrar: reg})
|
||||||
|
if srv.ownerRegistrar != reg {
|
||||||
|
t.Fatalf("ownerRegistrar = %#v, want the injected registrar", srv.ownerRegistrar)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,18 @@ type ChannelConfigStore interface {
|
|||||||
Reload(platform string) error
|
Reload(platform string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OwnerRegistrar registers a channel account id into the owner whitelist
|
||||||
|
// (configs/users/owner.yaml). It is the structural identity red line: a scan-login
|
||||||
|
// confirmation stamps the scanner's bound ilink_user_id into the whitelist so the
|
||||||
|
// channel bridge recognizes it as the owner via actor metadata, never a prompt.
|
||||||
|
// The composition root implements it (cmd/cyrene's ownerRegistrar) to persist the
|
||||||
|
// whitelist and hot-update the live owner profile. Registration is best-effort:
|
||||||
|
// a failure is logged by the caller but does not fail the already-applied
|
||||||
|
// channels.json persist.
|
||||||
|
type OwnerRegistrar interface {
|
||||||
|
RegisterChannelOwner(ctx context.Context, platform, accountID string) error
|
||||||
|
}
|
||||||
|
|
||||||
// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8).
|
// RagStore backs the admin knowledge-base RAG module (design/10 §3.12 module 8).
|
||||||
// *runtime/knowledge.Store is the production implementation. Every method is
|
// *runtime/knowledge.Store is the production implementation. Every method is
|
||||||
// read-only for this milestone: IndexDocument/DeleteDocument return a clear
|
// read-only for this milestone: IndexDocument/DeleteDocument return a clear
|
||||||
@@ -245,6 +257,11 @@ type Deps struct {
|
|||||||
// Optional: when nil the admin /channels/config endpoints return a clear
|
// Optional: when nil the admin /channels/config endpoints return a clear
|
||||||
// not-configured error, matching how the LLM stack behaves when unset.
|
// not-configured error, matching how the LLM stack behaves when unset.
|
||||||
Channels ChannelConfigStore
|
Channels ChannelConfigStore
|
||||||
|
// OwnerRegistrar stamps a scan-login's bound owner id into the owner
|
||||||
|
// whitelist (configs/users/owner.yaml) and hot-updates the live profile.
|
||||||
|
// Optional: when nil the weixin confirm flow still persists channels.json but
|
||||||
|
// skips the whitelist registration (best-effort by design).
|
||||||
|
OwnerRegistrar OwnerRegistrar
|
||||||
// AuditStore persists the admin audit trail (observability → audit). When nil
|
// AuditStore persists the admin audit trail (observability → audit). When nil
|
||||||
// the gateway degrades to an in-memory store so the endpoint still returns
|
// the gateway degrades to an in-memory store so the endpoint still returns
|
||||||
// data; the composition root injects NewSQLiteAuditStore over a.db for real
|
// data; the composition root injects NewSQLiteAuditStore over a.db for real
|
||||||
@@ -301,9 +318,12 @@ type Server struct {
|
|||||||
// that do not wire it keep working.
|
// that do not wire it keep working.
|
||||||
llm *model.RuntimeLLM
|
llm *model.RuntimeLLM
|
||||||
channels ChannelConfigStore
|
channels ChannelConfigStore
|
||||||
webhooks map[string]http.Handler
|
// ownerRegistrar stamps a scan-login bound owner id into the owner whitelist
|
||||||
log *slog.Logger
|
// (best-effort). See Deps.OwnerRegistrar.
|
||||||
hub *hub
|
ownerRegistrar OwnerRegistrar
|
||||||
|
webhooks map[string]http.Handler
|
||||||
|
log *slog.Logger
|
||||||
|
hub *hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// New validates cfg and deps and returns a ready Server. Auth is required; the
|
// New validates cfg and deps and returns a ready Server. Auth is required; the
|
||||||
@@ -317,30 +337,31 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
|||||||
return nil, fmt.Errorf("gateway: auth dependency required")
|
return nil, fmt.Errorf("gateway: auth dependency required")
|
||||||
}
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
auth: deps.Auth,
|
auth: deps.Auth,
|
||||||
bus: deps.Bus,
|
bus: deps.Bus,
|
||||||
caps: deps.Registry,
|
caps: deps.Registry,
|
||||||
runner: deps.Loop,
|
runner: deps.Loop,
|
||||||
sessions: deps.Session,
|
sessions: deps.Session,
|
||||||
personas: deps.Personas,
|
personas: deps.Personas,
|
||||||
memories: deps.Memories,
|
memories: deps.Memories,
|
||||||
history: deps.History,
|
history: deps.History,
|
||||||
iot: deps.IoT,
|
iot: deps.IoT,
|
||||||
auditor: deps.Scheduler,
|
auditor: deps.Scheduler,
|
||||||
taskOutputs: deps.Tasks,
|
taskOutputs: deps.Tasks,
|
||||||
automations: deps.Automations,
|
automations: deps.Automations,
|
||||||
users: deps.Users,
|
users: deps.Users,
|
||||||
backthink: deps.Backthink,
|
backthink: deps.Backthink,
|
||||||
rag: deps.RAG,
|
rag: deps.RAG,
|
||||||
obs: newObservability(deps),
|
obs: newObservability(deps),
|
||||||
streaming: deps.Streaming,
|
streaming: deps.Streaming,
|
||||||
frontend: deps.Frontend,
|
frontend: deps.Frontend,
|
||||||
llm: deps.LLM,
|
llm: deps.LLM,
|
||||||
channels: deps.Channels,
|
channels: deps.Channels,
|
||||||
webhooks: deps.Webhooks,
|
ownerRegistrar: deps.OwnerRegistrar,
|
||||||
dbPath: deps.DatabasePath,
|
webhooks: deps.Webhooks,
|
||||||
log: sourcedLog(deps.Log),
|
dbPath: deps.DatabasePath,
|
||||||
|
log: sourcedLog(deps.Log),
|
||||||
}
|
}
|
||||||
s.hub = newHub(s)
|
s.hub = newHub(s)
|
||||||
s.hub.orchestrator = newOrchestrator(s)
|
s.hub.orchestrator = newOrchestrator(s)
|
||||||
|
|||||||
+42
-2
@@ -4,6 +4,8 @@
|
|||||||
// this whitelist — and is never driven by prompt text or an LLM judgment.
|
// this whitelist — and is never driven by prompt text or an LLM judgment.
|
||||||
package user
|
package user
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
// OwnerAccount is a single channel-fingerprint that belongs to the owner. The
|
// OwnerAccount is a single channel-fingerprint that belongs to the owner. The
|
||||||
// policy engine matches channel-layer actor metadata (platform + account id)
|
// policy engine matches channel-layer actor metadata (platform + account id)
|
||||||
// against this list.
|
// against this list.
|
||||||
@@ -16,7 +18,14 @@ type OwnerAccount struct {
|
|||||||
// persona bound to the owner, and the per-channel account whitelist. It is the
|
// persona bound to the owner, and the per-channel account whitelist. It is the
|
||||||
// source data for owner recognition; it is not injected into the persona's
|
// source data for owner recognition; it is not injected into the persona's
|
||||||
// prompt as an authority.
|
// prompt as an authority.
|
||||||
|
//
|
||||||
|
// mu guards Accounts so a live profile can be hot-extended (RegisterAccount)
|
||||||
|
// while the channel bridge's ResolveActor reads it (IsOwner) concurrently. The
|
||||||
|
// mutex is unexported and carries no YAML tag, so it is invisible to encode and
|
||||||
|
// is not part of the persisted profile.
|
||||||
type OwnerProfile struct {
|
type OwnerProfile struct {
|
||||||
|
mu sync.RWMutex `yaml:"-"`
|
||||||
|
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
DisplayName string `yaml:"displayName"`
|
DisplayName string `yaml:"displayName"`
|
||||||
DefaultPersona string `yaml:"defaultPersona"`
|
DefaultPersona string `yaml:"defaultPersona"`
|
||||||
@@ -28,8 +37,15 @@ type OwnerProfile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsOwner reports whether the given (platform, accountID) pair belongs to the
|
// IsOwner reports whether the given (platform, accountID) pair belongs to the
|
||||||
// owner according to the whitelist.
|
// owner according to the whitelist. It is safe for concurrent use with
|
||||||
|
// RegisterAccount (the bridge reads this on every inbound message while a
|
||||||
|
// scan-login registration may extend the whitelist).
|
||||||
func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
||||||
|
if p == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
for _, a := range p.Accounts {
|
for _, a := range p.Accounts {
|
||||||
if a.Platform == platform && a.AccountID == accountID {
|
if a.Platform == platform && a.AccountID == accountID {
|
||||||
return true
|
return true
|
||||||
@@ -38,9 +54,33 @@ func (p *OwnerProfile) IsOwner(platform, accountID string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterAccount appends (platform, accountID) to the whitelist if it is not
|
||||||
|
// already present, leaving the profile otherwise untouched. It is the in-memory
|
||||||
|
// counterpart of RegisterOwnerAccount: the channel bridge holds the very same
|
||||||
|
// *OwnerProfile, so a scan-login confirmation can extend the whitelist without a
|
||||||
|
// restart. Duplicate (platform, accountID) pairs are a no-op, matching IsOwner.
|
||||||
|
func (p *OwnerProfile) RegisterAccount(platform, accountID string) {
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for _, a := range p.Accounts {
|
||||||
|
if a.Platform == platform && a.AccountID == accountID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Accounts = append(p.Accounts, OwnerAccount{Platform: platform, AccountID: accountID})
|
||||||
|
}
|
||||||
|
|
||||||
// AccountIdentifiers returns the distinct channel account ids owned by the
|
// AccountIdentifiers returns the distinct channel account ids owned by the
|
||||||
// owner, for diagnostics / listing.
|
// owner, for diagnostics / listing. Safe for concurrent use with RegisterAccount.
|
||||||
func (p *OwnerProfile) AccountIdentifiers() []string {
|
func (p *OwnerProfile) AccountIdentifiers() []string {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
seen := make(map[string]struct{}, len(p.Accounts))
|
seen := make(map[string]struct{}, len(p.Accounts))
|
||||||
out := make([]string, 0, len(p.Accounts))
|
out := make([]string, 0, len(p.Accounts))
|
||||||
for _, a := range p.Accounts {
|
for _, a := range p.Accounts {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SaveOwnerProfile writes p to path as YAML, atomically (temp file + rename) with
|
||||||
|
// mode 0600 and lenient parent-directory creation, matching the runtime config
|
||||||
|
// convention (SaveChannels). The whole struct is marshaled, so curated fields
|
||||||
|
// (notes, identityFile, displayName, ...) survive a round-trip. YAML comments in
|
||||||
|
// the existing file are not part of the struct and are dropped by the encode,
|
||||||
|
// consistent with the project's other config writers.
|
||||||
|
func SaveOwnerProfile(path string, p *OwnerProfile) error {
|
||||||
|
if p == nil {
|
||||||
|
return fmt.Errorf("user: save owner profile: empty profile")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return fmt.Errorf("user: save owner profile: empty path")
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user: encode %s: %w", path, err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("user: create %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(dir, ".owner-*.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user: temp file in %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
closed := false
|
||||||
|
defer func() {
|
||||||
|
_ = tmp.Close()
|
||||||
|
if !closed {
|
||||||
|
_ = os.Remove(tmpName)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// CreateTemp opens with 0600; chmod explicitly so the on-disk file never
|
||||||
|
// carries a broader mode even if the umask changes.
|
||||||
|
if err := tmp.Chmod(0o600); err != nil {
|
||||||
|
return fmt.Errorf("user: chmod %s: %w", tmpName, err)
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("user: write %s: %w", tmpName, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("user: sync %s: %w", tmpName, err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("user: close %s: %w", tmpName, err)
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
|
if err := os.Rename(tmpName, path); err != nil {
|
||||||
|
return fmt.Errorf("user: rename to %s: %w", path, err)
|
||||||
|
}
|
||||||
|
// Best-effort dir fsync so the rename is durable across a crash.
|
||||||
|
if d, derr := os.Open(dir); derr == nil {
|
||||||
|
_ = d.Sync()
|
||||||
|
_ = d.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterOwnerAccount loads the owner whitelist at path, appends (platform,
|
||||||
|
// accountID) when it is not already present (idempotent), and writes the updated
|
||||||
|
// profile back. It returns an error when the profile cannot be loaded or saved.
|
||||||
|
// The on-disk replace is atomic, so a concurrent reader never sees a partial
|
||||||
|
// file. Hot-reload of a running owner profile is the caller's concern (see
|
||||||
|
// cmd/cyrene's ownerRegistrar), which applies RegisterAccount to the live profile
|
||||||
|
// the channel bridge already holds.
|
||||||
|
func RegisterOwnerAccount(path, platform, accountID string) error {
|
||||||
|
if strings.TrimSpace(platform) == "" {
|
||||||
|
return fmt.Errorf("user: register owner account: empty platform")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(accountID) == "" {
|
||||||
|
return fmt.Errorf("user: register owner account: empty account id")
|
||||||
|
}
|
||||||
|
p, err := LoadOwnerProfile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user: load %s: %w", path, err)
|
||||||
|
}
|
||||||
|
p.RegisterAccount(platform, accountID)
|
||||||
|
return SaveOwnerProfile(path, p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSaveOwnerProfile_RoundTrip verifies a profile loaded from disk survives a
|
||||||
|
// Save → Load cycle without dropping curated fields (notes, identityFile, ...) or
|
||||||
|
// the account whitelist. It also exercises the atomic-write + parent-dir-create
|
||||||
|
// path (writing under a not-yet-existing subdirectory).
|
||||||
|
func TestSaveOwnerProfile_RoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
src := writeProfile(t, `id: owner
|
||||||
|
displayName: 叶酱
|
||||||
|
defaultPersona: cyrene
|
||||||
|
identityFile: configs/users/owner_identity.md
|
||||||
|
accounts:
|
||||||
|
- platform: kook
|
||||||
|
accountId: "329284282"
|
||||||
|
notes: "主人。陪伴对象。"
|
||||||
|
`)
|
||||||
|
orig, err := LoadOwnerProfile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(t.TempDir(), "nested", "deep", "owner.yaml")
|
||||||
|
if err := SaveOwnerProfile(dst, orig); err != nil {
|
||||||
|
t.Fatalf("SaveOwnerProfile: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat saved profile: %v", err)
|
||||||
|
}
|
||||||
|
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Errorf("saved file mode = %o, want 0600", perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := LoadOwnerProfile(dst)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load saved: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "owner" || got.DisplayName != "叶酱" || got.DefaultPersona != "cyrene" {
|
||||||
|
t.Fatalf("scalar fields = %+v, want owner/叶酱/cyrene", got)
|
||||||
|
}
|
||||||
|
if got.IdentityFile != "configs/users/owner_identity.md" {
|
||||||
|
t.Errorf("identityFile = %q, want preserved", got.IdentityFile)
|
||||||
|
}
|
||||||
|
if got.Notes != "主人。陪伴对象。" {
|
||||||
|
t.Errorf("notes = %q, want preserved", got.Notes)
|
||||||
|
}
|
||||||
|
if !got.IsOwner("kook", "329284282") {
|
||||||
|
t.Error("saved profile should still recognize the kook owner account")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSaveOwnerProfile_NilProfile errors instead of panicking.
|
||||||
|
func TestSaveOwnerProfile_NilProfile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if err := SaveOwnerProfile(filepath.Join(t.TempDir(), "o.yaml"), nil); err == nil {
|
||||||
|
t.Fatal("expected an error for a nil profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterOwnerAccount_AppendAndDedupe verifies that a new account is appended
|
||||||
|
// to the whitelist and recognized, a duplicate registration is a no-op, and an
|
||||||
|
// already-present account is never duplicated.
|
||||||
|
func TestRegisterOwnerAccount_AppendAndDedupe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := writeProfile(t, `id: owner
|
||||||
|
displayName: 叶酱
|
||||||
|
defaultPersona: cyrene
|
||||||
|
accounts:
|
||||||
|
- platform: kook
|
||||||
|
accountId: "329284282"
|
||||||
|
notes: "主人"
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err := RegisterOwnerAccount(path, "weixin-ilink", "wx1"); err != nil {
|
||||||
|
t.Fatalf("register new account: %v", err)
|
||||||
|
}
|
||||||
|
p, err := LoadOwnerProfile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if !p.IsOwner("weixin-ilink", "wx1") {
|
||||||
|
t.Error("newly registered (weixin-ilink, wx1) must be recognized as owner")
|
||||||
|
}
|
||||||
|
if !p.IsOwner("kook", "329284282") {
|
||||||
|
t.Error("existing (kook, 329284282) must still be recognized")
|
||||||
|
}
|
||||||
|
if got := len(p.Accounts); got != 2 {
|
||||||
|
t.Fatalf("accounts = %d, want 2 (kook + weixin-ilink)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate registration is a no-op.
|
||||||
|
if err := RegisterOwnerAccount(path, "weixin-ilink", "wx1"); err != nil {
|
||||||
|
t.Fatalf("re-register same account: %v", err)
|
||||||
|
}
|
||||||
|
p, _ = LoadOwnerProfile(path)
|
||||||
|
if got := len(p.Accounts); got != 2 {
|
||||||
|
t.Fatalf("accounts after duplicate = %d, want still 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-registering an existing kook account is also a no-op.
|
||||||
|
if err := RegisterOwnerAccount(path, "kook", "329284282"); err != nil {
|
||||||
|
t.Fatalf("re-register existing kook: %v", err)
|
||||||
|
}
|
||||||
|
p, _ = LoadOwnerProfile(path)
|
||||||
|
if got := len(p.Accounts); got != 2 {
|
||||||
|
t.Fatalf("accounts after kook dedupe = %d, want still 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterOwnerAccount_ValidatesInput rejects empty platform / account id.
|
||||||
|
func TestRegisterOwnerAccount_ValidatesInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := writeProfile(t, "id: owner\ndisplayName: 叶酱\naccounts: []\n")
|
||||||
|
if err := RegisterOwnerAccount(path, "", "wx1"); err == nil {
|
||||||
|
t.Error("empty platform must error")
|
||||||
|
}
|
||||||
|
if err := RegisterOwnerAccount(path, "weixin-ilink", ""); err == nil {
|
||||||
|
t.Error("empty account id must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterAccount_HotEffect verifies the in-memory mutation the channel bridge
|
||||||
|
// relies on: after RegisterAccount on a live profile, IsOwner sees it immediately
|
||||||
|
// and a duplicate is a no-op. This is the hot-reload path (no restart).
|
||||||
|
func TestRegisterAccount_HotEffect(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
p := &OwnerProfile{ID: "owner", DisplayName: "叶酱",
|
||||||
|
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "329284282"}}}
|
||||||
|
|
||||||
|
p.RegisterAccount("weixin-ilink", "wx1")
|
||||||
|
if !p.IsOwner("weixin-ilink", "wx1") {
|
||||||
|
t.Error("hot-registered (weixin-ilink, wx1) must be recognized immediately")
|
||||||
|
}
|
||||||
|
if len(p.Accounts) != 2 {
|
||||||
|
t.Fatalf("accounts = %d, want 2", len(p.Accounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
p.RegisterAccount("weixin-ilink", "wx1") // duplicate
|
||||||
|
if len(p.Accounts) != 2 {
|
||||||
|
t.Fatalf("accounts after duplicate = %d, want still 2", len(p.Accounts))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user