feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
This commit is contained in:
Aska committed 2026-09-07 13:29:41 +08:00
1 parent 2d7151f979
commit aedae2190b
18 files changed
+1743 -126

No files matched your search

+57 -5
View File
@@ -30,10 +30,14 @@ type PluginSummary struct {
AllowInStranger bool `json:"allowInStranger"`
}
// ChannelSummary is the wire shape of a registered channel adapter.
// ChannelSummary is the wire shape of a registered channel adapter. Accounts
// carries that platform's configured accounts (masked, no raw tokens) so the
// panel can show and edit the account config; it is omitted when s.channels is
// not wired or the platform has no configured account.
type ChannelSummary struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Name string `json:"name"`
Connected bool `json:"connected"`
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
}
// policyRequest is the POST /api/v1/admin/plugins/policy body.
@@ -133,8 +137,11 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
}
// adminListChannels lists the registered channel adapters (admin read).
// adminListChannels lists the registered channel adapters (admin read). When the
// channel config store is wired it also attaches each platform's configured
// accounts, masked so a raw token never reaches the client.
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
accountsByPlatform := s.maskedChannelAccounts()
out := make([]ChannelSummary, 0)
if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) {
@@ -142,12 +149,57 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
if !ok {
continue
}
out = append(out, ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()})
sum := ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()}
if accts, ok := accountsByPlatform[channelBasePlatform(ch.Name())]; ok && len(accts) > 0 {
sum.Accounts = accts
}
out = append(out, sum)
}
}
// A platform that has configured accounts but no registered adapter (e.g. the
// account was just saved) still surfaces, so its config is visible.
for platform, accts := range accountsByPlatform {
if len(accts) == 0 || s.hasChannelSummary(out, platform) {
continue
}
out = append(out, ChannelSummary{Name: platform, Accounts: accts})
}
writeJSON(w, http.StatusOK, out)
}
// maskedChannelAccounts returns the effective per-platform configured accounts,
// masked (no raw tokens). It is empty when the channel config store is not wired.
func (s *Server) maskedChannelAccounts() map[string][]AdminChannelAccount {
if s.channels == nil {
return map[string][]AdminChannelAccount{}
}
out := make(map[string][]AdminChannelAccount, 8)
for platform, accts := range s.channels.Load() {
list := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
if !configuredAccount(a) {
continue
}
list = append(list, adminChannelAccountOf(runtimeconfig.Masked(a)))
}
if len(list) > 0 {
out[platform] = list
}
}
return out
}
// hasChannelSummary reports whether out already contains a summary for platform
// (so a platform with a registered adapter is not duplicated).
func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
for _, c := range out {
if channelBasePlatform(c.Name) == platform {
return true
}
}
return false
}
// adminListIoT lists IoT devices (admin read).
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
out := make([]iotDeviceSummary, 0)
@@ -0,0 +1,266 @@
package gateway
// Admin channel-configuration writes (owner-only, audited). The panel saves a
// platform's account config (token/address/rate limit/black-or-white lists) to
// channels.json and hot-reloads that platform's adapters — no restart required
// (design/04 §3.1). The GET /admin/channels read path masks every token so a
// secret is never returned to the client; this write path persists the real
// token to the gitignored file and triggers the bridge reload.
//
// The request carries a generic config map (platform-specific keys such as
// bot_token / base_url / remote_url / endpointPath) plus convenience top-level
// fields for the common token/address fields, so the panel can send either the
// config object or the shortcut fields.
import (
"encoding/json"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// AdminChannelAccount is the wire shape of one configured channel account in the
// admin list. Config values are masked (no raw token) so nothing sensitive is
// returned to the client.
type AdminChannelAccount struct {
ID string `json:"id"`
Config map[string]any `json:"config,omitempty"`
AllowGroups []string `json:"allowGroups,omitempty"`
DenyGroups []string `json:"denyGroups,omitempty"`
AllowUsers []string `json:"allowUsers,omitempty"`
DenyUsers []string `json:"denyUsers,omitempty"`
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
MsgPerMin int `json:"msgPerMin,omitempty"`
MaxMessages int `json:"maxMessages,omitempty"`
}
// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body.
// Config holds platform-specific keys (bot_token, base_url, remote_url,
// endpointPath, ...); the top-level convenience fields are merged into it for the
// common token/address fields. AllowGroups/DenyGroups/AllowUsers/DenyUsers are the
// black-or-white lists (design/16 §4.6 §6); the *_PerMin/MaxMessages fields are
// the per-account flow limits (design/12 §6 策略三/四).
type channelConfigRequest struct {
ID string `json:"id"`
Token string `json:"token"`
BaseURL string `json:"baseURL"`
RemoteURL string `json:"remoteURL"`
ListenAddr string `json:"listenAddr"`
Mode string `json:"mode"`
AccessToken string `json:"accessToken"`
SelfID string `json:"selfID"`
Config map[string]any `json:"config"`
AllowGroups []string `json:"allowGroups"`
DenyGroups []string `json:"denyGroups"`
AllowUsers []string `json:"allowUsers"`
DenyUsers []string `json:"denyUsers"`
GroupMsgPerMin int `json:"groupMsgPerMin"`
MsgPerMin int `json:"msgPerMin"`
MaxMessages int `json:"maxMessages"`
}
// adminSaveChannelConfig persists one account's config for a platform into
// channels.json (Upsert by id) and hot-reloads that platform's adapters. The
// response carries the channels.json path and the account id so the panel can
// address the saved account (for a later edit/delete).
func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
if platform == "" {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
var req channelConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.channels == nil {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
return
}
cfg := channelConfigFromRequest(req)
if len(cfg) == 0 {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "至少提供 token/baseUrl/address 等配置字段"))
return
}
accounts := s.channels.Load()
updated := runtimeconfig.UpsertChannel(accounts, platform, config.ChannelAccount{
ID: strings.TrimSpace(req.ID),
Config: cfg,
AllowGroups: req.AllowGroups,
DenyGroups: req.DenyGroups,
AllowUsers: req.AllowUsers,
DenyUsers: req.DenyUsers,
GroupMsgPerMin: req.GroupMsgPerMin,
MsgPerMin: req.MsgPerMin,
MaxMessages: req.MaxMessages,
})
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
s.auditClaims(r, "channel.save", "error")
s.log.Error("channel save channels.json", "path", s.channels.Path(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json"))
return
}
if err := s.channels.Reload(platform); err != nil {
s.auditClaims(r, "channel.save", "error")
s.log.Error("channel reload", "platform", platform, "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
return
}
s.auditClaims(r, "channel.save", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"persisted": true,
"path": s.channels.Path(),
"platform": platform,
"accountId": savedAccountID(updated[platform], req.ID),
})
}
// adminDeleteChannelAccount removes one account (by the id query param) from a
// platform in channels.json and hot-reloads that platform so the adapter stops.
func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
id := strings.TrimSpace(r.URL.Query().Get("id"))
if platform == "" {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
if id == "" {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
return
}
if s.channels == nil {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
return
}
accounts := s.channels.Load()
updated := runtimeconfig.DeleteChannel(accounts, platform, id)
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
s.auditClaims(r, "channel.delete", "error")
s.log.Error("channel delete channels.json", "path", s.channels.Path(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json"))
return
}
if err := s.channels.Reload(platform); err != nil {
s.auditClaims(r, "channel.delete", "error")
s.log.Error("channel reload", "platform", platform, "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
return
}
s.auditClaims(r, "channel.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"persisted": true,
"path": s.channels.Path(),
"platform": platform,
"accountId": id,
})
}
// channelConfigFromRequest merges the request's convenience fields into the
// platform-specific config map, preferring keys the panel already set explicitly.
// It never injects a key the panel left out (an empty token is left absent so the
// adapter's zero value, not an empty-string token, is used).
func channelConfigFromRequest(req channelConfigRequest) map[string]any {
cfg := make(map[string]any, len(req.Config)+8)
for k, v := range req.Config {
cfg[k] = v
}
if req.Token != "" {
if _, ok := cfg["bot_token"]; !ok {
cfg["bot_token"] = req.Token
}
}
if req.BaseURL != "" {
if _, ok := cfg["base_url"]; !ok {
cfg["base_url"] = req.BaseURL
}
}
if req.RemoteURL != "" {
if _, ok := cfg["remote_url"]; !ok {
cfg["remote_url"] = req.RemoteURL
}
}
if req.ListenAddr != "" {
if _, ok := cfg["listen_addr"]; !ok {
cfg["listen_addr"] = req.ListenAddr
}
}
if req.Mode != "" {
if _, ok := cfg["mode"]; !ok {
cfg["mode"] = req.Mode
}
}
if req.AccessToken != "" {
if _, ok := cfg["access_token"]; !ok {
cfg["access_token"] = req.AccessToken
}
}
if req.SelfID != "" {
if _, ok := cfg["self_id"]; !ok {
cfg["self_id"] = req.SelfID
}
}
return cfg
}
// savedAccountID returns the account id that a save produced — the caller-supplied
// one when present, otherwise the id runtimeconfig.Upsert generated (which it
// appends last for a brand-new account).
func savedAccountID(list []config.ChannelAccount, supplied string) string {
if supplied != "" {
return supplied
}
if len(list) == 0 {
return ""
}
return list[len(list)-1].ID
}
// adminChannelAccountOf maps a (masked) account to its admin wire shape.
func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
return AdminChannelAccount{
ID: acct.ID,
Config: acct.Config,
AllowGroups: acct.AllowGroups,
DenyGroups: acct.DenyGroups,
AllowUsers: acct.AllowUsers,
DenyUsers: acct.DenyUsers,
GroupMsgPerMin: acct.GroupMsgPerMin,
MsgPerMin: acct.MsgPerMin,
MaxMessages: acct.MaxMessages,
}
}
// channelBasePlatform returns the bare platform of a (possibly multi-account)
// adapter name like "telegram:conf0" → "telegram", so config lookup groups every
// account of a platform together.
func channelBasePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
}
return name
}
// configuredAccount reports whether an account carries a meaningful config (a
// panel-created id or a real token/address), so the admin list skips the blank
// placeholder accounts the env fallback synthesizes for unconfigured platforms.
func configuredAccount(a config.ChannelAccount) bool {
if strings.TrimSpace(a.ID) != "" {
return true
}
for _, v := range a.Config {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return true
}
}
return false
}
@@ -0,0 +1,149 @@
package gateway
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// stubChannelConfigStore is a ChannelConfigStore backed by an in-memory map and a
// real temp channels.json path, recording Reload calls.
type stubChannelConfigStore struct {
path string
accounts map[string][]config.ChannelAccount
reloaded []string
reloadErr error
}
func (s *stubChannelConfigStore) Path() string { return s.path }
func (s *stubChannelConfigStore) Load() map[string][]config.ChannelAccount {
if s.accounts == nil {
return map[string][]config.ChannelAccount{}
}
return s.accounts
}
func (s *stubChannelConfigStore) Reload(platform string) error {
s.reloaded = append(s.reloaded, platform)
return s.reloadErr
}
func TestAdminSaveChannelConfigPersistsAndReloads(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
map[string]any{
"token": "bot-tok", "baseURL": "https://kook.example",
"groupMsgPerMin": 3, "denyGroups": []string{"g-9"},
}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var res struct {
Persisted bool `json:"persisted"`
Path string `json:"path"`
Platform string `json:"platform"`
AccountID string `json:"accountId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode save: %v (body=%s)", err, body)
}
if !res.Persisted || res.Path != path || res.Platform != "kook" || res.AccountID == "" {
t.Fatalf("save res = %+v", res)
}
// The account landed in channels.json with the real token and the black-list.
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn != nil {
t.Fatalf("load channels warn = %v", warn)
}
kook := accounts["kook"]
if len(kook) != 1 || kook[0].Config["bot_token"] != "bot-tok" {
t.Fatalf("channels.json kook = %+v, want one account with bot-tok", kook)
}
if len(kook[0].DenyGroups) != 1 || kook[0].DenyGroups[0] != "g-9" {
t.Fatalf("denyGroups = %v, want [g-9]", kook[0].DenyGroups)
}
if kook[0].GroupMsgPerMin != 3 {
t.Fatalf("groupMsgPerMin = %d, want 3", kook[0].GroupMsgPerMin)
}
// Reload was triggered for the saved platform.
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
}
// GET /admin/channels reflects the account and never leaks the raw token.
gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
if gResp.StatusCode != http.StatusOK {
t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody)
}
if strings.Contains(string(gBody), "bot-tok") {
t.Fatalf("raw token leaked in GET /admin/channels: %s", gBody)
}
if !strings.Contains(string(gBody), "kook") {
t.Fatalf("GET /admin/channels missing the kook account: %s", gBody)
}
}
func TestAdminSaveChannelConfigRejectsBlank(t *testing.T) {
t.Parallel()
store := &stubChannelConfigStore{path: filepath.Join(t.TempDir(), "channels.json"), accounts: map[string][]config.ChannelAccount{}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
map[string]any{"id": "a"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("blank config status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// No reload should have been triggered.
if len(store.reloaded) != 0 {
t.Fatalf("reloaded = %v, want none", store.reloaded)
}
}
func TestAdminSaveChannelConfigUnwired500(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no channel config store
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/kook/config",
map[string]any{"token": "x"}, tok)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("unwired status = %d, want 500 (body=%s)", resp.StatusCode, body)
}
}
func TestAdminDeleteChannelConfig(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "channels.json")
store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{
"kook": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
}}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/kook/config?id=a", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(store.reloaded) != 1 || store.reloaded[0] != "kook" {
t.Fatalf("reloaded = %v, want [kook]", store.reloaded)
}
// The file is rewritten without the account; the platform key is dropped.
accounts, warn := runtimeconfig.LoadChannels(path, nil)
if warn == nil {
t.Fatalf("expected a warn after deleting the last account (empty map is invalid)")
}
if _, ok := accounts["kook"]; ok && len(accounts["kook"]) > 0 {
t.Fatalf("kook account still present after delete: %+v", accounts)
}
}
+24
View File
@@ -157,6 +157,22 @@ type BackthinkStore interface {
List(ctx context.Context, limit int) ([]backthink.Record, error)
}
// ChannelConfigStore is the runtime-editable channel-adapter configuration
// (channels.json, design/04 §3.1). It is implemented by the composition root's
// channel store: Load returns the effective per-platform accounts (file → env
// fallback), Path reports the channels.json path, and Reload hot-replaces a
// platform's running adapters after a panel save (Bridge.Reload). Load returns
// account configs with real tokens so the owner write path can persist them; the
// admin read path masks them before returning to the client.
type ChannelConfigStore interface {
Path() string
// Load returns the effective per-platform accounts (file → env fallback),
// including the secret tokens needed to persist them.
Load() map[string][]config.ChannelAccount
// Reload rebuilds and hot-replaces the running adapters for platform.
Reload(platform 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
@@ -222,6 +238,10 @@ type Deps struct {
// Optional: when nil the admin /llm endpoints fall back to the env config so
// pre-existing gateway tests keep working.
LLM *model.RuntimeLLM
// Channels is the runtime-editable channel-adapter config (channels.json).
// Optional: when nil the admin /channels/config endpoints return a clear
// not-configured error, matching how the LLM stack behaves when unset.
Channels ChannelConfigStore
// 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
@@ -270,6 +290,7 @@ type Server struct {
// admin /llm endpoints fall back to the env config. It is optional so tests
// that do not wire it keep working.
llm *model.RuntimeLLM
channels ChannelConfigStore
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
@@ -306,6 +327,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
streaming: deps.Streaming,
frontend: deps.Frontend,
llm: deps.LLM,
channels: deps.Channels,
webhooks: deps.Webhooks,
log: sourcedLog(deps.Log),
}
@@ -385,6 +407,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig)))
mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount)))
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
mux.Handle("POST /api/v1/admin/memories", ownerAuth(http.HandlerFunc(s.adminAddMemory)))