register.go
Rama main · réplica de lectura del repositorio original.
// Copyright The Pit Project Owners. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Please see https://officer.openpit.dev and the OWNERS file for details.
// Package officer registers the Pit Officer concrete implementations.
package officer
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"slices"
"time"
"go.openpit.dev/officer/engine"
frameworkapp "go.openpit.dev/officer/framework/app"
"go.openpit.dev/officer/framework/auth"
fwbackend "go.openpit.dev/officer/framework/backend"
"go.openpit.dev/officer/framework/domain"
fwengine "go.openpit.dev/officer/framework/engine"
fwmarketdata "go.openpit.dev/officer/framework/marketdata"
frameworkmcp "go.openpit.dev/officer/framework/mcp"
"go.openpit.dev/officer/framework/node"
"go.openpit.dev/officer/framework/secret"
fwsigning "go.openpit.dev/officer/framework/signing"
"go.openpit.dev/officer/framework/store"
httpx "go.openpit.dev/officer/framework/web/httpapi"
"go.openpit.dev/officer/httpapi"
"go.openpit.dev/officer/internal/access"
"go.openpit.dev/officer/internal/accountmode"
appmarketdata "go.openpit.dev/officer/internal/marketdata"
"go.openpit.dev/officer/internal/nonrelease"
"go.openpit.dev/officer/internal/store/sqlite"
apptrading "go.openpit.dev/officer/internal/trading"
"go.openpit.dev/officer/mcptools"
"go.openpit.dev/officer/signing"
"go.openpit.dev/officer/web"
)
// Config carries the default composition's runtime inputs: the SQLite database
// path, the master key that seals stored secrets (nil when none is
// configured), and the operator's extra host names. The native OpenPit runtime
// is not one of them: the SDK loads it at process start (see
// engine.NewOpenPitEngineBuildFunc).
type Config struct {
SQLitePath string
MasterKey *secret.MasterKey
// AllowedHosts are extra host names the HTTP surface answers besides
// loopback; Register refuses an invalid one.
AllowedHosts []string
}
// loopbackHosts are the host names the HTTP surface always answers to, since
// it listens on loopback by default; Register adds Config.AllowedHosts after
// them.
var loopbackHosts = []string{"localhost", "127.0.0.1", "::1"}
type accessAuthorizer func(context.Context, domain.Caller, string) error
func (a accessAuthorizer) Authorize(ctx context.Context, caller domain.Caller, identifier string) error {
return a(ctx, caller, identifier)
}
type setupDecisions struct {
node node.Node
}
func (d setupDecisions) AccountModeChosen(ctx context.Context) (bool, error) {
return accountmode.Chosen(ctx, d.node)
}
// FundsLimitModeChosen reads the stored rows, where the Spot Funds limit-mode
// meta-policy row is present only once a mode was stored.
func (d setupDecisions) FundsLimitModeChosen(ctx context.Context) (bool, error) {
limits, err := d.node.ListLimits(ctx, "")
if err != nil {
return false, err
}
return len(limits.SpotFundsLimitModes) != 0, nil
}
func (d setupDecisions) CredentialsSealed() bool { return d.node.CredentialsSealed() }
// fundsLimitModePutRoute stores the Spot Funds limit mode, the decision
// FundsLimitModeChosen reads.
const fundsLimitModePutRoute = "limits.spot-funds-limit-mode.put"
// serialiseWithSetup wraps the handler of route id in routes so that, while
// first-run setup is open, it runs inside gate, as accountmode.ApplyDuringSetup
// stores the account mode: a write and its rollback, when the engine refuses
// it, finish before setup can complete and read the decision. Once setup is
// complete the handler runs alone. A route id absent from routes is a
// composition defect and panics.
func serialiseWithSetup(routes *httpx.RouteRegistry, id string, gate accountmode.SetupGate) {
all := routes.Routes()
i := slices.IndexFunc(all, func(route httpx.Route) bool { return route.ID == id })
if i < 0 {
panic(fmt.Sprintf("officer: route %q is not registered", id))
}
route := all[i]
next := route.Handler
route.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := gate.WhileInSetup(func() error {
next.ServeHTTP(w, r)
return nil
})
switch {
case errors.Is(err, domain.ErrSetupComplete):
next.ServeHTTP(w, r)
case err != nil:
httpx.WriteErr(w, err)
}
})
routes.Register(route)
}
// Register populates b with the Pit Officer concrete implementations built
// from cfg. The composition serves domain.DefaultRealm through one local node.
//
// During setup the caller resolver refuses API, MCP over HTTP and non-setup
// panel routes, and the installed authorizer repeats the refusal for API and
// panel routes. An added node hook builds the access Service whatever factories
// a host replaces. Build fails if the store lacks access.Store or the
// seal-report reader; nothing serves without the access Service.
//
// The store opens cfg.SQLitePath as given. A non-release host passes its own
// database path, prepared through PrepareNonReleaseDatabase.
func Register(b *frameworkapp.Builder, cfg Config) error {
if b == nil {
return errors.New("officer: nil builder")
}
// Build sets these in the node hook and the service factory before the
// app serves; the resolver and the route config read them afterwards.
var (
sealReports httpapi.SealReportSource
accessService *access.Service
backupNode node.Node
// Stays nil when a host replaces the service factory.
accountScope *accountmode.Scope
)
b.SetStoreFactory(func() (store.Store, error) {
return newStore(cfg)
})
b.SetNodeBuilder(func(
ctx context.Context,
st store.Store,
fatalHook frameworkapp.FatalShutdownHook,
) (node.Node, fwengine.Engine, error) {
return node.NewLocalNode(
ctx,
domain.DefaultRealm,
st,
engine.NewOpenPitEngineBuildFunc(),
fatalHook,
)
})
b.AddNodeHook(func(ctx context.Context, st store.Store, n node.Node) error {
accessState, ok := st.(access.Store)
if !ok {
return errors.New("officer: the store does not implement access.Store")
}
reports, err := sealReportSource(st)
if err != nil {
return err
}
accessService, err = access.New(accessState, n, os.Stderr, setupDecisions{node: n})
if err != nil {
return fmt.Errorf("officer: build access service: %w", err)
}
sealReports = reports
backupNode = n
return nil
})
b.SetSigningFactory(func(st store.RealmStore) (fwsigning.Service, error) {
return signing.New(st)
})
b.SetAuthorizer(accessAuthorizer(func(ctx context.Context, caller domain.Caller, identifier string) error {
return accessService.Authorize(ctx, caller, identifier)
}))
b.SetCallerResolver(func(r *http.Request) (domain.Caller, error) {
return accessService.ResolveCaller(r)
})
guards, err := browserGuards(cfg)
if err != nil {
return err
}
b.SetBrowserGuards(guards)
for _, provider := range appmarketdata.FirstPartyProviders() {
if err := b.RegisterMarketDataProvider(provider); err != nil {
return fmt.Errorf("officer: register market data provider: %w", err)
}
}
for _, provider := range apptrading.FirstPartyProviders() {
if err := b.RegisterTradingProvider(provider); err != nil {
return fmt.Errorf("officer: register trading provider: %w", err)
}
}
b.SetServiceFactory(func(
ctx context.Context,
n node.Node,
md fwbackend.MarketDataRuntime,
rt fwbackend.TradingRuntime,
signer fwsigning.Service,
registry *fwmarketdata.Registry,
mcpRegistry *frameworkmcp.ToolRegistry,
) (fwbackend.ControlPlane, error) {
// The account mode is read once per process; once setup is complete
// a stored change takes effect at the next start.
scope, err := accountmode.Load(ctx, n)
if err != nil {
return nil, err
}
accountScope = scope
opts := []fwbackend.Option{
fwbackend.WithMarketDataRegistry(registry),
fwbackend.WithTradingRuntime(rt),
fwbackend.WithMCPCatalogProvider(mcpRegistry),
fwbackend.WithAccountScope(scope),
// Read at Build so a host's wrapper also governs forced operations.
fwbackend.WithForceAuthorizer(b.Authorizer()),
}
if !fwbackend.ReleaseBuild() {
opts = append(opts, fwbackend.WithNonReleaseBuild())
}
return fwbackend.New(
n,
md,
signer,
engine.LockSettlementPrice,
opts...,
)
})
// A host that customises routes wraps b.RouteConfigBuilder(), so the
// account mode and the access mode Register enforces stay served.
b.SetRouteConfigBuilder(func(
svc fwbackend.ControlPlane, logs httpx.LogSource,
) frameworkapp.RouteConfig {
config := httpapi.RouteConfig(svc, logs, sealReports)
serialiseWithSetup(config.Routes, fundsLimitModePutRoute, accessService)
for _, route := range accountmode.Routes(svc, accountScope, accessService) {
config.Routes.Register(route)
}
for _, route := range httpapi.PreMigrationBackupRoutes(preMigrationBackupSource(cfg.SQLitePath, backupNode)) {
config.Routes.Register(route)
}
config.PanelRoutes = &httpx.RouteRegistry{}
for _, route := range access.Routes(accessService) {
config.PanelRoutes.Register(route)
}
config.PanelRoutes.Register(nonrelease.BuildRoute(func() nonrelease.Status {
return nonrelease.Status{Release: fwbackend.ReleaseBuild(), Phase: nonrelease.PhaseReady}
}))
config.AccessMode = func() httpx.AccessMode {
return manifestAccessMode(accessService)
}
return config
})
b.AddToolRegistrar(mcptools.RegisterTools)
b.SetSPAFactory(web.Dist)
return nil
}
func browserGuards(cfg Config) (httpx.BrowserGuards, error) {
host, err := httpx.AllowedHosts(slices.Concat(loopbackHosts, cfg.AllowedHosts))
if err != nil {
return httpx.BrowserGuards{}, fmt.Errorf("officer: browser guards: %w", err)
}
return httpx.BrowserGuards{
Host: host,
Origin: httpx.SameOrigin(),
ContentType: httpx.JSONBody(),
}, nil
}
func newStore(cfg Config) (store.Store, error) {
var opts []sqlite.Option
if fwbackend.ReleaseBuild() {
opts = append(opts, sqlite.WithPreMigrationCopy())
}
if cfg.MasterKey != nil {
opts = append(opts, sqlite.WithMasterKey(*cfg.MasterKey))
}
return sqlite.New(cfg.SQLitePath, domain.DefaultRealm, opts...)
}
// sealReportSource adapts the seal report of st, the store the store factory
// opened, to the source GET /credential-storage reads. It fails when st does
// not implement SealReport.
func sealReportSource(st store.Store) (httpapi.SealReportSource, error) {
reader, ok := st.(interface {
SealReport(ctx context.Context) (sqlite.SealReport, bool, error)
})
if !ok {
return nil, errors.New("officer: the store does not implement SealReport")
}
return func(ctx context.Context) (time.Time, int, bool, error) {
report, sealed, err := reader.SealReport(ctx)
return report.SealedAt, report.ResealedSecrets, sealed, err
}, nil
}
func preMigrationBackupSource(path string, n node.Node) httpapi.PreMigrationBackupSource {
return httpapi.PreMigrationBackupSource{
List: func() ([]httpapi.PreMigrationBackup, error) {
copies, err := sqlite.ListPreMigrationCopies(path)
if err != nil {
return nil, err
}
backups := make([]httpapi.PreMigrationBackup, 0, len(copies))
for _, copy := range copies {
backups = append(backups, httpapi.PreMigrationBackup{
Name: copy.Name, CreatedAt: copy.CreatedAt, SizeBytes: copy.Size,
})
}
return backups, nil
},
Open: func(name string) (*os.File, int64, error) {
return sqlite.OpenPreMigrationCopy(path, name)
},
Remove: func(name string) error {
return sqlite.RemovePreMigrationCopy(path, name)
},
Audit: func(ctx context.Context, action domain.AuditAction, detail domain.AuditDetail) error {
if n == nil {
return errors.New("officer: pre-migration backup: nil node")
}
caller, err := auth.CallerFromContext(ctx)
if err != nil {
return err
}
return n.AppendAudit(ctx, store.AuditEntry{Action: action, Detail: detail}, caller)
},
}
}
// manifestAccessMode is the route manifest's access mode for service's mode.
// An unknown mode maps to the empty mode, which the manifest refuses as an
// internal error.
func manifestAccessMode(service *access.Service) httpx.AccessMode {
switch service.Mode() {
case access.ModeSetup:
return httpx.AccessModeSetup
case access.ModeOpen:
return httpx.AccessModeOpen
case access.ModeToken:
return httpx.AccessModeToken
default:
return ""
}
}