Files
OwnCord/Server/internal/app/hub.go
T
J3vbandClaude Fable 5 63c87df487 refactor(b3-8): settings/audit family behind SettingsService (S-09, family 1) (#1477)
* feat(service): settings family — SettingsService over the Store seam

The B3-8 settings/audit family's service: List, Patch (whitelist,
boolean normalization, the require_2fa preconditions incl. the TOTP
census and the unrelated-key guard, atomic apply, one audit row per
changed key) and Setting (the read the hub and the backup scheduler
consume; wraps db.ErrNotFound as the store reports it). db gains
ApplySettings — the handler's raw upsert loop as one hand-written
transactional wrapper where raw SQL belongs — and Store carries it.

parseSettingsPatchBool duplicates auth.go's parseBooleanSettingValue
with the admin surface's own pinned error wording; both messages are
test-pinned, so the twins stay separate.

Service-level characterization in settings_test.go mirrors the
admin/api_test.go PATCH rows and adds the service-only contracts
(ErrNotFound wrap, audit rows, multi-key apply).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(admin): settings handlers thin over SettingsService; scheduler reads via it

handleGetSettings/handlePatchSettings become adapters (decode, delegate,
map ErrBadRequest to 400 with the service's prefix-free message); the
whitelist and every precondition now live only in the service, so
admin/types.go's copy is gone. MaintainBackups reads backup_schedule and
backup_retention through the service — its backup mechanics keep the
handle — and the maintenance chain threads Settings from the runtime the
hub stage built. NewHandler/NewAdminAPI gain the settings parameter;
all 207 construction sites wired via the newTestSettingsService helper.

Behavior parity pinned by the existing TestAdminAPI_*Settings* rows
(all green); the only unpinned change is the PATCH 500 path collapsing
its four stage-specific internal messages into one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* refactor(ws): hub settings cache reads through a SettingsReader

The hub's server_name/motd cache consumes a consumer-side SettingsReader
interface (service.SettingsService satisfies it; HubOptions.Settings is
required and validated like DB and Limiter — the RequiredCollaborators
pin gains the refusal case). hub_settings.go no longer touches db at
all, so the import pin from the B3-5 finisher goes, and its allowlist
row goes with it; the thinned admin settings handler's row is deleted
too — two allowlist rows down, the settings family's persistence now
lives only in db/ and service/.

Test helpers (both ws package namespaces) default the reader over the
test database; newBareHub wires it explicitly; production passes
Services.Settings from StartRuntime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* docs(boundaries,b3): settings/audit family re-measure and evidence

The backup pair takes its forecast boundary disposition; the family's
two deleted rows and the disposition counts (28/18/15 -> 24/18/17)
re-derived from the tool. Family evidence block appended to the B3-8
section; README B3 row records B3-5 complete and the family opened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* fix(service): prefix-free ErrBadRequest wraps for the pinned admin bodies

The %.0w rework was meant to ride the service commit but was left
unstaged: with the plain %w wrap the PATCH error bodies carry a
'bad request: ' prefix the admin pins reject. Zero-width wrapping keeps
errors.Is(ErrBadRequest) while err.Error() stays exactly the pinned
message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* test(app): lifecycle hub fixtures wire the required Settings reader

The two direct ws.NewHub sites in lifecycle_test predate Settings
becoming required; race across internal/app is green again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* test(db): cover ApplySettings — the db coverage floor caught the gap

CI's coverage floor failed db at 78.9% against 79.3%: ApplySettings was
exercised only from service tests, which do not count toward db's own
figure. Four db-side rows cover the apply, the empty no-op, the
in-transaction failure rollback and the begin failure, using the
package's full-migration opener.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

* chore(coverage): raise the service floor to the branch's measured 69.2

The settings family's tested service code raised the Linux figure from
the 67.8 floor to 69.2; the ratchet raises the floor in the same PR
(service is not in the run-varying set). db stays at 79.3 — this PR
restores its figure (79.5 with the ApplySettings tests), it did not set
out to raise it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 15:13:38 +00:00

128 lines
5.4 KiB
Go

package app
import (
"fmt"
"log/slog"
"net/url"
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/plugin"
"github.com/J3vb/OwnCord/Server/service"
"github.com/J3vb/OwnCord/Server/ws"
)
// StartRuntime builds the collaborators the hub and the router share,
// constructs the hub with everything it must hold before Run (B3-4:
// HubOptions replaced the pre-Run setters), and starts the dispatch
// goroutine.
//
// Before B3-3 this lived inside api.NewRouter, while main.go set the event
// persister and store after NewRouter returned — two owners of one hub, with
// nothing checking that the required collaborators were present before Run
// started. B3-3 collapsed the owners to one; B3-4 moved the pre-Run wiring
// into ws.NewHub itself, which now refuses to construct without its required
// collaborators, so an incomplete hub is a startup error here rather than a
// later panic.
//
// The limiter and the service layer are built here rather than in the router
// because the hub needs the SAME instances: the limiter persists auth
// lockouts and the service layer holds the permission cache the hub
// invalidates, so a second copy of either would silently split that state.
//
// It starts the hub, so every caller must stop it — App.Close does, through
// the "hub" close step; api's tests rely on the goleak ignore for
// ws.(*Hub).Run.func1 exactly as they did when NewRouter started it.
func StartRuntime(cfg *config.Config, database *db.DB, pluginRegistry *plugin.Registry) (api.Runtime, error) {
// Lockouts are persisted to the database so they survive restarts (M2).
limiter := auth.NewPersistentRateLimiter(database)
// Service layer — centralises business logic for REST and WS handlers.
// *db.DB satisfies service.Store directly.
svc := service.New(database, limiter)
lk, proc, voiceEnabled := buildVoice(cfg)
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware.
hub, err := ws.NewHub(ws.HubOptions{
DB: database,
Limiter: limiter,
Services: svc,
Settings: svc.Settings,
// nil pluginRegistry means plugins are disabled; the hub no-ops.
PluginRegistry: pluginRegistry,
LiveKit: lk,
LiveKitProcess: proc,
// Replay budget knobs land at construction — the dispatch loop
// reads the ring unlocked.
ReplayRingSize: cfg.EventPersistence.ReplayRingSize,
ReplayColdLimit: cfg.EventPersistence.ReplayColdLimit,
})
if err != nil {
return api.Runtime{}, fmt.Errorf("app: building hub: %w", err)
}
// The plugin event sink consumes the built hub's broadcaster, so it is
// the surviving two-phase wire (moved from api.routerPluginWiring).
if pluginRegistry != nil {
sink := pluginRegistry.Sink()
sink.SetBroadcaster(hub.BroadcastToChannel)
hub.SetPluginEventSink(sink)
}
// Start the supervised LiveKit process only once the hub holds it
// (OC-0019): the voice_join guard must be able to fail closed via
// IsRunning() == false the moment Start fails, never see a half-wired
// hub with a running process it does not know about.
if proc != nil {
if startErr := proc.Start(); startErr != nil {
slog.Error("failed to start LiveKit process", "error", startErr)
}
}
go hub.Run()
return api.Runtime{Hub: hub, Limiter: limiter, Services: svc, VoiceEnabled: voiceEnabled}, nil
}
// buildVoice creates the LiveKit client and, when OwnCord manages the
// companion process, the process manager — construction only; StartRuntime
// starts the process after the hub holds it. It reports whether voice is
// configured; the webhook, LiveKit health and signalling-proxy routes are
// still mounted by the router on exactly that condition (the `lkErr == nil`
// guard, now api.Runtime.VoiceEnabled).
func buildVoice(cfg *config.Config) (*ws.LiveKitClient, *ws.LiveKitProcess, bool) {
// Create LiveKit client if voice config is present; voice is disabled on failure.
lk, lkErr := ws.NewLiveKitClient(&cfg.Voice)
if lkErr != nil {
slog.Warn("failed to create LiveKit client, voice disabled", "error", lkErr)
return nil, nil, false
}
// Optionally build a companion LiveKit process — either from a
// configured binary or via checksum-verified auto-download (the
// download happens in the background inside Start). The hub keeps the
// process even if Start() later fails (OC-0019): its only hub consumer
// is the voice_join guard (`h.lkProcess != nil && !h.lkProcess.IsRunning()`),
// which reads a nil process as "LiveKit is externally managed, don't
// check". OwnCord being told to manage LiveKit and failing to launch it
// must fail joins closed via IsRunning() == false, not wave them
// through with no SFU running.
if cfg.Voice.LiveKitBinaryPath != "" || cfg.Voice.AutoDownloadLiveKit {
return lk, ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir), true
}
// Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs.
lkHost := ""
if u, parseErr := url.Parse(cfg.Voice.LiveKitURL); parseErr == nil {
lkHost = u.Hostname()
}
if lkHost != "" && lkHost != "localhost" && lkHost != "127.0.0.1" && lkHost != "::1" {
slog.Warn("LiveKit is externally managed but webhook endpoint is admin-IP-restricted — "+
"add the LiveKit server's IP to livekit_webhook_allowed_cidrs or webhooks will be silently dropped",
"livekit_host", lkHost)
}
return lk, nil, true
}