// 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 "" } }