mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
186 lines
6.9 KiB
Go
186 lines
6.9 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/permissions"
|
|
"github.com/J3vb/OwnCord/Server/plugin"
|
|
"github.com/J3vb/OwnCord/Server/service"
|
|
)
|
|
|
|
// HubOptions carries everything a Hub needs before Run starts (S-11 / B3-4).
|
|
// The four pre-Run setters this struct replaced (SetLiveKit,
|
|
// SetLiveKitProcess, SetPluginRegistry, ConfigureReplay) were all guarded by
|
|
// rejectIfRunning — construction-phase wiring pretending to be mutable state.
|
|
// What genuinely IS mutable after Run stays a setter: the event persister,
|
|
// event store and plugin event sink are atomic hot-swaps that the app wires
|
|
// after the dispatch loop starts (internal/app/persistence.go), and
|
|
// SetPendingVoiceModFlags is per-user runtime state.
|
|
type HubOptions struct {
|
|
// DB and Limiter are required: every dispatch path reads the database,
|
|
// and the handler deps capture the limiter at registration. NewHub
|
|
// refuses to build a Hub without them.
|
|
DB *db.DB
|
|
Limiter *auth.RateLimiter
|
|
|
|
// Services is the domain layer V2 handlers delegate to. Production
|
|
// always passes it; nil is the degraded fixture many ws tests build,
|
|
// where handlers keep their direct-DB fallback paths.
|
|
Services *service.Services
|
|
|
|
// Settings is required: the hub's settings cache (server name and MOTD
|
|
// on every auth_ok) reads through it instead of the raw handle — the
|
|
// B3-8 settings family owns those reads. Production passes
|
|
// Services.Settings; test helpers default it over the test database.
|
|
Settings SettingsReader
|
|
|
|
// LiveKit is the voice token signer; nil means voice is not configured
|
|
// and every voice join is refused. LiveKitProcess is the supervised
|
|
// companion SFU — it requires LiveKit, because a process no client can
|
|
// sign tokens for is unusable, and the voice_join guard reads a non-nil
|
|
// process's IsRunning to fail closed while it is down.
|
|
LiveKit *LiveKitClient
|
|
LiveKitProcess *LiveKitProcess
|
|
|
|
// PluginRegistry enables plugin slash-command dispatch; nil disables it.
|
|
// The plugin event sink is NOT here: it consumes the built hub's
|
|
// broadcaster, so it stays the two-phase SetPluginEventSink.
|
|
PluginRegistry *plugin.Registry
|
|
|
|
// Replay budget (event_persistence.replay_ring_size / replay_cold_limit).
|
|
// Zero keeps the compiled-in defaults; negative is refused rather than
|
|
// silently ignored.
|
|
ReplayRingSize int
|
|
ReplayColdLimit int
|
|
}
|
|
|
|
// NewHub creates a Hub ready to be started with Run, validating that the
|
|
// required collaborators are present — before B3-4, construction always
|
|
// succeeded and a missing collaborator surfaced as a later panic or a
|
|
// silently refused setter call. It also initializes the settings cache from
|
|
// the database. If opts.Services is non-nil, V2 handlers receive service
|
|
// references for business logic delegation.
|
|
func NewHub(opts HubOptions) (*Hub, error) {
|
|
if opts.DB == nil {
|
|
return nil, errors.New("ws: HubOptions.DB is required (every dispatch path reads it)")
|
|
}
|
|
if opts.Limiter == nil {
|
|
return nil, errors.New("ws: HubOptions.Limiter is required (handler deps capture it at registration)")
|
|
}
|
|
if opts.Settings == nil {
|
|
return nil, errors.New("ws: HubOptions.Settings is required (the settings cache reads through it)")
|
|
}
|
|
if opts.LiveKitProcess != nil && opts.LiveKit == nil {
|
|
return nil, errors.New("ws: HubOptions.LiveKitProcess without LiveKit — a supervised SFU no client can sign tokens for")
|
|
}
|
|
if opts.ReplayRingSize < 0 || opts.ReplayColdLimit < 0 {
|
|
return nil, fmt.Errorf("ws: negative replay budget (ring %d, cold %d)", opts.ReplayRingSize, opts.ReplayColdLimit)
|
|
}
|
|
|
|
database, limiter, svc := opts.DB, opts.Limiter, opts.Services
|
|
settingsReader := opts.Settings
|
|
|
|
ringSize := 1000
|
|
if opts.ReplayRingSize > 0 {
|
|
ringSize = opts.ReplayRingSize
|
|
}
|
|
|
|
reg := NewHandlerRegistry()
|
|
|
|
h := &Hub{
|
|
clients: make(map[int64]*Client),
|
|
db: database,
|
|
limiter: limiter,
|
|
settings: settingsReader,
|
|
broadcast: make(chan broadcastMsg, 1024),
|
|
clientEvents: make(chan clientEvent, 64),
|
|
stop: make(chan struct{}),
|
|
pubsub: NewPubSub(),
|
|
topicLimiter: NewTopicRateLimiter(topicRateLimitPerSecond, time.Second),
|
|
replayBuf: NewEventRingBuffer(ringSize),
|
|
registry: reg,
|
|
permChecker: permissions.NewChecker(database),
|
|
settingsName: "OwnCord Server",
|
|
settingsMotd: "Welcome!",
|
|
voiceKeyHolders: make(map[int64]int64),
|
|
fatalFn: func() { os.Exit(1) },
|
|
livekit: opts.LiveKit,
|
|
lkProcess: opts.LiveKitProcess,
|
|
pluginRegistry: opts.PluginRegistry,
|
|
}
|
|
if opts.ReplayColdLimit > 0 {
|
|
h.coldReplayLimit = opts.ReplayColdLimit
|
|
}
|
|
|
|
// V2 handler registrations (need Hub fields for deps).
|
|
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
|
|
|
|
chatDeps := ChatDeps{
|
|
Limiter: h.limiter,
|
|
}
|
|
presenceDeps := PresenceDeps{
|
|
Limiter: h.limiter,
|
|
}
|
|
reactionDeps := ReactionDeps{}
|
|
callDeps := CallDeps{Limiter: h.limiter}
|
|
if svc != nil {
|
|
chatDeps.MessageSvc = svc.Messages
|
|
presenceDeps.ChannelSvc = svc.Channels
|
|
reactionDeps.MessageSvc = svc.Messages
|
|
callDeps.DMSvc = svc.DMs
|
|
h.messageSvc = svc.Messages
|
|
h.perms = svc.Permissions
|
|
// So @here's offline narrowing can tell a disconnected idle/dnd reader
|
|
// (users.status keeps their last *chosen* value across a disconnect)
|
|
// from one who is actually still connected — the same live-connection
|
|
// rule presentableMembers applies to the members array.
|
|
svc.Messages.SetOnlineChecker(h.IsUserConnected)
|
|
// So every DM payload DMService builds (GET/POST /dms, POST
|
|
// /dms/group, PATCH /dms/{id}, and every broadcastDMOpen refresh)
|
|
// applies the same live-connection rule instead of only the ready
|
|
// payload's presentableDMChannels doing so (OC-0304).
|
|
svc.DMs.SetOnlineChecker(h.IsUserConnected)
|
|
}
|
|
|
|
registerChatHandlers(reg, chatDeps)
|
|
registerPresenceHandlers(reg, presenceDeps)
|
|
registerReactionHandlers(reg, reactionDeps)
|
|
registerCallHandlers(reg, callDeps)
|
|
// Phase C Step 9 — plugin slash commands. The registry closure predates
|
|
// B3-4 (the registry used to arrive via a post-construction setter); it
|
|
// stays a closure for the nil-interface reason below. MessageSvc gates
|
|
// broadcasts.
|
|
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
|
|
// A nil registry must yield a nil interface, not a typed-nil
|
|
// *plugin.Registry — the handler's "no plugins loaded" check is an
|
|
// interface comparison.
|
|
Registry: func() CommandDispatcher {
|
|
if h.pluginRegistry == nil {
|
|
return nil
|
|
}
|
|
return h.pluginRegistry
|
|
},
|
|
MessageSvc: h.messageSvc,
|
|
Limiter: h.limiter,
|
|
})
|
|
registerVoiceControlsV2(reg, VoiceDeps{
|
|
DB: h.db,
|
|
Limiter: h.limiter,
|
|
Permissions: h.permChecker,
|
|
PermSvc: h.perms,
|
|
LiveKit: h.livekit,
|
|
TokenGen: h, // Hub delegates to h.livekit at call time
|
|
KeyHolder: h,
|
|
Mod: h,
|
|
})
|
|
|
|
h.refreshSettingsLocked(context.Background())
|
|
return h, nil
|
|
}
|