GET /_system/federation/admin Implementation Plan (trip2g)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Добавить admin-only read-эндпоинт GET /_system/federation/admin, отдающий полную топологию федерации инстанса (self-identity, сабграфы, inbound/outbound секреты со scope, KB-ноты) одним JSON.
Architecture: Новый FastHTTP case-эндпоинт (internal/case/system/federationtopology/) по существующему паттерну (Endpoint/Env/Resolve), регистрируется через codegen роутера. Авторизация — req.UserToken() + IsAdmin() (cookie-сессия). Данные собираются из sqlc-запросов (секреты + scope батчем) и NoteViews.MCPFederationNotes. Ответ — easyjson.
Tech Stack: Go, FastHTTP, sqlc, easyjson, gencmd (router codegen).
Spec: docs/dev/federation_system_endpoint.md.
File Structure
- Create:
internal/case/system/federationtopology/resolve.go— Env, Resolve, response types. - Create:
internal/case/system/federationtopology/endpoint.go— Endpoint (Path/Method/Handle + admin gate). - Create:
internal/case/system/federationtopology/resolve_test.go— unit-тесты сборки. - Modify:
queries.read.sql— добавить batch-запрос scope по всем kid. - Modify (generated):
internal/db/queries.read.sql.go— черезmake sqlc. - Modify (generated):
internal/router/endpoints_gen.go,env.go— черезgo generate ./internal/router. - Modify (generated):
*_easyjson.go— черезgo generate ./...в пакете. - Modify:
cmd/server/main.go(app env) — реализовать недостающие методы новогоEnv, если их ещё нет.
Reference patterns (читать перед стартом):
- Endpoint:
internal/case/mcp/endpoint.go(Path/Method/Handle,env := req.Env.(Env)). - Admin gate:
internal/case/downloadonboardingvault/endpoint.go:21-29(token, _ := req.UserToken(); if token == nil || !token.IsAdmin() { 401 }). - Case/Env:
internal/case/admin/listfederationsecrets/resolve.go(Env interface + Resolve). - Existing secrets query:
queries.read.sqlListFederationSecrets(JOIN + group) иListFederationSecretSubgraphsByKID. - KB-notes:
internal/model/note.go:287(NoteViews.MCPFederationNotes),internal/model/mcp_federation_note.go:9(MCPFederationNote{Note,URL,ID,MaxDepth}). - Self URL:
internal/appconfig/config.go:77PublicURL, accessorcmd/server/main.go:1638func (a *app) PublicURL() string. - easyjson directive:
internal/federation/types.go:3. - Endpoint codegen:
internal/router/router.go:12(//go:generate go run ./gencmd), generated listinternal/router/endpoints_gen.go.
Task 1: Batch-запрос scope секретов
Files:
-
Modify:
queries.read.sql -
Generate:
internal/db/queries.read.sql.go(черезmake sqlc) -
Step 1: Добавить запрос в
queries.read.sql
В конце секции federation-запросов добавить (одним запросом тянем scope по всем kid, чтобы не делать N+1):
-- name: ListAllFederationSecretScopes :many
select fss.kid, s.id as subgraph_id, s.name as subgraph_name
from federation_secret_subgraphs fss
join subgraphs s on s.id = fss.subgraph_id
order by fss.kid, s.name;
- Step 2: Сгенерировать sqlc
Run: make sqlc
Expected: в internal/db/queries.read.sql.go появился метод ListAllFederationSecretScopes(ctx) ([]ListAllFederationSecretScopesRow, error) со строкой {Kid string; SubgraphID int64; SubgraphName string}.
- Step 3: Сборка
Run: go build ./internal/db/...
Expected: без ошибок.
- Step 4: Commit
git add queries.read.sql internal/db/queries.read.sql.go
git commit -m "feat(federation): batch query for federation secret scopes"
Task 2: Response-типы и Resolve (без авторизации)
Files:
-
Create:
internal/case/system/federationtopology/resolve.go -
Create:
internal/case/system/federationtopology/resolve_test.go -
Generate:
internal/case/system/federationtopology/resolve_easyjson.go -
Step 1: Написать падающий тест сборки ответа
resolve.go ещё нет — тест не скомпилируется (ожидаемо).
package federationtopology_test
import (
"context"
"testing"
"time"
"trip2g/internal/case/system/federationtopology"
"trip2g/internal/db"
"trip2g/internal/model"
"github.com/stretchr/testify/require"
)
func TestBuildTopology(t *testing.T) {
kbURL := "https://bob.team.io/_system/mcp"
env := &EnvMock{
PublicURLFunc: func() string { return "https://alice.team.io" },
ListAllSubgraphsFunc: func(ctx context.Context) ([]db.Subgraph, error) {
return []db.Subgraph{{ID: 1, Name: "internal"}}, nil
},
ListFederationSecretsFunc: func(ctx context.Context) ([]db.ListFederationSecretsRow, error) {
return []db.ListFederationSecretsRow{
{ID: 12, Kid: "bob-2026", KbUrl: &kbURL}, // outbound (есть kb_url)
{ID: 7, Kid: "carol-2026"}, // inbound (нет kb_url)
}, nil
},
ListAllFederationSecretScopesFunc: func(ctx context.Context) ([]db.ListAllFederationSecretScopesRow, error) {
return []db.ListAllFederationSecretScopesRow{
{Kid: "bob-2026", SubgraphID: 1, SubgraphName: "internal"},
}, nil
},
LatestNoteViewsFunc: func(ctx context.Context) (*model.NoteViews, error) {
return &model.NoteViews{MCPFederationNotes: []*model.MCPFederationNote{
{URL: kbURL, ID: "bob"},
}}, nil
},
}
out, err := federationtopology.Resolve(context.Background(), env)
require.NoError(t, err)
require.Equal(t, "https://alice.team.io/_system/mcp", out.Self.MCPURL)
require.Equal(t, "alice.team.io", out.Self.KBID)
require.Len(t, out.Self.Subgraphs, 1)
require.Len(t, out.Outbound, 1)
require.Equal(t, kbURL, *out.Outbound[0].KBURL)
require.Equal(t, []string{"internal"}, namesOf(out.Outbound[0].Subgraphs))
require.Len(t, out.Inbound, 1)
require.Nil(t, out.Inbound[0].KBURL)
require.Len(t, out.KBNotes, 1)
require.Equal(t, kbURL, out.KBNotes[0].KBURL)
_ = time.Now
}
func namesOf(ss []federationtopology.Subgraph) []string {
out := make([]string, 0, len(ss))
for _, s := range ss {
out = append(out, s.Name)
}
return out
}
- Step 2: Реализовать
resolve.go
package federationtopology
//go:generate go tool github.com/mailru/easyjson/easyjson -snake_case -all -no_std_marshalers ./resolve.go
//go:generate go tool github.com/matryer/moq -out mocks_test.go -pkg federationtopology_test . Env
import (
"context"
"fmt"
"net/url"
"strings"
"trip2g/internal/db"
"trip2g/internal/model"
)
type Env interface {
PublicURL() string
ListAllSubgraphs(ctx context.Context) ([]db.Subgraph, error)
ListFederationSecrets(ctx context.Context) ([]db.ListFederationSecretsRow, error)
ListAllFederationSecretScopes(ctx context.Context) ([]db.ListAllFederationSecretScopesRow, error)
LatestNoteViews(ctx context.Context) (*model.NoteViews, error)
}
type Subgraph struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type Self struct {
Name string `json:"name"`
KBID string `json:"kb_id"`
MCPURL string `json:"mcp_url"`
Subgraphs []Subgraph `json:"subgraphs"`
}
type Secret struct {
ID int64 `json:"id"`
KID string `json:"kid"`
KBURL *string `json:"kb_url,omitempty"`
RevokedAt *string `json:"revoked_at"`
Subgraphs []Subgraph `json:"subgraphs"`
}
type KBNote struct {
KBID string `json:"kb_id"`
KBURL string `json:"kb_url"`
Description string `json:"description"`
Path string `json:"path"`
}
type Response struct {
Self Self `json:"self"`
Outbound []Secret `json:"outbound"`
Inbound []Secret `json:"inbound"`
KBNotes []KBNote `json:"kb_notes"`
}
func Resolve(ctx context.Context, env Env) (*Response, error) {
publicURL := strings.TrimRight(env.PublicURL(), "/")
mcpURL := publicURL + "/_system/mcp"
host := publicURL
if u, err := url.Parse(publicURL); err == nil && u.Host != "" {
host = u.Host
}
subs, err := env.ListAllSubgraphs(ctx)
if err != nil {
return nil, fmt.Errorf("list subgraphs: %w", err)
}
selfSubs := make([]Subgraph, 0, len(subs))
for _, s := range subs {
selfSubs = append(selfSubs, Subgraph{ID: s.ID, Name: s.Name})
}
scopes, err := env.ListAllFederationSecretScopes(ctx)
if err != nil {
return nil, fmt.Errorf("list scopes: %w", err)
}
scopeByKID := map[string][]Subgraph{}
for _, sc := range scopes {
scopeByKID[sc.Kid] = append(scopeByKID[sc.Kid], Subgraph{ID: sc.SubgraphID, Name: sc.SubgraphName})
}
rows, err := env.ListFederationSecrets(ctx)
if err != nil {
return nil, fmt.Errorf("list secrets: %w", err)
}
resp := &Response{
Self: Self{Name: host, KBID: host, MCPURL: mcpURL, Subgraphs: selfSubs},
Outbound: []Secret{},
Inbound: []Secret{},
KBNotes: []KBNote{},
}
for _, r := range rows {
sec := Secret{ID: r.ID, KID: r.Kid, KBURL: r.KbUrl, Subgraphs: scopeByKID[r.Kid]}
if sec.Subgraphs == nil {
sec.Subgraphs = []Subgraph{}
}
if r.RevokedAt != nil {
s := r.RevokedAt.UTC().Format("2006-01-02T15:04:05Z")
sec.RevokedAt = &s
}
if r.KbUrl != nil {
resp.Outbound = append(resp.Outbound, sec)
} else {
resp.Inbound = append(resp.Inbound, sec)
}
}
views, err := env.LatestNoteViews(ctx)
if err != nil {
return nil, fmt.Errorf("note views: %w", err)
}
for _, n := range views.MCPFederationNotes {
note := KBNote{KBID: n.ID, KBURL: n.URL, Path: notePath(n), Description: noteDesc(n)}
resp.KBNotes = append(resp.KBNotes, note)
}
return resp, nil
}
// notePath / noteDesc извлекают путь и краткое описание из NoteView.
// ВНИМАНИЕ: подтвердить точные поля NoteView (internal/model/note.go) —
// path-поле и body. Если у MCPFederationNote.Note nil, вернуть пустые строки.
func notePath(n *model.MCPFederationNote) string {
if n.Note == nil {
return ""
}
return n.Note.Path // подтвердить имя поля по internal/model/note.go
}
func noteDesc(n *model.MCPFederationNote) string {
if n.Note == nil {
return ""
}
return "" // заполнить из тела ноты; см. NoteView (поле с markdown/plain телом)
}
Перед реализацией подтвердить точные поля
model.NoteView(path и тело) поinternal/model/note.goи точное имя/типdb.Subgraph(поляID,Name) по sqlc-моделям. Еслиdb.Subgraphназывает поля иначе — поправить маппинг. Это единственные неуточнённые сигнатуры; всё остальное скопировано из реальных типов.
- Step 3: Сгенерировать easyjson + моки
Run: go generate ./internal/case/system/federationtopology/
Expected: созданы resolve_easyjson.go и mocks_test.go.
- Step 4: Прогнать тест
Run: go test ./internal/case/system/federationtopology/ -run TestBuildTopology -v
Expected: PASS.
- Step 5: Commit
gofmt -w ./internal/case/system/federationtopology/
git add internal/case/system/federationtopology/
git commit -m "feat(federation): topology response builder for /_system/federation/admin"
Task 3: Endpoint с admin-гейтом
Files:
-
Create:
internal/case/system/federationtopology/endpoint.go -
Step 1: Реализовать Endpoint
package federationtopology
import (
"context"
"net/http"
"trip2g/internal/appreq"
)
type Endpoint struct{}
func (*Endpoint) Path() string { return "/_system/federation/admin" }
func (*Endpoint) Method() string { return http.MethodGet }
func (*Endpoint) Handle(req *appreq.Request) (interface{}, error) {
token, err := req.UserToken()
if err != nil {
return nil, err
}
if token == nil || !token.IsAdmin() {
req.Req.SetStatusCode(http.StatusUnauthorized)
return nil, nil
}
env := req.Env.(Env)
resp, err := Resolve(context.Context(req.Req), env)
if err != nil {
return nil, err
}
return resp, nil // *Response реализует easyjson.Marshaler → роутер сам сериализует (router.go:125)
}
context.Context(req.Req)— как вinternal/case/mcp/endpoint.go. Если там используется иной способ получить ctx — повторить его.
- Step 2: Сборка пакета
Run: go build ./internal/case/system/federationtopology/
Expected: без ошибок.
- Step 3: Commit
gofmt -w ./internal/case/system/federationtopology/
git add internal/case/system/federationtopology/endpoint.go
git commit -m "feat(federation): admin-gated GET /_system/federation/admin endpoint"
Task 4: Регистрация роута + реализация Env в app
Files:
-
Generate:
internal/router/endpoints_gen.go,internal/router/env.go -
Modify:
cmd/server/main.go(методы app, которых ещё нет) -
Step 1: Регенерировать список эндпоинтов
Run: go generate ./internal/router
Expected: в endpoints_gen.go добавился &federationtopology....Endpoint{} и его Env в RoutesEnv.
- Step 2: Собрать весь проект — найти недостающие методы Env
Run: go build ./...
Expected: ошибки компиляции вида «app does not implement RoutesEnv (missing method ListAllFederationSecretScopes / LatestNoteViews)». Это список того, что надо добавить на app.
- Step 3: Реализовать недостающие методы app
В cmd/server/main.go (где определены func (a *app) ...) добавить тонкие обёртки над уже существующими зависимостями. Пример для нового scope-запроса:
func (a *app) ListAllFederationSecretScopes(ctx context.Context) ([]db.ListAllFederationSecretScopesRow, error) {
return a.queries.ListAllFederationSecretScopes(ctx)
}
Для ListAllSubgraphs, ListFederationSecrets, LatestNoteViews, PublicURL — проверить, реализованы ли уже (скорее всего да: PublicURL есть на cmd/server/main.go:1638, ListFederationSecrets используется в GraphQL). Добавить только реально отсутствующие. Имена методов брать из ошибок компиляции Step 2.
Если
LatestNoteViewsна app называется иначе (например, отдаёт views синхронно без ctx) — поправить сигнатуруEnvвresolve.goпод фактическую и перегенерировать.
- Step 4: Сборка
Run: go build ./...
Expected: без ошибок.
- Step 5: Commit
gofmt -w .
git add internal/router/ cmd/server/main.go
git commit -m "feat(federation): register /_system/federation/admin route and wire env"
Task 5: Тест авторизации + smoke
Files:
-
Modify:
internal/case/system/federationtopology/resolve_test.go(добавить кейсы) -
Step 1: Тест — revoked секрет отдаётся с revoked_at, пустой scope = пустой массив
Добавить кейс: секрет с RevokedAt != nil и без scope → Outbound[i].RevokedAt != nil, Outbound[i].Subgraphs == [] (не nil, сериализуется как []).
func TestRevokedAndEmptyScope(t *testing.T) {
now := time.Now()
kbURL := "https://x/_system/mcp"
env := &EnvMock{
PublicURLFunc: func() string { return "https://me.io" },
ListAllSubgraphsFunc: func(ctx context.Context) ([]db.Subgraph, error) { return nil, nil },
ListFederationSecretsFunc: func(ctx context.Context) ([]db.ListFederationSecretsRow, error) {
return []db.ListFederationSecretsRow{{ID: 1, Kid: "k", KbUrl: &kbURL, RevokedAt: &now}}, nil
},
ListAllFederationSecretScopesFunc: func(ctx context.Context) ([]db.ListAllFederationSecretScopesRow, error) { return nil, nil },
LatestNoteViewsFunc: func(ctx context.Context) (*model.NoteViews, error) { return &model.NoteViews{}, nil },
}
out, err := federationtopology.Resolve(context.Background(), env)
require.NoError(t, err)
require.NotNil(t, out.Outbound[0].RevokedAt)
require.NotNil(t, out.Outbound[0].Subgraphs)
require.Len(t, out.Outbound[0].Subgraphs, 0)
}
- Step 2: Прогнать пакет
Run: go test ./internal/case/system/federationtopology/ -v
Expected: PASS.
- Step 3: Полный тест-сьют
Run: go test ./...
Expected: PASS (или без новых падений).
- Step 4: Commit
gofmt -w .
git add internal/case/system/federationtopology/
git commit -m "test(federation): revoked and empty-scope cases for topology endpoint"
Self-Review checklist
- Покрытие спека: self-identity ✓ (Task2), subgraphs ✓, outbound/inbound со scope ✓ (Task1+2), kb_notes ✓ (Task2), admin-only ✓ (Task3), revoked/broad ✓ (Task5). KB-note
description— подтвердить источник тела ноты при реализации. - Плейсхолдеры: два явно помеченных места требуют сверки полей (
NoteView.Path/тело,db.Subgraphполя) — это сверка реальных типов, не выдуманные имена; альтернатива хуже (угадать имя). Реализатор сверяет по указанным file:line. - Типы согласованы:
Response/Secret/Subgraphиспользуются одинаково в resolve.go, endpoint.go и тестах.