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
@@ -42,9 +42,9 @@ type weixinLoginAdmin interface {
|
||||
// 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.
|
||||
type weixinLoginQRResponse struct {
|
||||
QrUrl string `json:"qrUrl,omitempty"`
|
||||
QrCode string `json:"qrCode,omitempty"`
|
||||
Status string `json:"status"`
|
||||
QrUrl string `json:"qrUrl,omitempty"`
|
||||
QrCode string `json:"qrCode,omitempty"`
|
||||
Status string `json:"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)
|
||||
resp.BotID = botID
|
||||
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)
|
||||
} else {
|
||||
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
|
||||
// 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
|
||||
// bridge connects the now-configured adapter.
|
||||
func (s *Server) persistWeixinConfirmed(platform, accountID, token, botID, ownerID string) error {
|
||||
// bridge connects the now-configured adapter. When ownerID is non-empty it also
|
||||
// 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 {
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ package gateway
|
||||
// unwrap the gateway uses to reach the concrete *weixin_ilink.Adapter.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -81,9 +82,9 @@ func newWeixinGateway(t *testing.T, name string, channels ChannelConfigStore) (s
|
||||
wsrv := weixinLoginServer(t)
|
||||
wa, err := weixin_ilink.New(
|
||||
weixin_ilink.Config{
|
||||
BaseURL: wsrv.URL,
|
||||
LoginInterval: time.Millisecond,
|
||||
LoginTimeout: 5 * time.Second,
|
||||
BaseURL: wsrv.URL,
|
||||
LoginInterval: time.Millisecond,
|
||||
LoginTimeout: 5 * time.Second,
|
||||
},
|
||||
wsrv.Client(),
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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).
|
||||
// *runtime/knowledge.Store is the production implementation. Every method is
|
||||
// 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
|
||||
// not-configured error, matching how the LLM stack behaves when unset.
|
||||
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
|
||||
// the gateway degrades to an in-memory store so the endpoint still returns
|
||||
// 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.
|
||||
llm *model.RuntimeLLM
|
||||
channels ChannelConfigStore
|
||||
webhooks map[string]http.Handler
|
||||
log *slog.Logger
|
||||
hub *hub
|
||||
// ownerRegistrar stamps a scan-login bound owner id into the owner whitelist
|
||||
// (best-effort). See Deps.OwnerRegistrar.
|
||||
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
|
||||
@@ -317,30 +337,31 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
return nil, fmt.Errorf("gateway: auth dependency required")
|
||||
}
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
auth: deps.Auth,
|
||||
bus: deps.Bus,
|
||||
caps: deps.Registry,
|
||||
runner: deps.Loop,
|
||||
sessions: deps.Session,
|
||||
personas: deps.Personas,
|
||||
memories: deps.Memories,
|
||||
history: deps.History,
|
||||
iot: deps.IoT,
|
||||
auditor: deps.Scheduler,
|
||||
taskOutputs: deps.Tasks,
|
||||
automations: deps.Automations,
|
||||
users: deps.Users,
|
||||
backthink: deps.Backthink,
|
||||
rag: deps.RAG,
|
||||
obs: newObservability(deps),
|
||||
streaming: deps.Streaming,
|
||||
frontend: deps.Frontend,
|
||||
llm: deps.LLM,
|
||||
channels: deps.Channels,
|
||||
webhooks: deps.Webhooks,
|
||||
dbPath: deps.DatabasePath,
|
||||
log: sourcedLog(deps.Log),
|
||||
cfg: cfg,
|
||||
auth: deps.Auth,
|
||||
bus: deps.Bus,
|
||||
caps: deps.Registry,
|
||||
runner: deps.Loop,
|
||||
sessions: deps.Session,
|
||||
personas: deps.Personas,
|
||||
memories: deps.Memories,
|
||||
history: deps.History,
|
||||
iot: deps.IoT,
|
||||
auditor: deps.Scheduler,
|
||||
taskOutputs: deps.Tasks,
|
||||
automations: deps.Automations,
|
||||
users: deps.Users,
|
||||
backthink: deps.Backthink,
|
||||
rag: deps.RAG,
|
||||
obs: newObservability(deps),
|
||||
streaming: deps.Streaming,
|
||||
frontend: deps.Frontend,
|
||||
llm: deps.LLM,
|
||||
channels: deps.Channels,
|
||||
ownerRegistrar: deps.OwnerRegistrar,
|
||||
webhooks: deps.Webhooks,
|
||||
dbPath: deps.DatabasePath,
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
s.hub = newHub(s)
|
||||
s.hub.orchestrator = newOrchestrator(s)
|
||||
|
||||
Reference in new issue
Block a user